Skip to content

datarobot_genai.dragent.frontends.stream_converter

stream_converter

Convert ChatResponseChunk streams into AG-UI events.

NAT 1.6 added a stream_fn to tool_calling_agent that yields ChatResponseChunk objects (OpenAI-compatible streaming deltas). This module converts that stream into DRAgentEventResponse batches containing AG-UI TextMessage* and ToolCall* events.

See docs/nat-1.6-streaming.md for the full design.

convert_chunks_to_agui_events async

convert_chunks_to_agui_events(chunks: AsyncGenerator[ChatResponseChunk, None]) -> AsyncGenerator[DRAgentEventResponse, None]

Convert a ChatResponseChunk stream into AG-UI events.

Yields DRAgentEventResponse batches as chunks arrive. On upstream errors, emits RunErrorEvent and stops (does not propagate). On GeneratorExit (client disconnect), exits silently.

Source code in datarobot_genai/dragent/frontends/stream_converter.py
async def convert_chunks_to_agui_events(
    chunks: AsyncGenerator[ChatResponseChunk, None],
) -> AsyncGenerator[DRAgentEventResponse, None]:
    """Convert a ChatResponseChunk stream into AG-UI events.

    Yields ``DRAgentEventResponse`` batches as chunks arrive.  On upstream
    errors, emits ``RunErrorEvent`` and stops (does not propagate).  On
    ``GeneratorExit`` (client disconnect), exits silently.
    """
    active_message_id: str | None = None
    # parent_message_id for subsequent tool calls; a synthetic uuid here
    # renders an orphan message stub in the UI.
    last_text_message_id: str | None = None
    seen_tool_calls: bool = False
    tool_index_map: dict[int, str] = {}
    zero = default_usage_metrics()

    error: Exception | None = None
    try:
        async for chunk in chunks:
            if not isinstance(chunk, ChatResponseChunk) or not chunk.choices:
                continue

            delta = chunk.choices[0].delta
            events: list[Event] = []

            if delta and delta.content:
                # Args streaming is complete for all tracked tool calls.
                # Flush any end/result events deferred by the step adaptor.
                for tc_id in tool_index_map.values():
                    events.extend(mark_args_done(tc_id))
                tool_index_map.clear()

                if active_message_id is None:
                    # After a tool call cycle, the LLM response is a new turn
                    # but chunk.id may be reused.  Force a unique messageId.
                    active_message_id = (
                        str(uuid.uuid4()) if seen_tool_calls else (chunk.id or str(uuid.uuid4()))
                    )
                    events.append(TextMessageStartEvent(message_id=active_message_id))
                events.append(
                    TextMessageContentEvent(message_id=active_message_id, delta=delta.content)
                )

            if delta and delta.tool_calls:
                # Close any active text message before starting tool calls.
                if active_message_id is not None:
                    events.append(TextMessageEndEvent(message_id=active_message_id))
                    last_text_message_id = active_message_id
                    active_message_id = None
                seen_tool_calls = True

                for tc in delta.tool_calls:
                    tc_id = tc.id or tool_index_map.get(tc.index)  # type: ignore[assignment]
                    if tc_id is None:
                        logger.warning(
                            "Tool call chunk at index %d has no id and no prior mapping; skipping",
                            tc.index,
                        )
                        continue
                    is_new = tc.id is not None and tc.index not in tool_index_map
                    if is_new:
                        tool_index_map[tc.index] = tc_id
                        tool_name = tc.function.name if tc.function else ""
                        events.append(
                            ToolCallStartEvent(
                                tool_call_id=tc_id,
                                tool_call_name=tool_name,
                                parent_message_id=last_text_message_id or "",
                            )
                        )
                        # Hand the LLM-issued id to the step adaptor.
                        if tool_name:
                            register_tool_call(tool_name, tc_id)
                    arguments = tc.function.arguments if tc.function else None
                    if arguments:
                        events.append(ToolCallArgsEvent(tool_call_id=tc_id, delta=arguments))

            if events:
                yield DRAgentEventResponse(events=events, usage_metrics=zero, original_chunk=chunk)
    except Exception as exc:
        error = exc
    finally:
        if sys.exc_info()[0] is GeneratorExit:
            logger.debug("Client disconnected before end events could be delivered")
            return

    # Emit end/error events after the stream completes (normally or on error).
    # Errors are surfaced to the AG-UI client via RunErrorEvent rather than
    # propagated as exceptions, so NAT's streaming infrastructure stays stable.
    end: list[Event] = []
    # Mark remaining in-flight tool calls as args-done and flush deferred events.
    for tc_id in tool_index_map.values():
        end.extend(mark_args_done(tc_id))
    if active_message_id is not None:
        end.append(TextMessageEndEvent(message_id=active_message_id))
    if error is not None:
        end.append(RunErrorEvent(message=str(error), code="STREAM_ERROR"))
    if end:
        yield DRAgentEventResponse(events=end, usage_metrics=zero)