Skip to main content

aion_integration_cli/
demux.rs

1//! The stdout demux — the pure translation from a plain CLI agent's interleaved stdout lines into
2//! neutral [`ActivityEvent`]s.
3//!
4//! This is the case-(b) analogue of the Norn adapter's `translate` module, and the whole point of
5//! the observability-only shape: a plain-stdout agent has **no** structured channel, so its
6//! transcript is recovered by parsing the interleaved lines it prints. The demux is deliberately
7//! **lossless**: any line the classifier does not recognise falls through to
8//! [`ActivityEventKind::Raw`] carrying the verbatim line, so nothing an agent prints is ever
9//! silently dropped.
10//!
11//! # The recognised shape
12//!
13//! A line that parses as a JSON object with a `"type"` string is mapped onto a neutral kind:
14//!
15//! | line `type` | neutral kind |
16//! |---|---|
17//! | `"message"` (with `"text"`) | [`ActivityEventKind::Message`] (assistant role) |
18//! | `"tool_call"` | [`ActivityEventKind::ToolCall`] |
19//! | `"tool_result"` | [`ActivityEventKind::ToolResult`] |
20//! | `"stop"` (with `"reason"`) | [`ActivityEventKind::Stop`] |
21//!
22//! Everything else — a non-JSON log line, a JSON value without a known `type`, an empty line — is a
23//! [`ActivityEventKind::Raw`]. This mirrors real observability-only CLIs, which interleave a little
24//! structured output with free-form logging.
25//!
26//! The demux is pure and independently tested; the session ([`crate::session`]) is the only place
27//! that reads the child's stdout and stamps ordering onto the events this produces.
28
29use aion_core::{ActivityEvent, ActivityEventKind, MessageRole, StopKind};
30use chrono::Utc;
31use serde_json::Value;
32use uuid::Uuid;
33
34/// The `(workflow, run, activity, attempt)` identity stamped onto every demuxed event.
35///
36/// The plain-stdout agent carries no agent sub-identity, so [`ActivityEvent::agent_id`] is always
37/// the nil UUID and [`ActivityEvent::agent_role`] is a fixed neutral label — an honest reflection
38/// of a single-agent CLI with no multi-agent attribution to recover.
39#[derive(Clone, Debug)]
40pub struct EventIdentity {
41    /// The workflow this run belongs to.
42    pub workflow_id: aion_core::WorkflowId,
43    /// The concrete run of that workflow this attempt belongs to — stamped from the run spec,
44    /// never synthesised here. It is the axis that keeps two generations of a continue-as-new
45    /// chain from writing one fused transcript.
46    pub run_id: aion_core::RunId,
47    /// The activity within the workflow.
48    pub activity_id: aion_core::ActivityId,
49    /// The attempt number of the run.
50    pub attempt: u32,
51}
52
53/// The fixed agent role for a single-agent plain-stdout CLI (no sub-agent attribution to recover).
54const CLI_AGENT_ROLE: &str = "cli";
55
56/// The `source` label a [`ActivityEventKind::Raw`] passthrough carries — the adapter's stdout.
57const RAW_SOURCE: &str = "stdout";
58
59/// Maps one line of the child's stdout into a neutral [`ActivityEvent`].
60///
61/// `worker_seq` is the session's best-effort local monotonic order for the line. A line that parses
62/// as a recognised JSON object maps onto the matching kind; anything else falls through to
63/// [`ActivityEventKind::Raw`], so no line is ever dropped.
64#[must_use]
65pub fn line_to_event(identity: &EventIdentity, worker_seq: u64, line: &str) -> ActivityEvent {
66    let kind = classify_line(line);
67    ActivityEvent {
68        workflow_id: identity.workflow_id.clone(),
69        run_id: identity.run_id.clone(),
70        activity_id: identity.activity_id.clone(),
71        attempt: identity.attempt,
72        agent_id: Uuid::nil(),
73        agent_role: CLI_AGENT_ROLE.to_owned(),
74        emitted_at: Utc::now(),
75        worker_seq,
76        store_seq: None,
77        // A plain-stdout CLI has no token-delta channel, so no event is ever ephemeral.
78        ephemeral: false,
79        kind,
80    }
81}
82
83/// Classifies a stdout line into an [`ActivityEventKind`].
84///
85/// A JSON object with a known `"type"` maps onto the matching kind; every other line (non-JSON, an
86/// unknown `type`, a bare value) is a [`ActivityEventKind::Raw`] carrying the line verbatim.
87fn classify_line(line: &str) -> ActivityEventKind {
88    let Some(object) = serde_json::from_str::<Value>(line)
89        .ok()
90        .filter(Value::is_object)
91    else {
92        return raw_kind(line);
93    };
94    match object.get("type").and_then(Value::as_str) {
95        Some("message") => message_kind(&object),
96        Some("tool_call") => tool_call_kind(&object),
97        Some("tool_result") => tool_result_kind(&object),
98        Some("stop") => stop_kind(&object),
99        // A JSON object whose `type` this adapter does not map is passed through verbatim, never
100        // dropped: the raw value is the parsed object so the console still sees the structure.
101        _ => ActivityEventKind::Raw {
102            source: RAW_SOURCE.to_owned(),
103            value: object,
104        },
105    }
106}
107
108/// A [`ActivityEventKind::Raw`] carrying a stdout line verbatim as a JSON string value.
109fn raw_kind(line: &str) -> ActivityEventKind {
110    ActivityEventKind::Raw {
111        source: RAW_SOURCE.to_owned(),
112        value: Value::String(line.to_owned()),
113    }
114}
115
116/// Reads a string field from a JSON object, or an empty string when absent.
117fn string_field(object: &Value, key: &str) -> String {
118    object
119        .get(key)
120        .and_then(Value::as_str)
121        .unwrap_or_default()
122        .to_owned()
123}
124
125/// Builds a [`ActivityEventKind::Message`] from a `{"type":"message","text":...}` line.
126///
127/// A plain-stdout CLI's structured messages are agent output, so the neutral role is always
128/// [`MessageRole::Assistant`].
129fn message_kind(object: &Value) -> ActivityEventKind {
130    ActivityEventKind::Message {
131        role: MessageRole::Assistant,
132        text: string_field(object, "text"),
133    }
134}
135
136/// Builds a [`ActivityEventKind::ToolCall`] from a `{"type":"tool_call",...}` line.
137fn tool_call_kind(object: &Value) -> ActivityEventKind {
138    ActivityEventKind::ToolCall {
139        tool: string_field(object, "tool"),
140        call_id: string_field(object, "call_id"),
141        input: object.get("input").cloned().unwrap_or(Value::Null),
142    }
143}
144
145/// Builds a [`ActivityEventKind::ToolResult`] from a `{"type":"tool_result",...}` line.
146fn tool_result_kind(object: &Value) -> ActivityEventKind {
147    ActivityEventKind::ToolResult {
148        call_id: string_field(object, "call_id"),
149        output: object.get("output").cloned().unwrap_or(Value::Null),
150        is_error: object
151            .get("is_error")
152            .and_then(Value::as_bool)
153            .unwrap_or(false),
154    }
155}
156
157/// An adapter-SYNTHESIZED terminal stop event — not derived from any stdout
158/// line, because the ending it records is precisely one the agent could not
159/// announce (a signal death). Stamped exactly as every demuxed event is, so
160/// the transcript carries the ending's detail (e.g. `Other { "terminated by
161/// signal" }`) on the channel built to carry stop detail — the outcome
162/// record's canonical `stop_reason` collapses `Other` to `"other"`, so
163/// WITHOUT this event neither channel would say why the run ended.
164#[must_use]
165pub(crate) fn synthesized_stop_event(
166    identity: &EventIdentity,
167    worker_seq: u64,
168    reason: StopKind,
169) -> ActivityEvent {
170    ActivityEvent {
171        workflow_id: identity.workflow_id.clone(),
172        run_id: identity.run_id.clone(),
173        activity_id: identity.activity_id.clone(),
174        attempt: identity.attempt,
175        agent_id: Uuid::nil(),
176        agent_role: CLI_AGENT_ROLE.to_owned(),
177        emitted_at: Utc::now(),
178        worker_seq,
179        store_seq: None,
180        ephemeral: false,
181        kind: ActivityEventKind::Stop { reason },
182    }
183}
184
185/// Builds a [`ActivityEventKind::Stop`] from a `{"type":"stop","reason":...}` line.
186fn stop_kind(object: &Value) -> ActivityEventKind {
187    let reason = match object.get("reason").and_then(Value::as_str) {
188        Some("end_turn") => StopKind::EndTurn,
189        Some("tool_use") => StopKind::ToolUse,
190        Some("limit_reached") => StopKind::LimitReached,
191        Some("cancelled") => StopKind::Cancelled,
192        Some(other) => StopKind::Other {
193            reason: other.to_owned(),
194        },
195        None => StopKind::Other {
196            reason: "unknown".to_owned(),
197        },
198    };
199    ActivityEventKind::Stop { reason }
200}
201
202#[cfg(test)]
203#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
204mod tests {
205    use super::*;
206    use aion_core::{ActivityId, WorkflowId};
207    use serde_json::json;
208
209    fn identity() -> EventIdentity {
210        EventIdentity {
211            workflow_id: WorkflowId::new(Uuid::nil()),
212            run_id: aion_core::RunId::new(Uuid::from_u128(0x11)),
213            activity_id: ActivityId::from_sequence_position(5),
214            attempt: 2,
215        }
216    }
217
218    #[test]
219    fn message_line_maps_to_message_event() {
220        let line = json!({ "type": "message", "text": "working on it" }).to_string();
221        let event = line_to_event(&identity(), 0, &line);
222        assert_eq!(event.attempt, 2);
223        assert_eq!(event.agent_role, "cli");
224        assert_eq!(event.agent_id, Uuid::nil());
225        assert!(!event.ephemeral);
226        match event.kind {
227            ActivityEventKind::Message { role, text } => {
228                assert_eq!(role, MessageRole::Assistant);
229                assert_eq!(text, "working on it");
230            }
231            other => panic!("expected Message, got {other:?}"),
232        }
233    }
234
235    #[test]
236    fn tool_call_line_maps_to_tool_call_event() {
237        let line = json!({
238            "type": "tool_call",
239            "tool": "search",
240            "call_id": "c1",
241            "input": { "query": "x" },
242        })
243        .to_string();
244        let event = line_to_event(&identity(), 1, &line);
245        match event.kind {
246            ActivityEventKind::ToolCall {
247                tool,
248                call_id,
249                input,
250            } => {
251                assert_eq!(tool, "search");
252                assert_eq!(call_id, "c1");
253                assert_eq!(input, json!({ "query": "x" }));
254            }
255            other => panic!("expected ToolCall, got {other:?}"),
256        }
257    }
258
259    #[test]
260    fn tool_result_line_maps_to_tool_result_event() {
261        let line = json!({
262            "type": "tool_result",
263            "call_id": "c1",
264            "output": { "hits": 3 },
265            "is_error": false,
266        })
267        .to_string();
268        let event = line_to_event(&identity(), 2, &line);
269        match event.kind {
270            ActivityEventKind::ToolResult {
271                call_id,
272                output,
273                is_error,
274            } => {
275                assert_eq!(call_id, "c1");
276                assert_eq!(output, json!({ "hits": 3 }));
277                assert!(!is_error);
278            }
279            other => panic!("expected ToolResult, got {other:?}"),
280        }
281    }
282
283    #[test]
284    fn stop_line_maps_stop_reasons() {
285        for (label, expected) in [
286            ("end_turn", StopKind::EndTurn),
287            ("tool_use", StopKind::ToolUse),
288            ("limit_reached", StopKind::LimitReached),
289            ("cancelled", StopKind::Cancelled),
290        ] {
291            let line = json!({ "type": "stop", "reason": label }).to_string();
292            let event = line_to_event(&identity(), 3, &line);
293            match event.kind {
294                ActivityEventKind::Stop { reason } => assert_eq!(reason, expected),
295                other => panic!("expected Stop, got {other:?}"),
296            }
297        }
298    }
299
300    #[test]
301    fn unknown_stop_reason_falls_through_to_other() {
302        let line = json!({ "type": "stop", "reason": "weird" }).to_string();
303        let event = line_to_event(&identity(), 4, &line);
304        match event.kind {
305            ActivityEventKind::Stop {
306                reason: StopKind::Other { reason },
307            } => assert_eq!(reason, "weird"),
308            other => panic!("expected Stop::Other, got {other:?}"),
309        }
310    }
311
312    #[test]
313    fn a_plain_log_line_falls_through_to_raw_verbatim() {
314        let line = "  [info] starting the run, reading config...  ";
315        let event = line_to_event(&identity(), 5, line);
316        match event.kind {
317            ActivityEventKind::Raw { source, value } => {
318                assert_eq!(source, "stdout");
319                assert_eq!(value, Value::String(line.to_owned()));
320            }
321            other => panic!("expected Raw, got {other:?}"),
322        }
323    }
324
325    #[test]
326    fn a_json_object_with_unknown_type_is_raw_but_keeps_structure() {
327        let line = json!({ "type": "heartbeat", "seq": 4 }).to_string();
328        let event = line_to_event(&identity(), 6, &line);
329        match event.kind {
330            ActivityEventKind::Raw { source, value } => {
331                assert_eq!(source, "stdout");
332                assert_eq!(value["type"], json!("heartbeat"));
333                assert_eq!(value["seq"], json!(4));
334            }
335            other => panic!("expected Raw, got {other:?}"),
336        }
337    }
338
339    #[test]
340    fn a_bare_json_value_is_raw_not_mapped() {
341        // A JSON number/array/string is not an object, so it is Raw (a string of the verbatim line).
342        let line = "[1, 2, 3]";
343        let event = line_to_event(&identity(), 7, line);
344        match event.kind {
345            ActivityEventKind::Raw { value, .. } => {
346                assert_eq!(value, Value::String(line.to_owned()));
347            }
348            other => panic!("expected Raw, got {other:?}"),
349        }
350    }
351
352    #[test]
353    fn worker_seq_is_carried_onto_the_event() {
354        let event = line_to_event(&identity(), 42, "anything");
355        assert_eq!(event.worker_seq, 42);
356    }
357}