Procházet zdrojové kódy

fix: ensure tool_use is always followed by tool_result (#22646)

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Aiden Cline před 4 měsíci
rodič
revize
348a84969d

+ 26 - 1
packages/opencode/src/provider/transform.ts

@@ -75,7 +75,7 @@ export namespace ProviderTransform {
 
     if (model.api.id.includes("claude")) {
       const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
-      return msgs.map((msg) => {
+      msgs = msgs.map((msg) => {
         if (msg.role === "assistant" && Array.isArray(msg.content)) {
           return {
             ...msg,
@@ -101,6 +101,31 @@ export namespace ProviderTransform {
         return msg
       })
     }
+    if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) {
+      // Anthropic rejects assistant turns where tool_use blocks are followed by non-tool
+      // content, e.g. [tool_use, tool_use, text], with:
+      // `tool_use` ids were found without `tool_result` blocks immediately after...
+      //
+      // Reorder that invalid shape into [text] + [tool_use, tool_use]. Consecutive
+      // assistant messages are later merged by the provider/SDK, so preserving the
+      // original [tool_use...] then [text] order still produces the invalid payload.
+      //
+      // The root cause appears to be somewhere upstream where the stream is originally
+      // processed. We were unable to locate an exact narrower reproduction elsewhere,
+      // so we keep this transform in place for the time being.
+      msgs = msgs.flatMap((msg) => {
+        if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg]
+
+        const parts = msg.content
+        const first = parts.findIndex((part) => part.type === "tool-call")
+        if (first === -1) return [msg]
+        if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg]
+        return [
+          { ...msg, content: parts.filter((part) => part.type !== "tool-call") },
+          { ...msg, content: parts.filter((part) => part.type === "tool-call") },
+        ]
+      })
+    }
     if (
       model.providerID === "mistral" ||
       model.api.id.toLowerCase().includes("mistral") ||

+ 104 - 0
packages/opencode/test/provider/transform.test.ts

@@ -1271,6 +1271,110 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
     expect(result[0].content).toBe("")
     expect(result[1].content).toHaveLength(1)
   })
+
+  test("splits anthropic assistant messages when text trails tool calls", () => {
+    const msgs = [
+      {
+        role: "user",
+        content: [{ type: "text", text: "Check my home directory for PDFs" }],
+      },
+      {
+        role: "assistant",
+        content: [
+          { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
+          { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
+          { type: "text", text: "I checked your home directory and looked for PDF files." },
+        ],
+      },
+      {
+        role: "tool",
+        content: [
+          { type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } },
+          {
+            type: "tool-result",
+            toolCallId: "toolu_2",
+            toolName: "glob",
+            output: { type: "text", value: "No files found" },
+          },
+        ],
+      },
+    ] as any[]
+
+    const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
+
+    expect(result).toHaveLength(4)
+    expect(result[1]).toMatchObject({
+      role: "assistant",
+      content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }],
+    })
+    expect(result[2]).toMatchObject({
+      role: "assistant",
+      content: [
+        { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
+        { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
+      ],
+    })
+  })
+
+  test("leaves valid anthropic assistant tool ordering unchanged", () => {
+    const msgs = [
+      {
+        role: "assistant",
+        content: [
+          { type: "text", text: "I checked your home directory and looked for PDF files." },
+          { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
+          { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
+        ],
+      },
+    ] as any[]
+
+    const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
+
+    expect(result).toHaveLength(1)
+    expect(result[0].content).toMatchObject([
+      { type: "text", text: "I checked your home directory and looked for PDF files." },
+      { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
+      { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
+    ])
+  })
+
+  test("splits vertex anthropic assistant messages when text trails tool calls", () => {
+    const model = {
+      ...anthropicModel,
+      providerID: "google-vertex-anthropic",
+      api: {
+        id: "claude-sonnet-4@20250514",
+        url: "https://us-central1-aiplatform.googleapis.com",
+        npm: "@ai-sdk/google-vertex/anthropic",
+      },
+    }
+
+    const msgs = [
+      {
+        role: "assistant",
+        content: [
+          { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
+          { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
+          { type: "text", text: "I checked your home directory and looked for PDF files." },
+        ],
+      },
+    ] as any[]
+
+    const result = ProviderTransform.message(msgs, model, {}) as any[]
+
+    expect(result).toHaveLength(2)
+    expect(result[0]).toMatchObject({
+      role: "assistant",
+      content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }],
+    })
+    expect(result[1]).toMatchObject({
+      role: "assistant",
+      content: [
+        { type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
+        { type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
+      ],
+    })
+  })
 })
 
 describe("ProviderTransform.message - strip openai metadata when store=false", () => {

+ 264 - 1
packages/opencode/test/session/llm.test.ts

@@ -13,7 +13,7 @@ import { ProviderID, ModelID } from "../../src/provider/schema"
 import { Filesystem } from "../../src/util/filesystem"
 import { tmpdir } from "../fixture/fixture"
 import type { Agent } from "../../src/agent/agent"
-import type { MessageV2 } from "../../src/session/message-v2"
+import { MessageV2 } from "../../src/session/message-v2"
 import { SessionID, MessageID } from "../../src/session/schema"
 import { AppRuntime } from "../../src/effect/app-runtime"
 
@@ -909,6 +909,269 @@ describe("session.llm.stream", () => {
     })
   })
 
+  test("sends anthropic tool_use blocks with tool_result immediately after them", async () => {
+    const server = state.server
+    if (!server) {
+      throw new Error("Server not initialized")
+    }
+
+    const source = await loadFixture("anthropic", "claude-opus-4-6")
+    const model = source.model
+    const chunks = [
+      {
+        type: "message_start",
+        message: {
+          id: "msg-tool-order",
+          model: model.id,
+          usage: {
+            input_tokens: 3,
+            cache_creation_input_tokens: null,
+            cache_read_input_tokens: null,
+          },
+        },
+      },
+      {
+        type: "content_block_start",
+        index: 0,
+        content_block: { type: "text", text: "" },
+      },
+      {
+        type: "content_block_delta",
+        index: 0,
+        delta: { type: "text_delta", text: "ok" },
+      },
+      { type: "content_block_stop", index: 0 },
+      {
+        type: "message_delta",
+        delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
+        usage: {
+          input_tokens: 3,
+          output_tokens: 2,
+          cache_creation_input_tokens: null,
+          cache_read_input_tokens: null,
+        },
+      },
+      { type: "message_stop" },
+    ]
+    const request = waitRequest("/messages", createEventResponse(chunks))
+
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            $schema: "https://opencode.ai/config.json",
+            enabled_providers: ["anthropic"],
+            provider: {
+              anthropic: {
+                name: "Anthropic",
+                env: ["ANTHROPIC_API_KEY"],
+                npm: "@ai-sdk/anthropic",
+                api: "https://api.anthropic.com/v1",
+                models: {
+                  [model.id]: model,
+                },
+                options: {
+                  apiKey: "test-anthropic-key",
+                  baseURL: `${server.url.origin}/v1`,
+                },
+              },
+            },
+          }),
+        )
+      },
+    })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id))
+        const sessionID = SessionID.make("session-test-anthropic-tools")
+        const agent = {
+          name: "test",
+          mode: "primary",
+          options: {},
+          permission: [{ permission: "*", pattern: "*", action: "allow" }],
+        } satisfies Agent.Info
+        const user = {
+          id: MessageID.make("user-anthropic-tools"),
+          sessionID,
+          role: "user",
+          time: { created: Date.now() },
+          agent: agent.name,
+          model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id, variant: "max" },
+        } satisfies MessageV2.User
+
+        const input = [
+          {
+            info: {
+              id: "msg_user",
+              sessionID,
+              role: "user",
+              time: { created: 1 },
+              agent: "gentleman",
+              model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
+            },
+            parts: [
+              {
+                id: "p_user",
+                sessionID,
+                messageID: "msg_user",
+                type: "text",
+                text: "Can you check whether there are any PDF files in my home directory?",
+              },
+            ],
+          },
+          {
+            info: {
+              id: "msg_call",
+              sessionID,
+              parentID: "msg_user",
+              role: "assistant",
+              mode: "gentleman",
+              agent: "gentleman",
+              variant: "max",
+              path: { cwd: "/root", root: "/" },
+              cost: 0,
+              tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+              modelID: "claude-opus-4-6",
+              providerID: "anthropic",
+              time: { created: 2, completed: 3 },
+              finish: "tool-calls",
+            },
+            parts: [
+              {
+                id: "p_step",
+                sessionID,
+                messageID: "msg_call",
+                type: "step-start",
+              },
+              {
+                id: "p_read",
+                sessionID,
+                messageID: "msg_call",
+                type: "tool",
+                tool: "read",
+                callID: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
+                state: {
+                  status: "completed",
+                  input: { filePath: "/root" },
+                  output: "<path>/root</path>",
+                  metadata: {},
+                  title: "root",
+                  time: { start: 10, end: 11 },
+                },
+              },
+              {
+                id: "p_glob",
+                sessionID,
+                messageID: "msg_call",
+                type: "tool",
+                tool: "glob",
+                callID: "toolu_01APxrADs7VozN8uWzw9WwHr",
+                state: {
+                  status: "completed",
+                  input: { pattern: "**/*.pdf", path: "/root" },
+                  output: "No files found",
+                  metadata: {},
+                  title: "root",
+                  time: { start: 12, end: 13 },
+                },
+              },
+              {
+                id: "p_text",
+                sessionID,
+                messageID: "msg_call",
+                type: "text",
+                text: "I checked your home directory and looked for PDF files.",
+                time: { start: 14, end: 15 },
+              },
+            ],
+          },
+        ] as any[]
+
+        await drain({
+          user,
+          sessionID,
+          model: resolved,
+          agent,
+          system: [],
+          messages: await MessageV2.toModelMessages(input as any, resolved),
+          tools: {
+            read: tool({
+              description: "Stub read tool",
+              inputSchema: z.object({
+                filePath: z.string(),
+              }),
+              execute: async () => ({ output: "stub" }),
+            }),
+            glob: tool({
+              description: "Stub glob tool",
+              inputSchema: z.object({
+                pattern: z.string(),
+                path: z.string().optional(),
+              }),
+              execute: async () => ({ output: "stub" }),
+            }),
+          },
+        })
+
+        const capture = await request
+        const body = capture.body
+
+        expect(capture.url.pathname.endsWith("/messages")).toBe(true)
+        expect(body.messages).toStrictEqual([
+          {
+            role: "user",
+            content: [{ type: "text", text: "Can you check whether there are any PDF files in my home directory?" }],
+          },
+          {
+            role: "assistant",
+            content: [
+              {
+                type: "text",
+                text: "I checked your home directory and looked for PDF files.",
+              },
+              {
+                type: "tool_use",
+                id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
+                name: "read",
+                input: { filePath: "/root" },
+              },
+              {
+                type: "tool_use",
+                id: "toolu_01APxrADs7VozN8uWzw9WwHr",
+                name: "glob",
+                input: { pattern: "**/*.pdf", path: "/root" },
+                cache_control: {
+                  type: "ephemeral",
+                },
+              },
+            ],
+          },
+          {
+            role: "user",
+            content: [
+              {
+                type: "tool_result",
+                tool_use_id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
+                content: "<path>/root</path>",
+              },
+              {
+                type: "tool_result",
+                tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr",
+                content: "No files found",
+                cache_control: {
+                  type: "ephemeral",
+                },
+              },
+            ],
+          },
+        ])
+      },
+    })
+  })
+
   test("sends Google API payload for Gemini models", async () => {
     const server = state.server
     if (!server) {