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

270 lines
8.5 KiB
Go

// 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}
}