Deep-dive on streaming vs non-streaming request handling
Location: DefaultRequestHandler.java
1. initMessageSend()
→ Create TaskManager & RequestContext
2. queueManager.createOrTap(taskId)
→ Get/create EventQueue (MainQueue or ChildQueue)
3. registerAndExecuteAgentAsync()
→ Start AgentExecutor in background thread
4. resultAggregator.consumeAndBreakOnInterrupt(consumer)
→ Poll queue until terminal event or AUTH_REQUIRED
→ Blocking wait for events
5. cleanup(queue, task, async)
→ Close queue immediately OR in background
6. Return Task/Message to client
Events that cause polling loop exit:
TaskStatusUpdateEventwithisFinal() == trueMessage(legacy)Taskwith state: COMPLETED, CANCELED, FAILED, REJECTED, UNKNOWN
Behavior:
- Returns current task to client immediately
- Agent continues running in background
- Queue stays open, cleanup happens async
- Future events update TaskStore
Why: Allows client to handle authentication prompt while agent waits for credentials.
Location: DefaultRequestHandler.java
1. initMessageSend()
→ Same as non-streaming
2. queueManager.createOrTap(taskId)
→ Same
3. registerAndExecuteAgentAsync()
→ Same
4. resultAggregator.consumeAndEmit(consumer)
→ Returns Flow.Publisher<Event> immediately
→ Non-blocking
5. processor() wraps publisher:
- Validates task ID
- Adds task to QueueManager
- Stores push notification config
- Sends push notifications
6. cleanup(queue, task, true)
→ ALWAYS async for streaming
7. Return Flow.Publisher<StreamingEventKind>
Non-Streaming: Blocks until terminal event, then returns Task/Message Streaming: Returns Flow.Publisher immediately, client receives events as they arrive
Cleanup: Streaming ALWAYS uses async cleanup (background thread)
Location: server-common/.../events/EventConsumer.java
Purpose: Consumes events from EventQueue and exposes as reactive stream
Key Methods:
consume()→ ReturnsFlow.Publisher<Event>- Polls queue with 500ms timeout
- Closes queue on final event
- Thread-safe concurrent consumption
Usage:
EventConsumer consumer = new EventConsumer(eventQueue);
Flow.Publisher<Event> publisher = consumer.consume();
// Subscribe to receive events as they arriveLocation: server-common/.../tasks/ResultAggregator.java
Bridges EventConsumer and DefaultRequestHandler with three consumption modes:
Used by: onMessageSend() (non-streaming)
Behavior:
- Polls queue until terminal event or AUTH_REQUIRED
- Returns
EventTypeAndInterrupt(event, interrupted) - Blocking operation
- Exits early on AUTH_REQUIRED (interrupted = true)
Use Case: Non-streaming requests that need single final response
Used by: onMessageSendStream() (streaming)
Behavior:
- Returns all events as
Flow.Publisher<Event> - Non-blocking, immediate return
- Client subscribes to stream
- Events delivered as they arrive
Use Case: Streaming requests where client wants all events in real-time
Used by: onCancelTask()
Behavior:
- Consumes all events from queue
- Returns first
Messageor finalTaskfound - Simple consumption without streaming
- Blocks until queue exhausted
Use Case: Task cancellation where final state matters
| Aspect | Non-Streaming | Streaming |
|---|---|---|
| ResultAggregator Mode | consumeAndBreakOnInterrupt | consumeAndEmit |
| Return Type | Task/Message | Flow.Publisher |
| Blocking | Yes (until terminal event) | No (immediate return) |
| Cleanup | Immediate or async | Always async |
| AUTH_REQUIRED | Early exit, return task | Continue streaming |
| Use Case | Simple request/response | Real-time event updates |
Reality: Cleanup is ALWAYS asynchronous in both streaming and non-streaming flows. The cleanup happens in the finally block via cleanupProducer(), which runs in a background thread.
// Both flows (in finally block):
cleanupProducer(agentFuture, consumptionFuture, taskId, queue, isStreaming)
.whenComplete((res, err) -> {
if (err != null) {
LOGGER.error("Error during async cleanup for task {}", taskId, err);
}
});Key Points:
- Cleanup is initiated in
finallyblock regardless of flow outcome cleanupProducer()waits for both agent and consumption futures to complete- Queue closure happens in background, never blocking the request thread
- For streaming: EventConsumer manages queue lifecycle via
agentCompletedflag - For non-streaming: Queue is closed directly after agent completes
cleanup(queue, task, true); // ALWAYS async for streamingLogic: Streaming always uses async cleanup because:
- Publisher already returned to client
- Events may still be processing
- Queue cleanup happens in background
CompletableFuture.runAsync(agentExecutor::execute, executor)- Agent runs in background thread pool
- Enqueues events to MainQueue
- Single background thread: "MainEventBusProcessor"
- Processes events from MainEventBus
- Persists to TaskStore, distributes to ChildQueues
- Non-streaming: Request handler thread (blocking)
- Streaming: Subscriber thread (reactive)
- Polls ChildQueue for events
- Async cleanup: Background thread pool
- Immediate cleanup: Request handler thread
- Main Overview - Architecture and components
- Lifecycle - Queue lifecycle and cleanup
- Scenarios - Real-world usage patterns