Comandos de barra
El REPL que tenemos hasta ahora hace una sola cosa: toma una línea de entrada y la manda al modelo. No hay forma de preguntar "¿qué modelo estoy usando?", limpiar la conversación, cambiar de backend, o siquiera salir limpiamente sin Ctrl-D.
Es hora de una paleta de comandos. Toda línea que empieza con / se intercepta antes de llegar al modelo.
El dispatcher
El patrón es un registry minúsculo — un map de nombre a handler — más una función de ruteo en el REPL:
type command struct {
description string
usage string
run func(args string)
}
var commands = map[string]command{}
func init() {
commands["help"] = command{description: "show available commands", run: cmdHelp}
commands["model"] = command{description: "show or change the model", run: cmdModel}
commands["clear"] = command{description: "clear conversation history", run: cmdClear}
commands["tools"] = command{description: "list available tools", run: cmdTools}
commands["exit"] = command{description: "exit the harness", run: cmdExit}
}
func runCommand(line string) bool {
if !strings.HasPrefix(line, "/") { return false }
parts := strings.SplitN(strings.TrimPrefix(line, "/"), " ", 2)
name := parts[0]
args := ""
if len(parts) > 1 { args = strings.TrimSpace(parts[1]) }
c, ok := commands[name]
if !ok {
fmt.Printf("unknown command: /%s (try /help)\n", name)
return true
}
c.run(args)
return true
}
El REPL entonces se vuelve:
userInput := strings.TrimSpace(scanner.Text())
if userInput == "" { continue }
if runCommand(userInput) { continue } // ← línea nueva
// si no, mandar al modelo
Tres cosas para notar:
runCommanddevuelvebool— si la línea fue manejada o no. La tarea del REPL es decidir qué hacer con esa línea; los comandos y "mandar al modelo" son dos casos.- Los comandos desconocidos imprimen un error pero devuelven
true. De lo contrario, escribir/asdfse mandaría al modelo, lo cual confunde — ¿fue un typo o el modelo vio un mensaje con prefijo de barra? - El estado para los comandos vive a nivel de paquete.
provider,messages,compactor(después) son todos de paquete. Los comandos los mutan directamente. REPL de una sola goroutine significa que no hay locking.
Subiendo el estado a globales
Antes de este capítulo, messages era una variable local en main. Para dejar que los comandos la muten, la subimos (y a provider) al scope de paquete:
var (
provider Provider
messages []Message
)
agentLoop pierde su parámetro messages y ahora muta el global. El REPL pierde su patrón de retornar-y-reasignar.
Esta es una de esas decisiones donde "buen estilo Go" no concuerda con "lo que es fácil de enseñar". Las arquitecturas estrictas pasan el estado a través de structs y métodos. Para un REPL chico con una sola goroutine y sin tests, los globales son más simples y claros. Vamos a revisar esto cuando agreguemos subagentes (capítulo 11) y necesitemos múltiples instancias de Agent.
/model: comandos parametrizados
El comando interesante es /model. Sin args muestra el modelo actual y lista sugerencias. Con un arg, setea el modelo:
var knownModels = []string{
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-haiku-4-5",
}
func cmdModel(args string) {
if args == "" {
fmt.Printf("current: %s\n", provider.Model())
fmt.Println("suggestions:")
for _, m := range knownModels { fmt.Printf(" %s\n", m) }
return
}
provider.SetModel(args)
fmt.Printf("model: %s\n", args)
}
Dos notas de diseño:
- No validamos el id del modelo.
/model claude-footiene éxito; la siguiente llamada a la API falla con un 404. Está bien — el error se propaga por el mismo camino que cualquier otro error de la API. Provider.Model()/Provider.SetModel(name)existen por esto (capítulo 03). Una concesión a las preocupaciones específicas del proveedor, pero todo proveedor de LLM tiene noción de modelo, así que generaliza.
/clear: lo trivial que pueden ser las operaciones de estado
El slice messages es la historia entera de la conversación. El modelo es sin estado. Así que:
func cmdClear(_ string) {
messages = messages[:0]
fmt.Println("conversation cleared")
}
Una línea. El modelo no tiene memoria; limpiar nuestro slice local ES limpiar la conversación. Vamos a explorar por qué funciona esto en el capítulo 06.
/help: descubriendo qué hay disponible
Un error común al hacer BYO es sobre-ingeniar la ayuda. Solo lista los comandos, alfabetizados, con descripciones:
func cmdHelp(_ string) {
names := make([]string, 0, len(commands))
for n := range commands { names = append(names, n) }
sort.Strings(names)
for _, n := range names {
c := commands[n]
display := "/" + n
if c.usage != "" { display = c.usage }
fmt.Printf(" %-22s %s\n", display, c.description)
}
}
El campo usage es para comandos que reciben args. /model se muestra como /model [name]; /clear se muestra solo como /clear. Detalle minúsculo; importa mucho cuando tienes diez comandos.
Tropiezos
Mutar los headers de slice vs el almacenamiento subyacente. messages = messages[:0] mantiene el array subyacente (bueno para reutilizar memoria) y resetea el length. messages = nil también está bien. messages = []Message{} está bien pero asigna. len(messages) = 0 no existe.
SplitN en lugar de Split. Usamos strings.SplitN(s, " ", 2) para que los args con espacios (/model claude-opus-4-7) sobrevivan como un solo string. Un Split plano dividiría en cada espacio y rompería comandos como /some-command arg with spaces.
¿Dónde viven los comandos en términos de paquete? Los dejamos en main.go (bueno, commands.go en el mismo paquete que main) a propósito. Los comandos tocan todos los puntos de extensión — provider, messages, tools, compaction. Ponerlos en su propio paquete requeriría o bien pasarles todo el estado, o hacer todo global y exportado. Mejor mantener la capa de integración arriba.
En el repo actual. Todos los comandos viven en
commands.go. El patrón de registry (unmap[string]command, un dispatcherrunCommand, una funcióncmdXpor comando) no cambia desde este capítulo. Para el capítulo 11 ya agregamos/compact,/verbose,/subagents; cada uno es una entrada nueva eninit()y una funcióncmdXnueva. La forma escala linealmente.
Ahora prueba
- Agrega un comando
/quitcomo alias para/exit. El enfoque ingenuo es duplicar la entrada; uno más limpio es hacer que los aliases sean ciudadanos de primera clase. ¿Cuál se siente bien? - Prueba
/cleardespués de varios turnos. Después pregúntale al modelo "¿qué acabamos de discutir?" Verifica que no tiene memoria. - Esboza un comando
/saveque escriba el slicemessagesactual a un archivo JSON. Esboza un/loadque lo lea de vuelta. Este es un camino a la persistencia de la conversación; el capítulo 13 menciona otros.
Siguiente: 06 · El estado de la conversación.