DEV Community

anassBld
anassBld

Posted on

Why Tool-Calling Agents Drift in Production: Capability Manifests vs. Semantic Discovery

When you build a prototype AI agent with two or three tools—say, search_docs and calculator—tool calling feels like magic. The LLM effortlessly picks the right tool, extracts parameters, and completes the loop.

Then you take the system to production.

Suddenly your agent needs 35 tools: git branch operations, database schema migrations, transactional emails, cloud deployment triggers, browser automation, and API mutations.

At this scale, two naive architectural patterns immediately collapse:

  1. The "Kitchen Sink" Prompt: Shoveling 35 full JSON Schemas into the system prompt eats 15,000 tokens of context, inflates Time-To-First-Token (TTFT), and degrades attention over core instructions.
  2. Semantic Tool Discovery (Vector RAG for Tools): Embedding tool descriptions and retrieving the "top-k most relevant tools" based on user query similarity.

Here is why semantic tool discovery fails under production workloads, and the deterministic architectural primitives you should use instead.


The Failure Modes of Semantic Tool Discovery

Semantic tool retrieval assumes that linguistic similarity correlates with operational suitability. In complex execution graphs, this assumption is false.

[User Prompt: "Verify why migration failed and rollback the schema"]
                             │
            Vector Similarity Search (Top-3)
                             │
  ┌──────────────────────────┼──────────────────────────┐
  ▼                          ▼                          ▼
`rollback_transaction`    `revert_git_commit`       `drop_table_schema`
 (DB transaction)         (Source control)           (Destructive DDL)
Enter fullscreen mode Exit fullscreen mode

1. The Adjacent-Tool Ambiguity Trap

Consider tools with overlapping semantic domains: update_user_record, upsert_account, reconcile_identity_binding, and patch_profile.

When an agent needs to resolve an inconsistent account state, their embedding vectors sit millimeters apart in latent space. A tiny shift in user phrasing causes the retriever to surface patch_profile instead of reconcile_identity_binding. The agent attempts to execute an invalid tool for the task, hallucinates missing schema arguments, or worse, mutates user data through the wrong operational surface.

2. Semantic Drift Across Multi-Turn Loops

In an autonomous workflow lasting 10+ turns, intermediate tool outputs pollute the conversation history. If the agent retrieves tools dynamically on every turn based on the latest scratchpad state, it easily gets caught in a semantic drift loop: an error message mentioning "network timeout" causes the retriever to flood the prompt with socket-diagnostic tools instead of continuing the database recovery protocol.

3. Ambient Permission Bloat

If all tools are theoretically discoverable at every step, a prompt injection or a hallucinated reasoning branch can pull high-blast-radius execution tools (e.g., execute_shell_command or drop_database) into context when the task only required read-only analysis.


Primitive 1: Schema-Bound Capability Manifests

Instead of treating tools as an open pool of embeddings, group tools into deterministic, isolated capability manifests.

A capability manifest is a strictly bounded contract representing a single domain of authority (e.g., repo, database, cloud_deploy). Agents do not search for individual tools; they run within an explicitly mounted capability namespace.

from dataclasses import dataclass
from typing import Callable, Any
import json
import jsonschema

@dataclass(frozen=True)
class ToolDefinition:
    name: str
    description: str
    schema: dict[str, Any]
    handler: Callable[[dict[str, Any]], dict[str, Any]]
    is_mutation: bool = False

class CapabilityManifest:
    def __init__(self, namespace: str, tools: list[ToolDefinition]):
        self.namespace = namespace
        self._tools = {t.name: t for t in tools}

    def get_schemas(self) -> list[dict[str, Any]]:
        return [
            {
                "type": "function",
                "function": {
                    "name": f"{self.namespace}:{tool.name}",
                    "description": tool.description,
                    "parameters": tool.schema
                }
            }
            for tool in self._tools.values()
        ]

    def execute(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]:
        if tool_name not in self._tools:
            raise ValueError(f"Tool {tool_name} not registered in manifest '{self.namespace}'.")

        tool = self._tools[tool_name]
        # Strict preflight schema validation before touching handler code
        jsonschema.validate(instance=arguments, schema=tool.schema)
        return tool.handler(arguments)
Enter fullscreen mode Exit fullscreen mode

Specialist subagents are instantiated with only the manifests required for their scope. A code-auditing agent receives the repo:read manifest; it literally lacks the schema and runtime capability to invoke deploy:write.


Primitive 2: Append-Only Task Ledgers with Expiring Leases

When multiple agents coordinate (e.g., an Architect coordinator delegating to Backend and Frontend workers), the common beginner pattern is an "Agent Group Chat" where agents talk to each other in natural language.

Conversational multi-agent collaboration degrades rapidly past 3 agents due to split-brain states and context pollution.

Replace natural language handoffs with an out-of-band, append-only task ledger:

[Coordinator] ──────────Writes Task──────────► [Task Ledger: Pending]
                                                      │
                                           Claims with 5m lease
                                                      ▼
[Worker Specialist] ◄──────────────────── [Task Ledger: Leased]
        │
   Executes & Verifies
        │
        └──────────────Writes Receipt────────► [Task Ledger: Completed]
Enter fullscreen mode Exit fullscreen mode
  1. Atomic Leases: A worker claims a task by writing a heartbeat lease (e.g., leased_until: 2026-09-13T19:00:00Z). If the worker crashes or times out, the lease expires and the task automatically returns to the pending pool.
  2. Receipt Handoffs: Workers do not report back with paragraphs of conversational text. They write a structured Action Receipt containing the status code, output hashes (content_sha256), and affected resource IDs.
  3. Clean Scratchpads: The coordinator never ingests the worker's 40-step trial-and-error trajectory. It only ingests the terminal receipt, keeping coordinator context pristine.

Primitive 3: Cryptographic Intent Tokens (Two-Phase Execution)

For any tool that performs a mutation (writing files, updating databases, spending funds, sending external messages), the LLM must never execute directly against the wire in a single step.

Implement a deterministic two-phase commit using Intent Tokens:

import hashlib
import json

def generate_intent_token(account: str, action: str, target: str, payload: dict) -> str:
    canonical = json.dumps(
        {
            "account": account,
            "action": action,
            "target": target,
            "payload": payload
        },
        sort_keys=True,
        separators=(",", ":")
    )
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
Enter fullscreen mode Exit fullscreen mode

The Two-Phase Mutation Lifecycle:

  1. Phase 1: Dry-Run & Intent Attestation:
    • The LLM proposes the tool call.
    • The outer harness intercepts the call and runs a local preflight dry-run.
    • The harness validates permissions, checks for duplicate operations, computes intent_sha256, and issues a short-lived confirmation code (e.g., publish-comment-ee187b73eee3).
  2. Phase 2: Guarded Execution:
    • The mutation is dispatched only when the confirmation code and intent hash match the preflight attestation.
    • If the remote network connection times out during execution, the operation transitions to outcome_unknown and triggers deterministic out-of-band reconciliation rather than a blind retry.

Comparison: Semantic Retrieval vs. Manifest Architecture

Dimension Semantic Tool Discovery (Vector RAG) Schema-Bound Capability Manifests
Tool Selection Reliability Probabilistic; degrades with similar tool descriptions Deterministic; strictly bound to active namespace
Context Overhead High variance; retrieves unexpected schemas Bounded; exact schemas known at initialization
Blast Radius Protection Zero isolation; prompt injection can pull dangerous tools Hardware/sandbox-level isolation per subagent
Multi-Agent Coordination Chat history pollution & drift Structured task ledgers with expiring leases
Failure Recovery LLM guesses whether tool executed Two-phase intent attestation & action receipts

Architectural Takeaway

Stop asking your LLMs to navigate 40 loose tools in a shared global namespace.

Treat tool execution as a systems engineering problem:

  1. Bind subagents to minimal, typed Capability Manifests.
  2. Decouple coordination from chat history using Append-Only Task Ledgers.
  3. Gate all remote mutations behind Cryptographic Intent Tokens and out-of-band state receipts.

Your agents will run faster, cost a fraction in token burn, and stop failing silently in production.


What architecture does your team use to prevent tool schema drift and duplicate mutations as your agent toolkits expand? Let's discuss below.

Top comments (2)

Collapse
 
anasbuilds997 profile image
anassBld

That 62-to-5 telemetry stat is classic LLM inertia—if a generic primitive like grep or cat is visible in the context, models will almost always fall back to brute-forcing with it rather than paying the attention cost to inspect a specialized schema.

In practice, static manifests alone handle blast radius and token budgets, but we still need routing within dense namespaces. What worked best for us is tiering the tools: leaf workers only get the narrow, domain-specific execution tools without the general file/terminal catch-alls, while the orchestrator only sees high-level composite tools plus a deferred tool catalog (tool_search/tool_describe) that loads schemas on demand.

Removing the lazy fallback tools from the worker's active namespace does 90% of the work—if the agent doesn't have grep, it's forced to read the specialized tool schema and use it properly.

Collapse
 
mansio profile image
Mikhail

Your critique of semantic tool discovery is spot on. I saw this empirically: I built an MCP server with 62 registered tools, and runtime telemetry showed only 5 were ever actually invoked. The agent just defaulted to the cheapest visible tools (grep/read_file) and completely ignored the specialized ones.

Capability Manifests will definitely solve the context bloat and blast radius, but they don't fully solve discoverability. Even within a bounded namespace, the LLM still takes the path of least resistance. I had to implement cascade routing (exact match first, semantic fallback, AST graph for structure) to force the agent to actually use the right tools for the right job.

Have you found that static manifests are enough, or do you still need routing layers inside the namespace?