ESC

Type to search the knowledge base.

The Think, Act, Observe Loop

The agent loop is: reason, pick a tool, run it, feed the result back, repeat until stop. Timeouts, max steps, and what "done" means.

beginner6 min read
  • agents
  • react
  • loop

Without a max-step cap, “looks like it is thinking” is an infinite bill. The spinner is honest about latency and silent about a model that calls search twelve times with the same query. An agent loop is not a personality. It is a while you write, with a deadline.

Think, act, observe is the whole control flow: the model proposes, your code runs a tool (or refuses), you append the observation, you call the model again, you stop on purpose.

Key takeaways

  • You write the loop. The model only samples the next message or tool call.
  • Stop on final answer, max steps, wall clock, abort, or a spent error budget.
  • A tool error is an observation, not an automatic retry storm.
  • Traces should show thought, tool, args, and result. That is how you debug.
  • Parallel reads are fine. Serial writes, and cap both.

An agent loop you can actually stop

Providers differ on field names. The shape does not:

  1. Send messages plus tool schemas.
  2. If the response is text and no tool call, you are done.
  3. If it is one or more tool calls, run the ones you allow.
  4. Append tool results as a tool-role message (or the provider’s equivalent).
  5. Repeat until a stop rule fires.

That is the same idea as the JS event loop, with worse guarantees: each “turn” is an HTTP call that samples. You still need the outer scheduler.

type ToolCall = { id: string; name: string; args: unknown };
type ModelTurn =
  | { kind: 'final'; text: string }
  | { kind: 'tools'; calls: ToolCall[] };

type LoopStop =
  | { reason: 'final' }
  | { reason: 'max_steps'; steps: number }
  | { reason: 'deadline' }
  | { reason: 'aborted' }
  | { reason: 'error_budget' };

export async function runAgentLoop(opts: {
  complete: (messages: unknown[]) => Promise<ModelTurn>;
  execute: (call: ToolCall) => Promise<string>;
  messages: unknown[];
  maxSteps: number;
  deadlineMs: number;
  signal: AbortSignal;
  maxToolErrors: number;
}): Promise<{ text: string; stop: LoopStop; steps: number }> {
  const started = Date.now();
  let toolErrors = 0;

  for (let step = 1; step <= opts.maxSteps; step++) {
    if (opts.signal.aborted) {
      return { text: '', stop: { reason: 'aborted' }, steps: step };
    }
    if (Date.now() - started > opts.deadlineMs) {
      return { text: '', stop: { reason: 'deadline' }, steps: step };
    }

    const turn = await opts.complete(opts.messages);

    if (turn.kind === 'final') {
      return { text: turn.text, stop: { reason: 'final' }, steps: step };
    }

    for (const call of turn.calls) {
      let observation: string;
      try {
        observation = await opts.execute(call);
      } catch (err) {
        toolErrors += 1;
        observation = `ERROR: ${err instanceof Error ? err.message : 'tool_failed'}`;
        if (toolErrors >= opts.maxToolErrors) {
          return { text: '', stop: { reason: 'error_budget' }, steps: step };
        }
      }
      opts.messages.push({
        role: 'tool',
        toolCallId: call.id,
        content: observation,
      });
    }
  }

  return { text: '', stop: { reason: 'max_steps', steps: opts.maxSteps }, steps: opts.maxSteps };
}

maxSteps: 8 and deadlineMs: 20_000 are starting points, not research. Reads of public docs can be higher. Anything that sends email should be maxSteps: 2 and a human pause on the write.

Wire signal to the Stop button with AbortController. Cancel the in-flight completion and break the for. A cancelled fetch that still continues the loop is how you double-call a tool.

Stop conditions that actually stop

The model will often say “let me try again.” That is not a stop condition.

Condition Fires when Use it for
Final message Model emits no tool call Happy path
Max steps step > N Bounding tokens and loops
Wall clock now > deadline User-facing p95
Abort Client disconnected / Stop UX honesty
Error budget N tool exceptions Broken APIs, bad args
One write A side-effect tool succeeded Refunds, emails, deploys

“Done” is a product rule. For an issue bot, done might be: posted at most one comment, or produced a draft and stopped. For a docs chatbot with a search tool, done is: answered or refused after two searches. If you cannot write the rule in a sentence, you do not have a loop you can operate.

Do not retry a successful side-effecting tool just because the next completion 500’d. Idempotency keys belong in the tool schema. The loop should treat “already did the write” as terminal.

Reading a ReAct-style trace

ReAct (reason + act) interleaves a short thought with a tool name. You do not need the original paper’s format. You need a log you can grep.

type TraceEvent =
  | { t: number; type: 'thought'; text: string }
  | { t: number; type: 'tool'; name: string; args: unknown; callId: string }
  | { t: number; type: 'observation'; callId: string; ok: boolean; chars: number }
  | { t: number; type: 'stop'; reason: LoopStop['reason'] };

const trace: TraceEvent[] = [
  { t: 0, type: 'thought', text: 'Need open issues mentioning login.' },
  { t: 1, type: 'tool', name: 'list_open_issues', args: { query: 'login' }, callId: 'c1' },
  { t: 2, type: 'observation', callId: 'c1', ok: true, chars: 842 },
  { t: 3, type: 'thought', text: 'Issue 412 looks like a bug.' },
  { t: 4, type: 'tool', name: 'add_label', args: { issueId: '412', label: 'bug' }, callId: 'c2' },
  { t: 5, type: 'observation', callId: 'c2', ok: true, chars: 24 },
  { t: 6, type: 'stop', reason: 'final' },
];

If you see ten list_open_issues with identical args, the loop is missing a “same call signature” breaker. If you see a thought that claims a tool returned [] when the observation was 842 characters, the model is lying about the observation. Believe the trace, not the thought. Catalog those in agent failure modes.

Keep thoughts out of the user bubble unless you have a debug panel. Users asked for a triage, not a monologue.

Timeouts vs step budgets

They catch different bugs.

  • Step budget catches “one more search” loops. Cheap calls can still blow the budget.
  • Wall clock catches a single slow tool (your GitHub client hung).
  • Per-tool timeout should be smaller than the loop deadline. Five 8-second searches will miss a 20-second deadline even at maxSteps: 8.

Run independent read tools from one turn with Promise.allSettled. One failure becomes an observation for that callId. Do not fail the whole turn unless the schema said the reads were required as a set.

This loop is the runtime for what AI agents are. The schemas you pass in are tool schema design. None of that matters if maxSteps is Infinity.

Interview answer (out loud)

“The agent loop is reason, pick a tool, run it in my process, append the observation, repeat. I cap it with max steps, a wall-clock deadline, AbortController, and an error budget. A tool error is an observation. A successful write is often terminal. I would log thought, tool, args, and result so I can see identical searches and hallucinated observations. Without a cap, ‘it is thinking’ is an unbounded bill.”

Further reading (primary sources)

Related guides