use crate::{
ContextPack, Error, HarnessEvent, PolicyDecision, RecordedEvent, RunId, ToolCall, ToolCallId,
ToolProposal, TurnId,
};
use std::collections::BTreeSet;
#[derive(Clone, Debug, PartialEq)]
pub enum RunCommand {
RequestModel {
turn_id: TurnId,
step: u32,
context: ContextPack,
},
AwaitApproval {
call_id: ToolCallId,
reason: String,
},
ExecuteTool {
call: ToolCall,
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum RunPhase {
NotStarted,
ReadyForContext,
ReadyToRequestModel {
turn_id: TurnId,
step: u32,
context: ContextPack,
},
AwaitingModelResponse {
turn_id: TurnId,
step: u32,
context: ContextPack,
},
AwaitingToolCall {
turn_id: TurnId,
proposals: Vec<ToolProposal>,
},
AwaitingPolicy {
call: ToolCall,
},
AwaitingApproval {
call: ToolCall,
reason: String,
},
PolicyDenied {
call_id: ToolCallId,
reason: String,
},
ApprovalDenied {
call_id: ToolCallId,
reason: String,
},
ReadyToExecuteTool {
call: ToolCall,
},
ToolRunning {
call_id: ToolCallId,
},
ToolFailed {
call_id: ToolCallId,
reason: String,
},
TurnConcluded,
Finished,
Failed {
reason: String,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct RunState {
run_id: Option<RunId>,
next_seq: u64,
next_model_step: u32,
used_turn_ids: BTreeSet<TurnId>,
used_tool_call_ids: BTreeSet<ToolCallId>,
pending_compaction_turn_id: Option<TurnId>,
phase: RunPhase,
}
impl Default for RunState {
fn default() -> Self {
Self::new()
}
}
impl RunState {
pub fn new() -> Self {
Self {
run_id: None,
next_seq: 0,
next_model_step: 0,
used_turn_ids: BTreeSet::new(),
used_tool_call_ids: BTreeSet::new(),
pending_compaction_turn_id: None,
phase: RunPhase::NotStarted,
}
}
pub fn run_id(&self) -> Option<&RunId> {
self.run_id.as_ref()
}
pub fn next_seq(&self) -> u64 {
self.next_seq
}
pub fn phase(&self) -> &RunPhase {
&self.phase
}
pub fn pending_compaction_turn(&self) -> Option<&TurnId> {
self.pending_compaction_turn_id.as_ref()
}
pub fn pending_command(&self) -> Option<RunCommand> {
match &self.phase {
RunPhase::ReadyToRequestModel {
turn_id,
step,
context,
} => Some(RunCommand::RequestModel {
turn_id: turn_id.clone(),
step: *step,
context: context.clone(),
}),
RunPhase::AwaitingApproval { call, reason } => Some(RunCommand::AwaitApproval {
call_id: call.id.clone(),
reason: reason.clone(),
}),
RunPhase::ReadyToExecuteTool { call } => {
Some(RunCommand::ExecuteTool { call: call.clone() })
}
_ => None,
}
}
pub fn apply(&mut self, record: &RecordedEvent) -> Result<(), Error> {
if record.seq != self.next_seq {
return Err(Error::SequenceMismatch {
expected: self.next_seq,
actual: record.seq,
});
}
if let Some(expected) = &self.run_id {
let actual = record.event.run_id();
if actual != expected {
return Err(Error::RunIdMismatch {
expected: expected.to_string(),
actual: actual.to_string(),
});
}
}
self.apply_event(&record.event)?;
self.next_seq += 1;
Ok(())
}
fn apply_event(&mut self, event: &HarnessEvent) -> Result<(), Error> {
if let Some(expected_turn_id) = &self.pending_compaction_turn_id {
match event {
HarnessEvent::ContextBuilt { turn_id, .. } => {
ensure_turn(expected_turn_id, turn_id)?;
}
HarnessEvent::RunFailed { .. } => {}
_ => return Err(invalid(&self.phase, event)),
}
}
match (&self.phase, event) {
(RunPhase::NotStarted, HarnessEvent::RunStarted { run_id, .. }) => {
self.run_id = Some(run_id.clone());
self.phase = RunPhase::ReadyForContext;
Ok(())
}
(
RunPhase::ReadyForContext
| RunPhase::TurnConcluded
| RunPhase::PolicyDenied { .. }
| RunPhase::ApprovalDenied { .. }
| RunPhase::ToolFailed { .. },
HarnessEvent::ContextBuilt {
turn_id, context, ..
},
) => {
self.start_turn(turn_id, context)?;
self.pending_compaction_turn_id = None;
Ok(())
}
(
RunPhase::ReadyForContext
| RunPhase::TurnConcluded
| RunPhase::PolicyDenied { .. }
| RunPhase::ApprovalDenied { .. }
| RunPhase::ToolFailed { .. },
HarnessEvent::ContextCompacted {
turn_id,
dropped_turn_start,
dropped_turn_end_exclusive,
..
},
) => {
ensure_compaction_range(*dropped_turn_start, *dropped_turn_end_exclusive)?;
ensure_new_turn(&self.used_turn_ids, turn_id)?;
self.pending_compaction_turn_id = Some(turn_id.clone());
Ok(())
}
(
RunPhase::ReadyToRequestModel {
turn_id,
step,
context,
},
HarnessEvent::ModelRequested {
turn_id: actual_turn_id,
step: actual_step,
..
},
) => {
ensure_turn(turn_id, actual_turn_id)?;
ensure_step(*step, *actual_step)?;
self.phase = RunPhase::AwaitingModelResponse {
turn_id: turn_id.clone(),
step: *step,
context: context.clone(),
};
Ok(())
}
(
RunPhase::AwaitingModelResponse {
turn_id,
step,
context,
},
HarnessEvent::ModelFailed {
turn_id: actual_turn_id,
step: actual_step,
..
},
) => {
ensure_turn(turn_id, actual_turn_id)?;
ensure_step(*step, *actual_step)?;
self.phase = RunPhase::ReadyToRequestModel {
turn_id: turn_id.clone(),
step: *step,
context: context.clone(),
};
Ok(())
}
(
RunPhase::AwaitingModelResponse { turn_id, step, .. },
HarnessEvent::ModelResponded {
turn_id: actual_turn_id,
step: actual_step,
proposed_calls,
..
},
) => {
ensure_turn(turn_id, actual_turn_id)?;
ensure_step(*step, *actual_step)?;
self.next_model_step += 1;
self.phase = if proposed_calls.is_empty() {
RunPhase::TurnConcluded
} else {
RunPhase::AwaitingToolCall {
turn_id: turn_id.clone(),
proposals: proposed_calls.clone(),
}
};
Ok(())
}
(
RunPhase::AwaitingToolCall { turn_id, proposals },
HarnessEvent::ToolCallProposed {
turn_id: actual_turn_id,
call,
..
},
) => {
ensure_turn(turn_id, actual_turn_id)?;
ensure_proposed(proposals, call)?;
ensure_new_tool_call(&self.used_tool_call_ids, &call.id)?;
self.used_tool_call_ids.insert(call.id.clone());
self.phase = RunPhase::AwaitingPolicy { call: call.clone() };
Ok(())
}
(
RunPhase::AwaitingToolCall { turn_id, .. },
HarnessEvent::ToolProposalsRejected {
turn_id: actual_turn_id,
reason,
..
},
) => {
ensure_turn(turn_id, actual_turn_id)?;
ensure_tool_proposals_rejection_reason(reason)?;
self.phase = RunPhase::TurnConcluded;
Ok(())
}
(
RunPhase::AwaitingPolicy { call },
HarnessEvent::PolicyEvaluated {
call_id, decision, ..
},
) => {
ensure_call(&call.id, call_id)?;
match decision {
PolicyDecision::Allow => {
self.phase = RunPhase::ReadyToExecuteTool { call: call.clone() };
}
PolicyDecision::RequireApproval { reason } => {
self.phase = RunPhase::AwaitingApproval {
call: call.clone(),
reason: reason.clone(),
};
}
PolicyDecision::Deny { reason } => {
self.phase = RunPhase::PolicyDenied {
call_id: call.id.clone(),
reason: reason.clone(),
};
}
}
Ok(())
}
(
RunPhase::AwaitingApproval { call, .. },
HarnessEvent::ApprovalGranted { call_id, .. },
) => {
ensure_call(&call.id, call_id)?;
self.phase = RunPhase::ReadyToExecuteTool { call: call.clone() };
Ok(())
}
(
RunPhase::AwaitingApproval { call, .. },
HarnessEvent::ApprovalDenied {
call_id, reason, ..
},
) => {
ensure_call(&call.id, call_id)?;
self.phase = RunPhase::ApprovalDenied {
call_id: call.id.clone(),
reason: reason.clone(),
};
Ok(())
}
(RunPhase::ReadyToExecuteTool { call }, HarnessEvent::ToolStarted { call_id, .. }) => {
ensure_call(&call.id, call_id)?;
self.phase = RunPhase::ToolRunning {
call_id: call.id.clone(),
};
Ok(())
}
(RunPhase::ToolRunning { call_id }, HarnessEvent::ToolFinished { result, .. }) => {
ensure_call(call_id, &result.call_id)?;
self.phase = RunPhase::TurnConcluded;
Ok(())
}
(
RunPhase::ToolRunning { call_id },
HarnessEvent::ToolFailed {
call_id: actual_call_id,
reason,
..
},
) => {
ensure_call(call_id, actual_call_id)?;
self.phase = RunPhase::ToolFailed {
call_id: call_id.clone(),
reason: reason.clone(),
};
Ok(())
}
(RunPhase::TurnConcluded, HarnessEvent::RunFinished { .. }) => {
self.phase = RunPhase::Finished;
Ok(())
}
(phase, HarnessEvent::RunFailed { reason, .. }) if phase.can_fail() => {
self.pending_compaction_turn_id = None;
self.phase = RunPhase::Failed {
reason: reason.clone(),
};
Ok(())
}
(RunPhase::Finished | RunPhase::Failed { .. }, _) => Err(invalid(&self.phase, event)),
_ => Err(invalid(&self.phase, event)),
}
}
fn start_turn(&mut self, turn_id: &TurnId, context: &ContextPack) -> Result<(), Error> {
ensure_new_turn(&self.used_turn_ids, turn_id)?;
context.validate_budget()?;
self.used_turn_ids.insert(turn_id.clone());
self.phase = RunPhase::ReadyToRequestModel {
turn_id: turn_id.clone(),
step: self.next_model_step,
context: context.clone(),
};
Ok(())
}
}
impl RunPhase {
fn name(&self) -> &'static str {
match self {
Self::NotStarted => "not_started",
Self::ReadyForContext => "ready_for_context",
Self::ReadyToRequestModel { .. } => "ready_to_request_model",
Self::AwaitingModelResponse { .. } => "awaiting_model_response",
Self::AwaitingToolCall { .. } => "awaiting_tool_call",
Self::AwaitingPolicy { .. } => "awaiting_policy",
Self::AwaitingApproval { .. } => "awaiting_approval",
Self::PolicyDenied { .. } => "policy_denied",
Self::ApprovalDenied { .. } => "approval_denied",
Self::ReadyToExecuteTool { .. } => "ready_to_execute_tool",
Self::ToolRunning { .. } => "tool_running",
Self::ToolFailed { .. } => "tool_failed",
Self::TurnConcluded => "turn_concluded",
Self::Finished => "finished",
Self::Failed { .. } => "failed",
}
}
fn can_fail(&self) -> bool {
!matches!(
self,
Self::NotStarted | Self::Finished | Self::Failed { .. }
)
}
}
fn ensure_turn(expected: &TurnId, actual: &TurnId) -> Result<(), Error> {
if expected == actual {
return Ok(());
}
Err(Error::TurnMismatch {
expected: expected.to_string(),
actual: actual.to_string(),
})
}
fn ensure_new_turn(used_turn_ids: &BTreeSet<TurnId>, actual: &TurnId) -> Result<(), Error> {
if used_turn_ids.contains(actual) {
return Err(Error::TurnReused {
turn_id: actual.to_string(),
});
}
Ok(())
}
fn ensure_tool_proposals_rejection_reason(reason: &str) -> Result<(), Error> {
if reason.trim().is_empty() {
return Err(Error::EmptyToolProposalsRejectionReason);
}
Ok(())
}
fn ensure_compaction_range(start: u64, end_exclusive: u64) -> Result<(), Error> {
if start < end_exclusive {
return Ok(());
}
Err(Error::InvalidCompactionRange {
start,
end_exclusive,
})
}
fn ensure_step(expected: u32, actual: u32) -> Result<(), Error> {
if expected == actual {
return Ok(());
}
Err(Error::StepMismatch { expected, actual })
}
fn ensure_call(expected: &ToolCallId, actual: &ToolCallId) -> Result<(), Error> {
if expected == actual {
return Ok(());
}
Err(Error::ToolCallMismatch {
expected: expected.to_string(),
actual: actual.to_string(),
})
}
fn ensure_new_tool_call(
used_tool_call_ids: &BTreeSet<ToolCallId>,
actual: &ToolCallId,
) -> Result<(), Error> {
if used_tool_call_ids.contains(actual) {
return Err(Error::ToolCallReused {
call_id: actual.to_string(),
});
}
Ok(())
}
fn ensure_proposed(proposals: &[ToolProposal], call: &ToolCall) -> Result<(), Error> {
if proposals
.iter()
.any(|proposal| proposal.tool == call.tool && proposal.input == call.input)
{
return Ok(());
}
Err(Error::UnproposedToolCall)
}
fn invalid(phase: &RunPhase, event: &HarnessEvent) -> Error {
Error::InvalidTransition {
phase: phase.name(),
event: event.name(),
}
}
#[cfg(test)]
mod tests;