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;