// 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 ... (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)\s*(.*?)\s*`) ) // 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 }