Files
iasysadmin/internal/agent/cli.go
T
2026-09-27 16:04:02 -06:00

343 lines
11 KiB
Go

package agent
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"runtime/debug"
"strconv"
"strings"
"syscall"
"time"
"sysagent/internal/ctxmgr"
"sysagent/internal/memory"
"sysagent/internal/project"
"sysagent/internal/term"
"sysagent/internal/tools"
)
// maxLineBytes permite pegar bloques grandes (configs, logs) en el CLI.
const maxLineBytes = 1 << 20
// RunCLI ejecuta el modo interactivo (--cli).
//
// Gestión de señales: interrupts recibe SIGINT/SIGTERM desde main.
// - SIGINT durante una petición al modelo cancela solo esa petición.
// - SIGINT en el prompt, o SIGTERM en cualquier momento, termina la sesión.
func (a *Agent) RunCLI(ctx context.Context, interrupts <-chan os.Signal) error {
out := a.opts.Stdout
a.printBanner()
// La lectura de stdin bloquea, así que se hace en una goroutine que
// entrega líneas por canal; así el bucle principal puede atender señales.
lines := make(chan string)
readErr := make(chan error, 1)
go func() {
sc := bufio.NewScanner(a.opts.Stdin)
sc.Buffer(make([]byte, 64*1024), maxLineBytes)
for sc.Scan() {
lines <- sc.Text()
}
readErr <- sc.Err()
close(lines)
}()
// ask_secret lee del mismo canal de líneas (nunca un segundo lector de
// stdin, que competiría por la entrada) con el eco desactivado.
a.toolEnv.Prompt = a.secretPrompter(lines)
defer func() { a.toolEnv.Prompt = nil }()
for {
fmt.Fprint(out, "agent> ")
var line string
select {
case <-ctx.Done():
fmt.Fprintln(out)
return nil
case sig := <-interrupts:
fmt.Fprintf(out, "\n(%s) bye\n", sig)
return nil
case l, ok := <-lines:
if !ok {
fmt.Fprintln(out)
if err := <-readErr; err != nil {
return fmt.Errorf("failed to read input: %w", err)
}
return nil // EOF (Ctrl+D)
}
line = strings.TrimSpace(l)
}
if line == "" {
continue
}
a.appendHistory(line)
if strings.HasPrefix(line, "/") {
if quit := a.command(ctx, line); quit {
return nil
}
continue
}
a.log.Action("task", "CLI", "action", "user_input", "chars", fmt.Sprint(len(line)))
res, err := a.askInterruptible(ctx, line, interrupts)
switch {
case errors.Is(err, errTerminate):
fmt.Fprintln(out, "\n(terminated)")
return nil
case errors.Is(err, context.Canceled):
fmt.Fprintln(out, "\n(request cancelled)")
case err != nil:
fmt.Fprintf(out, "ERROR: %v\n", err)
default:
if res.Final != "" {
fmt.Fprintln(out, res.Final)
}
if res.Reason != "" {
fmt.Fprintf(out, "(stopped: %s)\n", res.Reason)
}
}
}
}
var errTerminate = errors.New("terminate requested")
// askInterruptible ejecuta el ciclo del agente en una goroutine y permite
// cancelarlo con Ctrl+C sin salir del CLI: la cancelación llega a la
// petición HTTP en curso y a los procesos hijos (se mata su grupo).
func (a *Agent) askInterruptible(ctx context.Context, text string, interrupts <-chan os.Signal) (turnResult, error) {
reqCtx, cancel := context.WithCancel(ctx)
defer cancel()
type result struct {
res turnResult
err error
}
done := make(chan result, 1)
go func() {
// Un pánico aquí no pasaría por los defer de main: se convierte en
// error para que la sesión continúe y contexto.mem se guarde.
defer func() {
if p := recover(); p != nil {
a.log.Errorf("internal error: %v\n%s", p, debug.Stack())
done <- result{err: fmt.Errorf("internal error: %v (details in agent.log)", p)}
}
}()
r, err := a.converse(reqCtx, "CLI", text, false)
done <- result{r, err}
}()
select {
case r := <-done:
return r.res, r.err
case sig := <-interrupts:
cancel()
<-done
if sig == syscall.SIGTERM {
return turnResult{}, errTerminate
}
return turnResult{}, context.Canceled
}
}
func (a *Agent) printBanner() {
out := a.opts.Stdout
fmt.Fprintln(out, "System Agent")
fmt.Fprintf(out, "Model: %s\n", a.cfg.Model)
fmt.Fprintf(out, "Mode: %s\n", a.policy.Mode)
fmt.Fprintf(out, "Project: %s\n", a.project.Root)
fmt.Fprintf(out, "Tools: %s (%d available)\n", a.toolMode, len(a.tools.Names()))
if a.opts.DryRun {
fmt.Fprintln(out, "Dry-run: true")
}
fmt.Fprintln(out, "Type /help for commands, /quit to exit.")
fmt.Fprintln(out)
}
// command ejecuta un comando interno. Devuelve true si hay que salir.
func (a *Agent) command(ctx context.Context, line string) bool {
out := a.opts.Stdout
fields := strings.Fields(line)
cmd := fields[0]
switch cmd {
case "/help":
fmt.Fprint(out, `Commands:
/help show this help
/status model, mode, context usage and project
/context context details (recent and historical); "/context summary" prints the summary
/memory show memoria.md
/policy show the effective execution policy
/history show this session's conversation
/journal [n] show the last n operational events of contexto.mem (default 20)
/compact summarize the older conversation into the historical context now
/secrets list the names of secrets entered this session (values are never shown)
/clear clear the recent conversation (summary, memory and journal are kept)
/quit exit (also /exit, Ctrl+D)
`)
case "/status":
fmt.Fprintf(out, "Model: %s\n", a.cfg.Model)
fmt.Fprintf(out, "API: %s (key %s)\n", a.cfg.APIURL, a.cfg.MaskedKey())
fmt.Fprintf(out, "Execution mode: %s\n", a.policy.Mode)
fmt.Fprintf(out, "Tool calling: %s (TOOL_CALLING=%s)\n", a.toolMode, a.cfg.ToolCalling)
fmt.Fprintf(out, "Dry-run: %t\n", a.opts.DryRun)
fmt.Fprintf(out, "Context usage: %s\n", a.usageString())
fmt.Fprintf(out, "Project root: %s\n", a.project.Root)
fmt.Fprintf(out, "Agent file: %s\n", a.agentFile)
fmt.Fprintf(out, "Running as root: %t\n", a.facts.IsRoot())
fmt.Fprintln(out, "Tasks: CLI")
case "/context":
fmt.Fprintf(out, "Context usage: %s\n", a.usageString())
fmt.Fprintf(out, "Recent messages: %d\n", a.ctx.Len())
if strings.TrimSpace(a.state.Summary) == "" {
fmt.Fprintln(out, "Historical summary: empty")
} else {
fmt.Fprintf(out, "Historical summary: %d bytes (~%d tokens), %d compaction(s)\n", len(a.state.Summary), ctxmgr.EstimateTokens(a.state.Summary), a.state.Compactions)
}
if a.prevSession != "" {
fmt.Fprintf(out, "Previous session messages loaded: %d bytes (folded in at the next compaction)\n", len(a.prevSession))
}
fmt.Fprintf(out, "Journal: %d events (%d not yet summarized)\n", len(a.state.Journal), len(a.state.Unsummarized()))
fmt.Fprintf(out, "Compaction: automatic at %d%% of the context; /compact to force\n", compactAtPct)
if len(fields) > 1 && fields[1] == "summary" {
fmt.Fprintf(out, "--- summary ---\n%s\n", a.state.Summary)
} else if strings.TrimSpace(a.state.Summary) != "" {
fmt.Fprintln(out, "(use /context summary to print it)")
}
case "/journal":
n := 20
if len(fields) > 1 {
if v, err := strconv.Atoi(fields[1]); err == nil && v > 0 {
n = v
}
}
j := a.state.Journal
if len(j) > n {
j = j[len(j)-n:]
}
if len(j) == 0 {
fmt.Fprintln(out, "(empty)")
}
fmt.Fprint(out, memory.FormatJournal(j))
case "/compact":
switch err := a.compact(ctx, "CLI"); {
case errors.Is(err, errNothingToCompact):
fmt.Fprintln(out, "Nothing to compact yet.")
case err != nil:
fmt.Fprintf(out, "Compaction failed: %v\n", err)
default:
fmt.Fprintf(out, "Context compacted. Usage now: %s\n", a.usageString())
}
case "/memory":
if content, err := a.mem.Read(); err == nil {
a.memoryMD = content
}
fmt.Fprintf(out, "--- %s ---\n%s\n", a.mem.Path(), strings.TrimSpace(a.log.Redact(a.memoryMD)))
case "/policy":
fmt.Fprint(out, a.policy.Summary())
case "/history":
h := a.ctx.History()
if len(h) == 0 {
fmt.Fprintln(out, "(empty)")
}
for i, m := range h {
content := m.Content
for _, tc := range m.ToolCalls {
content += fmt.Sprintf(" [tool_call %s %s]", tc.Function.Name, truncate(tc.Function.Arguments, 80))
}
fmt.Fprintf(out, "[%d] %s: %s\n", i+1, m.Role, truncate(content, 200))
}
case "/clear":
a.ctx.Clear()
fmt.Fprintln(out, "Recent conversation cleared.")
case "/secrets":
names := a.secrets.Names()
if len(names) == 0 {
fmt.Fprintln(out, "(none; the agent asks for them with ask_secret when needed)")
}
for _, n := range names {
fmt.Fprintf(out, "%s -> use as {{secret:%s}}\n", n, n)
}
case "/quit", "/exit":
return true
default:
fmt.Fprintf(out, "Unknown command %s (try /help)\n", cmd)
}
return false
}
// secretPrompter devuelve la función que pide un secreto al operador.
func (a *Agent) secretPrompter(lines <-chan string) tools.SecretPrompter {
return func(ctx context.Context, prompt string) (string, error) {
restore := func() {}
if f, ok := a.opts.Stdin.(*os.File); ok {
r, err := term.DisableEcho(f)
switch {
case err == nil:
restore = r
case errors.Is(err, term.ErrNotTerminal):
// Entrada por tubería: no hay eco que desactivar.
default:
// Terminal en el que no se puede ocultar lo que se escribe:
// mejor no pedir la contraseña.
return "", fmt.Errorf("cannot disable terminal echo: %w", err)
}
}
defer restore()
fmt.Fprint(a.opts.Stdout, prompt)
select {
case l, ok := <-lines:
fmt.Fprintln(a.opts.Stdout)
if !ok {
return "", errors.New("input closed")
}
return strings.TrimRight(l, "\r"), nil
case <-ctx.Done():
fmt.Fprintln(a.opts.Stdout)
return "", errors.New("secret input cancelled")
}
}
}
// appendHistory guarda la entrada del operador en .context-history (§79),
// con permisos 0600 y omitiendo líneas que parecen contener credenciales.
// Las contraseñas pedidas con ask_secret nunca pasan por aquí.
func (a *Agent) appendHistory(line string) {
if memory.LooksSecret(line) {
return
}
f, err := os.OpenFile(a.project.Path(project.HistoryFile), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
if err != nil {
a.log.Debugf("cannot write history: %v", err)
return
}
defer f.Close()
fmt.Fprintf(f, "%s\t%s\n", time.Now().Format(time.RFC3339), strings.ReplaceAll(a.log.Redact(line), "\n", " "))
}
// usageString formatea el uso de contexto. Siempre es una estimación
// (§84); "calibrated" indica que está ajustada con los prompt_tokens reales
// que reportó el servidor.
func (a *Agent) usageString() string {
tok, calibrated := a.ctx.Usage()
kind := "estimated"
if calibrated {
kind = fmt.Sprintf("estimated, calibrated with server usage; last request: %d prompt tokens reported", a.ctx.LastReported())
}
limit := a.ctx.ContextSize()
if limit == 0 {
return fmt.Sprintf("~%d / UNKNOWN (%s)", tok, kind)
}
return fmt.Sprintf("~%d / %d (%d%%, %s; limit from %s)", tok, limit, tok*100/limit, kind, a.ctxSource)
}
func truncate(s string, n int) string {
s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ")
if len([]rune(s)) <= n {
return s
}
return string([]rune(s)[:n]) + "..."
}