Skip to content
wannacode

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:

  1. Plan. The model says in words what it will do: “first I check whether the order was delivered”.
  2. Action. The model asks for one tool (a tool_use block), and the program runs it.
  3. Observation. The result goes back into the history (a tool_result block). 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_human tool. 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.

static class Shop
{
public const string SystemPrompt =
"You are the support agent of an online shop. Use the tools; a person decides about money.";
public const int MaxSteps = 5;
public const string Delivered = "delivered";
public static readonly Tool[] Tools =
[
new("get_order_status",
"Status and date of an order by its number.", "order_id"),
new("estimate_delivery",
"The delivery date the courier expects.", "order_id"),
new("open_return_request",
"Opens a return request. A colleague approves it.", "order_id"),
new("hand_to_human",
"Hands the conversation to a colleague, with the reason.", "reason"),
];
public static readonly Dictionary<string, Order> Orders = new()
{
["1042"] = new("shipped", "3 November"),
["1187"] = new("delivered", "26 October"),
};
// The request only waits: a colleague refunds the money.
public static readonly List<string> Requests = [];
}

The program runs what was asked against its own data. The return request goes into a list that a colleague looks at:

static ToolResult RunTool(ToolUse use)
{
var orderId = use.Input.GetValueOrDefault("order_id", "");
if (!Orders.TryGetValue(orderId, out var order))
{
return new ToolResult(use.Id, $"no order {orderId}", true);
}
var delivered = order.Status == Delivered;
string 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 new ToolResult(use.Id, $"{orderId} is not delivered yet", true);
}
var request = $"R-{Requests.Count + 1} for {orderId}";
Requests.Add(request);
text = $"{request} — waits for a colleague";
break;
default:
return new ToolResult(use.Id, $"no tool {use.Name}", true);
}
return new ToolResult(use.Id, text, false);
}

hand_to_human is not here. It is not an action but an exit from the loop.

See it

The loop

static void RunAgent(FakeModel model, string task)
{
Console.WriteLine($"task: {task}");
List<Message> history =
[new("user", [new TextBlock(task)])];
for (var step = 1; step <= MaxSteps; step++)
{
var reply =
model.Create(SystemPrompt, history, Tools);
var head = $" step {step}. ";
var text = TextOf(reply);
if (reply.StopReason != "tool_use")
{
Console.WriteLine($"{head}done: {text}");
return;
}
var use = ToolUseOf(reply);
Console.WriteLine($"{head}plan: {text}");
var action = Describe(use);
Console.WriteLine($" action: {action}");
if (use.Name == "hand_to_human")
{
Console.WriteLine(" stop: needs a person — "
+ use.Input["reason"]);
return;
}
history.Add(new("assistant", reply.Content));
var result = RunTool(use);
Console.WriteLine(" observation: "
+ result.Content);
history.Add(new("user", [result]));
}
Console.WriteLine($" stop: {MaxSteps} "
+ "steps, no answer → to a colleague");
}
Step by step, in words
  1. The task is the first message. The agent has 5 steps (MAX_STEPS) and four tools.
  2. Step 1. The model says a plan and asks for one action: get_order_status(1187).
  3. It is not hand_to_human, so the program runs it. The observation goes into the history.
  4. Step 2. The model sees “delivered”, so the plan changes: open_return_request(1187).
  5. The tool only opens request R-1. It waits for a colleague: the agent never refunds money.
  6. Step 3. The model answers with end_turn: the loop stops on an answer, after 3 of 5 steps.
  7. 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):

static string TextOf(Reply reply) =>
reply.Content.OfType<TextBlock>().FirstOrDefault()?.Text ?? "";
static ToolUse ToolUseOf(Reply reply) =>
reply.Content.OfType<ToolUse>().First();
static string Describe(ToolUse use) =>
$"{use.Name}({string.Join(", ", use.Input.Values)})";
static void RunAgent(FakeModel model, string task)
{
Console.WriteLine($"task: {task}");
List<Message> history =
[new("user", [new TextBlock(task)])];
for (var step = 1; step <= MaxSteps; step++)
{
var reply =
model.Create(SystemPrompt, history, Tools);
var head = $" step {step}. ";
var text = TextOf(reply);
if (reply.StopReason != "tool_use")
{
Console.WriteLine($"{head}done: {text}");
return;
}
var use = ToolUseOf(reply);
Console.WriteLine($"{head}plan: {text}");
var action = Describe(use);
Console.WriteLine($" action: {action}");
if (use.Name == "hand_to_human")
{
Console.WriteLine(" stop: needs a person — "
+ use.Input["reason"]);
return;
}
history.Add(new("assistant", reply.Content));
var result = RunTool(use);
Console.WriteLine(" observation: "
+ result.Content);
history.Add(new("user", [result]));
}
Console.WriteLine($" stop: {MaxSteps} "
+ "steps, no answer → to a colleague");
}
var model = new FakeModel();
RunAgent(model, "The headphones from order 1187 are broken. I want to return them.");
Console.WriteLine($"waiting for approval: {string.Join(", ", Requests)}");
Output
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 1187

Nowhere 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 == "")

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:

var model = new FakeModel();
RunAgent(model, "When does 1042 arrive? The courier said today.");
Output
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 colleague

Every 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?

var model = new FakeModel();
RunAgent(model, "I want 20 € compensation because 1042 is late.");
Answer

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.

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.
using var http = new HttpClient();
var key = Environment.GetEnvironmentVariable("LLM_API_KEY");
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + key);
var content = new StringContent(requestJson, Encoding.UTF8, "application/json");
var response = await http.PostAsync("https://<provider>/messages", content);
Console.WriteLine(await response.Content.ReadAsStringAsync());

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 runAgent is 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
{
const string NotDelivered = "The order is not delivered yet. You can return it after delivery.";
const string Thanks = "You are welcome! Write if you have a question.";
int lastId = 0;
public Reply Create(string system, List<Message> messages, Tool[] tools)
{
var task = ((TextBlock)messages[0].Content[0]).Text;
var seen = messages.SelectMany(m => m.Content)
.OfType<ToolResult>().Select(r => r.Content).ToList();
var n = seen.Count;
var id = task.Contains("1187") ? "1187" : "1042";
if (task.Contains("return"))
{
if (n == 0)
{
return Act("First I check whether the order was delivered.", "get_order_status", "order_id", id);
}
if (n == 1 && seen[0].Contains("delivered"))
{
return Act("It was delivered. I open a return request.", "open_return_request", "order_id", id);
}
if (n == 1)
{
return Say(NotDelivered);
}
var request = seen[^1].Split(' ')[0];
return Say($"I opened request {request} to return {id}. A colleague will approve it and write to you.");
}
if (task.Contains("compensation"))
{
if (n == 0)
{
return Act("I check the order.", "get_order_status", "order_id", id);
}
return Act("A person decides about compensation, not me.", "hand_to_human", "reason", $"compensation for {id}");
}
if (task.Contains("courier"))
{
var plan = n == 0 ? "I ask the courier for the date." : "No change. I ask again.";
return Act(plan, "estimate_delivery", "order_id", id);
}
return Say(Thanks);
}
// A plan (text) and one action (tool_use) in one answer.
Reply Act(string plan, string tool, string key, string value)
{
lastId++;
var use = new ToolUse($"call_{lastId}", tool, new() { [key] = value });
return new Reply([new TextBlock(plan), use], "tool_use");
}
static Reply Say(string text) => new([new TextBlock(text)], "end_turn");
}
The types. The same records as in the tool use lesson: the abstract Block and three subtypes. ToolUseOf is OfType<ToolUse>().First(): an answer with stop_reason tool_use always has such a block.

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.

Output
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 colleague

More 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

1. What makes the agent loop different from one request to the model?
2. What are the three exits from runAgent?
3. Why does open_return_request only open a request instead of refunding the money?
4. The model asks for estimate_delivery(1042) for the fifth time and the observation does not change. What should the program do?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·