1use crate::tools::catalog::ToolEffect;
19use agent_core::TurnOutcome;
20
21#[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 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct InterruptDisposition {
69 pub outcome: Option<TurnOutcome>,
73 pub auto_rerun_permitted: bool,
76}
77
78#[derive(Debug, Clone)]
82pub struct CallLedger {
83 call_id: String,
84 effect: ToolEffect,
85 phase: CallPhase,
86}
87
88impl CallLedger {
89 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 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 pub fn side_effect_possible(&self) -> bool {
146 matches!(self.phase, CallPhase::Started | CallPhase::Committed)
147 }
148
149 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 pub fn interrupted_started(call_id: &str, effect: ToolEffect) -> InterruptDisposition {
182 let mut ledger = Self::plan(call_id, effect);
183 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 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}