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 turnknowledge/action/ambiguousbefore hydration. Onactionturns inenforcemode, semantic + episodic + procedural retrieval is skipped because tool-binding turns do not need that context.tool_routing_mode(v7.0.1) — narrow the Anthropictools=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_keywordsmap
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_includetools (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 intool_trigger_keywords, plusalways_include. - Ambiguous / low-confidence /
Noneverdict → 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 (sí 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.
Recommended Adoption Path¶
- Start with empty
tool_trigger_keywords+ both knobs inobserve. WatchToolRoutingDecisionEventtelemetry — you will see every turn's narrowed list (which will initially equal the full set since missing entries mean "always include"). - 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. - Run a burn-in week in
observeon real traffic. Comparesaved_tokens_estimateto your currentLLMPreCallEventpayload size. - Flip
tool_routing_modeto"enforce". Keepintent_filter_modeinobserveone more week so the hydration path stays conservative. - Flip
intent_filter_modeto"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¶
- Feature Catalog § Intent Classification
- Feature Catalog § Tooling and Routing
- Guide 06 Lazy Tooling — procedural-memory-driven tool selection (complements this guide at the prompt layer).
- Release notes v7.0.0 — IntentClassifier.
- Release notes v7.0.1 — dynamic tool routing.
- Release notes v7.0.3 — i18n tokenizer,
always_include_tools.