@ramcharan_2020/agent-undo
v0.3.0
Published
Saga pattern engine for AI agent tool execution — automatically roll back side effects when an agent fails mid-task
Downloads
757
Maintainers
Readme
agent-undo 🛡️
Saga pattern engine for AI agent tool execution.
Automatically roll back side effects when an agent fails mid-task.
Status: v0.3 — the core saga engine, Vercel AI SDK / Mastra adapters, and MCP server are stable and tested. Saga state defaults to in-memory storage, but an optional SQLite backend is available via
SqliteSagaStoragefor crash-resistant persistence — see thecrash-recovery-demo.tsdemo.
npm install @ramcharan_2020/agent-undoThe Problem
Imagine a company called StoreBot, which builds AI customer-service agents for e-commerce brands.
When a customer says "I want to return item #882 and get a refund," StoreBot's AI agent runs three steps:
Step 1: Mark item #882 as returned in database (DB Write)
Step 2: Issue $40 refund via Stripe (Payment API)
Step 3: Generate shipping label via FedEx (Shipping API)Without agent-undo ❌
At Step 3, FedEx's API goes down and throws a 500 error. The agent crashes.
- ✅ The item is marked as returned in the DB.
- ✅ The $40 refund went through.
- ❌ No shipping label was generated, and the system is left in a broken, half-finished state.
- 😰 An engineer has to wake up at 2 AM to fix the database and refund manually.
With agent-undo ✅
StoreBot's developer installs agent-undo and wraps the tools with undo handlers:
const returnItem = defineTool({
name: 'return_item',
execute: async ({ itemId }) => db.items.update(itemId, { status: 'returned' }),
undo: async (_, { itemId }) => db.items.update(itemId, { status: 'active' }),
});
const refund = defineTool({
name: 'refund_payment',
execute: async ({ chargeId }) => stripe.refunds.create({ chargeId }),
undo: async (refund) => stripe.refunds.cancel(refund.id),
});Now, when FedEx's API crashes at Step 3:
agent-undocatches the crash instantly.- It inspects its transaction stack and sees Steps 1 and 2 completed.
- It automatically triggers
refund.undo()(cancels the refund). - It triggers
returnItem.undo()(marks the item back as active). - It throws a clean error:
"Task failed at step "step-3" (generate_label). All prior side-effects successfully rolled back."
Quickstart
1. Define your tools with undo handlers
import { defineTool } from '@ramcharan_2020/agent-undo';
const deductInventory = defineTool({
name: 'deduct_inventory',
execute: async ({ sku, qty }) => inventory.decrement(sku, qty),
undo: async (_, { sku, qty }) => inventory.increment(sku, qty),
});
const chargeCustomer = defineTool({
name: 'charge_customer',
execute: async ({ customerId, amount }) => stripe.charges.create({ customerId, amount }),
undo: async (charge) => stripe.refunds.create({ chargeId: charge.id }),
});2. Execute them in a saga transaction
import { SagaTransaction } from '@ramcharan_2020/agent-undo';
const saga = new SagaTransaction({ name: 'checkout-flow' });
try {
await saga.execute(deductInventory, { sku: 'abc', qty: 1 });
await saga.execute(chargeCustomer, { customerId: 'cus_123', amount: 2999 });
await saga.execute(sendEmail, { to: '[email protected]' }); // This fails!
} catch (error) {
// saga.status === 'ROLLED_BACK'
// Inventory was re-added, charge was refunded — system is clean.
console.log(error.message);
// "Task failed at step "step-3" (send_email). All prior side-effects successfully rolled back."
}3. Check the status
console.log(saga.status); // 'ROLLED_BACK' | 'COMPLETED' | 'FAILED'
console.log(saga.steps); // Array of each step with its stateIntegrations
agent-undo provides first-class adapters for popular AI agent frameworks.
Vercel AI SDK
Automatically wraps Vercel AI SDK tools with saga tracking and hooks into the agent loop lifecycle via onStepFinish.
import { generateText } from 'ai';
import { createVercelSaga } from '@ramcharan_2020/agent-undo/adapters/vercel-ai-sdk';
import { z } from 'zod';
const { tools, saga, onStepFinish } = createVercelSaga({
return_item: {
description: 'Mark an item as returned in the database',
parameters: z.object({ itemId: z.string() }),
execute: async ({ itemId }) => db.items.update(itemId, { status: 'returned' }),
undo: async (result, { itemId }) => db.items.update(itemId, { status: 'active' }),
},
refund_payment: {
description: 'Issue a refund',
parameters: z.object({ chargeId: z.string() }),
execute: async ({ chargeId }) => stripe.refunds.create({ chargeId }),
undo: async (refund) => stripe.refunds.cancel(refund.id),
},
});
const result = await generateText({
model: openai('gpt-4o'),
tools,
maxSteps: 5,
onStepFinish, // ← auto-detects failures & triggers rollback
});
if (saga.status === 'ROLLED_BACK') {
console.log('All side-effects were automatically undone.');
}How it works
- Each tool's
executeis wrapped:addStep()→ run original →markCompleted()ormarkFailed() execOptions(toolCallId,abortSignal) are properly forwarded to your execute functiononStepFinishcheckstoolResultsfor errors and triggerssaga.rollback()acrossmaxStepsrounds- Tools without
undoare safely skipped during rollback
Mastra
agent-undo also provides an adapter for Mastra that wraps your tool configs
with saga tracking, matching the shape createTool() expects.
import { Agent } from '@mastra/core/agent';
import { createMastraSaga } from '@ramcharan_2020/agent-undo/adapters/mastra';
import { z } from 'zod';
const { tools, saga } = createMastraSaga({
return_item: {
id: 'return_item',
description: 'Mark an item as returned in the database',
inputSchema: z.object({ itemId: z.string() }),
execute: async ({ itemId }) => db.items.update(itemId, { status: 'returned' }),
undo: async (result, { itemId }) => db.items.update(itemId, { status: 'active' }),
},
refund_payment: {
id: 'refund_payment',
description: 'Issue a refund for a charge',
inputSchema: z.object({ chargeId: z.string() }),
execute: async ({ chargeId }) => stripe.refunds.create({ chargeId }),
undo: async (refund) => stripe.refunds.cancel(refund.id),
},
});
const agent = new Agent({
tools: Object.values(tools),
});
// If a tool fails, check the saga:
if (saga.status === 'ROLLED_BACK') {
console.log('All side-effects were automatically undone.');
}Same rollback semantics as the core engine — failures are wrapped in
SagaRollbackError, and rollback runs in LIFO order automatically.
MCP Server
agent-undo ships an MCP server so any MCP-compatible agent (Claude Code, Claude
Desktop, etc.) can track and roll back multi-step tasks without any TypeScript
integration — just tool calls.
Setup
Add it to your MCP client config (e.g. Claude Code's mcp_servers.json):
{
"mcpServers": {
"agent-undo": {
"command": "npx",
"args": ["-y", "@ramcharan_2020/agent-undo", "agent-undo-mcp"]
}
}
}Tools exposed
| Tool | Purpose |
|------|---------|
| saga_begin | Start a new saga transaction |
| saga_add_step | Register a step along with a shell command that undoes it |
| saga_complete_step | Mark a registered step as completed |
| saga_fail_step | Mark a step as failed |
| saga_rollback | Run the undo command for every completed step, in reverse order |
| saga_status | Get the current saga's status and step summary |
| saga_list_steps | Full detail on every step (input, output, error, timestamps) |
How it works
Since MCP tools are plain function calls with no access to your in-process JS
closures, undo actions here are shell commands rather than JS functions —
you (or the calling agent) supply an undoCommand string when registering a
step, and saga_rollback executes each one via the shell, in reverse order,
skipping any step that doesn't have one.
Agent: saga_begin({ name: "checkout-flow" })
Agent: saga_add_step({ toolName: "create_backup", undoCommand: "rm -f backup.json" })
Agent: saga_complete_step({ stepId: "step-1" })
...
Agent: saga_fail_step({ stepId: "step-3", error: "shipping API down" })
Agent: saga_rollback() // runs "rm -f backup.json", etc., in LIFO orderBecause undo commands run via the shell with no sandboxing, treat them the same as any tool that executes agent-supplied commands: scope them to safe, reversible operations, and review what an agent registers as an undo command before trusting it in a workflow that touches real infrastructure.
API Reference
defineTool({ name, execute, undo? })
Creates a typed tool definition with an optional compensating undo action.
| Param | Type | Description |
|-------|------|-------------|
| name | string | Human-readable name |
| execute | (input) => Promise<output> | The forward action |
| undo | (output, input) => Promise<void> | Optional compensating action |
new SagaTransaction(options?)
Creates a new saga transaction that manages the LIFO rollback stack.
| Option | Default | Description |
|--------|---------|-------------|
| name | saga-{timestamp} | Name for debugging |
| autoRollbackOnFailure | true | Auto-rollback on step failure |
Methods
| Method | Returns | Description |
|--------|---------|-------------|
| execute(tool, input) | Promise<StepResult> | Execute a tool and auto-rollback on failure |
| addStep(tool, input) | string (step ID) | Register a step without executing it |
| markCompleted(stepId, output) | void | Mark a step as completed |
| markFailed(stepId, error) | void | Mark a step as failed |
| rollback() | Promise<void> | Trigger rollback of all completed steps |
Properties
| Property | Type | Description |
|----------|------|-------------|
| status | 'IN_PROGRESS' \| 'COMPLETED' \| 'FAILED' \| 'ROLLED_BACK' \| 'ROLLBACK_FAILED' | Overall transaction status |
| steps | readonly SagaStep[] | Snapshot of all steps |
| name | string | Transaction name |
wrapTools(tools, options?)
Wraps tool definitions with saga tracking — each call is auto-recorded.
const { execute, saga } = wrapTools({
return_item: returnItemDef,
refund: refundDef,
});
await execute.return_item({ itemId: '882' });
await execute.refund({ chargeId: 'ch_123' });
// If anything fails, saga auto-rolls back
console.log(saga.status);SagaRollbackError
Thrown when a saga transaction fails and auto-rollback completes.
try {
await saga.execute(fragileTool, input);
} catch (error) {
if (error instanceof SagaRollbackError) {
console.log(error.message); // Clean summary
console.log(error.cause); // Original error
}
}Step States
PENDING → COMPLETED → ROLLED_BACK
→ FAILED
→ COMPLETED → ROLLBACK_FAILED| State | Meaning |
|-------|---------|
| PENDING | Step registered but not yet executed |
| COMPLETED | Step executed successfully |
| FAILED | Step execution threw an error |
| ROLLED_BACK | Step was undone via its undo handler |
| ROLLBACK_FAILED | The undo handler itself threw an error |
Handling ROLLBACK_FAILED — When Undo Itself Fails
ROLLBACK_FAILED is the most critical state in the saga lifecycle. It means:
A tool executed successfully, but its undo handler threw an error during rollback. The rollback may be partially complete — some steps were undone, others were not. The system is in an unknown, potentially inconsistent state.
This is the one failure mode that cannot be automatically resolved by the saga engine. It requires your attention.
Detecting ROLLBACK_FAILED
Check the saga status:
if (saga.status === 'ROLLBACK_FAILED') {
// At least one undo handler failed — the system may be inconsistent
}Catch the SagaRollbackError at the call site:
When autoRollbackOnFailure is true (the default) and rollback itself
fails, saga.execute() throws a SagaRollbackError whose message
distinguishes this case from a clean rollback:
try {
await saga.execute(tool, input);
} catch (error) {
if (error instanceof SagaRollbackError) {
if (saga.status === 'ROLLBACK_FAILED') {
// ROLLBACK_FAILED — manual intervention required!
console.error(error.message);
} else {
// Clean rollback — all prior steps undone successfully
}
}
}Inspect individual steps to find which ones failed:
for (const step of saga.steps) {
if (step.state === 'ROLLBACK_FAILED') {
console.error(
`Undo handler failed for step "${step.toolName}":`,
step.error,
);
}
if (step.state === 'ROLLED_BACK') {
console.log(
`Step "${step.toolName}" was successfully rolled back.`,
);
}
if (step.state === 'COMPLETED') {
console.warn(
`Step "${step.toolName}" was NOT rolled back — its side effects remain!`,
);
}
}When ROLLBACK_FAILED occurs, you will typically see a mix of states:
- Some steps will be
ROLLED_BACK(undone successfully) - Some steps will be
ROLLBACK_FAILED(the undo handler itself threw) - Some steps may still be
COMPLETEDif the rollback stopped early
Your toolkit for handling ROLLBACK_FAILED
There are three recovery strategies, each appropriate for different scenarios:
1. Alerting (immediate notification)
At minimum, set up an alert when ROLLBACK_FAILED is detected. This is essential for production systems.
try {
await saga.execute(someTool, input);
} catch (error) {
if (saga.status === 'ROLLBACK_FAILED') {
// Send to PagerDuty, Slack, or your monitoring system
await alertOncall({
severity: 'critical',
message: `Saga "${saga.name}" entered ROLLBACK_FAILED state`,
details: saga.steps
.filter((s) => s.state !== 'ROLLED_BACK')
.map((s) => `${s.toolName}: ${s.state} — ${s.error}`),
});
}
}2. Retry with idempotent undo handlers
Important:
saga.rollback()only acts on steps still inCOMPLETEDstate. If a previous rollback attempt completed the LIFO loop (even with failures), there are noCOMPLETEDsteps left to retry — the failed step is alreadyROLLBACK_FAILEDand remaining steps were already attempted.Retry must be built into the undo handler itself, not the rollback call.
Design your undo handlers to internally retry transient failures. The
rollback() method catches all handler errors and continues, so your
handler's own retry is its only chance to succeed:
const shipOrder = defineTool({
name: 'ship_order',
execute: async ({ orderId }) => shippingApi.createLabel(orderId),
undo: async (label, { orderId }) => {
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
try {
await shippingApi.cancelLabel(label.id);
return; // Success
} catch (error) {
attempts++;
if (attempts >= maxAttempts) {
// Re-throw so the saga marks this as ROLLBACK_FAILED
throw error;
}
// Exponential backoff before retry
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempts)));
}
}
},
});Caveat: Only retry if the failure is transient (network timeout, rate limit). Logic bugs and auth failures won't resolve with retries — let those fail fast and go to the dead-letter queue.
3. Dead-letter queue (DLQ) — defer to manual intervention
For the worst case — where rollback keeps failing and you need to preserve full context — record the failed saga to a persistent store for manual inspection.
interface DeadLetterSaga {
name: string;
failedAt: string;
steps: SagaStep[];
error: unknown;
}
async function sendToDeadLetterQueue(saga: SagaTransaction): Promise<void> {
const dlqEntry: DeadLetterSaga = {
name: saga.name,
failedAt: new Date().toISOString(),
steps: [...saga.steps],
error: saga.steps.find((s) => s.state === 'ROLLBACK_FAILED')?.error,
};
// Write to a dead-letter table, file, or external system
await db.deadLetterQueue.insert(dlqEntry);
// Notify the operations team
await notifyOps({
channel: '#saga-alerts',
message: `Saga "${saga.name}" sent to dead-letter queue. Manual remediation required.`,
link: `https://admin.example.com/dlq/${saga.name}`,
});
}Why undo handlers fail (and how to prevent it)
Common reasons undo handlers throw, and how to harden them:
| Cause | Mitigation | |-------|------------| | Network failure (API down during undo) | Make undo handlers idempotent and retry-safe. Use the retry pattern above. | | Missing resource (the record was deleted between execute and undo) | Design undos as "best effort" — if the resource doesn't exist, treat that as success. | | Logic bug (the undo handler itself has a bug) | Unit-test your undo handlers just like your forward handlers. | | Expired token / auth (credentials rotated mid-saga) | Refresh credentials before attempting rollback, or keep short-lived sagas. |
Design your undo handlers for resilience
The best defense against ROLLBACK_FAILED is writing robust undo handlers. Follow these principles:
Idempotency: An undo handler should be safe to call multiple times. If the resource is already in the undone state, the second call should be a no-op.
undo: async (_, { chargeId }) => {
const charge = await stripe.charges.retrieve(chargeId);
if (charge.status !== 'refunded') {
await stripe.refunds.create({ chargeId });
} else {
console.log(`Charge ${chargeId} already refunded — skipping`);
}
}Log generously: Record every undo attempt so you have an audit trail if something goes wrong.
Fail gracefully: If an undo cannot complete, log enough context (input, output, error) for a human to fix it manually.
Architecture
@ramcharan_2020/agent-undo/
├── src/
│ ├── types.ts # Core types & interfaces
│ ├── saga-transaction.ts # SagaTransaction + SagaRollbackError
│ ├── define-tool.ts # defineTool() helper
│ ├── wrap-tools.ts # wrapTools() helper
│ ├── index.ts # Public API exports
│ ├── __tests__/saga.test.ts # core engine tests
│ ├── mcp/
│ │ └── server.ts # MCP server (saga_begin, saga_add_step, ...)
│ └── adapters/
│ ├── vercel-ai-sdk.ts # Vercel AI SDK integration
│ ├── mastra.ts # Mastra integration
│ └── __tests__/ # adapter tests
├── test-demo.ts # StoreBot demo script
├── checkout-demo.ts # Checkout flow demo
├── package.json
├── tsconfig.json
└── README.mdDevelopment
# Install dependencies
npm install
# Run tests
npm test
# Type check
npm run typecheck
# Build
npm run build
# Run the demos
npx tsx test-demo.ts # StoreBot quick demo
npx tsx checkout-demo.ts # Full checkout flow with rollback
npx tsx crash-recovery-demo.ts # SQLite persistence & crash recovery
Roadmap
- [x] Phase 1: Core saga engine (SagaTransaction, LIFO rollback)
- [x] Phase 2: Vercel AI SDK adapter (
createVercelSaga) - [x] Phase 2.5: Mastra adapter (
createMastraSaga) + MCP server (agent-undo-mcp) - [ ] Phase 3: State persistence & crash recovery (SQLite/Supabase)
- [ ] Phase 4: Visual dashboard & human-in-the-loop approvals
- [ ] Phase 5: v1.0 stable release & launch
License
MIT
