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}