Skip to content
Context Windowby Alex Janjic
Menu
contextwindow.us/posts/make-agent-tool-retries-idempotent-before-enabling-automatic-recoveryArticle

Make agent tool retries idempotent before enabling automatic recovery

An agent may retry a tool call after a timeout even though the first call finished. Give each action a stable key and save its result so a retry returns that result instead of creating a second ticket, payment, message, or deployment.

AI systemsSoftware engineering
Source links stay with the relevant section
An action passes through a saved execution record. A retry loops back to the same record and returns the saved result instead of running the outside action twice.

A timeout does not mean the action failed

An agent calls a tool to create a ticket. The ticket is created, but the reply is lost before the agent receives it. The agent sees a timeout, while the ticket service sees a finished request. If the agent tries again, it creates a second ticket.

Engineers call this idempotency. It means that repeating one action returns the first result instead of doing the work twice. Telling the model to use the same details is not enough. A retry may have a new call ID or slightly different JSON. Your application must decide which calls represent the same action.

This tutorial tests one failure point. The outside system finishes the action and the application saves the result, but the reply never reaches the agent. A retry with the same action key can return the saved result. This method cannot solve a different case where the outside action may have finished but no result was saved.

One action, even when delivery is retried

Agent

AgentTries again after a timeout

Safe tool boundary

Action key generatorStable key set by the application
Execution recordStores the key, request fingerprint, state, result, and version
Tool adapterRuns only for a new claim

Outside service

Outside systemCreates the ticket, payment, message, or deployment
  1. Agentrequested actionAction key generator
  2. Action key generatorclaim one action keyExecution record
  3. Execution recordnew claim can runTool adapter
  4. Tool adapterrun action with a provider key when availableOutside system
  5. Outside systemfinished resultTool adapter
  6. Tool adaptersave the finished resultExecution record
  7. Tool adapterfirst reply is lostAgent
  8. Agentretry with the same action keyExecution record
  9. Execution recordreturn the saved resultAgent
  10. Tool adaptercheck an unknown result by provider key or lookupOutside system
  • Solid line: normal saved flow
  • Dashed line: lost reply or result check
Read this diagram as text

The agent asks for an action. The application gives it a stable action key. The execution record allows only one owner to call the outside system. After the call finishes, it saves the result. The first reply is lost. When the agent retries with the same key, the execution record returns the saved result and does not call the outside system again. If the result was not saved, the system must check what happened before it retries.

The test loses the reply after the execution record saves Completed. If the result is unknown instead, the record must enter OutcomeUnknown and the tool must not run again.

Let the application choose the action key

Create the key when your product defines the action. Examples include one refund for one order or one release sent to one environment. Use the same key for network retries and new calls from the model. Do not rely on a call ID made by the model. Do not rely only on a hash of the model's JSON, because the same request can be written in different ways.

Save an intent fingerprint with the key. This fingerprint is a stable value based only on the fields that define the approved action. Reject the key if those fields change. This stops an old key from returning a result for a different amount, person, environment, or resource. Product code must decide which fields matter.

Approval and retry safety solve different problems. Approval says whether someone allowed the exact action. The execution record says whether that action already ran. Use both when your system has both risks.

How the execution record handles states, retries, and old data

Store one row for each tenant and action key. Enforce this with a unique database rule. The claim step must either create the first InProgress row or read the row that already exists. Only the caller that creates the row may call the outside system. InProgress means that one caller owns the work. Another attempt should wait or return a status that says to try later. It must not start the action again. For long tasks, renew the lease and use a version number so an old owner cannot save over a newer one. Completed stores the result the agent needs. Every later attempt with the same key and matching intent fingerprint returns that result. Keep only the data you need because tool results may contain private information. SafelyFailed means the adapter proved that no action happened. Only then may your rules allow another attempt. A timeout alone does not prove failure. OutcomeUnknown means the request may have reached the outside system, but the application does not know the result. Do not try the action again. Check the provider with an idempotency key, receipt, or lookup. If the provider supports none of these, a person must review the state. Do not keep a database transaction open while waiting for a network call. First save the claim, then call the outside system, then save the result. If the outside system supports idempotency keys, send the same key every time. Because these steps use separate transactions, you still need provider support or a lookup when the outside call finishes but the local result does not. Keep the unique key record longer than any retry, queue delivery, replay, or backup restore. You may delete a private result earlier, but keep a small record that still blocks duplicate work. If you delete the whole row too soon, an old retry looks like a new action.

Check the saved result before calling the tool

The tool adapter gets a LogicalActionKey from trusted application context. The model must not choose this key. First, call IExecutionLedger.ClaimAsync(). Only a new claim may call IMutatingTool.ExecuteAsync(). If the claim is complete, return its saved ToolOutcome.

The code below shows the tested failure case. IFaultInjector.AfterLedgerCommitAsync() runs after IExecutionLedger.CompleteAsync() so the test can lose the first reply without deleting the saved result. The full sample and its tests are in the linked GitHub repository.

src/ContextWindow.Agents.IdempotentTools/IdempotentToolExecutor.cs
namespace ContextWindow;


public sealed record LogicalActionKey(string TenantId, string ActionId);


public sealed record ToolRequest(string IntentFingerprint, string Payload);


public sealed record ToolOutcome(string ExternalId, string ResultJson);


public enum ClaimState
{
    Acquired,
    InProgress,
    Completed,
    SafelyFailed,
    OutcomeUnknown,
    IntentConflict
}


public sealed record ExecutionClaim(
    ClaimState State,
    long Fence,
    ToolOutcome? Outcome);


public interface IExecutionLedger
{
    ValueTask<ExecutionClaim> ClaimAsync(
        LogicalActionKey key,
        string intentFingerprint,
        CancellationToken cancellationToken);


    ValueTask CompleteAsync(
        LogicalActionKey key,
        long fence,
        ToolOutcome outcome,
        CancellationToken cancellationToken);
}


public interface IMutatingTool
{
    ValueTask<ToolOutcome> ExecuteAsync(
        ToolRequest request,
        string downstreamIdempotencyKey,
        CancellationToken cancellationToken);
}


public interface IFaultInjector
{
    ValueTask AfterLedgerCommitAsync(CancellationToken cancellationToken);
}


public sealed class IdempotentToolExecutor(
    IExecutionLedger ledger,
    IMutatingTool tool,
    IFaultInjector faultInjector)
{
    public async ValueTask<ToolOutcome> ExecuteAsync(
        LogicalActionKey key,
        ToolRequest request,
        CancellationToken cancellationToken)
    {
        ExecutionClaim claim = await ledger.ClaimAsync(
            key,
            request.IntentFingerprint,
            cancellationToken);


        if (claim.State is ClaimState.Completed)
        {
            return claim.Outcome
                ?? throw new InvalidOperationException(
                    "A completed execution must contain an outcome.");
        }


        if (claim.State is not ClaimState.Acquired)
        {
            throw new InvalidOperationException(
                $"Logical action cannot execute while it is {claim.State}.");
        }


        ToolOutcome outcome = await tool.ExecuteAsync(
            request,
            $"{key.TenantId}:{key.ActionId}",
            cancellationToken);


        using var completionTimeout = new CancellationTokenSource(
            TimeSpan.FromSeconds(5));


        await ledger.CompleteAsync(
            key,
            claim.Fence,
            outcome,
            completionTimeout.Token);


        await faultInjector.AfterLedgerCommitAsync(cancellationToken);
        return outcome;
    }
}
Tested control flow: claim one action, save its result, and return that result when the first reply is lost.

Test the gap between saving and replying

The test uses a database-backed execution record and a fake outside system that counts calls. The first attempt calls the outside system once, gets result R1, and saves R1. IFaultInjector.AfterLedgerCommitAsync() then throws a timeout before the caller receives R1. The second attempt uses the same LogicalActionKey and the same approved action. A third attempt uses a different model call ID and a different JSON field order, but it still describes the same approved action.

The checks are exact: the outside action runs once, the execution record is Completed, and every retry returns R1. Another test starts two calls with the same key and checks that only the claim owner reaches IMutatingTool.ExecuteAsync(). A final test reuses the key for a different action and confirms that the old result is not returned.

The complete sample includes these tests. The commands below run the response-loss test and the two-call race test.

Run the response-loss testsContextWindow.Agents.IdempotentTools
$ dotnet test tests/ContextWindow.Agents.IdempotentTools.Tests/ContextWindow.Agents.IdempotentTools.Tests.csproj --configuration Release --filter FullyQualifiedName~CommitThenTimeout
This test loses the first reply after the result is saved, then checks that the retry returns the same result.
$ dotnet test tests/ContextWindow.Agents.IdempotentTools.Tests/ContextWindow.Agents.IdempotentTools.Tests.csproj --configuration Release --filter FullyQualifiedName~ConcurrentDuplicate
This test checks that two calls with the same key produce one outside action.
Run these focused tests from the complete sample.

How to handle each saved state

For Completed, return the saved ToolOutcome and record that it was reused. If InProgress has an active lease, wait or tell the caller to try later. For SafelyFailed, retry only when the system can prove that the action did not happen. For OutcomeUnknown, stop automatic calls and check the outside system.

Use the same provider idempotency key or a provider lookup tied to a stable business reference. If the check finds the action, save its result as Completed and return it. If the check proves that no action happened, your rules may allow a new attempt. If the result is still unclear, leave the row in OutcomeUnknown for a person to review.

Turn on automatic recovery only after you define what happens in all four states. A retry setting only sends the request again. It does not identify the action, return a saved result, or explain an unknown outside result.

Start with one high-risk tool

Choose one tool where a duplicate would be costly. Define when two calls mean the same action and add a required LogicalActionKey to trusted application context. Add a table with a unique tenant and action key, an intent fingerprint, state, attempt version, timestamps, and a saved result or result reference.

Send every call through IdempotentToolExecutor.ExecuteAsync() before you enable automatic retries. Lose the first reply after IExecutionLedger.CompleteAsync(), then test the same request, a new model call, and two calls at the same time. Do not enable recovery until only one outside action runs and every retry gets the same result.

Next, test the case where the outside system may finish but the adapter does not receive its reply. Write down how the provider supports idempotency or result lookup before you add this check. If it supports neither, require a person to review the state instead of retrying.

What this design cannot solve

An execution record cannot make your database and an outside system act as one transaction. It can return a result it saved and stop two local callers from running the same action. It cannot discover an outside action that finished when no result was saved. For that, the outside system must support an idempotency key, a lookup, or another reliable check.

Each product must define what counts as one action. A key that is too narrow allows duplicates under new keys. A key that is too broad blocks separate work or returns the wrong result. The intent fingerprint detects changed details, but the product must still decide which fields matter.

How long you keep the record also matters. Keep the unique key longer than every possible retry. You may need to delete a private result sooner. In that case, keep a small record or result reference that still blocks the action from running twice.

Source

The complete sample is available at github.com/alex-janjic/ContextWindow.Agents.Idempotenttools.

Context Window dispatch

Practical AI engineering you can actually use

Working code, experiments, and production lessons. Published when there is something worth sending, never padded with AI news.

Confirmation is required. Read the privacy policy. Unsubscribe at any time.

The email edition is preparing to launch. No campaigns are being sent yet.