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>
585 lines
19 KiB
Markdown
585 lines
19 KiB
Markdown
# 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 <session_id>` 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
|