# Plan: Claude Code (`claude -p`) como proveedor LLM de la shell ## Objetivo Que `claude -p` sea un backend LLM más dentro de `shell/llm/`, al mismo nivel que la API HTTP de Anthropic u otros proveedores. Los agentes no saben si su "modelo" es una llamada REST o un subproceso de Claude Code — simplemente envían un `CompletionRequest` y reciben un `CompletionResult`. ## Estado: Completado --- ## Casos de uso - Configurar un agente con `model: claude-code` y que todas sus respuestas pasen por `claude -p` - Un agente usa Claude Code como modelo principal, obteniendo capacidades agenticas (bash, file I/O, git) gratis sin implementarlas en nuestra shell - Agentes que necesitan razonar sobre un repo completo delegan al modelo `claude-code` que ya tiene contexto del worktree - Migrar agentes entre proveedores cambiando solo el campo `model` en YAML - Combinar modelos: un agente usa `sonnet` para respuestas rápidas y `claude-code` para tareas que requieren ejecución --- ## Diseño ### Config YAML — el agente simplemente elige su modelo ```yaml agents: - name: "dev-bot" model: "claude-code" # ← usa claude -p como backend LLM model_config: binary: "claude" # path al binario (default: "claude") max_turns: 10 # turnos agenticos internos de claude -p timeout: "5m" allowed_tools: # tools que claude -p puede usar internamente - "bash" - "read_file" - "write_file" - "git" working_dir: "{{worktree}}" system_prompt_file: "prompts/dev-bot-system.md" - name: "chat-bot" model: "sonnet" # ← usa API HTTP normal model_config: api_key_env: "ANTHROPIC_API_KEY" ``` El campo `model` determina qué proveedor de `shell/llm/` se instancia. La `model_config` es específica de cada proveedor. --- ### Interfaz pura (core) — sin cambios La interfaz del core no cambia. El contrato ya existe: ```go // core/llm/types.go — esto ya existe o debería existir type CompletionRequest struct { SystemPrompt string Messages []Message Temperature float64 MaxTokens int } type CompletionResult struct { Content string TokensUsed TokenUsage FinishReason string // "stop", "max_turns", "timeout", "error" Metadata map[string]string } type TokenUsage struct { Input int Output int } ``` El core solo conoce esta interfaz. No sabe si detrás hay HTTP, un subproceso o una paloma mensajera. --- ### Shell — interfaz `Provider` y registro de proveedores ```go // shell/llm/provider.go type Provider interface { Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error) Close() error } // Registry mapea nombres de modelo a constructores de Provider type Registry struct { factories map[string]Factory } type Factory func(cfg map[string]any, logger *slog.Logger) (Provider, error) func (r *Registry) Register(name string, f Factory) func (r *Registry) Build(name string, cfg map[string]any, logger *slog.Logger) (Provider, error) ``` --- ### Shell — proveedor HTTP (el que ya existe o existiría) ```go // shell/llm/anthropic/provider.go type AnthropicProvider struct { client *http.Client apiKey string model string // "claude-sonnet-4-20250514", etc. baseURL string } func NewAnthropicProvider(cfg map[string]any, logger *slog.Logger) (llm.Provider, error) func (p *AnthropicProvider) Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error) { // Construir JSON → POST /v1/messages → parsear respuesta } ``` --- ### Shell — proveedor Claude Code (el nuevo) ```go // shell/llm/claudecode/provider.go type ClaudeCodeProvider struct { binary string maxTurns int timeout time.Duration allowedTools []string workingDir string systemPrompt string // contenido leído del archivo en construcción logger *slog.Logger } func NewClaudeCodeProvider(cfg map[string]any, logger *slog.Logger) (llm.Provider, error) func (p *ClaudeCodeProvider) Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error) { // 1. Construir el prompt final: system prompt del provider + messages del request // 2. Armar los args de claude -p // 3. Ejecutar subproceso // 4. Parsear JSON de salida // 5. Mapear a CompletionResult } ``` #### Construcción del comando (interno del provider) ```go func (p *ClaudeCodeProvider) buildArgs() []string { args := []string{"-p", "--output-format", "json"} if p.maxTurns > 0 { args = append(args, "--max-turns", strconv.Itoa(p.maxTurns)) } if len(p.allowedTools) > 0 { args = append(args, "--allowedTools", strings.Join(p.allowedTools, ",")) } if p.systemPrompt != "" { args = append(args, "--system-prompt", p.systemPrompt) } return args } func (p *ClaudeCodeProvider) Complete(ctx context.Context, req core.CompletionRequest) (core.CompletionResult, error) { ctx, cancel := context.WithTimeout(ctx, p.timeout) defer cancel() // Aplanar messages a un solo prompt para stdin prompt := flattenMessages(req.Messages) cmd := exec.CommandContext(ctx, p.binary, p.buildArgs()...) cmd.Dir = p.workingDir cmd.Stdin = strings.NewReader(prompt) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() return p.parseOutput(stdout.Bytes(), stderr.Bytes(), err) } ``` #### Parseo de la salida JSON ```go // claude -p --output-format json devuelve JSON lines con cada mensaje // El último bloque con role:"assistant" contiene la respuesta final type claudeOutputMessage struct { Role string `json:"role"` Content string `json:"content"` // ... campos adicionales del formato JSON de claude } func (p *ClaudeCodeProvider) parseOutput(stdout, stderr []byte, execErr error) (core.CompletionResult, error) { // Parsear JSON lines, extraer último mensaje assistant // Mapear exit code a FinishReason // Extraer token usage si está disponible } ``` --- ### Registro en el arranque ```go // shell/llm/registry_defaults.go func NewDefaultRegistry() *Registry { r := &Registry{factories: make(map[string]Factory)} r.Register("sonnet", anthropic.NewAnthropicProvider) r.Register("haiku", anthropic.NewAnthropicProvider) r.Register("opus", anthropic.NewAnthropicProvider) r.Register("claude-code", claudecode.NewClaudeCodeProvider) // ← nuevo return r } ``` ### Instanciación en el runtime del agente ```go // agents/runtime.go func (a *Agent) init(registry *llm.Registry) error { provider, err := registry.Build(a.cfg.Model, a.cfg.ModelConfig, a.logger) if err != nil { return fmt.Errorf("building LLM provider %q: %w", a.cfg.Model, err) } a.llm = provider return nil } // Después, cuando el agente necesita razonar: func (a *Agent) handleMessage(ctx context.Context, msg Message) (string, error) { req := core.CompletionRequest{ SystemPrompt: a.systemPrompt, Messages: a.buildMessages(msg), } result, err := a.llm.Complete(ctx, req) // ← no sabe si es HTTP o subproceso if err != nil { return "", err } return result.Content, nil } ``` --- ## Diferencia clave vs. modelo HTTP | Aspecto | Proveedor HTTP (`sonnet`) | Proveedor Claude Code (`claude-code`) | |---|---|---| | Transporte | HTTP a `api.anthropic.com` | Subproceso local `claude -p` | | Auth | API key | Session de Claude Code (login previo) | | Capacidades extra | Solo texto in/out | Agentic: bash, files, git dentro de `claude -p` | | Latencia | Baja por request | Mayor (startup del proceso + múltiples turnos internos) | | Costo | Por tokens via API | Por tokens via Claude Code (misma cuenta) | | Estado | Stateless | Puede mantener sesión (`--session-id`) | | Working dir | N/A | El worktree del agente | --- ## Flatten de mensajes para `claude -p` `claude -p` recibe el prompt por stdin como texto plano. Hay que aplanar el historial: ```go func flattenMessages(msgs []core.Message) string { var b strings.Builder for _, m := range msgs { switch m.Role { case "user": fmt.Fprintf(&b, "User: %s\n\n", m.Content) case "assistant": fmt.Fprintf(&b, "Assistant: %s\n\n", m.Content) } } return b.String() } ``` Alternativa para conversaciones largas: usar `--session-id` y enviar solo el último mensaje. --- ## Archivos a crear/modificar - `core/llm/types.go` — revisar que `CompletionRequest`/`CompletionResult` estén completos - `shell/llm/provider.go` — interfaz `Provider`, `Registry`, `Factory` - `shell/llm/anthropic/provider.go` — proveedor HTTP (refactorizar si ya existe) - **`shell/llm/claudecode/provider.go`** — proveedor Claude Code (nuevo) - `shell/llm/claudecode/parser.go` — parseo de JSON output de `claude -p` - `shell/llm/registry_defaults.go` — registro de proveedores disponibles - `agents/runtime.go` — usar `Registry.Build()` para instanciar el provider del agente - `internal/config/schema.go` — validar `model_config` según el `model` elegido --- ## Notas - **Fase 1**: Provider básico — stdin/stdout, sin sesiones, timeout simple - **Fase 2**: Soporte de `--session-id` para conversaciones con estado (el agente mantiene el session ID entre interacciones) - **Fase 3**: Streaming — `claude -p --output-format stream-json` para respuestas parciales en tiempo real a la sala Matrix - **Fase 4**: Pool de procesos — reutilizar sesiones de Claude Code para reducir latencia de startup - El agente no necesita implementar tools propios para bash/git/files si usa `claude-code` como modelo — Claude Code ya los tiene - Respetar `ctx` de shutdown: matar el subproceso con `cmd.Process.Kill()` si el contexto se cancela - El `working_dir` debería ser el worktree del agente para que Claude Code tenga contexto del repo