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` / `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}