The bug was quiet until it wasn't.
A parallel tool dispatcher sent two database WRITE operations to the same record at the same time. No lock. No guard. Both completed. The record ended up in a state that neither operation intended. It took a while to find because the individual tool functions were correct in isolation. The problem was the dispatcher had no information about whether two tools could safely run together.
The fix was straightforward: attach side-effect metadata to each tool before dispatch, then check it. That is what tool-side-effects-tag-rs does.
The shape of the fix
use tool_side_effects_tag::{SideEffects, Tag, SideEffect};
fn fetch_user(id: u64) -> String {
format!("user:{}", id)
}
fn update_balance(id: u64, delta: i64) -> bool {
// writes to DB
true
}
fn main() {
let tagged_fetch = Tag::new(
fetch_user,
SideEffects::from([SideEffect::Read, SideEffect::Idempotent]),
);
let tagged_update = Tag::new(
update_balance,
SideEffects::from([SideEffect::Write]),
);
// safe to run tagged_fetch concurrently with itself
println!("fetch parallel safe: {}", tagged_fetch.side_effects().is_parallel_safe());
// false for tagged_update
println!("update parallel safe: {}", tagged_update.side_effects().is_parallel_safe());
// is_retry_safe checks for Idempotent or Read, not Write or Destructive
println!("fetch retry safe: {}", tagged_fetch.side_effects().is_retry_safe());
println!("update retry safe: {}", tagged_update.side_effects().is_retry_safe());
}
With those predicates, the dispatcher can route tools correctly:
fn dispatch(tools: Vec<Tag<impl Fn()>>) {
let (safe, guarded): (Vec<_>, Vec<_>) = tools
.into_iter()
.partition(|t| t.side_effects().is_parallel_safe());
// fire safe group concurrently
// run guarded group sequentially
}
What it does NOT do
- It does not enforce that a tool actually behaves according to its declared tags. The metadata is advisory. A WRITE-tagged function can still be run concurrently if the caller ignores the check.
- It does not integrate with any specific async runtime. It is a pure data-carrying wrapper.
- It does not provide locking primitives. You bring your own concurrency control; this crate tells you when to apply it.
- It does not validate tag combinations. READ + DESTRUCTIVE is allowed by the type, even if it rarely makes sense in practice.
Inside the lib
Tag<T> is a newtype. Not a trait. Not a decorator macro.
pub struct Tag<T> {
inner: T,
side_effects: SideEffects,
}
You can tag any value: a function pointer, a closure, a struct with a call method, anything. The metadata travels through ownership without requiring T to implement any trait. That means you do not have to rewrite existing tool implementations to add tagging. You wrap them at registration time.
SideEffects is a HashSet<SideEffect> behind a thin newtype. The four tags are:
-
Read- reads state, does not modify it -
Write- modifies state -
Idempotent- safe to apply multiple times with the same result -
Destructive- cannot be undone
is_parallel_safe() returns true when the set contains only Read and/or Idempotent, and no Write or Destructive. is_retry_safe() returns true when the set contains Idempotent or only Read with no Destructive.
The serde feature is optional. Enable it when you need to serialize the tag metadata to logs, config files, or an API payload.
[dependencies]
tool-side-effects-tag = { version = "0.1", features = ["serde"] }
With serde enabled:
let effects = SideEffects::from([SideEffect::Write]);
let json = serde_json::to_string(&effects).unwrap();
// {"effects":["Write"]}
When useful
You are building a parallel tool dispatcher for an agent loop. Some tools are safe to run concurrently, others are not. You need a lightweight way to carry that information from tool registration to dispatch time without threading it through every function signature.
You want retry logic that knows which tools it can retry without side-effect concern. A tool marked Idempotent can be retried on a 429 or timeout. A tool marked Destructive should not be retried without human confirmation.
You want to log which side effects a tool invocation had for audit purposes. Serialize the SideEffects set alongside the result.
When NOT useful
If every tool in your system is read-only, the tags add overhead with no payoff. Just run them concurrently.
If your dispatcher is single-threaded and sequential, there is no parallel safety question to answer.
If your tool metadata lives in a config file or manifest, you may prefer a string-based representation rather than Rust types. This crate does not parse side-effect declarations from YAML or TOML.
Install
[dependencies]
tool-side-effects-tag = "0.1"
# with serde support
tool-side-effects-tag = { version = "0.1", features = ["serde"] }
No other required dependencies.
Siblings
| Lib | Boundary | Repo |
|---|---|---|
| tool-side-effects-tag (Python) | Same concept, Python API | MukundaKatta/tool-side-effects-tag |
| agentvet-rs | Validates tool args before calling | MukundaKatta/agentvet-rs |
| tool-loop-guard-rs | Detects repeated tool calls | MukundaKatta/tool-loop-guard-rs |
| agent-fn-registry (Python) | Registry with side-effects support | MukundaKatta/agent-fn-registry |
| tool-call-budgets (Python) | Per-tool call-count caps | MukundaKatta/tool-call-budgets |
What is next
The natural extension is a dispatcher helper that accepts a Vec<Tag<F>> and splits it into safe-parallel and must-sequential groups automatically. That would remove the partition boilerplate from every agent loop that uses this crate. It would also open the door to dependency-aware scheduling: run reads first, buffer writes, run destructive operations only after explicit confirmation.
A serde-based config loader would let tool metadata come from a manifest file rather than Rust code. That matters for agents where tools are loaded at runtime from plugins or external registries.
Source: MukundaKatta/tool-side-effects-tag-rs
Part of the Hermes Agent Challenge sprint.
Top comments (0)