Contents · 17 sections↓
- 01How do you limit destructive actions by an AI agent?
- 02What makes an action irreversible?
- 03When to cap irreversible actions (and when to do something else)
- 04What you need
- 05Step 1: Give your tools honest annotations
- 06Step 2: Mark irreversible tools in the budget
- 07Step 3: Watch the cap stop the third deletion
- 08Step 4: Decide what happens after the cap
- 09Step 5: Put a human in front of each irreversible call
- 10Step 6: Audit every refusal
- 11Step 7: Know what rewind can't undo
- 12The complete example
- 13Running it in production
- 14Common problems and fixes
- 15Limitations
- 16Frequently asked questions
- 17Next steps
To limit destructive actions by an AI agent, you need a hard rule that holds even when the model is wrong: this agent may delete, send or charge at most N times per run, and the call that would cross the line never runs. This guide is for developers who run agents unattended. It explains what makes an action irreversible and builds a cleanup agent with Promptise Foundry whose third deletion is refused. It then adds human approval, auditing, and a look at what rewind can't undo.
How do you limit destructive actions by an AI agent?
Mark the tools whose effects you can't take back as irreversible, count their calls separately from other tool calls, and refuse the call that would go over a small per-run limit before it executes. Put human approval in front of the ones that matter most, and record every refusal. In Promptise Foundry, list those tools in BudgetConfig.tool_costs with ToolCostAnnotation(irreversible=True) and set max_irreversible_per_run. The refused call doesn't run, the model gets a message telling it to stop, and the process pauses after the run.
Irreversible is broader than destructive. A deletion destroys data. A sent email or a payment only adds something, but you can't take it back either. Both count.
The budget reads `tool_costs`, not MCP annotations. Map destructiveHint to irreversible=True yourself, as step 2 does.
The cap is checked before approval. A refused call never reaches the reviewer, and a call the reviewer denies still uses up part of the cap.
The cap is per run. For a daily ceiling, give irreversible tools a cost weight and set max_cost_per_day.
Rewind undoes nothing outside the process. It restores the agent's memory, not the deleted rows.
[02]
What makes an action irreversible?
An action is irreversible when nothing you can do afterwards returns the world to how it was. It helps to sort an agent's tools into three groups:
Reversible: a soft delete, a draft, a change you can roll back from a backup within your own system.
Compensable: a payment you can refund or a post you can delete. The undo is a second action with its own side effects: the customer saw the charge, and someone may have seen the post.
Irreversible: a hard delete without backup, a sent email or SMS, a message posted to someone else's channel, an executed trade.
Destructive and irreversible are not the same. The Model Context Protocol describes tool behavior with four annotation hints. readOnlyHint says the tool changes nothing (default false). destructiveHint says it may destroy or overwrite data rather than only add (default true). idempotentHint says repeating it has no extra effect (default false). openWorldHint says it reaches outside systems (default true). Sending an email is additive, so an honest server marks it destructiveHint=False, yet it is irreversible. The MCP specification also says clients must treat annotations as untrusted unless they come from trusted servers. Use annotations as a starting point, and decide which tools count for your own agent.
A per-run cap works because one run of an autonomous agent should rarely need many irreversible actions. If it asks for more, the agent is probably wrong, looping or following injected instructions. Stopping at a small number limits the damage of every one of those failures at once. Here is where the cap sits in a single tool call:
model asks for delete_account("acct-103")
│
▼
┌───────────────────────────────────┐ would exceed ┌──────────────────────────────────┐
│ 1. budget guard, before the tool │───── a limit ───▶│ refused: "tool call ... was not │
│ irreversible_run + 1 > cap? │ │ executed", permission_denied │
└─────────────────┬─────────────────┘ │ (kind="budget") │
│ no: counted └──────────────────────────────────┘
▼
┌───────────────────────────────────┐ denied ┌──────────────────────────────────┐
│ 2. approval, if the tool is in │─────────────────▶│ "DENIED: <reason>", │
│ ApprovalPolicy.tools │ │ permission_denied (kind= │
└─────────────────┬─────────────────┘ │ "approval") │
│ approved └──────────────────────────────────┘
▼
3. the tool runs: the side effect happens
│
▼
end of the run: the first violation applies on_exceeded (pause, stop or escalate)[03]
When to cap irreversible actions (and when to do something else)
Control | What it stops | What it doesn't stop | Use it when |
|---|---|---|---|
Per-run cap (max_irreversible_per_run) | Runaway runs: loops, mass deletions, injected "delete everything" | The first N wrong actions | Every agent with irreversible tools |
Human approval (ApprovalPolicy) | Each action a person rejects | Rubber-stamped mistakes, slow queues | High-value actions, low volume |
Cap and approval together | Both, and a flood of approval requests | Mistakes a reviewer approves | Payments, deletions of customer data |
Cap of 0 | Every irreversible call, at once | Read-only tools | An incident: freeze an agent without stopping it |
Reversible design (soft delete, delayed send) | Makes many actions undoable | True external effects | When you own the tool |
Approvals and caps answer different questions. Approval asks "is this particular action right?". The cap asks "how much harm can one run do?". Use the cap everywhere, and add approval where one wrong action is already too many. For approval design, see human-in-the-loop approval for AI agents.
[04]
What you need
Python 3.10 or later and Promptise Foundry 1.5.0 or later.
An OpenAI API key in OPENAI_API_KEY for the runs with a real model (gpt-5-mini, under a cent each). The CI test at the end needs no key.
About 20 minutes.
pip install "promptise>=1.5.0"[05]
Step 1: Give your tools honest annotations
The agent cleans up a customer database. Its MCP server has a read-only lookup, a hard delete and an email tool, each annotated for what it really does:
"""Customer-account tools with honest MCP annotations (stdio server)."""
import json
import os
import sys
from pathlib import Path
from promptise.mcp.server import MCPServer
DB = Path(os.environ.get("ACCOUNTS_DB", Path(__file__).with_name("accounts.json")))
server = MCPServer(name="ops")
def load() -> dict:
return json.loads(DB.read_text())
def save(accounts: dict) -> None:
DB.write_text(json.dumps(accounts, indent=2))
@server.tool(read_only_hint=True)
async def list_inactive_accounts() -> list[str]:
"""IDs of accounts with no login for 12 months."""
return [a for a, info in load().items() if info["inactive"]]
@server.tool(destructive_hint=True, idempotent_hint=True)
async def delete_account(account_id: str) -> str:
"""Permanently delete an account and its data. This cannot be undone."""
accounts = load()
if accounts.pop(account_id, None) is None:
return f"{account_id} does not exist"
save(accounts)
print(f"ops_server: deleted {account_id}", file=sys.stderr, flush=True)
return f"deleted {account_id}"
@server.tool(destructive_hint=False, open_world_hint=True)
async def email_owner(account_id: str, message: str) -> str:
"""Email the account owner. A sent email can't be recalled."""
print(f"ops_server: emailed the owner of {account_id}", file=sys.stderr, flush=True)
return f"email sent to the owner of {account_id}"
if __name__ == "__main__":
server.run()reset_db.py writes a fresh accounts.json with three inactive and two active accounts and removes the demo journal; run it before each step. The annotations go to every MCP client in the tool list. Promptise uses them in other places too: its approval classifier never auto-approves a tool annotated destructiveHint=True as read-only, and server-side caching skips destructive tools. Annotate truthfully even if you also mark tools by hand.
[06]
Step 2: Mark irreversible tools in the budget
The budget doesn't read annotations. It looks up each tool call's name in BudgetConfig.tool_costs. This helper asks the server for its tools and builds that map from the annotations, using MCP's defaults, so a tool without annotations counts as destructive:
"""Build BudgetConfig.tool_costs from the MCP annotations a server declares."""
from promptise.mcp.client import MCPClient
from promptise.runtime import ToolCostAnnotation
def may_destroy(tool) -> bool:
"""MCP's defaults are readOnlyHint=False and destructiveHint=True:
only a tool that says it is read-only or additive counts as safe."""
hints = tool.annotations
if hints is None:
return True
if hints.readOnlyHint:
return False
return hints.destructiveHint is not False
async def irreversible_costs(server: dict, also: tuple[str, ...] = ()) -> dict[str, ToolCostAnnotation]:
"""Mark every tool that may destroy data, plus the names in `also`, as irreversible."""
async with MCPClient(transport="stdio", command=server["command"], args=server["args"],
env=server.get("env")) as client:
tools = await client.list_tools()
return {
tool.name: ToolCostAnnotation(irreversible=True, cost_weight=5.0)
for tool in tools
if may_destroy(tool) or tool.name in also
}show_costs.py prints the map twice, from the annotations alone and with also=("email_owner",):
from annotations: ['delete_account']
with also=: {'delete_account': 'cost_weight=5.0 irreversible=True', 'email_owner': 'cost_weight=5.0 irreversible=True'}The annotations alone mark delete_account. email_owner is additive (destructiveHint=False), so it only counts because also= adds it by name. Two settings decide how a marked tool is counted:
`irreversible=True` adds 1 to the run's irreversible counter, which max_irreversible_per_run limits. All irreversible tools share one counter.
`cost_weight` (default 1.0) is added to the run and day cost counters. It is an abstract unit, not money. Step 4 uses it for a daily ceiling.
Keys must match the tool names the agent sees. A misspelled key has no effect, and the tool then counts as an ordinary call with weight 1.0.
[07]
Step 3: Watch the cap stop the third deletion
The cleanup agent runs on a trigger. Its instructions tell it to delete every inactive account, and its budget allows two irreversible actions per run:
"""The cleanup agent: ops tools, a full journal, and at most two irreversible actions per run."""
import sys
from pathlib import Path
from irreversible import irreversible_costs
from promptise.runtime import (
AgentProcess, BudgetConfig, HookContext, HookEvent, JournalConfig, ProcessConfig,
)
HERE = Path(__file__).parent
JOURNAL = HERE / ".promptise" / "journal"
DB = HERE / "accounts.json"
def server_spec(db: Path) -> dict:
return {"command": sys.executable, "args": [str(HERE / "ops_server.py")],
"env": {"ACCOUNTS_DB": str(db)}}
async def build_process(*, db: Path = DB, journal: Path = JOURNAL,
max_tool_calls_per_day: int | None = None, **extra) -> AgentProcess:
server = server_spec(db)
costs = await irreversible_costs(server, also=("email_owner",))
process = AgentProcess("cleanup", ProcessConfig(
model="openai:gpt-5-mini",
instructions="You run the nightly account cleanup. Each trigger event is one cleanup run: "
"delete every inactive account. Nobody is watching, so don't ask questions; "
"report what you did.",
servers={"ops": server},
journal=JournalConfig(level="full", path=str(journal)),
budget=BudgetConfig(enabled=True, max_irreversible_per_run=2, tool_costs=costs,
max_tool_calls_per_day=max_tool_calls_per_day),
**extra,
))
@process.hooks.on(HookEvent.PERMISSION_DENIED)
async def refused(ctx: HookContext) -> None:
d = ctx.data
print(f"permission_denied: kind={d['kind']} tool={d['tool_name']} "
f"decided_by={d['decided_by']} reason={d['reason']!r}")
return process"""Step 3: three inactive accounts, max_irreversible_per_run=2."""
import asyncio
from cleanup_agent import build_process
from demo_utils import report, run_task
async def main() -> None:
process = await build_process()
await process.start()
await run_task(process)
await report(process)
await process.stop()
asyncio.run(main())demo_utils.py holds two helpers for the demos. run_task() injects one trigger event and waits for its task_completed hook, and report() prints the journal's tool_call entries, the agent's answer, the process state and the accounts left. There are three inactive accounts, so the agent tries to delete three:
Error in BudgetGuard.on_tool_start callback: BudgetExceededError("Budget limit max_irreversible_per_run reached (limit 2); tool call 'delete_account' was not executed. Stop calling tools and give your final answer.")
permission_denied: kind=budget tool=delete_account decided_by=budget reason='budget limit max_irreversible_per_run reached (3 > 2)'
ops_server: deleted acct-101
ops_server: deleted acct-102
Error in BudgetGuard.on_tool_start callback: BudgetExceededError("Budget limit max_irreversible_per_run reached (limit 2); tool call 'email_owner' was not executed. Stop calling tools and give your final answer.")
permission_denied: kind=budget tool=email_owner decided_by=budget reason='budget limit max_irreversible_per_run reached (3 > 2)'
Budget violation: max_irreversible_per_run (limit=2, current=3) — action=pause
tool call: list_inactive_accounts {}
tool call: delete_account {'account_id': 'acct-101'}
tool call: delete_account {'account_id': 'acct-102'}
agent: Run summary — nightly-cleanup …
- Deleted this run: acct-101, acct-102
- Still inactive and not deleted: acct-103
- email_owner(acct-103) -> not executed (BudgetExceededError)
…
state: suspended | suspend_reason: budget
accounts left: ['acct-103', 'acct-200', 'acct-201']Read the lines in order:
The model asked for all three deletions in one turn. The budget guard checks parallel calls one by one, before any of them runs. The first two were counted, and the third would have made the count 3, over the cap of 2. It was refused: permission_denied fired with kind=budget.
Only two deletions happened. The server deleted acct-101 and acct-102. acct-103 still exists.
The model was told. The refused call's result was the BudgetExceededError text, which ends with "Stop calling tools and give your final answer." The model then tried email_owner, which is irreversible too, and the same counter refused it. Its report lists acct-103 as not deleted.
The process paused. After the run, the first violation applied on_exceeded, which defaults to "pause": the state is suspended with suspend_reason budget.
Two log lines look like errors but are expected. LangChain logs every exception raised in a callback as Error in BudgetGuard.on_tool_start callback, and the budget logs Budget violation ... action=pause. Also note that the journal has no tool_call entry for the refused call: the guard runs before the journal records it.
[08]
Step 4: Decide what happens after the cap
on_exceeded sets what happens after the run in which a limit was hit:
on_exceeded | Effect | Use it for |
|---|---|---|
"pause" (default) | Suspends the process (suspend_reason="budget"). New events wait in the queue. resume() or the API continues | Most agents: a person looks before more actions happen |
"stop" | Stops the process. Queued events are dead-lettered | Agents that must not continue until someone starts them again |
"escalate" | Sends the escalation webhook or bus event, then suspends | Unattended agents someone must hear about; see AI agent escalation |
The cap is per run. After resume(), the next run starts with a fresh counter and may delete two more accounts. There is no max_irreversible_per_day. For a daily ceiling, give irreversible tools a weight, make read-only tools nearly free, and set max_cost_per_day:
"""A per-run cap resets every run; a cost weight turns max_cost_per_day into a daily cap."""
import asyncio
from promptise.runtime import BudgetConfig, ToolCostAnnotation
from promptise.runtime.budget import BudgetState
config = BudgetConfig(
enabled=True,
max_irreversible_per_run=2,
max_cost_per_day=16, # three deletions (5 each) plus some cheap reads
tool_costs={
"delete_account": ToolCostAnnotation(irreversible=True, cost_weight=5.0),
"list_inactive_accounts": ToolCostAnnotation(cost_weight=0.01),
},
)
async def main() -> None:
state = BudgetState(config) # the process keeps one of these
for run in (1, 2):
await state.reset_run() # the process does this at the start of every run
await state.record_tool_call("list_inactive_accounts", enforce=True)
for n in range(1, 4):
refused = await state.record_tool_call("delete_account", enforce=True)
verdict = f"refused by {refused.limit_name}" if refused else "allowed"
print(f"run {run}, delete #{n}: {verdict}")
left = {k: round(v, 2) for k, v in state.remaining().items() if v is not None}
print("left:", left)
asyncio.run(main())run 1, delete #1: allowed
run 1, delete #2: allowed
run 1, delete #3: refused by max_irreversible_per_run
run 2, delete #1: allowed
run 2, delete #2: refused by max_cost_per_day
run 2, delete #3: refused by max_cost_per_day
left: {'irreversible_run': 1, 'cost_day': 0.98}Run 1 hit the per-run cap at the third deletion. Run 2 started with a fresh per-run counter, but the daily cost counter was already at 10.01 of 16. One more deletion (weight 5) fit; the second was refused by max_cost_per_day. Every tool call adds its weight to the daily cost, which is why the reads cost 0.01. With a journal, the daily counters survive restarts, so a crash loop can't reset them.
During an incident, set the cap to 0. PATCH /api/v1/processes/{name}/budget with {"max_irreversible_per_run": 0} (or process.apply_governance_config("budget") after changing process.config.budget in Python) refuses every irreversible call from the next tool call on, while reads keep working.
[09]
Step 5: Put a human in front of each irreversible call
Add an ApprovalPolicy for the irreversible tools. The handler here stands in for Slack or a review queue: it approves the first request and denies the second:
"""Step 5: a human approves each irreversible call, inside the same cap."""
import asyncio
from cleanup_agent import build_process
from demo_utils import report, run_task
from promptise.approval import ApprovalDecision, ApprovalPolicy
answers = iter([True, False]) # the reviewer approves the first request, denies the second
async def reviewer(request) -> ApprovalDecision: # stands in for Slack, a queue or a UI
approved = next(answers, False)
print(f"reviewer asked: {request.tool_name}({request.arguments}) -> "
f"{'approve' if approved else 'deny'}")
return ApprovalDecision(approved=approved, reviewer_id="alice",
reason=None if approved else "acct-102 has an open invoice")
async def main() -> None:
process = await build_process(
approval=ApprovalPolicy(tools=["delete_account", "email_owner"], handler=reviewer),
)
await process.start()
await run_task(process)
await report(process)
await process.stop()
asyncio.run(main())Error in BudgetGuard.on_tool_start callback: BudgetExceededError("Budget limit max_irreversible_per_run reached (limit 2); tool call 'delete_account' was not executed. Stop calling tools and give your final answer.")
permission_denied: kind=budget tool=delete_account decided_by=budget reason='budget limit max_irreversible_per_run reached (3 > 2)'
reviewer asked: delete_account({'account_id': 'acct-101'}) -> approve
reviewer asked: delete_account({'account_id': 'acct-102'}) -> deny
permission_denied: kind=approval tool=delete_account decided_by=reviewer reason='acct-102 has an open invoice'
ops_server: deleted acct-101
Budget violation: max_irreversible_per_run (limit=2, current=3) — action=pause
tool call: list_inactive_accounts {}
tool call: delete_account {'account_id': 'acct-101'}
tool call: delete_account {'account_id': 'acct-102'}
agent: Nightly cleanup run report …
- acct-101 — deleted (confirmed).
- acct-102 — DENIED: account has an open invoice (deletion not performed).
- acct-103 — not executed. Error returned: "BudgetExceededError: …"
…
state: suspended | suspend_reason: budget
accounts left: ['acct-102', 'acct-103', 'acct-200', 'acct-201']The reviewer was asked twice, not three times. The budget guard runs before the approval gate, so the third call was refused before anyone was bothered. That makes the cap a limit on approval requests as well, which helps against approval fatigue.
The denied call still counted. The guard counts a call when it lets it through to the approval step, so acct-102's denied deletion used one of the two slots and acct-103 was refused. One account was deleted, and the cap was spent. This errs on the safe side: an agent can't keep proposing deletions until a tired reviewer says yes. Size the cap with it in mind. The journal records both delete_account calls as tool_call entries, because they passed the guard. Read tool_result entries to see what actually happened.
[10]
Step 6: Audit every refusal
Every refusal fires the permission_denied lifecycle hook, whatever refused it. The refused hook in cleanup_agent.py printed the lines you saw in steps 3 and 5. In production, write them to your audit log or alert on them. The data has the same shape for every refusal, plus a few keys that depend on its kind:
kind | Refused by | Extra keys |
|---|---|---|
"budget" | A budget limit, for a tool call (tool_name set) or a whole run (max_runs_per_day, tool_name is None) | limit_name, limit_value, current_value; event_id for a refused run |
"approval" | The reviewer, a classifier rule, the gate, or a permission_request hook | request_id, decided_by as in the decision |
"guardrail" | The payload scan or the guardrail scanner, for a trigger event | event_id, trigger_type |
permission_denied can't block anything: it reports a decision already made. To refuse a call by policy before a human sees it, use a permission_request hook that raises HookBlocked, with fail_closed=True so a crashing policy hook blocks instead of letting the call through. The lifecycle hooks guide covers both.
[11]
Step 7: Know what rewind can't undo
Rewind rolls a process back to an earlier journal entry. It restores the agent's context state and conversation, nothing else:
"""Rewind restores the agent's memory of a run, not the accounts it deleted."""
import asyncio
import json
from cleanup_agent import HERE, JOURNAL, build_process
from demo_utils import run_task
from promptise.runtime.journal import FileJournal
def snapshot(process, label: str) -> None:
status = process.status()
accounts = sorted(json.loads((HERE / "accounts.json").read_text()))
print(f"{label}: conversation={status['conversation_messages']} messages, "
f"tool calls left today={status['budget']['tool_calls_day']}, accounts={accounts}")
async def main() -> None:
process = await build_process(max_tool_calls_per_day=100)
await process.start()
await run_task(process)
snapshot(process, "after the run ")
entries = await FileJournal(str(JOURNAL)).read("cleanup")
target = next(e for e in entries if e.entry_type == "trigger_event") # the run's first entry
result = await process.rewind(target.entry_id, "both", actor="alice")
print("rewind:", result.plan.summary)
snapshot(process, "after the rewind")
await process.stop()
asyncio.run(main())…
ops_server: deleted acct-101
ops_server: deleted acct-102
…
after the run : conversation=2 messages, tool calls left today=97, accounts=['acct-103', 'acct-200', 'acct-201']
rewind: rewind (both): 11 entries affected (2 conversation, 7 code/state).
after the rewind: conversation=0 messages, tool calls left today=97, accounts=['acct-103', 'acct-200', 'acct-201']After the rewind, the agent no longer remembers the run: its conversation is empty again. The two deleted accounts are still gone, and the daily budget is not refunded. Rewind is for an agent that went off track in its own head, as rewinding an AI agent explains. It is no undo button for the world. That is the strongest argument for the cap: the only safe irreversible action is one that never ran.
[12]
The complete example
irreversible-demo/
├── ops_server.py # MCP tools with honest annotations (step 1)
├── reset_db.py # a fresh accounts.json
├── irreversible.py # annotations -> BudgetConfig.tool_costs (step 2)
├── show_costs.py # print that map (step 2)
├── cleanup_agent.py # the process: cap of 2, full journal, permission_denied hook
├── demo_utils.py # run_task() and report() for the demos
├── cleanup.py # step 3: the third deletion is refused
├── daily_cap.py # step 4: per-run cap vs a daily ceiling
├── approvals.py # step 5: a reviewer inside the cap
├── rewind_demo.py # step 7: rewind restores memory, not accounts
├── scripted_model.py # a chat model that plays back a script (tests)
├── conftest.py # pytest fixtures: the scripted model, a cleanup process
└── test_cap.py # CI: the cap with a scripted modelpip install "promptise>=1.5.0" pytest pytest-asyncio
export OPENAI_API_KEY=sk-...
python reset_db.py && python cleanup.py # two deletions, the third refused
python reset_db.py && python approvals.py # one approved, one denied, one refused
python reset_db.py && python rewind_demo.py # rewind leaves the accounts deleted
pytest test_cap.py # no API key needed[13]
Running it in production
Test the cap in CI
A cap you have never seen fire is a guess. Test it with a scripted model that always asks for three deletions, so the test is the same on every run and needs no API key. conftest.py has two fixtures: model, which swaps the real model for a ScriptedModel, and cleanup, which starts the cleanup process on a temporary copy of the database. Both are explained in testing autonomous AI agents.
"""CI checks for the irreversible cap: a scripted model, no API key, no real accounts."""
import json
from demo_utils import run_task
DELETE_ALL = [("delete_account", {"account_id": a}) for a in ("acct-101", "acct-102", "acct-103")]
async def test_third_deletion_is_refused(model, cleanup):
process, db = cleanup
model.script = [DELETE_ALL, "Deleted what the budget allowed."]
await run_task(process)
assert sorted(json.loads(db.read_text())) == ["acct-103", "acct-200", "acct-201"]
assert process.status()["suspend_reason"] == "budget"
async def test_zero_turns_irreversible_tools_off(model, cleanup):
process, db = cleanup
process.config.budget.max_irreversible_per_run = 0 # what PATCH .../budget does
process.apply_governance_config("budget")
model.script = [DELETE_ALL[:1], "Nothing was deleted."]
await run_task(process)
assert len(json.loads(db.read_text())) == 5test_cap.py::test_third_deletion_is_refused PASSED [ 50%]
test_cap.py::test_zero_turns_irreversible_tools_off PASSED [100%]
============================== 2 passed in 3.03s ===============================The second test checks the incident switch: with the cap at 0, not even one deletion runs.
Size the cap from real runs
Look at a week of journals or traces and count the irreversible calls per run. Set the cap just above what a healthy run needs. For a nightly job that deletes a handful of records, the right cap might be 10, not 1,000. Review every run that hits it: it is either a bug, an attack or a job that needs a different design, such as a batch tool with its own checks.
Make irreversible tools safer on their own
The cap limits how many actions run. Your tools decide how bad each one is. Give tools idempotency keys, so a retried run doesn't send the same email twice. Prefer soft deletes with a purge job. Let a send tool queue the message and deliver it a few minutes later, so there is time to cancel it. Return clear results ("deleted", "already deleted", "refused: account has an open invoice") so the model can report honestly. If the MCP server must enforce approval for every client, not only this agent, mark the tool requires_approval=True and install an ApprovalGateMiddleware on the server.
Don't redeliver dead letters blindly
process.dead_letters holds events that didn't run, and redeliver_dead_letters() puts them back on the queue. For an agent with irreversible tools, check the journal before you redeliver: an event dead-lettered as "process stopped" may have done part of its work. In Promptise 1.5.0, a stop() that arrives while a finished run's task_completed hooks are still running dead-letters that finished event too. A blind redelivery would then run the cleanup again.
Alert on refusals, not only on failures
A refused call is not an error in the run: the run completes and reports. Without an alert, a paused agent can sit for days. Use on_exceeded="escalate" with an escalation target, or send permission_denied hook data to your alerting. Budget refusals also emit budget.exceeded and process.suspended events to the runtime's event notifier.
[14]
Common problems and fixes
Symptom | Cause | Fix |
|---|---|---|
The cap never fires | The tool name in tool_costs doesn't match the name the agent sees | Build tool_costs from the server's tool list, as in step 2 |
An email tool isn't counted | It is annotated additive (destructiveHint=False) | Add it by name: also=("email_owner",) |
The agent stops after one deletion with approval on | Denied approvals use up the cap | Size the cap for approved plus denied calls |
The process is suspended after a normal-looking run | One call was refused, and on_exceeded="pause" applied | Check permission_denied data; resume() when it's safe |
Logs show | LangChain logs the refusal raised by the guard | Expected; filter it or alert on permission_denied instead |
A resumed agent deletes more | The cap resets every run | Add a daily ceiling with cost_weight and max_cost_per_day |
Rewind didn't restore deleted data | Rewind restores context and conversation only | Restore from backups; prevent with the cap and approvals |
A refused call is missing from the journal | The guard refuses before the journal records the call | Record refusals from the permission_denied hook |
[15]
Limitations
Counts, not judgment. The cap knows how many irreversible calls it let through, not whether they were right. The first N wrong actions still happen.
One shared counter. All irreversible tools count together. A separate limit per tool needs a permission_request hook or a second process.
Per run only. There is no daily irreversible limit; the cost-weight workaround shares the daily cost counter with every other tool.
Annotations are hints. The budget never reads them. A tool from an untrusted server can be marked read-only and still delete.
Approval denials count. This errs on the safe side. Plan the cap for it.
No undo. Neither rewind nor the journal reverses external side effects.
[16]
Frequently asked questions
How do I stop an AI agent from deleting too much data?
Count deletions and other irreversible calls separately and refuse the call that would exceed a small per-run limit before it runs. In Promptise Foundry, set max_irreversible_per_run and mark the tools with ToolCostAnnotation(irreversible=True). The refused call never runs, the model is told to stop, and the process pauses until someone resumes it.
What is the difference between a destructive and an irreversible action?
A destructive action destroys or overwrites data, such as a hard delete. An irreversible action is any action you can't take back, which also includes additive ones like sending an email, posting a message or charging a card. MCP's destructiveHint covers only the first group, so mark irreversible tools by hand as well.
Does Promptise read the MCP destructiveHint annotation?
Its approval classifier and its caching do: a tool annotated destructiveHint=True is never auto-approved as read-only and never cached. The autonomy budget doesn't. It counts irreversible calls only for tools listed in BudgetConfig.tool_costs with irreversible=True, so build that map from the annotations yourself, as this guide's helper does.
Should I use human approval or a per-run cap for risky tool calls?
Use both for high-stakes tools. Approval checks each action; the cap limits how many actions, and how many approval requests, one run can produce. The cap runs first, so a runaway run never floods the reviewer. For lower-stakes irreversible tools, the cap alone is often enough.
What happens when an AI agent hits its irreversible action limit?
The call that would exceed the limit is refused before it runs and returns an error the model reads as the tool result. The permission_denied hook fires with kind="budget". When the run ends, the budget's on_exceeded action applies: by default the process is suspended until someone resumes it.
Can I undo an agent's actions with rewind?
No. Rewind restores the agent's context state and conversation to an earlier journal entry, so it forgets what it did. Deleted records stay deleted, sent emails stay sent, and spent budget is not refunded. Treat rewind as a tool for the agent's memory, and prevent irreversible mistakes with caps, approvals and reversible tool design.
[17]
Next steps
Add reviewers and timeouts to risky calls: human-in-the-loop approval for AI agents.
Limit tool calls, LLM turns and cost per run and day: AI agent cost control.
Roll back an agent's memory, and see what it doesn't restore: rewind an AI agent.
React to refusals and approvals in code: AI agent lifecycle hooks.
Test the cap without a model: test autonomous AI agents.
Secure the tools themselves: MCP security best practices.
Reference: autonomy budget, lifecycle hooks, rewind and approval in the Promptise docs.