Skip to main content

behest_core/
run.rs

1//! Sans-IO run state machine for the agent prompt loop.
2//!
3//! The run loop is modeled as a pure state machine that receives input events,
4//! transitions between states, and produces output actions. This design is:
5//!
6//! - **Runtime-agnostic**: no dependency on tokio or any async runtime.
7//! - **Serializable**: the entire machine state can be serialized for
8//!   checkpoint/resume across process boundaries.
9//! - **Testable**: transitions can be verified without network, model API,
10//!   or actual tool execution.
11//!
12//! The [`transition`] function is the core: `(state, input, config) -> (new_state, actions)`.
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use crate::message::{ChatRequest, ContentPart, FinishReason, Message, TokenUsage};
18use crate::tool_types::ToolCall;
19
20/// Shared cancellation message to avoid string duplication.
21const CANCELLED_MSG: &str = "cancelled";
22
23/// The state of a single agent run.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub enum RunState {
26    /// Initial state, ready to accept a user message.
27    Idle,
28    /// Building the context (system prompt, history, RAG) for the model call.
29    BuildingContext,
30    /// Waiting for the model to respond (non-streaming).
31    CallingModel,
32    /// Receiving streaming chunks from the model.
33    StreamingModel,
34    /// Processing the completed model response.
35    ProcessingResponse,
36    /// Executing tool calls requested by the model.
37    ExecutingTools,
38    /// Waiting for human approval of a tool call.
39    WaitingApproval,
40    /// Compacting conversation memory.
41    Compacting,
42    /// Demoting old messages to long-term storage.
43    Demoting,
44    /// Run completed successfully.
45    Finished,
46    /// Run was aborted due to an error or cancellation.
47    Aborted,
48}
49
50/// Input events that drive the state machine forward.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub enum RunInput {
53    /// A user message has been received.
54    UserMessageReceived {
55        /// Content parts of the user message.
56        content: Vec<ContentPart>,
57    },
58    /// A streaming text chunk was received from the model.
59    ModelChunkReceived {
60        /// Incremental text delta.
61        delta: String,
62    },
63    /// The model completed its response (non-streaming or end of stream).
64    ModelCompleted {
65        /// The complete assistant message.
66        message: Message,
67        /// Token usage for this model call.
68        usage: TokenUsage,
69    },
70    /// Tool calls were requested by the model.
71    ToolCallRequested {
72        /// The tool calls to execute.
73        calls: Vec<ToolCall>,
74    },
75    /// A tool execution completed.
76    ToolResultReceived {
77        /// The call ID this result corresponds to.
78        call_id: String,
79        /// The tool output.
80        output: Value,
81    },
82    /// Human approval was granted for a pending tool call.
83    ToolApprovalGranted {
84        /// The call ID that was approved.
85        call_id: String,
86    },
87    /// Human approval was rejected for a pending tool call.
88    ToolApprovalRejected {
89        /// The call ID that was rejected.
90        call_id: String,
91        /// Optional reason for rejection.
92        reason: Option<String>,
93    },
94    /// Memory compaction completed.
95    MemoryCompactionCompleted {
96        /// Summary text produced by the compactor.
97        summary: String,
98    },
99    /// A runtime-level error occurred.
100    RuntimeErrorReceived {
101        /// Description of the error.
102        error: String,
103    },
104    /// Cancellation was requested.
105    CancellationRequested,
106}
107
108/// Actions produced by the state machine that must be executed by the runtime.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub enum RunAction {
111    /// Request a model completion (non-streaming or streaming).
112    RequestModel {
113        /// The chat request to send.
114        request: ChatRequest,
115    },
116    /// Execute one or more tool calls.
117    ExecuteTool {
118        /// The tool calls to execute.
119        calls: Vec<ToolCall>,
120    },
121    /// Request human approval before executing a tool.
122    RequestToolApproval {
123        /// The tool call requiring approval.
124        call: ToolCall,
125        /// Human-readable reason approval is needed.
126        reason: String,
127    },
128    /// Compact conversation memory (summarize old messages).
129    CompactMemory,
130    /// Demote old messages to long-term storage.
131    DemoteMemory,
132    /// The run has finished.
133    FinishRun {
134        /// Reason for finishing.
135        reason: FinishReason,
136        /// Total token usage for the run.
137        usage: TokenUsage,
138    },
139    /// The run was aborted.
140    AbortRun {
141        /// Error that caused the abort.
142        error: String,
143    },
144    /// No action needed (e.g., during streaming).
145    Noop,
146}
147
148/// Configuration for the run state machine.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct RunConfig {
151    /// Maximum number of tool-calling iterations (model → tool → model loops).
152    pub max_iterations: usize,
153    /// Maximum total tokens across all model calls.
154    pub max_tokens: Option<usize>,
155    /// Whether to continue when a tool fails.
156    pub continue_on_tool_failure: bool,
157}
158
159impl Default for RunConfig {
160    fn default() -> Self {
161        Self {
162            max_iterations: 10,
163            max_tokens: None,
164            continue_on_tool_failure: true,
165        }
166    }
167}
168
169/// The core transition function.
170///
171/// Given the current state, an input event, and configuration, returns the next
172/// state and the actions the runtime should execute.
173///
174/// # Determinism
175///
176/// This function is pure: for the same `(state, input, config)`, it always
177/// produces the same `(next_state, actions)`. This property enables:
178/// - Deterministic replay from event logs
179/// - Snapshot-based crash recovery
180/// - Property-based testing
181#[must_use]
182pub fn transition(
183    state: &RunState,
184    input: RunInput,
185    _config: &RunConfig,
186) -> (RunState, Vec<RunAction>) {
187    match (state, input) {
188        // ---- Idle ----
189        (RunState::Idle, RunInput::UserMessageReceived { .. }) => {
190            (RunState::BuildingContext, vec![RunAction::Noop])
191        }
192        (RunState::Idle, RunInput::CancellationRequested) => (
193            RunState::Aborted,
194            vec![RunAction::AbortRun {
195                error: "cancelled before start".to_string(),
196            }],
197        ),
198
199        // ---- BuildingContext ----
200        (RunState::BuildingContext, RunInput::UserMessageReceived { .. }) => {
201            // Context built implicitly by the runtime adapter; transition to model call.
202            // The runtime adapter constructs the ChatRequest and feeds it back.
203            (RunState::CallingModel, vec![RunAction::Noop])
204        }
205
206        // ---- CallingModel ----
207        (RunState::CallingModel, RunInput::ModelChunkReceived { .. }) => {
208            (RunState::StreamingModel, vec![RunAction::Noop])
209        }
210        (RunState::CallingModel, RunInput::ModelCompleted { message, usage: _ }) => {
211            if !message.tool_calls().is_empty() {
212                let calls = message.tool_calls().to_vec();
213                (
214                    RunState::ExecutingTools,
215                    vec![RunAction::ExecuteTool { calls }],
216                )
217            } else {
218                (
219                    RunState::Finished,
220                    vec![RunAction::FinishRun {
221                        reason: FinishReason::Stop,
222                        usage: TokenUsage::new(0, 0),
223                    }],
224                )
225            }
226        }
227        (RunState::CallingModel, RunInput::RuntimeErrorReceived { error }) => {
228            (RunState::Aborted, vec![RunAction::AbortRun { error }])
229        }
230        (RunState::CallingModel, RunInput::CancellationRequested) => (
231            RunState::Aborted,
232            vec![RunAction::AbortRun {
233                error: CANCELLED_MSG.to_string(),
234            }],
235        ),
236
237        // ---- StreamingModel ----
238        (RunState::StreamingModel, RunInput::ModelChunkReceived { .. }) => {
239            (RunState::StreamingModel, vec![RunAction::Noop])
240        }
241        (RunState::StreamingModel, RunInput::ModelCompleted { message, usage }) => {
242            if !message.tool_calls().is_empty() {
243                let calls = message.tool_calls().to_vec();
244                (
245                    RunState::ExecutingTools,
246                    vec![RunAction::ExecuteTool { calls }],
247                )
248            } else {
249                (
250                    RunState::Finished,
251                    vec![RunAction::FinishRun {
252                        reason: FinishReason::Stop,
253                        usage,
254                    }],
255                )
256            }
257        }
258        (RunState::StreamingModel, RunInput::RuntimeErrorReceived { error }) => {
259            (RunState::Aborted, vec![RunAction::AbortRun { error }])
260        }
261        (RunState::StreamingModel, RunInput::CancellationRequested) => (
262            RunState::Aborted,
263            vec![RunAction::AbortRun {
264                error: CANCELLED_MSG.to_string(),
265            }],
266        ),
267
268        // ---- ProcessingResponse ----
269        (RunState::ProcessingResponse, RunInput::ToolCallRequested { calls }) => (
270            RunState::ExecutingTools,
271            vec![RunAction::ExecuteTool { calls }],
272        ),
273
274        // ---- ExecutingTools ----
275        (RunState::ExecutingTools, RunInput::ToolResultReceived { .. }) => {
276            // After all tools complete, the runtime adapter should transition
277            // back to BuildingContext for the next model call.
278            // Individual tool results are collected by the runtime.
279            (RunState::ExecutingTools, vec![RunAction::Noop])
280        }
281        (RunState::ExecutingTools, RunInput::RuntimeErrorReceived { .. }) => {
282            (RunState::BuildingContext, vec![RunAction::Noop])
283        }
284
285        // ---- WaitingApproval ----
286        (RunState::WaitingApproval, RunInput::ToolApprovalGranted { call_id }) => (
287            RunState::ExecutingTools,
288            vec![RunAction::ExecuteTool {
289                calls: vec![ToolCall::new(call_id, String::new(), Value::Null)],
290            }],
291        ),
292        (RunState::WaitingApproval, RunInput::ToolApprovalRejected { call_id: _, reason }) => {
293            // Tool was rejected; feed rejection back to the model
294            let _error_msg = reason.unwrap_or_else(|| "tool execution was rejected".to_string());
295            (RunState::BuildingContext, vec![RunAction::Noop])
296        }
297
298        // ---- Compacting ----
299        (RunState::Compacting, RunInput::MemoryCompactionCompleted { .. }) => {
300            (RunState::BuildingContext, vec![RunAction::Noop])
301        }
302
303        // ---- Terminal states ----
304        (RunState::Finished, _) | (RunState::Aborted, _) => {
305            // No transitions from terminal states
306            (state.clone(), vec![])
307        }
308
309        // ---- Catch-all for unhandled combinations ----
310        _ => (state.clone(), vec![RunAction::Noop]),
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::message::ContentPart;
318
319    fn make_config() -> RunConfig {
320        RunConfig::default()
321    }
322
323    #[test]
324    fn idle_to_building_context_on_user_message() {
325        let (next, actions) = transition(
326            &RunState::Idle,
327            RunInput::UserMessageReceived {
328                content: vec![ContentPart::text("hello")],
329            },
330            &make_config(),
331        );
332        assert_eq!(next, RunState::BuildingContext);
333        assert!(matches!(actions.as_slice(), [RunAction::Noop]));
334    }
335
336    #[test]
337    fn idle_cancellation_aborts() {
338        let (next, actions) = transition(
339            &RunState::Idle,
340            RunInput::CancellationRequested,
341            &make_config(),
342        );
343        assert_eq!(next, RunState::Aborted);
344        assert!(matches!(actions.as_slice(), [RunAction::AbortRun { .. }]));
345    }
346
347    #[test]
348    fn calling_model_with_tool_calls_transitions_to_executing() {
349        let msg = Message::Assistant {
350            content: vec![],
351            tool_calls: vec![ToolCall::new("call_1", "search", Value::Null)],
352        };
353        let (next, actions) = transition(
354            &RunState::CallingModel,
355            RunInput::ModelCompleted {
356                message: msg,
357                usage: TokenUsage::new(100, 50),
358            },
359            &make_config(),
360        );
361        assert_eq!(next, RunState::ExecutingTools);
362        assert!(matches!(
363            actions.as_slice(),
364            [RunAction::ExecuteTool { .. }]
365        ));
366    }
367
368    #[test]
369    fn calling_model_without_tools_finishes() {
370        let msg = Message::assistant_text("done");
371        let (next, actions) = transition(
372            &RunState::CallingModel,
373            RunInput::ModelCompleted {
374                message: msg,
375                usage: TokenUsage::new(100, 50),
376            },
377            &make_config(),
378        );
379        assert_eq!(next, RunState::Finished);
380        assert!(matches!(actions.as_slice(), [RunAction::FinishRun { .. }]));
381    }
382
383    #[test]
384    fn terminal_states_no_transition() {
385        for state in &[RunState::Finished, RunState::Aborted] {
386            let (next, actions) = transition(
387                state,
388                RunInput::UserMessageReceived {
389                    content: vec![ContentPart::text("hello")],
390                },
391                &make_config(),
392            );
393            assert_eq!(next, *state);
394            assert!(actions.is_empty());
395        }
396    }
397
398    #[test]
399    fn approval_granted_executes_tool() {
400        let (next, actions) = transition(
401            &RunState::WaitingApproval,
402            RunInput::ToolApprovalGranted {
403                call_id: "call_1".to_string(),
404            },
405            &make_config(),
406        );
407        assert_eq!(next, RunState::ExecutingTools);
408        assert!(matches!(
409            actions.as_slice(),
410            [RunAction::ExecuteTool { .. }]
411        ));
412    }
413
414    #[test]
415    fn approval_rejected_goes_to_building_context() {
416        let (next, _actions) = transition(
417            &RunState::WaitingApproval,
418            RunInput::ToolApprovalRejected {
419                call_id: "call_1".to_string(),
420                reason: Some("too dangerous".to_string()),
421            },
422            &make_config(),
423        );
424        assert_eq!(next, RunState::BuildingContext);
425    }
426
427    #[test]
428    fn deterministic_same_input_same_output() {
429        let state = RunState::Idle;
430        let config = make_config();
431        let input = RunInput::UserMessageReceived {
432            content: vec![ContentPart::text("hello")],
433        };
434
435        let (next1, actions1) = transition(&state, input.clone(), &config);
436        let (next2, actions2) = transition(&state, input, &config);
437
438        assert_eq!(next1, next2);
439        // Compare actions by debug representation
440        assert_eq!(format!("{actions1:?}"), format!("{actions2:?}"));
441    }
442
443    #[test]
444    fn streaming_chunks_keep_streaming_state() {
445        let (next, actions) = transition(
446            &RunState::StreamingModel,
447            RunInput::ModelChunkReceived {
448                delta: "hello".to_string(),
449            },
450            &make_config(),
451        );
452        assert_eq!(next, RunState::StreamingModel);
453        assert!(matches!(actions.as_slice(), [RunAction::Noop]));
454    }
455}