AI Coding Agent Tool Schemas: Strict JSON Contracts That Survive Retries

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 schemaVersion on breaking changes, require idempotencyKey on 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:

  1. Model emits args that almost match the schema
  2. Runtime coerces or drops fields
  3. Tool “succeeds” with ambiguous output
  4. Model retries with a slightly different shape
  5. 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; retryable is 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

Last updated on September 11, 2026


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 Reply