Skip to content

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

AllowlistStage(*, always_include: Sequence[str] = (), catalog: ToolCatalog | None = None)

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
def __init__(
    self,
    *,
    always_include: Sequence[str] = (),
    catalog: ToolCatalog | None = None,
) -> None:
    self._always_include = tuple(name for name in always_include if name)
    self._catalog = catalog

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:

  1. an explicit state["forced_tool_choice"] stamp — tests and adopters pre-stamp directly;
  2. the registered resolver, which re-runs every iteration so force and release are symmetric by construction;
  3. 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
def __init__(
    self,
    *,
    resolver: Any = None,
    state_key: str = "forced_tool_choice",
    is_tool_message: Callable[[Any], bool] = _default_is_tool_message,
) -> None:
    self._resolver = resolver
    self._state_key = state_key
    self._is_tool_message = is_tool_message

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
def __init__(
    self,
    *,
    verdict: Any = None,
    trigger_keywords: Mapping[str, Sequence[str]] | None = None,
    always_include: Sequence[str] = DEFAULT_ALWAYS_INCLUDE,
) -> None:
    self._verdict = verdict
    self._trigger_keywords = dict(trigger_keywords or {})
    self._always_include = tuple(name for name in always_include if name)

decide

decide(tools: Sequence[Any]) -> StageOutcome

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
def decide(self, tools: Sequence[Any]) -> StageOutcome:
    """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.
    """
    tools = tuple(tools)
    if not tools:
        return StageOutcome.abstain("no candidate tools")

    verdict = self._verdict
    label = getattr(verdict, "label", None)
    if verdict is None or label == "ambiguous":
        return StageOutcome.abstain("no actionable verdict")

    always = set(self._always_include)

    if label == "knowledge":
        # Unnamed tools survive here exactly as they do on the action
        # path: they cannot be gated by a name-keyed policy, and the
        # v7.0.1 helper kept them.
        return StageOutcome.select(
            filter_by_names(tools, always),
            "knowledge turn: always-include only",
        )

    keep = set(always)
    keep.update(getattr(verdict, "matched_tool_keywords", None) or [])
    for tool in tools:
        name = tool_name(tool)
        if name is None:
            continue
        if not self._trigger_keywords.get(name):
            keep.add(name)

    return StageOutcome.select(
        filter_by_names(tools, keep), f"{label} turn: keyword intersection",
    )

LazyRoutingStage

LazyRoutingStage(*, allowed_names: Sequence[str] | None = None)

Narrow to the tool names procedural routing resolved.

Source code in src/symfonic/capabilities/tools/stages/lazy.py
def __init__(self, *, allowed_names: Sequence[str] | None = None) -> None:
    self._allowed = None if allowed_names is None else tuple(allowed_names)

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
def __init__(
    self,
    *,
    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,
) -> None:
    self._catalog = catalog
    self._max_safety_rank = _safety_rank(max_safety_level)
    self._budget_usd = budget_usd
    self._include_unknown_cost = include_unknown_cost
    self._available_capabilities = available_capabilities
    self._agent_visible_only = agent_visible_only
    self._categories = (
        None if categories is None else {_category_value(c) for c in categories}
    )

RolePaletteStage

RolePaletteStage(*, resolver: Any = None, role: str = '')

Narrow to the palette a role resolver returns.

Three answers, three meanings:

  • None — no policy. Abstain (the safe default for an empty role_tools map 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 to all_tools with 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 forced tool_choice.
Source code in src/symfonic/capabilities/tools/stages/role.py
def __init__(self, *, resolver: Any = None, role: str = "") -> None:
    self._resolver = resolver
    self._role = role

RoutedTool dataclass

RoutedTool(content: str, metadata: Mapping[str, Any] = dict())

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

resolved_messages() -> Sequence[Any]

messages if supplied, else the conversation on state.

Source code in src/symfonic/capabilities/tools/context.py
def resolved_messages(self) -> Sequence[Any]:
    """``messages`` if supplied, else the conversation on ``state``."""
    if self.messages:
        return self.messages
    return self.state.get("messages") or ()

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

force(choice: str | None, detail: str = '', *, degraded: bool = False) -> StageOutcome

Record a forced tool choice without touching the candidate set.

Source code in src/symfonic/capabilities/tools/values.py
@classmethod
def force(
    cls, choice: str | None, detail: str = "", *, degraded: bool = False,
) -> StageOutcome:
    """Record a forced tool choice without touching the candidate set."""
    return cls(
        tools=None,
        forced_choice=choice,
        sets_forced_choice=True,
        degraded=degraded,
        detail=detail,
    )

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

ToolCatalog(descriptors: Sequence[ToolDescriptor])

An ordered, immutable set of :class:ToolDescriptor readings.

Source code in src/symfonic/capabilities/tools/catalog.py
def __init__(self, descriptors: Sequence[ToolDescriptor]) -> None:
    by_name: dict[str, ToolDescriptor] = {}
    for descriptor in descriptors:
        if descriptor.name in by_name:
            raise ValueError(
                f"duplicate tool name {descriptor.name!r} in the catalogue; "
                "two tools cannot share a name (rename one with "
                "@symfonic_tool(name=...))"
            )
        by_name[descriptor.name] = descriptor
    self._descriptors: tuple[ToolDescriptor, ...] = tuple(descriptors)
    self._by_name = by_name

from_registry classmethod

from_registry(registry: ToolRegistryLike) -> ToolCatalog

Read a registry, lifting each tool's registration metadata.

Source code in src/symfonic/capabilities/tools/catalog.py
@classmethod
def from_registry(cls, registry: ToolRegistryLike) -> ToolCatalog:
    """Read a registry, lifting each tool's registration metadata."""
    descriptors: list[ToolDescriptor] = []
    for tool in registry.all_tools():
        name = getattr(tool, "name", None)
        registration = (
            registry.get_registration(name) if isinstance(name, str) else None
        )
        descriptors.append(
            ToolDescriptor.from_tool(tool, registration=registration)
        )
    return cls(descriptors)

from_tools classmethod

from_tools(tools: Iterable[Any]) -> ToolCatalog

Read a bare sequence of tool objects (no registry metadata).

Source code in src/symfonic/capabilities/tools/catalog.py
@classmethod
def from_tools(cls, tools: Iterable[Any]) -> ToolCatalog:
    """Read a bare sequence of tool objects (no registry metadata)."""
    return cls([ToolDescriptor.from_tool(tool) for tool in tools])

manifest_entries

manifest_entries() -> tuple[str, ...]

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
def manifest_entries(self) -> tuple[str, ...]:
    """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.
    """
    return tuple(d.manifest_entry for d in self.visible_descriptors())

resolve

resolve(names: Iterable[str]) -> tuple[Any, ...]

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
def resolve(self, names: Iterable[str]) -> tuple[Any, ...]:
    """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.
    """
    wanted = {name for name in names}
    return tuple(d.tool for d in self._descriptors if d.name in wanted)

subset

subset(names: Iterable[str]) -> ToolCatalog

A catalogue holding only names, in catalogue order.

Source code in src/symfonic/capabilities/tools/catalog.py
def subset(self, names: Iterable[str]) -> ToolCatalog:
    """A catalogue holding only ``names``, in catalogue order."""
    wanted = {name for name in names}
    return ToolCatalog([d for d in self._descriptors if d.name in wanted])

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.

manifest_entry property

manifest_entry: str

The rendered name: summary manifest line.

summary property

summary: str

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

from_tool(tool: Any, *, registration: Any = None) -> ToolDescriptor

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
@classmethod
def from_tool(cls, tool: Any, *, registration: Any = None) -> ToolDescriptor:
    """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.
    """
    raw_name = getattr(tool, "name", None)
    name = raw_name if isinstance(raw_name, str) and raw_name else type(tool).__name__

    sym_meta = getattr(tool, "_symfonic_metadata", None)
    visibility = getattr(sym_meta, "visibility", None)
    lifted = _lifted_metadata(tool)

    hidden = (
        visibility == "hidden"
        or lifted.get("visible_to_agents") is False
        or getattr(registration, "visible_to_agents", True) is False
    )

    return cls(
        name=name,
        tool=tool,
        description=getattr(tool, "description", "") or "",
        hidden=hidden,
        category=getattr(registration, "category", None),
        safety_level=getattr(registration, "safety_level", None),
        cost_usd=getattr(registration, "cost_usd", None),
        requires=getattr(registration, "requires", None),
        max_calls_per_invocation=getattr(
            registration, "max_calls_per_invocation", None,
        ),
        scope=getattr(sym_meta, "scope", None),
        routing_mode=getattr(sym_meta, "routing_mode", None),
    )

ToolPalette dataclass

ToolPalette(names: frozenset[str] | None = None, forced: str | None = None)

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

check(registered: frozenset[str]) -> None

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
def check(self, registered: frozenset[str]) -> None:
    """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.
    """
    if self.forced is None:
        return
    if self.forced not in registered:
        raise ForcedToolUnavailable(
            f"this turn forces {self.forced!r}, which is not a registered "
            f"tool. Registered: {sorted(registered) or 'none'}."
        )
    if self.names is not None and self.forced not in self.names:
        raise ForcedToolUnavailable(
            f"this turn forces {self.forced!r} and resolves a palette that "
            f"excludes it ({sorted(self.names)}). A selection stage that "
            "declares protects_forced = False dropped it deliberately, so "
            "the force and that stage disagree about this call. Align them "
            "rather than letting one win silently."
        )

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

ToolSelectionPipeline(stages: Sequence[SelectionStage])

Run selection stages in order and report what they decided.

Source code in src/symfonic/capabilities/tools/pipeline.py
def __init__(self, stages: Sequence[SelectionStage]) -> None:
    self._stages = tuple(stages)

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

contribute(request: CapabilityRequest) -> CapabilityContribution

Declare the routing stage and the handler that answers it.

Source code in src/symfonic/capabilities/tools/capability.py
def contribute(self, request: CapabilityRequest) -> CapabilityContribution:
    """Declare the routing stage and the handler that answers it."""

    async def handle(context: Any) -> StageResult[Any]:
        turn_request = getattr(context, "request", None)
        if turn_request is None:  # pragma: no cover - defensive
            return no_change("no turn request on the stage context")
        cue = self.cue_from(turn_request)
        outcome: Any = _NO_ROUTER
        if self.entries_for is not None:
            entries_for = self.entries_for
            outcome = await resolve_palette(
                entries=lambda: entries_for(turn_request),
                registered=self.registered,
            )
        # Every routing refusal means *allow every tool*. It is not the end
        # of the turn's resolution any more: a forced choice is a decision
        # about which call must happen, and it survives a router that had
        # nothing to say about which calls exist.
        routed = outcome.names if isinstance(outcome, Routed) else None
        # Three states, not two: routed, refused-with-a-reason, and no
        # router composed at all. Collapsing the last two reads a reason
        # off a sentinel.
        reason = getattr(outcome, "reason", "") or "no router composed"

        if not self.stages:
            if routed is None:
                # Reported rather than silent: "why is that tool never
                # offered?" is answered by this reason.
                return no_change(reason)
            return applied(
                ResolvedInput(
                    capability=TOOLS_CAPABILITY,
                    value=routed,
                    # The cue that produced this palette, so a reader of a
                    # turn can get from "these tools" back to "for this
                    # question".
                    provenance=cue[:120],
                )
            )

        from symfonic.capabilities.tools.pipeline import (
            ToolSelectionPipeline,
        )
        from symfonic.capabilities.tools.selection import run_selection

        selection = await run_selection(
            ToolSelectionPipeline(self.stages),
            sorted(routed) if routed is not None else self.registered,
            state=getattr(turn_request, "properties", None),
            messages=getattr(turn_request, "history", ()),
            query=cue,
        )
        names = frozenset(selection.names)
        palette = ToolPalette(
            names=names if routed is not None or names else None,
            forced=selection.forced_choice,
        )
        if palette.names is None and palette.forced is None:
            return no_change(reason or "selection narrowed nothing")
        return applied(
            ResolvedInput(
                capability=TOOLS_CAPABILITY,
                value=palette,
                provenance=_provenance(cue, selection),
            )
        )

    # Declared from what this composition actually does. The router reads
    # the procedural layer; the stages read the turn. A capability that
    # asked for ``memory-read`` in order to run an allowlist would be
    # holding authority it never exercises, which is the shape of every
    # over-grant an audit later has to reason about.
    effects = (
        frozenset({"memory-read"})
        if self.entries_for is not None
        else frozenset()
    )
    if not self.entries_for and not self.stages:
        # Preconditions only: nothing to resolve before the model, so no
        # stage is declared. The gate still runs, because it hangs off the
        # dispatch point rather than off a stage -- which is the whole
        # reason it can judge the amended call.
        return CapabilityContribution(
            capability=TOOLS_CAPABILITY,
            preconditions=self.preconditions,
        )
    return CapabilityContribution(
        capability=TOOLS_CAPABILITY,
        preconditions=self.preconditions,
        stages=(
            StageDescriptor(
                stage_id=ROUTING_STAGE,
                phase=Phase.PROMPT_ASSEMBLY,
                capability=TOOLS_CAPABILITY,
                priority=_ROUTING_PRIORITY,
                effects=effects,
                kind=StageKind.RESOLUTION,
                emits=frozenset({"tools.routed"}),
            ),
        ),
        handlers={ROUTING_STAGE: handle},
        effect_grants=effects,
    )

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 "¿VENDIDOS?" would look like a routing policy and behave like a bug.

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 keywords is empty, or names a tool outside registered.

Source code in src/symfonic/capabilities/tools/keywords.py
def keyword_router(
    keywords: Mapping[str, Iterable[str]],
    *,
    registered: Iterable[str] | None = None,
) -> Any:
    """An ``entries_for`` router that surfaces tools whose words appear.

    Args:
        keywords: tool name -> the words that should surface it. Matching is
            case-insensitive and accent-insensitive, because a router that
            missed ``"¿VENDIDOS?"`` would look like a routing policy and behave
            like a bug.
        registered: the palette the plan binds. When given, a keyword map
            naming anything outside it is refused here.

    Raises:
        ValueError: if ``keywords`` is empty, or names a tool outside
            ``registered``.
    """
    if not keywords:
        raise ValueError(
            "a keyword router with no keywords can never route: it would "
            "satisfy the capability's 'a router exists' precondition and then "
            "refuse every turn for want of entries, which reads as a routing "
            "decision rather than as a missing configuration"
        )

    if registered is not None:
        palette = set(registered)
        unknown = sorted(set(keywords) - palette)
        if unknown:
            raise ValueError(
                f"these keyword entries name tools the plan does not bind: "
                f"{unknown}. Routing drops names it cannot resolve, so the "
                "deployment would see a keyword that silently never works."
            )

    folded = {
        tool: tuple(_fold(word) for word in words if _fold(word))
        for tool, words in keywords.items()
    }

    async def entries_for(turn_request: Any) -> list[RoutedTool]:
        cue = _fold(str(getattr(turn_request, "prompt", "") or ""))
        if not cue:
            return []
        return [
            RoutedTool(content=tool, metadata={"action_tool": tool, "active": True})
            for tool, words in folded.items()
            if any(word in cue for word in words)
        ]

    return entries_for

tool_name

tool_name(tool: Any) -> str | None

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.

Source code in src/symfonic/capabilities/tools/values.py
def tool_name(tool: Any) -> str | None:
    """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.
    """
    name = getattr(tool, "name", None)
    if isinstance(name, str) and name:
        return name
    return None