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)
+ }
+}