15 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: PutidempotencyKey(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.
Bedrock Converse toolConfig: production guidance
typescript
❌ Omitting 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: {} },
};
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
typescript
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
typescript
✅ Always echo the same 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 },
]);
}
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.
typescript
{
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 concurrentrunApplyPatch 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
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Newly added
- n8n vs AI Agent: I Gave Both the Same Job (Local, Ollama)
- Local AI Video Generator: Build Your Own App (Free, Python, No GPU)
- AI Wrote Raw Binary Instead of Code. Here’s What Happened (WebAssembly)
- I Built a Browser That Only Runs Encrypted Binary (Ep 1)
- RAG From Scratch: Chunking, Embeddings, Reranking and Evaluation (Real Numbers)
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
Already subscribed? or open the subscribe page.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.