Model Routing¶
symfonic-core treats model selection as an adopter-owned concern. The framework orchestrates the agent loop; the adopter assigns models per role and picks the provider that serves each model. This concept doc explains how the four knobs that touch model selection compose, and why the architectural line sits where it does.
The four knobs¶
| Knob | Layer | Default | What it controls |
|---|---|---|---|
AgentConfig.model |
core/config.py |
empty ModelConfig |
The DEFAULT model for every site in the framework that calls an LLM. |
FrameworkConfig.role_models (v7.9.0) |
agent/config.py:1017 |
{} |
Per-role ModelConfig overrides. v7.9.0 wires "action"; v7.9.1+ wires the remaining roles. |
FrameworkConfig.procedural_force_first_action_tool (v7.8.3) |
agent/config.py:909 |
False |
Proactive forcing — set tool_choice to the bound action tool on turn 1 when one bare-id precondition resolves unambiguously. |
ModelProvider.supports_forced_tool_choice(config) (v7.8.5) |
core/providers.py:51 |
True |
Per-provider, per-config introspector. Lets adopters declare "this model rejects forced tool_choice under these conditions" so the engine abstains cleanly. |
The load-bearing architectural principle¶
The framework may constrain WHEN and WITH WHAT TOOLS the model is invoked, but the framework MUST NOT author the model's tool arguments or modify model-owned reasoning surfaces. Procedural compliance is the framework's job; reasoning and arguments remain the model's. Model assignment per role is the adopter's job.
This principle was codified in v7.8.3 and has held through every subsequent release. It draws a hard line in three places:
- Forcing is OK — constraining the choice set to a specific tool name is a framework responsibility.
- Arg authorship is NOT OK — pre-injecting an
AIMessagewith tool_calls already populated would make the framework the agent and the model a passenger. - Reasoning toggles are NOT OK — auto-disabling
config.thinkingto make forcing succeed would cross from choice-set constraint into reasoning-suppression.
The four knobs above all sit inside this line.
The canonical recipe: asymmetric routing¶
The pattern emerged from a production 3-engine A/B/C probe (2026-05-27)
which documented a model-class compliance ceiling: the adopter's legacy
gpt-4.1-nano agent scored 6/6 on the WLM probe vs the symfonic
claude-opus-4-6 + thinking agent's 1/6, on the same prompt
with the same tool catalog. The variable was the model class —
opus' RLHF prefers verbal narration on explanatory prompts; nano
was trained on a tighter "see tool → call tool" prior.
The framework can't retrain a model. It CAN give adopters the asymmetric-routing knob:
from symfonic.core.providers import (
AnthropicProvider, MultiProviderRouter, OpenAIProvider,
)
from symfonic.core.config import AgentConfig, ModelConfig
from symfonic.agent import FrameworkConfig, SymfonicAgent
provider = MultiProviderRouter(
default=AnthropicProvider(),
routes={
"gpt-": OpenAIProvider(),
"o1-": OpenAIProvider(),
"o3-": OpenAIProvider(),
},
)
agent = SymfonicAgent(
model_provider=provider,
config=FrameworkConfig(
agent=AgentConfig(
model=ModelConfig(
model_name="claude-opus-4-6",
thinking={"type": "enabled", "budget_tokens": 4096},
),
),
role_models={
"action": ModelConfig(model_name="gpt-4.1-nano"),
},
procedural_force_first_action_tool=True,
procedural_render_preflight_in_l1=True,
# ... your other knobs
),
)
What happens at runtime:
| Turn type | Model | Forcing eligible? | Why |
|---|---|---|---|
| General reasoning | claude-opus-4-6 + thinking |
NO | AnthropicProvider.supports_forced_tool_choice returns False when config.thinking is set. Engine abstains; reactive precondition_gate (v7.7.4) remains. |
| Action turn (the react node brain LLM) | gpt-4.1-nano |
YES | MultiProviderRouter dispatches to OpenAIProvider; supports_forced_tool_choice returns True; v7.8.3 forcing engages. |
The composition reads "opus thinks about everything; nano acts on procedural rules." That's the asymmetric-routing pattern.
Why each piece is necessary¶
Without procedural_force_first_action_tool=True: the model
emits zero tool_use blocks under load, narrates compliance
instead of acting (a documented failure mode under
claude-opus-4-6 + thinking).
Without MultiProviderRouter: role_models["action"]=
ModelConfig(model_name="gpt-4.1-nano") with AnthropicProvider
crashes at the API boundary with 404 model_not_found —
AnthropicProvider.get_chat_model hard-codes ChatAnthropic
regardless of model_name.
Without supports_forced_tool_choice returning False on
Anthropic + thinking: the engine's tool_choice payload reaches
the Anthropic API and the call crashes with 400 (Anthropic's
published constraint: forced tool_choice is incompatible with
extended thinking).
Without role_models["action"] override: the action turn runs
on the default model — claude-opus-4-6 + thinking — which hits
the model-class compliance ceiling and emits zero tool_use
blocks.
All four pieces are necessary; remove any one and the composition breaks at a known wire-layer or API-layer failure mode.
Inspecting your composition before going live¶
A reference configuration prints a routing table for any composition you build:
role=action model=gpt-4.1-nano provider=OpenAIProvider forces_ok=True
role=summary model=claude-opus-4-6 provider=AnthropicProvider forces_ok=False
role=critic model=claude-opus-4-6 provider=AnthropicProvider forces_ok=False
...
forces_ok=True means the engine's v7.8.3 force lever will engage
on that role's turns; forces_ok=False means it will abstain
(refuse-to-force pattern). Use this output to verify your
composition before pointing real API keys at it.
Role taxonomy¶
| Role | What it routes | v7.9.0 status |
|---|---|---|
action |
The react node brain LLM (the model that consumes tool definitions and emits tool_use blocks) |
WIRED |
summary |
Context-window summarisation (core/nodes/context_window.py:128) |
Reserved, wires in v7.9.x |
critic |
Metacognition critic (agent/middleware/metacognitive.py:270) |
Reserved, wires in v7.9.x |
router |
CapabilityRouter (memory/layers/procedural/router.py) |
Reserved, wires in v7.9.x |
reflection |
Self-reflection (engine.py:6481) |
Reserved, wires in v7.9.x |
consolidation |
Phase 12 promotion (core/learning/consolidation.py) |
Reserved, wires in v7.9.x |
Adopters can SET reserved roles today — the resolver returns them
correctly via SymfonicAgent.resolve_model_config(role) — the
consuming sites just don't read them yet. The wirings land in
subsequent v7.9.x patches.
When to use which knob¶
| Symptom | Knob |
|---|---|
Model narrates "I tried to call X but couldn't" instead of emitting tool_use |
procedural_force_first_action_tool=True |
| Above PLUS using opus + thinking | Above + asymmetric routing to a non-thinking model for the action role |
| Anthropic API returns 400 on forced turns | Either disable thinking on ModelConfig OR route the action role to a non-Anthropic model |
| Want one provider for cheap/fast tool calls and another for expensive reasoning | MultiProviderRouter + role_models["action"]=ModelConfig(model_name=...) |
| Custom provider (in-house model server) | Implement the ModelProvider protocol; MultiProviderRouter accepts any conforming provider as default or as a route |
What model routing does NOT do¶
- Does not retrain the model. Model-class compliance ceilings are real; the framework routes around them but cannot eliminate them.
- Does not modify
ModelConfiginstances. The resolver returns the adopter's exact instance; the router dispatches without copy or mutation. - Does not auto-disable thinking. Adopters who want thinking
off should set it off in
ModelConfig. v7.8.5 refuse-to-force is the principled response when thinking forbids forcing. - Does not author tool arguments. v7.8.3 declared this off- limits; v7.8.5 and v7.9.x preserve the line.
- Does not cache per-role chat model instances.
get_chat_modelis called per turn by the framework; adopters who need instance-level caching wrap the provider themselves.
Out-of-graph consumers (v7.16.0)¶
Background workers (Celery, RQ, APScheduler) often need to invoke a
chat model outside the agent's run() / stream() graph —
the canonical example is a scheduled job that summarises a batch of
new memories overnight, or a cron task that critiques a finished
conversation asynchronously.
Pre-v7.16 the adopter pattern was:
That works, but it bypasses resolve_model_config(role) entirely
— any role_models[role] overrides the adopter has configured
get silently ignored, and the worker silently runs on the default
AgentConfig.model instead of the role's bound model. Adopters
hit this when role routing worked in the foreground but not in the
worker, with no error to point at.
The supported pattern from v7.16.0 is:
chat = agent.get_chat_model("action") # or "summary", "critic", ...
response = await chat.ainvoke([HumanMessage(content="...")])
SymfonicAgent.get_chat_model(role) is stateless and safe to
call from any thread or process — it reads only self._config
and dispatches to the provider's get_chat_model. Adopters
instantiate one SymfonicAgent per worker process and call this
from task bodies; role routing flows through unchanged.
Default role="action" matches the v7.9.0 wired role (the
tool-using brain LLM archetype). Pass a different role string when
your worker plays a different cognitive role — for example, a
nightly summariser would call agent.get_chat_model("summary")
and an offline critic would call agent.get_chat_model("critic").
Unknown roles fall back to AgentConfig.model (the same
fallback resolve_model_config documents).
The old agent._model_provider.get_chat_model(...) pattern keeps
working — the framework does not force migration — but role-aware
adopters should prefer the public get_chat_model(role) method.
Pre-v7.8.x context¶
Before v7.8.3, model selection was implicit: the agent used
config.agent.model for every LLM call site. There was no
per-role override, no forcing knob, and no provider introspection.
Adopters who wanted different models per site wrote their own
dispatcher logic outside the framework.
The v7.8.x and v7.9.x line shipped the four knobs above as
canonical surfaces, with the v7.8.3 architectural principle as the
governing line. Adopters can still write custom dispatchers — the
ModelProvider protocol is runtime_checkable — but the
common patterns now have first-class framework support.
References¶
- API:
symfonic.core.providers - API:
SymfonicAgent.resolve_model_config(role) - API:
SymfonicAgent.get_chat_model(role)(v7.16.0 — out-of-graph consumer surface) - Tests:
tests/agent/test_role_models.py,tests/agent/test_get_chat_model.py,tests/agent/test_force_under_thinking.py,tests/unit/observability/test_multi_provider_router.py - CHANGELOG entries:
v7.8.3(force knob declared),v7.8.4(force knob wired),v7.8.5(refuse-to-force),v7.9.0(role_models),v7.9.1(MultiProviderRouter),v7.16.0(get_chat_model(role)public surface for out-of-graph consumers)