270 lines
8.5 KiB
Go
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}
|
|
}
|