Skip to content

Commit c7eead8

Browse files
notgitikagitikavj
andauthored
fix: surface Python errors during agentcore dev (#359)
* fix: surface Python errors during agentcore dev * fix: address PR review comments --------- Co-authored-by: gitikavj <gitikavj@amazon.com>
1 parent e1d1e9b commit c7eead8

File tree

6 files changed

+324
-43
lines changed

6 files changed

+324
-43
lines changed

src/cli/operations/dev/__tests__/dev-server.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,4 +218,105 @@ describe('DevServer', () => {
218218
expect(onExit).toHaveBeenCalledWith(0);
219219
});
220220
});
221+
222+
describe('Python traceback detection', () => {
223+
const TRACEBACK = [
224+
'Traceback (most recent call last):',
225+
' File "/app/.venv/lib/python3.12/site-packages/uvicorn/server.py", line 86, in _serve',
226+
' config.load()',
227+
' File "/app/myagent/main.py", line 1, in <module>',
228+
' import nonexistent_package',
229+
"ModuleNotFoundError: No module named 'nonexistent_package'",
230+
].join('\n');
231+
232+
it('emits only user-code frames and exception line for tracebacks', async () => {
233+
await server.start();
234+
235+
mockChild.stderr.emit('data', Buffer.from(TRACEBACK));
236+
237+
const errorCalls = (onLog as ReturnType<typeof vi.fn>).mock.calls.filter((c: unknown[]) => c[0] === 'error');
238+
const errorMessages = errorCalls.map((c: unknown[]) => c[1]);
239+
240+
// Should include user frame + code + exception, but NOT site-packages frame
241+
expect(errorMessages).toContain(' File "/app/myagent/main.py", line 1, in <module>');
242+
expect(errorMessages).toContain(' import nonexistent_package');
243+
expect(errorMessages).toContain("ModuleNotFoundError: No module named 'nonexistent_package'");
244+
// Should NOT include internal frames
245+
expect(errorMessages).not.toContain(
246+
' File "/app/.venv/lib/python3.12/site-packages/uvicorn/server.py", line 86, in _serve'
247+
);
248+
});
249+
250+
it('does not emit traceback lines as info', async () => {
251+
await server.start();
252+
253+
mockChild.stderr.emit('data', Buffer.from(TRACEBACK));
254+
255+
const infoCalls = (onLog as ReturnType<typeof vi.fn>).mock.calls.filter((c: unknown[]) => c[0] === 'info');
256+
// None of the traceback lines should leak as info
257+
for (const [, msg] of infoCalls) {
258+
expect(msg).not.toContain('Traceback');
259+
expect(msg).not.toContain('nonexistent_package');
260+
}
261+
});
262+
263+
it('resumes normal classification after traceback ends', async () => {
264+
await server.start();
265+
266+
mockChild.stderr.emit('data', Buffer.from(TRACEBACK + '\nINFO: some normal log'));
267+
268+
expect(onLog).toHaveBeenCalledWith('info', 'INFO: some normal log');
269+
});
270+
});
271+
272+
describe('stderr crash buffer', () => {
273+
it('emits buffered stderr as errors on non-zero exit', async () => {
274+
await server.start();
275+
276+
// Emit some non-traceback stderr lines
277+
mockChild.stderr.emit('data', Buffer.from('some debug output'));
278+
mockChild.stderr.emit('data', Buffer.from('another line'));
279+
280+
mockChild.emit('exit', 1);
281+
282+
const errorCalls = (onLog as ReturnType<typeof vi.fn>).mock.calls.filter((c: unknown[]) => c[0] === 'error');
283+
const errorMessages = errorCalls.map((c: unknown[]) => c[1]);
284+
285+
expect(errorMessages).toContain('some debug output');
286+
expect(errorMessages).toContain('another line');
287+
expect(onExit).toHaveBeenCalledWith(1);
288+
});
289+
290+
it('does not emit stderr buffer on clean exit (code 0)', async () => {
291+
await server.start();
292+
293+
mockChild.stderr.emit('data', Buffer.from('some debug output'));
294+
mockChild.emit('exit', 0);
295+
296+
const errorCalls = (onLog as ReturnType<typeof vi.fn>).mock.calls.filter((c: unknown[]) => c[0] === 'error');
297+
expect(errorCalls).toHaveLength(0);
298+
expect(onExit).toHaveBeenCalledWith(0);
299+
});
300+
301+
it('clears stderr buffer after traceback to avoid duplication', async () => {
302+
await server.start();
303+
304+
const traceback = [
305+
'Traceback (most recent call last):',
306+
' File "/app/main.py", line 1, in <module>',
307+
' import bad',
308+
"ModuleNotFoundError: No module named 'bad'",
309+
].join('\n');
310+
311+
mockChild.stderr.emit('data', Buffer.from(traceback));
312+
vi.mocked(onLog).mockClear();
313+
314+
// Now exit — stderr buffer should be empty since traceback cleared it
315+
mockChild.emit('exit', 1);
316+
317+
const errorCalls = (onLog as ReturnType<typeof vi.fn>).mock.calls.filter((c: unknown[]) => c[0] === 'error');
318+
expect(errorCalls).toHaveLength(0);
319+
expect(onExit).toHaveBeenCalledWith(1);
320+
});
321+
});
221322
});

src/cli/operations/dev/dev-server.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,30 @@ export interface SpawnConfig {
2626
* Handles process spawning, output parsing, and lifecycle management.
2727
* Subclasses implement prepare() and getSpawnConfig() for mode-specific behavior.
2828
*/
29+
/** Keep the last 20 stderr lines so we can surface them if the process crashes.
30+
* 20 lines is enough to capture a typical Python traceback or error context
31+
* without accumulating unbounded memory for long-running servers. */
32+
const STDERR_BUFFER_SIZE = 20;
33+
34+
/** Paths that indicate internal framework frames (not user code) */
35+
const INTERNAL_FRAME_PATTERNS = [
36+
'/site-packages/',
37+
'<frozen ',
38+
'/multiprocessing/',
39+
'/asyncio/',
40+
'/concurrent/',
41+
'/importlib/',
42+
];
43+
44+
function isInternalFrame(line: string): boolean {
45+
return INTERNAL_FRAME_PATTERNS.some(p => line.includes(p));
46+
}
47+
2948
export abstract class DevServer {
3049
protected child: ChildProcess | null = null;
50+
private recentStderr: string[] = [];
51+
private inTraceback = false;
52+
private tracebackBuffer: string[] = [];
3153

3254
constructor(
3355
protected readonly config: DevConfig,
@@ -71,6 +93,41 @@ export abstract class DevServer {
7193
/** Returns the command, args, cwd, and environment for the child process. */
7294
protected abstract getSpawnConfig(): SpawnConfig;
7395

96+
/**
97+
* Emit a filtered Python traceback: only user code frames and the exception line.
98+
* Internal frames (site-packages, frozen modules, asyncio, etc.) are stripped out.
99+
*/
100+
private emitFilteredTraceback(onLog: (level: LogLevel, message: string) => void): void {
101+
const buf = this.tracebackBuffer;
102+
if (buf.length === 0) return;
103+
104+
// The last line is the exception (e.g., "ModuleNotFoundError: ...")
105+
const exceptionLine = buf[buf.length - 1]!;
106+
107+
// Collect user-code frames: a "File ..." line followed by its code line.
108+
// Frames come in pairs: " File "path", line N, in func" + " code_line"
109+
const userFrames: string[] = [];
110+
for (let i = 0; i < buf.length - 1; i++) {
111+
const frameLine = buf[i]!;
112+
const trimmed = frameLine.trimStart();
113+
if (trimmed.startsWith('File ') && !isInternalFrame(frameLine)) {
114+
userFrames.push(frameLine);
115+
// Include the next line (source code) if it exists and is indented
116+
const nextLine = buf[i + 1];
117+
if (nextLine && nextLine.startsWith(' ') && !nextLine.trimStart().startsWith('File ')) {
118+
userFrames.push(nextLine);
119+
}
120+
}
121+
}
122+
123+
if (userFrames.length > 0) {
124+
for (const frame of userFrames) {
125+
onLog('error', frame);
126+
}
127+
}
128+
onLog('error', exceptionLine);
129+
}
130+
74131
/** Attach stdout/stderr/error/exit handlers to the child process. */
75132
private attachHandlers(): void {
76133
const { onLog, onExit } = this.options.callbacks;
@@ -88,6 +145,31 @@ export abstract class DevServer {
88145
if (!output) return;
89146
for (const line of output.split('\n')) {
90147
if (!line) continue;
148+
// Buffer recent stderr for crash context
149+
this.recentStderr.push(line);
150+
if (this.recentStderr.length > STDERR_BUFFER_SIZE) {
151+
this.recentStderr.shift();
152+
}
153+
// Detect Python traceback blocks: buffer all lines, then emit a
154+
// filtered version showing only user code frames + the exception.
155+
if (line.startsWith('Traceback (most recent call last)')) {
156+
this.inTraceback = true;
157+
this.tracebackBuffer = [];
158+
}
159+
if (this.inTraceback) {
160+
this.tracebackBuffer.push(line);
161+
const isStackFrame = line.startsWith(' ') || line.startsWith('File ');
162+
const isTracebackHeader = line.startsWith('Traceback ');
163+
if (!isStackFrame && !isTracebackHeader) {
164+
// Traceback ended — emit filtered summary and clear the
165+
// stderr buffer so these lines aren't re-emitted on exit.
166+
this.emitFilteredTraceback(onLog);
167+
this.inTraceback = false;
168+
this.tracebackBuffer = [];
169+
this.recentStderr = [];
170+
}
171+
continue;
172+
}
91173
const lower = line.toLowerCase();
92174
if (lower.includes('warning')) onLog('warn', line);
93175
else if (lower.includes('error')) onLog('error', line);
@@ -100,6 +182,14 @@ export abstract class DevServer {
100182
onExit(1);
101183
});
102184

103-
this.child?.on('exit', code => onExit(code));
185+
this.child?.on('exit', code => {
186+
if (code !== 0 && code !== null && this.recentStderr.length > 0) {
187+
for (const line of this.recentStderr) {
188+
onLog('error', line);
189+
}
190+
this.recentStderr = [];
191+
}
192+
onExit(code);
193+
});
104194
}
105195
}

src/cli/operations/dev/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ export {
1010

1111
export { getDevConfig, getDevSupportedAgents, getAgentPort, loadProjectConfig, type DevConfig } from './config';
1212

13-
export { invokeAgent, invokeAgentStreaming } from './invoke';
13+
export { ConnectionError, ServerError, invokeAgent, invokeAgentStreaming } from './invoke';

src/cli/operations/dev/invoke.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
/** Error thrown when the dev server returns a non-OK HTTP response. */
2+
export class ServerError extends Error {
3+
constructor(
4+
public readonly statusCode: number,
5+
body: string
6+
) {
7+
super(body || `Server returned ${statusCode}`);
8+
this.name = 'ServerError';
9+
}
10+
}
11+
12+
/** Error thrown when the connection to the dev server fails. */
13+
export class ConnectionError extends Error {
14+
constructor(cause: Error) {
15+
super(cause.message);
16+
this.name = 'ConnectionError';
17+
}
18+
}
19+
120
/** Logger interface for SSE events and error logging */
221
export interface SSELogger {
322
logSSEEvent(rawLine: string): void;
@@ -100,6 +119,11 @@ export async function* invokeAgentStreaming(
100119
body: JSON.stringify({ prompt: msg }),
101120
});
102121

122+
if (!res.ok) {
123+
const body = await res.text();
124+
throw new ServerError(res.status, body);
125+
}
126+
103127
if (!res.body) {
104128
yield '(empty response)';
105129
return;
@@ -168,6 +192,12 @@ export async function* invokeAgentStreaming(
168192

169193
return;
170194
} catch (err) {
195+
// Re-throw ServerError directly — no retries for HTTP errors
196+
if (err instanceof ServerError) {
197+
logger?.log?.('error', `Server error (${err.statusCode}): ${err.message}`);
198+
throw err;
199+
}
200+
171201
lastError = err instanceof Error ? err : new Error(String(err));
172202
const isConnectionError = lastError.message.includes('fetch') || lastError.message.includes('ECONNREFUSED');
173203

@@ -188,8 +218,8 @@ export async function* invokeAgentStreaming(
188218
}
189219

190220
// Log final failure after all retries exhausted with full details
191-
const finalError = lastError ?? new Error('Failed to connect to dev server after retries');
192-
logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.stack ?? finalError.message}`);
221+
const finalError = new ConnectionError(lastError ?? new Error('Failed to connect to dev server after retries'));
222+
logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.message}`);
193223
throw finalError;
194224
}
195225

@@ -223,6 +253,11 @@ export async function invokeAgent(portOrOptions: number | InvokeOptions, message
223253
body: JSON.stringify({ prompt: msg }),
224254
});
225255

256+
if (!res.ok) {
257+
const body = await res.text();
258+
throw new ServerError(res.status, body);
259+
}
260+
226261
const text = await res.text();
227262
if (!text) {
228263
return '(empty response)';
@@ -236,6 +271,12 @@ export async function invokeAgent(portOrOptions: number | InvokeOptions, message
236271
// Handle plain JSON response (non-streaming frameworks)
237272
return extractResult(text);
238273
} catch (err) {
274+
// Re-throw ServerError directly — no retries for HTTP errors
275+
if (err instanceof ServerError) {
276+
logger?.log?.('error', `Server error (${err.statusCode}): ${err.message}`);
277+
throw err;
278+
}
279+
239280
lastError = err instanceof Error ? err : new Error(String(err));
240281
const isConnectionError = lastError.message.includes('fetch') || lastError.message.includes('ECONNREFUSED');
241282

@@ -256,7 +297,7 @@ export async function invokeAgent(portOrOptions: number | InvokeOptions, message
256297
}
257298

258299
// Log final failure after all retries exhausted with full details
259-
const finalError = lastError ?? new Error('Failed to connect to dev server after retries');
260-
logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.stack ?? finalError.message}`);
300+
const finalError = new ConnectionError(lastError ?? new Error('Failed to connect to dev server after retries'));
301+
logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.message}`);
261302
throw finalError;
262303
}

0 commit comments

Comments
 (0)