Skip to main content

aion_integration_cli/
session.rs

1//! [`CliSession`] — a live plain-stdout CLI agent run behind the [`AgentSession`] seam.
2//!
3//! This is the **observability-only** case (case (b), §3A.1): the agent has no structured control
4//! channel at all. One background **pump task** owns the child's stdout, reads it line by line,
5//! demuxes each line into a neutral [`ActivityEvent`] ([`crate::demux`]), and forwards it to the
6//! [`AgentSession::events`] stream. When stdout closes (end of run) it captures the terminal result
7//! (the final stdout line, how the child ended, and the last stop reason the agent announced) and
8//! hands it to [`AgentSession::wait_result`].
9//!
10//! The capability set is **always empty**: [`AgentSession::capabilities`] returns
11//! [`InterventionCapabilities::none`] and [`AgentSession::intervene`] rejects **every** command with
12//! [`HarnessError::CapabilityNotSupported`]. There is no control channel to route a command onto —
13//! the empty advertisement is a first-class tier, not a degenerate one, and the server never routes
14//! a command to a session that advertises nothing.
15
16use std::sync::Arc;
17
18use aion_core::{
19    ActivityEvent, AgentOutcome, InterventionCapabilities, InterventionCommand, Payload, StopKind,
20};
21use aion_integrations::contract::AgentSession;
22use aion_integrations::error::HarnessError;
23use async_trait::async_trait;
24use futures::stream::BoxStream;
25use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader, Lines};
26use tokio::sync::{Mutex, mpsc, oneshot};
27
28use crate::demux::{self, EventIdentity};
29
30/// How the child process ended, kept honest by naming all three endings.
31///
32/// A flattened `Option<i32>` conflated two endings that must not share a fate:
33/// `None` used to mean BOTH "no child at all" (the in-memory reader path) and
34/// "terminated by a signal" — and a signal-killed agent fell through the
35/// non-zero-exit refusal into a result claiming the agent finished its turn.
36enum ChildEnd {
37    /// No child process exists — the session runs over a bare reader (tests,
38    /// in-memory drivers). End of stream is the only ending observable.
39    NoChild,
40    /// The child exited on its own, with this exit code.
41    Exited(i32),
42    /// The child was terminated by a signal: it did not end by its own choice.
43    Signalled,
44    /// The child's exit status could not be read at all (an OS-level `wait`
45    /// failure) — an ending that cannot honestly be classified as any of the
46    /// other three, carried so it refuses loudly instead of guessing.
47    StatusUnreadable {
48        /// The wait error as the OS reported it.
49        detail: String,
50    },
51}
52
53/// The terminal outcome the pump captures when stdout closes: the final line,
54/// how the child ended, and the last stop reason the agent itself announced.
55struct TerminalOutcome {
56    /// The last non-empty stdout line the agent printed, if any — its "final answer".
57    final_line: Option<String>,
58    /// How the child process ended.
59    end: ChildEnd,
60    /// The reason from the LAST `{"type":"stop",...}` line the demux produced,
61    /// if the run announced one — the agent's own account of why it stopped,
62    /// retained so the outcome record reports it instead of inventing one.
63    announced_stop: Option<StopKind>,
64}
65
66/// A live plain-stdout CLI agent run for one activity attempt.
67///
68/// Generic over the child's stdout read half so tests can drive it over an in-memory reader; in
69/// production it is the child process's stdout. The child handle is held so it is reaped on drop.
70pub struct CliSession {
71    capabilities: InterventionCapabilities,
72    events: Option<mpsc::UnboundedReceiver<ActivityEvent>>,
73    result: Option<oneshot::Receiver<TerminalOutcome>>,
74    pump: Option<tokio::task::JoinHandle<()>>,
75    /// The attempt's `(workflow, run, activity, attempt)` identity — stamped onto every demuxed
76    /// event by the pump, and the material for the outcome record's derived `session_id` (a
77    /// one-shot process has no persisted session, so the identity quadruple IS the conversation's
78    /// honest handle).
79    identity: EventIdentity,
80    /// The spawned child, reaped when the session is dropped. `None` for the in-memory test path.
81    child: Arc<Mutex<Option<tokio::process::Child>>>,
82}
83
84impl CliSession {
85    /// Builds a session over a child's stdout read half and starts the reader pump.
86    ///
87    /// `identity` stamps the run key onto every demuxed event. `child` (when present) is awaited by
88    /// the pump for the exit status and reaped on drop. The pump starts here so events stream from
89    /// the moment the session exists.
90    pub(crate) fn start<R>(
91        stdout: R,
92        identity: EventIdentity,
93        child: Option<tokio::process::Child>,
94    ) -> Self
95    where
96        R: AsyncRead + Unpin + Send + 'static,
97    {
98        let (event_tx, event_rx) = mpsc::unbounded_channel();
99        let (result_tx, result_rx) = oneshot::channel();
100        let child = Arc::new(Mutex::new(child));
101
102        let pump = tokio::spawn(pump_loop(
103            BufReader::new(stdout).lines(),
104            identity.clone(),
105            event_tx,
106            result_tx,
107            Arc::clone(&child),
108        ));
109
110        Self {
111            capabilities: InterventionCapabilities::none(),
112            events: Some(event_rx),
113            result: Some(result_rx),
114            pump: Some(pump),
115            identity,
116            child,
117        }
118    }
119}
120
121/// The reader pump: demuxes every stdout line into an event until stdout closes, then captures the
122/// terminal outcome (final line + exit status) and sends it to the result waiter.
123///
124/// A send failure on the event channel means the consumer dropped the stream; the pump keeps
125/// reading so the terminal result is still captured, it just stops forwarding events.
126async fn pump_loop<R>(
127    mut lines: Lines<BufReader<R>>,
128    identity: EventIdentity,
129    event_tx: mpsc::UnboundedSender<ActivityEvent>,
130    result_tx: oneshot::Sender<TerminalOutcome>,
131    child: Arc<Mutex<Option<tokio::process::Child>>>,
132) where
133    R: AsyncRead + Unpin + Send,
134{
135    let mut worker_seq: u64 = 0;
136    let mut final_line: Option<String> = None;
137    let mut announced_stop: Option<StopKind> = None;
138    loop {
139        match lines.next_line().await {
140            Ok(Some(line)) => {
141                if !line.trim().is_empty() {
142                    final_line = Some(line.clone());
143                }
144                let event = demux::line_to_event(&identity, worker_seq, &line);
145                worker_seq = worker_seq.saturating_add(1);
146                // The agent's own stop announcement is part of the terminal
147                // truth, not just transcript colour: the LAST one is retained
148                // so the outcome record can report the reason the demux
149                // already produced instead of inventing `end_turn`.
150                if let aion_core::ActivityEventKind::Stop { reason } = &event.kind {
151                    announced_stop = Some(reason.clone());
152                }
153                let _ = event_tx.send(event);
154            }
155            // End of stdout == end of run: capture the terminal outcome and stop.
156            Ok(None) => break,
157            Err(error) => {
158                tracing::debug!(%error, "cli adapter: reader pump ended on a stdout read error");
159                break;
160            }
161        }
162    }
163
164    let end = wait_child_end(&child).await;
165    // A signal death is an ending the agent could not announce, so the pump
166    // announces it: a synthesized terminal Stop event puts the detail
167    // ("terminated by signal") on the transcript — the one channel whose
168    // `StopKind::Other` carries a reason verbatim, where the outcome record's
169    // canonical vocabulary collapses it to `"other"`.
170    if matches!(end, ChildEnd::Signalled) {
171        let stop = demux::synthesized_stop_event(
172            &identity,
173            worker_seq,
174            StopKind::Other {
175                reason: "terminated by signal".to_owned(),
176            },
177        );
178        let _ = event_tx.send(stop);
179    }
180    // A dropped result receiver (the caller gave up on wait_result) is benign.
181    let _ = result_tx.send(TerminalOutcome {
182        final_line,
183        end,
184        announced_stop,
185    });
186}
187
188/// Awaits the child's exit and reports how it ended — exit code, signal
189/// termination, or no child at all (the in-memory reader path).
190async fn wait_child_end(child: &Arc<Mutex<Option<tokio::process::Child>>>) -> ChildEnd {
191    let mut guard = child.lock().await;
192    let Some(child) = guard.as_mut() else {
193        return ChildEnd::NoChild;
194    };
195    match child.wait().await {
196        Ok(status) => match status.code() {
197            Some(code) => ChildEnd::Exited(code),
198            // On Unix an exit status without a code is a signal termination;
199            // it must never be mistaken for a clean exit.
200            None => ChildEnd::Signalled,
201        },
202        Err(error) => ChildEnd::StatusUnreadable {
203            detail: error.to_string(),
204        },
205    }
206}
207
208#[async_trait]
209impl AgentSession for CliSession {
210    fn capabilities(&self) -> &InterventionCapabilities {
211        // Always the empty set: observability-only, no control channel.
212        &self.capabilities
213    }
214
215    fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
216        // The stream is taken once; a second call yields an empty stream rather than panicking.
217        match self.events.take() {
218            Some(receiver) => Box::pin(tokio_stream::wrappers::UnboundedReceiverStream::new(
219                receiver,
220            )),
221            None => Box::pin(futures::stream::empty()),
222        }
223    }
224
225    async fn intervene(&self, cmd: InterventionCommand) -> Result<(), HarnessError> {
226        // The empty capability set rejects EVERY command: there is no control channel at all. This
227        // is the first-class observability-only rejection, not an internal failure.
228        Err(HarnessError::capability_not_supported(format!(
229            "{:?}",
230            cmd.kind.primitive()
231        )))
232    }
233
234    async fn wait_result(mut self) -> Result<Payload, HarnessError> {
235        let receiver = self
236            .result
237            .take()
238            .ok_or_else(|| HarnessError::protocol("run result already awaited"))?;
239        let outcome = receiver.await.map_err(|_recv| {
240            HarnessError::transport("cli agent result channel closed before the run finished")
241        })?;
242        terminal_outcome_into_payload(outcome, &self.identity)
243    }
244}
245
246/// Interprets the captured terminal outcome as the replay-authoritative [`Payload`].
247///
248/// A non-zero exit code is an application-level failure ([`HarnessError::Harness`]), and an
249/// unreadable exit status refuses rather than guesses. Every other ending yields the canonical
250/// [`AgentOutcome`] record — the same JSON result shape the worker captures from EVERY harness.
251/// `text` is the final stdout line (the agent's "answer"); a run that printed nothing yields the
252/// honest empty string, never an invented answer. `stop_reason` is the run's own account, through
253/// the crate's demux vocabulary:
254///
255/// - the LAST `{"type":"stop","reason":...}` line the agent printed, when it printed one — the
256///   reason the demux already produced, never re-derived;
257/// - [`StopKind::EndTurn`] ONLY when a cleanly-ended run announced no stop line at all
258///   (end-of-stdout with a zero exit, or the in-memory no-child path);
259/// - a signal-killed child is [`StopKind::Other`] (`terminated by signal`): the completion policy
260///   is unchanged — such a run completed before this and still does — but the record stops
261///   claiming the agent chose to end its turn, and it overrides any earlier announced stop
262///   because a stop line followed by a signal death was a mid-run announcement, not the ending.
263///
264/// `session_id` is derived from the attempt's identity
265/// (`{workflow_id}:{run_id}:{activity_id}:{attempt}`): a cli agent is a one-shot process with no
266/// persisted session, so the conversation the record names IS that one invocation, and the
267/// identity quadruple is its honest handle. Per the record's contract the id is meaningful only
268/// to the harness that minted it — this harness has no load path, so the handle identifies the
269/// conversation and promises nothing. The harness's `final_line` is already the final agent
270/// message, so it honestly fills both `text` and `final_message`; there is no earlier-answer
271/// accumulation in this adapter.
272fn terminal_outcome_into_payload(
273    outcome: TerminalOutcome,
274    identity: &EventIdentity,
275) -> Result<Payload, HarnessError> {
276    let TerminalOutcome {
277        final_line,
278        end,
279        announced_stop,
280    } = outcome;
281    let stop = match end {
282        ChildEnd::Exited(code) if code != 0 => {
283            return Err(HarnessError::harness(format!(
284                "cli agent exited with a non-zero status: {code}"
285            )));
286        }
287        ChildEnd::StatusUnreadable { detail } => {
288            return Err(HarnessError::transport(format!(
289                "the cli agent's exit status could not be read, so the run has no honest \
290                 terminal classification: {detail}"
291            )));
292        }
293        ChildEnd::Signalled => StopKind::Other {
294            reason: "terminated by signal".to_owned(),
295        },
296        ChildEnd::Exited(_) | ChildEnd::NoChild => announced_stop.unwrap_or(StopKind::EndTurn),
297    };
298    let session_id = format!(
299        "{}:{}:{}:{}",
300        identity.workflow_id, identity.run_id, identity.activity_id, identity.attempt
301    );
302    let final_line = final_line.unwrap_or_default();
303    AgentOutcome::new(final_line.clone(), final_line, &stop, session_id)
304        .into_payload()
305        .map_err(|source| {
306            HarnessError::protocol(format!("cli agent result is not encodable: {source}"))
307        })
308}
309
310impl Drop for CliSession {
311    fn drop(&mut self) {
312        // Abort the pump and best-effort kill the child so a dropped session leaks neither a task
313        // nor a process.
314        if let Some(pump) = self.pump.take() {
315            pump.abort();
316        }
317        if let Ok(mut guard) = self.child.try_lock()
318            && let Some(child) = guard.as_mut()
319        {
320            let _ = child.start_kill();
321        }
322    }
323}
324
325#[cfg(test)]
326#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
327mod tests;