33Converts LangGraph graph.astream() events into Agentex streaming updates
44and pushes them to Redis via adk.streaming contexts. For use with async
55ACP agents that stream via Redis rather than HTTP yields.
6+
7+ Unified surface
8+ ---------------
9+ This module is now implemented on top of ``LangGraphTurn`` and
10+ ``UnifiedEmitter.auto_send_turn``, the same surface used by every other
11+ harness adapter (pydantic-ai, openai-agents, etc.). The public signature
12+ and return type are preserved identically.
13+
14+ AGX1-377 note: LangGraph emits tool requests as ``StreamTaskMessageFull`` events
15+ (from "updates" events), NOT Start+Delta+Done like pydantic-ai. ``auto_send``
16+ handles Full events correctly; no coalescing wrapper is needed.
617"""
718
819
@@ -18,185 +29,28 @@ async def stream_langgraph_events(stream, task_id: str) -> str:
1829 models like gpt-5/o1/o3 (chunk.content is a list of typed content blocks
1930 in the Responses API responses/v1 format).
2031
32+ Reimplemented on ``UnifiedEmitter.auto_send_turn(LangGraphTurn(...))`` for
33+ cross-harness consistency. Behavior is identical to the previous bespoke
34+ implementation (verified by characterization tests in test_langgraph_async.py).
35+
36+ AGX1-377 note: LangGraph emits tool requests as ``Full`` events (from "updates"),
37+ NOT Start+Delta+Done like pydantic-ai. ``auto_send`` handles Full events
38+ correctly; no coalescing wrapper is needed.
39+
2140 Args:
2241 stream: Async iterator from graph.astream(..., stream_mode=["messages", "updates"])
2342 task_id: The Agentex task ID to stream messages to.
2443
2544 Returns:
2645 The accumulated final text output from the agent.
2746 """
28- # Lazy imports so langgraph/langchain aren't required at module load time
29- from langchain_core .messages import ToolMessage , AIMessageChunk
30-
31- from agentex .lib import adk
32- from agentex .types .text_content import TextContent
33- from agentex .types .reasoning_content import ReasoningContent
34- from agentex .types .task_message_delta import TextDelta
35- from agentex .types .task_message_update import StreamTaskMessageDelta
36- from agentex .types .tool_request_content import ToolRequestContent
37- from agentex .types .tool_response_content import ToolResponseContent
38- from agentex .types .reasoning_summary_delta import ReasoningSummaryDelta
39-
40- text_context = None
41- reasoning_context = None
42- final_text = ""
43-
44- try :
45- async for event_type , event_data in stream :
46- if event_type == "messages" :
47- chunk , metadata = event_data
48-
49- if not isinstance (chunk , AIMessageChunk ) or not chunk .content :
50- continue
51-
52- # ----------------------------------------------------------
53- # Case 1: content is a plain string (regular models)
54- # ----------------------------------------------------------
55- if isinstance (chunk .content , str ):
56- if reasoning_context :
57- await reasoning_context .close ()
58- reasoning_context = None
59-
60- if not text_context :
61- final_text = ""
62- text_context = await adk .streaming .streaming_task_message_context (
63- task_id = task_id ,
64- initial_content = TextContent (
65- author = "agent" ,
66- content = "" ,
67- format = "markdown" ,
68- ),
69- ).__aenter__ ()
70-
71- final_text += chunk .content
72- await text_context .stream_update (
73- StreamTaskMessageDelta (
74- parent_task_message = text_context .task_message ,
75- delta = TextDelta (type = "text" , text_delta = chunk .content ),
76- type = "delta" ,
77- )
78- )
79-
80- # ----------------------------------------------------------
81- # Case 2: content is a list of typed blocks (reasoning models)
82- # Responses API (responses/v1) format:
83- # {"type": "reasoning", "summary": [{"type": "summary_text", "text": "..."}]}
84- # {"type": "text", "text": "..."}
85- # ----------------------------------------------------------
86- elif isinstance (chunk .content , list ):
87- for block in chunk .content :
88- if not isinstance (block , dict ):
89- continue
90-
91- block_type = block .get ("type" )
92-
93- if block_type == "reasoning" :
94- reasoning_text = ""
95- for s in block .get ("summary" , []):
96- if isinstance (s , dict ) and s .get ("type" ) == "summary_text" :
97- reasoning_text += s .get ("text" , "" )
98- if not reasoning_text :
99- continue
100-
101- if text_context :
102- await text_context .close ()
103- text_context = None
104-
105- if not reasoning_context :
106- reasoning_context = await adk .streaming .streaming_task_message_context (
107- task_id = task_id ,
108- initial_content = ReasoningContent (
109- author = "agent" ,
110- summary = [],
111- content = [],
112- type = "reasoning" ,
113- style = "active" ,
114- ),
115- ).__aenter__ ()
116-
117- await reasoning_context .stream_update (
118- StreamTaskMessageDelta (
119- parent_task_message = reasoning_context .task_message ,
120- delta = ReasoningSummaryDelta (
121- type = "reasoning_summary" ,
122- summary_index = 0 ,
123- summary_delta = reasoning_text ,
124- ),
125- type = "delta" ,
126- )
127- )
128-
129- elif block_type == "text" :
130- text_delta = block .get ("text" , "" )
131- if not text_delta :
132- continue
133-
134- if reasoning_context :
135- await reasoning_context .close ()
136- reasoning_context = None
137-
138- if not text_context :
139- final_text = ""
140- text_context = await adk .streaming .streaming_task_message_context (
141- task_id = task_id ,
142- initial_content = TextContent (
143- author = "agent" ,
144- content = "" ,
145- format = "markdown" ,
146- ),
147- ).__aenter__ ()
148-
149- final_text += text_delta
150- await text_context .stream_update (
151- StreamTaskMessageDelta (
152- parent_task_message = text_context .task_message ,
153- delta = TextDelta (type = "text" , text_delta = text_delta ),
154- type = "delta" ,
155- )
156- )
157-
158- elif event_type == "updates" :
159- for node_name , state_update in event_data .items ():
160- if node_name == "agent" :
161- messages = state_update .get ("messages" , [])
162- for msg in messages :
163- if text_context :
164- await text_context .close ()
165- text_context = None
166- if reasoning_context :
167- await reasoning_context .close ()
168- reasoning_context = None
169-
170- if hasattr (msg , "tool_calls" ) and msg .tool_calls :
171- for tc in msg .tool_calls :
172- await adk .messages .create (
173- task_id = task_id ,
174- content = ToolRequestContent (
175- tool_call_id = tc ["id" ],
176- name = tc ["name" ],
177- arguments = tc ["args" ],
178- author = "agent" ,
179- ),
180- )
181-
182- elif node_name == "tools" :
183- messages = state_update .get ("messages" , [])
184- for msg in messages :
185- if isinstance (msg , ToolMessage ):
186- await adk .messages .create (
187- task_id = task_id ,
188- content = ToolResponseContent (
189- tool_call_id = msg .tool_call_id ,
190- name = msg .name or "unknown" ,
191- content = msg .content if isinstance (msg .content , str ) else str (msg .content ),
192- author = "agent" ,
193- ),
194- )
195- finally :
196- # Always close open contexts
197- if text_context :
198- await text_context .close ()
199- if reasoning_context :
200- await reasoning_context .close ()
201-
202- return final_text
47+ from agentex .lib .core .harness .emitter import UnifiedEmitter
48+ from agentex .lib .adk ._modules ._langgraph_turn import LangGraphTurn
49+
50+ # AGX1-377 note: LangGraph emits tool requests as Full events (from "updates"),
51+ # NOT Start+Delta+Done like pydantic-ai. auto_send handles Full events correctly;
52+ # no coalescing wrapper is needed.
53+ turn = LangGraphTurn (stream , model = None )
54+ emitter = UnifiedEmitter (task_id = task_id , trace_id = None , parent_span_id = None )
55+ result = await emitter .auto_send_turn (turn )
56+ return result .final_text
0 commit comments