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, RunId, 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 concrete run of that workflow the target attempt belongs to.
124    ///
125    /// Required, and load-bearing for routing: a continue-as-new chain reuses one
126    /// `workflow_id` while ordinals and attempts restart in each generation, so without the run
127    /// axis a command aimed at generation two would resolve generation one's owner. Delivering a
128    /// steer to the wrong generation is a control-plane fault, not a display bug.
129    pub run_id: RunId,
130    /// The target activity within the workflow.
131    pub activity_id: ActivityId,
132    /// The target attempt. Commands to a stale (superseded) attempt are no-ops.
133    pub attempt: u32,
134    /// The auth subject that issued the command, when auth is enabled.
135    ///
136    /// A neutral subject label (the same identity the auth layer records), carried so the
137    /// transcript can attribute the intervention. `None` when auth is off.
138    pub issued_by: Option<String>,
139    /// When the command was issued (operator-clock instant).
140    pub issued_at: DateTime<Utc>,
141    /// The neutral control primitive to apply.
142    pub kind: InterventionKind,
143}
144
145/// The neutral outcome of routing one [`InterventionCommand`] to the worker owning the target
146/// attempt — the ack that surfaces back to the operator.
147///
148/// The three variants ARE the three distinct outcome classes the design locks (§6.4), expressed
149/// harness-neutrally so the wire, the server, and the ops console never inspect a harness error:
150///
151/// - [`Self::Applied`] — the session accepted and applied the command.
152/// - [`Self::CapabilityNotSupported`] — the target harness does not advertise the command's
153///   primitive. The server gates on the advertised set BEFORE routing, so this is normally
154///   returned by the server without a wire round-trip; a worker returns it too if a gated command
155///   still reaches it.
156/// - [`Self::StaleTarget`] — the target `(workflow, activity, attempt)` is finished, superseded by
157///   a later attempt, or unknown (the attempt-scoped no-op). It is an honest NACK, never a crash.
158///
159/// Carried as its own enum (not a `Result`) so it round-trips over `ts-rs` into the ops console
160/// exactly like the other real-time DTOs, and so a future outcome class is an additive variant.
161#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
162#[serde(tag = "outcome")]
163pub enum InterventionOutcome {
164    /// The command was delivered to the live session and applied.
165    Applied,
166    /// The command's primitive is not in the target harness's advertised capability set.
167    CapabilityNotSupported {
168        /// The primitive the target does not support.
169        primitive: InterventionPrimitive,
170    },
171    /// The target attempt is finished, superseded, or unknown — an attempt-scoped no-op.
172    StaleTarget {
173        /// Human-readable detail describing why the target is stale.
174        detail: String,
175    },
176}
177
178impl InterventionOutcome {
179    /// Returns `true` when the command was applied to a live session.
180    #[must_use]
181    pub const fn is_applied(&self) -> bool {
182        matches!(self, Self::Applied)
183    }
184
185    /// Builds a [`Self::CapabilityNotSupported`] naming the ungated primitive.
186    #[must_use]
187    pub const fn capability_not_supported(primitive: InterventionPrimitive) -> Self {
188        Self::CapabilityNotSupported { primitive }
189    }
190
191    /// Builds a [`Self::StaleTarget`] with a detail message.
192    #[must_use]
193    pub fn stale_target(detail: impl Into<String>) -> Self {
194        Self::StaleTarget {
195            detail: detail.into(),
196        }
197    }
198}
199
200/// A single neutral intervention primitive, independent of any command payload.
201///
202/// The discriminant an [`InterventionCapabilities`] advertises and the primitive each
203/// [`InterventionKind`] belongs to. Modelled as its own enum (rather than five booleans) so the
204/// capability set is an explicit set of primitives with no fixed-width shape to grow.
205#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
206#[serde(tag = "primitive")]
207pub enum InterventionPrimitive {
208    /// Corresponds to [`InterventionKind::InjectMessage`].
209    InjectMessage,
210    /// Corresponds to [`InterventionKind::Cancel`].
211    Cancel,
212    /// Corresponds to [`InterventionKind::PauseResume`].
213    PauseResume,
214    /// Corresponds to [`InterventionKind::UpdateBudget`].
215    UpdateBudget,
216    /// Corresponds to [`InterventionKind::RespondToApproval`].
217    RespondToApproval,
218}
219
220impl InterventionKind {
221    /// The neutral primitive this command belongs to.
222    #[must_use]
223    pub const fn primitive(&self) -> InterventionPrimitive {
224        match self {
225            Self::InjectMessage { .. } => InterventionPrimitive::InjectMessage,
226            Self::Cancel { .. } => InterventionPrimitive::Cancel,
227            Self::PauseResume { .. } => InterventionPrimitive::PauseResume,
228            Self::UpdateBudget { .. } => InterventionPrimitive::UpdateBudget,
229            Self::RespondToApproval { .. } => InterventionPrimitive::RespondToApproval,
230        }
231    }
232}
233
234/// The set of neutral intervention primitives a harness advertises support for.
235///
236/// The server and ops console gate on THIS, never on harness identity. An **empty** set is a
237/// first-class, valid advertisement — an observability-only harness supports no interventions,
238/// and the console offers no controls for it. It is a legitimate tier, not a degenerate one.
239///
240/// Modelled as an explicit list of supported [`InterventionPrimitive`]s. Duplicates are ignored
241/// by the accessors; ordering is not significant.
242#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq, Default)]
243pub struct InterventionCapabilities {
244    /// The primitives this harness supports. Empty = observability-only.
245    pub supported: Vec<InterventionPrimitive>,
246}
247
248impl InterventionCapabilities {
249    /// The empty capability set — an observability-only harness that supports no interventions.
250    ///
251    /// This is a first-class, valid advertisement (not an error): the ops console offers no
252    /// controls for a harness advertising it.
253    #[must_use]
254    pub fn none() -> Self {
255        Self::default()
256    }
257
258    /// Builds a capability set from an iterator of supported primitives.
259    pub fn from_primitives(primitives: impl IntoIterator<Item = InterventionPrimitive>) -> Self {
260        Self {
261            supported: primitives.into_iter().collect(),
262        }
263    }
264
265    /// Returns `true` when no intervention primitive is supported (observability-only).
266    #[must_use]
267    pub fn is_empty(&self) -> bool {
268        self.supported.is_empty()
269    }
270
271    /// Returns `true` when the given primitive is advertised as supported.
272    #[must_use]
273    pub fn supports_primitive(&self, primitive: InterventionPrimitive) -> bool {
274        self.supported.contains(&primitive)
275    }
276
277    /// Returns `true` when the given command's primitive is advertised as supported.
278    ///
279    /// The server uses this to refuse an unadvertised primitive before it is ever routed.
280    #[must_use]
281    pub fn supports(&self, kind: &InterventionKind) -> bool {
282        self.supports_primitive(kind.primitive())
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use chrono::{DateTime, Utc};
289    use serde::de::DeserializeOwned;
290
291    use super::{
292        ApprovalDecision, InjectPriority, InterventionCapabilities, InterventionCommand,
293        InterventionKind, InterventionOutcome, InterventionPrimitive, RunId, WorkflowId,
294    };
295    use crate::ids::ActivityId;
296
297    fn fixed_time() -> DateTime<Utc> {
298        DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default()
299    }
300
301    fn round_trip<T>(value: &T) -> Result<T, serde_json::Error>
302    where
303        T: DeserializeOwned + serde::Serialize,
304    {
305        let json = serde_json::to_string(value)?;
306        serde_json::from_str::<T>(&json)
307    }
308
309    fn command(kind: InterventionKind) -> InterventionCommand {
310        InterventionCommand {
311            workflow_id: WorkflowId::new(uuid::Uuid::nil()),
312            run_id: RunId::new(uuid::Uuid::from_u128(5)),
313            activity_id: ActivityId::from_sequence_position(3),
314            attempt: 1,
315            issued_by: Some("operator@example.com".to_owned()),
316            issued_at: fixed_time(),
317            kind,
318        }
319    }
320
321    #[test]
322    fn every_intervention_variant_round_trips() -> Result<(), Box<dyn std::error::Error>> {
323        let kinds = vec![
324            InterventionKind::InjectMessage {
325                text: "use the other module".to_owned(),
326                priority: InjectPriority::Interrupt,
327            },
328            InterventionKind::InjectMessage {
329                text: "some context".to_owned(),
330                priority: InjectPriority::Normal,
331            },
332            InterventionKind::Cancel {
333                reason: "operator abort".to_owned(),
334            },
335            InterventionKind::PauseResume { paused: true },
336            InterventionKind::PauseResume { paused: false },
337            InterventionKind::UpdateBudget {
338                max_tokens: Some(10_000),
339                max_turns: None,
340            },
341            InterventionKind::RespondToApproval {
342                call_id: "call-9".to_owned(),
343                decision: ApprovalDecision::Approve,
344                note: Some("looks fine".to_owned()),
345            },
346            InterventionKind::RespondToApproval {
347                call_id: "call-10".to_owned(),
348                decision: ApprovalDecision::Deny,
349                note: None,
350            },
351        ];
352        for kind in kinds {
353            let cmd = command(kind);
354            let decoded = round_trip(&cmd)?;
355            assert_eq!(cmd, decoded);
356        }
357        Ok(())
358    }
359
360    #[test]
361    fn command_without_auth_subject_round_trips() -> Result<(), Box<dyn std::error::Error>> {
362        let mut cmd = command(InterventionKind::Cancel {
363            reason: "shutdown".to_owned(),
364        });
365        cmd.issued_by = None;
366        let decoded = round_trip(&cmd)?;
367        assert_eq!(decoded.issued_by, None);
368        assert_eq!(cmd, decoded);
369        Ok(())
370    }
371
372    #[test]
373    fn empty_capability_set_is_first_class() -> Result<(), Box<dyn std::error::Error>> {
374        let observability_only = InterventionCapabilities::none();
375        assert!(observability_only.is_empty());
376        assert_eq!(observability_only, InterventionCapabilities::default());
377
378        // An empty set supports no primitive: the console offers no controls, and the server
379        // refuses every command before routing it. This is a valid tier, not an error.
380        let cancel = InterventionKind::Cancel {
381            reason: "x".to_owned(),
382        };
383        assert!(!observability_only.supports(&cancel));
384
385        // Round-trips cleanly as a valid advertisement.
386        let decoded = round_trip(&observability_only)?;
387        assert_eq!(observability_only, decoded);
388        Ok(())
389    }
390
391    #[test]
392    fn capabilities_gate_on_advertised_primitives() {
393        let caps = InterventionCapabilities::from_primitives([
394            InterventionPrimitive::InjectMessage,
395            InterventionPrimitive::Cancel,
396        ]);
397        assert!(!caps.is_empty());
398        assert!(caps.supports(&InterventionKind::InjectMessage {
399            text: "hi".to_owned(),
400            priority: InjectPriority::Normal,
401        }));
402        assert!(caps.supports(&InterventionKind::Cancel {
403            reason: "stop".to_owned(),
404        }));
405        assert!(!caps.supports(&InterventionKind::PauseResume { paused: true }));
406        assert!(!caps.supports(&InterventionKind::UpdateBudget {
407            max_tokens: None,
408            max_turns: None,
409        }));
410    }
411
412    #[test]
413    fn every_outcome_round_trips() -> Result<(), Box<dyn std::error::Error>> {
414        let outcomes = vec![
415            InterventionOutcome::Applied,
416            InterventionOutcome::capability_not_supported(InterventionPrimitive::PauseResume),
417            InterventionOutcome::stale_target("attempt 2 superseded"),
418        ];
419        for outcome in outcomes {
420            let decoded = round_trip(&outcome)?;
421            assert_eq!(outcome, decoded);
422        }
423        // Only `Applied` reports applied; the two NACK classes do not.
424        assert!(InterventionOutcome::Applied.is_applied());
425        assert!(!InterventionOutcome::stale_target("gone").is_applied());
426        assert!(
427            !InterventionOutcome::capability_not_supported(InterventionPrimitive::Cancel)
428                .is_applied()
429        );
430        Ok(())
431    }
432}