Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Middleware that stamps the originating ``tool_call_id`` onto a client tool's
interrupt.

A converted :class:`~pyagentspec.tools.ClientTool` hands control back to the
caller by raising ``interrupt({"type": "client_tool_request", ...})``. That
payload does not carry the id of the tool call that triggered it, but a caller
resuming the run needs that id to correlate the tool result it supplies back to
the specific parked interrupt (multiple client tool calls can be pending at
once). This middleware runs inside ``create_agent``'s tool-call path — where the
``tool_call_id`` is available on the request — and stamps it onto the
``client_tool_request`` interrupt as it propagates, so resume can match it.
"""

from __future__ import annotations

from typing import Any, Awaitable, Callable

from langchain.agents.middleware.types import AgentMiddleware, ToolCallRequest
from langgraph.errors import GraphInterrupt

_ToolResult = Any


def _stamp_tool_call_id(exc: GraphInterrupt, tool_call_id: str | None) -> None:
"""Add ``tool_call_id`` to any pending ``client_tool_request`` interrupt that
is missing one. Mutates the interrupt value in place so the id is persisted
when LangGraph parks the interrupt."""
if not tool_call_id:
return
args = getattr(exc, "args", ())
pending = args[0] if args and isinstance(args[0], (list, tuple)) else ()
for interrupt in list(pending) + list(getattr(exc, "interrupts", ()) or ()):
value = getattr(interrupt, "value", None)
if (
isinstance(value, dict)
and value.get("type") == "client_tool_request"
and not value.get("tool_call_id")
):
value["tool_call_id"] = tool_call_id


class ClientToolCallIdMiddleware(AgentMiddleware):
"""Attach the originating ``tool_call_id`` to ``client_tool_request``
interrupts so a resuming caller can correlate the tool result it returns."""

def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], _ToolResult],
) -> _ToolResult:
try:
return handler(request)
except GraphInterrupt as exc:
_stamp_tool_call_id(exc, request.tool_call.get("id"))
raise

async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[_ToolResult]],
) -> _ToolResult:
try:
return await handler(request)
except GraphInterrupt as exc:
_stamp_tool_call_id(exc, request.tool_call.get("id"))
raise
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
_build_type_from_schema,
create_pydantic_model_from_properties,
)
from pyagentspec.adapters.langgraph._client_tool_middleware import (
ClientToolCallIdMiddleware,
)
from pyagentspec.adapters.langgraph._node_execution import (
NodeExecutor,
extract_outputs_from_invoke_result,
Expand Down Expand Up @@ -1175,8 +1178,16 @@ def _create_react_agent_with_given_info(
response_format=output_model,
state_schema=state_schema,
)
if middleware:
create_agent_kwargs["middleware"] = middleware
# Client tools hand control back to the caller via a
# ``client_tool_request`` interrupt. Append a middleware that stamps the
# originating tool_call_id onto that interrupt so a resuming caller can
# correlate the tool result it returns to the right parked interrupt
# (innermost, so it sees the interrupt straight off the tool call).
effective_middleware = list(middleware or [])
if any(isinstance(t, AgentSpecClientTool) for t in tools):
effective_middleware.append(ClientToolCallIdMiddleware())
if effective_middleware:
create_agent_kwargs["middleware"] = effective_middleware
compiled_graph = langchain_agents.create_agent(**create_agent_kwargs)

# To enable flow execution traces monkey patch all the functions that invoke the compiled graph
Expand Down
55 changes: 55 additions & 0 deletions pyagentspec/tests/adapters/langgraph/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1103,3 +1103,58 @@ def double_tool_func(x: int) -> int:
),
):
app.invoke(bad, config=config)


def test_client_tool_in_agent_interrupt_carries_tool_call_id() -> None:
"""A ClientTool called by an agent parks a ``client_tool_request`` interrupt
that carries the originating ``tool_call_id``. Without it a caller resuming
the run cannot correlate the tool result it returns to the parked interrupt,
so the run stalls. The adapter wires ``ClientToolCallIdMiddleware`` to stamp
it."""
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnableConfig
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver

from pyagentspec.adapters.langgraph import AgentSpecLoader

class FakeModel(FakeMessagesListChatModel, ChatOpenAI):
pass

fake_model = FakeModel(
responses=[
AIMessage(
content="",
tool_calls=[{"name": "ask_user_question", "args": {}, "id": "call_ask_1"}],
),
AIMessage(content="Done"),
]
)

agent_spec = Agent(
name="agent",
system_prompt="You are a helpful agent.",
llm_config=OpenAiCompatibleConfig(name="llm", model_id="fake", url="null"),
tools=[
ClientTool(
name="ask_user_question",
description="Ask the user a question.",
inputs=[],
)
],
)
loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver())
with patch.object(
AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_model
):
app = loader.load_component(agent_spec)

config = RunnableConfig({"configurable": {"thread_id": "client-ag-1"}})
interrupt_value = _invoke_until_interrupt(
app, {"messages": [{"role": "user", "content": "hi"}]}, config=config
)

assert interrupt_value["type"] == "client_tool_request"
assert interrupt_value["name"] == "ask_user_question"
assert interrupt_value["tool_call_id"] == "call_ask_1"