aion_core/activity_event.rs
1//! The `ActivityEvent` envelope for the agent-observability real-time channel.
2//!
3//! This module defines the *typed contract* for a live, durable, per-`(workflow, run, activity,
4//! attempt)` transcript of what an agent harness is doing inside an activity: its messages,
5//! tool calls, tool results, progress, stop reasons, and (ephemeral) token deltas. It is the
6//! sibling of [`crate::cluster_event`] — a **non-replay real-time DTO** that crosses the
7//! Rust -> TypeScript boundary via `ts-rs` into the ops-console generated bindings.
8//!
9//! The wire shapes live in `aion-core` (not `aion-server` / the SDK) for the same reason the
10//! cluster events do: only this leaf crate depends on `ts-rs`, so this is the single place a
11//! Rust type can cross into the ops console's generated union. The `aion-integrations` SDK
12//! re-exports these neutral types; the worker-side per-harness adapter is the single point that
13//! maps a harness's native events into this envelope.
14//!
15//! # Harness neutrality (LOCKED)
16//!
17//! Every type in this module is **harness-neutral**: it names no agent harness, no transport,
18//! and no wire protocol. There is no `Norn`, no JSON-RPC, and no stdio concept here. A harness
19//! is integrated by mapping its native events into these shapes in the worker-side adapter,
20//! never by editing this module. [`ActivityEventKind::Raw`] is the passthrough fallback that
21//! makes the harness-agnostic path possible (and forward-compatible when a harness emits a
22//! shape the adapter does not yet classify).
23//!
24//! # Observability, never replay
25//!
26//! An `ActivityEvent` is an observability record. It is **never** part of the workflow replay
27//! log: the replay-authoritative output of an activity is its single terminal result, not its
28//! transcript. These types deliberately carry no behaviour and no engine coupling — they are
29//! pure data.
30//!
31//! # `u64` precision across the TS boundary
32//!
33//! The ts-rs config exports every `u64` as TS `number` (`with_large_int("number")`), which
34//! truncates above `2^53`. [`ActivityEvent::worker_seq`] and [`ActivityEvent::store_seq`] are
35//! `u64`. This is the *same* accepted ceiling that already applies to [`crate::EventEnvelope::seq`]
36//! and the cluster channel's sequence fields; the transcript sequence follows the established
37//! project convention rather than a divergent string encoding.
38
39use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41use uuid::Uuid;
42
43use crate::ids::{ActivityId, RunId, WorkflowId};
44
45/// The role a conversational message is attributed to.
46///
47/// Harness-neutral: the worker-side adapter maps a harness's native speaker attribution onto
48/// these roles. `Tool` covers a tool/function participant turn where the harness models one.
49#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
50#[serde(tag = "role")]
51pub enum MessageRole {
52 /// A turn attributed to the operator / end user.
53 User,
54 /// A turn produced by the agent (model output).
55 Assistant,
56 /// A system / instruction turn.
57 System,
58 /// A turn attributed to a tool or function participant.
59 Tool,
60}
61
62/// A fine-grained progress signal within an activity attempt.
63///
64/// Harness-neutral projection of the incremental, non-terminal signals a harness can emit
65/// (streaming text/thinking fragments, tool-call argument streaming, usage estimates). It is a
66/// tagged union so a harness advertises only the progress shapes it actually produces; anything
67/// unclassifiable falls through to [`ActivityEventKind::Raw`] instead.
68#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
69#[serde(tag = "detail")]
70pub enum ProgressDetail {
71 /// A running estimate of resource usage for the attempt so far.
72 UsageEstimate {
73 /// Estimated input (prompt) tokens consumed so far, when the harness reports it.
74 input_tokens: Option<u64>,
75 /// Estimated output (completion) tokens produced so far, when the harness reports it.
76 output_tokens: Option<u64>,
77 },
78 /// A free-form, human-readable progress note the adapter could not model more precisely.
79 Note {
80 /// The progress note text.
81 text: String,
82 },
83}
84
85/// Why an agent run reached a terminal boundary.
86///
87/// Harness-neutral projection of a harness's native stop/finish reason. `Other` carries the
88/// harness's raw reason label for reasons this neutral set does not enumerate, so no stop reason
89/// is ever silently dropped.
90#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
91#[serde(tag = "stop")]
92pub enum StopKind {
93 /// The agent completed its turn normally (produced its result).
94 EndTurn,
95 /// The agent stopped to await a tool result before continuing.
96 ToolUse,
97 /// The run hit a configured resource limit (tokens / turns / time).
98 LimitReached,
99 /// The run was cancelled (e.g. by an intervention or shutdown).
100 Cancelled,
101 /// The run stopped because of an error.
102 Error {
103 /// Human-readable error description.
104 message: String,
105 },
106 /// A stop reason this neutral set does not enumerate; carries the harness's raw label.
107 Other {
108 /// The harness's raw stop-reason label.
109 reason: String,
110 },
111}
112
113/// The payload of an [`ActivityEvent`] — the classified kind of transcript signal.
114///
115/// **Kinds are LOCKED:** `Message`, `ToolCall`, `ToolResult`, `Progress`, `Stop`, `Raw`, plus
116/// `Delta` carried on the same channel but flagged ephemeral (forwarded to the WS, never
117/// persisted). [`Self::Raw`] is the passthrough fallback — critical for the harness-agnostic
118/// path and for forward-compat when a harness adds an event shape the adapter does not yet map.
119#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
120#[serde(tag = "kind")]
121pub enum ActivityEventKind {
122 /// A complete conversational message (assistant text/thinking, an operator turn, etc.).
123 Message {
124 /// Who the message is attributed to.
125 role: MessageRole,
126 /// The message text.
127 text: String,
128 },
129 /// The agent invoked a tool/function with structured input.
130 ToolCall {
131 /// The tool/function name.
132 tool: String,
133 /// Correlation id linking this call to its eventual [`Self::ToolResult`].
134 call_id: String,
135 /// The structured tool input.
136 #[ts(type = "unknown")]
137 input: serde_json::Value,
138 },
139 /// A tool/function returned a result for a prior [`Self::ToolCall`].
140 ToolResult {
141 /// Correlation id matching the originating [`Self::ToolCall`].
142 call_id: String,
143 /// The structured tool output.
144 #[ts(type = "unknown")]
145 output: serde_json::Value,
146 /// Whether the tool reported an error result.
147 is_error: bool,
148 },
149 /// A fine-grained, non-terminal progress signal.
150 Progress {
151 /// The progress detail.
152 detail: ProgressDetail,
153 },
154 /// The agent run reached a terminal boundary.
155 Stop {
156 /// Why the run stopped.
157 reason: StopKind,
158 },
159 /// Passthrough fallback for an unmapped or other-harness line.
160 ///
161 /// Carries the source label the adapter observed and the raw value verbatim, so nothing is
162 /// ever silently dropped and the harness-agnostic path stays lossless.
163 Raw {
164 /// A label identifying where the raw value came from (adapter-defined).
165 source: String,
166 /// The raw value, passed through verbatim.
167 #[ts(type = "unknown")]
168 value: serde_json::Value,
169 },
170 /// An ephemeral token delta — forwarded to the WS only, **never persisted**.
171 ///
172 /// Always carried with [`ActivityEvent::ephemeral`] set to `true`.
173 Delta {
174 /// The id of the message this fragment belongs to.
175 message_id: String,
176 /// The incremental text fragment.
177 text_fragment: String,
178 },
179}
180
181/// A live transcript event for one `(workflow, run, activity, attempt)` produced by an agent
182/// harness.
183///
184/// Streamed to the ops console in real time and persisted to a durable observability keyspace
185/// (except [`Self::ephemeral`] events). It is **never** mixed into workflow replay history — the
186/// activity's single terminal result is the replay-authoritative output, not this transcript.
187///
188/// # The run axis is REQUIRED, not optional
189///
190/// A continue-as-new chain reuses one [`WorkflowId`] across generations while activity ordinals
191/// restart at `0` and attempts restart at `1` in each new run. Without [`Self::run_id`] two
192/// generations of one chain produce byte-identical stream identities, and their transcripts fuse:
193/// generation two's first event lands on generation one's stream head. `run_id` is therefore a
194/// plain required field — an `Option` would let the ambiguity back in through the `None` arm, and
195/// there is no honest value to default it to. The same law already holds one layer over, where
196/// the activity idempotency key is run-scoped over the identical triple.
197///
198/// # Ordering
199///
200/// [`Self::emitted_at`] and [`Self::worker_seq`] are best-effort producer-side ordering hints.
201/// [`Self::store_seq`] is assigned by the server at durable-commit time and is `None` until the
202/// event has been persisted — an unpersisted (e.g. ephemeral, or in-flight) event carries no
203/// store sequence.
204#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
205pub struct ActivityEvent {
206 /// The workflow this activity belongs to.
207 pub workflow_id: WorkflowId,
208 /// The concrete run of that workflow this event was produced in — the second stream axis.
209 ///
210 /// Required. Two generations of one continue-as-new chain share a `workflow_id` and restart
211 /// their ordinals and attempts, so this is the only field that keeps their transcripts apart.
212 pub run_id: RunId,
213 /// The activity within the workflow.
214 pub activity_id: ActivityId,
215 /// The attempt number of the activity this event was produced during.
216 pub attempt: u32,
217 /// Sub-identity of the agent that produced this event — REQUIRED for multi-agent
218 /// attribution (a single activity attempt may run several agents).
219 pub agent_id: Uuid,
220 /// The role/label of the producing agent (e.g. an orchestrator vs a sub-agent).
221 pub agent_role: String,
222 /// Producer-clock instant the event was emitted (best-effort ordering hint).
223 pub emitted_at: DateTime<Utc>,
224 /// Worker-local best-effort monotonic sequence.
225 ///
226 /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
227 pub worker_seq: u64,
228 /// Server-stamped monotonic sequence assigned at durable-commit time; `None` until the event
229 /// is persisted (ephemeral events are never persisted and always carry `None`).
230 ///
231 /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
232 pub store_seq: Option<u64>,
233 /// When `true`, this event is WS-forward-only and is **never persisted** (token deltas).
234 pub ephemeral: bool,
235 /// The classified payload of this event.
236 pub kind: ActivityEventKind,
237}
238
239#[cfg(test)]
240mod tests {
241 use chrono::{DateTime, Utc};
242 use serde::de::DeserializeOwned;
243 use serde_json::json;
244 use uuid::Uuid;
245
246 use super::{
247 ActivityEvent, ActivityEventKind, MessageRole, ProgressDetail, RunId, StopKind, WorkflowId,
248 };
249 use crate::ids::ActivityId;
250
251 fn fixed_time() -> DateTime<Utc> {
252 DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default()
253 }
254
255 fn round_trip<T>(value: &T) -> Result<T, serde_json::Error>
256 where
257 T: DeserializeOwned + serde::Serialize,
258 {
259 let json = serde_json::to_string(value)?;
260 serde_json::from_str::<T>(&json)
261 }
262
263 fn envelope(kind: ActivityEventKind, ephemeral: bool, store_seq: Option<u64>) -> ActivityEvent {
264 ActivityEvent {
265 workflow_id: WorkflowId::new(Uuid::nil()),
266 run_id: RunId::new(Uuid::from_u128(5)),
267 activity_id: ActivityId::from_sequence_position(7),
268 attempt: 2,
269 agent_id: Uuid::nil(),
270 agent_role: "orchestrator".to_owned(),
271 emitted_at: fixed_time(),
272 worker_seq: 42,
273 store_seq,
274 ephemeral,
275 kind,
276 }
277 }
278
279 #[test]
280 fn envelope_round_trips_through_json() -> Result<(), Box<dyn std::error::Error>> {
281 let event = envelope(
282 ActivityEventKind::Message {
283 role: MessageRole::Assistant,
284 text: "hello".to_owned(),
285 },
286 false,
287 Some(9),
288 );
289 let decoded = round_trip(&event)?;
290 assert_eq!(event, decoded);
291 Ok(())
292 }
293
294 #[test]
295 fn every_event_kind_round_trips() -> Result<(), Box<dyn std::error::Error>> {
296 let kinds = vec![
297 ActivityEventKind::Message {
298 role: MessageRole::User,
299 text: "steer".to_owned(),
300 },
301 ActivityEventKind::ToolCall {
302 tool: "read_file".to_owned(),
303 call_id: "call-1".to_owned(),
304 input: json!({ "path": "/tmp/x" }),
305 },
306 ActivityEventKind::ToolResult {
307 call_id: "call-1".to_owned(),
308 output: json!({ "bytes": 12 }),
309 is_error: false,
310 },
311 ActivityEventKind::Progress {
312 detail: ProgressDetail::UsageEstimate {
313 input_tokens: Some(100),
314 output_tokens: None,
315 },
316 },
317 ActivityEventKind::Progress {
318 detail: ProgressDetail::Note {
319 text: "thinking".to_owned(),
320 },
321 },
322 ActivityEventKind::Stop {
323 reason: StopKind::EndTurn,
324 },
325 ActivityEventKind::Stop {
326 reason: StopKind::Error {
327 message: "boom".to_owned(),
328 },
329 },
330 ActivityEventKind::Stop {
331 reason: StopKind::Other {
332 reason: "custom".to_owned(),
333 },
334 },
335 ActivityEventKind::Raw {
336 source: "unknown-harness".to_owned(),
337 value: json!({ "anything": [1, 2, 3] }),
338 },
339 ];
340 for kind in kinds {
341 let event = envelope(kind, false, None);
342 let decoded = round_trip(&event)?;
343 assert_eq!(event, decoded);
344 }
345 Ok(())
346 }
347
348 /// The run axis is carried on the wire and DISTINGUISHES two events that are
349 /// otherwise byte-identical — the continue-as-new case, where one workflow id
350 /// spans generations whose ordinals and attempts both restart.
351 #[test]
352 fn the_run_axis_survives_the_wire_and_separates_two_generations()
353 -> Result<(), Box<dyn std::error::Error>> {
354 let generation_one = envelope(
355 ActivityEventKind::Message {
356 role: MessageRole::Assistant,
357 text: "same text".to_owned(),
358 },
359 false,
360 None,
361 );
362 let mut generation_two = generation_one.clone();
363 generation_two.run_id = RunId::new(Uuid::from_u128(6));
364
365 let decoded = round_trip(&generation_one)?;
366 assert_eq!(decoded.run_id, generation_one.run_id);
367 // Everything except the run matches, and the events are still distinct.
368 assert_eq!(generation_one.workflow_id, generation_two.workflow_id);
369 assert_eq!(generation_one.activity_id, generation_two.activity_id);
370 assert_eq!(generation_one.attempt, generation_two.attempt);
371 assert_ne!(generation_one, generation_two);
372 Ok(())
373 }
374
375 #[test]
376 fn ephemeral_delta_round_trips_without_store_seq() -> Result<(), Box<dyn std::error::Error>> {
377 let event = envelope(
378 ActivityEventKind::Delta {
379 message_id: "msg-1".to_owned(),
380 text_fragment: "wor".to_owned(),
381 },
382 true,
383 None,
384 );
385 let decoded = round_trip(&event)?;
386 assert!(decoded.ephemeral);
387 assert_eq!(decoded.store_seq, None);
388 assert_eq!(event, decoded);
389 Ok(())
390 }
391}