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 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.
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
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
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.
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 }));
1 is less than 3, so the first state is not done yet.
3 equals the goal of 3, so the second state should stop.
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.
}
If action === 'act', spread the state and override count with count + 1.
Otherwise return a copy of the state as-is.
function act(state, action) {
if (action === 'act') return { ...state, count: state.count + 1 };
return { ...state };
}
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'
}
Return true if status is 'done'.
Also return true if status is 'failed'; everything else is false.
function isTerminal(state) {
return state.status === 'done' || state.status === 'failed';
}
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;
}
After computing next, compare next.count to next.goal.
When the goal is reached, return a copy with status set to 'done'.
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);
let next = act(state, action);
if (next.count >= next.goal) next = { ...next, status: 'done' };
return next;
}
Which feature defines a system as an agent rather than a single tool call?
A tool is called once and returns; an agent is defined by the loop — perceive, decide, act, observe — plus a termination condition. The model, the network, and the return value are all incidental; the loop and the exit are the essence.
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 states —
running,done,failed— let the machine signal completion;doneandfailedare terminal. - An agent stops for one of three reasons: goal met, budget exhausted, or error.
- Write the termination check: an agent that never reaches
donegrinds 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.