ai agents

Compensating Actions for Tool-Using AI Agents

Learn how to make AI agent side effects safer with compensating actions, recovery policies, idempotent undo steps, and operator-ready audit trails.

Introduction

Tool-using AI agents are most valuable when they can do real work: open pull requests, update tickets, send messages, change configuration, start deployments, or call internal APIs. Those actions are also where reliability stops being a prompt-engineering problem and becomes a distributed-systems problem. A model can choose a sensible action, the tool can partly succeed, and the workflow can still end in a state that nobody intended.

Retries and idempotency keys reduce duplicate writes, but they do not answer every recovery question. What should happen if an agent created the wrong ticket, moved a customer to the wrong plan, posted a message to the wrong channel, or started a deployment that later failed validation? Some outcomes need a compensating action: a deliberate follow-up step that repairs, reverses, neutralizes, or clearly escalates the side effect.

This article shows how to design compensating actions for tool-using AI agents. The examples use TypeScript and SQL, but the pattern applies to workflow engines, queues, background workers, serverless jobs, and long-running agent runtimes.

Treat Every Write Tool as a Side-Effect Contract

A compensating action is not an afterthought bolted onto a failed workflow. It starts with the contract for each write tool. Before an agent can call a tool, the runtime should know what the tool changes, whether the change can be reversed, how to prove the outcome, and who must approve a repair.

Useful tool metadata includes:

  • effect: the business effect of the tool, not just the function name.
  • impact: whether the action is low-risk, customer-visible, financial, destructive, or security-sensitive.
  • idempotency: how repeated execution is deduplicated.
  • reconcile: how to prove whether the side effect happened.
  • compensate: what action can repair or neutralize the effect.
  • approval: whether the forward action or compensation needs human approval.

Here is a compact TypeScript shape:

type ToolImpact = "read" | "write" | "customer_visible" | "destructive";

type CompensationMode =
  | "automatic"
  | "approval_required"
  | "manual_runbook"
  | "not_supported";

type WriteToolContract<TArgs, TResult> = {
  name: string;
  impact: ToolImpact;
  validate(args: unknown): TArgs;
  execute(args: TArgs, context: ToolContext): Promise<TResult>;
  reconcile(effect: StoredEffect): Promise<ReconciliationResult>;
  compensation: {
    mode: CompensationMode;
    reason: string;
    buildArgs?: (effect: StoredEffect) => unknown;
    execute?: (args: unknown, context: ToolContext) => Promise<unknown>;
  };
};

This contract forces design decisions into code review. If a tool says not_supported, that is acceptable only when the team has consciously accepted the operational cost. For example, sending an email cannot be undone. The compensation may be a follow-up correction message, a support task, and an audit note rather than a literal rollback.

Prefer forward recovery over fake rollback

Distributed systems rarely roll back perfectly. A payment authorization, user notification, deployment, or third-party API update may have already escaped your boundary. In those cases, the better compensation is forward recovery: create a correcting event, move the workflow to a safe state, and make the user-visible outcome explicit.

For an AI agent, this distinction matters because the model may describe a compensation as "undo that change" even when undo is impossible. The runtime should expose precise actions such as closeTicket, restorePreviousConfig, postCorrection, revertPullRequest, or requestHumanReview. The model can propose intent, but the workflow chooses from concrete recovery tools.

Store Forward Effects and Compensation State

Compensations need durable facts. A chat transcript may explain why the agent acted, but recovery code needs structured records: what tool ran, which arguments were used, what external resource changed, whether the outcome is proven, and which compensation has already been attempted.

A relational schema can separate the forward effect from the compensation lifecycle:

create type agent_effect_status as enum (
  'planned',
  'executing',
  'succeeded',
  'failed',
  'uncertain',
  'compensation_pending',
  'compensating',
  'compensated',
  'manual_review'
);

create table agent_effects (
  id uuid primary key,
  run_id uuid not null,
  step_id text not null,
  tool_name text not null,
  effect_key text not null,
  idempotency_key text not null,
  arguments jsonb not null,
  result jsonb,
  external_ref text,
  status agent_effect_status not null,
  compensation_mode text not null,
  compensation_args jsonb,
  compensation_attempts integer not null default 0,
  last_error text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (run_id, effect_key)
);

create index agent_effects_recovery_idx
  on agent_effects (status, updated_at);

The effect_key is the logical identity of the side effect within one run. It should be stable across retries and worker restarts. A common pattern is to combine the step id, tool name, target resource, and a stable hash of validated arguments. If the agent re-plans the same action after a crash, the unique constraint returns the existing effect record instead of creating another one.

Record enough context to compensate safely

The compensation should not depend on a model remembering what happened. Store the fields needed to repair the effect before or immediately after executing the forward action.

For example, a configuration update tool should record the previous value, new value, config version, and resource id. A ticket update tool should record the prior status and comment id. A pull request tool should record the branch, commit, PR number, and repository. If you discover during recovery that the saved context is incomplete, route the effect to manual_review instead of asking the model to guess.

Decide When to Compensate

Not every failure should trigger compensation. If the forward action never ran, there is nothing to undo. If the outcome is uncertain, the system should reconcile first. If a compensation could make the situation worse, the right move is operator review.

Use a small decision table:

Forward outcome Example Recovery action
Not started Worker crashed before tool call Retry forward action
Succeeded but later invalid Deployment succeeded, smoke test failed Run approved compensation
Uncertain API timeout after request was sent Reconcile before retry or compensate
Irreversible Email sent to user Create correction task or follow-up message
High impact Billing, security, deletion Require human approval

The workflow should apply that table deterministically. The model may help summarize context for a human, but it should not be the only component deciding whether to reverse a customer-visible change.

Here is one way to encode the decision:

type RecoveryDecision =
  | { action: "retry_forward" }
  | { action: "reconcile_first" }
  | { action: "compensate_now" }
  | { action: "request_approval"; reason: string }
  | { action: "manual_review"; reason: string };

function chooseRecovery(effect: StoredEffect): RecoveryDecision {
  if (effect.status === "planned" || effect.status === "failed") {
    return { action: "retry_forward" };
  }

  if (effect.status === "uncertain") {
    return { action: "reconcile_first" };
  }

  if (effect.compensationMode === "not_supported") {
    return {
      action: "manual_review",
      reason: "tool_has_no_automatic_compensation",
    };
  }

  if (effect.compensationMode === "approval_required") {
    return {
      action: "request_approval",
      reason: "compensation_requires_human_approval",
    };
  }

  if (effect.status === "compensation_pending") {
    return { action: "compensate_now" };
  }

  return {
    action: "manual_review",
    reason: `unsupported_recovery_state:${effect.status}`,
  };
}

This function is intentionally boring. Recovery logic should be predictable, testable, and visible in logs. The agent can still explain why a compensation is needed, but the runtime should enforce the rules.

Execute Compensations with the Same Discipline as Forward Actions

A compensation is also a side effect. It needs idempotency, policy checks, leases, audit events, and observability. Otherwise the repair path becomes another source of production incidents.

The compensation worker should:

  1. Acquire one eligible effect with a lease.
  2. Reconcile uncertain outcomes before changing anything.
  3. Re-check policy and approval state.
  4. Build compensation arguments from stored facts.
  5. Execute with a compensation idempotency key.
  6. Mark the effect as compensated, manual review, or failed with a clear reason.
async function compensateEffect(effect: StoredEffect, registry: ToolRegistry) {
  const contract = registry.getWriteTool(effect.toolName);
  const decision = chooseRecovery(effect);

  if (decision.action === "reconcile_first") {
    const result = await contract.reconcile(effect);
    await storeReconciliation(effect.id, result);
    return;
  }

  if (decision.action === "request_approval") {
    await requestCompensationApproval(effect.id, decision.reason);
    return;
  }

  if (decision.action !== "compensate_now") {
    await markManualReview(effect.id, decision.reason ?? decision.action);
    return;
  }

  if (!contract.compensation.execute || !contract.compensation.buildArgs) {
    await markManualReview(effect.id, "missing_compensation_implementation");
    return;
  }

  const args = contract.compensation.buildArgs(effect);
  const idempotencyKey = `${effect.id}:compensation`;

  await contract.compensation.execute(args, {
    runId: effect.runId,
    idempotencyKey,
    reason: "agent_effect_compensation",
  });

  await markCompensated(effect.id, { idempotencyKey });
}

The idempotency key for compensation should be different from the forward action key. Retrying the repair should deduplicate with previous repair attempts, not with the original action.

Do not compensate stale state blindly

Before a compensation changes an external system, check whether the resource has changed since the agent touched it. If a human already fixed the ticket, a deploy pipeline already rolled back, or a newer workflow updated the same config value, automatic compensation may overwrite good work.

Common stale-state checks include:

  • Version numbers or ETags.
  • Last-modified timestamps.
  • Current owner or status fields.
  • The exact comment, branch, deployment, or message id created by the agent.
  • A compare-and-swap update when the external API supports it.

If the current state no longer matches the expected post-action state, stop and create a manual review item. "Do no further damage" is more important than making every recovery path automatic.

Make Operators Part of the Recovery Design

Some compensations should be automatic. Many should not. Human review is not a failure of agent design; it is the right boundary for actions with ambiguous state, irreversible consequences, or customer impact.

An operator-ready review item should include:

  • The original objective and run id.
  • The model decision that proposed the action.
  • The validated tool arguments.
  • The external resource that changed.
  • The reconciliation result.
  • The proposed compensation and its risk.
  • The reason approval is required.
  • Links to logs, traces, and audit records.

Avoid presenting only a raw transcript. Transcripts are useful context, but operators need structured facts and a small set of allowed actions. A good review screen offers commands such as "approve compensation", "mark already repaired", "retry reconciliation", or "escalate to incident".

Measure compensation quality

Compensation paths should have their own metrics:

  • Effects by status and tool.
  • Time from failed validation to compensation completion.
  • Automatic versus manual compensation rate.
  • Compensation failures by reason.
  • Stale-state conflicts.
  • Irreversible effects created by agents.
  • Human approval latency.

These metrics show where the system is too risky, too manual, or missing enough context to recover. If a particular tool often lands in manual review because it cannot prove outcomes, the next engineering task is not a better prompt. It is a better reconciliation endpoint, resource identifier, or side-effect contract.

Test Compensation Paths Before Production Incidents

Compensation code often sits idle until something has already gone wrong. That makes it easy for schemas, permissions, or third-party APIs to drift. Treat compensations as first-class behavior and test them alongside forward actions.

Start with unit tests for recovery decisions:

import assert from "node:assert/strict";

const effect = {
  status: "compensation_pending",
  compensationMode: "approval_required",
} as StoredEffect;

assert.deepEqual(chooseRecovery(effect), {
  action: "request_approval",
  reason: "compensation_requires_human_approval",
});

Then add integration tests around representative tools:

  • Forward action succeeds, validation fails, compensation succeeds.
  • Forward action times out, reconciliation proves success, compensation is requested.
  • Compensation retry uses the same compensation idempotency key.
  • Resource state changed externally, so the effect moves to manual review.
  • Tool has no automatic compensation, so the operator item has enough context.

For agent workflows, also test that model retries cannot create multiple compensation records for the same effect. The runtime should own that invariant through database constraints and idempotency keys.

Conclusion and Next Steps

Compensating actions make tool-using AI agents safer because they acknowledge a basic truth: side effects do not always line up with workflow state. A useful agent runtime records what it intended to change, proves what actually changed, and has a deliberate path for repairing, reversing, or escalating the result.

For your next write-capable agent tool, define the side-effect contract before exposing it to the model. Store the forward effect, give it a reconciliation strategy, decide whether compensation is automatic or approval-gated, and test the repair path under failure. That work is less glamorous than a smarter prompt, but it is what turns agent autonomy into an operationally survivable system.