Goatfied

prompting

Tool Call Retries With Deterministic Guards Implementation Guide: practical systems guide

Technical field guide on tool call retries with deterministic guards implementation guide for teams building dependable AI coding workflows.

2026-07-208 min readBy Goatfied
Engineering team collaborating on prompt engineering

LLM tool calls fail. The model picks the wrong argument type, fabricates a file path, or calls a non-existent function. When your pipeline swallows these errors and moves on, you ship broken automation. When it halts entirely, you lose the 90% of work that succeeded. The trick is retrying with constraints that prevent the same mistake twice.

Most retry logic is a `for` loop with exponential backoff. That works for transient network faults. It doesn't work when the model confidently calls `readFile("/src/componets/Button.tsx")` three times in a row, each time misspelling "components." You need guards that make the invalid action impossible on subsequent attempts.

Why naive retries compound the problem

A typical implementation catches the tool call error, appends it to the message history, and asks the model to try again. The model sees its own mistake, but nothing in the environment has changed. It still has the same context window, the same biased prior that "componets" might be valid, and no new information to break the loop.

Worse, each retry burns tokens and latency. By attempt three, you've tripled your cost and your user has been waiting 15 seconds for a typo fix. If your agent runs 50 tool calls in a session, a 10% per-call failure rate without mitigation means >99% chance of at least one failure cascading into wasted work.

Deterministic guards shift the burden from the model to the runtime. Instead of hoping the model self-corrects, you intervene: remove the invalid option, inject the correct schema, or scope the action space so the same error is unreachable.

Three guard patterns that prevent repeat failures

**1. Enum exhaustion**: If a tool call selects from a known set—file paths, function names, config keys—and the model picks an invalid member, remove that member from the choices and regenerate. After a failed `editFile("componets/Button.tsx")`, your guard lists valid paths matching `**/Button.tsx` and passes them as an explicit enum in the tool schema. The model can't repeat the typo because "componets/Button.tsx" no longer exists in the option set.

**2. Schema tightening**: Some tool call failures come from loose types. A parameter accepts `string`, the model sends a file path, your handler expects a relative path, you get an absolute path and crash. On retry, replace `string` with `{ type: "string", pattern: "^[^/].*" }` or a custom format. For Goatfied's `editFile` tool, we auto-narrow path parameters after failure to only include files present in the working tree, passed as a TypeScript union type so the model sees concrete options.

**3. Scoped replay with correction hints**: If the model called `deleteFile` on a path that doesn't exist, append a system message before retry: "The path does not exist. Available paths in this directory: [list]." Crucially, do *not* just say "try again." Give the model a concrete, deterministic constraint it didn't have before.

Implementation sketch with TypeScript

Here's a minimal guard harness that tracks attempts and applies constraints. Real production code needs more error taxonomy, but this shows the structure:


async function callToolWithGuards(

  tool: Tool,

  args: Record<string, unknown>,

  maxAttempts = 3

): Promise<ToolResult> {

  const attemptLog: string[] = [];



  for (let attempt = 1; attempt <= maxAttempts; attempt++) {

    try {

      return await tool.execute(args);

    } catch (err) {

      attemptLog.push(`Attempt ${attempt}: ${err.message}`);

      

      if (attempt === maxAttempts) throw err;



      // Apply guard based on error type

      const guard = inferGuard(tool, args, err);

      if (guard.type === "enum") {

        tool.schema.parameters.properties[guard.param].enum = guard.validValues;

      } else if (guard.type === "hint") {

        args["__retryContext"] = guard.message;

      }

    }

  }

}



function inferGuard(tool: Tool, args: Record<string, unknown>, err: Error) {

  if (err.message.includes("path not found")) {

    const validPaths = listAvailablePaths(tool.workingDir);

    return { type: "enum", param: "path", validValues: validPaths };

  }

  return { type: "hint", message: err.message };

}

In practice, you'll also log the attempt history and feed it back to the model as part of the message thread, so it understands *why* the schema changed. The key insight: modify the tool definition itself between attempts, not just the prompt.

Compose guards with validation gates

Goatfied's agent loop runs `plan -> constrain -> edit -> validate -> retry`. The "validate" step runs compile, lint, and test checks after every edit, so the model can't proceed if the code doesn't build. This creates a natural guard boundary: if a tool call introduced a syntax error, the validator catches it, and the retry loop receives not just "failed" but "failed with compiler error at line 47: unknown identifier."

For tools that modify state (file edits, config changes), pair each guard with a validation function that runs post-execution. If validation fails, roll back the state change and retry with the validation message appended. This prevents compounding broken state across attempts.


async function executeWithValidation(

  tool: Tool,

  args: Record<string, unknown>,

  validator: (result: ToolResult) => Promise<void>

): Promise<ToolResult> {

  const snapshot = captureState();

  try {

    const result = await callToolWithGuards(tool, args);

    await validator(result);

    return result;

  } catch (err) {

    restoreState(snapshot);

    throw new ValidatedError(err.message, snapshot);

  }

}

When to escalate instead of retrying

Not every failure should trigger a retry. If the model calls a tool that doesn't exist, retrying won't help—it's a deeper plan failure. Set thresholds:

  • **Unknown tool/function**: escalate immediately, force replan
  • **Invalid argument type or missing required param**: apply schema guard, retry once
  • **Validation failure (syntax error, test fail)**: retry up to 3 times with error output in context
  • **Transient I/O error (network, filesystem lock)**: standard exponential backoff, up to 5 attempts

Goatfied's loop treats compile failures and unknown tool calls differently for this reason. An import error means the edit attempt partially succeeded but introduced breakage, so the model should see the compiler output and adjust. A hallucinated function name means the plan itself was wrong, so the model needs to regenerate the plan phase, not just tweak arguments.

Logging and replay for post-mortems

When a guarded retry succeeds, log the full attempt history: what failed, which guard applied, what the model saw on retry. This gives you a corpus for improving tool schemas and prompt design. In our telemetry, ~40% of retry successes come from enum exhaustion, meaning the model was conceptually correct but lacked the exact valid option. That insight justifies pre-populating enums with file tree state in the initial tool definition, reducing retries altogether.

For debugging, make the attempt log available in the UI. Cursor and Copilot hide retry internals; users see only success or failure. Goatfied surfaces the edit diff, validation output, and retry count inline, so engineers understand what the agent tried and why it worked (or didn't).

  • [Goatfied vs Cursor vs GitHub Copilot: a benchmark across 50 real PR tasks](/blog/goatfied-vs-cursor-vs-github-copilot-benchmark-50-pr-tasks)
  • [The Goatfied agent loop: how we ship code that actually compiles first try](/blog/goatfied-agent-loop-compiles-first-try)

Related posts

Tool Call Retries With Deterministic Guards Implementation Guide: practical systems guide | Goatfied Blog