From 6292953bdf93f17a0e81abeee087ed2027371f4c Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Fri, 19 Jun 2026 20:58:23 +0400 Subject: [PATCH] fix(adapters/langgraph): stamp tool_call_id onto client_tool_request interrupts A ClientTool hands control back to the caller by raising interrupt({"type": "client_tool_request", ...}), but the payload omitted the originating tool_call_id. Callers resuming a run correlate the tool result they return to the parked interrupt by tool_call_id, so without it the resume could not match and the agent went silent after the user answered (e.g. ask_user_question). Wire a ClientToolCallIdMiddleware into create_agent for agents that declare a ClientTool; running in the tool-call path where the id is available, it stamps the tool_call_id onto the client_tool_request interrupt as it propagates so a resuming caller can correlate its tool result. --- .../langgraph/_client_tool_middleware.py | 66 +++++++++++++++++++ .../adapters/langgraph/_langgraphconverter.py | 15 ++++- .../tests/adapters/langgraph/test_tools.py | 55 ++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 pyagentspec/src/pyagentspec/adapters/langgraph/_client_tool_middleware.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_client_tool_middleware.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_client_tool_middleware.py new file mode 100644 index 00000000..24628582 --- /dev/null +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_client_tool_middleware.py @@ -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 diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index fb54281f..13439407 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -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, @@ -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 diff --git a/pyagentspec/tests/adapters/langgraph/test_tools.py b/pyagentspec/tests/adapters/langgraph/test_tools.py index f27a0d82..4cbc253b 100644 --- a/pyagentspec/tests/adapters/langgraph/test_tools.py +++ b/pyagentspec/tests/adapters/langgraph/test_tools.py @@ -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"