From 39f6e066d6379031ea88742edbf6ed69a549268e Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Thu, 2 Apr 2026 19:43:47 +0800 Subject: [PATCH] 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) + } +}