Skip to main content

adk_graph/
interrupt.rs

1//! Human-in-the-loop interrupt types
2
3use adk_core::ToolConfirmationRequest;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// Interrupt request from a node or configuration
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub enum Interrupt {
10    /// Interrupt before executing a node
11    Before(String),
12    /// Interrupt after executing a node
13    After(String),
14    /// Dynamic interrupt from within a node
15    Dynamic {
16        /// Message to display to the user
17        message: String,
18        /// Optional data for the interrupt
19        data: Option<Value>,
20    },
21}
22
23impl std::fmt::Display for Interrupt {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::Before(node) => write!(f, "Interrupt before '{}'", node),
27            Self::After(node) => write!(f, "Interrupt after '{}'", node),
28            Self::Dynamic { message, .. } => write!(f, "Dynamic interrupt: {}", message),
29        }
30    }
31}
32
33/// Helper to create a dynamic interrupt from within a node
34pub fn interrupt(message: &str) -> Interrupt {
35    Interrupt::Dynamic { message: message.to_string(), data: None }
36}
37
38/// Helper to create a dynamic interrupt with data
39pub fn interrupt_with_data(message: &str, data: Value) -> Interrupt {
40    Interrupt::Dynamic { message: message.to_string(), data: Some(data) }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44struct PendingGraphToolConfirmation {
45    node: String,
46    request: ToolConfirmationRequest,
47}
48
49/// A persisted tool-authorization pause emitted by a graph.
50///
51/// Use [`Self::from_stream_event`] when directly streaming a graph, or
52/// [`GraphInterruptPayload::tool_confirmation`] when the graph runs as a
53/// [`crate::agent::GraphAgent`].
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct GraphToolConfirmationPause {
57    /// Graph node whose agent requested authorization.
58    pub node: String,
59    /// The exact tool call the caller must approve or deny.
60    pub request: ToolConfirmationRequest,
61    /// Thread to resume after a decision.
62    pub thread_id: String,
63    /// Checkpoint saved before the pause was reported.
64    pub checkpoint_id: String,
65}
66
67impl GraphToolConfirmationPause {
68    /// Reserved custom-event and dynamic-interrupt marker for tool confirmation.
69    pub const KIND: &str = "tool_confirmation";
70
71    /// Read a tool-confirmation pause from a graph stream event.
72    pub fn from_stream_event(event: &crate::stream::StreamEvent) -> Option<Self> {
73        let crate::stream::StreamEvent::Custom { event_type, data, .. } = event else {
74            return None;
75        };
76        (event_type == Self::KIND).then(|| serde_json::from_value(data.clone()).ok()).flatten()
77    }
78
79    /// Read a tool-confirmation pause from an interrupted graph execution.
80    pub fn from_interrupted_execution(
81        interrupted: &crate::error::InterruptedExecution,
82    ) -> Option<Self> {
83        let (node, request) = pending_from_interrupt(&interrupted.interrupt)?;
84        Some(Self {
85            node,
86            request,
87            thread_id: interrupted.thread_id.clone(),
88            checkpoint_id: interrupted.checkpoint_id.clone(),
89        })
90    }
91
92    pub(crate) fn pending_interrupt(node: String, request: ToolConfirmationRequest) -> Interrupt {
93        Interrupt::Dynamic {
94            message: Self::KIND.to_string(),
95            data: Some(Self::pending_data(node, request)),
96        }
97    }
98
99    pub(crate) fn pending_data(node: String, request: ToolConfirmationRequest) -> Value {
100        serde_json::to_value(PendingGraphToolConfirmation { node, request }).unwrap_or(Value::Null)
101    }
102}
103
104fn pending_from_interrupt(interrupt: &Interrupt) -> Option<(String, ToolConfirmationRequest)> {
105    let Interrupt::Dynamic { message, data } = interrupt else { return None };
106    if message != GraphToolConfirmationPause::KIND {
107        return None;
108    }
109    let pending: PendingGraphToolConfirmation = serde_json::from_value(data.clone()?).ok()?;
110    Some((pending.node, pending.request))
111}
112
113/// The reserved `Event::provider_metadata` key carrying a graph interrupt.
114pub const INTERRUPT_METADATA_KEY: &str = "adk.graph.interrupt";
115
116/// A graph interrupt as it crosses the [`Agent`](adk_core::Agent) boundary.
117///
118/// `GraphAgent` cannot return `GraphError::Interrupted` to a `Runner`: the trait
119/// yields events, and an error would end the invocation. It therefore emits one
120/// event carrying this payload, so a caller can read which node paused, why, and
121/// which checkpoint to resume from.
122///
123/// It travels as JSON in `Event::provider_metadata` under
124/// [`INTERRUPT_METADATA_KEY`] rather than as a field on `adk_core::EventActions`.
125/// A graph-shaped type in `adk-core` would put a Tier 3 concept in a Tier 1
126/// crate; every other consumer of the event is unaffected by an extra metadata
127/// key.
128///
129/// # Example
130///
131/// ```rust,no_run
132/// use adk_graph::interrupt::GraphInterruptPayload;
133/// # fn handle(event: &adk_core::Event) {
134/// if let Some(pause) = GraphInterruptPayload::from_event(event) {
135///     println!("paused at {:?}: {:?}", pause.node, pause.message);
136///     // Resume by invoking the same thread again, supplying any decision.
137/// }
138/// # }
139/// ```
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct GraphInterruptPayload {
142    /// `"before"`, `"after"`, or `"dynamic"`.
143    pub kind: String,
144    /// The gated node, for a static interrupt. `None` for a dynamic one, which
145    /// carries a message instead.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub node: Option<String>,
148    /// The message a node supplied, for a dynamic interrupt.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub message: Option<String>,
151    /// The data a node attached with `NodeOutput::interrupt_with_data`.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub data: Option<Value>,
154    /// The thread to resume.
155    pub thread_id: String,
156    /// The checkpoint the run stopped at.
157    pub checkpoint_id: String,
158}
159
160impl GraphInterruptPayload {
161    /// Build a payload from an interrupt and the run it stopped.
162    pub fn new(interrupt: &Interrupt, thread_id: &str, checkpoint_id: &str) -> Self {
163        let (kind, node, message, data) = match interrupt {
164            Interrupt::Before(node) => ("before", Some(node.clone()), None, None),
165            Interrupt::After(node) => ("after", Some(node.clone()), None, None),
166            Interrupt::Dynamic { message, data } => {
167                ("dynamic", None, Some(message.clone()), data.clone())
168            }
169        };
170        Self {
171            kind: kind.to_string(),
172            node,
173            message,
174            data,
175            thread_id: thread_id.to_string(),
176            checkpoint_id: checkpoint_id.to_string(),
177        }
178    }
179
180    /// Read the payload from an event, or `None` if the event is not a graph
181    /// interrupt.
182    pub fn from_event(event: &adk_core::Event) -> Option<Self> {
183        let raw = event.provider_metadata.get(INTERRUPT_METADATA_KEY)?;
184        serde_json::from_str(raw).ok()
185    }
186
187    /// Build a graph-agent interrupt payload for a tool-confirmation pause.
188    pub fn from_tool_confirmation_pause(pause: GraphToolConfirmationPause) -> Self {
189        let thread_id = pause.thread_id.clone();
190        let checkpoint_id = pause.checkpoint_id.clone();
191        Self {
192            kind: GraphToolConfirmationPause::KIND.to_string(),
193            node: Some(pause.node.clone()),
194            message: None,
195            data: serde_json::to_value(pause).ok(),
196            thread_id,
197            checkpoint_id,
198        }
199    }
200
201    /// Read the structured tool-confirmation pause, if this payload represents one.
202    pub fn tool_confirmation(&self) -> Option<GraphToolConfirmationPause> {
203        if self.kind != GraphToolConfirmationPause::KIND {
204            return None;
205        }
206        serde_json::from_value(self.data.clone()?).ok()
207    }
208
209    /// Serialize for transport in `Event::provider_metadata`.
210    pub fn to_metadata_value(&self) -> String {
211        serde_json::to_string(self).unwrap_or_default()
212    }
213}