Skip to content

feat: multi-agent support with registry, adapters, and CLI/MCP integration (v0.5.0)#71

Closed
dean0x wants to merge 19 commits intomainfrom
fix/agent-field-alignment
Closed

feat: multi-agent support with registry, adapters, and CLI/MCP integration (v0.5.0)#71
dean0x wants to merge 19 commits intomainfrom
fix/agent-field-alignment

Conversation

@dean0x
Copy link
Owner

@dean0x dean0x commented Mar 4, 2026

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.

  • Agent Registry: InMemoryAgentRegistry with AgentAdapter interface for pluggable agent backends
  • 3 Built-in Adapters: Claude Code, Codex CLI, Gemini CLI — each with correct CLI args, auto-accept flags, and env var handling
  • BaseAgentAdapter: Abstract base class eliminates duplication across adapters (Template Method pattern)
  • MCP Tools: DelegateTask accepts agent field, new ListAgents tool, agent support in ScheduleTask and CreatePipeline
  • CLI: --agent/-a flag on beat run, beat schedule create, beat pipeline create; new beat agents list command
  • Database: Migration v7 adds agent TEXT DEFAULT 'claude' column — backward compatible
  • Task Lifecycle: Agent preserved through retry, resume, and schedule execution
  • Error Handling: AGENT_NOT_FOUND and AGENT_MISCONFIGURED error codes with descriptive messages

Architecture

AgentRegistry (interface)
  └─ InMemoryAgentRegistry
       ├─ ClaudeAdapter  (claude --print --dangerously-skip-permissions --output-format json)
       ├─ CodexAdapter   (codex --quiet --full-auto)
       └─ GeminiAdapter  (gemini -sandbox false)

WorkerPool.spawn(task)
  → registry.get(task.agent ?? 'claude')
  → adapter.spawn(prompt, workingDir, taskId)

Key Design Decisions

  • AgentProvider is a string union type validated at boundaries (Zod + type guard)
  • Agent field is optional everywhere — defaults to 'claude' for full backward compatibility
  • Each adapter encapsulates its own command, args, env filtering, and prompt transformation
  • Only Claude strips nesting env vars (CLAUDECODE, CLAUDE_CODE_*) — other agents preserve their API keys
  • ProcessSpawnerAdapter wraps legacy ProcessSpawner interface for test backward compat

Test plan

  • Build passes (npm run build)
  • Type check passes (npx tsc --noEmit)
  • All 7 test groups pass (1,157+ tests, 0 failures)
  • New test coverage: agent registry, all 3 adapters, domain agent field, MCP agent tools, CLI agent commands
  • Backward compatibility: all pre-existing tests pass without modification
  • Agent preserved through retry/resume/schedule lifecycle
  • Schedule repository correctly parses agent from task_template JSON

Dean Sharon and others added 13 commits March 4, 2026 17:26
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>
@greptile-apps
Copy link

greptile-apps bot commented Mar 4, 2026

Confidence Score: 4/5

  • Safe to merge after addressing the spawn/resolveAuth command name mismatch; the comment inaccuracy is trivial.
  • The core architecture is solid with proper single source of truth for provider enums, correct backward compatibility, and agent preservation through task lifecycle. One genuine logic issue affects only non-default binary names (tests/CI with custom paths) and does not affect production defaults. The comment inaccuracy is cosmetic. All previously-flagged issues have been addressed in this revision.
  • src/implementations/base-agent-adapter.ts (spawn/resolveAuth command name mismatch)

Sequence Diagram

sequenceDiagram
    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)
Loading

Last reviewed commit: 7bd9f44

Dean Sharon added 2 commits March 4, 2026 21:08
Addresses Greptile review feedback — validates agent values from the
database against the known provider enum instead of accepting any string.
@greptile-apps
Copy link

greptile-apps bot commented Mar 4, 2026

Additional Comments (3)

src/core/domain.ts
PipelineCreateRequest lacks a pipeline-level agent field, breaking the fallback pattern

priority and workingDirectory have pipeline-level defaults in PipelineCreateRequest with proper fallback in ScheduleManagerService.createPipeline:

priority: step.priority ?? request.priority,
workingDirectory: step.workingDirectory ?? request.workingDirectory,
agent: step.agent,   // ← no fallback to a pipeline-level default

agent is treated inconsistently: a caller can specify a default priority and working directory for the whole pipeline, but they cannot specify a default agent for steps that don't override it. Steps without agent will silently fall back to 'claude' at execution time, even if the caller intended a different default.

While the MCP adapter resolves this by pre-coalescing s.agent ?? data.agent before calling createPipeline, the service contract itself is incomplete. Programmatic callers of createPipeline have no way to express a pipeline-level agent default.

Suggest adding readonly agent?: AgentProvider to PipelineCreateRequest and mirroring the pattern in ScheduleManagerService.createPipeline:

agent: step.agent ?? request.agent,

src/adapters/mcp-adapter.ts
Hardcoded enum values duplicated across Zod schemas — risk of drift from AGENT_PROVIDERS

The agent enum ['claude', 'codex', 'gemini', 'aider'] is hardcoded in at least four Zod schemas across mcp-adapter.ts, task-repository.ts, and schedule-repository.ts, instead of being derived from the canonical AGENT_PROVIDERS constant. If a new agent is added to AGENT_PROVIDERS in core/agents.ts, every schema must be updated manually — one missed location will silently reject the new agent at runtime.

The same pattern also appears at:

  • src/adapters/mcp-adapter.ts:84, src/adapters/mcp-adapter.ts:122, src/adapters/mcp-adapter.ts:136
  • src/implementations/task-repository.ts:37
  • src/implementations/schedule-repository.ts:71

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()

src/implementations/task-repository.ts
Loose cast and || coercion instead of nullish coalescing

agent: (data.agent as AgentProvider) || undefined,

At this point data.agent is AgentProvider | null (the Zod schema validated it as .nullable()), so the explicit cast to AgentProvider is redundant and misleading — it type-asserts away the null before the || handles it. This also uses || (falsy check) where ?? (nullish check) is the intended semantic.

      agent: data.agent ?? undefined,

…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.
@greptile-apps
Copy link

greptile-apps bot commented Mar 4, 2026

Additional Comments (2)

src/cli/commands/agents.ts
process.exit(0) inside listAgents makes the function untestable

listAgents hard-terminates the process, making it impossible to unit-test without mocking process.exit. Looking at other CLI commands in the codebase (e.g., cancelTask, getTaskStatus), they return normally and leave exit-handling to the caller in cli.ts. Consider removing the process.exit(0) call from listAgents and letting the main dispatcher handle process lifecycle — this aligns with the codebase's existing pattern and enables proper unit testing of the function's output logic.


src/adapters/mcp-adapter.ts
aider is missing from MCP/CLI agent enums despite being listed as a built-in adapter

The PR description states "4 Built-in Adapters: Claude Code, Codex CLI, Gemini CLI, Aider" and the architecture diagram includes AiderAdapter. However, aider is absent from:

  • AGENT_PROVIDERS in src/core/agents.ts (only ['claude', 'codex', 'gemini'])
  • All Zod enums in mcp-adapter.ts (z.enum(['claude', 'codex', 'gemini']))
  • AGENT_DESCRIPTIONS record
  • bootstrap.ts adapter registration
  • No AiderAdapter class exists anywhere in the diff

If a user passes --agent aider via the CLI or MCP, the isAgentProvider check will reject it immediately. If AiderAdapter is intentionally deferred to a follow-up PR, the PR description should be corrected and the architecture diagram updated to reflect only 3 adapters in this release.

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
@greptile-apps
Copy link

greptile-apps bot commented Mar 4, 2026

Additional Comments (1)

src/core/agents.ts
CLI presence assumed to mean authenticated

checkAgentAuth returns ready: true with method: 'cli-login' solely because the binary exists in $PATH, without verifying the user is actually logged in. For example, if gemini or codex is installed but not authenticated, the registry will report the agent as ready, the WorkerPool will attempt to spawn it, and the task will fail at runtime with an opaque auth error rather than a clear pre-flight message.

The loginHint already contains the right action — consider returning ready: false here and surfacing the hint, or at minimum downgrading to a method: 'cli-installed' status with a separate authVerified: false field so callers can distinguish "CLI found, auth unknown" from "fully configured via env-var or stored key":

// 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 gemini and codex where there is no fallback re-auth flow inside the worker — a spawned process that exits non-zero due to auth errors will be interpreted as a task failure rather than an agent misconfiguration.

- 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 : {}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Comment on lines +270 to +277
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,
};
}

/**
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Comment on lines +139 to +143
return { provider, ready: true, method: 'cli-installed', cliFound: true };
}

// 4. Nothing configured
return {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Comment on lines +271 to +286
// 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);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment inaccuracy: the comment on line 273 says "all 4 agent adapters" but the adapters array on line 284 contains exactly 3 adapters.

Suggested change
// 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.

Comment on lines +98 to +115
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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@dean0x
Copy link
Owner Author

dean0x commented Mar 5, 2026

Closing in favor of a new PR from correctly-named branch feat/67-multi-agent-support (same code, same commit 7bd9f44).

@dean0x dean0x closed this Mar 5, 2026
@dean0x dean0x deleted the fix/agent-field-alignment branch March 5, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.5.0: Multi-Agent Support

1 participant