Coding agents do not fail because the model “forgot TypeScript.” They fail because tool schemas are sloppy: optional fields that mean three things, stringly-typed enums, errors returned as free-text in content, and no idempotency key on apply_patch. Retries then double-apply hunks, open duplicate PRs, or burn a Bedrock session on schema ping-pong. The unfair advantage is treating every tool as a versioned JSON contract with typed success/error and retry-safe semantics — the same discipline you use for public APIs.
⚡ TL;DR: Freeze tool input/output with JSON Schema (or Zod → schema), bump
schemaVersionon breaking changes, requireidempotencyKeyon mutating tools, and return structured errors (code,retryable,details) instead of prose. Validate before side effects. Map contracts into Bedrock action groups / OpenAPI the same way you typecheck handlers — see Bedrock Action Groups. Pair with idempotent tool calls against DynamoDB.
Why loose schemas create agent loops
An agent loop looks like intelligence thrashing. Mechanically it is usually:
- Model emits args that almost match the schema
- Runtime coerces or drops fields
- Tool “succeeds” with ambiguous output
- Model retries with a slightly different shape
- Mutating tool runs twice
Strict contracts collapse steps 2–5 into a single typed failure the model can correct once.
// tools/contracts/apply-patch.ts
import { z } from "zod";
export const ApplyPatchArgsV1 = z.object({
schemaVersion: z.literal(1),
idempotencyKey: z.string().min(8).max(128),
repoRoot: z.string().min(1),
files: z
.array(
z.object({
path: z.string().regex(/^[a-zA-Z0-9._/-]+$/),
// ✅ Unified diff or full replacement — pick ONE mode per call
mode: z.enum(["unified_diff", "replace_full"]),
content: z.string().max(500_000),
}),
)
.min(1)
.max(40),
dryRun: z.boolean().default(false),
});
export type ApplyPatchArgsV1 = z.infer<typeof ApplyPatchArgsV1>;
export const ApplyPatchResultV1 = z.discriminatedUnion("ok", [
z.object({
ok: z.literal(true),
schemaVersion: z.literal(1),
applied: z.array(z.string()),
skippedAsDuplicate: z.boolean(),
}),
z.object({
ok: z.literal(false),
schemaVersion: z.literal(1),
error: z.object({
code: z.enum([
"VALIDATION",
"CONFLICT",
"FORBIDDEN_PATH",
"TIMEOUT",
"INTERNAL",
]),
retryable: z.boolean(),
message: z.string().max(500),
details: z.record(z.unknown()).optional(),
}),
}),
]);
❌ Returning { "message": "failed to apply" } teaches the model nothing and invites another blind retry.
✅ Returning CONFLICT + retryable: false + conflicting paths lets the agent re-read files once.
Validate at the boundary, then execute once
Parse with Zod (or AJV) at the tool gateway. Reject before touching the filesystem or AWS. Persist idempotency outcomes so Bedrock retries and your own agent runner share the same truth.
// tools/gateway.ts
import { createHash } from "node:crypto";
import { ApplyPatchArgsV1, ApplyPatchResultV1 } from "./contracts/apply-patch";
type Store = {
get(key: string): Promise<unknown | null>;
put(key: string, value: unknown, ttlSec: number): Promise<void>;
};
export async function runApplyPatch(
raw: unknown,
store: Store,
apply: (args: ApplyPatchArgsV1) => Promise<string[]>,
) {
const parsed = ApplyPatchArgsV1.safeParse(raw);
if (!parsed.success) {
return ApplyPatchResultV1.parse({
ok: false,
schemaVersion: 1,
error: {
code: "VALIDATION",
retryable: false,
message: "args failed schema",
details: { issues: parsed.error.issues.slice(0, 20) },
},
});
}
const args = parsed.data;
const key = `tool:apply_patch:v1:${args.idempotencyKey}`;
const prior = await store.get(key);
if (prior) {
// ✅ Same key → same result (including prior failures you choose to cache)
return ApplyPatchResultV1.parse(prior);
}
try {
if (args.dryRun) {
const result = {
ok: true as const,
schemaVersion: 1 as const,
applied: args.files.map((f) => f.path),
skippedAsDuplicate: false,
};
await store.put(key, result, 86400);
return result;
}
const applied = await apply(args);
const result = {
ok: true as const,
schemaVersion: 1 as const,
applied,
skippedAsDuplicate: false,
};
await store.put(key, result, 86400);
return result;
} catch (e) {
const result = {
ok: false as const,
schemaVersion: 1 as const,
error: {
code: "INTERNAL" as const,
retryable: true,
message: e instanceof Error ? e.message.slice(0, 500) : "unknown",
},
};
// Optionally cache non-retryable failures only
return ApplyPatchResultV1.parse(result);
}
}
export function hashToolArgs(name: string, args: unknown) {
return createHash("sha256")
.update(name)
.update(JSON.stringify(args))
.digest("hex");
}
This mirrors the DynamoDB idempotency pattern in Bedrock Agents: Idempotent Tool Calls and Lambda Powertools Idempotency.
Version schemas like APIs
Never silently reinterpret an optional field. Add schemaVersion (or tool name suffix _v2). Keep two handlers during rollout; have the agent policy prefer the newest.
// tools/registry.ts
export type ToolDef = {
name: string; // apply_patch_v1
schemaVersion: number;
inputSchema: object; // JSON Schema for Bedrock / OpenAPI
handler: (raw: unknown) => Promise<unknown>;
mutates: boolean;
};
export const registry: ToolDef[] = [
{
name: "apply_patch_v1",
schemaVersion: 1,
mutates: true,
inputSchema: {
type: "object",
additionalProperties: false,
required: ["schemaVersion", "idempotencyKey", "repoRoot", "files"],
properties: {
schemaVersion: { const: 1 },
idempotencyKey: { type: "string", minLength: 8, maxLength: 128 },
repoRoot: { type: "string" },
dryRun: { type: "boolean" },
files: {
type: "array",
minItems: 1,
maxItems: 40,
items: {
type: "object",
additionalProperties: false,
required: ["path", "mode", "content"],
properties: {
path: { type: "string" },
mode: { enum: ["unified_diff", "replace_full"] },
content: { type: "string", maxLength: 500000 },
},
},
},
},
},
handler: async () => {
throw new Error("wire to runApplyPatch");
},
},
];
// ❌ Renaming enum values in place ("unified_diff" → "diff") without a new tool name
// ✅ apply_patch_v2 with a migration note in the tool description
Generate OpenAPI/JSON Schema from the same Zod source so Bedrock action groups cannot drift from handlers — the workflow in Bedrock Action Groups: OpenAPI Tools That Typecheck.
Typed errors beat conversational recovery
Put retry policy in the error object, not in the system prompt. Agents and orchestrators should branch on retryable and code.
export function shouldRetryToolError(err: {
code: string;
retryable: boolean;
}): "retry" | "replan" | "escalate" {
if (err.code === "VALIDATION") return "replan";
if (err.code === "FORBIDDEN_PATH") return "escalate";
if (err.code === "CONFLICT") return "replan";
if (err.retryable) return "retry";
return "escalate";
}
For prod-touching tools (deploy, IAM, data deletes), structured errors still need human gates — Human-in-the-Loop Gates.
Test contracts the way you test APIs
Golden fixtures: valid args, each validation failure, idempotent replay, and conflict. Run them in CI against the gateway, not only against mocked model transcripts.
// tools/contracts/apply-patch.test.ts
import { ApplyPatchArgsV1 } from "./apply-patch";
const base = {
schemaVersion: 1 as const,
idempotencyKey: "agent-turn-9f3c",
repoRoot: "/workspace/app",
files: [{ path: "src/a.ts", mode: "replace_full" as const, content: "export {}" }],
};
test("rejects path traversal", () => {
const r = ApplyPatchArgsV1.safeParse({
...base,
files: [{ path: "../secrets/env", mode: "replace_full", content: "x" }],
});
expect(r.success).toBe(false);
});
test("requires idempotencyKey on mutate", () => {
const { idempotencyKey: _, ...rest } = base;
expect(ApplyPatchArgsV1.safeParse(rest).success).toBe(false);
});
Checklist
- [ ] Every mutating tool requires
idempotencyKey+schemaVersion - [ ]
additionalProperties: false(or Zod strip/strict) on inputs - [ ] Discriminated success/error results;
retryableis explicit - [ ] Validate before side effects; cache idempotent outcomes
- [ ] Tool names versioned on breaking changes; dual-run during migrate
- [ ] OpenAPI/JSON Schema generated from the same source as handlers
- [ ] CI fixtures for validation, replay, and conflict paths
- [ ] Prod-mutating tools behind dual control / HITL
Related reading
- Bedrock Action Groups: OpenAPI Tools That Typecheck Against Handlers
- Bedrock Agents: Idempotent Tool Calls Against DynamoDB Writes
- Lambda Powertools Idempotency: DynamoDB Keys That Survive Retries
- Human-in-the-Loop Gates: Dual Control for Prod-Touching Agent Tools
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.