Skip to main content

agent_engine/tools/
ledger.rs

1//! Task 25 — typed tool-call ledger (spec §8.3).
2//!
3//! Each tool call advances through a MONOTONIC lifecycle:
4//!
5//! ```text
6//! planned -> authorized -> started -> committed -> result_recorded
7//! ```
8//!
9//! Transitions are single-step-forward only; any skip or backward move is
10//! rejected and never applied (the ledger state is therefore monotonic —
11//! invalid transitions cannot be represented in a committed ledger). When a
12//! turn is interrupted (cancellation or transport failure) with a call that
13//! has STARTED but not yet recorded a result — i.e. a side effect is
14//! possible but its commit status is unknown — a `NonIdempotent` call must
15//! surface [`TurnOutcome::InterruptedAfterSideEffect`] and must NEVER be
16//! automatically rerun. Read-only and idempotent calls stay retryable.
17
18use crate::tools::catalog::ToolEffect;
19use agent_core::TurnOutcome;
20
21/// One monotonic phase of a tool call's lifecycle (spec §8.3). The derived
22/// ordering matches the pipeline order, so `<`/`>` express "earlier/later
23/// phase" directly.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
25pub enum CallPhase {
26    Planned,
27    Authorized,
28    Started,
29    Committed,
30    ResultRecorded,
31}
32
33impl CallPhase {
34    /// The immediately following phase, or `None` at the terminal phase.
35    fn successor(self) -> Option<CallPhase> {
36        match self {
37            CallPhase::Planned => Some(CallPhase::Authorized),
38            CallPhase::Authorized => Some(CallPhase::Started),
39            CallPhase::Started => Some(CallPhase::Committed),
40            CallPhase::Committed => Some(CallPhase::ResultRecorded),
41            CallPhase::ResultRecorded => None,
42        }
43    }
44}
45
46/// A rejected non-monotonic transition. The ledger is left UNCHANGED when
47/// this is returned, so no invalid state is ever observable.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct NonMonotonic {
50    pub from: CallPhase,
51    pub to: CallPhase,
52}
53
54impl std::fmt::Display for NonMonotonic {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(
57            f,
58            "non-monotonic tool-call ledger transition {:?} -> {:?}",
59            self.from, self.to
60        )
61    }
62}
63
64impl std::error::Error for NonMonotonic {}
65
66/// Disposition of a call when its turn is interrupted.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct InterruptDisposition {
69    /// Typed terminal outcome to surface when the interruption straddled a
70    /// possible-but-unconfirmed side effect that must not be silently
71    /// retried. `None` when the interruption is an ordinary cancellation.
72    pub outcome: Option<TurnOutcome>,
73    /// Whether the runtime may AUTOMATICALLY rerun this call. Always `false`
74    /// for a `NonIdempotent` call with an unknown commit status.
75    pub auto_rerun_permitted: bool,
76}
77
78/// Per-call ledger. Cloneable/inspectable, but the phase can only ever be
79/// advanced one monotonic step at a time through [`CallLedger::advance_to`]
80/// (or its named helpers).
81#[derive(Debug, Clone)]
82pub struct CallLedger {
83    call_id: String,
84    effect: ToolEffect,
85    phase: CallPhase,
86}
87
88impl CallLedger {
89    /// Open a ledger for a freshly PLANNED call.
90    pub fn plan(call_id: impl Into<String>, effect: ToolEffect) -> Self {
91        Self {
92            call_id: call_id.into(),
93            effect,
94            phase: CallPhase::Planned,
95        }
96    }
97
98    pub fn call_id(&self) -> &str {
99        &self.call_id
100    }
101
102    pub fn effect(&self) -> ToolEffect {
103        self.effect
104    }
105
106    pub fn phase(&self) -> CallPhase {
107        self.phase
108    }
109
110    /// Advance to `to`, which MUST be the immediate successor of the current
111    /// phase. Any other target (skip, repeat, or backward) is rejected and
112    /// leaves the ledger unchanged.
113    pub fn advance_to(&mut self, to: CallPhase) -> Result<(), NonMonotonic> {
114        if self.phase.successor() == Some(to) {
115            self.phase = to;
116            Ok(())
117        } else {
118            Err(NonMonotonic {
119                from: self.phase,
120                to,
121            })
122        }
123    }
124
125    pub fn authorize(&mut self) -> Result<(), NonMonotonic> {
126        self.advance_to(CallPhase::Authorized)
127    }
128
129    pub fn start(&mut self) -> Result<(), NonMonotonic> {
130        self.advance_to(CallPhase::Started)
131    }
132
133    pub fn commit(&mut self) -> Result<(), NonMonotonic> {
134        self.advance_to(CallPhase::Committed)
135    }
136
137    pub fn record_result(&mut self) -> Result<(), NonMonotonic> {
138        self.advance_to(CallPhase::ResultRecorded)
139    }
140
141    /// True once the call may have produced a side effect but has not yet
142    /// recorded a result: it has STARTED (commit status unknown) or
143    /// COMMITTED (side effect definitely occurred), and is not yet
144    /// `ResultRecorded`.
145    pub fn side_effect_possible(&self) -> bool {
146        matches!(self.phase, CallPhase::Started | CallPhase::Committed)
147    }
148
149    /// Disposition if the turn is interrupted with this call in its current
150    /// phase. When no side effect is possible (not started, or already
151    /// recorded), the interruption is an ordinary cancellation and a rerun
152    /// is safe. When a side effect is possible, the effect class decides:
153    ///
154    /// - `NonIdempotent` => `InterruptedAfterSideEffect`, no auto-rerun;
155    /// - `IdempotentWrite` / `ReadOnly` => plain cancellation, retryable.
156    pub fn on_interrupt(&self) -> InterruptDisposition {
157        if !self.side_effect_possible() {
158            return InterruptDisposition {
159                outcome: None,
160                auto_rerun_permitted: true,
161            };
162        }
163        match self.effect {
164            ToolEffect::NonIdempotent => InterruptDisposition {
165                outcome: Some(TurnOutcome::InterruptedAfterSideEffect {
166                    call_id: self.call_id.clone(),
167                }),
168                auto_rerun_permitted: false,
169            },
170            ToolEffect::IdempotentWrite | ToolEffect::ReadOnly => InterruptDisposition {
171                outcome: None,
172                auto_rerun_permitted: true,
173            },
174        }
175    }
176
177    /// Shared policy for the dispatch paths: the interruption disposition of
178    /// a call that has STARTED (possible side effect, result not recorded).
179    /// This keeps the single- and multi-tool dispatch branches using ONE
180    /// definition of the interrupted-side-effect rule.
181    pub fn interrupted_started(call_id: &str, effect: ToolEffect) -> InterruptDisposition {
182        let mut ledger = Self::plan(call_id, effect);
183        // Planned -> Authorized -> Started; these are always valid here.
184        let _ = ledger.authorize();
185        let _ = ledger.start();
186        ledger.on_interrupt()
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn full_pipeline_advances_monotonically() {
196        let mut l = CallLedger::plan("call-1", ToolEffect::NonIdempotent);
197        assert_eq!(l.phase(), CallPhase::Planned);
198        l.authorize().unwrap();
199        l.start().unwrap();
200        l.commit().unwrap();
201        l.record_result().unwrap();
202        assert_eq!(l.phase(), CallPhase::ResultRecorded);
203    }
204
205    #[test]
206    fn skipping_a_phase_is_rejected_and_leaves_state_unchanged() {
207        let mut l = CallLedger::plan("call-1", ToolEffect::NonIdempotent);
208        let err = l.advance_to(CallPhase::Started).unwrap_err();
209        assert_eq!(
210            err,
211            NonMonotonic {
212                from: CallPhase::Planned,
213                to: CallPhase::Started
214            }
215        );
216        // Unchanged after rejection.
217        assert_eq!(l.phase(), CallPhase::Planned);
218    }
219
220    #[test]
221    fn backward_transition_is_rejected() {
222        let mut l = CallLedger::plan("call-1", ToolEffect::ReadOnly);
223        l.authorize().unwrap();
224        l.start().unwrap();
225        assert!(l.advance_to(CallPhase::Authorized).is_err());
226        assert!(l.advance_to(CallPhase::Planned).is_err());
227        assert_eq!(l.phase(), CallPhase::Started);
228    }
229
230    #[test]
231    fn repeating_the_current_phase_is_rejected() {
232        let mut l = CallLedger::plan("call-1", ToolEffect::ReadOnly);
233        l.authorize().unwrap();
234        assert!(l.advance_to(CallPhase::Authorized).is_err());
235        assert_eq!(l.phase(), CallPhase::Authorized);
236    }
237
238    #[test]
239    fn advancing_past_terminal_is_rejected() {
240        let mut l = CallLedger::plan("call-1", ToolEffect::ReadOnly);
241        l.authorize().unwrap();
242        l.start().unwrap();
243        l.commit().unwrap();
244        l.record_result().unwrap();
245        assert!(l.record_result().is_err());
246        assert_eq!(l.phase(), CallPhase::ResultRecorded);
247    }
248
249    #[test]
250    fn interrupt_before_start_is_a_plain_cancellation() {
251        let mut l = CallLedger::plan("call-1", ToolEffect::NonIdempotent);
252        l.authorize().unwrap();
253        let d = l.on_interrupt();
254        assert_eq!(d.outcome, None);
255        assert!(d.auto_rerun_permitted);
256    }
257
258    #[test]
259    fn interrupt_started_nonidempotent_is_interrupted_no_rerun() {
260        let d = CallLedger::interrupted_started("call-9", ToolEffect::NonIdempotent);
261        assert_eq!(
262            d.outcome,
263            Some(TurnOutcome::InterruptedAfterSideEffect {
264                call_id: "call-9".to_string()
265            })
266        );
267        assert!(!d.auto_rerun_permitted, "NonIdempotent must not auto-rerun");
268    }
269
270    #[test]
271    fn interrupt_committed_nonidempotent_is_interrupted_no_rerun() {
272        let mut l = CallLedger::plan("call-9", ToolEffect::NonIdempotent);
273        l.authorize().unwrap();
274        l.start().unwrap();
275        l.commit().unwrap();
276        let d = l.on_interrupt();
277        assert_eq!(
278            d.outcome,
279            Some(TurnOutcome::InterruptedAfterSideEffect {
280                call_id: "call-9".to_string()
281            })
282        );
283        assert!(!d.auto_rerun_permitted);
284    }
285
286    #[test]
287    fn interrupt_started_idempotent_stays_retryable() {
288        let d = CallLedger::interrupted_started("call-2", ToolEffect::IdempotentWrite);
289        assert_eq!(d.outcome, None);
290        assert!(
291            d.auto_rerun_permitted,
292            "idempotent writes remain safely retryable"
293        );
294    }
295
296    #[test]
297    fn interrupt_started_readonly_stays_retryable() {
298        let d = CallLedger::interrupted_started("call-3", ToolEffect::ReadOnly);
299        assert_eq!(d.outcome, None);
300        assert!(d.auto_rerun_permitted);
301    }
302
303    #[test]
304    fn interrupt_after_result_recorded_is_not_a_side_effect() {
305        let mut l = CallLedger::plan("call-4", ToolEffect::NonIdempotent);
306        l.authorize().unwrap();
307        l.start().unwrap();
308        l.commit().unwrap();
309        l.record_result().unwrap();
310        let d = l.on_interrupt();
311        assert_eq!(d.outcome, None, "a recorded result is not interrupted");
312    }
313}