Files
agents_and_robots/dev/issues/completed/006-añadir-claude-p.md
T
egutierrez f561f686c4 refactor: migrar tasks/ a dev/issues/ con estructura de desarrollo
Se mueve la documentación de issues/tasks de .claude/tasks/ a dev/issues/
para separar la planificación de desarrollo de la configuración de Claude.
Se añade dev/README.md como índice de la carpeta de desarrollo. Los issues
completados se mueven a dev/issues/completed/. Esto permite que dev/ sea
el punto central de documentación interna del proyecto.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:41:16 +00:00

9.8 KiB

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

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:

// 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

// 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)

// 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)

// 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)

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

// 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

// 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

// 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:

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