359 lines
10 KiB
Go
359 lines
10 KiB
Go
// Package agent coordina el arranque, la construcción del contexto y los
|
|
// dos modos de ejecución (tareas y --cli).
|
|
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"sysagent/internal/audit"
|
|
"sysagent/internal/config"
|
|
"sysagent/internal/ctxmgr"
|
|
"sysagent/internal/llm"
|
|
"sysagent/internal/memory"
|
|
"sysagent/internal/policy"
|
|
"sysagent/internal/project"
|
|
"sysagent/internal/tools"
|
|
)
|
|
|
|
// Options son los parámetros de arranque que llegan desde la línea de comandos.
|
|
type Options struct {
|
|
Root string // PROJECT_ROOT (normalmente os.Getwd())
|
|
CLI bool
|
|
DryRun bool
|
|
Verbose bool
|
|
// Rerun fuerza a repetir tareas que contexto.mem registra como DONE.
|
|
Rerun bool
|
|
Version string
|
|
Stdin io.Reader
|
|
Stdout io.Writer
|
|
Stderr io.Writer
|
|
}
|
|
|
|
// Agent contiene todo el estado cargado durante el arranque.
|
|
type Agent struct {
|
|
opts Options
|
|
project *project.Project
|
|
cfg *config.Config
|
|
policy *policy.Policy
|
|
log *audit.Logger
|
|
llm llm.Client
|
|
ctx *ctxmgr.Manager
|
|
guard *policy.Guard
|
|
tools *tools.Registry
|
|
toolEnv *tools.Env
|
|
secrets *tools.SecretStore
|
|
// unlock libera el bloqueo de instancia única de PROJECT_ROOT.
|
|
unlock func()
|
|
|
|
// toolMode es el protocolo de herramientas en uso: "native" (campo tools
|
|
// de la API) o "json" (bloques ```tool en el texto). Con TOOL_CALLING=auto
|
|
// se empieza en native y se cambia a json si el servidor lo rechaza.
|
|
toolMode string
|
|
|
|
agentFile string // nombre real encontrado (agent.md o una variante)
|
|
agentMD string
|
|
memoryMD string
|
|
mem *memory.Store
|
|
|
|
// state es el contenido de contexto.mem: resumen histórico, diario
|
|
// operativo y estado de tareas. Se guarda tras cada turno.
|
|
state *memory.State
|
|
// prevSession es el contexto reciente de sesiones anteriores, en texto.
|
|
// Se muestra al modelo hasta que la primera compactación lo incorpora.
|
|
prevSession string
|
|
// prevRecent son esos mismos mensajes; se vuelven a guardar al salir
|
|
// junto con los de esta sesión para no perderlos antes de resumirlos.
|
|
prevRecent []llm.Message
|
|
// compactPending pide compactar antes de la primera petición porque el
|
|
// contexto de sesiones anteriores no cabía completo en el prompt.
|
|
compactPending bool
|
|
// currentTask es la tarea o mensaje en curso; tras una compactación se
|
|
// repite al modelo para que no la pierda (§20: CURRENT TASK).
|
|
currentTask string
|
|
|
|
tasks []project.Task
|
|
facts SystemFacts
|
|
ctxSource string // de dónde proviene el tamaño de contexto
|
|
taskStatus map[string]TaskStatus
|
|
}
|
|
|
|
// TaskStatus es el estado de una tarea.
|
|
type TaskStatus string
|
|
|
|
const (
|
|
StatusPending TaskStatus = "PENDING"
|
|
StatusInProgress TaskStatus = "IN_PROGRESS"
|
|
StatusBlocked TaskStatus = "BLOCKED"
|
|
StatusDone TaskStatus = "DONE"
|
|
StatusFailed TaskStatus = "FAILED"
|
|
)
|
|
|
|
// Bootstrap inicializa el agente respetando estrictamente el orden de §9:
|
|
//
|
|
// 1. PROJECT_ROOT 2. init.conf 3. agent.md 4. política 5. memoria.md
|
|
// 6. contexto.mem 7. modo 8. tareas 9. contexto inicial 10. modelo
|
|
//
|
|
// agent.md se carga siempre antes que cualquier otro Markdown.
|
|
// Cualquier error aquí impide arrancar: un agente mal configurado no debe
|
|
// actuar sobre el sistema.
|
|
func Bootstrap(ctx context.Context, opts Options) (*Agent, error) {
|
|
a := &Agent{opts: opts, taskStatus: map[string]TaskStatus{}}
|
|
|
|
// 1. PROJECT_ROOT
|
|
p, err := project.Open(opts.Root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a.project = p
|
|
|
|
// El logger se abre en cuanto se conoce PROJECT_ROOT para poder auditar
|
|
// el resto del arranque.
|
|
log, err := audit.New(opts.Stderr, p.Path(project.AuditFile), opts.Verbose)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a.log = log
|
|
log.Infof("starting sysagent %s in %s", opts.Version, p.Root)
|
|
|
|
// 2. init.conf
|
|
cfg, err := config.Load(p.Path(config.FileName))
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, a.fail(fmt.Errorf("init.conf not found in PROJECT_ROOT (%s)", p.Root))
|
|
}
|
|
return nil, a.fail(err)
|
|
}
|
|
a.cfg = cfg
|
|
log.AddSecret(cfg.APIKey, cfg.MaskedKey())
|
|
for _, k := range cfg.Unknown {
|
|
log.Warnf("init.conf: unknown key %s ignored", k)
|
|
}
|
|
log.Infof("init.conf: API_URL=%s MODEL=%s CONTEXT_SIZE=%d API key: %s", cfg.APIURL, cfg.Model, cfg.ContextSize, cfg.MaskedKey())
|
|
|
|
// 3. agent.md
|
|
name, variant, err := p.FindAgentFile()
|
|
if err != nil {
|
|
return nil, a.fail(err)
|
|
}
|
|
if variant {
|
|
log.Warnf("using %s as agent.md (name differs only in case)", name)
|
|
}
|
|
agentMD, err := p.ReadInstructionFile(name)
|
|
if err != nil {
|
|
return nil, a.fail(err)
|
|
}
|
|
a.agentFile, a.agentMD = name, agentMD
|
|
|
|
// 4. Política
|
|
pol, err := policy.Parse(agentMD)
|
|
if err != nil {
|
|
return nil, a.fail(err)
|
|
}
|
|
a.policy = pol
|
|
for _, d := range pol.Unknown {
|
|
log.Warnf("agent.md: unknown policy directive %s ignored", d)
|
|
}
|
|
if !pol.Declared {
|
|
log.Warnf("agent.md has no ```policy block: using defaults (guarded, all capabilities denied)")
|
|
}
|
|
log.Infof("policy: mode=%s declared=%t", pol.Mode, pol.Declared)
|
|
|
|
// 5. memoria.md (se crea con 0600 si no existe)
|
|
created, tooOpen, err := p.EnsureMemoryFile()
|
|
if err != nil {
|
|
return nil, a.fail(err)
|
|
}
|
|
if created {
|
|
log.Infof("created %s with mode 0600", project.MemoryFile)
|
|
}
|
|
if tooOpen {
|
|
log.Warnf("%s is readable by group/others; consider chmod 600", project.MemoryFile)
|
|
}
|
|
a.mem = memory.NewStore(p.Path(project.MemoryFile))
|
|
if a.memoryMD, err = a.mem.Read(); err != nil {
|
|
return nil, a.fail(err)
|
|
}
|
|
|
|
// 6. contexto.mem
|
|
st, warning, err := memory.LoadState(p.Path(project.ContextFile))
|
|
if err != nil {
|
|
return nil, a.fail(err)
|
|
}
|
|
if warning != "" {
|
|
log.Warnf("%s", warning)
|
|
}
|
|
a.state = st
|
|
a.prevRecent = st.Recent
|
|
if len(st.Recent) > 0 {
|
|
a.prevSession, a.compactPending = renderPrevious(st.Recent, cfg.ContextSize)
|
|
}
|
|
log.Infof("contexto.mem: summary=%d bytes compactions=%d journal=%d recent=%d", len(st.Summary), st.Compactions, len(st.Journal), len(st.Recent))
|
|
|
|
if err := p.EnsureWorkDirs(); err != nil {
|
|
return nil, a.fail(err)
|
|
}
|
|
unlock, err := p.Lock()
|
|
if err != nil {
|
|
return nil, a.fail(err)
|
|
}
|
|
a.unlock = unlock
|
|
|
|
// 7-8. Modo y tareas. En --cli se ignoran todos los *.md de tareas.
|
|
if !opts.CLI {
|
|
tasks, skipped, missing, err := p.DiscoverTasks(name, pol.TaskOrder)
|
|
if err != nil {
|
|
return nil, a.fail(err)
|
|
}
|
|
for _, s := range skipped {
|
|
log.Warnf("task %s skipped: not a regular file directly inside PROJECT_ROOT (external symlink?) or too large", s)
|
|
}
|
|
for _, m := range missing {
|
|
log.Warnf("TASK_ORDER lists %s but it does not exist", m)
|
|
}
|
|
a.tasks = tasks
|
|
for _, t := range tasks {
|
|
a.taskStatus[t.Name] = StatusPending
|
|
}
|
|
if opts.Rerun {
|
|
log.Infof("--rerun: tasks recorded as DONE will be executed again")
|
|
}
|
|
log.Infof("tasks discovered: %d", len(tasks))
|
|
}
|
|
|
|
// Revisión de seguridad de lo que da instrucciones o credenciales.
|
|
for _, f := range p.CheckSecurity(name, a.tasks, cfg.APIKey != "") {
|
|
if f.Fatal {
|
|
return nil, a.fail(fmt.Errorf("insecure project: %s", f.Message))
|
|
}
|
|
log.Warnf("security: %s", f.Message)
|
|
}
|
|
|
|
// Herramientas: toda acción pasa por el Guard de la política.
|
|
guard, err := policy.NewGuard(pol, p.Root, name)
|
|
if err != nil {
|
|
return nil, a.fail(err)
|
|
}
|
|
a.guard = guard
|
|
a.secrets = tools.NewSecretStore()
|
|
a.toolEnv = &tools.Env{
|
|
Guard: guard, Policy: pol, Log: log, Root: p.Root, DryRun: opts.DryRun, Memory: a.mem,
|
|
HTTP: tools.NewHTTPClient(), UserAgent: "sysagent/" + opts.Version, SearchURL: cfg.SearchURL,
|
|
Secrets: a.secrets,
|
|
}
|
|
a.tools = tools.NewRegistry(a.toolEnv)
|
|
a.toolMode = "native"
|
|
if cfg.ToolCalling == "json" {
|
|
a.toolMode = "json"
|
|
}
|
|
|
|
// 9. Contexto inicial
|
|
a.facts = CollectFacts()
|
|
a.ctx = ctxmgr.New(a.buildSystemPrompt(), cfg.ContextSize)
|
|
a.updateOverhead()
|
|
if cfg.ContextSize > 0 {
|
|
a.ctxSource = "init.conf CONTEXT_SIZE"
|
|
}
|
|
|
|
// 10. Modelo
|
|
client := llm.NewOpenAIClient(llm.Options{
|
|
BaseURL: cfg.APIURL,
|
|
APIKey: cfg.APIKey,
|
|
Model: cfg.Model,
|
|
UserAgent: "sysagent/" + opts.Version,
|
|
Timeout: cfg.RequestTimeout,
|
|
})
|
|
a.llm = client
|
|
if cfg.ContextSize == 0 {
|
|
dctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
info, err := client.DiscoverModel(dctx)
|
|
cancel()
|
|
if err != nil {
|
|
log.Warnf("could not query model server: %v", err)
|
|
}
|
|
if info.ContextSize > 0 {
|
|
a.ctx.SetContextSize(info.ContextSize)
|
|
a.ctxSource = info.Source
|
|
log.Infof("context size %d obtained from %s", info.ContextSize, info.Source)
|
|
if len(st.Recent) > 0 {
|
|
// Con el límite ya conocido, ajustar el tamaño del contexto previo.
|
|
a.prevSession, a.compactPending = renderPrevious(st.Recent, info.ContextSize)
|
|
a.ctx.SetSystem(a.buildSystemPrompt())
|
|
}
|
|
} else {
|
|
log.Infof("context size UNKNOWN: server default will be used")
|
|
}
|
|
}
|
|
mode := "tasks"
|
|
if opts.CLI {
|
|
mode = "cli"
|
|
}
|
|
a.journal(memory.JournalEntry{Kind: "session", Text: fmt.Sprintf("session start: mode=%s policy=%s dry_run=%t model=%s", mode, pol.Mode, opts.DryRun, cfg.Model)})
|
|
return a, nil
|
|
}
|
|
|
|
// fail registra un error fatal de arranque en agent.log y lo devuelve.
|
|
// No se imprime en consola: main muestra el error una sola vez.
|
|
func (a *Agent) fail(err error) error {
|
|
if a.unlock != nil {
|
|
a.unlock()
|
|
a.unlock = nil
|
|
}
|
|
a.log.Action("task", "startup", "action", "bootstrap", "result", "error", "error", err.Error())
|
|
return err
|
|
}
|
|
|
|
// Close guarda contexto.mem y libera recursos. Debe llamarse siempre antes
|
|
// de terminar (también tras SIGINT/SIGTERM, §57).
|
|
func (a *Agent) Close() {
|
|
if a.state != nil && a.ctx != nil {
|
|
a.journal(memory.JournalEntry{Kind: "session", Text: "session end"})
|
|
a.saveState()
|
|
}
|
|
if a.unlock != nil {
|
|
a.unlock()
|
|
a.unlock = nil
|
|
}
|
|
if a.log != nil {
|
|
a.log.Infof("sysagent stopped")
|
|
a.log.Close()
|
|
}
|
|
}
|
|
|
|
// SystemFacts son datos observados del sistema local (no supuestos).
|
|
type SystemFacts struct {
|
|
Hostname string
|
|
OS string
|
|
UID int
|
|
EUID int
|
|
}
|
|
|
|
// IsRoot indica si el proceso tiene privilegios efectivos de root.
|
|
func (f SystemFacts) IsRoot() bool { return f.EUID == 0 }
|
|
|
|
// CollectFacts lee datos básicos del sistema. Los que no se pueden obtener
|
|
// quedan como UNKNOWN en lugar de suponerse.
|
|
func CollectFacts() SystemFacts {
|
|
f := SystemFacts{Hostname: "UNKNOWN", OS: "UNKNOWN", UID: os.Getuid(), EUID: os.Geteuid()}
|
|
if h, err := os.Hostname(); err == nil {
|
|
f.Hostname = h
|
|
}
|
|
if file, err := os.Open("/etc/os-release"); err == nil {
|
|
defer file.Close()
|
|
sc := bufio.NewScanner(file)
|
|
for sc.Scan() {
|
|
if v, ok := strings.CutPrefix(sc.Text(), "PRETTY_NAME="); ok {
|
|
f.OS = strings.Trim(v, `"`)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return f
|
|
}
|