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, 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 activity within the workflow.
44    pub activity_id: aion_core::ActivityId,
45    /// The attempt number of the run.
46    pub attempt: u32,
47}
48
49/// The fixed agent role for a single-agent plain-stdout CLI (no sub-agent attribution to recover).
50const CLI_AGENT_ROLE: &str = "cli";
51
52/// The `source` label a [`ActivityEventKind::Raw`] passthrough carries — the adapter's stdout.
53const RAW_SOURCE: &str = "stdout";
54
55/// Maps one line of the child's stdout into a neutral [`ActivityEvent`].
56///
57/// `worker_seq` is the session's best-effort local monotonic order for the line. A line that parses
58/// as a recognised JSON object maps onto the matching kind; anything else falls through to
59/// [`ActivityEventKind::Raw`], so no line is ever dropped.
60#[must_use]
61pub fn line_to_event(identity: &EventIdentity, worker_seq: u64, line: &str) -> ActivityEvent {
62    let kind = classify_line(line);
63    ActivityEvent {
64        workflow_id: identity.workflow_id.clone(),
65        activity_id: identity.activity_id.clone(),
66        attempt: identity.attempt,
67        agent_id: Uuid::nil(),
68        agent_role: CLI_AGENT_ROLE.to_owned(),
69        emitted_at: Utc::now(),
70        worker_seq,
71        store_seq: None,
72        // A plain-stdout CLI has no token-delta channel, so no event is ever ephemeral.
73        ephemeral: false,
74        kind,
75    }
76}
77
78/// Classifies a stdout line into an [`ActivityEventKind`].
79///
80/// A JSON object with a known `"type"` maps onto the matching kind; every other line (non-JSON, an
81/// unknown `type`, a bare value) is a [`ActivityEventKind::Raw`] carrying the line verbatim.
82fn classify_line(line: &str) -> ActivityEventKind {
83    let Some(object) = serde_json::from_str::<Value>(line)
84        .ok()
85        .filter(Value::is_object)
86    else {
87        return raw_kind(line);
88    };
89    match object.get("type").and_then(Value::as_str) {
90        Some("message") => message_kind(&object),
91        Some("tool_call") => tool_call_kind(&object),
92        Some("tool_result") => tool_result_kind(&object),
93        Some("stop") => stop_kind(&object),
94        // A JSON object whose `type` this adapter does not map is passed through verbatim, never
95        // dropped: the raw value is the parsed object so the console still sees the structure.
96        _ => ActivityEventKind::Raw {
97            source: RAW_SOURCE.to_owned(),
98            value: object,
99        },
100    }
101}
102
103/// A [`ActivityEventKind::Raw`] carrying a stdout line verbatim as a JSON string value.
104fn raw_kind(line: &str) -> ActivityEventKind {
105    ActivityEventKind::Raw {
106        source: RAW_SOURCE.to_owned(),
107        value: Value::String(line.to_owned()),
108    }
109}
110
111/// Reads a string field from a JSON object, or an empty string when absent.
112fn string_field(object: &Value, key: &str) -> String {
113    object
114        .get(key)
115        .and_then(Value::as_str)
116        .unwrap_or_default()
117        .to_owned()
118}
119
120/// Builds a [`ActivityEventKind::Message`] from a `{"type":"message","text":...}` line.
121///
122/// A plain-stdout CLI's structured messages are agent output, so the neutral role is always
123/// [`MessageRole::Assistant`].
124fn message_kind(object: &Value) -> ActivityEventKind {
125    ActivityEventKind::Message {
126        role: MessageRole::Assistant,
127        text: string_field(object, "text"),
128    }
129}
130
131/// Builds a [`ActivityEventKind::ToolCall`] from a `{"type":"tool_call",...}` line.
132fn tool_call_kind(object: &Value) -> ActivityEventKind {
133    ActivityEventKind::ToolCall {
134        tool: string_field(object, "tool"),
135        call_id: string_field(object, "call_id"),
136        input: object.get("input").cloned().unwrap_or(Value::Null),
137    }
138}
139
140/// Builds a [`ActivityEventKind::ToolResult`] from a `{"type":"tool_result",...}` line.
141fn tool_result_kind(object: &Value) -> ActivityEventKind {
142    ActivityEventKind::ToolResult {
143        call_id: string_field(object, "call_id"),
144        output: object.get("output").cloned().unwrap_or(Value::Null),
145        is_error: object
146            .get("is_error")
147            .and_then(Value::as_bool)
148            .unwrap_or(false),
149    }
150}
151
152/// Builds a [`ActivityEventKind::Stop`] from a `{"type":"stop","reason":...}` line.
153fn stop_kind(object: &Value) -> ActivityEventKind {
154    let reason = match object.get("reason").and_then(Value::as_str) {
155        Some("end_turn") => StopKind::EndTurn,
156        Some("tool_use") => StopKind::ToolUse,
157        Some("limit_reached") => StopKind::LimitReached,
158        Some("cancelled") => StopKind::Cancelled,
159        Some(other) => StopKind::Other {
160            reason: other.to_owned(),
161        },
162        None => StopKind::Other {
163            reason: "unknown".to_owned(),
164        },
165    };
166    ActivityEventKind::Stop { reason }
167}
168
169#[cfg(test)]
170#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
171mod tests {
172    use super::*;
173    use aion_core::{ActivityId, WorkflowId};
174    use serde_json::json;
175
176    fn identity() -> EventIdentity {
177        EventIdentity {
178            workflow_id: WorkflowId::new(Uuid::nil()),
179            activity_id: ActivityId::from_sequence_position(5),
180            attempt: 2,
181        }
182    }
183
184    #[test]
185    fn message_line_maps_to_message_event() {
186        let line = json!({ "type": "message", "text": "working on it" }).to_string();
187        let event = line_to_event(&identity(), 0, &line);
188        assert_eq!(event.attempt, 2);
189        assert_eq!(event.agent_role, "cli");
190        assert_eq!(event.agent_id, Uuid::nil());
191        assert!(!event.ephemeral);
192        match event.kind {
193            ActivityEventKind::Message { role, text } => {
194                assert_eq!(role, MessageRole::Assistant);
195                assert_eq!(text, "working on it");
196            }
197            other => panic!("expected Message, got {other:?}"),
198        }
199    }
200
201    #[test]
202    fn tool_call_line_maps_to_tool_call_event() {
203        let line = json!({
204            "type": "tool_call",
205            "tool": "search",
206            "call_id": "c1",
207            "input": { "query": "x" },
208        })
209        .to_string();
210        let event = line_to_event(&identity(), 1, &line);
211        match event.kind {
212            ActivityEventKind::ToolCall {
213                tool,
214                call_id,
215                input,
216            } => {
217                assert_eq!(tool, "search");
218                assert_eq!(call_id, "c1");
219                assert_eq!(input, json!({ "query": "x" }));
220            }
221            other => panic!("expected ToolCall, got {other:?}"),
222        }
223    }
224
225    #[test]
226    fn tool_result_line_maps_to_tool_result_event() {
227        let line = json!({
228            "type": "tool_result",
229            "call_id": "c1",
230            "output": { "hits": 3 },
231            "is_error": false,
232        })
233        .to_string();
234        let event = line_to_event(&identity(), 2, &line);
235        match event.kind {
236            ActivityEventKind::ToolResult {
237                call_id,
238                output,
239                is_error,
240            } => {
241                assert_eq!(call_id, "c1");
242                assert_eq!(output, json!({ "hits": 3 }));
243                assert!(!is_error);
244            }
245            other => panic!("expected ToolResult, got {other:?}"),
246        }
247    }
248
249    #[test]
250    fn stop_line_maps_stop_reasons() {
251        for (label, expected) in [
252            ("end_turn", StopKind::EndTurn),
253            ("tool_use", StopKind::ToolUse),
254            ("limit_reached", StopKind::LimitReached),
255            ("cancelled", StopKind::Cancelled),
256        ] {
257            let line = json!({ "type": "stop", "reason": label }).to_string();
258            let event = line_to_event(&identity(), 3, &line);
259            match event.kind {
260                ActivityEventKind::Stop { reason } => assert_eq!(reason, expected),
261                other => panic!("expected Stop, got {other:?}"),
262            }
263        }
264    }
265
266    #[test]
267    fn unknown_stop_reason_falls_through_to_other() {
268        let line = json!({ "type": "stop", "reason": "weird" }).to_string();
269        let event = line_to_event(&identity(), 4, &line);
270        match event.kind {
271            ActivityEventKind::Stop {
272                reason: StopKind::Other { reason },
273            } => assert_eq!(reason, "weird"),
274            other => panic!("expected Stop::Other, got {other:?}"),
275        }
276    }
277
278    #[test]
279    fn a_plain_log_line_falls_through_to_raw_verbatim() {
280        let line = "  [info] starting the run, reading config...  ";
281        let event = line_to_event(&identity(), 5, line);
282        match event.kind {
283            ActivityEventKind::Raw { source, value } => {
284                assert_eq!(source, "stdout");
285                assert_eq!(value, Value::String(line.to_owned()));
286            }
287            other => panic!("expected Raw, got {other:?}"),
288        }
289    }
290
291    #[test]
292    fn a_json_object_with_unknown_type_is_raw_but_keeps_structure() {
293        let line = json!({ "type": "heartbeat", "seq": 4 }).to_string();
294        let event = line_to_event(&identity(), 6, &line);
295        match event.kind {
296            ActivityEventKind::Raw { source, value } => {
297                assert_eq!(source, "stdout");
298                assert_eq!(value["type"], json!("heartbeat"));
299                assert_eq!(value["seq"], json!(4));
300            }
301            other => panic!("expected Raw, got {other:?}"),
302        }
303    }
304
305    #[test]
306    fn a_bare_json_value_is_raw_not_mapped() {
307        // A JSON number/array/string is not an object, so it is Raw (a string of the verbatim line).
308        let line = "[1, 2, 3]";
309        let event = line_to_event(&identity(), 7, line);
310        match event.kind {
311            ActivityEventKind::Raw { value, .. } => {
312                assert_eq!(value, Value::String(line.to_owned()));
313            }
314            other => panic!("expected Raw, got {other:?}"),
315        }
316    }
317
318    #[test]
319    fn worker_seq_is_carried_onto_the_event() {
320        let event = line_to_event(&identity(), 42, "anything");
321        assert_eq!(event.worker_seq, 42);
322    }
323}