Skip to main content

agent_types/
execution.rs

1//! Execution-related pure types: FinishReason, RunOutcome.
2
3use serde::{Deserialize, Serialize};
4
5/// Semantic finish reason, replacing scattered `Option<String>` matching.
6///
7/// Each variant captures a distinct end-of-turn signal from the LLM provider.
8/// Conversion helpers ([`FinishReason::from_openai`], [`FinishReason::from_anthropic`],
9/// [`FinishReason::from_responses`]) normalise the provider-specific strings.
10#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
11pub enum FinishReason {
12    /// Model finished naturally (OpenAI `"stop"` / Anthropic `"end_turn"`).
13    Stop,
14    /// Model requested tool calls (Anthropic `"tool_use"`).
15    ///
16    /// OpenAI's `"tool_calls"` is consumed at the client layer as
17    /// `StreamChunk::ToolCall` and never reaches the react loop.
18    ToolUse,
19    /// Output was truncated by the token limit.
20    ///
21    /// - OpenAI: `"length"`
22    /// - Anthropic: `"max_tokens"`
23    /// - Responses API: `"incomplete"`
24    Truncated {
25        /// Provider-specific reason string, if available.
26        reason: Option<String>,
27    },
28    /// Any other / unknown finish reason.
29    Other(String),
30}
31
32impl FinishReason {
33    /// Normalise an OpenAI Chat Completions `finish_reason` value.
34    pub fn from_openai(s: Option<&str>) -> Self {
35        match s {
36            Some("stop") => Self::Stop,
37            Some("length") => Self::Truncated {
38                reason: Some("length".into()),
39            },
40            // "tool_calls" is consumed client-side, never reaches the react loop.
41            Some(other) => Self::Other(other.to_string()),
42            None => Self::Stop, // stream ended normally
43        }
44    }
45
46    /// Normalise an Anthropic `stop_reason` value.
47    pub fn from_anthropic(s: Option<&str>) -> Self {
48        match s {
49            Some("end_turn") => Self::Stop,
50            Some("tool_use") => Self::ToolUse,
51            Some("max_tokens") => Self::Truncated {
52                reason: Some("max_tokens".into()),
53            },
54            Some(other) => Self::Other(other.to_string()),
55            None => Self::Stop,
56        }
57    }
58
59    /// Normalise an OpenAI Responses API status / incomplete reason.
60    pub fn from_responses(reason: Option<&str>) -> Self {
61        match reason {
62            None => Self::Stop,
63            Some("incomplete") => Self::Truncated { reason: None },
64            Some(other) => Self::Other(other.to_string()),
65        }
66    }
67
68    /// Unified conversion when the provider is unknown at compile time.
69    ///
70    /// Handles the union of OpenAI Chat and Anthropic stop-reason strings,
71    /// as well as the Responses API `"incomplete:reason"` format.
72    /// OpenAI `"tool_calls"` is consumed client-side and never reaches here,
73    /// so it is not matched.
74    pub fn from_raw(s: Option<&str>) -> Self {
75        match s {
76            Some("stop") | Some("end_turn") => Self::Stop,
77            Some("tool_use") => Self::ToolUse,
78            Some("length") => Self::Truncated {
79                reason: Some("length".into()),
80            },
81            Some("max_tokens") => Self::Truncated {
82                reason: Some("max_tokens".into()),
83            },
84            // Responses API: only "incomplete:max_output_tokens" is truncation;
85            // "incomplete:content_filter" is a safety refusal, not a token limit.
86            Some("incomplete:max_output_tokens") => Self::Truncated {
87                reason: Some("incomplete:max_output_tokens".into()),
88            },
89            Some(s) if s.starts_with("incomplete:") => Self::Other(s.to_string()),
90            Some(other) => Self::Other(other.to_string()),
91            None => Self::Stop,
92        }
93    }
94
95    /// Returns `true` if the response was truncated by the token limit.
96    pub fn is_truncated(&self) -> bool {
97        matches!(self, Self::Truncated { .. })
98    }
99}
100
101/// The outcome of an Agent turn or run.
102///
103/// Represents the state after a turn completes:
104/// - `Completed` — task finished successfully; run ends.
105/// - `Continuing` — turn ended but the run is still in progress (guard nudge).
106/// - `Failed` — unrecoverable error; run ends.
107/// - `MaxTurnsExceeded` — hit the turn cap; run ends.
108/// - `Cancelled` — user or system cancelled; run ends.
109#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
110pub enum RunOutcome {
111    Completed,
112    Continuing,
113    Failed { error: String },
114    MaxTurnsExceeded { turns: u32 },
115    Cancelled,
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn openai_stop() {
124        assert_eq!(FinishReason::from_openai(Some("stop")), FinishReason::Stop);
125    }
126
127    #[test]
128    fn openai_length() {
129        assert_eq!(
130            FinishReason::from_openai(Some("length")),
131            FinishReason::Truncated {
132                reason: Some("length".into())
133            }
134        );
135    }
136
137    #[test]
138    fn openai_none() {
139        assert_eq!(FinishReason::from_openai(None), FinishReason::Stop);
140    }
141
142    #[test]
143    fn openai_other() {
144        assert_eq!(
145            FinishReason::from_openai(Some("content_filter")),
146            FinishReason::Other("content_filter".into())
147        );
148    }
149
150    #[test]
151    fn anthropic_end_turn() {
152        assert_eq!(
153            FinishReason::from_anthropic(Some("end_turn")),
154            FinishReason::Stop
155        );
156    }
157
158    #[test]
159    fn anthropic_tool_use() {
160        assert_eq!(
161            FinishReason::from_anthropic(Some("tool_use")),
162            FinishReason::ToolUse
163        );
164    }
165
166    #[test]
167    fn anthropic_max_tokens() {
168        assert_eq!(
169            FinishReason::from_anthropic(Some("max_tokens")),
170            FinishReason::Truncated {
171                reason: Some("max_tokens".into())
172            }
173        );
174    }
175
176    #[test]
177    fn responses_incomplete() {
178        assert_eq!(
179            FinishReason::from_responses(Some("incomplete")),
180            FinishReason::Truncated { reason: None }
181        );
182    }
183
184    #[test]
185    fn responses_none() {
186        assert_eq!(FinishReason::from_responses(None), FinishReason::Stop);
187    }
188
189    #[test]
190    fn is_truncated_true() {
191        assert!(FinishReason::Truncated { reason: None }.is_truncated());
192        assert!(
193            FinishReason::Truncated {
194                reason: Some("length".into())
195            }
196            .is_truncated()
197        );
198    }
199
200    #[test]
201    fn is_truncated_false() {
202        assert!(!FinishReason::Stop.is_truncated());
203        assert!(!FinishReason::ToolUse.is_truncated());
204        assert!(!FinishReason::Other("x".into()).is_truncated());
205    }
206
207    #[test]
208    fn from_raw_incomplete_prefix() {
209        assert_eq!(
210            FinishReason::from_raw(Some("incomplete:max_output_tokens")),
211            FinishReason::Truncated {
212                reason: Some("incomplete:max_output_tokens".into())
213            }
214        );
215        // content_filter is a safety refusal, not truncation
216        assert_eq!(
217            FinishReason::from_raw(Some("incomplete:content_filter")),
218            FinishReason::Other("incomplete:content_filter".into())
219        );
220    }
221}
222
223#[cfg(test)]
224mod proptest_tests {
225    use super::*;
226    use proptest::prelude::*;
227
228    proptest! {
229        #[test]
230        fn from_openai_never_panics(s in proptest::option::of(".*")) {
231            let result = FinishReason::from_openai(s.as_deref());
232            // Verify it returns a valid variant
233            match result {
234                FinishReason::Stop | FinishReason::ToolUse |
235                FinishReason::Truncated { .. } | FinishReason::Other(_) => {}
236            }
237        }
238
239        #[test]
240        fn from_anthropic_never_panics(s in proptest::option::of(".*")) {
241            let result = FinishReason::from_anthropic(s.as_deref());
242            match result {
243                FinishReason::Stop | FinishReason::ToolUse |
244                FinishReason::Truncated { .. } | FinishReason::Other(_) => {}
245            }
246        }
247
248        #[test]
249        fn from_raw_never_panics(s in proptest::option::of(".*")) {
250            let result = FinishReason::from_raw(s.as_deref());
251            match result {
252                FinishReason::Stop | FinishReason::ToolUse |
253                FinishReason::Truncated { .. } | FinishReason::Other(_) => {}
254            }
255        }
256
257        #[test]
258        fn from_responses_never_panics(s in proptest::option::of(".*")) {
259            let result = FinishReason::from_responses(s.as_deref());
260            match result {
261                FinishReason::Stop | FinishReason::ToolUse |
262                FinishReason::Truncated { .. } | FinishReason::Other(_) => {}
263            }
264        }
265
266        #[test]
267        fn truncated_is_always_truncated(reason in proptest::option::of(".*")) {
268            let fr = FinishReason::Truncated { reason };
269            assert!(fr.is_truncated());
270        }
271    }
272}