Skip to main content

ag_ui/server/
transform.rs

1//! The one extension point: rewriting the event stream on its way out.
2//!
3//! Everything that wants to observe, drop, rewrite or add events implements
4//! [`StreamTransformer`]. There is deliberately no second mechanism — an early
5//! draft of this crate also carried a builder of `map_content` / `map_result` /
6//! `map_interrupt` closures ported from the .NET SDK, which meant two ways to
7//! do the same thing and a pile of `Box<dyn Fn>`. Those hooks are built-in
8//! transformers now: see [`ToolResultToState`]. So is the compatibility knob
9//! for consumers that predate subagents: [`SubagentVisibility`].
10//!
11//! Transformers run in the order they were added, each seeing what the previous
12//! one produced, before the ordering verifier sees anything. That order is what
13//! makes [`FilterToolCalls`] safe: the verifier never sees the half of a tool
14//! call that was dropped.
15//!
16//! ```
17//! # use ag_ui::server::{FilterToolCalls, TransformerChain, ToolResultToState};
18//! let chain = TransformerChain::new()
19//!     .with(FilterToolCalls::deny(["internal_debug"]))
20//!     .with(ToolResultToState::snapshot("load_document").replacing());
21//! assert_eq!(chain.len(), 2);
22//! ```
23
24use std::collections::{HashMap, HashSet};
25
26use crate::{Event, MessageId, PatchOperation, SubagentRunId, ToolCallId};
27use serde_json::Value;
28
29/// Rewrites events on their way from an agent to the transport.
30///
31/// # Why `&mut self`
32///
33/// Any useful transformer is a small state machine: dropping a tool call means
34/// remembering which id was dropped so its `TOOL_CALL_ARGS` go too. Taking
35/// `&mut self` says that directly instead of pushing every implementation into
36/// `RefCell`. The chain is owned by the run, so there is no sharing to lose.
37///
38/// # Contract
39///
40/// Returning an empty `Vec` drops the event. Returning several events splices
41/// them in, in order. A transformer that drops the start of something must drop
42/// its continuation and terminator too, or the ordering verifier will reject
43/// what it produces.
44pub trait StreamTransformer: Send {
45    /// Rewrites one event into zero or more events.
46    fn transform(&mut self, event: Event) -> Vec<Event>;
47}
48
49/// Transformers applied in sequence.
50///
51/// An empty chain is free: the run skips it entirely rather than allocating a
52/// `Vec` per event.
53#[derive(Default)]
54pub struct TransformerChain {
55    transformers: Vec<Box<dyn StreamTransformer>>,
56}
57
58impl std::fmt::Debug for TransformerChain {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("TransformerChain")
61            .field("len", &self.transformers.len())
62            .finish()
63    }
64}
65
66impl TransformerChain {
67    /// An empty chain.
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Appends a transformer, returning the chain for further building.
73    #[must_use]
74    pub fn with(mut self, transformer: impl StreamTransformer + 'static) -> Self {
75        self.push(transformer);
76        self
77    }
78
79    /// Appends a transformer.
80    pub fn push(&mut self, transformer: impl StreamTransformer + 'static) {
81        self.transformers.push(Box::new(transformer));
82    }
83
84    /// How many transformers are in the chain.
85    pub fn len(&self) -> usize {
86        self.transformers.len()
87    }
88
89    /// Whether the chain would pass every event through untouched.
90    pub fn is_empty(&self) -> bool {
91        self.transformers.is_empty()
92    }
93
94    /// Runs `event` through every transformer in order.
95    pub fn transform(&mut self, event: Event) -> Vec<Event> {
96        let mut current = vec![event];
97        for transformer in &mut self.transformers {
98            let mut next = Vec::with_capacity(current.len());
99            for event in current.drain(..) {
100                next.extend(transformer.transform(event));
101            }
102            current = next;
103            if current.is_empty() {
104                break;
105            }
106        }
107        current
108    }
109}
110
111impl StreamTransformer for TransformerChain {
112    fn transform(&mut self, event: Event) -> Vec<Event> {
113        Self::transform(self, event)
114    }
115}
116
117/// Which side of the list passes.
118#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
119enum FilterMode {
120    Allow,
121    Deny,
122}
123
124/// Drops whole tool calls by tool name.
125///
126/// Both halves of a call are removed — `TOOL_CALL_START`, its `TOOL_CALL_ARGS`,
127/// the `TOOL_CALL_END`, the `TOOL_CALL_RESULT` and any encrypted reasoning blob
128/// attached to it — so what reaches the client is a stream that never mentions
129/// the tool.
130///
131/// ```
132/// # use ag_ui::Event;
133/// # use ag_ui::server::{FilterToolCalls, StreamTransformer};
134/// let mut filter = FilterToolCalls::deny(["secret_tool"]);
135/// assert!(filter.transform(Event::tool_call_start("c1", "secret_tool")).is_empty());
136/// assert!(filter.transform(Event::tool_call_args("c1", "{}")).is_empty());
137/// assert_eq!(filter.transform(Event::tool_call_start("c2", "public")).len(), 1);
138/// ```
139#[derive(Clone, Debug)]
140pub struct FilterToolCalls {
141    mode: FilterMode,
142    names: HashSet<String>,
143    dropped: HashSet<ToolCallId>,
144}
145
146impl FilterToolCalls {
147    /// Passes only calls to the named tools.
148    pub fn allow<I, T>(names: I) -> Self
149    where
150        I: IntoIterator<Item = T>,
151        T: Into<String>,
152    {
153        Self::new(FilterMode::Allow, names)
154    }
155
156    /// Passes everything except calls to the named tools.
157    pub fn deny<I, T>(names: I) -> Self
158    where
159        I: IntoIterator<Item = T>,
160        T: Into<String>,
161    {
162        Self::new(FilterMode::Deny, names)
163    }
164
165    fn new<I, T>(mode: FilterMode, names: I) -> Self
166    where
167        I: IntoIterator<Item = T>,
168        T: Into<String>,
169    {
170        Self {
171            mode,
172            names: names.into_iter().map(Into::into).collect(),
173            dropped: HashSet::new(),
174        }
175    }
176
177    fn passes(&self, name: &str) -> bool {
178        match self.mode {
179            FilterMode::Allow => self.names.contains(name),
180            FilterMode::Deny => !self.names.contains(name),
181        }
182    }
183
184    /// Records the verdict for a call and reports whether it should be dropped.
185    fn judge(&mut self, id: &ToolCallId, name: &str) -> bool {
186        if self.passes(name) {
187            self.dropped.remove(id);
188            false
189        } else {
190            self.dropped.insert(id.clone());
191            true
192        }
193    }
194}
195
196impl StreamTransformer for FilterToolCalls {
197    fn transform(&mut self, event: Event) -> Vec<Event> {
198        let drop = match &event {
199            Event::ToolCallStart(payload) => {
200                self.judge(&payload.tool_call_id, &payload.tool_call_name)
201            }
202            Event::ToolCallChunk(payload) => match (&payload.tool_call_id, &payload.tool_call_name)
203            {
204                (Some(id), Some(name)) => self.judge(id, name),
205                (Some(id), None) => self.dropped.contains(id),
206                _ => false,
207            },
208            Event::ToolCallArgs(payload) => self.dropped.contains(&payload.tool_call_id),
209            Event::ToolCallEnd(payload) => self.dropped.contains(&payload.tool_call_id),
210            Event::ToolCallResult(payload) => self.dropped.contains(&payload.tool_call_id),
211            Event::ReasoningEncryptedValue(payload) => {
212                payload.subtype == crate::ReasoningEncryptedValueSubtype::ToolCall
213                    && self.dropped.contains(&ToolCallId::new(&payload.entity_id))
214            }
215            _ => false,
216        };
217
218        if drop { Vec::new() } else { vec![event] }
219    }
220}
221
222/// How a promoted tool result reaches the client's state.
223#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
224enum StateForm {
225    /// The result is the new state.
226    Snapshot,
227    /// The result is an RFC 6902 patch against the current state.
228    Delta,
229}
230
231/// Promotes a named tool's result into a state event.
232///
233/// A tool whose job is to produce state — `load_document`, `set_filters` —
234/// otherwise forces the agent to emit the result and then publish the state by
235/// hand. This transformer does it: when `tool_name` returns, its JSON content
236/// becomes a `STATE_SNAPSHOT` or a `STATE_DELTA`.
237///
238/// The result event is kept by default so the client still sees the tool
239/// completed; [`replacing`](Self::replacing) drops it instead. Content that is
240/// not JSON of the expected shape is left alone — a malformed result must not
241/// take down the run.
242///
243/// ```
244/// # use ag_ui::Event;
245/// # use ag_ui::server::{StreamTransformer, ToolResultToState};
246/// let mut promote = ToolResultToState::snapshot("load_document").replacing();
247/// promote.transform(Event::tool_call_start("c1", "load_document"));
248/// let out = promote.transform(Event::tool_call_result("m1", "c1", r#"{"title":"Notes"}"#));
249/// assert_eq!(out, vec![Event::state_snapshot(serde_json::json!({"title": "Notes"}))]);
250/// ```
251#[derive(Clone, Debug)]
252pub struct ToolResultToState {
253    tool_name: String,
254    form: StateForm,
255    keep_result: bool,
256    names: HashMap<ToolCallId, String>,
257}
258
259impl ToolResultToState {
260    /// Turns `tool_name`'s result into a `STATE_SNAPSHOT`.
261    pub fn snapshot(tool_name: impl Into<String>) -> Self {
262        Self::new(tool_name, StateForm::Snapshot)
263    }
264
265    /// Turns `tool_name`'s result into a `STATE_DELTA`. The result content must
266    /// be a JSON array of RFC 6902 operations.
267    pub fn delta(tool_name: impl Into<String>) -> Self {
268        Self::new(tool_name, StateForm::Delta)
269    }
270
271    fn new(tool_name: impl Into<String>, form: StateForm) -> Self {
272        Self {
273            tool_name: tool_name.into(),
274            form,
275            keep_result: true,
276            names: HashMap::new(),
277        }
278    }
279
280    /// Drops the `TOOL_CALL_RESULT` instead of emitting it alongside the state
281    /// event.
282    #[must_use]
283    pub fn replacing(mut self) -> Self {
284        self.keep_result = false;
285        self
286    }
287
288    fn state_event(&self, content: &str) -> Option<Event> {
289        match self.form {
290            StateForm::Snapshot => serde_json::from_str::<Value>(content)
291                .ok()
292                .map(Event::state_snapshot),
293            StateForm::Delta => serde_json::from_str::<Vec<PatchOperation>>(content)
294                .ok()
295                .map(Event::state_delta),
296        }
297    }
298}
299
300impl StreamTransformer for ToolResultToState {
301    fn transform(&mut self, event: Event) -> Vec<Event> {
302        match &event {
303            Event::ToolCallStart(payload) => {
304                if payload.tool_call_name == self.tool_name {
305                    self.names
306                        .insert(payload.tool_call_id.clone(), payload.tool_call_name.clone());
307                }
308            }
309            Event::ToolCallChunk(payload) => {
310                match (&payload.tool_call_id, &payload.tool_call_name) {
311                    (Some(id), Some(name)) if name == &self.tool_name => {
312                        self.names.insert(id.clone(), name.clone());
313                    }
314                    _ => {}
315                }
316            }
317            Event::ToolCallResult(payload) => {
318                let promoted = self
319                    .names
320                    .remove(&payload.tool_call_id)
321                    .and_then(|_| self.state_event(&payload.content))
322                    .map(|mut state| {
323                        // Provenance travels with the state: a subagent's
324                        // result promoted is the subagent's publish.
325                        if let Some(id) = &payload.subagent_run_id {
326                            state.set_subagent_run_id(id.clone());
327                        }
328                        state
329                    });
330                if let Some(state) = promoted {
331                    return if self.keep_result {
332                        vec![event, state]
333                    } else {
334                        vec![state]
335                    };
336                }
337            }
338            _ => {}
339        }
340        vec![event]
341    }
342}
343
344/// What a consumer sees of an agent's subagents.
345///
346/// [`Attributed`](Self::Attributed) — the default, and no transformer at all —
347/// sends the stream as the agent emitted it. The other two exist because a
348/// client older than `@ag-ui/client` 0.0.59 rejects the `SUBAGENT_*` event
349/// *types* while decoding: an unknown field is tolerated, an unknown event
350/// type is not, and there is nothing a client can do about it after the
351/// fact. A producer with such consumers must not send them, and this is how
352/// it does not.
353///
354/// Upstream's integrations default to inline and make the full surface
355/// opt-in. This crate defaults the other way, because a transformer that
356/// rewrites the stream is opt-in here like every other: an agent that wrote
357/// `ctx.subagent(..)` meant it, and silently flattening what it said is the
358/// kind of surprise the design notes argue against. Flip it per endpoint when
359/// your consumers are older:
360///
361/// ```
362/// # use ag_ui::RunOutcome;
363/// # use ag_ui::server::{Agent, Result, RunContext, Runner, SubagentVisibility};
364/// # struct MyAgent;
365/// # impl Agent for MyAgent {
366/// #     type State = ();
367/// #     async fn run(&self, _ctx: &mut RunContext<()>) -> Result<RunOutcome> { Ok(RunOutcome::Success) }
368/// # }
369/// let runner = Runner::new(MyAgent).transformer(SubagentVisibility::inline());
370/// # let _ = runner;
371/// ```
372#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
373pub enum SubagentVisibility {
374    /// The full surface: the lifecycle events, and `subagentRunId` on
375    /// everything a subagent produced.
376    #[default]
377    Attributed,
378    /// The pre-subagent shape: no lifecycle events and no `subagentRunId`
379    /// anywhere — not on events, not on the messages inside
380    /// `MESSAGES_SNAPSHOT` or the `RUN_STARTED` input echo, not on the
381    /// interrupts a paused run reports. A subagent's own text arrives as the
382    /// parent's work. A `*_CHUNK` event that named no id is given the one its
383    /// attribution resolved to, since without the attribution the consumer
384    /// would resolve it differently.
385    ///
386    /// A subagent's steps are dropped rather than flattened. A step brackets
387    /// its own agent's graph, and as the parent's it would either collide
388    /// with an open step of the same name — the common shape: a parent
389    /// `tools` step wrapping the delegation and the child's own `tools`
390    /// inside it — or misdescribe the parent's graph. The lifecycle events
391    /// were the child's structure; so are its steps.
392    Inline,
393    /// Only the parent's own events. Everything a subagent produced is
394    /// dropped — including the result of a call it requested, even when the
395    /// parent executed it, since a result for a call the consumer never saw
396    /// is a protocol error. The converse holds too: a result answering the
397    /// *parent's* call is kept, untagged, whoever executed it — the consumer
398    /// saw the call, and a call left unanswered in the history is what the
399    /// next request would carry back.
400    ///
401    /// The one thing kept is the run's shared state. A `STATE_SNAPSHOT` or
402    /// `STATE_DELTA` a subagent published describes the *thread's* state,
403    /// not the subagent's work: a client that never saw it would mirror a
404    /// stale state and send that back on its next request. It goes out with
405    /// the tag cleared, as the parent's.
406    Hidden,
407}
408
409impl SubagentVisibility {
410    /// The transformer for this mode. Pointless but harmless for
411    /// [`Attributed`](Self::Attributed), which passes everything through.
412    pub fn filter(self) -> SubagentFilter {
413        SubagentFilter::new(self)
414    }
415
416    /// The transformer for [`Inline`](Self::Inline).
417    pub fn inline() -> SubagentFilter {
418        Self::Inline.filter()
419    }
420
421    /// The transformer for [`Hidden`](Self::Hidden).
422    pub fn hidden() -> SubagentFilter {
423        Self::Hidden.filter()
424    }
425}
426
427/// The transformer behind [`SubagentVisibility`].
428///
429/// Both modes keep the consuming normalizer's model of the stream — one open
430/// chunk-continuable stream per owner — because a `*_CHUNK` event that names
431/// no id is resolved *through its attribution* on the consuming side, and
432/// stripping the attribution would send it to the wrong stream. Such a chunk
433/// is given the id it resolves to (and, for a tool call, the name the
434/// consumer needs to reopen it) before its tag goes.
435///
436/// [`Hidden`](SubagentVisibility::Hidden) additionally remembers what each
437/// subagent owns — messages, tool calls, activities — for the rest of the
438/// run, not only while they are open, so an untagged continuation, re-open,
439/// patch or result for a subagent's entity is dropped with the rest of it
440/// rather than leaking into the parent's stream. That is the owner-aware
441/// verifier's reading too: the first writer owns the id, and an absent tag
442/// agrees with any owner. An entity the consumer has seen opened keeps that
443/// visibility until it closes, whatever a snapshot says about its owner
444/// meanwhile — a consumer must never be left with a message it saw opened
445/// and never sees closed.
446#[derive(Debug)]
447pub struct SubagentFilter {
448    mode: SubagentVisibility,
449    /// The open streams, per owner.
450    streams: Streams,
451    /// Text message ids a subagent owns, and which.
452    hidden_text: HashMap<MessageId, SubagentRunId>,
453    /// Reasoning ids — the block and the message inside it — a subagent
454    /// owns. A bucket of its own, as the verifiers keep it, so a producer
455    /// that reuses an id across the two kinds is not misread.
456    hidden_reasoning: HashMap<MessageId, SubagentRunId>,
457    /// Tool call ids a subagent owns — or that sit in a message it owns.
458    hidden_tool_calls: HashMap<ToolCallId, SubagentRunId>,
459    /// Every activity seen, and whether a subagent owns it.
460    activities: HashMap<MessageId, bool>,
461    /// What is open, and whether the consumer saw it open.
462    open_visibility: HashMap<(Family, String), bool>,
463    /// Whether the consumer saw each tool call start, for the result that
464    /// may arrive long after the call closed.
465    call_visibility: HashMap<ToolCallId, bool>,
466}
467
468/// The families a `*_CHUNK` event may continue.
469#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
470enum Family {
471    Text,
472    Reasoning,
473    Tool,
474}
475
476/// One open stream, as the consuming normalizer models it.
477#[derive(Clone, Debug)]
478struct Stream {
479    family: Family,
480    id: String,
481    /// What a chunk reopening a tool call on the consuming side must carry.
482    tool_name: Option<String>,
483    parent_message_id: Option<MessageId>,
484}
485
486/// The open streams: one per owner, replaced by the next that owner opens,
487/// closed by a terminator or — for whoever executed it — by a tool result.
488#[derive(Debug, Default)]
489struct Streams {
490    parent: Option<Stream>,
491    subagents: HashMap<SubagentRunId, Stream>,
492}
493
494/// What a chunk naming no id turned out to be.
495enum Bare {
496    /// Not such a chunk.
497    No,
498    /// Resolved to a stream, and given its id.
499    Resolved {
500        owner: Option<SubagentRunId>,
501        family: Family,
502        id: String,
503    },
504    /// Nothing to resolve it against; the consumer will say so.
505    Unresolved,
506}
507
508impl Streams {
509    /// `owner` now has `stream` open, and nothing else — unless it is the
510    /// same stream, in which case what is known about it is kept.
511    fn open(&mut self, owner: &Option<SubagentRunId>, stream: Stream) {
512        let current = match owner {
513            None => &mut self.parent,
514            Some(owner) => match self.subagents.get_mut(owner) {
515                Some(current) => {
516                    Self::replace(current, stream);
517                    return;
518                }
519                None => {
520                    self.subagents.insert(owner.clone(), stream);
521                    return;
522                }
523            },
524        };
525        match current {
526            Some(current) => Self::replace(current, stream),
527            None => *current = Some(stream),
528        }
529    }
530
531    fn replace(current: &mut Stream, stream: Stream) {
532        if current.family == stream.family && current.id == stream.id {
533            if current.tool_name.is_none() {
534                current.tool_name = stream.tool_name;
535            }
536            if current.parent_message_id.is_none() {
537                current.parent_message_id = stream.parent_message_id;
538            }
539        } else {
540            *current = stream;
541        }
542    }
543
544    fn close_id(&mut self, family: Family, id: &str) {
545        if self
546            .parent
547            .as_ref()
548            .is_some_and(|open| open.family == family && open.id == id)
549        {
550            self.parent = None;
551        }
552        self.subagents
553            .retain(|_, open| !(open.family == family && open.id == id));
554    }
555
556    fn close_owner(&mut self, owner: &Option<SubagentRunId>) {
557        match owner {
558            None => self.parent = None,
559            Some(owner) => {
560                self.subagents.remove(owner);
561            }
562        }
563    }
564
565    fn clear(&mut self) {
566        self.parent = None;
567        self.subagents.clear();
568    }
569
570    /// The subagent under which `id` is open, if it is open under one.
571    fn owner_of_open(&self, family: Family, id: &str) -> Option<SubagentRunId> {
572        self.subagents
573            .iter()
574            .find(|(_, open)| open.family == family && open.id == id)
575            .map(|(owner, _)| owner.clone())
576    }
577
578    /// The stream a chunk naming no id continues — the normalizer's rule:
579    /// the tagged owner's open stream; untagged, the parent's, else the only
580    /// open one.
581    fn resolve(
582        &self,
583        family: Family,
584        tag: &Option<SubagentRunId>,
585    ) -> Option<(Option<SubagentRunId>, Stream)> {
586        let of_family = |open: &&Stream| open.family == family;
587        if let Some(owner) = tag {
588            return self
589                .subagents
590                .get(owner)
591                .filter(of_family)
592                .map(|open| (Some(owner.clone()), open.clone()));
593        }
594        if let Some(open) = self.parent.as_ref().filter(of_family) {
595            return Some((None, open.clone()));
596        }
597        let mut candidates = self
598            .subagents
599            .iter()
600            .filter(|(_, open)| open.family == family);
601        match (candidates.next(), candidates.next()) {
602            (Some((owner, open)), None) => Some((Some(owner.clone()), open.clone())),
603            _ => None,
604        }
605    }
606
607    /// Folds an event with an id into the model. `owner` is whose the event
608    /// is; `tag` is what it carries, which is what a result closes by.
609    fn observe(
610        &mut self,
611        event: &Event,
612        owner: &Option<SubagentRunId>,
613        tag: &Option<SubagentRunId>,
614    ) {
615        let text = |id: &MessageId| Stream {
616            family: Family::Text,
617            id: id.as_str().to_owned(),
618            tool_name: None,
619            parent_message_id: None,
620        };
621        let reasoning = |id: &MessageId| Stream {
622            family: Family::Reasoning,
623            id: id.as_str().to_owned(),
624            tool_name: None,
625            parent_message_id: None,
626        };
627        let tool = |id: &ToolCallId, name: Option<&String>, parent: Option<&MessageId>| Stream {
628            family: Family::Tool,
629            id: id.as_str().to_owned(),
630            tool_name: name.cloned(),
631            parent_message_id: parent.cloned(),
632        };
633        match event {
634            Event::TextMessageStart(e) => self.open(owner, text(&e.message_id)),
635            Event::TextMessageContent(e) => self.open(owner, text(&e.message_id)),
636            Event::TextMessageEnd(e) => self.close_id(Family::Text, e.message_id.as_str()),
637            Event::TextMessageChunk(e) => {
638                if let Some(id) = &e.message_id {
639                    self.open(owner, text(id));
640                }
641            }
642            Event::ReasoningMessageStart(e) => self.open(owner, reasoning(&e.message_id)),
643            Event::ReasoningMessageContent(e) => self.open(owner, reasoning(&e.message_id)),
644            Event::ReasoningMessageEnd(e) => {
645                self.close_id(Family::Reasoning, e.message_id.as_str());
646            }
647            Event::ReasoningEnd(e) => self.close_id(Family::Reasoning, e.message_id.as_str()),
648            Event::ReasoningMessageChunk(e) => {
649                if let Some(id) = &e.message_id {
650                    self.open(owner, reasoning(id));
651                }
652            }
653            Event::ToolCallStart(e) => self.open(
654                owner,
655                tool(
656                    &e.tool_call_id,
657                    Some(&e.tool_call_name),
658                    e.parent_message_id.as_ref(),
659                ),
660            ),
661            Event::ToolCallArgs(e) => self.open(owner, tool(&e.tool_call_id, None, None)),
662            Event::ToolCallEnd(e) => self.close_id(Family::Tool, e.tool_call_id.as_str()),
663            Event::ToolCallChunk(e) => {
664                if let Some(id) = &e.tool_call_id {
665                    self.open(
666                        owner,
667                        tool(id, e.tool_call_name.as_ref(), e.parent_message_id.as_ref()),
668                    );
669                }
670            }
671            // A result answers a call, so the call is over — and the party
672            // answering has moved on from whatever else it had open.
673            Event::ToolCallResult(e) => {
674                self.close_id(Family::Tool, e.tool_call_id.as_str());
675                self.close_owner(tag);
676            }
677            Event::RunFinished(_) | Event::RunError(_) => self.clear(),
678            _ => {}
679        }
680    }
681}
682
683impl SubagentFilter {
684    /// A filter for `mode`.
685    pub fn new(mode: SubagentVisibility) -> Self {
686        Self {
687            mode,
688            streams: Streams::default(),
689            hidden_text: HashMap::new(),
690            hidden_reasoning: HashMap::new(),
691            hidden_tool_calls: HashMap::new(),
692            activities: HashMap::new(),
693            open_visibility: HashMap::new(),
694            call_visibility: HashMap::new(),
695        }
696    }
697
698    /// The mode this filter applies.
699    pub fn mode(&self) -> SubagentVisibility {
700        self.mode
701    }
702
703    /// The stream a stream-bearing event belongs to, if it names one.
704    fn entity(event: &Event) -> Option<(Family, &str)> {
705        Some(match event {
706            Event::TextMessageStart(e) => (Family::Text, e.message_id.as_str()),
707            Event::TextMessageContent(e) => (Family::Text, e.message_id.as_str()),
708            Event::TextMessageEnd(e) => (Family::Text, e.message_id.as_str()),
709            Event::TextMessageChunk(e) => (Family::Text, e.message_id.as_ref()?.as_str()),
710            Event::ReasoningStart(e) => (Family::Reasoning, e.message_id.as_str()),
711            Event::ReasoningMessageStart(e) => (Family::Reasoning, e.message_id.as_str()),
712            Event::ReasoningMessageContent(e) => (Family::Reasoning, e.message_id.as_str()),
713            Event::ReasoningMessageEnd(e) => (Family::Reasoning, e.message_id.as_str()),
714            Event::ReasoningEnd(e) => (Family::Reasoning, e.message_id.as_str()),
715            Event::ReasoningMessageChunk(e) => (Family::Reasoning, e.message_id.as_ref()?.as_str()),
716            Event::ToolCallStart(e) => (Family::Tool, e.tool_call_id.as_str()),
717            Event::ToolCallArgs(e) => (Family::Tool, e.tool_call_id.as_str()),
718            Event::ToolCallEnd(e) => (Family::Tool, e.tool_call_id.as_str()),
719            Event::ToolCallResult(e) => (Family::Tool, e.tool_call_id.as_str()),
720            Event::ToolCallChunk(e) => (Family::Tool, e.tool_call_id.as_ref()?.as_str()),
721            _ => return None,
722        })
723    }
724
725    /// The message a tool call sits in, when the event says.
726    fn carrier(event: &Event) -> Option<&MessageId> {
727        match event {
728            Event::ToolCallStart(e) => e.parent_message_id.as_ref(),
729            Event::ToolCallChunk(e) => e.parent_message_id.as_ref(),
730            _ => None,
731        }
732    }
733
734    /// Whose an event is: its tag; else the recorded owner of its entity —
735    /// for a tool call, the owner of the message that carries it; else
736    /// whoever has that entity open; else the parent.
737    fn owner_for(&self, event: &Event, tag: &Option<SubagentRunId>) -> Option<SubagentRunId> {
738        if tag.is_some() {
739            return tag.clone();
740        }
741        let (family, id) = Self::entity(event)?;
742        let recorded = match family {
743            Family::Text => self.hidden_text.get(&MessageId::new(id)).cloned(),
744            Family::Reasoning => self.hidden_reasoning.get(&MessageId::new(id)).cloned(),
745            Family::Tool => Self::carrier(event)
746                .and_then(|parent| self.hidden_text.get(parent).cloned())
747                .or_else(|| self.hidden_tool_calls.get(&ToolCallId::new(id)).cloned()),
748        };
749        recorded.or_else(|| self.streams.owner_of_open(family, id))
750    }
751
752    /// Gives a chunk naming no id the id its attribution resolves to on the
753    /// consuming side, so that stripping the attribution afterwards cannot
754    /// send it to another stream.
755    fn fill_bare(&self, event: &mut Event, tag: &Option<SubagentRunId>) -> Bare {
756        let family = match event {
757            Event::TextMessageChunk(e) if e.message_id.is_none() => Family::Text,
758            Event::ReasoningMessageChunk(e) if e.message_id.is_none() => Family::Reasoning,
759            Event::ToolCallChunk(e) if e.tool_call_id.is_none() => Family::Tool,
760            _ => return Bare::No,
761        };
762        let Some((owner, stream)) = self.streams.resolve(family, tag) else {
763            return Bare::Unresolved;
764        };
765        let id = stream.id.clone();
766        match event {
767            Event::TextMessageChunk(e) => e.message_id = Some(MessageId::new(&id)),
768            Event::ReasoningMessageChunk(e) => e.message_id = Some(MessageId::new(&id)),
769            Event::ToolCallChunk(e) => {
770                e.tool_call_id = Some(ToolCallId::new(&id));
771                if e.tool_call_name.is_none() {
772                    e.tool_call_name = stream.tool_name;
773                }
774                if e.parent_message_id.is_none() {
775                    e.parent_message_id = stream.parent_message_id;
776                }
777            }
778            _ => unreachable!("matched above"),
779        }
780        Bare::Resolved { owner, family, id }
781    }
782
783    /// Strips the subagent surface from an event, or drops it entirely when
784    /// it *is* the subagent surface — the lifecycle, and a subagent's steps.
785    fn inline(&mut self, mut event: Event) -> Vec<Event> {
786        let tag = event.subagent_run_id().cloned();
787        if let Bare::No = self.fill_bare(&mut event, &tag) {
788            let owner = self.owner_for(&event, &tag);
789            self.streams.observe(&event, &owner, &tag);
790        }
791        let subagents_step =
792            matches!(event, Event::StepStarted(_) | Event::StepFinished(_)) && tag.is_some();
793        if subagents_step {
794            return Vec::new();
795        }
796        match &mut event {
797            Event::SubagentStarted(_) | Event::SubagentFinished(_) | Event::SubagentError(_) => {
798                return Vec::new();
799            }
800            Event::MessagesSnapshot(snapshot) => {
801                for message in &mut snapshot.messages {
802                    message.set_subagent_run_id(None);
803                }
804            }
805            Event::RunStarted(started) => {
806                if let Some(input) = &mut started.input {
807                    for message in &mut input.messages {
808                        message.set_subagent_run_id(None);
809                    }
810                }
811            }
812            Event::RunFinished(finished) => Self::strip_interrupt_tags(finished),
813            _ => {
814                event.clear_subagent_run_id();
815            }
816        }
817        vec![event]
818    }
819
820    /// The interrupts a paused run reports name the subagent that raised
821    /// them, and a consumer that never saw that subagent has no group to
822    /// file them under. The question still stands, so the interrupt stays;
823    /// only the tag goes.
824    fn strip_interrupt_tags(finished: &mut crate::RunFinishedEvent) {
825        if let Some(crate::RunOutcome::Interrupt { interrupts }) = &mut finished.outcome {
826            for interrupt in interrupts {
827                interrupt.subagent_run_id = None;
828            }
829        }
830    }
831
832    /// Keeps the parent's events and drops a subagent's, remembering what
833    /// each subagent owns so that an untagged event is judged by its opener.
834    fn hidden(&mut self, mut event: Event) -> Vec<Event> {
835        let tag = event.subagent_run_id().cloned();
836        let owned = tag.is_some();
837        let keep = match self.fill_bare(&mut event, &tag) {
838            // The chunk continues a stream: it goes where the stream went.
839            Bare::Resolved { owner, family, id } => self
840                .open_visibility
841                .get(&(family, id))
842                .copied()
843                .unwrap_or(owner.is_none()),
844            Bare::Unresolved => !owned,
845            Bare::No => {
846                let owner = self.owner_for(&event, &tag);
847                self.streams.observe(&event, &owner, &tag);
848                self.judge(&event, &tag, owner.as_ref())
849            }
850        };
851        if !keep {
852            return Vec::new();
853        }
854        match &mut event {
855            // Authoritative: the snapshot restates the conversation, so what
856            // it carries is re-read — and what it does not carry is left as
857            // the run established it, as the verifiers leave it.
858            Event::MessagesSnapshot(snapshot) => {
859                self.seed_hidden(&snapshot.messages, true);
860                let hidden_calls = &self.hidden_tool_calls;
861                snapshot
862                    .messages
863                    .retain_mut(|message| Self::show_message(message, hidden_calls));
864            }
865            // History, not a rewrite: what it carries is remembered alongside
866            // what the run has already shown.
867            Event::RunStarted(started) => {
868                if let Some(input) = &mut started.input {
869                    self.seed_hidden(&input.messages, false);
870                    let hidden_calls = &self.hidden_tool_calls;
871                    input
872                        .messages
873                        .retain_mut(|message| Self::show_message(message, hidden_calls));
874                }
875            }
876            Event::StateSnapshot(_)
877            | Event::StateDelta(_)
878            | Event::ToolCallResult(_)
879            | Event::ActivitySnapshot(_)
880            | Event::ActivityDelta(_) => {
881                event.clear_subagent_run_id();
882            }
883            Event::RunFinished(finished) => Self::strip_interrupt_tags(finished),
884            _ => {}
885        }
886        vec![event]
887    }
888
889    /// Whether the consumer sees an event with an id, by who owns it.
890    fn judge(
891        &mut self,
892        event: &Event,
893        tag: &Option<SubagentRunId>,
894        owner: Option<&SubagentRunId>,
895    ) -> bool {
896        let owned = tag.is_some();
897        match event {
898            Event::SubagentStarted(_) | Event::SubagentFinished(_) | Event::SubagentError(_) => {
899                false
900            }
901
902            Event::TextMessageStart(e) => self.opened(Family::Text, &e.message_id, owner),
903            Event::TextMessageChunk(e) => match &e.message_id {
904                Some(id) => self.opened(Family::Text, id, owner),
905                None => unreachable!("a bare chunk was resolved or passed through"),
906            },
907            Event::TextMessageContent(e) => self.continued(Family::Text, &e.message_id, owned),
908            Event::TextMessageEnd(e) => self.closed(Family::Text, &e.message_id, owned),
909
910            Event::ReasoningStart(e) => self.opened(Family::Reasoning, &e.message_id, owner),
911            Event::ReasoningMessageStart(e) => self.opened(Family::Reasoning, &e.message_id, owner),
912            Event::ReasoningMessageChunk(e) => match &e.message_id {
913                Some(id) => self.opened(Family::Reasoning, id, owner),
914                None => unreachable!("a bare chunk was resolved or passed through"),
915            },
916            Event::ReasoningMessageContent(e) => {
917                self.continued(Family::Reasoning, &e.message_id, owned)
918            }
919            Event::ReasoningMessageEnd(e) => self.closed(Family::Reasoning, &e.message_id, owned),
920            Event::ReasoningEnd(e) => self.closed(Family::Reasoning, &e.message_id, owned),
921
922            Event::ToolCallStart(e) => self.call_opened(&e.tool_call_id, owner),
923            Event::ToolCallChunk(e) => match &e.tool_call_id {
924                Some(id) => self.call_opened(id, owner),
925                None => unreachable!("a bare chunk was resolved or passed through"),
926            },
927            Event::ToolCallArgs(e) => self.call_continued(&e.tool_call_id, owned),
928            Event::ToolCallEnd(e) => {
929                let keep = self.call_continued(&e.tool_call_id, owned);
930                self.open_visibility
931                    .remove(&(Family::Tool, e.tool_call_id.as_str().to_owned()));
932                keep
933            }
934            // A result goes where its call went, whoever executed it: one for
935            // a call the consumer never saw is a protocol error, and one for
936            // a call it did see is owed.
937            Event::ToolCallResult(e) => self
938                .call_visibility
939                .get(&e.tool_call_id)
940                .copied()
941                .unwrap_or_else(|| !self.hidden_tool_calls.contains_key(&e.tool_call_id)),
942
943            // An activity is owned by the snapshot that minted it, and only a
944            // replacing snapshot re-mints it — the verifiers' rule. A merge
945            // into a visible activity is kept whoever wrote it, as a result
946            // for a visible call is: the entity is the consumer's to keep
947            // whole.
948            Event::ActivitySnapshot(e) => {
949                let existing = self.activities.get(&e.message_id).copied();
950                let hidden = match existing {
951                    Some(hidden) if !e.replace => hidden,
952                    _ => owned,
953                };
954                self.activities.insert(e.message_id.clone(), hidden);
955                !hidden
956            }
957            Event::ActivityDelta(e) => {
958                !self.activities.get(&e.message_id).copied().unwrap_or(false)
959            }
960
961            // An opaque blob for an entity the consumer never saw goes with
962            // the entity, as `FilterToolCalls` drops one for a dropped call.
963            Event::ReasoningEncryptedValue(e) => {
964                !owned
965                    && match e.subtype {
966                        crate::ReasoningEncryptedValueSubtype::ToolCall => !self
967                            .hidden_tool_calls
968                            .contains_key(&ToolCallId::new(e.entity_id.clone())),
969                        crate::ReasoningEncryptedValueSubtype::Message => {
970                            let id = MessageId::new(e.entity_id.clone());
971                            !self.hidden_text.contains_key(&id)
972                                && !self.hidden_reasoning.contains_key(&id)
973                        }
974                    }
975            }
976
977            // The thread's state, whoever published it.
978            Event::StateSnapshot(_) | Event::StateDelta(_) => true,
979
980            Event::RunFinished(_) | Event::RunError(_) => {
981                self.open_visibility.clear();
982                true
983            }
984
985            _ => !owned,
986        }
987    }
988
989    fn owners_mut(&mut self, family: Family) -> &mut HashMap<MessageId, SubagentRunId> {
990        match family {
991            Family::Text => &mut self.hidden_text,
992            Family::Reasoning => &mut self.hidden_reasoning,
993            Family::Tool => unreachable!("tool calls have their own map"),
994        }
995    }
996
997    fn is_hidden(&self, family: Family, id: &str) -> bool {
998        match family {
999            Family::Text => self.hidden_text.contains_key(&MessageId::new(id)),
1000            Family::Reasoning => self.hidden_reasoning.contains_key(&MessageId::new(id)),
1001            Family::Tool => self.hidden_tool_calls.contains_key(&ToolCallId::new(id)),
1002        }
1003    }
1004
1005    /// A message opens under `owner` — the first writer keeps it — and the
1006    /// consumer sees it iff the owner is the parent, unless it is already
1007    /// open, in which case it keeps the visibility it was opened with.
1008    fn opened(&mut self, family: Family, id: &MessageId, owner: Option<&SubagentRunId>) -> bool {
1009        if let Some(owner) = owner {
1010            self.owners_mut(family)
1011                .entry(id.clone())
1012                .or_insert_with(|| owner.clone());
1013        }
1014        *self
1015            .open_visibility
1016            .entry((family, id.as_str().to_owned()))
1017            .or_insert(owner.is_none())
1018    }
1019
1020    fn continued(&self, family: Family, id: &MessageId, owned: bool) -> bool {
1021        !owned
1022            && self
1023                .open_visibility
1024                .get(&(family, id.as_str().to_owned()))
1025                .copied()
1026                .unwrap_or_else(|| !self.is_hidden(family, id.as_str()))
1027    }
1028
1029    fn closed(&mut self, family: Family, id: &MessageId, owned: bool) -> bool {
1030        let keep = self.continued(family, id, owned);
1031        self.open_visibility
1032            .remove(&(family, id.as_str().to_owned()));
1033        keep
1034    }
1035
1036    fn call_opened(&mut self, id: &ToolCallId, owner: Option<&SubagentRunId>) -> bool {
1037        if let Some(owner) = owner {
1038            self.hidden_tool_calls
1039                .entry(id.clone())
1040                .or_insert_with(|| owner.clone());
1041        }
1042        let visible = *self
1043            .open_visibility
1044            .entry((Family::Tool, id.as_str().to_owned()))
1045            .or_insert(owner.is_none());
1046        self.call_visibility.insert(id.clone(), visible);
1047        visible
1048    }
1049
1050    fn call_continued(&self, id: &ToolCallId, owned: bool) -> bool {
1051        !owned
1052            && self
1053                .open_visibility
1054                .get(&(Family::Tool, id.as_str().to_owned()))
1055                .copied()
1056                .unwrap_or_else(|| !self.hidden_tool_calls.contains_key(id))
1057    }
1058
1059    /// Re-reads what a replay says about ownership. Authoritatively — a
1060    /// `MESSAGES_SNAPSHOT` — an untagged message takes its id back for the
1061    /// parent; as history — the `RUN_STARTED` echo — only the subagents'
1062    /// messages are added. A tool message goes where its call went, so one
1063    /// answering a visible call is not hidden however it is tagged.
1064    fn seed_hidden(&mut self, messages: &[crate::Message], authoritative: bool) {
1065        for message in messages {
1066            let id = message.id().clone();
1067            if let crate::Message::Activity(_) = message {
1068                if authoritative || !self.activities.contains_key(&id) {
1069                    self.activities
1070                        .insert(id, message.subagent_run_id().is_some());
1071                }
1072                continue;
1073            }
1074            let owner = match message {
1075                crate::Message::Tool(tool) => {
1076                    self.hidden_tool_calls.get(&tool.tool_call_id).cloned()
1077                }
1078                _ => message.subagent_run_id().cloned(),
1079            };
1080            let calls: Vec<ToolCallId> = match message {
1081                crate::Message::Assistant(assistant) => assistant
1082                    .tool_calls
1083                    .iter()
1084                    .flatten()
1085                    .map(|call| call.id.clone())
1086                    .collect(),
1087                _ => Vec::new(),
1088            };
1089            let family = match message {
1090                crate::Message::Reasoning(_) => Family::Reasoning,
1091                _ => Family::Text,
1092            };
1093            match owner {
1094                Some(owner) => {
1095                    for call in calls {
1096                        self.hidden_tool_calls.insert(call, owner.clone());
1097                    }
1098                    self.owners_mut(family).insert(id, owner);
1099                }
1100                None if authoritative => {
1101                    self.owners_mut(family).remove(&id);
1102                    for call in &calls {
1103                        self.hidden_tool_calls.remove(call);
1104                    }
1105                }
1106                None => {}
1107            }
1108        }
1109    }
1110
1111    /// Whether a replayed message reaches the consumer, stripping the tag
1112    /// from the one kind that may carry one there: a tool message answering
1113    /// a call the consumer saw is the parent's, whoever executed it.
1114    fn show_message(
1115        message: &mut crate::Message,
1116        hidden_calls: &HashMap<ToolCallId, SubagentRunId>,
1117    ) -> bool {
1118        match message {
1119            crate::Message::Tool(tool) => {
1120                if hidden_calls.contains_key(&tool.tool_call_id) {
1121                    return false;
1122                }
1123                tool.subagent_run_id = None;
1124                true
1125            }
1126            _ => message.subagent_run_id().is_none(),
1127        }
1128    }
1129}
1130
1131impl StreamTransformer for SubagentFilter {
1132    fn transform(&mut self, event: Event) -> Vec<Event> {
1133        match self.mode {
1134            SubagentVisibility::Attributed => vec![event],
1135            SubagentVisibility::Inline => self.inline(event),
1136            SubagentVisibility::Hidden => self.hidden(event),
1137        }
1138    }
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143    use super::*;
1144    use crate::{ReasoningEncryptedValueSubtype, TextMessageRole};
1145    use serde_json::json;
1146
1147    #[test]
1148    fn allow_list_drops_everything_unlisted() {
1149        let mut filter = FilterToolCalls::allow(["search"]);
1150        assert_eq!(
1151            filter
1152                .transform(Event::tool_call_start("c1", "search"))
1153                .len(),
1154            1
1155        );
1156        assert!(
1157            filter
1158                .transform(Event::tool_call_start("c2", "delete_everything"))
1159                .is_empty()
1160        );
1161        assert!(
1162            filter
1163                .transform(Event::tool_call_args("c2", "{}"))
1164                .is_empty()
1165        );
1166        assert!(
1167            filter
1168                .transform(Event::tool_call_result("m1", "c2", "gone"))
1169                .is_empty()
1170        );
1171        assert_eq!(filter.transform(Event::tool_call_args("c1", "{}")).len(), 1);
1172    }
1173
1174    #[test]
1175    fn filter_leaves_unrelated_events_alone() {
1176        let mut filter = FilterToolCalls::deny(["nope"]);
1177        let event = Event::text_message_start("m1", TextMessageRole::Assistant);
1178        assert_eq!(filter.transform(event.clone()), vec![event]);
1179    }
1180
1181    #[test]
1182    fn filter_drops_encrypted_reasoning_for_a_dropped_call() {
1183        let mut filter = FilterToolCalls::deny(["nope"]);
1184        filter.transform(Event::tool_call_start("c1", "nope"));
1185        let blob = Event::reasoning_encrypted_value(
1186            ReasoningEncryptedValueSubtype::ToolCall,
1187            "c1",
1188            "opaque",
1189        );
1190        assert!(filter.transform(blob).is_empty());
1191    }
1192
1193    #[test]
1194    fn promoting_keeps_the_result_by_default() {
1195        let mut promote = ToolResultToState::snapshot("load");
1196        promote.transform(Event::tool_call_start("c1", "load"));
1197        let result = Event::tool_call_result("m1", "c1", r#"{"a":1}"#);
1198        assert_eq!(
1199            promote.transform(result.clone()),
1200            vec![result, Event::state_snapshot(json!({"a": 1}))]
1201        );
1202    }
1203
1204    #[test]
1205    fn promoting_a_patch_emits_a_delta() {
1206        let mut promote = ToolResultToState::delta("patch_state").replacing();
1207        promote.transform(Event::tool_call_start("c1", "patch_state"));
1208        let content = r#"[{"op":"replace","path":"/step","value":2}]"#;
1209        assert_eq!(
1210            promote.transform(Event::tool_call_result("m1", "c1", content)),
1211            vec![Event::state_delta(vec![PatchOperation::replace(
1212                "/step", 2
1213            )])]
1214        );
1215    }
1216
1217    #[test]
1218    fn unparseable_content_passes_through_untouched() {
1219        let mut promote = ToolResultToState::snapshot("load").replacing();
1220        promote.transform(Event::tool_call_start("c1", "load"));
1221        let result = Event::tool_call_result("m1", "c1", "not json");
1222        assert_eq!(promote.transform(result.clone()), vec![result]);
1223    }
1224
1225    #[test]
1226    fn other_tools_are_not_promoted() {
1227        let mut promote = ToolResultToState::snapshot("load");
1228        promote.transform(Event::tool_call_start("c1", "something_else"));
1229        let result = Event::tool_call_result("m1", "c1", r#"{"a":1}"#);
1230        assert_eq!(promote.transform(result.clone()), vec![result]);
1231    }
1232
1233    #[test]
1234    fn chain_runs_transformers_in_order() {
1235        let mut chain = TransformerChain::new()
1236            .with(FilterToolCalls::deny(["load"]))
1237            .with(ToolResultToState::snapshot("load"));
1238        // The filter removes the call first, so the promoter never sees it.
1239        assert!(
1240            chain
1241                .transform(Event::tool_call_start("c1", "load"))
1242                .is_empty()
1243        );
1244        assert!(
1245            chain
1246                .transform(Event::tool_call_result("m1", "c1", r#"{"a":1}"#))
1247                .is_empty()
1248        );
1249    }
1250}