Skip to content

datarobot_genai.dragent.plugins.datarobot_dragent_normalization

datarobot_dragent_normalization

NAT middleware: normalize native-NAT agent output to DRAgentEventResponse.

Native NAT agents (e.g. tool_calling_agent / per_user_tool_calling_agent) emit str for single output and ChatResponseChunk for streaming output. DRAgent's frontend, moderation, and converters all expect the canonical DRAgentEventResponse. This middleware sits innermost (declared last in a function's middleware list) so it converts native output into DRAgentEventResponse before any outer middleware (moderation, otel conventions) or the frontend sees it. Agents that already emit DRAgentEventResponse pass through unchanged.

Because NAT middleware is per-function and opt-in (not inherited from the parent workflow), this middleware must be declared on whichever function actually produces native-NAT output — including an inner per_user_tool_calling_agent referenced by a memory wrapper's inner_agent_name.

Streaming conversion from ChatResponseChunk to AG-UI events is implemented by convert_chunks_to_agui_events in this module (formerly dragent.frontends.stream_converter).

DataRobotDRAgentNormalizationConfig

Bases: FunctionMiddlewareBaseConfig

NAT middleware: normalize native-NAT agent output to DRAgentEventResponse.

No configuration fields; declare it on a function's middleware list as the last (innermost) entry so downstream moderation and converters only ever see DRAgentEventResponse.

Source code in datarobot_genai/dragent/plugins/datarobot_dragent_normalization.py
class DataRobotDRAgentNormalizationConfig(
    FunctionMiddlewareBaseConfig,  # type: ignore[misc]
    name="datarobot_dragent_normalization",  # type: ignore[call-arg]
):
    """NAT middleware: normalize native-NAT agent output to ``DRAgentEventResponse``.

    No configuration fields; declare it on a function's ``middleware`` list as the last
    (innermost) entry so downstream moderation and converters only ever see
    ``DRAgentEventResponse``.
    """

DataRobotDRAgentNormalizationMiddleware

Bases: FunctionMiddleware

Convert native-NAT output (str / ChatResponse / ChatResponseChunk) to DRAgentEventResponse; pass DRAgentEventResponse through unchanged.

Source code in datarobot_genai/dragent/plugins/datarobot_dragent_normalization.py
class DataRobotDRAgentNormalizationMiddleware(FunctionMiddleware):
    """Convert native-NAT output (``str`` / ``ChatResponse`` / ``ChatResponseChunk``) to
    ``DRAgentEventResponse``; pass ``DRAgentEventResponse`` through unchanged.
    """

    def __init__(
        self,
        config: DataRobotDRAgentNormalizationConfig,
        builder: Builder,  # noqa: ARG002
    ) -> None:
        super().__init__()
        self._config = config

    @property
    def enabled(self) -> bool:
        return True

    async def function_middleware_invoke(
        self,
        *args: Any,
        call_next: CallNext,
        context: FunctionMiddlewareContext,  # noqa: ARG002
        **kwargs: Any,
    ) -> Any:
        output = await call_next(*args, **kwargs)
        return _normalize_single_output(output)

    async def function_middleware_stream(
        self,
        *args: Any,
        call_next: CallNextStream,
        context: FunctionMiddlewareContext,  # noqa: ARG002
        **kwargs: Any,
    ) -> AsyncIterator[DRAgentEventResponse]:
        async with contextlib.aclosing(
            cast(AsyncGenerator[Any, None], call_next(*args, **kwargs))
        ) as upstream:
            iterator = upstream.__aiter__()
            try:
                first = await iterator.__anext__()
            except StopAsyncIteration:
                return

            # dragent-native agents already emit DRAgentEventResponse -> passthrough. Frame a
            # mid-run failure as a terminal RUN_ERROR
            if isinstance(first, DRAgentEventResponse):
                open_text_ids: set[str] = set()
                try:
                    track_open_text_in_events(open_text_ids, first.events)
                    yield first
                    async for chunk in iterator:
                        track_open_text_in_events(open_text_ids, chunk.events)
                        yield chunk
                except Exception as exc:
                    logger.exception("Agent stream failed; ending with RUN_ERROR")
                    for message_id in open_text_ids:
                        yield DRAgentEventResponse(
                            events=[TextMessageEndEvent(message_id=message_id)],
                            usage_metrics=default_usage_metrics(),
                        )
                    yield run_error_response(str(exc))
                return

            # Native NAT agents emit ChatResponseChunk -> convert to AG-UI events. Reuse
            # the stateful stream converter, re-prepending the peeked first chunk.
            async def _rechained() -> AsyncGenerator[Any]:
                yield first
                async for chunk in iterator:
                    yield chunk

            async for event_response in convert_chunks_to_agui_events(_rechained()):
                yield event_response

resolve_streaming_tool_call_id

resolve_streaming_tool_call_id(*, index: int, chunk_id: str | None, tool_index_map: dict[int, str]) -> tuple[str | None, bool]

Return (tool_call_id, is_new) for one OpenAI-style streaming tool delta.

Follow-up chunks should only carry index, but some providers (Gemini via LiteLLM) may re-emit a new id that appends a __thought__ signature. Once an index is mapped, keep the first id for START/ARGS correlation.

Source code in datarobot_genai/dragent/plugins/datarobot_dragent_normalization.py
def resolve_streaming_tool_call_id(
    *,
    index: int,
    chunk_id: str | None,
    tool_index_map: dict[int, str],
) -> tuple[str | None, bool]:
    """Return ``(tool_call_id, is_new)`` for one OpenAI-style streaming tool delta.

    Follow-up chunks should only carry ``index``, but some providers (Gemini via
    LiteLLM) may re-emit a new ``id`` that appends a ``__thought__`` signature.
    Once an index is mapped, keep the first id for START/ARGS correlation.
    """
    if index in tool_index_map:
        return tool_index_map[index], False
    if chunk_id is None:
        return None, False
    return chunk_id, True

convert_chunks_to_agui_events async

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

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/plugins/datarobot_dragent_normalization.py
async def convert_chunks_to_agui_events(
    chunks: AsyncGenerator[ChatResponseChunk],
) -> AsyncGenerator[DRAgentEventResponse]:
    """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 mapped_tc_id in tool_index_map.values():
                    events.extend(mark_args_done(mapped_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, is_new = resolve_streaming_tool_call_id(
                        index=tc.index,
                        chunk_id=tc.id,
                        tool_index_map=tool_index_map,
                    )
                    if tc_id is None:
                        logger.warning(
                            "Tool call chunk at index %d has no id and no prior mapping; skipping",
                            tc.index,
                        )
                        continue
                    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 mapped_tc_id in tool_index_map.values():
        end.extend(mark_args_done(mapped_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=RUN_ERROR_CODE))
    if end:
        yield DRAgentEventResponse(events=end, usage_metrics=zero)

datarobot_dragent_normalization_middleware async

datarobot_dragent_normalization_middleware(config: DataRobotDRAgentNormalizationConfig, builder: Builder) -> AsyncIterator[DataRobotDRAgentNormalizationMiddleware]

Register the DRAgent output-normalization middleware for NAT workflows.

Source code in datarobot_genai/dragent/plugins/datarobot_dragent_normalization.py
@register_middleware(  # type: ignore[untyped-decorator]
    config_type=DataRobotDRAgentNormalizationConfig
)
async def datarobot_dragent_normalization_middleware(
    config: DataRobotDRAgentNormalizationConfig,
    builder: Builder,
) -> AsyncIterator[DataRobotDRAgentNormalizationMiddleware]:
    """Register the DRAgent output-normalization middleware for NAT workflows."""
    yield DataRobotDRAgentNormalizationMiddleware(config, builder)