NAT 1.6 streaming architecture in DRAgent
Background
NAT 1.4.1 did not expose a streaming path for tool_calling_agent. All intermediate output was delivered through NAT's StepAdaptor.process() callback, which receives fully-formed IntermediateStep objects and converts them to AG-UI events. This is the "step adaptor" path.
In NVIDIA/NeMo-Agent-Toolkit#1595, NAT added token-by-token streaming to tool_calling_agent via a stream_fn that yields ChatResponseChunk objects (OpenAI-compatible streaming deltas). In NVIDIA/NeMo-Agent-Toolkit#1717, NAT added incremental tool call chunk streaming to that same path. These chunks contain partial text content and incremental tool call arguments as they arrive from the LLM. NAT's built-in frontend renders these directly, but DRAgent needs AG-UI events, so we have to convert them.
Two delivery paths now coexist
After NAT 1.6, DRAgent has two parallel paths for delivering events to the AG-UI client:
-
Step adaptor path (
StepAdaptor.process()): NAT calls this with completeIntermediateStepobjects for reasoning steps, tool starts/ends, custom events, and run lifecycle events. This is still the primary path for most event types. -
Stream conversion path (
stream_converter.py): Theper_user_tool_calling_agentwraps the NATstream_fnand pipesChatResponseChunkobjects throughconvert_chunks_to_agui_events(), which converts them toTextMessage*andToolCall*AG-UI events. This path handles real-time text streaming and incremental tool call argument delivery.
Both paths emit DRAgentEventResponse objects that the SSE transport sends to the client. The step adaptor path handles structured events (reasoning, run lifecycle), while the stream conversion path handles low-latency token-by-token delivery.
How stream conversion works
convert_chunks_to_agui_events() (in stream_converter.py) is a standalone async generator that consumes ChatResponseChunk objects and yields DRAgentEventResponse batches. It is fully self-contained with no dependency on the step adaptor. It tracks state across chunks:
-
Text streaming: The first chunk with text content triggers
TextMessageStartEvent. Subsequent chunks produceTextMessageContentEvent. On stream completion,TextMessageEndEventis emitted. -
Tool call streaming: OpenAI-style tool call deltas arrive incrementally. The first chunk for a tool call carries an
idand the functionname, producingToolCallStartEvent. Follow-up chunks carry only anindex(noid) and argument fragments, producingToolCallArgsEvent. Atool_index_mapmaintains the index-to-id mapping so follow-up chunks can be correlated. On stream completion,ToolCallEndEventis emitted for all active tool calls. -
Parallel tool calls: Multiple tool calls can arrive interleaved in a single chunk (different
indexvalues). Each is tracked independently.
Error handling
Upstream exceptions (e.g., LLM provider errors, network failures) are caught by convert_chunks_to_agui_events() and surfaced to the AG-UI client as RunErrorEvent(code="STREAM_ERROR"). The exception is not propagated to the caller. This is intentional: NAT's streaming infrastructure does not expect exceptions from stream_fn consumers, and propagating would cause unhandled error responses or broken SSE connections.
If the client disconnects mid-stream (GeneratorExit), end events are skipped (the client is gone) and the generator exits cleanly.
Note: The step adaptor path (StepAdaptor.process()) handles errors differently. A single step failing to process (bad payload, serialization error) returns None and logs the exception. It does not emit RunErrorEvent, because a step-level error does not mean the entire run has failed—the agent continues and subsequent steps follow.
Where the wrapping happens
per_user_tool_calling_agent.py is the glue. It:
- Calls
tool_calling_agent_workflow.__wrapped__()to get the originalFunctionInfo - If
stream_fnis present, wraps it: the wrapper pipes chunks throughconvert_chunks_to_agui_events()fromstream_converter.py - Yields a new
FunctionInfowith the wrappedstream_fnand the originalsingle_fn - If
stream_fnisNone, yields the originalFunctionInfounchanged
Other NAT 1.6 changes affecting the runtime
-
UserManager monkey-patch: In NVIDIA/NeMo-Agent-Toolkit#1775, NAT added centralized user identity management via
UserManager.extract_user_from_connection(), but it only supports standard auth (JWT, cookies, API key). DRAgent monkey-patches this method to also checkX-DataRobot-Authorization-Context, falling back to the original implementation. Applied once at import time with an idempotency guard. This also made our previousset_metadata_from_http_requestoverride ineffective (NAT overwritesuser_idafter the call), so that override was removed. -
Health routes: NAT 1.6 no longer calls
self.add_health_route(app)during setup. DRAgent registers health endpoints (/,/ping,/health) explicitly inbuild_app(). -
verify_ssl stripping: In NVIDIA/NeMo-Agent-Toolkit#1640, NAT added
verify_sslto LLM config objects. CrewAI forwards all config keys to litellm, which rejects unknown keys. We stripverify_sslat both entry points (_crewai_model_factoryandlitellm_crewai_internal). -
Import path changes: NAT 1.6 moved
nat.agent.tool_calling_agenttonat.plugins.langchain.agent.tool_calling_agent.TokenUsageBaseModelmoved fromnat.profiler.callbackstonat.data_models.token_usage(see NVIDIA/NeMo-Agent-Toolkit#1748). -
CrewAI callback patch removed: In NVIDIA/NeMo-Agent-Toolkit#1803, NAT fixed the
CrewAIProfilerHandler._llm_call_monkey_patchfor crewai >= 1.1.0 (see NVIDIA/NeMo-Agent-Toolkit#1802), so our compatibility patch was removed. -
User identity via JWT: In NVIDIA/NeMo-Agent-Toolkit#1584, NAT added JWT/cookie-based user ID resolution, which #1775 later centralized into
UserManager. DRAgent's auth context header is not part of NAT's supported auth methods, hence the monkey-patch.