Execution safety for AI agent tool calls: idempotency, caching, singleflight, policy, and approval gating, as a library, not a service.
Project description
tbay
Execution safety for AI agent tool calls: idempotency, TTL caching, singleflight deduplication, risk-tiered policy, and human approval gating, as a library you install, not a service you depend on.
from tbay import TbayClient, guarded
client = TbayClient("sqlite:///~/.tbay/db.sqlite")
# or: TbayClient("postgresql://user:pass@host/dbname")
@guarded(client, policy="readonly")
def github_search(query: str) -> dict:
return real_github_api_call(query)
@guarded(client, policy="destructive")
def refund_customer(customer_id: str, amount: float) -> dict:
return stripe_refund(customer_id, amount)
@guarded only ever wraps a plain callable, so it drops in under
LangChain's @tool, the OpenAI Agents SDK's @function_tool, CrewAI tools,
or bare functions, with zero framework-specific code. See examples/.
Why
Agent frameworks solve planning and orchestration. None of them solve execution safety: once a tool is selected, nothing stops it from being called twice, cached when it shouldn't be, called too often, or fired on a destructive action without a human in the loop. tbay sits underneath any framework and handles that, durably, across processes, in whatever database you already run.
This is not a hosted service. You pip install it; state (idempotency
keys, cached results, the audit log) lives entirely in a database you own.
Nothing calls home.
Install
pip install tbay # SQLite backend, stdlib only
pip install tbay[postgres] # + Postgres backend
# or, with uv:
uv add tbay
uv add "tbay[postgres]"
How it works
Every @guarded call computes an idempotency key (tool name + normalized
args + tenant), then atomically claims a row in your database:
- First caller for a given key becomes the owner and runs the real function.
- Concurrent callers with the identical key block and receive the same
result once the owner finishes (singleflight). No in-memory daemon is
required; coordination happens through the database's own atomicity
(
INSERT ... ON CONFLICT, plus an advisory lock for the Postgres backend whenmax_concurrentis set). - Later callers, after the owner finishes, get the stored result
instead of re-running: permanently for
mutating/destructivepolicies (true idempotency), or until the policy'scache_ttlexpires forreadonlypolicies. destructive-policy calls pause inWAITING_APPROVALuntil someone runstbay approve <execution_id>(optionally after a webhook fires).- Not every tool call is idempotent. An LLM call used to decide
something, "roll a die", "get the current time": calling these twice with
the same arguments should not return the same cached answer. Use the
volatilepolicy (idempotent: false) for these; tbay then ignores caching, dedup, andkey_fnentirely and runs the call fresh every time.
Policies
policies:
# Safe to call repeatedly; safe to serve a slightly stale answer.
readonly:
cache_ttl: 5m
singleflight: true
max_retries: 2
retry_backoff: 1s
# Has a real effect, but must never double-run for the same input.
mutating:
idempotent: true
cache_ttl: 0 # 0/omitted means "keep the result forever"
max_retries: 0
# Has a real-world consequence a human should sign off on.
destructive:
idempotent: true
approval_required: true
approval_timeout: 1h
approval_bypass_arg: amount # optional: skip approval for small values
approval_bypass_max: 50
redact_args: [card_number] # optional: mask these args in the audit log
# Runs fresh every time, even with identical arguments: an LLM call used
# to decide something, a random number, "get the current time".
volatile:
idempotent: false
max_retries: 1
retry_backoff: 0.5
Pass a policy file via TbayClient(db_url, policy_file="policy.yaml"), or
override policies in code (client.policies["readonly"].cache_ttl = 60).
See policy.example.yaml for every field, including the rate_limit and
max_concurrent throughput guardrails.
Approval webhooks
Setting approval_webhook on a policy makes tbay fire an HTTP POST when a
call enters WAITING_APPROVAL, so a human finds out without polling
tbay log themselves. The body is
{"execution_id": "...", "tool_name": "..."}; look the execution up with
tbay log --tool <tool_name> to see its (possibly redacted) arguments
before approving or rejecting it.
The webhook is best-effort: if the URL is unreachable or returns an error,
tbay ignores it silently and the call still waits normally. tbay approve/tbay reject always work, whether or not the webhook fired, so a
flaky webhook endpoint can never leave a call stuck.
Approval bypass and other guardrails
approval_bypass_arg/approval_bypass_max let small, low-risk calls
through automatically while still pausing anything larger for a human:
a refund of $50 or less runs immediately, a refund of $500 waits for
tbay approve. rate_limit and max_concurrent protect a tool (and
whatever paid or rate-limited API it calls) from a runaway agent loop by
capping how often it can be called and how many calls can be in flight at
once. execution_timeout gives up on a hung call after a set time, though
this is best-effort: Python can't force-kill a thread, so the call may keep
running in the background even after tbay marks it failed.
CLI
tbay log # the audit log
tbay log --tool refund_customer --status WAITING_APPROVAL
tbay approve <execution_id>
tbay reject <execution_id>
Point the CLI at the same database as your app with --db-url or the
TBAY_DB_URL environment variable.
Examples
examples/plain_python_demo.py: no framework, just@guardedfunctions, covering readonly caching, mutating idempotency, a volatile LLM call, and an approval bypass threshold.examples/langchain_demo.py: stacks under LangChain's@tool.examples/openai_agents_demo.py: stacks under the OpenAI Agents SDK's@function_tool.
Development
Uses uv for dependency management:
uv sync --extra dev
uv run pytest
Postgres-backed tests are skipped unless TBAY_TEST_PG_DSN is set to a
running Postgres instance (CI provides one automatically).
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tbay-0.1.0.tar.gz.
File metadata
- Download URL: tbay-0.1.0.tar.gz
- Upload date:
- Size: 25.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9c73b6455974fff96c3c0e5c50fa7f9c104d3adcea8adbab9407ef99adca88c9
|
|
| MD5 |
0fb03854b71915bd1180aabc5b3b37e6
|
|
| BLAKE2b-256 |
dff226dab8784c448d95c063acceadfa3b6201f8c0fdfedafd769241f434c157
|
File details
Details for the file tbay-0.1.0-py3-none-any.whl.
File metadata
- Download URL: tbay-0.1.0-py3-none-any.whl
- Upload date:
- Size: 25.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
00567bec75ffed2b0a58d8e783fc24925e0344354c4dbefc8a6d0ee659719ccd
|
|
| MD5 |
38332610f3afa2bab91333d132c5a151
|
|
| BLAKE2b-256 |
f7f69cce8c4ce88b21066668afd5dbb0a4805602fc5102dbd972fce3f4dc008e
|