Skip to the solution
HardSystem Design15 min read

Guardrails for AI agents: rate limits, budget caps, and HITL?

559 views

The problem

Question presented to candidate: "An agent in production got stuck in a loop overnight: it retried the same failing tool call hundreds of times, sent a burst of emails and ran up a large model bill. Design the guardrails that would have stopped it: rate limits, budget caps and human escalation. Where does each check live, and what should happen when one trips?"

What a strong answer should cover:

  • Hard limits on every run: steps or turns, tokens and dollars, wall-clock time, and per-tool call counts, all enforced in the loop runtime rather than requested in the prompt
  • Budget enforcement that cannot overshoot: reserve the worst-case cost of the next call (known input plus max_tokens output) before making it, instead of checking after the money is spent
  • Rate limits per tool and per side effect (emails, refunds, writes) using token buckets, plus per-user and per-tenant quotas, separate from the provider rate limits you retry against
  • Circuit breakers for unproductive behaviour: identical repeated calls, error streaks and no-progress detection
  • What happens on a trip: stop cleanly, keep the trace and partial work, and escalate to a human with a summary and options, rather than crash or silently continue; plus tiered approval for risky actions
  • Observability: per-run spend and step metrics, alerts on limit trips, and limits calibrated from real run distributions

Clarifying questions expected:

  • What does a normal run look like (steps, tokens, duration), so limits can be set from data?
  • Which tools have real-world side effects, and what volume of them is ever legitimate per run?
  • Is there a human available to take escalations, and how fast?

Code / implementation expected: Yes โ€” a loop wrapper that enforces step and budget caps, or a per-tool rate limiter with a breaker.

  • agents
  • cost

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

15 min read ยท 13 sections

Read the code

A run governor: check-after vs reserve-before budgets
// A run governor for an agent loop: step cap plus a dollar budget, enforced
// either after each model call or by reserving the worst case before it.
// ASSUMED prices (illustrative, not a vendor list price): $3 per million input
// tokens, $15 per million output tokens. Money is kept in integer micro-dollars.
const PRICE_IN = 3, PRICE_OUT = 15;           // micro-dollars per token
const MAX_TOKENS = 1024;                      // output cap sent with every call
const BUDGET = 500000;                        // $0.50 per run
const STEP_CAP = 25;

// Stub model + tools: the context grows with every tool result, and step 6
// reads a large file (40,000 tokens) into the context. Output is 400 tokens per step.
function stepUsage(context) { return { input: context, output: 400 }; }
function toolResultTokens(step) { return step === 6 ? 40000 : 1500; }
function usd(micro) {  // integer rounding to 4 decimals, identical in every language
  const t = Math.floor((micro + 50) / 100);
  return "$" + Math.floor(t / 10000) + "." + String(t % 10000).padStart(4, "0");
}

function run(policy, verbose) {
  let context = 2000, spent = 0, step = 0, stop = "step cap " + STEP_CAP;
  while (step < STEP_CAP) {
    if (policy === "reserve") {
      const worst = context * PRICE_IN + MAX_TOKENS * PRICE_OUT;  // known input + max output
      if (spent + worst > BUDGET) { stop = "reserve " + usd(worst) + " would exceed budget"; break; }
    }
    const u = stepUsage(context);
    const cost = u.input * PRICE_IN + u.output * PRICE_OUT;
    spent += cost;
    step++;
    if (verbose) console.log("  step " + String(step).padStart(2) + "  input " + String(u.input).padStart(6) + "  cost " + usd(cost) + "  total " + usd(spent));
    if (policy === "after" && spent > BUDGET) { stop = "budget exceeded after the call"; break; }
    context += u.output + toolResultTokens(step);
  }
  return { steps: step, spent, stop };
}

console.log("reserve-before policy, step by step (budget " + usd(BUDGET) + "):");
const r = run("reserve", true);
console.log("");
console.log("policy             steps  spent      over budget  stopped because");
for (const [name, p] of [["none (step cap)", "none"], ["check after call", "after"], ["reserve before", "reserve"]]) {
  const x = run(p, false);
  const over = Math.max(0, x.spent - BUDGET);
  console.log(name.padEnd(19) + String(x.steps).padEnd(7) + usd(x.spent).padEnd(11) + usd(over).padEnd(13) + x.stop);
}
console.log("");
console.log("on stop: status=paused_for_human, keep the trace, return partial work after " + r.steps + " steps");
Per-tool token buckets, a repeat-call breaker and escalation
// Per-tool rate limits (token buckets), a repeat-call breaker and escalation
// to a human, wrapped around a stub agent that gets stuck. Fake clock, seconds.
// Integer token bucket: 60 units = 1 token, so 1 token per minute is 1 unit per second.
class Bucket {
  constructor(capacity, unitsPerSec) { this.max = capacity * 60; this.units = this.max; this.rate = unitsPerSec; this.last = 0; }
  take(now) {
    this.units = Math.min(this.max, this.units + (now - this.last) * this.rate);
    this.last = now;
    if (this.units >= 60) { this.units -= 60; return true; }
    return false;
  }
  left() { const h = Math.floor((this.units * 100) / 60); return Math.floor(h / 100) + "." + String(h % 100).padStart(2, "0"); }
}
const LIMITS = { search: new Bucket(10, 60), send_email: new Bucket(3, 1) };  // search 1/s, email 3 burst then 1/min
const REPEAT_LIMIT = 3;           // same tool + same args this many times in a row -> break
const MAX_DENIALS = 4;            // too many guard denials in one run -> escalate

// Stub model: a fixed sequence of tool calls, one every 5 seconds. It retries a
// failing search with identical arguments and then tries to email everyone.
const plan = [
  ["search", "q=refund policy"], ["search", "q=refund policy"], ["send_email", "to=a@x.com"],
  ["send_email", "to=b@x.com"], ["send_email", "to=c@x.com"], ["send_email", "to=d@x.com"],
  ["send_email", "to=e@x.com"], ["search", "q=order 7"], ["search", "q=order 7"], ["search", "q=order 7"],
  ["send_email", "to=f@x.com"],
];

let lastKey = "", repeats = 0, denials = 0, status = "completed";
const log = [];
for (let i = 0; i < plan.length; i++) {
  const now = i * 5;
  const [tool, args] = plan[i];
  const key = tool + " " + args;
  repeats = key === lastKey ? repeats + 1 : 1;
  lastKey = key;
  let verdict;
  if (repeats >= REPEAT_LIMIT) {
    verdict = "BREAK: " + repeats + " identical calls in a row";
    status = "paused_for_human";
  } else if (!LIMITS[tool].take(now)) {
    denials++;
    verdict = "DENY: rate limit (" + LIMITS[tool].left() + " tokens left)";
    if (denials >= MAX_DENIALS) status = "paused_for_human";
  } else verdict = "ok";
  console.log("t=" + String(now).padStart(2) + "  " + key.padEnd(24) + verdict);
  log.push({ now, key, verdict });
  if (status !== "completed") break;
}
console.log("");
console.log("run status: " + status + " after " + log.length + " of " + plan.length + " planned calls");
const sent = log.filter((l) => l.key.startsWith("send_email") && l.verdict === "ok").length;
console.log("emails actually sent: " + sent + ", denied by rate limit: " + denials);
console.log("escalation message: \"Agent paused: " + log[log.length - 1].verdict + ". Approve continue, edit plan, or stop.\"");

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 75 of 83 in AI engineering

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

How do you validate and repair structured LLM output at runtime?

Continue