Skip to content

Commit d0d4eb4

Browse files
committed
separate repl evaluator-error handling
1 parent 1d3fa00 commit d0d4eb4

File tree

4 files changed

+178
-122
lines changed

4 files changed

+178
-122
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import 'ses';
2+
import '@ocap/repo-tools/test-utils/mock-endoify';
3+
import { EvaluatorError, SampleGenerationError } from '@metamask/kernel-errors';
4+
import { describe, it, expect } from 'vitest';
5+
6+
import { processEvaluationError, stripStackTrace } from './evaluator-error.ts';
7+
import { ERROR } from './symbols.ts';
8+
9+
describe('stripStackTrace', () => {
10+
it('strips stack trace from Error', () => {
11+
const error = new Error('test error');
12+
error.stack = 'Error: test error\n at test.js:1:1';
13+
const stripped = stripStackTrace(error);
14+
expect(stripped).toBeInstanceOf(Error);
15+
expect((stripped as Error).message).toBe('test error');
16+
const strippedError = stripped as Error;
17+
expect(strippedError.stack).not.toContain('at test.js');
18+
});
19+
20+
it('preserves error cause chain', () => {
21+
const inner = new Error('inner');
22+
const outer = new Error('outer', { cause: inner });
23+
const stripped = stripStackTrace(outer);
24+
expect((stripped as Error).message).toBe('outer');
25+
expect((stripped as Error).cause).toBeInstanceOf(Error);
26+
expect(((stripped as Error).cause as Error).message).toBe('inner');
27+
});
28+
29+
it('returns non-Error values unchanged', () => {
30+
expect(stripStackTrace('string')).toBe('string');
31+
expect(stripStackTrace(42)).toBe(42);
32+
expect(stripStackTrace(null)).toBeNull();
33+
});
34+
});
35+
36+
describe('processEvaluationError', () => {
37+
it('does nothing when result has no error', () => {
38+
const result: { [ERROR]?: unknown } = {};
39+
expect(() => processEvaluationError(result, 'code')).not.toThrow();
40+
});
41+
42+
it('throws EvaluatorError for internal errors', () => {
43+
const result: { [ERROR]?: unknown } = {
44+
[ERROR]: new EvaluatorError('test', 'code', new Error('cause')),
45+
};
46+
expect(() => processEvaluationError(result, 'code')).toThrow(
47+
EvaluatorError,
48+
);
49+
});
50+
51+
it('throws SampleGenerationError for SyntaxError', () => {
52+
const result: { [ERROR]?: unknown } = {
53+
[ERROR]: new SyntaxError('syntax error'),
54+
};
55+
expect(() => processEvaluationError(result, 'bad code')).toThrow(
56+
SampleGenerationError,
57+
);
58+
});
59+
60+
it('throws SampleGenerationError for ReferenceError', () => {
61+
const result: { [ERROR]?: unknown } = {
62+
[ERROR]: new ReferenceError('reference error'),
63+
};
64+
expect(() => processEvaluationError(result, 'bad code')).toThrow(
65+
SampleGenerationError,
66+
);
67+
});
68+
69+
it('throws SampleGenerationError for Error objects with SyntaxError name', () => {
70+
const error = Object.assign(new Error('error'), { name: 'SyntaxError' });
71+
const result: { [ERROR]?: unknown } = { [ERROR]: error };
72+
expect(() => processEvaluationError(result, 'bad code')).toThrow(
73+
SampleGenerationError,
74+
);
75+
});
76+
77+
it('processes and assigns valid-feedback errors', () => {
78+
const result: { [ERROR]?: unknown } = {
79+
[ERROR]: new Error('user error'),
80+
};
81+
processEvaluationError(result, 'code');
82+
expect(result[ERROR]).toBeInstanceOf(Error);
83+
const processedError = result[ERROR] as Error;
84+
expect(processedError.message).toBe('user error');
85+
});
86+
87+
it('wraps non-Error values as Error for valid-feedback', () => {
88+
const result: { [ERROR]?: unknown } = { [ERROR]: 'string error' };
89+
processEvaluationError(result, 'code');
90+
expect(result[ERROR]).toBeInstanceOf(Error);
91+
expect((result[ERROR] as Error).message).toBe('string error');
92+
});
93+
94+
it('strips stack traces from valid-feedback errors', () => {
95+
const error = new Error('user error');
96+
error.stack = 'Error: user error\n at test.js:1:1';
97+
const result: { [ERROR]?: unknown } = { [ERROR]: error };
98+
processEvaluationError(result, 'code');
99+
const processedError = result[ERROR] as Error;
100+
expect(processedError.message).toBe('user error');
101+
expect(processedError.stack).not.toContain('at test.js');
102+
});
103+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { SampleGenerationError, EvaluatorError } from '@metamask/kernel-errors';
2+
3+
import type { EvaluationResult } from './prepare-evaluation.ts';
4+
import { ERROR } from './symbols.ts';
5+
6+
/**
7+
* Strips stack traces from an error while preserving the message and cause chain.
8+
*
9+
* @param error - The error to strip stack traces from.
10+
* @returns The error without stack traces.
11+
*/
12+
export const stripStackTrace = (error: unknown): unknown => {
13+
if (!(error instanceof Error)) {
14+
return error;
15+
}
16+
return new Error(
17+
error.message,
18+
...(error.cause ? [{ cause: stripStackTrace(error.cause) }] : []),
19+
);
20+
};
21+
22+
const asError = (error: unknown): Error =>
23+
error instanceof Error ? error : new Error(String(error));
24+
25+
const isSyntaxError = (error: unknown): boolean =>
26+
error instanceof SyntaxError ||
27+
(error instanceof Error && error.name === 'SyntaxError');
28+
29+
const isReferenceError = (error: unknown): boolean =>
30+
error instanceof ReferenceError ||
31+
(error instanceof Error && error.name === 'ReferenceError');
32+
33+
/**
34+
* Processes any error in the evaluation result. If an error exists, classifies it
35+
* and either throws (for retry/exit errors) or processes and assigns it back to
36+
* the result (for valid feedback errors).
37+
*
38+
* @param result - The evaluation result object that may contain an error.
39+
* @param code - The code that was being evaluated.
40+
* @throws {SampleGenerationError} For syntax/reference errors that should trigger retry.
41+
* @throws {EvaluatorError} For internal errors that should exit the attempt.
42+
*/
43+
export const processEvaluationError = (
44+
result: EvaluationResult,
45+
code: string,
46+
): void => {
47+
if (!Object.hasOwn(result, ERROR)) {
48+
return;
49+
}
50+
const error = result[ERROR];
51+
52+
// Check if this is already an EvaluatorError (thrown by safe wrappers)
53+
if (error instanceof EvaluatorError) {
54+
throw error;
55+
}
56+
57+
// Check if this is a sample generation error (syntax/reference errors)
58+
if (isSyntaxError(error) || isReferenceError(error)) {
59+
throw new SampleGenerationError(
60+
code,
61+
stripStackTrace(asError(error)) as Error,
62+
);
63+
}
64+
65+
// All other errors are valid feedback (capability errors, NotImplemented, etc.)
66+
result[ERROR] = stripStackTrace(asError(error));
67+
};

packages/kernel-agents/src/strategies/repl/evaluator.test.ts

Lines changed: 5 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import 'ses'; // We need the real Compartment, not the mock.
22
import '@ocap/repo-tools/test-utils/mock-endoify';
3-
import { EvaluatorError } from '@metamask/kernel-errors';
43
import { Logger } from '@metamask/logger';
54
import { describe, it, expect, beforeEach, vi } from 'vitest';
65

76
import { makeEvaluator } from './evaluator.ts';
87
import {
98
CommentMessage,
10-
EvaluationMessage,
119
ImportMessage,
1210
ResultMessage,
1311
StatementMessage,
@@ -94,37 +92,6 @@ describe('evaluator', () => {
9492
});
9593
});
9694

97-
describe('classifies errors', () => {
98-
it('rejects EvaluatorError as internal error', async () => {
99-
const evaluatorWithError = makeEvaluator({
100-
initState: () => ({ consts: {}, lets: {} }),
101-
capabilities: {
102-
badCap: {
103-
func: () => {
104-
throw new EvaluatorError('test', 'code', new Error('cause'));
105-
},
106-
schema: { description: 'Bad capability', args: {} },
107-
},
108-
},
109-
});
110-
const statement = new EvaluationMessage('badCap();');
111-
await expect(evaluatorWithError([], statement)).rejects.toThrow(
112-
EvaluatorError,
113-
);
114-
});
115-
116-
it('returns user errors as valid feedback without stack traces', async () => {
117-
const history: ReplTranscript = [];
118-
const result = await evaluator(
119-
history,
120-
StatementMessage.fromCode(
121-
'(function() { throw new Error("user error"); })();',
122-
),
123-
);
124-
expect(result?.messageBody.error).toBe('Error: user error');
125-
});
126-
});
127-
12895
describe('creates result messages', () => {
12996
it('creates result with return value', async () => {
13097
const history: ReplTranscript = [];
@@ -136,18 +103,6 @@ describe('evaluator', () => {
136103
expect(result?.messageBody.return).toBe('"hello"');
137104
});
138105

139-
it('creates result with error', async () => {
140-
const history: ReplTranscript = [];
141-
const result = await evaluator(
142-
history,
143-
StatementMessage.fromCode(
144-
'(function() { throw new Error("test"); })();',
145-
),
146-
);
147-
expect(result).toBeInstanceOf(ResultMessage);
148-
expect(result?.messageBody.error).toContain('Error: test');
149-
});
150-
151106
it('creates result with declaration value', async () => {
152107
const history: ReplTranscript = [];
153108
const result = await evaluator(
@@ -169,17 +124,13 @@ describe('evaluator', () => {
169124
});
170125

171126
describe('manages state', () => {
172-
it('does not update state on error', async () => {
127+
it('does not update state when evaluation has error', async () => {
173128
const initialState = { consts: {}, lets: {} };
174129
const history: ReplTranscript = [];
175-
try {
176-
await evaluator(
177-
history,
178-
StatementMessage.fromCode('const x = undefined.y;'),
179-
);
180-
} catch {
181-
// Expected to throw
182-
}
130+
await evaluator(
131+
history,
132+
StatementMessage.fromCode('const x = undefined.y;'),
133+
);
183134
expect(state).toStrictEqual(initialState);
184135
});
185136
});

packages/kernel-agents/src/strategies/repl/evaluator.ts

Lines changed: 3 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { SampleGenerationError, EvaluatorError } from '@metamask/kernel-errors';
1+
import { EvaluatorError } from '@metamask/kernel-errors';
22
import { mergeDisjointRecords } from '@metamask/kernel-utils';
33
import type { Logger } from '@metamask/logger';
44

55
import { makeCompartment } from './compartment.ts';
6+
import { processEvaluationError } from './evaluator-error.ts';
67
import {
78
CommentMessage,
89
EvaluationMessage,
@@ -17,58 +18,6 @@ import { extractCapabilities } from '../../capabilities/capability.ts';
1718
import type { CapabilityRecord } from '../../types.ts';
1819
import { ifDefined } from '../../utils.ts';
1920

20-
/**
21-
* Error classification result for compartment errors.
22-
*/
23-
type ErrorClassification =
24-
| { type: 'sample-generation'; error: SampleGenerationError }
25-
| { type: 'internal'; error: EvaluatorError }
26-
| { type: 'valid-feedback'; error: Error };
27-
28-
/**
29-
* Classifies a compartment error into one of three categories:
30-
* 1. Sample generation errors (syntax/reference errors) - should trigger retry
31-
* 2. Internal errors (REPL infrastructure violations) - should exit attempt
32-
* 3. Valid feedback errors (capability errors, etc.) - should be surfaced to agent
33-
*
34-
* @param error - The error to classify.
35-
* @param code - The code that was being evaluated.
36-
* @returns The classification result.
37-
*/
38-
const classifyCompartmentError = (
39-
error: unknown,
40-
code: string,
41-
): ErrorClassification => {
42-
const cause = error instanceof Error ? error : new Error(String(error));
43-
44-
// Check if this is already an EvaluatorError (thrown by safe wrappers)
45-
if (cause instanceof EvaluatorError) {
46-
return {
47-
type: 'internal',
48-
error: cause,
49-
};
50-
}
51-
52-
// Check if this is a sample generation error (syntax/reference errors)
53-
if (
54-
cause instanceof SyntaxError ||
55-
cause instanceof ReferenceError ||
56-
cause.name === 'SyntaxError' ||
57-
cause.name === 'ReferenceError'
58-
) {
59-
return {
60-
type: 'sample-generation',
61-
error: new SampleGenerationError(code, cause),
62-
};
63-
}
64-
65-
// All other errors are valid feedback (capability errors, NotImplemented, etc.)
66-
return {
67-
type: 'valid-feedback',
68-
error: cause,
69-
};
70-
};
71-
7221
const validateStatement = (
7322
statement: StatementMessage,
7423
): { earlyResult?: ResultMessage | null } => {
@@ -158,21 +107,7 @@ export const makeEvaluator = ({
158107
}
159108

160109
// Handle errors caught by $catch (user code errors)
161-
if (Object.hasOwn(result, ERROR)) {
162-
const classification = classifyCompartmentError(result[ERROR], code);
163-
if (['sample-generation', 'internal'].includes(classification.type)) {
164-
throw classification.error;
165-
}
166-
// Valid feedback error: treat as result, stripping out the stack trace
167-
const withoutStack = (error: unknown): unknown =>
168-
error instanceof Error
169-
? new Error(
170-
error.message,
171-
...(error.cause ? [{ cause: withoutStack(error.cause) }] : []),
172-
)
173-
: error;
174-
result[ERROR] = withoutStack(result[ERROR]);
175-
}
110+
processEvaluationError(result, code);
176111

177112
// Update the state and return the result
178113
const stepResult = [ERROR, RETURN, 'value'].some((key) =>

0 commit comments

Comments
 (0)