Bedrock Converse toolConfig: Idempotent Tool Results Under Retries

0 views

Converse / ConverseStream with toolConfig is the right production API for coding agents — until a retry, a mid-stream disconnect, or a Step Functions task token replay re-invokes apply_patch with the same toolUseId. At-least-once is the default. The unfair advantage is treating every tool as an idempotent command keyed in the schema, with durable toolResult caching keyed by toolUseId + args hash.

⚡ TL;DR: Put idempotencyKey (or derive from toolUseId) in every mutating tool schema. Before side effects, claim a DynamoDB lock / conditional write. On retry, return the cached toolResult content. Align with AI Coding Agent Tool Schemas, Exactly-Once Illusions, and Day-35 patterns like Idempotent Tool Calls Against DynamoDB without rehashing the bootcamp article.

toolConfig is a contract, not a prompt hint

import {
  BedrockRuntimeClient,
  ConverseCommand,
  ToolConfiguration,
} from "@aws-sdk/client-bedrock-runtime";

const toolConfig: ToolConfiguration = {
  tools: [
    {
      toolSpec: {
        name: "apply_patch",
        description:
          "Apply a unified diff to a repo workspace. Safe under retries when idempotencyKey is stable.",
        inputSchema: {
          json: {
            type: "object",
            properties: {
              workspaceId: { type: "string" },
              unifiedDiff: { type: "string" },
              // ✅ Client/model must pass a stable key per logical mutation
              idempotencyKey: {
                type: "string",
                minLength: 8,
                maxLength: 128,
                description: "Stable key for this patch intent (UUID or hash)",
              },
            },
            required: ["workspaceId", "unifiedDiff", "idempotencyKey"],
            additionalProperties: false,
          },
        },
      },
    },
  ],
  toolChoice: { auto: {} },
};

❌ Omitting idempotencyKey and hoping toolUseId alone is enough can work if you always key on Bedrock’s toolUseId — but multi-agent replays and your own “regenerate” buttons often mint a new toolUseId for the same logical patch. Prefer an explicit business key plus toolUseId for result caching.

Claim → execute → cache toolResult

import { createHash } from "crypto";
import {
  DynamoDBClient,
  PutItemCommand,
  GetItemCommand,
} from "@aws-sdk/client-dynamodb";

const ddb = new DynamoDBClient({});
const TABLE = process.env.TOOL_IDEMPOTENCY_TABLE!;

function argsHash(input: Record<string, unknown>): string {
  const { idempotencyKey: _k, ...rest } = input;
  return createHash("sha256").update(JSON.stringify(rest)).digest("hex").slice(0, 32);
}

export async function runApplyPatch(toolUseId: string, input: {
  workspaceId: string;
  unifiedDiff: string;
  idempotencyKey: string;
}) {
  const pk = `tool#apply_patch#${input.idempotencyKey}`;
  const hash = argsHash(input);

  // 1) Fast path: prior success
  const existing = await ddb.send(
    new GetItemCommand({
      TableName: TABLE,
      Key: { pk: { S: pk } },
    })
  );
  if (existing.Item?.status?.S === "SUCCEEDED") {
    if (existing.Item.argsHash?.S !== hash) {
      // ✅ Same key, different args = conflict, do not silently apply
      return {
        toolUseId,
        content: [
          {
            text: JSON.stringify({
              ok: false,
              error: "IdempotencyKeyConflict",
              message: "idempotencyKey reused with different arguments",
            }),
          },
        ],
        status: "error" as const,
      };
    }
    return {
      toolUseId,
      content: [{ json: JSON.parse(existing.Item.resultJson!.S!) }],
      status: "success" as const,
    };
  }

  // 2) Claim in-flight (conditional)
  try {
    await ddb.send(
      new PutItemCommand({
        TableName: TABLE,
        Item: {
          pk: { S: pk },
          status: { S: "IN_FLIGHT" },
          toolUseId: { S: toolUseId },
          argsHash: { S: hash },
          ttl: { N: String(Math.floor(Date.now() / 1000) + 86400) },
        },
        ConditionExpression:
          "attribute_not_exists(pk) OR #s = :failed OR (attribute_exists(ttl) AND ttl < :now)",
        ExpressionAttributeNames: { "#s": "status" },
        ExpressionAttributeValues: {
          ":failed": { S: "FAILED" },
          ":now": { N: String(Math.floor(Date.now() / 1000)) },
        },
      })
    );
  } catch (e: any) {
    if (e?.name === "ConditionalCheckFailedException") {
      // Another worker owns it — wait/poll or return in-progress error for model retry
      return {
        toolUseId,
        content: [{ text: JSON.stringify({ ok: false, error: "InFlight" }) }],
        status: "error" as const,
      };
    }
    throw e;
  }

  // 3) Side effect once
  try {
    const result = await applyDiffOnce(input.workspaceId, input.unifiedDiff);
    await ddb.send(
      new PutItemCommand({
        TableName: TABLE,
        Item: {
          pk: { S: pk },
          status: { S: "SUCCEEDED" },
          toolUseId: { S: toolUseId },
          argsHash: { S: hash },
          resultJson: { S: JSON.stringify(result) },
          ttl: { N: String(Math.floor(Date.now() / 1000) + 7 * 86400) },
        },
      })
    );
    return {
      toolUseId,
      content: [{ json: result }],
      status: "success" as const,
    };
  } catch (err: any) {
    await ddb.send(
      new PutItemCommand({
        TableName: TABLE,
        Item: {
          pk: { S: pk },
          status: { S: "FAILED" },
          toolUseId: { S: toolUseId },
          argsHash: { S: hash },
          error: { S: String(err?.message ?? err) },
          ttl: { N: String(Math.floor(Date.now() / 1000) + 3600) },
        },
      })
    );
    return {
      toolUseId,
      content: [{ text: JSON.stringify({ ok: false, error: "ApplyFailed" }) }],
      status: "error" as const,
    };
  }
}

async function applyDiffOnce(workspaceId: string, unifiedDiff: string) {
  // workspace FS / git apply — must itself be safe if partially applied
  return { workspaceId, filesTouched: 3, ok: true };
}

Wire results back into Converse correctly

const bedrock = new BedrockRuntimeClient({});

async function turn(messages: any[]) {
  const res = await bedrock.send(
    new ConverseCommand({
      modelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
      messages,
      toolConfig,
    })
  );

  const out = res.output?.message;
  if (!out) return res;

  const toolUses = (out.content ?? []).filter((c: any) => c.toolUse);
  if (!toolUses.length) return res;

  const toolResults = [];
  for (const block of toolUses) {
    const tu = block.toolUse!;
    const result = await runApplyPatch(tu.toolUseId!, tu.input as any);
    toolResults.push({
      toolResult: {
        toolUseId: result.toolUseId,
        content: result.content,
        status: result.status,
      },
    });
  }

  // Continue conversation with tool results — same messages array, no duplicate applies
  return turn([
    ...messages,
    out,
    { role: "user", content: toolResults },
  ]);
}

✅ Always echo the same toolUseId in toolResult. ❌ Inventing a new id breaks the model’s tool loop.

For schema evolution and strict JSON, reuse lessons from AI Coding Agent Tool Schemas. For async long tools, combine with Bedrock Agents Plus EventBridge — store the idempotency record before enqueue.

Read-only tools still need contracts

Idempotency is mandatory for mutators, but read tools (grep_repo, read_file) still need strict schemas and size limits so retries do not blow token budgets. Cap maxBytes, require workspaceId, and return truncated markers explicitly so the model does not invent the rest of the file.

{
  name: "read_file",
  inputSchema: {
    json: {
      type: "object",
      properties: {
        workspaceId: { type: "string" },
        path: { type: "string", maxLength: 512 },
        maxBytes: { type: "integer", minimum: 1, maximum: 100_000 },
      },
      required: ["workspaceId", "path"],
      additionalProperties: false,
    },
  },
}

Testing retries without burning Bedrock

Unit-test the claim table with three concurrent runApplyPatch calls sharing one idempotencyKey — assert a single applyDiffOnce. Integration-test by recording a Converse transcript fixture and replaying the tool phase twice. Only then run a live Bedrock soak. This keeps CI cheap while still proving the unfair advantage: retries become free no-ops instead of corrupted workspaces.

Checklist

  • [ ] Every mutating tool schema requires idempotencyKey (or documented toolUseId-only policy)
  • [ ] DynamoDB (or similar) claim with conditional write before side effects
  • [ ] Cached success returns identical toolResult JSON on retry
  • [ ] Key reuse with different args → explicit conflict error (not second apply)
  • [ ] toolResult.toolUseId matches Bedrock’s toolUseId
  • [ ] TTL on idempotency rows; FAILED reusable after backoff
  • [ ] Load test: duplicate Converse retries do not double-apply patches

Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.