Skip to content

symfonic.agent.triage.tool_routing

tool_routing

Dynamic tool routing — per-turn resolved_tools narrowing (v7.0.1).

v7.0.0 shipped the IntentClassifier (T06/T07) but only wired it into the hydration path. The Anthropic tools= API payload still carried the full static manifest every turn — paying 5-7k tokens per casual query when the tenant ran ~18 tools.

This module closes that gap. narrow_tools_for_intent computes the per-turn tool subset from an IntentVerdict and a list of registered BaseTool instances, reusing the DomainTemplate.tool_trigger_keywords map that v6.1.8 already ships for prompt-text gating. Pure-function / keyword intersection only — zero LLM calls, zero allocations beyond the output list.

The engine wires this via FrameworkConfig.tool_routing_mode:

  • off (default) — helper never runs, resolved_tools stays None, react.py binds the full static list. Byte-for-byte identical to v7.0.0.
  • observe — helper runs, narrowed set is computed, a tool_routing_decision callback event fires, but resolved_tools receives the full manifest so the LLM sees no behaviour change. Safe to enable in prod while gathering data.
  • enforce — narrowed set is written to state["resolved_tools"] and becomes the only list the LLM sees this turn.

estimate_dropped_tokens

estimate_dropped_tokens(dropped_tools: Sequence[Any]) -> int

Rough token estimate for the tools we chose not to bind.

Used for the tool_routing_decision telemetry event so operators can correlate classifier behaviour with token savings without instrumenting the provider payload themselves. Uses the standard chars // 4 rule of thumb against description + name. Schemas are ignored: the estimate is a floor, not a ceiling.

Source code in symfonic/agent/triage/tool_routing.py
def estimate_dropped_tokens(dropped_tools: Sequence[Any]) -> int:
    """Rough token estimate for the tools we chose not to bind.

    Used for the ``tool_routing_decision`` telemetry event so
    operators can correlate classifier behaviour with token savings
    without instrumenting the provider payload themselves. Uses the
    standard ``chars // 4`` rule of thumb against ``description`` +
    ``name``. Schemas are ignored: the estimate is a floor, not a
    ceiling.
    """
    total = 0
    for tool in dropped_tools:
        desc = getattr(tool, "description", "") or ""
        name = tool_name(tool) or ""
        total += (len(desc) + len(name)) // 4
    return total

narrow_tools_for_intent

narrow_tools_for_intent(verdict: IntentVerdict | None, all_tools: Sequence[Any], domain: DomainTemplate | None, *, always_include: Sequence[str] = DEFAULT_ALWAYS_INCLUDE) -> list[Any]

Return the subset of all_tools to bind for the current turn.

Decision matrix:

  • verdict is None or the verdict is ambiguous — return all_tools unchanged. Conservative fallback preserves recall whenever the signal is noisy.
  • verdict.label == "knowledge" — return only the always_include tools that exist in all_tools. The classifier is confident the turn is a lookup; the LLM should not need action tools.
  • verdict.label == "action" — keep any tool whose name appears in verdict.matched_tool_keywords (per the domain's tool_trigger_keywords map). Tools with no keyword list declared in the domain are always kept for back-compat (v6.1.8 semantics: absent entry means "always include"). Always add the always_include tools.

Parameters:

Name Type Description Default
verdict IntentVerdict | None

Output of IntentClassifier.classify for the current query. None triggers the fallback.

required
all_tools Sequence[Any]

The full tool manifest registered on the agent. Each element must expose a name attribute (satisfied by langchain_core.tools.BaseTool and by the demo stubs used in tests).

required
domain DomainTemplate | None

The active DomainTemplate. Used to read tool_trigger_keywords. None collapses to the "no trigger keywords declared" case — every tool is considered for action turns.

required
always_include Sequence[str]

Tool names that must appear in every narrowed set regardless of verdict. Defaults to ("hydrate_context",).

DEFAULT_ALWAYS_INCLUDE

Returns:

Type Description
list[Any]

A list of tools preserving the input order. Never raises;

list[Any]

unknown tool names, missing name attributes, and empty

list[Any]

all_tools all return the same conservative fallback.

T3.1.2: the decision matrix itself now lives in :class:~symfonic.capabilities.tools.IntentRoutingStage. This function is the synchronous v7.0.1 entry point and keeps its exact contract — including returning [] for an empty input and the input list (not a stage abstention) for the fallback paths.

Source code in symfonic/agent/triage/tool_routing.py
def narrow_tools_for_intent(
    verdict: IntentVerdict | None,
    all_tools: Sequence[Any],
    domain: DomainTemplate | None,
    *,
    always_include: Sequence[str] = DEFAULT_ALWAYS_INCLUDE,
) -> list[Any]:
    """Return the subset of ``all_tools`` to bind for the current turn.

    Decision matrix:

    - ``verdict`` is ``None`` or the verdict is ``ambiguous`` —
      return ``all_tools`` unchanged. Conservative fallback preserves
      recall whenever the signal is noisy.
    - ``verdict.label == "knowledge"`` — return only the
      ``always_include`` tools that exist in ``all_tools``. The
      classifier is confident the turn is a lookup; the LLM should
      not need action tools.
    - ``verdict.label == "action"`` — keep any tool whose name
      appears in ``verdict.matched_tool_keywords`` (per the domain's
      ``tool_trigger_keywords`` map). Tools with no keyword list
      declared in the domain are always kept for back-compat (v6.1.8
      semantics: absent entry means "always include"). Always add
      the ``always_include`` tools.

    Args:
        verdict: Output of ``IntentClassifier.classify`` for the
            current query. ``None`` triggers the fallback.
        all_tools: The full tool manifest registered on the agent.
            Each element must expose a ``name`` attribute (satisfied
            by ``langchain_core.tools.BaseTool`` and by the demo
            stubs used in tests).
        domain: The active ``DomainTemplate``. Used to read
            ``tool_trigger_keywords``. ``None`` collapses to the
            "no trigger keywords declared" case — every tool is
            considered for action turns.
        always_include: Tool names that must appear in every
            narrowed set regardless of verdict. Defaults to
            ``("hydrate_context",)``.

    Returns:
        A list of tools preserving the input order. Never raises;
        unknown tool names, missing ``name`` attributes, and empty
        ``all_tools`` all return the same conservative fallback.

    T3.1.2: the decision matrix itself now lives in
    :class:`~symfonic.capabilities.tools.IntentRoutingStage`. This
    function is the synchronous v7.0.1 entry point and keeps its exact
    contract — including returning ``[]`` for an empty input and the
    *input list* (not a stage abstention) for the fallback paths.
    """
    if not all_tools:
        return []

    stage = IntentRoutingStage(
        verdict=verdict,
        trigger_keywords=(
            dict(getattr(domain, "tool_trigger_keywords", None) or {})
            if domain is not None
            else {}
        ),
        always_include=always_include,
    )
    outcome = stage.decide(all_tools)
    if outcome.abstained:
        return list(all_tools)
    return list(outcome.tools or ())