aion_integrations/contract.rs
1//! The harness-integration seam: the [`AgentHarness`] trait an integrator implements and the
2//! live [`AgentSession`] it produces.
3//!
4//! This is the SDK's published extension seam, mirroring `aion-client`'s `WorkflowTransport`
5//! (a dedicated `#[async_trait]` contract module). It is **harness-blind by construction**: no
6//! signature names a concrete harness, a transport, or a wire protocol. A harness is integrated
7//! by implementing these traits in a separate adapter crate; the seam speaks only neutral run
8//! identity, the neutral [`ActivityEvent`] / [`InterventionCommand`] types, the neutral
9//! [`InterventionCapabilities`] advertisement, and a neutral terminal [`Payload`].
10//!
11//! # Designed from two cases so it is provably general
12//!
13//! The seam is designed against two deliberately different integrations at once, so it cannot be
14//! shaped by any one harness:
15//!
16//! - a full rich-path harness (a bidirectional channel, streamed events out, acknowledged
17//! commands in, a structured terminal result), and
18//! - a plain-stdout, **observability-only** CLI agent (no command channel at all): it demuxes
19//! interleaved stdout into [`ActivityEvent`]s (mostly [`aion_core::ActivityEventKind::Raw`]),
20//! advertises an **empty** [`InterventionCapabilities`] set, and yields its final output as the
21//! result.
22//!
23//! The empty-capability case is what forces [`AgentSession::intervene`] to be
24//! optional-by-capability: an empty advertisement is a valid, first-class tier, and a session
25//! that advertises it rejects every command with [`HarnessError::CapabilityNotSupported`].
26
27use aion_core::{ActivityEvent, InterventionCapabilities, InterventionCommand, Payload};
28use async_trait::async_trait;
29use futures::stream::BoxStream;
30
31use crate::error::HarnessError;
32use crate::spec::AgentRunSpec;
33
34/// How to run one agent harness — the SDK's published extension seam.
35///
36/// An integrator implements this (in a separate adapter crate) to teach Aion how to spawn or
37/// connect their harness for a single activity attempt. It is harness-blind: [`Self::start`]
38/// takes only the neutral [`AgentRunSpec`] and returns a live [`AgentSession`].
39#[async_trait]
40pub trait AgentHarness: Send + Sync {
41 /// The live session type this harness produces for one attempt.
42 type Session: AgentSession;
43
44 /// Spawns or connects the harness for one activity attempt, negotiates capabilities, and
45 /// returns a live session.
46 ///
47 /// `spec` carries the neutral run identity (`workflow_id`, `activity_id`, `attempt`) and the
48 /// input [`Payload`] — never any harness-specific configuration.
49 ///
50 /// # Errors
51 ///
52 /// Returns a [`HarnessError`] when the harness cannot be spawned/connected or when the
53 /// capability handshake fails ([`HarnessError::Transport`] / [`HarnessError::Protocol`]).
54 async fn start(&self, spec: AgentRunSpec) -> Result<Self::Session, HarnessError>;
55}
56
57/// A live agent run for one activity attempt.
58///
59/// Produced by [`AgentHarness::start`]. Exposes the negotiated capability set, a stream of
60/// neutral events OUT, a neutral command sink IN, and a single terminal result.
61#[async_trait]
62pub trait AgentSession: Send {
63 /// The capability set negotiated at start.
64 ///
65 /// The server and ops console gate on THIS, never on harness identity. An **empty** set is a
66 /// first-class advertisement — an observability-only harness supports no interventions.
67 fn capabilities(&self) -> &InterventionCapabilities;
68
69 /// The stream of neutral events produced by this run.
70 ///
71 /// Every item is an [`ActivityEvent`]; how the adapter derives them (mapping a structured
72 /// notification, or demuxing interleaved stdout into mostly
73 /// [`aion_core::ActivityEventKind::Raw`]) is an adapter-internal detail invisible here.
74 fn events(&mut self) -> BoxStream<'static, ActivityEvent>;
75
76 /// Delivers a neutral intervention command into the running agent.
77 ///
78 /// A session whose advertised [`Self::capabilities`] set does not contain the command's
79 /// primitive rejects it with [`HarnessError::CapabilityNotSupported`]. An observability-only
80 /// session (empty set) rejects **every** command this way; the server never routes one to it
81 /// because the advertised set is empty.
82 ///
83 /// # Errors
84 ///
85 /// Returns [`HarnessError::CapabilityNotSupported`] when the command's primitive is not
86 /// advertised, [`HarnessError::StaleTarget`] when the command targets a superseded attempt,
87 /// or a transport/protocol error when delivery fails.
88 async fn intervene(&self, cmd: InterventionCommand) -> Result<(), HarnessError>;
89
90 /// Awaits the single terminal result of the run.
91 ///
92 /// Consumes the session: exactly one terminal result is produced per attempt, and it is the
93 /// replay-authoritative activity output the worker captures.
94 ///
95 /// **The completed result is the canonical agent-outcome record.** A completed run's payload
96 /// is the JSON serialization of [`aion_core::AgentOutcome`] —
97 /// `{"text": <the whole accumulated output>, "final_message": <the last completed answer>,
98 /// "stop_reason": <canonical snake_case reason>,
99 /// "session_id": <the handle of the conversation that produced the outcome>}` —
100 /// from EVERY harness. The adapter maps its native terminal outcome onto the record at this
101 /// boundary (`stop_reason` spelled through the one
102 /// [`StopKind::canonical_stop_reason`](aion_core::StopKind::canonical_stop_reason) mapping);
103 /// a structured native outcome (the value of a run configured with an output schema) fills
104 /// `text` and `final_message` with its canonical JSON text — serialized at the adapter's
105 /// boundary, never handed on as a raw object (Tom's ruling, 2026-09-01); a native outcome
106 /// that cannot fill the record at all (a null final output) is a typed
107 /// [`HarnessError::Contract`] naming this contract and what was found — a deterministic
108 /// refusal the worker maps to a terminal activity failure — never a payload a workflow's
109 /// decoder then chokes on.
110 ///
111 /// # Errors
112 ///
113 /// Returns [`HarnessError::Harness`] when the run reported an application-level failure,
114 /// [`HarnessError::Contract`] when the completed run's native outcome cannot satisfy the
115 /// agent-outcome contract, or a transport/protocol error when the terminal result could not
116 /// be received.
117 async fn wait_result(self) -> Result<Payload, HarnessError>;
118}
119
120/// An object-safe erased [`AgentSession`], so a worker can drive a session behind a
121/// `Box<dyn ..>` without being generic over the concrete harness.
122///
123/// It mirrors [`AgentSession`] exactly EXCEPT that the terminal [`Self::wait_result`]
124/// takes `self: Box<Self>` (rather than `self` by value), which is what makes the
125/// trait object-safe — an owned-`self` method is not dispatchable through `dyn`. A
126/// blanket impl over every [`AgentSession`] means an integrator implements only the
127/// ergonomic typed trait and gets the erased form for free.
128///
129/// The async methods are `?Send`-dispatched. [`AgentSession`] is `Send` but not
130/// `Sync`, so a `&session` cannot cross threads; the worker holds an erased session
131/// inside ONE task and drives it (plus its event drain) there — never `tokio::spawn`-ing
132/// a borrow of it — so requiring `Sync` would over-constrain every adapter for no gain.
133#[async_trait(?Send)]
134pub trait DynAgentSession: Send {
135 /// The negotiated capability set — see [`AgentSession::capabilities`].
136 fn capabilities(&self) -> &InterventionCapabilities;
137
138 /// The neutral event stream OUT — see [`AgentSession::events`].
139 fn events(&mut self) -> BoxStream<'static, ActivityEvent>;
140
141 /// Deliver a neutral command IN — see [`AgentSession::intervene`].
142 ///
143 /// # Errors
144 ///
145 /// Propagates [`AgentSession::intervene`]'s errors unchanged.
146 async fn intervene(&self, cmd: InterventionCommand) -> Result<(), HarnessError>;
147
148 /// Await the single terminal result — see [`AgentSession::wait_result`]. Takes
149 /// `Box<Self>` (not `self`) so the trait stays object-safe.
150 ///
151 /// # Errors
152 ///
153 /// Propagates [`AgentSession::wait_result`]'s errors unchanged.
154 async fn wait_result(self: Box<Self>) -> Result<Payload, HarnessError>;
155}
156
157#[async_trait(?Send)]
158impl<S: AgentSession + 'static> DynAgentSession for S {
159 fn capabilities(&self) -> &InterventionCapabilities {
160 AgentSession::capabilities(self)
161 }
162
163 fn events(&mut self) -> BoxStream<'static, ActivityEvent> {
164 AgentSession::events(self)
165 }
166
167 async fn intervene(&self, cmd: InterventionCommand) -> Result<(), HarnessError> {
168 AgentSession::intervene(self, cmd).await
169 }
170
171 async fn wait_result(self: Box<Self>) -> Result<Payload, HarnessError> {
172 AgentSession::wait_result(*self).await
173 }
174}
175
176/// An object-safe erased [`AgentHarness`], so a worker can HOLD a harness behind a
177/// `Arc<dyn DynAgentHarness>` (the typed [`AgentHarness`] is not object-safe — it has
178/// an associated `Session` type).
179///
180/// A blanket impl over every [`AgentHarness`] erases the session type into a
181/// `Box<dyn DynAgentSession>`, so a composition root passes any typed harness and the
182/// worker drives it without naming the concrete type.
183#[async_trait]
184pub trait DynAgentHarness: Send + Sync {
185 /// Spawn/connect the harness for one attempt, returning an erased session.
186 ///
187 /// # Errors
188 ///
189 /// Propagates [`AgentHarness::start`]'s errors unchanged.
190 async fn start_dyn(&self, spec: AgentRunSpec)
191 -> Result<Box<dyn DynAgentSession>, HarnessError>;
192}
193
194#[async_trait]
195impl<H: AgentHarness> DynAgentHarness for H
196where
197 H::Session: 'static,
198{
199 async fn start_dyn(
200 &self,
201 spec: AgentRunSpec,
202 ) -> Result<Box<dyn DynAgentSession>, HarnessError> {
203 let session = AgentHarness::start(self, spec).await?;
204 Ok(Box::new(session))
205 }
206}