Skip to content

symfonic.capabilities.tools.procedural

procedural

Narrow an Agent's palette on cue-matched procedural skills (CORE-227).

The owner contract, composed for a native Agent or an AgentHost composer: only a procedural row selected by cue match narrows the palette. Importance-fallback and empty-cue rows are not routing evidence, so the turn keeps every registered tool and its routing stage says why. A layer that cannot report row origin is refused at composition instead of being routed origin-blind. A router over no registered tools has no palette to resolve: it composes no routing stage and reads nothing, so the turn stays native and one AgentHost composer need not branch on a tenant's empty tool set (the owner decision for zero registered tools). A decline here is a router's no-match answer, not an unavailable report, so nothing is counted under tools.routing_unavailable.

Narrowing on a matched cue is an Agent improvement, not legacy bind_tools parity. Narrowing selects what the model is offered; it does not authorise or forbid a call -- preconditions do that.

The layer is read structurally (retrieve_report). On Agent and AgentHost the router reads the TenantScope it was composed for: their turns state no scope. Only a caller that builds a kernel TurnRequest with scope= itself routes on that scope, and a stated scope that is not a TenantScope reads nothing and allows every tool.

CueMatchedPalette dataclass

CueMatchedPalette(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, ...] = (), routing_unavailable: RoutingUnavailable | None = None, warning_already_issued: bool = False, count_unavailable: bool = True, declined: dict = dict())

Bases: ToolsCapability

A router whose decline names the origins it would not narrow on.

The resolver reads an empty match as "no skills in the procedural layer", which is false when unmatched rows exist; the reason is restated per turn.

procedural_router

procedural_router(layer: Any, scope: Any, *, registered: Sequence[str], top_k: int = 10, stages: Sequence[Any] = (), preconditions: Sequence[Any] = ()) -> CueMatchedPalette

A ToolsCapability that narrows only on cue-matched skills of layer.

The composed Agent must also be granted memory-read (GrantEffects("memory-read")): the router reads the procedural layer.

With nothing registered it composes no router: no routing stage, no memory-read, no count. stages and preconditions still compose.

Raises:

Type Description
ConfigurationError

layer cannot report row origin, scope is not a TenantScope, or top_k is not a positive int.

Source code in src/symfonic/capabilities/tools/procedural.py
def procedural_router(
    layer: Any,
    scope: Any,
    *,
    registered: Sequence[str],
    top_k: int = 10,
    stages: Sequence[Any] = (),
    preconditions: Sequence[Any] = (),
) -> CueMatchedPalette:
    """A ``ToolsCapability`` that narrows only on cue-matched skills of ``layer``.

    The composed ``Agent`` must also be granted ``memory-read``
    (``GrantEffects("memory-read")``): the router reads the procedural layer.

    With nothing ``registered`` it composes no router: no routing stage, no
    ``memory-read``, no count. ``stages`` and ``preconditions`` still compose.

    Raises:
        ConfigurationError: ``layer`` cannot report row origin, ``scope`` is
            not a ``TenantScope``, or ``top_k`` is not a positive ``int``.
    """
    from symfonic.core.scope import TenantScope

    if not callable(getattr(layer, "retrieve_report", None)):
        raise ConfigurationError(
            "procedural_router needs a layer with retrieve_report: rows without an "
            "origin cannot tell a cue match from an importance fallback"
        )
    if not isinstance(scope, TenantScope):
        raise ConfigurationError(
            f"procedural_router needs a TenantScope, not {type(scope).__name__}"
        )
    names = tuple(registered)
    if type(top_k) is not int or top_k < 1:
        raise ConfigurationError("procedural_router top_k must be a positive int")
    declined: dict[int, tuple[str, ...]] = {}

    async def entries_for(request: Any) -> Any:
        stated = getattr(request, "scope", None)
        target = scope if stated is None else stated
        if not isinstance(target, TenantScope):
            raise TypeError(f"the turn states a {type(target).__name__}, not a TenantScope")
        cue = str(getattr(request, "prompt", "") or "")
        hits = await layer.retrieve_report(target, cue, top_k=top_k)
        matched = [hit.entry for hit in hits if hit.origin in NARROWING_ORIGINS]
        if hits and not matched:
            declined[id(request)] = tuple(sorted({hit.origin for hit in hits}))
        return matched

    return CueMatchedPalette(
        entries_for=entries_for if names else None,
        registered=names,
        stages=tuple(stages),
        preconditions=tuple(preconditions),
        declined=declined,
    )