Skip to content

Guide 07 — Intent Classification & Dynamic Tool Routing

Two knobs shipped in v7.0 cut token cost on deployments with ten or more bound tools. Both are additive, both default off, both have a safe observe mode for a burn-in period before enforce.

  • intent_filter_mode (v7.0 T07) — label each turn knowledge / action / ambiguous before hydration. On action turns in enforce mode, semantic + episodic + procedural retrieval is skipped because tool-binding turns do not need that context.
  • tool_routing_mode (v7.0.1) — narrow the Anthropic tools= API payload per turn using the intent classifier's verdict. On a stub 18-tool fabric deployment, knowledge-intent queries dropped from 653 tokens of tool schemas to 12 tokens — 98% reduction.

The classifier is deterministic and uses verb+keyword matching with no LLM calls. It is hot-cached on the agent instance so enabling these knobs does not allocate per turn.

Prerequisites

  • Completed Guide 05 Production
  • A domain with more than ~6 tools
  • At least a rough DomainTemplate.tool_trigger_keywords map

The Three Modes

Both knobs share the same off / observe / enforce tri-state. The semantics differ by subsystem:

intent_filter_mode

Mode Hydration behaviour
"off" (default) Classifier never runs. Full hydration every turn — byte-for-byte v6.x behaviour.
"observe" Classifier runs. Emits ExtensionEvent(type="intent_classified") with verdict payload. Hydration unchanged.
"enforce" Classifier runs. action turns skip semantic + episodic + procedural retrieval. knowledge and ambiguous fall back to the full hydration path.

tool_routing_mode

Mode Tool binding behaviour
"off" (default) state["resolved_tools"] stays None. Full static list binds.
"observe" Narrower computes the subset. ToolRoutingDecisionEvent fires with {would_route_to: [...], saved_tokens_estimate: N}. Full list still binds.
"enforce" Narrowed list is written to state["resolved_tools"]. LLM only sees the subset.

Minimum Config

from symfonic.agent.config import FrameworkConfig
from symfonic.agent.domain import DomainTemplate

cfg = FrameworkConfig(
    intent_filter_mode="observe",
    tool_routing_mode="observe",
    domain=DomainTemplate(
        tool_trigger_keywords={
            # Map each tool to action-verb + domain keywords that should
            # trigger inclusion. Tools NOT listed are ALWAYS kept
            # (v6.1.8 back-compat: missing entry means "always include").
            "mysql_query":  ["mysql", "query", "sql", "database"],
            "bash":         ["bash", "shell", "command", "run"],
            "read_file":    ["read", "cat", "show", "file"],
            "write_file":   ["write", "save", "edit", "file"],
            # ... one entry per fat tool
        },
    ),
)

The Narrower

symfonic.agent.triage.tool_routing.narrow_tools_for_intent is a pure function. Zero LLM calls, zero async I/O. Signature:

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

Behaviour by verdict:

  • Knowledge intent → keeps only always_include tools (default ("hydrate_context",)). Knowledge turns do not call tools except the JIT hydration escape hatch.
  • Action intent → keeps tools whose names match verdict.matched_tool_keywords, plus tools without an entry in tool_trigger_keywords, plus always_include.
  • Ambiguous / low-confidence / None verdict → returns the full manifest. Conservative fallback preserves recall.

always_include_tools (v7.0.3)

Default ("hydrate_context",). The hydrate_context tool is the JIT escape hatch — the LLM can call it to request more context on any turn, even knowledge turns narrowed to a minimal manifest. Fabric and other downstream domains can pin additional low-cost capabilities on every turn:

cfg = FrameworkConfig(
    tool_routing_mode="enforce",
    always_include_tools=("hydrate_context", "emit_telemetry", "read_feature_flag"),
)

Set to () (empty tuple) only if you know hydrate_context will be bound some other way — otherwise JIT mode loses its escape hatch.

i18n notes (v7.0.3)

The lexicon's TOKEN_RE was upgraded from [A-Za-z][A-Za-z0-9_\-]* to \w[\w\-]* with re.UNICODE. Spanish, German, French, Portuguese, Chinese, and Japanese queries now emit tokens that the classifier can match against keyword lists. Input is also NFC-normalised so precomposed ("á") and decomposed ("a" + U+0301) forms produce identical tokens.

Accents are preserved. "gráfica" still does NOT match "grafica". Folding changes meaning in some Spanish contexts ( vs si) and is deferred to a future opt-in knob.

Deployments on non-ASCII traffic that ran intent_filter_mode="enforce" on v7.0.0-v7.0.2 were silently falling to the ambiguous path (zero tokens emitted). v7.0.3 surfaces the real verdicts — operators should verify classification matches expectations on multilingual corpora before re-deploying.

Observing the decisions

Subscribe to ToolRoutingDecisionEvent in observe mode to collect telemetry before flipping to enforce:

from symfonic.agent.triage.events import ToolRoutingDecisionEvent

class RoutingAuditor:
    async def on_tool_routing_decision(self, event: ToolRoutingDecisionEvent) -> None:
        # event.mode:                 "observe" or "enforce"
        # event.intent_label:         "knowledge" / "action" / "ambiguous"
        # event.total_tools:          int
        # event.narrowed_tool_names:  list[str]
        # event.saved_tokens_estimate: int
        print(
            f"[routing] {event.mode} intent={event.intent_label} "
            f"total={event.total_tools} narrowed={len(event.narrowed_tool_names)} "
            f"saved_tokens~{event.saved_tokens_estimate}"
        )

async for _ in agent.run("...", scope=scope, callbacks=[RoutingAuditor()]):
    pass

ExtensionEvent(type="intent_classified") carries the verdict payload in observe mode — subscribe via whichever ExtensionEvent dispatch your handler supports.

Benchmark (v7.0.1)

Stub 18-tool fabric manifest (mysql_query with 400-token description plus 17 smaller tools). Knowledge-intent query: "What is the SOUL schema for this tenant?"

off     : 18 tools,  ~653 tokens
observe : 18 tools,  ~653 tokens   (event fired, bind unchanged)
enforce :  1 tool,   ~ 12 tokens   (only hydrate_context)

delta   : -641 tokens (98.2% reduction)

Action-intent query: "Restart the payments service now"

off     : 18 tools, ~653 tokens
enforce :  2 tools, ~ 24 tokens    (restart_service + hydrate_context)
delta   : -629 tokens (96.3% reduction)

Harness: .claude/temp/tool_routing_benchmark.py in the repo.

  1. Start with empty tool_trigger_keywords + both knobs in observe. Watch ToolRoutingDecisionEvent telemetry — you will see every turn's narrowed list (which will initially equal the full set since missing entries mean "always include").
  2. Add trigger keywords for the fat tools first (mysql_query, bash, git, SREs Loki, etc.). Each keyword entry narrows that tool out of knowledge turns.
  3. Run a burn-in week in observe on real traffic. Compare saved_tokens_estimate to your current LLMPreCallEvent payload size.
  4. Flip tool_routing_mode to "enforce". Keep intent_filter_mode in observe one more week so the hydration path stays conservative.
  5. Flip intent_filter_mode to "enforce". Action turns now skip semantic + episodic + procedural retrieval. Confirm recall on your knowledge corpus has not regressed.

Streaming paths (stream(), stream_events()) are a deliberate scope decision for v7.0.1 — intent filtering is not wired to those paths in v7.0.1. Extending coverage is tracked for v7.1.

See also