Skip to content

[Feature Request]: Deterministic tool I/O pre/post-processing (middleware/hooks) for agents (MCP motivating case) #20386

@dgenio

Description

@dgenio

Feature Description

We need a deterministic “communication layer” between LlamaIndex agents and tool execution.

Core need

  1. Deterministic input preprocessing before tool execution

    • Example: an MCP billing tool expects an id field, but id must be filled/overridden deterministically (e.g., derived from authenticated session/customer context) — NOT chosen or hallucinated by the LLM.
  2. Deterministic output postprocessing after tool returns and before the agent sees it

    • Example: tool returns ~300 fields; we need deterministic filtering down to 3 fields to avoid context bloat and keep agent behavior stable.

Observed limitation
With current agent+tools patterns, once tools are registered the agent generates tool-call JSON/args and invokes tools without an obvious native “intercept/transform” layer for:

  • tool-call input args (pre)
  • tool output (post)
    in a way that is reusable across tools (cross-cutting concern), and especially clean for MCP tools.

Proposed feature (medium-detail, not a full spec)
Support one (or a combination) of:

  • Agent/tool execution hook or middleware to transform tool inputs/outputs deterministically (pre/post).
  • First-class tool wrapper/adapter API pattern documented and supported for IO transforms (so it’s ergonomic + consistent).
  • Maintainer guidance on preferred placement: agent runner vs tool runner vs callback/event layer.

Minimal code example (current workaround vs desired)
Current workaround (per-tool wrapper; works but doesn’t scale well across many tools and doesn’t feel “first-class”):

from llama_index.core.agent.workflow import FunctionAgent
from llama_index.core.tools import FunctionTool

# Deterministic value from outside the LLM (e.g., auth/session layer)
SESSION_CUSTOMER_ID = "cust_123456"

def call_mcp_billing(payload: dict) -> dict:
    """Placeholder: call MCP tool server and return raw JSON (~300 fields)."""
    # ... network call ...
    return {"balance": 12.34, "due_date": "2025-12-31", "status": "OPEN", "lots_more": "..."}  # etc.

def billing_lookup(question: str, id: str | None = None) -> dict:
    # PRE: deterministically override/ignore LLM-provided `id`
    payload = {"id": SESSION_CUSTOMER_ID, "question": question}

    raw = call_mcp_billing(payload)

    # POST: deterministically shrink tool output before it hits agent context
    return {k: raw.get(k) for k in ("balance", "due_date", "status")}

billing_tool = FunctionTool.from_defaults(
    fn=billing_lookup,
    name="billing_lookup",
    description="Get billing info for the authenticated customer."
)

agent = FunctionAgent(tools=[billing_tool])
# await agent.run("Why is my bill higher this month?")

Desired ergonomics (illustrative API; not current):

agent = FunctionAgent(
    tools=mcp_tools,
    tool_io_middleware=[
        ToolIOMiddleware(
            before=lambda tool_name, args: override_id(args, SESSION_CUSTOMER_ID),
            after=lambda tool_name, out: filter_fields(out, tool_name),
        )
    ],
)

Notes

Reason

We need deterministic tool I/O transforms for reliability and predictable integrations in production agent systems (MCP is a motivating case, but this is broadly useful for any tool calling).

Why this matters

  • Reliability & correctness: LLM-chosen identifiers/fields can be wrong or inconsistent. Deterministically overriding fields like id prevents “random” tool calls and removes a class of failures.
  • Compliance/auditability: For regulated environments, having a deterministic tool-call envelope (and a deterministic “view” of tool outputs that the agent consumes) improves auditability and reduces risk.
  • Cost/latency + quality: Tool outputs with hundreds of fields create context bloat, increasing token usage and failure modes. Deterministic filtering (e.g., 300 → 3 fields) is a cross-cutting concern.
  • Separation of concerns: Forcing every tool to implement its own filtering/override logic duplicates effort and makes behavior inconsistent.

Workarounds being explored (with tradeoffs)

  • Put pre/post logic inside the MCP tool itself (e.g., add fields_to_return) — shifts responsibility into every tool and doesn’t solve cross-cutting concerns.
  • Use agent only as a tool selector; execute tool calls deterministically outside the agent — reduces the value of integrated agent/tool patterns.

Related issues searched (not exact duplicates, but adjacent)

Keywords searched: “tool pre-processing”, “tool post-processing”, “tool middleware”, “agent tool hooks”, “callbacks tool input”, “tool output filtering”, “MCP”, “deterministic tool call”.

Value of Feature

What this unlocks

  • A consistent, maintainable way to enforce deterministic tool-call envelopes (e.g., override/fill id from trusted context) without relying on the LLM.
  • Deterministic, tool-specific (or global) output shaping to reduce context bloat and improve stability (e.g., 300 fields → 3 fields).
  • Cleaner MCP tool integrations where the agent remains responsible for “what to do next”, but the communication layer stays deterministic and policy-compliant.

Expected impact

  • Reduced token usage + faster responses by controlling tool output size before it reaches the agent.
  • Fewer production incidents caused by malformed/undesired tool args.
  • Clearer architecture guidance: where do input/output transforms belong (agent runner vs tool runner vs callback/event layer)?

What I’m asking maintainers for

  • Either a first-class hook/middleware mechanism for tool IO transforms, OR
  • A documented “blessed” wrapper/adapter pattern that is ergonomic, composable, and works across ToolSpecs (including MCP), with guidance on best practice placement in the stack.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesttriageIssue needs to be triaged/prioritized

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions