Hard-Lever Tools — the four-knob framework¶
symfonic-core gives adopters four orthogonal levers for tool-call compliance. Each lever is opt-in and each defaults to a behaviour that preserves the previous release byte-for-byte. They compose deliberately: an adopter with a high-compliance procedural workflow typically uses two or three together, and understanding the orthogonal axes is the difference between "the framework is fighting me" and "the framework is doing exactly what I asked".
This doc explains:
- The four levers.
- Why they're orthogonal (and the v7.19.5 empirical finding that forced the orthogonality lens).
- The thinking + forcing constraint that bounds two of them.
- The cache-cost trade-offs each lever introduces.
- When to use each, and when to use them together.
For the model-routing lens (asymmetric routing, role assignment),
read docs/concepts/model-routing.md. The two concept docs are
sibling reads — model-routing answers "which model handles this
turn?" and this doc answers "which tool can that model call?".
The four levers¶
| Lever | Knob | Default | Layer | Wire surface |
|---|---|---|---|---|
| L1 — Tool-choice forcing | FrameworkConfig.procedural_force_tool_choice: Literal["off","soft","hard"] (v7.20.0) |
"off" |
Engine resolver at engine.py::_maybe_resolve_forced_tool_choice |
Sets tool_choice={"type":"tool","name":<pre_tool>} on the bound runnable for turn 1 when one bare-id precondition resolves unambiguously |
| L2 — Tool routing | FrameworkConfig.tool_routing_mode: Literal["off","observe","enforce"] (v7.0.1) |
"off" |
Engine pre-bind at tool_routing.apply_tool_routing |
Narrows the bound tool catalogue per turn based on an intent classifier — the model only sees the tools the router approves |
| L3 — Intent filtering | FrameworkConfig.intent_filter_mode: Literal["off","observe","enforce"] (v7.0.0 Thalamic) |
"off" |
Engine pre-LLM at engine.py::_intent_filter |
Drops the entire LLM turn when the user's intent doesn't match the agent's domain — useful for tenant-scoped agents that should refuse out-of-scope queries |
| L4 — Lazy tooling | FrameworkConfig.lazy_tooling: bool (v7.0.0) |
True |
Engine pre-bind at engine.py::_lazy_resolve_tools |
Per-query keyword-driven tool catalogue narrowing via CapabilityRouter — lighter than L2, runs even when L2 is "off" |
All four are gates ON the wire payload the provider receives. None of them mutate the model's reasoning or author its tool arguments — see the architectural line below.
Why orthogonal? — the v7.19.5 empirical finding¶
A production adopter's D1/D2/D3 capture (2026-06-05) under
claude-opus-4-6 + thinking showed 60-80% intermittent HARD-lever
fire under multi-procedure pressure. The natural conclusion was "L1
forcing is unreliable here, we need L1 strict-fail" — but the
diagnostic instrumentation revealed something different:
L1 (
procedural_force_first_action_tool=True) is silently advisory underthinking=True. The Anthropic API rejects forcedtool_choicewhen extended thinking is enabled (HTTP 400), so the framework abstains cleanly (per the v7.8.5 contract). The observed compliance variance was NOT about L1 — it was about L2 (tool_routing_mode), which narrowed the bound palette regardless of thinking state.
The two levers compose orthogonally:
- L1 controls which tool the model MUST call (when the resolver picks a winner).
- L2 controls which tools the model CAN call at all (regardless of forcing).
Under thinking-mode, L1 abstains but L2 keeps narrowing — and L2's
narrowing IS the compliance signal adopters often want. Many
production adopters with intermittent compliance issues find
tool_routing_mode="enforce" solves their problem without
touching L1 at all.
The single sentence that prevents the next adopter from chasing the same ghost:
Adopters experiencing intermittent HARD-lever fire under thinking should test
tool_routing_mode="enforce"before adopting post-hoc rewrite enforcers.
The thinking + forcing constraint (v7.8.5 contract)¶
Anthropic's published extended-thinking constraint:
When thinking is enabled, only
{type:auto}and{type:none}are accepted; forcedtool_choicereturns HTTP 400.
The framework consults the provider's supports_forced_tool_choice(config)
introspector BEFORE stamping tool_choice. When the introspector
returns False, the resolver abstains cleanly — no 400, no crash,
just a per-turn refusal WARN and the model proceeds with auto tool
choice.
This means:
- L1
"soft"(the v7.20.0 default mapping for legacy bool=True) + thinking + Anthropic = the lever is silently advisory. Use L2"enforce"to constrain the choice set instead. - L1
"hard"+ thinking + Anthropic = the framework RAISES aRuntimeErrorat the resolver call. Adopters wiring compliance boundaries can catch this and refuse the turn upstream. - L1
"hard"+ non-Anthropic (or thinking disabled) = the framework forces. Provider compliance is on the provider.
The architect line for the no-auto-disable rule: the framework MAY
constrain the choice set; the framework MUST NOT silently flip the
model's reasoning configuration. Auto-disabling config.thinking to
make forcing succeed would cross that line — reasoning is
model-owned.
The architectural line (v7.8.3, codified)¶
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.
Three places this draws a line:
- Forcing is OK — constraining the choice set to a specific
tool name is a framework responsibility (L1, L2
"enforce"). - 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 levers above all sit inside this line. Adopter-implemented state predicates (the v7.7.4 precondition contract) sit on the reactive side of this line — they observe what the model emitted and emit a synthetic ToolMessage error when a precondition fails; they don't pre-author the tool call.
Cache-cost trade-offs¶
Each lever has a different cache footprint:
| Lever | Cache footprint | Why |
|---|---|---|
| L1 forcing | Zero impact | tool_choice is a per-request parameter on the wire, not part of the prompt cache key. Forcing only affects the response, not the cached prefix. |
L2 routing ("enforce") |
Volatile per turn | The bound tools list IS part of the Anthropic prompt-cache key. Each turn-specific narrowing breaks the cache prefix — adopters with narrow palettes per turn see warm $0.068/turn drop to cold $0.387/turn. Mitigation: manifest_cache_position="volatile" (v7.4.2 default-cached → 7.5 default-volatile) splits the manifest into a stable L1 catalogue + per-turn L2 narrowed subset. |
L2 routing ("observe") |
Zero impact | Observe mode emits the routing decision event without changing the bound list. Use this to gather data before flipping to "enforce". |
L3 intent filter ("enforce") |
No-op when off-topic | When the filter trips, the whole LLM turn doesn't happen — no token spend at all. When it lets the turn through, no cache impact. |
| L4 lazy tooling | Volatile per turn | Same mechanism as L2 — the per-query narrowed tool palette is part of the cache key. Defaults to True because most adopters benefit; flip to False for byte-stable prefixes (at the cost of always sending the full manifest). |
Adopters with cache-cost sensitivity who want L2 compliance should
pair tool_routing_mode="enforce" with manifest_cache_position="volatile"
to split the catalogue. See docs/guides/06-lazy-tooling.md and
docs/concepts/cost-tracking.md for the cache-prefix invariant
detail.
When to use each¶
L1 (procedural_force_tool_choice):
- A procedural skill mandates a specific tool call on turn 1 (e.g. "always verify integration status before scheduling").
- Use
"soft"for adopters who can tolerate clean abstain on provider refusal (thinking-mode under Anthropic). - Use
"hard"for compliance / audit boundaries that cannot accept silent abstain. The framework raisesRuntimeErrorand the adopter's outer handler refuses the turn explicitly.
L2 (tool_routing_mode):
- The agent has many tools but most queries only need a subset.
- The intent classifier reliably distinguishes "action" from "knowledge" or domain-specific buckets.
- Use
"observe"first to validate routing decisions before flipping to"enforce".
L3 (intent_filter_mode):
- The agent is tenant-scoped and should refuse out-of-scope queries (e.g. a billing agent should refuse "what's the weather?").
- Saves the whole LLM call when off-topic.
L4 (lazy_tooling):
- The agent has many tools and the keyword-driven router is good enough at narrowing per query.
- Default
True— most adopters benefit. - Disable when cache-cost sensitivity outweighs compliance benefit.
Refusal-reason contract¶
When L1 abstains under provider refusal, the per-turn refusal WARN
(engine.py::_maybe_resolve_forced_tool_choice line ~2700) emits a
structured payload that includes the provider's optional
refusal_reason(config) -> str | None companion method. Anthropic's
AnthropicProvider returns the extended-thinking constraint text;
other providers fall through to the generic v7.8.5-contract default
string.
Adopters wiring their own ModelProvider Protocol who want
diagnostic clarity SHOULD implement refusal_reason alongside
supports_forced_tool_choice — the WARN log surfaces what the
adopter declared as the structural cause, not a generic placeholder.
See docs/guides/11-troubleshooting.md for the four router-style
recipes that consume this surface.
Subscribing to ProcedureSelectionEvent (v7.20.0)¶
When L1 picks a winner (the unambiguous-choice gate holds and the
resolver returns a pre_tool), the engine emits
ProcedureSelectionEvent carrying procedure_id, action_tool,
pre_tool, force_mode, and candidate_count. Subscribe at the
deps-level CallbackManager for per-procedure billing attribution,
A/B-test bucket assignment, or audit-trail entries:
from symfonic.core.callbacks.manager import CallbackManager
from symfonic.core.contracts.callbacks import ProcedureSelectionEvent
class _SelectionLogger:
async def on_procedure_selection(
self, event: ProcedureSelectionEvent,
) -> None:
my_audit_sink.record(
procedure_id=event.procedure_id,
forced_tool=event.pre_tool,
mode=event.force_mode,
)
agent._deps.get(CallbackManager).add(_SelectionLogger())
The event fires only on successful selection; abstain paths (off mode, multi-skill ambiguity, provider refusal) do NOT emit. Adopters who need to observe abstain consult the v7.19.2 per-turn refusal WARN log family.
Related reads¶
docs/concepts/model-routing.md— the model-assignment lens (which model handles each turn).docs/guides/10-procedural-rules.md— authoring procedural skills with bare-id preconditions.docs/guides/11-troubleshooting.md— production adopter recipes that compose these levers (v7.20.0 sketch).docs/guides/12-state-predicate-preconditions.md— the reactive-side complement (state predicates that gate tool calls by external state, not just message history).docs/guides/13-tool-call-dispatch-rewriter.md— theon_tool_call_dispatchhook for rewriting tool calls before dispatch (the rewrite side of the compliance story; NOT a framework lever, an adopter-owned mutation seam).docs/concepts/cost-tracking.md— cache-prefix invariants and the cost implications of the volatile/cached split..agent/team/plans/2026-06-05-v7.20-v7.21-production-adopter-guide.md— the source plan that surfaced this four-lever framework.