Skip to main content

tool_side_effects_tag/
lib.rs

1//! # tool-side-effects-tag
2//!
3//! Declare what an LLM agent tool actually does so the scheduler / retry
4//! layer can make the right decision per-tool.
5//!
6//! If your scheduler does not know which tools are reads and which are
7//! writes, it cannot run them in parallel safely. If your retry layer does
8//! not know which tools are idempotent, it cannot retry them safely. This
9//! crate gives you a tiny vocabulary of [`SideEffect`] tags plus inspection
10//! helpers that classify a tool as parallel-safe, retry-safe, or
11//! destructive.
12//!
13//! The Python sibling uses a function decorator. Rust does not have
14//! decorators, so the idiom here is an associated-metadata pattern: tools
15//! implement [`HasSideEffects`] (or hold a [`Tag`] / [`SideEffects`]) and
16//! the free functions [`is_parallel_safe`], [`is_retry_safe`], and
17//! [`is_destructive`] read that set.
18//!
19//! ## Quick example
20//!
21//! ```
22//! use tool_side_effects_tag::{
23//!     HasSideEffects, SideEffect, SideEffects,
24//!     is_destructive, is_parallel_safe, is_retry_safe,
25//! };
26//!
27//! struct SearchWeb;
28//! impl HasSideEffects for SearchWeb {
29//!     fn side_effects(&self) -> SideEffects {
30//!         let mut s = SideEffects::new();
31//!         s.insert(SideEffect::Read);
32//!         s
33//!     }
34//! }
35//!
36//! let tool = SearchWeb;
37//! assert!(is_parallel_safe(&tool.side_effects()));
38//! assert!(is_retry_safe(&tool.side_effects()));
39//! assert!(!is_destructive(&tool.side_effects()));
40//! ```
41//!
42//! ## With a [`Tag`] wrapper
43//!
44//! ```
45//! use tool_side_effects_tag::{SideEffect, SideEffects, Tag};
46//!
47//! let mut effects = SideEffects::new();
48//! effects.insert(SideEffect::Write);
49//! effects.insert(SideEffect::Idempotent);
50//! let upsert = Tag::new("upsert_user", effects);
51//!
52//! assert_eq!(*upsert.value(), "upsert_user");
53//! assert!(upsert.effects().contains(SideEffect::Write));
54//! ```
55//!
56//! ## Feature flags
57//!
58//! - `serde` — derives `Serialize` / `Deserialize` for [`SideEffect`] and
59//!   [`SideEffects`]. Off by default; enable with
60//!   `features = ["serde"]`.
61
62#![deny(missing_docs)]
63
64use std::collections::HashSet;
65use std::fmt;
66use std::str::FromStr;
67
68#[cfg(feature = "serde")]
69use serde::{Deserialize, Serialize};
70
71/// Standard side-effect categories for an agent tool.
72///
73/// String form matches the Python sibling library:
74/// `"read"`, `"write"`, `"idempotent"`, `"destructive"`, `"external"`,
75/// `"expensive"`, `"network"`.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
78#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
79pub enum SideEffect {
80    /// Reads data. No state mutation. Safe to parallelize and retry.
81    Read,
82    /// Mutates internal state. Not parallel-safe by default.
83    Write,
84    /// Repeated calls with the same args produce the same effect. Retry-safe.
85    Idempotent,
86    /// Removes or invalidates state (delete, drop, purge). Never auto-retry.
87    Destructive,
88    /// Touches a third-party system (email, payments, webhooks). Not
89    /// retry-safe without [`SideEffect::Idempotent`].
90    External,
91    /// High cost (tokens, money, time). Caller may want extra confirmation.
92    Expensive,
93    /// Makes a network call. Subject to retryable transient errors.
94    Network,
95}
96
97impl SideEffect {
98    /// Stable string slug used by [`Display`] and [`FromStr`].
99    pub fn as_str(&self) -> &'static str {
100        match self {
101            SideEffect::Read => "read",
102            SideEffect::Write => "write",
103            SideEffect::Idempotent => "idempotent",
104            SideEffect::Destructive => "destructive",
105            SideEffect::External => "external",
106            SideEffect::Expensive => "expensive",
107            SideEffect::Network => "network",
108        }
109    }
110}
111
112impl fmt::Display for SideEffect {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118/// Error returned by [`SideEffect::from_str`] when the input does not match
119/// any known tag.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct ParseSideEffectError {
122    /// The input that failed to parse.
123    pub input: String,
124}
125
126impl fmt::Display for ParseSideEffectError {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        write!(f, "unknown side-effect tag: {:?}", self.input)
129    }
130}
131
132impl std::error::Error for ParseSideEffectError {}
133
134impl FromStr for SideEffect {
135    type Err = ParseSideEffectError;
136    fn from_str(s: &str) -> Result<Self, Self::Err> {
137        match s {
138            "read" => Ok(SideEffect::Read),
139            "write" => Ok(SideEffect::Write),
140            "idempotent" => Ok(SideEffect::Idempotent),
141            "destructive" => Ok(SideEffect::Destructive),
142            "external" => Ok(SideEffect::External),
143            "expensive" => Ok(SideEffect::Expensive),
144            "network" => Ok(SideEffect::Network),
145            other => Err(ParseSideEffectError {
146                input: other.to_string(),
147            }),
148        }
149    }
150}
151
152/// An unordered set of [`SideEffect`] tags attached to a tool.
153///
154/// Backed by [`HashSet`]; insertion order is not preserved. Use
155/// [`SideEffects::iter`] to read out the contents.
156#[derive(Debug, Clone, Default, PartialEq, Eq)]
157#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
158#[cfg_attr(feature = "serde", serde(transparent))]
159pub struct SideEffects {
160    inner: HashSet<SideEffect>,
161}
162
163impl SideEffects {
164    /// Construct an empty set.
165    pub fn new() -> Self {
166        Self {
167            inner: HashSet::new(),
168        }
169    }
170
171    /// Insert a tag. Returns `true` if the tag was newly added.
172    pub fn insert(&mut self, effect: SideEffect) -> bool {
173        self.inner.insert(effect)
174    }
175
176    /// Remove a tag. Returns `true` if the tag was present.
177    pub fn remove(&mut self, effect: SideEffect) -> bool {
178        self.inner.remove(&effect)
179    }
180
181    /// Returns `true` iff the set contains `effect`.
182    pub fn contains(&self, effect: SideEffect) -> bool {
183        self.inner.contains(&effect)
184    }
185
186    /// Iterate over the tags in arbitrary order.
187    pub fn iter(&self) -> impl Iterator<Item = &SideEffect> {
188        self.inner.iter()
189    }
190
191    /// Number of tags in the set.
192    pub fn len(&self) -> usize {
193        self.inner.len()
194    }
195
196    /// `true` iff the set is empty.
197    pub fn is_empty(&self) -> bool {
198        self.inner.is_empty()
199    }
200}
201
202impl FromIterator<SideEffect> for SideEffects {
203    fn from_iter<I: IntoIterator<Item = SideEffect>>(iter: I) -> Self {
204        Self {
205            inner: iter.into_iter().collect(),
206        }
207    }
208}
209
210/// Implement on a tool type to declare its side-effect surface.
211///
212/// The dispatch / retry layer can then call [`is_parallel_safe`],
213/// [`is_retry_safe`], or [`is_destructive`] on the returned set without
214/// caring what the concrete tool type is.
215pub trait HasSideEffects {
216    /// Return the side-effect set for this tool.
217    fn side_effects(&self) -> SideEffects;
218}
219
220/// Pairs any value with a [`SideEffects`] set. Lets you tag tool handles,
221/// closures, command structs, anything.
222#[derive(Debug, Clone)]
223pub struct Tag<T> {
224    value: T,
225    effects: SideEffects,
226}
227
228impl<T> Tag<T> {
229    /// Wrap `value` with the given `effects` set.
230    pub fn new(value: T, effects: SideEffects) -> Self {
231        Self { value, effects }
232    }
233
234    /// Borrow the inner value.
235    pub fn value(&self) -> &T {
236        &self.value
237    }
238
239    /// Borrow the attached side-effect set.
240    pub fn effects(&self) -> &SideEffects {
241        &self.effects
242    }
243
244    /// Consume the [`Tag`] and return the inner value, dropping the tags.
245    pub fn into_inner(self) -> T {
246        self.value
247    }
248}
249
250impl<T> HasSideEffects for Tag<T> {
251    fn side_effects(&self) -> SideEffects {
252        self.effects.clone()
253    }
254}
255
256/// Safe to run alongside other tools.
257///
258/// Rules:
259/// - [`SideEffect::Read`]-only with no [`SideEffect::Write`] / [`SideEffect::Destructive`] -> safe.
260/// - [`SideEffect::Write`] / [`SideEffect::Destructive`] present -> not safe.
261/// - Empty / untagged -> not safe (conservative default).
262pub fn is_parallel_safe(effects: &SideEffects) -> bool {
263    if effects.is_empty() {
264        return false;
265    }
266    if effects.contains(SideEffect::Write) || effects.contains(SideEffect::Destructive) {
267        return false;
268    }
269    effects.contains(SideEffect::Read)
270}
271
272/// Safe to auto-retry on transient error.
273///
274/// Rules:
275/// - [`SideEffect::Destructive`] -> never (caller must opt in per-call).
276/// - [`SideEffect::Idempotent`] explicitly tagged -> safe.
277/// - [`SideEffect::Read`]-only with no [`SideEffect::Write`] -> safe.
278/// - Otherwise -> not safe.
279pub fn is_retry_safe(effects: &SideEffects) -> bool {
280    if effects.is_empty() {
281        return false;
282    }
283    if effects.contains(SideEffect::Destructive) {
284        return false;
285    }
286    if effects.contains(SideEffect::Idempotent) {
287        return true;
288    }
289    if effects.contains(SideEffect::Read) && !effects.contains(SideEffect::Write) {
290        return true;
291    }
292    false
293}
294
295/// `true` iff the set contains [`SideEffect::Destructive`].
296pub fn is_destructive(effects: &SideEffects) -> bool {
297    effects.contains(SideEffect::Destructive)
298}