|
| 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 | +} |
0 commit comments