2756557498
Se estandariza la numeración de todos los issues de 3 dígitos a 4 dígitos (e.g. 005 → 0005, 010 → 0010) para mantener consistencia con la convención definida en create_issue.md. Se actualiza el README con los nuevos nombres y links. No hay cambios de contenido en los issues, solo renombrado. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
317 lines
9.8 KiB
Markdown
317 lines
9.8 KiB
Markdown
# 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 |