Skip to main content

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::UnboundedSender;
49
50use crate::agent_runtime::StreamDelta;
51
52/// Strong sender side, wrapped in `Arc` so the runtime callback can clone
53/// cheaply on every lookup. Re-exported here so callers don't have to know
54/// the runtime's internal type.
55pub type StreamingSinkSender = Arc<UnboundedSender<StreamDelta>>;
56
57/// Per-session lookup table. Shared via `Arc` between the kernel handle,
58/// the agent runtime, and the gateway dispatch layer.
59#[derive(Default)]
60pub struct StreamingSinkRegistry {
61    inner: Mutex<HashMap<String, Weak<UnboundedSender<StreamDelta>>>>,
62}
63
64impl StreamingSinkRegistry {
65    /// Create an empty registry.
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Register a strong sender under `session_id`. Stores a `Weak` so the
71    /// entry auto-cleans when the gateway's collector drops its strong
72    /// reference — no explicit `unregister` needed in the happy path.
73    pub fn register(&self, session_id: &str, sender: &StreamingSinkSender) {
74        let weak = Arc::downgrade(sender);
75        self.inner
76            .lock()
77            .expect("StreamingSinkRegistry mutex poisoned")
78            .insert(session_id.to_string(), weak);
79    }
80
81    /// Look up the sink for `session_id`. Returns `Some(tx)` only if a
82    /// strong sender is still alive (i.e., the collector task hasn't
83    /// completed and dropped its `Arc`). Misses are silent — the runtime
84    /// simply skips emitting the delta.
85    pub fn lookup(&self, session_id: &str) -> Option<StreamingSinkSender> {
86        let guard = self
87            .inner
88            .lock()
89            .expect("StreamingSinkRegistry mutex poisoned");
90        guard
91            .get(session_id)
92            .and_then(|w| w.upgrade())
93            .map(|a| Arc::clone(&a))
94    }
95
96    /// Explicit unregister. Rarely needed — `Weak::upgrade` returning
97    /// `None` is the normal cleanup path. Exposed for symmetry / tests
98    /// that want to assert pre-cleanup behavior.
99    pub fn unregister(&self, session_id: &str) {
100        self.inner
101            .lock()
102            .expect("StreamingSinkRegistry mutex poisoned")
103            .remove(session_id);
104    }
105
106    /// Test/observability helper: number of active sessions.
107    #[cfg(test)]
108    pub fn len(&self) -> usize {
109        self.inner
110            .lock()
111            .expect("StreamingSinkRegistry mutex poisoned")
112            .len()
113    }
114    /// Returns true if there are no active streaming sessions.
115    #[cfg(test)]
116    pub fn is_empty(&self) -> bool {
117        self.len() == 0
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::agent_runtime::StreamingSinkTx;
125
126    #[test]
127    fn lookup_miss_when_empty() {
128        let r = StreamingSinkRegistry::new();
129        assert!(r.lookup("missing").is_none());
130    }
131
132    #[test]
133    fn lookup_returns_strong_when_alive() {
134        let r = StreamingSinkRegistry::new();
135        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<StreamDelta>();
136        let sender: StreamingSinkTx = Arc::new(tx);
137        r.register("s1", &sender);
138        let looked = r.lookup("s1").expect("should find live sink");
139        assert!(Arc::ptr_eq(&looked, &sender));
140    }
141
142    #[test]
143    fn lookup_misses_after_drop() {
144        let r = StreamingSinkRegistry::new();
145        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<StreamDelta>();
146        let sender: StreamingSinkTx = Arc::new(tx);
147        r.register("s1", &sender);
148        drop(sender);
149        assert!(r.lookup("s1").is_none(), "stale Weak must not upgrade");
150    }
151
152    #[test]
153    fn unregister_clears_entry() {
154        let r = StreamingSinkRegistry::new();
155        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<StreamDelta>();
156        let sender: StreamingSinkTx = Arc::new(tx);
157        r.register("s1", &sender);
158        r.unregister("s1");
159        assert!(r.lookup("s1").is_none());
160        // The strong sender is still alive — caller can keep using it
161        // directly; the registry just no longer points at it.
162        assert!(Arc::strong_count(&sender) >= 1);
163    }
164}