Skip to main content

agent_engine/tools/
output.rs

1//! Task 26 — bounded tool-output delta channels with an explicit
2//! backpressure/coalescing policy (spec §8.4).
3//!
4//! High-volume tool output used to ride an UNBOUNDED `mpsc` queue from the
5//! producing tool to a detached forwarding task; a fast producer with a slow
6//! consumer could therefore grow RSS without limit. This module replaces
7//! that path with:
8//!
9//! - a **bounded channel** ([`DELTA_CHANNEL_CAPACITY`] chunks, each retained
10//!   chunk capped at [`DELTA_MAX_CHUNK_BYTES`]);
11//! - an explicit **coalescing overflow buffer** (at most
12//!   [`DELTA_COALESCE_CAP_BYTES`] bytes): when the channel is full, chunks
13//!   merge into one pending byte-capped buffer instead of queueing without
14//!   bound;
15//! - an explicit **drop policy** beyond the coalesce cap, with exact
16//!   dropped/coalesced byte and chunk counters ([`OutputCounters`]);
17//! - a **UI-preview budget** enforced by the forwarding task at production
18//!   time via [`agent_core::BoundedText`] — the downstream (unbounded) UI
19//!   event queue can never receive more than the budget from one tool call;
20//! - **cancellation** that terminates the forwarding task and thereby
21//!   closes the channel, releasing producers: a send after close is a
22//!   counted drop, never a block.
23//!
24//! Policy choice (documented, deliberate): producers are NEVER blocked on
25//! the UI consumer. The delta lane is a preview lane — full fidelity for
26//! model history is owned by the tool's own bounded result buffer — so the
27//! bounded policy here is coalesce-then-drop with exact accounting rather
28//! than producer backpressure, which would let a stalled UI consumer stall
29//! tool execution and cancellation.
30//!
31//! Memory invariant: bytes retained in flight are bounded by
32//! `DELTA_CHANNEL_CAPACITY * DELTA_MAX_CHUNK_BYTES + DELTA_COALESCE_CAP_BYTES`
33//! per tool call, and always equal `produced - forwarded - dropped` in the
34//! counters (coalesced bytes are informational overlap, not a third bucket).
35
36use std::path::PathBuf;
37use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
38use std::sync::{Arc, Mutex};
39
40use tokio_util::sync::CancellationToken;
41
42/// Maximum chunks queued in the bounded delta channel.
43pub const DELTA_CHANNEL_CAPACITY: usize = 64;
44
45/// Per-chunk retention cap: a single produced chunk larger than this keeps
46/// only a UTF-8-safe prefix (the cut tail is a counted drop).
47pub const DELTA_MAX_CHUNK_BYTES: usize = 64 * 1024;
48
49/// Byte cap of the coalescing overflow buffer used while the channel is full.
50pub const DELTA_COALESCE_CAP_BYTES: usize = 64 * 1024;
51
52/// Default per-tool-call UI-preview byte budget enforced by the forwarder.
53/// Matches the historical `max_tool_buffer` bound (256 KiB) so bounded tool
54/// results remain visually identical.
55pub const DEFAULT_UI_PREVIEW_BYTES: usize = 256 * 1024;
56
57// ── Budgets ─────────────────────────────────────────────────────────────────
58
59/// Independent per-call output byte budgets (spec §8.4). The UI-preview
60/// lane (streamed deltas + the final `ToolResult` event) and the
61/// model-history lane (the `tool_result` block content) are bounded
62/// SEPARATELY: tightening one never widens or narrows the other. Both are
63/// enforced through [`agent_core::BoundedText`] at the point the bounded
64/// text is produced — never by materializing the full output first.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct OutputBudgets {
67    /// Total bytes one tool call may contribute to the UI event lane.
68    pub ui_preview_bytes: usize,
69    /// Total bytes one tool call's result may occupy in model history.
70    pub model_history_bytes: usize,
71}
72
73impl OutputBudgets {
74    /// Runtime defaults: the compiled UI-preview budget alongside the
75    /// configured model-history budget (`max_tool_output`).
76    pub fn for_limits(max_tool_output: usize) -> Self {
77        Self {
78            ui_preview_bytes: DEFAULT_UI_PREVIEW_BYTES,
79            model_history_bytes: max_tool_output,
80        }
81    }
82
83    /// Hard upper bound retained by the UI delta lane itself, independent
84    /// of the consumer and configured preview budget.
85    pub const fn max_ui_retained_bytes() -> u64 {
86        (DELTA_CHANNEL_CAPACITY * DELTA_MAX_CHUNK_BYTES + DELTA_COALESCE_CAP_BYTES) as u64
87    }
88}
89
90/// Model-history production result. `text` is always a UTF-8 prefix at most
91/// the configured model-history budget; the original is represented only by
92/// exact byte accounting, never retained in full.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct BoundedOutput {
95    pub text: String,
96    pub original_bytes: usize,
97    pub retained_bytes: usize,
98    pub truncated: bool,
99}
100
101/// Bound a final result string for the UI event lane, appending a
102/// metadata-only truncation marker when anything was cut. UTF-8-safe and
103/// greedy via [`agent_core::BoundedText`].
104pub fn bounded_preview(s: &str, max_bytes: usize) -> String {
105    let bounded = agent_core::BoundedText::new(s, max_bytes);
106    if !bounded.truncated {
107        return bounded.text;
108    }
109    format!(
110        "{}\n\n[preview truncated — {} of {} bytes shown]",
111        bounded.text, bounded.retained_bytes, bounded.original_bytes
112    )
113}
114
115// ── Spill artifact ──────────────────────────────────────────────────────────
116
117/// Opt-in spill policy: capture the FULL produced delta stream (before any
118/// cap/coalesce/drop decision) into a private (`0600`, T4 helpers) artifact
119/// under `dir`, so bounded previews never mean lost data.
120#[derive(Debug, Clone)]
121pub struct SpillPolicy {
122    pub dir: PathBuf,
123}
124
125/// Point-in-time spill outcome. Metadata only: path, byte count, failure.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct SpillReport {
128    pub path: PathBuf,
129    pub bytes: u64,
130    pub failed: bool,
131}
132
133/// Lazily opened private spill file. First write creates the directory
134/// (`0700`) and the file (`0600`, symlink-refusing, append-only). An I/O
135/// failure degrades ONCE (warn + `failed` flag) and disables further
136/// writes; it can never fail the tool call or block the stream.
137struct SpillState {
138    path: PathBuf,
139    dir: PathBuf,
140    file: Mutex<Option<std::fs::File>>,
141    opened: AtomicBool,
142    bytes: AtomicU64,
143    failed: AtomicBool,
144}
145
146impl std::fmt::Debug for SpillState {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.debug_struct("SpillState")
149            .field("path", &self.path)
150            .field("bytes", &self.bytes.load(Ordering::Relaxed))
151            .field("failed", &self.failed.load(Ordering::Relaxed))
152            .finish()
153    }
154}
155
156impl SpillState {
157    fn new(policy: SpillPolicy) -> Self {
158        static SPILL_SEQ: AtomicU64 = AtomicU64::new(0);
159        let seq = SPILL_SEQ.fetch_add(1, Ordering::Relaxed);
160        let path = policy
161            .dir
162            .join(format!("tool-output-{}-{seq}.log", std::process::id()));
163        Self {
164            path,
165            dir: policy.dir,
166            file: Mutex::new(None),
167            opened: AtomicBool::new(false),
168            bytes: AtomicU64::new(0),
169            failed: AtomicBool::new(false),
170        }
171    }
172
173    fn report(&self) -> SpillReport {
174        SpillReport {
175            path: self.path.clone(),
176            bytes: self.bytes.load(Ordering::Relaxed),
177            failed: self.failed.load(Ordering::Relaxed),
178        }
179    }
180
181    fn fail(&self, action: &str, err: &dyn std::fmt::Display) {
182        if !self.failed.swap(true, Ordering::Relaxed) {
183            // Metadata only: our own artifact path and error, never content.
184            tracing::warn!(
185                path = %self.path.display(),
186                error = %err,
187                "tool-output spill {action} failed — artifact disabled for this call"
188            );
189        }
190    }
191
192    fn write(&self, chunk: &str) {
193        if self.failed.load(Ordering::Relaxed) {
194            return;
195        }
196        let mut file = self
197            .file
198            .lock()
199            .unwrap_or_else(std::sync::PoisonError::into_inner);
200        if file.is_none() {
201            if self.opened.swap(true, Ordering::Relaxed) {
202                return; // a previous open failed
203            }
204            if let Err(err) = agent_core::core::private_fs::ensure_private_dir(&self.dir) {
205                self.fail("dir create", &err);
206                return;
207            }
208            match agent_core::core::private_fs::open_private_append(&self.path) {
209                Ok(handle) => *file = Some(handle),
210                Err(err) => {
211                    self.fail("open", &err);
212                    return;
213                }
214            }
215        }
216        if let Some(handle) = file.as_mut() {
217            use std::io::Write as _;
218            match handle.write_all(chunk.as_bytes()) {
219                Ok(()) => {
220                    self.bytes.fetch_add(chunk.len() as u64, Ordering::Relaxed);
221                }
222                Err(err) => {
223                    self.fail("write", &err);
224                    *file = None;
225                }
226            }
227        }
228    }
229}
230
231/// Cloneable read handle onto a call's spill artifact state.
232#[derive(Debug, Clone)]
233pub struct SpillHandle {
234    state: Arc<SpillState>,
235}
236
237impl SpillHandle {
238    pub fn report(&self) -> SpillReport {
239        self.state.report()
240    }
241}
242
243// ── Counters ────────────────────────────────────────────────────────────────
244
245/// Exact per-call output accounting. All methods are lock-free and cheap;
246/// metadata only (byte/chunk counts, never content).
247#[derive(Debug, Default)]
248pub struct OutputCounters {
249    produced_chunks: AtomicU64,
250    produced_bytes: AtomicU64,
251    forwarded_chunks: AtomicU64,
252    forwarded_bytes: AtomicU64,
253    coalesced_chunks: AtomicU64,
254    coalesced_bytes: AtomicU64,
255    dropped_chunks: AtomicU64,
256    dropped_bytes: AtomicU64,
257    model_history_truncated_chunks: AtomicU64,
258    model_history_dropped_bytes: AtomicU64,
259}
260
261/// Point-in-time copy of [`OutputCounters`].
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
263pub struct OutputCountersSnapshot {
264    /// Chunks/bytes handed to [`DeltaSender::send`] by the producer.
265    pub produced_chunks: u64,
266    pub produced_bytes: u64,
267    /// Chunks/bytes actually emitted to the UI consumer.
268    pub forwarded_chunks: u64,
269    pub forwarded_bytes: u64,
270    /// Chunks/bytes merged through the overflow buffer (still delivered
271    /// unless separately counted as dropped).
272    pub coalesced_chunks: u64,
273    pub coalesced_bytes: u64,
274    /// Chunks that lost at least one byte / bytes never delivered to the UI
275    /// (coalesce-cap cuts, per-chunk cap cuts, budget cuts, closed-channel
276    /// sends).
277    pub dropped_chunks: u64,
278    pub dropped_bytes: u64,
279    /// Model-history chunks that lost at least one byte and exact bytes cut
280    /// while producing the independently bounded history prefix.
281    pub model_history_truncated_chunks: u64,
282    pub model_history_dropped_bytes: u64,
283}
284
285impl OutputCountersSnapshot {
286    /// Bytes currently retained in flight (channel + overflow buffer):
287    /// `produced - forwarded - dropped`.
288    pub fn retained_bytes(&self) -> u64 {
289        self.produced_bytes
290            .saturating_sub(self.forwarded_bytes)
291            .saturating_sub(self.dropped_bytes)
292    }
293}
294
295impl OutputCounters {
296    pub fn note_produced(&self, bytes: usize) {
297        self.produced_chunks.fetch_add(1, Ordering::Relaxed);
298        self.produced_bytes
299            .fetch_add(bytes as u64, Ordering::Relaxed);
300    }
301
302    pub fn note_forwarded(&self, bytes: usize) {
303        self.forwarded_chunks.fetch_add(1, Ordering::Relaxed);
304        self.forwarded_bytes
305            .fetch_add(bytes as u64, Ordering::Relaxed);
306    }
307
308    pub fn note_coalesced(&self, bytes: usize) {
309        self.coalesced_chunks.fetch_add(1, Ordering::Relaxed);
310        self.coalesced_bytes
311            .fetch_add(bytes as u64, Ordering::Relaxed);
312    }
313
314    pub fn note_dropped(&self, bytes: usize, chunks: u64) {
315        self.dropped_chunks.fetch_add(chunks, Ordering::Relaxed);
316        self.dropped_bytes
317            .fetch_add(bytes as u64, Ordering::Relaxed);
318    }
319
320    fn note_model_history_truncated(&self, bytes: usize) {
321        self.model_history_truncated_chunks
322            .fetch_add(1, Ordering::Relaxed);
323        self.model_history_dropped_bytes
324            .fetch_add(bytes as u64, Ordering::Relaxed);
325    }
326
327    pub fn snapshot(&self) -> OutputCountersSnapshot {
328        OutputCountersSnapshot {
329            produced_chunks: self.produced_chunks.load(Ordering::Relaxed),
330            produced_bytes: self.produced_bytes.load(Ordering::Relaxed),
331            forwarded_chunks: self.forwarded_chunks.load(Ordering::Relaxed),
332            forwarded_bytes: self.forwarded_bytes.load(Ordering::Relaxed),
333            coalesced_chunks: self.coalesced_chunks.load(Ordering::Relaxed),
334            coalesced_bytes: self.coalesced_bytes.load(Ordering::Relaxed),
335            dropped_chunks: self.dropped_chunks.load(Ordering::Relaxed),
336            dropped_bytes: self.dropped_bytes.load(Ordering::Relaxed),
337            model_history_truncated_chunks: self
338                .model_history_truncated_chunks
339                .load(Ordering::Relaxed),
340            model_history_dropped_bytes: self.model_history_dropped_bytes.load(Ordering::Relaxed),
341        }
342    }
343}
344
345// ── Channel ─────────────────────────────────────────────────────────────────
346
347struct DeltaShared {
348    overflow: Mutex<String>,
349    notify: tokio::sync::Notify,
350    counters: Arc<OutputCounters>,
351    spill: Option<Arc<SpillState>>,
352    model_history: Mutex<ModelHistoryState>,
353}
354
355#[derive(Debug)]
356struct ModelHistoryState {
357    text: String,
358    original_bytes: usize,
359    budget: usize,
360    truncated: bool,
361}
362
363impl ModelHistoryState {
364    fn new(budget: usize) -> Self {
365        Self {
366            text: String::with_capacity(budget.min(64 * 1024)),
367            original_bytes: 0,
368            budget,
369            truncated: false,
370        }
371    }
372
373    fn push(&mut self, chunk: &str, counters: &OutputCounters) {
374        self.original_bytes = self.original_bytes.saturating_add(chunk.len());
375        let room = self.budget.saturating_sub(self.text.len());
376        let bounded = agent_core::BoundedText::new(chunk, room);
377        self.text.push_str(&bounded.text);
378        if bounded.truncated {
379            self.truncated = true;
380            counters.note_model_history_truncated(
381                bounded
382                    .original_bytes
383                    .saturating_sub(bounded.retained_bytes),
384            );
385        }
386    }
387
388    fn snapshot(&self) -> BoundedOutput {
389        BoundedOutput {
390            text: self.text.clone(),
391            original_bytes: self.original_bytes,
392            retained_bytes: self.text.len(),
393            truncated: self.truncated,
394        }
395    }
396}
397
398/// Producer half handed to tools as `ToolChannels::tx_delta`. `send` never
399/// blocks and never fails: full channels coalesce, closed channels drop —
400/// both exactly counted.
401pub struct DeltaSender {
402    tx: tokio::sync::mpsc::Sender<String>,
403    shared: Arc<DeltaShared>,
404}
405
406/// Consumer half: drains the bounded channel and the coalescing overflow
407/// buffer in production order.
408pub struct DeltaReceiver {
409    rx: tokio::sync::mpsc::Receiver<String>,
410    shared: Arc<DeltaShared>,
411}
412
413/// Cloneable observation handle for one tool call's independently bounded
414/// outputs and exact counters.
415#[derive(Clone)]
416pub struct OutputHandle {
417    shared: Arc<DeltaShared>,
418}
419
420impl OutputHandle {
421    pub fn counters(&self) -> Arc<OutputCounters> {
422        Arc::clone(&self.shared.counters)
423    }
424
425    pub fn model_history(&self) -> BoundedOutput {
426        self.shared
427            .model_history
428            .lock()
429            .unwrap_or_else(std::sync::PoisonError::into_inner)
430            .snapshot()
431    }
432
433    pub fn spill_report(&self) -> Option<SpillReport> {
434        self.shared.spill.as_ref().map(|state| state.report())
435    }
436}
437
438/// One bounded delta channel pair plus its shared output handle.
439pub struct ToolDeltaChannel {
440    pub sender: DeltaSender,
441    pub receiver: DeltaReceiver,
442    output: OutputHandle,
443}
444
445impl ToolDeltaChannel {
446    pub fn output_handle(&self) -> OutputHandle {
447        self.output.clone()
448    }
449}
450
451/// Construct a bounded delta channel with fresh counters and default budgets.
452pub fn delta_channel() -> ToolDeltaChannel {
453    delta_channel_with_budgets(OutputBudgets::for_limits(DEFAULT_UI_PREVIEW_BYTES), None)
454}
455
456/// Backward-compatible constructor for callers selecting only spill policy.
457pub fn delta_channel_with(spill: Option<SpillPolicy>) -> ToolDeltaChannel {
458    delta_channel_with_budgets(OutputBudgets::for_limits(DEFAULT_UI_PREVIEW_BYTES), spill)
459}
460
461/// Construct one output producer with independent UI/model-history budgets.
462pub fn delta_channel_with_budgets(
463    budgets: OutputBudgets,
464    spill: Option<SpillPolicy>,
465) -> ToolDeltaChannel {
466    let (tx, rx) = tokio::sync::mpsc::channel(DELTA_CHANNEL_CAPACITY);
467    let shared = Arc::new(DeltaShared {
468        overflow: Mutex::new(String::new()),
469        notify: tokio::sync::Notify::new(),
470        counters: Arc::new(OutputCounters::default()),
471        spill: spill.map(|policy| Arc::new(SpillState::new(policy))),
472        model_history: Mutex::new(ModelHistoryState::new(budgets.model_history_bytes)),
473    });
474    ToolDeltaChannel {
475        sender: DeltaSender {
476            tx,
477            shared: Arc::clone(&shared),
478        },
479        receiver: DeltaReceiver {
480            rx,
481            shared: Arc::clone(&shared),
482        },
483        output: OutputHandle { shared },
484    }
485}
486
487/// Append `chunk` to the byte-capped overflow buffer at a valid UTF-8
488/// boundary; the cut remainder is a counted drop.
489fn coalesce_into(overflow: &mut String, chunk: &str, counters: &OutputCounters) {
490    let room = DELTA_COALESCE_CAP_BYTES.saturating_sub(overflow.len());
491    let bounded = agent_core::BoundedText::new(chunk, room);
492    if bounded.retained_bytes > 0 {
493        overflow.push_str(&bounded.text);
494        counters.note_coalesced(bounded.retained_bytes);
495    }
496    if bounded.truncated {
497        counters.note_dropped(bounded.original_bytes - bounded.retained_bytes, 1);
498    }
499}
500
501impl DeltaSender {
502    /// Shared counters handle (also visible from the receiver/forwarder).
503    pub fn counters(&self) -> Arc<OutputCounters> {
504        Arc::clone(&self.shared.counters)
505    }
506
507    /// Read handle onto this call's spill artifact, when policy enabled one.
508    pub fn spill_handle(&self) -> Option<SpillHandle> {
509        self.shared.spill.as_ref().map(|state| SpillHandle {
510            state: Arc::clone(state),
511        })
512    }
513
514    /// Non-blocking bounded send. Policy, in order:
515    /// 1. account production (and spill the FULL chunk when enabled —
516    ///    before any cap/coalesce/drop decision);
517    /// 2. cap the retained chunk at [`DELTA_MAX_CHUNK_BYTES`] (UTF-8-safe);
518    /// 3. if the overflow buffer is non-empty, coalesce there (preserves
519    ///    production order — channel items are always older than overflow
520    ///    content);
521    /// 4. else try the bounded channel; on Full coalesce, on Closed count a
522    ///    drop and return immediately (the producer is RELEASED, never
523    ///    blocked, once the consumer is gone).
524    pub fn send(&self, chunk: String) {
525        let counters = &self.shared.counters;
526        counters.note_produced(chunk.len());
527        if chunk.is_empty() {
528            return;
529        }
530        if let Some(spill) = &self.shared.spill {
531            spill.write(&chunk);
532        }
533        self.shared
534            .model_history
535            .lock()
536            .unwrap_or_else(std::sync::PoisonError::into_inner)
537            .push(&chunk, counters);
538        let chunk = if chunk.len() > DELTA_MAX_CHUNK_BYTES {
539            let bounded = agent_core::BoundedText::new(&chunk, DELTA_MAX_CHUNK_BYTES);
540            counters.note_dropped(bounded.original_bytes - bounded.retained_bytes, 1);
541            bounded.text
542        } else {
543            chunk
544        };
545        {
546            let mut overflow = self
547                .shared
548                .overflow
549                .lock()
550                .unwrap_or_else(std::sync::PoisonError::into_inner);
551            if !overflow.is_empty() {
552                coalesce_into(&mut overflow, &chunk, counters);
553                drop(overflow);
554                self.shared.notify.notify_one();
555                return;
556            }
557        }
558        match self.tx.try_send(chunk) {
559            Ok(()) => {}
560            Err(tokio::sync::mpsc::error::TrySendError::Full(chunk)) => {
561                let mut overflow = self
562                    .shared
563                    .overflow
564                    .lock()
565                    .unwrap_or_else(std::sync::PoisonError::into_inner);
566                coalesce_into(&mut overflow, &chunk, counters);
567                drop(overflow);
568                self.shared.notify.notify_one();
569            }
570            Err(tokio::sync::mpsc::error::TrySendError::Closed(chunk)) => {
571                counters.note_dropped(chunk.len(), 1);
572            }
573        }
574    }
575}
576
577impl DeltaReceiver {
578    pub fn counters(&self) -> Arc<OutputCounters> {
579        Arc::clone(&self.shared.counters)
580    }
581
582    fn drain_overflow(&self) -> Option<String> {
583        let mut overflow = self
584            .shared
585            .overflow
586            .lock()
587            .unwrap_or_else(std::sync::PoisonError::into_inner);
588        if overflow.is_empty() {
589            None
590        } else {
591            Some(std::mem::take(&mut *overflow))
592        }
593    }
594
595    /// Non-blocking drain of everything currently pending, coalesced into
596    /// one batch in production order (channel first, then overflow).
597    pub fn try_drain(&mut self) -> Option<String> {
598        let mut batch = String::new();
599        while let Ok(chunk) = self.rx.try_recv() {
600            batch.push_str(&chunk);
601        }
602        if let Some(overflow) = self.drain_overflow() {
603            batch.push_str(&overflow);
604        }
605        if batch.is_empty() {
606            None
607        } else {
608            Some(batch)
609        }
610    }
611
612    /// Await the next coalesced batch; `None` once every sender is dropped
613    /// and all pending content (including overflow) is drained.
614    pub async fn recv(&mut self) -> Option<String> {
615        loop {
616            if let Some(batch) = self.try_drain() {
617                return Some(batch);
618            }
619            tokio::select! {
620                maybe = self.rx.recv() => match maybe {
621                    Some(first) => {
622                        let mut batch = first;
623                        while let Ok(chunk) = self.rx.try_recv() {
624                            batch.push_str(&chunk);
625                        }
626                        if let Some(overflow) = self.drain_overflow() {
627                            batch.push_str(&overflow);
628                        }
629                        return Some(batch);
630                    }
631                    None => return self.drain_overflow(),
632                },
633                _ = self.shared.notify.notified() => continue,
634            }
635        }
636    }
637}
638
639// ── UI forwarder ────────────────────────────────────────────────────────────
640
641/// Live UI forwarding tasks — a metadata-only diagnostic gauge the leak
642/// harness asserts against after cancellation.
643static ACTIVE_UI_FORWARDERS: AtomicUsize = AtomicUsize::new(0);
644
645/// Number of UI forwarding tasks currently alive in this process.
646pub fn active_ui_forwarder_count() -> usize {
647    ACTIVE_UI_FORWARDERS.load(Ordering::SeqCst)
648}
649
650/// Spawn the forwarding task for one tool call: drains the bounded channel,
651/// enforces the UI-preview byte budget at production time (UTF-8-safe via
652/// `BoundedText`), and terminates on cancellation or channel close. On
653/// termination the receiver drops, closing the channel and RELEASING every
654/// producer (their sends become counted drops).
655pub fn spawn_ui_forwarder(
656    mut receiver: DeltaReceiver,
657    ui_budget_bytes: usize,
658    cancel: CancellationToken,
659    emit: impl Fn(String) + Send + 'static,
660) -> tokio::task::JoinHandle<()> {
661    ACTIVE_UI_FORWARDERS.fetch_add(1, Ordering::SeqCst);
662    tokio::spawn(async move {
663        /// Decrements the gauge on every exit path (return, cancel, panic).
664        struct Gauge;
665        impl Drop for Gauge {
666            fn drop(&mut self) {
667                ACTIVE_UI_FORWARDERS.fetch_sub(1, Ordering::SeqCst);
668            }
669        }
670        let _gauge = Gauge;
671        let counters = receiver.counters();
672        let mut forwarded_total: usize = 0;
673        loop {
674            tokio::select! {
675                biased;
676                _ = cancel.cancelled() => break,
677                maybe = receiver.recv() => match maybe {
678                    None => break,
679                    Some(batch) => {
680                        let room = ui_budget_bytes.saturating_sub(forwarded_total);
681                        let bounded = agent_core::BoundedText::new(&batch, room);
682                        if bounded.retained_bytes > 0 {
683                            forwarded_total += bounded.retained_bytes;
684                            counters.note_forwarded(bounded.retained_bytes);
685                            emit(bounded.text);
686                        }
687                        if bounded.truncated {
688                            counters.note_dropped(
689                                bounded.original_bytes - bounded.retained_bytes,
690                                1,
691                            );
692                        }
693                    }
694                },
695            }
696        }
697        // Cancellation may win while bytes remain in the bounded queue.
698        // Receiver drop releases the producer; account every such byte as a
699        // cancellation drop before the task exits.
700        let retained = counters.snapshot().retained_bytes();
701        if retained > 0 {
702            counters.note_dropped(retained as usize, 1);
703        }
704        // Receiver drops here: the channel closes and producers are
705        // released — a post-cancel send is a counted drop, never a block.
706    })
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use std::time::Duration;
713
714    /// A fast producer with NO consumer keeps retained bytes bounded by the
715    /// channel + coalesce caps; everything beyond is a counted drop, and the
716    /// accounting is exact (`produced == retained + forwarded + dropped`).
717    #[tokio::test]
718    async fn burst_without_consumer_bounds_retained_bytes_exactly() {
719        let channel = delta_channel();
720        let sender = channel.sender;
721        let counters = sender.counters();
722        let chunk = "x".repeat(1024);
723        for _ in 0..10_000 {
724            sender.send(chunk.clone());
725        }
726        let snap = counters.snapshot();
727        assert_eq!(snap.produced_bytes, 10_000 * 1024);
728        assert_eq!(snap.produced_chunks, 10_000);
729        assert_eq!(snap.forwarded_bytes, 0);
730        let ceiling = (DELTA_CHANNEL_CAPACITY * 1024 + DELTA_COALESCE_CAP_BYTES) as u64;
731        assert!(
732            snap.retained_bytes() <= ceiling,
733            "retained {} exceeds ceiling {ceiling}",
734            snap.retained_bytes()
735        );
736        assert!(snap.dropped_bytes > 0, "beyond-cap bytes must be dropped");
737        assert!(snap.coalesced_chunks > 0, "full channel must coalesce");
738        assert_eq!(
739            snap.produced_bytes,
740            snap.retained_bytes() + snap.forwarded_bytes + snap.dropped_bytes,
741            "accounting must be exact"
742        );
743    }
744
745    /// Whatever survives the coalesce/drop policy is a PREFIX of the
746    /// produced stream: order is preserved and nothing is reordered across
747    /// the channel/overflow split.
748    #[tokio::test]
749    async fn drained_output_is_an_ordered_prefix_of_production() {
750        let mut channel = delta_channel();
751        let mut full = String::new();
752        for i in 0..2_000 {
753            let chunk = format!("[chunk-{i:05}]");
754            full.push_str(&chunk);
755            channel.sender.send(chunk);
756        }
757        let mut received = String::new();
758        while let Some(batch) = channel.receiver.try_drain() {
759            received.push_str(&batch);
760        }
761        assert!(
762            full.starts_with(&received),
763            "received content must be an ordered prefix of production"
764        );
765        let snap = channel.sender.counters().snapshot();
766        assert_eq!(
767            received.len() as u64,
768            snap.produced_bytes - snap.dropped_bytes,
769            "drained bytes must equal produced minus dropped"
770        );
771    }
772
773    /// An oversized single chunk retains only a bounded UTF-8-safe prefix.
774    #[tokio::test]
775    async fn oversized_chunk_is_capped_at_production() {
776        let mut channel = delta_channel();
777        // Multibyte char straddling the cap: BoundedText must cut safely.
778        let big = "é".repeat(DELTA_MAX_CHUNK_BYTES); // 2 bytes each => 2x cap
779        channel.sender.send(big.clone());
780        let batch = channel.receiver.try_drain().expect("prefix retained");
781        assert!(batch.len() <= DELTA_MAX_CHUNK_BYTES);
782        assert!(big.starts_with(&batch));
783        let snap = channel.sender.counters().snapshot();
784        assert_eq!(snap.dropped_bytes, (big.len() - batch.len()) as u64);
785    }
786
787    /// A dropped receiver RELEASES the producer: sends return immediately
788    /// as counted drops.
789    #[tokio::test]
790    async fn closed_channel_releases_producer_with_counted_drops() {
791        let channel = delta_channel();
792        drop(channel.receiver);
793        let counters = channel.sender.counters();
794        channel.sender.send("after close".to_string());
795        let snap = counters.snapshot();
796        assert_eq!(snap.dropped_bytes, "after close".len() as u64);
797        assert_eq!(snap.dropped_chunks, 1);
798        assert_eq!(snap.retained_bytes(), 0);
799    }
800
801    /// The forwarder enforces the UI budget (UTF-8-safe), counts the cut as
802    /// dropped, and terminates when the producer hangs up.
803    #[tokio::test]
804    async fn forwarder_enforces_ui_budget_and_ends_on_close() {
805        let channel = delta_channel();
806        let (out_tx, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
807        let handle = spawn_ui_forwarder(
808            channel.receiver,
809            10,
810            CancellationToken::new(),
811            move |batch| {
812                let _ = out_tx.send(batch);
813            },
814        );
815        channel.sender.send("hello ".to_string());
816        // Give the forwarder time to drain the first batch so the budget
817        // boundary is exercised across separate emissions too.
818        tokio::time::sleep(Duration::from_millis(50)).await;
819        channel.sender.send("world!!".to_string());
820        drop(channel.sender);
821        tokio::time::timeout(Duration::from_secs(5), handle)
822            .await
823            .expect("forwarder must terminate on close")
824            .expect("forwarder must not panic");
825        let mut emitted = String::new();
826        while let Ok(batch) = out_rx.try_recv() {
827            emitted.push_str(&batch);
828        }
829        assert_eq!(emitted.len(), 10, "UI budget must be exact");
830        assert!("hello world!!".starts_with(&emitted));
831    }
832
833    /// Cancellation terminates the forwarding task (gauge returns to its
834    /// baseline) and releases the producer (post-cancel sends are counted
835    /// drops on a closed channel).
836    #[tokio::test]
837    async fn cancellation_closes_forwarder_and_releases_producer() {
838        let baseline = active_ui_forwarder_count();
839        let channel = delta_channel();
840        let cancel = CancellationToken::new();
841        let handle = spawn_ui_forwarder(channel.receiver, usize::MAX, cancel.clone(), |_| {});
842        channel.sender.send("before cancel".to_string());
843        cancel.cancel();
844        tokio::time::timeout(Duration::from_secs(5), handle)
845            .await
846            .expect("forwarder must terminate on cancellation")
847            .expect("forwarder must not panic");
848        // Gauge returns to baseline (poll: other tests may run in parallel).
849        let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
850        while active_ui_forwarder_count() > baseline && tokio::time::Instant::now() < deadline {
851            tokio::time::sleep(Duration::from_millis(10)).await;
852        }
853        assert!(
854            active_ui_forwarder_count() <= baseline,
855            "no forwarding task may remain alive after cancellation"
856        );
857        let counters = channel.sender.counters();
858        let before = counters.snapshot();
859        assert_eq!(
860            before.retained_bytes(),
861            0,
862            "cancellation must account every queued/coalesced byte as dropped"
863        );
864        channel.sender.send("after cancel".to_string());
865        let after = counters.snapshot();
866        assert_eq!(
867            after.dropped_bytes - before.dropped_bytes,
868            "after cancel".len() as u64,
869            "post-cancel sends must be released as counted drops"
870        );
871    }
872
873    /// UI and model-history are separate production-time lanes: changing
874    /// either budget cannot change the other lane's exact retained prefix.
875    #[tokio::test]
876    async fn ui_and_model_history_budgets_are_independent() {
877        let budgets = OutputBudgets {
878            ui_preview_bytes: 7,
879            model_history_bytes: 11,
880        };
881        let channel = delta_channel_with_budgets(budgets, None);
882        let output = channel.output_handle();
883        let (ui_tx, mut ui_rx) = tokio::sync::mpsc::unbounded_channel();
884        let forwarder = spawn_ui_forwarder(
885            channel.receiver,
886            budgets.ui_preview_bytes,
887            CancellationToken::new(),
888            move |chunk| {
889                let _ = ui_tx.send(chunk);
890            },
891        );
892        channel.sender.send("abcdefgh".to_string());
893        channel.sender.send("ijklmnop".to_string());
894        drop(channel.sender);
895        forwarder.await.expect("forwarder");
896
897        let mut ui = String::new();
898        while let Ok(chunk) = ui_rx.try_recv() {
899            ui.push_str(&chunk);
900        }
901        let history = output.model_history();
902        assert_eq!(ui, "abcdefg");
903        assert_eq!(history.text, "abcdefghijk");
904        assert!(history.truncated);
905        assert_eq!(history.original_bytes, 16);
906        assert_eq!(history.retained_bytes, 11);
907    }
908
909    /// Honest memory oracle: this exercises the production bounded output
910    /// channel and asserts its exact retained-byte ceiling. It does NOT claim
911    /// process RSS; allocator/runtime overhead is outside this deterministic
912    /// in-process test. The generator reuses one 64 KiB chunk and never
913    /// creates a 1 GiB file or String.
914    #[tokio::test]
915    async fn synthetic_one_gib_production_is_bounded_without_materializing_full_output() {
916        const GIB: u64 = 1024 * 1024 * 1024;
917        const CHUNK: usize = 64 * 1024;
918        let budgets = OutputBudgets {
919            ui_preview_bytes: 1024,
920            model_history_bytes: 4096,
921        };
922        let channel = delta_channel_with_budgets(budgets, None);
923        let output = channel.output_handle();
924        let chunk = "x".repeat(CHUNK);
925        for _ in 0..(GIB / CHUNK as u64) {
926            channel.sender.send(chunk.clone());
927        }
928        let history = output.model_history();
929        let snap = output.counters().snapshot();
930        assert_eq!(history.original_bytes as u64, GIB);
931        assert_eq!(history.retained_bytes, budgets.model_history_bytes);
932        assert_eq!(snap.model_history_dropped_bytes, GIB - 4096);
933        assert_eq!(snap.model_history_truncated_chunks, GIB / CHUNK as u64);
934        assert!(snap.retained_bytes() <= OutputBudgets::max_ui_retained_bytes());
935    }
936
937    #[cfg(unix)]
938    #[test]
939    #[serial_test::serial(umask)]
940    fn optional_spill_is_0600_under_umask_000() {
941        use std::os::unix::fs::PermissionsExt as _;
942
943        struct Umask(libc::mode_t);
944        impl Drop for Umask {
945            fn drop(&mut self) {
946                unsafe {
947                    libc::umask(self.0);
948                }
949            }
950        }
951        let guard = Umask(unsafe { libc::umask(0) });
952        let tmp = tempfile::TempDir::new().unwrap();
953        let channel = delta_channel_with_budgets(
954            OutputBudgets::for_limits(8),
955            Some(SpillPolicy {
956                dir: tmp.path().join("spill"),
957            }),
958        );
959        let output = channel.output_handle();
960        channel.sender.send("secret".into());
961        let report = output.spill_report().unwrap();
962        assert!(!report.failed);
963        assert_eq!(
964            std::fs::metadata(&report.path)
965                .unwrap()
966                .permissions()
967                .mode()
968                & 0o777,
969            0o600
970        );
971        assert_eq!(
972            std::fs::metadata(report.path.parent().unwrap())
973                .unwrap()
974                .permissions()
975                .mode()
976                & 0o777,
977            0o700
978        );
979        drop(guard);
980    }
981
982    #[cfg(unix)]
983    #[test]
984    fn optional_spill_refuses_planted_symlink_without_writing_victim() {
985        use std::os::unix::fs::symlink;
986
987        let tmp = tempfile::TempDir::new().unwrap();
988        let dir = tmp.path().join("spill");
989        std::fs::create_dir(&dir).unwrap();
990        let victim = tmp.path().join("victim");
991        std::fs::write(&victim, "unchanged").unwrap();
992        let channel =
993            delta_channel_with_budgets(OutputBudgets::for_limits(8), Some(SpillPolicy { dir }));
994        let output = channel.output_handle();
995        let target = output.spill_report().unwrap().path;
996        symlink(&victim, &target).unwrap();
997        channel.sender.send("must-not-write".into());
998        let report = output.spill_report().unwrap();
999        assert!(report.failed);
1000        assert_eq!(report.bytes, 0);
1001        assert_eq!(std::fs::read_to_string(victim).unwrap(), "unchanged");
1002    }
1003
1004    /// Spill is opt-in, captures the exact full production stream before
1005    /// truncation/drop, and T4 creates both directory and artifact privately.
1006    #[cfg(unix)]
1007    #[tokio::test]
1008    async fn optional_spill_is_exact_and_private_0600() {
1009        use std::os::unix::fs::PermissionsExt as _;
1010
1011        let tmp = tempfile::TempDir::new().expect("tempdir");
1012        let dir = tmp.path().join("spill");
1013        let channel = delta_channel_with_budgets(
1014            OutputBudgets {
1015                ui_preview_bytes: 2,
1016                model_history_bytes: 3,
1017            },
1018            Some(SpillPolicy { dir: dir.clone() }),
1019        );
1020        let output = channel.output_handle();
1021        channel.sender.send("hello".to_string());
1022        channel.sender.send("🌟world".to_string());
1023        let report = output.spill_report().expect("spill enabled");
1024
1025        assert!(!report.failed);
1026        assert_eq!(report.bytes, "hello🌟world".len() as u64);
1027        assert_eq!(
1028            std::fs::read_to_string(&report.path).unwrap(),
1029            "hello🌟world"
1030        );
1031        assert_eq!(
1032            std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777,
1033            0o700
1034        );
1035        assert_eq!(
1036            std::fs::metadata(&report.path)
1037                .unwrap()
1038                .permissions()
1039                .mode()
1040                & 0o777,
1041            0o600
1042        );
1043    }
1044}