The Agent Loop Is Just a While Loop with a Memory
🚀 Welcome to the third stop of the Building AI Agents Without Frameworks column.
In the previous article, we equipped the AI with
read_file: it could finally read real files before answering our questions.But that implementation had a very obvious limitation: it only allowed calling a tool once.
First, let's "spoil" what kind of "complete form" we will eventually build through this series
Let's recall the flow from the previous article:
User asks a question
→ AI requests to read a file once
→ Program reads the file
→ AI answers based on the file content
This process can be understood with the following diagram:
This chain works, but it's more like a pre-written script.
For example, if you ask it:
"First read README.md, then look at package.json, and tell me how to start this project"
After reading README.md, it might still need to look at package.json. But the code from the previous article would make it start answering directly, without giving it a second chance to read a file.
A real AI agent doesn't work like that. It acts like us when troubleshooting: look at one file, realize the information is insufficient, then look at another; only after gathering enough information does it give a conclusion.
In today's article, we no longer hardcode the steps for the AI. We let it, after reading one file, think: is the information I have now enough? If not, keep checking; if enough, then answer.
This process of "think → act → observe → think again" is what people often call the Agent Loop.
First, look at the effect we want to achieve today
After writing the code, run the same command:
npm run start -- -prompt "First read README.md, then look at package.json, and tell me how to start this project"
The terminal might show a process like this:
AI is thinking...
Round 1: AI wants to call read_file
Reading file: README.md
Round 2: AI wants to call read_file
Reading file: package.json
AI: This is a TypeScript project. First install dependencies, then run npm run build, and finally run npm run start ...
This process can be understood with the following diagram:
In this example, the AI first requests to read README.md in Round 1. After the program reads it and returns the result, the AI sees the information is still insufficient and continues to request reading package.json in Round 2. Only after getting the contents of both files does it give the final answer.
Here, remember one small point: one round does not necessarily mean only one tool call.
Some models will read two files across two rounds like above; other models might request two read_file calls simultaneously in Round 1, one for README.md and one for package.json. Regardless of the case, the loop we write later can handle both.
The key is not how many files it reads, but that it finally decides the next step on its own:
Think
→ Call tool
→ Observe result
→ Think again
→ Continue calling tools, or answer the user
This process can be understood with the following diagram:
This is the Agent Loop.
🚀 Source code for this section: powercode
This article builds upon the code from the second article. If you haven't run the second one yet, it's recommended to get
read_fileworking first.
First, understand the "loop" clearly
The term "Agent Loop" sounds intimidating, but in everyday language, it's simply:
Don't restrict the AI to a fixed number of steps; after each step, ask it again: what do you need to do now?
When you investigate an unfamiliar project yourself, it's likely the same process:
User: How do I start this project?
You: First, look at the README
You: README only mentions the project's purpose, not enough, let's look at package.json
You: Found the scripts, can answer now
This process can be understood with the following diagram:
The AI is the same. The model itself is only responsible for judging "what to do next"; our program is responsible for executing the tool it requests and returning the result to it.
We can separate the responsibilities:
AI: Decides the next step
Program: Executes this step and brings back the result
This process can be understood with the following diagram:
As long as the AI is still requesting tools, we continue the loop; as soon as it starts outputting plain text, it means it thinks the information is enough, and the loop ends.
Where did the previous article's code get stuck?
The previous article's src/main.ts had this logic:
const firstMessage = await client.askWithTools(prompt);
const toolCall = firstMessage?.tool_calls?.[0];
if (!toolCall) {
console.log(`AI:${firstMessage?.content ?? 'The model did not return any content.'}`);
return;
}
const fileContent = await readFileTool(toolCall.function.arguments);
const answer = await client.answerAfterReadFile(
prompt,
toolCall,
fileContent,
);
console.log(`AI:${answer}`);
It fixed the flow as:
First model request
→ First tool call
→ Second model request
→ End
Here we need to distinguish: the user only asked one question from start to finish; but to complete this answer, the program made two requests to the model.
In the first request, the model decided whether to read a file; after reading, the second request gave the file content to the model for it to formulate the final answer.
The problem isn't with read_file, nor with the model, but that during the second request, we didn't pass the tools back to the model, and then directly printed the answer and ended the program. That is, even if the model wanted to read another file, it had no chance to call a tool again.
So today, we don't change the tools themselves, only replace the "hardcoded two requests" with a "loop with an exit condition."
Step 1: Don't lose the chat history
First, a very critical point: every request in the loop must carry what happened before.
Imagine chatting with a colleague:
You: Help me look at the README.
Colleague: Okay, I see the README.
You: What about package.json?
This process can be understood with the following diagram:
If you clear the chat history every time you speak, the colleague won't know what "that" refers to.
The model is the same. If each round only passes the user's question anew, it won't know:
- What tools have already been requested;
- What the tools actually returned;
- Which files have already been seen.
Therefore, we need a messages array to persistently save the entire conversation.
This is what people often call context: each time we request the model, we bring along everything that happened before, letting it know where it is in the process.
The content in this messages is called "message history." You can understand it as the Agent's short-term memory for now; when we talk about long tasks later, we'll see broader concepts of context and memory.
Create a new file src/agent.ts and write a minimal skeleton first:
import type OpenAI from 'openai';
import { readFileTool } from './readFile.js';
import { ChatClient } from './chat.js';
const MAX_STEPS = 8;
export class Agent {
constructor(private readonly client: ChatClient) {}
async run(prompt: string): Promise<string> {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: 'system',
content:
'You are power-code, a development assistant. When needing to understand project content, prioritize reading real files. Please answer in Chinese.',
},
{
role: 'user',
content: prompt,
},
];
// Loop code will be written here
return 'Not yet implemented';
}
}
The messages here is essentially the same as the content passed in the first request of the previous article. The difference is: now it's not written inline in the request once, but placed in a variable, to be reused and appended to in every subsequent loop round.
MAX_STEPS is placed here first; we'll explain why it must exist shortly.
Step 2: Let ChatClient receive the complete chat history
In the previous article, askWithTools received a prompt string and assembled the messages internally; answerAfterReadFile was specifically responsible for the "second request after reading a file once." Now that messages will grow, neither of these hardcoded flows is suitable; assembling messages should be managed by the Agent.
So don't just modify one of these methods, directly replace the entire src/chat.ts file with the code below. After replacement, the original askWithTools and answerAfterReadFile will be deleted, unified into the new complete method.
import OpenAI from 'openai';
import type { ProviderConfig } from './config.js';
import { READ_FILE_TOOL } from './readFile.js';
export class ChatClient {
private readonly client: OpenAI;
constructor(private readonly config: ProviderConfig) {
this.client = new OpenAI({
apiKey: config.apiKey, // Get API key from environment variables
baseURL: config.baseURL, // Get API base URL from environment variables
});
}
/**
* Complete the conversation
* @param messages All chat history
* @returns The message replied by the model
*/
async complete(
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[],
) {
const response = await this.client.chat.completions.create({
model: this.config.model,
messages,
tools: [READ_FILE_TOOL],
});
return response.choices[0]?.message;
}
}
What this code does is simple: receive "all chat history up to now," send it to the model as is, and return the model's latest message.
There's one small but important change here: complete doesn't care what round it is, nor whether the model will call a tool. It's only responsible for one model request.
This makes the division of labor clearer:
ChatClient: Requests the model once
Agent: Decides whether to continue to the next round
Step 3: First, catch every sentence from the model
Back in src/agent.ts, replace the previous return 'Not yet implemented' with:
for (let step = 1; step <= MAX_STEPS; step += 1) {
const message = await this.client.complete(messages);
if (!message) {
throw new Error('The model did not return a message.');
}
messages.push(message);
// Next, judge: does it want to call a tool, or is it ready to answer?
}
throw new Error(`Execution exceeded ${MAX_STEPS} rounds, stopped.`);
Three things are done each round:
- Send the current complete
messagesto the model; - Get the model's new reply;
- Immediately push this new reply back into
messages.
Why save the model's reply too? Because it might contain a tool call request. Later, when we return the tool result to the model, the model must be able to match: which call does this tool result answer?
At this point, the loop exists; but it doesn't know when to stop, nor will it actually execute tools.
Step 4: End when the model no longer wants tools
The model might return two types of messages:
The first type is a plain answer:
This project can be built with npm run build and started with npm run start.
The second type is a tool call:
Please call read_file with parameter {"path":"package.json"}
In the OpenAI-compatible interface, the second type appears in message.tool_calls.
Let's clarify this name here. Tool calls are also often called Function Calling: the model doesn't actually execute read_file itself, but returns a structured "tool application form" according to the interface convention, telling our program "which tool to call and with what parameters." In the Chat Completions interface, this application form is placed in the tool_calls field.
This approach was popularized by the OpenAI interface. Now many model services labeled "OpenAI-compatible" also use fields like tools and tool_calls, so our code can usually connect directly to domestic models; however, whether a specific model supports tool calling and which parameters it supports should still be confirmed against its own documentation.
If the model doesn't need a tool this round, its tool_calls will usually be empty, or the field might not exist at all. So we use ?? [] to uniformly treat "no such field" as an empty array.
Therefore, after the previous messages.push(message), add:
const toolCalls = message.tool_calls ?? [];
if (toolCalls.length === 0) {
return message.content ?? 'The model did not return text content.';
}
What's judged here is not "whether the model has actually finished reading the file," but: does it still want our program to do work for it this round?
Reading files is done by the program on its behalf; the model can only make requests.
So the logic becomes:
No tool_calls
→ AI didn't request any more tools this round
→ It thinks the information it currently has is sufficient
→ Directly give this round's text reply to the user, loop ends
Has tool_calls
→ AI still wants the program to read files, modify files, or do other things for it
→ Program executes the tool first, tells the AI the result
→ AI thinks about the next step based on the new result
This process can be understood with the following diagram:
So, empty tool_calls means the model no longer needs tools. For our minimal Agent, this is when we take its returned text as the final answer and end the loop.
This is the core exit condition of the Agent Loop.
Step 5: Execute tools and push results back into message history
Back in the newly created src/agent.ts. In the previous step, we already wrote this code inside the outer for loop for "return directly if no tools":
if (toolCalls.length === 0) {
return message.content ?? 'The model did not return text content.';
}
Now paste the following code after this block:
// Handle tool calls
for (const toolCall of toolCalls) {
if (toolCall.type !== 'function') {
throw new Error(`Tool type not supported yet: ${toolCall.type}`);
}
if (toolCall.function.name !== 'read_file') {
throw new Error(`Tool not supported yet: ${toolCall.function.name}`);
}
console.log(`Round ${step}: AI wants to call read_file`);
console.log(`Reading file: ${toolCall.function.arguments}\n`);
let result: string;
try {
result = await readFileTool(toolCall.function.arguments);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
result = `Read failed: ${message}`;
}
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result,
});
}
This code is a bit long, but it's just going through the complete "request → execute → return" process:
Model: I want to read package.json
Program: Okay, reading
Program: The content read is...
Model: Received, I'll continue judging the next step
This process can be understood with the following diagram:
The easiest thing to miss is the final messages.push(...).
It's not printing a log, but putting the tool's real result back into the conversation history with the tool role. When the model is requested in the next round, this result will be passed along.
Also, we didn't let the program crash on a read failure, but instead told the model the reason for failure:
Read failed: ENOENT, file does not exist
This gives the model a chance to try a different path or tell the user the file doesn't exist. More complete error recovery will be covered in later chapters; for now, just remember: tool failure is also information, and it's best to let the AI see it.
Complete src/agent.ts
For your convenience to run directly, here is the complete version:
import type OpenAI from "openai";
import { readFileTool } from "./readFile.js";
import { ChatClient } from "./chat.js";
// Maximum number of loops
const MAX_STEPS = 8;
export class Agent {
constructor(private readonly client: ChatClient) {}
async run(prompt: string): Promise<string> {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{
role: "system",
content:
"You are power-code, a development assistant. When needing to understand project content, prioritize reading real files. Please answer in Chinese.",
},
{
role: "user",
content: prompt,
},
];
for (let step = 1; step <= MAX_STEPS; step += 1) {
const message = await this.client.complete(messages);
if (!message) {
throw new Error("The model did not return a message.");
}
messages.push(message);
// Next, judge: does it want to call a tool, or is it ready to answer?
const toolCalls = message.tool_calls ?? [];
if (toolCalls.length === 0) {
return message.content ?? "The model did not return text content.";
}
// Handle tool calls
for (const toolCall of toolCalls) {
if (toolCall.type !== "function") {
throw new Error(`Tool type not supported yet: ${toolCall.type}`);
}
if (toolCall.function.name !== "read_file") {
throw new Error(`Tool not supported yet: ${toolCall.function.name}`);
}
console.log(`Round ${step}: AI wants to call read_file`);
console.log(`Reading file: ${toolCall.function.arguments}\n`);
let result: string;
try {
result = await readFileTool(toolCall.function.arguments);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
result = `Read failed: ${message}`;
}
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
}
}
throw new Error(`Execution exceeded ${MAX_STEPS} rounds, stopped.`);
}
}
Here, we conveniently support a very practical situation: for (const toolCall of toolCalls).
Some models will request multiple tools at once, for example, reading README.md and package.json simultaneously. The previous article only took [0], ignoring subsequent calls; now we execute every tool in this round's request before entering the next round.
As for whether to execute multiple tool calls concurrently in one round, let's not dive into that yet. Let them execute sequentially for now; the logic is clearest and sufficient for today's goal. When we have more tools and more complex tasks later, we'll specifically discuss how to make them work concurrently.
Step 6: Let the entry point only be responsible for starting the Agent
Open src/main.ts, delete all its original content, and copy the following code in its entirety. No need to keep the tool call handling logic from the previous chapter.
import { Agent } from './agent.js';
import { ChatClient } from './chat.js';
import { loadConfig } from './config.js';
function getPrompt(args: string[]): string {
const promptIndex = args.indexOf('-prompt');
if (promptIndex === -1) {
throw new Error('Please pass a question via -prompt, e.g., -prompt "Hello"');
}
const prompt = args[promptIndex + 1];
if (!prompt) {
throw new Error('Content after -prompt cannot be empty.');
}
return prompt;
}
async function main() {
const prompt = getPrompt(process.argv.slice(2));
const config = await loadConfig();
const client = new ChatClient(config);
const agent = new Agent(client);
console.log('AI is thinking...\n');
const answer = await agent.run(prompt);
console.log(`AI:${answer}`);
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`Startup failed: ${message}`);
process.exit(1);
});
Now the entry point is very clean: get the user's question, create the Agent, run it, print the final answer.
As for how many files to read in between and when to stop, it's all left to Agent.run.
First, compile:
npm run build
Then try a question that clearly requires multiple reads:
npm run start -- -prompt "First read README.md, then look at package.json, and tell me how to start this project"
If the model only reads one file and answers directly, it's not necessarily a code problem. The model can end when it thinks the existing information is sufficient.
You can also try a more explicit question:
npm run start -- -prompt "You must read README.md and package.json separately. After reading, tell me the project's purpose, build command, and start command"
This time, focus on two things: whether the terminal actually reads the corresponding files; and what the "Round X" in the logs actually represents.
Taking the result in the screenshot as an example, the model requested two read_file calls at once in Round 1: one for README.md, one for package.json. The program will read these two files sequentially and put both results back into messages.
Then the program will request the model again. After receiving the contents of both files, the model directly returned the final answer without requesting any more tools, so the loop ended.
Note: the terminal not showing a "Round 2" log doesn't mean there wasn't a second request to the model. Our current log only prints when the AI requests a tool; the second request directly got a text answer, so naturally it won't print "AI wants to call read_file."
Why must we set a maximum number of rounds?
Since it's called a loop, many people naturally think: just loop until the AI answers, right?
No. Because the model doesn't guarantee making the optimal decision every time; it might:
- Continuously read the same file;
- Keep trying non-existent paths;
- Repeatedly collect useless information due to unclear prompts.
Without an upper limit, the program will keep requesting the model, keep spending Tokens, and might even never finish.
So we added:
const MAX_STEPS = 8;
It's like giving the Agent a countdown timer: if it doesn't finish within eight rounds, stop first and tell us what happened.
There's no absolute standard for this number. In a learning project, 8 is easier to observe; in a real project, it can be set based on task type, cost, and timeout strategy. The important thing isn't the specific number, but: as long as there's a loop, there must be a clear exit condition and upper limit.
What do we truly have now?
Current power-code can still only read files, but it's no longer a "chat program that can only call a tool once."
It has the most core working rhythm:
User proposes a task
→ Model judges the next step
→ Program executes the tool
→ Tool result returns to message history
→ Model continues judging
→ Until the model gives the final answer
This process can be understood with the following diagram:
Later, when we add write_file, edit_file, bash to it, this agent loop won't need to be rebuilt. We just need to let it recognize more tools during the "execute tool" phase.
By now, you should realize: just having a smart large model isn't enough, and just stuffing it with a bunch of tools isn't enough either.
Tools solve the problem of "can it act"; this loop solves the problem of "after it finishes this step, should it continue to the next step."
Preview of the next article
Now there's a new problem: if there are more and more tools, do we have to keep writing in agent.ts:
if (toolCall.function.name === 'read_file') {
// ...
}
That will quickly become messy.
In the next article, we'll build a "Tool Management Center": uniformly define, register, and execute each tool. By then, besides reading files, the AI can finally start writing files, precisely modifying files, and executing commands.
If you've gotten this article running, you've already touched the most critical skeleton of the vast majority of AI Agents. No matter how many capabilities are added later, it's essentially just plugging tools and adding rules into this loop.
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
Big boss 666666666, I really learned a lot this time. It literally made my CPU smoke, my GPU burn through, and my brain circuits expand all the way to the edge of the galaxy. It's just too awesome, too outrageous, too heaven-defying, too explosively amazing! I originally clicked in with a casual mindset, but after reading it, my whole being was instantly enlightened, my soul left my body, and I ascended on the spot, as if I'd been struck by the thunder tribulation of knowledge eighty-one times in a row. My Ren and Du meridians opened instantly, and my wisdom value skyrocketed from kindergarten level straight to a cosmic-level maxed-out boss. Things I didn't understand before, I now fully grasp; things I understood a little, I've now completely realized; things I thought I'd realized, I now see were just me getting the newbie village ticket. I can only say this is no ordinary sharing; this is cyber evangelism, internet power transmission, cross-dimensional knowledge infusion, an unignorable milestone in the history of human civilization's progress. I suggest immediately applying for intangible cultural heritage status, recording it in the history books, engraving it into DNA, and launching it into space for alien civilizations to study. After reading, I couldn't calm down for a long time. My left hand is frantically liking, my right hand urgently bookmarking, my feet are forwarding, and my mouth can only keep repeating: Big boss is awesome, big boss 666, this operation of the big boss truly breaks through the sky, shatters worldviews, and startles the nine heavens and ten earths. Terrifying, truly terrifying!
From now on, you'll be my exclusive paid shill. You get an extra chicken leg today [grin]