Skip to main content

agent_base/types/
finish_reason.rs

1/// Semantic finish reason, replacing scattered `Option<String>` matching.
2///
3/// Each variant captures a distinct end-of-turn signal from the LLM provider.
4/// Conversion helpers ([`FinishReason::from_openai`], [`FinishReason::from_anthropic`],
5/// [`FinishReason::from_responses`]) normalise the provider-specific strings.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub enum FinishReason {
8    /// Model finished naturally (OpenAI `"stop"` / Anthropic `"end_turn"`).
9    Stop,
10    /// Model requested tool calls (Anthropic `"tool_use"`).
11    ///
12    /// OpenAI's `"tool_calls"` is consumed at the client layer as
13    /// `StreamChunk::ToolCall` and never reaches the react loop.
14    ToolUse,
15    /// Output was truncated by the token limit.
16    ///
17    /// - OpenAI: `"length"`
18    /// - Anthropic: `"max_tokens"`
19    /// - Responses API: `"incomplete"`
20    Truncated {
21        /// Provider-specific reason string, if available.
22        reason: Option<String>,
23    },
24    /// Any other / unknown finish reason.
25    Other(String),
26}
27
28impl FinishReason {
29    /// Normalise an OpenAI Chat Completions `finish_reason` value.
30    pub fn from_openai(s: Option<&str>) -> Self {
31        match s {
32            Some("stop") => Self::Stop,
33            Some("length") => Self::Truncated {
34                reason: Some("length".into()),
35            },
36            // "tool_calls" is consumed client-side, never reaches the react loop.
37            Some(other) => Self::Other(other.to_string()),
38            None => Self::Stop, // stream ended normally
39        }
40    }
41
42    /// Normalise an Anthropic `stop_reason` value.
43    pub fn from_anthropic(s: Option<&str>) -> Self {
44        match s {
45            Some("end_turn") => Self::Stop,
46            Some("tool_use") => Self::ToolUse,
47            Some("max_tokens") => Self::Truncated {
48                reason: Some("max_tokens".into()),
49            },
50            Some(other) => Self::Other(other.to_string()),
51            None => Self::Stop,
52        }
53    }
54
55    /// Normalise an OpenAI Responses API status / incomplete reason.
56    pub fn from_responses(reason: Option<&str>) -> Self {
57        match reason {
58            None => Self::Stop,
59            Some("incomplete") => Self::Truncated { reason: None },
60            Some(other) => Self::Other(other.to_string()),
61        }
62    }
63
64    /// Unified conversion when the provider is unknown at compile time.
65    ///
66    /// Handles the union of OpenAI Chat and Anthropic stop-reason strings,
67    /// as well as the Responses API `"incomplete:reason"` format.
68    /// OpenAI `"tool_calls"` is consumed client-side and never reaches here,
69    /// so it is not matched.
70    pub fn from_raw(s: Option<&str>) -> Self {
71        match s {
72            Some("stop") | Some("end_turn") => Self::Stop,
73            Some("tool_use") => Self::ToolUse,
74            Some("length") => Self::Truncated {
75                reason: Some("length".into()),
76            },
77            Some("max_tokens") => Self::Truncated {
78                reason: Some("max_tokens".into()),
79            },
80            // Responses API: only "incomplete:max_output_tokens" is truncation;
81            // "incomplete:content_filter" is a safety refusal, not a token limit.
82            Some("incomplete:max_output_tokens") => Self::Truncated {
83                reason: Some("incomplete:max_output_tokens".into()),
84            },
85            Some(s) if s.starts_with("incomplete:") => Self::Other(s.to_string()),
86            Some(other) => Self::Other(other.to_string()),
87            None => Self::Stop,
88        }
89    }
90
91    /// Returns `true` if the response was truncated by the token limit.
92    pub fn is_truncated(&self) -> bool {
93        matches!(self, Self::Truncated { .. })
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn openai_stop() {
103        assert_eq!(FinishReason::from_openai(Some("stop")), FinishReason::Stop);
104    }
105
106    #[test]
107    fn openai_length() {
108        assert_eq!(
109            FinishReason::from_openai(Some("length")),
110            FinishReason::Truncated {
111                reason: Some("length".into())
112            }
113        );
114    }
115
116    #[test]
117    fn openai_none() {
118        assert_eq!(FinishReason::from_openai(None), FinishReason::Stop);
119    }
120
121    #[test]
122    fn openai_other() {
123        assert_eq!(
124            FinishReason::from_openai(Some("content_filter")),
125            FinishReason::Other("content_filter".into())
126        );
127    }
128
129    #[test]
130    fn anthropic_end_turn() {
131        assert_eq!(
132            FinishReason::from_anthropic(Some("end_turn")),
133            FinishReason::Stop
134        );
135    }
136
137    #[test]
138    fn anthropic_tool_use() {
139        assert_eq!(
140            FinishReason::from_anthropic(Some("tool_use")),
141            FinishReason::ToolUse
142        );
143    }
144
145    #[test]
146    fn anthropic_max_tokens() {
147        assert_eq!(
148            FinishReason::from_anthropic(Some("max_tokens")),
149            FinishReason::Truncated {
150                reason: Some("max_tokens".into())
151            }
152        );
153    }
154
155    #[test]
156    fn responses_incomplete() {
157        assert_eq!(
158            FinishReason::from_responses(Some("incomplete")),
159            FinishReason::Truncated { reason: None }
160        );
161    }
162
163    #[test]
164    fn responses_none() {
165        assert_eq!(FinishReason::from_responses(None), FinishReason::Stop);
166    }
167
168    #[test]
169    fn is_truncated_true() {
170        assert!(FinishReason::Truncated { reason: None }.is_truncated());
171        assert!(
172            FinishReason::Truncated {
173                reason: Some("length".into())
174            }
175            .is_truncated()
176        );
177    }
178
179    #[test]
180    fn is_truncated_false() {
181        assert!(!FinishReason::Stop.is_truncated());
182        assert!(!FinishReason::ToolUse.is_truncated());
183        assert!(!FinishReason::Other("x".into()).is_truncated());
184    }
185
186    #[test]
187    fn from_raw_incomplete_prefix() {
188        assert_eq!(
189            FinishReason::from_raw(Some("incomplete:max_output_tokens")),
190            FinishReason::Truncated {
191                reason: Some("incomplete:max_output_tokens".into())
192            }
193        );
194        // content_filter is a safety refusal, not truncation
195        assert_eq!(
196            FinishReason::from_raw(Some("incomplete:content_filter")),
197            FinishReason::Other("incomplete:content_filter".into())
198        );
199    }
200}