Skip to main content

platonic_core/
run.rs

1//! Pure run state machine.
2
3use crate::{
4    ContextPack, Error, HarnessEvent, PolicyDecision, RecordedEvent, RunId, ToolCall, ToolCallId,
5    ToolProposal, TurnId,
6};
7use std::collections::BTreeSet;
8
9/// Host command requested by the run state machine.
10#[derive(Clone, Debug, PartialEq)]
11pub enum RunCommand {
12    /// Ask the host to make a model request.
13    RequestModel {
14        /// Turn whose bounded context is ready.
15        turn_id: TurnId,
16        /// Monotonic model step to record with the request and response.
17        step: u32,
18        /// Validated context to submit to the model provider.
19        context: ContextPack,
20    },
21    /// Ask the host to obtain approval for a tool call.
22    AwaitApproval {
23        /// Pending call requiring an approval decision.
24        call_id: ToolCallId,
25        /// Policy explanation to present to the approver.
26        reason: String,
27    },
28    /// Ask the host to execute a validated tool call.
29    ExecuteTool {
30        /// Approved or policy-allowed call ready for execution.
31        call: ToolCall,
32    },
33}
34
35/// Current phase of one run.
36#[derive(Clone, Debug, PartialEq)]
37pub enum RunPhase {
38    /// No `run_started` event has been applied.
39    NotStarted,
40    /// The run is waiting for context to be built for the next model turn.
41    ReadyForContext,
42    /// Context is built and the next command is a model request.
43    ReadyToRequestModel {
44        /// Turn whose context is ready.
45        turn_id: TurnId,
46        /// Monotonic model step expected in the request and response events.
47        step: u32,
48        /// Budget-validated context to send to the model.
49        context: ContextPack,
50    },
51    /// A model request was recorded; the run is waiting for the response.
52    AwaitingModelResponse {
53        /// Turn awaiting a model response.
54        turn_id: TurnId,
55        /// Model step the response must match.
56        step: u32,
57        /// Budget-validated context retained if the request fails.
58        context: ContextPack,
59    },
60    /// The model response contained at least one tool proposal.
61    AwaitingToolCall {
62        /// Turn that produced the proposals.
63        turn_id: TurnId,
64        /// Model-authored proposals awaiting host validation and classification.
65        proposals: Vec<ToolProposal>,
66    },
67    /// A validated tool call is waiting for policy evaluation.
68    AwaitingPolicy {
69        /// Validated and effect-classified call to evaluate.
70        call: ToolCall,
71    },
72    /// Policy requires approval before tool execution.
73    AwaitingApproval {
74        /// Call withheld until approval is recorded.
75        call: ToolCall,
76        /// Policy explanation for requiring approval.
77        reason: String,
78    },
79    /// Policy denied the tool call; the turn is concluded and may continue or fail.
80    PolicyDenied {
81        /// Call rejected by policy.
82        call_id: ToolCallId,
83        /// Recorded policy explanation.
84        reason: String,
85    },
86    /// A human or host actor denied the pending approval; the turn is concluded and may continue or fail.
87    ApprovalDenied {
88        /// Call denied before execution.
89        call_id: ToolCallId,
90        /// Recorded approval denial explanation.
91        reason: String,
92    },
93    /// The tool call may be executed.
94    ReadyToExecuteTool {
95        /// Policy-allowed or approved call ready for host execution.
96        call: ToolCall,
97    },
98    /// Tool execution has started.
99    ToolRunning {
100        /// Call that crossed the host execution boundary.
101        call_id: ToolCallId,
102    },
103    /// A tool failed; the turn is concluded and may continue or fail.
104    ToolFailed {
105        /// Call whose execution failed.
106        call_id: ToolCallId,
107        /// Recorded host failure explanation.
108        reason: String,
109    },
110    /// The current turn completed successfully; the host may finish or continue.
111    TurnConcluded,
112    /// The run finished successfully.
113    Finished,
114    /// The run finished unsuccessfully.
115    Failed {
116        /// Durable terminal failure explanation.
117        reason: String,
118    },
119}
120
121/// Durable state derived by replaying one run's ordered event ledger.
122///
123/// The state machine performs no IO. Hosts inspect [`Self::pending_command`] for
124/// requested effects and [`Self::pending_compaction_turn`] for the narrow
125/// post-compaction context gate, then apply the resulting recorded event.
126/// A [`HarnessEvent::ModelFailed`] restores the identical pending model command
127/// without advancing its step.
128///
129/// # Examples
130///
131/// A tool turn advances only after the proposed call is validated, approved,
132/// executed, and recorded:
133///
134/// ```
135/// use platonic_core::*;
136/// use serde_json::json;
137///
138/// # fn main() -> Result<(), Error> {
139/// let run_id = RunId::new("run-1")?;
140/// let turn_id = TurnId::new("turn-1")?;
141/// let call_id = ToolCallId::new("call-1")?;
142/// let tool = ToolName::new("file.write")?;
143/// let input = json!({"path": "note.txt", "content": "done"});
144/// let proposal = ToolProposal {
145///     tool: tool.clone(),
146///     input: input.clone(),
147/// };
148/// let call = ToolCall {
149///     id: call_id.clone(),
150///     tool,
151///     effect: EffectClass::WorkspaceWrite,
152///     input,
153/// };
154///
155/// let events = vec![
156///     HarnessEvent::RunStarted {
157///         run_id: run_id.clone(),
158///         agent_id: AgentId::new("agent-1")?,
159///     },
160///     HarnessEvent::ContextBuilt {
161///         run_id: run_id.clone(),
162///         turn_id: turn_id.clone(),
163///         context: ContextPack { token_budget: 10, fragments: vec![] },
164///     },
165///     HarnessEvent::ModelRequested {
166///         run_id: run_id.clone(),
167///         turn_id: turn_id.clone(),
168///         step: 0,
169///         model: ModelName::new("model-1")?,
170///     },
171///     HarnessEvent::ModelResponded {
172///         run_id: run_id.clone(),
173///         turn_id: turn_id.clone(),
174///         step: 0,
175///         output: Message {
176///             role: MessageRole::Assistant,
177///             content: "I will write the file.".into(),
178///         },
179///         proposed_calls: vec![proposal],
180///         served_model: None,
181///         usage: Some(ModelUsage { input_tokens: 3, output_tokens: 5 }),
182///     },
183///     HarnessEvent::ToolCallProposed {
184///         run_id: run_id.clone(),
185///         turn_id,
186///         call: call.clone(),
187///     },
188///     HarnessEvent::PolicyEvaluated {
189///         run_id: run_id.clone(),
190///         call_id: call_id.clone(),
191///         decision: PolicyDecision::RequireApproval {
192///             reason: "workspace write".into(),
193///         },
194///     },
195///     HarnessEvent::ApprovalGranted {
196///         run_id: run_id.clone(),
197///         call_id: call_id.clone(),
198///         actor_id: ActorId::new("human-1")?,
199///     },
200///     HarnessEvent::ToolStarted {
201///         run_id: run_id.clone(),
202///         call_id: call_id.clone(),
203///     },
204///     HarnessEvent::ToolFinished {
205///         run_id: run_id.clone(),
206///         result: ToolResult {
207///             call_id,
208///             summary: "wrote note.txt".into(),
209///             data: json!({}),
210///             artifacts: vec![],
211///             visibility: ResultVisibility::Both,
212///         },
213///     },
214///     HarnessEvent::RunFinished { run_id },
215/// ];
216///
217/// let mut state = RunState::new();
218/// for (seq, event) in events.into_iter().enumerate() {
219///     state.apply(&RecordedEvent {
220///         seq: seq as u64,
221///         occurred_at_ms: 0,
222///         event,
223///     })?;
224///     if seq == 5 {
225///         assert!(matches!(state.pending_command(), Some(RunCommand::AwaitApproval { .. })));
226///     }
227///     if seq == 6 {
228///         assert!(matches!(state.pending_command(), Some(RunCommand::ExecuteTool { .. })));
229///     }
230/// }
231/// assert_eq!(state.phase(), &RunPhase::Finished);
232/// # Ok(())
233/// # }
234/// ```
235#[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    /// Creates an unbound state expecting sequence zero and `run_started`.
254    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    /// Returns the bound run id, or `None` before `run_started` is applied.
267    pub fn run_id(&self) -> Option<&RunId> {
268        self.run_id.as_ref()
269    }
270
271    /// Returns the next contiguous per-run sequence number.
272    pub fn next_seq(&self) -> u64 {
273        self.next_seq
274    }
275
276    /// Returns the phase derived from all successfully applied events.
277    pub fn phase(&self) -> &RunPhase {
278        &self.phase
279    }
280
281    /// Returns the turn whose compacted context must be built next.
282    ///
283    /// After an accepted [`HarnessEvent::ContextCompacted`], [`Self::phase`]
284    /// intentionally remains the surrounding stable phase and
285    /// [`Self::pending_command`] returns `None`. While this returns `Some`, only
286    /// a matching [`HarnessEvent::ContextBuilt`] or terminal
287    /// [`HarnessEvent::RunFailed`] can be accepted. Either accepted event clears
288    /// the pending turn.
289    pub fn pending_compaction_turn(&self) -> Option<&TurnId> {
290        self.pending_compaction_turn_id.as_ref()
291    }
292
293    /// Derives the pending host IO command without mutating run state.
294    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    /// Validates and applies one event, advancing the sequence only on success.
317    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;