Skip to content

symfonic.capabilities.tools.keywords

keywords

Per-turn tool narrowing from a keyword map, composed publicly.

:class:~.capability.ToolsCapability already narrows a turn's palette and takes a low-level router: an async callable returning procedural rows whose action_tool names a tool. That is the right seam for a deployment with a procedural memory layer, and the wrong one for a scaffold, which expresses the same intent as DomainTemplate.tool_trigger_keywords -- a map from a tool to the words that should surface it. The template field goes away here; the intent does not.

Narrowing only. The plan binds every registered tool once, at compile time. A router decides what a turn offers the model. It cannot add a tool the plan did not bind, and a map naming one is refused at composition rather than producing a keyword that silently never works: routing drops unresolved names, so the failure would otherwise reach the deployment as "that keyword does nothing".

No state. A router is built once and used for every turn of every tenant a host serves, so anything remembered on it would be cross-turn state at best and cross-tenant state at worst.

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.

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