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        // Policy refusal is a result-path class. If an adapter reports it while
248        // delivering an intervention, it follows the explicit stale-target NACK
249        // path without ending the run.
250        Err(HarnessError::StaleTarget { detail } | HarnessError::PolicyRefused { detail, .. }) => {
251            InterventionOutcome::stale_target(detail)
252        }
253        Err(error) => InterventionOutcome::stale_target(error.to_string()),
254    };
255    match &outcome {
256        InterventionOutcome::Applied => debug!(?primitive, "agent driver: intervention delivered"),
257        InterventionOutcome::CapabilityNotSupported { primitive } => {
258            debug!(
259                ?primitive,
260                "agent driver: intervention gated (capability not supported)"
261            );
262        }
263        InterventionOutcome::StaleTarget { detail } => {
264            warn!(?primitive, %detail, "agent driver: intervention delivery failed");
265        }
266    }
267    if let Some(ack) = ack {
268        drop(ack.send(outcome));
269    }
270}
271
272/// Receives the next control message, or pends forever when there is no control
273/// channel — so the `select!` arm simply never fires in the no-intervention case.
274async fn recv_control(control: &mut Option<ControlReceiver>) -> Option<ControlMessage> {
275    match control {
276        Some(receiver) => receiver.recv().await,
277        None => std::future::pending().await,
278    }
279}
280
281/// Delivers one command to the session, replies its neutral ack (when a reply
282/// channel is present), and logs a capability-gated rejection or a delivery fault
283/// without ending the run.
284async fn deliver_command<S>(session: &S, message: ControlMessage)
285where
286    S: AgentSession,
287{
288    let ControlMessage { command, ack } = message;
289    let primitive = command.kind.primitive();
290    let outcome = apply_command(session, command).await;
291    match &outcome {
292        InterventionOutcome::Applied => {
293            debug!(?primitive, "agent driver: intervention delivered");
294        }
295        InterventionOutcome::CapabilityNotSupported { primitive } => {
296            // A gated command is a normal outcome of capability negotiation, not a
297            // run failure: the server should not route an unadvertised primitive,
298            // but if one arrives the driver rejects it cleanly and keeps running.
299            debug!(
300                ?primitive,
301                "agent driver: intervention gated (capability not supported)"
302            );
303        }
304        InterventionOutcome::StaleTarget { detail } => {
305            // A transport/protocol/stale fault delivering a command does not fail
306            // the run — the run continues and its terminal result stands.
307            warn!(?primitive, %detail, "agent driver: intervention delivery failed");
308        }
309    }
310    // Reply the ack to the waiting operator, if any. A dropped receiver (operator
311    // gone) is benign — the command still applied to the session.
312    if let Some(ack) = ack {
313        drop(ack.send(outcome));
314    }
315}
316
317/// Applies one command to the session and maps the session's result onto the
318/// neutral [`InterventionOutcome`] ack the operator receives.
319///
320/// The mapping is the harness-blind translation of the neutral error taxonomy into
321/// the three locked outcome classes (§6.4): a capability-gated rejection becomes
322/// [`InterventionOutcome::CapabilityNotSupported`]; a stale-target rejection or any
323/// transport/protocol/harness fault becomes [`InterventionOutcome::StaleTarget`]
324/// (an honest NACK, never a crash — the run continues regardless).
325async fn apply_command<S>(session: &S, command: InterventionCommand) -> InterventionOutcome
326where
327    S: AgentSession,
328{
329    let primitive = command.kind.primitive();
330    match session.intervene(command).await {
331        Ok(()) => InterventionOutcome::Applied,
332        Err(HarnessError::CapabilityNotSupported { .. }) => {
333            InterventionOutcome::capability_not_supported(primitive)
334        }
335        // Policy refusal is a result-path class. If an adapter reports it while
336        // delivering an intervention, it follows the explicit stale-target NACK
337        // path without ending the run.
338        Err(HarnessError::StaleTarget { detail } | HarnessError::PolicyRefused { detail, .. }) => {
339            InterventionOutcome::stale_target(detail)
340        }
341        Err(error) => InterventionOutcome::stale_target(error.to_string()),
342    }
343}
344
345/// Maps a [`HarnessError`] into a [`DispatchOutcome::Failed`] a caller can report.
346///
347/// [`HarnessError::PolicyRefused`] is stochastic but is not an ordinary
348/// transient failure: it maps to its dedicated activity kind so the engine can
349/// route first. A [`HarnessError::Harness`] (the agent ran but reported failure),
350/// a transport/protocol/stale fault, and an [`HarnessError::Occupied`] spawn
351/// refusal (a live sibling holds the declared tree — issue #33's field case) are
352/// retryable activity failures: each CAN be transient (a provider-overload
353/// burst, a one-off malformed frame, a dropped pipe, an occupant that exits or
354/// dies), so the engine may re-dispatch under policy.
355///
356/// A DETERMINISTIC error ([`HarnessError::is_deterministic`] — today
357/// [`HarnessError::Contract`] or [`HarnessError::Configuration`]) is TERMINAL,
358/// and both arms are terminal for one reason: the next attempt meets the same
359/// wall, because the wall is a property of how the run is CONFIGURED rather
360/// than of the attempt.
361///
362/// * [`HarnessError::Contract`] — the run completed, but its native outcome
363///   cannot satisfy the canonical agent-outcome contract.
364/// * [`HarnessError::Configuration`] — the harness could not be LAUNCHED as
365///   configured, so no run started at all: an environment pass-through entry
366///   that is a `KEY=VALUE` pair instead of a name, a declaration omitting the
367///   variable the program is looked up on. The next attempt reads the same
368///   document.
369///
370/// Retrying either re-spends a whole live agent run — or a whole dispatch
371/// budget — to hit the same refusal (a `retry 5 backoff 30s..5m` policy would
372/// burn five attempts on a permanent misconfiguration), so neither is
373/// retried. The classification itself lives in the seam crate as an exhaustive
374/// match, so a new variant decides its fate there, at compile time — never by
375/// this function's default, and never by this comment falling behind it.
376#[must_use]
377pub fn harness_error_to_outcome(error: &HarnessError) -> DispatchOutcome {
378    let failure = match error {
379        HarnessError::PolicyRefused { .. } => ActivityFailure::policy_refused(error.to_string()),
380        _ if error.is_deterministic() => ActivityFailure::terminal(error.to_string()),
381        _ => ActivityFailure::retryable(error.to_string()),
382    };
383    DispatchOutcome::Failed {
384        failure: failure.into(),
385    }
386}
387
388#[cfg(test)]
389#[path = "agent_tests.rs"]
390mod tests;