npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 SqliteSagaStorage for crash-resistant persistence — see the crash-recovery-demo.ts demo.

npm install @ramcharan_2020/agent-undo

The 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:

  1. agent-undo catches the crash instantly.
  2. It inspects its transaction stack and sees Steps 1 and 2 completed.
  3. It automatically triggers refund.undo() (cancels the refund).
  4. It triggers returnItem.undo() (marks the item back as active).
  5. 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 state

Integrations

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 execute is wrapped: addStep() → run original → markCompleted() or markFailed()
  • execOptions (toolCallId, abortSignal) are properly forwarded to your execute function
  • onStepFinish checks toolResults for errors and triggers saga.rollback() across maxSteps rounds
  • Tools without undo are 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 order

Because 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 COMPLETED if 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 in COMPLETED state. If a previous rollback attempt completed the LIFO loop (even with failures), there are no COMPLETED steps left to retry — the failed step is already ROLLBACK_FAILED and 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.md

Development

# 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