Skip to main content
Agents re-run the same read-only tools constantly — the same lookup, the same retrieval, the same get_weather("SF") — and pay full cost every time. The @tool decorator lets Visceral find that duplicate work and, once it has proven a stored result is still safe to reuse, serve it at the tool boundary instead of executing the tool again.
@tool works on sync and async functions and composes with node().
str
required
The tool’s stable identity. Non-empty.
bool
default:"False"
Declares the tool has no side effects. This is what lets Visceral hash the call and capture its input/output, so the backend can find duplicate work. Without it, @tool only opens a span with static metadata — no hash, no capture.
bool
default:"False"
Declares that a previously computed result (fresh enough by the rule’s own bar) is an acceptable substitute for a live call. Serving needs both readonly and replay_safe — they are separate declarations, and one never implies the other.
Sequence[str] | None
An allow-list of argument names the call identity keys on. Defaults to keying on all arguments. Pass e.g. ("customer_id",) to ignore volatile arguments (a request id, a timestamp) so semantically identical calls share one identity and reuse each other. Must be a non-empty sequence of non-empty strings — a bare str is rejected.
str | None
Bump it to invalidate every prior identity for this tool — for example after you change what the tool returns.
float | None
A positive upper bound on how old a served result may be. It can only shorten the freshness window the backend derived; it never lengthens it.

The two flags, concretely

readonly=True

Observe and deduplicate. Visceral hashes the call and captures its input/output so the backend can see the same work happening twice. On its own, it never substitutes a result — your tool always runs.

+ replay_safe=True

Reuse. With apply=True and an active backend rule, a proven-fresh stored result may stand in for a live call — and the tool is skipped.
readonly alone gives you dedup discovery; readonly + replay_safe + apply=True gives you actual reuse.

When a call is actually served

Serving only happens when all of these hold — readonly=True, replay_safe=True, wrap(..., apply=True), and an active rule whose identity matches the call. Then, before executing:
  1. The call’s identity hash is matched against your workspace’s active reuse rules (fetched and refreshed the same way as cache-layout rules).
  2. On a match, the stored result is fetched, decrypted with the workspace key the SDK already holds, and verified against the server’s content hash.
  3. If everything checks out, the stored value is returned and the tool never runs. The trace records visceral.tool.served_decision_id.
Verify sampling. At a server-controlled rate, a served call becomes a dual execution: the real tool runs anyway, its output is captured, the span records visceral.tool.verify_match, and your agent still receives the stored result. That’s how Visceral keeps proving a reuse rule stays correct.

Guardrails

  • Fail-open means “run the real tool.” Every failure anywhere — no matching rule, no key, a timeout, a hash mismatch, a value that won’t serialize — falls through to executing the tool. A failure costs savings, never correctness. Exceptions raised by your tool propagate exactly as they would without the decorator.
  • Writes invalidate reuse. Any readonly=False decorated call in the run records a writer, and serving refuses a stored result older than that writer. Wrapping your run in a node() scopes this correctly across async branches.
  • Content is redacted like everything else. No argument value is ever placed on a visceral.tool.* attribute — only the identity hash and declared metadata. Arguments and return values travel the same encrypted path as LLM content. See Privacy & data.

Kill switch

@tool reuse rides the apply=True opt-in, so it’s off until you turn apply on. To disable just tool reuse while keeping prompt-cache optimizations, set VISCERAL_TOOL_APPLY=0. VISCERAL_TOOL_SERVE_TIMEOUT_SECONDS (default 0.15) bounds each serve fetch — on timeout, the real tool runs. See Configuration.