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 fromtoolUseId) in every mutating tool schema. Before side effects, claim a DynamoDB lock / conditional write. On retry, return the cachedtoolResultcontent. 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 documentedtoolUseId-only policy) - [ ] DynamoDB (or similar) claim with conditional write before side effects
- [ ] Cached success returns identical
toolResultJSON on retry - [ ] Key reuse with different args → explicit conflict error (not second apply)
- [ ]
toolResult.toolUseIdmatches Bedrock’stoolUseId - [ ] TTL on idempotency rows; FAILED reusable after backoff
- [ ] Load test: duplicate Converse retries do not double-apply patches
Most viewed
Newly added
- CloudWatch EMF for LLM Cost: Per-Tenant Token Metrics That Survive Sampling
- SQS FIFO + Lambda: Ordered Agent Job Queues Without Double-Applies
- Bedrock Converse toolConfig: Idempotent Tool Results Under Retries
- IAM Condition Keys for Agent Runtimes: Limit Blast Radius by Tag
- Lambda Response Streaming: Keep AI Coding Gateways Under Client Timeouts
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.