Skip to content

Commit 37cfa09

Browse files
declan-scaleclaude
andcommitted
feat(langgraph): optional on_final_ai_message callback for usage capture
Adds an additive on_final_ai_message=None parameter to convert_langgraph_to_agentex_events so callers can capture AIMessage usage_metadata without re-traversing the stream. No behavior change when omitted. Also adds a DeprecationWarning to create_langgraph_tracing_handler and its module docstring, pointing to the unified harness surface, and updates the sync module docstring with the preferred unified path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent df3461c commit 37cfa09

3 files changed

Lines changed: 282 additions & 2 deletions

File tree

src/agentex/lib/adk/_modules/_langgraph_sync.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,28 @@
33
Converts LangGraph graph.astream() events into Agentex TaskMessageUpdate
44
events that are yielded back over the HTTP response. For use with sync ACP
55
agents that stream via HTTP yields rather than Redis.
6+
7+
Unified sync path
8+
-----------------
9+
Prefer using ``LangGraphTurn`` with ``UnifiedEmitter.yield_turn`` for new
10+
agents, which adds usage capture and optional tracing via the shared harness
11+
surface::
12+
13+
from agentex.lib.core.harness.emitter import UnifiedEmitter
14+
from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn
15+
16+
turn = LangGraphTurn(stream)
17+
emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=span_id)
18+
async for event in emitter.yield_turn(turn):
19+
yield event
20+
21+
``convert_langgraph_to_agentex_events`` remains available as a lower-level
22+
primitive (e.g. for callers that need the raw event stream without the
23+
harness envelope).
624
"""
725

826

9-
async def convert_langgraph_to_agentex_events(stream):
27+
async def convert_langgraph_to_agentex_events(stream, on_final_ai_message=None):
1028
"""Convert LangGraph streaming events to Agentex TaskMessageUpdate events.
1129
1230
Expects the stream from graph.astream() called with
@@ -22,8 +40,17 @@ async def convert_langgraph_to_agentex_events(stream):
2240
Supports both regular models (chunk.content is a str) and reasoning models
2341
like gpt-5/o1/o3 (chunk.content is a list of typed content blocks).
2442
43+
AGX1-377 note: LangGraph emits tool requests as ``StreamTaskMessageFull`` (from
44+
"updates" events), NOT Start+Delta+Done like pydantic-ai. No coalesce_tool_requests
45+
option is needed for LangGraph.
46+
2547
Args:
2648
stream: Async iterator from graph.astream(..., stream_mode=["messages", "updates"])
49+
on_final_ai_message: Optional callback ``(msg: AIMessage) -> None`` called for
50+
each ``AIMessage`` in an "agent" node update. Use this to capture
51+
``usage_metadata`` for token accounting without re-traversing the stream.
52+
The callback fires *after* all events for that message are yielded.
53+
No-op when ``None`` (default).
2754
2855
Yields:
2956
TaskMessageUpdate events (Start, Delta, Done, Full)
@@ -205,6 +232,13 @@ async def convert_langgraph_to_agentex_events(stream):
205232
)
206233
message_index += 1
207234

235+
# Notify caller of the final AIMessage (e.g. for usage capture)
236+
if on_final_ai_message is not None:
237+
from langchain_core.messages import AIMessage as _AIMessage
238+
239+
if isinstance(msg, _AIMessage):
240+
on_final_ai_message(msg)
241+
208242
elif node_name == "tools":
209243
messages = state_update.get("messages", [])
210244
for msg in messages:

src/agentex/lib/adk/_modules/_langgraph_tracing.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1-
"""LangChain callback handler that creates Agentex spans for LLM calls and tool executions."""
1+
"""LangChain callback handler that creates Agentex spans for LLM calls and tool executions.
2+
3+
.. deprecated::
4+
``AgentexLangGraphTracingHandler`` and ``create_langgraph_tracing_handler`` are
5+
superseded by the unified harness surface (``LangGraphTurn`` +
6+
``UnifiedEmitter``), which derives spans automatically from the canonical
7+
event stream without requiring a LangChain callback handler.
8+
9+
They remain importable and functional for backward compatibility, but new
10+
agents should use the unified path instead.
11+
"""
212
# ruff: noqa: ARG002
313
# Callback methods must accept all arguments defined by LangChain's AsyncCallbackHandler interface.
414

515
from __future__ import annotations
616

17+
import warnings
718
from uuid import UUID
819
from typing import Any, override
920

@@ -31,6 +42,11 @@ class AgentexLangGraphTracingHandler(AsyncCallbackHandler):
3142
├── llm:<model> (LLM call)
3243
├── tool:<tool_name> (tool execution)
3344
└── llm:<model> (LLM call)
45+
46+
.. deprecated::
47+
Use ``LangGraphTurn`` with ``UnifiedEmitter`` instead. The unified
48+
harness derives equivalent spans from the canonical event stream,
49+
removing the need for a LangChain callback handler entirely.
3450
"""
3551

3652
def __init__(
@@ -237,7 +253,28 @@ def create_langgraph_tracing_handler(
237253
238254
Returns:
239255
An ``AgentexLangGraphTracingHandler`` instance ready to use as a LangChain callback.
256+
257+
.. deprecated::
258+
Use ``LangGraphTurn`` with ``UnifiedEmitter`` instead. The unified harness
259+
derives equivalent spans from the canonical event stream automatically, with
260+
no LangChain callback required::
261+
262+
from agentex.lib.core.harness.emitter import UnifiedEmitter
263+
from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn
264+
265+
turn = LangGraphTurn(stream)
266+
emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=span_id)
267+
result = await emitter.auto_send_turn(turn)
268+
269+
This function remains available for backward compatibility.
240270
"""
271+
warnings.warn(
272+
"create_langgraph_tracing_handler is deprecated. Use LangGraphTurn with "
273+
"UnifiedEmitter instead — the unified harness derives equivalent spans from "
274+
"the canonical event stream without a LangChain callback handler.",
275+
DeprecationWarning,
276+
stacklevel=2,
277+
)
241278
return AgentexLangGraphTracingHandler(
242279
trace_id=trace_id,
243280
parent_span_id=parent_span_id,
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Tests for the sync LangGraph -> Agentex stream event converter.
2+
3+
Covers:
4+
- Basic text, tool call, and tool response emission
5+
- on_final_ai_message callback for usage capture
6+
- Deprecation warning emitted by create_langgraph_tracing_handler
7+
8+
NOTE: langchain_core imports must be deferred to test-function scope because
9+
conftest.py stubs out ``langchain_core.messages`` with MagicMock for ADK
10+
package-level tests. The real classes are imported lazily inside each test.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import sys
16+
import warnings
17+
from typing import Any, AsyncIterator
18+
19+
import pytest
20+
21+
from agentex.types.task_message_update import (
22+
StreamTaskMessageFull,
23+
)
24+
from agentex.types.tool_request_content import ToolRequestContent
25+
from agentex.types.tool_response_content import ToolResponseContent
26+
from agentex.lib.adk._modules._langgraph_sync import convert_langgraph_to_agentex_events
27+
28+
# ---------------------------------------------------------------------------
29+
# Helpers
30+
# ---------------------------------------------------------------------------
31+
32+
33+
async def _collect(stream: AsyncIterator[Any]) -> list[Any]:
34+
return [e async for e in stream]
35+
36+
37+
def _make_stream(events: list[tuple[str, Any]]) -> AsyncIterator[tuple[str, Any]]:
38+
async def _gen():
39+
for e in events:
40+
yield e
41+
42+
return _gen()
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# Remove the conftest stubs for langchain_core so real classes are used
47+
# ---------------------------------------------------------------------------
48+
49+
50+
@pytest.fixture(autouse=True)
51+
def _real_langchain_core():
52+
"""Remove conftest MagicMock stubs so real langchain_core types are used."""
53+
stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")]
54+
saved = {k: sys.modules.pop(k) for k in stub_keys}
55+
# Re-import the real modules
56+
import importlib
57+
58+
importlib.import_module("langchain_core.messages")
59+
yield
60+
# Restore stubs after the test
61+
sys.modules.update(saved)
62+
63+
64+
class TestTextStreaming:
65+
async def test_plain_text_emits_start_delta_done(self):
66+
from langchain_core.messages import AIMessage, AIMessageChunk
67+
68+
chunk = AIMessageChunk(content="Hello, world!")
69+
events = [
70+
("messages", (chunk, {})),
71+
("updates", {"agent": {"messages": [AIMessage(content="Hello, world!")]}}),
72+
]
73+
out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events)))
74+
types = [type(e).__name__ for e in out]
75+
assert "StreamTaskMessageStart" in types
76+
assert "StreamTaskMessageDelta" in types
77+
assert "StreamTaskMessageDone" in types
78+
79+
async def test_empty_chunk_content_is_skipped(self):
80+
from langchain_core.messages import AIMessageChunk
81+
82+
chunk = AIMessageChunk(content="")
83+
events = [("messages", (chunk, {}))]
84+
out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events)))
85+
assert out == []
86+
87+
88+
class TestToolCallEmission:
89+
async def test_tool_call_emits_full_message(self):
90+
from langchain_core.messages import AIMessage
91+
92+
tc = {"id": "call_1", "name": "get_weather", "args": {"city": "Paris"}}
93+
ai_msg = AIMessage(content="", tool_calls=[tc])
94+
events = [("updates", {"agent": {"messages": [ai_msg]}})]
95+
out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events)))
96+
assert len(out) == 1
97+
assert isinstance(out[0], StreamTaskMessageFull)
98+
content = out[0].content
99+
assert isinstance(content, ToolRequestContent)
100+
assert content.tool_call_id == "call_1"
101+
assert content.name == "get_weather"
102+
assert content.arguments == {"city": "Paris"}
103+
assert content.author == "agent"
104+
105+
async def test_tool_response_emits_full_message(self):
106+
from langchain_core.messages import ToolMessage
107+
108+
tool_msg = ToolMessage(content="Sunny, 72F", tool_call_id="call_1", name="get_weather")
109+
events = [("updates", {"tools": {"messages": [tool_msg]}})]
110+
out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events)))
111+
assert len(out) == 1
112+
assert isinstance(out[0], StreamTaskMessageFull)
113+
content = out[0].content
114+
assert isinstance(content, ToolResponseContent)
115+
assert content.tool_call_id == "call_1"
116+
assert content.name == "get_weather"
117+
assert content.content == "Sunny, 72F"
118+
assert content.author == "agent"
119+
120+
121+
class TestOnFinalAiMessageCallback:
122+
async def test_callback_called_for_ai_message_in_agent_node(self):
123+
from langchain_core.messages import AIMessage
124+
125+
captured: list[Any] = []
126+
ai_msg = AIMessage(content="Hello!")
127+
128+
events = [("updates", {"agent": {"messages": [ai_msg]}})]
129+
await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append))
130+
assert len(captured) == 1
131+
assert captured[0] is ai_msg
132+
133+
async def test_callback_not_called_for_tool_messages(self):
134+
from langchain_core.messages import ToolMessage
135+
136+
captured: list[Any] = []
137+
tool_msg = ToolMessage(content="result", tool_call_id="c1", name="t")
138+
139+
events = [("updates", {"tools": {"messages": [tool_msg]}})]
140+
await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append))
141+
assert captured == []
142+
143+
async def test_callback_receives_usage_metadata(self):
144+
from langchain_core.messages import AIMessage
145+
146+
captured: list[Any] = []
147+
usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
148+
ai_msg = AIMessage(content="Answer.", usage_metadata=usage)
149+
150+
events = [("updates", {"agent": {"messages": [ai_msg]}})]
151+
await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append))
152+
assert len(captured) == 1
153+
assert captured[0].usage_metadata == usage
154+
155+
async def test_no_callback_is_noop(self):
156+
from langchain_core.messages import AIMessage
157+
158+
ai_msg = AIMessage(content="Hello!")
159+
events = [("updates", {"agent": {"messages": [ai_msg]}})]
160+
out = await _collect(convert_langgraph_to_agentex_events(_make_stream(events)))
161+
assert isinstance(out, list)
162+
163+
async def test_callback_called_multiple_times_for_multi_step(self):
164+
from langchain_core.messages import AIMessage
165+
166+
captured: list[Any] = []
167+
ai_msg_1 = AIMessage(content="Step 1")
168+
ai_msg_2 = AIMessage(content="Step 2")
169+
170+
events = [
171+
("updates", {"agent": {"messages": [ai_msg_1]}}),
172+
("updates", {"agent": {"messages": [ai_msg_2]}}),
173+
]
174+
await _collect(convert_langgraph_to_agentex_events(_make_stream(events), on_final_ai_message=captured.append))
175+
assert len(captured) == 2
176+
assert captured[0] is ai_msg_1
177+
assert captured[1] is ai_msg_2
178+
179+
async def test_callback_called_after_tool_call_events_yielded(self):
180+
"""The callback fires after all events for that AIMessage are yielded."""
181+
from langchain_core.messages import AIMessage
182+
183+
yield_order: list[str] = []
184+
185+
async def _gen():
186+
tc = {"id": "c1", "name": "t", "args": {}}
187+
ai_msg = AIMessage(content="", tool_calls=[tc])
188+
yield ("updates", {"agent": {"messages": [ai_msg]}})
189+
190+
def _cb(msg):
191+
yield_order.append("callback")
192+
193+
async for _ in convert_langgraph_to_agentex_events(_gen(), on_final_ai_message=_cb):
194+
yield_order.append("event")
195+
196+
# The tool call Full event is emitted before the callback fires
197+
assert yield_order.index("event") < yield_order.index("callback")
198+
199+
200+
class TestDeprecationWarning:
201+
def test_create_langgraph_tracing_handler_emits_deprecation_warning(self):
202+
from agentex.lib.adk._modules._langgraph_tracing import create_langgraph_tracing_handler
203+
204+
with warnings.catch_warnings(record=True) as w:
205+
warnings.simplefilter("always")
206+
create_langgraph_tracing_handler(trace_id="t1")
207+
assert any(issubclass(warning.category, DeprecationWarning) for warning in w), (
208+
"create_langgraph_tracing_handler must emit a DeprecationWarning"
209+
)

0 commit comments

Comments
 (0)