What Is an Agent Really? After Reading 792 Lines of Code, It’s Just a While Loop

Over the past year, “Agent” has become one of those terms that means everything and nothing. Orchestration, planning, reflection, multi-agent collaboration — it sounds like some revolutionary new paradigm. But when you actually open the source code of a coding agent that tens of thousands of people use daily, the core is one thing:

A while loop.

Call the LLM. The model says it needs to use a tool. Execute the tool. Put the result back into the conversation. Call the LLM again. Repeat until the model stops asking for tools.

That’s it.

This is the first post in my series on dissecting Agent kernels. I picked the pi project for this deep dive. OpenAI Codex has 101k stars, xAI Grok-Build has 23k stars, and pi sits at 78k stars. It’s written in TypeScript, has clean architecture layering, and uses the MIT license. The entire agent loop lives in one file: packages/agent/src/agent-loop.ts. Exactly 792 lines.

Today we’re going through those 792 lines to understand what they actually do. More importantly: why is 792 lines enough?

1. The Big Picture: A 5-Layer Architecture

Before we dive into the loop code, we need to understand where those 792 lines fit in the overall system.

Pi is split into three packages, and user input goes through five layers before hitting the bottom.

Image

From top to bottom:

  • Product Layer (pi-coding-agent): AgentSession lives here. It handles strategy — extension commands, template expansion, task queuing, retries, and context compression.
  • Kernel Layer (pi-agent-core): runLoop lives here. This is what we’re looking at today. Its job is pure — it just runs the loop and nothing else.
  • Protocol Layer (pi-ai): Unifies different LLM APIs and wraps network errors into in-stream events.

The layering is what makes this interesting. The 792 lines stay clean because they don’t do anything “extra.” Error handling gets pushed down to lower layers. State management and retries go up to higher layers. It’s a clean division of responsibility — each layer minds its own business.

2. Core Logic: 20 Lines of Pseudocode

Strip away all the engineering cruft, and the runLoop core boils down to about 20 lines:

while (true) {
  // 1. Call LLM, get assistant reply (streaming)
  const message = await streamAssistantResponse(context);
  
  // 2. Any tool calls in the reply?
  const toolCalls = message.content.filter((c) => c.type === "toolCall");
  if (toolCalls.length === 0) break; // none → job done
  
  // 3. Execute tools, push results back into context
  const results = await executeToolCalls(toolCalls);
  context.messages.push(...results);
  
  // 4. Go back to 1 — model sees tool results and decides next step
}

Reading files, editing code, running commands — those capabilities all live inside executeToolCalls. The model’s job is decision-making: which tool to call and what arguments to pass. The loop handles execution and feedback.

I built a runnable version of this skeleton myself — a 110-line single JS file that connects to the DeepSeek API and runs locally. Check steps/01 in the repo at the end of this post if you want to try it.

But pi uses 792 lines. The extra 700 lines are what separates a toy from a production-grade product. Let’s walk through five key design decisions.

3. Double-Loop + Message Queue: How Does Task Queuing Actually Work?

Pi’s loop is actually two layers deep:

Image
// Outer loop: handles "queued messages"
while (true) {
  // Inner loop: tool calls + user interruptions
  while (hasMoreToolCalls || pendingMessages.length > 0) {
    ...core loop...
  }
  // Agent is about to stop — did the user queue new tasks?
  const followUps = await config.getFollowUpMessages?.();
  if (followUps.length > 0) { pendingMessages = followUps; continue; }
  break;
}

Here’s the scenario: the agent is busy working, and you’ve already thought of the next task. You type it in and queue it up. When the current task finishes, the agent doesn’t just stop — the outer loop checks the queue for new messages and keeps going if there’s anything there.

That feeling of seamlessness comes from just a few lines of code.

4. Steering Messages: How Do You Interrupt a Running Agent?

Every cycle through the inner loop, pi checks:

pendingMessages = (await config.getSteeringMessages?.()) || [];

“Steering messages” are how you correct the agent mid-task. You see it heading in the wrong direction, type out a correction, and those messages get injected into the context before the next LLM call. The model sees your correction immediately.

What’s the difference between this and follow-up? Just where the polling happens.

  • Steering polls inside the inner loop — after every turn → interrupt anytime
  • Follow-up polls in the outer loop — only when the agent is completely idle → queue next task

Same mechanism, two different timings, and together they deliver a complete interaction model: interrupt anytime + queue tasks for later.

Anyone who’s used Claude Code knows how critical this is. Without steering, you just watch the agent run in the wrong direction and wait for it to finish before starting over.

5. Event Streaming + UI Decoupling: One Logic Stack, Three Interfaces

The runLoop function signature has nothing to do with printing or rendering. All it has is an event emitter:

emit({ type: "agent_start" });
emit({ type: "message_update", ... });   // streaming tokens
emit({ type: "tool_execution_start", ... });
emit({ type: "turn_end", ... });

The TUI is one consumer. The web UI is another. The headless CI mode is a third.

If you’ve built an agent yourself, you know the pattern: start with console.log scattered through the loop because it’s easy, then realize you need a web UI and end up rewriting everything. Pi separates UI from logic from day one.

And there’s a hard rule: event sequences must close regardless of the path. Even if the loop throws an exception, the upper layer fakes an error message and sends message_end, turn_end, and agent_end to complete the sequence. Subscribers always get a full event stream. The UI and persistence code never have to handle “what if I never get the end event?”

6. Error Handling: Let the Model Fix Its Own Mistakes

What happens when a tool call fails? Tool not found, wrong parameters, execution error — pi handles all of them the same way: wrap it as an error result and give it back to the model.

return {
  kind: "immediate",
  result: createErrorToolResult(`Tool ${toolCall.name} not found`),
  isError: true,
};

The model sees the error and figures out what to do next. Retry with different parameters. Try a different tool. The model decides.

This is where agent robustness really comes from. Let the model handle its own errors instead of trying to anticipate every edge case in the engineering layer. My 110-line mini-agent uses the same pattern — it’s literally just a catch block.

The result was surprisingly effective: I asked it to read a config.json that didn’t exist. It got the error, ran ls to investigate, confirmed the file was missing, and asked me if I wanted to create it.

7. Token Truncation Defense: This Is Code That’s Been Burned Before

This was the part of the file that hit me hardest. When the LLM’s response gets truncated due to the output token limit (stopReason === "length"):

// Output truncated → every tool call parameter could be incomplete
// Mark all of them as failed — don't execute a single one
const batch = message.stopReason === "length"
  ? await failToolCallsFromTruncatedMessage(toolCalls, emit)
  : await executeToolCalls(...);

Why go this far?

Because the streaming tool-call parser is a “best-effort” JSON repairer. A truncated parameter might get repaired into something that looks completely valid — the schema validator would even pass it. Take a write_file call where the file content gets cut in half. The JSON repairer fixes it up, the parser accepts it, and if you execute it, you just wrote half a file to disk.

That’s a data corruption incident.

So pi’s choice is: fail everything and let the model retry, rather than execute a single suspicious call. This code was almost certainly added after a real production outage.

8. Three-Stage Tool Execution Pipeline: prepare → execute → finalize

Each tool call goes through three stages:

// prepare: find the tool, validate parameters, run beforeToolCall hooks
// execute: run the actual tool, stream progress
// finalize: run afterToolCall hooks, transform results

The permission system hooks into the prepare stage. If a hook returns block, execution stops — this is exactly what you see in Claude Code when it asks “allow this command to run?”

The finalize stage can transform results — redact sensitive data, truncate overly long output, etc.

Tool calls in the same batch run in parallel by default — but only the execute stage runs in parallel. The prepare stage is serial. Why? Because you don’t want multiple permission dialogs popping up at the same time. The user wouldn’t be able to handle them one by one.

And if any tool in the batch declares executionMode: "sequential", the whole batch drops down to serial. Running a file edit and a shell command in parallel can produce unpredictable results.

“Parallelize only the part that should be parallelized” — this is the kind of detail that’s easy to overlook when you’re building an agent from scratch.

9. Beyond the 792 Lines: What the Other Layers Carry

The 792 lines stay clean because the layers above and below carry their share of the weight.

What the upper layer (product layer) handles:

  • Extension commands and template expansion
  • Task queuing and retry strategies
  • Context compression (when conversations get too long to fit in token limits)

What the lower layer (protocol layer) handles:

  • Unified API format across different LLM providers
  • Network errors wrapped as in-stream events

The pi-ai layer makes one critical promise: once a stream returns, it never rejects. Any network failure becomes an error event inside the stream. The kernel layer can branch on stopReason instead of wrapping everything in try/catch — and that’s only possible because of this guarantee.

Try It Yourself

The series notes, two architecture diagrams, and the mini-agent code are all in this repo. Each post maps to a runnable step — plug in a DeepSeek or GLM API key and you’re off.

https://github.com/yanhua1010/build-your-own-coding-agent

Practical Summary / Action Checklist

  1. Understand what an Agent actually is: A while loop. Call LLM → execute tools → feed results back → keep going until the model stops asking for tools.
  2. Layered architecture: Product layer owns strategy, kernel layer owns the loop, protocol layer owns the API. Clean boundaries make clean code.
  3. Double-loop structure: Inner loop runs tool calls. Outer loop handles queued tasks. Steering polls inside the inner loop. Follow-up polls in the outer loop.
  4. Event-driven UI decoupling: Emit events, don’t render. Event sequences must close every time — even on exceptions, fake the end events.
  5. Error handling: Return errors to the model as tool results. Let the model figure out the next move.
  6. Token truncation defense: When output is truncated, execute zero tool calls. Fail everything and let the model retry.
  7. Three-stage pipeline: prepare (validation/permissions) → execute (actual work) → finalize (transform results). Prepare runs serial, execute can run parallel.

One-Page Reference

Module Responsibility Key Mechanism
Product Layer (pi-coding-agent) Strategy, queuing, retries, compression AgentSession
Kernel Layer (pi-agent-core) The while loop runLoop, 792 lines
Protocol Layer (pi-ai) Unified API, error wrapping Stream never rejects
Event System UI decoupling emit + closed event sequences
Tool Execution Three-stage pipeline prepare → execute → finalize
Token Truncation Defensive handling On truncation, fail all

FAQ

Q: Is an Agent really just a while loop?
A: The core logic is absolutely a while loop. Of those 792 lines, the loop body is just calling the LLM, checking for tool calls, executing tools, and returning results. The complexity lives outside the loop — error handling, event dispatch, permission management.

Q: What’s the difference between Steering and Follow-up?
A: Steering polls inside the inner loop — after every turn — so you can correct the agent mid-task. Follow-up polls in the outer loop — only after the current task is completely done — so you can queue the next task. Same mechanism, different timing.

Q: Why not parallelize all tool executions?
A: The prepare stage needs to run serial because permission dialogs can’t pop up simultaneously. Plus, if any tool declares sequential mode, the whole batch drops to serial — some operations produce unpredictable results if they run in parallel (like editing a file and running a shell command at the same time).

Q: Why can’t truncated tool calls be executed?
A: Because a truncated JSON payload can be “repaired” into something that looks structurally valid. If write_file content gets cut in half, the repairer might still produce parseable JSON, but you’d write incomplete data to disk. Pi’s approach is to fail everything rather than execute anything suspicious.

Q: What does “event sequences must close” mean?
A: It means that regardless of whether the loop completes normally or throws an exception, the agent_end event will be sent. The UI just subscribes to events and never needs to handle “what if I don’t get the end signal.”

Q: Can I use this approach with other LLM APIs?
A: Yes. The pi-ai layer is exactly that — an adapter that unifies different SDKs into the same event format. If you’re building your own, just wrap your chosen SDK’s streaming interface to produce the same shape of events.

Q: What’s the gap between the mini-agent and pi?
A: The mini-agent is 110 lines — it implements the core while loop and tool execution, and it runs. Pi’s 792 lines add the double-loop structure, event system, token truncation defense, and three-stage pipeline. That’s the distance between a toy and a production tool.


The pi source code referenced in this article is licensed under MIT License, copyright earendil-works.