symfonic.capabilities.tools.stages¶
stages ¶
The selection stages (T3.1.2).
Six levers that used to be six unrelated shapes — a pure function, an engine coroutine, a config tuple read in two places, two deps-registered Protocols, and a registry keyword set — now share one signature, so the composition order is something you can read instead of reconstruct.
AllowlistStage ¶
Re-add mandated tools that an earlier stage dropped.
A mandated name that is not in the catalogue is ignored rather than fabricated: the framework constrains the choice set, it never authors tools.
Source code in src/symfonic/capabilities/tools/stages/allowlist.py
ForcedChoiceStage ¶
ForcedChoiceStage(*, resolver: Any = None, state_key: str = 'forced_tool_choice', is_tool_message: Callable[[Any], bool] = _default_is_tool_message)
Resolve the forced tool_choice for this iteration.
Precedence, unchanged from v7.10:
- an explicit
state["forced_tool_choice"]stamp — tests and adopters pre-stamp directly; - the registered resolver, which re-runs every iteration so force and release are symmetric by construction;
None— let the model choose, the safe default.
The release check runs on both paths: once a ToolMessage naming
the forced tool is in history the force is spent. A resolver that
raises releases too, because a stalled loop is worse than an
unforced turn.
Source code in src/symfonic/capabilities/tools/stages/forced.py
IntentRoutingStage ¶
IntentRoutingStage(*, verdict: Any = None, trigger_keywords: Mapping[str, Sequence[str]] | None = None, always_include: Sequence[str] = DEFAULT_ALWAYS_INCLUDE)
Narrow the turn's tools from an IntentVerdict-shaped object.
Decision matrix (unchanged from v7.0.1):
- no verdict, or
ambiguous— abstain. A noisy signal must not cost recall. knowledge— keep only the always-include names.action— keep the verdict's matched tools, plus every tool the domain declares no trigger keywords for (pre-v6.1.8 semantics: an absent entry means "always include"), plus always-include.
Source code in src/symfonic/capabilities/tools/stages/intent.py
decide ¶
The decision matrix, synchronously.
Split out because the v7.0.1 entry point
(narrow_tools_for_intent) is synchronous and is called from
inside a running event loop. One implementation, two callers —
the alternative was asyncio.run from a coroutine, which
raises, or a second copy of the matrix, which drifts.
Source code in src/symfonic/capabilities/tools/stages/intent.py
LazyRoutingStage ¶
PolicyStage ¶
PolicyStage(*, catalog: ToolCatalog, max_safety_level: Any = None, budget_usd: float | None = None, include_unknown_cost: bool = True, available_capabilities: set[type] | None = None, agent_visible_only: bool = False, categories: Sequence[Any] | None = None)
Drop tools the configured policy forbids.
A tool absent from the catalogue is kept. Its metadata is unknown, and denying it would make "the registry has never heard of this tool" indistinguishable from "policy forbids this tool" — the first is a wiring bug that has to stay visible.
Source code in src/symfonic/capabilities/tools/stages/policy.py
RolePaletteStage ¶
Narrow to the palette a role resolver returns.
Three answers, three meanings:
None— no policy. Abstain (the safe default for an emptyrole_toolsmap or an unmapped role).- a non-empty list — the palette. It can only narrow; a resolver returning tools that were not offered is not honoured, because the framework constrains the choice set and never authors it.
[]— abstain with a warning. The Protocol has always documented that the consumer "will detect this and abstain toall_toolswith a WARN"; before T3.1.2 nothing did, and an empty palette reached_bind_tools, which binds nothing for an empty list — silently dropping every tool AND any forcedtool_choice.
Source code in src/symfonic/capabilities/tools/stages/role.py
SelectionStage ¶
Bases: Protocol
One narrowing (or widening) lever in the selection pipeline.
name labels the stage in the decision trace.
protects_forced declares whether the pipeline reinstates a forced
tool this stage dropped. It is True for stages that run after
force resolution and express a preference (a role palette, a policy
ceiling); a stage that must be able to drop a forced tool — a hard
security filter, say — sets it False and says so.
apply never raises for a caller's benefit: the pipeline contains
failures either way, but a stage that can degrade meaningfully should
return StageOutcome.abstain(degraded=True) and log why.