symfonic.capabilities.tools¶
tools ¶
Tool catalogue and selection capability (T3.1.2).
Two things live here:
The catalogue — one reading of the tool registry, and the only place tool metadata and the agent-facing manifest are derived. Whatever wants to know a tool's name, summary, visibility, cost, safety level, or required capability asks the catalogue; nothing re-derives it.
The selection stages — intent routing, lazy (procedural) routing, operator allowlists, role palettes, forced choice, and registry policy, each an object with the same signature, composed by an explicit pipeline that records what every stage decided. The order is a list you can read, not an emergent property of where the code happened to live.
The capability imports nothing but its own package (LAY-ADR §2): tools, registries, resolvers, and verdicts are all read structurally.
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
ForcedToolUnavailable ¶
Bases: ContractViolationError
A forced tool that cannot be offered. Raised before the model is called.
A ContractViolationError because that is what it is: two parts of a
composition disagreeing, or a composition naming a tool that does not
exist. A caller catching the framework's contract error catches this
without knowing the name.
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
RoutedTool
dataclass
¶
One routing row, in the shape :mod:..routing reads.
content and metadata['action_tool'] carry the same name because
routing prefers the second and falls back to the first; filling both means
this row survives either rule rather than depending on which one is
current.
SelectionContext
dataclass
¶
SelectionContext(state: MutableMapping[str, Any] = dict(), messages: Sequence[Any] = (), role: str = '', query: str = '')
What the stages know about the turn being selected for.
state is passed through to adopter-supplied resolvers by
identity, not by copy: the pre-T3.1.2 react node handed them the live
LangGraph state and a resolver that reads a tenant key out of it must
keep working.
forced_choice is the one mutable field. The pipeline writes it
when a stage resolves a force, and later stages (and the pipeline's
own protection rule) read it. It is deliberately not an input a
caller pre-seeds — the forced-choice stage owns that question, and a
second writer would recreate the "two answers to one question" bug
class the plan model exists to remove.
resolved_messages ¶
messages if supplied, else the conversation on state.
SelectionResult
dataclass
¶
SelectionResult(tools: tuple[Any, ...] = (), forced_choice: str | None = None, trace: tuple[StageRecord, ...] = ())
The tools to bind, the choice to force, and why.
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.
StageOutcome
dataclass
¶
StageOutcome(tools: tuple[Any, ...] | None = None, forced_choice: str | None = None, sets_forced_choice: bool = False, degraded: bool = False, detail: str = '')
One stage's answer.
tools is None means abstain — "no policy here, leave the set
alone" — and is distinct from an empty tuple, which means "policy
applied and nothing survived". Collapsing the two is how a resolver
that could not decide ends up handing the model zero tools.
force
classmethod
¶
Record a forced tool choice without touching the candidate set.
Source code in src/symfonic/capabilities/tools/values.py
StageRecord
dataclass
¶
StageRecord(stage: str, action: str, kept: tuple[str, ...] = (), dropped: tuple[str, ...] = (), added: tuple[str, ...] = (), reinstated: tuple[str, ...] = (), forced_choice: str | None = None, detail: str = '')
What one stage did, in names, for the decision trace.
ToolCatalog ¶
An ordered, immutable set of :class:ToolDescriptor readings.
Source code in src/symfonic/capabilities/tools/catalog.py
from_registry
classmethod
¶
Read a registry, lifting each tool's registration metadata.
Source code in src/symfonic/capabilities/tools/catalog.py
from_tools
classmethod
¶
Read a bare sequence of tool objects (no registry metadata).
manifest_entries ¶
The agent-facing manifest: name: summary per visible tool.
This is the single source the system prompt renders. A tool hidden by any of the three visibility signals never appears, so a deliberately-hidden tool cannot leak into a rendered prompt.
Source code in src/symfonic/capabilities/tools/catalog.py
resolve ¶
Return the live tools for names, in catalogue order.
Unknown names are dropped rather than raising: the caller is usually a router repeating a name from somewhere less trusted than the registry.
Source code in src/symfonic/capabilities/tools/catalog.py
subset ¶
A catalogue holding only names, in catalogue order.
ToolDescriptor
dataclass
¶
ToolDescriptor(name: str, tool: Any = None, description: str = '', hidden: bool = False, category: Any = None, safety_level: Any = None, cost_usd: float | None = None, requires: type | None = None, max_calls_per_invocation: int | None = None, scope: str | None = None, routing_mode: str | None = None)
Everything the framework knows about one tool, read once.
tool is the live object so a selection stage can hand the same
instance back to bind_tools; every other field is derived data
the rest of the framework used to re-derive for itself.
summary
property
¶
The first non-empty description line.
A LangChain tool's description is its whole docstring; framework tools run to several hundred characters of Args/Returns prose, enough to blow a JIT manifest budget on one entry.
from_tool
classmethod
¶
Read a tool (and optionally its registry registration).
Hidden-ness has three independent sources and any one of them is
decisive: @symfonic_tool(visibility="hidden"), the lifted
metadata["visible_to_agents"] is False a vanilla LangChain
tool can carry, and ToolRegistration.visible_to_agents. The
pre-T3.1.2 manifest derivation saw only the first two because it
never held the registration.
Source code in src/symfonic/capabilities/tools/values.py
ToolPalette
dataclass
¶
The tools this turn offers, and the one it requires.
names of None means the full palette -- the no-router case, and
the case a refusing router lands on. It must stay indistinguishable from
the behaviour before routing existed.
forced of None means the model chooses. Anything else is bound as
the provider's tool_choice, which is a requirement rather than a
suggestion: the model returns that call or the provider errors.
check ¶
Refuse a palette that cannot be honoured, here rather than later.
Two ways it cannot be, and both are deterministic -- the same composition and the same turn give the same refusal every time, which is what makes a forced selection something an operator can rely on rather than something that usually works.
Forcing a tool the deployment never registered is a configuration error wearing a runtime disguise: the provider would reject the call with its own message, on its own schedule, naming its own field.
Forcing a tool this turn's palette excludes is the sharper one. The
pipeline reinstates a forced tool that a protecting stage dropped,
so reaching here means a stage that declared protects_forced =
False -- a hard filter -- deliberately removed it. Two rules then
disagree about one call, and picking either silently is worse than
saying so: honour the force and the hard filter was decorative;
honour the filter and the force was.
Source code in src/symfonic/kernel/contracts/palette.py
ToolRegistryLike ¶
Bases: Protocol
The narrow slice of a tool registry the catalogue reads.
Structural on purpose: the capability layer may not import the
legacy symfonic.core.tools.registry module, and a registry that
answers these two questions is a valid source whatever else it is.
ToolSelectionPipeline ¶
ToolsCapability
dataclass
¶
ToolsCapability(entries_for: Callable[[Any], Any] | None = None, registered: tuple[str, ...] = (), cue_from: Callable[[Any], str] = lambda request: str(getattr(request, 'prompt', '') or ''), stages: tuple[Any, ...] = (), preconditions: tuple[Any, ...] = ())
Narrow the palette a turn offers the model, from the turn's own cue.
contribute ¶
Declare the routing stage and the handler that answers it.
Source code in src/symfonic/capabilities/tools/capability.py
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | |
keyword_router ¶
keyword_router(keywords: Mapping[str, Iterable[str]], *, registered: Iterable[str] | None = None) -> Any
An entries_for router that surfaces tools whose words appear.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
keywords
|
Mapping[str, Iterable[str]]
|
tool name -> the words that should surface it. Matching is
case-insensitive and accent-insensitive, because a router that
missed |
required |
registered
|
Iterable[str] | None
|
the palette the plan binds. When given, a keyword map naming anything outside it is refused here. |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
Source code in src/symfonic/capabilities/tools/keywords.py
tool_name ¶
Best-effort structural name read.
Returns None for an object with no usable name. Callers treat
None as "cannot be gated by name" and keep the tool, because
dropping it would make a naming bug look like a policy decision.