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 src/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.

Source code in src/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.
    """
    if not all_tools:
        return []

    always_set = {name for name in always_include if name}

    # Fallback for the None / ambiguous / low-confidence path.
    if verdict is None or verdict.label == "ambiguous":
        return list(all_tools)

    if verdict.label == "knowledge":
        return _filter_by_names(all_tools, always_set)

    # verdict.label == "action"
    trigger_map: dict[str, list[str]] = {}
    if domain is not None:
        trigger_map = dict(getattr(domain, "tool_trigger_keywords", None) or {})

    # Union of: always_include + tools explicitly matched by the
    # verdict + tools with no trigger_keywords declared (back-compat).
    keep_names: set[str] = set(always_set)
    keep_names.update(verdict.matched_tool_keywords or [])

    # Include tools with no keyword list -- pre-v6.1.8 behaviour demands
    # "absent entry means always include".
    for tool in all_tools:
        name = _tool_name(tool)
        if name is None:
            continue
        if name not in trigger_map or not trigger_map[name]:
            keep_names.add(name)

    return _filter_by_names(all_tools, keep_names)