// Package tools implementa las herramientas que el modelo puede invocar: // ejecución de procesos, shell, sistema de archivos e información del sistema. // // Toda herramienta sigue el mismo camino (Registry.Execute): // // 1. validar estrictamente los argumentos JSON (campos desconocidos = error); // 2. consultar al policy.Guard; // 3. en --dry-run, describir la acción en lugar de ejecutarla; // 4. ejecutar, auditar en agent.log y redactar secretos de la salida. // // Nunca se ejecuta texto del modelo que no haya pasado por este camino. package tools import ( "bytes" "context" "encoding/json" "fmt" "net/http" "runtime/debug" "sort" "strings" "time" "sysagent/internal/audit" "sysagent/internal/llm" "sysagent/internal/memory" "sysagent/internal/policy" ) // Env es el entorno compartido por todas las herramientas. type Env struct { Guard *policy.Guard Policy *policy.Policy Log *audit.Logger Root string // PROJECT_ROOT DryRun bool // Memory es el almacén de memoria.md (herramientas remember/forget). Memory *memory.Store // HTTP es el cliente compartido de las herramientas de red (§67). HTTP *http.Client UserAgent string // SearchURL es la plantilla del buscador ({query}); vacío = DuckDuckGo. SearchURL string // Secrets guarda credenciales de la sesión; Prompt las pide al operador // (nil en modo tareas). Secrets *SecretStore Prompt SecretPrompter // NVDBase y DebianBase permiten apuntar cve_lookup a espejos (y a // servidores de prueba); vacío = servicios oficiales. NVDBase, DebianBase string // Now permite fijar la hora en tests (nombres de backup). Now func() time.Time } func (e *Env) now() time.Time { if e.Now != nil { return e.Now() } return time.Now() } // Result es el resultado de una herramienta. type Result struct { // Output es el texto que recibe el modelo. Output string // OK indica éxito (para comandos: exit code 0). OK bool // Denied indica que la política impidió la acción. Denied bool // Summary es una línea corta para consola y auditoría. Summary string // FilesChanged lista archivos creados/modificados/eliminados. FilesChanged []string // Backups lista copias de seguridad creadas. Backups []string // ExitCode de comandos (-1 si no aplica o no arrancó). ExitCode int // Audit son pares clave/valor adicionales para agent.log. Audit []string // Diff es el cambio exacto aplicado a un archivo existente (§88). Diff string } // Tool es una herramienta invocable por el modelo. type Tool interface { Name() string Description() string // Schema devuelve el JSON Schema de los argumentos. Schema() map[string]any // Run ejecuta la herramienta con argumentos ya decodificados por decode. Run(ctx context.Context, env *Env, raw json.RawMessage) Result } // Registry agrupa las herramientas disponibles. type Registry struct { env *Env tools map[string]Tool } // NewRegistry crea el registro con todas las herramientas estándar. func NewRegistry(env *Env) *Registry { r := &Registry{env: env, tools: map[string]Tool{}} for _, t := range []Tool{ execTool{}, shellTool{}, readFileTool{}, writeFileTool{}, editFileTool{}, appendFileTool{}, copyFileTool{}, moveFileTool{}, removeFileTool{}, mkdirTool{}, listDirTool{}, statTool{}, chmodTool{}, chownTool{}, systemInfoTool{}, rememberTool{}, forgetTool{}, webSearchTool{}, webFetchTool{}, cveLookupTool{}, downloadTool{}, applyPatchTool{}, diffFilesTool{}, registerToolTool{}, findProgramTool{}, askSecretTool{}, } { r.tools[t.Name()] = t } return r } // Names devuelve los nombres ordenados. func (r *Registry) Names() []string { names := make([]string, 0, len(r.tools)) for n := range r.tools { names = append(names, n) } sort.Strings(names) return names } // Specs devuelve las definiciones para tool calling nativo. func (r *Registry) Specs() []llm.Tool { var out []llm.Tool for _, n := range r.Names() { t := r.tools[n] out = append(out, llm.Tool{Type: "function", Function: llm.ToolFunction{ Name: t.Name(), Description: t.Description(), Parameters: t.Schema(), }}) } return out } // Describe genera la documentación de herramientas para el protocolo JSON // (cuando el servidor no soporta tool calling nativo). func (r *Registry) Describe() string { var b strings.Builder for _, n := range r.Names() { t := r.tools[n] schema, _ := json.Marshal(t.Schema()) fmt.Fprintf(&b, "- %s: %s\n arguments schema: %s\n", t.Name(), t.Description(), schema) } return b.String() } // Execute valida y ejecuta una llamada. label identifica la tarea para auditoría. func (r *Registry) Execute(ctx context.Context, label, name, args string) Result { t, ok := r.tools[name] if !ok { return Result{Output: fmt.Sprintf("error: unknown tool %q; available tools: %s", name, strings.Join(r.Names(), ", ")), Summary: "unknown tool " + name, ExitCode: -1} } args = strings.TrimSpace(args) if args == "" { args = "{}" } if !json.Valid([]byte(args)) || !strings.HasPrefix(args, "{") { return Result{Output: fmt.Sprintf("error: arguments for %s must be a JSON object, got: %s", name, clip(args, 200)), Summary: "invalid arguments", ExitCode: -1} } if len(args) > maxArgsBytes { return Result{Output: fmt.Sprintf("error: arguments for %s are too large (%d bytes, max %d)", name, len(args), maxArgsBytes), Summary: "arguments too large", ExitCode: -1} } start := time.Now() res := r.run(ctx, t, args) elapsed := time.Since(start).Round(time.Millisecond) r.env.Log.Debugf("tool %s args=%s -> ok=%t denied=%t exit=%d (%s)", name, clip(args, 500), res.OK, res.Denied, res.ExitCode, elapsed) res.Output = r.env.Log.Redact(res.Output) res.Summary = r.env.Log.Redact(res.Summary) result := "ok" switch { case res.Denied: result = "denied" case !res.OK: result = "error" } fields := []string{"task", label, "action", "tool:" + name, "detail", clip(res.Summary, 500), "result", result, "exit_code", fmt.Sprint(res.ExitCode), "elapsed", elapsed.String()} if len(res.FilesChanged) > 0 { fields = append(fields, "files_changed", strings.Join(res.FilesChanged, ",")) } if len(res.Backups) > 0 { fields = append(fields, "backups", strings.Join(res.Backups, ",")) } if r.env.DryRun { fields = append(fields, "dry_run", "true") } fields = append(fields, res.Audit...) r.env.Log.Action(fields...) return res } // maxArgsBytes limita el tamaño de los argumentos de una llamada. const maxArgsBytes = 40 << 20 // run ejecuta la herramienta recuperando pánicos: un fallo de programación // en una herramienta se informa como error al modelo en lugar de tumbar al // agente a mitad de una tarea (sin guardar contexto.mem). func (r *Registry) run(ctx context.Context, t Tool, args string) (res Result) { defer func() { if p := recover(); p != nil { r.env.Log.Errorf("internal error in tool %s: %v\n%s", t.Name(), p, debug.Stack()) res = Result{Output: fmt.Sprintf("internal error in tool %s: %v (reported in agent.log)", t.Name(), p), Summary: fmt.Sprintf("internal error: %v", p), ExitCode: -1} } }() return t.Run(ctx, r.env, json.RawMessage(args)) } // decode decodifica estrictamente los argumentos en v. func decode(raw json.RawMessage, v any) error { dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { return fmt.Errorf("invalid arguments: %w", err) } return nil } // fail construye un Result de error. func fail(summary string, err error) Result { res := Result{Output: "error: " + err.Error(), Summary: summary + ": " + err.Error(), ExitCode: -1} if policy.IsDenied(err) { res.Denied = true } return res } // dryRun construye el Result de una acción simulada. func dryRun(what string) Result { return Result{OK: true, Output: "DRY-RUN: not executed. Would " + what, Summary: "dry-run: " + what, ExitCode: -1} } func clip(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." } // Helpers para esquemas JSON compactos. func obj(required []string, props map[string]any) map[string]any { s := map[string]any{"type": "object", "properties": props, "additionalProperties": false} if len(required) > 0 { s["required"] = required } return s } func str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } func num(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} } func boolean(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} } func strList(desc string) map[string]any { return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc} }