fix(providers): robust tool call extraction for pretty-printed JSON
Replace fragile strings.Index approach with a multi-strategy extraction: 1. Try direct match first (compact JSON) 2. Try trimmed whitespace (pretty-printed JSON) 3. Fall back to finding tool_calls anywhere in text Also fix stripToolCallsFromText to handle the same cases. Fixes #2136
This commit is contained in:
parent
15a70ac45c
commit
67f59c0f03
1 changed files with 123 additions and 1 deletions
|
|
@ -9,6 +9,25 @@ import (
|
|||
// Both ClaudeCliProvider and CodexCliProvider use this to extract
|
||||
// tool calls that the model outputs in its response text.
|
||||
func extractToolCallsFromText(text string) []ToolCall {
|
||||
// Try direct match first (handles compact JSON)
|
||||
if calls := extractToolCallsFromTextImpl(text); len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
// Try with trimmed whitespace (handles pretty-printed JSON with leading whitespace)
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed != text {
|
||||
if calls := extractToolCallsFromTextImpl(trimmed); len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
}
|
||||
// Try to parse the entire text as JSON and look for tool_calls
|
||||
// (handles cases where tool_calls is nested or prefixed with other content)
|
||||
return extractToolCallsFromJSON(text)
|
||||
}
|
||||
|
||||
// extractToolCallsFromTextImpl does the actual extraction assuming the input
|
||||
// starts with the JSON containing tool_calls.
|
||||
func extractToolCallsFromTextImpl(text string) []ToolCall {
|
||||
start := strings.Index(text, `{"tool_calls"`)
|
||||
if start == -1 {
|
||||
return nil
|
||||
|
|
@ -56,8 +75,87 @@ func extractToolCallsFromText(text string) []ToolCall {
|
|||
return result
|
||||
}
|
||||
|
||||
// extractToolCallsFromJSON attempts to parse the entire text as JSON and
|
||||
// extract tool_calls from anywhere in the object graph.
|
||||
func extractToolCallsFromJSON(text string) []ToolCall {
|
||||
// Try to find {"tool_calls": ...} anywhere in the text using a simple search
|
||||
idx := strings.Index(text, `"tool_calls"`)
|
||||
if idx == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the opening brace before "tool_calls"
|
||||
braceStart := idx
|
||||
for braceStart >= 0 && text[braceStart] != '{' {
|
||||
braceStart--
|
||||
}
|
||||
if braceStart < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
end := findMatchingBrace(text, braceStart)
|
||||
if end == braceStart {
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonStr := text[braceStart:end]
|
||||
var wrapper struct {
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil || len(wrapper.ToolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []ToolCall
|
||||
for _, tc := range wrapper.ToolCalls {
|
||||
var args map[string]any
|
||||
json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
|
||||
result = append(result, ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: tc.Type,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: args,
|
||||
Function: &FunctionCall{
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// stripToolCallsFromText removes tool call JSON from response text.
|
||||
// Handles both compact JSON and pretty-printed JSON with whitespace.
|
||||
func stripToolCallsFromText(text string) string {
|
||||
// Try direct match first
|
||||
result := stripToolCallsImpl(text)
|
||||
if result != text {
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
// Try with trimmed whitespace
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed != text {
|
||||
result = stripToolCallsImpl(trimmed)
|
||||
if result != trimmed {
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
}
|
||||
// Try to find and strip tool_calls JSON from anywhere in the text
|
||||
return stripToolCallsFromJSON(text)
|
||||
}
|
||||
|
||||
// stripToolCallsImpl does the actual stripping assuming the input starts with JSON.
|
||||
func stripToolCallsImpl(text string) string {
|
||||
start := strings.Index(text, `{"tool_calls"`)
|
||||
if start == -1 {
|
||||
return text
|
||||
|
|
@ -68,5 +166,29 @@ func stripToolCallsFromText(text string) string {
|
|||
return text
|
||||
}
|
||||
|
||||
return strings.TrimSpace(text[:start] + text[end:])
|
||||
return text[:start] + text[end:]
|
||||
}
|
||||
|
||||
// stripToolCallsFromJSON finds and removes tool_calls JSON from anywhere in the text.
|
||||
func stripToolCallsFromJSON(text string) string {
|
||||
idx := strings.Index(text, `"tool_calls"`)
|
||||
if idx == -1 {
|
||||
return text
|
||||
}
|
||||
|
||||
// Find the opening brace before "tool_calls"
|
||||
braceStart := idx
|
||||
for braceStart >= 0 && text[braceStart] != '{' {
|
||||
braceStart--
|
||||
}
|
||||
if braceStart < 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
end := findMatchingBrace(text, braceStart)
|
||||
if end == braceStart {
|
||||
return text
|
||||
}
|
||||
|
||||
return strings.TrimSpace(text[:braceStart] + text[end:])
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue