Skip to content

Commit 551372b

Browse files
GWealecopybara-github
authored andcommitted
fix: require patched async LangGraph runtime
The old LangGraph version range allowed releases affected by a published security advisory, and the adapter called synchronous state and invocation APIs from an async path, including a state lookup with no checkpointer. This requires a patched LangGraph release and moves the adapter to the async compiled-state APIs (CompiledStateGraph, aget_state, ainvoke), skipping the state lookup when the graph has no checkpointer. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 950895002
1 parent be5828f commit 551372b

2 files changed

Lines changed: 90 additions & 92 deletions

File tree

src/google/adk/agents/langgraph_agent.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
from typing import Any
1718
from typing import AsyncGenerator
1819
from typing import Union
1920

@@ -23,7 +24,7 @@
2324
from langchain_core.messages import HumanMessage
2425
from langchain_core.messages import SystemMessage
2526
from langchain_core.runnables.config import RunnableConfig
26-
from langgraph.graph.graph import CompiledGraph
27+
from langgraph.graph.state import CompiledStateGraph
2728
from pydantic import ConfigDict
2829
from typing_extensions import override
2930

@@ -53,14 +54,20 @@ def _get_last_human_messages(
5354

5455

5556
class LangGraphAgent(BaseAgent):
56-
"""Currently a concept implementation, supports single and multi-turn."""
57+
"""Adapts a compiled LangGraph state graph for single or multi-turn use.
58+
59+
When using a persistent checkpointer, set ``LANGGRAPH_STRICT_MSGPACK=true``
60+
before importing LangGraph and compiling the graph. LangGraph's patched
61+
releases provide schema-derived checkpoint allowlisting, but do not enable
62+
strict deserialization by default.
63+
"""
5764

5865
model_config = ConfigDict(
5966
arbitrary_types_allowed=True,
6067
)
6168
"""The pydantic model config."""
6269

63-
graph: CompiledGraph
70+
graph: CompiledStateGraph
6471

6572
instruction: str = ''
6673

@@ -73,13 +80,16 @@ async def _run_async_impl(
7380
# Needed for langgraph checkpointer (for subsequent invocations; multi-turn)
7481
config: RunnableConfig = {'configurable': {'thread_id': ctx.session.id}}
7582

76-
# Add instruction as SystemMessage if graph state is empty
77-
current_graph_state = self.graph.get_state(config)
78-
graph_messages = (
79-
current_graph_state.values.get('messages', [])
80-
if current_graph_state.values
81-
else []
82-
)
83+
# Add instruction as SystemMessage if graph state is empty. State lookup is
84+
# only valid when the compiled graph has a checkpointer.
85+
graph_messages: list[Any] = []
86+
if self.graph.checkpointer:
87+
current_graph_state = await self.graph.aget_state(config)
88+
graph_messages = (
89+
current_graph_state.values.get('messages', [])
90+
if current_graph_state.values
91+
else []
92+
)
8393
messages: list[BaseMessage] = (
8494
[SystemMessage(content=self.instruction)]
8595
if self.instruction and not graph_messages
@@ -89,7 +99,7 @@ async def _run_async_impl(
8999
messages += self._get_messages(ctx.session.events)
90100

91101
# Use the Runnable
92-
final_state = self.graph.invoke({'messages': messages}, config)
102+
final_state = await self.graph.ainvoke({'messages': messages}, config)
93103
result = final_state['messages'][-1].content
94104

95105
result_event = Event(

tests/unittests/agents/test_langgraph_agent.py

Lines changed: 69 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -12,85 +12,24 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from unittest.mock import AsyncMock
1516
from unittest.mock import MagicMock
1617

1718
import pytest
1819

19-
# Skip all tests in this module if LangGraph dependencies are not available
20-
LANGGRAPH_AVAILABLE = True
21-
try:
22-
from google.adk.agents.invocation_context import InvocationContext
23-
from google.adk.agents.langgraph_agent import LangGraphAgent
24-
from google.adk.events.event import Event
25-
from google.adk.plugins.plugin_manager import PluginManager
26-
from google.genai import types
27-
from langchain_core.messages import AIMessage
28-
from langchain_core.messages import HumanMessage
29-
from langchain_core.messages import SystemMessage
30-
from langgraph.graph.graph import CompiledGraph
31-
except ImportError:
32-
LANGGRAPH_AVAILABLE = False
20+
pytest.importorskip("langgraph", reason="LangGraph dependencies not available")
3321

34-
# IMPORTANT: Dummy classes are REQUIRED in this file but NOT in A2A test files.
35-
# Here's why this file is different from A2A test files:
36-
#
37-
# 1. MODULE-LEVEL USAGE IN DECORATORS:
38-
# This file uses @pytest.mark.parametrize decorator with complex nested structures
39-
# that directly reference imported types like Event(), types.Content(), types.Part.from_text().
40-
# These decorator expressions are evaluated during MODULE COMPILATION TIME,
41-
# not during test execution time.
42-
#
43-
# 2. A2A TEST FILES PATTERN:
44-
# Most A2A test files only use imported types within test method bodies:
45-
# - Inside test functions: def test_something(): Message(...)
46-
# - These are evaluated during TEST EXECUTION TIME when tests are skipped
47-
# - No NameError occurs because skipped tests don't execute their bodies
48-
#
49-
# 3. WHAT HAPPENS WITHOUT DUMMIES:
50-
# If we remove dummy classes from this file:
51-
# - Python tries to compile the @pytest.mark.parametrize decorator
52-
# - It encounters Event(...), types.Content(...), etc.
53-
# - These names are undefined → NameError during module compilation
54-
# - Test collection fails before pytest.mark.skipif can even run
55-
#
56-
# 4. WHY DUMMIES WORK:
57-
# - DummyTypes() can be called like Event() → returns DummyTypes instance
58-
# - DummyTypes.__getattr__ handles types.Content → returns DummyTypes instance
59-
# - DummyTypes.__call__ handles types.Part.from_text() → returns DummyTypes instance
60-
# - The parametrize decorator gets dummy objects instead of real ones
61-
# - Tests still get skipped due to pytestmark, so dummies never actually run
62-
#
63-
# 5. EXCEPTION CASES IN A2A FILES:
64-
# A few A2A files DID need dummies initially because they had:
65-
# - Type annotations: def create_helper(x: str) -> Message
66-
# - But we removed those type annotations to eliminate the need for dummies
67-
#
68-
# This file cannot avoid dummies because the parametrize decorator usage
69-
# is fundamental to the test structure and cannot be easily refactored.
70-
71-
class DummyTypes:
72-
73-
def __getattr__(self, name):
74-
return DummyTypes()
75-
76-
def __call__(self, *args, **kwargs):
77-
return DummyTypes()
78-
79-
InvocationContext = DummyTypes()
80-
LangGraphAgent = DummyTypes()
81-
Event = DummyTypes()
82-
PluginManager = DummyTypes()
83-
types = (
84-
DummyTypes()
85-
) # Must support chained calls like types.Content(), types.Part.from_text()
86-
AIMessage = DummyTypes()
87-
HumanMessage = DummyTypes()
88-
SystemMessage = DummyTypes()
89-
CompiledGraph = DummyTypes()
90-
91-
pytestmark = pytest.mark.skipif(
92-
not LANGGRAPH_AVAILABLE, reason="LangGraph dependencies not available"
93-
)
22+
from google.adk.agents.invocation_context import InvocationContext
23+
from google.adk.agents.langgraph_agent import LangGraphAgent
24+
from google.adk.events.event import Event
25+
from google.adk.plugins.plugin_manager import PluginManager
26+
from google.genai import types
27+
from langchain_core.messages import AIMessage
28+
from langchain_core.messages import HumanMessage
29+
from langchain_core.messages import SystemMessage
30+
from langgraph.graph import MessagesState
31+
from langgraph.graph import StateGraph
32+
from langgraph.graph.state import CompiledStateGraph
9433

9534

9635
@pytest.mark.parametrize(
@@ -219,15 +158,15 @@ def __call__(self, *args, **kwargs):
219158
async def test_langgraph_agent(
220159
checkpointer_value, events_list, expected_messages
221160
):
222-
mock_graph = MagicMock(spec=CompiledGraph)
161+
mock_graph = MagicMock(spec=CompiledStateGraph)
223162
mock_graph_state = MagicMock()
224163
mock_graph_state.values = {}
225-
mock_graph.get_state.return_value = mock_graph_state
164+
mock_graph.aget_state = AsyncMock(return_value=mock_graph_state)
226165

227166
mock_graph.checkpointer = checkpointer_value
228-
mock_graph.invoke.return_value = {
229-
"messages": [AIMessage(content="test response")]
230-
}
167+
mock_graph.ainvoke = AsyncMock(
168+
return_value={"messages": [AIMessage(content="test response")]}
169+
)
231170

232171
mock_parent_context = MagicMock(spec=InvocationContext)
233172
mock_parent_context._state_schema = None
@@ -257,8 +196,57 @@ async def test_langgraph_agent(
257196
assert result_event.author == "weather_agent"
258197
assert result_event.content.parts[0].text == "test response"
259198

260-
mock_graph.invoke.assert_called_once()
261-
mock_graph.invoke.assert_called_with(
199+
if checkpointer_value:
200+
mock_graph.aget_state.assert_awaited_once_with(
201+
{"configurable": {"thread_id": mock_session.id}}
202+
)
203+
else:
204+
mock_graph.aget_state.assert_not_awaited()
205+
mock_graph.ainvoke.assert_awaited_once_with(
262206
{"messages": expected_messages},
263207
{"configurable": {"thread_id": mock_session.id}},
264208
)
209+
210+
211+
@pytest.mark.asyncio
212+
async def test_langgraph_agent_runs_real_compiled_state_graph():
213+
"""A real compiled state graph runs through the asynchronous adapter."""
214+
observed_messages = []
215+
216+
def respond(state: MessagesState) -> dict[str, list[AIMessage]]:
217+
observed_messages.extend(state["messages"])
218+
return {"messages": [AIMessage(content="real graph response")]}
219+
220+
graph_builder = StateGraph(MessagesState)
221+
graph_builder.add_node("respond", respond)
222+
graph_builder.set_entry_point("respond")
223+
graph_builder.set_finish_point("respond")
224+
graph = graph_builder.compile()
225+
226+
parent_context = MagicMock(spec=InvocationContext)
227+
parent_context._state_schema = None
228+
mock_session = MagicMock()
229+
mock_session.id = "session-id"
230+
mock_session.events = []
231+
parent_context.session = mock_session
232+
parent_context.user_content = types.Content(
233+
role="user", parts=[types.Part.from_text(text="test prompt")]
234+
)
235+
parent_context.branch = "parent_agent"
236+
parent_context.end_invocation = False
237+
parent_context.invocation_id = "test_invocation_id"
238+
parent_context.model_copy.return_value = parent_context
239+
parent_context.plugin_manager = PluginManager(plugins=[])
240+
agent = LangGraphAgent(
241+
name="weather_agent",
242+
instruction="test system prompt",
243+
graph=graph,
244+
)
245+
246+
events = [event async for event in agent.run_async(parent_context)]
247+
248+
assert len(events) == 1
249+
assert events[0].content.parts[0].text == "real graph response"
250+
assert len(observed_messages) == 1
251+
assert isinstance(observed_messages[0], SystemMessage)
252+
assert observed_messages[0].content == "test system prompt"

0 commit comments

Comments
 (0)