Skip to content

symfonic.core.contracts.tool_policy

tool_policy

v8.1.0 -- ToolCallPolicy declarative per-intent routing primitive.

A single declarative rule that core fans out to TWO seams of one React iteration, sharing ONE match predicate:

  • PRE-model (model select, react.py ~:814): when model is set and match(ctx) is True, the policy supplies the React brain model for THIS iteration -- composed BEFORE the adopter role_model_resolver.
  • POST-model (tool redirect, react.py ~:1147): when redirect_to is set and match(ctx) is True AND the emitted tool matches when_tool AND guard (if any) passes, the emitted tool call is rewritten to redirect_to -- composed BEFORE the adopter on_tool_call_dispatch callback, then the palette gate runs last.

One policy may carry model only (pure per-intent model), redirect_to only (pure tool enforcement), or BOTH (the synthesis: cheap model + pinned tool from a single declaration).

Forward-compat note (v8.1.0 API decision): there is NO action field. A redirect is inferred from redirect_to being set. The v8.1 release ships ONLY redirect + model (reject / abstain-sentinel are DEFERRED). A future reject action can be added as an explicit action field then, without carrying a dead single-valued Literal today.

The policy is a @dataclass(frozen=True) (NOT pydantic) because it carries bare callables (match, guard, args_transform). FrameworkConfig is a frozen Pydantic model; a frozen dataclass holds callables cleanly and mirrors the existing DispatchContext resolver-contract convention (resolvers.py).

ToolCallPolicy dataclass

ToolCallPolicy(
    name: str,
    match: Callable[[DispatchContext], bool],
    when_tool: str | frozenset[str] | None = None,
    redirect_to: str | None = None,
    model: ModelConfig | None = None,
    args_transform: Callable[
        [dict[str, Any], DispatchContext], dict[str, Any]
    ]
    | None = None,
    guard: Callable[[DispatchContext], bool] | None = None,
)

Declarative {intent -> model + tool-redirect} rule (v8.1.0).

Fields:

  • name -- required, non-empty. For logging / inspection / test assertions.
  • match -- the intent gate, Callable[[DispatchContext], bool]. Runs at BOTH seams against an equivalent DispatchContext (the adopter classifies intent off messages[-1].content / scope / resolved_tool_names). Core ships NO intent classifier -- intent is adopter business logic.
  • when_tool -- POST-model emitted-tool filter. str | frozenset[str] | None. None matches any emitted tool. NO meaning at the PRE-model seam (no tool exists yet there). A SEPARATE field from match so match stays a pure intent predicate reusable at both seams.
  • redirect_to -- POST-model target tool name. Setting it makes the policy a redirect policy. Subject to the palette gate (an unrouted target is dropped with a WARNING; the original dispatches).
  • model -- PRE-model per-intent model override (ModelConfig). Applied ONLY at the PRE-model seam.
  • args_transform -- optional POST-model arg rewriter, Callable[[args, ctx], args]. None passes args through.
  • guard -- optional deterministic precondition, Callable[[DispatchContext], bool]. Distinct from match: match = "is this the right intent?"; guard = "is the precondition satisfiable?" (e.g. the stored table exists for THIS scope). guard gates the REDIRECT only -- it does NOT gate model-selection (a missing data source must not change which model reasons). The policy redirects only when match(ctx) AND (guard is None or guard(ctx)).

matches_tool

matches_tool(tool_name: str) -> bool

True when tool_name satisfies when_tool (POST-model).

Source code in src/symfonic/core/contracts/tool_policy.py
def matches_tool(self, tool_name: str) -> bool:
    """True when ``tool_name`` satisfies ``when_tool`` (POST-model)."""
    if self.when_tool is None:
        return True
    if isinstance(self.when_tool, str):
        return tool_name == self.when_tool
    return tool_name in self.when_tool