From d87ad3abb3759e39fee8a51037124cfea8f4ed0b Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Tue, 21 Apr 2026 09:15:33 +0400 Subject: [PATCH 1/8] Forward LangChain agent middleware from AgentSpecLoader to create_agent Add a `middleware` keyword on the LangGraph `AgentSpecLoader` and its converter. When the list is non-empty it's passed as `middleware=` to `langchain_agents.create_agent` inside `_create_react_agent_with_given_info`. Both the loader and the converter copy the list on construction, so later mutations by the caller don't leak in. If `middleware` is `None` or `[]`, `create_agent` is called exactly as before, no `middleware=` kwarg and no behavior change for existing users. Why: downstream code that wants to use `HumanInTheLoopMiddleware` or its own state/message-rewriting middleware currently has to subclass the converter and copy-paste the entire `_create_react_agent_with_given_info` method just to thread the keyword through. Middleware is a LangGraph thing, so this parameter only shows up on the LangGraph adapter. Other adapters have their own loader classes and aren't affected. Signed-off-by: Salah Pichen --- .../adapters/langgraph/_langgraphconverter.py | 23 ++- .../adapters/langgraph/agentspecloader.py | 11 +- .../langgraph/test_middleware_parameter.py | 149 ++++++++++++++++++ 3 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index a07c7365..7c3bfe4e 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -190,6 +190,22 @@ def _exec_body(ns: Dict[str, Any]) -> None: class AgentSpecToLangGraphConverter: + def __init__(self, middleware: Optional[List[Any]] = None) -> None: + """Create a converter. + + Parameters + ---------- + middleware + Optional list of LangChain agent middleware instances forwarded + to ``langchain_agents.create_agent(middleware=...)`` when + building a ReAct agent for an Agent Spec ``Agent``. Order is + preserved (index ``0`` is outermost). When ``None`` or empty, + the ``middleware`` keyword is omitted from the ``create_agent`` + call, preserving the byte-identical behavior of earlier + releases. + """ + self._middleware: List[Any] = list(middleware or []) + def convert( self, agentspec_component: AgentSpecComponent, @@ -1115,7 +1131,7 @@ def _create_react_agent_with_given_info( inputs=inputs, ) - compiled_graph = langchain_agents.create_agent( + create_agent_kwargs: Dict[str, Any] = dict( name=name, model=model, tools=langgraph_tools, @@ -1124,6 +1140,11 @@ def _create_react_agent_with_given_info( response_format=output_model, state_schema=state_schema, ) + # Omit the keyword when no middleware was supplied so the call is + # byte-identical to earlier releases. + if self._middleware: + create_agent_kwargs["middleware"] = self._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/src/pyagentspec/adapters/langgraph/agentspecloader.py b/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py index b0d768b3..c8f11a9d 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py @@ -49,6 +49,13 @@ class AgentSpecLoader(AdapterAgnosticAgentSpecLoader): type names match only the exact serialized component type. When allow and block entries both match, the closest match in the component class hierarchy wins; block entries win same-distance ties. + middleware: + Optional list of LangChain agent middleware instances forwarded verbatim to + ``langchain_agents.create_agent(middleware=...)`` when compiling an Agent Spec + ``Agent`` into a ReAct graph. Order is preserved — index ``0`` is the outermost + middleware. When ``None`` or an empty list, the ``middleware`` keyword is + omitted entirely from the ``create_agent`` call and behavior is identical to + earlier releases. """ def __init__( @@ -60,6 +67,7 @@ def __init__( *, allowed_components: Optional[ComponentPolicyInput] = None, blocked_components: Optional[ComponentPolicyInput] = None, + middleware: Optional[List[Any]] = None, ) -> None: super().__init__( plugins=plugins, @@ -69,10 +77,11 @@ def __init__( ) self.checkpointer = checkpointer self.config = config + self._middleware: List[Any] = list(middleware or []) @property def agentspec_to_runtime_converter(self) -> AgentSpecToLangGraphConverter: - return AgentSpecToLangGraphConverter() + return AgentSpecToLangGraphConverter(middleware=self._middleware) @property def runtime_to_agentspec_converter(self) -> LangGraphToAgentSpecConverter: diff --git a/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py new file mode 100644 index 00000000..65839f9c --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py @@ -0,0 +1,149 @@ +# Copyright © 2025, 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +from typing import Any, Dict, List +from unittest.mock import patch + +import pytest +from langchain_core.messages import AIMessage + +from pyagentspec.adapters.langgraph import AgentSpecLoader +from pyagentspec.agent import Agent +from pyagentspec.llms import OpenAiCompatibleConfig +from pyagentspec.property import Property +from pyagentspec.tools import ClientTool + + +def _get_fake_model() -> Any: + """Minimal chat model that immediately finishes, so we never hit a real LLM.""" + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_openai import ChatOpenAI + + class FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + return FakeModel(responses=[AIMessage(content="Done")]) + + +def _build_agent() -> Agent: + return 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", + description="Ask the user something", + inputs=[Property(title="question", json_schema={"type": "string"})], + outputs=[Property(title="answer", json_schema={})], + ) + ], + ) + + +class _StopCreateAgent(Exception): + """Raised inside the ``create_agent`` spy to short-circuit graph compilation. + + We only care about the keyword arguments ``create_agent`` receives — + letting the real call proceed requires valid LangChain middleware + instances, which these tests deliberately do not construct. + """ + + +def _spy_create_agent(captured: Dict[str, Any]): + def spy(**kwargs: Any) -> Any: + captured.update(kwargs) + raise _StopCreateAgent() + + return spy + + +def _capture_create_agent_kwargs(loader_or_converter_factory) -> Dict[str, Any]: + """Drive a load through ``loader_or_converter_factory()`` and capture kwargs.""" + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.adapters.langgraph._types import langchain_agents + + captured: Dict[str, Any] = {} + agent_spec = _build_agent() + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + return_value=_get_fake_model(), + ), patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): + loader_or_converter = loader_or_converter_factory(MemorySaver()) + with pytest.raises(_StopCreateAgent): + loader_or_converter.load_component(agent_spec) + return captured + + +def test_default_omits_middleware_kwarg() -> None: + """``AgentSpecLoader()`` without ``middleware`` must not pass ``middleware=``.""" + captured = _capture_create_agent_kwargs(lambda cp: AgentSpecLoader(checkpointer=cp)) + assert "middleware" not in captured + + +def test_empty_list_omits_middleware_kwarg() -> None: + """Passing an empty list is treated the same as omitting the parameter.""" + captured = _capture_create_agent_kwargs( + lambda cp: AgentSpecLoader(checkpointer=cp, middleware=[]) + ) + assert "middleware" not in captured + + +def test_middleware_forwarded_in_order() -> None: + """A non-empty list reaches ``create_agent`` in the original order.""" + a, b = object(), object() + captured = _capture_create_agent_kwargs( + lambda cp: AgentSpecLoader(checkpointer=cp, middleware=[a, b]) + ) + assert captured.get("middleware") == [a, b] + + +def test_converter_accepts_middleware_directly() -> None: + """A list passed directly to the converter reaches ``create_agent``.""" + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.adapters.langgraph._types import langchain_agents + + captured: Dict[str, Any] = {} + sentinel = object() + agent_spec = _build_agent() + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + return_value=_get_fake_model(), + ), patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): + converter = AgentSpecToLangGraphConverter(middleware=[sentinel]) + with pytest.raises(_StopCreateAgent): + converter.convert( + agent_spec, + tool_registry={}, + checkpointer=MemorySaver(), + ) + assert captured.get("middleware") == [sentinel] + + +def test_middleware_list_is_copied() -> None: + """Mutating the caller's list after construction must not leak into conversions.""" + a = object() + caller_list: List[Any] = [a] + + def make_loader(cp: Any) -> AgentSpecLoader: + loader = AgentSpecLoader(checkpointer=cp, middleware=caller_list) + # Post-construction mutation must not affect the loader's behavior. + caller_list.append(object()) + caller_list[0] = object() + return loader + + captured = _capture_create_agent_kwargs(make_loader) + assert captured.get("middleware") == [a] From 1e271ade078c658f4773938cbbe884b46b42e036 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Mon, 11 May 2026 15:24:48 +0400 Subject: [PATCH 2/8] Wire middleware through to agents inside flow nodes --- .../adapters/langgraph/_langgraphconverter.py | 1 + .../adapters/langgraph/_node_execution.py | 8 +- .../tests/adapters/langgraph/conftest.py | 29 ++- .../langgraph/test_middleware_parameter.py | 202 +++++++++++++----- .../tests/adapters/langgraph/test_tools.py | 21 +- 5 files changed, 186 insertions(+), 75 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 7c3bfe4e..5a09060c 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -723,6 +723,7 @@ def _agent_node_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=self._middleware, ) def _llm_node_convert_to_langgraph( diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index dab0a0c7..476c2d7a 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -487,6 +487,7 @@ def __init__( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: Optional[List[Any]] = None, ) -> None: super().__init__(node) if not isinstance(self.node, AgentSpecAgentNode): @@ -495,6 +496,7 @@ def __init__( self.checkpointer = checkpointer self.converted_components = converted_components self.config = config + self._middleware: List[Any] = list(middleware or []) self._agents_cache: Dict[str, CompiledStateGraph[Any, Any]] = {} def _create_react_agent_with_given_input_values( @@ -508,9 +510,9 @@ def _create_react_agent_with_given_input_values( agentspec_component = self.node.agent system_prompt = render_template(agentspec_component.system_prompt, inputs) if system_prompt not in self._agents_cache: - self._agents_cache[ - system_prompt - ] = AgentSpecToLangGraphConverter()._create_react_agent_with_given_info( + self._agents_cache[system_prompt] = AgentSpecToLangGraphConverter( + middleware=self._middleware + )._create_react_agent_with_given_info( name=agentspec_component.name, system_prompt=system_prompt, agent=agentspec_component, diff --git a/pyagentspec/tests/adapters/langgraph/conftest.py b/pyagentspec/tests/adapters/langgraph/conftest.py index cc697e0b..db65d26f 100644 --- a/pyagentspec/tests/adapters/langgraph/conftest.py +++ b/pyagentspec/tests/adapters/langgraph/conftest.py @@ -5,7 +5,7 @@ # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. from pathlib import Path -from typing import Any +from typing import Any, List, Optional import pytest @@ -21,6 +21,33 @@ def pytest_collection_modifyitems(config: Any, items: Any): ) +def make_fake_chat_model(responses: Optional[List[Any]] = None) -> Any: + """Build a chat model stub that replays a fixed list of ``AIMessage`` responses. + + Mixes ``FakeMessagesListChatModel`` into ``ChatOpenAI`` so call sites that + do ``isinstance(model, ChatOpenAI)`` (or rely on ``ChatOpenAI``-specific + bindings) continue to work. When ``responses`` is omitted, a one-shot + tool-calling sequence is returned to match the historical default used by + confirmation tests. + """ + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + if responses is None: + responses = [ + AIMessage( + content="Calling tool", + tool_calls=[{"name": "double_tool", "args": {"x": 5}, "id": "call_1"}], + ), + AIMessage(content="Done"), + ] + return _FakeModel(responses=responses) + + def get_weather(city: str) -> str: """Returns the weather in a specific city. Args diff --git a/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py index 65839f9c..96def33b 100644 --- a/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py +++ b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py @@ -4,31 +4,40 @@ # (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. -from typing import Any, Dict, List +from typing import Any, Callable, Dict, List from unittest.mock import patch import pytest -from langchain_core.messages import AIMessage from pyagentspec.adapters.langgraph import AgentSpecLoader from pyagentspec.agent import Agent +from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge +from pyagentspec.flows.flow import Flow +from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode from pyagentspec.llms import OpenAiCompatibleConfig from pyagentspec.property import Property from pyagentspec.tools import ClientTool -def _get_fake_model() -> Any: - """Minimal chat model that immediately finishes, so we never hit a real LLM.""" - from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel - from langchain_openai import ChatOpenAI +class _StopCreateAgent(Exception): + """Raised inside the ``create_agent`` spy to short-circuit graph compilation. - class FakeModel(FakeMessagesListChatModel, ChatOpenAI): - pass + The kwarg-capture tests only care about what ``create_agent`` is called + with; letting the real call proceed would require valid LangChain + middleware instances, which these tests deliberately do not construct. + """ - return FakeModel(responses=[AIMessage(content="Done")]) + +def _spy_create_agent(captured: Dict[str, Any]) -> Callable[..., Any]: + def spy(**kwargs: Any) -> Any: + captured.update(kwargs) + raise _StopCreateAgent() + + return spy -def _build_agent() -> Agent: +@pytest.fixture +def agent_spec() -> Agent: return Agent( name="agent", system_prompt="You are a helpful agent.", @@ -44,69 +53,81 @@ def _build_agent() -> Agent: ) -class _StopCreateAgent(Exception): - """Raised inside the ``create_agent`` spy to short-circuit graph compilation. +@pytest.fixture +def agent_flow(agent_spec: Agent) -> Flow: + start_node = StartNode(name="start") + agent_node = AgentNode(name="agent_node", agent=agent_spec) + end_node = EndNode(name="end") + return Flow( + name="flow", + start_node=start_node, + nodes=[start_node, agent_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_agent", from_node=start_node, to_node=agent_node), + ControlFlowEdge(name="agent_to_end", from_node=agent_node, to_node=end_node), + ], + data_flow_connections=[], + ) - We only care about the keyword arguments ``create_agent`` receives — - letting the real call proceed requires valid LangChain middleware - instances, which these tests deliberately do not construct. - """ +@pytest.fixture +def capture_create_agent_kwargs( + agent_spec: Agent, +) -> Callable[[Callable[[Any], Any]], Dict[str, Any]]: + """Return a callable that drives a load and returns the kwargs ``create_agent`` saw.""" -def _spy_create_agent(captured: Dict[str, Any]): - def spy(**kwargs: Any) -> Any: - captured.update(kwargs) - raise _StopCreateAgent() + def _capture(loader_factory: Callable[[Any], Any]) -> Dict[str, Any]: + from langgraph.checkpoint.memory import MemorySaver - return spy + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.adapters.langgraph._types import langchain_agents + captured: Dict[str, Any] = {} + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + return_value=object(), + ), patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): + loader_or_converter = loader_factory(MemorySaver()) + with pytest.raises(_StopCreateAgent): + loader_or_converter.load_component(agent_spec) + return captured -def _capture_create_agent_kwargs(loader_or_converter_factory) -> Dict[str, Any]: - """Drive a load through ``loader_or_converter_factory()`` and capture kwargs.""" - from langgraph.checkpoint.memory import MemorySaver + return _capture - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) - from pyagentspec.adapters.langgraph._types import langchain_agents - captured: Dict[str, Any] = {} - agent_spec = _build_agent() - with patch.object( - AgentSpecToLangGraphConverter, - "_llm_convert_to_langgraph", - return_value=_get_fake_model(), - ), patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): - loader_or_converter = loader_or_converter_factory(MemorySaver()) - with pytest.raises(_StopCreateAgent): - loader_or_converter.load_component(agent_spec) - return captured - - -def test_default_omits_middleware_kwarg() -> None: +def test_default_omits_middleware_kwarg( + capture_create_agent_kwargs: Callable[[Callable[[Any], Any]], Dict[str, Any]], +) -> None: """``AgentSpecLoader()`` without ``middleware`` must not pass ``middleware=``.""" - captured = _capture_create_agent_kwargs(lambda cp: AgentSpecLoader(checkpointer=cp)) + captured = capture_create_agent_kwargs(lambda cp: AgentSpecLoader(checkpointer=cp)) assert "middleware" not in captured -def test_empty_list_omits_middleware_kwarg() -> None: +def test_empty_list_omits_middleware_kwarg( + capture_create_agent_kwargs: Callable[[Callable[[Any], Any]], Dict[str, Any]], +) -> None: """Passing an empty list is treated the same as omitting the parameter.""" - captured = _capture_create_agent_kwargs( + captured = capture_create_agent_kwargs( lambda cp: AgentSpecLoader(checkpointer=cp, middleware=[]) ) assert "middleware" not in captured -def test_middleware_forwarded_in_order() -> None: +def test_middleware_forwarded_in_order( + capture_create_agent_kwargs: Callable[[Callable[[Any], Any]], Dict[str, Any]], +) -> None: """A non-empty list reaches ``create_agent`` in the original order.""" a, b = object(), object() - captured = _capture_create_agent_kwargs( + captured = capture_create_agent_kwargs( lambda cp: AgentSpecLoader(checkpointer=cp, middleware=[a, b]) ) assert captured.get("middleware") == [a, b] -def test_converter_accepts_middleware_directly() -> None: +def test_converter_accepts_middleware_directly(agent_spec: Agent) -> None: """A list passed directly to the converter reaches ``create_agent``.""" from langgraph.checkpoint.memory import MemorySaver @@ -117,11 +138,10 @@ def test_converter_accepts_middleware_directly() -> None: captured: Dict[str, Any] = {} sentinel = object() - agent_spec = _build_agent() with patch.object( AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", - return_value=_get_fake_model(), + return_value=object(), ), patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): converter = AgentSpecToLangGraphConverter(middleware=[sentinel]) with pytest.raises(_StopCreateAgent): @@ -133,7 +153,9 @@ def test_converter_accepts_middleware_directly() -> None: assert captured.get("middleware") == [sentinel] -def test_middleware_list_is_copied() -> None: +def test_middleware_list_is_copied( + capture_create_agent_kwargs: Callable[[Callable[[Any], Any]], Dict[str, Any]], +) -> None: """Mutating the caller's list after construction must not leak into conversions.""" a = object() caller_list: List[Any] = [a] @@ -145,5 +167,81 @@ def make_loader(cp: Any) -> AgentSpecLoader: caller_list[0] = object() return loader - captured = _capture_create_agent_kwargs(make_loader) + captured = capture_create_agent_kwargs(make_loader) assert captured.get("middleware") == [a] + + +def test_middleware_forwarded_through_flow_agent_node(agent_flow: Flow) -> None: + """Regression: middleware must reach ``create_agent`` for agents inside flows. + + Before this fix, ``AgentNodeExecutor`` instantiated a fresh + ``AgentSpecToLangGraphConverter()`` (no middleware), so any middleware + configured on the outer ``AgentSpecLoader`` was silently dropped for + agents embedded in flows. + """ + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.adapters.langgraph._types import langchain_agents + + captured: Dict[str, Any] = {} + sentinel = object() + compiled = AgentSpecLoader(checkpointer=MemorySaver(), middleware=[sentinel]).load_component( + agent_flow + ) + + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + return_value=object(), + ), patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): + # Triggering execution of the AgentNode lazily compiles the inner agent, + # which is where the middleware kwarg is forwarded. + with pytest.raises(_StopCreateAgent): + compiled.invoke( + {"inputs": {}, "messages": [{"role": "user", "content": ""}]}, + config={"configurable": {"thread_id": "flow-mw-regression"}}, + ) + + assert captured.get("middleware") == [sentinel] + + +def test_middleware_hook_runs_for_flow_agent_node(agent_flow: Flow) -> None: + """Execution: a middleware instance threaded through a flow's AgentNode is actually invoked. + + A real ``AgentMiddleware`` subclass records each ``before_agent`` call; the + LLM is faked via the shared ``make_fake_chat_model`` helper so we never hit + the network and the agent finishes after a single ``AIMessage``. + """ + from langchain.agents.middleware import AgentMiddleware + from langchain_core.messages import AIMessage + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + + from .conftest import make_fake_chat_model + + fake_model = make_fake_chat_model(responses=[AIMessage(content="Done")]) + + calls: List[str] = [] + + class _RecordingMiddleware(AgentMiddleware): + def before_agent(self, state: Any, runtime: Any) -> None: # type: ignore[override] + calls.append("before_agent") + + with patch.object( + AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_model + ): + compiled = AgentSpecLoader( + checkpointer=MemorySaver(), middleware=[_RecordingMiddleware()] + ).load_component(agent_flow) + compiled.invoke( + {"inputs": {}, "messages": [{"role": "user", "content": "hi"}]}, + config={"configurable": {"thread_id": "flow-mw-execution"}}, + ) + + assert calls == ["before_agent"] diff --git a/pyagentspec/tests/adapters/langgraph/test_tools.py b/pyagentspec/tests/adapters/langgraph/test_tools.py index f27a0d82..522943b8 100644 --- a/pyagentspec/tests/adapters/langgraph/test_tools.py +++ b/pyagentspec/tests/adapters/langgraph/test_tools.py @@ -23,6 +23,8 @@ from pyagentspec.property import IntegerProperty, Property from pyagentspec.tools import ClientTool, RemoteTool, ServerTool +from .conftest import make_fake_chat_model as _get_fake_model + class DummyResponse: def __init__(self, obj, status_code: int = 200): @@ -672,25 +674,6 @@ def test_flow_with_remote_tool_confirmation_reject_does_not_call_http() -> None: assert "denied execution" in str(result["outputs"]) -def _get_fake_model() -> Any: - from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel - from langchain_core.messages import AIMessage - from langchain_openai import ChatOpenAI - - class FakeModel(FakeMessagesListChatModel, ChatOpenAI): - pass - - return FakeModel( - responses=[ - AIMessage( - content="Calling tool", - tool_calls=[{"name": "double_tool", "args": {"x": 5}, "id": "call_1"}], - ), - AIMessage(content="Done"), - ] - ) - - def test_server_tool_confirmation_in_agent_approve_executes_tool() -> None: from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.memory import MemorySaver From 13185c5688bdb577143dc8b89ace8e46f0d4e4c2 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Wed, 13 May 2026 10:24:32 +0400 Subject: [PATCH 3/8] Annotate compiled_graph for mypy create_agent(**kwargs) hides the return type, so mypy on 3.10/3.11 was failing. The annotation fixes it. --- .../src/pyagentspec/adapters/langgraph/_langgraphconverter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 5a09060c..0ded5cb4 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -1145,7 +1145,9 @@ def _create_react_agent_with_given_info( # byte-identical to earlier releases. if self._middleware: create_agent_kwargs["middleware"] = self._middleware - compiled_graph = langchain_agents.create_agent(**create_agent_kwargs) + compiled_graph: CompiledStateGraph[Any, Any, Any] = langchain_agents.create_agent( + **create_agent_kwargs + ) # To enable flow execution traces monkey patch all the functions that invoke the compiled graph From 2d8d69716cd2b48008a9e4575e9f2541621314a3 Mon Sep 17 00:00:00 2001 From: Salah Date: Fri, 29 May 2026 08:59:33 +0400 Subject: [PATCH 4/8] Address PR review: move middleware to convert(), refactor tests --- .../adapters/langgraph/_langgraphconverter.py | 71 +++++--- .../adapters/langgraph/_node_execution.py | 5 +- .../adapters/langgraph/agentspecloader.py | 6 +- .../tests/adapters/langgraph/conftest.py | 29 +--- .../langgraph/test_middleware_parameter.py | 163 ++++++++---------- .../tests/adapters/langgraph/test_tools.py | 21 ++- 6 files changed, 146 insertions(+), 149 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 0ded5cb4..eb1ef127 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -190,21 +190,8 @@ def _exec_body(ns: Dict[str, Any]) -> None: class AgentSpecToLangGraphConverter: - def __init__(self, middleware: Optional[List[Any]] = None) -> None: - """Create a converter. - - Parameters - ---------- - middleware - Optional list of LangChain agent middleware instances forwarded - to ``langchain_agents.create_agent(middleware=...)`` when - building a ReAct agent for an Agent Spec ``Agent``. Order is - preserved (index ``0`` is outermost). When ``None`` or empty, - the ``middleware`` keyword is omitted from the ``create_agent`` - call, preserving the byte-identical behavior of earlier - releases. - """ - self._middleware: List[Any] = list(middleware or []) + def __init__(self) -> None: + pass def convert( self, @@ -213,9 +200,31 @@ def convert( converted_components: Optional[Dict[str, Any]] = None, checkpointer: Optional[Checkpointer] = None, config: Optional[RunnableConfig] = None, + middleware: Optional[List[Any]] = None, **kwargs: Any, ) -> Any: - """Convert the given PyAgentSpec component object into the corresponding LangGraph component""" + """Convert the given PyAgentSpec component object into the corresponding LangGraph component. + + Parameters + ---------- + agentspec_component: + The Agent Spec component to convert. + tool_registry: + Dictionary mapping tool names to LangGraph tool objects. + converted_components: + Optional cache of already-converted components (keyed by component id). + checkpointer: + Optional LangGraph checkpointer to wire into created graphs. + config: + Optional ``RunnableConfig`` to pass to created runnables/graphs. + middleware: + Optional list of LangChain agent middleware instances forwarded to + ``langchain_agents.create_agent(middleware=...)`` when compiling an Agent + Spec ``Agent`` into a ReAct graph. Order is preserved — index ``0`` is the + outermost middleware. When ``None`` or an empty list, the ``middleware`` + keyword is omitted entirely from the ``create_agent`` call. + """ + middleware_list: List[Any] = list(middleware or []) if converted_components is None: converted_components = {} if config is None: @@ -225,7 +234,7 @@ def convert( config = RunnableConfig({}) if agentspec_component.id not in converted_components: converted_components[agentspec_component.id] = self._convert( - agentspec_component, tool_registry, converted_components, checkpointer, config + agentspec_component, tool_registry, converted_components, checkpointer, config, middleware_list ) return converted_components[agentspec_component.id] @@ -236,6 +245,7 @@ def _convert( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: List[Any], ) -> Any: if isinstance(agentspec_component, AgentSpecAgent): return self._agent_convert_to_langgraph( @@ -244,6 +254,7 @@ def _convert( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) elif isinstance(agentspec_component, AgentSpecSwarm): return self._swarm_convert_to_langgraph( @@ -252,6 +263,7 @@ def _convert( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) elif isinstance(agentspec_component, AgentSpecLlmConfig): return self._llm_convert_to_langgraph(agentspec_component, config=config) @@ -290,6 +302,7 @@ def _convert( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) elif isinstance(agentspec_component, AgentSpecNode): return self._node_convert_to_langgraph( @@ -298,6 +311,7 @@ def _convert( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) elif isinstance(agentspec_component, AgentSpecComponent): raise NotImplementedError( @@ -341,6 +355,7 @@ def _flow_convert_to_langgraph( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: List[Any], ) -> CompiledStateGraph[Any, Any, Any]: graph_builder = StateGraph( @@ -356,6 +371,7 @@ def _flow_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) for node in flow.nodes } @@ -536,6 +552,7 @@ def _node_convert_to_langgraph( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: List[Any], ) -> "NodeExecutor": if isinstance(node, AgentSpecStartNode): return self._start_node_convert_to_langgraph(node) @@ -564,6 +581,7 @@ def _node_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) elif isinstance(node, AgentSpecBranchingNode): return self._branching_node_convert_to_langgraph(node) @@ -714,6 +732,7 @@ def _agent_node_convert_to_langgraph( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: List[Any], ) -> "NodeExecutor": from pyagentspec.adapters.langgraph._node_execution import AgentNodeExecutor @@ -723,7 +742,7 @@ def _agent_node_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, - middleware=self._middleware, + middleware=middleware, ) def _llm_node_convert_to_langgraph( @@ -1010,6 +1029,7 @@ def _swarm_convert_to_langgraph( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: List[Any], ) -> CompiledStateGraph[Any, Any, Any]: if agentspec_component.handoff is AgentSpecHandoffMode.NEVER: # As of now, we cannot control what langgraph-swarm does internally in terms of conversation sharing. @@ -1040,6 +1060,7 @@ def _swarm_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) handoffs: dict[str, list[str]] = {agent_name: [] for agent_name in agents} for from_agent, to_agent in agentspec_component.relationships: @@ -1059,6 +1080,7 @@ def _swarm_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, additional_langgraph_tools=[ langgraph_swarm.create_handoff_tool(agent_name=to_agent_name) for to_agent_name in handoffs.get(agent.name, []) @@ -1086,6 +1108,7 @@ def _create_react_agent_with_given_info( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: List[Any], additional_langgraph_tools: Optional[List[LangGraphTool]] = None, ) -> CompiledStateGraph[Any, Any, Any]: model = self.convert( @@ -1141,13 +1164,9 @@ def _create_react_agent_with_given_info( response_format=output_model, state_schema=state_schema, ) - # Omit the keyword when no middleware was supplied so the call is - # byte-identical to earlier releases. - if self._middleware: - create_agent_kwargs["middleware"] = self._middleware - compiled_graph: CompiledStateGraph[Any, Any, Any] = langchain_agents.create_agent( - **create_agent_kwargs - ) + if middleware: + create_agent_kwargs["middleware"] = 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 @@ -1233,6 +1252,7 @@ def _agent_convert_to_langgraph( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: List[Any], ) -> CompiledStateGraph[Any, Any, Any]: return self._create_react_agent_with_given_info( name=agentspec_component.name, @@ -1247,6 +1267,7 @@ def _agent_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) def _llm_convert_to_langgraph( diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index 476c2d7a..3d144e6f 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -510,9 +510,7 @@ def _create_react_agent_with_given_input_values( agentspec_component = self.node.agent system_prompt = render_template(agentspec_component.system_prompt, inputs) if system_prompt not in self._agents_cache: - self._agents_cache[system_prompt] = AgentSpecToLangGraphConverter( - middleware=self._middleware - )._create_react_agent_with_given_info( + self._agents_cache[system_prompt] = AgentSpecToLangGraphConverter()._create_react_agent_with_given_info( name=agentspec_component.name, system_prompt=system_prompt, agent=agentspec_component, @@ -525,6 +523,7 @@ def _create_react_agent_with_given_input_values( converted_components=self.converted_components, checkpointer=self.checkpointer, config=self.config, + middleware=self._middleware, ) return self._agents_cache[system_prompt] diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py b/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py index c8f11a9d..360616a2 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py @@ -54,8 +54,7 @@ class AgentSpecLoader(AdapterAgnosticAgentSpecLoader): ``langchain_agents.create_agent(middleware=...)`` when compiling an Agent Spec ``Agent`` into a ReAct graph. Order is preserved — index ``0`` is the outermost middleware. When ``None`` or an empty list, the ``middleware`` keyword is - omitted entirely from the ``create_agent`` call and behavior is identical to - earlier releases. + omitted entirely from the ``create_agent`` call. """ def __init__( @@ -81,7 +80,7 @@ def __init__( @property def agentspec_to_runtime_converter(self) -> AgentSpecToLangGraphConverter: - return AgentSpecToLangGraphConverter(middleware=self._middleware) + return AgentSpecToLangGraphConverter() @property def runtime_to_agentspec_converter(self) -> LangGraphToAgentSpecConverter: @@ -296,5 +295,6 @@ def load_component(self, agentspec_component: AgentSpecComponent) -> LangGraphRu tool_registry=self.tool_registry, checkpointer=self.checkpointer, config=self.config, + middleware=self._middleware, ), ) diff --git a/pyagentspec/tests/adapters/langgraph/conftest.py b/pyagentspec/tests/adapters/langgraph/conftest.py index db65d26f..cc697e0b 100644 --- a/pyagentspec/tests/adapters/langgraph/conftest.py +++ b/pyagentspec/tests/adapters/langgraph/conftest.py @@ -5,7 +5,7 @@ # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. from pathlib import Path -from typing import Any, List, Optional +from typing import Any import pytest @@ -21,33 +21,6 @@ def pytest_collection_modifyitems(config: Any, items: Any): ) -def make_fake_chat_model(responses: Optional[List[Any]] = None) -> Any: - """Build a chat model stub that replays a fixed list of ``AIMessage`` responses. - - Mixes ``FakeMessagesListChatModel`` into ``ChatOpenAI`` so call sites that - do ``isinstance(model, ChatOpenAI)`` (or rely on ``ChatOpenAI``-specific - bindings) continue to work. When ``responses`` is omitted, a one-shot - tool-calling sequence is returned to match the historical default used by - confirmation tests. - """ - from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel - from langchain_core.messages import AIMessage - from langchain_openai import ChatOpenAI - - class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): - pass - - if responses is None: - responses = [ - AIMessage( - content="Calling tool", - tool_calls=[{"name": "double_tool", "args": {"x": 5}, "id": "call_1"}], - ), - AIMessage(content="Done"), - ] - return _FakeModel(responses=responses) - - def get_weather(city: str) -> str: """Returns the weather in a specific city. Args diff --git a/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py index 96def33b..91fe8947 100644 --- a/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py +++ b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py @@ -4,14 +4,13 @@ # (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. -from typing import Any, Callable, Dict, List +from typing import Any, Callable, Dict, List, Optional from unittest.mock import patch import pytest -from pyagentspec.adapters.langgraph import AgentSpecLoader from pyagentspec.agent import Agent -from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge +from pyagentspec.flows.edges import ControlFlowEdge from pyagentspec.flows.flow import Flow from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode from pyagentspec.llms import OpenAiCompatibleConfig @@ -37,7 +36,7 @@ def spy(**kwargs: Any) -> Any: @pytest.fixture -def agent_spec() -> Agent: +def agent() -> Agent: return Agent( name="agent", system_prompt="You are a helpful agent.", @@ -54,9 +53,9 @@ def agent_spec() -> Agent: @pytest.fixture -def agent_flow(agent_spec: Agent) -> Flow: +def agent_flow(agent: Agent) -> Flow: start_node = StartNode(name="start") - agent_node = AgentNode(name="agent_node", agent=agent_spec) + agent_node = AgentNode(name="agent_node", agent=agent) end_node = EndNode(name="end") return Flow( name="flow", @@ -72,11 +71,11 @@ def agent_flow(agent_spec: Agent) -> Flow: @pytest.fixture def capture_create_agent_kwargs( - agent_spec: Agent, -) -> Callable[[Callable[[Any], Any]], Dict[str, Any]]: - """Return a callable that drives a load and returns the kwargs ``create_agent`` saw.""" + agent: Agent, +) -> Callable[[Optional[List[Any]]], Dict[str, Any]]: + """Return a callable that drives a conversion and returns the kwargs ``create_agent`` saw.""" - def _capture(loader_factory: Callable[[Any], Any]) -> Dict[str, Any]: + def _capture(middleware: Optional[List[Any]]) -> Dict[str, Any]: from langgraph.checkpoint.memory import MemorySaver from pyagentspec.adapters.langgraph._langgraphconverter import ( @@ -85,89 +84,69 @@ def _capture(loader_factory: Callable[[Any], Any]) -> Dict[str, Any]: from pyagentspec.adapters.langgraph._types import langchain_agents captured: Dict[str, Any] = {} - with patch.object( - AgentSpecToLangGraphConverter, - "_llm_convert_to_langgraph", - return_value=object(), - ), patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): - loader_or_converter = loader_factory(MemorySaver()) + with patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): + loader = AgentSpecToLangGraphConverter() with pytest.raises(_StopCreateAgent): - loader_or_converter.load_component(agent_spec) + loader.convert( + agent, + tool_registry={}, + converted_components={agent.llm_config.id: object()}, + checkpointer=MemorySaver(), + middleware=middleware, + ) return captured return _capture -def test_default_omits_middleware_kwarg( - capture_create_agent_kwargs: Callable[[Callable[[Any], Any]], Dict[str, Any]], -) -> None: - """``AgentSpecLoader()`` without ``middleware`` must not pass ``middleware=``.""" - captured = capture_create_agent_kwargs(lambda cp: AgentSpecLoader(checkpointer=cp)) - assert "middleware" not in captured - - -def test_empty_list_omits_middleware_kwarg( - capture_create_agent_kwargs: Callable[[Callable[[Any], Any]], Dict[str, Any]], +@pytest.mark.parametrize( + "middleware", + [None, []], + ids=["none", "empty_list"], +) +def test_omits_middleware_kwarg_when_not_provided( + capture_create_agent_kwargs: Callable[[Optional[List[Any]]], Dict[str, Any]], + middleware: Optional[List[Any]], ) -> None: - """Passing an empty list is treated the same as omitting the parameter.""" - captured = capture_create_agent_kwargs( - lambda cp: AgentSpecLoader(checkpointer=cp, middleware=[]) - ) + """``AgentSpecLoader()`` without ``middleware`` (or with empty list) must not pass ``middleware=``.""" + captured = capture_create_agent_kwargs(middleware) assert "middleware" not in captured def test_middleware_forwarded_in_order( - capture_create_agent_kwargs: Callable[[Callable[[Any], Any]], Dict[str, Any]], + capture_create_agent_kwargs: Callable[[Optional[List[Any]]], Dict[str, Any]], ) -> None: """A non-empty list reaches ``create_agent`` in the original order.""" a, b = object(), object() - captured = capture_create_agent_kwargs( - lambda cp: AgentSpecLoader(checkpointer=cp, middleware=[a, b]) - ) + captured = capture_create_agent_kwargs([a, b]) assert captured.get("middleware") == [a, b] -def test_converter_accepts_middleware_directly(agent_spec: Agent) -> None: - """A list passed directly to the converter reaches ``create_agent``.""" +def test_middleware_list_is_copied( + agent: Agent, +) -> None: + """Mutating the caller's list after construction must not leak into conversions.""" from langgraph.checkpoint.memory import MemorySaver - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter from pyagentspec.adapters.langgraph._types import langchain_agents + a = object() + caller_list: List[Any] = [a] + loader = AgentSpecLoader(checkpointer=MemorySaver(), middleware=caller_list) + caller_list.append(object()) + caller_list[0] = object() + captured: Dict[str, Any] = {} - sentinel = object() with patch.object( AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=object(), ), patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): - converter = AgentSpecToLangGraphConverter(middleware=[sentinel]) with pytest.raises(_StopCreateAgent): - converter.convert( - agent_spec, - tool_registry={}, - checkpointer=MemorySaver(), - ) - assert captured.get("middleware") == [sentinel] - + loader.load_component(agent) -def test_middleware_list_is_copied( - capture_create_agent_kwargs: Callable[[Callable[[Any], Any]], Dict[str, Any]], -) -> None: - """Mutating the caller's list after construction must not leak into conversions.""" - a = object() - caller_list: List[Any] = [a] - - def make_loader(cp: Any) -> AgentSpecLoader: - loader = AgentSpecLoader(checkpointer=cp, middleware=caller_list) - # Post-construction mutation must not affect the loader's behavior. - caller_list.append(object()) - caller_list[0] = object() - return loader - - captured = capture_create_agent_kwargs(make_loader) assert captured.get("middleware") == [a] @@ -186,17 +165,19 @@ def test_middleware_forwarded_through_flow_agent_node(agent_flow: Flow) -> None: ) from pyagentspec.adapters.langgraph._types import langchain_agents + agent_llm_id = agent_flow.nodes[1].agent.llm_config.id # type: ignore[union-attr] captured: Dict[str, Any] = {} sentinel = object() - compiled = AgentSpecLoader(checkpointer=MemorySaver(), middleware=[sentinel]).load_component( - agent_flow + checkpointer = MemorySaver() + compiled = AgentSpecToLangGraphConverter().convert( + agent_flow, + tool_registry={}, + converted_components={agent_llm_id: object()}, + checkpointer=checkpointer, + middleware=[sentinel], ) - with patch.object( - AgentSpecToLangGraphConverter, - "_llm_convert_to_langgraph", - return_value=object(), - ), patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): + with patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): # Triggering execution of the AgentNode lazily compiles the inner agent, # which is where the middleware kwarg is forwarded. with pytest.raises(_StopCreateAgent): @@ -212,20 +193,21 @@ def test_middleware_hook_runs_for_flow_agent_node(agent_flow: Flow) -> None: """Execution: a middleware instance threaded through a flow's AgentNode is actually invoked. A real ``AgentMiddleware`` subclass records each ``before_agent`` call; the - LLM is faked via the shared ``make_fake_chat_model`` helper so we never hit - the network and the agent finishes after a single ``AIMessage``. + LLM is injected via ``converted_components`` so we never hit the network + and the agent finishes after a single ``AIMessage``. """ from langchain.agents.middleware import AgentMiddleware + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel from langchain_core.messages import AIMessage from langgraph.checkpoint.memory import MemorySaver - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter - from .conftest import make_fake_chat_model + class _FakeModel(FakeMessagesListChatModel): + def bind_tools(self, tools: Any, **kwargs: Any) -> Any: + return self - fake_model = make_fake_chat_model(responses=[AIMessage(content="Done")]) + fake_model = _FakeModel(responses=[AIMessage(content="Done")]) calls: List[str] = [] @@ -233,15 +215,20 @@ class _RecordingMiddleware(AgentMiddleware): def before_agent(self, state: Any, runtime: Any) -> None: # type: ignore[override] calls.append("before_agent") - with patch.object( - AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_model - ): - compiled = AgentSpecLoader( - checkpointer=MemorySaver(), middleware=[_RecordingMiddleware()] - ).load_component(agent_flow) - compiled.invoke( - {"inputs": {}, "messages": [{"role": "user", "content": "hi"}]}, - config={"configurable": {"thread_id": "flow-mw-execution"}}, - ) + middleware_instance = _RecordingMiddleware() + agent_llm_id = agent_flow.nodes[1].agent.llm_config.id # type: ignore[union-attr] + checkpointer = MemorySaver() + + compiled = AgentSpecToLangGraphConverter().convert( + agent_flow, + tool_registry={}, + converted_components={agent_llm_id: fake_model}, + checkpointer=checkpointer, + middleware=[middleware_instance], + ) + compiled.invoke( + {"inputs": {}, "messages": [{"role": "user", "content": "hi"}]}, + config={"configurable": {"thread_id": "flow-mw-execution"}}, + ) assert calls == ["before_agent"] diff --git a/pyagentspec/tests/adapters/langgraph/test_tools.py b/pyagentspec/tests/adapters/langgraph/test_tools.py index 522943b8..f27a0d82 100644 --- a/pyagentspec/tests/adapters/langgraph/test_tools.py +++ b/pyagentspec/tests/adapters/langgraph/test_tools.py @@ -23,8 +23,6 @@ from pyagentspec.property import IntegerProperty, Property from pyagentspec.tools import ClientTool, RemoteTool, ServerTool -from .conftest import make_fake_chat_model as _get_fake_model - class DummyResponse: def __init__(self, obj, status_code: int = 200): @@ -674,6 +672,25 @@ def test_flow_with_remote_tool_confirmation_reject_does_not_call_http() -> None: assert "denied execution" in str(result["outputs"]) +def _get_fake_model() -> Any: + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + + class FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + return FakeModel( + responses=[ + AIMessage( + content="Calling tool", + tool_calls=[{"name": "double_tool", "args": {"x": 5}, "id": "call_1"}], + ), + AIMessage(content="Done"), + ] + ) + + def test_server_tool_confirmation_in_agent_approve_executes_tool() -> None: from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.memory import MemorySaver From b91f890635ea93e1095331a7017ee4ae35476195 Mon Sep 17 00:00:00 2001 From: Salah Date: Fri, 29 May 2026 09:03:56 +0400 Subject: [PATCH 5/8] Remove implementation detail from docstring --- .../tests/adapters/langgraph/test_middleware_parameter.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py index 91fe8947..f1f5e6d9 100644 --- a/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py +++ b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py @@ -151,13 +151,7 @@ def test_middleware_list_is_copied( def test_middleware_forwarded_through_flow_agent_node(agent_flow: Flow) -> None: - """Regression: middleware must reach ``create_agent`` for agents inside flows. - - Before this fix, ``AgentNodeExecutor`` instantiated a fresh - ``AgentSpecToLangGraphConverter()`` (no middleware), so any middleware - configured on the outer ``AgentSpecLoader`` was silently dropped for - agents embedded in flows. - """ + """Middleware must reach ``create_agent`` for agents inside flows.""" from langgraph.checkpoint.memory import MemorySaver from pyagentspec.adapters.langgraph._langgraphconverter import ( From 3751f67bfb7152e46d33e349a72dd089ff14c883 Mon Sep 17 00:00:00 2001 From: Salah Date: Fri, 5 Jun 2026 20:15:31 +0400 Subject: [PATCH 6/8] style: black formatting --- .../pyagentspec/adapters/langgraph/_langgraphconverter.py | 7 ++++++- .../src/pyagentspec/adapters/langgraph/_node_execution.py | 4 +++- .../tests/adapters/langgraph/test_middleware_parameter.py | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index eb1ef127..a103e494 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -234,7 +234,12 @@ def convert( config = RunnableConfig({}) if agentspec_component.id not in converted_components: converted_components[agentspec_component.id] = self._convert( - agentspec_component, tool_registry, converted_components, checkpointer, config, middleware_list + agentspec_component, + tool_registry, + converted_components, + checkpointer, + config, + middleware_list, ) return converted_components[agentspec_component.id] diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index 3d144e6f..f1999aef 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -510,7 +510,9 @@ def _create_react_agent_with_given_input_values( agentspec_component = self.node.agent system_prompt = render_template(agentspec_component.system_prompt, inputs) if system_prompt not in self._agents_cache: - self._agents_cache[system_prompt] = AgentSpecToLangGraphConverter()._create_react_agent_with_given_info( + self._agents_cache[ + system_prompt + ] = AgentSpecToLangGraphConverter()._create_react_agent_with_given_info( name=agentspec_component.name, system_prompt=system_prompt, agent=agentspec_component, diff --git a/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py index f1f5e6d9..33bbdeb4 100644 --- a/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py +++ b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py @@ -84,7 +84,9 @@ def _capture(middleware: Optional[List[Any]]) -> Dict[str, Any]: from pyagentspec.adapters.langgraph._types import langchain_agents captured: Dict[str, Any] = {} - with patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): + with patch.object( + langchain_agents, "create_agent", side_effect=_spy_create_agent(captured) + ): loader = AgentSpecToLangGraphConverter() with pytest.raises(_StopCreateAgent): loader.convert( From 668c0a63fa5c26784d9c02c0cbb7f380d347a2d5 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Mon, 8 Jun 2026 21:30:47 +0400 Subject: [PATCH 7/8] Forward middleware through nested subflow conversions --- .../adapters/langgraph/_langgraphconverter.py | 9 +++ .../langgraph/test_middleware_parameter.py | 60 ++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index a103e494..ff7a5b6c 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -599,6 +599,7 @@ def _node_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) elif isinstance(node, AgentSpecCatchExceptionNode): return self._catch_exception_node_convert_to_langgraph( @@ -607,6 +608,7 @@ def _node_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) elif isinstance(node, AgentSpecInputMessageNode): return self._input_message_node_convert_to_langgraph(node) @@ -619,6 +621,7 @@ def _node_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) else: raise NotImplementedError( @@ -648,6 +651,7 @@ def _map_node_convert_to_langgraph( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: List[Any], ) -> "NodeExecutor": from pyagentspec.adapters.langgraph._node_execution import MapNodeExecutor @@ -657,6 +661,7 @@ def _map_node_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) if not isinstance(subflow, CompiledStateGraph): raise TypeError("MapNodeExecutor can only be initialized with MapNode") @@ -670,6 +675,7 @@ def _flow_node_convert_to_langgraph( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: List[Any], ) -> "NodeExecutor": from pyagentspec.adapters.langgraph._node_execution import FlowNodeExecutor @@ -679,6 +685,7 @@ def _flow_node_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) if not isinstance(subflow, CompiledStateGraph): raise TypeError("FlowNodeExecutor can only initialize FlowNode") @@ -696,6 +703,7 @@ def _catch_exception_node_convert_to_langgraph( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: List[Any], ) -> "NodeExecutor": from pyagentspec.adapters.langgraph._node_execution import CatchExceptionNodeExecutor @@ -705,6 +713,7 @@ def _catch_exception_node_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) if not isinstance(subflow, CompiledStateGraph): raise TypeError( diff --git a/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py index 33bbdeb4..8f7381d9 100644 --- a/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py +++ b/pyagentspec/tests/adapters/langgraph/test_middleware_parameter.py @@ -12,7 +12,7 @@ from pyagentspec.agent import Agent from pyagentspec.flows.edges import ControlFlowEdge from pyagentspec.flows.flow import Flow -from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode +from pyagentspec.flows.nodes import AgentNode, EndNode, FlowNode, StartNode from pyagentspec.llms import OpenAiCompatibleConfig from pyagentspec.property import Property from pyagentspec.tools import ClientTool @@ -69,6 +69,29 @@ def agent_flow(agent: Agent) -> Flow: ) +@pytest.fixture +def nested_agent_flow(agent_flow: Flow) -> Flow: + """An outer flow whose single ``FlowNode`` wraps ``agent_flow`` as a subflow. + + Exercises the recursive ``self.convert(subflow, ...)`` path in + ``_flow_node_convert_to_langgraph`` to ensure the ``middleware`` threaded into + ``convert`` still reaches an ``AgentNode`` nested inside a subflow. + """ + start_node = StartNode(name="outer_start") + flow_node = FlowNode(name="flow_node", subflow=agent_flow) + end_node = EndNode(name="outer_end") + return Flow( + name="outer_flow", + start_node=start_node, + nodes=[start_node, flow_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_flow", from_node=start_node, to_node=flow_node), + ControlFlowEdge(name="flow_to_end", from_node=flow_node, to_node=end_node), + ], + data_flow_connections=[], + ) + + @pytest.fixture def capture_create_agent_kwargs( agent: Agent, @@ -185,6 +208,41 @@ def test_middleware_forwarded_through_flow_agent_node(agent_flow: Flow) -> None: assert captured.get("middleware") == [sentinel] +def test_middleware_forwarded_through_nested_subflow_agent_node(nested_agent_flow: Flow) -> None: + """Regression: middleware must reach agents nested inside a ``FlowNode`` subflow. + + ``_flow_node_convert_to_langgraph`` recurses via ``self.convert(subflow, ...)``, + so the ``middleware`` threaded into ``convert`` must be forwarded through the + subflow conversion to reach an ``AgentNode`` one (or more) subflows deep. + """ + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.adapters.langgraph._types import langchain_agents + + nested_agent = nested_agent_flow.nodes[1].subflow.nodes[1].agent # type: ignore[union-attr] + captured: Dict[str, Any] = {} + sentinel = object() + compiled = AgentSpecToLangGraphConverter().convert( + nested_agent_flow, + tool_registry={}, + converted_components={nested_agent.llm_config.id: object()}, + checkpointer=MemorySaver(), + middleware=[sentinel], + ) + + with patch.object(langchain_agents, "create_agent", side_effect=_spy_create_agent(captured)): + with pytest.raises(_StopCreateAgent): + compiled.invoke( + {"inputs": {}, "messages": [{"role": "user", "content": ""}]}, + config={"configurable": {"thread_id": "nested-flow-mw-regression"}}, + ) + + assert captured.get("middleware") == [sentinel] + + def test_middleware_hook_runs_for_flow_agent_node(agent_flow: Flow) -> None: """Execution: a middleware instance threaded through a flow's AgentNode is actually invoked. From 57fa4663f97b75683165ed72fa562d89b6309f8e Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Thu, 11 Jun 2026 13:25:47 +0400 Subject: [PATCH 8/8] Address review nits --- .../adapters/langgraph/_langgraphconverter.py | 3 --- .../adapters/langgraph/agentspecloader.py | 15 +++++++-------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index ff7a5b6c..fb54281f 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -190,9 +190,6 @@ def _exec_body(ns: Dict[str, Any]) -> None: class AgentSpecToLangGraphConverter: - def __init__(self) -> None: - pass - def convert( self, agentspec_component: AgentSpecComponent, diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py b/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py index 360616a2..00d9f239 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py @@ -39,6 +39,12 @@ class AgentSpecLoader(AdapterAgnosticAgentSpecLoader): enables features that require a checkpointer (e.g., client tools). config: Optional ``RunnableConfig`` to pass to created runnables/graphs. + middleware: + Optional list of LangChain agent middleware instances forwarded verbatim to + ``langchain_agents.create_agent(middleware=...)`` when compiling an Agent Spec + ``Agent`` into a ReAct graph. Order is preserved — index ``0`` is the outermost + middleware. When ``None`` or an empty list, the ``middleware`` keyword is + omitted entirely from the ``create_agent`` call. allowed_components: Optional iterable of Agent Spec component type names or Component classes allowed to be loaded. If omitted, all component types are allowed unless blocked. @@ -49,12 +55,6 @@ class AgentSpecLoader(AdapterAgnosticAgentSpecLoader): type names match only the exact serialized component type. When allow and block entries both match, the closest match in the component class hierarchy wins; block entries win same-distance ties. - middleware: - Optional list of LangChain agent middleware instances forwarded verbatim to - ``langchain_agents.create_agent(middleware=...)`` when compiling an Agent Spec - ``Agent`` into a ReAct graph. Order is preserved — index ``0`` is the outermost - middleware. When ``None`` or an empty list, the ``middleware`` keyword is - omitted entirely from the ``create_agent`` call. """ def __init__( @@ -63,10 +63,9 @@ def __init__( plugins: Optional[List[ComponentDeserializationPlugin]] = None, checkpointer: Optional[Checkpointer] = None, config: Optional[RunnableConfig] = None, - *, + middleware: Optional[List[Any]] = None, allowed_components: Optional[ComponentPolicyInput] = None, blocked_components: Optional[ComponentPolicyInput] = None, - middleware: Optional[List[Any]] = None, ) -> None: super().__init__( plugins=plugins,