Skip to main content

ag_ui/event/
subagent.rs

1//! Subagent lifecycle: `SUBAGENT_STARTED`, `SUBAGENT_FINISHED`,
2//! `SUBAGENT_ERROR`.
3//!
4//! Many frameworks let an agent delegate to child agents — a supervisor
5//! dispatching research tasks, an agents-as-tools pattern where a tool call
6//! *is* a nested agent, a planner farming out subtasks in parallel. To a
7//! frontend all of that arrives as one event stream, and without extra
8//! information three concurrent researchers render as one undifferentiated
9//! wall of text.
10//!
11//! The protocol's subagent support solves exactly that and nothing more: it
12//! **attributes** each event to the subagent that produced it, and reports
13//! when subagents start and stop. It does not orchestrate, schedule or define
14//! subagents — that stays with the framework.
15//!
16//! # Attribution
17//!
18//! Most events carry an optional `subagentRunId` naming who produced them —
19//! the text, tool-call, activity, reasoning, step and state families, plus
20//! `RAW` and `CUSTOM`. An event without one belongs to the parent agent, so a
21//! stream that never sets the field behaves exactly as it did before subagents
22//! existed. The events that describe the run as a whole — `RUN_STARTED`,
23//! `RUN_FINISHED`, `RUN_ERROR` — cannot carry it, and neither can
24//! `MESSAGES_SNAPSHOT`, whose messages carry their own. See
25//! [`Event::subagent_run_id`](crate::event::Event::subagent_run_id) and
26//! [`EventType::is_attributable`](crate::event::EventType::is_attributable).
27//!
28//! Attribution stands on its own: a producer may tag events without ever
29//! emitting the three lifecycle events, and a consumer must accept an
30//! identifier it never saw announced. Attribution on `STATE_*` is provenance,
31//! not ownership — the state stays run-scoped, and an attributed snapshot
32//! replaces the run's state like any other. There is no per-subagent state.
33//!
34//! # `subagentRunId` names an invocation, not a definition
35//!
36//! The easiest thing to get wrong. A [`SubagentRunId`] is an opaque handle for
37//! **one invocation**: run the same subagent twice and you get two values. It
38//! is not a name and not a stable id for a reusable definition — that is
39//! [`SubagentStartedEvent::name`]. The symmetry with the top-level run is the
40//! way to remember it: `agentId` is to `runId` as `name` is to `subagentRunId`.
41//!
42//! The one exception is suspension. A subagent that finished with
43//! [`SubagentOutcome::Suspended`] *may* reuse its id on the run that resumes
44//! it; a consumer treats that later `SUBAGENT_STARTED` as a continuation,
45//! never a duplicate.
46//!
47//! # Compatibility
48//!
49//! Attribution is additive and safe — an unknown *field* is tolerated — but
50//! the three lifecycle events are unknown *event types* to a client older than
51//! subagent support, and such a client fails while decoding, before any
52//! application code runs. A producer with older consumers must not emit them;
53//! the server runtime's `SubagentVisibility` transformer exists for that.
54//!
55//! The model here follows upstream's `docs/concepts/subagents.mdx`.
56
57use serde::{Deserialize, Serialize};
58use serde_json::Value;
59
60use crate::event::BaseEvent;
61use crate::ids::{MessageId, SubagentRunId, ToolCallId};
62
63/// Announces a subagent invocation and gives it a name a UI can display.
64///
65/// On this event, unlike the attributable ones, `subagent_run_id` is required:
66/// it is the subject, not a tag.
67#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
70#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
71pub struct SubagentStartedEvent {
72    /// Timestamp, raw provider event and metadata.
73    #[serde(flatten)]
74    pub base: BaseEvent,
75    /// Opaque id for this invocation. See the [module docs](self) for what it
76    /// is not.
77    pub subagent_run_id: SubagentRunId,
78    /// The subagent's declared type or name, for display — the reusable half.
79    pub name: String,
80    /// Human-readable description.
81    #[serde(
82        default,
83        deserialize_with = "crate::serde_util::reject_null",
84        skip_serializing_if = "Option::is_none"
85    )]
86    pub description: Option<String>,
87    /// The enclosing subagent, when subagents nest. May name one that has
88    /// already finished — a parent legitimately finishes before its child.
89    #[serde(
90        default,
91        deserialize_with = "crate::serde_util::reject_null",
92        skip_serializing_if = "Option::is_none"
93    )]
94    pub parent_subagent_run_id: Option<SubagentRunId>,
95    /// The tool call that spawned this subagent, for the agents-as-tools
96    /// pattern: lets a consumer render the subagent inside the tool-call card
97    /// without inspecting `rawEvent`.
98    #[serde(
99        default,
100        deserialize_with = "crate::serde_util::reject_null",
101        skip_serializing_if = "Option::is_none"
102    )]
103    pub parent_tool_call_id: Option<ToolCallId>,
104    /// The message that held that tool call.
105    #[serde(
106        default,
107        deserialize_with = "crate::serde_util::reject_null",
108        skip_serializing_if = "Option::is_none"
109    )]
110    pub parent_message_id: Option<MessageId>,
111}
112
113impl SubagentStartedEvent {
114    /// Announces a subagent invocation.
115    pub fn new(subagent_run_id: impl Into<SubagentRunId>, name: impl Into<String>) -> Self {
116        Self {
117            base: BaseEvent::default(),
118            subagent_run_id: subagent_run_id.into(),
119            name: name.into(),
120            description: None,
121            parent_subagent_run_id: None,
122            parent_tool_call_id: None,
123            parent_message_id: None,
124        }
125    }
126
127    /// Sets the description.
128    #[must_use]
129    pub fn with_description(mut self, description: impl Into<String>) -> Self {
130        self.description = Some(description.into());
131        self
132    }
133
134    /// Names the enclosing subagent.
135    #[must_use]
136    pub fn with_parent_subagent(mut self, parent: impl Into<SubagentRunId>) -> Self {
137        self.parent_subagent_run_id = Some(parent.into());
138        self
139    }
140
141    /// Links the subagent to the tool call that spawned it — the
142    /// agents-as-tools pattern, where a UI draws the subagent inside the
143    /// call's card.
144    #[must_use]
145    pub fn with_parent_tool_call(mut self, tool_call_id: impl Into<ToolCallId>) -> Self {
146        self.parent_tool_call_id = Some(tool_call_id.into());
147        self
148    }
149
150    /// Links the subagent to the assistant message that held the spawning
151    /// tool call, when the call sat in one.
152    #[must_use]
153    pub fn with_parent_message(mut self, message_id: impl Into<MessageId>) -> Self {
154        self.parent_message_id = Some(message_id.into());
155        self
156    }
157}
158
159/// How a subagent invocation's stream segment closed for this run.
160///
161/// Mirrors [`RunOutcome`](crate::outcome::RunOutcome) one level down. The
162/// field is optional on `SUBAGENT_FINISHED`, and absent reads as success —
163/// but an explicit `null` is rejected, because the field is newer than the
164/// fix that made every official producer omit valueless fields.
165#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
166#[serde(tag = "type", rename_all = "lowercase")]
167#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
168#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
169pub enum SubagentOutcome {
170    /// The work completed.
171    Success,
172    /// The workflow is paused awaiting outside input — a human approval
173    /// raised inside the subagent, say. The run then ends with an interrupt
174    /// outcome, and because every started subagent closes before
175    /// `RUN_FINISHED`, the paused one still emits `SUBAGENT_FINISHED` — with
176    /// this outcome, so a UI can show "waiting" rather than "done".
177    // `rename_all` on the enum names the variants; the field inside needs its
178    // own, or it goes out as `interrupt_ids`.
179    #[serde(rename_all = "camelCase")]
180    Suspended {
181        /// The run-level interrupts this subagent directly owns; each such
182        /// [`Interrupt`](crate::outcome::Interrupt) carries `subagent_run_id`
183        /// back. May be empty or absent: an ancestor suspended because a
184        /// *descendant* interrupted owns no interrupt itself. Absent or a
185        /// list, never `null`, like every other field on this surface.
186        #[serde(
187            default,
188            skip_serializing_if = "Option::is_none",
189            deserialize_with = "crate::serde_util::reject_null"
190        )]
191        interrupt_ids: Option<Vec<String>>,
192    },
193}
194
195impl SubagentOutcome {
196    /// Builds a suspended outcome naming the interrupts the subagent owns.
197    pub fn suspended(interrupt_ids: impl Into<Vec<String>>) -> Self {
198        Self::Suspended {
199            interrupt_ids: Some(interrupt_ids.into()),
200        }
201    }
202
203    /// Whether the subagent is waiting rather than done.
204    pub const fn is_suspended(&self) -> bool {
205        matches!(self, Self::Suspended { .. })
206    }
207
208    /// The interrupts a suspended subagent owns, or an empty slice.
209    pub fn interrupt_ids(&self) -> &[String] {
210        match self {
211            Self::Success => &[],
212            Self::Suspended { interrupt_ids } => interrupt_ids.as_deref().unwrap_or(&[]),
213        }
214    }
215}
216
217/// Closes a subagent invocation's stream segment for this run.
218#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
221#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
222pub struct SubagentFinishedEvent {
223    /// Timestamp, raw provider event and metadata.
224    #[serde(flatten)]
225    pub base: BaseEvent,
226    /// The invocation being closed — the id from `SUBAGENT_STARTED`.
227    pub subagent_run_id: SubagentRunId,
228    /// The subagent's completion payload, mirroring `RUN_FINISHED.result`.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub result: Option<Value>,
231    /// How it ended. Absent means success (the legacy reading); a JSON `null`
232    /// is rejected — see [`SubagentOutcome`].
233    #[serde(
234        default,
235        deserialize_with = "crate::serde_util::reject_null",
236        skip_serializing_if = "Option::is_none"
237    )]
238    pub outcome: Option<SubagentOutcome>,
239}
240
241impl SubagentFinishedEvent {
242    /// Closes a subagent invocation without declaring an outcome (legacy
243    /// shape, read as success).
244    pub fn new(subagent_run_id: impl Into<SubagentRunId>) -> Self {
245        Self {
246            base: BaseEvent::default(),
247            subagent_run_id: subagent_run_id.into(),
248            result: None,
249            outcome: None,
250        }
251    }
252
253    /// Sets the completion payload.
254    #[must_use]
255    pub fn with_result(mut self, result: impl Into<Value>) -> Self {
256        self.result = Some(result.into());
257        self
258    }
259
260    /// Sets the outcome.
261    #[must_use]
262    pub fn with_outcome(mut self, outcome: SubagentOutcome) -> Self {
263        self.outcome = Some(outcome);
264        self
265    }
266}
267
268/// Marks a subagent invocation as failed.
269#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase")]
271#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
272#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
273pub struct SubagentErrorEvent {
274    /// Timestamp, raw provider event and metadata.
275    #[serde(flatten)]
276    pub base: BaseEvent,
277    /// The invocation that failed — the id from `SUBAGENT_STARTED`.
278    pub subagent_run_id: SubagentRunId,
279    /// What went wrong, for a human.
280    pub message: String,
281    /// Machine-readable error code.
282    #[serde(
283        default,
284        deserialize_with = "crate::serde_util::reject_null",
285        skip_serializing_if = "Option::is_none"
286    )]
287    pub code: Option<String>,
288}
289
290impl SubagentErrorEvent {
291    /// Fails a subagent invocation with a message.
292    pub fn new(subagent_run_id: impl Into<SubagentRunId>, message: impl Into<String>) -> Self {
293        Self {
294            base: BaseEvent::default(),
295            subagent_run_id: subagent_run_id.into(),
296            message: message.into(),
297            code: None,
298        }
299    }
300
301    /// Sets the error code.
302    #[must_use]
303    pub fn with_code(mut self, code: impl Into<String>) -> Self {
304        self.code = Some(code.into());
305        self
306    }
307}