Skip to main content

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}