Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions src/agent/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2648,9 +2648,13 @@ export class AgentRuntime {
if (options.announceInput === true) {
// An interrupted call never reached `tool-input-end`, so its
// `tool-input-start` is still buffered and `inputAnnounced` is false
// — which would suppress the `tool-output-error` below. On the
// declined-recovery path that leaves the client with a reasoning
// block and then nothing at all.
// — which would suppress the `tool-output-error` below. Every
// terminal path passes `announceInput`, because on all of them the
// run stops here and the client would otherwise be left with
// whatever preceded the truncation and then nothing at all. Which
// path declined recovery — exposed reasoning, a spent step budget, a
// second interruption, an exposed sibling — is invisible to the
// user, so it must not decide whether the failure renders (#3737).
//
// The name is safe to publish here. `tool-call` is what can supersede
// a name, and it also sets `inputAvailable`, which fails the guard
Expand Down Expand Up @@ -2689,9 +2693,14 @@ export class AgentRuntime {
await persistToolResult(toolResult);
}
for (const toolCall of streamedToolCalls) {
await recordIncompleteLocalToolError(toolCall, {
announceInput: declinedRecoveryForExposedReasoning,
});
// Terminal. Every incomplete local call recorded here is also
// terminalized into history, so announce unconditionally and let the
// wire carry the same failure. `recordIncompleteLocalToolError`
// guards on `providerExecuted`, completeness and a final result, so
// only genuinely truncated local calls are announced, and
// `announceStreamedToolCallInput` is idempotent for any already
// surfaced upstream.
await recordIncompleteLocalToolError(toolCall, { announceInput: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close the incomplete tool input before emitting its result

When this newly widened branch handles a truncated call without exposed reasoning, such as maxSteps exhaustion or a second interruption, it emits tool-input-start and partial deltas followed directly by tool-output-error. The AG-UI mapping in src/agent/ag-ui/browser-encoder.ts:926-932 converts an output error only to ToolCallResult, unlike tool-input-error and tool-input-available, which call completeToolInput first. Consequently AG-UI clients receive ToolCallStart, ToolCallArgs, and ToolCallResult without the required ToolCallEnd, leaving the tool-input lifecycle open. Terminalizing this path must either emit an input terminal event or teach the AG-UI encoder to close an incomplete input before its result.

Useful? React with 👍 / 👎.

}
sendSSE(controller, encoder, { type: "step-end" });
break;
Expand Down
157 changes: 155 additions & 2 deletions src/agent/runtime/refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2053,11 +2053,140 @@ describe("agent runtime refresh hooks", () => {
body.match(/"type":"tool-output-available"/g)?.length ?? 0,
hasFinalResult ? 1 : 0,
);
assertEquals(body.match(/"type":"tool-output-error"/g)?.length ?? 0, 0);
// #3737. Failing closed is about not re-running the batch, not about
// hiding the truncation. The exposed sibling rendered, but the
// interrupted call is terminalized into history here, so it also has to
// reach the wire — one announce and one failure for it, and none for the
// exposed sibling, which is complete and so never enters that branch.
assertEquals(
body.match(
/"type":"tool-input-start","toolCallId":"truncated-agent-after-exposure"/g,
)?.length ?? 0,
1,
);
assertEquals(
body.match(
/"type":"tool-output-error","toolCallId":"truncated-agent-after-exposure"/g,
)?.length ?? 0,
1,
);
assertEquals(body.match(/"type":"tool-output-error"/g)?.length ?? 0, 1);
assertEquals(body.match(/"type":"tool-input-error"/g)?.length ?? 0, 0);
assertEquals(body.includes("Unexpected recovery."), false);
}
});

it("surfaces a truncated local tool call when maxSteps exhaustion ends the run", async () => {
// #3737. Recovery is declined here because the step budget is spent, not
// because reasoning was exposed, so the flush #3735 added never runs. The
// run terminalizes the truncated call into history either way, so the wire
// has to carry the same failure — otherwise the stream ends with no text,
// no tool call and no error.
let finishedResponse: AgentResponse | undefined;
let callCount = 0;
const model: ModelRuntime = {
provider: "hosted",
modelId: "hosted/truncated-at-max-steps",
async doGenerate() {
return {
content: [{ type: "text", text: "unused" }],
finishReason: "stop",
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
};
},
async doStream() {
callCount++;
if (callCount === 1) {
return {
stream: createRuntimeStream([
{
type: "tool-call",
toolCallId: "issue3737-committed",
toolName: "issue3737_probe",
input: '{"revision":"first"}',
},
{
type: "finish",
finishReason: "tool-calls",
usage: { inputTokens: 1, outputTokens: 1 },
},
]),
};
}
return {
stream: createRuntimeStream([
{
type: "tool-input-start",
id: "issue3737-truncated",
toolName: "issue3737_probe",
},
{
type: "tool-input-delta",
id: "issue3737-truncated",
delta: '{"revision":"trunc',
},
{
type: "finish",
finishReason: "tool-calls",
usage: { inputTokens: 1, outputTokens: 1 },
},
]),
};
},
};
const probeTool = tool({
id: "issue3737_probe",
description: "Apply a revision",
inputSchema: defineSchema((v) => v.object({ revision: v.string() }))(),
execute: async ({ revision }) => ({ revision }),
});
const assistant = eagerAgent({
model: "hosted/truncated-at-max-steps",
system: "Exhaust the step budget on a truncated tool call.",
tools: { issue3737_probe: probeTool },
maxSteps: 2,
resolveModelTransport: async () => ({ model }),
});

const body = await (await assistant.stream({
input: "Apply both revisions",
onFinish: (result) => {
finishedResponse = result;
},
})).toDataStreamResponse().text();

assertEquals(callCount, 2);
// The truncated call must be announced so its failure card has something
// to render against.
assertEquals(
body.match(
/"type":"tool-input-start","toolCallId":"issue3737-truncated"/g,
)?.length ?? 0,
1,
);
// Exactly one failure event on the wire for it — never a tool-input-error
// alongside the tool-output-error.
assertEquals(
body.match(/"type":"tool-output-error","toolCallId":"issue3737-truncated"/g)?.length ?? 0,
1,
);
assertEquals(body.match(/"type":"tool-input-error"/g)?.length ?? 0, 0);
assertEquals(
body.includes(
'Stream terminated before tool-call event fired for \\"issue3737_probe\\"',
),
true,
);
assertExists(finishedResponse);
assertEquals(
finishedResponse.toolCalls.map((toolCall) => [toolCall.id, toolCall.status]),
[
["issue3737-committed", "completed"],
["issue3737-truncated", "error"],
],
);
});

it("does not report stale text when an ordinary tool-only step exhausts maxSteps", async () => {
let callCount = 0;
let finishedResponse: AgentResponse | undefined;
Expand Down Expand Up @@ -2211,7 +2340,31 @@ describe("agent runtime refresh hooks", () => {
assertEquals(callCount, 2);
assertEquals(toolResults, []);
assertEquals(body.match(/Created the Outlook assistant\./g)?.length ?? 0, 1);
assertEquals(body.match(/"type":"tool-output-error"/g)?.length ?? 0, 0);
// #3737. The step-1 placeholder is still off the wire: recovery ran, so it
// is provisional and gets re-asked. The step-2 placeholder is terminal —
// the assertions below show it kept in the assistant message with a
// matching tool-result error and an errored entry in `toolCalls`, so
// holding it back from the wire made the persisted history and the live
// stream disagree. One announce and one failure, for the terminal one only.
assertEquals(
body.match(
/"type":"tool-input-start","toolCallId":"toolu_repeated_placeholder"/g,
)?.length ?? 0,
1,
);
assertEquals(
body.match(
/"type":"tool-input-start","toolCallId":"toolu_placeholder_after_text"/g,
)?.length ?? 0,
0,
);
assertEquals(
body.match(/"type":"tool-output-error","toolCallId":"toolu_repeated_placeholder"/g)?.length ??
0,
1,
);
assertEquals(body.match(/"type":"tool-output-error"/g)?.length ?? 0, 1);
assertEquals(body.match(/"type":"tool-input-error"/g)?.length ?? 0, 0);
assertEquals(body.includes("Unexpected second recovery."), false);
const completedResponse = finishedResponse as AgentResponse | undefined;
assertExists(completedResponse);
Expand Down