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