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}