Skip to main content

entelix_core/tools/
effect.rs

1//! [`ToolEffect`] — declarative side-effect classification + retry hints.
2//!
3//! These metadata fields ride alongside `Tool::name` / `Tool::description`
4//! and let the runtime reason about a tool *before* it is dispatched:
5//!
6//! - `ToolEffect` partitions the tool surface into `ReadOnly` /
7//!   `Mutating` / `Destructive` so [`Approver`](crate) defaults
8//!   (and optionally `PolicyLayer` guardrails) can require human
9//!   confirmation for irreversible operations.
10//! - [`RetryHint`] tells [`tower::retry::RetryLayer`] (and similar
11//!   middleware) how many times a tool may be retried, and over what
12//!   spread. Defaults are conservative — the runtime never retries
13//!   a tool that does not opt in.
14//! - [`Tool::idempotent`] is the cheap binary version of
15//!   `RetryHint::is_some()` for callers that only care about
16//!   "safe to retry on transport hiccup".
17//!
18//! All fields surface as `gen_ai.tool.*` OTel attributes so dashboards
19//! can partition spend / latency / error rate by side-effect class.
20
21use std::time::Duration;
22
23use serde::{Deserialize, Serialize};
24
25/// Side-effect classification of a tool — surfaces both to the
26/// runtime (Approver defaults, retry policy) and to the LLM
27/// (rendered in the tool description so the model can reason about
28/// safety on its own).
29#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31#[non_exhaustive]
32pub enum ToolEffect {
33    /// Pure read — no side effects on external state. Safe to call
34    /// in parallel, retry on transport hiccups, cache aggressively.
35    /// Examples: search, fetch, calculator.
36    #[default]
37    ReadOnly,
38    /// Changes external state but the change is recoverable
39    /// (overwrite, undo, idempotent overwrite). Retry-safe with a
40    /// reasonable backoff. Examples: write a file, set a config
41    /// value, update a record.
42    Mutating,
43    /// Irreversible — once it runs the operator cannot undo. Default
44    /// `Approver` policy MAY require human confirmation. Retry is
45    /// off by default. Examples: send an email, post a payment,
46    /// delete a row, run an `rm -rf`.
47    Destructive,
48}
49
50impl ToolEffect {
51    /// Stable wire string used by OTel attributes
52    /// (`gen_ai.tool.effect`) and event-log records.
53    #[must_use]
54    pub const fn as_wire(self) -> &'static str {
55        match self {
56            Self::ReadOnly => "read_only",
57            Self::Mutating => "mutating",
58            Self::Destructive => "destructive",
59        }
60    }
61}
62
63impl std::fmt::Display for ToolEffect {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str(self.as_wire())
66    }
67}
68
69/// How a `Tool` opts into runtime retry. `None` (the default) means
70/// the tool is *not* retried by middleware.
71///
72/// Tools that expose `RetryHint` should be idempotent for the
73/// `attempts` count they declare — middleware will treat the hint
74/// as authoritative.
75#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct RetryHint {
78    /// Maximum number of attempts (including the first call).
79    /// Must be `>= 1`.
80    pub max_attempts: u32,
81    /// Initial backoff between attempts. Middleware applies
82    /// exponential growth + jitter on top of this baseline.
83    pub initial_backoff: Duration,
84}
85
86impl RetryHint {
87    /// Conservative default for a transport-bound idempotent tool —
88    /// 3 attempts, 200 ms initial backoff.
89    #[must_use]
90    pub const fn idempotent_transport() -> Self {
91        Self {
92            max_attempts: 3,
93            initial_backoff: Duration::from_millis(200),
94        }
95    }
96
97    /// Construct a hint with custom values. Panics on
98    /// `max_attempts == 0` because a zero retry budget is a config
99    /// bug, not a runtime condition the middleware should silently
100    /// paper over.
101    #[must_use]
102    pub const fn new(max_attempts: u32, initial_backoff: Duration) -> Self {
103        assert!(max_attempts >= 1, "RetryHint::max_attempts must be >= 1");
104        Self {
105            max_attempts,
106            initial_backoff,
107        }
108    }
109}
110
111#[cfg(test)]
112#[allow(clippy::unwrap_used)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn effect_default_is_read_only() {
118        assert_eq!(ToolEffect::default(), ToolEffect::ReadOnly);
119    }
120
121    #[test]
122    fn effect_wire_strings_are_stable() {
123        assert_eq!(ToolEffect::ReadOnly.as_wire(), "read_only");
124        assert_eq!(ToolEffect::Mutating.as_wire(), "mutating");
125        assert_eq!(ToolEffect::Destructive.as_wire(), "destructive");
126    }
127
128    #[test]
129    fn effect_serde_round_trip() {
130        let s = serde_json::to_string(&ToolEffect::Destructive).unwrap();
131        assert_eq!(s, "\"destructive\"");
132        let back: ToolEffect = serde_json::from_str(&s).unwrap();
133        assert_eq!(back, ToolEffect::Destructive);
134    }
135
136    #[test]
137    fn retry_hint_const_ctor_baseline() {
138        let h = RetryHint::idempotent_transport();
139        assert_eq!(h.max_attempts, 3);
140        assert_eq!(h.initial_backoff, Duration::from_millis(200));
141    }
142
143    #[test]
144    #[should_panic(expected = "RetryHint::max_attempts must be >= 1")]
145    fn retry_hint_zero_attempts_panics() {
146        let _ = RetryHint::new(0, Duration::from_millis(100));
147    }
148}