287 lines
6.1 KiB
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()
|
|
}
|