- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
21 KiB
Multi-Provider Code Agent Interface
Status: Complete (Weeks 1-5) 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 | ✅ Complete | Agent HTTP endpoints, health monitoring, metrics, DI wiring |
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.RWMutexfor 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 execparser.go- Stream-JSON NDJSON parser for Claude Code outputadapter_test.go- Comprehensive test coverageparser_test.go- Parser unit tests
Key Features:
- Wraps
kubectl execfor pod access - Uses
--output-format stream-jsonfor structured NDJSON output - Supports
--resume <session_id>for conversation continuation - Maps
AllowedToolsto--allowedToolsflag - Uses
--dangerously-skip-permissionsfor 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/SSEclient.go- HTTP client with SSE subscription supportadapter_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
usernameandpasswordconfig - 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 (✅ Complete - Week 5)
Agent Management Endpoints
# List all registered agents
GET /agents
{
"data": {
"agents": [
{
"provider": "claudecode",
"name": "Claude Code",
"available": true,
"default": true,
"supported_models": ["claude-sonnet-4-20250514"],
"default_model": "claude-sonnet-4-20250514"
}
],
"default_agent": "claudecode",
"total_agents": 1,
"available_count": 1
}
}
# Get agent capabilities
GET /agents/{provider}
{
"data": {
"provider": "claudecode",
"supports_session_continuation": true,
"supports_model_selection": false,
"supports_tool_control": true,
"supports_streaming": true,
"supported_models": ["claude-sonnet-4-20250514"],
"default_model": "claude-sonnet-4-20250514",
"max_prompt_length": 100000
}
}
# Set default agent
POST /agents/default
Content-Type: application/json
{
"provider": "opencode"
}
# Agent health status
GET /agents/health
{
"data": {
"agents": [
{
"provider": "claudecode",
"name": "Claude Code",
"healthy": true,
"message": "available",
"latency": "1.234ms",
"checked_at": "2025-01-27T10:00:00Z"
}
],
"healthy_count": 1,
"total_count": 1,
"default_agent": "claudecode",
"default_healthy": true
}
}
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:
- Active session is cleared (no cross-provider continuation)
- New provider is validated as available
- 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
├── handlers/
│ ├── agents.go ✅ Week 5: Agent management endpoints
│ └── agents_test.go ✅ Week 5: Handler tests
├── service/
│ ├── project_service.go ✅ Week 4: Agent registry integration
│ ├── project_service_agent.go ✅ Week 4: Agent execution methods + metrics
│ ├── project_service_commands.go ✅ Extracted shell/git commands
│ └── project_service_queue.go ✅ Extracted queue operations
├── metrics/
│ └── metrics.go ✅ Week 5: Agent metrics (requests, tool use, availability)
└── worker/
└── queue_processor.go ⬜ Future: Use CodeAgent for queue
cmd/
└── rdev-api/
├── main.go ✅ Week 5: Agent registry DI wiring
└── openapi.go ✅ Week 5: Agent API documentation
Observability (✅ Complete - Week 5)
Prometheus Metrics
| Metric | Labels | Description |
|---|---|---|
rdev_agent_requests_total |
provider, status | Total code agent requests |
rdev_agent_request_duration_seconds |
provider | Execution duration histogram |
rdev_agent_tool_use_total |
provider, tool | Tool invocations by agents |
rdev_agent_available |
provider | Availability gauge (1=available, 0=unavailable) |
Health Check
GET /agents/health
{
"data": {
"agents": [
{
"provider": "claudecode",
"name": "Claude Code",
"healthy": true,
"message": "available",
"latency": "1.234ms",
"checked_at": "2025-01-27T10:00:00Z"
}
],
"healthy_count": 1,
"total_count": 1,
"default_agent": "claudecode",
"default_healthy": true
}
}
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
- Event callback pattern - Matches existing
OutputHandler, enables streaming - Registry pattern - Allows runtime switching, extensible for more providers
- OpenCode as sidecar - Keeps Claude Code as proven default, OpenCode opt-in
- Session per provider - No cross-provider session sharing to avoid state corruption
- First-registered default - Registry automatically uses first agent as default
- Backward compatibility - Falls back to legacy executor when no registry configured
- File splitting - Service files split to comply with 500-line limit