Guide 6: Lazy Tooling — Reduce LLM Token Cost¶
Legacy entry point: removed in 11.3.0
This page uses SymfonicAgent and has not been rewritten for
symfonic.Agent. From 11.3.0, SymfonicAgent(...) and
AgentBuilder().build() raise EntryPointRemovedError, a migration error,
before anything is built, with no compatibility shim, so the examples below
that construct either one do not run on 11.3.0 or later.
symfonic-core==9.12.* is the newest public PyPI line on which both still
construct (private 11.0 to 11.2.6 did too), but it predates the 11.x features, and this page may use some
of them; pinning it does not migrate an 11.x application. For new code
start with
The Simple Agent, and migrate existing code with
Guide 22 and
Moving to 11.0.
Why it matters¶
Every tool schema sent to the LLM in the system prompt consumes tokens. For agents with 10–30 tools, this can easily exceed 5 k tokens per request, hitting rate limits (OpenAI's 30 k TPM tier, Anthropic's 40 k TPM tier) and increasing latency and cost.
Lazy tooling solves this by using the agent's own procedural memory to select only the 3–5 tools relevant to each query before the prompt is built.
How it works¶
On each request:
_lazy_resolve_tools(query, scope)queries the procedural memory layer.- The engine scores each registered skill against the incoming query.
- Top-N tool names are returned (default: up to 10 candidates, filtered by
active=Truemetadata flag). - Only the matching tool schemas go into the HMS system prompt for this turn.
- All other tools are hidden from the LLM.
Activation requirements (ALL THREE ARE MANDATORY)¶
1. Enable the HMS system-prompt pipeline¶
lazy_tooling only activates through the HMS system-prompt pipeline.
_lazy_resolve_tools is called exclusively inside the if self._enable_hms_prompt
branch of run() / stream():
Legacy 9.12 example: Install symfonic-core==9.12.* for this SymfonicAgent snippet. For version 11, see the Agent migration guide.
from symfonic.agent.config import FrameworkConfig
from symfonic.agent.engine import SymfonicAgent
agent = SymfonicAgent(
model_provider=provider,
config=FrameworkConfig(
lazy_tooling=True, # not enough alone
enable_hms_prompt=True, # REQUIRED
),
)
Without FrameworkConfig(enable_hms_prompt=True) the flag has no effect
and all tools are still sent on every request.
2. Register procedural memory and populate it with skills¶
Each tool must be registered as a skill in MemoryLayer.PROCEDURAL
before lazy tooling can filter it. Do this once at agent bootstrap:
from symfonic.memory.models.entry import MemoryEntry
from symfonic.memory.types import MemoryLayer
async def bootstrap_skills(agent, scope):
proc = agent.orchestrator.get_layer(MemoryLayer.PROCEDURAL)
skills = [
("search_case_law", "Search legal case database by citation"),
("draft_contract", "Generate contract from template and params"),
("calculate_damages", "Compute damages based on jurisdiction"),
]
memory_scope = scope.to_memory_scope()
for name, description in skills:
await proc.write(
memory_scope,
MemoryEntry(
content=name,
layer=MemoryLayer.PROCEDURAL,
tenant_id=scope.tenant_id,
metadata={"description": description, "active": True},
),
)
The procedural layer must be available on the orchestrator. It is
included automatically when you pass an embedding_provider (which
triggers HMSFactory.build or HMSFactory.build_in_memory).
Skill storage contract (important). When resolving the per-turn tool subset, the engine reads each active skill's tool identifier from its
action_toolmetadata if present, otherwise fromcontent. That identifier must be the exact name of a tool registered on the agent (e.g."web_search"), not a sentence, heading, or rich instruction — descriptive text belongs in thesteps/context/descriptionmetadata, never in the field used as the identifier.Since v9.3.0 (issue #36), a resolved name that does not match any registered tool is dropped and a
LazySkillResolutionWarningis emitted once per agent. Previously such a name was passed through and rendered into the system prompt as a phantom "active tool", inviting hallucinated tool calls. If you seed procedural skills from richer memory (headings, summaries), setmetadata["action_tool"]to the canonical tool name explicitly:
3. Populate DomainTemplate.tool_manifest¶
from symfonic.agent.domain import DomainTemplate
LEGAL_DOMAIN = DomainTemplate(
name="Legal Assistant",
tool_manifest=[
"search_case_law: Search legal case database by citation",
"draft_contract: Generate contract from template and params",
"calculate_damages: Compute damages based on jurisdiction",
# list ALL tools the agent knows about
],
)
config = FrameworkConfig(
domain=LEGAL_DOMAIN,
lazy_tooling=True,
)
Full working example¶
Legacy 9.12 example: Install symfonic-core==9.12.* for this SymfonicAgent snippet. For version 11, see the Agent migration guide.
import asyncio
from symfonic.agent.config import FrameworkConfig
from symfonic.agent.domain import DomainTemplate
from symfonic.agent.engine import SymfonicAgent
from symfonic.agent.types import FrameworkTenantScope
from symfonic.core.testing import MockModelProvider
from symfonic.memory.models.entry import MemoryEntry
from symfonic.memory.types import MemoryLayer
LEGAL_DOMAIN = DomainTemplate(
name="legal-assistant",
tool_manifest=[
"search_case_law: Search legal case database by citation",
"draft_contract: Generate contract from template and params",
"calculate_damages: Compute damages based on jurisdiction",
],
)
class SimpleEmbedder:
async def embed(self, text: str) -> list[float]:
return [0.1] * 384
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
return [[0.1] * 384 for _ in texts]
async def main() -> None:
agent = SymfonicAgent(
model_provider=MockModelProvider(response="OK"),
embedding_provider=SimpleEmbedder(), # activates in-memory HMS
config=FrameworkConfig(
domain=LEGAL_DOMAIN,
lazy_tooling=True,
enable_hms_prompt=True, # REQUIRED
),
)
scope = FrameworkTenantScope(tenant_id="law-firm-1")
# Bootstrap skills into procedural memory
proc = agent.orchestrator.get_layer(MemoryLayer.PROCEDURAL)
memory_scope = scope.to_memory_scope()
for name, desc in [
("search_case_law", "Search legal case database"),
("draft_contract", "Generate contract from template"),
("calculate_damages", "Compute damages by jurisdiction"),
]:
await proc.write(
memory_scope,
MemoryEntry(
content=name,
layer=MemoryLayer.PROCEDURAL,
tenant_id=scope.tenant_id,
metadata={"description": desc, "active": True},
),
)
response = await agent.run("Find cases on copyright infringement", scope=scope)
print(response.final_response)
asyncio.run(main())
How many tokens does it save?¶
| Scenario | Tools sent | Approx. tokens | Saving |
|---|---|---|---|
| Without lazy tooling (20 tools) | 20 | ~4 000 | — |
| With lazy tooling (top 5 matched) | 5 | ~1 000 | ~75 % |
At 1 000 requests/day on a 30 k TPM plan that is the difference between hitting the limit before noon and staying well under it all day.
Debugging checklist¶
If lazy tooling is not working (all tools are still sent):
- Check for
LazyToolingWarningat agent construction. If you see it, at least one prerequisite is missing. - Check logs for
"lazy_tooling skipped:"INFO messages — they name the exact reason the filter was bypassed. - Verify procedural layer has skills:
proc = agent.orchestrator.get_layer(MemoryLayer.PROCEDURAL)
skills = await proc.retrieve(scope.to_memory_scope(), "", top_k=100)
print(f"Registered skills: {len(skills)}")
- Confirm
enable_hms_prompt=Trueis set onFrameworkConfig(...)passed toSymfonicAgent(config=...). - Check
tool_manifestis non-empty:
- Silence the warning once you understand the trade-off:
import warnings
from symfonic.agent.warnings import LazyToolingWarning
warnings.filterwarnings("ignore", category=LazyToolingWarning)
On symfonic.Agent: procedural_router¶
A native Agent (or an AgentHost composer) gets the same palette rule
from procedural_router, with no SymfonicAgent:
from symfonic import Agent
from symfonic.capabilities.tools.procedural import procedural_router
from symfonic.kernel.contracts.effects import GrantEffects
agent = Agent(
provider,
tools=tools,
capabilities=[
GrantEffects("memory-read"), # the router reads the procedural layer
procedural_router(layer, scope, registered=tuple(t.name for t in tools)),
],
)
- Only a skill matched by the request's cue narrows the offered tools. When
the layer answers with importance-fallback or empty-cue rows, every
registered tool stays offered and the routing stage says why. Composing
ToolsCapability(entries_for=layer.retrieve)by hand narrows on those fallback rows too. layermust provideretrieve_report;scopeis theTenantScopethe agent serves (in anAgentHost, the composer's scope).Agent.runandAgent.streamstate no per-turn scope, so onAgentandAgentHostthe router always reads its composed scope: to route per tenant, compose one agent per scope withAgentHost. Only a caller that builds a kernelTurnRequestwithscope=itself routes on that scope.- A layer without
retrieve_reportor a non-TenantScopescope is refused when the router is built. With no registered tools the router composes nothing to route: no routing stage, no procedural read, no count, and the turn stays native. AnAgentHostcomposer can build it the same way for a tenant whose tool set is empty. - Narrowing chooses what the model is offered; it does not authorise or
forbid a call.
agent_from_configdoes not compose this router fromlazy_tooling: compose it yourself where you have a procedural layer.
Lazy tooling vs tool_routing_mode (v7.0.1)¶
These two knobs solve different problems and can be combined:
| Knob | Layer | Mechanism | When to use |
|---|---|---|---|
lazy_tooling (v5.6) |
Prompt layer | Procedural-memory-driven selection of 3-5 relevant tools; filters {{ACTIVE_TOOLS}} injection |
Deployments with a populated procedural layer and many registered tools |
tool_routing_mode (v7.0.1) |
Wire layer | Intent-classifier verdict narrows the Anthropic tools= API payload |
Deployments where even the wire-level manifest is fat (~5-7k tokens for 18+ tools) |
For an 18-tool fabric deployment, tool_routing_mode="enforce" cut
knowledge-intent queries from 653 tokens of tool schemas to 12 tokens
(98% reduction) — see v7.0.1 release notes.
Combine the two for maximum savings: lazy_tooling picks relevant
tools, then tool_routing_mode narrows further per intent.
See also¶
- Feature Catalog § Tooling and Routing
- Guide 4: Plugins and Domains — DomainTemplate and tool_manifest setup
- Guide 5: Production — Full deployment topology
- Guide 7: Intent & Tool Routing — wire-level
narrowing via
tool_routing_mode src/symfonic/agent/warnings.py—LazyToolingWarningdocstring