diff --git a/.changeset/openai-tool-exchange-400-recovery.md b/.changeset/openai-tool-exchange-400-recovery.md new file mode 100644 index 000000000..8337d0174 --- /dev/null +++ b/.changeset/openai-tool-exchange-400-recovery.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kosong": patch +--- + +Recognize the OpenAI-compatible `role 'tool' must be a response to a preceding message with 'tool_calls'` and `assistant message with 'tool_calls' must be followed by tool messages` 400s (OpenAI / DeepSeek / vLLM / Qwen phrasings) as recoverable tool-exchange structural errors, so the post-400 strict-resend fallback fires and un-bricks the session instead of failing every subsequent turn — including after switching providers or models. diff --git a/.changeset/projector-drops-orphan-tool-results.md b/.changeset/projector-drops-orphan-tool-results.md new file mode 100644 index 000000000..3768dfd6e --- /dev/null +++ b/.changeset/projector-drops-orphan-tool-results.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core": patch +--- + +Drop orphan tool results at the projection boundary so a malformed history cannot brick a session. A `tool` result whose assistant `tool_call` is nowhere in the history (e.g. an older session whose compaction cut fell inside a tool exchange, restored via the legacy path) is now removed from every projected request, not only on the post-400 strict resend. The stored history is left faithful to the wire records — so consumers that model it, like the transcript fold length, stay in sync — while a strict provider (OpenAI / DeepSeek) always receives a valid request. The drop is surfaced via the projection-repair log rather than done silently. diff --git a/packages/agent-core/src/agent/compaction/full.ts b/packages/agent-core/src/agent/compaction/full.ts index 2cef566e4..a310fd905 100644 --- a/packages/agent-core/src/agent/compaction/full.ts +++ b/packages/agent-core/src/agent/compaction/full.ts @@ -412,8 +412,15 @@ export class FullCompaction { let overflowShrinkCount = 0; let emptyOrTruncatedShrinkCount = 0; while (true) { + // A request-building projection: close still-open calls in the sliced + // prefix (synthesizeMissing) and drop stray results with no call anywhere + // (dropOrphanResults), so the summarizer request cannot be rejected by a + // strict provider even when the history carries a legacy-restore orphan. const messages = [ - ...this.agent.context.project(historyForModel, { synthesizeMissing: true }), + ...this.agent.context.project(historyForModel, { + synthesizeMissing: true, + dropOrphanResults: true, + }), createUserMessage(instruction), ]; const estimatedCompactionRequestTokens = this.estimateRequestTokens(messages); diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index fa59da0de..3b3580187 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -308,14 +308,18 @@ export class ContextMemory { }; // Wire backward-compat: a pre-rework `context.apply_compaction` record (which // has no `keptUserMessageCount`) used `[summary, ...history.slice(compactedCount)]` - // semantics and kept a verbatim recent tail. Reproduce that exact shape on - // restore so resuming a session compacted by an older version does not - // silently drop the recent assistant/tool tail beyond `compactedCount`. Gated - // on `records.restoring`, so the live/forward path — which always sets - // `contextSummary` and `keptUserMessageCount` — is unaffected. The projector's - // tool-adjacency repair keeps the restored tail well-formed for strict - // providers; compaction only runs at a clean step boundary, so the tail has no - // open tool exchange to track. + // semantics and kept a verbatim recent tail. Reproduce that shape on restore + // so resuming a session compacted by an older version does not silently drop + // the recent assistant/tool tail beyond `compactedCount`. Gated on + // `records.restoring`, so the live/forward path — which always sets + // `contextSummary` and `keptUserMessageCount` — is unaffected. + // + // The cut can land inside a tool exchange, leaving the tail starting with an + // orphan `tool` result whose assistant is now in the summarized prefix. The + // history is kept faithful to the wire records (so the transcript reducer's + // fold length stays in sync); the projector drops the orphan at the wire + // boundary — see `dropOrphanToolResults` — so a strict provider still gets a + // valid request without mutating the stored history here. const isLegacyRestore = this.agent.records.restoring !== null && input.keptUserMessageCount === undefined && @@ -429,14 +433,21 @@ export class ContextMemory { } get messages(): Message[] { - return this.project(this.history); + // The normal wire projection. `dropOrphanResults` is on for every + // request-building projection (here, `strictMessages`, and the compaction + // summarizer): a stray result with no matching call anywhere is wire-invalid + // on strict providers and useless to the model, so it never reaches the + // provider — while fragment projections (e.g. token estimation of a history + // slice) leave it alone. + return this.project(this.history, { dropOrphanResults: true }); } // Last-resort projection for the post-400 strict resend: close every open tool - // call (including a trailing in-flight one) and drop any stray tool result with - // no matching call, so the request is wire-compliant for strict providers no - // matter how the history was mangled. Only used when the provider has already - // rejected the normal projection — see the adjacency fallback in `turn-step`. + // call (including a trailing in-flight one), drop stray tool results, drop a + // leading non-user message, and merge consecutive assistant turns, so the + // request is wire-compliant for strict providers no matter how the history was + // mangled. Only used when the provider has already rejected the normal + // projection — see the adjacency fallback in `turn-step`. get strictMessages(): Message[] { return this.project(this.history, { synthesizeMissing: true, diff --git a/packages/agent-core/src/agent/context/projector.ts b/packages/agent-core/src/agent/context/projector.ts index 9471b2ead..ebf08dc1b 100644 --- a/packages/agent-core/src/agent/context/projector.ts +++ b/packages/agent-core/src/agent/context/projector.ts @@ -19,11 +19,13 @@ export interface ProjectOptions { readonly synthesizeMissing?: boolean; /** * When `true`, drop any `tool_result` whose `toolCallId` matches no assistant - * `tool_use` anywhere in the provided messages. Strict providers reject such a - * stray result as an "unexpected `tool_result`". Off by default so the normal - * path never silently discards recorded output; the post-400 strict-resend - * fallback enables it (together with `synthesizeMissing`) as a last resort to - * force a wire-compliant request out of an otherwise-bricked session. + * `tool_use` anywhere in the provided messages. Such an orphan is wire-invalid + * on every strict provider and useless to the model (it has no record of the + * call the result answers). Enabled on every request-building projection — the + * normal wire, the strict resend, and the compaction summarizer — so a stray + * result never reaches a provider. Left OFF for non-request projections (e.g. + * token-estimating a history slice), where a result's matching call may + * legitimately sit outside the slice and must not be mistaken for an orphan. */ readonly dropOrphanResults?: boolean; /** @@ -69,7 +71,7 @@ export type ProjectionAnomaly = * was lost (a genuine defect worth investigating). */ | { readonly kind: 'tool_result_synthesized'; readonly toolCallId: string; readonly trailing: boolean } - /** A result with no matching call anywhere was dropped (strict resend only). */ + /** A result with no matching call anywhere was dropped (wire exits only). */ | { readonly kind: 'orphan_tool_result_dropped'; readonly toolCallId: string } /** A leading non-user message was dropped so the first turn is user (strict). */ | { readonly kind: 'leading_non_user_dropped'; readonly role: string } @@ -189,8 +191,12 @@ function repairToolExchangeAdjacency( // Remove any `tool_result` whose `toolCallId` matches no assistant `tool_use` // anywhere in the projected messages. Strict providers reject such a stray -// result; the post-400 strict-resend fallback drops them as a last resort. Kept -// separate from the adjacency repair so the normal path never discards output. +// result, and it is useless to the model regardless (it has no record of the +// call the result answers), so every request-building projection drops it (via +// `dropOrphanResults`). Kept separate from the adjacency repair, which only +// reorders results that DO have a matching call; this removes the ones that do +// not. Reported via `onAnomaly` so the drop leaves a trace instead of silently +// discarding a recorded result. function dropOrphanToolResults( messages: readonly Message[], onAnomaly?: (anomaly: ProjectionAnomaly) => void, diff --git a/packages/agent-core/test/agent/compaction/anthropic-compliance.test.ts b/packages/agent-core/test/agent/compaction/anthropic-compliance.test.ts index fa2315858..3fed7fdf1 100644 --- a/packages/agent-core/test/agent/compaction/anthropic-compliance.test.ts +++ b/packages/agent-core/test/agent/compaction/anthropic-compliance.test.ts @@ -244,7 +244,7 @@ describe('compaction — Anthropic wire compliance', () => { { role: 'tool', content: [{ type: 'text', text: 'done' }], toolCalls: [], toolCallId: 'call_2' }, ]; - // Normal send path: no synthesizeMissing, no dropOrphanResults. + // Normal send path: no synthesizeMissing. const projected = ctx.agent.context.project(orphaned); const wire = await toAnthropicWire(projected, [BASH_TOOL]); assertValidAnthropic(wire); @@ -260,20 +260,19 @@ describe('compaction — Anthropic wire compliance', () => { ).toBe(true); }); - it('drops a stray tool result with no matching call on the strict resend path', async () => { + it('drops a stray tool result with no matching call from request projections', async () => { const ctx = testAgent(); ctx.configure({ provider: PROVIDER, modelCapabilities: CAPS }); - // A tool_result whose tool_use is gone (e.g. an undo removed the assistant). - // The normal path leaves it (it has no anchor); the strict resend drops it. + // A tool_result whose tool_use is gone (e.g. an undo removed the assistant, + // or a legacy-restore compaction cut mid-exchange). Every request-building + // projection (`messages`, `strictMessages`, the summarizer) enables + // dropOrphanResults — it has no anchor and is useless to the model. const stray: ContextMessage[] = [ { role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [], origin: { kind: 'user' } }, { role: 'tool', content: [{ type: 'text', text: 'orphan output' }], toolCalls: [], toolCallId: 'gone' }, ]; - const projected = ctx.agent.context.project(stray, { - synthesizeMissing: true, - dropOrphanResults: true, - }); + const projected = ctx.agent.context.project(stray, { dropOrphanResults: true }); expect(projected.some((m) => m.role === 'tool')).toBe(false); const wire = await toAnthropicWire(projected); assertValidAnthropic(wire); diff --git a/packages/agent-core/test/agent/context/projector.test.ts b/packages/agent-core/test/agent/context/projector.test.ts index 8ddcad859..f3cfbcf51 100644 --- a/packages/agent-core/test/agent/context/projector.test.ts +++ b/packages/agent-core/test/agent/context/projector.test.ts @@ -341,8 +341,9 @@ describe('project tool_use/tool_result adjacency', () => { tool('orphan-result'), user('u2'), ]; + // Without dropOrphanResults (fragment projections, e.g. token estimation) + // the stray result stays where it was; nothing references it. const projected = project(history); - // The stray result stays where it was; nothing references it. expect(projected.map((m) => [m.role, m.toolCallId])).toEqual([ ['user', undefined], ['assistant', undefined], @@ -350,6 +351,10 @@ describe('project tool_use/tool_result adjacency', () => { ['tool', 'orphan-result'], ['user', undefined], ]); + // Request-building projections enable dropOrphanResults and remove it. + const wire = project(history, { dropOrphanResults: true }); + expect(wire.some((m) => m.toolCallId === 'orphan-result')).toBe(false); + expect(wire.some((m) => m.toolCallId === 'a')).toBe(true); }); it('does not crash when a tool result appears before its tool_use', () => { @@ -425,15 +430,18 @@ describe('project repair reporting', () => { ]); }); - it('reports a dropped orphan result only on the strict path', () => { + it('reports a dropped orphan result when dropOrphanResults is set', () => { const history: ContextMessage[] = [user('u1'), assistant(['a']), tool('a'), tool('stray')]; - const normal: ProjectionAnomaly[] = []; - project(history, { onAnomaly: (a) => normal.push(a) }); - expect(normal).toEqual([]); // normal path leaves the stray result in place + // Fragment projections (no flag) leave the stray in place and report nothing. + const fragment: ProjectionAnomaly[] = []; + project(history, { onAnomaly: (a) => fragment.push(a) }); + expect(fragment).toEqual([]); - const strict: ProjectionAnomaly[] = []; - project(history, { dropOrphanResults: true, onAnomaly: (a) => strict.push(a) }); - expect(strict).toEqual([{ kind: 'orphan_tool_result_dropped', toolCallId: 'stray' }]); + // Request-building projections (normal wire, strict resend, summarizer) + // enable the flag, drop the stray, and surface the repair. + const wire: ProjectionAnomaly[] = []; + project(history, { dropOrphanResults: true, onAnomaly: (a) => wire.push(a) }); + expect(wire).toEqual([{ kind: 'orphan_tool_result_dropped', toolCallId: 'stray' }]); }); it('reports a whitespace-only text drop but not a truly-empty one', () => { diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index 12afac0dc..2540d7340 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -388,6 +388,95 @@ describe('Agent resume', () => { ]); }); + it('keeps a legacy mid-tool-exchange cut faithful but projects it wire-valid', async () => { + // A pre-rework compaction record (no `keptUserMessageCount`) restores via the + // legacy path, which keeps a verbatim tail `history.slice(compactedCount)`. + // Here the cut (compactedCount=2) lands *between* the assistant `tool_call` + // and its result, so the retained tail starts with a `tool` message whose + // assistant was summarized away — a wire-invalid orphan a strict provider + // (OpenAI / DeepSeek) rejects with "role 'tool' must be a response to a + // preceding message with 'tool_calls'". The restore keeps the history + // faithful (so the transcript reducer's fold length stays in sync); the + // projector drops the orphan at the wire boundary. + const persistence = new RecordingAgentPersistence([ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'first prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'orphan-step', turnId: '0', step: 1 }, + }, + { + type: 'context.append_loop_event', + event: { + type: 'tool.call', + uuid: 'orphan-call', + turnId: '0', + step: 1, + stepUuid: 'orphan-step', + toolCallId: 'call_orphaned', + name: 'Bash', + args: { command: 'pwd' }, + }, + }, + { + type: 'context.append_loop_event', + event: { + type: 'tool.result', + parentUuid: 'orphan-call', + toolCallId: 'call_orphaned', + result: { output: 'ok', isError: false }, + }, + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'second prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'context.apply_compaction', + summary: 'Compacted the first exchange.', + compactedCount: 2, + tokensBefore: 120, + tokensAfter: 24, + }, + ]); + const ctx = testAgent({ persistence }); + + await ctx.agent.resume(); + + // The stored history stays faithful to the wire records: the orphan `tool` + // result is kept verbatim (not mutated away at restore), so downstream + // consumers that model the history from the records — e.g. the transcript + // reducer's fold length — stay in sync. + expect(ctx.agent.context.history.some((message) => message.role === 'tool')).toBe(true); + + // But the projected wire the provider actually sees has no orphan: every + // `tool` result is answered by a preceding assistant `tool_calls`. + const projected = ctx.agent.context.messages; + const toolCallIds = new Set( + projected.flatMap((message) => + message.role === 'assistant' ? message.toolCalls.map((toolCall) => toolCall.id) : [], + ), + ); + const orphanToolResults = projected.filter( + (message) => + message.role === 'tool' && + (message.toolCallId === undefined || !toolCallIds.has(message.toolCallId)), + ); + expect(orphanToolResults).toEqual([]); + }); + it('projects restored cancelled compactions into replay records', async () => { const persistence = new RecordingAgentPersistence([ { diff --git a/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts b/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts index 67fe75d4c..cab6151d5 100644 --- a/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts +++ b/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts @@ -32,6 +32,14 @@ const ADJACENCY_400 = new APIStatusError( // structural rejection. Verbatim from the field, doubled space included. const MOONSHOT_TOOL_CALL_ID_400 = new APIStatusError(400, '400 tool_call_id is not found'); +// The OpenAI / DeepSeek phrasing of an orphan `tool` result — a `tool` message +// with no preceding assistant `tool_calls`. This is what a DeepSeek / OpenAI- +// compatible provider returns for a history bricked by a stray tool result. +const OPENAI_ROLE_TOOL_400 = new APIStatusError( + 400, + "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", +); + function userMessage(text: string): Message { return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] }; } @@ -98,6 +106,19 @@ describe('executeLoopStep — tool exchange adjacency fallback', () => { expect(llm.calls[1]?.messages).toBe(strictMessages); }); + it('resends once and recovers after an OpenAI/DeepSeek role-tool 400', async () => { + const { input, llm, strictCalls, strictMessages } = makeHarness(OPENAI_ROLE_TOOL_400); + + const result = await runTurn(input); + + expect(result.stopReason).toBe('end_turn'); + // Exactly two provider calls: the rejected one and the strict resend. + expect(llm.callCount).toBe(2); + expect(strictCalls.count).toBe(1); + expect(llm.calls[0]?.messages).toEqual([userMessage('normal projection')]); + expect(llm.calls[1]?.messages).toBe(strictMessages); + }); + it('does not resend for an unrelated 400 — the error propagates and strict is untouched', async () => { const { input, llm, strictCalls } = makeHarness(new APIStatusError(400, 'Bad request')); diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 56d3fef9b..e35980560 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -174,11 +174,14 @@ export function isContextOverflowStatusError(statusCode: number, message: string // `tool_result`/`tool` blocks are not correctly paired and adjacent — a missing // result, a stray result with no matching call, or a result that does not // immediately follow its call. Anthropic phrases this in terms of -// `tool_use`/`tool_result`; OpenAI-compatible providers (Moonshot / Kimi) phrase -// it as a `tool_call_id` that "is not found" in the preceding assistant message. -// The validation runs before any generation, so the error is a non-retryable -// 4xx. A caller can react by resending a re-projected, strictly wire-compliant -// request rather than leaving the session permanently stuck. +// `tool_use`/`tool_result`. OpenAI-compatible providers phrase it in terms of +// `tool_call_id` / `role 'tool'` / `tool_calls`: Moonshot / Kimi as a +// `tool_call_id` that "is not found", and OpenAI / DeepSeek / vLLM / Qwen as a +// `role 'tool'` message without a preceding `tool_calls`, or an assistant +// `tool_calls` not followed by its tool results. The validation runs before any +// generation, so the error is a non-retryable 4xx. A caller can react by +// resending a re-projected, strictly wire-compliant request rather than leaving +// the session permanently stuck. const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [ /tool_use[\s\S]*tool_result/, /tool_result[\s\S]*tool_use/, @@ -189,6 +192,27 @@ const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [ // (doubled space). Anchored on `tool_call_id` so an unrelated "not found" // (e.g. a 404-style body) cannot trip the recovery. /tool_call_id[\s\S]*not found/, + // OpenAI / DeepSeek / vLLM and other OpenAI-compatible providers phrase the + // same structural rejection in terms of `role 'tool'` / `tool_calls` instead + // of Anthropic's `tool_use` / `tool_result`, in two mirror-image shapes: + // + // - An orphan `tool` result whose preceding assistant carries no matching + // `tool_calls`: "messages with role 'tool' must be a response to a + // preceding message with 'tool_calls'". + // - An assistant `tool_calls` with no following `tool` results: "an + // assistant message with 'tool_calls' must be followed by tool messages + // responding to each 'tool_call_id'. the following tool_call_ids did not + // have response messages: ...", or the terse "(insufficient tool messages + // following tool_calls message)". + // + // Both are wire-structure defects the strict resend repairs (drop the orphan + // result / synthesize the missing one). Quote style around `tool`/`tool_calls` + // varies by provider (straight or backtick), so the anchors tolerate an + // optional surrounding quote char. + /role\s+['"`]?tool['"`]?\s+must be a response to a preceding message/, + /assistant message with\s+['"`]?tool_calls['"`]?\s+must be followed by tool messages/, + /tool_call_ids? did not have response messages/, + /insufficient tool messages following/, ] as const; export function isToolExchangeAdjacencyError(error: unknown): boolean { diff --git a/packages/kosong/test/errors.test.ts b/packages/kosong/test/errors.test.ts index e6b8bb132..47c4c3eb8 100644 --- a/packages/kosong/test/errors.test.ts +++ b/packages/kosong/test/errors.test.ts @@ -267,6 +267,59 @@ describe('isToolExchangeAdjacencyError', () => { ).toBe(true); }); + // OpenAI / DeepSeek / vLLM and other OpenAI-compatible providers phrase the + // orphan-`tool`-result case as a `role 'tool'` message that has no preceding + // assistant `tool_calls`. Observed verbatim in the field (see zed #41531, + // llama_index #13715). Quote style varies by provider (straight or backtick). + it('matches the OpenAI/DeepSeek role-tool-without-tool_calls 400', () => { + expect( + isToolExchangeAdjacencyError( + new APIStatusError( + 400, + "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", + ), + ), + ).toBe(true); + expect( + isToolExchangeAdjacencyError( + new APIStatusError( + 400, + 'Role `tool` must be a response to a preceding message with `tool_calls`', + ), + ), + ).toBe(true); + }); + + // The mirror-image OpenAI-compatible rejection: an assistant `tool_calls` + // message with no following `tool` results. OpenAI/Portkey (#6621, error + // 10067) spell it out; Qwen/DashScope (#454) uses double quotes; some + // providers emit the terse "(insufficient tool messages following ...)". + it('matches the assistant-tool_calls-without-response 400', () => { + expect( + isToolExchangeAdjacencyError( + new APIStatusError( + 400, + "An assistant message with 'tool_calls' must be followed by tool messages responding to each " + + "'tool_call_id'. The following tool_call_ids did not have response messages: call_hSmZB4G8", + ), + ), + ).toBe(true); + expect( + isToolExchangeAdjacencyError( + new APIStatusError( + 400, + 'An assistant message with "tool_calls" must be followed by tool messages responding to each ' + + '"tool_call_id". The following tool_call_ids did not have response messages: message[322].role', + ), + ), + ).toBe(true); + expect( + isToolExchangeAdjacencyError( + new APIStatusError(400, '(insufficient tool messages following tool_calls message)'), + ), + ).toBe(true); + }); + it('does not match a context-overflow 400 or unrelated errors', () => { expect( isToolExchangeAdjacencyError(new APIContextOverflowError(400, 'context length exceeded')), @@ -275,6 +328,13 @@ describe('isToolExchangeAdjacencyError', () => { // A bare "not found" without a tool_call_id anchor must not match, so an // unrelated 404-style body cannot trip the tool-exchange recovery. expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'resource not found'))).toBe(false); + // A model-availability 400 (observed alongside this family in the field) is a + // config error, not a tool-exchange defect — strict resend must not fire. + expect( + isToolExchangeAdjacencyError( + new APIStatusError(400, '400 Not supported model mimo-v2.5-pro-ultraspeed'), + ), + ).toBe(false); expect(isToolExchangeAdjacencyError(new APIStatusError(500, ANTHROPIC_MISSING_RESULT))).toBe( false, ); @@ -298,6 +358,26 @@ describe('isRecoverableRequestStructureError', () => { ).toBe(true); }); + it('matches the OpenAI-compatible role-tool / assistant-tool_calls pairing 400s', () => { + expect( + isRecoverableRequestStructureError( + new APIStatusError( + 400, + "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", + ), + ), + ).toBe(true); + expect( + isRecoverableRequestStructureError( + new APIStatusError( + 400, + "An assistant message with 'tool_calls' must be followed by tool messages responding to each " + + "'tool_call_id'. The following tool_call_ids did not have response messages: call_hSmZB4G8", + ), + ), + ).toBe(true); + }); + it('matches empty / whitespace-only text content rejections', () => { expect( isRecoverableRequestStructureError(