post inicial
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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]) + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user