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}