Skip to main content

salvor_tools/
retry.rs

1//! [`RetryPolicy`]: the per-effect rule for retrying a *live* tool execution
2//! that failed, encoded as pure data.
3
4use salvor_core::Effect;
5
6/// Whether and how a failed live tool execution may be retried, as a function
7/// of the tool's [`Effect`].
8///
9/// This encodes the retry column of the effect table. It is pure data
10/// and logic: it decides nothing and enforces nothing on its own.
11///
12/// # Scope: live execution only
13///
14/// This governs a tool call that is executing **now** and failed. It says
15/// nothing about replay: a *completed* recorded call is read from the log and
16/// never re-executed, whatever its effect. That rule lives in
17/// [`salvor_core::Effect`] and the replay cursor, not here.
18///
19/// # Consumer: the agent loop
20///
21/// This type is consumed by the runtime loop, which owns retry enforcement.
22/// When a live [`DynTool::call_json`](crate::DynTool::call_json) returns a
23/// [`ToolError::Handler`](crate::ToolError::Handler), the loop asks
24/// [`RetryPolicy::for_effect`] what it is allowed to do:
25///
26/// - [`Retry`](Self::Retry): re-execute the call.
27/// - [`RetryWithSameKey`](Self::RetryWithSameKey): re-execute, reusing the
28///   original idempotency key (carried on [`ToolCtx`](crate::ToolCtx)) so the
29///   provider collapses duplicate attempts into one effect.
30/// - [`Never`](Self::Never): do not auto-retry; the write's at-most-once intent
31///   was recorded before execution, and a crash between intent and completion
32///   surfaces as `NeedsReconciliation` for a human.
33///
34/// This layer only classifies. Recording intent, counting attempts, and
35/// surfacing reconciliation are the loop's job.
36#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
37pub enum RetryPolicy {
38    /// Retry freely. A [`Read`](Effect::Read) has no side effect, so a repeat
39    /// attempt only re-observes state.
40    Retry,
41    /// Retry, but only reusing the same idempotency key. For an
42    /// [`Idempotent`](Effect::Idempotent) call, the shared key is what makes a
43    /// retry safe.
44    RetryWithSameKey,
45    /// Never auto-retry. A [`Write`](Effect::Write) that may have landed is not
46    /// re-attempted on a guess; it is left for human reconciliation.
47    Never,
48}
49
50impl RetryPolicy {
51    /// The retry policy for a tool of the given effect class.
52    ///
53    /// This is the whole table: [`Read`](Effect::Read) maps to
54    /// [`Retry`](Self::Retry), [`Idempotent`](Effect::Idempotent) to
55    /// [`RetryWithSameKey`](Self::RetryWithSameKey), and
56    /// [`Write`](Effect::Write) to [`Never`](Self::Never).
57    pub fn for_effect(effect: Effect) -> Self {
58        match effect {
59            Effect::Read => RetryPolicy::Retry,
60            Effect::Idempotent => RetryPolicy::RetryWithSameKey,
61            Effect::Write => RetryPolicy::Never,
62        }
63    }
64
65    /// Whether a failed live execution of this policy may be re-attempted at
66    /// all. A convenience over matching; `false` only for [`Never`](Self::Never).
67    pub fn allows_retry(self) -> bool {
68        !matches!(self, RetryPolicy::Never)
69    }
70}