1use behest_provider::FinishReason;
11
12use super::run::RunStatus;
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum TurnState {
21 CheckingPolicy,
23 BuildingContext,
25 CallingModel,
27 Compacting,
29 ProcessingResponse,
31 ExecutingTools,
33 Persisting,
35}
36
37#[derive(Debug, Clone)]
41pub enum TurnOutcome {
42 Success,
44 PolicyExceeded {
46 reason: String,
48 },
49 ContextOverflow,
51 ProviderError {
53 message: String,
55 },
56 PipelineError {
58 message: String,
60 },
61 NoToolCalls,
63 NotToolCalls {
65 finish_reason: FinishReason,
67 },
68 ToolFailure {
70 message: String,
72 },
73 OutputTruncated,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum TurnAction {
80 Continue {
82 next: TurnState,
84 },
85 BreakLoop,
87 CompactAndRetry,
89 Fail {
91 reason: String,
93 },
94}
95
96pub struct TurnTransition;
101
102impl TurnTransition {
103 #[must_use]
105 pub fn resolve(state: TurnState, outcome: &TurnOutcome) -> TurnAction {
106 match (state, outcome) {
107 (TurnState::CheckingPolicy, TurnOutcome::PolicyExceeded { reason }) => {
108 TurnAction::Fail {
109 reason: reason.clone(),
110 }
111 }
112 (TurnState::CheckingPolicy, TurnOutcome::Success) => TurnAction::Continue {
113 next: TurnState::BuildingContext,
114 },
115
116 (TurnState::BuildingContext | TurnState::Compacting, TurnOutcome::Success)
117 | (TurnState::ProcessingResponse, TurnOutcome::OutputTruncated) => {
118 TurnAction::Continue {
119 next: TurnState::CallingModel,
120 }
121 }
122 (
123 TurnState::BuildingContext | TurnState::Compacting,
124 TurnOutcome::PipelineError { .. },
125 ) => TurnAction::Fail {
126 reason: "context build failed".to_string(),
127 },
128
129 (TurnState::CallingModel, TurnOutcome::Success) => TurnAction::Continue {
130 next: TurnState::ProcessingResponse,
131 },
132 (TurnState::CallingModel, TurnOutcome::ContextOverflow) => TurnAction::CompactAndRetry,
133 (TurnState::CallingModel, TurnOutcome::ProviderError { .. }) => TurnAction::Fail {
134 reason: "provider error".to_string(),
135 },
136
137 (
138 TurnState::ProcessingResponse,
139 TurnOutcome::NoToolCalls | TurnOutcome::NotToolCalls { .. },
140 ) => TurnAction::BreakLoop,
141 (TurnState::ProcessingResponse, TurnOutcome::Success) => TurnAction::Continue {
142 next: TurnState::ExecutingTools,
143 },
144
145 (TurnState::ExecutingTools, TurnOutcome::Success) => TurnAction::Continue {
146 next: TurnState::Persisting,
147 },
148 (TurnState::ExecutingTools, TurnOutcome::ToolFailure { .. }) => TurnAction::Fail {
149 reason: "tool execution failed".to_string(),
150 },
151
152 (TurnState::Persisting, TurnOutcome::Success) => TurnAction::Continue {
153 next: TurnState::CheckingPolicy,
154 },
155 (TurnState::Persisting, TurnOutcome::PipelineError { .. }) => TurnAction::Fail {
156 reason: "persistence failed".to_string(),
157 },
158
159 (_, outcome) => TurnAction::Fail {
160 reason: format!("unexpected outcome {outcome:?} in state {state:?}"),
161 },
162 }
163 }
164
165 #[must_use]
167 pub fn status_for(state: TurnState) -> RunStatus {
168 match state {
169 TurnState::CheckingPolicy | TurnState::Compacting | TurnState::BuildingContext => {
170 RunStatus::BuildingContext
171 }
172 TurnState::CallingModel | TurnState::ProcessingResponse => RunStatus::CallingModel,
173 TurnState::ExecutingTools => RunStatus::WaitingForTools,
174 TurnState::Persisting => RunStatus::Persisting,
175 }
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn policy_exceeded_fails() {
185 let action = TurnTransition::resolve(
186 TurnState::CheckingPolicy,
187 &TurnOutcome::PolicyExceeded {
188 reason: "iteration".into(),
189 },
190 );
191 assert_eq!(
192 action,
193 TurnAction::Fail {
194 reason: "iteration".into()
195 }
196 );
197 }
198
199 #[test]
200 fn policy_ok_proceeds_to_context() {
201 let action = TurnTransition::resolve(TurnState::CheckingPolicy, &TurnOutcome::Success);
202 assert_eq!(
203 action,
204 TurnAction::Continue {
205 next: TurnState::BuildingContext
206 }
207 );
208 }
209
210 #[test]
211 fn context_overflow_compacts_and_retries() {
212 let action =
213 TurnTransition::resolve(TurnState::CallingModel, &TurnOutcome::ContextOverflow);
214 assert_eq!(action, TurnAction::CompactAndRetry);
215 }
216
217 #[test]
218 fn model_success_moves_to_processing() {
219 let action = TurnTransition::resolve(TurnState::CallingModel, &TurnOutcome::Success);
220 assert_eq!(
221 action,
222 TurnAction::Continue {
223 next: TurnState::ProcessingResponse
224 }
225 );
226 }
227
228 #[test]
229 fn provider_error_fails() {
230 let action = TurnTransition::resolve(
231 TurnState::CallingModel,
232 &TurnOutcome::ProviderError {
233 message: "boom".into(),
234 },
235 );
236 assert_eq!(
237 action,
238 TurnAction::Fail {
239 reason: "provider error".into()
240 }
241 );
242 }
243
244 #[test]
245 fn compaction_success_returns_to_model() {
246 let action = TurnTransition::resolve(TurnState::Compacting, &TurnOutcome::Success);
247 assert_eq!(
248 action,
249 TurnAction::Continue {
250 next: TurnState::CallingModel
251 }
252 );
253 }
254
255 #[test]
256 fn no_tool_calls_breaks_loop() {
257 let action =
258 TurnTransition::resolve(TurnState::ProcessingResponse, &TurnOutcome::NoToolCalls);
259 assert_eq!(action, TurnAction::BreakLoop);
260 }
261
262 #[test]
263 fn not_tool_calls_finish_breaks() {
264 let action = TurnTransition::resolve(
265 TurnState::ProcessingResponse,
266 &TurnOutcome::NotToolCalls {
267 finish_reason: FinishReason::Stop,
268 },
269 );
270 assert_eq!(action, TurnAction::BreakLoop);
271 }
272
273 #[test]
274 fn tool_calls_moves_to_execution() {
275 let action = TurnTransition::resolve(TurnState::ProcessingResponse, &TurnOutcome::Success);
276 assert_eq!(
277 action,
278 TurnAction::Continue {
279 next: TurnState::ExecutingTools
280 }
281 );
282 }
283
284 #[test]
285 fn output_truncated_continues_to_model() {
286 let action =
287 TurnTransition::resolve(TurnState::ProcessingResponse, &TurnOutcome::OutputTruncated);
288 assert_eq!(
289 action,
290 TurnAction::Continue {
291 next: TurnState::CallingModel
292 }
293 );
294 }
295
296 #[test]
297 fn persisting_success_returns_to_check() {
298 let action = TurnTransition::resolve(TurnState::Persisting, &TurnOutcome::Success);
299 assert_eq!(
300 action,
301 TurnAction::Continue {
302 next: TurnState::CheckingPolicy
303 }
304 );
305 }
306
307 #[test]
308 fn status_maps_correctly() {
309 assert_eq!(
310 TurnTransition::status_for(TurnState::BuildingContext),
311 RunStatus::BuildingContext
312 );
313 assert_eq!(
314 TurnTransition::status_for(TurnState::CallingModel),
315 RunStatus::CallingModel
316 );
317 assert_eq!(
318 TurnTransition::status_for(TurnState::ExecutingTools),
319 RunStatus::WaitingForTools
320 );
321 assert_eq!(
322 TurnTransition::status_for(TurnState::Persisting),
323 RunStatus::Persisting
324 );
325 }
326}