diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4e42fb0d2..cf6afdc86 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -149,25 +149,27 @@ export namespace LLM { const tools = await resolveTools(input) - // LiteLLM and some Anthropic proxies require the tools parameter to be present - // when message history contains tool calls, even if no tools are being used. - // Add a dummy tool that is never called to satisfy this validation. - // This is enabled for: - // 1. Providers with "litellm" in their ID or API ID (auto-detected) - // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways) - const isLiteLLMProxy = - provider.options?.["litellmProxy"] === true || - input.model.providerID.toLowerCase().includes("litellm") || - input.model.api.id.toLowerCase().includes("litellm") - - if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) { - tools["_noop"] = tool({ - description: - "Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed", - inputSchema: jsonSchema({ type: "object", properties: {} }), - execute: async () => ({ output: "", title: "", metadata: {} }), - }) + // altimate_change start — ensure tool definitions exist for all tool_use blocks in history + // The Anthropic API (and proxies like LiteLLM) require 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. Add stub definitions for any missing tools. + // Fixes: https://github.com/AltimateAI/altimate-code/issues/678 + const referencedTools = toolNamesFromMessages(input.messages) + for (const name of referencedTools) { + if (!Object.hasOwn(tools, name)) { + tools[name] = tool({ + description: `[Historical] Tool no longer available in this session`, + inputSchema: jsonSchema({ type: "object", properties: {} }), + execute: async () => ({ + output: "This tool is no longer available. Please use an alternative approach.", + title: "", + metadata: {}, + }), + }) + } } + // altimate_change end return streamText({ onError(error) { @@ -265,15 +267,20 @@ export namespace LLM { return input.tools } - // Check if messages contain any tool-call content - // Used to determine if a dummy tool should be added for LiteLLM proxy compatibility - export function hasToolCalls(messages: ModelMessage[]): boolean { + // altimate_change start — collect tool names from message history to prevent API validation errors + // Anthropic API requires every tool_use block in message history to have a matching tool + // definition. When agents switch (e.g. Plan→Builder) or MCP tools disconnect, the history + // may reference tools no longer in the active set. This function extracts those names so + // stub definitions can be added. Fixes #678. + export function toolNamesFromMessages(messages: ModelMessage[]): Set { + const names = new Set() for (const msg of messages) { if (!Array.isArray(msg.content)) continue for (const part of msg.content) { - if (part.type === "tool-call" || part.type === "tool-result") return true + if (part.type === "tool-call" || part.type === "tool-result") names.add(part.toolName) } } - return false + return names } + // altimate_change end } diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index b9542088a..93cfce546 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -14,90 +14,72 @@ import type { Agent } from "../../src/agent/agent" import type { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID } from "../../src/session/schema" -describe("session.llm.hasToolCalls", () => { - test("returns false for empty messages array", () => { - expect(LLM.hasToolCalls([])).toBe(false) +describe("session.llm.toolNamesFromMessages", () => { + test("returns empty set for empty messages", () => { + expect(LLM.toolNamesFromMessages([])).toEqual(new Set()) }) - test("returns false for messages with only text content", () => { + test("returns empty set for messages with no tool calls", () => { const messages: ModelMessage[] = [ - { - role: "user", - content: [{ type: "text", text: "Hello" }], - }, + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { role: "assistant", content: [{ type: "text", text: "Hi" }] }, + ] + expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set()) + }) + + test("extracts tool names from tool-call blocks", () => { + const messages = [ { role: "assistant", - content: [{ type: "text", text: "Hi there" }], + content: [ + { type: "tool-call", toolCallId: "call-1", toolName: "bash" }, + { type: "tool-call", toolCallId: "call-2", toolName: "read" }, + ], }, - ] - expect(LLM.hasToolCalls(messages)).toBe(false) + ] as ModelMessage[] + expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set(["bash", "read"])) }) - test("returns true when messages contain tool-call", () => { + test("deduplicates tool names across messages", () => { const messages = [ { - role: "user", - content: [{ type: "text", text: "Run a command" }], + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-1", toolName: "bash" }], }, { role: "assistant", - content: [ - { - type: "tool-call", - toolCallId: "call-123", - toolName: "bash", - }, - ], + content: [{ type: "tool-call", toolCallId: "call-2", toolName: "bash" }], }, ] as ModelMessage[] - expect(LLM.hasToolCalls(messages)).toBe(true) + expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set(["bash"])) }) - test("returns true when messages contain tool-result", () => { + test("extracts tool names from tool-result blocks", () => { const messages = [ { role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call-123", - toolName: "bash", - }, - ], + content: [{ type: "tool-result", toolCallId: "call-1", toolName: "bash" }], }, ] as ModelMessage[] - expect(LLM.hasToolCalls(messages)).toBe(true) + expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set(["bash"])) }) - test("returns false for messages with string content", () => { - const messages: ModelMessage[] = [ + test("extracts from both tool-call and tool-result blocks", () => { + const messages = [ { - role: "user", - content: "Hello world", + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-1", toolName: "bash" }], }, { - role: "assistant", - content: "Hi there", + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-1", toolName: "bash" }], }, - ] - expect(LLM.hasToolCalls(messages)).toBe(false) - }) - - test("returns true when tool-call is mixed with text content", () => { - const messages = [ { - role: "assistant", - content: [ - { type: "text", text: "Let me run that command" }, - { - type: "tool-call", - toolCallId: "call-456", - toolName: "read", - }, - ], + role: "tool", + content: [{ type: "tool-result", toolCallId: "call-2", toolName: "read" }], }, ] as ModelMessage[] - expect(LLM.hasToolCalls(messages)).toBe(true) + expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set(["bash", "read"])) }) })