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).

Explicit RoutingUnavailable is a different, opt-in composition outcome: missing prerequisites retain the full palette before selection policy, warn once, and cannot certify compatibility cutover routing. Actual router declines below retain their existing answers and do not become prerequisite warnings.

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.

RoutingUnavailable dataclass

RoutingUnavailable(missing: tuple[str, ...])

Composition's explicit construction-or-no-palette report, NOT router decline.

Names missing routing prerequisites, or (no-registered-tools) that no palette exists to resolve. No probing, retrieval or fictitious empty router. It does not authorize compatibility-envelope admission or establish legacy warning byte parity. warn's advice to compose a serving router assumes registered tools; the host composes the no-registered-tools report with its warning already issued, so that advice is never emitted for it there.

ToolRoutingUnavailableWarning

Bases: UserWarning

Native composition cannot route; full palette precedes selection policy.

Explicit v11 category/text difference from legacy LazyToolingWarning. One warn call per capability construction, never per tool/model round. Display and deduplication still respect the caller's Python warning filters.

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))