# Multi-Provider Code Agent Interface > **Status:** In Progress (Weeks 1-4 Complete) > **Feature:** Unified interface supporting Claude Code and OpenCode providers ## Overview This document describes the architecture for supporting multiple code agent providers (Claude Code, OpenCode) through a unified interface. The design enables provider switching at runtime without breaking existing functionality. ## Implementation Progress | Phase | Status | Description | |-------|--------|-------------| | Week 1: Foundation | ✅ Complete | Domain models, port interface, registry | | Week 2: Claude Code Adapter | ✅ Complete | kubectl exec wrapper, stream-json parser | | Week 3: OpenCode Adapter | ✅ Complete | HTTP/SSE client, session management | | Week 4: Service Integration | ✅ Complete | ProjectService integration, event streaming | | Week 5: Polish | ⬜ Pending | Model selection API, health monitoring, metrics, docs | ## Architecture ### Current Flow ``` Handler → ProjectService → CodeAgent (port) → ClaudeCodeAdapter | OpenCodeAdapter ↓ ↓ kubectl exec HTTP API claude -p opencode serve ``` ### Fallback Support When `CodeAgentRegistry` is not configured, the service falls back to the legacy `CommandExecutor` for backward compatibility. ## Domain Models (✅ Implemented) ### File: `internal/domain/code_agent.go` ```go // AgentProvider identifies which code agent implementation to use type AgentProvider string const ( AgentProviderClaudeCode AgentProvider = "claudecode" AgentProviderOpenCode AgentProvider = "opencode" ) // Validation and parsing func (p AgentProvider) IsValid() bool func (p AgentProvider) String() string func ParseAgentProvider(s string) (AgentProvider, error) func ValidAgentProviders() []AgentProvider // AgentRequest contains parameters for executing a code agent command type AgentRequest struct { Prompt string ProjectID ProjectID SessionID string // For continuation AllowedTools []string // Tool restrictions Model string // Model override (OpenCode only) WorkingDir string // Defaults to /workspace Timeout time.Duration // Execution timeout Metadata map[string]string // Provider-specific options } // AgentEventType categorizes events emitted during agent execution type AgentEventType string const ( AgentEventOutput AgentEventType = "output" AgentEventToolUse AgentEventType = "tool_use" AgentEventToolResult AgentEventType = "tool_result" AgentEventThinking AgentEventType = "thinking" AgentEventError AgentEventType = "error" AgentEventComplete AgentEventType = "complete" ) // AgentEvent represents a single event during agent execution type AgentEvent struct { Type AgentEventType Timestamp time.Time Content string Stream string // "stdout", "stderr", or empty ToolName string // For tool_use/tool_result events ToolInput map[string]any // Tool invocation arguments Metadata map[string]any } // AgentEventHandler is a callback for receiving agent events type AgentEventHandler func(event AgentEvent) // AgentResult contains the outcome of agent execution type AgentResult struct { SessionID string // For continuation ExitCode int DurationMs int64 Error error TokensUsed *AgentTokenUsage // If available FinalOutput string } func (r *AgentResult) Success() bool // ExitCode == 0 && Error == nil // AgentTokenUsage tracks token consumption type AgentTokenUsage struct { InputTokens int64 OutputTokens int64 TotalTokens int64 } // AgentCapabilities describes what a provider supports type AgentCapabilities struct { Provider AgentProvider SupportsSessionContinuation bool SupportsModelSelection bool SupportsToolControl bool SupportedModels []string DefaultModel string MaxPromptLength int SupportsStreaming bool } ``` ### Project Extension ```go // In domain/project.go type Project struct { // ... existing fields ... AgentProvider AgentProvider // Which code agent to use } // New label for K8s discovery const LabelAgentProvider = "rdev.orchard9.ai/agent-provider" ``` ### Error Handling ```go // In domain/errors.go var ErrInvalidAgentProvider = errors.New("invalid agent provider") ``` ## Port Interface (✅ Implemented) ### File: `internal/port/code_agent.go` ```go // CodeAgent defines operations for executing AI coding agent commands type CodeAgent interface { // Name returns a human-readable name for this agent Name() string // Provider returns the agent provider identifier Provider() domain.AgentProvider // Execute runs an agent command and streams events to the handler Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) // Cancel attempts to cancel a running agent session Cancel(ctx context.Context, sessionID string) error // Capabilities returns what this agent supports Capabilities() domain.AgentCapabilities // Available returns true if the agent is ready to accept requests Available(ctx context.Context) bool } // CodeAgentRegistry manages registered code agent implementations type CodeAgentRegistry interface { // Register adds an agent for a provider (overwrites existing) Register(agent CodeAgent) // Get returns the agent for a specific provider (nil if not found) Get(provider domain.AgentProvider) CodeAgent // Default returns the default agent (nil if empty) Default() CodeAgent // SetDefault sets the default provider (error if not registered) SetDefault(provider domain.AgentProvider) error // Available returns all registered providers Available() []domain.AgentProvider // AvailableAgents returns agents that are currently available AvailableAgents(ctx context.Context) []CodeAgent } ``` ## Provider Registry (✅ Implemented) ### File: `internal/adapter/codeagent/registry.go` ```go // Registry implements port.CodeAgentRegistry with thread-safe agent management type Registry struct { mu sync.RWMutex agents map[domain.AgentProvider]port.CodeAgent defProv domain.AgentProvider hasAgent bool } func NewRegistry() *Registry // Additional methods beyond interface func (r *Registry) DefaultProvider() domain.AgentProvider func (r *Registry) Count() int ``` **Thread Safety:** - `sync.RWMutex` for concurrent access - Read locks for: `Get`, `Default`, `Available`, `AvailableAgents`, `DefaultProvider`, `Count` - Write locks for: `Register`, `SetDefault` - First registered agent becomes default automatically **Test Coverage:** - Register/Get operations - Default selection (first registered) - SetDefault (success and failure) - Available providers listing - AvailableAgents filtering - Concurrent access (race-tested) - Re-registration overwrites ## Claude Code Adapter (✅ Implemented) ### Package: `internal/adapter/codeagent/claudecode/` **Files:** - `adapter.go` - CodeAgent implementation wrapping kubectl exec - `parser.go` - Stream-JSON NDJSON parser for Claude Code output - `adapter_test.go` - Comprehensive test coverage - `parser_test.go` - Parser unit tests **Key Features:** - Wraps `kubectl exec` for pod access - Uses `--output-format stream-json` for structured NDJSON output - Supports `--resume ` for conversation continuation - Maps `AllowedTools` to `--allowedTools` flag - Uses `--dangerously-skip-permissions` for non-interactive mode **Command Construction:** ```go func (a *Adapter) buildCommandArgs(namespace, podName string, req *domain.AgentRequest) []string { args := []string{ "exec", "-n", namespace, podName, "--", "claude", "-p", "--output-format", "stream-json", "--dangerously-skip-permissions", } if req.SessionID != "" { args = append(args, "--resume", req.SessionID) } for _, tool := range req.AllowedTools { args = append(args, "--allowedTools", tool) } if req.WorkingDir != "" && req.WorkingDir != "/workspace" { args = append(args, "--add-dir", req.WorkingDir) } args = append(args, req.Prompt) return args } ``` **Stream JSON Message Types:** | Type | Description | Mapped Event | |------|-------------|--------------| | `init` | Session started | `AgentEventOutput` | | `message` | Text output from assistant | `AgentEventOutput` | | `tool_use` | Tool invocation | `AgentEventToolUse` | | `tool_result` | Tool response | `AgentEventToolResult` | | `result` | Execution complete | `AgentEventComplete` | **Capabilities:** ```go func (a *Adapter) Capabilities() domain.AgentCapabilities { return domain.AgentCapabilities{ Provider: domain.AgentProviderClaudeCode, SupportsSessionContinuation: true, SupportsModelSelection: false, // Claude Code only uses Claude SupportsToolControl: true, SupportedModels: []string{"claude-sonnet-4-20250514", "claude-opus-4-20250514"}, DefaultModel: "claude-sonnet-4-20250514", MaxPromptLength: 0, // Unlimited SupportsStreaming: true, } } ``` ## OpenCode Adapter (✅ Implemented) ### Package: `internal/adapter/codeagent/opencode/` **Files:** - `adapter.go` - CodeAgent implementation using HTTP/SSE - `client.go` - HTTP client with SSE subscription support - `adapter_test.go` - Mock server tests for all operations **HTTP Client API:** ```go type Client struct { baseURL string httpClient *http.Client username string password string } // Health check func (c *Client) Health(ctx context.Context) (*HealthResponse, error) // Session management func (c *Client) CreateSession(ctx context.Context, req *CreateSessionRequest) (*Session, error) func (c *Client) GetSession(ctx context.Context, sessionID string) (*Session, error) func (c *Client) AbortSession(ctx context.Context, sessionID string) error // Message sending func (c *Client) SendMessage(ctx context.Context, sessionID string, req *SendMessageRequest) (*SendMessageResponse, error) func (c *Client) SendPromptAsync(ctx context.Context, sessionID string, req *SendMessageRequest) error // SSE streaming func (c *Client) SubscribeEvents(ctx context.Context) (<-chan SSEEvent, error) ``` **SSE Event Mapping:** | SSE Event | Description | Mapped Event | |-----------|-------------|--------------| | `server.connected` | Connected to server | `AgentEventOutput` | | `message.created` | New message | `AgentEventOutput` | | `message.updated` | Message updated | `AgentEventOutput` | | `tool.started` | Tool execution started | `AgentEventToolUse` | | `tool.completed` | Tool execution done | `AgentEventToolResult` | | `session.completed` | Session finished | `AgentEventComplete` | | `error` | Error occurred | `AgentEventError` | **Capabilities:** ```go func (a *Adapter) Capabilities() domain.AgentCapabilities { return domain.AgentCapabilities{ Provider: domain.AgentProviderOpenCode, SupportsSessionContinuation: true, SupportsModelSelection: true, // OpenCode supports multiple providers SupportsToolControl: true, SupportedModels: []string{ "claude-sonnet-4-20250514", "claude-opus-4-20250514", "gpt-4o", "gpt-4-turbo", "gemini-pro", }, DefaultModel: "claude-sonnet-4-20250514", MaxPromptLength: 0, // Unlimited SupportsStreaming: true, } } ``` **Authentication:** - Basic auth support via `username` and `password` config - Default username: `opencode` ## Service Integration (✅ Implemented) ### Files Modified/Created | File | Description | |------|-------------| | `project_service.go` | Core service with agent registry support | | `project_service_agent.go` | Agent execution and resolution methods | | `project_service_commands.go` | Shell/Git command execution (extracted) | | `project_service_queue.go` | Queue operations (extracted) | ### ProjectService Changes **New Fields:** ```go type ProjectService struct { // ... existing fields ... agentRegistry port.CodeAgentRegistry // Optional code agent registry } func (s *ProjectService) WithCodeAgentRegistry(registry port.CodeAgentRegistry) *ProjectService ``` **Updated Request/Response:** ```go type ExecuteClaudeRequest struct { ProjectID domain.ProjectID Prompt string StreamID string SessionID string // Optional: resume a previous session Model string // Optional: model override (OpenCode only) AllowedTools []string // Optional: restrict tool access Audit *AuditContext } type ExecuteClaudeResult struct { CommandID domain.CommandID StreamURL string SessionID string // Session ID for continuation AgentProvider domain.AgentProvider // Which provider handled the request } ``` **Agent Resolution:** ```go // resolveAgent returns the appropriate CodeAgent for a project. // Returns nil if no agent registry is configured or no agent is available. func (s *ProjectService) resolveAgent(project *domain.Project) port.CodeAgent { if s.agentRegistry == nil { return nil } // Try project-specific agent first if project.AgentProvider != "" { if agent := s.agentRegistry.Get(project.AgentProvider); agent != nil { return agent } } // Fall back to default return s.agentRegistry.Default() } ``` **Event Streaming:** Agent events are converted to SSE stream events: | Agent Event | Stream Event | Data | |-------------|--------------|------| | `AgentEventOutput` | `output` | `{line, stream}` | | `AgentEventToolUse` | `tool_use` | `{tool, input}` | | `AgentEventToolResult` | `tool_result` | `{output}` | | `AgentEventError` | `error` | `{error}` | | `AgentEventComplete` | `agent_complete` | metadata | | (final) | `complete` | `{exit_code, duration_ms, session_id, provider}` | **Additional Service Methods:** ```go // Get capabilities for a specific provider func (s *ProjectService) GetAgentCapabilities(provider domain.AgentProvider) *domain.AgentCapabilities // List all available providers func (s *ProjectService) ListAvailableAgents() []domain.AgentProvider // Get/set default agent func (s *ProjectService) GetDefaultAgent() domain.AgentProvider func (s *ProjectService) SetDefaultAgent(provider domain.AgentProvider) error ``` ## API Changes (⬜ Pending - Week 5) ### Project Response ```json { "id": "proj-123", "name": "my-project", "agent_provider": "claudecode", "agent_capabilities": { "supports_session_continuation": true, "supports_model_selection": false } } ``` ### Update Provider ```http PATCH /projects/{id} Content-Type: application/json { "agent_provider": "opencode" } ``` ### Execute with Model (OpenCode only) ```http POST /projects/{id}/claude Content-Type: application/json { "prompt": "Fix the bug in main.go", "model": "gpt-4o", "session_id": "prev-session-123" } ``` ## Configuration ### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `CODE_AGENT_DEFAULT` | `claudecode` | Default provider for new projects | | `OPENCODE_ENABLED` | `false` | Enable OpenCode adapter | | `OPENCODE_URL` | `http://127.0.0.1:4096` | OpenCode server URL | | `OPENCODE_USERNAME` | `opencode` | OpenCode basic auth username | | `OPENCODE_PASSWORD` | (none) | OpenCode basic auth password | ### Project-Level Override Projects can specify their preferred provider in the database. On provider switch: 1. Active session is cleared (no cross-provider continuation) 2. New provider is validated as available 3. Next command uses new provider ## File Structure ``` internal/ ├── domain/ │ ├── code_agent.go ✅ AgentProvider, AgentRequest, AgentEvent, etc. │ ├── code_agent_test.go ✅ Domain model tests │ ├── project.go ✅ Added AgentProvider field │ └── errors.go ✅ Added ErrInvalidAgentProvider ├── port/ │ └── code_agent.go ✅ CodeAgent, CodeAgentRegistry interfaces ├── adapter/ │ └── codeagent/ │ ├── registry.go ✅ Provider registry implementation │ ├── registry_test.go ✅ Registry tests (incl. concurrent access) │ ├── claudecode/ ✅ Week 2 │ │ ├── adapter.go ✅ CodeAgent implementation │ │ ├── parser.go ✅ Stream-JSON NDJSON parser │ │ ├── adapter_test.go ✅ Adapter tests │ │ └── parser_test.go ✅ Parser tests │ └── opencode/ ✅ Week 3 │ ├── adapter.go ✅ CodeAgent implementation │ ├── client.go ✅ HTTP/SSE client │ └── adapter_test.go ✅ Mock server tests ├── service/ │ ├── project_service.go ✅ Week 4: Agent registry integration │ ├── project_service_agent.go ✅ Week 4: Agent execution methods │ ├── project_service_commands.go ✅ Extracted shell/git commands │ └── project_service_queue.go ✅ Extracted queue operations └── worker/ └── queue_processor.go ⬜ Week 5: Use CodeAgent for queue ``` ## Observability (⬜ Pending - Week 5) ### Metrics | Metric | Labels | Description | |--------|--------|-------------| | `code_agent_requests_total` | provider, project, status | Total requests | | `code_agent_duration_seconds` | provider, project | Execution duration | | `code_agent_events_total` | provider, event_type | Streaming events | ### Health Check ```http GET /health { "status": "healthy", "agents": { "claudecode": "available", "opencode": "unavailable" } } ``` ## Risks and Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | OpenCode API changes | Adapter breaks | Pin to specific version, add API versioning | | Latency difference (subprocess vs HTTP) | User experience varies | Monitor p99 latency, document tradeoffs | | Session state incompatibility | Can't resume across providers | Clear session on provider switch | | Container image size increase | Slower deployments | OpenCode sidecar optional, not in base image | ## Design Decisions 1. **Event callback pattern** - Matches existing `OutputHandler`, enables streaming 2. **Registry pattern** - Allows runtime switching, extensible for more providers 3. **OpenCode as sidecar** - Keeps Claude Code as proven default, OpenCode opt-in 4. **Session per provider** - No cross-provider session sharing to avoid state corruption 5. **First-registered default** - Registry automatically uses first agent as default 6. **Backward compatibility** - Falls back to legacy executor when no registry configured 7. **File splitting** - Service files split to comply with 500-line limit