The agent loop: plan, action, observation
In short: An AI agent is a loop: the model states a plan and asks for an action, the program runs it and returns the observation, until there is an answer, a step limit or a need for a person.
Problem
The assistant answers but does not do the job
The customer writes: “The headphones from order 1187 are broken. I want to return them.”
This is not a question with one answer. Someone has to check that the order was delivered, open a return request and tell the customer what happens next. If the order is not delivered yet, the path is different. If the customer wants compensation, it is different again.
The loop from the tool use lesson answered a question: the model asks for data, the program gives it, the model answers. Here several actions must happen one after another, and which one comes next is known only after the previous one.
You can write it with an if for every case. It works for the cases you foresaw. Customers also write the ones you did not.
The idea
The agent: plan, action, observation
An agent is a program in which the model picks the next step towards a goal. Every step is three things:
- Plan. The model says in words what it will do: “first I check whether the order was delivered”.
- Action. The model asks for one tool (a
tool_useblock), and the program runs it. - Observation. The result goes back into the history (a
tool_resultblock). At the next step the model sees it and picks again.
This is the agent loop. The program keeps three exits from it:
- Answer. The model is done and returns
end_turn. - Step limit. After 5 steps without an answer the program stops and hands over to a colleague.
- Needs a person. The model itself asks for the
hand_to_humantool. The program does not run it; it stops.
The agent’s tools are safe: order status, the courier’s delivery date and a return request. The request only waits for a colleague. Money never goes through the agent.
See it
The tools and the data
The four tools. The description is for the model: it chooses by it.
function tool(name, description, field) { const schema = { type: "object", properties: { [field]: { type: "string" } }, required: [field], }; return { name, description, input_schema: schema };}
const TOOLS = [ tool("get_order_status", "Status and date of an order by its number.", "order_id"), tool("estimate_delivery", "The delivery date the courier expects.", "order_id"), tool("open_return_request", "Opens a return request. A colleague approves it.", "order_id"), tool("hand_to_human", "Hands the conversation to a colleague, with the reason.", "reason"),];The program runs what was asked against its own data. The return request goes into a list that a colleague looks at:
function runTool(use) { const orderId = use.input.order_id ?? ""; const order = ORDERS[orderId]; if (order === undefined) return result(use, `no order ${orderId}`, true); const delivered = order.status === DELIVERED; let text; switch (use.name) { case "get_order_status": text = `${orderId} — ${order.status}, delivery ${order.date}`; break; case "estimate_delivery": text = delivered ? `${orderId} — already delivered` : `${orderId} — expected delivery ${order.date}, no new scan`; break; case "open_return_request": { if (!delivered) return result(use, `${orderId} is not delivered yet`, true); const request = `R-${REQUESTS.length + 1} for ${orderId}`; REQUESTS.push(request); text = `${request} — waits for a colleague`; break; } default: return result(use, `no tool ${use.name}`, true); } return result(use, text);}
function result(use, text, isError = false) { const block = { type: "tool_result", tool_use_id: use.id, content: text }; if (isError) block.is_error = true; return block;}hand_to_human is not here. It is not an action but an exit from the loop.
See it
The loop
function runAgent(model, task) { console.log(`task: ${task}`); const first = { type: "text", text: task }; const history = [{ role: "user", content: [first] }]; for (let step = 1; step <= MAX_STEPS; step++) { const reply = model.create(SYSTEM, history, TOOLS); const head = ` step ${step}. `; const text = textOf(reply); if (reply.stopReason !== "tool_use") { console.log(`${head}done: ${text}`); return; } const use = toolUseOf(reply); console.log(`${head}plan: ${text}`); const action = describe(use); console.log(` action: ${action}`); if (use.name === "hand_to_human") { console.log(" stop: needs a person —", use.input.reason); return; } history.push({ role: "assistant", content: reply.content }); const result = runTool(use); console.log(` observation: ${result.content}`); history.push({ role: "user", content: [result] }); } console.log(` stop: ${MAX_STEPS} steps, no answer`, "→ to a colleague");}Step by step, in words
- The task is the first message. The agent has 5 steps (MAX_STEPS) and four tools.
- Step 1. The model says a plan and asks for one action: get_order_status(1187).
- It is not hand_to_human, so the program runs it. The observation goes into the history.
- Step 2. The model sees “delivered”, so the plan changes: open_return_request(1187).
- The tool only opens request R-1. It waits for a colleague: the agent never refunds money.
- Step 3. The model answers with end_turn: the loop stops on an answer, after 3 of 5 steps.
- Two more exits stay ready: the step limit and hand_to_human. Without them the agent can loop forever.
The helper functions find the plan (the text block) and the action (the tool_use block):
function textOf(reply) { return reply.content.find((b) => b.type === "text")?.text ?? "";}
function toolUseOf(reply) { return reply.content.find((b) => b.type === "tool_use");}
function describe(use) { return `${use.name}(${Object.values(use.input).join(", ")})`;}function runAgent(model, task) { console.log(`task: ${task}`); const first = { type: "text", text: task }; const history = [{ role: "user", content: [first] }]; for (let step = 1; step <= MAX_STEPS; step++) { const reply = model.create(SYSTEM, history, TOOLS); const head = ` step ${step}. `; const text = textOf(reply); if (reply.stopReason !== "tool_use") { console.log(`${head}done: ${text}`); return; } const use = toolUseOf(reply); console.log(`${head}plan: ${text}`); const action = describe(use); console.log(` action: ${action}`); if (use.name === "hand_to_human") { console.log(" stop: needs a person —", use.input.reason); return; } history.push({ role: "assistant", content: reply.content }); const result = runTool(use); console.log(` observation: ${result.content}`); history.push({ role: "user", content: [result] }); } console.log(` stop: ${MAX_STEPS} steps, no answer`, "→ to a colleague");}const model = new FakeModel();runAgent(model, "The headphones from order 1187 are broken. I want to return them.");console.log("waiting for approval:", REQUESTS.join(", "));task: The headphones from order 1187 are broken. I want to return them. step 1. plan: First I check whether the order was delivered. action: get_order_status(1187) observation: 1187 — delivered, delivery 26 October step 2. plan: It was delivered. I open a return request. action: open_return_request(1187) observation: R-1 for 1187 — waits for a colleague step 3. done: I opened request R-1 to return 1187. A colleague will approve it and write to you.waiting for approval: R-1 for 1187Nowhere does the code say “after the status, open a request”. The model picks it because the observation says “1187 — delivered, delivery 26 October”. The program keeps the limit, runs and records.
The model here is a fake client, as in the earlier lessons: from the words of the task and the observations so far it picks the next step. Its rule is in “More”.
Try it
Fill in the blank
Which action does the program not run but stop the loop on?
if (use.name === "") {hand_to_human. The model asks for it when the task is not for it: compensation, a complaint, something outside the rules. The program prints the reason and stops. A real shop would send the conversation to a colleague with the whole history.
The idea
When the agent does not stop
The customer writes “When does 1042 arrive? The courier said today.”. The courier has no new scan, and the model asks again and again:
const model = new FakeModel();runAgent(model, "When does 1042 arrive? The courier said today.");task: When does 1042 arrive? The courier said today. step 1. plan: I ask the courier for the date. action: estimate_delivery(1042) observation: 1042 — expected delivery 3 November, no new scan step 2. plan: No change. I ask again. action: estimate_delivery(1042) observation: 1042 — expected delivery 3 November, no new scan step 3. plan: No change. I ask again. action: estimate_delivery(1042) observation: 1042 — expected delivery 3 November, no new scan step 4. plan: No change. I ask again. action: estimate_delivery(1042) observation: 1042 — expected delivery 3 November, no new scan step 5. plan: No change. I ask again. action: estimate_delivery(1042) observation: 1042 — expected delivery 3 November, no new scan stop: 5 steps, no answer → to a colleagueEvery step sends the whole history, so every step costs more than the one before (the numbers are in the cost lesson). Without the limit the program loops until the money runs out. With it the customer reaches a person after 5 steps.
The limit is the last defence, not the only one. Here the model asks the same thing five times, and the program could have seen that at the second step. That is the lesson’s exercise.
Try it
Predict the output
The customer wants compensation. What does runAgent print?
const model = new FakeModel();runAgent(model, "I want 20 € compensation because 1042 is late.");Two steps. The model checks the order and then asks for hand_to_human: compensation is not something it decides. The program does not run that action and stops with the reason. It does not open a return request and does not reach the limit.
Try it
Find the bug
A colleague writes runAgent, and every conversation ends with “5 steps, no answer”, even when the customer got an answer. Which line has the bug?
Pick the line with the bug.
break leaves only the loop. After it the program reaches the line after the loop and prints that the limit ran out. So the customer gets an answer and right after it “to a colleague”. It must be return: the answer is an exit from the whole function.
The idea
Run it at your place
This program is not in the lesson’s check: you need an account with a model provider, a key and money for every call. The key is in LLM_API_KEY, never in the code. Providers ship official SDKs for Java, C#, Python and JavaScript with the same shape (messages, roles, tokens, tools); the exact class and method names are in the provider’s documentation.
The first request carries the task and the four tools. The model name is a placeholder:
{ "model": "<model name>", "max_tokens": 1024, "system": "You are the support agent of an online shop. Use the tools; a person decides about money.", "tools": [ { "name": "get_order_status", "description": "Status and date of an order by its number.", "input_schema": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"] } }, { "name": "estimate_delivery", "description": "The delivery date the courier expects.", "input_schema": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"] } }, { "name": "open_return_request", "description": "Opens a return request. A colleague approves it.", "input_schema": { "type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"] } }, { "name": "hand_to_human", "description": "Hands the conversation to a colleague, with the reason.", "input_schema": { "type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"] } } ], "messages": [ { "role": "user", "content": "The headphones from order 1187 are broken. I want to return them." } ]}The answer carries both the plan and the action: a text block and a tool_use block in one message. The numbers in usage are rough, by the rule from the tool use lesson:
{ "content": [ { "type": "text", "text": "First I check whether the order was delivered." }, { "type": "tool_use", "id": "call_1", "name": "get_order_status", "input": {"order_id": "1187"} } ], "stop_reason": "tool_use", "usage": {"input_tokens": 87, "output_tokens": 13}}Every request is sent with the language’s standard HTTP client, and the loop around it is runAgent. The address is a placeholder:
// requestJson is the request JSON above.// The exact key header is in the provider’s documentation.const response = await fetch("https://<provider>/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.LLM_API_KEY}`, "Content-Type": "application/json", }, body: requestJson,});console.log(await response.text());The idea
When it hurts
- The path is known. If “order status” is always the same request, write it in code. The agent is slower, costs more and may pick something different each time. It pays off when the steps depend on what shows up along the way.
- Actions with consequences. Refunds, cancellations, an email to every customer. The agent prepares them, a person approves them. The model may pick the wrong order, and the text it reads may mislead it. How that happens is in the prompt injection lesson, and the checks around the model are in the guardrails lesson.
- No limit. A limit on steps, on tokens (or euros) and on time. Each of them stops the agent and hands over to a person instead of looping.
- No log. When the agent gets it wrong, you need to see the plan, the action and the observation of every step. The output of
runAgentis exactly such a log. - No tests. An agent is measured like any app with a model: a test set of tasks with checks on the final answer, but also on the path (which actions, how many steps).
More precisely, if you are curious
One action per step. A real model may ask for several tools in one answer, as in the tool use lesson. Here every step is one action so that the order is visible. A program with several actions runs them all and returns all the results in one user message.
The plan is text. The model writes “first I check”, but nothing forces it to do exactly that. The program does not trust the plan; it runs only the action, and only if it is on the list of tools. The plan is useful for the log and for the person who reads it afterwards.
A name from the papers. The rhythm “thought, action, observation” is known as ReAct (reason + act). There are also agent libraries that run the loop for you. Inside they are the same three things and the same exits.
The fake model inside. It collects the observations from the history and counts them. For a return: first the status; if it is “delivered”, a request; then an answer with the request number. For compensation: the status, then hand_to_human. For the courier: it asks again at every step. The tool_use ids are call_1, call_2.
// Stands in for the real model: recorded answers,// with no network and the same every time.class FakeModel { static NOT_DELIVERED = "The order is not delivered yet. You can return it after delivery."; static THANKS = "You are welcome! Write if you have a question."; lastId = 0;
create(system, messages, tools) { const task = messages[0].content[0].text; const seen = messages .flatMap((m) => m.content) .filter((b) => b.type === "tool_result") .map((b) => b.content); const n = seen.length; const id = task.includes("1187") ? "1187" : "1042"; if (task.includes("return")) { if (n === 0) return this.act("First I check whether the order was delivered.", "get_order_status", "order_id", id); if (n === 1 && seen[0].includes("delivered")) { return this.act("It was delivered. I open a return request.", "open_return_request", "order_id", id); } if (n === 1) return FakeModel.say(FakeModel.NOT_DELIVERED); const request = seen[n - 1].split(" ")[0]; return FakeModel.say(`I opened request ${request} to return ${id}. A colleague will approve it and write to you.`); } if (task.includes("compensation")) { if (n === 0) return this.act("I check the order.", "get_order_status", "order_id", id); return this.act("A person decides about compensation, not me.", "hand_to_human", "reason", `compensation for ${id}`); } if (task.includes("courier")) { const plan = n === 0 ? "I ask the courier for the date." : "No change. I ask again."; return this.act(plan, "estimate_delivery", "order_id", id); } return FakeModel.say(FakeModel.THANKS); }
// A plan (text) and one action (tool_use) in one answer. act(plan, tool, key, value) { this.lastId++; const use = { type: "tool_use", id: `call_${this.lastId}`, name: tool, input: { [key]: value } }; return { content: [{ type: "text", text: plan }, use], stopReason: "tool_use" }; }
static say(text) { return { content: [{ type: "text", text }], stopReason: "end_turn" }; }}Exercise
The same action twice
Solve it in your editor.
The customer writes “When does 1042 arrive? The courier said today.”, and the agent asks the courier five times before it stops. Add a check to runAgent: if the model asks for the same action as at the previous step (the same name and the same input), the program prints stop: the same action twice → to a colleague and stops.
task: When does 1042 arrive? The courier said today. step 1. plan: I ask the courier for the date. action: estimate_delivery(1042) observation: 1042 — expected delivery 3 November, no new scan step 2. plan: No change. I ask again. action: estimate_delivery(1042) stop: the same action twice → to a colleagueMore tasks
- A token budget. Count the tokens of every step (words × 1.3, as in the tool use lesson) and stop when the total goes over 300. At which step does the courier task stop?
- An order not delivered yet. Run a return task for order 1042, which is not delivered yet. Which exit does the agent take and what does it tell the customer?
Test
Test yourself
Related lessons
Section titled "Related lessons"- Builds on: Structured output: JSON by a schema · Tool use: the model asks, the program acts · Evaluation: a test set for the assistant · Cost and latency: budget, cache, a smaller model
- Continues in: Prompt injection: the email that gives orders · Guardrails: checks before and after the model · The support assistant
- Tasks on this topic: Agents