1use std::collections::{HashMap, VecDeque};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::time::Duration;
22
23use tokio::sync::mpsc;
24use zeph_sanitizer::pii::PiiFilter;
25use zeph_sanitizer::secret_mask::SecretMaskRegistry;
26use zeph_sanitizer::{ContentSanitizer, ContentSource, ContentSourceKind};
27
28use crate::state::SubAgentState;
29
30const FORWARD_CHANNEL_CAPACITY: usize = 128;
33
34const FORWARD_RING_CAPACITY: usize = 200;
36
37const FORWARD_BUFFER_GRACE: Duration = Duration::from_secs(5);
40
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub struct ForwardSurfaces {
49 pub tui: bool,
51 pub bare: bool,
53}
54
55impl ForwardSurfaces {
56 #[must_use]
58 pub fn any(self) -> bool {
59 self.tui || self.bare
60 }
61}
62
63#[derive(Debug, Clone)]
67pub(crate) struct RawChunk {
68 task_id: Arc<str>,
69 def_name: Arc<str>,
70 seq: u64,
71 kind: ForwardChunkKind,
72}
73
74#[derive(Debug, Clone)]
77#[non_exhaustive]
78pub(crate) enum ForwardChunkKind {
79 Text(String),
81 Thinking(String),
83 Terminal(SubAgentState),
86}
87
88#[derive(Debug, Clone)]
97pub(crate) struct SanitizedChunk {
98 pub(crate) task_id: Arc<str>,
100 pub(crate) def_name: Arc<str>,
102 pub(crate) seq: u64,
104 pub(crate) kind: SanitizedChunkKind,
106}
107
108#[derive(Debug, Clone)]
110#[non_exhaustive]
111pub(crate) enum SanitizedChunkKind {
112 Text(String),
114 Thinking(String),
116 Terminal(SubAgentState),
118}
119
120pub(crate) struct SanitizeLayers {
131 pub(crate) sanitizer: ContentSanitizer,
132 pub(crate) secret_registry: Option<Arc<SecretMaskRegistry>>,
133 pub(crate) pii_filter: Option<PiiFilter>,
134}
135
136fn sanitize_text(raw_text: &str, def_name: &str, layers: &SanitizeLayers) -> String {
137 let source = ContentSource::new(ContentSourceKind::ToolResult).with_identifier(def_name);
138 let mut body = layers.sanitizer.sanitize(raw_text, source).body;
139 if let Some(registry) = &layers.secret_registry {
140 body = registry.mask(&body);
141 }
142 if let Some(filter) = &layers.pii_filter {
143 body = filter.scrub(&body).into_owned();
144 }
145 body
146}
147
148fn sanitize_chunk(raw: RawChunk, layers: &SanitizeLayers) -> SanitizedChunk {
149 let kind = match raw.kind {
150 ForwardChunkKind::Text(text) => {
151 SanitizedChunkKind::Text(sanitize_text(&text, raw.def_name.as_ref(), layers))
152 }
153 ForwardChunkKind::Thinking(text) => {
154 SanitizedChunkKind::Thinking(sanitize_text(&text, raw.def_name.as_ref(), layers))
155 }
156 ForwardChunkKind::Terminal(state) => SanitizedChunkKind::Terminal(state),
157 };
158 SanitizedChunk {
159 task_id: raw.task_id,
160 def_name: raw.def_name,
161 seq: raw.seq,
162 kind,
163 }
164}
165
166pub(crate) struct ForwardSender {
175 tx: mpsc::Sender<RawChunk>,
176 task_id: Arc<str>,
177 def_name: Arc<str>,
178 seq: AtomicU64,
179 dropped: AtomicU64,
180}
181
182impl ForwardSender {
183 pub(crate) fn new(tx: mpsc::Sender<RawChunk>, task_id: Arc<str>, def_name: Arc<str>) -> Self {
184 Self {
185 tx,
186 task_id,
187 def_name,
188 seq: AtomicU64::new(0),
189 dropped: AtomicU64::new(0),
190 }
191 }
192
193 fn try_send(&self, kind: ForwardChunkKind) {
194 let seq = self.seq.fetch_add(1, Ordering::Relaxed);
195 let chunk = RawChunk {
196 task_id: Arc::clone(&self.task_id),
197 def_name: Arc::clone(&self.def_name),
198 seq,
199 kind,
200 };
201 if self.tx.try_send(chunk).is_ok() {
202 tracing::debug!(
203 task_id = %self.task_id,
204 seq,
205 "subagent.forward.emit"
206 );
207 } else {
208 let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
209 tracing::warn!(
210 task_id = %self.task_id,
211 seq,
212 dropped,
213 "subagent.forward.drop: ingress channel full, chunk dropped"
214 );
215 }
216 }
217
218 pub(crate) fn send_text(&self, text: &str) {
222 if text.is_empty() {
223 return;
224 }
225 self.try_send(ForwardChunkKind::Text(text.to_owned()));
226 }
227
228 pub(crate) fn send_thinking(&self, text: &str) {
231 if text.is_empty() {
232 return;
233 }
234 self.try_send(ForwardChunkKind::Thinking(text.to_owned()));
235 }
236
237 pub(crate) fn send_terminal(&self, state: SubAgentState) {
240 tracing::debug!(task_id = %self.task_id, ?state, "subagent.forward.terminal");
241 self.try_send(ForwardChunkKind::Terminal(state));
242 }
243}
244
245pub(crate) type ForwardBuffer = std::sync::Mutex<HashMap<String, VecDeque<String>>>;
246
247fn display_line(kind: &SanitizedChunkKind) -> Option<String> {
250 match kind {
251 SanitizedChunkKind::Text(t) => Some(t.clone()),
252 SanitizedChunkKind::Thinking(t) => Some(format!("[thinking] {t}")),
253 SanitizedChunkKind::Terminal(_) => None,
254 }
255}
256
257fn state_str(state: SubAgentState) -> &'static str {
258 match state {
259 SubAgentState::Submitted => "submitted",
260 SubAgentState::Working => "working",
261 SubAgentState::Completed => "completed",
262 SubAgentState::Failed => "failed",
263 SubAgentState::Canceled => "canceled",
264 }
265}
266
267fn emit_bare_line(chunk: &SanitizedChunk) {
271 #[derive(serde::Serialize)]
272 struct BareForwardEvent<'a> {
273 task_id: &'a str,
274 def_name: &'a str,
275 seq: u64,
276 kind: &'static str,
277 #[serde(skip_serializing_if = "Option::is_none")]
278 content: Option<&'a str>,
279 #[serde(skip_serializing_if = "Option::is_none")]
280 state: Option<&'static str>,
281 }
282
283 let (kind, content, state) = match &chunk.kind {
284 SanitizedChunkKind::Text(t) => ("text", Some(t.as_str()), None),
285 SanitizedChunkKind::Thinking(t) => ("thinking", Some(t.as_str()), None),
286 SanitizedChunkKind::Terminal(s) => ("terminal", None, Some(state_str(*s))),
287 };
288 let event = BareForwardEvent {
289 task_id: &chunk.task_id,
290 def_name: &chunk.def_name,
291 seq: chunk.seq,
292 kind,
293 content,
294 state,
295 };
296 if let Ok(line) = serde_json::to_string(&event) {
297 println!("{line}");
298 }
299}
300
301fn dispatch_chunk(chunk: &SanitizedChunk, surfaces: ForwardSurfaces, buffer: &ForwardBuffer) {
303 if surfaces.tui
304 && let Some(line) = display_line(&chunk.kind)
305 {
306 let mut guard = buffer
307 .lock()
308 .unwrap_or_else(std::sync::PoisonError::into_inner);
309 let ring = guard.entry(chunk.task_id.to_string()).or_default();
310 ring.push_back(line);
311 while ring.len() > FORWARD_RING_CAPACITY {
312 ring.pop_front();
313 }
314 }
315 if surfaces.bare {
316 emit_bare_line(chunk);
317 }
318}
319
320pub(crate) fn new_channel(
322 task_id: Arc<str>,
323 def_name: Arc<str>,
324) -> (ForwardSender, mpsc::Receiver<RawChunk>) {
325 let (tx, rx) = mpsc::channel(FORWARD_CHANNEL_CAPACITY);
326 (ForwardSender::new(tx, task_id, def_name), rx)
327}
328
329pub(crate) async fn run_forward_drain(
350 task_id: Arc<str>,
351 def_name: Arc<str>,
352 rx: mpsc::Receiver<RawChunk>,
353 layers: SanitizeLayers,
354 surfaces: ForwardSurfaces,
355 buffer: Arc<ForwardBuffer>,
356) {
357 run_forward_drain_with(
358 task_id,
359 def_name,
360 rx,
361 layers,
362 surfaces,
363 buffer,
364 dispatch_chunk,
365 )
366 .await;
367}
368
369async fn run_forward_drain_with(
377 task_id: Arc<str>,
378 def_name: Arc<str>,
379 mut rx: mpsc::Receiver<RawChunk>,
380 layers: SanitizeLayers,
381 surfaces: ForwardSurfaces,
382 buffer: Arc<ForwardBuffer>,
383 mut dispatch: impl FnMut(&SanitizedChunk, ForwardSurfaces, &ForwardBuffer),
384) {
385 let mut next_seq: u64 = 0;
386
387 loop {
388 if let Some(raw) = rx.recv().await {
389 next_seq = raw.seq + 1;
390 let is_terminal = matches!(raw.kind, ForwardChunkKind::Terminal(_));
391 let chunk = sanitize_chunk(raw, &layers);
392 dispatch(&chunk, surfaces, &buffer);
393 if is_terminal {
394 break;
395 }
396 } else {
397 tracing::warn!(
398 task_id = %task_id,
399 "subagent.forward.terminal: ingress channel closed without an explicit \
400 terminal chunk — synthesizing hard-abort backstop"
401 );
402 let synthesized = SanitizedChunk {
403 task_id: Arc::clone(&task_id),
404 def_name: Arc::clone(&def_name),
405 seq: next_seq,
406 kind: SanitizedChunkKind::Terminal(SubAgentState::Canceled),
407 };
408 dispatch(&synthesized, surfaces, &buffer);
409 break;
410 }
411 }
412
413 tokio::time::sleep(FORWARD_BUFFER_GRACE).await;
414 buffer
415 .lock()
416 .unwrap_or_else(std::sync::PoisonError::into_inner)
417 .remove(task_id.as_ref());
418}
419
420pub(crate) fn forwarded_tail(buffer: &ForwardBuffer, task_id: &str, n: usize) -> Vec<String> {
424 let guard = buffer
425 .lock()
426 .unwrap_or_else(std::sync::PoisonError::into_inner);
427 guard.get(task_id).map_or_else(Vec::new, |ring| {
428 ring.iter().rev().take(n).rev().cloned().collect()
429 })
430}
431
432pub(crate) fn new_buffer() -> Arc<ForwardBuffer> {
434 Arc::new(std::sync::Mutex::new(HashMap::new()))
435}
436
437#[cfg(test)]
438mod tests {
439 use std::sync::atomic::AtomicUsize;
440
441 use zeph_config::sanitizer::PiiFilterConfig;
442
443 use super::*;
444
445 fn layers() -> SanitizeLayers {
446 SanitizeLayers {
447 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
448 secret_registry: None,
449 pii_filter: None,
450 }
451 }
452
453 async fn run_and_count_terminals(
461 task_id: Arc<str>,
462 def_name: Arc<str>,
463 rx: mpsc::Receiver<RawChunk>,
464 surfaces: ForwardSurfaces,
465 buffer: Arc<ForwardBuffer>,
466 ) -> usize {
467 let terminal_dispatches = Arc::new(AtomicUsize::new(0));
468 let counter = Arc::clone(&terminal_dispatches);
469 run_forward_drain_with(
470 task_id,
471 def_name,
472 rx,
473 layers(),
474 surfaces,
475 buffer,
476 move |chunk, surfaces, buffer| {
477 if matches!(chunk.kind, SanitizedChunkKind::Terminal(_)) {
478 counter.fetch_add(1, Ordering::SeqCst);
479 }
480 dispatch_chunk(chunk, surfaces, buffer);
481 },
482 )
483 .await;
484 terminal_dispatches.load(Ordering::SeqCst)
485 }
486
487 #[tokio::test(start_paused = true)]
488 async fn happy_path_emits_no_spurious_second_terminal() {
489 let task_id: Arc<str> = Arc::from("task-1");
495 let def_name: Arc<str> = Arc::from("agent-1");
496 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
497 let buffer = new_buffer();
498
499 sender.send_text("hello");
500 sender.send_terminal(SubAgentState::Completed);
501 drop(sender);
502
503 let terminal_count = run_and_count_terminals(
504 Arc::clone(&task_id),
505 def_name,
506 rx,
507 ForwardSurfaces {
508 tui: true,
509 bare: false,
510 },
511 Arc::clone(&buffer),
512 )
513 .await;
514
515 assert_eq!(
516 terminal_count, 1,
517 "exactly one terminal chunk must be dispatched — a second would mean the drain \
518 looped back to recv() after the explicit terminal (C-new-1 regression)"
519 );
520 let tail = forwarded_tail(&buffer, &task_id, 10);
521 assert!(
522 tail.is_empty(),
523 "buffer entry must be evicted after grace window"
524 );
525 }
526
527 #[tokio::test(start_paused = true)]
528 async fn hard_abort_without_explicit_terminal_synthesizes_backstop() {
529 let task_id: Arc<str> = Arc::from("task-2");
530 let def_name: Arc<str> = Arc::from("agent-2");
531 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
532 let buffer = new_buffer();
533
534 sender.send_text("partial output");
535 drop(sender); let terminal_count = run_and_count_terminals(
538 Arc::clone(&task_id),
539 def_name,
540 rx,
541 ForwardSurfaces {
542 tui: true,
543 bare: false,
544 },
545 buffer,
546 )
547 .await;
548
549 assert_eq!(
550 terminal_count, 1,
551 "exactly one synthesized backstop terminal must be dispatched on hard abort"
552 );
553 }
554
555 #[tokio::test(start_paused = true)]
556 async fn zero_consumer_surfaces_still_drains_without_panicking() {
557 let task_id: Arc<str> = Arc::from("task-3");
558 let def_name: Arc<str> = Arc::from("agent-3");
559 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
560 let buffer = new_buffer();
561
562 sender.send_text("no one is listening");
563 sender.send_terminal(SubAgentState::Completed);
564 drop(sender);
565
566 run_forward_drain(
567 task_id,
568 def_name,
569 rx,
570 layers(),
571 ForwardSurfaces::default(),
572 buffer,
573 )
574 .await;
575 }
576
577 #[tokio::test(start_paused = true)]
578 async fn secret_registry_masks_forwarded_text_and_thinking() {
579 use zeph_sanitizer::secret_mask::{SecretCategory, SecretMaskRegistry};
582
583 let registry = Arc::new(SecretMaskRegistry::new());
584 registry.register(
585 "MY_KEY",
586 "sk-live-topsecretvalue123",
587 SecretCategory::ApiKey,
588 );
589
590 let task_id: Arc<str> = Arc::from("task-secret");
591 let def_name: Arc<str> = Arc::from("agent-secret");
592 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
593 let buffer = new_buffer();
594
595 sender.send_text("the key is sk-live-topsecretvalue123, use it wisely");
596 sender.send_thinking("I will use sk-live-topsecretvalue123 to authenticate");
597 sender.send_terminal(SubAgentState::Completed);
598 drop(sender);
599
600 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
601 let collected = Arc::clone(&seen);
602 let layers = SanitizeLayers {
603 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
604 secret_registry: Some(registry),
605 pii_filter: None,
606 };
607 run_forward_drain_with(
608 task_id,
609 def_name,
610 rx,
611 layers,
612 ForwardSurfaces {
613 tui: true,
614 bare: false,
615 },
616 buffer,
617 move |chunk, surfaces, buffer| {
618 collected.lock().unwrap().push(chunk.clone());
619 dispatch_chunk(chunk, surfaces, buffer);
620 },
621 )
622 .await;
623
624 let chunks = seen.lock().unwrap();
625 for chunk in chunks.iter() {
626 match &chunk.kind {
627 SanitizedChunkKind::Text(t) | SanitizedChunkKind::Thinking(t) => {
628 assert!(
629 !t.contains("sk-live-topsecretvalue123"),
630 "forwarded content must not contain the raw secret: {t}"
631 );
632 }
633 SanitizedChunkKind::Terminal(_) => {}
634 }
635 }
636 }
637
638 #[tokio::test(start_paused = true)]
639 async fn pii_filter_scrubs_forwarded_email() {
640 let task_id: Arc<str> = Arc::from("task-pii");
643 let def_name: Arc<str> = Arc::from("agent-pii");
644 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
645 let buffer = new_buffer();
646
647 sender.send_text("contact me at victim@example.com for details");
648 sender.send_terminal(SubAgentState::Completed);
649 drop(sender);
650
651 let seen = Arc::new(std::sync::Mutex::new(Vec::<SanitizedChunk>::new()));
652 let collected = Arc::clone(&seen);
653 let layers = SanitizeLayers {
654 sanitizer: ContentSanitizer::new(&zeph_config::ContentIsolationConfig::default()),
655 secret_registry: None,
656 pii_filter: Some(PiiFilter::new(PiiFilterConfig::default())),
657 };
658 run_forward_drain_with(
659 task_id,
660 def_name,
661 rx,
662 layers,
663 ForwardSurfaces {
664 tui: true,
665 bare: false,
666 },
667 buffer,
668 move |chunk, surfaces, buffer| {
669 collected.lock().unwrap().push(chunk.clone());
670 dispatch_chunk(chunk, surfaces, buffer);
671 },
672 )
673 .await;
674
675 let chunks = seen.lock().unwrap();
676 let text_chunk = chunks
677 .iter()
678 .find(|c| matches!(c.kind, SanitizedChunkKind::Text(_)))
679 .expect("one text chunk must have been dispatched");
680 let SanitizedChunkKind::Text(ref t) = text_chunk.kind else {
681 unreachable!()
682 };
683 assert!(
684 !t.contains("victim@example.com"),
685 "forwarded content must not contain the raw email address: {t}"
686 );
687 }
688
689 #[tokio::test(start_paused = true)]
690 async fn buffer_entry_survives_during_grace_window_then_evicted() {
691 let task_id: Arc<str> = Arc::from("task-grace");
695 let def_name: Arc<str> = Arc::from("agent-grace");
696 let (sender, rx) = new_channel(Arc::clone(&task_id), Arc::clone(&def_name));
697 let buffer = new_buffer();
698
699 sender.send_text("visible during the grace window");
700 sender.send_terminal(SubAgentState::Completed);
701 drop(sender);
702
703 let drain_buffer = Arc::clone(&buffer);
704 let drain_task_id = Arc::clone(&task_id);
705 let handle = tokio::spawn(run_forward_drain(
706 drain_task_id,
707 def_name,
708 rx,
709 layers(),
710 ForwardSurfaces {
711 tui: true,
712 bare: false,
713 },
714 drain_buffer,
715 ));
716
717 tokio::time::advance(Duration::from_millis(1)).await;
719 tokio::task::yield_now().await;
720
721 let mid_window_tail = forwarded_tail(&buffer, &task_id, 10);
722 assert_eq!(
723 mid_window_tail.len(),
724 1,
725 "exactly one forwarded line expected"
726 );
727 assert!(
728 mid_window_tail[0].contains("visible during the grace window"),
729 "the transcript must still be visible during the grace window, got: {:?}",
730 mid_window_tail[0]
731 );
732
733 tokio::time::advance(FORWARD_BUFFER_GRACE + Duration::from_millis(1)).await;
734 handle.await.expect("drain task must not panic");
735
736 let post_eviction_tail = forwarded_tail(&buffer, &task_id, 10);
737 assert!(
738 post_eviction_tail.is_empty(),
739 "buffer entry must be evicted once the grace window elapses"
740 );
741 }
742
743 #[test]
744 fn empty_text_is_not_sent() {
745 let task_id: Arc<str> = Arc::from("task-4");
746 let def_name: Arc<str> = Arc::from("agent-4");
747 let (sender, mut rx) = new_channel(task_id, def_name);
748 sender.send_text("");
749 sender.send_thinking("");
750 drop(sender);
751 assert!(
752 rx.try_recv().is_err(),
753 "empty text/thinking must not be sent onto the ingress channel"
754 );
755 }
756
757 #[test]
758 fn channel_full_increments_drop_counter_and_does_not_panic() {
759 let task_id: Arc<str> = Arc::from("task-5");
760 let def_name: Arc<str> = Arc::from("agent-5");
761 let (sender, mut rx) = new_channel(task_id, def_name);
762 for i in 0..FORWARD_CHANNEL_CAPACITY + 10 {
763 sender.send_text(&format!("chunk {i}"));
764 }
765 let mut received = 0;
767 while rx.try_recv().is_ok() {
768 received += 1;
769 }
770 assert!(
771 received > 0,
772 "at least some chunks must have been delivered"
773 );
774 assert!(
775 received <= FORWARD_CHANNEL_CAPACITY,
776 "received must never exceed channel capacity"
777 );
778 }
779
780 #[test]
781 fn forward_surfaces_any() {
782 assert!(!ForwardSurfaces::default().any());
783 assert!(
784 ForwardSurfaces {
785 tui: true,
786 bare: false
787 }
788 .any()
789 );
790 assert!(
791 ForwardSurfaces {
792 tui: false,
793 bare: true
794 }
795 .any()
796 );
797 }
798}