Module 8 · Agents & Multi-Agent Orchestration ⏱ 18 min

An Agent Is a State Machine

By the end of this lesson you will be able to:
  • Describe an agent as a state machine that perceives, decides, acts, and observes until it reaches a terminal state
  • Name the parts of the agent cycle and the status states that mark completion or failure
  • Implement a termination check so an agent stops on its goal instead of looping forever

The word agent is sold as something magical — a system that thinks for itself and takes action on your behalf. Strip away the marketing and an agent is a much more ordinary thing: a program that runs in a loop, looks at its situation, picks one action, takes it, and looks again, until it decides to stop. There is no ghost inside it.

That framing is the whole lesson. Once you see an agent as a state machine, every mysterious property becomes an ordinary engineering question. How does it know when to stop? — because you wrote a termination condition. How does it remember what happened? — because it carries state between turns. Can it run forever? — only if you forgot to give it a budget. The magic was never in the model; it was in the loop you build around it.

flowchart LR
  P["perceive<br/>read state"] --> D["decide<br/>pick an action"]
  D --> A["act<br/>run the action"]
  A --> O["observe<br/>fold result into state"]
  O --> C["reached a terminal state?"]
  C -->|"no"| P
  C -->|"yes"| DONE["stop"]
  style DONE fill:#166534,color:#fff
The agent cycle: perceive the state, decide an action, act, observe the result, and either loop or stop.

The four phases of one turn

A single trip around the agent loop has four named phases, and the names matter because they are the vocabulary every framework uses. Perceive is reading the current state of the world — the conversation so far, a tool's last result, a sensor reading. Decide is choosing an action using a policy: a function from state to action that, in an LLM agent, is the model plus its prompt. Act is executing that action — calling a tool, writing a row, sending a message. Observe is folding the outcome back into state so the next turn can see it.

The decision is the only step that involves judgment; the other three are plumbing. That is exactly why you can build and test an entire agent without a real model: swap the policy for a stub that returns scripted actions, and the perceive-decide-act-observe machinery is still the real thing.

One turn of a counter agent: decide whether to act, apply it, and mark done when the goal is met. Press Run.
function decide(s) {
  return s.count >= s.goal ? 'stop' : 'act';
}
function act(s, action) {
  return action === 'act' ? { ...s, count: s.count + 1 } : s;
}
function cycle(s) {
  const action = decide(s);
  let next = act(s, action);
  if (next.count >= next.goal) next = { ...next, status: 'done' };
  return next;
}

let s = { status: 'running', count: 0, goal: 2 };
console.log(s.status, s.count);
s = cycle(s); console.log(s.status, s.count);
s = cycle(s); console.log(s.status, s.count);

Status states and terminal states

The state an agent carries is more than the raw world; it includes the agent's own status. A clean design gives the machine a small set of named states — running, done, failed — and the cycle's job is partly to move between them. When the goal is met, the cycle transitions to done and the loop stops cleanly. When something goes wrong, it transitions to failed and stops with a reason. While neither has happened, it stays running.

A state the machine never leaves on its own is called terminal. done and failed are terminal; running is not. The termination condition you write is really a single check: have I reached a terminal state? If yes, stop; if no, take another turn.

flowchart LR
  IDLE["idle"] -->|"start"| RUN["running"]
  RUN -->|"goal met"| DONE["done"]
  RUN -->|"budget exhausted"| PARTIAL["done (partial)"]
  RUN -->|"error"| FAILED["failed"]
  style DONE fill:#166534,color:#fff
  style PARTIAL fill:#b45309,color:#fff
  style FAILED fill:#b91c1c,color:#fff
Status states and their transitions: running exits to done, a partial done, or failed.
isTerminal guards a run loop: the agent cycles until it reaches done or failed. Press Run.
function decide(s) { return s.count >= s.goal ? 'stop' : 'act'; }
function act(s, a) { return a === 'act' ? { ...s, count: s.count + 1 } : s; }
function cycle(s) {
  const action = decide(s);
  let n = act(s, action);
  if (n.count >= n.goal) n = { ...n, status: 'done' };
  return n;
}
function isTerminal(s) {
  return s.status === 'done' || s.status === 'failed';
}
function run(state, maxSteps) {
  let steps = 0;
  while (steps < maxSteps && !isTerminal(state)) {
    state = cycle(state);
    steps++;
  }
  return { state, steps };
}

const r = run({ status: 'running', count: 0, goal: 3 }, 100);
console.log(r.state.status, r.state.count);
console.log('steps', r.steps);

Three reasons to stop

An agent stops for exactly three reasons, and naming them removes the last of the mystery. It reached its goal — a predicate you wrote, like the answer is verified or the count hit the target, returned true. It exhausted its budget — a step counter hit its ceiling, so the loop stops whether or not the goal was met. Or it hit an error it cannot recover from, moving the machine to failed.

The goal exit is the happy path; the budget exit is the safety net; the error exit is honest failure. Notice that two of the three do not depend on the model behaving well. That independence is the point: a well-built agent terminates even when the model is unhelpful, because the budget and the error handling are yours, not the model's.

flowchart TD
  S["after each turn"] --> C["check the stop conditions"]
  C -->|"goal predicate true"| OK["success exit<br/>status = done"]
  C -->|"steps at ceiling"| CAP["safety exit<br/>budget exhausted"]
  C -->|"unrecoverable error"| ERR["failure exit<br/>status = failed"]
  style OK fill:#166534,color:#fff
  style CAP fill:#b45309,color:#fff
  style ERR fill:#b91c1c,color:#fff
Three exits after each turn: success on the goal, safety on the budget, or failure on an error.
The budget safety exit: the goal is unreachable in the budget, so the loop stops partial. Press Run.
function decide(s) { return s.count >= s.goal ? 'stop' : 'act'; }
function act(s, a) { return a === 'act' ? { ...s, count: s.count + 1 } : s; }
function cycle(s) {
  const action = decide(s);
  let n = act(s, action);
  if (n.count >= n.goal) n = { ...n, status: 'done' };
  return n;
}
function isTerminal(s) { return s.status === 'done' || s.status === 'failed'; }
function run(state, maxSteps) {
  let steps = 0;
  while (steps < maxSteps && !isTerminal(state)) {
    state = cycle(state);
    steps++;
  }
  return { state, steps };
}

const r = run({ status: 'running', count: 0, goal: 50 }, 4);
console.log(r.state.status, r.state.count);
console.log('steps', r.steps);

Agent, tool, and one-shot — three different things

These words get blurred, and the blur causes real design mistakes. A tool is a single function: you call it once, it returns, it is finished — no loop, no state, no decision. A one-shot prompt is a single round trip to a model: you send text, it sends text back, end of story. An agent is neither; it is the loop that can call a tool, read the result, call another tool, and stop only when its own termination condition fires.

If your system has no loop, it is not an agent, however much the marketing insists. And if it has a loop but no termination condition, it is a bug waiting to happen. The loop and the exit, together, are what make an agent.

Exercise

What does this print? The policy returns the action for the current state.

function decide(s) {
  if (s.count >= s.goal) return 'stop';
  return 'act';
}
console.log(decide({ count: 1, goal: 3 }));
console.log(decide({ count: 3, goal: 3 }));
Exercise

Write act(state, action) that returns the next state. When action is 'act', increment count by 1. For any other action (like 'stop'), leave the state's values unchanged. Always return a fresh state object carrying status, count, and goal.

function act(state, action) {
  // 'act' -> count + 1; any other action -> unchanged. Return a new state object.
}
Exercise

Write isTerminal(state) that returns true when the agent has reached a terminal state — status is 'done' or 'failed' — and false for any other status (like 'running' or 'idle').

function isTerminal(state) {
  // true only when status is 'done' or 'failed'
}
Exercise

This cycle decides an action and applies it correctly, but it never reaches a terminal state when the goal is met — so an agent using it stays running forever and only stops when the budget runs out. Add the missing check: when count reaches goal, set status to 'done'. The decide and act helpers are already correct.

function decide(s) { return s.count >= s.goal ? 'stop' : 'act'; }
function act(s, action) { return action === 'act' ? { ...s, count: s.count + 1 } : s; }

function cycle(state) {
  const action = decide(state);
  const next = act(state, action);
  // BUG: never transitions to a terminal state, so the agent loops until the budget saves it
  return next;
}
Exercise

Which feature defines a system as an agent rather than a single tool call?

Recap

  • An agent is a state machine that cycles through perceive, decide, act, observe until it reaches a terminal state — not magic, just a loop with an exit.
  • The four phases: perceive (read state), decide (a policy picks an action), act (run it), observe (fold the result back in).
  • Status statesrunning, done, failed — let the machine signal completion; done and failed are terminal.
  • An agent stops for one of three reasons: goal met, budget exhausted, or error.
  • Write the termination check: an agent that never reaches done grinds on until the budget saves it, or hangs forever.

Next you will build the full agent loop with a hard step budget, so termination is guaranteed no matter what the model does.

Checkpoint quiz

In the perceive-decide-act-observe cycle, which phase chooses what to do next?

An agent does all its work correctly but never sets its status to a terminal state. What happens?

Go deeper — technical resources