Skip to main content

runifold_agent/
outcome.rs

1use runifold_core::Usage;
2use runifold_model::{Message, ModelResponse, StructuredOutputError};
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5
6/// Successful terminal state of an agent run.
7#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
8pub struct AgentOutcome {
9    /// Final model response.
10    pub response: ModelResponse,
11    /// Complete canonical transcript, including tool calls and results.
12    pub transcript: Vec<Message>,
13    /// Model turns performed by this agent.
14    pub turns: u32,
15    /// Tool calls attempted by this agent.
16    pub tool_calls: u32,
17    /// Successful direct child-agent delegations performed by this agent.
18    pub delegations: u32,
19    /// Shared run-tree usage snapshot at completion.
20    pub usage: Usage,
21}
22
23impl AgentOutcome {
24    /// Collects model-visible terminal text in canonical content order.
25    #[must_use]
26    pub fn text(&self) -> String {
27        self.response.text()
28    }
29
30    /// Consumes the outcome and returns only model-visible terminal text.
31    ///
32    /// Use this only when the transcript, usage, counters, warnings, and
33    /// provider-specific response data are no longer needed.
34    #[must_use]
35    pub fn into_text(self) -> String {
36        self.response.into_text()
37    }
38
39    /// Returns the number of bounded terminal repair turns in this execution.
40    #[must_use]
41    pub fn terminal_repairs(&self) -> u32 {
42        self.transcript
43            .iter()
44            .filter(|message| {
45                message.metadata.get("runifold.terminal_repair")
46                    == Some(&serde_json::Value::Bool(true))
47            })
48            .count()
49            .try_into()
50            .unwrap_or(u32::MAX)
51    }
52
53    /// Returns the number of internal-turn review repair turns in this execution.
54    #[must_use]
55    pub fn turn_review_repairs(&self) -> u32 {
56        self.transcript
57            .iter()
58            .filter(|message| {
59                message.metadata.get("runifold.turn_review_repair")
60                    == Some(&serde_json::Value::Bool(true))
61            })
62            .count()
63            .try_into()
64            .unwrap_or(u32::MAX)
65    }
66
67    /// Returns the number of semantic-review repair turns in this execution.
68    #[must_use]
69    pub fn terminal_review_repairs(&self) -> u32 {
70        self.transcript
71            .iter()
72            .filter(|message| {
73                message.metadata.get("runifold.terminal_review_repair")
74                    == Some(&serde_json::Value::Bool(true))
75            })
76            .count()
77            .try_into()
78            .unwrap_or(u32::MAX)
79    }
80
81    /// Locally validates and decodes the final model response while preserving
82    /// the complete canonical outcome.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`StructuredOutputError`] when the response is missing textual
87    /// output, contains a refusal, or does not deserialize as `T`.
88    pub fn into_structured<T>(self) -> Result<StructuredAgentOutcome<T>, StructuredOutputError>
89    where
90        T: DeserializeOwned,
91    {
92        let output = self.response.structured()?;
93        Ok(StructuredAgentOutcome {
94            output,
95            outcome: self,
96        })
97    }
98}
99
100/// A locally validated typed value and its complete Agent execution outcome.
101#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
102pub struct StructuredAgentOutcome<T> {
103    /// Deserialized final output.
104    pub output: T,
105    /// Canonical response, transcript, counters, and usage.
106    pub outcome: AgentOutcome,
107}
108
109#[cfg(test)]
110mod tests {
111    use std::collections::BTreeMap;
112
113    use runifold_core::Usage;
114    use runifold_model::{
115        ContentPart, FinishReason, ModelRef, ModelResponse, ModelUsage, StructuredOutputErrorKind,
116    };
117    use serde::Deserialize;
118
119    use super::AgentOutcome;
120
121    #[derive(Debug, Deserialize, Eq, PartialEq)]
122    struct Answer {
123        value: u32,
124    }
125
126    fn outcome(text: &str) -> AgentOutcome {
127        AgentOutcome {
128            response: ModelResponse {
129                id: Some("response".into()),
130                model: ModelRef::new("test", "model"),
131                content: vec![ContentPart::text(text)],
132                finish_reason: FinishReason::Stop,
133                usage: ModelUsage::default(),
134                warnings: Vec::new(),
135                provider_metadata: BTreeMap::new(),
136                provider_events: Vec::new(),
137            },
138            transcript: Vec::new(),
139            turns: 1,
140            tool_calls: 0,
141            delegations: 0,
142            usage: Usage::default(),
143        }
144    }
145
146    #[test]
147    fn typed_outcome_preserves_canonical_execution_metadata() {
148        let typed = outcome("{\"value\":42}")
149            .into_structured::<Answer>()
150            .unwrap();
151
152        assert_eq!(typed.output, Answer { value: 42 });
153        assert_eq!(typed.outcome.response.id.as_deref(), Some("response"));
154        assert_eq!(typed.outcome.turns, 1);
155    }
156
157    #[test]
158    fn typed_outcome_rejects_a_shape_mismatch() {
159        let error = outcome("{\"value\":\"wrong\"}")
160            .into_structured::<Answer>()
161            .unwrap_err();
162
163        assert_eq!(error.kind, StructuredOutputErrorKind::InvalidOutput);
164    }
165}