post inicial
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package toolcall
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseFormats(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
in string
|
||||
names []string
|
||||
args string
|
||||
}{
|
||||
"tool fence": {
|
||||
in: "Voy a revisar.\n```tool\n{\"tool\": \"exec\", \"arguments\": {\"command\": \"uname\", \"args\": [\"-a\"]}}\n```",
|
||||
names: []string{"exec"}, args: `{"command": "uname", "args": ["-a"]}`,
|
||||
},
|
||||
"xml qwen": {
|
||||
in: "<tool_call>\n{\"name\": \"read_file\", \"arguments\": {\"path\": \"/etc/hosts\"}}\n</tool_call>",
|
||||
names: []string{"read_file"}, args: `{"path": "/etc/hosts"}`,
|
||||
},
|
||||
"bare json": {
|
||||
in: `{"tool":"system_info","arguments":{}}`,
|
||||
names: []string{"system_info"}, args: `{}`,
|
||||
},
|
||||
"string arguments": {
|
||||
in: "```tool\n{\"name\":\"exec\",\"arguments\":\"{\\\"command\\\":\\\"id\\\"}\"}\n```",
|
||||
names: []string{"exec"}, args: `{"command":"id"}`,
|
||||
},
|
||||
"list": {
|
||||
in: "```tool\n[{\"tool\":\"stat\",\"arguments\":{\"path\":\"a\"}},{\"tool\":\"stat\",\"arguments\":{\"path\":\"b\"}}]\n```",
|
||||
names: []string{"stat", "stat"},
|
||||
},
|
||||
"null arguments": {
|
||||
in: "```tool\n{\"tool\":\"system_info\",\"arguments\":null}\n```",
|
||||
names: []string{"system_info"}, args: `{}`,
|
||||
},
|
||||
}
|
||||
for name, c := range cases {
|
||||
calls, found, err := Parse(c.in)
|
||||
if err != nil || !found {
|
||||
t.Errorf("%s: found=%v err=%v", name, found, err)
|
||||
continue
|
||||
}
|
||||
if len(calls) != len(c.names) {
|
||||
t.Errorf("%s: %d calls", name, len(calls))
|
||||
continue
|
||||
}
|
||||
for i, n := range c.names {
|
||||
if calls[i].Name != n {
|
||||
t.Errorf("%s: call %d name %q", name, i, calls[i].Name)
|
||||
}
|
||||
if calls[i].ID == "" {
|
||||
t.Errorf("%s: missing ID", name)
|
||||
}
|
||||
}
|
||||
if c.args != "" && calls[0].Arguments != c.args {
|
||||
t.Errorf("%s: args %q", name, calls[0].Arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNoCall(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"El servicio está activo. TASK_STATUS: DONE",
|
||||
"Ejemplo de config:\n```json\n{\"listen\": 80}\n```",
|
||||
"```bash\nsystemctl status nginx\n```",
|
||||
"",
|
||||
} {
|
||||
calls, found, err := Parse(in)
|
||||
if found || err != nil || len(calls) != 0 {
|
||||
t.Errorf("%q: found=%v err=%v calls=%v", in, found, err, calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMalformed(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"```tool\n{\"tool\": \"exec\", \"arguments\": {\"command\": \n```",
|
||||
"```tool\n{\"arguments\": {}}\n```",
|
||||
"```tool\n{\"tool\":\"exec\",\"arguments\":[1,2]}\n```",
|
||||
"<tool_call>not json</tool_call>",
|
||||
} {
|
||||
_, found, err := Parse(in)
|
||||
if !found || err == nil {
|
||||
t.Errorf("%q: expected malformed error, found=%v err=%v", in, found, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user