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."