Skip to content

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:

  1. _lazy_resolve_tools(query, scope) queries the procedural memory layer.
  2. The engine scores each registered skill against the incoming query.
  3. Top-N tool names are returned (default: up to 10 candidates, filtered by active=True metadata flag).
  4. Only the matching tool schemas go into the HMS system prompt for this turn.
  5. 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_tool metadata if present, otherwise from content. 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 the steps / context / description metadata, 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 LazySkillResolutionWarning is 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), set metadata["action_tool"] to the canonical tool name explicitly:

MemoryEntry(
    content="Look up case law by citation.",      # human-readable skill text
    layer=MemoryLayer.PROCEDURAL,
    tenant_id=scope.tenant_id,
    metadata={"action_tool": "search_case_law",    # canonical tool identifier
              "steps": [...], "active": True},
)

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):

  1. Check for LazyToolingWarning at agent construction. If you see it, at least one prerequisite is missing.
  2. Check logs for "lazy_tooling skipped:" INFO messages — they name the exact reason the filter was bypassed.
  3. 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)}")
  1. Confirm enable_hms_prompt=True is set on FrameworkConfig(...) passed to SymfonicAgent(config=...).
  2. Check tool_manifest is non-empty:
print(agent.config.domain.tool_manifest)
  1. 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