All posts

Keep the agent loop boring

Tool-using agents fail in dull, predictable ways. Timeouts, budgets and typed tools matter more than clever prompts.

2 min read

  • AI
  • Agents
  • TypeScript

An agent is a loop: the model picks a tool, you run it, you feed the result back, and repeat until the model answers. The exciting part is the model. The part that decides whether it works in production is the loop around it, and that loop should be as boring as possible.

Type every tool

Each tool gets a name, a description written for the model, and a strict input schema. Validate the model's arguments before running anything.

ts
import { z } from "zod";

const searchOrders = {
  name: "search_orders",
  description: "Find a customer's orders by email. Returns at most 10 orders.",
  input: z.object({
    email: z.string().email(),
    status: z.enum(["open", "shipped", "refunded"]).optional(),
  }),
  run: async ({ email, status }) => db.orders.find({ email, status }).limit(10),
};

When validation fails, return the error to the model as the tool result. Models are good at correcting their own arguments when told exactly what was wrong.

Put hard limits on the loop

ts
const MAX_STEPS = 8;
const TOOL_TIMEOUT_MS = 10_000;

for (let step = 0; step < MAX_STEPS; step++) {
  const reply = await model.next(messages);
  if (reply.type === "answer") return reply.text;

  const tool = tools[reply.tool];
  const result = await withTimeout(tool.run(tool.input.parse(reply.args)), TOOL_TIMEOUT_MS)
    .catch((err) => ({ error: String(err) }));

  messages.push({ role: "tool", name: reply.tool, content: JSON.stringify(result) });
}

return "I couldn't finish that. Here's what I found so far.";

A step cap stops runaway loops. A timeout per tool stops one slow API from hanging the whole request. A graceful final message beats an infinite spinner.

Keep tool results small

Returning a 5,000-row query result to the model is slow, expensive and usually counterproductive. Trim results to what the model needs to decide the next step, and let a follow-up tool fetch details on demand.

Separate reading from writing

Tools that read data can run freely. Tools that change things (refunds, emails, deletes) should either require confirmation from a person or run with tight, explicit limits. The model proposes; your code decides.

Log everything, then evaluate

Store every step: the messages, the tool calls, the arguments, the results and the timings. When something goes wrong, that trace is the only way to see why. Over time, those traces become test cases that tell you whether a new prompt or model is actually better.

Clever prompting gets a demo working. Boring engineering keeps it working.

Found this useful? Share it with someone building the same thing.

Building something like this?

Have a project, a role, or just an idea worth exploring? Drop me a message. I usually reply within a day.