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}