Skip to content

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:

PrioritySourceSet By
1SYMPHONY_AGENT_PROVIDER_OVERRIDE env varCLI --codex/--claude flag via startAll()
2SYMPHONY_AGENT_RUNTIME_OVERRIDE env varCLI --codex/--claude flag via startAll()
3Project config agent.providerPer-project settings in UI
4Project config agent.runtimePer-project settings in UI
5Global config agent.providersymphony.config.json
6Global config agent.runtimesymphony.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:

  1. persistDefaultProvider() reads symphony.config.json, merges agent.provider and agent.runtime, writes back
  2. Two env vars are set on child processes (web server + orchestrator): SYMPHONY_AGENT_PROVIDER_OVERRIDE and SYMPHONY_AGENT_RUNTIME_OVERRIDE
  3. 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:

  1. Reads the same JSON MCP config file
  2. Iterates each server entry
  3. Generates -c mcp_servers.<name>.command=<value>, -c mcp_servers.<name>.args=[...], and -c mcp_servers.<name>.env={...} args
  4. TOML values are properly quoted using JSON.stringify() for strings, [...] for arrays, { k = v } for inline tables

Key Components

FileResponsibility
server/orchestrator/agentProvider.tsProvider interface, two implementations (Claude/Codex), resolution chain, MCP config translation
server/orchestrator/agentRuntime.tsRuntime abstraction layer mapping runtime names to providers
server/orchestrator/agentRunner.tsbuildAgentLaunchSpec() delegates to provider; launchAgent() spawns subprocess
cli/commands/start.tspersistDefaultProvider() writes config; sets env overrides for child processes
cli/index.tsParses --codex/--claude flags, enforces mutual exclusivity
server/utils/config.tsloadConfig() normalizes provider, resolves default model per provider
server/orchestrator/agentDispatcher.tsdispatchAgent() 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 to claude-cli. No warning is logged.
  • No per-project UI for provider selection: Project config supports agent.provider but 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.