Skip to content

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:

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

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_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

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

  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)

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.
  • layer must provide retrieve_report; scope is the TenantScope the agent serves (in an AgentHost, the composer's scope). Agent.run and Agent.stream state no per-turn scope, so on Agent and AgentHost the router always reads its composed scope: to route per tenant, compose one agent per scope with AgentHost. Only a caller that builds a kernel TurnRequest with scope= itself routes on that scope.
  • A layer without retrieve_report or a non-TenantScope scope 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. An AgentHost composer 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_config does not compose this router from lazy_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