Skip to main content

aion_worker/runtime/
agent.rs

1//! The harness-blind trait driver: [`spawn_agent`] drives ANY [`AgentHarness`]
2//! generically (NOI-4, §3A.1).
3//!
4//! This is a NEW additive spawn mode beside the worker's existing one-shot
5//! `.output()` capture (the `run_norn_step` path in the norn-fan-worker example,
6//! which stays working and unchanged). It is the single place the worker DRIVES
7//! an agent session:
8//!
9//! - it pumps the session's neutral [`AgentSession::events`] stream OUT to a
10//!   caller-supplied event sink (the worker's transcript delivery — an
11//!   [`mpsc::UnboundedSender<ActivityEvent>`], the `event_sender` NOI-5 wires to
12//!   liminal),
13//! - it feeds neutral commands IN from a caller-supplied control source (the
14//!   `control_receiver` NOI-6 wires from the server's intervention PUSH) to
15//!   [`AgentSession::intervene`], and
16//! - it correlates the single terminal [`AgentSession::wait_result`] into
17//!   [`DispatchOutcome::Completed { output }`][DispatchOutcome::Completed] — the
18//!   same replay-authoritative output the one-shot capture produces today.
19//!
20//! **The worker stays harness-blind.** [`spawn_agent`] is generic over
21//! `AgentHarness`; it never names a concrete adapter, a transport, or a wire
22//! protocol. The concrete harness (Norn, or a future one) is injected by the
23//! binary composition root (aion-cli), never chosen here. This crate depends on
24//! `aion-integrations` for the TRAIT ONLY and has NO edge to
25//! `aion-integration-norn` or `norn` (the §3A.4 invariant, CI-gated).
26//!
27//! # Result/event split (structural, §4.1)
28//!
29//! Events flow only to the event sink; the terminal result flows only from
30//! [`AgentSession::wait_result`]. An event can never be captured as the result
31//! and the result is never delivered as an event — the two are distinct channels
32//! of the session by construction, so the driver never has to disambiguate them.
33
34use aion_core::{InterventionCommand, InterventionOutcome};
35use aion_integrations::contract::{AgentHarness, AgentSession, DynAgentHarness, DynAgentSession};
36use aion_integrations::error::HarnessError;
37use aion_integrations::spec::AgentRunSpec;
38use futures::StreamExt;
39use tokio::sync::{mpsc, oneshot};
40use tracing::{debug, warn};
41
42use crate::activity::ActivityFailure;
43use crate::runtime::loop_::DispatchOutcome;
44
45/// The neutral event sink the driver pumps a session's [`AgentSession::events`]
46/// out to. This is the `event_sender` the worker's [`ActivityContext`] transcript
47/// delivery installs; NOI-5 forwards it onto a liminal events channel.
48///
49/// [`ActivityContext`]: crate::context::ActivityContext
50pub type ActivityEventSender = mpsc::UnboundedSender<aion_core::ActivityEvent>;
51
52/// One routed intervention on the driver's control channel: the neutral command
53/// plus an OPTIONAL reply channel the driver answers with the neutral
54/// [`InterventionOutcome`] ack.
55///
56/// The ack is what closes the loop back to the operator (NOI-6 §6.4): after the
57/// driver calls [`AgentSession::intervene`] it maps the session's result onto an
58/// [`InterventionOutcome`] and, when an `ack` sender is present, replies with it.
59/// A `None` ack is the fire-and-forget shape (used where no operator is waiting,
60/// e.g. an internal test that only observes the applied side-effect).
61#[derive(Debug)]
62pub struct ControlMessage {
63    /// The neutral command to apply to the session.
64    pub command: InterventionCommand,
65    /// Optional reply channel the driver answers with the applied/gated/stale ack.
66    pub ack: Option<oneshot::Sender<InterventionOutcome>>,
67}
68
69impl ControlMessage {
70    /// A fire-and-forget control message with no ack reply channel.
71    #[must_use]
72    pub const fn new(command: InterventionCommand) -> Self {
73        Self { command, ack: None }
74    }
75
76    /// A control message paired with a reply channel for its ack.
77    #[must_use]
78    pub const fn with_ack(
79        command: InterventionCommand,
80        ack: oneshot::Sender<InterventionOutcome>,
81    ) -> Self {
82        Self {
83            command,
84            ack: Some(ack),
85        }
86    }
87}
88
89/// The neutral command source the driver feeds into a session's
90/// [`AgentSession::intervene`]. This is the `control_receiver` the worker installs
91/// per attempt; NOI-6 delivers server-routed operator commands (each with its ack
92/// reply channel) onto it.
93pub type ControlReceiver = mpsc::UnboundedReceiver<ControlMessage>;
94
95/// Drives one activity attempt through the neutral [`AgentHarness`] seam and
96/// returns its terminal [`DispatchOutcome`].
97///
98/// This is the harness-blind trait driver (NOI-4). It:
99///
100/// 1. starts the harness for `spec` (spawn/connect + capability handshake),
101/// 2. concurrently pumps the session's [`AgentSession::events`] to `event_sender`
102///    and feeds `control_receiver` commands to [`AgentSession::intervene`] until
103///    the event stream ends, then
104/// 3. awaits [`AgentSession::wait_result`] and maps it into
105///    [`DispatchOutcome::Completed { output }`][DispatchOutcome::Completed].
106///
107/// A `control_receiver` of `None` runs the session with no intervention channel —
108/// the observability-only shape. When present, a command whose primitive the
109/// session does not advertise is rejected by the session with
110/// [`HarnessError::CapabilityNotSupported`]; the driver logs that rejection and
111/// keeps running (a gated command is a normal, non-fatal outcome, not a run
112/// failure).
113///
114/// The event stream ending signals end-of-run: the driver then takes the terminal
115/// result. This is why events must be a distinct channel from the result — the
116/// driver relies on the stream closing (not on inspecting any event) to know the
117/// run is done, and the result arrives only from [`AgentSession::wait_result`].
118///
119/// # Errors
120///
121/// Returns [`HarnessError`] when the harness cannot be started or when the
122/// terminal result cannot be received. A harness-reported application failure
123/// ([`HarnessError::Harness`]) is returned to the caller, which maps it to a
124/// [`DispatchOutcome::Failed`] via [`harness_error_to_outcome`]; transport and
125/// protocol faults are surfaced as-is for the caller to classify.
126pub async fn spawn_agent<H>(
127    harness: &H,
128    spec: AgentRunSpec,
129    event_sender: ActivityEventSender,
130    control_receiver: Option<ControlReceiver>,
131) -> Result<DispatchOutcome, HarnessError>
132where
133    H: AgentHarness,
134{
135    let mut session = harness.start(spec).await?;
136
137    // The events stream is a detached `'static` stream: taking it does NOT borrow
138    // the session, so the driver can still call `intervene`/`wait_result` on the
139    // session while pumping events. This is what lets all three run in one task.
140    // (Disambiguated to the typed `AgentSession` — the blanket `DynAgentSession`
141    // impl also defines an `events`, so the method must name its trait.)
142    let mut events = AgentSession::events(&mut session);
143    let mut control = control_receiver;
144
145    // Pump events out and commands in until the event stream closes (end-of-run).
146    // Commands after the stream closes cannot be delivered — the session is
147    // terminating — so the loop ends with the stream.
148    loop {
149        tokio::select! {
150            biased;
151            // A command from the server-routed control channel. Feed it into the
152            // session; a capability-gated rejection is logged, not fatal.
153            maybe_message = recv_control(&mut control) => {
154                match maybe_message {
155                    Some(message) => deliver_command(&session, message).await,
156                    // The control channel closed: drop it and keep pumping events
157                    // to the terminal result (a closed control channel is not an
158                    // end-of-run signal — only the event stream closing is).
159                    None => control = None,
160                }
161            }
162            event = events.next() => {
163                match event {
164                    Some(event) => {
165                        // A closed event sink means the transcript consumer went
166                        // away; stop forwarding but keep draining to the result.
167                        if event_sender.send(event).is_err() {
168                            debug!("agent driver: event sink closed; stopping event forwarding");
169                            break;
170                        }
171                    }
172                    // End of the event stream == end of run. Take the result next.
173                    None => break,
174                }
175            }
176        }
177    }
178
179    // The single terminal result — the replay-authoritative activity output.
180    let output = AgentSession::wait_result(session).await?;
181    Ok(DispatchOutcome::Completed { output })
182}
183
184/// Drives one activity attempt through an ERASED [`DynAgentHarness`] — the same
185/// harness-blind driver as [`spawn_agent`], but over a `dyn` harness a worker can
186/// HOLD without being generic (the typed [`AgentHarness`] is not object-safe).
187///
188/// Behaviour is identical to [`spawn_agent`]: it starts the harness, concurrently
189/// pumps the session's events to `event_sender` and feeds `control_receiver`
190/// commands to the session, and maps the terminal result into
191/// [`DispatchOutcome::Completed`]. The only difference is the erased session type,
192/// so the whole event/intervention contract (§4.1 result/event split, capability
193/// gating, stale-target no-op) holds unchanged.
194///
195/// # Errors
196///
197/// Returns [`HarnessError`] when the harness cannot be started or the terminal
198/// result cannot be received — same taxonomy as [`spawn_agent`].
199pub async fn spawn_dyn_agent(
200    harness: &dyn DynAgentHarness,
201    spec: AgentRunSpec,
202    event_sender: ActivityEventSender,
203    control_receiver: Option<ControlReceiver>,
204) -> Result<DispatchOutcome, HarnessError> {
205    let mut session = harness.start_dyn(spec).await?;
206    let mut events = session.events();
207    let mut control = control_receiver;
208
209    loop {
210        tokio::select! {
211            biased;
212            maybe_message = recv_control(&mut control) => {
213                match maybe_message {
214                    Some(message) => deliver_dyn_command(session.as_ref(), message).await,
215                    None => control = None,
216                }
217            }
218            event = events.next() => {
219                match event {
220                    Some(event) => {
221                        if event_sender.send(event).is_err() {
222                            debug!("agent driver: event sink closed; stopping event forwarding");
223                            break;
224                        }
225                    }
226                    None => break,
227                }
228            }
229        }
230    }
231
232    let output = session.wait_result().await?;
233    Ok(DispatchOutcome::Completed { output })
234}
235
236/// The erased twin of [`deliver_command`] — delivers one command to a
237/// [`DynAgentSession`], replies the ack, and logs a gated/stale outcome without
238/// ending the run.
239async fn deliver_dyn_command(session: &dyn DynAgentSession, message: ControlMessage) {
240    let ControlMessage { command, ack } = message;
241    let primitive = command.kind.primitive();
242    let outcome = match session.intervene(command).await {
243        Ok(()) => InterventionOutcome::Applied,
244        Err(HarnessError::CapabilityNotSupported { .. }) => {
245            InterventionOutcome::capability_not_supported(primitive)
246        }
247        Err(HarnessError::StaleTarget { detail }) => InterventionOutcome::stale_target(detail),
248        Err(error) => InterventionOutcome::stale_target(error.to_string()),
249    };
250    match &outcome {
251        InterventionOutcome::Applied => debug!(?primitive, "agent driver: intervention delivered"),
252        InterventionOutcome::CapabilityNotSupported { primitive } => {
253            debug!(
254                ?primitive,
255                "agent driver: intervention gated (capability not supported)"
256            );
257        }
258        InterventionOutcome::StaleTarget { detail } => {
259            warn!(?primitive, %detail, "agent driver: intervention delivery failed");
260        }
261    }
262    if let Some(ack) = ack {
263        drop(ack.send(outcome));
264    }
265}
266
267/// Receives the next control message, or pends forever when there is no control
268/// channel — so the `select!` arm simply never fires in the no-intervention case.
269async fn recv_control(control: &mut Option<ControlReceiver>) -> Option<ControlMessage> {
270    match control {
271        Some(receiver) => receiver.recv().await,
272        None => std::future::pending().await,
273    }
274}
275
276/// Delivers one command to the session, replies its neutral ack (when a reply
277/// channel is present), and logs a capability-gated rejection or a delivery fault
278/// without ending the run.
279async fn deliver_command<S>(session: &S, message: ControlMessage)
280where
281    S: AgentSession,
282{
283    let ControlMessage { command, ack } = message;
284    let primitive = command.kind.primitive();
285    let outcome = apply_command(session, command).await;
286    match &outcome {
287        InterventionOutcome::Applied => {
288            debug!(?primitive, "agent driver: intervention delivered");
289        }
290        InterventionOutcome::CapabilityNotSupported { primitive } => {
291            // A gated command is a normal outcome of capability negotiation, not a
292            // run failure: the server should not route an unadvertised primitive,
293            // but if one arrives the driver rejects it cleanly and keeps running.
294            debug!(
295                ?primitive,
296                "agent driver: intervention gated (capability not supported)"
297            );
298        }
299        InterventionOutcome::StaleTarget { detail } => {
300            // A transport/protocol/stale fault delivering a command does not fail
301            // the run — the run continues and its terminal result stands.
302            warn!(?primitive, %detail, "agent driver: intervention delivery failed");
303        }
304    }
305    // Reply the ack to the waiting operator, if any. A dropped receiver (operator
306    // gone) is benign — the command still applied to the session.
307    if let Some(ack) = ack {
308        drop(ack.send(outcome));
309    }
310}
311
312/// Applies one command to the session and maps the session's result onto the
313/// neutral [`InterventionOutcome`] ack the operator receives.
314///
315/// The mapping is the harness-blind translation of the neutral error taxonomy into
316/// the three locked outcome classes (§6.4): a capability-gated rejection becomes
317/// [`InterventionOutcome::CapabilityNotSupported`]; a stale-target rejection or any
318/// transport/protocol/harness fault becomes [`InterventionOutcome::StaleTarget`]
319/// (an honest NACK, never a crash — the run continues regardless).
320async fn apply_command<S>(session: &S, command: InterventionCommand) -> InterventionOutcome
321where
322    S: AgentSession,
323{
324    let primitive = command.kind.primitive();
325    match session.intervene(command).await {
326        Ok(()) => InterventionOutcome::Applied,
327        Err(HarnessError::CapabilityNotSupported { .. }) => {
328            InterventionOutcome::capability_not_supported(primitive)
329        }
330        Err(HarnessError::StaleTarget { detail }) => InterventionOutcome::stale_target(detail),
331        Err(error) => InterventionOutcome::stale_target(error.to_string()),
332    }
333}
334
335/// Maps a [`HarnessError`] into a [`DispatchOutcome::Failed`] a caller can report.
336///
337/// A [`HarnessError::Harness`] (the agent ran but reported failure) and a
338/// transport/protocol/stale fault are all retryable activity failures: the
339/// attempt did not produce an accepted result, so the engine re-dispatches. This
340/// keeps the trait driver's failure mapping in one place beside the driver.
341#[must_use]
342pub fn harness_error_to_outcome(error: &HarnessError) -> DispatchOutcome {
343    DispatchOutcome::Failed {
344        failure: ActivityFailure::retryable(error.to_string()).into(),
345    }
346}
347
348#[cfg(test)]
349#[path = "agent_tests.rs"]
350mod tests;