ollie/cmd/olliesrv/internal/agent/text_parse.go

287 lines
6.1 KiB
Go

package agent
import (
"encoding/json"
"fmt"
"strings"
"ollie/cmd/olliesrv/internal/backend"
)
// 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 {
toolNames[t.Name] = true
}
var calls []backend.ToolCall
for _, name := range []string{"shell"} {
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 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()
}