Skip to content

Commit 30cf1e0

Browse files
declan-scaleclaude
andcommitted
feat(langgraph): LangGraphTurn + langgraph_usage_to_turn_usage
Implements LangGraphTurn (HarnessTurn protocol) that wraps a LangGraph astream() event stream and captures usage from AIMessage.usage_metadata via the on_final_ai_message callback. Implements langgraph_usage_to_turn_usage that maps all UsageMetadata fields (input/output/total/cache_read/reasoning) onto the framework-agnostic TurnUsage model. Zero token counts are preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 37cfa09 commit 30cf1e0

2 files changed

Lines changed: 342 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""HarnessTurn adapter for LangGraph astream() event streams.
2+
3+
Provides ``LangGraphTurn`` (a ``HarnessTurn`` implementation) and the
4+
``langgraph_usage_to_turn_usage`` helper that maps LangGraph's
5+
``AIMessage.usage_metadata`` onto the framework-agnostic ``TurnUsage`` model.
6+
7+
AGX1-377 note: LangGraph emits tool requests as ``StreamTaskMessageFull`` events
8+
(from "updates" events), NOT Start+Delta+Done like pydantic-ai. ``auto_send``
9+
handles Full events correctly; no coalescing wrapper is needed.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from typing import Any, AsyncIterator
15+
16+
from agentex.lib.core.harness.types import TurnUsage, StreamTaskMessage
17+
from agentex.lib.adk._modules._langgraph_sync import convert_langgraph_to_agentex_events
18+
19+
20+
def langgraph_usage_to_turn_usage(usage_metadata: Any, model: str | None) -> TurnUsage:
21+
"""Map LangGraph ``AIMessage.usage_metadata`` onto ``TurnUsage``.
22+
23+
``usage_metadata`` may be ``None`` (model doesn't report usage).
24+
Real zero token counts (e.g. 0 output tokens) are preserved as 0, NOT
25+
coerced to ``None``.
26+
27+
Mapping::
28+
29+
input_tokens -> input_tokens
30+
output_tokens -> output_tokens
31+
total_tokens -> total_tokens
32+
input_token_details.cache_read -> cached_input_tokens
33+
output_token_details.reasoning -> reasoning_tokens
34+
35+
Args:
36+
usage_metadata: The ``usage_metadata`` dict from an ``AIMessage``,
37+
or ``None`` if the model did not report usage.
38+
model: The model name string to attach to the ``TurnUsage``, or ``None``.
39+
40+
Returns:
41+
A populated ``TurnUsage`` instance.
42+
"""
43+
if usage_metadata is None:
44+
return TurnUsage(model=model)
45+
46+
raw_input = (usage_metadata or {}).get("input_tokens")
47+
raw_output = (usage_metadata or {}).get("output_tokens")
48+
raw_total = (usage_metadata or {}).get("total_tokens")
49+
input_details = (usage_metadata or {}).get("input_token_details") or {}
50+
output_details = (usage_metadata or {}).get("output_token_details") or {}
51+
raw_cache_read = input_details.get("cache_read")
52+
raw_reasoning = output_details.get("reasoning")
53+
54+
return TurnUsage(
55+
model=model,
56+
input_tokens=raw_input,
57+
output_tokens=raw_output,
58+
total_tokens=raw_total,
59+
cached_input_tokens=raw_cache_read,
60+
reasoning_tokens=raw_reasoning,
61+
)
62+
63+
64+
class LangGraphTurn:
65+
"""HarnessTurn wrapping a LangGraph ``astream()`` event stream.
66+
67+
Implements the ``HarnessTurn`` Protocol so it can be passed to either
68+
``UnifiedEmitter.yield_turn`` (sync HTTP ACP) or
69+
``UnifiedEmitter.auto_send_turn`` (async / temporal).
70+
71+
Usage::
72+
73+
stream = graph.astream(
74+
{"messages": [{"role": "user", "content": user_message}]},
75+
stream_mode=["messages", "updates"],
76+
)
77+
turn = LangGraphTurn(stream, model=model_name)
78+
79+
# Sync HTTP ACP
80+
async for event in emitter.yield_turn(turn):
81+
yield event
82+
83+
# Async / temporal
84+
result = await emitter.auto_send_turn(turn)
85+
86+
AGX1-377 note: LangGraph tool requests are ``StreamTaskMessageFull`` (from
87+
"updates"), NOT Start+Delta+Done like pydantic-ai. No ``coalesce_tool_requests``
88+
option is needed.
89+
90+
Usage data is captured lazily via the ``on_final_ai_message`` callback and
91+
is only valid after ``events`` has been fully consumed.
92+
"""
93+
94+
def __init__(self, stream: Any, model: str | None = None) -> None:
95+
self._stream = stream
96+
self._model = model
97+
self._usage: TurnUsage = TurnUsage(model=model)
98+
99+
@property
100+
def events(self) -> AsyncIterator[StreamTaskMessage]:
101+
return self._generate_events()
102+
103+
async def _generate_events(self) -> AsyncIterator[StreamTaskMessage]:
104+
def _capture(ai_msg: Any) -> None:
105+
usage_metadata = getattr(ai_msg, "usage_metadata", None)
106+
if usage_metadata is not None:
107+
self._usage = langgraph_usage_to_turn_usage(usage_metadata, self._model)
108+
109+
async for ev in convert_langgraph_to_agentex_events(self._stream, on_final_ai_message=_capture):
110+
yield ev
111+
112+
def usage(self) -> TurnUsage:
113+
"""Return the usage captured from the last AIMessage in the stream.
114+
115+
Valid only after ``events`` has been fully consumed.
116+
Returns a zero-usage ``TurnUsage`` if the model did not report usage.
117+
"""
118+
return self._usage
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"""Tests for LangGraphTurn and langgraph_usage_to_turn_usage."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
from typing import Any
7+
8+
import pytest
9+
10+
from agentex.lib.core.harness.types import TurnUsage
11+
from agentex.lib.adk._modules._langgraph_turn import LangGraphTurn, langgraph_usage_to_turn_usage
12+
13+
# ---------------------------------------------------------------------------
14+
# Remove conftest stubs so real langchain_core types are used
15+
# ---------------------------------------------------------------------------
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def _real_langchain_core():
20+
stub_keys = [k for k in sys.modules if k.startswith("langchain_core") or k.startswith("langgraph")]
21+
saved = {k: sys.modules.pop(k) for k in stub_keys}
22+
import importlib
23+
24+
importlib.import_module("langchain_core.messages")
25+
yield
26+
sys.modules.update(saved)
27+
28+
29+
# ---------------------------------------------------------------------------
30+
# Helpers
31+
# ---------------------------------------------------------------------------
32+
33+
34+
def _make_stream(events: list[tuple[str, Any]]):
35+
async def _gen():
36+
for e in events:
37+
yield e
38+
39+
return _gen()
40+
41+
42+
async def _drain(turn: LangGraphTurn) -> list[Any]:
43+
return [e async for e in turn.events]
44+
45+
46+
# ---------------------------------------------------------------------------
47+
# langgraph_usage_to_turn_usage
48+
# ---------------------------------------------------------------------------
49+
50+
51+
class TestLangGraphUsageToTurnUsage:
52+
def test_none_usage_returns_empty_turn_usage(self):
53+
result = langgraph_usage_to_turn_usage(None, model="gpt-4")
54+
assert result == TurnUsage(model="gpt-4")
55+
56+
def test_basic_token_fields_mapped(self):
57+
usage = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
58+
result = langgraph_usage_to_turn_usage(usage, model="gpt-4")
59+
assert result.input_tokens == 10
60+
assert result.output_tokens == 5
61+
assert result.total_tokens == 15
62+
assert result.model == "gpt-4"
63+
64+
def test_zero_output_tokens_preserved_not_coerced_to_none(self):
65+
"""Real zero counts must be preserved as 0, not None."""
66+
usage = {"input_tokens": 10, "output_tokens": 0, "total_tokens": 10}
67+
result = langgraph_usage_to_turn_usage(usage, model=None)
68+
assert result.output_tokens == 0
69+
70+
def test_cache_read_mapped_to_cached_input_tokens(self):
71+
usage = {
72+
"input_tokens": 20,
73+
"output_tokens": 5,
74+
"total_tokens": 25,
75+
"input_token_details": {"cache_read": 8},
76+
}
77+
result = langgraph_usage_to_turn_usage(usage, model=None)
78+
assert result.cached_input_tokens == 8
79+
80+
def test_reasoning_mapped_to_reasoning_tokens(self):
81+
usage = {
82+
"input_tokens": 10,
83+
"output_tokens": 15,
84+
"total_tokens": 25,
85+
"output_token_details": {"reasoning": 6},
86+
}
87+
result = langgraph_usage_to_turn_usage(usage, model=None)
88+
assert result.reasoning_tokens == 6
89+
90+
def test_missing_optional_fields_are_none(self):
91+
usage = {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}
92+
result = langgraph_usage_to_turn_usage(usage, model=None)
93+
assert result.cached_input_tokens is None
94+
assert result.reasoning_tokens is None
95+
96+
def test_full_usage_object(self):
97+
usage = {
98+
"input_tokens": 100,
99+
"output_tokens": 50,
100+
"total_tokens": 150,
101+
"input_token_details": {"cache_read": 30},
102+
"output_token_details": {"reasoning": 20},
103+
}
104+
result = langgraph_usage_to_turn_usage(usage, model="claude-3-5-sonnet")
105+
assert result == TurnUsage(
106+
model="claude-3-5-sonnet",
107+
input_tokens=100,
108+
output_tokens=50,
109+
total_tokens=150,
110+
cached_input_tokens=30,
111+
reasoning_tokens=20,
112+
)
113+
114+
def test_model_none_is_preserved(self):
115+
result = langgraph_usage_to_turn_usage({"input_tokens": 1}, model=None)
116+
assert result.model is None
117+
118+
def test_empty_input_token_details_does_not_crash(self):
119+
usage = {"input_tokens": 5, "input_token_details": {}}
120+
result = langgraph_usage_to_turn_usage(usage, model=None)
121+
assert result.cached_input_tokens is None
122+
123+
def test_empty_output_token_details_does_not_crash(self):
124+
usage = {"output_tokens": 5, "output_token_details": {}}
125+
result = langgraph_usage_to_turn_usage(usage, model=None)
126+
assert result.reasoning_tokens is None
127+
128+
129+
# ---------------------------------------------------------------------------
130+
# LangGraphTurn
131+
# ---------------------------------------------------------------------------
132+
133+
134+
class TestLangGraphTurn:
135+
async def test_events_yields_from_sync_converter(self):
136+
from langchain_core.messages import AIMessage, AIMessageChunk
137+
138+
chunk = AIMessageChunk(content="Hello!")
139+
ai_msg = AIMessage(content="Hello!")
140+
stream = _make_stream(
141+
[
142+
("messages", (chunk, {})),
143+
("updates", {"agent": {"messages": [ai_msg]}}),
144+
]
145+
)
146+
turn = LangGraphTurn(stream)
147+
events = await _drain(turn)
148+
assert len(events) > 0
149+
150+
async def test_usage_is_empty_before_stream_consumed(self):
151+
turn = LangGraphTurn(_make_stream([]))
152+
# usage() before events consumed should return a default TurnUsage
153+
usage = turn.usage()
154+
assert isinstance(usage, TurnUsage)
155+
156+
async def test_usage_captured_from_ai_message(self):
157+
from langchain_core.messages import AIMessage
158+
159+
usage_meta = {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15}
160+
ai_msg = AIMessage(content="Hi!", usage_metadata=usage_meta)
161+
stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})])
162+
turn = LangGraphTurn(stream, model="gpt-4")
163+
await _drain(turn)
164+
165+
usage = turn.usage()
166+
assert usage.input_tokens == 10
167+
assert usage.output_tokens == 5
168+
assert usage.total_tokens == 15
169+
assert usage.model == "gpt-4"
170+
171+
async def test_usage_not_updated_when_no_usage_metadata(self):
172+
from langchain_core.messages import AIMessage
173+
174+
ai_msg = AIMessage(content="Hi!")
175+
stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})])
176+
turn = LangGraphTurn(stream, model="gpt-4")
177+
await _drain(turn)
178+
179+
usage = turn.usage()
180+
assert usage == TurnUsage(model="gpt-4")
181+
182+
async def test_usage_captures_cache_read_and_reasoning(self):
183+
from langchain_core.messages import AIMessage
184+
185+
usage_meta = {
186+
"input_tokens": 100,
187+
"output_tokens": 50,
188+
"total_tokens": 150,
189+
"input_token_details": {"cache_read": 30},
190+
"output_token_details": {"reasoning": 20},
191+
}
192+
ai_msg = AIMessage(content="Result", usage_metadata=usage_meta)
193+
stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})])
194+
turn = LangGraphTurn(stream, model="claude-3-5-sonnet")
195+
await _drain(turn)
196+
197+
usage = turn.usage()
198+
assert usage.cached_input_tokens == 30
199+
assert usage.reasoning_tokens == 20
200+
201+
async def test_harness_turn_protocol_conformance(self):
202+
"""LangGraphTurn satisfies the HarnessTurn Protocol."""
203+
from agentex.lib.core.harness.types import HarnessTurn
204+
205+
turn = LangGraphTurn(_make_stream([]))
206+
assert isinstance(turn, HarnessTurn), "LangGraphTurn must satisfy HarnessTurn Protocol"
207+
208+
async def test_empty_stream_yields_no_events(self):
209+
turn = LangGraphTurn(_make_stream([]))
210+
events = await _drain(turn)
211+
assert events == []
212+
213+
async def test_model_none_default(self):
214+
turn = LangGraphTurn(_make_stream([]))
215+
assert turn.usage().model is None
216+
217+
async def test_model_passed_through_to_usage(self):
218+
from langchain_core.messages import AIMessage
219+
220+
ai_msg = AIMessage(content="ok", usage_metadata={"input_tokens": 1, "output_tokens": 0, "total_tokens": 1})
221+
stream = _make_stream([("updates", {"agent": {"messages": [ai_msg]}})])
222+
turn = LangGraphTurn(stream, model="my-model")
223+
await _drain(turn)
224+
assert turn.usage().model == "my-model"

0 commit comments

Comments
 (0)