Skip to content

Commit fbf9d09

Browse files
nicklaunchesclaude
andcommitted
Drop dangling tool calls from a chat turn's history before the model sees it
A frontend tool handler torn down mid-run leaves an assistant message whose tool call will never be answered in the agent's live thread. Every retry sends it straight back up as input.messages, and the AI SDK refuses the conversation with AI_MissingToolResultsError naming the same call id each time. Found live: one person's next three messages all failed that way, and the conversation stayed dead until they worked out for themselves to start another one. Routines already sanitize the history they seed for exactly this failure. That sanitizer moves to agents/history-sanitize.ts, re-exported from routines/run-turn.ts so the routines path is unchanged, and now runs on every built-in chat turn as well: on run, and on the clone the runtime makes before each run, which the base class hard-codes to itself. The rule for what counts as answered is the model API's own. A result answers a call only if it lands before the next user or system message, because the API walks the conversation in order and refuses it at that message. A browser handler that resolved late appended its result after the person had typed again, so the call read as answered here while every retry still failed with the same id. A call a resume is about to answer survives the pass, since run appends that result after conversion. Ids are never rewritten and the stored thread is untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaHWJ1niprhBc5NzJ9pxme
1 parent 257c128 commit fbf9d09

6 files changed

Lines changed: 657 additions & 98 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### A conversation is no longer stuck after a tool call went unanswered
12+
13+
A tool that runs in the browser can be torn down while its call is still open, most often because
14+
the tab was closed or reloaded mid-run. The call stayed in the thread with no result, every retry
15+
sent it back up, and the model API refused the whole conversation with `Tool result is missing for
16+
tool call ...`. The next three things the person typed failed identically, and the only way out was
17+
to notice that and start another channel.
18+
19+
A chat turn now drops a tool call nothing is going to answer before the conversation reaches the
20+
model, which is what routines already did for the history they seed. A result counts as an answer
21+
only if it arrives before the next thing the person or the system said, matching the rule the model
22+
API enforces, so a handler that resolves after the person has typed again no longer looks like an
23+
answer. Ids are never rewritten and the stored thread is untouched, so the transcript still shows
24+
what happened and a call waiting on a resume still gets its result.
25+
1126
### Coworkers are made in a wizard and managed in a dialog
1227

1328
Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs,
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* The one filter that keeps a broken conversation from being replayed at a model provider for ever.
3+
*
4+
* IT LIVES HERE BECAUSE BOTH TURN PATHS NEED IT. A routine's headless turn seeds history itself
5+
* (`routines/run-turn.ts`) and a chat turn is handed history by the browser (`copilot.ts`). Both
6+
* hand that history to a `BuiltInAgent`, which converts it and lets the model provider validate the
7+
* call/result pairing. `run-turn.ts` imports `../copilot`, so this cannot live in either of them
8+
* without one importing the other back.
9+
*/
10+
import type { Message, ToolCall } from "@ag-ui/client";
11+
12+
/** Whether a message said nothing at all — no text, no parts, nothing to show a person. */
13+
function isSilent(message: Message): boolean {
14+
const content = (message as { content?: unknown }).content;
15+
if (content === undefined || content === null) return true;
16+
if (typeof content === "string") return content.length === 0;
17+
if (Array.isArray(content)) return content.length === 0;
18+
return false;
19+
}
20+
21+
/**
22+
* Refuse to re-present a conversation the model API will reject.
23+
*
24+
* FOUND IN PRODUCTION, TWICE, ON BOTH PATHS. First on routines: two firings of one routine, fifteen
25+
* minutes apart, both failed with `Tool result is missing for tool call
26+
* call_TTbiXzJVNifQt8ioU1JJmj4S.` — the SAME call id both times, so it did not come from the live
27+
* turn: the channel's Intelligence thread held an assistant message carrying a tool call whose
28+
* result message never landed, because an earlier CHAT turn was interrupted mid-call. Then on chat
29+
* itself: `AI_MissingToolResultsError: Tool result is missing for tool call
30+
* chatcmpl-tool-8dd56dc7497c5ea9`, thrown three times in a row on one person's next three attempts
31+
* to say anything. There the damaged message was not even durable: a frontend tool handler was torn
32+
* down mid-run, so the live agent's messages in the browser held the call, the store did not, and
33+
* every retry sent the same unanswerable call back up as `input.messages`.
34+
*
35+
* A model provider validates call/result pairing, so one historical dangle poisons EVERY later turn
36+
* that replays it: on routines until the fatigue rule disables the routine, and on chat until the
37+
* person works out for themselves that the conversation is dead and starts another one. A permanent
38+
* failure grown out of transient damage, and nothing the person did wrong.
39+
*
40+
* WHY DROPPING IS THE RIGHT ANSWER, and not repair. History here is CONTEXT for a turn, not a
41+
* transaction to resume. A dangling call is already permanently unanswerable — the tool run that
42+
* would have answered it ended when that turn did, and there is no result to invent. The only two
43+
* options are to seed a conversation the API refuses, or to seed the same conversation minus a call
44+
* that never completed. The second one loses a fragment of an interrupted exchange; the first one
45+
* takes the conversation away.
46+
*
47+
* WHAT THIS DOES NOT DO. It does not DELETE anything from the platform. The thread still holds every
48+
* row and the person still sees the interrupted exchange in their channel. This is a read-side
49+
* filter on one turn's input and nothing more.
50+
*
51+
* IDS ARE NEVER CHANGED, which is what keeps `persistedInputMessages`' id-subtraction in
52+
* `run-turn.ts` correct: a message this pass stripped a tool call from keeps its id and is still
53+
* subtracted out as historic, and a message it dropped was never a candidate to persist. So
54+
* sanitizing cannot turn a firing into one that re-persists the transcript.
55+
*
56+
* The rules, in order:
57+
* 1. A tool call is ANSWERED if some later message carries it as `toolCallId`, or if the caller
58+
* says it is answered elsewhere. Later, not merely present: a result ahead of its call is not a
59+
* pairing any provider accepts either.
60+
* 2. An assistant message keeps only its answered calls. If that leaves it with no calls and
61+
* nothing said, the message is dropped — an empty assistant husk is itself invalid for some
62+
* providers, so stripping the call is not enough.
63+
* 3. A tool result whose `toolCallId` matches no surviving call is dropped: the mirror-image dangle,
64+
* which is what an interruption between the two rows leaves behind in the other order.
65+
*
66+
* Order is preserved, the input array is not mutated, and a message the pass does not change is
67+
* returned as the same object — a healthy thread, which is nearly all of them, goes through
68+
* untouched rather than through a re-normalization that could quietly differ.
69+
*
70+
* @param answeredElsewhere Call ids that are about to be answered by something this history cannot
71+
* see, and so must survive. That is the interrupt resume: `BuiltInAgent.run` appends a tool result
72+
* per `input.resume` entry, keyed by `interruptId`, AFTER converting the messages
73+
* (`@copilotkit/runtime/dist/agent/index.mjs`, the `resumeEntries` block in `run`). Dropping the
74+
* call that the resume answers would turn a resumable interrupt into an orphaned result, which is
75+
* the same error seen from the other side.
76+
*/
77+
export function sanitizeSeededHistory(
78+
history: Message[],
79+
answeredElsewhere: ReadonlySet<string> = new Set(),
80+
): Message[] {
81+
/** Every position that answers a call id, in order. */
82+
const answersFor = new Map<string, number[]>();
83+
for (const [index, message] of history.entries()) {
84+
const { toolCallId } = message as { toolCallId?: string };
85+
if (toolCallId === undefined) continue;
86+
answersFor.set(toolCallId, [...(answersFor.get(toolCallId) ?? []), index]);
87+
}
88+
/*
89+
* Where a call's answer may still land: before the next thing a person or the system said.
90+
*
91+
* The model API walks the conversation in order and refuses it the moment a user or system
92+
* message arrives while a call is still unanswered. So a result that turns up after a later
93+
* user message does not answer anything, however real it was. This happened live: a browser
94+
* tool handler resolved late, its result was appended after the person had already typed the
95+
* next message, and the call read as answered here while the API still threw on every retry.
96+
*/
97+
const boundaryAfter: number[] = new Array(history.length).fill(
98+
history.length,
99+
);
100+
for (
101+
let index = history.length - 1, next = history.length;
102+
index >= 0;
103+
index -= 1
104+
) {
105+
boundaryAfter[index] = next;
106+
const { role } = history[index] as { role?: string };
107+
if (role === "user" || role === "system" || role === "developer")
108+
next = index;
109+
}
110+
const answeredWithin = (id: string, index: number): boolean =>
111+
(answersFor.get(id) ?? []).some(
112+
(at) => at > index && at < (boundaryAfter[index] ?? history.length),
113+
);
114+
115+
const surviving = new Set<string>();
116+
const kept: (Message | undefined)[] = history.map((message, index) => {
117+
const { toolCalls } = message as { toolCalls?: ToolCall[] };
118+
if (toolCalls === undefined) return message;
119+
120+
const answered = toolCalls.filter((call) => {
121+
if (answeredElsewhere.has(call.id)) return true;
122+
return answeredWithin(call.id, index);
123+
});
124+
for (const call of answered) surviving.add(call.id);
125+
126+
// The husk check goes FIRST so it also catches a row that arrived with no calls and nothing
127+
// said — the same invalid shape, reached without a dangle.
128+
if (answered.length === 0 && isSilent(message)) return undefined;
129+
// The healthy path, and the only one that returns the very same object.
130+
if (answered.length === toolCalls.length) return message;
131+
132+
/*
133+
* Cast for the same reason `toAgentMessage` casts: `Message` is a union discriminated on `role`,
134+
* and a spread over the union widens past every branch of it. Neither rewrite here can change
135+
* the role or the shape — one narrows the `toolCalls` array, the other removes the key — so
136+
* there is nothing to narrow against and nothing that could stop being a `Message`.
137+
*/
138+
if (answered.length > 0) {
139+
return { ...message, toolCalls: answered } as Message;
140+
}
141+
// Text it did say, minus a call it cannot complete.
142+
const { toolCalls: _dropped, ...rest } = message as Message & {
143+
toolCalls?: ToolCall[];
144+
};
145+
return rest as Message;
146+
});
147+
148+
return kept.filter((message): message is Message => {
149+
if (message === undefined) return false;
150+
const { toolCallId } = message as { toolCallId?: string };
151+
return toolCallId === undefined || surviving.has(toolCallId);
152+
});
153+
}

‎server/src/copilot.ts‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
COMPUTER_GUIDANCE,
1515
PROVENANCE_GUIDANCE,
1616
} from "../../shared/bot-prompt";
17+
import { sanitizeSeededHistory } from "./agents/history-sanitize";
1718
import type { AgentActor } from "./agents/profile-types";
1819
import type { AgentFetch, StallGuard } from "./channels/stall-guard";
1920
import type { DeploymentConfig } from "./config";
@@ -419,7 +420,7 @@ async function buildAgent(
419420
* what keeps a narrowed run from being told it holds something it was not offered.
420421
*/
421422
const withTools = (tools: GrantedTool[]) =>
422-
new BuiltInAgent(
423+
new BuiltInAgentWithSaneHistory(
423424
builtInAgentConfiguration(
424425
agent,
425426
model,
@@ -666,6 +667,68 @@ function remoteAgentWithStandingRole(
666667
return remote;
667668
}
668669

670+
/**
671+
* A built-in Bot that will not hand the model provider a conversation it is going to refuse.
672+
*
673+
* FOUND LIVE, ON CHAT. One person's next three messages each failed with
674+
* `AI_MissingToolResultsError: Tool result is missing for tool call chatcmpl-tool-8dd56dc7497c5ea9`,
675+
* thrown out of the AI SDK's `convertToLanguageModelPrompt`. A frontend tool handler had been torn
676+
* down while its call was open, so the agent's live messages in the browser carried an assistant
677+
* message whose tool call never got a result. The durable store did not have it, nothing was going
678+
* to answer it, and every retry sent it straight back up as `input.messages`. The conversation was
679+
* finished until the person worked out for themselves to start another one.
680+
*
681+
* The guard has to be on this side of `run`. `BuiltInAgent.run` converts `input.messages` itself,
682+
* with no seam in between, so wrapping the agent is the only place left to stand. The reasoning for
683+
* why a dangling call is DROPPED rather than repaired, and why ids are never changed, is in
684+
* `agents/history-sanitize.ts`, where the routines path found the same failure first.
685+
*
686+
* A RESUMED CALL IS NOT A DANGLE. `run` appends a tool result for each `input.resume` entry by
687+
* `interruptId` AFTER converting the messages, so a call that a resume is about to answer must
688+
* survive this pass or the appended result lands on nothing.
689+
*/
690+
class BuiltInAgentWithSaneHistory extends BuiltInAgent {
691+
/**
692+
* The configuration, held a second time because the base class keeps its own copy private and
693+
* {@link clone} has to build another one of THIS class rather than of the base.
694+
*/
695+
private readonly configuration: BuiltInAgentConfiguration;
696+
697+
constructor(configuration: BuiltInAgentConfiguration) {
698+
super(configuration);
699+
this.configuration = configuration;
700+
}
701+
702+
run(input: RunAgentInput): Observable<BaseEvent> {
703+
const answeredByResume = new Set(
704+
(input.resume ?? []).map((entry) => entry.interruptId),
705+
);
706+
return super.run({
707+
...input,
708+
messages: sanitizeSeededHistory(input.messages, answeredByResume),
709+
});
710+
}
711+
712+
/**
713+
* Carried by hand, for the same reason {@link RunBuiltAgent.clone} is.
714+
*
715+
* The runtime clones an agent before every run, and the base class's clone hard-codes
716+
* `new BuiltInAgent(this.config)`: inherited unchanged, the very first message anybody sends
717+
* would go through an agent that does none of the above. The middleware list is copied because
718+
* the base clone copies it, and it is reached through a cast because `AbstractAgent` declares it
719+
* private. Nothing registers middleware on a built-in Bot today, and this is here so that the day
720+
* something does, it is not lost in a clone.
721+
*/
722+
clone(): BuiltInAgentWithSaneHistory {
723+
const cloned = new BuiltInAgentWithSaneHistory(this.configuration);
724+
type WithMiddlewares = { middlewares: unknown[] };
725+
(cloned as unknown as WithMiddlewares).middlewares = [
726+
...(this as unknown as WithMiddlewares).middlewares,
727+
];
728+
return cloned;
729+
}
730+
}
731+
669732
/**
670733
* An agent whose tools are decided when the run starts, because that is the first moment anybody
671734
* knows what the run is about, and who is asking on whose behalf.

0 commit comments

Comments
 (0)