Skip to main content

aion_core/
intervention.rs

1//! The `InterventionCommand` type — the harness-neutral mid-run control vocabulary.
2//!
3//! This module defines the *typed contract* for the live, best-effort, mid-run control channel
4//! INTO a running agent. It is the sibling of [`crate::activity_event`] (events flow OUT;
5//! interventions flow IN) and, like it, a **non-replay real-time DTO** that crosses the
6//! Rust -> TypeScript boundary via `ts-rs` into the ops-console generated bindings.
7//!
8//! # Harness neutrality (LOCKED)
9//!
10//! The command vocabulary is defined in **harness-neutral semantic primitives**, never in any
11//! harness's native terms. There is no `Norn`, no `Steer`, no `Update`, no `CancellationToken`,
12//! and no JSON-RPC concept anywhere in this module. The wire, the server, and the ops console
13//! speak ONLY these neutral primitives; ALL harness-specific translation lives in exactly one
14//! place — the worker-side per-harness adapter — never in this module.
15//!
16//! **The design test:** a primitive belongs in the neutral enum ONLY if it can plausibly map
17//! onto a non-specific conversational-agent harness. Anything that only makes sense as one
18//! harness's feature belongs behind the adapter, not here.
19//!
20//! # Capability gating and the empty set
21//!
22//! A harness advertises **which** neutral primitives it supports via [`InterventionCapabilities`].
23//! An **empty** capability set is first-class and valid, not an error: an observability-only
24//! harness advertises no primitives, and the ops console offers no controls for it. The server
25//! gates every command against the advertised set before it is ever routed.
26//!
27//! # Observability, never replay
28//!
29//! An intervention is recorded as a durable observability event (so the transcript shows
30//! "operator intervened here") but is **never** part of the workflow replay log. In particular
31//! [`InterventionKind::Cancel`] stops the *agent run* as a control act; it does NOT write
32//! workflow replay state — a workflow-visible cancel/signal is a different thing entirely and
33//! stays on the engine's replay-log paths. These types carry no behaviour — they are pure data.
34
35use chrono::{DateTime, Utc};
36use serde::{Deserialize, Serialize};
37
38use crate::ids::{ActivityId, WorkflowId};
39
40/// Priority of an injected out-of-band message.
41#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
42#[serde(tag = "priority")]
43pub enum InjectPriority {
44    /// A queued user turn — batches, and may not wake an idle agent.
45    Normal,
46    /// Act now. This is what "steer" is: an interrupt-priority injection.
47    Interrupt,
48}
49
50/// A decision answering a pending human-in-the-loop approval gate.
51#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
52#[serde(tag = "decision")]
53pub enum ApprovalDecision {
54    /// Allow the agent's proposed next action to proceed.
55    Approve,
56    /// Decline the agent's proposed next action.
57    Deny,
58}
59
60/// The complete set of harness-neutral mid-run control primitives.
61///
62/// Exactly five primitives — the whole universal agent-control surface. Each is gated by the
63/// harness's advertised [`InterventionCapabilities`]. None is specific to any one harness:
64/// [`Self::InjectMessage`] and [`Self::Cancel`] are universal; [`Self::PauseResume`] is the
65/// standard suspend/resume any stepped agent loop can expose; [`Self::UpdateBudget`] maps onto
66/// any harness with token/turn limits; [`Self::RespondToApproval`] maps onto any harness with a
67/// tool-use / permission gate.
68#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
69#[serde(tag = "kind")]
70pub enum InterventionKind {
71    /// Inject an out-of-band user turn into the running agent (steer / redirect / add context).
72    ///
73    /// SUBSUMES "steer": steering is just an [`InjectPriority::Interrupt`] injection. There is no
74    /// separate `Steer`/`Update` variant in the neutral enum.
75    InjectMessage {
76        /// The message text to inject.
77        text: String,
78        /// Whether to act now (`Interrupt`) or queue the turn (`Normal`).
79        priority: InjectPriority,
80    },
81    /// Stop the agent run (this run's current execution).
82    ///
83    /// This is an observability/control act and is DISTINCT from a workflow-visible
84    /// cancel/signal, which stays on the engine's replay-log paths and is NOT an intervention.
85    Cancel {
86        /// Human-readable reason for the cancellation.
87        reason: String,
88    },
89    /// Suspend or resume the agent between steps.
90    ///
91    /// Capability-gated: harnesses that cannot suspend mid-step advertise no support for it.
92    PauseResume {
93        /// `true` to suspend, `false` to resume.
94        paused: bool,
95    },
96    /// Adjust the run's resource limits mid-flight.
97    UpdateBudget {
98        /// New maximum token budget, when the operator sets one.
99        max_tokens: Option<u64>,
100        /// New maximum turn budget, when the operator sets one.
101        max_turns: Option<u32>,
102    },
103    /// Answer a pending tool-use / permission gate — human-in-the-loop approval of the agent's
104    /// next action.
105    RespondToApproval {
106        /// Correlation id of the pending approval being answered.
107        call_id: String,
108        /// The approve/deny decision.
109        decision: ApprovalDecision,
110        /// An optional note recorded alongside the decision.
111        note: Option<String>,
112    },
113}
114
115/// A mid-run control command routed operator -> server -> the worker owning the activity-attempt.
116///
117/// Recorded as a durable observability event (auditable, visible on transcript replay) but
118/// **never** part of the workflow replay log. A command addressed to a stale attempt is a no-op.
119#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
120pub struct InterventionCommand {
121    /// The workflow the target activity belongs to.
122    pub workflow_id: WorkflowId,
123    /// The target activity within the workflow.
124    pub activity_id: ActivityId,
125    /// The target attempt. Commands to a stale (superseded) attempt are no-ops.
126    pub attempt: u32,
127    /// The auth subject that issued the command, when auth is enabled.
128    ///
129    /// A neutral subject label (the same identity the auth layer records), carried so the
130    /// transcript can attribute the intervention. `None` when auth is off.
131    pub issued_by: Option<String>,
132    /// When the command was issued (operator-clock instant).
133    pub issued_at: DateTime<Utc>,
134    /// The neutral control primitive to apply.
135    pub kind: InterventionKind,
136}
137
138/// The neutral outcome of routing one [`InterventionCommand`] to the worker owning the target
139/// attempt — the ack that surfaces back to the operator.
140///
141/// The three variants ARE the three distinct outcome classes the design locks (§6.4), expressed
142/// harness-neutrally so the wire, the server, and the ops console never inspect a harness error:
143///
144/// - [`Self::Applied`] — the session accepted and applied the command.
145/// - [`Self::CapabilityNotSupported`] — the target harness does not advertise the command's
146///   primitive. The server gates on the advertised set BEFORE routing, so this is normally
147///   returned by the server without a wire round-trip; a worker returns it too if a gated command
148///   still reaches it.
149/// - [`Self::StaleTarget`] — the target `(workflow, activity, attempt)` is finished, superseded by
150///   a later attempt, or unknown (the attempt-scoped no-op). It is an honest NACK, never a crash.
151///
152/// Carried as its own enum (not a `Result`) so it round-trips over `ts-rs` into the ops console
153/// exactly like the other real-time DTOs, and so a future outcome class is an additive variant.
154#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
155#[serde(tag = "outcome")]
156pub enum InterventionOutcome {
157    /// The command was delivered to the live session and applied.
158    Applied,
159    /// The command's primitive is not in the target harness's advertised capability set.
160    CapabilityNotSupported {
161        /// The primitive the target does not support.
162        primitive: InterventionPrimitive,
163    },
164    /// The target attempt is finished, superseded, or unknown — an attempt-scoped no-op.
165    StaleTarget {
166        /// Human-readable detail describing why the target is stale.
167        detail: String,
168    },
169}
170
171impl InterventionOutcome {
172    /// Returns `true` when the command was applied to a live session.
173    #[must_use]
174    pub const fn is_applied(&self) -> bool {
175        matches!(self, Self::Applied)
176    }
177
178    /// Builds a [`Self::CapabilityNotSupported`] naming the ungated primitive.
179    #[must_use]
180    pub const fn capability_not_supported(primitive: InterventionPrimitive) -> Self {
181        Self::CapabilityNotSupported { primitive }
182    }
183
184    /// Builds a [`Self::StaleTarget`] with a detail message.
185    #[must_use]
186    pub fn stale_target(detail: impl Into<String>) -> Self {
187        Self::StaleTarget {
188            detail: detail.into(),
189        }
190    }
191}
192
193/// A single neutral intervention primitive, independent of any command payload.
194///
195/// The discriminant an [`InterventionCapabilities`] advertises and the primitive each
196/// [`InterventionKind`] belongs to. Modelled as its own enum (rather than five booleans) so the
197/// capability set is an explicit set of primitives with no fixed-width shape to grow.
198#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
199#[serde(tag = "primitive")]
200pub enum InterventionPrimitive {
201    /// Corresponds to [`InterventionKind::InjectMessage`].
202    InjectMessage,
203    /// Corresponds to [`InterventionKind::Cancel`].
204    Cancel,
205    /// Corresponds to [`InterventionKind::PauseResume`].
206    PauseResume,
207    /// Corresponds to [`InterventionKind::UpdateBudget`].
208    UpdateBudget,
209    /// Corresponds to [`InterventionKind::RespondToApproval`].
210    RespondToApproval,
211}
212
213impl InterventionKind {
214    /// The neutral primitive this command belongs to.
215    #[must_use]
216    pub const fn primitive(&self) -> InterventionPrimitive {
217        match self {
218            Self::InjectMessage { .. } => InterventionPrimitive::InjectMessage,
219            Self::Cancel { .. } => InterventionPrimitive::Cancel,
220            Self::PauseResume { .. } => InterventionPrimitive::PauseResume,
221            Self::UpdateBudget { .. } => InterventionPrimitive::UpdateBudget,
222            Self::RespondToApproval { .. } => InterventionPrimitive::RespondToApproval,
223        }
224    }
225}
226
227/// The set of neutral intervention primitives a harness advertises support for.
228///
229/// The server and ops console gate on THIS, never on harness identity. An **empty** set is a
230/// first-class, valid advertisement — an observability-only harness supports no interventions,
231/// and the console offers no controls for it. It is a legitimate tier, not a degenerate one.
232///
233/// Modelled as an explicit list of supported [`InterventionPrimitive`]s. Duplicates are ignored
234/// by the accessors; ordering is not significant.
235#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, Default)]
236pub struct InterventionCapabilities {
237    /// The primitives this harness supports. Empty = observability-only.
238    pub supported: Vec<InterventionPrimitive>,
239}
240
241impl InterventionCapabilities {
242    /// The empty capability set — an observability-only harness that supports no interventions.
243    ///
244    /// This is a first-class, valid advertisement (not an error): the ops console offers no
245    /// controls for a harness advertising it.
246    #[must_use]
247    pub fn none() -> Self {
248        Self::default()
249    }
250
251    /// Builds a capability set from an iterator of supported primitives.
252    pub fn from_primitives(primitives: impl IntoIterator<Item = InterventionPrimitive>) -> Self {
253        Self {
254            supported: primitives.into_iter().collect(),
255        }
256    }
257
258    /// Returns `true` when no intervention primitive is supported (observability-only).
259    #[must_use]
260    pub fn is_empty(&self) -> bool {
261        self.supported.is_empty()
262    }
263
264    /// Returns `true` when the given primitive is advertised as supported.
265    #[must_use]
266    pub fn supports_primitive(&self, primitive: InterventionPrimitive) -> bool {
267        self.supported.contains(&primitive)
268    }
269
270    /// Returns `true` when the given command's primitive is advertised as supported.
271    ///
272    /// The server uses this to refuse an unadvertised primitive before it is ever routed.
273    #[must_use]
274    pub fn supports(&self, kind: &InterventionKind) -> bool {
275        self.supports_primitive(kind.primitive())
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use chrono::{DateTime, Utc};
282    use serde::de::DeserializeOwned;
283
284    use super::{
285        ApprovalDecision, InjectPriority, InterventionCapabilities, InterventionCommand,
286        InterventionKind, InterventionOutcome, InterventionPrimitive, WorkflowId,
287    };
288    use crate::ids::ActivityId;
289
290    fn fixed_time() -> DateTime<Utc> {
291        DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default()
292    }
293
294    fn round_trip<T>(value: &T) -> Result<T, serde_json::Error>
295    where
296        T: DeserializeOwned + serde::Serialize,
297    {
298        let json = serde_json::to_string(value)?;
299        serde_json::from_str::<T>(&json)
300    }
301
302    fn command(kind: InterventionKind) -> InterventionCommand {
303        InterventionCommand {
304            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
305            activity_id: ActivityId::from_sequence_position(3),
306            attempt: 1,
307            issued_by: Some("operator@example.com".to_owned()),
308            issued_at: fixed_time(),
309            kind,
310        }
311    }
312
313    #[test]
314    fn every_intervention_variant_round_trips() -> Result<(), Box<dyn std::error::Error>> {
315        let kinds = vec![
316            InterventionKind::InjectMessage {
317                text: "use the other module".to_owned(),
318                priority: InjectPriority::Interrupt,
319            },
320            InterventionKind::InjectMessage {
321                text: "some context".to_owned(),
322                priority: InjectPriority::Normal,
323            },
324            InterventionKind::Cancel {
325                reason: "operator abort".to_owned(),
326            },
327            InterventionKind::PauseResume { paused: true },
328            InterventionKind::PauseResume { paused: false },
329            InterventionKind::UpdateBudget {
330                max_tokens: Some(10_000),
331                max_turns: None,
332            },
333            InterventionKind::RespondToApproval {
334                call_id: "call-9".to_owned(),
335                decision: ApprovalDecision::Approve,
336                note: Some("looks fine".to_owned()),
337            },
338            InterventionKind::RespondToApproval {
339                call_id: "call-10".to_owned(),
340                decision: ApprovalDecision::Deny,
341                note: None,
342            },
343        ];
344        for kind in kinds {
345            let cmd = command(kind);
346            let decoded = round_trip(&cmd)?;
347            assert_eq!(cmd, decoded);
348        }
349        Ok(())
350    }
351
352    #[test]
353    fn command_without_auth_subject_round_trips() -> Result<(), Box<dyn std::error::Error>> {
354        let mut cmd = command(InterventionKind::Cancel {
355            reason: "shutdown".to_owned(),
356        });
357        cmd.issued_by = None;
358        let decoded = round_trip(&cmd)?;
359        assert_eq!(decoded.issued_by, None);
360        assert_eq!(cmd, decoded);
361        Ok(())
362    }
363
364    #[test]
365    fn empty_capability_set_is_first_class() -> Result<(), Box<dyn std::error::Error>> {
366        let observability_only = InterventionCapabilities::none();
367        assert!(observability_only.is_empty());
368        assert_eq!(observability_only, InterventionCapabilities::default());
369
370        // An empty set supports no primitive: the console offers no controls, and the server
371        // refuses every command before routing it. This is a valid tier, not an error.
372        let cancel = InterventionKind::Cancel {
373            reason: "x".to_owned(),
374        };
375        assert!(!observability_only.supports(&cancel));
376
377        // Round-trips cleanly as a valid advertisement.
378        let decoded = round_trip(&observability_only)?;
379        assert_eq!(observability_only, decoded);
380        Ok(())
381    }
382
383    #[test]
384    fn capabilities_gate_on_advertised_primitives() {
385        let caps = InterventionCapabilities::from_primitives([
386            InterventionPrimitive::InjectMessage,
387            InterventionPrimitive::Cancel,
388        ]);
389        assert!(!caps.is_empty());
390        assert!(caps.supports(&InterventionKind::InjectMessage {
391            text: "hi".to_owned(),
392            priority: InjectPriority::Normal,
393        }));
394        assert!(caps.supports(&InterventionKind::Cancel {
395            reason: "stop".to_owned(),
396        }));
397        assert!(!caps.supports(&InterventionKind::PauseResume { paused: true }));
398        assert!(!caps.supports(&InterventionKind::UpdateBudget {
399            max_tokens: None,
400            max_turns: None,
401        }));
402    }
403
404    #[test]
405    fn every_outcome_round_trips() -> Result<(), Box<dyn std::error::Error>> {
406        let outcomes = vec![
407            InterventionOutcome::Applied,
408            InterventionOutcome::capability_not_supported(InterventionPrimitive::PauseResume),
409            InterventionOutcome::stale_target("attempt 2 superseded"),
410        ];
411        for outcome in outcomes {
412            let decoded = round_trip(&outcome)?;
413            assert_eq!(outcome, decoded);
414        }
415        // Only `Applied` reports applied; the two NACK classes do not.
416        assert!(InterventionOutcome::Applied.is_applied());
417        assert!(!InterventionOutcome::stale_target("gone").is_applied());
418        assert!(
419            !InterventionOutcome::capability_not_supported(InterventionPrimitive::Cancel)
420                .is_applied()
421        );
422        Ok(())
423    }
424}