use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::message::{ChatRequest, ContentPart, FinishReason, Message, TokenUsage};
use crate::tool_types::ToolCall;
const CANCELLED_MSG: &str = "cancelled";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RunState {
Idle,
BuildingContext,
CallingModel,
StreamingModel,
ProcessingResponse,
ExecutingTools,
WaitingApproval,
Compacting,
Demoting,
Finished,
Aborted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RunInput {
UserMessageReceived {
content: Vec<ContentPart>,
},
ModelChunkReceived {
delta: String,
},
ModelCompleted {
message: Message,
usage: TokenUsage,
},
ToolCallRequested {
calls: Vec<ToolCall>,
},
ToolResultReceived {
call_id: String,
output: Value,
},
ToolApprovalGranted {
call_id: String,
},
ToolApprovalRejected {
call_id: String,
reason: Option<String>,
},
MemoryCompactionCompleted {
summary: String,
},
RuntimeErrorReceived {
error: String,
},
CancellationRequested,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RunAction {
RequestModel {
request: ChatRequest,
},
ExecuteTool {
calls: Vec<ToolCall>,
},
RequestToolApproval {
call: ToolCall,
reason: String,
},
CompactMemory,
DemoteMemory,
FinishRun {
reason: FinishReason,
usage: TokenUsage,
},
AbortRun {
error: String,
},
Noop,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunConfig {
pub max_iterations: usize,
pub max_tokens: Option<usize>,
pub continue_on_tool_failure: bool,
}
impl Default for RunConfig {
fn default() -> Self {
Self {
max_iterations: 10,
max_tokens: None,
continue_on_tool_failure: true,
}
}
}
#[must_use]
pub fn transition(
state: &RunState,
input: RunInput,
_config: &RunConfig,
) -> (RunState, Vec<RunAction>) {
match (state, input) {
(RunState::Idle, RunInput::UserMessageReceived { .. }) => {
(RunState::BuildingContext, vec![RunAction::Noop])
}
(RunState::Idle, RunInput::CancellationRequested) => (
RunState::Aborted,
vec![RunAction::AbortRun {
error: "cancelled before start".to_string(),
}],
),
(RunState::BuildingContext, RunInput::UserMessageReceived { .. }) => {
(RunState::CallingModel, vec![RunAction::Noop])
}
(RunState::CallingModel, RunInput::ModelChunkReceived { .. }) => {
(RunState::StreamingModel, vec![RunAction::Noop])
}
(RunState::CallingModel, RunInput::ModelCompleted { message, usage: _ }) => {
if !message.tool_calls().is_empty() {
let calls = message.tool_calls().to_vec();
(
RunState::ExecutingTools,
vec![RunAction::ExecuteTool { calls }],
)
} else {
(
RunState::Finished,
vec![RunAction::FinishRun {
reason: FinishReason::Stop,
usage: TokenUsage::new(0, 0),
}],
)
}
}
(RunState::CallingModel, RunInput::RuntimeErrorReceived { error }) => {
(RunState::Aborted, vec![RunAction::AbortRun { error }])
}
(RunState::CallingModel, RunInput::CancellationRequested) => (
RunState::Aborted,
vec![RunAction::AbortRun {
error: CANCELLED_MSG.to_string(),
}],
),
(RunState::StreamingModel, RunInput::ModelChunkReceived { .. }) => {
(RunState::StreamingModel, vec![RunAction::Noop])
}
(RunState::StreamingModel, RunInput::ModelCompleted { message, usage }) => {
if !message.tool_calls().is_empty() {
let calls = message.tool_calls().to_vec();
(
RunState::ExecutingTools,
vec![RunAction::ExecuteTool { calls }],
)
} else {
(
RunState::Finished,
vec![RunAction::FinishRun {
reason: FinishReason::Stop,
usage,
}],
)
}
}
(RunState::StreamingModel, RunInput::RuntimeErrorReceived { error }) => {
(RunState::Aborted, vec![RunAction::AbortRun { error }])
}
(RunState::StreamingModel, RunInput::CancellationRequested) => (
RunState::Aborted,
vec![RunAction::AbortRun {
error: CANCELLED_MSG.to_string(),
}],
),
(RunState::ProcessingResponse, RunInput::ToolCallRequested { calls }) => (
RunState::ExecutingTools,
vec![RunAction::ExecuteTool { calls }],
),
(RunState::ExecutingTools, RunInput::ToolResultReceived { .. }) => {
(RunState::ExecutingTools, vec![RunAction::Noop])
}
(RunState::ExecutingTools, RunInput::RuntimeErrorReceived { .. }) => {
(RunState::BuildingContext, vec![RunAction::Noop])
}
(RunState::WaitingApproval, RunInput::ToolApprovalGranted { call_id }) => (
RunState::ExecutingTools,
vec![RunAction::ExecuteTool {
calls: vec![ToolCall::new(call_id, String::new(), Value::Null)],
}],
),
(RunState::WaitingApproval, RunInput::ToolApprovalRejected { call_id: _, reason }) => {
let _error_msg = reason.unwrap_or_else(|| "tool execution was rejected".to_string());
(RunState::BuildingContext, vec![RunAction::Noop])
}
(RunState::Compacting, RunInput::MemoryCompactionCompleted { .. }) => {
(RunState::BuildingContext, vec![RunAction::Noop])
}
(RunState::Finished, _) | (RunState::Aborted, _) => {
(state.clone(), vec![])
}
_ => (state.clone(), vec![RunAction::Noop]),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::ContentPart;
fn make_config() -> RunConfig {
RunConfig::default()
}
#[test]
fn idle_to_building_context_on_user_message() {
let (next, actions) = transition(
&RunState::Idle,
RunInput::UserMessageReceived {
content: vec![ContentPart::text("hello")],
},
&make_config(),
);
assert_eq!(next, RunState::BuildingContext);
assert!(matches!(actions.as_slice(), [RunAction::Noop]));
}
#[test]
fn idle_cancellation_aborts() {
let (next, actions) = transition(
&RunState::Idle,
RunInput::CancellationRequested,
&make_config(),
);
assert_eq!(next, RunState::Aborted);
assert!(matches!(actions.as_slice(), [RunAction::AbortRun { .. }]));
}
#[test]
fn calling_model_with_tool_calls_transitions_to_executing() {
let msg = Message::Assistant {
content: vec![],
tool_calls: vec![ToolCall::new("call_1", "search", Value::Null)],
};
let (next, actions) = transition(
&RunState::CallingModel,
RunInput::ModelCompleted {
message: msg,
usage: TokenUsage::new(100, 50),
},
&make_config(),
);
assert_eq!(next, RunState::ExecutingTools);
assert!(matches!(
actions.as_slice(),
[RunAction::ExecuteTool { .. }]
));
}
#[test]
fn calling_model_without_tools_finishes() {
let msg = Message::assistant_text("done");
let (next, actions) = transition(
&RunState::CallingModel,
RunInput::ModelCompleted {
message: msg,
usage: TokenUsage::new(100, 50),
},
&make_config(),
);
assert_eq!(next, RunState::Finished);
assert!(matches!(actions.as_slice(), [RunAction::FinishRun { .. }]));
}
#[test]
fn terminal_states_no_transition() {
for state in &[RunState::Finished, RunState::Aborted] {
let (next, actions) = transition(
state,
RunInput::UserMessageReceived {
content: vec![ContentPart::text("hello")],
},
&make_config(),
);
assert_eq!(next, *state);
assert!(actions.is_empty());
}
}
#[test]
fn approval_granted_executes_tool() {
let (next, actions) = transition(
&RunState::WaitingApproval,
RunInput::ToolApprovalGranted {
call_id: "call_1".to_string(),
},
&make_config(),
);
assert_eq!(next, RunState::ExecutingTools);
assert!(matches!(
actions.as_slice(),
[RunAction::ExecuteTool { .. }]
));
}
#[test]
fn approval_rejected_goes_to_building_context() {
let (next, _actions) = transition(
&RunState::WaitingApproval,
RunInput::ToolApprovalRejected {
call_id: "call_1".to_string(),
reason: Some("too dangerous".to_string()),
},
&make_config(),
);
assert_eq!(next, RunState::BuildingContext);
}
#[test]
fn deterministic_same_input_same_output() {
let state = RunState::Idle;
let config = make_config();
let input = RunInput::UserMessageReceived {
content: vec![ContentPart::text("hello")],
};
let (next1, actions1) = transition(&state, input.clone(), &config);
let (next2, actions2) = transition(&state, input, &config);
assert_eq!(next1, next2);
assert_eq!(format!("{actions1:?}"), format!("{actions2:?}"));
}
#[test]
fn streaming_chunks_keep_streaming_state() {
let (next, actions) = transition(
&RunState::StreamingModel,
RunInput::ModelChunkReceived {
delta: "hello".to_string(),
},
&make_config(),
);
assert_eq!(next, RunState::StreamingModel);
assert!(matches!(actions.as_slice(), [RunAction::Noop]));
}
}