prompting
Tool Call Retries With Deterministic Guards Operational Checklist: practical systems guide
Technical field guide on tool call retries with deterministic guards operational checklist for teams building dependable AI coding workflows.
LLM tool calls fail. A lot. Your agent asks to write a file at a path that doesn't exist, tries to import a module you haven't installed, or formats JSON with a trailing comma that breaks your parser. The naive fix—just retry with the error message—works sometimes, but in production you get stuck in loops: the model hallucinates the same bad path three times in a row, burns tokens, and your user stares at a spinner.
Deterministic guards break the loop. Before you send the tool result back to the LLM, you run cheap, fast checks: does this path exist? Is this valid JSON? Does this import resolve? If a guard fails, you don't retry blindly—you constrain the next attempt with a concrete restriction. The model can't repeat the same mistake because the guardrail won't let it through.
This checklist covers the mechanics: what to guard, when to retry, and how to wire the feedback without turning your agent into a Rube Goldberg machine.
Why retries alone aren't enough
A basic retry flow sends the LLM's tool call to your runtime, captures any error, and loops back with "that failed, try again." This works when the error is genuinely novel—network hiccup, transient lock—but most agent failures are structural. The model doesn't know your filesystem layout, hasn't seen your schema constraints, and can't predict which imports your environment provides.
Retrying the same prompt with the same context produces the same hallucination. You waste three turns getting `FileNotFoundError: src/components/Button.tsx` when the correct path is `src/ui/Button.tsx`. Token costs scale linearly; user patience does not.
Deterministic guards run *before* you hand control back to the LLM. A guard is a function that inspects the tool call arguments, checks invariants, and either passes or rejects with a structured error. If it rejects, you immediately modify the next prompt to rule out the bad argument—no round trip to the model required.
Identify guard-worthy invariants first
Not every tool call needs a guard. Start by logging your agent's actual failures over a few dozen runs. Group them:
- **Path errors**: file doesn't exist, directory traversal outside allowed root, write to read-only location.
- **Schema violations**: invalid JSON, missing required field, type mismatch (string instead of int).
- **Dependency failures**: import not installed, API key missing, service unreachable.
- **Semantic errors**: editing line 500 in a 200-line file, referencing a variable not in scope.
Anything you see more than twice is a candidate. Good guards are cheap to evaluate—filesystem checks, regex matches, schema validators you already run in CI. If a guard takes >100ms or requires an LLM call itself, skip it; you're trading one latency problem for another.
Example from a real Goatfied workflow: agents would call `edit_file` with `start_line: 0`. Python is zero-indexed; our editor displays one-indexed line numbers. The guard rejects and the retry prompt explicitly states "line numbers are 1-indexed."
Implement guards as pipeline stages
Wire guards as middleware between the LLM's raw tool call and your executor. Pseudo-code:
def execute_tool_call(call):
for guard in get_guards(call.tool_name):
result = guard.check(call.arguments)
if not result.passed:
return RetrySignal(
constraint=result.constraint_message,
original_error=result.details
)
return actual_execute(call)
Each guard returns a pass/fail plus a **constraint message**. This message is a short, factual statement you'll inject into the retry prompt: "The path `src/foo.ts` does not exist. Available paths in `src/` are: `main.ts`, `utils.ts`, `types.ts`."
Keep constraint messages factual and narrow. Don't say "your path is wrong"—enumerate the valid options. Don't say "fix the JSON"—show the schema excerpt that failed.
Structure retry prompts to surface constraints
When a guard rejects, you need to communicate *why* and *how to fix it* without the model ignoring you. Append a system-level instruction block before the next attempt:
TOOL CALL REJECTED by deterministic guard.
Constraint: {constraint_message}
You must revise your tool call to satisfy this constraint.
Original error: {original_error}
This sits above the normal conversational context. The model sees it can't proceed with the same argument. In practice, this cuts repeat-identical failures from ~60% of retry loops to under 5% (measured internally; your mileage will vary based on model and task).
At Goatfied, the agent loop already runs compile/lint/test gates after every edit. Guards slot in *before* those gates—they're cheaper and catch errors that would never reach the compiler. A guard that validates import paths against `package.json` dependencies runs in <10ms; waiting for TypeScript to error on a missing import takes a full compile cycle.
Limit retry depth and log rejection patterns
Even with guards, you can hit edge cases: the model doesn't understand the constraint, the task is genuinely impossible, or your guard has a bug. Hard-cap retries per tool call at 3. After that, escalate: log the full rejection chain and either fail gracefully or route to a human.
Log every guard rejection with:
- Tool name and arguments
- Guard that failed
- Constraint message issued
- Whether the retry succeeded
This corpus tells you which guards are working (high success rate after constraint) and which are confusing (model retries but keeps failing). If a guard triggers often but rarely leads to success, either the constraint message is unclear or the task needs rethinking.
Combine guards with compile-time feedback for code edits
For code-focused agents, guards and compile checks are complementary. A guard verifies you're editing a file that exists and the line range is sane. The compile step verifies the *content* of the edit is syntactically valid. Both run before you commit the change; both produce concrete feedback.
Goatfied's agent loop runs: plan → constrain (guards apply here) → edit → validate (compile/lint/test) → retry if needed. Small, reversible diffs mean a failed edit doesn't corrupt state. The guard catches "you tried to edit a file that isn't in the workspace"; the validator catches "your edit introduced a syntax error."
This two-stage check keeps retries focused. If a guard fails, you know it's a precondition problem (wrong path, bad schema). If the validator fails, you know the edit itself is broken. The model gets different constraint messages for each and doesn't conflate them.
Gradually expand your guard library
Start with 3-5 guards for your most common failures. Implement them as simple functions with no external dependencies. As you log more rejections, add guards when:
- You see the same error pattern across multiple tasks
- The check is fast and deterministic
- The constraint message you'd generate is concrete and actionable
Don't guard for everything. If an error only happens once in a hundred runs, handling it in a retry without a guard is fine. Guards are for the 80% of preventable mistakes that burn tokens and time.
Over six months running Goatfied internally, we went from zero guards to twelve. The top three (path existence, line range bounds, schema validation) account for 70% of all rejections. The rest handle edge cases we saw often enough to justify the maintenance cost.
Related reading
- [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)
