Skip to content

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_resolver seam), and
  • deterministic tool redirect (the v7.19 on_tool_call_dispatch seam).

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 action field. A redirect is inferred from redirect_to being set. v8.1.0 ships only redirect + model; a reject action is deferred and would be added later as an explicit action field, avoiding a dead single-valued Literal today.


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 existing on_tool_call_dispatch dispatch 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_block over fetch_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 model half still applied — it ran pre-model — but a cheaper model cannot force a tool call to exist.)
  • model is 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.
  • guard gates the redirect, not the model. A missing data source must not change which model reasons, so guard is 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.