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}