diff --git a/internal/adapter/openai/tool_sieve_functioncall.go b/internal/adapter/openai/tool_sieve_functioncall.go index bfdfaa6..c629ace 100644 --- a/internal/adapter/openai/tool_sieve_functioncall.go +++ b/internal/adapter/openai/tool_sieve_functioncall.go @@ -7,12 +7,9 @@ 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. + // 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 } @@ -26,6 +23,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 +40,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 +55,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) + } +}