tool-side-effects-tag 0.1.0

Declare what an LLM agent tool actually does (read, write, idempotent, destructive, external, expensive, network) so the scheduler / retry layer can decide what's parallel-safe and retry-safe. Zero runtime deps; serde optional.
Documentation

tool-side-effects-tag

Crates.io License: MIT OR Apache-2.0

Declare what an LLM agent tool actually does so the scheduler / retry layer can make the right decision per-tool. Zero runtime deps.

use tool_side_effects_tag::{
    is_destructive, is_parallel_safe, is_retry_safe,
    HasSideEffects, SideEffect, SideEffects,
};

struct SearchWeb;
impl HasSideEffects for SearchWeb {
    fn side_effects(&self) -> SideEffects {
        let mut s = SideEffects::new();
        s.insert(SideEffect::Read);
        s.insert(SideEffect::Network);
        s
    }
}

struct UpsertUser;
impl HasSideEffects for UpsertUser {
    fn side_effects(&self) -> SideEffects {
        let mut s = SideEffects::new();
        s.insert(SideEffect::Write);
        s.insert(SideEffect::Idempotent);
        s
    }
}

struct DeleteAccount;
impl HasSideEffects for DeleteAccount {
    fn side_effects(&self) -> SideEffects {
        let mut s = SideEffects::new();
        s.insert(SideEffect::Destructive);
        s
    }
}

let search = SearchWeb.side_effects();
let upsert = UpsertUser.side_effects();
let delete = DeleteAccount.side_effects();

assert!(is_parallel_safe(&search));   // pure read
assert!(!is_parallel_safe(&upsert));  // writes
assert!(is_retry_safe(&upsert));      // idempotent write
assert!(is_destructive(&delete));

Why

Most agent loops treat every tool the same way. If retry is on, it's on for everything, including send_email, which is unfortunate when the network blip causes a duplicate. If parallelism is on, it's on for everything, including upsert_user, which races with itself.

tool-side-effects-tag is a one-line declaration so the dispatcher knows what to do per-tool. Zero magic.

The standard tags:

Tag What it means
Read No state mutation. Safe to parallelize and retry.
Write Mutates state. Not parallel-safe by default.
Idempotent Same args produce the same effect. Retry-safe.
Destructive Delete/drop/purge. Never auto-retry.
External Third-party system (email, payments). Not retry-safe without Idempotent.
Expensive High cost. Caller may want extra confirmation.
Network Makes a network call. Subject to transient errors.

Install

[dependencies]
tool-side-effects-tag = "0.1"

Optional serde support:

[dependencies]
tool-side-effects-tag = { version = "0.1", features = ["serde"] }

API

use tool_side_effects_tag::{
    HasSideEffects,     // implement on your tool types
    SideEffect,         // the enum
    SideEffects,        // a set of tags
    Tag,                // wrap any T with a SideEffects set
    is_parallel_safe,   // &SideEffects -> bool
    is_retry_safe,      // &SideEffects -> bool
    is_destructive,     // &SideEffects -> bool
};

SideEffect

Debug + Clone + Copy + PartialEq + Eq + Hash, plus Display and FromStr over the lowercase slugs "read", "write", "idempotent", "destructive", "external", "expensive", "network". Unknown inputs return ParseSideEffectError.

SideEffects

Thin wrapper around HashSet<SideEffect>:

use tool_side_effects_tag::{SideEffect, SideEffects};

let mut s = SideEffects::new();
s.insert(SideEffect::Read);
s.insert(SideEffect::Network);
assert!(s.contains(SideEffect::Read));
assert_eq!(s.len(), 2);
s.remove(SideEffect::Network);
for e in s.iter() {
    println!("{e}");
}

SideEffects also implements FromIterator<SideEffect> so you can do:

use tool_side_effects_tag::{SideEffect, SideEffects};
let s: SideEffects = [SideEffect::Write, SideEffect::Idempotent].into_iter().collect();

Tag<T>

Pair any value with a SideEffects set:

use tool_side_effects_tag::{SideEffect, SideEffects, Tag};

let mut effects = SideEffects::new();
effects.insert(SideEffect::Destructive);
let tag = Tag::new("delete_account", effects);

assert_eq!(*tag.value(), "delete_account");
assert!(tag.effects().contains(SideEffect::Destructive));
let inner = tag.into_inner();

Tag<T> itself implements HasSideEffects, so you can pass a Tag<MyTool> anywhere a HasSideEffects is expected.

Conservative defaults

  • An untagged (empty) set returns false for both is_parallel_safe and is_retry_safe. If you don't know what a tool does, don't run it in parallel and don't retry it.
  • A destructive set returns false for is_retry_safe even if also tagged Idempotent. Destructive intent overrides idempotent for retry purposes. If you actually want the retry, the caller should ask for it explicitly.

Companion libraries

License

Licensed under either of

at your option.