Skip to main content

aion_core/
agent_outcome.rs

1//! The canonical agent-outcome record — the ONE shape every agent harness
2//! emits as a completed agent activity's result.
3//!
4//! Task #181's ruling (docs commit `c223c8c`): the `agent` marker had
5//! universalized one harness path's shape (`-> String`, "parse it
6//! downstream"), while the harnesses honestly emitted three different ad-hoc
7//! JSON shapes. The fix is this record, defined by aion, snake_case and
8//! AWL-expressible: every harness maps its native terminal outcome onto it at
9//! the integration boundary, and a workflow's `agent` action declares a record
10//! of exactly this shape and reads `text` / `final_message` / `stop_reason` /
11//! `session_id` as fields.
12//!
13//! Task #205's ruling widened the record with a third mandatory field,
14//! `session_id` — see [`AgentOutcome::session_id`] for its semantics. The
15//! widening is a replacement, not an addition: the wire shape is exactly the
16//! three keys, a two-field payload no longer decodes, and a two-field AWL declaration no longer
17//! checks. Task #241 replaces that record in turn with a fourth mandatory field,
18//! `final_message`: [`AgentOutcome::text`] remains the whole accumulated output, while
19//! [`AgentOutcome::final_message`] is the last completed answer alone. That distinction prevents an
20//! exact-token judge from falsely failing a successful run merely because earlier narrated answers
21//! precede the final verdict. The wire shape is now exactly four keys; a three-field payload no
22//! longer decodes and a three-field AWL declaration no longer checks.
23//!
24//! # One stop-reason vocabulary, by subtraction
25//!
26//! The canonical `stop_reason` strings are the snake_case renderings of the
27//! existing neutral [`StopKind`] semantic set — no third stop-reason table
28//! exists anywhere. [`StopKind::canonical_stop_reason`] is the single mapping,
29//! so the transcript channel (which carries `StopKind` itself) and this
30//! outcome record cannot drift. `StopKind`'s own serialization is untouched:
31//! the transcript wire is live and is a different channel from the result.
32//!
33//! # The record rides inside a [`Payload`]
34//!
35//! Nothing about the type-erased result path changes: an activity result is
36//! still a [`Payload`] of `{content_type, bytes}`. The record is the JSON the
37//! bytes carry for a completed agent activity.
38
39use serde::{Deserialize, Serialize};
40
41use crate::activity_event::StopKind;
42use crate::payload::{ContentType, Payload};
43
44impl StopKind {
45    /// The canonical `snake_case` `stop_reason` string for this stop kind.
46    ///
47    /// This is the ONE mapping from the neutral stop vocabulary onto the
48    /// [`AgentOutcome::stop_reason`] wire strings. The detail payloads of
49    /// [`StopKind::Error`] and [`StopKind::Other`] deliberately do not reach
50    /// the canonical string: the canonical vocabulary is closed, and the
51    /// detail belongs to the transcript channel that carries the full
52    /// `StopKind`.
53    #[must_use]
54    pub fn canonical_stop_reason(&self) -> &'static str {
55        match self {
56            Self::EndTurn => "end_turn",
57            Self::ToolUse => "tool_use",
58            Self::LimitReached => "limit_reached",
59            Self::Cancelled => "cancelled",
60            Self::Error { .. } => "error",
61            Self::Other { .. } => "other",
62        }
63    }
64}
65
66/// The error serializing an [`AgentOutcome`] as its result [`Payload`].
67#[derive(Debug, thiserror::Error)]
68pub enum AgentOutcomeError {
69    /// The record's mandatory `session_id` is the empty string.
70    ///
71    /// A vacant mandatory field is a harness bug, refused here by name at the
72    /// one funnel every harness serializes through — never shipped as a
73    /// silently empty handle a workflow would try to resume with.
74    #[error(
75        "the agent-outcome record's mandatory `session_id` is empty — every harness fills it \
76         with the handle of the conversation that produced this outcome, and an empty handle \
77         names no conversation"
78    )]
79    EmptySessionId,
80    /// The record could not be encoded as JSON — unreachable for four plain
81    /// strings, but never swallowed.
82    #[error("the agent-outcome record is not encodable: {0}")]
83    Encode(#[from] serde_json::Error),
84}
85
86/// The canonical agent-outcome record: what a completed agent activity's
87/// result payload carries, from every harness.
88///
89/// Four fields, all `snake_case`, all mandatory, all AWL-expressible — the
90/// AWL checker demands an `agent` action's declared return type be a record of
91/// exactly this shape (`text: String`, `final_message: String`, `stop_reason: String`,
92/// `session_id: String`), so a workflow reads those values as ordinary record fields.
93///
94/// Empty `text` and `final_message` values on a completed run are honest, not a bug: an agent that
95/// finished its turn having emitted only tool calls said nothing, and the harness reports that
96/// truth rather than inventing words (the work is on the transcript). When the run produced exactly
97/// one completed answer, `final_message == text`; that duplication is the honest degenerate case.
98/// `stop_reason` is one of the canonical strings produced by [`StopKind::canonical_stop_reason`]:
99/// `end_turn | tool_use | limit_reached | cancelled | error | other`.
100#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
101pub struct AgentOutcome {
102    /// The whole run output: every completed agent answer, accumulated by the harness.
103    pub text: String,
104    /// The run's LAST completed agent answer message, alone and exactly as the agent said it.
105    ///
106    /// Reasoning, tool output, and user turns never enter this field. It exists separately from
107    /// [`Self::text`] so an exact-token judge reads the actual final verdict rather than falsely
108    /// failing a successful run because narration from earlier answers precedes it. It is empty when
109    /// the agent produced no answer text at all, and equals `text` when there was exactly one answer.
110    pub final_message: String,
111    /// The canonical `snake_case` stop reason
112    /// ([`StopKind::canonical_stop_reason`]).
113    pub stop_reason: String,
114    /// The handle of the conversation that produced this outcome.
115    ///
116    /// A FACT, not a promise: the id names the session the harness actually
117    /// ran — minted, loaded, or derived by that harness — and says nothing
118    /// about whether it can be resumed. Resumability is the per-agent question
119    /// a harness's own handshake answers (for ACP, the `initialize` response's
120    /// `loadSession` capability), never something this field asserts.
121    ///
122    /// The id is meaningful only to the harness that minted it; resume routing
123    /// pairs the id with the harness identity the workflow record already
124    /// carries. It is never empty — a harness with a vacant handle is refused
125    /// at [`AgentOutcome::into_payload`], not shipped.
126    pub session_id: String,
127}
128
129impl AgentOutcome {
130    /// Builds the record from the whole accumulated answer text, the last completed answer alone,
131    /// the neutral stop kind, and the conversation handle, spelling `stop_reason` through the one
132    /// canonical mapping.
133    #[must_use]
134    pub fn new(
135        text: impl Into<String>,
136        final_message: impl Into<String>,
137        stop: &StopKind,
138        session_id: impl Into<String>,
139    ) -> Self {
140        Self {
141            text: text.into(),
142            final_message: final_message.into(),
143            stop_reason: stop.canonical_stop_reason().to_owned(),
144            session_id: session_id.into(),
145        }
146    }
147
148    /// Serializes the record as the activity's JSON result [`Payload`].
149    ///
150    /// # Errors
151    ///
152    /// [`AgentOutcomeError::EmptySessionId`] when the mandatory `session_id`
153    /// is the empty string — the vacancy is refused by name rather than
154    /// shipped. [`AgentOutcomeError::Encode`] when the record cannot be
155    /// encoded — unreachable for four plain strings, but never swallowed.
156    pub fn into_payload(self) -> Result<Payload, AgentOutcomeError> {
157        if self.session_id.is_empty() {
158            return Err(AgentOutcomeError::EmptySessionId);
159        }
160        let bytes = serde_json::to_vec(&self)?;
161        Ok(Payload::new(ContentType::Json, bytes))
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use serde_json::json;
168
169    use super::{AgentOutcome, AgentOutcomeError, StopKind};
170
171    type TestResult = Result<(), Box<dyn std::error::Error>>;
172
173    /// The canonical vocabulary is the closed set the ruling names, spelled by
174    /// exactly one mapping. Enumerated over every variant so a new `StopKind`
175    /// arm cannot ship without a canonical spelling.
176    #[test]
177    fn every_stop_kind_has_its_canonical_snake_case_spelling() {
178        let cases = [
179            (StopKind::EndTurn, "end_turn"),
180            (StopKind::ToolUse, "tool_use"),
181            (StopKind::LimitReached, "limit_reached"),
182            (StopKind::Cancelled, "cancelled"),
183            (
184                StopKind::Error {
185                    message: "boom".to_owned(),
186                },
187                "error",
188            ),
189            (
190                StopKind::Other {
191                    reason: "teleported".to_owned(),
192                },
193                "other",
194            ),
195        ];
196        for (kind, expected) in cases {
197            assert_eq!(kind.canonical_stop_reason(), expected);
198        }
199    }
200
201    /// The wire form is exactly the four `snake_case` keys the AWL record
202    /// declares — this is the shape every harness emits and every workflow
203    /// decodes, so it is pinned as JSON, not as a Rust round-trip alone.
204    #[test]
205    fn the_record_serializes_as_the_four_snake_case_fields() -> TestResult {
206        let outcome = AgentOutcome::new(
207            "narration\nthe answer",
208            "the answer",
209            &StopKind::EndTurn,
210            "sess-7",
211        );
212        assert_eq!(
213            serde_json::to_value(&outcome)?,
214            json!({
215                "text": "narration\nthe answer",
216                "final_message": "the answer",
217                "stop_reason": "end_turn",
218                "session_id": "sess-7",
219            })
220        );
221        Ok(())
222    }
223
224    #[test]
225    fn the_record_round_trips_through_its_payload() -> TestResult {
226        let outcome = AgentOutcome::new("", "", &StopKind::EndTurn, "sess-7");
227        let payload = outcome.clone().into_payload()?;
228        assert_eq!(payload.content_type(), &super::ContentType::Json);
229        let decoded: AgentOutcome = serde_json::from_slice(payload.bytes())?;
230        assert_eq!(decoded, outcome);
231        assert_eq!(
232            decoded.text, "",
233            "an empty text on a completed run is honest and survives the wire"
234        );
235        assert_eq!(
236            decoded.final_message, "",
237            "an empty final_message is honest and survives the wire without refusal"
238        );
239        Ok(())
240    }
241
242    /// A vacant mandatory `session_id` is refused by name at the serialization
243    /// funnel — no harness can ship a silently empty handle.
244    #[test]
245    fn an_empty_session_id_is_refused_by_name() -> Result<(), String> {
246        let error = AgentOutcome::new("the answer", "the answer", &StopKind::EndTurn, "")
247            .into_payload()
248            .err()
249            .ok_or("an empty session_id must not serialize")?;
250        assert!(
251            matches!(error, AgentOutcomeError::EmptySessionId),
252            "the refusal is the named vacancy, got {error:?}"
253        );
254        assert!(
255            error.to_string().contains("session_id"),
256            "the refusal names the field: {error}"
257        );
258        Ok(())
259    }
260
261    /// A three-field payload — the pre-#241 wire shape — no longer decodes: the widening replaced the
262    /// record rather than adding beside it, so a stale emitter fails loudly at the decode boundary
263    /// instead of producing an invented final message.
264    #[test]
265    fn a_three_field_payload_no_longer_decodes() {
266        let stale = json!({
267            "text": "the answer",
268            "stop_reason": "end_turn",
269            "session_id": "sess-7",
270        });
271        let decoded: Result<AgentOutcome, _> = serde_json::from_value(stale);
272        assert!(
273            decoded.is_err(),
274            "the pre-#241 shape must be refused, not defaulted"
275        );
276    }
277}