rdev/docs/features/multi-provider.md
jordan 39df51defd feat: Add multi-provider code agent interface with Claude Code and OpenCode adapters
Implements weeks 1-4 of the multi-provider architecture:

Week 1 - Foundation:
- Add domain models (AgentProvider, AgentRequest, AgentEvent, AgentResult)
- Define CodeAgent port interface with Execute, Cancel, Capabilities
- Create thread-safe provider registry with first-registered default

Week 2 - Claude Code Adapter:
- Extract kubectl exec logic into CodeAgent implementation
- Parse stream-json output format (init, message, tool_use, result)
- Support session continuation via --resume flag

Week 3 - OpenCode Adapter:
- HTTP/SSE client for opencode serve API
- Session management (create, send message, abort)
- Event streaming with documented buffer rationale

Week 4 - Quality & Polish:
- Fix race condition in OpenCode Cancel method
- Add AgentRequest.Validate() with ErrPromptRequired, ErrInvalidTimeout
- Document DefaultAvailabilityTimeout constants
- Add HTTP error context for debugging

Also includes:
- Work queue system with PostgreSQL adapter
- Credential store for infrastructure secrets
- Project templates with Woodpecker CI integration
- Comprehensive test coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 09:25:51 -07:00

19 KiB

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

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

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

// In domain/errors.go
var ErrInvalidAgentProvider = errors.New("invalid agent provider")

Port Interface ( Implemented)

File: internal/port/code_agent.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

// 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 <session_id> for conversation continuation
  • Maps AllowedTools to --allowedTools flag
  • Uses --dangerously-skip-permissions for non-interactive mode

Command Construction:

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:

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:

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:

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:

type ProjectService struct {
    // ... existing fields ...
    agentRegistry port.CodeAgentRegistry // Optional code agent registry
}

func (s *ProjectService) WithCodeAgentRegistry(registry port.CodeAgentRegistry) *ProjectService

Updated Request/Response:

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:

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

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

{
  "id": "proj-123",
  "name": "my-project",
  "agent_provider": "claudecode",
  "agent_capabilities": {
    "supports_session_continuation": true,
    "supports_model_selection": false
  }
}

Update Provider

PATCH /projects/{id}
Content-Type: application/json

{
  "agent_provider": "opencode"
}

Execute with Model (OpenCode only)

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

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