From 07578f9c56bd7ff574376736dfb087181264e767 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 13:09:41 +0800 Subject: [PATCH 1/7] fix tool prompt parameter examples for exec tools --- internal/util/tool_prompt.go | 51 ++++++++++++++++++++++++++++--- internal/util/tool_prompt_test.go | 26 ++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 internal/util/tool_prompt_test.go diff --git a/internal/util/tool_prompt.go b/internal/util/tool_prompt.go index b0b0f75..9394eda 100644 --- a/internal/util/tool_prompt.go +++ b/internal/util/tool_prompt.go @@ -1,5 +1,7 @@ package util +import "strings" + // BuildToolCallInstructions generates the unified tool-calling instruction block // used by all adapters (OpenAI, Claude, Gemini). It uses attention-optimized // structure: rules → negative examples → positive examples → anchor. @@ -19,7 +21,7 @@ func BuildToolCallInstructions(toolNames []string) string { ex1 = n used["ex1"] = true // Write/execute-type tools - case !used["ex2"] && matchAny(n, "write_to_file", "apply_diff", "execute_command", "Write", "Edit", "MultiEdit", "Bash"): + case !used["ex2"] && matchAny(n, "write_to_file", "apply_diff", "execute_command", "exec_command", "Write", "Edit", "MultiEdit", "Bash"): ex2 = n used["ex2"] = true // Interactive/meta tools @@ -28,6 +30,9 @@ func BuildToolCallInstructions(toolNames []string) string { used["ex3"] = true } } + ex1Params := exampleReadParams(ex1) + ex2Params := exampleWriteOrExecParams(ex2) + ex3Params := exampleInteractiveParams(ex3) return `TOOL CALL FORMAT — FOLLOW EXACTLY: @@ -47,6 +52,7 @@ RULES: 4) Do NOT wrap the XML in markdown code fences (no triple backticks). 5) After receiving a tool result, use it directly. Only call another tool if the result is insufficient. 6) If you want to say something AND call a tool, output text first, then the XML block on its own. +7) Parameters MUST use the exact field names from the selected tool schema. ❌ WRONG — Do NOT do these: Wrong 1 — mixed text and XML: @@ -62,7 +68,7 @@ Example A — Single tool: ` + ex1 + ` - {"path":"src/main.go"} + ` + ex1Params + ` @@ -70,11 +76,11 @@ Example B — Two tools in parallel: ` + ex1 + ` - {"path":"config.json"} + ` + ex1Params + ` ` + ex2 + ` - {"path":"output.txt","content":"Hello world"} + ` + ex2Params + ` @@ -82,7 +88,7 @@ Example C — Tool with complex nested JSON parameters: ` + ex3 + ` - {"question":"Which approach do you prefer?","follow_up":[{"text":"Option A"},{"text":"Option B"}]} + ` + ex3Params + ` @@ -97,3 +103,38 @@ func matchAny(name string, candidates ...string) bool { } return false } + +func exampleReadParams(name string) string { + switch strings.TrimSpace(name) { + case "Read": + return `{"file_path":"README.md"}` + case "Glob": + return `{"pattern":"**/*.go","path":"."}` + default: + return `{"path":"src/main.go"}` + } +} + +func exampleWriteOrExecParams(name string) string { + switch strings.TrimSpace(name) { + case "Bash", "execute_command": + return `{"command":"pwd"}` + case "exec_command": + return `{"cmd":"pwd"}` + case "Edit": + return `{"file_path":"README.md","old_string":"foo","new_string":"bar"}` + case "MultiEdit": + return `{"file_path":"README.md","edits":[{"old_string":"foo","new_string":"bar"}]}` + default: + return `{"path":"output.txt","content":"Hello world"}` + } +} + +func exampleInteractiveParams(name string) string { + switch strings.TrimSpace(name) { + case "Task": + return `{"description":"Investigate flaky tests","prompt":"Run targeted tests and summarize failures"}` + default: + return `{"question":"Which approach do you prefer?","follow_up":[{"text":"Option A"},{"text":"Option B"}]}` + } +} diff --git a/internal/util/tool_prompt_test.go b/internal/util/tool_prompt_test.go new file mode 100644 index 0000000..e10f176 --- /dev/null +++ b/internal/util/tool_prompt_test.go @@ -0,0 +1,26 @@ +package util + +import ( + "strings" + "testing" +) + +func TestBuildToolCallInstructions_ExecCommandUsesCmdExample(t *testing.T) { + out := BuildToolCallInstructions([]string{"exec_command"}) + if !strings.Contains(out, `exec_command`) { + t.Fatalf("expected exec_command in examples, got: %s", out) + } + if !strings.Contains(out, `{"cmd":"pwd"}`) { + t.Fatalf("expected cmd parameter example for exec_command, got: %s", out) + } +} + +func TestBuildToolCallInstructions_ExecuteCommandUsesCommandExample(t *testing.T) { + out := BuildToolCallInstructions([]string{"execute_command"}) + if !strings.Contains(out, `execute_command`) { + t.Fatalf("expected execute_command in examples, got: %s", out) + } + if !strings.Contains(out, `{"command":"pwd"}`) { + t.Fatalf("expected command parameter example for execute_command, got: %s", out) + } +} From 50ce88ca3f98783f673a92f393f77d60da502d06 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 13:32:47 +0800 Subject: [PATCH 2/7] tighten functionCall detection to quoted JSON keys --- internal/adapter/openai/tool_sieve_core.go | 39 ++++++++++++++++++- .../adapter/openai/tool_sieve_xml_test.go | 31 +++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index 5d96503..3d70551 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -183,7 +183,7 @@ func findToolSegmentStart(s string) int { return -1 } lower := strings.ToLower(s) - keywords := []string{"tool_calls", "\"function\"", "function.name:", "functioncall", "\"tool_use\""} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "\"tool_use\""} bestKeyIdx := -1 for _, kw := range keywords { idx := strings.Index(lower, kw) @@ -191,6 +191,9 @@ func findToolSegmentStart(s string) int { bestKeyIdx = idx } } + if fnKeyIdx := findQuotedFunctionCallKeyStart(s); fnKeyIdx >= 0 && (bestKeyIdx < 0 || fnKeyIdx < bestKeyIdx) { + bestKeyIdx = fnKeyIdx + } // Also detect XML tool call tags. for _, tag := range xmlToolTagsToDetect { idx := strings.Index(lower, tag) @@ -240,13 +243,16 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix lower := strings.ToLower(captured) keyIdx := -1 - keywords := []string{"tool_calls", "\"function\"", "function.name:", "functioncall", "\"tool_use\""} + keywords := []string{"tool_calls", "\"function\"", "function.name:", "\"tool_use\""} for _, kw := range keywords { idx := strings.Index(lower, kw) if idx >= 0 && (keyIdx < 0 || idx < keyIdx) { keyIdx = idx } } + if fnKeyIdx := findQuotedFunctionCallKeyStart(captured); fnKeyIdx >= 0 && (keyIdx < 0 || fnKeyIdx < keyIdx) { + keyIdx = fnKeyIdx + } if keyIdx < 0 { return "", nil, "", false @@ -276,3 +282,32 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart) return prefixPart, parsed.Calls, suffixPart, true } + +func findQuotedFunctionCallKeyStart(s string) int { + lower := strings.ToLower(s) + const key = "\"functioncall\"" + for from := 0; from < len(lower); { + rel := strings.Index(lower[from:], key) + if rel < 0 { + return -1 + } + idx := from + rel + if !hasJSONObjectContextPrefix(lower[:idx]) { + from = idx + 1 + continue + } + j := idx + len(key) + for j < len(lower) && (lower[j] == ' ' || lower[j] == '\t' || lower[j] == '\r' || lower[j] == '\n') { + j++ + } + if j < len(lower) && lower[j] == ':' { + return idx + } + from = idx + 1 + } + return -1 +} + +func hasJSONObjectContextPrefix(prefix string) bool { + return strings.LastIndex(prefix, "{") >= 0 +} diff --git a/internal/adapter/openai/tool_sieve_xml_test.go b/internal/adapter/openai/tool_sieve_xml_test.go index 0cf98e4..6c251a3 100644 --- a/internal/adapter/openai/tool_sieve_xml_test.go +++ b/internal/adapter/openai/tool_sieve_xml_test.go @@ -120,6 +120,37 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) { } } +func TestFindToolSegmentStartIgnoresFunctionCallProse(t *testing.T) { + input := "Please explain the functionCall API field and how clients should parse it." + if got := findToolSegmentStart(input); got != -1 { + t.Fatalf("expected no tool segment start for prose, got %d", got) + } +} + +func TestFindToolSegmentStartDetectsQuotedFunctionCallKey(t *testing.T) { + input := `prefix {"functionCall": {"name":"search_web","args":{"query":"x"}}}` + want := strings.Index(input, "{") + if got := findToolSegmentStart(input); got != want { + t.Fatalf("expected JSON object start %d, got %d", want, got) + } +} + +func TestProcessToolSieveDoesNotBufferFunctionCallProse(t *testing.T) { + var state toolStreamSieveState + chunk := "Please explain the functionCall API field and keep streaming this sentence." + events := processToolSieveChunk(&state, chunk, []string{"search_web"}) + var text string + for _, evt := range events { + text += evt.Content + if len(evt.ToolCalls) > 0 { + t.Fatalf("expected no tool calls for prose, got %#v", evt.ToolCalls) + } + } + if text != chunk { + t.Fatalf("expected prose to pass through immediately, got %q", text) + } +} + func TestProcessToolSieveDetectsGeminiFunctionCallPayload(t *testing.T) { var state toolStreamSieveState events := processToolSieveChunk(&state, `{"functionCall":{"name":"search_web","args":{"query":"latest"}}}`, []string{"search_web"}) From 15bf77e044cf775bf040dbd6c6e05dfe9439750f Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 13:40:21 +0800 Subject: [PATCH 3/7] refactor tool sieve functionCall helpers into separate file --- internal/adapter/openai/tool_sieve_core.go | 29 ----------------- .../adapter/openai/tool_sieve_functioncall.go | 32 +++++++++++++++++++ 2 files changed, 32 insertions(+), 29 deletions(-) create mode 100644 internal/adapter/openai/tool_sieve_functioncall.go diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index 3d70551..1b3c975 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -282,32 +282,3 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix prefixPart, suffixPart = trimWrappingJSONFence(prefixPart, suffixPart) return prefixPart, parsed.Calls, suffixPart, true } - -func findQuotedFunctionCallKeyStart(s string) int { - lower := strings.ToLower(s) - const key = "\"functioncall\"" - for from := 0; from < len(lower); { - rel := strings.Index(lower[from:], key) - if rel < 0 { - return -1 - } - idx := from + rel - if !hasJSONObjectContextPrefix(lower[:idx]) { - from = idx + 1 - continue - } - j := idx + len(key) - for j < len(lower) && (lower[j] == ' ' || lower[j] == '\t' || lower[j] == '\r' || lower[j] == '\n') { - j++ - } - if j < len(lower) && lower[j] == ':' { - return idx - } - from = idx + 1 - } - return -1 -} - -func hasJSONObjectContextPrefix(prefix string) bool { - return strings.LastIndex(prefix, "{") >= 0 -} diff --git a/internal/adapter/openai/tool_sieve_functioncall.go b/internal/adapter/openai/tool_sieve_functioncall.go new file mode 100644 index 0000000..f3cacbe --- /dev/null +++ b/internal/adapter/openai/tool_sieve_functioncall.go @@ -0,0 +1,32 @@ +package openai + +import "strings" + +func findQuotedFunctionCallKeyStart(s string) int { + lower := strings.ToLower(s) + const key = "\"functioncall\"" + for from := 0; from < len(lower); { + rel := strings.Index(lower[from:], key) + if rel < 0 { + return -1 + } + idx := from + rel + if !hasJSONObjectContextPrefix(lower[:idx]) { + from = idx + 1 + continue + } + j := idx + len(key) + for j < len(lower) && (lower[j] == ' ' || lower[j] == '\t' || lower[j] == '\r' || lower[j] == '\n') { + j++ + } + if j < len(lower) && lower[j] == ':' { + return idx + } + from = idx + 1 + } + return -1 +} + +func hasJSONObjectContextPrefix(prefix string) bool { + return strings.LastIndex(prefix, "{") >= 0 +} From 02fe3e4bfcf6c266611a69c1e6663b0c4cbbbcce Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 15:19:45 +0800 Subject: [PATCH 4/7] fix: detect loose functionCall keys in tool sieve --- .../adapter/openai/tool_sieve_functioncall.go | 41 ++++++++++++++++++- .../adapter/openai/tool_sieve_xml_test.go | 15 +++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/internal/adapter/openai/tool_sieve_functioncall.go b/internal/adapter/openai/tool_sieve_functioncall.go index f3cacbe..66d754e 100644 --- a/internal/adapter/openai/tool_sieve_functioncall.go +++ b/internal/adapter/openai/tool_sieve_functioncall.go @@ -4,7 +4,22 @@ import "strings" func findQuotedFunctionCallKeyStart(s string) int { lower := strings.ToLower(s) - const key = "\"functioncall\"" + quotedIdx := findFunctionCallKeyStart(lower, `"functioncall"`) + baretIdx := findFunctionCallKeyStart(lower, "functioncall") + + switch { + case quotedIdx < 0: + return baretIdx + case baretIdx < 0: + return quotedIdx + case quotedIdx < baretIdx: + return quotedIdx + default: + return baretIdx + } +} + +func findFunctionCallKeyStart(lower, key string) int { for from := 0; from < len(lower); { rel := strings.Index(lower[from:], key) if rel < 0 { @@ -15,6 +30,10 @@ func findQuotedFunctionCallKeyStart(s string) int { from = idx + 1 continue } + if !hasJSONKeyBoundary(lower, idx, len(key)) { + from = idx + 1 + continue + } j := idx + len(key) for j < len(lower) && (lower[j] == ' ' || lower[j] == '\t' || lower[j] == '\r' || lower[j] == '\n') { j++ @@ -30,3 +49,23 @@ func findQuotedFunctionCallKeyStart(s string) int { func hasJSONObjectContextPrefix(prefix string) bool { return strings.LastIndex(prefix, "{") >= 0 } + +func hasJSONKeyBoundary(s string, idx, keyLen int) bool { + if idx > 0 { + prev := s[idx-1] + if isLowerAlphaNumeric(prev) { + return false + } + } + if end := idx + keyLen; end < len(s) { + next := s[end] + if isLowerAlphaNumeric(next) { + return false + } + } + return true +} + +func isLowerAlphaNumeric(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '_' +} diff --git a/internal/adapter/openai/tool_sieve_xml_test.go b/internal/adapter/openai/tool_sieve_xml_test.go index 6c251a3..a86fe33 100644 --- a/internal/adapter/openai/tool_sieve_xml_test.go +++ b/internal/adapter/openai/tool_sieve_xml_test.go @@ -135,6 +135,21 @@ func TestFindToolSegmentStartDetectsQuotedFunctionCallKey(t *testing.T) { } } +func TestFindToolSegmentStartDetectsLooseFunctionCallKey(t *testing.T) { + input := `prefix {functionCall: {"name":"search_web","args":{"query":"x"}}}` + want := strings.Index(input, "{") + if got := findToolSegmentStart(input); got != want { + t.Fatalf("expected JSON object start %d, got %d", want, got) + } +} + +func TestFindToolSegmentStartIgnoresLooseFunctionCallProse(t *testing.T) { + input := "Please explain why functionCall: is used in documentation examples." + if got := findToolSegmentStart(input); got != -1 { + t.Fatalf("expected no tool segment start for prose, got %d", got) + } +} + func TestProcessToolSieveDoesNotBufferFunctionCallProse(t *testing.T) { var state toolStreamSieveState chunk := "Please explain the functionCall API field and keep streaming this sentence." From 02d64c192e25a46caa7cd8c9e560143ebed70544 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 18:22:30 +0800 Subject: [PATCH 5/7] fix: prioritize quoted functionCall keys in tool sieve --- .../adapter/openai/tool_sieve_functioncall.go | 18 +++++++++--------- internal/adapter/openai/tool_sieve_xml_test.go | 8 ++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/adapter/openai/tool_sieve_functioncall.go b/internal/adapter/openai/tool_sieve_functioncall.go index 66d754e..bfdfaa6 100644 --- a/internal/adapter/openai/tool_sieve_functioncall.go +++ b/internal/adapter/openai/tool_sieve_functioncall.go @@ -5,18 +5,18 @@ import "strings" func findQuotedFunctionCallKeyStart(s string) int { lower := strings.ToLower(s) quotedIdx := findFunctionCallKeyStart(lower, `"functioncall"`) - baretIdx := findFunctionCallKeyStart(lower, "functioncall") + bareIdx := findFunctionCallKeyStart(lower, "functioncall") - switch { - case quotedIdx < 0: - return baretIdx - case baretIdx < 0: + // Prefer quoted JSON keys when present. Bare-key detection is a fallback + // for loose payloads like {functionCall:{...}}. + // + // This avoids anchoring on earlier prose such as: + // "... {note} functionCall: ... {\"functionCall\":{...}}" + // where choosing the earliest bare match can hide the real tool payload. + if quotedIdx >= 0 { return quotedIdx - case quotedIdx < baretIdx: - return quotedIdx - default: - return baretIdx } + return bareIdx } func findFunctionCallKeyStart(lower, key string) int { diff --git a/internal/adapter/openai/tool_sieve_xml_test.go b/internal/adapter/openai/tool_sieve_xml_test.go index a86fe33..e390cd9 100644 --- a/internal/adapter/openai/tool_sieve_xml_test.go +++ b/internal/adapter/openai/tool_sieve_xml_test.go @@ -143,6 +143,14 @@ func TestFindToolSegmentStartDetectsLooseFunctionCallKey(t *testing.T) { } } +func TestFindToolSegmentStartPrefersQuotedFunctionCallOverEarlierBareProse(t *testing.T) { + input := `prefix {note} functionCall: docs hint {"functionCall":{"name":"search_web","args":{"query":"x"}}}` + want := strings.Index(input, `{"functionCall"`) + if got := findToolSegmentStart(input); got != want { + t.Fatalf("expected quoted functionCall JSON start %d, got %d", want, got) + } +} + func TestFindToolSegmentStartIgnoresLooseFunctionCallProse(t *testing.T) { input := "Please explain why functionCall: is used in documentation examples." if got := findToolSegmentStart(input); got != -1 { From 39f6e066d6379031ea88742edbf6ed69a549268e Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 19:43:47 +0800 Subject: [PATCH 6/7] fix: harden functionCall key detection in tool sieve --- .../adapter/openai/tool_sieve_functioncall.go | 48 +++++++++++++++---- .../openai/tool_sieve_functioncall_test.go | 23 +++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 internal/adapter/openai/tool_sieve_functioncall_test.go diff --git a/internal/adapter/openai/tool_sieve_functioncall.go b/internal/adapter/openai/tool_sieve_functioncall.go index bfdfaa6..35b0257 100644 --- a/internal/adapter/openai/tool_sieve_functioncall.go +++ b/internal/adapter/openai/tool_sieve_functioncall.go @@ -7,16 +7,16 @@ func findQuotedFunctionCallKeyStart(s string) int { quotedIdx := findFunctionCallKeyStart(lower, `"functioncall"`) bareIdx := findFunctionCallKeyStart(lower, "functioncall") - // Prefer quoted JSON keys when present. Bare-key detection is a fallback - // for loose payloads like {functionCall:{...}}. - // - // This avoids anchoring on earlier prose such as: - // "... {note} functionCall: ... {\"functionCall\":{...}}" - // where choosing the earliest bare match can hide the real tool payload. - if quotedIdx >= 0 { + if quotedIdx < 0 { + return bareIdx + } + if bareIdx < 0 { return quotedIdx } - return bareIdx + if bareIdx < quotedIdx { + return bareIdx + } + return quotedIdx } func findFunctionCallKeyStart(lower, key string) int { @@ -26,6 +26,10 @@ func findFunctionCallKeyStart(lower, key string) int { return -1 } idx := from + rel + if isInsideJSONString(lower, idx) { + from = idx + 1 + continue + } if !hasJSONObjectContextPrefix(lower[:idx]) { from = idx + 1 continue @@ -39,6 +43,14 @@ func findFunctionCallKeyStart(lower, key string) int { j++ } if j < len(lower) && lower[j] == ':' { + k := j + 1 + for k < len(lower) && (lower[k] == ' ' || lower[k] == '\t' || lower[k] == '\r' || lower[k] == '\n') { + k++ + } + if k < len(lower) && lower[k] != '{' { + from = idx + 1 + continue + } return idx } from = idx + 1 @@ -46,6 +58,26 @@ func findFunctionCallKeyStart(lower, key string) int { return -1 } +func isInsideJSONString(s string, idx int) bool { + inString := false + escaped := false + for i := 0; i < idx; i++ { + c := s[i] + if escaped { + escaped = false + continue + } + if c == '\\' && inString { + escaped = true + continue + } + if c == '"' { + inString = !inString + } + } + return inString +} + func hasJSONObjectContextPrefix(prefix string) bool { return strings.LastIndex(prefix, "{") >= 0 } diff --git a/internal/adapter/openai/tool_sieve_functioncall_test.go b/internal/adapter/openai/tool_sieve_functioncall_test.go new file mode 100644 index 0000000..265f3e6 --- /dev/null +++ b/internal/adapter/openai/tool_sieve_functioncall_test.go @@ -0,0 +1,23 @@ +package openai + +import "testing" + +func TestFindQuotedFunctionCallKeyStart_PrefersEarlierBareKey(t *testing.T) { + input := `{functionCall:{"name":"a","arguments":"{}"},"message":"literal text: \"functionCall\": not a key"}` + + got := findQuotedFunctionCallKeyStart(input) + want := 1 + if got != want { + t.Fatalf("findQuotedFunctionCallKeyStart() = %d, want %d", got, want) + } +} + +func TestFindQuotedFunctionCallKeyStart_PrefersEarlierQuotedKey(t *testing.T) { + input := `{"functionCall":{"name":"a","arguments":"{}"},"note":"functionCall appears in prose"}` + + got := findQuotedFunctionCallKeyStart(input) + want := 1 + if got != want { + t.Fatalf("findQuotedFunctionCallKeyStart() = %d, want %d", got, want) + } +} From 24655342a74a6d810964d310241318bc0fed0577 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 20:11:29 +0800 Subject: [PATCH 7/7] fix: prefer quoted functionCall keys over bare matches --- internal/adapter/openai/tool_sieve_functioncall.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/internal/adapter/openai/tool_sieve_functioncall.go b/internal/adapter/openai/tool_sieve_functioncall.go index 35b0257..c629ace 100644 --- a/internal/adapter/openai/tool_sieve_functioncall.go +++ b/internal/adapter/openai/tool_sieve_functioncall.go @@ -7,16 +7,13 @@ func findQuotedFunctionCallKeyStart(s string) int { quotedIdx := findFunctionCallKeyStart(lower, `"functioncall"`) bareIdx := findFunctionCallKeyStart(lower, "functioncall") - if quotedIdx < 0 { - return bareIdx - } - if bareIdx < 0 { + // Prefer the quoted JSON key whenever we have a structural match. + // Bare-key detection is only for loose payloads where the quoted form + // is absent. + if quotedIdx >= 0 { return quotedIdx } - if bareIdx < quotedIdx { - return bareIdx - } - return quotedIdx + return bareIdx } func findFunctionCallKeyStart(lower, key string) int {