Skip to main content

ag_ui/
outcome.rs

1//! Run outcomes and the human-in-the-loop interrupt protocol.
2//!
3//! A run does not only end in success or error. It can also *pause*: the agent
4//! emits `RUN_FINISHED` with an [`RunOutcome::Interrupt`] outcome listing one or
5//! more [`Interrupt`]s, the client collects the answers, and the next request
6//! resumes the run by passing [`ResumeEntry`] values in
7//! [`RunAgentInput::resume`](crate::input::RunAgentInput::resume).
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::JsonObject;
13use crate::error::{Error, Result};
14use crate::ids::{SubagentRunId, ToolCallId};
15
16/// A request for human input that pauses the run.
17#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
20#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
21pub struct Interrupt {
22    /// Correlation id — echoed back as
23    /// [`ResumeEntry::interrupt_id`] when the run resumes.
24    pub id: String,
25    /// Machine-readable reason, for example `"tool_approval"`.
26    pub reason: String,
27    /// Human-readable prompt to show the user.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub message: Option<String>,
30    /// The tool call awaiting approval, when the interrupt is about one.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub tool_call_id: Option<ToolCallId>,
33    /// JSON Schema the resume payload must satisfy — lets a client render a
34    /// form for the answer.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    #[cfg_attr(
37        feature = "schemars",
38        schemars(with = "Option<std::collections::BTreeMap<String, serde_json::Value>>")
39    )]
40    #[cfg_attr(feature = "utoipa", schema(value_type = Option<Object>))]
41    pub response_schema: Option<JsonObject>,
42    /// When the interrupt stops being answerable, as an ISO-8601 timestamp.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub expires_at: Option<String>,
45    /// Integration-specific extras.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    #[cfg_attr(
48        feature = "schemars",
49        schemars(with = "Option<std::collections::BTreeMap<String, serde_json::Value>>")
50    )]
51    #[cfg_attr(feature = "utoipa", schema(value_type = Option<Object>))]
52    pub metadata: Option<JsonObject>,
53    /// The subagent whose work raised this interrupt, when it was raised
54    /// inside one — absent for a root-raised interrupt. Attribution lives on
55    /// each interrupt rather than on `RUN_FINISHED` because one run can carry
56    /// interrupts from several subagents; a client uses it to render the
57    /// approval request inside that subagent's group. A JSON `null` is
58    /// rejected — see [`crate::event::subagent`].
59    #[serde(
60        default,
61        deserialize_with = "crate::serde_util::reject_null",
62        skip_serializing_if = "Option::is_none"
63    )]
64    pub subagent_run_id: Option<SubagentRunId>,
65}
66
67impl Interrupt {
68    /// Builds an interrupt from its two required fields.
69    pub fn new(id: impl Into<String>, reason: impl Into<String>) -> Self {
70        Self {
71            id: id.into(),
72            reason: reason.into(),
73            ..Default::default()
74        }
75    }
76
77    /// Attributes the interrupt to the subagent that raised it.
78    #[must_use]
79    pub fn with_subagent_run_id(mut self, subagent_run_id: impl Into<SubagentRunId>) -> Self {
80        self.subagent_run_id = Some(subagent_run_id.into());
81        self
82    }
83}
84
85/// How a run ended.
86///
87/// The field is optional on `RUN_FINISHED`: producers that predate the
88/// interrupt protocol omit it entirely, which consumers must read as success.
89#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
90#[serde(tag = "type", rename_all = "lowercase")]
91#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
92#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
93pub enum RunOutcome {
94    /// The run completed.
95    Success,
96    /// The run is paused, waiting on the answers to `interrupts`.
97    Interrupt {
98        /// The pending requests. The protocol requires at least one; see
99        /// [`RunOutcome::validate`].
100        interrupts: Vec<Interrupt>,
101    },
102}
103
104impl RunOutcome {
105    /// Builds an interrupt outcome.
106    pub fn interrupt(interrupts: impl Into<Vec<Interrupt>>) -> Self {
107        Self::Interrupt {
108            interrupts: interrupts.into(),
109        }
110    }
111
112    /// Whether the run is paused rather than finished.
113    pub const fn is_interrupt(&self) -> bool {
114        matches!(self, Self::Interrupt { .. })
115    }
116
117    /// The pending interrupts, or an empty slice for a success outcome.
118    pub fn interrupts(&self) -> &[Interrupt] {
119        match self {
120            Self::Success => &[],
121            Self::Interrupt { interrupts } => interrupts,
122        }
123    }
124
125    /// Checks the one rule the type system cannot: an `interrupt` outcome must
126    /// carry at least one interrupt.
127    ///
128    /// Deserializing does not enforce this, so that a stray empty array from a
129    /// buggy producer surfaces as a protocol error you can log rather than as
130    /// an unparseable event that kills the stream.
131    pub fn validate(&self) -> Result<()> {
132        match self {
133            Self::Interrupt { interrupts } if interrupts.is_empty() => Err(Error::Protocol(
134                "RUN_FINISHED outcome `interrupt` requires at least one interrupt".to_owned(),
135            )),
136            _ => Ok(()),
137        }
138    }
139}
140
141/// How the client answered one interrupt.
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
143#[serde(rename_all = "lowercase")]
144#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
145#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
146pub enum ResumeStatus {
147    /// The user answered; `payload` carries the answer.
148    Resolved,
149    /// The user declined or the request timed out.
150    Cancelled,
151}
152
153impl ResumeStatus {
154    /// The status string as it appears on the wire.
155    pub const fn as_str(&self) -> &'static str {
156        match self {
157            Self::Resolved => "resolved",
158            Self::Cancelled => "cancelled",
159        }
160    }
161}
162
163/// One answer to one [`Interrupt`], sent on the resuming request.
164#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
167#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
168pub struct ResumeEntry {
169    /// The [`Interrupt::id`] being answered.
170    pub interrupt_id: String,
171    /// Whether the user answered or declined.
172    pub status: ResumeStatus,
173    /// The answer. Shape is up to the agent; when the interrupt supplied a
174    /// `responseSchema`, this should satisfy it. For a tool approval that was
175    /// edited before approval, agents that advertise `approveWithEdits` expect
176    /// `{"editedArgs": …}` here.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub payload: Option<Value>,
179    /// Envelope data about the answer — signatures, routing keys — as opposed
180    /// to `payload`, which is the answer the agent asked for and will act on.
181    /// A request field, so nothing merges into it. Absent or an object — a
182    /// JSON `null` is rejected. See [`crate::metadata`].
183    #[serde(
184        default,
185        deserialize_with = "crate::serde_util::reject_null",
186        skip_serializing_if = "Option::is_none"
187    )]
188    #[cfg_attr(
189        feature = "schemars",
190        schemars(with = "Option<std::collections::BTreeMap<String, serde_json::Value>>")
191    )]
192    #[cfg_attr(feature = "utoipa", schema(value_type = Option<Object>))]
193    pub metadata: Option<JsonObject>,
194}
195
196impl ResumeEntry {
197    /// Answers an interrupt with a payload.
198    pub fn resolved(interrupt_id: impl Into<String>, payload: impl Into<Value>) -> Self {
199        Self {
200            interrupt_id: interrupt_id.into(),
201            status: ResumeStatus::Resolved,
202            payload: Some(payload.into()),
203            metadata: None,
204        }
205    }
206
207    /// Declines an interrupt.
208    pub fn cancelled(interrupt_id: impl Into<String>) -> Self {
209        Self {
210            interrupt_id: interrupt_id.into(),
211            status: ResumeStatus::Cancelled,
212            payload: None,
213            metadata: None,
214        }
215    }
216
217    /// Attaches envelope metadata to the answer.
218    #[must_use]
219    pub fn with_metadata(mut self, metadata: JsonObject) -> Self {
220        self.metadata = Some(metadata);
221        self
222    }
223}