Skip to content

feat(openai): emit AG-UI text and tool events - #161

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

apucacao merged 18 commits into
mainfrom
cursor/agui-openai-7ca3

Conversation

@apucacao

Copy link
Copy Markdown
Contributor

OpenAI messages and agents map Responses stream events and the handler tool loop onto the shared AG-UI subset. Argument text and results follow capture_content. config().stream() stays chunk then done.

Verified with the OpenAI 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 Responses stream events 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.

Comment thread packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py Outdated
Comment thread packages/openai-messages/src/launchdarkly_ai_openai_messages/handler.py Outdated
Comment thread packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py Outdated
Do not close a call from the tool hook once argument deltas have started.
Read hosted tool output from response.output_item.done, and close every
open call when the messages stream fails.

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

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

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.

Comment thread packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py Outdated
File-search results and code-interpreter outputs are SDK models, so
json.dumps marked a successful call failed. A streamed prefix no longer
blocks the rest of the arguments, and a messages failure or a call that
never becomes a function result still gets TOOL_CALL_RESULT.

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 4d7504c against base b7a9f3a (#164). 263 tests pass. Findings 1 and 2 come from replaying the event order against this PR's TurnEmitter, not from a live OpenAI run.

Must fix

Both come from one timing gap in openai-agents: the Runner works in a background task, puts raw model events on a queue (put_nowait, agents/run_internal/run_loop.py:1484) and calls our hooks directly. Hooks mutate the shared emitter immediately; raw events reach it only when the consumer reads them. A slow consumer (proxy, backpressure) reorders the two.

1. Tool arguments are published truncated when the consumer is slow (openai-agents/.../handler.py:327-337, 354, 790-791)
Consumer has read output_item.added and {"q":; the tool finishes before more is read. on_tool_start skips closing because deltas exist (L330); on_tool_end → tool_result closes the call with no arguments (agui.py:202); later deltas are dropped as ended (agui.py:163). Replay output: START, ARGS '{"q":', END, RESULT. test_a_streaming_prefix_is_not_closed_by_the_tool_hook covers only the on_tool_start half.
Fix: in on_tool_end, before tool_result, self.pending.extend(self.emitter.tool_end(str(call_id), arguments=getattr(context, "tool_arguments", None))), plus a test replaying this order.

2. Two model turns merge into one message when the consumer is slow (openai-agents/.../handler.py:260)
on_llm_start closes the open text via pending. Turn 1 "Let me " read, "check." still queued, turn 2 starts: msg-1 closes early, the late "check." opens msg-2, and turn 2's text joins it. Replay: msg-1 = "Let me ", msg-2 = "check.Answer: 42".
Fix: close text from the stream itself, in read order: in _map_responses_event, if kind == "response.created": return emitter.end_text(), and delete L260. (#165 does the same on message_start.)

Should fix

3. The hosted-tool mapping can't run, and it's in both packages (openai-messages/.../handler.py:447-524, 551-556; openai-agents/.../handler.py:606-683, 710-715)
_build_tools builds only "type": "function" (openai-messages/.../handler.py:53-65) and _build_agent_tools only FunctionTool (openai-agents/.../handler.py:79-117, Agent(...) at 219-224), so mcp_call, custom_tool_call, file_search_call and code_interpreter_call events never arrive. ~75 lines per copy plus tests.
Fix: delete _HOSTED_OUTPUT_FIELD, _HOSTED_TOOL_NAME, _json_safe, _hosted_item_result, the mcp_*/custom_tool_call_* keys, the mcp_call.failed and output_item.done branches, and test_hosted_* / test_model_objects_are_json_not_failures. Re-add when hosted tools are wired.

4. The mapper is byte-identical in both packages (openai-messages/.../handler.py:437-560 vs openai-agents/.../handler.py:596-719)
Fixes 1–2 and any new event type must be made twice; openai-agents doesn't depend on openai-messages. After 3 it's ~30 lines: put one copy where both already depend, or at least cross-reference the copies.

5. Tool-result content differs between the two OpenAI handlers (openai-messages/.../handler.py:738 vs openai-agents/.../handler.py:107, 354)
A tool returning {"a": 1}: openai-messages emits '{"a":1}' while the model was sent str(result) = {'a': 1} (L744); openai-agents emits {'a': 1}. #163 and #165 use yet other rules.
Fix: report what the model received, emitter.tool_result(str(tc.call_id), str(result)), and pin the rule in the spec.

6. No test sends function_call_arguments.delta (openai-messages/tests/test_handler.py)
Deltas carry only item_id (fc_…), mapped to call_id via item_to_call. If that breaks, deltas go to an unknown id and are silently dropped (agui.py:162). One test: two parallel function_call items with interleaved deltas, asserting each toolCallId gets its full argument string.

7. Shared-reader edit duplicated with #163, and no spec / JS counterpart
This PR edits agui.py (_arg_text, new tool_end, agui.py:107, 167, 170-194); #163 has the same hunk (only has_argument_deltas, L154-156, is unique here). Move it into #164 and rebase. The monorepo has no AG-UI spec and js-ai-sdk has nothing matching.

Nice to have

  1. The tool_end docstring says "a partial delta cannot publish truncated JSON", but when the streamed prefix isn't a prefix of compact argument_text(arguments), extra is "" and the call ends truncated (hits LangChain in #163, not this PR). Weaken the docstring or fall back to "streamed text wins"; belongs in #164.

Confirmed correct: parallel calls stay separate via item_to_call; post-stream start/end (openai-messages/.../handler.py:695-699) dedupes against streamed ones; model/tool/max-steps/outer errors all call fail_open; graph() and config().stream() still get chunk via StableProjector.

Verdict: fix 1 and 2 first. ~−150 lines possible (3), ~−30 more with 4. Not checked: a live OpenAI stream; direct external callers of handler.stream(), who now get TEXT_MESSAGE_* instead of chunk.

The agents hook can finish a tool before the consumer has read the rest
of its argument deltas. The result now closes with the full argument
string. A new response closes the open text message from the stream
itself, not from the background hook.

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.

Comment thread packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py
Co-authored-by: alexis <alexis@launchdarkly.com>
@apucacao

Copy link
Copy Markdown
Contributor Author

bugbot 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>
Hosted tool events cannot arrive while only function tools are offered,
so that mapping is gone. Both handlers report the string the model was
sent, and argument deltas stay on the call they belong to.

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

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

Addressed the review. The slow-consumer fixes are in e8c324e and 57abadc. The rest is in f43b888, on top of reader c8d198c.

Must fix:

  1. on_tool_end closes the call with tool_arguments before the result, so a prefix is not sealed.
  2. Text closes on response.created in read order. on_llm_start no longer closes it. A failed turn still emits TEXT_MESSAGE_END after the root span is marked failed (57abadc).

Should fix:
3. Hosted and MCP mapping is removed. These handlers only offer function tools.
4. The remaining mapper is launchdarkly_ai_server.responses.map_responses_event, used by both packages.
5. Both handlers report str(result), the text the model was sent.
6. Parallel function_call_arguments.delta events are tested and stay on their own call ids.
7. The shared tool_end change moved to #164 (c8d198c). This branch merged that reader head and dropped its own copy.

@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.

Comment thread packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py
A tool result was emitted while the assistant message was still open, so an
AG-UI client could attach the result to the wrong turn. The agents hook now
ends that message before the tool result. A provider error in the messages
handler ended the span and then raised without TEXT_MESSAGE_END; the failure
path now closes the message after the span is failed.

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 3248d3c. Configure here.

Comment thread packages/openai-agents/src/launchdarkly_ai_openai_agents/handler.py Outdated
The agents tool hook runs before the consumer has read every text delta
from that model call. Closing the message in the hook split the rest of
the turn into a second message and put the tool result ahead of it. The
stream now keeps that text on the open message, then closes it before
the tool result.

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

cursor Bot commented Oct 13, 2026

Copy link
Copy Markdown

bugbot run

1 similar comment
@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 OpenAI
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 OpenAI argument-prefix
test that main's copy of the reader tests does not have.

Co-authored-by: alexis <alexis@launchdarkly.com>
@apucacao
apucacao merged commit 2b1b2d4 into main Oct 14, 2026
7 checks passed
@apucacao
apucacao deleted the cursor/agui-openai-7ca3 branch October 14, 2026 21:50
@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