Agent Provider System
Overview
The agent provider system enables Symphony to dispatch agents via different CLI backends (Claude CLI and Codex CLI). Users select a provider at startup via --claude or --codex flags, and the choice persists in symphony.config.json. A six-level resolution chain determines the effective provider at dispatch time, supporting global defaults, per-project overrides, and runtime environment overrides.
How It Works
Provider Resolution Precedence
The resolveAgentProvider() function evaluates these sources top-to-bottom, first non-null wins:
| Priority | Source | Set By |
|---|---|---|
| 1 | SYMPHONY_AGENT_PROVIDER_OVERRIDE env var | CLI --codex/--claude flag via startAll() |
| 2 | SYMPHONY_AGENT_RUNTIME_OVERRIDE env var | CLI --codex/--claude flag via startAll() |
| 3 | Project config agent.provider | Per-project settings in UI |
| 4 | Project config agent.runtime | Per-project settings in UI |
| 5 | Global config agent.provider | symphony.config.json |
| 6 | Global config agent.runtime | symphony.config.json |
Default (all null): claude-cli.
Two Provider Implementations
Claude CLI (claude-cli):
- Command:
claude -p --system-prompt <path> --model <model> --mcp-config <path> --strict-mcp-config --dangerously-skip-permissions --disable-slash-commands --output-format stream-json --verbose <prompt> - Model names passed through directly (
opus,sonnet,haiku) - MCP config: native JSON file path via
--mcp-config - Project instructions mode:
implicit(Claude reads CLAUDE.md automatically — no injection needed)
Codex CLI (codex-cli):
- Command:
codex exec --json --ephemeral --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox --cd <worktree> --model <model> -c model_instructions_file=<path> <mcp args...> <prompt> - Model name translation:
opus->gpt-5.4,sonnet->gpt-5.3-codex,haiku->gpt-5.1-codex-mini - MCP config: parsed and converted to individual
-c mcp_servers.<name>.*=<value>TOML inline args - Project instructions mode:
inject(CLAUDE.md must be injected into the system prompt since Codex doesn't auto-read it)
Provider Persistence
When --codex or --claude is passed to symphony start:
persistDefaultProvider()readssymphony.config.json, mergesagent.providerandagent.runtime, writes back- Two env vars are set on child processes (web server + orchestrator):
SYMPHONY_AGENT_PROVIDER_OVERRIDEandSYMPHONY_AGENT_RUNTIME_OVERRIDE - The env override takes priority 1-2, ensuring the flag choice is active for the current session even if config is changed mid-run
MCP Config Translation
Claude CLI accepts a JSON MCP config file natively. Codex CLI requires MCP servers to be specified as TOML config overrides via -c flags. The buildCodexMcpConfigArgs() function:
- Reads the same JSON MCP config file
- Iterates each server entry
- Generates
-c mcp_servers.<name>.command=<value>,-c mcp_servers.<name>.args=[...], and-c mcp_servers.<name>.env={...}args - TOML values are properly quoted using
JSON.stringify()for strings,[...]for arrays,{ k = v }for inline tables
Key Components
| File | Responsibility |
|---|---|
server/orchestrator/agentProvider.ts | Provider interface, two implementations (Claude/Codex), resolution chain, MCP config translation |
server/orchestrator/agentRuntime.ts | Runtime abstraction layer mapping runtime names to providers |
server/orchestrator/agentRunner.ts | buildAgentLaunchSpec() delegates to provider; launchAgent() spawns subprocess |
cli/commands/start.ts | persistDefaultProvider() writes config; sets env overrides for child processes |
cli/index.ts | Parses --codex/--claude flags, enforces mutual exclusivity |
server/utils/config.ts | loadConfig() normalizes provider, resolves default model per provider |
server/orchestrator/agentDispatcher.ts | dispatchAgent() calls resolveAgentRuntime() with full precedence input |
Design Decisions
1. Why a provider abstraction instead of hardcoding Claude CLI? Symphony was built for Claude but designed to support alternative agent backends. The AgentProvider interface isolates CLI invocation details (command, args, model names, MCP handling) from orchestration logic. Adding a new provider requires implementing one interface, not touching dispatch code.
2. Why env var overrides take highest priority? The --codex/--claude flags set env vars on child processes. This ensures the flag choice is respected for the entire session, even if a user changes symphony.config.json while Symphony is running. The env override is the "session intent" while config is the "persisted default".
3. Why persist the provider to config AND use env vars? Persistence (symphony.config.json) means the choice survives restarts without re-specifying the flag. Env vars ensure the current session uses the flag immediately without reloading config. Both mechanisms serve different lifecycles.
4. Why translate MCP config to TOML args for Codex? Codex CLI doesn't support JSON MCP config files natively. Rather than maintaining two config formats, Symphony writes one JSON format and translates to Codex's TOML -c flag syntax at launch time. This keeps MCP config generation provider-agnostic.
5. Why separate provider from runtime?provider is the specific CLI tool (claude-cli, codex-cli). runtime is the abstract backend (claude, codex). This distinction exists because the prompt builder uses runtime to decide how to inject project instructions (implicit for Claude, inject for Codex), while provider drives the actual subprocess command. The separation allows future providers that share a runtime (e.g., a hypothetical claude-api provider with claude runtime).
6. Why model name aliasing for Codex? Symphony's config uses human-friendly model names (opus, sonnet, haiku). Codex CLI requires specific model IDs (gpt-5.4, gpt-5.3-codex). The resolveModel() method on each provider handles this mapping, keeping the config portable across providers.
7. Why mutual exclusivity of --codex and --claude flags? Combining both flags is always a user error. Failing fast with a clear message prevents confusing behavior where one flag silently wins.
Known Gaps
- No runtime provider validation: If a configured provider string is invalid,
normalizeAgentProvider()silently falls back toclaude-cli. No warning is logged. - No per-project UI for provider selection: Project config supports
agent.providerbut there's no UI field to set it. Must be configured via raw JSON in project settings. - Codex MCP arg quoting: The TOML string quoting uses
JSON.stringify()which handles most cases but may fail on strings containing backslashes in unusual patterns. - No provider health check: Symphony doesn't verify the selected CLI tool is installed before dispatching. Agent launch failure surfaces as a spawn error after worktree creation.