diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 1a30a83a25..cdb200eff6 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -369,6 +369,126 @@ export namespace ProviderTransform { } // altimate_change start — expose the pure request projection used before input-budget estimation + // altimate_change start — flatten tool history when a request declares no tools + /** JSON.stringify that never throws on circular or non-serializable values. */ + function safeJson(value: unknown): string { + try { + return JSON.stringify(value) ?? "" + } catch { + return String(value) + } + } + + /** Render a tool call as the plain text a summarizer can still read. */ + function renderToolCall(part: any): string { + const name = typeof part?.toolName === "string" ? part.toolName : "tool" + const raw = part?.input ?? part?.args + const args = typeof raw === "string" ? raw : raw === undefined ? "" : safeJson(raw) + return args ? `[tool call: ${name}(${args})]` : `[tool call: ${name}]` + } + + /** + * Render a tool result as plain text, tolerating every output shape the SDK emits. + * + * The `content` array shape carries readable text parts; stringifying it whole would hand the + * summarizer `[{"type":"text","text":"…"}]` instead of the text it needs, so it is unwrapped. + */ + function renderToolResult(part: any): string { + const name = typeof part?.toolName === "string" ? part.toolName : "tool" + const out = part?.output ?? part?.result + let body = "" + if (typeof out === "string") body = out + else if (out && typeof out === "object") { + const shape = out as any + if (shape.type === "content" && Array.isArray(shape.value)) { + body = shape.value + .map((item: any) => { + if (item?.type === "text" && typeof item.text === "string") return item.text + if (item?.type === "media") return `[${item.mediaType ?? "media"}]` + return safeJson(item) + }) + .filter((line: string) => line.length > 0) + .join("\n") + } else { + const value = shape.value ?? shape + body = typeof value === "string" ? value : safeJson(value) + } + } else if (out !== undefined) body = String(out) + return `[tool result: ${name}]${body ? `\n${body}` : ""}` + } + + /** + * Rewrite `tool-call` / `tool-result` parts into text when the outgoing request declares + * no tools. + * + * The Altimate gateway rejects a request that carries tool-call messages while declaring no + * `tools`: its Responses-API namespace conversion only runs when `tools` is present, so the + * history references functions the request never declares and every provider in its fallback + * chain fails, surfacing one generic error. Measured directly, OpenAI and Anthropic both + * ACCEPT that shape — this is a gateway-side defect, tracked separately. + * + * Flattening is applied to every provider rather than branching on one, because a request + * that declares no tools has no use for structured tool parts either way: the toolless + * agents only summarize. One code path avoids a provider-specific branch that would silently + * rot as the gateway changes. + * + * Consecutive assistant turns are coalesced. Removing the intervening `tool` message would + * otherwise leave two adjacent assistant messages for the ordinary agentic shape + * (`user → assistant(call) → tool(result) → assistant(text) → user`). The AI SDK's Anthropic + * provider happens to coalesce those before transport, but the gateway's own request path is + * not known to, and this fix exists precisely because the gateway is stricter than upstream. + * + * Parts that were not converted pass through untouched, so an intentionally blank assistant + * text part — the separator `message-v2.ts` preserves between Anthropic signed-reasoning + * blocks — is never dropped here. + */ + export function flattenToolParts(msgs: ModelMessage[]): ModelMessage[] { + const result: ModelMessage[] = [] + + // Every message this function appends is freshly constructed, so extending `content` in + // place can never mutate the caller's input. + const appendAssistant = (parts: any[]) => { + const previous = result.at(-1) + if (previous && previous.role === "assistant" && Array.isArray(previous.content)) { + previous.content = [...previous.content, ...parts] as typeof previous.content + return + } + result.push({ role: "assistant", content: parts } as ModelMessage) + } + + for (const msg of msgs) { + if (msg.role === "tool") { + const parts = Array.isArray(msg.content) ? msg.content : [] + const text = parts + .map((part) => renderToolResult(part)) + .filter((line) => line.trim().length > 0) + .join("\n") + if (!text) continue + appendAssistant([{ type: "text", text }]) + continue + } + if (msg.role === "assistant" && Array.isArray(msg.content)) { + const converted: any[] = [] + for (const part of msg.content as any[]) { + if (part?.type === "tool-call" || part?.type === "tool-result") { + const text = part.type === "tool-call" ? renderToolCall(part) : renderToolResult(part) + // Only a part this function produced may be dropped for being blank. + if (text.trim().length > 0) converted.push({ type: "text", text }) + continue + } + converted.push(part) + } + // An assistant turn that held nothing but tool parts must not become an empty message. + if (converted.length === 0) continue + appendAssistant(converted) + continue + } + result.push(msg) + } + return result + } + // altimate_change end + export function messagesForInputEstimate(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] { const projected = unsupportedParts(msgs, model) const mistral = diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4f85b10c5b..19c5b7f165 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -267,6 +267,10 @@ export namespace LLM { const requestOptions = clampReasoningBudget(params.options, maxOutputTokens) // altimate_change end + // altimate_change start — detect a toolless request once, for the message flattening below + const declaresNoTools = Object.keys(tools).filter((x) => x !== "invalid").length === 0 + // altimate_change end + return streamText({ onError(error) { l.error("stream error", { @@ -322,7 +326,13 @@ export namespace LLM { content: x, }), ), - ...input.messages, + // altimate_change start — a request that declares no tools must not carry tool-call + // messages. The toolless agents (compaction, title, summary) summarize a session's own + // history, so they would otherwise send tool calls referencing functions the request + // never declares. The Altimate gateway fails every provider in its fallback chain on + // that shape and reports one generic error; see ProviderTransform.flattenToolParts. + ...(declaresNoTools ? ProviderTransform.flattenToolParts(input.messages) : input.messages), + // altimate_change end ], model: wrapLanguageModel({ model: language, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6184d53127..3378e68da3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4062,6 +4062,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the system: [], small: true, tools: {}, + // altimate_change start — title generation is toolless, but without an explicit "none" the + // historical-tool-stub injection in LLM.stream repopulates `tools` from any tool parts in + // the context, which both re-declares tools this request cannot use and suppresses the + // toolless message flattening. compaction.ts passes the same flag for the same reason. + toolChoice: "none" as const, + // altimate_change end model, abort: new AbortController().signal, sessionID: input.session.id, diff --git a/packages/opencode/test/provider/flatten-tool-parts.test.ts b/packages/opencode/test/provider/flatten-tool-parts.test.ts new file mode 100644 index 0000000000..fd1f2c52a3 --- /dev/null +++ b/packages/opencode/test/provider/flatten-tool-parts.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, test } from "bun:test" +import { ProviderTransform } from "@/provider/transform" +import type { ModelMessage } from "ai" + +/** + * A request that declares no tools must not carry tool-call messages: the Altimate gateway + * rejects that shape, failing every provider in its fallback chain and reporting one generic + * error (issue #1315). OpenAI and Anthropic both accept it — the defect is gateway-side, and + * this transform is the client-side mitigation. + */ +describe("ProviderTransform.flattenToolParts", () => { + const history = (): ModelMessage[] => [ + { role: "user", content: "list the files" }, + { + role: "assistant", + content: [ + { type: "text", text: "Checking." }, + { type: "tool-call", toolCallId: "c1", toolName: "bash", input: { cmd: "ls" } }, + ], + } as unknown as ModelMessage, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "c1", toolName: "bash", output: { value: "a.txt\nb.txt" } }], + } as unknown as ModelMessage, + { role: "user", content: "Summarize the conversation above." }, + ] + + function toolPartTypes(msgs: ModelMessage[]) { + return msgs.flatMap((m) => (Array.isArray(m.content) ? m.content.map((p: any) => p?.type) : [])) + } + + test("removes every tool-call and tool-result part", () => { + const out = ProviderTransform.flattenToolParts(history()) + expect(toolPartTypes(out)).not.toContain("tool-call") + expect(toolPartTypes(out)).not.toContain("tool-result") + expect(out.some((m) => m.role === "tool")).toBe(false) + }) + + test("preserves the tool name, arguments and output as readable text", () => { + const text = JSON.stringify(ProviderTransform.flattenToolParts(history())) + expect(text).toContain("bash") + expect(text).toContain("ls") + expect(text).toContain("a.txt") + }) + + test("keeps surrounding conversation intact", () => { + const out = ProviderTransform.flattenToolParts(history()) + expect(out[0]).toEqual({ role: "user", content: "list the files" }) + expect(out.at(-1)).toEqual({ role: "user", content: "Summarize the conversation above." }) + }) + + test("an assistant turn holding only tool calls does not become an empty message", () => { + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "c1", toolName: "bash", input: {} }], + } as unknown as ModelMessage, + { role: "user", content: "summarize" }, + ]) + for (const msg of out) { + if (Array.isArray(msg.content)) expect(msg.content.length).toBeGreaterThan(0) + } + }) + + test("an orphaned tool result (head truncated away its call) still flattens", () => { + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "gone", toolName: "bash", output: { value: "out" } }], + } as unknown as ModelMessage, + { role: "user", content: "summarize" }, + ]) + expect(out.some((m) => m.role === "tool")).toBe(false) + expect(JSON.stringify(out)).toContain("out") + }) + + test("messages with no tool parts are returned unchanged", () => { + const plain: ModelMessage[] = [ + { role: "user", content: "hi" }, + { role: "assistant", content: [{ type: "text", text: "hello" }] } as unknown as ModelMessage, + ] + expect(ProviderTransform.flattenToolParts(plain)).toEqual(plain) + }) + + // --- regressions from the consensus review of PR #1319 --- + + test("the canonical agentic shape does not leave consecutive assistant messages", () => { + // user -> assistant(text+call) -> tool(result) -> assistant(text) -> user is the ordinary + // shape of every session compaction summarizes. Dropping the tool message used to leave two + // adjacent assistant turns. + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "assistant", + content: [ + { type: "text", text: "Checking." }, + { type: "tool-call", toolCallId: "c1", toolName: "bash", input: { cmd: "ls" } }, + ], + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "c1", toolName: "bash", output: { type: "text", value: "a.txt" } }], + }, + { role: "assistant", content: [{ type: "text", text: "I found a.txt." }] }, + { role: "user", content: "summarize" }, + ] as unknown as ModelMessage[]) + + expect(out.map((m) => m.role)).toEqual(["user", "assistant", "user"]) + for (let i = 1; i < out.length; i++) expect(out[i].role === out[i - 1].role).toBe(false) + // nothing is lost in the merge + const text = JSON.stringify(out) + expect(text).toContain("Checking.") + expect(text).toContain("a.txt") + expect(text).toContain("I found a.txt.") + }) + + test("an intentionally blank assistant text part survives", () => { + // message-v2.ts preserves a single space between Anthropic signed-reasoning blocks; the + // blank-part filter must only ever drop parts this transform itself produced. + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "reasoning", text: "thinking" }, { type: "text", text: " " }] }, + { role: "user", content: "summarize" }, + ] as unknown as ModelMessage[]) + const parts = (out[1] as any).content + expect(parts.some((p: any) => p.type === "text" && p.text === " ")).toBe(true) + expect(parts.some((p: any) => p.type === "reasoning")).toBe(true) + }) + + test("a tool-result embedded in an assistant message is flattened too", () => { + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "assistant", + content: [{ type: "tool-result", toolCallId: "c9", toolName: "bash", output: { type: "text", value: "x" } }], + }, + { role: "user", content: "summarize" }, + ] as unknown as ModelMessage[]) + expect(JSON.stringify(out)).not.toContain('"tool-result"') + expect(JSON.stringify(out)).toContain("x") + }) + + test("content-array output is unwrapped into readable text, not JSON", () => { + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "tool-call", toolCallId: "c1", toolName: "read", input: {} }] }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "c1", + toolName: "read", + output: { type: "content", value: [{ type: "text", text: "hello world" }] }, + }, + ], + }, + { role: "user", content: "s" }, + ] as unknown as ModelMessage[]) + const rendered = JSON.stringify(out) + expect(rendered).toContain("hello world") + expect(rendered).not.toContain('\\"type\\":\\"text\\"') + }) + + test("parallel tool calls and multiple results keep their order", () => { + const out = ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "a", toolName: "first", input: {} }, + { type: "tool-call", toolCallId: "b", toolName: "second", input: {} }, + ], + }, + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: "a", toolName: "first", output: { type: "text", value: "R1" } }, + { type: "tool-result", toolCallId: "b", toolName: "second", output: { type: "text", value: "R2" } }, + ], + }, + { role: "user", content: "s" }, + ] as unknown as ModelMessage[]) + const flat = (out[1] as any).content.map((p: any) => p.text).join("\n") + expect(flat.indexOf("first")).toBeLessThan(flat.indexOf("second")) + expect(flat.indexOf("R1")).toBeLessThan(flat.indexOf("R2")) + }) + + test("output shapes without a value do not throw", () => { + for (const output of [ + { type: "error-text", value: "boom" }, + { type: "json", value: { a: 1 } }, + { type: "execution-denied" }, + "plain string", + ]) { + const run = () => + ProviderTransform.flattenToolParts([ + { role: "user", content: "go" }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "c1", toolName: "t", output }], + }, + ] as unknown as ModelMessage[]) + expect(run).not.toThrow() + } + }) + + test("the caller's input array is never mutated", () => { + const input = [ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "tool-call", toolCallId: "c1", toolName: "bash", input: {} }] }, + { role: "tool", content: [{ type: "tool-result", toolCallId: "c1", toolName: "bash", output: { type: "text", value: "o" } }] }, + ] as unknown as ModelMessage[] + const before = JSON.stringify(input) + ProviderTransform.flattenToolParts(input) + expect(JSON.stringify(input)).toEqual(before) + }) +})