Skip to main content

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    /// A fragment of the agent's reasoning as it streams — the thinking before an answer, as
84    /// distinct from the answer's own token stream ([`ActivityEventKind::Delta`]).
85    ///
86    /// Ephemeral by nature: the durable record of the reasoning is the accumulated message the
87    /// harness's boundary emits. Distinct from [`Self::Note`] on the wire so that a consumer never
88    /// has to infer from the SHAPE of a note's text whether it is words a model wrote or a label
89    /// standing in for a dropped per-token payload — the console once did, and a label with one
90    /// capital letter in it read as reasoning (aion#224).
91    Thinking {
92        /// The harness's identifier for the message this reasoning belongs to, or empty when the
93        /// harness offers none — the same label the answer's [`ActivityEventKind::Delta`] carries.
94        message_id: String,
95        /// The reasoning text fragment.
96        text: String,
97    },
98}
99
100/// Why an agent run reached a terminal boundary.
101///
102/// Harness-neutral projection of a harness's native stop/finish reason. `Other` carries the
103/// harness's raw reason label for reasons this neutral set does not enumerate, so no stop reason
104/// is ever silently dropped.
105#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
106#[serde(tag = "stop")]
107pub enum StopKind {
108    /// The agent completed its turn normally (produced its result).
109    EndTurn,
110    /// The agent stopped to await a tool result before continuing.
111    ToolUse,
112    /// The run hit a configured resource limit (tokens / turns / time).
113    LimitReached,
114    /// The run was cancelled (e.g. by an intervention or shutdown).
115    Cancelled,
116    /// The run stopped because of an error.
117    Error {
118        /// Human-readable error description.
119        message: String,
120    },
121    /// A stop reason this neutral set does not enumerate; carries the harness's raw label.
122    Other {
123        /// The harness's raw stop-reason label.
124        reason: String,
125    },
126}
127
128/// The payload of an [`ActivityEvent`] — the classified kind of transcript signal.
129///
130/// **Kinds are LOCKED:** `Message`, `ToolCall`, `ToolResult`, `Progress`, `Stop`, `Raw`, plus
131/// `Delta` carried on the same channel but flagged ephemeral (forwarded to the WS, never
132/// persisted). [`Self::Raw`] is the passthrough fallback — critical for the harness-agnostic
133/// path and for forward-compat when a harness adds an event shape the adapter does not yet map.
134#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
135#[serde(tag = "kind")]
136pub enum ActivityEventKind {
137    /// A complete conversational message (assistant text/thinking, an operator turn, etc.).
138    Message {
139        /// Who the message is attributed to.
140        role: MessageRole,
141        /// The message text.
142        text: String,
143    },
144    /// The agent invoked a tool/function with structured input.
145    ToolCall {
146        /// The tool/function name.
147        tool: String,
148        /// Correlation id linking this call to its eventual [`Self::ToolResult`].
149        call_id: String,
150        /// The structured tool input.
151        #[ts(type = "unknown")]
152        input: serde_json::Value,
153    },
154    /// A tool/function returned a result for a prior [`Self::ToolCall`].
155    ToolResult {
156        /// Correlation id matching the originating [`Self::ToolCall`].
157        call_id: String,
158        /// The structured tool output.
159        #[ts(type = "unknown")]
160        output: serde_json::Value,
161        /// Whether the tool reported an error result.
162        is_error: bool,
163    },
164    /// A fine-grained, non-terminal progress signal.
165    Progress {
166        /// The progress detail.
167        detail: ProgressDetail,
168    },
169    /// The agent run reached a terminal boundary.
170    Stop {
171        /// Why the run stopped.
172        reason: StopKind,
173    },
174    /// Passthrough fallback for an unmapped or other-harness line.
175    ///
176    /// Carries the source label the adapter observed and the raw value verbatim, so nothing is
177    /// ever silently dropped and the harness-agnostic path stays lossless.
178    Raw {
179        /// A label identifying where the raw value came from (adapter-defined).
180        source: String,
181        /// The raw value, passed through verbatim.
182        #[ts(type = "unknown")]
183        value: serde_json::Value,
184    },
185    /// An ephemeral token delta — forwarded to the WS only, **never persisted**.
186    ///
187    /// Always carried with [`ActivityEvent::ephemeral`] set to `true`.
188    Delta {
189        /// The id of the message this fragment belongs to.
190        message_id: String,
191        /// The incremental text fragment.
192        text_fragment: String,
193    },
194}
195
196/// A live transcript event for one `(workflow, run, activity, attempt)` produced by an agent
197/// harness.
198///
199/// Streamed to the ops console in real time and persisted to a durable observability keyspace
200/// (except [`Self::ephemeral`] events). It is **never** mixed into workflow replay history — the
201/// activity's single terminal result is the replay-authoritative output, not this transcript.
202///
203/// # The run axis is REQUIRED, not optional
204///
205/// A continue-as-new chain reuses one [`WorkflowId`] across generations while activity ordinals
206/// restart at `0` and attempts restart at `1` in each new run. Without [`Self::run_id`] two
207/// generations of one chain produce byte-identical stream identities, and their transcripts fuse:
208/// generation two's first event lands on generation one's stream head. `run_id` is therefore a
209/// plain required field — an `Option` would let the ambiguity back in through the `None` arm, and
210/// there is no honest value to default it to. The same law already holds one layer over, where
211/// the activity idempotency key is run-scoped over the identical triple.
212///
213/// # Ordering
214///
215/// [`Self::emitted_at`] and [`Self::worker_seq`] are best-effort producer-side ordering hints.
216/// [`Self::store_seq`] is assigned by the server at durable-commit time and is `None` until the
217/// event has been persisted — an unpersisted (e.g. ephemeral, or in-flight) event carries no
218/// store sequence.
219#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq)]
220pub struct ActivityEvent {
221    /// The workflow this activity belongs to.
222    pub workflow_id: WorkflowId,
223    /// The concrete run of that workflow this event was produced in — the second stream axis.
224    ///
225    /// Required. Two generations of one continue-as-new chain share a `workflow_id` and restart
226    /// their ordinals and attempts, so this is the only field that keeps their transcripts apart.
227    pub run_id: RunId,
228    /// The activity within the workflow.
229    pub activity_id: ActivityId,
230    /// The attempt number of the activity this event was produced during.
231    pub attempt: u32,
232    /// Sub-identity of the agent that produced this event — REQUIRED for multi-agent
233    /// attribution (a single activity attempt may run several agents).
234    pub agent_id: Uuid,
235    /// The role/label of the producing agent (e.g. an orchestrator vs a sub-agent).
236    pub agent_role: String,
237    /// Producer-clock instant the event was emitted (best-effort ordering hint).
238    pub emitted_at: DateTime<Utc>,
239    /// Worker-local best-effort monotonic sequence.
240    ///
241    /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
242    pub worker_seq: u64,
243    /// Server-stamped monotonic sequence assigned at durable-commit time; `None` until the event
244    /// is persisted (ephemeral events are never persisted and always carry `None`).
245    ///
246    /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
247    pub store_seq: Option<u64>,
248    /// When `true`, this event is WS-forward-only and is **never persisted** (token deltas).
249    pub ephemeral: bool,
250    /// The classified payload of this event.
251    pub kind: ActivityEventKind,
252}
253
254#[cfg(test)]
255mod tests {
256    use chrono::{DateTime, Utc};
257    use serde::de::DeserializeOwned;
258    use serde_json::json;
259    use uuid::Uuid;
260
261    use super::{
262        ActivityEvent, ActivityEventKind, MessageRole, ProgressDetail, RunId, StopKind, WorkflowId,
263    };
264    use crate::ids::ActivityId;
265
266    fn fixed_time() -> DateTime<Utc> {
267        DateTime::from_timestamp(1_700_000_000, 0).unwrap_or_default()
268    }
269
270    fn round_trip<T>(value: &T) -> Result<T, serde_json::Error>
271    where
272        T: DeserializeOwned + serde::Serialize,
273    {
274        let json = serde_json::to_string(value)?;
275        serde_json::from_str::<T>(&json)
276    }
277
278    fn envelope(kind: ActivityEventKind, ephemeral: bool, store_seq: Option<u64>) -> ActivityEvent {
279        ActivityEvent {
280            workflow_id: WorkflowId::new(Uuid::nil()),
281            run_id: RunId::new(Uuid::from_u128(5)),
282            activity_id: ActivityId::from_sequence_position(7),
283            attempt: 2,
284            agent_id: Uuid::nil(),
285            agent_role: "orchestrator".to_owned(),
286            emitted_at: fixed_time(),
287            worker_seq: 42,
288            store_seq,
289            ephemeral,
290            kind,
291        }
292    }
293
294    #[test]
295    fn envelope_round_trips_through_json() -> Result<(), Box<dyn std::error::Error>> {
296        let event = envelope(
297            ActivityEventKind::Message {
298                role: MessageRole::Assistant,
299                text: "hello".to_owned(),
300            },
301            false,
302            Some(9),
303        );
304        let decoded = round_trip(&event)?;
305        assert_eq!(event, decoded);
306        Ok(())
307    }
308
309    #[test]
310    fn every_event_kind_round_trips() -> Result<(), Box<dyn std::error::Error>> {
311        let kinds = vec![
312            ActivityEventKind::Message {
313                role: MessageRole::User,
314                text: "steer".to_owned(),
315            },
316            ActivityEventKind::ToolCall {
317                tool: "read_file".to_owned(),
318                call_id: "call-1".to_owned(),
319                input: json!({ "path": "/tmp/x" }),
320            },
321            ActivityEventKind::ToolResult {
322                call_id: "call-1".to_owned(),
323                output: json!({ "bytes": 12 }),
324                is_error: false,
325            },
326            ActivityEventKind::Progress {
327                detail: ProgressDetail::UsageEstimate {
328                    input_tokens: Some(100),
329                    output_tokens: None,
330                },
331            },
332            ActivityEventKind::Progress {
333                detail: ProgressDetail::Note {
334                    text: "thinking".to_owned(),
335                },
336            },
337            ActivityEventKind::Stop {
338                reason: StopKind::EndTurn,
339            },
340            ActivityEventKind::Stop {
341                reason: StopKind::Error {
342                    message: "boom".to_owned(),
343                },
344            },
345            ActivityEventKind::Stop {
346                reason: StopKind::Other {
347                    reason: "custom".to_owned(),
348                },
349            },
350            ActivityEventKind::Raw {
351                source: "unknown-harness".to_owned(),
352                value: json!({ "anything": [1, 2, 3] }),
353            },
354        ];
355        for kind in kinds {
356            let event = envelope(kind, false, None);
357            let decoded = round_trip(&event)?;
358            assert_eq!(event, decoded);
359        }
360        Ok(())
361    }
362
363    /// The run axis is carried on the wire and DISTINGUISHES two events that are
364    /// otherwise byte-identical — the continue-as-new case, where one workflow id
365    /// spans generations whose ordinals and attempts both restart.
366    #[test]
367    fn the_run_axis_survives_the_wire_and_separates_two_generations()
368    -> Result<(), Box<dyn std::error::Error>> {
369        let generation_one = envelope(
370            ActivityEventKind::Message {
371                role: MessageRole::Assistant,
372                text: "same text".to_owned(),
373            },
374            false,
375            None,
376        );
377        let mut generation_two = generation_one.clone();
378        generation_two.run_id = RunId::new(Uuid::from_u128(6));
379
380        let decoded = round_trip(&generation_one)?;
381        assert_eq!(decoded.run_id, generation_one.run_id);
382        // Everything except the run matches, and the events are still distinct.
383        assert_eq!(generation_one.workflow_id, generation_two.workflow_id);
384        assert_eq!(generation_one.activity_id, generation_two.activity_id);
385        assert_eq!(generation_one.attempt, generation_two.attempt);
386        assert_ne!(generation_one, generation_two);
387        Ok(())
388    }
389
390    #[test]
391    fn ephemeral_delta_round_trips_without_store_seq() -> Result<(), Box<dyn std::error::Error>> {
392        let event = envelope(
393            ActivityEventKind::Delta {
394                message_id: "msg-1".to_owned(),
395                text_fragment: "wor".to_owned(),
396            },
397            true,
398            None,
399        );
400        let decoded = round_trip(&event)?;
401        assert!(decoded.ephemeral);
402        assert_eq!(decoded.store_seq, None);
403        assert_eq!(event, decoded);
404        Ok(())
405    }
406}