DEV Community

Mukunda Rao Katta
Mukunda Rao Katta

Posted on

Rust: Tag Your Agent Tools With Side-Effect Metadata Before Dispatching Them in Parallel

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());
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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"] }
Enter fullscreen mode Exit fullscreen mode

With serde enabled:

let effects = SideEffects::from([SideEffect::Write]);
let json = serde_json::to_string(&effects).unwrap();
// {"effects":["Write"]}
Enter fullscreen mode Exit fullscreen mode

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"] }
Enter fullscreen mode Exit fullscreen mode

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)