1use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use crate::message::{ChatRequest, ContentPart, FinishReason, Message, TokenUsage};
18use crate::tool_types::ToolCall;
19
20const CANCELLED_MSG: &str = "cancelled";
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub enum RunState {
26 Idle,
28 BuildingContext,
30 CallingModel,
32 StreamingModel,
34 ProcessingResponse,
36 ExecutingTools,
38 WaitingApproval,
40 Compacting,
42 Demoting,
44 Finished,
46 Aborted,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub enum RunInput {
53 UserMessageReceived {
55 content: Vec<ContentPart>,
57 },
58 ModelChunkReceived {
60 delta: String,
62 },
63 ModelCompleted {
65 message: Message,
67 usage: TokenUsage,
69 },
70 ToolCallRequested {
72 calls: Vec<ToolCall>,
74 },
75 ToolResultReceived {
77 call_id: String,
79 output: Value,
81 },
82 ToolApprovalGranted {
84 call_id: String,
86 },
87 ToolApprovalRejected {
89 call_id: String,
91 reason: Option<String>,
93 },
94 MemoryCompactionCompleted {
96 summary: String,
98 },
99 RuntimeErrorReceived {
101 error: String,
103 },
104 CancellationRequested,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub enum RunAction {
111 RequestModel {
113 request: ChatRequest,
115 },
116 ExecuteTool {
118 calls: Vec<ToolCall>,
120 },
121 RequestToolApproval {
123 call: ToolCall,
125 reason: String,
127 },
128 CompactMemory,
130 DemoteMemory,
132 FinishRun {
134 reason: FinishReason,
136 usage: TokenUsage,
138 },
139 AbortRun {
141 error: String,
143 },
144 Noop,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct RunConfig {
151 pub max_iterations: usize,
153 pub max_tokens: Option<usize>,
155 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#[must_use]
182pub fn transition(
183 state: &RunState,
184 input: RunInput,
185 _config: &RunConfig,
186) -> (RunState, Vec<RunAction>) {
187 match (state, input) {
188 (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 (RunState::BuildingContext, RunInput::UserMessageReceived { .. }) => {
201 (RunState::CallingModel, vec![RunAction::Noop])
204 }
205
206 (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 (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 (RunState::ProcessingResponse, RunInput::ToolCallRequested { calls }) => (
270 RunState::ExecutingTools,
271 vec![RunAction::ExecuteTool { calls }],
272 ),
273
274 (RunState::ExecutingTools, RunInput::ToolResultReceived { .. }) => {
276 (RunState::ExecutingTools, vec![RunAction::Noop])
280 }
281 (RunState::ExecutingTools, RunInput::RuntimeErrorReceived { .. }) => {
282 (RunState::BuildingContext, vec![RunAction::Noop])
283 }
284
285 (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 let _error_msg = reason.unwrap_or_else(|| "tool execution was rejected".to_string());
295 (RunState::BuildingContext, vec![RunAction::Noop])
296 }
297
298 (RunState::Compacting, RunInput::MemoryCompactionCompleted { .. }) => {
300 (RunState::BuildingContext, vec![RunAction::Noop])
301 }
302
303 (RunState::Finished, _) | (RunState::Aborted, _) => {
305 (state.clone(), vec![])
307 }
308
309 _ => (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 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}