Skip to main content

behest_runtime/
turn.rs

1//! Turn-based state machine for agent control flow.
2//!
3//! Replaces flat loop `break`/`continue`/`return` with explicit
4//! [`TurnState`] → [`TurnOutcome`] → [`TurnAction`] transitions
5//! driven by a pure [`TurnTransition::resolve`] function.
6//!
7//! This makes the agent loop's control flow auditable, testable, and
8//! extensible without touching the core executor.
9
10use behest_provider::FinishReason;
11
12use super::run::RunStatus;
13
14use serde::{Deserialize, Serialize};
15
16/// States in the agent turn cycle.
17///
18/// Each iteration of the agent loop moves through a subset of these states.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum TurnState {
21    /// Validating iteration count and token budget before any work.
22    CheckingPolicy,
23    /// Running proactive compaction then building the chat context.
24    BuildingContext,
25    /// Calling the model provider (streaming or complete).
26    CallingModel,
27    /// Running reactive compaction after a context overflow.
28    Compacting,
29    /// Parsing the model response and deciding whether to call tools.
30    ProcessingResponse,
31    /// Executing tool calls returned by the model.
32    ExecutingTools,
33    /// Persisting tool results and preparing for the next turn.
34    Persisting,
35}
36
37/// Outcome of a turn — what the state produced.
38///
39/// Drives the transition to the next state.
40#[derive(Debug, Clone)]
41pub enum TurnOutcome {
42    /// State executed successfully.
43    Success,
44    /// Policy limit reached (iteration or token).
45    PolicyExceeded {
46        /// Human-readable reason.
47        reason: String,
48    },
49    /// Provider returned a context overflow error.
50    ContextOverflow,
51    /// Provider returned a non-retryable error.
52    ProviderError {
53        /// Error message.
54        message: String,
55    },
56    /// Context or storage error.
57    PipelineError {
58        /// Error message.
59        message: String,
60    },
61    /// Model response has no tool calls — agent is done.
62    NoToolCalls,
63    /// Model finish reason is not ToolCalls — agent is done.
64    NotToolCalls {
65        /// The finish reason that stopped the loop.
66        finish_reason: FinishReason,
67    },
68    /// Tool execution failed and policy does not allow continuation.
69    ToolFailure {
70        /// Error message.
71        message: String,
72    },
73    /// Model output was truncated (FinishReason::Length). Recovery may be attempted.
74    OutputTruncated,
75}
76
77/// Action the loop executor should take after a transition.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum TurnAction {
80    /// Proceed to the next state in the normal flow.
81    Continue {
82        /// Next state to enter.
83        next: TurnState,
84    },
85    /// Break the loop and complete the run successfully.
86    BreakLoop,
87    /// Run compaction then retry CallingModel.
88    CompactAndRetry,
89    /// Terminate the run with an error.
90    Fail {
91        /// Error message.
92        reason: String,
93    },
94}
95
96/// Pure resolver mapping `(TurnState, TurnOutcome)` → `TurnAction`.
97///
98/// This function encodes the complete control-flow logic. It is
99/// stateless and can be tested exhaustively without the runtime.
100pub struct TurnTransition;
101
102impl TurnTransition {
103    /// Resolves the next action given the current state and outcome.
104    #[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    /// Derives the [`RunStatus`] implied by a given turn state.
166    #[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}