entelix_core/interruption.rs
1//! Single human-in-the-loop primitive for the entelix runtime.
2//!
3//! Every paused dispatch flows through one shape:
4//!
5//! ```text
6//! Error::Interrupted { kind: InterruptionKind, payload: serde_json::Value }
7//! ```
8//!
9//! - **`kind`** — typed reason. SDK-defined variants like
10//! [`InterruptionKind::ApprovalPending`] carry the structured data
11//! the resumer needs (e.g. `tool_use_id`); operator-defined pauses
12//! use [`InterruptionKind::Custom`].
13//! - **`payload`** — operator free-form data, passed straight through
14//! to the resumer. For typed kinds (`ApprovalPending`) the payload
15//! is usually `Value::Null`; for `Custom` it carries whatever the
16//! tool / node decided to surface.
17//!
18//! Mid-tool, mid-node, and middleware-layer pauses all use the same
19//! primitive — return [`Err`] from `Tool::execute`, a graph node's
20//! `Runnable<S, S>::invoke`, or any `tower::Layer`'s `call` future.
21//! The dispatch loop catches the error, persists a checkpoint, and
22//! returns it to the caller; the caller resumes via
23//! `entelix_graph::CompiledGraph::resume_with(Command, &ctx)`.
24
25use serde::{Deserialize, Serialize};
26
27use crate::error::Error;
28
29/// Phase at which a graph-scheduled pause fires — before the marked
30/// node runs (`Before`) or after it returns Ok (`After`).
31///
32/// `non_exhaustive` matches the workspace-wide pub-enum hygiene gate
33/// (`cargo xtask surface-hygiene`). The `Before` / `After` partition
34/// of node-boundary timing is conceptually closed, so operator match
35/// sites typically use a `_ => unreachable!("…")` fall-through arm
36/// — the marker exists as future-proofing against a deliberate SDK
37/// addition (e.g. wrapper phases for fan-out joins) rather than a
38/// signal of an open variant set.
39#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "snake_case")]
41#[non_exhaustive]
42pub enum InterruptionPhase {
43 /// Pause before the marked node executes; resume re-runs the
44 /// node from saved pre-state.
45 Before,
46 /// Pause after the marked node returns Ok; resume continues
47 /// forward, skipping a re-run of the just-completed node.
48 After,
49}
50
51/// Why a dispatch paused. SDK variants carry typed structured data
52/// the resumer needs to thread the decision back; operator-defined
53/// pauses surface as [`Self::Custom`] with arbitrary payload.
54///
55/// `non_exhaustive` so post-1.0 SDK variants ship as MINOR; operator
56/// match sites should always carry a fall-through `_ =>` arm for
57/// future-proofing.
58#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(tag = "type", rename_all = "snake_case")]
60#[non_exhaustive]
61pub enum InterruptionKind {
62 /// Operator-defined pause. The associated `payload` on
63 /// [`Error::Interrupted`] carries whatever the tool, node, or
64 /// layer surfaced. Default kind for [`crate::interrupt`].
65 Custom,
66 /// A tool-dispatch approval is pending. The agent runtime's
67 /// `ApprovalLayer` raises this kind when an `Approver` returns
68 /// `ApprovalDecision::AwaitExternal`. Resume via
69 /// `entelix_graph::Command::ApproveTool { tool_use_id, decision }`
70 /// — the SDK threads the decision to the resumed dispatch.
71 ApprovalPending {
72 /// The pending tool-use id awaiting an external decision.
73 /// Identical to the `tool_use_id` carried on the upstream
74 /// `ContentPart::ToolUse` block; the resumer routes the
75 /// `ApprovalDecision` to the correct in-flight call.
76 tool_use_id: String,
77 },
78 /// A graph-scheduled pause point fired
79 /// (`StateGraph::interrupt_before` / `interrupt_after`). The
80 /// `phase` and `node` together identify which node the pause
81 /// fired around — the resumer reads them to surface
82 /// `"paused before <node-name>"` / `"paused after <node-name>"`
83 /// without inspecting the payload's free-form structure.
84 ScheduledPause {
85 /// Whether the pause fired before or after the node ran.
86 phase: InterruptionPhase,
87 /// Name of the node the pause is anchored to.
88 node: String,
89 },
90}
91
92/// Pause the current dispatch with a [`InterruptionKind::Custom`]
93/// reason. The most common HITL primitive — wrap any tool-body
94/// branch, node body, or layer hook that needs to hand control back
95/// to the caller for human review.
96///
97/// Returns `Err(Error::Interrupted { kind: Custom, payload })` so
98/// call sites read as `return interrupt(value);`. For typed kinds,
99/// reach for [`interrupt_with`].
100pub fn interrupt<T>(payload: serde_json::Value) -> Result<T, Error> {
101 Err(Error::Interrupted {
102 kind: InterruptionKind::Custom,
103 payload,
104 })
105}
106
107/// Pause the current dispatch with a typed [`InterruptionKind`].
108/// Used by SDK-internal sites (the agent runtime's `ApprovalLayer`
109/// raises [`InterruptionKind::ApprovalPending`] this way) and by
110/// operators with structured pause-reasons of their own.
111///
112/// `payload` is operator free-form context that survives round-trip
113/// through the `SessionLog`. Pass `Value::Null` when the typed kind
114/// already carries everything the resumer needs.
115pub fn interrupt_with<T>(kind: InterruptionKind, payload: serde_json::Value) -> Result<T, Error> {
116 Err(Error::Interrupted { kind, payload })
117}
118
119#[cfg(test)]
120#[allow(clippy::unwrap_used)]
121mod tests {
122 use super::*;
123 use serde_json::json;
124
125 #[test]
126 fn interrupt_returns_custom_kind() {
127 let err = interrupt::<()>(json!({"need": "review"})).unwrap_err();
128 match err {
129 Error::Interrupted { kind, payload } => {
130 assert!(matches!(kind, InterruptionKind::Custom));
131 assert_eq!(payload, json!({"need": "review"}));
132 }
133 other => panic!("expected Interrupted, got {other:?}"),
134 }
135 }
136
137 #[test]
138 fn interrupt_with_carries_typed_kind() {
139 let err = interrupt_with::<()>(
140 InterruptionKind::ApprovalPending {
141 tool_use_id: "tu-1".into(),
142 },
143 serde_json::Value::Null,
144 )
145 .unwrap_err();
146 match err {
147 Error::Interrupted { kind, .. } => {
148 assert_eq!(
149 kind,
150 InterruptionKind::ApprovalPending {
151 tool_use_id: "tu-1".into()
152 }
153 );
154 }
155 other => panic!("expected Interrupted, got {other:?}"),
156 }
157 }
158
159 #[test]
160 fn interruption_kind_serializes_with_typed_tag() {
161 let custom = serde_json::to_value(&InterruptionKind::Custom).unwrap();
162 assert_eq!(custom, json!({"type": "custom"}));
163 let approval = serde_json::to_value(&InterruptionKind::ApprovalPending {
164 tool_use_id: "tu-1".into(),
165 })
166 .unwrap();
167 assert_eq!(
168 approval,
169 json!({"type": "approval_pending", "tool_use_id": "tu-1"})
170 );
171 }
172}