feat: multi-agent support with registry, adapters, and CLI/MCP integration (v0.5.0)#71
feat: multi-agent support with registry, adapters, and CLI/MCP integration (v0.5.0)#71
Conversation
Introduce AgentProvider type, AgentAdapter/AgentRegistry interfaces, AGENT_NOT_FOUND/AGENT_MISCONFIGURED error codes, and agent field on Task/TaskRequest domain models. Default agent is 'claude' for backward compatibility. Co-Authored-By: Claude <noreply@anthropic.com>
Each adapter implements AgentAdapter with provider-specific CLI args, auto-accept flags, and environment variable stripping to prevent credential leakage between agent processes.
InMemoryAgentRegistry provides Map-based adapter lookup by provider. ProcessSpawnerAdapter wraps legacy ProcessSpawner as AgentAdapter for backward compatibility with existing test infrastructure.
Migration v7 adds 'agent TEXT' column to tasks table, defaulting existing tasks to 'claude'. Task repository updated with agent field in all SQL statements, Zod schema, and domain mapping.
Worker pool constructor now accepts AgentRegistry instead of ProcessSpawner. spawn() resolves the correct adapter via task.agent field (defaults to 'claude'). Bootstrap wiring updated to construct InMemoryAgentRegistry.
Ensures task.agent is carried forward when retrying or resuming tasks, maintaining agent affinity across task lifecycle operations.
Add createAgentRegistryFromSpawner helper for backward-compatible test setup. Update worker pool unit tests, handler-setup tests, and integration tests to construct EventDrivenWorkerPool with AgentRegistry instead of ProcessSpawner.
…e and ListAgents tool Add multi-agent support to the MCP surface: - DelegateTask: optional agent field (z.enum) selects which agent runs the task - ScheduleTask: optional agent field propagated to schedule template - CreatePipeline: per-step agent override + default agent field - ListAgents: new MCP tool returns all providers with registration status - TaskStatus: includes agent in single-task response (defaults to 'claude') - Domain: add agent field to ScheduleCreateRequest and PipelineStepRequest
…splay CLI surface for multi-agent support: - beat run: --agent/-a flag to select agent provider (claude, codex, gemini, aider) - beat agents list: new command showing all available agents with descriptions - beat status: shows agent field in task detail output (defaults to 'claude') - help: updated with agent flag docs, agent commands section, and usage example
…rough schedules - Bootstrap: register Claude, Codex, Gemini, Aider adapters in production - Bootstrap: pass AgentRegistry to MCPAdapter constructor - ScheduleManager: propagate agent field to taskTemplate in createSchedule - ScheduleManager: propagate per-step agent in createPipeline
New test files: - agents.test.ts: AGENT_PROVIDERS constant, DEFAULT_AGENT, isAgentProvider guard (10 tests) - agent-registry.test.ts: InMemoryAgentRegistry get/has/list/dispose (11 tests) - agent-adapters.test.ts: Claude/Codex/Gemini/Aider spawn args and env stripping (16 tests) Updated test files: - mcp-adapter.test.ts: DelegateTask agent field, ListAgents tool (5 tests) - cli.test.ts: agent flag parsing, agents list command, status display (7 tests) - domain.test.ts: createTask with agent field (4 tests) Total new tests: 53 | All suites passing: 1177 tests
- Stop stripping auth/config env vars for non-Claude adapters (P0 fix): Gemini adapter was stripping GEMINI_API_KEY, breaking authentication. Codex and Aider adapters were unnecessarily stripping config vars. Only Claude has documented nesting indicators that need stripping. - Remove unused ok import from process-spawner-adapter (P2 cleanup)
…tory schema Three alignment fixes for v0.5.0 multi-agent support: 1. TaskRequestSchema in schedule-repository.ts was missing the `agent` field. Zod strips unknown fields by default, so scheduled tasks always spawned with default agent regardless of what was specified. 2. CLI `beat schedule create` was missing --agent/-a flag parsing. The MCP ScheduleTask tool had agent support but the CLI did not. 3. CLI `beat pipeline` was missing --agent/-a flag parsing. The MCP CreatePipeline tool had agent support but the CLI did not. Co-Authored-By: Claude <noreply@anthropic.com>
Confidence Score: 4/5
Sequence DiagramsequenceDiagram
participant CLI as CLI / MCP
participant TM as TaskManager
participant WP as EventDrivenWorkerPool
participant AR as InMemoryAgentRegistry
participant AA as AgentAdapter (Claude/Codex/Gemini)
participant Proc as Child Process
CLI->>TM: delegate(TaskRequest { agent? })
TM->>TM: createTask (agent preserved)
TM->>WP: spawn(task)
WP->>WP: agentProvider = task.agent ?? 'claude'
WP->>AR: get(agentProvider)
AR-->>WP: Result<AgentAdapter>
WP->>AA: spawn(prompt, workingDir, taskId)
AA->>AA: isCommandInPath(this.command)
AA->>AA: resolveAuth() → env vars / config / PATH
AA->>Proc: spawn(command, args, { env: cleanEnv })
Proc-->>AA: ChildProcess + PID
AA-->>WP: Result<{ process, pid }>
WP-->>TM: Worker registered
TM-->>CLI: Task created (taskId)
Last reviewed commit: 7bd9f44 |
Addresses Greptile review feedback — validates agent values from the database against the known provider enum instead of accepting any string.
Additional Comments (3)
priority: step.priority ?? request.priority,
workingDirectory: step.workingDirectory ?? request.workingDirectory,
agent: step.agent, // ← no fallback to a pipeline-level default
While the MCP adapter resolves this by pre-coalescing Suggest adding agent: step.agent ?? request.agent,
The agent enum The same pattern also appears at:
Consider using the exported constant as the single source of truth: import { AGENT_PROVIDERS } from '../core/agents.js';
// In Zod schema:
agent: z.enum(AGENT_PROVIDERS as [AgentProvider, ...AgentProvider[]]).optional()
agent: (data.agent as AgentProvider) || undefined,At this point |
…gemini) Removes aider from AgentProvider type, AGENT_PROVIDERS constant, all Zod schemas, CLI help/error messages, bootstrap registration, and test fixtures. Deletes src/implementations/aider-adapter.ts.
Additional Comments (2)
The PR description states "4 Built-in Adapters: Claude Code, Codex CLI, Gemini CLI, Aider" and the architecture diagram includes
If a user passes |
Pre-spawn auth check (resolveAuth) validates credentials before spawning agent processes. Resolution order: env var → config file → CLI in PATH → AGENT_MISCONFIGURED error with actionable hints. - Add AGENT_AUTH metadata, checkAgentAuth(), maskApiKey() to agents.ts - Add loadAgentConfig/saveAgentConfig/resetAgentConfig to configuration.ts - Add resolveAuth() to BaseAgentAdapter with env injection from config - Add CLI: beat agents check, beat agents config set/show/reset - Add MCP: ConfigureAgent tool (check/set/reset), enhance ListAgents - Add tests: 833 passing across core/implementations/CLI/adapters
Additional Comments (1)
The // 3. Check CLI binary in PATH (login-based auth assumed)
if (isCommandInPath(auth.command)) {
return { provider, ready: true, method: 'cli-login', cliFound: true };
}This is especially risky for |
- Derive Zod agent enums from AGENT_PROVIDERS_TUPLE (single source of truth) - Fix envPrefixesToStrip regression: exact match CLAUDECODE, prefix CLAUDE_CODE_ - Remove misleading transformPrompt bash wrapping from ClaudeAdapter - Change cli-login to cli-installed with "auth not verified" display - Fix killGracePeriodMs non-null assertion with ?? 5000 fallback - Fix || to ?? coercion for agent field in task-repository - Clean unused imports in mock-agent.ts - Add pipeline-level agent default to PipelineCreateRequest - Make ProcessSpawnerAdapter provider configurable via constructor - Replace hardcoded agent strings in CLI error messages
|
|
||
| const existing = loadConfigFile(); | ||
| const agents = ( | ||
| existing.agents && typeof existing.agents === 'object' && !Array.isArray(existing.agents) ? existing.agents : {} |
There was a problem hiding this comment.
API key written to world-readable file
saveAgentConfig (and identically resetAgentConfig further below) calls writeFileSync with encoding 'utf-8', which creates/updates the file using the process umask. On most Linux/macOS systems this results in 0644 — readable by every user on the machine. Storing third-party API keys (OPENAI_API_KEY, GEMINI_API_KEY) in a world-readable file is a meaningful security risk on shared or multi-user systems.
The fix is to pass a mode that restricts access to the owner only, consistently for every write:
| existing.agents && typeof existing.agents === 'object' && !Array.isArray(existing.agents) ? existing.agents : {} | |
| writeFileSync(_configFilePath, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }); |
The same change should be applied to the writeFileSync call inside resetAgentConfig (line ~339 in this file). Note that the pre-existing setConfigValue / resetConfigValue writes on this file don't store secrets, but they will also need to be kept consistent once the mode is enforced — the last writer wins on an existing file's permissions. The safest approach is to apply 0o600 to all writes of this file.
| if (!section || typeof section !== 'object' || Array.isArray(section)) return {}; | ||
| const record = section as Record<string, unknown>; | ||
| return { | ||
| apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined, | ||
| }; | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
Loose key parameter bypasses compile-time safety
key is typed as string, but the function only supports 'apiKey'. Any call site that passes a different key string (e.g. from a typo or a future expansion) will compile cleanly and fail at runtime with an error response. Narrowing the type to the literal union prevents this class of mistake at the type level:
| if (!section || typeof section !== 'object' || Array.isArray(section)) return {}; | |
| const record = section as Record<string, unknown>; | |
| return { | |
| apiKey: typeof record.apiKey === 'string' ? record.apiKey : undefined, | |
| }; | |
| } | |
| /** | |
| export function saveAgentConfig( | |
| provider: AgentProvider, | |
| key: 'apiKey', | |
| value: string, | |
| ): { ok: true } | { ok: false; error: string } { | |
| // key is always 'apiKey' — guard no longer needed at runtime |
All callers in this PR already pass the string literal 'apiKey', so this is a non-breaking change. If additional config keys are added later, the union can be widened explicitly.
| return { provider, ready: true, method: 'cli-installed', cliFound: true }; | ||
| } | ||
|
|
||
| // 4. Nothing configured | ||
| return { |
There was a problem hiding this comment.
CLI-in-PATH treated as ready: true — silent false positive at auth check time
Step 3 returns { ready: true, method: 'cli-installed' } purely because the CLI binary exists on PATH. For the Claude provider this heuristic is reasonable (the claude binary manages its own OAuth session). For codex and gemini, which the adapters run with --full-auto / -sandbox false (i.e. non-interactive), a missing API key or un-initialized session will only surface as a task-level failure after the worker is spawned, not at the pre-spawn auth gate in resolveAuth().
Consider differentiating the behaviour: Claude can keep the current heuristic (binary present → ready), while Codex and Gemini could return ready: false with a hint when neither an env var nor a stored config key is found, regardless of PATH:
// In checkAgentAuth / resolveAuth, after env-var and config-file checks:
if (provider === 'claude' && isCommandInPath(auth.command)) {
// Login-based auth is viable for Claude
return { provider, ready: true, method: 'cli-installed', cliFound: true };
}
// Codex and Gemini require an explicit API key
return { provider, ready: false, method: 'none', cliFound: isCommandInPath(auth.command), hint: ... };At minimum, the authStatus field returned by ListAgents / beat agents check should indicate 'cli-installed (key unverified)' rather than the same 'ready' shown for env-var or config-file authenticated agents, so users are not misled into thinking Codex/Gemini are fully configured.
- Use AGENT_PROVIDERS.join() instead of hardcoded agent lists in cli.ts - Refactor agents list/config show to use ui.note() (matches config show, schedule get) - Refactor agents check to use ui.step() per row (matches status/schedule list) - Add Agent field to schedule detail view and creation success info - Show agent in pipeline visualization title
| // Register AgentRegistry for multi-agent support (v0.5.0) | ||
| // ARCHITECTURE: If a custom ProcessSpawner is injected (tests), wrap it in a | ||
| // compatibility adapter. Otherwise, register all 4 agent adapters. | ||
| container.registerSingleton('agentRegistry', () => { | ||
| if (options.processSpawner) { | ||
| logger.info('Using ProcessSpawnerAdapter for injected ProcessSpawner'); | ||
| const adapter = new ProcessSpawnerAdapter(options.processSpawner); | ||
| return new InMemoryAgentRegistry([adapter]); | ||
| } | ||
|
|
||
| const configResult = container.get<Configuration>('config'); | ||
| if (!configResult.ok) throw new Error('Config required for AgentRegistry'); | ||
| const cfg = configResult.value; | ||
| const adapters = [new ClaudeAdapter(cfg), new CodexAdapter(cfg), new GeminiAdapter(cfg)]; | ||
| return new InMemoryAgentRegistry(adapters); | ||
| }); |
There was a problem hiding this comment.
Comment inaccuracy: the comment on line 273 says "all 4 agent adapters" but the adapters array on line 284 contains exactly 3 adapters.
| // Register AgentRegistry for multi-agent support (v0.5.0) | |
| // ARCHITECTURE: If a custom ProcessSpawner is injected (tests), wrap it in a | |
| // compatibility adapter. Otherwise, register all 4 agent adapters. | |
| container.registerSingleton('agentRegistry', () => { | |
| if (options.processSpawner) { | |
| logger.info('Using ProcessSpawnerAdapter for injected ProcessSpawner'); | |
| const adapter = new ProcessSpawnerAdapter(options.processSpawner); | |
| return new InMemoryAgentRegistry([adapter]); | |
| } | |
| const configResult = container.get<Configuration>('config'); | |
| if (!configResult.ok) throw new Error('Config required for AgentRegistry'); | |
| const cfg = configResult.value; | |
| const adapters = [new ClaudeAdapter(cfg), new CodexAdapter(cfg), new GeminiAdapter(cfg)]; | |
| return new InMemoryAgentRegistry(adapters); | |
| }); | |
| // Register AgentRegistry for multi-agent support (v0.5.0) | |
| // ARCHITECTURE: If a custom ProcessSpawner is injected (tests), wrap it in a | |
| // compatibility adapter. Otherwise, register all 3 agent adapters. |
| spawn(prompt: string, workingDirectory: string, taskId?: string): Result<{ process: ChildProcess; pid: number }> { | ||
| try { | ||
| // Pre-spawn: verify CLI binary exists before anything else | ||
| if (!isCommandInPath(this.command)) { | ||
| return err( | ||
| agentMisconfigured( | ||
| this.provider, | ||
| [ | ||
| `CLI binary '${this.command}' not found in PATH.`, | ||
| ` Install: ${this.authConfig.loginHint}`, | ||
| ].join('\n'), | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| // Pre-spawn auth validation | ||
| const authResult = this.resolveAuth(); | ||
| if (!authResult.ok) return authResult; |
There was a problem hiding this comment.
spawn() and resolveAuth() check different binary names, causing spurious failures with non-standard command names.
spawn() guards on isCommandInPath(this.command) (the potentially customized constructor argument, line 101), but resolveAuth() step 3 calls isCommandInPath(auth.command) — the hardcoded standard binary name from AGENT_AUTH (line 80).
When a non-standard command name is passed (e.g., new GeminiAdapter(config, 'mock-gemini') in CI/test harnesses), the spawn guard passes but resolveAuth() checks the standard binary name and returns AGENT_MISCONFIGURED even though the real binary is present and usable.
Fix: Update the resolveAuth() method signature to accept an optional command parameter with default value this.command, then use that parameter in step 3's isCommandInPath() check. Update the call in spawn() to pass this.resolveAuth(this.command) so both checks use the same binary name.
|
Closing in favor of a new PR from correctly-named branch feat/67-multi-agent-support (same code, same commit 7bd9f44). |
Summary
Implements pluggable multi-agent support for Backbeat (closes #67). Tasks can now be executed by different AI coding agents instead of only Claude Code.
InMemoryAgentRegistrywithAgentAdapterinterface for pluggable agent backendsDelegateTaskacceptsagentfield, newListAgentstool, agent support inScheduleTaskandCreatePipeline--agent/-aflag onbeat run,beat schedule create,beat pipeline create; newbeat agents listcommandagent TEXT DEFAULT 'claude'column — backward compatibleAGENT_NOT_FOUNDandAGENT_MISCONFIGUREDerror codes with descriptive messagesArchitecture
Key Design Decisions
AgentProvideris a string union type validated at boundaries (Zod + type guard)'claude'for full backward compatibilityCLAUDECODE,CLAUDE_CODE_*) — other agents preserve their API keysProcessSpawnerAdapterwraps legacyProcessSpawnerinterface for test backward compatTest plan
npm run build)npx tsc --noEmit)