# Agent Adapter Contract This document defines the contract between Agent Orchestrator and CLI-based coding agent adapters. Every agent must implement the contract defined in `backend/internal/ports/agent.go` to be usable by AO. ## Table of Contents - [Overview](#overview) - [Agent Contract](#agent-contract) - [Hook System](#hook-system) - [Session Metadata](#session-metadata) - [Agent Lifecycle](#agent-lifecycle) - [Implementation Guide](#implementation-guide) - [Supported Agents](#supported-agents) - [Testing](#testing) --- ## Overview Agent adapters let AO run and observe different CLI coding agents without hardcoding agent-specific behavior into the spawn engine. The adapter contract provides: - **Agent discovery** — Find and validate the agent binary - **Launch configuration** — Build the native agent command - **Session restoration** — Resume existing sessions - **Hook integration** — Install and manage agent hooks - **Metadata extraction** — Surface session title, summary, and native session ID ```mermaid graph TB AO[Agent Orchestrator] -->|uses| Adapter[Agent Adapter] Adapter -->|implements| Contract[ports.Agent Contract] Contract --> Methods[Required Methods] Methods --> GetConfig[GetConfigSpec] Methods --> GetLaunch[GetLaunchCommand] Methods --> GetStrategy[GetPromptDeliveryStrategy] Methods --> GetHooks[GetAgentHooks] Methods --> GetRestore[GetRestoreCommand] Methods --> SessionInfo[SessionInfo] Methods --> Uninstall[UninstallHooks] Adapter --> Agent[Native CLI Agent] Adapter --> Hooks[Agent Hook Config] ``` --- ## Agent Contract ### Port Interface The `ports.Agent` interface (defined in `backend/internal/ports/agent.go`) specifies the required adapter methods: ```go // Agent is the interface all coding agent adapters must implement. type Agent interface { // GetConfigSpec describes user-facing agent configuration. GetConfigSpec() ConfigSpec // GetLaunchCommand builds the native agent command for spawning a new session. GetLaunchCommand(ctx context.Context, cfg LaunchConfig) ([]string, error) // GetPromptDeliveryStrategy reports how the prompt is delivered. GetPromptDeliveryStrategy() PromptDeliveryStrategy // GetAgentHooks installs or merges AO hooks into the agent's workspace-local config. GetAgentHooks(ctx context.Context, cfg WorkspaceHookConfig) error // GetRestoreCommand builds a command to resume an existing session. GetRestoreCommand(ctx context.Context, cfg RestoreConfig) ([]string, bool, error) // SessionInfo returns normalized session metadata from the session record. SessionInfo(ctx context.Context, session SessionRef) (SessionInfo, bool, error) // UninstallHooks removes AO hooks from the workspace. UninstallHooks(ctx context.Context, workspacePath string) error } ``` ### Method Details #### GetConfigSpec() Returns a specification of user-facing configuration options: ```go type ConfigSpec struct { // Permissions is the permission mode the agent supports. Permissions PermissionMode // Model is the optional model identifier (e.g., "claude-3-5-sonnet-20241022"). Model string // SupportsSystemPrompt indicates the agent accepts a system prompt. SupportsSystemPrompt bool // SupportsRestore indicates the agent supports session restoration. SupportsRestore bool } ``` #### GetLaunchCommand() Builds the native agent launch command: ```mermaid flowchart LR Input[LaunchConfig] --> Validate[Validate Config] Validate --> Resolve[Resolve Binary] Resolve --> Build[Build Command] Build --> Apply[Apply Permissions] Apply --> Model[Add Model Flag] Model --> System[Add System Prompt] System --> Prompt[Add Prompt] Prompt --> Output[Launch Command] ``` **Input (`LaunchConfig`):** - `SessionID` — AO session identifier - `ProjectID` — AO project identifier - `Prompt` — User prompt to send - `Config` — Agent-specific configuration - `Permissions` — Permission mode override - `WorkspacePath` — Path to session worktree **Output:** Executable command line as argv array #### GetPromptDeliveryStrategy() Reports how the prompt is delivered to the agent: ```go type PromptDeliveryStrategy string const ( // StrategyArgv means the prompt is passed as a command-line argument. StrategyArgv PromptDeliveryStrategy = "argv" // StrategyStdin means the prompt is written to stdin after launch. StrategyStdin PromptDeliveryStrategy = "stdin" ) ``` #### GetAgentHooks() Installs AO hooks into the agent's workspace-local configuration: ```mermaid flowchart TD Input[WorkspaceHookConfig] --> Read[Read Existing Config] Read --> Merge[Merge AO Hooks] Merge --> Dedup[Deduplicate Entries] Dedupe --> Preserve[Preserve User Hooks] Preserve --> Write[Write Updated Config] Write --> Gitignore[Update .gitignore] ``` **Requirements:** - Preserve all user hooks - Deduplicate AO hook entries - Make AO hooks machine-portable (use `ao hooks ...` command) - Ensure all written files are covered by `.gitignore` #### GetRestoreCommand() Builds a command to resume an existing session: **Input (`RestoreConfig`):** - `Session` — Complete session record with metadata - `Permissions` — Permission mode to apply - `SystemPrompt` — System prompt to re-apply **Output:** - `cmd` — Restore command argv - `ok` — True if restore is supported - `err` — Error if restore fails #### SessionInfo() Returns normalized session metadata from the stored session record: ```go type SessionInfo struct { // AgentSessionID is the native agent's session identifier. AgentSessionID string // Title is the display title derived from the first user prompt. Title string // Summary is the display summary derived from the final assistant message. Summary string // Metadata contains adapter-specific extra fields. Metadata map[string]any } ``` **Important:** `SessionInfo` must read from the `session.Metadata` map (populated by hooks), not from scanning agent transcript/cache files. #### UninstallHooks() Removes AO hooks from the workspace while preserving user hooks. --- ## Hook System ### Hook Overview AO uses agent hooks to receive activity signals and session metadata from running agents: ```mermaid sequenceDiagram participant AO as AO Daemon participant Adapter as Agent Adapter participant Agent as Native Agent participant Hook as Hook Command Note over AO: Spawning session AO->>Adapter: GetAgentHooks(config) Adapter->>Hook: Write hook config Hook-->>Adapter: Config written AO->>Agent: Launch agent Agent->>Agent: Running in worktree Note over Agent: Agent triggers event Agent->>Hook: Execute hook callback Hook->>Hook: Read stdin payload Hook->>AO: POST /api/v1/sessions/{id}/activity AO->>AO: Update activity_state AO->>Hook: 200 OK Hook-->>Agent: Exit 0 Agent->>Agent: Continue work ``` ### Hook Events AO supports hooks for these agent events: | Event | Purpose | Activity State | |-------|---------|----------------| | `SessionStart` | Session initialized | - | | `UserPromptSubmit` | User submitted prompt | `active` | | `AssistantMessage` | Assistant response | - | | `ToolUse` | Agent used a tool | `active` | | `Stop` | Session stopped | `idle` or `exited` | ### Hook Contract All hook callbacks follow this contract: ```bash # Hook command format ao hooks # Input (stdin) # JSON payload from agent (agent-specific format) # Output (stdout) # None (discarded) # Exit code # 0 = success (always, even on delivery failure) ``` ### Hook Behavior ```mermaid flowchart TD Trigger[Agent Event Triggered] --> Execute[Execute Hook Command] Execute --> ReadEnv[Read AO_SESSION_ID
from environment] ReadEnv --> CheckSession{Is AO Session?} CheckSession -->|No| Exit[Exit 0 - ignore] CheckSession -->|Yes| ReadStdin[Read JSON Payload
from stdin] ReadStdin --> Derive[Derive Activity State
from event] Derive --> Post[POST to Daemon
/api/v1/sessions/{id}/activity] Post --> Success{Success?} Success -->|Yes| Update[Daemon updates
activity_state] Success -->|No| Log[Log to hooks.log
under AO_DATA_DIR] Update --> Exit Log --> Exit ``` **Critical rules:** 1. **Always exit 0** — Hook failure must never break the user's agent 2. **Log failures** — Append to `hooks.log` under `AO_DATA_DIR` 3. **Best-effort delivery** — The daemon may be temporarily unavailable 4. **Idempotent** — Duplicate hook deliveries are safe ### Hook Installation Patterns Different agents use different hook mechanisms: #### Claude Code / Compatible Agents ```json // .claude/hooks.json { "SessionStart": ["ao hooks claude-code SessionStart"], "UserPromptSubmit": ["ao hooks claude-code UserPromptSubmit"], "Stop": ["ao hooks claude-code Stop"] } ``` #### Factory Droid ```json // .factory/hooks.json { "hooks": [ { "event": "agent:beforeThinking", "command": "ao hooks droid beforeThinking" }, { "event": "agent:afterThinking", "command": "ao hooks droid afterThinking" } ] } ``` #### Codex (Session Flags) ```bash # Codex passes hooks as session flags codex -c 'hooks.SessionStart=["ao hooks codex SessionStart"]' \ -c 'hooks.Stop=["ao hooks codex Stop"]' \ --dangerously-bypass-hook-trust ``` --- ## Session Metadata ### Metadata Keys Hook callbacks persist normalized keys in the session metadata JSON blob: ```go const ( // MetadataKeyAgentSessionID is the native agent's session identifier. MetadataKeyAgentSessionID = "agentSessionId" // MetadataKeyTitle is the display title from the first user prompt. MetadataKeyTitle = "title" // MetadataKeySummary is the display summary from the final assistant message. MetadataKeySummary = "summary" ) ``` ### Metadata Flow ```mermaid sequenceDiagram participant Hook as Hook Callback participant Daemon as AO Daemon participant Store as SQLite Store participant UI as Dashboard Note over Hook: User submits first prompt Hook->>Daemon: POST /activity
{state: "active", title: "..."} Daemon->>Store: Update session metadata
metadata.title = "..." Store->>Store: CDC: session.updated Store->>UI: SSE: session.updated UI->>UI: Update session title Note over Hook: Agent finishes work Hook->>Daemon: POST /activity
{state: "idle", summary: "..."} Daemon->>Store: Update session metadata
metadata.summary = "..." Store->>Store: CDC: session.updated Store->>UI: SSE: session.updated UI->>UI: Update session summary ``` ### Metadata Persistence ```mermaid flowchart LR Hook[Hook Callback] --> POST[POST Activity] POST --> LCM[Lifecycle Manager] LCM --> Update[Update Session Record] Update --> Metadata[Set Metadata Fields] Metadata --> CDC[CDC Broadcast] CDC --> UI[Dashboard Updates] ``` --- ## Agent Lifecycle ### Spawn Flow ```mermaid sequenceDiagram participant User as User participant UI as Dashboard participant API as HTTP API participant Mgr as Session Manager participant Adapter as Agent Adapter participant Runtime as Runtime participant Agent as Agent Process User->>UI: Click "Spawn Session" UI->>API: POST /sessions API->>Mgr: Spawn(config) Note over Mgr: 1. Create session row Mgr->>Mgr: Insert session with empty metadata Note over Mgr: 2. Prepare workspace Mgr->>Adapter: GetAgentHooks(config) Adapter->>Adapter: Write hook config Adapter-->>Mgr: Hooks installed Note over Mgr: 3. Build launch command Mgr->>Adapter: GetLaunchCommand(config) Adapter-->>Mgr: Launch command argv Note over Mgr: 4. Execute in runtime Mgr->>Runtime: Execute(agent command) Runtime->>Agent: Spawn process Note over Agent: Agent starts Agent->>Agent: Execute SessionStart hook Agent->>API: POST /activity (agentSessionId) API->>API: Update metadata Agent-->>Runtime: Running Runtime-->>Mgr: Session active Mgr-->>API: Session response API-->>UI: Session created ``` ### Restore Flow ```mermaid sequenceDiagram participant User as User participant UI as Dashboard participant API as HTTP API participant Mgr as Session Manager participant Adapter as Agent Adapter participant Runtime as Runtime participant Agent as Agent Process User->>UI: Click "Restore Session" UI->>API: POST /sessions/{id}/restore API->>Mgr: Restore(session) Note over Mgr: Check adapter supports restore Mgr->>Adapter: GetRestoreCommand(config) alt Restore supported Adapter-->>Mgr: Restore command Mgr->>Runtime: Execute(restore command) Runtime->>Agent: Spawn process Agent-->>Mgr: Running Mgr-->>API: Session restored else Restore not supported Adapter-->>Mgr: (cmd, false, nil) Mgr->>Mgr: Fresh spawn instead Mgr-->>API: Session created (new) end API-->>UI: Session response ``` ### Activity Flow ```mermaid stateDiagram-v2 [*] --> Spawning: Spawn() Spawning --> Idle: Agent starts Idle --> Active: User prompt Active --> Active: Agent working Active --> Waiting: Needs input Waiting --> Active: User responds Active --> Idle: Agent completes Idle --> Exited: Agent exits Active --> Exited: Agent crashes Exited --> [*] note right of Active Hook: UserPromptSubmit Hook: ToolUse activity_state = "active" end note note right of Waiting Hook: WaitingForInput activity_state = "waiting_input" end note note right of Idle Hook: Stop activity_state = "idle" end note note right of Exited Hook: Stop activity_state = "exited" is_terminated = true end note ``` --- ## Implementation Guide ### Adapter Template ```go package myagent import ( "context" "fmt" ) // Plugin is the MyAgent adapter. type Plugin struct { // Adapter state (e.g., resolved binary path) } // New returns a ready-to-register MyAgent adapter. func New() *Plugin { return &Plugin{} } // GetConfigSpec describes the agent configuration. func (p *Plugin) GetConfigSpec() ports.ConfigSpec { return ports.ConfigSpec{ Permissions: ports.PermissionReadWrite, // or ReadOnly, WriteOnly SupportsSystemPrompt: true, SupportsRestore: true, } } // GetLaunchCommand builds the native agent command. func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ([]string, error) { // 1. Validate config if err := cfg.Config.Validate(); err != nil { return nil, fmt.Errorf("myagent: %w", err) } // 2. Resolve binary binary, err := p.resolveBinary(ctx) if err != nil { return nil, err } // 3. Build command cmd := []string{binary} // Add flags (model, permissions, etc.) if cfg.Config.Model != "" { cmd = append(cmd, "--model", cfg.Config.Model) } // Add system prompt if supported if cfg.SystemPrompt != "" { cmd = append(cmd, "--system-prompt", cfg.SystemPrompt) } // Add prompt if cfg.Prompt != "" { cmd = append(cmd, "--prompt", cfg.Prompt) } return cmd, nil } // GetPromptDeliveryStrategy reports how the prompt is delivered. func (p *Plugin) GetPromptDeliveryStrategy() ports.PromptDeliveryStrategy { return ports.StrategyArgv // or StrategyStdin } // GetAgentHooks installs AO hooks. func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error { // 1. Read existing config config, err := p.readConfig(cfg.WorkspacePath) if err != nil { return err } // 2. Add AO hooks (preserving user hooks) config = p.addHooks(config, cfg.SessionID) // 3. Write updated config if err := p.writeConfig(cfg.WorkspacePath, config); err != nil { return err } // 4. Ensure .gitignore coverage if err := p.ensureGitignore(cfg.WorkspacePath); err != nil { return err } return nil } // GetRestoreCommand builds a restore command. func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) ([]string, bool, error) { // 1. Extract native session ID from metadata sessionID := cfg.Session.Metadata[ports.MetadataKeyAgentSessionID] if sessionID == "" { return nil, false, nil // Restore not supported } // 2. Build restore command binary, err := p.resolveBinary(ctx) if err != nil { return nil, false, err } cmd := []string{ binary, "--resume", sessionID, // Re-apply permissions and system prompt } return cmd, true, nil } // SessionInfo returns normalized metadata. func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) { info := ports.SessionInfo{ AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID], Title: session.Metadata[ports.MetadataKeyTitle], Summary: session.Metadata[ports.MetadataKeySummary], } // Return hasData=true if any field is populated hasData := info.AgentSessionID != "" || info.Title != "" || info.Summary != "" return info, hasData, nil } // UninstallHooks removes AO hooks. func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error { // Remove AO hook entries while preserving user hooks config, err := p.readConfig(workspacePath) if err != nil { return err } config = p.removeHooks(config) return p.writeConfig(workspacePath, config) } ``` ### Hook Implementation ```go // Add hooks to the agent's config func (p *Plugin) addHooks(config map[string]any, sessionID string) map[string]any { // Define AO hook commands aoHooks := map[string]string{ "SessionStart": "ao hooks myagent SessionStart", "UserPromptSubmit": "ao hooks myagent UserPromptSubmit", "Stop": "ao hooks myagent Stop", } // Merge with existing config, preserving user hooks for event, command := range aoHooks { if config[event] == nil { config[event] = []string{command} } else { // Deduplicate: check if AO hook already exists hooks := config[event].([]string) found := false for _, h := range hooks { if strings.HasPrefix(h, "ao hooks myagent") { found = true break } } if !found { config[event] = append(hooks, command) } } } return config } ``` ### Gitignore Enforcement ```go import "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil" func (p *Plugin) ensureGitignore(workspacePath string) error { // Every file the adapter writes must be gitignored filesToIgnore := []string{ ".myagent/config.json", ".myagent/hooks.json", } return hookutil.EnsureWorkspaceGitignore(workspacePath, ".myagent/") } ``` --- ## Supported Agents ### Current Adapters AO currently supports 23+ agent adapters: ```mermaid mindmap root((Agent Adapters)) Anthropic Claude Code OpenAI Codex Cursor Cursor OpenCode OpenCode Aider Aider Amp Amp Code Goose Goose GitHub Copilot xAI Grok Alibaba Qwen Code Moonshot Kimi Code Reverb Crush Cline Cline Factory Droid Devin Augment Auggie Continue Continue Kiro Kiro Kilo Kilo Code Agy Agy Roo Roo Code Windsurf Windsurf ``` ### Adapter Locations ``` backend/internal/adapters/agent/ ├── agy/ ├── aider/ ├── amp/ ├── augie/ ├── claudecode/ ├── clone/ ├── codex/ ├── continue/ ├── cursor/ ├── devin/ ├── droid/ ├── grok/ ├── goose/ ├── kilo/ ├── kimi/ ├── kiro/ ├── opencode/ ├── qwen/ ├── roo/ ├── crush/ ├── windsurf/ └── ... ``` ### Adapter Capabilities | Agent | Permissions | System Prompt | Restore | Hooks | |-------|-------------|---------------|---------|-------| | Claude Code | ✓ | ✓ | ✓ | ✓ | | Codex | ✓ | ✓ | ✓ | ✓ (session flags) | | Cursor | ✓ | ✓ | ✗ | ✗ | | OpenCode | ✓ | ✓ | ✗ | ✗ | | Aider | ✓ | ✓ | ✓ | ✓ | | Amp | ✓ | ✓ | ✓ | ✓ | | Grok | ✓ | ✓ | ✓ | ✓ (Claude compat) | | ... | ... | ... | ... | ... | --- ## Testing ### Unit Tests ```go func TestGetLaunchCommand(t *testing.T) { p := New() cfg := ports.LaunchConfig{ SessionID: "test-session", Prompt: "Write a function", Config: domain.AgentConfig{ Model: "gpt-4", }, } cmd, err := p.GetLaunchCommand(context.Background(), cfg) assert.NoError(t, err) assert.Contains(t, cmd, "myagent") assert.Contains(t, cmd, "--model", "gpt-4") assert.Contains(t, cmd, "Write a function") } ``` ### Integration Tests ```go func TestAgentE2E(t *testing.T) { // Skip if agent not installed if !agentInstalled(t) { t.Skip("myagent not installed") } // Create test project ctx := context.Background() project := createTestProject(t) // Spawn session session := spawnSession(t, ctx, project.ID, "myagent", "Hello") // Wait for activity waitForActivity(t, ctx, session.ID, "active") // Verify metadata info := getSessionInfo(t, ctx, session.ID) assert.NotEmpty(t, info.AgentSessionID) assert.NotEmpty(t, info.Title) // Kill session killSession(t, ctx, session.ID) } ``` ### Conformance Tests ```go // TestGetAgentHooksFootprintIsGitignored verifies all adapter-written // files are covered by .gitignore. func TestGetAgentHooksFootprintIsGitignored(t *testing.T) { registry := agentRegistry.New() for _, adapter := range registry.List() { t.Run(adapter, func(t *testing.T) { tmpDir := t.TempDir() cfg := ports.WorkspaceHookConfig{ WorkspacePath: tmpDir, SessionID: "test-session", } agent := registry.Get(adapter) err := agent.GetAgentHooks(context.Background(), cfg) assert.NoError(t, err) // Verify all written files are gitignored verifyGitignore(t, tmpDir) }) } } ``` ### Hook Tests ```go func TestHookDelivery(t *testing.T) { // Mock daemon endpoint server := mockActivityServer(t) defer server.Close() // Simulate hook callback payload := map[string]any{ "state": "active", "title": "Test session", } cmd := exec.Command("ao", "hooks", "myagent", "UserPromptSubmit", "test-session") cmd.Env = append(cmd.Env, fmt.Sprintf("AO_SESSION_ID=%s", "test-session"), fmt.Sprintf("AO_DAEMON_URL=%s", server.URL), ) stdin, _ := cmd.StdinPipe() json.NewEncoder(stdin).Encode(payload) stdin.Close() err := cmd.Run() assert.NoError(t, err) // Verify daemon received the activity assert.ActivityReceived(t, server, "active") } ``` --- ## Summary **Key points:** 1. **Port contract** — All agents implement `ports.Agent` 2. **Hooks are critical** — Activity signals and metadata flow through hooks 3. **Always gitignore** — Every adapter-written file must be covered 4. **Metadata from hooks** — `SessionInfo` reads from metadata, not files 5. **Preserve user hooks** — Never delete user configuration 6. **Exit 0 always** — Hook failure must never break the agent **Implementation checklist:** - [ ] Implement `ports.Agent` interface - [ ] Write hook installation/removal - [ ] Ensure all files are gitignored - [ ] Implement `SessionInfo` from metadata - [ ] Add unit tests - [ ] Add integration tests - [ ] Add conformance tests - [ ] Register in daemon wiring