Skip to content

symfonic.capabilities.tools.routing

routing

Lazy tool routing as a capability the kernel can run.

_lazy_resolve_tools narrows the palette from the turn's own input by asking the procedural layer which skills are relevant and resolving those to registered tool names. It is per-turn and asynchronous; the plan binds tools once and is immutable. This module is the resolution half — it produces the subset and leaves it in the turn's snapshot, and :mod:symfonic.kernel.palette binds it at the model-call boundary.

Four refusals, each preserved and each with its reason. They are not error handling; they are the documented answers legacy gives, and every one of them means allow every tool rather than allow none:

  • no procedural layer registered — nothing to route between;
  • the layer returned no entries — no routing information exists;
  • every returned skill is inactive — the deployment turned them all off;
  • a skill resolves to no registered tool — a skill whose content is prose would otherwise be appended verbatim and rendered as a phantom tool the model is told it can call (issue #36).

The fourth is why resolution is checked against the registered palette rather than trusted: an unresolved name is dropped, and dropping every name lands on the third refusal rather than on an empty palette.

Routed dataclass

Routed(names: frozenset[str], unresolved: tuple[str, ...] = ())

The palette a turn resolved to, and what it dropped getting there.

RoutingRefusal dataclass

RoutingRefusal(reason: str, unresolved: tuple[str, ...] = ())

Why a turn was not narrowed, in the resolver's own words.

resolve_palette async

resolve_palette(*, entries: Callable[[], Any], registered: Sequence[str]) -> Routed | RoutingRefusal

Narrow registered to the tools this turn's skills name.

entries is a zero-argument awaitable returning the procedural layer's hits. A callable rather than the rows themselves so a refusal short-circuits the query instead of ordering it and discarding the answer.

Source code in src/symfonic/capabilities/tools/routing.py
async def resolve_palette(
    *,
    entries: Callable[[], Any],
    registered: Sequence[str],
) -> Routed | RoutingRefusal:
    """Narrow ``registered`` to the tools this turn's skills name.

    ``entries`` is a zero-argument awaitable returning the procedural layer's
    hits. A callable rather than the rows themselves so a refusal short-circuits
    the query instead of ordering it and discarding the answer.
    """
    try:
        rows = await entries()
    except Exception as exc:  # noqa: BLE001 - a store fault is not a turn fault
        return RoutingRefusal(
            reason=f"procedural retrieval failed ({type(exc).__name__}); "
            "allowing all tools"
        )
    if not rows:
        return RoutingRefusal(
            reason="no skills in the procedural layer; allowing all tools"
        )

    active = [row for row in rows if _is_active(row)]
    if not active:
        return RoutingRefusal(
            reason="every skill is inactive; allowing all tools"
        )

    known = set(registered)
    names: list[str] = []
    unresolved: list[str] = []
    for row in active:
        candidate = _candidate_of(row)
        if not candidate:
            continue
        if candidate in known:
            if candidate not in names:
                names.append(candidate)
        elif candidate not in unresolved:
            unresolved.append(candidate)

    if not names:
        return RoutingRefusal(
            reason="no skill resolved to a registered tool; allowing all tools",
            unresolved=tuple(unresolved),
        )
    return Routed(names=frozenset(names), unresolved=tuple(unresolved))