Skip to content

Commit db98d39

Browse files
committed
fix(dashboard-agent): one failed tool call no longer fails the whole turn
A tool's exception is handed to the same hook a stream failure is, so a single failed call marked the turn failed: the agent worked around it and answered, and the user still got "Something went wrong" under a finished answer. The model was also told the turn was over instead of what happened to that call, so it was flying blind on the retry, and the exception itself was never logged. A failed call now reads as a failed call, is logged, and leaves the turn alone; only a stream failure ends it.
1 parent ec5068c commit db98d39

2 files changed

Lines changed: 94 additions & 6 deletions

File tree

‎internal-packages/dashboard-agent/src/dashboard-agent.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
prepareTurnMessages,
3737
sanitizeReplayedToolInputs,
3838
truncateEvalToolOutput,
39+
TOOL_FAILED_MESSAGE,
3940
TURN_FAILED_MESSAGE,
4041
turnFailureMessageId,
4142
type DashboardAgentEvalPolicyCheck,
@@ -1182,6 +1183,63 @@ describe("a turn that ends in an error", () => {
11821183
// The user's question stays in the transcript next to it.
11831184
expect(stored.some((message) => message.role === "user")).toBe(true);
11841185
});
1186+
1187+
/**
1188+
* A tool's exception is handed to the same hook a stream failure is, so one failed
1189+
* call used to mark the whole turn failed: the model worked around it, answered,
1190+
* and the user still got "Something went wrong" under a finished answer — and the
1191+
* model was told the turn was over instead of what to try next.
1192+
*/
1193+
it("a failed tool call the model works around is not a failed turn", async () => {
1194+
const { store, history } = transcriptStore();
1195+
harness = mockChatAgent(dashboardAgent, {
1196+
chatId: "chat_tool_error",
1197+
clientData: CLIENT_DATA,
1198+
setupLocals: ({ set }) => {
1199+
set(dashboardAgentStoreKey, store);
1200+
set(dashboardAgentToolsKey, {
1201+
render_view: tool({
1202+
description: "test double",
1203+
inputSchema: z.object({ blocks: z.array(z.unknown()).optional() }),
1204+
execute: async () => ({ blocks: [] }),
1205+
}),
1206+
offer_watch: tool({
1207+
description: "test double that fails the way a tool bug does",
1208+
inputSchema: z.object({ note: z.string().optional() }),
1209+
execute: async (): Promise<{ ok: boolean }> => {
1210+
throw new Error("Cannot read properties of undefined (reading 'fingerprint')");
1211+
},
1212+
}),
1213+
});
1214+
set(
1215+
dashboardAgentModelKey,
1216+
mockModel([
1217+
toolCallStep("render_view", {}, "tc_verdict"),
1218+
toolCallStep("offer_watch", {}, "tc_offer"),
1219+
textStep("Rate limited — the retries all land in one window."),
1220+
])
1221+
);
1222+
},
1223+
});
1224+
1225+
const turn = await harness.sendMessage(userMessage("why is send-order-receipt failing?"));
1226+
1227+
// The model is told what happened to that call, not that the turn is over.
1228+
const toolError = turn.chunks.find(
1229+
(chunk) => (chunk as { type?: string }).type === "tool-output-error"
1230+
) as { errorText?: string } | undefined;
1231+
expect(toolError?.errorText).toBe(TOOL_FAILED_MESSAGE);
1232+
expect(collectText(turn.chunks)).toBe("Rate limited — the retries all land in one window.");
1233+
1234+
await new Promise((r) => setTimeout(r, 30));
1235+
1236+
// And nothing tells the user the turn didn't finish, because it did.
1237+
const stored = history();
1238+
expect(stored.find((message) => message.id === turnFailureMessageId(0))).toBeUndefined();
1239+
expect(JSON.stringify(stored)).not.toContain(TURN_FAILED_MESSAGE);
1240+
// The tool's own words never reach the transcript either.
1241+
expect(JSON.stringify(stored)).not.toContain("fingerprint");
1242+
});
11851243
});
11861244

11871245
// Back-compat: resumed chats replay their original metadata shape.

‎internal-packages/dashboard-agent/src/dashboard-agent.ts‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ export {
8888
export const TURN_FAILED_MESSAGE =
8989
"Something went wrong on my side, so that turn didn't finish. Ask again and I'll pick it up.";
9090

91+
/**
92+
* What one failed tool call leaves behind. The panel shows it verbatim on the call,
93+
* so it stays one clause and what to do next is the system prompt's job; the turn
94+
* itself is still alive, and saying it isn't would end a turn the model can still
95+
* finish.
96+
*/
97+
export const TOOL_FAILED_MESSAGE = "That call failed and returned no data.";
98+
9199
/** Stable per turn, so a re-run of the error path can't stack two records. */
92100
export function turnFailureMessageId(turn: number): string {
93101
return `turn-error:${turn}`;
@@ -391,15 +399,27 @@ export const dashboardAgent = chat.agent({
391399
idleTimeoutInSeconds: 60,
392400

393401
uiMessageStreamOptions: {
394-
// The stream carries the same sentence the transcript keeps, so the live chunk
395-
// and the stored record never disagree. The provider's own message is logged
396-
// here and goes no further.
402+
/**
403+
* Called for a failed TOOL CALL as well as a failed stream, and what it returns
404+
* is both what the panel shows and what the model reads back as that call's
405+
* result. Only `run`'s own `onError` sees a stream failure, and it has already
406+
* run by the time an error reaches here, so the flag is what tells the two
407+
* apart: a call the model can recover from must neither claim the turn is over
408+
* nor be the only thing the model is told about it.
409+
*
410+
* For a turn failure the stream carries the same sentence the transcript keeps,
411+
* so the live chunk and the stored record never disagree. Either way the
412+
* provider's — or the tool's — own message is logged here and goes no further.
413+
*/
397414
onError: (error) => {
398-
locals.set(turnErroredKey, true);
399-
logger.error("dashboard-agent turn failed", {
415+
if (locals.get(turnErroredKey) === true) return TURN_FAILED_MESSAGE;
416+
// A tool's exception is swallowed into that call's result, so this is the only
417+
// place it is ever visible.
418+
logger.error("dashboard-agent tool call failed", {
400419
error: error instanceof Error ? error.message : String(error),
420+
stack: error instanceof Error ? error.stack : undefined,
401421
});
402-
return TURN_FAILED_MESSAGE;
422+
return TOOL_FAILED_MESSAGE;
403423
},
404424
},
405425

@@ -616,6 +636,16 @@ export const dashboardAgent = chat.agent({
616636
resolveDashboardAgentModel(resolved.model ?? "anthropic:claude-sonnet-4-6"),
617637
messages,
618638
abortSignal: signal,
639+
// Only a stream failure reaches here — a tool's own failure never does. That
640+
// is what makes this the turn's error flag: a failed call the model then
641+
// works around used to mark the whole turn failed and append the apology to
642+
// a turn that had already answered.
643+
onError: ({ error }) => {
644+
locals.set(turnErroredKey, true);
645+
logger.error("dashboard-agent turn failed", {
646+
error: error instanceof Error ? error.message : String(error),
647+
});
648+
},
619649
prepareStep: stepCachePrepareStep(options) as never,
620650
// Per model call, so the head-start prefix and this one can be compared.
621651
onStepFinish: (finished) =>

0 commit comments

Comments
 (0)