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}