post inicial
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user