Repository navigation
feat(claude): emit AG-UI text and tool events - #165
Conversation
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 Claude stream events and the tool loop onto the shared subset. Argument text and results follow capture_content. Co-authored-by: alexis <alexis@launchdarkly.com>
|
bugbot run |
|
bugbot run |
A streamed tool_use that never becomes a tool turn, and any sibling still open when a call raises, now get a failed TOOL_CALL_RESULT. Co-authored-by: alexis <alexis@launchdarkly.com>
A stream error, a handler that raises, and a streamed call the final message never runs now emit TOOL_CALL_RESULT before the turn ends. Co-authored-by: alexis <alexis@launchdarkly.com>
|
bugbot run |
jeffdupont
left a comment
There was a problem hiding this comment.
Reviewed at d8f1166. pytest packages/claude-agents/tests packages/claude-messages/tests: 249 passed. Findings 1, 2 and 6 were confirmed with throwaway probes.
Must fix
1. A failed run can be recorded as "abandoned" instead of ERROR (claude-messages handler.py:640-645, 698-705; claude-agents handler.py:938-948)
On error the handlers now yield closing tool events inside except before marking spans failed. If the consumer closes the stream at that yield, GeneratorExit skips the outer except Exception, and finally ends the root span as UNSET with launchdarkly.stream.abandoned=True. Probe: raising tool + consumer that breaks on the first TOOL_CALL_RESULT gives root statuses [], abandoned: True. Before this PR the root got ERROR. In claude-agents the yields (939-942) also come before tool_telemetry(exc) / fail_span, so tool spans are abandoned too.
Fix: messages, delete the yields in the two inner excepts (643-644, 701-704); the outer except at 749 already does fail_open() after fail_span. Agents, move the pending flush + fail_open to just before raise, after fail_span. ~−6 lines.
2. claude-agents emits the internal MCP name as toolCallName (claude-agents/.../handler.py:737)
content_block_start uses block["name"] as-is, i.e. mcp__tool-mcp__search (spans.py:66-67). The stream event arrives before PreToolUse (include_partial_messages=True, :849), so the hook's tool_start(use_id, display_name) (270) is deduped away. Probe: {'type':'TOOL_CALL_START','toolCallId':'tu-1','toolCallName':'mcp__tool-mcp__search'}. claude-messages, #161 and #163 all emit the config name.
Fix: tool_display_name(str(block.get("name") or "")) at 737 (spans.py:74).
Should fix
3. claude-agents has no test for any tool event (claude-agents/tests/test_handler.py)
_map_claude_stream_event (727-767), the saw_args/content_block_stop rule and the hook → pending flush are untested; that's how 2 got through. One test: content_block_start(tool_use, mcp__tool-mcp__search) → input_json_delta → content_block_stop + Pre/PostToolUse, asserting START (name search) / ARGS / END / RESULT once each, in order, with capture on and off.
4. With no configured tools, built-in tool calls are reported as failed (claude-agents/.../handler.py:831-847, 904-909)
Hooks are only built when there are native/MCP tools (833), so nothing sends tool_result; per the comment around 503-507 the run keeps Claude Code's built-ins. A successful Read gets START/ARGS/END, then fail_open() at done emits toolFailed: True. (Unverified whether built-ins run with allowed_tools=[] under the default permission mode.)
Fix: emit results from the UserMessage ToolResultBlocks the SDK already yields: emitter.tool_result(block.tool_use_id, block.content, failed=bool(block.is_error)); the emitter's dedupe (agui.py:190) drops the hook's duplicate.
5. Written against a different TurnEmitter.tool_end than #161/#163 (agui.py:164-184)
#161 and #163 both add _arg_text/prefix extension to tool_end; this PR doesn't and keeps its own saw_args (agents 730, 811, 761-763). After either merges, claude-messages' tool_end(..., arguments=block.input) (666-668) runs against untested emitter logic. Claude streams spaced JSON and argument_text is compact, so the prefix never matches; harmless today, but untested. Best fixed by landing that change once in #164 and rebasing all three.
Nice to have
MagicMock(type="tool_use", id="c1", name="search")names the mock, not.name: probe givestoolCallName = "<MagicMock name='search.name' ...>"intest_a_streamed_tool_without_tool_use_is_closed/test_a_stream_error_closes_an_opened_tool. Setblock.name = "search"and assert it.claude-agents/tests/test_handler.py:1940-1944loosened== "chunk"to "one of three types"; the fake stream is fixed, so assert the exact type.
Cross-provider: openai-agents (#161) closes text before tool_start (no parentMessageId); both Claude handlers keep text open and attach it. claude-agents sends the SDK's tool_response (285) while claude-messages sends the handler's return value (709), which may be shaped differently for MCP tools (unverified). Also: no AG-UI spec in the monorepo and no js-ai-sdk counterpart (see #164).
Verdict: fix 1 and 2 first. Not checked: real Agent SDK behaviour (built-in permissions with allowed_tools=[], tool_response shape, hook tool_use_id == stream block id, which the dedupe relies on); mypy.
Closing tool events inside an error handler let a consumer stop the stream before the root span was marked failed, so the run looked abandoned. Streamed MCP tools now use the config name. Co-authored-by: alexis <alexis@launchdarkly.com>
|
bugbot run |
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>
A Read or other built-in with no configured hook was marked failed at the end of the run. The user message's tool result now closes it, and the stream test covers the full tool lifecycle. Co-authored-by: alexis <alexis@launchdarkly.com>
|
Addressed the review. The span and MCP-name fixes are in Must fix:
Should fix: |
|
bugbot run |
The reader now sits on main, including configure(). Keep the Claude AG-UI stream and that capture default so this branch merges into the reader. Co-authored-by: alexis <alexis@launchdarkly.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit c90090d. Configure here.
| str(block.tool_use_id), | ||
| block.content, | ||
| failed=bool(getattr(block, "is_error", False)), | ||
| ): |
There was a problem hiding this comment.
Tool results mishandle structured content
Medium Severity
Built-in and hook tool results pass raw block.content / tool_response into tool_result. That value is often a list of content blocks rather than a string, so safe_tool_content either JSON-encodes the blocks or treats SDK objects as a failed call. Successful tools then show as An error occurred. with toolFailed, or as a second encoding instead of the text the model received.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit c90090d. Configure here.


Claude messages and agents map Claude stream events and the tool loop onto the shared AG-UI subset. Argument text and results follow
capture_content.config().stream()stayschunkthendone.Verified with the Claude handler tests and
mypy.