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;