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` / `stop_reason` as fields.
11//!
12//! # One stop-reason vocabulary, by subtraction
13//!
14//! The canonical `stop_reason` strings are the snake_case renderings of the
15//! existing neutral [`StopKind`] semantic set — no third stop-reason table
16//! exists anywhere. [`StopKind::canonical_stop_reason`] is the single mapping,
17//! so the transcript channel (which carries `StopKind` itself) and this
18//! outcome record cannot drift. `StopKind`'s own serialization is untouched:
19//! the transcript wire is live and is a different channel from the result.
20//!
21//! # The record rides inside a [`Payload`]
22//!
23//! Nothing about the type-erased result path changes: an activity result is
24//! still a [`Payload`] of `{content_type, bytes}`. The record is the JSON the
25//! bytes carry for a completed agent activity.
26
27use serde::{Deserialize, Serialize};
28
29use crate::activity_event::StopKind;
30use crate::payload::{ContentType, Payload};
31
32impl StopKind {
33 /// The canonical `snake_case` `stop_reason` string for this stop kind.
34 ///
35 /// This is the ONE mapping from the neutral stop vocabulary onto the
36 /// [`AgentOutcome::stop_reason`] wire strings. The detail payloads of
37 /// [`StopKind::Error`] and [`StopKind::Other`] deliberately do not reach
38 /// the canonical string: the canonical vocabulary is closed, and the
39 /// detail belongs to the transcript channel that carries the full
40 /// `StopKind`.
41 #[must_use]
42 pub fn canonical_stop_reason(&self) -> &'static str {
43 match self {
44 Self::EndTurn => "end_turn",
45 Self::ToolUse => "tool_use",
46 Self::LimitReached => "limit_reached",
47 Self::Cancelled => "cancelled",
48 Self::Error { .. } => "error",
49 Self::Other { .. } => "other",
50 }
51 }
52}
53
54/// The canonical agent-outcome record: what a completed agent activity's
55/// result payload carries, from every harness.
56///
57/// Two fields, both `snake_case`, both AWL-expressible — the AWL checker demands
58/// an `agent` action's declared return type be a record of exactly this shape
59/// (`text: String`, `stop_reason: String`), so a workflow reads
60/// `reply.text` and `reply.stop_reason` as ordinary record fields.
61///
62/// An empty `text` on a completed run is honest, not a bug: an agent that
63/// finished its turn having emitted only tool calls said nothing, and the
64/// harness reports that truth rather than inventing words (the work is on the
65/// transcript). `stop_reason` is one of the canonical strings produced by
66/// [`StopKind::canonical_stop_reason`]:
67/// `end_turn | tool_use | limit_reached | cancelled | error | other`.
68#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
69pub struct AgentOutcome {
70 /// The agent's terminal text — the run's actual output.
71 pub text: String,
72 /// The canonical `snake_case` stop reason
73 /// ([`StopKind::canonical_stop_reason`]).
74 pub stop_reason: String,
75}
76
77impl AgentOutcome {
78 /// Builds the record from the agent's terminal text and the neutral stop
79 /// kind, spelling `stop_reason` through the one canonical mapping.
80 #[must_use]
81 pub fn new(text: impl Into<String>, stop: &StopKind) -> Self {
82 Self {
83 text: text.into(),
84 stop_reason: stop.canonical_stop_reason().to_owned(),
85 }
86 }
87
88 /// Serializes the record as the activity's JSON result [`Payload`].
89 ///
90 /// # Errors
91 ///
92 /// Returns the `serde_json` error when the record cannot be encoded —
93 /// unreachable for two plain strings, but never swallowed.
94 pub fn into_payload(self) -> Result<Payload, serde_json::Error> {
95 let bytes = serde_json::to_vec(&self)?;
96 Ok(Payload::new(ContentType::Json, bytes))
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use serde_json::json;
103
104 use super::{AgentOutcome, StopKind};
105
106 type TestResult = Result<(), Box<dyn std::error::Error>>;
107
108 /// The canonical vocabulary is the closed set the ruling names, spelled by
109 /// exactly one mapping. Enumerated over every variant so a new `StopKind`
110 /// arm cannot ship without a canonical spelling.
111 #[test]
112 fn every_stop_kind_has_its_canonical_snake_case_spelling() {
113 let cases = [
114 (StopKind::EndTurn, "end_turn"),
115 (StopKind::ToolUse, "tool_use"),
116 (StopKind::LimitReached, "limit_reached"),
117 (StopKind::Cancelled, "cancelled"),
118 (
119 StopKind::Error {
120 message: "boom".to_owned(),
121 },
122 "error",
123 ),
124 (
125 StopKind::Other {
126 reason: "teleported".to_owned(),
127 },
128 "other",
129 ),
130 ];
131 for (kind, expected) in cases {
132 assert_eq!(kind.canonical_stop_reason(), expected);
133 }
134 }
135
136 /// The wire form is exactly the two `snake_case` keys the AWL record
137 /// declares — this is the shape every harness emits and every workflow
138 /// decodes, so it is pinned as JSON, not as a Rust round-trip alone.
139 #[test]
140 fn the_record_serializes_as_the_two_snake_case_fields() -> TestResult {
141 let outcome = AgentOutcome::new("the answer", &StopKind::EndTurn);
142 assert_eq!(
143 serde_json::to_value(&outcome)?,
144 json!({ "text": "the answer", "stop_reason": "end_turn" })
145 );
146 Ok(())
147 }
148
149 #[test]
150 fn the_record_round_trips_through_its_payload() -> TestResult {
151 let outcome = AgentOutcome::new("", &StopKind::EndTurn);
152 let payload = outcome.clone().into_payload()?;
153 assert_eq!(payload.content_type(), &super::ContentType::Json);
154 let decoded: AgentOutcome = serde_json::from_slice(payload.bytes())?;
155 assert_eq!(decoded, outcome);
156 assert_eq!(
157 decoded.text, "",
158 "an empty text on a completed run is honest and survives the wire"
159 );
160 Ok(())
161 }
162}