Skip to content

Commit 90f07b0

Browse files
committed
fix(langgraph): preserve response IDs across streamed chunks
1 parent 765e1ae commit 90f07b0

2 files changed

Lines changed: 29 additions & 0 deletions

File tree

agent-langgraph-agui/src/parallel_tools.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ async def run(self, input):
4545
# for explicit reuse and when an HTTP consumer cancels/closes its run.
4646
streams = {}
4747
self._tool_streams = streams
48+
self._response_message_ids = {}
4849
self._upstream_stream = None
4950
self._graph_stream = None
5051
try:
@@ -66,6 +67,7 @@ async def run(self, input):
6667
await self._graph_stream.aclose()
6768
finally:
6869
streams.clear()
70+
self._response_message_ids.clear()
6971

7072
def _handle_stream_events(self, input):
7173
# Upstream run uses a bare async-for. Keep its generator so closing
@@ -82,7 +84,25 @@ async def _handle_single_event(self, event, state):
8284
active_run = self.active_run
8385
kind = event.get("event")
8486
model_key = (self._current_lane(), event.get("run_id"))
87+
if kind == "on_chat_model_stream":
88+
chunk = event.get("data", {}).get("chunk")
89+
# Responses API announces its durable ID in a metadata-only chunk.
90+
# Later chunks get lc_run IDs from LangChain; emitting those creates
91+
# a second copy when the final snapshot restores the provider ID.
92+
metadata = _get(chunk, "response_metadata", {}) or {}
93+
response_id = metadata.get("id")
94+
if response_id and _get(chunk, "id") == response_id:
95+
self._response_message_ids.setdefault(model_key, response_id)
96+
stable_id = self._response_message_ids.get(model_key)
97+
if stable_id and _get(chunk, "id") != stable_id:
98+
clean = (
99+
{**chunk, "id": stable_id}
100+
if isinstance(chunk, dict)
101+
else chunk.model_copy(update={"id": stable_id})
102+
)
103+
event = {**event, "data": {**event["data"], "chunk": clean}}
85104
if kind == "on_chat_model_end":
105+
self._response_message_ids.pop(model_key, None)
86106
saved = self.get_message_in_progress(self.active_run["id"])
87107
try:
88108
for slot in self._tool_streams.pop(model_key, {}).values():

agent-langgraph-agui/tests/test_tool_protocol.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,3 +603,12 @@ async def send(_client, request, **_kwargs):
603603
assert all("public marker 43" in part["output"] for part in results)
604604
assert "Both client results: public marker 43" in json.dumps(snapshot(second))
605605
assert "synthetic-access" not in json.dumps(first + second)
606+
# Streamed owners and final history must describe the same messages. A
607+
# Responses API metadata-only chunk has the provider ID before text/tools.
608+
for events in (first, second):
609+
final_ids = {message["id"] for message in snapshot(events)}
610+
for event in events:
611+
if event["type"] == "TEXT_MESSAGE_START":
612+
assert event["messageId"] in final_ids
613+
elif event["type"] == "TOOL_CALL_START":
614+
assert event["parentMessageId"] in final_ids

0 commit comments

Comments
 (0)