diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index 5d96503..1b3c975 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 diff --git a/internal/adapter/openai/tool_sieve_functioncall.go b/internal/adapter/openai/tool_sieve_functioncall.go new file mode 100644 index 0000000..c629ace --- /dev/null +++ b/internal/adapter/openai/tool_sieve_functioncall.go @@ -0,0 +1,100 @@ +package openai + +import "strings" + +func findQuotedFunctionCallKeyStart(s string) int { + lower := strings.ToLower(s) + quotedIdx := findFunctionCallKeyStart(lower, `"functioncall"`) + bareIdx := findFunctionCallKeyStart(lower, "functioncall") + + // 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 + } + return bareIdx +} + +func findFunctionCallKeyStart(lower, key string) int { + for from := 0; from < len(lower); { + rel := strings.Index(lower[from:], key) + if rel < 0 { + return -1 + } + idx := from + rel + if isInsideJSONString(lower, idx) { + from = idx + 1 + continue + } + if !hasJSONObjectContextPrefix(lower[:idx]) { + 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++ + } + 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 + } + 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 +} + +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_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) + } +} diff --git a/internal/adapter/openai/tool_sieve_xml_test.go b/internal/adapter/openai/tool_sieve_xml_test.go index 0cf98e4..e390cd9 100644 --- a/internal/adapter/openai/tool_sieve_xml_test.go +++ b/internal/adapter/openai/tool_sieve_xml_test.go @@ -120,6 +120,60 @@ 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 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 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 { + 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." + 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"}) diff --git a/internal/util/tool_prompt.go b/internal/util/tool_prompt.go index b0b0f75..9394eda 100644 --- a/internal/util/tool_prompt.go +++ b/internal/util/tool_prompt.go @@ -1,5 +1,7 @@ package util +import "strings" + // BuildToolCallInstructions generates the unified tool-calling instruction block // used by all adapters (OpenAI, Claude, Gemini). It uses attention-optimized // structure: rules → negative examples → positive examples → anchor. @@ -19,7 +21,7 @@ func BuildToolCallInstructions(toolNames []string) string { ex1 = n used["ex1"] = true // Write/execute-type tools - case !used["ex2"] && matchAny(n, "write_to_file", "apply_diff", "execute_command", "Write", "Edit", "MultiEdit", "Bash"): + case !used["ex2"] && matchAny(n, "write_to_file", "apply_diff", "execute_command", "exec_command", "Write", "Edit", "MultiEdit", "Bash"): ex2 = n used["ex2"] = true // Interactive/meta tools @@ -28,6 +30,9 @@ func BuildToolCallInstructions(toolNames []string) string { used["ex3"] = true } } + ex1Params := exampleReadParams(ex1) + ex2Params := exampleWriteOrExecParams(ex2) + ex3Params := exampleInteractiveParams(ex3) return `TOOL CALL FORMAT — FOLLOW EXACTLY: @@ -47,6 +52,7 @@ RULES: 4) Do NOT wrap the XML in markdown code fences (no triple backticks). 5) After receiving a tool result, use it directly. Only call another tool if the result is insufficient. 6) If you want to say something AND call a tool, output text first, then the XML block on its own. +7) Parameters MUST use the exact field names from the selected tool schema. ❌ WRONG — Do NOT do these: Wrong 1 — mixed text and XML: @@ -62,7 +68,7 @@ Example A — Single tool: ` + ex1 + ` - {"path":"src/main.go"} + ` + ex1Params + ` @@ -70,11 +76,11 @@ Example B — Two tools in parallel: ` + ex1 + ` - {"path":"config.json"} + ` + ex1Params + ` ` + ex2 + ` - {"path":"output.txt","content":"Hello world"} + ` + ex2Params + ` @@ -82,7 +88,7 @@ Example C — Tool with complex nested JSON parameters: ` + ex3 + ` - {"question":"Which approach do you prefer?","follow_up":[{"text":"Option A"},{"text":"Option B"}]} + ` + ex3Params + ` @@ -97,3 +103,38 @@ func matchAny(name string, candidates ...string) bool { } return false } + +func exampleReadParams(name string) string { + switch strings.TrimSpace(name) { + case "Read": + return `{"file_path":"README.md"}` + case "Glob": + return `{"pattern":"**/*.go","path":"."}` + default: + return `{"path":"src/main.go"}` + } +} + +func exampleWriteOrExecParams(name string) string { + switch strings.TrimSpace(name) { + case "Bash", "execute_command": + return `{"command":"pwd"}` + case "exec_command": + return `{"cmd":"pwd"}` + case "Edit": + return `{"file_path":"README.md","old_string":"foo","new_string":"bar"}` + case "MultiEdit": + return `{"file_path":"README.md","edits":[{"old_string":"foo","new_string":"bar"}]}` + default: + return `{"path":"output.txt","content":"Hello world"}` + } +} + +func exampleInteractiveParams(name string) string { + switch strings.TrimSpace(name) { + case "Task": + return `{"description":"Investigate flaky tests","prompt":"Run targeted tests and summarize failures"}` + default: + return `{"question":"Which approach do you prefer?","follow_up":[{"text":"Option A"},{"text":"Option B"}]}` + } +} diff --git a/internal/util/tool_prompt_test.go b/internal/util/tool_prompt_test.go new file mode 100644 index 0000000..e10f176 --- /dev/null +++ b/internal/util/tool_prompt_test.go @@ -0,0 +1,26 @@ +package util + +import ( + "strings" + "testing" +) + +func TestBuildToolCallInstructions_ExecCommandUsesCmdExample(t *testing.T) { + out := BuildToolCallInstructions([]string{"exec_command"}) + if !strings.Contains(out, `exec_command`) { + t.Fatalf("expected exec_command in examples, got: %s", out) + } + if !strings.Contains(out, `{"cmd":"pwd"}`) { + t.Fatalf("expected cmd parameter example for exec_command, got: %s", out) + } +} + +func TestBuildToolCallInstructions_ExecuteCommandUsesCommandExample(t *testing.T) { + out := BuildToolCallInstructions([]string{"execute_command"}) + if !strings.Contains(out, `execute_command`) { + t.Fatalf("expected execute_command in examples, got: %s", out) + } + if !strings.Contains(out, `{"command":"pwd"}`) { + t.Fatalf("expected command parameter example for execute_command, got: %s", out) + } +}