Skip to content

Conversation

@eyurtsev
Copy link
Collaborator

@eyurtsev eyurtsev commented Oct 7, 2025

Adds generator-based middleware for intercepting tool execution in agents. Middleware can retry on errors, cache results, modify requests, or short-circuit execution.

Implementation

Middleware Protocol

class AgentMiddleware:
    def on_tool_call(
        self,
        request: ToolCallRequest,
        state: StateT,
        runtime: Runtime[ContextT],
    ) -> Generator[ToolCallRequest | ToolMessage | Command, ToolMessage | Command, None]:
        """
        Yields: ToolCallRequest (execute), ToolMessage (cached result), or Command (control flow)
        Receives: ToolMessage or Command via .send()
        Returns: None (final result is last value sent to handler)
        """
        yield request  # passthrough

Composition
Multiple middleware compose automatically (first = outermost), with _chain_tool_call_handlers() stacking them like nested function calls.

Examples

Retry on error:

class RetryMiddleware(AgentMiddleware):
    def on_tool_call(self, request, state, runtime):
        for attempt in range(3):
            response = yield request
            if not isinstance(response, ToolMessage) or response.status != "error":
                return
            if attempt == 2:
                return  # Give up

Cache results:

class CacheMiddleware(AgentMiddleware):
    def on_tool_call(self, request, state, runtime):
        cache_key = (request.tool_call["name"], tuple(request.tool_call["args"].items()))
        if cached := self.cache.get(cache_key):
            yield ToolMessage(content=cached, tool_call_id=request.tool_call["id"])
        else:
            response = yield request
            self.cache[cache_key] = response.content

Emulate tools with LLM

class ToolEmulator(AgentMiddleware):
    def on_tool_call(self, request, state, runtime):
        prompt = f"""Emulate: {request.tool_call["name"]}
Description: {request.tool.description}
Args: {request.tool_call["args"]}
Return ONLY the tool's output."""

        response = emulator_model.invoke([HumanMessage(prompt)])
        yield ToolMessage(
            content=response.content,
            tool_call_id=request.tool_call["id"],
            name=request.tool_call["name"],
        )

Modify requests:

class ScalingMiddleware(AgentMiddleware):
    def on_tool_call(self, request, state, runtime):
        if "value" in request.tool_call["args"]:
            request.tool_call["args"]["value"] *= 2
        yield request

@github-actions github-actions bot added langchain Related to the package `langchain` v1 Issue specific to LangChain 1.0 feature and removed feature labels Oct 7, 2025
@eyurtsev eyurtsev requested a review from sydney-runkle October 7, 2025 04:11
@eyurtsev eyurtsev marked this pull request as ready for review October 7, 2025 04:11


ToolCallHandler = Callable[
[ToolCallRequest, Any, Any], Generator[ToolCallRequest, ToolCallResponse, ToolCallResponse]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this part?

[ToolCallRequest, Any, Any]

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a note, but in tool node those things don't behave as state and runtime necessarily... We can update the 3rd one to guaranteed runtime in a bit

input_type: Literal["list", "dict", "tool_calls"],
config: RunnableConfig,
input: list[AnyMessage] | dict[str, Any] | BaseModel,
runtime: Any,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense we don't want to import here

Copy link
Collaborator

@sydney-runkle sydney-runkle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar notes as previous PR, would love to look at tracing more carefully for nested handlers.

Comment on lines +683 to +689
# Verify nested retry pattern
assert "outer_0" in call_log
assert "inner_0" in call_log
assert "inner_1" in call_log

tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)]
assert len(tool_messages) == 1
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we confirm the exact contents + order of the call log?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do in the test below, so this seems not relevant

Comment on lines 241 to 246
def on_tool_call(
self,
request: ToolCallRequest,
state: StateT, # noqa: ARG002
runtime: Runtime[ContextT], # noqa: ARG002
) -> Generator[ToolCallRequest, ToolCallResponse, ToolCallResponse]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yeah no async support right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not right now. It would be post v1 thing. And you can get really far without async logic with this interceptor pattern -- you need async if the actual interceptor needs to do IO (e.g., access redis)



ToolCallHandler = Callable[
[ToolCallRequest, Any, Any],
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sydney-runkle , i'll likely need to fix this in 2 separate changes:

  1. We need a fix to expose actual state here (similar to the patch I made a few weeks back -- i'll port it to this code)
  2. We need to update the unit tests for ToolNode to always run within langgraph runtime (as it can only be used from within langgraph runtime), that'll allow us to guarantee that the runtime exists (except for python 3.10 async)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's check if we're OK with functionality everywhere, and can then do a quick follow through to fix types

@github-actions github-actions bot added feature and removed feature labels Oct 8, 2025
Copy link
Collaborator

@sydney-runkle sydney-runkle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exciting pattern.

Want to further investigate tracing. Happy to help document!

@eyurtsev eyurtsev enabled auto-merge (squash) October 8, 2025 16:43
@eyurtsev eyurtsev merged commit 1bf29da into master Oct 8, 2025
35 checks passed
@eyurtsev eyurtsev deleted the eugene/on_tool_call_2 branch October 8, 2025 16:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature langchain Related to the package `langchain` v1 Issue specific to LangChain 1.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants