Mustafa Nevfel

8 min read

Run AI agents like queue workers: briefs, budgets and stop reasons

How I run AI coding agents like queue workers: written briefs, narrow tools, hard budgets, idempotent writes, typed stop reasons and review gates.

Key points

  • An agent loop fails like a queue worker: runaway retries, duplicated side effects, silent partial work. The same controls apply.
  • A model's stop_reason says why one generation ended, not whether the task did. The loop needs its own: done, out_of_budget, blocked, failed.
  • Derive idempotency keys in the harness from task, tool and canonical arguments. The key must reach the system that owns the side effect.
  • MCP tool annotations are hints, not enforcement. Put least privilege in credentials and treat annotations as documentation.
  • Size tasks to one review sitting. Review capacity, not generation speed, is the throughput limit.

An AI coding agent is a worker process with an open budget. It can run as long, spend as much and touch as many systems as you allow. The failure modes are the ones I know from queue workers in production: runaway retries, duplicated side effects, partial work reported as finished, and no record of why a run ended. I use the same controls: a written brief, narrow tools, hard budgets, idempotency keys, a typed stop reason and a review gate before anything merges.

What does an agent loop share with a queue worker?

Both pull a unit of work, run steps with side effects, and get retried by something that does not know what already happened.

Queue worker Agent loop
Job payload with a schema Brief with an acceptance check
Execution timeout, max attempts Budgets: wall clock, steps, tokens, tool errors
Idempotency key on side effects Key from task, tool and arguments
Dead-letter queue with a reason Typed stop reason, logged and routed
Least-privilege service account Narrow typed tools, read/write split
Code review, staged rollout Review gate against the brief

One difference: the plan is generated at run time, so I cannot review its code path beforehand. The weight moves to the brief going in and the gate coming out.

What goes into a brief?

The brief is the job payload: a one-sentence goal, constraints, non-goals, a machine-runnable acceptance check and a size cap. Values are illustrative.

text
Goal: honor Retry-After (delay-seconds and HTTP-date) on 429 and 503 in the HTTP client wrapper.
Constraints: touch only src/http/* and test/http/*; keep the public request() signature; no new dependencies.
Non-goals: exponential backoff, jitter, circuit breaking.
Done when: `pnpm test http` exits 0 and the new tests fail on the old code.
Size cap: 300 changed lines.

Non-goals do the most work. Agents add things: an adjacent refactor, a renamed helper. Without a non-goal, "also fixed X" reads as initiative; with one, it is a defect I can reject by pointing at a line.

I hold the size cap hardest. A model writes a large diff faster than anyone verifies it, so review capacity is the throughput limit, and I size tasks to one review sitting. When a diff will not fit, I split the task, not the review. The cost is decomposition work up front.

How narrow should the tools be?

As narrow as an API permission. The OWASP Top 10 for LLM Applications names the opposite Excessive Agency (too much functionality, permission or autonomy). A generic run_shell or execute_sql tool is the first by construction; its credentials decide the rest. I prefer run_tests(path) and open_pull_request(branch, title).

MCP has a vocabulary for this: a JSON Schema inputSchema plus optional annotations readOnlyHint, destructiveHint, idempotentHint and openWorldHint. In the 2026-07-28 schema the defaults are pessimistic: an unannotated tool is not read-only, destructive, not idempotent and open-world. But the schema calls every annotation a hint and says clients should never base tool-use decisions on annotations from untrusted servers. They suit prompts and display. The barrier is the credential: the read tool holds a read-only one, and the write tool validates input and rate-limits.

Tool execution errors (isError: true) are meant to reach the model so it can self-correct; I budget them, because a model correcting itself forever is a retry storm. The schema also has no idempotency-key field (idempotentHint only describes behavior), so write tools get a key parameter that the harness fills in. I do not trust a model to repeat a key across attempts.

What does a bounded loop look like?

next is the model adapter: it returns a call or a halt carrying an expected ending, and throws on real failures.

ts
import { createHash } from "node:crypto";

type Tool = {
  effect: "read" | "write" | "irreversible";
  run(args: unknown, ctx: { idempotencyKey: string; signal: AbortSignal }): Promise<unknown>;
};
type Budget = { maxSteps: number; maxTokens: number; wallClockMs: number; maxToolErrors: number };

export type Stop =
  | { kind: "done"; summary: string }
  | { kind: "out_of_budget"; limit: "steps" | "tokens" | "wall_clock" | "tool_errors" }
  | { kind: "blocked"; reason: string }
  | { kind: "failed"; error: string };

type Turn = { tokens: number } & (
  | { type: "call"; tool: string; args: unknown }
  | { type: "halt"; stop: Stop }
);

type Deps = {
  next(history: readonly unknown[], opts: { maxTokens: number; signal: AbortSignal }): Promise<Turn>;
  tools: ReadonlyMap<string, Tool>; // a Map, not an object: names come from the model
  ledger: Map<string, unknown>; // production: a table with a unique key
  trace(event: object): void;
};

const canonical = (v: unknown) =>
  JSON.stringify(v, (_, x) =>
    x && typeof x === "object" && !Array.isArray(x)
      ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : 1)))
      : x,
  );

export async function runTask(taskId: string, { next, tools, ledger, trace }: Deps, budget: Budget) {
  const signal = AbortSignal.timeout(budget.wallClockMs);
  const history: unknown[] = [];
  let step = 0, tokens = 0, errors = 0;
  const stop = (s: Stop) => {
    trace({ taskId, step, tokens, stop: s });
    return { stop: s, step, tokens };
  };

  while (step < budget.maxSteps) {
    if (signal.aborted) return stop({ kind: "out_of_budget", limit: "wall_clock" });
    if (tokens >= budget.maxTokens) return stop({ kind: "out_of_budget", limit: "tokens" });
    step++;

    let turn: Turn;
    try {
      turn = await next(history, { maxTokens: budget.maxTokens - tokens, signal });
    } catch (e) {
      return stop(signal.aborted
        ? { kind: "out_of_budget", limit: "wall_clock" }
        : { kind: "failed", error: String(e) });
    }
    tokens += turn.tokens;
    if (turn.type === "halt") return stop(turn.stop);

    const tool = tools.get(turn.tool);
    if (tool?.effect === "irreversible")
      return stop({ kind: "blocked", reason: `approval needed: ${turn.tool}` });

    const key = createHash("sha256").update(canonical([taskId, turn.tool, turn.args])).digest("hex");
    let ok = true;
    try {
      if (!tool) throw new Error(`unknown tool: ${turn.tool}`);
      const write = tool.effect === "write";
      const result = write && ledger.has(key)
        ? ledger.get(key)
        : await tool.run(turn.args, { idempotencyKey: key, signal });
      if (write) ledger.set(key, result);
      history.push({ tool: turn.tool, ok, result });
    } catch (e) {
      ok = false;
      history.push({ tool: turn.tool, ok, error: String(e) });
    }
    trace({ taskId, step, tool: turn.tool, key, ok });
    errors = ok ? 0 : errors + 1;
    if (errors > budget.maxToolErrors) return stop({ kind: "out_of_budget", limit: "tool_errors" });
  }
  return stop({ kind: "out_of_budget", limit: "steps" });
}

The decisions that matter:

  • Budgets are checked before each step. Overshoot is bounded by one model call: the remaining tokens cap its output, though its input size is known only afterward.
  • Irreversible tools never run inside the loop. The harness returns blocked and a human decides. Resuming needs history persisted, which the sketch skips.
  • The key hashes task, tool and sorted arguments. A retried step, or a repeated call after a crash, hits the ledger instead of the world. Two costs: identical legitimate writes in one task collapse into one, and a replay that rephrases its arguments gets a new key. Where an effect has a natural identity, such as one pull request per task, I key on that.
  • The ledger alone is not exactly-once. If the process dies after the side effect and before ledger.set, the replay repeats it. The key must reach the system that owns the effect, as an Idempotency-Key header or a unique constraint. Payment APIs with idempotency keys show the shape: a repeated key returns the first saved response; the same key with different parameters is an error. A downstream system that cannot do this keeps that write out of the loop.

Why does the loop need its own stop reason?

Model APIs already return one. Anthropic's Messages API documents seven stop_reason values: end_turn, max_tokens, stop_sequence, tool_use, pause_turn, refusal and model_context_window_exceeded. They say why one generation ended, not whether the task did. end_turn can arrive with empty content, particularly after tool results according to the docs, so a loop that reads it as "done" reports success on nothing. A refusal is a normal HTTP 200, so an error handler never sees it.

Finishing is an explicit finish tool call, which the adapter returns as done; the gate reruns the acceptance check instead of trusting the report. The adapter maps the API values:

  • end_turn without finish is not done. I return blocked: a model that stopped unfinished needs a person to look.
  • max_tokens and model_context_window_exceeded become out_of_budget (limit tokens).
  • refusal becomes blocked; a person rewrites the brief or retries on another model.
  • pause_turn comes only with server tools such as web search, when the server-side loop hits its iteration limit (10 per request by default). The docs say to resend the response as-is; their sample caps that loop with its own max_continuations, and I count each continuation as a step.

Four outcomes, four owners:

  • done: the agent claims completion; the gate decides whether it is accepted.
  • out_of_budget: a human re-scopes. Raising the budget by reflex hides the signal: the task is usually too large or the brief too vague.
  • blocked: a human decides, then the task resumes.
  • failed: a defect in a tool, the brief or the model call.

What does the review gate check?

Mechanical checks come first: the acceptance command exits 0, typecheck and lint pass, and the diff stays within the allowed paths and the size cap. These reject cheaply, without anyone reading.

Then judgment, from a human or a second agent that gets the brief, the diff and the check output, not the producer's transcript. It walks each constraint and non-goal and says where the diff satisfies or violates it. It may share the producer's blind spots, so its output orders my reading; it is not a verdict. Anything touching money, auth, migrations or deletion gets a human regardless.

What do I log and measure?

I trace every step with task id, tool, effect class, idempotency key, tokens and outcome, and hash or redact arguments, because traces outlive the secrets that pass through them. Three numbers drive decisions: the stop-reason mix, the gate rejection rate by task class, and cost per accepted task, since cost per run rewards cheap failures.

When is this overkill, and where does autonomy grow?

For a spike, a throwaway script or a one-file change I watch in the editor, a ledger and a gate cost more than the task; supervision is the control. Gates add latency, a reviewer agent adds a pass over every diff, and the harness is code someone maintains.

Autonomy grows by effect class, not by enthusiasm: read-only tools first, then reversible writes on a branch or in a sandbox behind CI and review, with production writes behind approval until the data says otherwise. I promote a task class only when its rejection rate stays low over a meaningful sample.

Checklist

  • Brief: goal, constraints, non-goals, machine-run acceptance check, a size cap of one review sitting.
  • Narrow, typed tools; enforce in credentials, not annotations.
  • Budgets on steps, tokens, wall clock and consecutive tool errors.
  • Harness-generated idempotency key on every write, passed to the system of record.
  • Four stop reasons, graphed separately. end_turn is not done; irreversible actions and refusals end as blocked.
  • Gate against the brief: mechanical checks, then a reviewer.
  • Trace every tool call; track cost per accepted task.

Snippets from this note

  1. Snippet · TypeScript

    Bounded AI agent loop with step, token, time and error budgets

    Check every budget before each step, share one AbortSignal with the model call and the tools, and end with a typed out_of_budget that names the limit.

  2. Snippet · TypeScript

    Map Claude stop_reason to a typed agent loop outcome

    Map stop_reason in the adapter to done, blocked or out_of_budget, resume on pause_turn, and throw on anything unexpected rather than reporting success.

  3. Snippet · TypeScript

    Derive idempotency keys for AI agent tool calls

    Hash the task id, tool name and canonical arguments in the harness, and pass the key to the system that owns the side effect, not only to a local ledger.

Mustafa Nevfel is a founder and software architect, shipping production systems since 2012 and writing about architecture, real-time systems, AI agents and what breaks in production. About the author · Follow on X