Agent Guardrails: Step Limits, Timeouts, Cost Caps and Human Approval
Add the four limits every agent needs before it touches real systems: a step budget, per-call timeouts, a token/cost cap, and a human approval gate for risky tools.
By Avnish Yadav
An agent loop keeps going until the model says it's done. That's the point, and also the risk: a confused model can call the same tool fifty times, wait forever on a slow API, run up a large bill, or take an action you didn't want. In this lesson you'll add four guardrails to the loop from the previous lesson.
You'll need: the agent loop from Build a Tool-Calling AI Agent Loop in TypeScript.
The four limits
| Guardrail | Stops | Typical setting |
|---|---|---|
| Step budget | endless loops | a small fixed number of model turns |
| Timeouts | a hung tool or model call | seconds per call, plus a total deadline |
| Token/cost cap | runaway spending | a token budget per run |
| Approval gate | unwanted side effects | a human confirms risky tools |
Pick the numbers from your own use case and logs; there's no universal right value.
A step budget
Count model turns and stop when the budget runs out. Return a clear result instead of throwing, so the caller can decide what to do:
type RunResult =
| { status: "done"; answer: string }
| { status: "stopped"; reason: string; lastMessage?: string };
const MAX_STEPS = 8;
for (let step = 1; step <= MAX_STEPS; step++) {
const reply = await model.next(messages);
if (reply.type === "final") return { status: "done", answer: reply.text };
// ...run the tool, append the result...
}
return { status: "stopped", reason: `Step budget of ${MAX_STEPS} used up` };
Also stop early when the model repeats itself. If it asks for the same tool with the same arguments twice in a row, it's stuck:
const key = `${call.name}:${JSON.stringify(call.args)}`;
if (key === lastCallKey) return { status: "stopped", reason: `Repeated call: ${call.name}` };
lastCallKey = key;
Timeouts on every call
Use AbortSignal.timeout for each tool, and one overall deadline for the whole run:
const deadline = AbortSignal.timeout(60_000); // whole run
async function runTool(tool: Tool, args: unknown) {
const signal = AbortSignal.any([deadline, AbortSignal.timeout(10_000)]);
return tool.run(args, signal);
}
Pass the signal into fetch and database clients inside the tool, so a timeout actually cancels the work instead of just ignoring the result. AbortSignal.any needs Node.js 20 or later.
A token budget
Every model response reports how many tokens it used. Add them up and stop before the budget is exceeded:
const TOKEN_BUDGET = 50_000;
let used = 0;
const reply = await model.next(messages);
used += reply.usage.inputTokens + reply.usage.outputTokens;
if (used > TOKEN_BUDGET) return { status: "stopped", reason: `Token budget used up (${used})` };
Remember that each turn resends the whole conversation, so input tokens grow every step. Large tool results are the usual culprit: trim them before adding them to the messages (keep the fields the model needs, drop the rest).
Human approval for risky tools
Split tools into two kinds. Read tools (search, look up an order) can run freely. Write tools (send an email, refund a payment, delete a record) wait for a person:
type Tool = { name: string; risky: boolean; run: (args: unknown, signal: AbortSignal) => Promise<unknown> };
if (tool.risky) {
const ok = await requestApproval({ tool: tool.name, args }); // Slack button, admin page, CLI prompt...
if (!ok) {
messages.push(toolResult(call.id, "The user declined this action. Do not retry it; explain what you would have done."));
continue;
}
}
Tell the model when an action is declined. Otherwise it often tries the same action again.
Log every decision
Write one log line per step: the step number, the tool and arguments, how long it took, tokens used so far, and why the run stopped. When something goes wrong, this log is how you find out what the agent "thought". Never log secrets or full personal data in tool arguments.
Common mistakes
- Limits that throw exceptions halfway through. Return a stopped result so the UI can explain what happened.
- Timeouts that don't cancel. Without passing the signal down, the work keeps running in the background.
- Letting the model decide what's risky. Mark tools as risky in code, not in the prompt.
- One huge tool result. It eats the token budget in a single step.