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    /// Locally validates and decodes the final model response while preserving
54    /// the complete canonical outcome.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`StructuredOutputError`] when the response is missing textual
59    /// output, contains a refusal, or does not deserialize as `T`.
60    pub fn into_structured<T>(self) -> Result<StructuredAgentOutcome<T>, StructuredOutputError>
61    where
62        T: DeserializeOwned,
63    {
64        let output = self.response.structured()?;
65        Ok(StructuredAgentOutcome {
66            output,
67            outcome: self,
68        })
69    }
70}
71
72/// A locally validated typed value and its complete Agent execution outcome.
73#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
74pub struct StructuredAgentOutcome<T> {
75    /// Deserialized final output.
76    pub output: T,
77    /// Canonical response, transcript, counters, and usage.
78    pub outcome: AgentOutcome,
79}
80
81#[cfg(test)]
82mod tests {
83    use std::collections::BTreeMap;
84
85    use runifold_core::Usage;
86    use runifold_model::{
87        ContentPart, FinishReason, ModelRef, ModelResponse, ModelUsage, StructuredOutputErrorKind,
88    };
89    use serde::Deserialize;
90
91    use super::AgentOutcome;
92
93    #[derive(Debug, Deserialize, Eq, PartialEq)]
94    struct Answer {
95        value: u32,
96    }
97
98    fn outcome(text: &str) -> AgentOutcome {
99        AgentOutcome {
100            response: ModelResponse {
101                id: Some("response".into()),
102                model: ModelRef::new("test", "model"),
103                content: vec![ContentPart::text(text)],
104                finish_reason: FinishReason::Stop,
105                usage: ModelUsage::default(),
106                warnings: Vec::new(),
107                provider_metadata: BTreeMap::new(),
108                provider_events: Vec::new(),
109            },
110            transcript: Vec::new(),
111            turns: 1,
112            tool_calls: 0,
113            delegations: 0,
114            usage: Usage::default(),
115        }
116    }
117
118    #[test]
119    fn typed_outcome_preserves_canonical_execution_metadata() {
120        let typed = outcome("{\"value\":42}")
121            .into_structured::<Answer>()
122            .unwrap();
123
124        assert_eq!(typed.output, Answer { value: 42 });
125        assert_eq!(typed.outcome.response.id.as_deref(), Some("response"));
126        assert_eq!(typed.outcome.turns, 1);
127    }
128
129    #[test]
130    fn typed_outcome_rejects_a_shape_mismatch() {
131        let error = outcome("{\"value\":\"wrong\"}")
132            .into_structured::<Answer>()
133            .unwrap_err();
134
135        assert_eq!(error.kind, StructuredOutputErrorKind::InvalidOutput);
136    }
137}