From 29406436c3e8119aa67ba8d63964428a768a0997 Mon Sep 17 00:00:00 2001 From: Code_G Date: Sun, 26 Jul 2026 03:02:43 +0900 Subject: [PATCH] fix(executor): keep tool_result blocks first when injecting the cloaking reminder `prependToFirstUserMessage` always inserted the `` text block at index 0 of the first user message's content array. When a client sends a history that begins with an assistant `tool_use` turn, that first user message is the `tool_result` carrier, and Anthropic requires those blocks to stay at the head of the message. Prepending pushed them out of first position, so the upstream rejected the whole request with: messages.N: `tool_use` ids were found without `tool_result` blocks immediately after: Append the reminder instead when the content array already leads with a `tool_result` block; behaviour is unchanged for every other message shape. Co-Authored-By: Claude Opus 5 --- internal/runtime/executor/claude_executor.go | 25 ++++++++++- .../runtime/executor/claude_executor_test.go | 41 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 8e10f9893..17c70ce0b 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -2116,9 +2116,22 @@ IMPORTANT: this context may or may not be relevant to your tasks. You should not if content.IsArray() { newBlock := fmt.Sprintf(`{"type":"text","text":%q}`, prefixBlock) var newArray string - if content.Raw == "[]" || content.Raw == "" { + switch { + case content.Raw == "[]" || content.Raw == "": newArray = "[" + newBlock + "]" - } else { + case leadsWithToolResult(content): + // Anthropic requires the user message that immediately follows an + // assistant tool_use turn to LEAD with its tool_result blocks. + // Prepending here would push them out of first position and the + // upstream rejects the request with + // "tool_use ids were found without tool_result blocks immediately after". + // Append instead, so the tool_result blocks stay at the head. + if trimmed := strings.TrimRight(content.Raw, " \t\r\n"); strings.HasSuffix(trimmed, "]") { + newArray = trimmed[:len(trimmed)-1] + "," + newBlock + "]" + } else { + newArray = "[" + newBlock + "," + content.Raw[1:] + } + default: newArray = "[" + newBlock + "," + content.Raw[1:] } payload, _ = sjson.SetRawBytes(payload, contentPath, []byte(newArray)) @@ -2130,6 +2143,14 @@ IMPORTANT: this context may or may not be relevant to your tasks. You should not return payload } +// leadsWithToolResult reports whether a message content array starts with a +// tool_result block. Such a message answers a preceding assistant tool_use turn, +// and Anthropic requires its tool_result blocks to remain first. +func leadsWithToolResult(content gjson.Result) bool { + first := content.Get("0") + return first.Exists() && first.Get("type").String() == "tool_result" +} + // applyCloaking applies cloaking transformations to the payload based on config and client. // Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation. func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) ([]byte, error) { diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index ccb9e7df4..b78d1fc36 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -2979,3 +2979,44 @@ func TestEnsureClaudeThinkingDisplay_SkipsWhenThinkingMissing(t *testing.T) { t.Fatalf("thinking should remain absent: %s", out) } } + +func TestPrependToFirstUserMessage_KeepsToolResultBlocksFirst(t *testing.T) { + // A conversation that opens on an assistant tool_use makes the first user + // message a tool_result carrier. Anthropic requires those blocks to stay at + // the head of the message, so the reminder must be appended, not prepended. + payload := []byte(`{"messages":[` + + `{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{}}]},` + + `{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}` + + `]}`) + + out := prependToFirstUserMessage(payload, "guidance") + + blocks := gjson.GetBytes(out, "messages.1.content") + if got := blocks.Get("0.type").String(); got != "tool_result" { + t.Fatalf("first block type = %q, want tool_result: %s", got, out) + } + if got := blocks.Get("0.tool_use_id").String(); got != "toolu_1" { + t.Fatalf("tool_use_id = %q, want toolu_1: %s", got, out) + } + last := blocks.Array()[len(blocks.Array())-1] + if last.Get("type").String() != "text" || !strings.Contains(last.Get("text").String(), "guidance") { + t.Fatalf("reminder should be appended last: %s", out) + } +} + +func TestPrependToFirstUserMessage_PrependsWhenNoLeadingToolResult(t *testing.T) { + payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) + + out := prependToFirstUserMessage(payload, "guidance") + + blocks := gjson.GetBytes(out, "messages.0.content") + if got := blocks.Get("0.type").String(); got != "text" { + t.Fatalf("first block type = %q, want text: %s", got, out) + } + if !strings.Contains(blocks.Get("0.text").String(), "guidance") { + t.Fatalf("reminder should be prepended first: %s", out) + } + if got := blocks.Get("1.text").String(); got != "hello" { + t.Fatalf("original block should follow, got %q: %s", got, out) + } +}