post inicial

This commit is contained in:
lastdragon
2026-09-27 16:04:02 -06:00
parent d84587e1f3
commit 52864794f5
67 changed files with 15023 additions and 2 deletions
+358
View File
@@ -0,0 +1,358 @@
// 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
}