From 02d64c192e25a46caa7cd8c9e560143ebed70544 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 18:22:30 +0800 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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 {