Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/repair-unanswered-tool-use.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'vscode-agent-platform-connector': patch
---

Fix recurring Vertex 400 "tool_use ids were found without tool_result blocks immediately after" by synthesizing placeholder tool results for assistant tool calls whose results never landed in the chat history (e.g. a prior turn failed or was cancelled mid-tool-call). Previously one failed tool call wedged the conversation, causing every subsequent request to fail with the same 400.
53 changes: 53 additions & 0 deletions src/vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,8 @@ export function buildClaudeBody(model: ModelDef, req: NormRequest): unknown {
if (content.length) messages.push({role: m.role, content});
}

repairUnansweredToolUse(messages);

const body: Record<string, unknown> = {
anthropic_version: 'vertex-2023-10-16',
messages,
Expand All @@ -639,6 +641,57 @@ export function buildClaudeBody(model: ModelDef, req: NormRequest): unknown {
return {...body, ...req.modelOptions};
}

/**
* Anthropic also enforces the inverse of the orphaned-`tool_result` rule
* handled above: every assistant `tool_use` block must be answered by a
* matching `tool_result` in the immediately-following user message. VS Code's
* history violates this whenever a turn dies mid-tool-call (network error,
* upstream 4xx/5xx, user cancellation) — the assistant's `tool_use` is
* recorded but its result never lands, and from then on *every* request built
* from that history is rejected with a 400 ("tool_use ids were found without
* tool_result blocks immediately after"), wedging the conversation. Repair in
* place by synthesizing error placeholder results for the unanswered ids.
*/
function repairUnansweredToolUse(
messages: Array<Record<string, unknown>>,
): void {
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
if (m.role !== 'assistant' || !Array.isArray(m.content)) continue;
const toolUseIds = (m.content as Array<Record<string, unknown>>)
.filter((b) => b.type === 'tool_use')
.map((b) => b.id as string);
if (!toolUseIds.length) continue;

const next = messages[i + 1];
const nextBlocks =
next?.role === 'user' && Array.isArray(next.content)
? (next.content as Array<Record<string, unknown>>)
: null;
const answered = new Set(
(nextBlocks ?? [])
.filter((b) => b.type === 'tool_result')
.map((b) => b.tool_use_id as string),
);
const missing = toolUseIds.filter((id) => !answered.has(id));
if (!missing.length) continue;

const placeholders = missing.map((id) => ({
type: 'tool_result',
tool_use_id: id,
is_error: true,
content: 'Tool call did not complete; no result was recorded.',
}));
if (nextBlocks && answered.size) {
// The following turn already carries some results; complete it in place
// (tool_result turns we build contain only tool_result blocks).
nextBlocks.push(...placeholders);
} else {
messages.splice(i + 1, 0, {role: 'user', content: placeholders});
}
}
}

/* -------------------------------------------------------------------------- */
/* SSE plumbing */
/* -------------------------------------------------------------------------- */
Expand Down
155 changes: 150 additions & 5 deletions test/claude-body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,156 @@ describe('buildClaudeBody tool_use / tool_result pairing', () => {
],
});

// The tool_result now follows a plain user turn, not the assistant
// tool_use, so its id is no longer in scope and it must be dropped.
const hasToolResult = messages
// The real tool_result follows a plain user turn, not the assistant
// tool_use, so its id is out of scope and must be dropped. The assistant
// tool_use is then unanswered, so a synthetic error result is inserted
// immediately after it instead.
const results = messages
.flatMap((m) => (Array.isArray(m.content) ? m.content : []))
.some((c) => c.type === 'tool_result');
expect(hasToolResult).toBe(false);
.filter((c) => c.type === 'tool_result');
expect(results).toHaveLength(1);
expect(results[0].tool_use_id).toBe('call_1');
expect(results[0].is_error).toBe(true);
expectToolPairingValid(messages);
});
});

/**
* Assert the invariant Vertex enforces: every assistant tool_use id has a
* matching tool_result in the immediately-following message, and every
* tool_result pairs with a tool_use in the immediately-preceding message.
*/
function expectToolPairingValid(messages: Array<Record<string, any>>): void {
for (let i = 0; i < messages.length; i++) {
const blocks = Array.isArray(messages[i].content)
? messages[i].content
: [];
const useIds = blocks
.filter((c: any) => c.type === 'tool_use')
.map((c: any) => c.id);
if (!useIds.length) continue;
const nextBlocks = Array.isArray(messages[i + 1]?.content)
? messages[i + 1].content
: [];
const resultIds = new Set(
nextBlocks
.filter((c: any) => c.type === 'tool_result')
.map((c: any) => c.tool_use_id),
);
for (const id of useIds) expect(resultIds.has(id)).toBe(true);
}
}

describe('buildClaudeBody unanswered tool_use repair', () => {
it('inserts a synthetic result when a plain user turn follows a tool_use', () => {
// A prior turn died mid-tool-call, then the user sent a new prompt.
const messages = claudeMessages({
messages: [
{role: 'user', text: 'hi'},
{
role: 'assistant',
toolCalls: [{id: 'call_dead', name: 'do_thing', input: {}}],
},
{role: 'user', text: 'try again please'},
],
});

expectToolPairingValid(messages);
const synthetic = messages[2].content.find(
(c: any) => c.type === 'tool_result',
);
expect(synthetic.tool_use_id).toBe('call_dead');
expect(synthetic.is_error).toBe(true);
// The user's real prompt still follows, untouched.
expect(messages[3].content).toEqual([
{type: 'text', text: 'try again please'},
]);
});

it('completes a partial tool_result turn missing some ids', () => {
const messages = claudeMessages({
messages: [
{
role: 'assistant',
toolCalls: [
{id: 'call_a', name: 'do_thing', input: {}},
{id: 'call_b', name: 'do_other', input: {}},
],
},
{role: 'user', toolResults: [{callId: 'call_a', content: 'ok'}]},
],
});

expectToolPairingValid(messages);
const results = messages[1].content.filter(
(c: any) => c.type === 'tool_result',
);
expect(results.map((r: any) => r.tool_use_id).sort()).toEqual([
'call_a',
'call_b',
]);
expect(results.find((r: any) => r.tool_use_id === 'call_a').is_error).toBe(
undefined,
);
expect(results.find((r: any) => r.tool_use_id === 'call_b').is_error).toBe(
true,
);
});

it('appends a synthetic result turn after a trailing tool_use', () => {
const messages = claudeMessages({
messages: [
{role: 'user', text: 'hi'},
{
role: 'assistant',
toolCalls: [{id: 'call_tail', name: 'do_thing', input: {}}],
},
],
});

expectToolPairingValid(messages);
expect(messages[messages.length - 1].role).toBe('user');
});

it('repairs consecutive failed tool-call turns independently', () => {
// Two turns in a row died mid-tool-call — the "wedged conversation" case
// where every retry re-fails and appends another dangling tool_use.
const messages = claudeMessages({
messages: [
{role: 'user', text: 'hi'},
{
role: 'assistant',
toolCalls: [{id: 'call_1', name: 'do_thing', input: {}}],
},
{
role: 'assistant',
toolCalls: [{id: 'call_2', name: 'do_thing', input: {}}],
},
{role: 'user', text: 'still broken?'},
],
});

expectToolPairingValid(messages);
});

it('leaves properly paired sequences untouched', () => {
const messages = claudeMessages({
messages: [
{role: 'user', text: 'hi'},
{
role: 'assistant',
toolCalls: [{id: 'call_1', name: 'do_thing', input: {}}],
},
{role: 'user', toolResults: [{callId: 'call_1', content: 'ok'}]},
{role: 'assistant', text: 'done'},
],
});

expectToolPairingValid(messages);
const results = messages
.flatMap((m) => (Array.isArray(m.content) ? m.content : []))
.filter((c) => c.type === 'tool_result');
expect(results).toHaveLength(1);
expect(results[0].is_error).toBe(undefined);
});
});
Loading