From 4634ad104b274c12c0eccd7c3e5fb2acf78307ac Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Fri, 6 Mar 2026 22:14:28 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20a=C3=B1adir=20claude-code=20como=20prov?= =?UTF-8?q?eedor=20LLM=20via=20claude=20-p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa un nuevo proveedor LLM que ejecuta 'claude -p' como subproceso, permitiendo usar Claude Code como backend de cualquier agente Matrix. Cambios: - pkg/llm/types.go: nueva constante ProviderClaudeCode - pkg/llm/router.go: routing de 'claude-code' antes de 'claude*' (Anthropic API) - internal/config/schema.go: nuevo tipo ClaudeCodeCfg con campos para binary, timeout, disable_tools, allowed/disallowed tools, permission_mode, model, fallback_model, session_id y add_dirs - shell/llm/claudecode.go: provider completo — buildClaudeArgs(), flattenMessages(), parseClaudeOutput() y filterEnv() para limpiar ANTHROPIC_API_KEY del entorno y que claude use su propia auth OAuth - shell/llm/factory.go: case 'claude-code' en FromConfig(), WithFallback() ahora recibe fallbackCfg para sobreescribir model/max_tokens al hacer fallback - agents/runtime.go: actualizado para pasar fallbackCfg a WithFallback() No se tocó: los proveedores existentes (anthropic.go, openai.go), el core puro de decision ni el listener de Matrix. --- agents/runtime.go | 2 +- internal/config/schema.go | 18 +++ pkg/llm/router.go | 2 + pkg/llm/types.go | 7 +- shell/llm/claudecode.go | 247 ++++++++++++++++++++++++++++++++++++++ shell/llm/factory.go | 13 +- 6 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 shell/llm/claudecode.go diff --git a/agents/runtime.go b/agents/runtime.go index e2dbefb..0285352 100644 --- a/agents/runtime.go +++ b/agents/runtime.go @@ -129,7 +129,7 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (* if err != nil { logger.Warn("fallback LLM config error", "err", err) } else { - llmFunc = shelllm.WithFallback(primaryLLM, fallbackLLM, llmLog) + llmFunc = shelllm.WithFallback(primaryLLM, fallbackLLM, cfg.LLM.Fallback, llmLog) } } diff --git a/internal/config/schema.go b/internal/config/schema.go index 35b5502..a4fc233 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -80,6 +80,24 @@ type LLMProviderCfg struct { BaseURL string `yaml:"base_url"` MaxTokens int `yaml:"max_tokens"` Temperature float64 `yaml:"temperature"` + + // ClaudeCode holds configuration for the claude-code provider (claude -p). + ClaudeCode ClaudeCodeCfg `yaml:"claude_code"` +} + +// ClaudeCodeCfg configures the claude -p subprocess provider. +type ClaudeCodeCfg struct { + Binary string `yaml:"binary"` // path to claude binary (default: "claude") + Timeout time.Duration `yaml:"timeout"` // subprocess timeout (default: 5m) + DisableTools bool `yaml:"disable_tools"` // pass --tools "" to disable all internal tools + AllowedTools []string `yaml:"allowed_tools"` // tools claude -p can use internally (e.g. Bash, Read, Edit) + DisallowedTools []string `yaml:"disallowed_tools"` // tools to block + WorkingDir string `yaml:"working_dir"` // working directory for claude -p + PermissionMode string `yaml:"permission_mode"` // default, acceptEdits, bypassPermissions, plan + Model string `yaml:"model"` // inner model: sonnet, opus, haiku, or full name + FallbackModel string `yaml:"fallback_model"` // fallback model if primary is overloaded + SessionID string `yaml:"session_id"` // fixed session ID for continuity + AddDirs []string `yaml:"add_dirs"` // additional directories accessible } type LLMReasoningCfg struct { diff --git a/pkg/llm/router.go b/pkg/llm/router.go index 172e162..fbe7f6f 100644 --- a/pkg/llm/router.go +++ b/pkg/llm/router.go @@ -5,6 +5,8 @@ import "strings" // Route maps a model string to its provider. Pure function. func Route(model string) ProviderID { switch { + case model == "claude-code" || strings.HasPrefix(model, "claude-code/"): + return ProviderClaudeCode case strings.HasPrefix(model, "claude"): return ProviderAnthropic case strings.HasPrefix(model, "gpt"), strings.HasPrefix(model, "o1"), strings.HasPrefix(model, "o3"): diff --git a/pkg/llm/types.go b/pkg/llm/types.go index fc96628..e452f0f 100644 --- a/pkg/llm/types.go +++ b/pkg/llm/types.go @@ -16,9 +16,10 @@ const ( type ProviderID string const ( - ProviderAnthropic ProviderID = "anthropic" - ProviderOpenAI ProviderID = "openai" - ProviderOllama ProviderID = "ollama" + ProviderAnthropic ProviderID = "anthropic" + ProviderOpenAI ProviderID = "openai" + ProviderOllama ProviderID = "ollama" + ProviderClaudeCode ProviderID = "claude-code" ) type Message struct { diff --git a/shell/llm/claudecode.go b/shell/llm/claudecode.go new file mode 100644 index 0000000..8c98b12 --- /dev/null +++ b/shell/llm/claudecode.go @@ -0,0 +1,247 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "strings" + "time" + + "github.com/enmanuel/agents/internal/config" + coretypes "github.com/enmanuel/agents/pkg/llm" +) + +const ( + defaultClaudeBinary = "claude" + defaultClaudeTimeout = 5 * time.Minute +) + +// claudeJSONOutput represents the JSON output from `claude -p --output-format json`. +type claudeJSONOutput struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + CostUSD float64 `json:"cost_usd"` + IsError bool `json:"is_error"` + Duration float64 `json:"duration_api_ms"` + NumTurns int `json:"num_turns"` + Result string `json:"result"` + SessionID string `json:"session_id"` + TotalCost float64 `json:"total_cost_usd"` + Usage claudeUsage `json:"usage"` + ContentBlock []claudeContent `json:"content"` +} + +type claudeUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type claudeContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// NewClaudeCodeComplete creates a CompleteFunc that executes `claude -p` as a subprocess. +func NewClaudeCodeComplete(cfg config.ClaudeCodeCfg, log *slog.Logger) coretypes.CompleteFunc { + binary := cfg.Binary + if binary == "" { + binary = defaultClaudeBinary + } + + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultClaudeTimeout + } + + return func(ctx context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + args := buildClaudeArgs(cfg, req) + + prompt := flattenMessages(req.Messages) + + log.Debug("claude_code_exec", + "binary", binary, + "args", strings.Join(args, " "), + "prompt_len", len(prompt), + ) + + cmd := exec.CommandContext(ctx, binary, args...) + if cfg.WorkingDir != "" { + cmd.Dir = cfg.WorkingDir + } + // Build clean env: inherit parent but remove ANTHROPIC_API_KEY + // so claude uses its own OAuth auth instead of a potentially invalid key. + cmd.Env = filterEnv(os.Environ(), "ANTHROPIC_API_KEY") + cmd.Stdin = strings.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + start := time.Now() + err := cmd.Run() + elapsed := time.Since(start) + + log.Debug("claude_code_done", + "elapsed_ms", elapsed.Milliseconds(), + "stdout_len", stdout.Len(), + "stderr_len", stderr.Len(), + "exit_err", err, + ) + + return parseClaudeOutput(stdout.Bytes(), stderr.Bytes(), err, elapsed, log) + } +} + +// buildClaudeArgs constructs the CLI arguments for claude -p. +func buildClaudeArgs(cfg config.ClaudeCodeCfg, req coretypes.CompletionRequest) []string { + args := []string{"--print", "--output-format", "json"} + + if req.SystemPrompt != "" { + args = append(args, "--system-prompt", req.SystemPrompt) + } + + if cfg.DisableTools { + args = append(args, "--tools", "") + } else { + if len(cfg.AllowedTools) > 0 { + args = append(args, "--allowedTools") + args = append(args, cfg.AllowedTools...) + } + + if len(cfg.DisallowedTools) > 0 { + args = append(args, "--disallowedTools") + args = append(args, cfg.DisallowedTools...) + } + } + + if cfg.PermissionMode != "" { + args = append(args, "--permission-mode", cfg.PermissionMode) + } + + if cfg.Model != "" { + args = append(args, "--model", cfg.Model) + } + + if cfg.FallbackModel != "" { + args = append(args, "--fallback-model", cfg.FallbackModel) + } + + if cfg.SessionID != "" { + args = append(args, "--session-id", cfg.SessionID) + } + + for _, dir := range cfg.AddDirs { + args = append(args, "--add-dir", dir) + } + + return args +} + +// flattenMessages converts a conversation history into a single text prompt for stdin. +func flattenMessages(msgs []coretypes.Message) string { + var b strings.Builder + for _, m := range msgs { + switch m.Role { + case coretypes.RoleUser: + fmt.Fprintf(&b, "User: %s\n\n", m.Content) + case coretypes.RoleAssistant: + fmt.Fprintf(&b, "Assistant: %s\n\n", m.Content) + case coretypes.RoleTool: + fmt.Fprintf(&b, "Tool result: %s\n\n", m.Content) + } + } + return b.String() +} + +// parseClaudeOutput parses the JSON output from `claude -p --output-format json`. +func parseClaudeOutput( + stdout, stderr []byte, + execErr error, + elapsed time.Duration, + log *slog.Logger, +) (coretypes.CompletionResponse, error) { + // If the process failed and there's no stdout, report the error + if execErr != nil && len(stdout) == 0 { + errMsg := string(stderr) + if errMsg == "" { + errMsg = execErr.Error() + } + return coretypes.CompletionResponse{}, fmt.Errorf("claude-code process failed: %s", errMsg) + } + + // Parse JSON output + var output claudeJSONOutput + if err := json.Unmarshal(stdout, &output); err != nil { + // Fall back to treating stdout as plain text + log.Warn("claude_code_json_parse_failed", "err", err, "stdout_len", len(stdout)) + return coretypes.CompletionResponse{ + Content: strings.TrimSpace(string(stdout)), + FinishReason: "stop", + }, nil + } + + if output.IsError { + return coretypes.CompletionResponse{}, fmt.Errorf("claude-code error: %s", output.Result) + } + + // Extract text from result field or content blocks + content := output.Result + if content == "" && len(output.ContentBlock) > 0 { + var parts []string + for _, block := range output.ContentBlock { + if block.Type == "text" && block.Text != "" { + parts = append(parts, block.Text) + } + } + content = strings.Join(parts, "\n") + } + + finishReason := "stop" + if execErr != nil { + finishReason = "error" + } + + log.Info("claude_code_response", + "content_len", len(content), + "input_tokens", output.Usage.InputTokens, + "output_tokens", output.Usage.OutputTokens, + "num_turns", output.NumTurns, + "cost_usd", output.TotalCost, + "elapsed_ms", elapsed.Milliseconds(), + ) + + return coretypes.CompletionResponse{ + Content: content, + Usage: coretypes.TokenUsage{ + InputTokens: output.Usage.InputTokens, + OutputTokens: output.Usage.OutputTokens, + TotalTokens: output.Usage.InputTokens + output.Usage.OutputTokens, + }, + FinishReason: finishReason, + }, nil +} + +// filterEnv returns a copy of environ with the named keys removed. +func filterEnv(environ []string, keys ...string) []string { + out := make([]string, 0, len(environ)) + for _, e := range environ { + skip := false + for _, k := range keys { + if strings.HasPrefix(e, k+"=") { + skip = true + break + } + } + if !skip { + out = append(out, e) + } + } + return out +} diff --git a/shell/llm/factory.go b/shell/llm/factory.go index 07fcb6a..a0fb728 100644 --- a/shell/llm/factory.go +++ b/shell/llm/factory.go @@ -23,18 +23,27 @@ func FromConfig(cfg config.LLMProviderCfg, log *slog.Logger) (coretypes.Complete base = "http://localhost:11434/v1" } return NewOpenAIComplete("OLLAMA_API_KEY", base, log), nil + case "claude-code": + return NewClaudeCodeComplete(cfg.ClaudeCode, log), nil default: return nil, fmt.Errorf("unknown LLM provider: %s", cfg.Provider) } } // WithFallback wraps primary with a fallback CompleteFunc. -// If primary returns an error, fallback is tried. -func WithFallback(primary, fallback coretypes.CompleteFunc, log *slog.Logger) coretypes.CompleteFunc { +// If primary returns an error, fallback is tried with the fallback config's model. +func WithFallback(primary, fallback coretypes.CompleteFunc, fallbackCfg config.LLMProviderCfg, log *slog.Logger) coretypes.CompleteFunc { return func(ctx context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) { resp, err := primary(ctx, req) if err != nil { log.Warn("llm_fallback_triggered", "primary_err", err) + // Override request fields with fallback config values + if fallbackCfg.Model != "" { + req.Model = fallbackCfg.Model + } + if fallbackCfg.MaxTokens > 0 { + req.MaxTokens = fallbackCfg.MaxTokens + } return fallback(ctx, req) } return resp, nil