Skip to content

Commit ef4c7bd

Browse files
committed
fix: keep tool_use/tool_result pairs atomic when trimming context
1 parent d0885f8 commit ef4c7bd

3 files changed

Lines changed: 87 additions & 12 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'vscode-agent-platform-connector': patch
3+
---
4+
5+
Fix a second cause of the Anthropic tool-pairing 400 ("tool_use ids were found
6+
without tool_result blocks" / "unexpected tool_use_id in tool_result blocks").
7+
`fitRequestToContext`'s oldest-message trimming (used to keep long
8+
conversations within the model's context window) could drop an assistant
9+
`tool_use` turn while keeping its paired `tool_result` reply, or vice versa,
10+
producing an invalid sequence upstream. Trimming now treats a `tool_use`
11+
assistant turn and its immediately-following `tool_result` turn as one atomic
12+
unit that is always dropped or kept together.

src/vertex.ts

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,30 +1068,57 @@ export function fitRequestToContext(
10681068
let total = estimateRequestTokens(req);
10691069
if (total <= budget) return req;
10701070

1071-
const messages = [...req.messages];
1071+
const messages = req.messages;
10721072
// Indices we must never drop: all system messages and the final message.
10731073
const lastIdx = messages.length - 1;
10741074
const protectedIdx = new Set<number>([lastIdx]);
10751075
messages.forEach((m, i) => {
10761076
if (m.role === 'system') protectedIdx.add(i);
10771077
});
10781078

1079-
// Walk from oldest to newest, removing droppable messages until we fit.
1079+
// Group messages into atomic drop units: an assistant `tool_use` turn and
1080+
// its immediately-following `tool_result` reply must be dropped (or kept)
1081+
// together. Trimming just one half leaves the other as an orphan and
1082+
// upstream rejects the request with a 400 ("tool_use ids were found
1083+
// without tool_result blocks" / "unexpected tool_use_id in tool_result").
1084+
const units: Array<{
1085+
indices: number[];
1086+
tokens: number;
1087+
protected: boolean;
1088+
}> = [];
1089+
for (let i = 0; i < messages.length; ) {
1090+
const m = messages[i];
1091+
const pairsWithNext =
1092+
m.role === 'assistant' &&
1093+
!!m.toolCalls?.length &&
1094+
!!messages[i + 1]?.toolResults?.length;
1095+
const indices = pairsWithNext ? [i, i + 1] : [i];
1096+
const tokens = indices.reduce(
1097+
(sum, idx) => sum + estimateMessageTokens(messages[idx]),
1098+
0,
1099+
);
1100+
units.push({
1101+
indices,
1102+
tokens,
1103+
protected: indices.some((idx) => protectedIdx.has(idx)),
1104+
});
1105+
i += indices.length;
1106+
}
1107+
1108+
// Walk from oldest to newest, removing droppable units until we fit.
10801109
let removed = 0;
1081-
for (let i = 0; i < messages.length && total > budget; i++) {
1082-
if (protectedIdx.has(i)) continue;
1083-
if (!messages[i]) continue;
1084-
total -= estimateMessageTokens(messages[i]);
1085-
// Tombstone; filtered out below to keep indices stable during the loop.
1086-
(messages as Array<NormMessage | null>)[i] = null;
1087-
removed++;
1110+
const dropped = new Set<number>();
1111+
for (const unit of units) {
1112+
if (total <= budget) break;
1113+
if (unit.protected) continue;
1114+
total -= unit.tokens;
1115+
for (const idx of unit.indices) dropped.add(idx);
1116+
removed += unit.indices.length;
10881117
}
10891118

10901119
if (removed === 0) return req;
10911120

1092-
const trimmed = (messages as Array<NormMessage | null>).filter(
1093-
(m): m is NormMessage => m !== null,
1094-
);
1121+
const trimmed = messages.filter((_, i) => !dropped.has(i));
10951122
log(
10961123
`context safeguard: trimmed ${removed} oldest message(s); ` +
10971124
`~${total} est tokens <= ${budget} budget (limit ${limit})`,

test/context-fit.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,40 @@ describe('fitRequestToContext', () => {
6464
});
6565
expect(large).toBeGreaterThan(small);
6666
});
67+
68+
it('drops a tool_use/tool_result pair together, never splitting them', () => {
69+
// Each old turn (assistant tool_use + user tool_result) is large enough
70+
// that dropping just one of the two wouldn't be enough to hit budget,
71+
// forcing the trimmer to consider the pair as a unit.
72+
const big = 'x'.repeat(4000);
73+
const req: NormRequest = {
74+
messages: [
75+
{role: 'system', text: 'system prompt'},
76+
{
77+
role: 'assistant',
78+
toolCalls: [{id: 'call_1', name: 'do_thing', input: {a: big}}],
79+
},
80+
{role: 'user', toolResults: [{callId: 'call_1', content: big}]},
81+
userMessage('latest ' + big),
82+
],
83+
};
84+
85+
const fitted = fitRequestToContext(model, req);
86+
expect(fitted).not.toBe(req);
87+
88+
const hasOrphanToolUse = fitted.messages.some(
89+
(m, i) =>
90+
m.role === 'assistant' &&
91+
m.toolCalls?.length &&
92+
!fitted.messages[i + 1]?.toolResults?.length,
93+
);
94+
const hasOrphanToolResult = fitted.messages.some((m, i) => {
95+
if (!m.toolResults?.length) return false;
96+
const prev = fitted.messages[i - 1];
97+
const ids = new Set((prev?.toolCalls ?? []).map((t) => t.id));
98+
return m.toolResults.some((r) => !ids.has(r.callId));
99+
});
100+
expect(hasOrphanToolUse).toBe(false);
101+
expect(hasOrphanToolResult).toBe(false);
102+
});
67103
});

0 commit comments

Comments
 (0)