oxios_kernel/streaming_sink.rs
1//! Streaming sink registry — per-session lookup table for the WebSocket
2//! streaming path.
3//!
4//! Why this exists
5//! ================
6//!
7//! The agent runtime's `AgentEvent::TextChunk` callback fires per text delta
8//! during `run_streaming`. The Web UI needs each delta as a live `token`
9//! chunk. Without plumbing `streaming_sink: Option<...>` through every layer
10//! between the gateway and the runtime callback (Supervisor trait, AgentApi,
11//! AgentTool, AgentLifecycleManager, Orchestrator, AgentRuntime), we use a
12//! session-keyed registry:
13//!
14//! 1. The **gateway** creates `mpsc::unbounded()` + a collector task right
15//! before invoking the orchestrator. The collector holds the **strong**
16//! `Arc<UnboundedSender<StreamDelta>>` for the duration of the turn
17//! (with `target_conn_id = Some(conn_id)` on every partial `OutgoingMessage`,
18//! mirroring `gateway.rs:491`). The gateway also stores a `Weak` under
19//! `session_id` in this registry.
20//! 2. The **runtime** callback looks up the registry by `session_id` (which it
21//! already has via `transparency_session`) and sends `StreamDelta::Text`
22//! directly into the channel — no plumbing through intermediate layers.
23//! 3. When the collector task completes, the strong `Arc` drops. `Weak::upgrade`
24//! on the next runtime lookup returns `None` and the registry lookup
25//! cleanly misses — **no explicit unregister required, no stale entries**.
26//!
27//! Design rationale (vs threaded `Option<StreamingSinkTx>` params)
28//! =================================================================
29//!
30//! `run_with_directive` is a `Supervisor` trait method with concrete impls
31//! on `BasicSupervisor` and `NoOpSupervisor`, and `dyn Supervisor` is held
32//! by `AgentLifecycleManager`, `AgentApi`, and `AgentTool`. Threading the
33//! sink through there would touch every impl + every holder. The registry
34//! avoids all of that because the runtime callback already has the
35//! `session_id` it needs to look up the sink — no new arguments required.
36//!
37//! Concurrency
38//! ===========
39//!
40//! The registry is `Send + Sync` (it owns a `Mutex<HashMap<...>>` of `Weak`s,
41//! neither contains interior mutability beyond the mutex itself). Lookup is
42//! O(n) where n = active turns; in practice n ≤ a few during normal use, so
43//! a `HashMap` is fine. A `DashMap` would be a one-line swap if load grows.
44
45use std::collections::HashMap;
46use std::sync::{Arc, Mutex, Weak};
47
48use tokio::sync::mpsc::Sender;
49
50/// Bounded capacity for streaming delta channels. Each delta is a small text
51/// chunk (~100-500 bytes); 256 caps the buffer at ~128 KB, preventing OOM
52/// from slow clients while absorbing normal burst latency.
53pub const STREAMING_CHANNEL_CAPACITY: usize = 256;
54
55use crate::agent_runtime::StreamDelta;
56
57/// Strong sender side, wrapped in `Arc` so the runtime callback can clone
58/// cheaply on every lookup. Re-exported here so callers don't have to know
59/// the runtime's internal type.
60pub type StreamingSinkSender = Arc<Sender<StreamDelta>>;
61
62/// Per-session lookup table. Shared via `Arc` between the kernel handle,
63/// the agent runtime, and the gateway dispatch layer.
64#[derive(Default)]
65pub struct StreamingSinkRegistry {
66 inner: Mutex<HashMap<String, Weak<Sender<StreamDelta>>>>,
67}
68
69impl StreamingSinkRegistry {
70 /// Create an empty registry.
71 pub fn new() -> Self {
72 Self::default()
73 }
74
75 /// Register a strong sender under `session_id`. Stores a `Weak` so the
76 /// entry auto-cleans when the gateway's collector drops its strong
77 /// reference — no explicit `unregister` needed in the happy path.
78 pub fn register(&self, session_id: &str, sender: &StreamingSinkSender) {
79 let weak = Arc::downgrade(sender);
80 self.inner
81 .lock()
82 .unwrap_or_else(|e| {
83 tracing::error!("streaming sink mutex poisoned — recovering");
84 e.into_inner()
85 })
86 .insert(session_id.to_string(), weak);
87 }
88
89 /// Look up the sink for `session_id`. Returns `Some(tx)` only if a
90 /// strong sender is still alive (i.e., the collector task hasn't
91 /// completed and dropped its `Arc`). Misses are silent — the runtime
92 /// simply skips emitting the delta.
93 pub fn lookup(&self, session_id: &str) -> Option<StreamingSinkSender> {
94 let guard = self.inner.lock().unwrap_or_else(|e| {
95 tracing::error!("streaming sink mutex poisoned — recovering");
96 e.into_inner()
97 });
98 guard
99 .get(session_id)
100 .and_then(|w| w.upgrade())
101 .map(|a| Arc::clone(&a))
102 }
103
104 /// Explicit unregister. Rarely needed — `Weak::upgrade` returning
105 /// `None` is the normal cleanup path. Exposed for symmetry / tests
106 /// that want to assert pre-cleanup behavior.
107 pub fn unregister(&self, session_id: &str) {
108 self.inner
109 .lock()
110 .unwrap_or_else(|e| {
111 tracing::error!("streaming sink mutex poisoned — recovering");
112 e.into_inner()
113 })
114 .remove(session_id);
115 }
116
117 /// Test/observability helper: number of active sessions.
118 #[cfg(test)]
119 pub fn len(&self) -> usize {
120 self.inner
121 .lock()
122 .unwrap_or_else(|e| {
123 tracing::error!("streaming sink mutex poisoned — recovering");
124 e.into_inner()
125 })
126 .len()
127 }
128 /// Returns true if there are no active streaming sessions.
129 #[cfg(test)]
130 pub fn is_empty(&self) -> bool {
131 self.len() == 0
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use crate::agent_runtime::StreamingSinkTx;
139
140 #[test]
141 fn lookup_miss_when_empty() {
142 let r = StreamingSinkRegistry::new();
143 assert!(r.lookup("missing").is_none());
144 }
145
146 #[test]
147 fn lookup_returns_strong_when_alive() {
148 let r = StreamingSinkRegistry::new();
149 let (tx, _rx) = tokio::sync::mpsc::channel::<StreamDelta>(8);
150 let sender: StreamingSinkTx = Arc::new(tx);
151 r.register("s1", &sender);
152 let looked = r.lookup("s1").expect("should find live sink");
153 assert!(Arc::ptr_eq(&looked, &sender));
154 }
155
156 #[test]
157 fn lookup_misses_after_drop() {
158 let r = StreamingSinkRegistry::new();
159 let (tx, _rx) = tokio::sync::mpsc::channel::<StreamDelta>(8);
160 let sender: StreamingSinkTx = Arc::new(tx);
161 r.register("s1", &sender);
162 drop(sender);
163 assert!(r.lookup("s1").is_none(), "stale Weak must not upgrade");
164 }
165
166 #[test]
167 fn unregister_clears_entry() {
168 let r = StreamingSinkRegistry::new();
169 let (tx, _rx) = tokio::sync::mpsc::channel::<StreamDelta>(8);
170 let sender: StreamingSinkTx = Arc::new(tx);
171 r.register("s1", &sender);
172 r.unregister("s1");
173 assert!(r.lookup("s1").is_none());
174 // The strong sender is still alive — caller can keep using it
175 // directly; the registry just no longer points at it.
176 assert!(Arc::strong_count(&sender) >= 1);
177 }
178}