Skip to content

Commit 6368c11

Browse files
fix: guard tool event callbacks (AI-assisted) (#81696)
Summary: - This PR wraps embedded-agent tool-handler onExecutionPhase and per-run onAgentEvent emissions in best-effort warning guards and adds regression tests for throwing and rejecting callbacks. - PR surface: Source +31, Tests +44. Total +75 across 2 files. - Reproducibility: yes. Current main directly invokes the relevant callbacks in the tool-start and tool-event ... sync observer can leak unless guarded; I did not run a failing current-main repro in this read-only review. Automerge notes: - No ClawSweeper repair was needed after automerge opt-in. Validation: - ClawSweeper review passed for head 65de17d. - Required merge gates passed before the squash merge. Prepared head SHA: 65de17d Review: #81696 (comment) Co-authored-by: xuyi1243 <maginaxwhz@gmail.com>
1 parent dc9b1d5 commit 6368c11

2 files changed

Lines changed: 85 additions & 10 deletions

File tree

src/agents/embedded-agent-subscribe.handlers.tools.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,50 @@ describe("handleToolExecutionStart read path checks", () => {
334334
expect(ctx.state.itemActiveIds.has("tool:tool-await-flush")).toBe(true);
335335
expect(ctx.state.itemActiveIds.has("command:tool-await-flush")).toBe(true);
336336
});
337+
338+
it("keeps processing tool start when progress callbacks throw", async () => {
339+
const { ctx, warn, onExecutionPhase, onAgentEvent } = createTestContext();
340+
onExecutionPhase.mockImplementation(() => {
341+
throw new Error("phase exploded");
342+
});
343+
onAgentEvent.mockImplementation(() => {
344+
throw new Error("event exploded");
345+
});
346+
347+
const evt: ToolExecutionStartEvent = {
348+
type: "tool_execution_start",
349+
toolName: "exec",
350+
toolCallId: "tool-callback-throws",
351+
args: { command: "echo hi" },
352+
};
353+
354+
await handleToolExecutionStart(ctx, evt);
355+
356+
expect(ctx.state.toolMetaById.has("tool-callback-throws")).toBe(true);
357+
expect(ctx.state.itemStartedCount).toBe(2);
358+
expect(warn).toHaveBeenCalledWith(
359+
expect.stringContaining("tool execution phase callback failed"),
360+
);
361+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("tool agent event callback failed"));
362+
});
363+
364+
it("does not leak unhandled rejections when tool start progress rejects", async () => {
365+
const { ctx, warn, onAgentEvent } = createTestContext();
366+
onAgentEvent.mockRejectedValue(new Error("progress failed"));
367+
368+
const evt: ToolExecutionStartEvent = {
369+
type: "tool_execution_start",
370+
toolName: "exec",
371+
toolCallId: "tool-callback-rejects",
372+
args: { command: "echo hi" },
373+
};
374+
375+
await handleToolExecutionStart(ctx, evt);
376+
await Promise.resolve();
377+
378+
expect(ctx.state.toolMetaById.has("tool-callback-rejects")).toBe(true);
379+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("tool agent event callback failed"));
380+
});
337381
});
338382

339383
describe("handleToolExecutionEnd cron mutation tracking", () => {

src/agents/embedded-agent-subscribe.handlers.tools.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -299,12 +299,43 @@ function emitTrackedItemEvent(ctx: ToolHandlerContext, itemData: AgentItemEventD
299299
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
300300
data: itemData,
301301
});
302-
void ctx.params.onAgentEvent?.({
302+
emitAgentEventCallbackBestEffort(ctx, {
303303
stream: "item",
304304
data: itemData,
305305
});
306306
}
307307

308+
function warnBestEffortEventFailure(ctx: ToolHandlerContext, label: string, error: unknown): void {
309+
ctx.log.warn(`${label} callback failed: ${String(error)}`);
310+
}
311+
312+
function emitExecutionPhaseBestEffort(
313+
ctx: ToolHandlerContext,
314+
info: Parameters<NonNullable<ToolHandlerContext["params"]["onExecutionPhase"]>>[0],
315+
): void {
316+
try {
317+
ctx.params.onExecutionPhase?.(info);
318+
} catch (error) {
319+
warnBestEffortEventFailure(ctx, "tool execution phase", error);
320+
}
321+
}
322+
323+
function emitAgentEventCallbackBestEffort(
324+
ctx: ToolHandlerContext,
325+
event: Parameters<NonNullable<ToolHandlerContext["params"]["onAgentEvent"]>>[0],
326+
): void {
327+
try {
328+
const result = ctx.params.onAgentEvent?.(event);
329+
if (isPromiseLike<void>(result)) {
330+
void Promise.resolve(result).catch((error: unknown) => {
331+
warnBestEffortEventFailure(ctx, "tool agent event", error);
332+
});
333+
}
334+
} catch (error) {
335+
warnBestEffortEventFailure(ctx, "tool agent event", error);
336+
}
337+
}
338+
308339
function readToolResultDetailsRecord(result: unknown): Record<string, unknown> | undefined {
309340
return readRecordField(asOptionalObjectRecord(result)?.details);
310341
}
@@ -780,7 +811,7 @@ export function handleToolExecutionStart(
780811
const args = evt.args;
781812
const runId = ctx.params.runId;
782813
ctx.state.toolExecutionSinceLastBlockReply = true;
783-
ctx.params.onExecutionPhase?.({
814+
emitExecutionPhaseBestEffort(ctx, {
784815
phase: "tool_execution_started",
785816
tool: toolName,
786817
toolCallId,
@@ -898,7 +929,7 @@ export function handleToolExecutionStart(
898929
};
899930
emitTrackedItemEvent(ctx, itemData);
900931
// Best-effort typing signal; do not block tool summaries on slow emitters.
901-
void ctx.params.onAgentEvent?.({
932+
emitAgentEventCallbackBestEffort(ctx, {
902933
stream: "tool",
903934
data: {
904935
phase: "start",
@@ -1037,7 +1068,7 @@ export function handleToolExecutionUpdate(
10371068
};
10381069
emitTrackedItemEvent(ctx, itemData);
10391070
if (!toolProgress) {
1040-
void ctx.params.onAgentEvent?.({
1071+
emitAgentEventCallbackBestEffort(ctx, {
10411072
stream: "tool",
10421073
data: {
10431074
phase: "update",
@@ -1075,7 +1106,7 @@ export function handleToolExecutionUpdate(
10751106
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
10761107
data: outputData,
10771108
});
1078-
void ctx.params.onAgentEvent?.({
1109+
emitAgentEventCallbackBestEffort(ctx, {
10791110
stream: "command_output",
10801111
data: outputData,
10811112
});
@@ -1322,7 +1353,7 @@ export async function handleToolExecutionEnd(
13221353
: {}),
13231354
};
13241355
emitTrackedItemEvent(ctx, itemData);
1325-
void ctx.params.onAgentEvent?.({
1356+
emitAgentEventCallbackBestEffort(ctx, {
13261357
stream: "tool",
13271358
data: {
13281359
phase: "result",
@@ -1368,7 +1399,7 @@ export async function handleToolExecutionEnd(
13681399
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
13691400
data: approvalData,
13701401
});
1371-
void ctx.params.onAgentEvent?.({
1402+
emitAgentEventCallbackBestEffort(ctx, {
13721403
stream: "approval",
13731404
data: approvalData,
13741405
});
@@ -1435,7 +1466,7 @@ export async function handleToolExecutionEnd(
14351466
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
14361467
data: outputData,
14371468
});
1438-
void ctx.params.onAgentEvent?.({
1469+
emitAgentEventCallbackBestEffort(ctx, {
14391470
stream: "command_output",
14401471
data: outputData,
14411472
});
@@ -1461,7 +1492,7 @@ export async function handleToolExecutionEnd(
14611492
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
14621493
data: approvalData,
14631494
});
1464-
void ctx.params.onAgentEvent?.({
1495+
emitAgentEventCallbackBestEffort(ctx, {
14651496
stream: "approval",
14661497
data: approvalData,
14671498
});
@@ -1507,7 +1538,7 @@ export async function handleToolExecutionEnd(
15071538
...(ctx.params.sessionKey ? { sessionKey: ctx.params.sessionKey } : {}),
15081539
data: patchData,
15091540
});
1510-
void ctx.params.onAgentEvent?.({
1541+
emitAgentEventCallbackBestEffort(ctx, {
15111542
stream: "patch",
15121543
data: patchData,
15131544
});

0 commit comments

Comments
 (0)