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