Skip to content

feat(langchain): emit AG-UI text and tool events - #163

Merged
apucacao merged 17 commits into
mainfrom
cursor/agui-langchain-7ca3
Oct 14, 2026
Merged

apucacao merged 17 commits into
mainfrom
cursor/agui-langchain-7ca3

Conversation

@apucacao

Copy link
Copy Markdown
Contributor

LangChain messages and agents map model chunks and the tool loop onto the shared AG-UI subset. Argument text and results follow capture_content. config().stream() stays chunk then done.

Verified with the LangChain handler tests and mypy.

Open in Web Open in Cursor 

cursoragent and others added 3 commits October 13, 2026 14:18
Handlers can yield the AG-UI text and tool subset. config().stream()
still yields chunk then done. stream() is the opt-in entry.

Co-authored-by: alexis <alexis@launchdarkly.com>
A handler that raises mid-call can still report the lifecycle without the
tool result.

Co-authored-by: alexis <alexis@launchdarkly.com>
Messages and agents map model chunks and the tool loop onto the shared
subset. Argument text and results follow capture_content.

Co-authored-by: alexis <alexis@launchdarkly.com>
@cursor

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

bugbot run

@apucacao
apucacao marked this pull request as ready for review October 13, 2026 15:44

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Later LangChain tool_call_chunks carry only an index. Remember the id
from the first piece so those slices are not dropped.

Co-authored-by: alexis <alexis@launchdarkly.com>
@cursor

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

bugbot run

Later chunks that omit the call id, and a tool_end that used to skip
the complete arguments after a prefix, no longer publish truncated
JSON. A failed turn also closes the calls it already opened.

Co-authored-by: alexis <alexis@launchdarkly.com>
@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@jeffdupont jeffdupont left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 462209b against base b7a9f3a (#164). langchain-messages 126 passed, langchain-agents 162 passed, client -k agui 16 passed. Findings 2–4 were reproduced with probes (a real create_react_agent with a fake model for 2; real langchain_core AIMessageChunks for 3 and 4).

Must fix

1. Spec §1.8 says handlers yield chunk; this PR stops doing that (langchain-messages/tests/test_handler.py:1175; monorepo TESTING.md §1.8 "Streaming", §3.15)
test_yields_chunk_events now expects TEXT_MESSAGE_CONTENT, so the spec test fails by design. config().stream() still yields chunks via StableProjector (client.py:255), but direct handler.stream() callers get a different shape. There's no AG-UI spec on any monorepo branch, and no js-ai-sdk counterpart.
Fix: land the AG-UI handler-event contract in §1.8 and open the JS counterpart before merging.

2. langchain-agents leaves tool calls open when a run fails (langchain-agents/.../handler.py:582-587)
The except Exception block closes spans and re-raises but never calls emitter.fail_open(). Reproduced with a raising tool: TOOL_CALL_START/ARGS/END for c1 and d1, then the raise, with no TOOL_CALL_RESULT. on_tool_error does append to finished_calls (spans.py:443-447), but that's drained only inside the astream loop. langchain-messages (:1064), #161 and #165 all call fail_open() here.
Fix: for rich in emitter.fail_open(): yield rich before raise at 587, plus a raising-tool test asserting each started call gets a failed TOOL_CALL_RESULT.

3. Streamed tool events and the tool loop disagree about which calls exist (langchain-messages/.../handler.py:834-884 vs :897-899, :961-966)
Event ids come from tool_call_chunks; the loop still uses accumulated_tool_calls = chunk.tool_calls from the last chunk that had any, and AIMessageChunk.tool_calls is parsed from that one chunk only (langchain_core/messages/ai.py:554-570, 1.6.4). With OpenAI-shaped chunks for two parallel calls, events correctly stream call_1/call_2, then the loop sees [{'name': '', 'id': None, 'args': {}}] and raises No handler registered for tool "", and fail_open reports both as failed though neither ran. With a split {"q + ":"x"}, events show {"q":"x"} but the tool gets {}. The loop bug predates this PR (reproduces on b7a9f3a), but these events can't be right on top of it. The tests hand-build FakeAIMessage with whole tool_calls (tests/test_handler.py:1184-1266), which hides it.
Fix: accumulate the LangChain way, gathered = chunk if gathered is None else gathered + chunk, use gathered.tool_calls, and take ids from gathered.tool_call_chunks by index. That replaces tool_chunk_ids/seen_call_ids (~15 lines) and makes event and loop ids the same by construction. Add one test with real AIMessageChunks.

Should fix

4. Argument "prefix repair" requires byte-identical compact JSON (client/.../agui.py:178-187)
{"q": "x (with a space) streamed and final {"q":"xy"} fails startswith, so the truncated JSON is published silently. Worse, when a later chunk carries whole tool_calls, LangChain synthesises a tool_call_chunk with spaced json.dumps (ai.py:518-528), streamed as a second delta: probe gives {"q": "x + {"q": "xy"}, invalid JSON. (Per-provider spacing unverified.)
Fix: once 3 is fixed this repair has no job; delete _arg_text and keep "emit full args only if nothing streamed".

5. The same TurnEmitter.tool_end edit is in #161 (agui.py:107,163,166-190). Whichever merges second conflicts; move it into #164 once if it survives 4.

6. Agents tool results come from new callback state when the graph already returns them (langchain-agents/.../spans.py:307-308,409-414,420-425,443-447,513-518; handler.py:533-543)
The tools step of agent.astream() already yields ToolMessages with tool_call_id, content and status="error" (langgraph prebuilt/tool_node.py:1011). The callback copy needs the OTel span handler (spans.py:549-550), and its id (tool_call_id or run_id) can differ from the message id (tc.id or tc.name, handler.py:525), which would open a second call.
Fix: handle msg.type == "tool" in the existing loop with emitter.tool_result(msg.tool_call_id, msg.content, failed=getattr(msg, "status", None) == "error"); delete the spans.py changes and the finished_calls loop (~35 lines).

7. No test covers agents tool events (langchain-agents/tests/test_handler.py has no TOOL_CALL). One success test (START/ARGS/END/RESULT per call, distinct parallel ids) and one raising-tool test; the latter would have caught 2.

Nice to have

  1. Cross-provider mapping differs: #161 closes text before tool_start (no parentMessageId) while this PR and #165 keep it open. The missing-id fallback is the tool name here (tc.get("id") or tc["name"], messages :962,1006,1016, agents :525), so two parallel same-name id-less calls collapse into one (dedupe at agui.py:142,196); #161/#165 use "". Pick one rule in #164/the spec.
  2. The len(seen_call_ids) == 1 fallback (handler.py:867-868) puts an id-less, index-less slice on the only call seen so far even if it belongs to a second call; 3's fix removes it.
  3. tests/test_handler.py:1285-1291: replace the messageId conditional with any(e["type"] == "TEXT_MESSAGE_CONTENT" and e["delta"] == "visible answer" for e in events).

Verdict: fix 1, 2 and 3 first. ~−50 lines possible (3, 4, 6). Not checked: real provider streams through LangChain; all chunk shapes were hand-built AIMessageChunks.

The messages loop used the last chunk's tool_calls, which drops earlier
parallel calls. Chunks are merged the way LangChain adds them, and a
failed agent stream closes the tool calls it already opened.

Co-authored-by: alexis <alexis@launchdarkly.com>
@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

cursoragent and others added 3 commits October 13, 2026 18:51
Message ids are unique across runs, tool results always carry content,
and a run cannot finish while a tool call is open. Argument text is
completed only when it extends what was streamed. Disconnects close the
handler, and callers can echo threadId and runId.

Co-authored-by: alexis <alexis@launchdarkly.com>
Co-authored-by: alexis <alexis@launchdarkly.com>
The agent stream reports the tool result the graph already returned,
instead of a second copy recorded by the span callback.

Co-authored-by: alexis <alexis@launchdarkly.com>
@cursor

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

Addressed the review. Tool-call accumulation and the failed-run close are in 23a0548. Tool messages are in 3555490, on top of reader c8d198c.

Must fix:

  1. config().stream() is still chunk then done. Direct handler.stream() yields the AG-UI subset. The contract is documented on the reader (agui module docstring in c8d198c). The monorepo TESTING.md and a JS counterpart are not in this repository.
  2. A failed agent stream calls fail_open after the root span is failed. The raising-tool test expects a TOOL_CALL_RESULT for each started call.
  3. Streamed chunks are merged with LangChain addition, and the loop runs gathered.tool_calls. A real AIMessageChunk test covers two parallel calls.

Should fix:
4 and 5. The argument-suffix rule lives once in #164. A non-matching prefix is left as streamed text, so spaced JSON is not concatenated with compact JSON.
6. Agent tool results come from the graph ToolMessage (tool_call_id, content, status), not a second callback list.
7. The success test checks distinct parallel ids through start, args, end, and result. The raising-tool test covers the failure path.

@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

A streamed chunk can open a tool call that never appears in
gathered.tool_calls. The success path then ended the turn without
TOOL_CALL_END or TOOL_CALL_RESULT. Those calls are now closed as masked
failures before the next turn, and a provider error closes the open text
message after the span is failed.

Co-authored-by: alexis <alexis@launchdarkly.com>
@cursor

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

bugbot run

2 similar comments
@cursor

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

bugbot run

@cursor

cursor Bot commented Oct 14, 2026

Copy link
Copy Markdown

bugbot run

cursoragent and others added 4 commits October 14, 2026 14:27
Handlers can yield the AG-UI text and tool subset. config().stream()
still yields chunk then done. stream() is the opt-in entry.

Co-authored-by: alexis <alexis@launchdarkly.com>
A handler that raises mid-call can still report the lifecycle without the
tool result.

Co-authored-by: alexis <alexis@launchdarkly.com>
Message ids are unique across runs, tool results always carry content,
and a run cannot finish while a tool call is open. Argument text is
completed only when it extends what was streamed. Disconnects close the
handler, and callers can echo threadId and runId.

Co-authored-by: alexis <alexis@launchdarkly.com>
contextlib.aclosing always awaits aclose. A handler may return a plain
async iterator, and that call raised AttributeError after the loop had
already succeeded. The tracker now closes the stream only when aclose
is present.

Co-authored-by: alexis <alexis@launchdarkly.com>
@apucacao
apucacao force-pushed the cursor/agui-reader-7ca3 branch from ae82b2c to 37c7387 Compare October 14, 2026 21:27
The reader now sits on main, including configure(). Keep the LangChain
AG-UI stream and that capture default so this branch merges into the
reader.

Co-authored-by: alexis <alexis@launchdarkly.com>
Base automatically changed from cursor/agui-reader-7ca3 to main October 14, 2026 21:36
The reader and Claude streams are on main. Keep the LangChain
argument-prefix test that main's copy of the reader tests does not have.

Co-authored-by: alexis <alexis@launchdarkly.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e30bb95. Configure here.

A graph step can start a tool call and never send a tool message. The
success path ended the text and yielded done with that call still open.
It now closes those calls as masked failures before the run finishes.

Co-authored-by: alexis <alexis@launchdarkly.com>
@apucacao
apucacao merged commit ab5e9e1 into main Oct 14, 2026
7 checks passed
@apucacao
apucacao deleted the cursor/agui-langchain-7ca3 branch October 14, 2026 22:01
@github-actions github-actions Bot mentioned this pull request Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants