agent: parse text-based tool calls instead of failing

Models that don't support native function calling emit tool calls as
plain text. Instead of treating this as ToolUnsupportedError, parse the
text and execute the tool calls normally.

Handles both direct JSON and shorthand (key=value) formats with relaxed
JSON support (unquoted keys).
This commit is contained in:
Levi Neely 2026-05-23 11:46:49 +02:00
parent 1485decdbb
commit 9d6800a51c
2 changed files with 346 additions and 35 deletions

View File

@ -249,15 +249,13 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
return fmt.Errorf("step %d: %s", step, stopReason)
}
// Detect models that emit tool calls as plain text instead of using the
// function-calling API. If the response contains no API-level tool calls
// but the text matches a registered tool name followed by ':' or '(',
// the model does not support function calling — treat as ToolUnsupportedError
// so freeloader can blacklist and cycle to the next model.
if len(toolCalls) == 0 && len(cfg.Tools) > 0 && hasTextToolCall(content.String(), cfg.Tools) {
return fmt.Errorf("step %d: %w", step, &backend.ToolUnsupportedError{
Message: "model emitted tool call as text (function calling API not supported)",
})
// Parse text-based tool calls: models that don't support the function
// calling API emit tool invocations as plain text (e.g.
// "call_tool: calls=[...]"). Parse these into proper ToolCall structs.
if len(toolCalls) == 0 && len(cfg.Tools) > 0 {
if parsed := parseTextToolCalls(content.String(), cfg.Tools); len(parsed) > 0 {
toolCalls = parsed
}
}
// Execute tool calls, running consecutive parallel-read-safe tools concurrently.
@ -638,16 +636,280 @@ func inferTaskStateUpdate(ts *TaskState, msg backend.Message, results []toolResu
}
}
// hasTextToolCall returns true when the assistant text contains a registered
// tool name immediately followed by ':' or '(', indicating the model tried to
// invoke a tool by writing the call as plain text rather than via the API.
func hasTextToolCall(text string, tools []backend.Tool) bool {
// parseTextToolCalls extracts tool calls from assistant text when the model
// emits them as plain text instead of using the function calling API.
// Recognizes formats:
// - tool_name: {"key": ...} (JSON object)
// - tool_name: key=[...], key2=... (shorthand: key=value pairs → JSON object)
// Returns nil if no valid tool calls are found.
func parseTextToolCalls(text string, tools []backend.Tool) []backend.ToolCall {
toolNames := make(map[string]bool, len(tools))
for _, t := range tools {
if strings.Contains(text, t.Name+":") || strings.Contains(text, t.Name+"(") {
return true
toolNames[t.Name] = true
}
var calls []backend.ToolCall
for _, name := range []string{"execute_code", "call_tool", "pipe"} {
if !toolNames[name] {
continue
}
prefix := name + ":"
idx := strings.Index(text, prefix)
if idx < 0 {
continue
}
rest := strings.TrimSpace(text[idx+len(prefix):])
if rest == "" {
continue
}
var argsJSON string
if rest[0] == '{' {
// Direct JSON object
argsJSON = extractJSONObject(rest)
if argsJSON == "" {
argsJSON = extractJSONObject(relaxJSON(rest))
}
} else {
// Shorthand format: key=[...], key2=value
argsJSON = shorthandToJSON(rest)
}
if argsJSON == "" {
continue
}
var check json.RawMessage
if json.Unmarshal([]byte(argsJSON), &check) != nil {
continue
}
calls = append(calls, backend.ToolCall{
ID: fmt.Sprintf("text-%s-%d", name, len(calls)),
Name: name,
Arguments: json.RawMessage(argsJSON),
})
}
return calls
}
// shorthandToJSON converts "key=[...], key2=value" format to a JSON object.
// Handles: steps=[{code: "date"}], timeout=30
func shorthandToJSON(s string) string {
// Find the first key=value where value starts with [ or {
eqIdx := strings.IndexByte(s, '=')
if eqIdx < 0 {
return ""
}
key := strings.TrimSpace(s[:eqIdx])
if key == "" {
return ""
}
valStr := s[eqIdx+1:]
// Extract the value (balanced brackets/braces)
val := extractBalanced(strings.TrimSpace(valStr))
if val == "" {
return ""
}
// Relax the value's JSON (unquoted keys → quoted)
relaxed := relaxJSON(val)
// Build the JSON object: {"key": relaxed_value}
keyJSON, _ := json.Marshal(key)
result := "{" + string(keyJSON) + ":" + relaxed + "}"
// Check if there are more key=value pairs after the value
afterVal := strings.TrimSpace(valStr[len(val):])
if len(afterVal) > 0 && afterVal[0] == ',' {
// Parse additional key=value pairs
extra := parseExtraKV(afterVal[1:])
if extra != "" {
// Merge: strip trailing } from result, append extra
result = result[:len(result)-1] + "," + extra + "}"
}
}
return false
return result
}
// extractBalanced extracts a balanced [...] or {...} from the start of s.
func extractBalanced(s string) string {
if len(s) == 0 {
return ""
}
open := s[0]
var close byte
switch open {
case '[':
close = ']'
case '{':
close = '}'
default:
// Simple value (number, string) — take until comma or newline
end := strings.IndexAny(s, ",\n")
if end < 0 {
return strings.TrimSpace(s)
}
return strings.TrimSpace(s[:end])
}
depth := 0
inStr := false
escaped := false
for i := 0; i < len(s); i++ {
c := s[i]
if escaped {
escaped = false
continue
}
if c == '\\' && inStr {
escaped = true
continue
}
if c == '"' {
inStr = !inStr
continue
}
if inStr {
continue
}
if c == open {
depth++
} else if c == close {
depth--
if depth == 0 {
return s[:i+1]
}
}
}
return ""
}
// parseExtraKV parses "key=value, key2=value2" into JSON fields (without outer braces).
func parseExtraKV(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
var parts []string
for s != "" {
eqIdx := strings.IndexByte(s, '=')
if eqIdx < 0 {
break
}
key := strings.TrimSpace(s[:eqIdx])
s = strings.TrimSpace(s[eqIdx+1:])
val := extractBalanced(s)
if val == "" {
break
}
s = strings.TrimSpace(s[len(val):])
if len(s) > 0 && s[0] == ',' {
s = s[1:]
}
keyJSON, _ := json.Marshal(key)
parts = append(parts, string(keyJSON)+":"+relaxJSON(val))
}
return strings.Join(parts, ",")
}
// extractJSONObject finds the first balanced {...} in s.
func extractJSONObject(s string) string {
start := strings.IndexByte(s, '{')
if start < 0 {
return ""
}
depth := 0
inStr := false
escaped := false
for i := start; i < len(s); i++ {
c := s[i]
if escaped {
escaped = false
continue
}
if c == '\\' && inStr {
escaped = true
continue
}
if c == '"' {
inStr = !inStr
continue
}
if inStr {
continue
}
if c == '{' {
depth++
} else if c == '}' {
depth--
if depth == 0 {
return s[start : i+1]
}
}
}
return ""
}
// relaxJSON converts common relaxed-JSON patterns to valid JSON:
// - unquoted keys (word: → "word":)
// - single-quoted strings → double-quoted
// This is a best-effort heuristic for model output.
func relaxJSON(s string) string {
var out strings.Builder
out.Grow(len(s))
i := 0
for i < len(s) {
c := s[i]
// Single-quoted string → double-quoted
if c == '\'' {
out.WriteByte('"')
i++
for i < len(s) && s[i] != '\'' {
if s[i] == '"' {
out.WriteString(`\"`)
} else if s[i] == '\\' && i+1 < len(s) {
out.WriteByte(s[i])
i++
out.WriteByte(s[i])
} else {
out.WriteByte(s[i])
}
i++
}
out.WriteByte('"')
if i < len(s) {
i++ // skip closing '
}
continue
}
// Unquoted key before colon: word followed by optional whitespace then ':'
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' {
j := i
for j < len(s) && (s[j] >= 'a' && s[j] <= 'z' || s[j] >= 'A' && s[j] <= 'Z' || s[j] >= '0' && s[j] <= '9' || s[j] == '_') {
j++
}
// Check if followed by optional whitespace then ':'
k := j
for k < len(s) && (s[k] == ' ' || s[k] == '\t') {
k++
}
if k < len(s) && s[k] == ':' {
// It's an unquoted key — quote it
out.WriteByte('"')
out.WriteString(s[i:j])
out.WriteByte('"')
i = j
continue
}
// Not a key, just copy the word
out.WriteString(s[i:j])
i = j
continue
}
out.WriteByte(c)
i++
}
return out.String()
}
// classifyError returns a short string identifying the error type for the

View File

@ -2,39 +2,88 @@ package agent
import (
"context"
"encoding/json"
"testing"
"ollie/pkg/backend"
)
// TestTextToolCall_DetectedAsUnsupported verifies that when a model emits a tool
// call as plain text (no API-level tool calls) the loop returns a
// ToolUnsupportedError so freeloader can blacklist and cycle.
func TestTextToolCall_DetectedAsUnsupported(t *testing.T) {
// TestTextToolCall_ParsedAndExecuted verifies that when a model emits a tool
// call as plain text (no API-level tool calls) the loop parses it and executes
// it as a normal tool call.
func TestTextToolCall_ParsedAndExecuted(t *testing.T) {
be := defaultBE()
// Model responds with text that mimics a tool invocation instead of using
// the function-calling API.
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
return textStream(`execute_code: steps=[{code: "print('hi')"}]`), nil
be.respond = func(_ context.Context, msgs []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
// First call: model emits text tool call
for _, m := range msgs {
if m.Role == "tool" {
// Second call after tool result: respond normally
return textStream("done"), nil
}
}
return textStream(`call_tool: {"calls":[{"tool":"file_read","args":["/tmp/test.go"]}]}`), nil
}
var execCalled bool
c := newCore(t, be, nil)
c.runtime.Tools = []backend.Tool{{Name: "execute_code", Description: "run code"}}
evs := collectEvents(context.Background(), c, "run something")
errEvs := byRole(evs, "error")
if len(errEvs) == 0 {
t.Fatal("expected an error event; got none")
c.runtime.Tools = []backend.Tool{
{Name: "call_tool", Description: "run tools"},
{Name: "execute_code", Description: "run code"},
}
// The error must mention tool_unsupported behaviour (not a plain backend error).
if got := errEvs[0]; got == "" {
t.Error("error event content is empty")
c.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, error) {
execCalled = true
if name != "call_tool" {
t.Errorf("expected tool name 'call_tool', got %q", name)
}
return "file contents here", nil
}
evs := collectEvents(context.Background(), c, "read a file")
if !execCalled {
t.Fatal("expected Exec to be called for text-based tool call")
}
errEvs := byRole(evs, "error")
if len(errEvs) != 0 {
t.Errorf("unexpected error events: %v", errEvs)
}
}
// TestTextToolCall_RelaxedJSON verifies parsing of relaxed JSON (unquoted keys).
func TestTextToolCall_RelaxedJSON(t *testing.T) {
be := defaultBE()
be.respond = func(_ context.Context, msgs []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {
for _, m := range msgs {
if m.Role == "tool" {
return textStream("done"), nil
}
}
return textStream(`execute_code: steps=[{code: "date"}]`), nil
}
var execCalled bool
c := newCore(t, be, nil)
c.runtime.Tools = []backend.Tool{
{Name: "execute_code", Description: "run code"},
{Name: "call_tool", Description: "run tools"},
}
c.runtime.Exec = func(_ context.Context, name string, args json.RawMessage) (string, error) {
execCalled = true
if name != "execute_code" {
t.Errorf("expected tool name 'execute_code', got %q", name)
}
return "Sat May 23 11:00:00 UTC 2026", nil
}
collectEvents(context.Background(), c, "what time is it")
if !execCalled {
t.Fatal("expected Exec to be called for relaxed-JSON text tool call")
}
}
// TestTextToolCall_NoFalsePositive verifies that a normal text response that
// does not contain a tool-call pattern is not mistakenly flagged.
// does not contain a tool-call pattern is not mistakenly parsed.
func TestTextToolCall_NoFalsePositive(t *testing.T) {
be := defaultBE()
be.respond = func(_ context.Context, _ []backend.Message, _ []backend.Tool, _ backend.GenerationParams) (<-chan backend.StreamEvent, error) {