Skip to content

Commit 6d44703

Browse files
anandgupta42claude
andcommitted
fix: [AI-678] add stub tool definitions for historical tool_use blocks
The Anthropic API requires every `tool_use` block in message history to have a matching tool definition. When agents switch (Plan→Builder), MCP tools disconnect, or tools are filtered by permissions, the history may reference tools absent from the current set — causing a 400 error: "Requests with 'tool_use' and 'tool_result' blocks must include tool definition." Replace the LiteLLM-only `_noop` workaround with a general fix: - Extract all tool names from `tool-call` blocks in message history - Add stub definitions for any names missing from the active tools set - Stubs return "tool no longer available" if the model attempts to call them Closes #678 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent daaaceb commit 6d44703

2 files changed

Lines changed: 88 additions & 18 deletions

File tree

packages/opencode/src/session/llm.ts

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -149,25 +149,27 @@ export namespace LLM {
149149

150150
const tools = await resolveTools(input)
151151

152-
// LiteLLM and some Anthropic proxies require the tools parameter to be present
153-
// when message history contains tool calls, even if no tools are being used.
154-
// Add a dummy tool that is never called to satisfy this validation.
155-
// This is enabled for:
156-
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
157-
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
158-
const isLiteLLMProxy =
159-
provider.options?.["litellmProxy"] === true ||
160-
input.model.providerID.toLowerCase().includes("litellm") ||
161-
input.model.api.id.toLowerCase().includes("litellm")
162-
163-
if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
164-
tools["_noop"] = tool({
165-
description:
166-
"Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed",
167-
inputSchema: jsonSchema({ type: "object", properties: {} }),
168-
execute: async () => ({ output: "", title: "", metadata: {} }),
169-
})
152+
// altimate_change start — ensure tool definitions exist for all tool_use blocks in history
153+
// The Anthropic API (and proxies like LiteLLM) require every tool_use block in
154+
// message history to have a matching tool definition. When agents switch (Plan→Builder),
155+
// MCP tools disconnect, or tools are filtered by permissions, the history may reference
156+
// tools absent from the current set. Add stub definitions for any missing tools.
157+
// Fixes: https://github.com/AltimateAI/altimate-code/issues/678
158+
const referencedTools = toolNamesFromMessages(input.messages)
159+
for (const name of referencedTools) {
160+
if (!tools[name]) {
161+
tools[name] = tool({
162+
description: `[Historical] Tool no longer available in this session`,
163+
inputSchema: jsonSchema({ type: "object", properties: {} }),
164+
execute: async () => ({
165+
output: "This tool is no longer available. Please use an alternative approach.",
166+
title: "",
167+
metadata: {},
168+
}),
169+
})
170+
}
170171
}
172+
// altimate_change end
171173

172174
return streamText({
173175
onError(error) {
@@ -276,4 +278,21 @@ export namespace LLM {
276278
}
277279
return false
278280
}
281+
282+
// altimate_change start — collect tool names from message history to prevent API validation errors
283+
// Anthropic API requires every tool_use block in message history to have a matching tool
284+
// definition. When agents switch (e.g. Plan→Builder) or MCP tools disconnect, the history
285+
// may reference tools no longer in the active set. This function extracts those names so
286+
// stub definitions can be added. Fixes #678.
287+
export function toolNamesFromMessages(messages: ModelMessage[]): Set<string> {
288+
const names = new Set<string>()
289+
for (const msg of messages) {
290+
if (!Array.isArray(msg.content)) continue
291+
for (const part of msg.content) {
292+
if (part.type === "tool-call") names.add(part.toolName)
293+
}
294+
}
295+
return names
296+
}
297+
// altimate_change end
279298
}

packages/opencode/test/session/llm.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,57 @@ import type { Agent } from "../../src/agent/agent"
1414
import type { MessageV2 } from "../../src/session/message-v2"
1515
import { SessionID, MessageID } from "../../src/session/schema"
1616

17+
describe("session.llm.toolNamesFromMessages", () => {
18+
test("returns empty set for empty messages", () => {
19+
expect(LLM.toolNamesFromMessages([])).toEqual(new Set())
20+
})
21+
22+
test("returns empty set for messages with no tool calls", () => {
23+
const messages: ModelMessage[] = [
24+
{ role: "user", content: [{ type: "text", text: "Hello" }] },
25+
{ role: "assistant", content: [{ type: "text", text: "Hi" }] },
26+
]
27+
expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set())
28+
})
29+
30+
test("extracts tool names from tool-call blocks", () => {
31+
const messages = [
32+
{
33+
role: "assistant",
34+
content: [
35+
{ type: "tool-call", toolCallId: "call-1", toolName: "bash" },
36+
{ type: "tool-call", toolCallId: "call-2", toolName: "read" },
37+
],
38+
},
39+
] as ModelMessage[]
40+
expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set(["bash", "read"]))
41+
})
42+
43+
test("deduplicates tool names across messages", () => {
44+
const messages = [
45+
{
46+
role: "assistant",
47+
content: [{ type: "tool-call", toolCallId: "call-1", toolName: "bash" }],
48+
},
49+
{
50+
role: "assistant",
51+
content: [{ type: "tool-call", toolCallId: "call-2", toolName: "bash" }],
52+
},
53+
] as ModelMessage[]
54+
expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set(["bash"]))
55+
})
56+
57+
test("ignores tool-result blocks (only extracts from tool-call)", () => {
58+
const messages = [
59+
{
60+
role: "tool",
61+
content: [{ type: "tool-result", toolCallId: "call-1", toolName: "bash" }],
62+
},
63+
] as ModelMessage[]
64+
expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set())
65+
})
66+
})
67+
1768
describe("session.llm.hasToolCalls", () => {
1869
test("returns false for empty messages array", () => {
1970
expect(LLM.hasToolCalls([])).toBe(false)

0 commit comments

Comments
 (0)