The AI agent loop: letting a café chatbot do several steps
The AI agent loop in TypeScript: call the model, run the tools it asks for, send results back, repeat until done, with a limit so it never runs forever.
What is the AI agent loop? It is the code that calls the model, runs the tool the model asks for, sends the result back and calls again, until the model answers in text, with a limit so the agent never runs forever. Want to see the Mộc chatbot handle a four-step request on its own? Let's write it.
Which question is the chatbot stuck on?
In the previous post, the chatbot for Mộc (a small Vietnamese coffee shop that sells online; the shop and its data in this series are made up) learned to look up orders with tool calling. When a customer asks "Has my order 1041 been delivered yet?", the chatbot calls the get_order tool, gets the real status back, and answers correctly.
One question still stops halfway, though. A customer writes "I ordered this morning and nobody has called me, my number is 0901000001". The model calls find_order_by_phone, gets back order 1041, then asks to call get_order to find the status. The code from the last post was only built for one round of tool calls, so all it could print was "the model still wants to call get_order, but this code only runs one round, so it stops here".
This post fixes that. By the end, the chatbot handles a request that needs four model calls on its own: find the order by phone number, check its status, add an item if the order hasn't left, and report back to the customer.
Can't I just add another if for the second round?
My first idea was to copy one more block. After the second call, if the model still wants a tool, run the tool, send the result and call a third time. The phone number question would work right away.
The problem is that Mộc has more than two-step questions. The owner wants the chatbot to handle requests like "I ordered this morning, my number is 0901000001. If it hasn't gone out yet, please add a bac xiu" (bac xiu is a Vietnamese iced coffee with lots of milk). That request chains three tools: find the order number, check whether the order is still being made, and only then add the item.
So that's another if block. Then a five-step question shows up, and the code becomes a staircase of nested ifs where every step looks almost exactly like the one above it.
The truth is that my code doesn't decide the number of steps. The model reads the question, reads the tool results, and picks the next step itself. The code only has to do one thing over and over, so it should be a loop.
A while (true) loop has a cost too. If the model keeps asking for tools one day, say because a tool keeps failing and the model keeps retrying, the program never stops, and every round is a billed API call. That's why an agent loop always comes with a limit.
What steps make up an agent loop?
According to Anthropic's tool use docs, every time the model wants to use a tool, it returns a stop_reason of "tool_use". When it has enough to answer, the stop_reason is "end_turn". The agent loop is built on exactly that signal:
- Send the current
messagesarray to the model. - Append the model's reply, unchanged, to
messages. - If
stop_reasonis not"tool_use", this is the final answer, so return it. - If it is
"tool_use", run everytool_useblock in the reply, collect thetool_resultblocks into one message with theuserrole, and append it tomessages. - Go back to step 1, unless you've hit the limit on model calls.
Notice that step 3 says "is not tool_use" rather than "is end_turn". The model can stop for other reasons, for example "max_tokens" when the answer runs past the token limit. Writing it this way means the loop never carries on when there is no tool to run.
Which files does the project have?
This post continues the chatbot from the previous one. I add a new tool, add_item, an agent.ts file that holds the loop, and two small scripts to test the limit and to test two messages in a row. Every file is in this post so you can type it in yourself:
04-agent-loop/
package.json
src/
shop-info.ts opening hours, delivery fee, price list (unchanged)
orders.ts order data, two lookup functions and an add-item function (new)
tools.ts descriptions of 3 tools for the model + the code that runs them
prompts.ts system prompt
offline.ts scripted model, so it runs without an API key
call-llm.ts calls the real Claude (or the scripted model)
agent.ts the agent loop (new)
ask-moc.ts asks 6 sample questions
limit.ts tries hitting the call limit
two-messages.ts tries two messages in a rowThe project only needs the official SDK and runs .ts files directly with Node.js 22.18 or later:
{
"name": "moc-ai-agent-04",
"private": true,
"type": "module",
"dependencies": {
"@anthropic-ai/sdk": "1.5.0"
}
}The shop's fixed information is the same as in the previous two posts. Prices are in Vietnamese đồng; 25,000 đồng is roughly one US dollar.
export const MENU: Record<string, number> = {
"black coffee": 25000,
"milk coffee": 29000,
"bac xiu": 32000,
"salt coffee": 35000,
"egg banh mi": 25000,
};
export const OPENING_HOURS = "Mộc is open from 6:30 a.m. to 10 p.m. every day.";
export const SHIPPING = "Mộc delivers within 5 km for 15,000 đồng; orders of 150,000 đồng or more ship free.";
// Turns the menu into lines like "- salt coffee: 35,000 đồng" for the prompt.
export function priceList(): string {
return Object.entries(MENU)
.map(([item, price]) => `- ${item}: ${price.toLocaleString("en-US")} đồng`)
.join("\n");
}How is the add-item tool different from the two lookup tools?
The two old tools only read data. add_item changes a real order, so I put the rules right inside the addItem function: you can only add something that's on the price list, and only while the order is still being made. If an item gets added after the order is on the delivery bike, the driver has no way to pick it up.
import { MENU } from "./shop-info.ts";
export type Order = {
orderId: string;
phone: string;
items: string[];
status: "being made" | "out for delivery" | "delivered";
placedAt: string;
};
const ORDERS: Order[] = [
{ orderId: "1041", phone: "0901000001", items: ["milk coffee", "egg banh mi"], status: "being made", placedAt: "today 8:12" },
{ orderId: "1042", phone: "0901000002", items: ["bac xiu"], status: "out for delivery", placedAt: "today 9:40" },
];
export function findOrderById(orderId: string): Order | undefined {
return ORDERS.find((order) => order.orderId === orderId);
}
export function findOrderByPhone(phone: string): Order | undefined {
return ORDERS.find((order) => order.phone === phone);
}
// Add items to an order. Only allowed while the order is still being made: once it is on the bike, it can't change.
export function addItem(orderId: string, item: string, quantity: number): { ok: true; order: Order } | { ok: false; reason: string } {
const order = findOrderById(orderId);
if (!order) return { ok: false, reason: `There is no order ${orderId}.` };
if (!(item in MENU)) return { ok: false, reason: `Mộc doesn't sell "${item}".` };
if (order.status !== "being made") return { ok: false, reason: `Order ${orderId} is ${order.status}, so nothing can be added to it.` };
for (let i = 0; i < quantity; i++) order.items.push(item);
return { ok: true, order };
}Why check the status here instead of just telling the model in the prompt? Because a prompt is a request, and code is a rule. The model might misread the question or skip the get_order step, but addItem will never add an item to an order that's out for delivery, no matter who calls it.
tools.ts gets a description for add_item and a new branch in runTool. The description says clearly when to call it ("only call this when the customer clearly says which item and how many"), because the model picks tools mostly based on that text:
import type Anthropic from "@anthropic-ai/sdk";
import { addItem, findOrderById, findOrderByPhone } from "./orders.ts";
export const TOOLS: Anthropic.Tool[] = [
{
name: "get_order",
description:
"Get the items and current status of an order by its order number (4 digits, e.g. 1041). " +
"Call this whenever a customer asks whether an order has been delivered, where it is, or when it will arrive.",
strict: true,
input_schema: {
type: "object",
properties: { order_id: { type: "string", description: "The 4-digit order number, without a # sign" } },
required: ["order_id"],
additionalProperties: false,
},
},
{
name: "find_order_by_phone",
description:
"Find a customer's most recent order number by phone number (10 digits, starting with 0). " +
"Returns only the order number; to get the status, call get_order next.",
strict: true,
input_schema: {
type: "object",
properties: { phone: { type: "string", description: "10-digit phone number" } },
required: ["phone"],
additionalProperties: false,
},
},
{
name: "add_item",
description:
"Add items to an order that is still being made. Only call this when the customer clearly says which item and how many. " +
"If the order is out for delivery or delivered, the tool returns an error.",
strict: true,
input_schema: {
type: "object",
properties: {
order_id: { type: "string", description: "The 4-digit order number" },
item: { type: "string", description: "The item name exactly as in the price list, e.g. bac xiu" },
quantity: { type: "integer", description: "How many, from 1 to 5" },
},
required: ["order_id", "item", "quantity"],
additionalProperties: false,
},
},
];
export type ToolResult = { content: string; isError: boolean };
// The model wrote this input: always check it before using it, like data a user submits.
export function runTool(name: string, rawInput: unknown): ToolResult {
const input = (rawInput ?? {}) as Record<string, unknown>;
if (name === "get_order") {
const id = String(input.order_id ?? "").trim();
if (!/^\d{4}$/.test(id)) return { content: `Order number "${id}" is invalid, it must be exactly 4 digits.`, isError: true };
const order = findOrderById(id);
if (!order) return { content: `There is no order ${id}.`, isError: true };
return { content: JSON.stringify({ order_id: order.orderId, items: order.items, status: order.status, placed_at: order.placedAt }), isError: false };
}
if (name === "find_order_by_phone") {
const phone = String(input.phone ?? "").replace(/\s/g, "");
if (!/^0\d{9}$/.test(phone)) return { content: `Phone number "${phone}" is invalid.`, isError: true };
const order = findOrderByPhone(phone);
if (!order) return { content: "No order found for this number.", isError: true };
return { content: JSON.stringify({ order_id: order.orderId }), isError: false };
}
if (name === "add_item") {
const id = String(input.order_id ?? "").trim();
const item = String(input.item ?? "").trim().toLowerCase();
const quantity = Number(input.quantity);
if (!/^\d{4}$/.test(id)) return { content: `Order number "${id}" is invalid, it must be exactly 4 digits.`, isError: true };
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 5) return { content: `Quantity ${input.quantity} is invalid (1 to 5).`, isError: true };
const result = addItem(id, item, quantity);
if (!result.ok) return { content: result.reason, isError: true };
return { content: JSON.stringify({ order_id: result.order.orderId, items: result.order.items, status: result.order.status }), isError: false };
}
return { content: `There is no tool called "${name}".`, isError: true };
}quantity has the type integer in the schema, so with strict: true the model always sends a whole number. But the schema doesn't know the shop only takes up to 5 of an item at a time, so the code still checks the 1 to 5 range itself, the same way the previous post checked order numbers.
The system prompt gets one more line about adding items. The rest stays the same:
import { OPENING_HOURS, SHIPPING, priceList } from "./shop-info.ts";
export const SYSTEM_V4 = `You are a customer service assistant for Mộc, a coffee shop. Keep answers short and friendly, in English.
Only use the information in the <shop_info> tags to answer general questions.
<shop_info>
${OPENING_HOURS}
${SHIPPING}
Prices:
${priceList()}
</shop_info>
When a customer asks about an order, use the tools to look it up, and only state the status exactly as the tool returns it.
Only add items when the customer clearly says which item and how many; after adding, read the order's items back to the customer.
If a tool reports an error or finds no order, say so honestly and ask the customer to check the order number or phone number.`;How do you write the agent loop in TypeScript?
The API call function is the same as in the previous post. It takes the system prompt and the messages array, sends the list of tools along, and uses the scripted model when there's no API key:
import Anthropic from "@anthropic-ai/sdk";
import { offlineModel, type Reply } from "./offline.ts";
import { TOOLS } from "./tools.ts";
export const MODEL = "claude-opus-5-5";
export function hasKey(): boolean {
return Boolean(process.env.ANTHROPIC_API_KEY) && process.env.MOC_OFFLINE !== "1";
}
export async function callModel(system: string, messages: Anthropic.MessageParam[]): Promise<Reply> {
if (!hasKey()) return offlineModel(system, messages);
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from the environment
const response = await client.messages.create({
model: MODEL,
max_tokens: 16000,
output_config: { effort: "low" },
system,
tools: TOOLS, // now three tools, including add_item
messages,
});
return {
content: response.content,
stop_reason: response.stop_reason,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
};
}
export function textOf(content: Anthropic.ContentBlock[]): string {
return content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("");
}Here is the heart of the post, the runAgent function. It takes the customer's question and the maximum number of model calls, and returns the answer, the number of calls it made and a log of the tools it ran:
import type Anthropic from "@anthropic-ai/sdk";
import { callModel, textOf } from "./call-llm.ts";
import { SYSTEM_V4 } from "./prompts.ts";
import { runTool } from "./tools.ts";
export type AgentResult = { answer: string; calls: number; log: string[] };
// The agent loop: call the model, run the tools it asks for, send the results back, repeat until it answers in text.
export async function runAgent(question: string, maxCalls = 6): Promise<AgentResult> {
const messages: Anthropic.MessageParam[] = [{ role: "user", content: question }];
const log: string[] = [];
for (let call = 1; call <= maxCalls; call++) {
const reply = await callModel(SYSTEM_V4, messages);
messages.push({ role: "assistant", content: reply.content }); // always send it back unchanged
// No more tools requested: this is the final answer.
if (reply.stop_reason !== "tool_use") return { answer: textOf(reply.content), calls: call, log };
// Run EVERY tool_use block of this turn, collect the results in ONE message.
const results: Anthropic.ToolResultBlockParam[] = [];
for (const block of reply.content) {
if (block.type !== "tool_use") continue;
const result = runTool(block.name, block.input);
log.push(`call ${call}: ${block.name}(${JSON.stringify(block.input)}) → ${result.content}${result.isError ? " (is_error)" : ""}`);
results.push({ type: "tool_result", tool_use_id: block.id, content: result.content, is_error: result.isError });
}
messages.push({ role: "user", content: results });
}
// Hit the limit: stop and say so honestly, instead of looping forever.
return {
answer: "Sorry, Mộc couldn't finish this request. A staff member will check it and message you shortly.",
calls: maxCalls,
log: [...log, `stopped: called the model ${maxCalls} times without a final answer`],
};
}The loop is shorter than you might expect, but a few parts are written on purpose.
I count model calls, not tool runs. One call can return several tool_use blocks at once, for example when a customer asks about two orders in one message, and the inner for loop runs all of them. What costs money and time is each API call, so that's where the limit goes.
The model's reply goes into messages as soon as it arrives, even when it is the final answer. This post doesn't need that yet, but it keeps messages a complete history of the exchange, and you'll see at the end of the post why that history matters.
When the limit is hit, the function doesn't throw. It returns an apology and promises that a staff member will check. For the customer, a polite answer beats an error screen. For the shop, the "stopped" line in the log is a sign that the question needs a look.
I picked the number 6 myself. The hardest question in this post needs 4 calls, so 6 leaves a little room. Choose yours based on the hardest task your agent has to do, then watch the log for questions that hit the limit.
How does the scripted model answer in several steps?
To run without an API key, offline.ts is a hand-written script that returns replies shaped exactly like the API's for the six sample questions. It's more complicated than last time, because it has to know which tool just ran in order to choose the next step:
// Scripted model: a hand-written script that returns replies shaped exactly like the API's, so this runs without a key.
// It decides with regular expressions; it does not understand sentences like a real model.
import type Anthropic from "@anthropic-ai/sdk";
import { MENU } from "./shop-info.ts";
export type Reply = Pick<Anthropic.Message, "content" | "stop_reason"> & { inputTokens: number; outputTokens: number };
let count = 0;
function callTool(name: string, input: Record<string, string | number>): Reply {
count += 1;
const block = { type: "tool_use", id: `toolu_offline_${count}`, name, input } as Anthropic.ToolUseBlock;
return { content: [block], stop_reason: "tool_use", inputTokens: 0, outputTokens: 0 };
}
function answer(text: string): Reply {
return { content: [{ type: "text", text } as Anthropic.TextBlock], stop_reason: "end_turn", inputTokens: 0, outputTokens: 0 };
}
const itemIn = (sentence: string) => Object.keys(MENU).find((item) => sentence.includes(item));
const wantsToAdd = (sentence: string) => /\badd\b/.test(sentence) && itemIn(sentence) !== undefined;
// Name of the tool from the latest call: find the tool_use block whose id matches the result's tool_use_id.
function toolName(messages: Anthropic.MessageParam[], toolUseId: string): string {
const previous = messages.at(-2)!.content as Anthropic.ContentBlock[];
return (previous.find((b) => b.type === "tool_use" && b.id === toolUseId) as Anthropic.ToolUseBlock).name;
}
export function offlineModel(system: string, messages: Anthropic.MessageParam[]): Reply {
const question = String(messages[0].content).toLowerCase();
const last = messages.at(-1)!;
// First turn: only the customer's question.
if (typeof last.content === "string") {
const id = question.match(/\b\d{4}\b/)?.[0];
if (id && wantsToAdd(question)) return callTool("add_item", { order_id: id, item: itemIn(question)!, quantity: 1 });
if (id) return callTool("get_order", { order_id: id });
const phone = question.match(/\b0\d{9}\b/)?.[0];
if (phone) return callTool("find_order_by_phone", { phone });
if (wantsToAdd(question)) return answer("Could you send Mộc your order number or the phone number you ordered with, so we can add it?");
if (/what time|open/.test(question)) return answer(`Hi! ${system.split("\n").find((line) => /is open from/.test(line)) ?? ""}`);
return answer("Sorry, Mộc doesn't have that information.");
}
// Later turns: our code sent back a tool result (in a message with the "user" role).
const result = (last.content as Anthropic.ToolResultBlockParam[]).find((b) => b.type === "tool_result")!;
const content = String(result.content);
if (result.is_error) return answer(`Sorry, ${content.charAt(0).toLowerCase()}${content.slice(1)} Is there anything else Mộc can help with?`);
const order = JSON.parse(content) as { order_id: string; items?: string[]; status?: string; placed_at?: string };
const tool = toolName(messages, result.tool_use_id);
if (tool === "find_order_by_phone") return callTool("get_order", { order_id: order.order_id });
if (tool === "get_order" && wantsToAdd(question) && order.status === "being made") {
return callTool("add_item", { order_id: order.order_id, item: itemIn(question)!, quantity: 1 });
}
if (tool === "add_item") return answer(`Done! Order ${order.order_id} now has: ${order.items!.join(", ")}, and it is still being made.`);
return answer(`Your order ${order.order_id} (${order.items!.join(", ")}), placed ${order.placed_at}, is ${order.status}.`);
}To be clear about its limits: this script chooses the next step with regular expressions and a few if statements, while a real model chooses based on what the question means and on each tool's description. A real model might also ask the customer a question before calling any tool, or call get_order twice to be sure. So the output below shows you exactly how data flows through the loop, but not Claude's wording or Claude's choices.
The nice part is that agent.ts has no idea whether it is talking to the script or to a real model. The loop only looks at stop_reason and the tool_use blocks, so it runs the same way in both cases.
Running six questions
ask-moc.ts sends six questions one after another, one runAgent call per question, then prints the tool log and the number of model calls. The first four are the same as last time; the last two use the new tool:
import { runAgent } from "./agent.ts";
import { hasKey, MODEL } from "./call-llm.ts";
const QUESTIONS = [
"What time do you open?",
"Has my order 1041 been delivered yet?",
"Order #1099 still hasn't arrived",
"I ordered this morning and nobody has called me, my number is 0901000001",
"I ordered this morning, my number is 0901000001. If it hasn't gone out yet, please add a bac xiu",
"Please add an egg banh mi to order 1042",
];
console.log(hasKey() ? `Calling the real Claude (${MODEL})\n` : "OFFLINE mode (scripted, not a real model)\n");
for (const question of QUESTIONS) {
const result = await runAgent(question);
console.log(`Customer: ${question}`);
for (const line of result.log) console.log(` ${line}`);
console.log(`Mộc: ${result.answer}`);
console.log(` (${result.calls} model call${result.calls === 1 ? "" : "s"})\n`);
}I ran it without an API key, so it uses the scripted model:
OFFLINE mode (scripted, not a real model)
Customer: What time do you open?
Mộc: Hi! Mộc is open from 6:30 a.m. to 10 p.m. every day.
(1 model call)
Customer: Has my order 1041 been delivered yet?
call 1: get_order({"order_id":"1041"}) → {"order_id":"1041","items":["milk coffee","egg banh mi"],"status":"being made","placed_at":"today 8:12"}
Mộc: Your order 1041 (milk coffee, egg banh mi), placed today 8:12, is being made.
(2 model calls)
Customer: Order #1099 still hasn't arrived
call 1: get_order({"order_id":"1099"}) → There is no order 1099. (is_error)
Mộc: Sorry, there is no order 1099. Is there anything else Mộc can help with?
(2 model calls)
Customer: I ordered this morning and nobody has called me, my number is 0901000001
call 1: find_order_by_phone({"phone":"0901000001"}) → {"order_id":"1041"}
call 2: get_order({"order_id":"1041"}) → {"order_id":"1041","items":["milk coffee","egg banh mi"],"status":"being made","placed_at":"today 8:12"}
Mộc: Your order 1041 (milk coffee, egg banh mi), placed today 8:12, is being made.
(3 model calls)
Customer: I ordered this morning, my number is 0901000001. If it hasn't gone out yet, please add a bac xiu
call 1: find_order_by_phone({"phone":"0901000001"}) → {"order_id":"1041"}
call 2: get_order({"order_id":"1041"}) → {"order_id":"1041","items":["milk coffee","egg banh mi"],"status":"being made","placed_at":"today 8:12"}
call 3: add_item({"order_id":"1041","item":"bac xiu","quantity":1}) → {"order_id":"1041","items":["milk coffee","egg banh mi","bac xiu"],"status":"being made"}
Mộc: Done! Order 1041 now has: milk coffee, egg banh mi, bac xiu, and it is still being made.
(4 model calls)
Customer: Please add an egg banh mi to order 1042
call 1: add_item({"order_id":"1042","item":"egg banh mi","quantity":1}) → Order 1042 is out for delivery, so nothing can be added to it. (is_error)
Mộc: Sorry, order 1042 is out for delivery, so nothing can be added to it. Is there anything else Mộc can help with?
(2 model calls)Look at the fourth question first. This is the one that stopped halfway in the last post. Now it goes through both tools and answers with the status. Not a single line of code was written for this question in particular; the loop simply ran one more round.
The fifth question is the hardest, with three tools and four model calls. The order "find the order, check its status, then add the item" isn't in my code. It comes from the tool descriptions ("returns only the order number; to get the status, call get_order next") and from the condition the customer gave ("if it hasn't gone out yet").
The last question shows the rule in addItem at work. The model calls add_item for order 1042, the function refuses because the order is out for delivery, and that error travels through is_error so the model can apologize to the customer. The loop doesn't need to know anything about it.
One small detail: every order question now costs 2 to 4 API calls, and each later call sends the whole history of the earlier ones again. With the real Claude, the fifth question uses several times more input tokens than the first. Later posts in this series measure that cost with real numbers.
What happens when the limit is hit?
To see how the stopping branch works, limit.ts runs the fifth question again but allows only 2 model calls, while the question needs 4:
// Run the question that needs 4 model calls, but allow only 2, to see how the agent stops.
import { runAgent } from "./agent.ts";
const question = "I ordered this morning, my number is 0901000001. If it hasn't gone out yet, please add a bac xiu";
const result = await runAgent(question, 2);
console.log(`Customer: ${question}`);
for (const line of result.log) console.log(` ${line}`);
console.log(`Mộc: ${result.answer}`);Customer: I ordered this morning, my number is 0901000001. If it hasn't gone out yet, please add a bac xiu
call 1: find_order_by_phone({"phone":"0901000001"}) → {"order_id":"1041"}
call 2: get_order({"order_id":"1041"}) → {"order_id":"1041","items":["milk coffee","egg banh mi"],"status":"being made","placed_at":"today 8:12"}
stopped: called the model 2 times without a final answer
Mộc: Sorry, Mộc couldn't finish this request. A staff member will check it and message you shortly.The agent stops after two calls, before it gets to add the item. The customer gets an honest answer instead of silence, and the "stopped" line in the log tells the shop that order 1041 needs someone to check it.
Notice that the item was not added. That's why I prefer setting the limit too early rather than too late. An agent that stops halfway just needs a person to finish the job, while an agent that runs forever can cost money and do things nobody wanted.
The agent forgets what the customer just said
The loop works well for each message on its own. But real customers don't send one message and leave. They ask, read the answer, and write again. two-messages.ts tries exactly that, with one runAgent call per message:
// The customer sends two messages in a row. Each one is a new runAgent call, with a new messages array.
import { runAgent } from "./agent.ts";
for (const message of ["Has my order 1041 been delivered yet?", "Then please add a bac xiu as well"]) {
const result = await runAgent(message);
console.log(`Customer: ${message}`);
for (const line of result.log) console.log(` ${line}`);
console.log(`Mộc: ${result.answer}\n`);
}Customer: Has my order 1041 been delivered yet?
call 1: get_order({"order_id":"1041"}) → {"order_id":"1041","items":["milk coffee","egg banh mi"],"status":"being made","placed_at":"today 8:12"}
Mộc: Your order 1041 (milk coffee, egg banh mi), placed today 8:12, is being made.
Customer: Then please add a bac xiu as well
Mộc: Could you send Mộc your order number or the phone number you ordered with, so we can add it?The customer gave the order number one message earlier, and yet the agent asks for it again. The reason is the first line of runAgent, where every call creates a new messages array that holds only the current message. The API remembers nothing between two calls, so the second message reaches the model as if it were the first line from a stranger. A real model would ask again too, because it has no way to know which order "it" means.
The fix sounds simple: keep the messages array between messages. But a customer who chats all morning makes that array grow and grow, every call sends all of it again, and the model has a limit on how much it can read at once. How much to keep and what to drop is the subject of the next posts. You can follow the whole series at Build your first AI agent, and if you're preparing for interviews, the AI agent questions are in the interview question bank.