Guide 6: Lazy Tooling — Reduce LLM Token Cost¶
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():
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¶
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)
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