1use crate::{
4 ContextPack, Error, HarnessEvent, PolicyDecision, RecordedEvent, RunId, ToolCall, ToolCallId,
5 ToolProposal, TurnId,
6};
7use std::collections::BTreeSet;
8
9#[derive(Clone, Debug, PartialEq)]
11pub enum RunCommand {
12 RequestModel {
14 turn_id: TurnId,
16 step: u32,
18 context: ContextPack,
20 },
21 AwaitApproval {
23 call_id: ToolCallId,
25 reason: String,
27 },
28 ExecuteTool {
30 call: ToolCall,
32 },
33}
34
35#[derive(Clone, Debug, PartialEq)]
37pub enum RunPhase {
38 NotStarted,
40 ReadyForContext,
42 ReadyToRequestModel {
44 turn_id: TurnId,
46 step: u32,
48 context: ContextPack,
50 },
51 AwaitingModelResponse {
53 turn_id: TurnId,
55 step: u32,
57 context: ContextPack,
59 },
60 AwaitingToolCall {
62 turn_id: TurnId,
64 proposals: Vec<ToolProposal>,
66 },
67 AwaitingPolicy {
69 call: ToolCall,
71 },
72 AwaitingApproval {
74 call: ToolCall,
76 reason: String,
78 },
79 PolicyDenied {
81 call_id: ToolCallId,
83 reason: String,
85 },
86 ApprovalDenied {
88 call_id: ToolCallId,
90 reason: String,
92 },
93 ReadyToExecuteTool {
95 call: ToolCall,
97 },
98 ToolRunning {
100 call_id: ToolCallId,
102 },
103 ToolFailed {
105 call_id: ToolCallId,
107 reason: String,
109 },
110 TurnConcluded,
112 Finished,
114 Failed {
116 reason: String,
118 },
119}
120
121#[derive(Clone, Debug, PartialEq)]
236pub struct RunState {
237 run_id: Option<RunId>,
238 next_seq: u64,
239 next_model_step: u32,
240 used_turn_ids: BTreeSet<TurnId>,
241 used_tool_call_ids: BTreeSet<ToolCallId>,
242 pending_compaction_turn_id: Option<TurnId>,
243 phase: RunPhase,
244}
245
246impl Default for RunState {
247 fn default() -> Self {
248 Self::new()
249 }
250}
251
252impl RunState {
253 pub fn new() -> Self {
255 Self {
256 run_id: None,
257 next_seq: 0,
258 next_model_step: 0,
259 used_turn_ids: BTreeSet::new(),
260 used_tool_call_ids: BTreeSet::new(),
261 pending_compaction_turn_id: None,
262 phase: RunPhase::NotStarted,
263 }
264 }
265
266 pub fn run_id(&self) -> Option<&RunId> {
268 self.run_id.as_ref()
269 }
270
271 pub fn next_seq(&self) -> u64 {
273 self.next_seq
274 }
275
276 pub fn phase(&self) -> &RunPhase {
278 &self.phase
279 }
280
281 pub fn pending_compaction_turn(&self) -> Option<&TurnId> {
290 self.pending_compaction_turn_id.as_ref()
291 }
292
293 pub fn pending_command(&self) -> Option<RunCommand> {
295 match &self.phase {
296 RunPhase::ReadyToRequestModel {
297 turn_id,
298 step,
299 context,
300 } => Some(RunCommand::RequestModel {
301 turn_id: turn_id.clone(),
302 step: *step,
303 context: context.clone(),
304 }),
305 RunPhase::AwaitingApproval { call, reason } => Some(RunCommand::AwaitApproval {
306 call_id: call.id.clone(),
307 reason: reason.clone(),
308 }),
309 RunPhase::ReadyToExecuteTool { call } => {
310 Some(RunCommand::ExecuteTool { call: call.clone() })
311 }
312 _ => None,
313 }
314 }
315
316 pub fn apply(&mut self, record: &RecordedEvent) -> Result<(), Error> {
318 if record.seq != self.next_seq {
319 return Err(Error::SequenceMismatch {
320 expected: self.next_seq,
321 actual: record.seq,
322 });
323 }
324
325 if let Some(expected) = &self.run_id {
326 let actual = record.event.run_id();
327 if actual != expected {
328 return Err(Error::RunIdMismatch {
329 expected: expected.to_string(),
330 actual: actual.to_string(),
331 });
332 }
333 }
334
335 self.apply_event(&record.event)?;
336 self.next_seq += 1;
337 Ok(())
338 }
339
340 fn apply_event(&mut self, event: &HarnessEvent) -> Result<(), Error> {
341 if let Some(expected_turn_id) = &self.pending_compaction_turn_id {
342 match event {
343 HarnessEvent::ContextBuilt { turn_id, .. } => {
344 ensure_turn(expected_turn_id, turn_id)?;
345 }
346 HarnessEvent::RunFailed { .. } => {}
347 _ => return Err(invalid(&self.phase, event)),
348 }
349 }
350
351 match (&self.phase, event) {
352 (RunPhase::NotStarted, HarnessEvent::RunStarted { run_id, .. }) => {
353 self.run_id = Some(run_id.clone());
354 self.phase = RunPhase::ReadyForContext;
355 Ok(())
356 }
357 (
358 RunPhase::ReadyForContext
359 | RunPhase::TurnConcluded
360 | RunPhase::PolicyDenied { .. }
361 | RunPhase::ApprovalDenied { .. }
362 | RunPhase::ToolFailed { .. },
363 HarnessEvent::ContextBuilt {
364 turn_id, context, ..
365 },
366 ) => {
367 self.start_turn(turn_id, context)?;
368 self.pending_compaction_turn_id = None;
369 Ok(())
370 }
371 (
372 RunPhase::ReadyForContext
373 | RunPhase::TurnConcluded
374 | RunPhase::PolicyDenied { .. }
375 | RunPhase::ApprovalDenied { .. }
376 | RunPhase::ToolFailed { .. },
377 HarnessEvent::ContextCompacted {
378 turn_id,
379 dropped_turn_start,
380 dropped_turn_end_exclusive,
381 ..
382 },
383 ) => {
384 ensure_compaction_range(*dropped_turn_start, *dropped_turn_end_exclusive)?;
385 ensure_new_turn(&self.used_turn_ids, turn_id)?;
386 self.pending_compaction_turn_id = Some(turn_id.clone());
387 Ok(())
388 }
389 (
390 RunPhase::ReadyToRequestModel {
391 turn_id,
392 step,
393 context,
394 },
395 HarnessEvent::ModelRequested {
396 turn_id: actual_turn_id,
397 step: actual_step,
398 ..
399 },
400 ) => {
401 ensure_turn(turn_id, actual_turn_id)?;
402 ensure_step(*step, *actual_step)?;
403 self.phase = RunPhase::AwaitingModelResponse {
404 turn_id: turn_id.clone(),
405 step: *step,
406 context: context.clone(),
407 };
408 Ok(())
409 }
410 (
411 RunPhase::AwaitingModelResponse {
412 turn_id,
413 step,
414 context,
415 },
416 HarnessEvent::ModelFailed {
417 turn_id: actual_turn_id,
418 step: actual_step,
419 ..
420 },
421 ) => {
422 ensure_turn(turn_id, actual_turn_id)?;
423 ensure_step(*step, *actual_step)?;
424 self.phase = RunPhase::ReadyToRequestModel {
425 turn_id: turn_id.clone(),
426 step: *step,
427 context: context.clone(),
428 };
429 Ok(())
430 }
431 (
432 RunPhase::AwaitingModelResponse { turn_id, step, .. },
433 HarnessEvent::ModelResponded {
434 turn_id: actual_turn_id,
435 step: actual_step,
436 proposed_calls,
437 ..
438 },
439 ) => {
440 ensure_turn(turn_id, actual_turn_id)?;
441 ensure_step(*step, *actual_step)?;
442 self.next_model_step += 1;
443 self.phase = if proposed_calls.is_empty() {
444 RunPhase::TurnConcluded
445 } else {
446 RunPhase::AwaitingToolCall {
447 turn_id: turn_id.clone(),
448 proposals: proposed_calls.clone(),
449 }
450 };
451 Ok(())
452 }
453 (
454 RunPhase::AwaitingToolCall { turn_id, proposals },
455 HarnessEvent::ToolCallProposed {
456 turn_id: actual_turn_id,
457 call,
458 ..
459 },
460 ) => {
461 ensure_turn(turn_id, actual_turn_id)?;
462 ensure_proposed(proposals, call)?;
463 ensure_new_tool_call(&self.used_tool_call_ids, &call.id)?;
464 self.used_tool_call_ids.insert(call.id.clone());
465 self.phase = RunPhase::AwaitingPolicy { call: call.clone() };
466 Ok(())
467 }
468 (
469 RunPhase::AwaitingToolCall { turn_id, .. },
470 HarnessEvent::ToolProposalsRejected {
471 turn_id: actual_turn_id,
472 reason,
473 ..
474 },
475 ) => {
476 ensure_turn(turn_id, actual_turn_id)?;
477 ensure_tool_proposals_rejection_reason(reason)?;
478 self.phase = RunPhase::TurnConcluded;
479 Ok(())
480 }
481 (
482 RunPhase::AwaitingPolicy { call },
483 HarnessEvent::PolicyEvaluated {
484 call_id, decision, ..
485 },
486 ) => {
487 ensure_call(&call.id, call_id)?;
488 match decision {
489 PolicyDecision::Allow => {
490 self.phase = RunPhase::ReadyToExecuteTool { call: call.clone() };
491 }
492 PolicyDecision::RequireApproval { reason } => {
493 self.phase = RunPhase::AwaitingApproval {
494 call: call.clone(),
495 reason: reason.clone(),
496 };
497 }
498 PolicyDecision::Deny { reason } => {
499 self.phase = RunPhase::PolicyDenied {
500 call_id: call.id.clone(),
501 reason: reason.clone(),
502 };
503 }
504 }
505 Ok(())
506 }
507 (
508 RunPhase::AwaitingApproval { call, .. },
509 HarnessEvent::ApprovalGranted { call_id, .. },
510 ) => {
511 ensure_call(&call.id, call_id)?;
512 self.phase = RunPhase::ReadyToExecuteTool { call: call.clone() };
513 Ok(())
514 }
515 (
516 RunPhase::AwaitingApproval { call, .. },
517 HarnessEvent::ApprovalDenied {
518 call_id, reason, ..
519 },
520 ) => {
521 ensure_call(&call.id, call_id)?;
522 self.phase = RunPhase::ApprovalDenied {
523 call_id: call.id.clone(),
524 reason: reason.clone(),
525 };
526 Ok(())
527 }
528 (RunPhase::ReadyToExecuteTool { call }, HarnessEvent::ToolStarted { call_id, .. }) => {
529 ensure_call(&call.id, call_id)?;
530 self.phase = RunPhase::ToolRunning {
531 call_id: call.id.clone(),
532 };
533 Ok(())
534 }
535 (RunPhase::ToolRunning { call_id }, HarnessEvent::ToolFinished { result, .. }) => {
536 ensure_call(call_id, &result.call_id)?;
537 self.phase = RunPhase::TurnConcluded;
538 Ok(())
539 }
540 (
541 RunPhase::ToolRunning { call_id },
542 HarnessEvent::ToolFailed {
543 call_id: actual_call_id,
544 reason,
545 ..
546 },
547 ) => {
548 ensure_call(call_id, actual_call_id)?;
549 self.phase = RunPhase::ToolFailed {
550 call_id: call_id.clone(),
551 reason: reason.clone(),
552 };
553 Ok(())
554 }
555 (RunPhase::TurnConcluded, HarnessEvent::RunFinished { .. }) => {
556 self.phase = RunPhase::Finished;
557 Ok(())
558 }
559 (phase, HarnessEvent::RunFailed { reason, .. }) if phase.can_fail() => {
560 self.pending_compaction_turn_id = None;
561 self.phase = RunPhase::Failed {
562 reason: reason.clone(),
563 };
564 Ok(())
565 }
566 (RunPhase::Finished | RunPhase::Failed { .. }, _) => Err(invalid(&self.phase, event)),
567 _ => Err(invalid(&self.phase, event)),
568 }
569 }
570
571 fn start_turn(&mut self, turn_id: &TurnId, context: &ContextPack) -> Result<(), Error> {
572 ensure_new_turn(&self.used_turn_ids, turn_id)?;
573 context.validate_budget()?;
574 self.used_turn_ids.insert(turn_id.clone());
575 self.phase = RunPhase::ReadyToRequestModel {
576 turn_id: turn_id.clone(),
577 step: self.next_model_step,
578 context: context.clone(),
579 };
580 Ok(())
581 }
582}
583
584impl RunPhase {
585 fn name(&self) -> &'static str {
586 match self {
587 Self::NotStarted => "not_started",
588 Self::ReadyForContext => "ready_for_context",
589 Self::ReadyToRequestModel { .. } => "ready_to_request_model",
590 Self::AwaitingModelResponse { .. } => "awaiting_model_response",
591 Self::AwaitingToolCall { .. } => "awaiting_tool_call",
592 Self::AwaitingPolicy { .. } => "awaiting_policy",
593 Self::AwaitingApproval { .. } => "awaiting_approval",
594 Self::PolicyDenied { .. } => "policy_denied",
595 Self::ApprovalDenied { .. } => "approval_denied",
596 Self::ReadyToExecuteTool { .. } => "ready_to_execute_tool",
597 Self::ToolRunning { .. } => "tool_running",
598 Self::ToolFailed { .. } => "tool_failed",
599 Self::TurnConcluded => "turn_concluded",
600 Self::Finished => "finished",
601 Self::Failed { .. } => "failed",
602 }
603 }
604
605 fn can_fail(&self) -> bool {
606 !matches!(
607 self,
608 Self::NotStarted | Self::Finished | Self::Failed { .. }
609 )
610 }
611}
612
613fn ensure_turn(expected: &TurnId, actual: &TurnId) -> Result<(), Error> {
614 if expected == actual {
615 return Ok(());
616 }
617 Err(Error::TurnMismatch {
618 expected: expected.to_string(),
619 actual: actual.to_string(),
620 })
621}
622
623fn ensure_new_turn(used_turn_ids: &BTreeSet<TurnId>, actual: &TurnId) -> Result<(), Error> {
624 if used_turn_ids.contains(actual) {
625 return Err(Error::TurnReused {
626 turn_id: actual.to_string(),
627 });
628 }
629 Ok(())
630}
631
632fn ensure_tool_proposals_rejection_reason(reason: &str) -> Result<(), Error> {
633 if reason.trim().is_empty() {
634 return Err(Error::EmptyToolProposalsRejectionReason);
635 }
636 Ok(())
637}
638
639fn ensure_compaction_range(start: u64, end_exclusive: u64) -> Result<(), Error> {
640 if start < end_exclusive {
641 return Ok(());
642 }
643 Err(Error::InvalidCompactionRange {
644 start,
645 end_exclusive,
646 })
647}
648
649fn ensure_step(expected: u32, actual: u32) -> Result<(), Error> {
650 if expected == actual {
651 return Ok(());
652 }
653 Err(Error::StepMismatch { expected, actual })
654}
655
656fn ensure_call(expected: &ToolCallId, actual: &ToolCallId) -> Result<(), Error> {
657 if expected == actual {
658 return Ok(());
659 }
660 Err(Error::ToolCallMismatch {
661 expected: expected.to_string(),
662 actual: actual.to_string(),
663 })
664}
665
666fn ensure_new_tool_call(
667 used_tool_call_ids: &BTreeSet<ToolCallId>,
668 actual: &ToolCallId,
669) -> Result<(), Error> {
670 if used_tool_call_ids.contains(actual) {
671 return Err(Error::ToolCallReused {
672 call_id: actual.to_string(),
673 });
674 }
675 Ok(())
676}
677
678fn ensure_proposed(proposals: &[ToolProposal], call: &ToolCall) -> Result<(), Error> {
679 if proposals
680 .iter()
681 .any(|proposal| proposal.tool == call.tool && proposal.input == call.input)
682 {
683 return Ok(());
684 }
685 Err(Error::UnproposedToolCall)
686}
687
688fn invalid(phase: &RunPhase, event: &HarnessEvent) -> Error {
689 Error::InvalidTransition {
690 phase: phase.name(),
691 event: event.name(),
692 }
693}
694
695#[cfg(test)]
696mod tests;