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]) + "..." }