Files
iasysadmin/internal/toolcall/parser.go
T
2026-09-27 16:04:02 -06:00

152 lines
4.4 KiB
Go

// Package toolcall interpreta llamadas a herramientas escritas como texto
// por el modelo, para servidores o modelos sin tool calling nativo (§33).
//
// Formatos aceptados:
//
// 1. Bloque cercado ```tool (el formato que se enseña al modelo):
// ```tool
// {"tool": "exec", "arguments": {"command": "uname", "args": ["-a"]}}
// ```
// 2. Etiquetas <tool_call>...</tool_call> (plantillas Hermes/Qwen, que
// algunos servidores devuelven sin procesar).
// 3. Bloque ```json o respuesta completa que sea un objeto JSON con las
// claves "tool"/"name" y "arguments".
//
// Seguridad: aquí solo se extrae y valida la estructura. Si algo parece una
// llamada pero no es JSON válido, se devuelve un error para informar al
// modelo; nunca se intenta "adivinar" ni ejecutar texto libre.
package toolcall
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// Call es una llamada extraída del texto.
type Call struct {
ID string
Name string
Arguments string // objeto JSON serializado
}
var (
fenceRe = regexp.MustCompile("(?s)```(tool|json|tool_call)?[ \\t]*\\n(.*?)\\n?```")
xmlCallRe = regexp.MustCompile(`(?s)<tool_call>\s*(.*?)\s*</tool_call>`)
)
// Parse extrae las llamadas de content.
//
// Devuelve found=false si el texto no contiene ningún intento de llamada
// (respuesta final normal). Si hay un intento mal formado, devuelve err.
func Parse(content string) (calls []Call, found bool, err error) {
var candidates []string
for _, m := range xmlCallRe.FindAllStringSubmatch(content, -1) {
candidates = append(candidates, m[1])
}
for _, m := range fenceRe.FindAllStringSubmatch(content, -1) {
lang, body := m[1], strings.TrimSpace(m[2])
switch lang {
case "tool", "tool_call":
candidates = append(candidates, body)
case "json", "":
// Un bloque json/sin lenguaje solo cuenta si tiene forma de llamada;
// el modelo puede mostrar JSON como ejemplo o como dato.
if looksLikeCall(body) {
candidates = append(candidates, body)
}
}
}
if len(candidates) == 0 {
trimmed := strings.TrimSpace(content)
if strings.HasPrefix(trimmed, "{") && strings.HasSuffix(trimmed, "}") && looksLikeCall(trimmed) {
candidates = append(candidates, trimmed)
}
}
if len(candidates) == 0 {
return nil, false, nil
}
for i, c := range candidates {
parsed, err := parseCandidate(c)
if err != nil {
return nil, true, fmt.Errorf("tool call %d is malformed: %w", i+1, err)
}
for _, p := range parsed {
p.ID = fmt.Sprintf("text-%d", len(calls)+1)
calls = append(calls, p)
}
}
return calls, true, nil
}
// looksLikeCall comprueba superficialmente que el JSON tenga claves de llamada.
func looksLikeCall(s string) bool {
return (strings.Contains(s, `"tool"`) || strings.Contains(s, `"name"`)) && strings.Contains(s, `"arguments"`)
}
type rawCall struct {
Tool string `json:"tool"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// parseCandidate acepta un objeto o una lista de objetos.
func parseCandidate(s string) ([]Call, error) {
s = strings.TrimSpace(s)
var list []rawCall
if strings.HasPrefix(s, "[") {
if err := json.Unmarshal([]byte(s), &list); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
} else {
var one rawCall
if err := json.Unmarshal([]byte(s), &one); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
list = []rawCall{one}
}
if len(list) == 0 {
return nil, fmt.Errorf("empty list")
}
var out []Call
for _, rc := range list {
name := rc.Tool
if name == "" {
name = rc.Name
}
if name == "" {
return nil, fmt.Errorf(`missing "tool" name`)
}
args, err := normalizeArgs(rc.Arguments)
if err != nil {
return nil, fmt.Errorf("tool %s: %w", name, err)
}
out = append(out, Call{Name: name, Arguments: args})
}
return out, nil
}
// normalizeArgs acepta arguments como objeto o como string que contiene un
// objeto JSON (formato OpenAI serializado).
func normalizeArgs(raw json.RawMessage) (string, error) {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return "{}", nil
}
if strings.HasPrefix(s, `"`) {
var inner string
if err := json.Unmarshal(raw, &inner); err != nil {
return "", fmt.Errorf("invalid arguments string: %w", err)
}
s = strings.TrimSpace(inner)
}
if !strings.HasPrefix(s, "{") || !json.Valid([]byte(s)) {
return "", fmt.Errorf("arguments must be a JSON object")
}
return s, nil
}