The agent was slow.
Not broken, not wrong. Just slow. Every turn it made three tool calls in sequence. First it fetched user data. Then it fetched account status. Then it applied the update. Each call waited for the one before it. Total latency per turn: about four seconds.
The fetch calls had no dependency on each other. So I parallelized them. Spun up two threads, fired both fetches at the same time, joined, then ran the update. Latency dropped to two seconds. Looked like a clean win.
A week later I had a bug report. Two concurrent sessions had been updating the same record at the same time. The updates were not conflicting writes in the database sense. The record was updated twice, in the wrong order, and the second write clobbered the first. The final state was wrong.
I looked at the code. The parallelization was the problem. I had parallelized the two "fetch" calls. Except one of them was not just a fetch. It fetched data and then updated a last-seen timestamp as a side effect. So I was running two writes concurrently. Not a fetch and an update. Two writes, on the same record, overlapping.
The fix was not complicated. I had to stop treating "fetch" as a synonym for "read-only." I needed a way to declare what a tool actually does, not what its name suggests. And I needed the loop to use that declaration when deciding what to parallelize.
tool-side-effects-tag is the small Python library I wrote for this. On PyPI as tool-side-effects-tag. 25 tests, zero dependencies.
The shape of the fix
The library gives you four tags: READ, WRITE, IDEMPOTENT, DESTRUCTIVE. You attach them to a function with the @tag_side_effects decorator.
from tool_side_effects_tag import tag_side_effects, SideEffect, get_tags
@tag_side_effects(SideEffect.READ)
def fetch_user_data(user_id: str) -> dict:
return db.get_user(user_id)
@tag_side_effects(SideEffect.WRITE)
def fetch_and_touch_user(user_id: str) -> dict:
user = db.get_user(user_id)
db.update_last_seen(user_id)
return user
@tag_side_effects(SideEffect.WRITE, SideEffect.IDEMPOTENT)
def upsert_preferences(user_id: str, prefs: dict) -> None:
db.upsert(user_id, prefs)
@tag_side_effects(SideEffect.DESTRUCTIVE)
def delete_account(user_id: str) -> None:
db.delete_user(user_id)
Then at the dispatch layer, before you decide whether to run two tools in parallel:
from tool_side_effects_tag import is_parallel_safe, is_retry_safe
tools_to_run = [fetch_user_data, fetch_and_touch_user, apply_update]
parallel_safe = [t for t in tools_to_run if is_parallel_safe(t)]
must_sequence = [t for t in tools_to_run if not is_parallel_safe(t)]
# parallel_safe: [fetch_user_data]
# must_sequence: [fetch_and_touch_user, apply_update]
fetch_and_touch_user has a WRITE tag. The loop sees it is not parallel-safe and pulls it out. No more concurrent writes.
You can also check retry safety before deciding whether to retry a failed call:
if error_is_transient(err):
if is_retry_safe(failed_tool):
retry(failed_tool, args)
else:
log_and_escalate(failed_tool, args)
DESTRUCTIVE tools come back as not retry-safe. You do not want to delete the same account twice because a network timeout made you uncertain whether the first call landed.
What it does NOT do
A few things worth being explicit about.
It does not enforce anything at call time. The library does not intercept calls or block execution. It gives you a way to declare and query properties. What you do with the answer is your problem.
It does not know about your database transactions. If two writes are idempotent individually but conflict with each other, this library does not see that. You still need to handle concurrent-write semantics at the storage layer.
It does not infer tags from function signatures or docstrings. You declare tags explicitly. This is intentional. Inference would be wrong too often and you would not notice until production.
It does not replace a proper job queue or distributed lock. For high-throughput systems, you need real concurrency primitives. This library is for agent loops, not high-frequency write paths.
Inside the library: derived properties from tags
The design decision that made this library pleasant to work with: is_parallel_safe and is_retry_safe are computed properties, not stored fields.
There is no parallel_safe: True field anywhere. The library derives those answers from the tag set at query time.
The rules are simple:
-
is_parallel_safereturnsTrueif the tool has onlyREADorIDEMPOTENTtags, or both. AnyWRITEorDESTRUCTIVEtag makes itFalse. -
is_retry_safereturnsTrueif the tool has noDESTRUCTIVEtag. AWRITEtool can be retry-safe if it is alsoIDEMPOTENT. A pureREADis always retry-safe.
# WRITE + IDEMPOTENT: retry-safe, not parallel-safe
@tag_side_effects(SideEffect.WRITE, SideEffect.IDEMPOTENT)
def update_preference(user_id: str, key: str, value: str) -> None:
db.set(user_id, key, value)
is_retry_safe(update_preference) # True
is_parallel_safe(update_preference) # False
A PUT endpoint that writes a preference is a good example. You can retry it if the first call timed out. You would get the same result. But you should not run two of them at the same time against the same key, because ordering matters.
Because the derived properties come from tags, adding a tag automatically updates every derived answer. You do not have to find and update a separate config dict. You change one decorator argument, and every consumer of is_parallel_safe or is_retry_safe sees the new answer on the next query.
When this is useful
Agent loops that parallelize tool calls to reduce latency. Before you dispatch two calls at the same time, check both are parallel-safe. One unsafe tool in the batch is enough to kill the optimization.
Retry logic in the dispatch handler. Do not retry blindly. Check is_retry_safe before queuing a second attempt. This matters most for long-running agentic pipelines where a failed step might have already mutated state.
Review and auditing. Tags make implicit behavior explicit in code. A new team member can read the decorators and understand the write/read contract of each tool without tracing through the implementation.
Scheduler or planner agents. If you are building an agent that plans a sequence of tool calls before executing them, you can filter the plan for unsafe combinations before committing to execution.
When NOT to use this
For tools that have complex write semantics that depend on runtime state, a static tag will not capture the full picture. A tool that sometimes writes and sometimes reads depending on its arguments would need finer-grained control than a decorator.
For teams that need enforced contracts, not advisory ones. This library gives you the metadata. It does not block anything. If your team needs hard enforcement, you need a wrapper or middleware layer built on top of this.
For replacing transactional database semantics. If two writes need to be atomic, fix the transaction boundary, not the tag.
Install
pip install tool-side-effects-tag
Zero dependencies. Python 3.8+.
from tool_side_effects_tag import (
tag_side_effects,
SideEffect,
get_tags,
is_parallel_safe,
is_retry_safe,
)
Source at MukundaKatta/tool-side-effects-tag.
Siblings
| Lib | Boundary | Repo |
|---|---|---|
| agent-fn-registry | Stores tags alongside the function, schema, and defaults in one registry | MukundaKatta/agent-fn-registry |
| agent-shadow-mode | DESTRUCTIVE-tagged tools are good candidates for shadowing before going live | MukundaKatta/agent-shadow-mode |
| tool-call-budgets | Cap DESTRUCTIVE tools harder, let READ tools run more freely | MukundaKatta/tool-call-budgets |
| agentvet | Validate args before running any tool, regardless of its tags | MukundaKatta/agentvet |
The most useful combination is agent-fn-registry plus tool-side-effects-tag. The registry keeps all tool metadata in one place. The tags live there alongside the schema and the callable. The dispatch loop reads both from the same source, no drift.
What's next
A few things would make this more useful in practice. First, a way to attach tags to async functions without changing the behavior of asyncio.iscoroutinefunction checks. The current decorator preserves the wrapped function's identity, but there are edge cases with some introspection tools.
Second, a query for "can I run this batch of tools in parallel" that takes a list and returns a bool. Right now you check each tool individually. A batch helper would make the dispatch layer cleaner.
Third, broader tag vocabulary. Some tools are read-only but take a long time and should not be called more than once per turn. A EXPENSIVE tag that feeds into its own derived property would cover that case.
The library is small by design. It does one thing: declare what a tool does to the world and let the loop ask about it. The parallelization logic, the retry logic, the scheduling all belong to the loop. The tags are just the vocabulary.
That vocabulary was missing. Now it's there.
Top comments (0)