ToolCallPolicy — declarative per-intent {model + tool-redirect} (v8.1.0)¶
ToolCallPolicy is a single declarative rule that core fans out to two
seams of every React iteration, sharing one match predicate. It
unifies two capabilities that previously required two separate adopter
callbacks:
- per-intent model selection (the v7.23
role_model_resolverseam), and - deterministic tool redirect (the v7.19
on_tool_call_dispatchseam).
You declare your intent logic ONCE (match); core applies the model half
before the LLM runs and the tool-redirect half after it emits a tool call.
None of it lives in the system prompt.
from symfonic.core.contracts import ToolCallPolicy
from symfonic.core.config import ModelConfig
policy = ToolCallPolicy(
name="sales_data_read",
match=lambda ctx: "sales" in ctx.messages[-1].content.lower(),
model=ModelConfig(model_name="claude-haiku-4-5"), # PRE-model
when_tool="run_block",
redirect_to="fetch_data", # POST-model
guard=lambda ctx: brand_sales_table_exists(ctx.scope),
args_transform=lambda args, ctx: {"table": "brand_sales"},
)
framework_config = FrameworkConfig(tool_call_policies=(policy,))
On a turn where match is True, the cheap model reasons the turn and
the emitted run_block call is rewritten to fetch_data — from one
declaration.
The data model¶
ToolCallPolicy is a @dataclass(frozen=True) (not pydantic — it carries
bare callables). Lives at symfonic.core.contracts.ToolCallPolicy.
| Field | Type | Role |
|---|---|---|
name |
str (required, non-empty) |
logging / inspection / test assertions |
match |
Callable[[DispatchContext], bool] |
the intent gate; runs at BOTH seams |
when_tool |
str \| frozenset[str] \| None |
POST-model emitted-tool filter; None = any tool |
redirect_to |
str \| None |
POST-model target tool; setting it makes the policy a redirect policy |
model |
ModelConfig \| None |
PRE-model per-intent model override |
args_transform |
Callable[[args, ctx], args] \| None |
POST-model arg rewriter |
guard |
Callable[[DispatchContext], bool] \| None |
deterministic precondition; gates the REDIRECT only |
A policy must set at least one of model or redirect_to (a no-op
policy is a construction-time error). match = "is this the right
intent?"; guard = "is the precondition satisfiable?" — kept separate so
the same match can be reused across policies with different guards.
v8.1.0 API note: there is no
actionfield. A redirect is inferred fromredirect_tobeing set. v8.1.0 ships onlyredirect+model; arejectaction is deferred and would be added later as an explicitactionfield, avoiding a dead single-valuedLiteraltoday.
The two-seam fan-out¶
React iteration
┌────────────────────────────────────────────────┐
│ PRE-model seam (react.py ~:814) │
│ policies (match + model) FIRST │
│ → role_model_resolver │
│ → snapshot default (config.model) │
│ ── LLM runs, emits tool_calls ── │
│ POST-model seam (react.py ~:1147) │
│ policies (match + when_tool + guard) FIRST │
│ → adopter on_tool_call_dispatch (override) │
│ → palette gate (always last) │
└────────────────────────────────────────────────┘
Both seams build the same DispatchContext shape via one shared helper
(_build_dispatch_context), so a match predicate behaves consistently at
both points. The two contexts are point-in-time snapshots of the same
turn: at the POST-model seam messages includes the just-emitted
AIMessage and resolved_tool_names / forced_tool_choice reflect the
post-LLM reality. For intent classification off messages[-1] (the user
turn) and scope, both seams agree.
First-match-wins within the list, independently at each seam. A different policy may win the model seam than wins the tool seam — both are evaluated in declared order.
Precedence¶
Adopter has all three set: tool_call_policies, role_model_resolver,
on_tool_call_dispatch.
| Seam | Order of evaluation | Winner |
|---|---|---|
| PRE-model | 1. tool_call_policies (first match+model) → 2. role_model_resolver → 3. snapshot default |
first policy model; else resolver; else default |
| POST-model | 1. internal policy handler (first match+when_tool+guard) → 2. adopter on_tool_call_dispatch (sees the policy's rewrite as input) |
policy rewrites first; adopter callback can further override |
| Palette gate | always last, on whatever rewrite survived | unrouted target → drop + WARN, original dispatches |
A declared policy is the more specific, inspectable intent; the free-form resolver/callback is the catch-all escape hatch.
Byte-identity guarantee (load-bearing)¶
tool_call_policies = () (the default) is byte-identical to v8.0.1.
Both seams short-circuit when the tuple is empty:
- PRE-model: the policy loop is skipped, falling straight through to the
existing
role_model_resolver/ snapshot-default path. - POST-model: the policy block is gated on
tool_calls and _policies; an empty tuple skips it entirely, leaving the existingon_tool_call_dispatchdispatch untouched.
Every existing adopter that sets no policies sees zero behaviour change.
Caveats (read these)¶
- The redirect fixes tool CHOICE, not the model's data-exists BELIEF. A
policy changes which tool runs; it cannot change the model's reasoning.
Wrong-tool-choice (the model picked
run_blockoverfetch_data) is fully fixed. A model that believes the data does not exist is not. - Model-refusal-with-no-tool-call is unreachable by the tool half. The
POST-model seam fires only when the LLM emits a tool call. If the model
refuses with plain text and emits no tool call, there is nothing to
redirect — keep your procedure-level anti-refusal guidance for that mode.
(The
modelhalf still applied — it ran pre-model — but a cheaper model cannot force a tool call to exist.) modelis a hint that still depends on the model picking a tool. The redirect is what makes the tool deterministic; the model swap only changes WHICH model reasons. They compose (cheap model + pinned tool) but neither alone guarantees a tool call.guardgates the redirect, not the model. A missing data source must not change which model reasons, soguardis evaluated only at the POST-model seam.
Relationship to the raw seams¶
ToolCallPolicy is a convenience over the two raw seams, not a new
capability. You can still wire role_model_resolver
(see per-dispatch-model-resolver.md) and
on_tool_call_dispatch directly; the policy list collapses the common
"if intent AND wrong-tool AND precondition → rewrite" + "cheap model for
this intent" boilerplate into one declared table. Adopters with exotic
logic drop back to the raw callbacks (which remain the final override).
Reference adopter demonstration¶
A runnable reference configuration demonstrates ToolCallPolicy on a
production sales-data case — swap to a cheap model AND redirect
run_block → fetch_data, guarded on the sales table existing. It also maps
the standalone role_model_resolver, scope_blend_mode, get_transcript,
the durability gate, and the raw on_tool_call_dispatch seam in one place.