# The Loop: What Is the Minimal Unit of Agent Work?

> packages/agent/src/agent-loop.ts implements the turn-based cycle: add prompt → call LLM → emit events → execute pending tool calls → repeat until stop. This page asks what each AgentEvent type signals, how tool execution mode (sequential vs. parallel) is chosen, and what the difference is between runAgentLoop and runAgentLoopContinue. The test files agent-loop.test.ts and agent.test.ts show which invariants the authors actually enforce.

- Repository: earendil-works/pi
- GitHub: https://github.com/earendil-works/pi
- Human wiki: https://grok-wiki.com/public/wiki/earendil-works-pi-8b87608fc234
- Complete Markdown: https://grok-wiki.com/public/wiki/earendil-works-pi-8b87608fc234/llms-full.txt

## Source Files

- `packages/agent/src/agent-loop.ts`
- `packages/agent/src/agent.ts`
- `packages/agent/src/types.ts`
- `packages/agent/test/agent-loop.test.ts`
- `packages/agent/test/agent.test.ts`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [packages/agent/src/agent-loop.ts](packages/agent/src/agent-loop.ts)
- [packages/agent/src/agent.ts](packages/agent/src/agent.ts)
- [packages/agent/src/types.ts](packages/agent/src/types.ts)
- [packages/agent/test/agent-loop.test.ts](packages/agent/test/agent-loop.test.ts)
- [packages/agent/test/agent.test.ts](packages/agent/test/agent.test.ts)
</details>

# The Loop: What Is the Minimal Unit of Agent Work?

The agent loop in `packages/agent/src/agent-loop.ts` defines the fundamental rhythm of this runtime: receive a prompt, call the LLM, dispatch tool calls, collect results, and decide whether to continue. Everything else — state management, subscribers, queuing — is scaffolding around that cycle. This page examines what each turn is made of, what each event type signals to observers, how the loop decides between sequential and parallel tool execution, and what contract distinguishes `runAgentLoop` from `runAgentLoopContinue`.

Understanding this loop is the prerequisite for building anything on top of the `Agent` class: custom tools, context transformations, steering flows, or test harnesses all depend on being able to reason about where in this cycle their hooks fire and what invariants they can rely on.

---

## What is a Turn?

The loop operates at two granularities: the **run** (from first prompt to `agent_end`) and the **turn** (from one `turn_start` to the next `turn_end`). A turn is precisely one LLM call plus all the tool calls that LLM response spawns. The turn ends after all tool results are back; then the loop decides whether to start another turn.

The outer structure in `runLoop` makes this visible:

```typescript
// packages/agent/src/agent-loop.ts:170-254
while (true) {
  let hasMoreToolCalls = true;
  while (hasMoreToolCalls || pendingMessages.length > 0) {
    // ... emit turn_start, inject steering messages, call LLM, execute tools, emit turn_end
  }
  const followUpMessages = (await config.getFollowUpMessages?.()) || [];
  if (followUpMessages.length > 0) { pendingMessages = followUpMessages; continue; }
  break;
}
await emit({ type: "agent_end", messages: newMessages });
```

The inner `while` loop handles tool chaining (the LLM returned tool calls → execute → feed back → LLM again). The outer `while` loop handles the case where new messages arrive from a follow-up queue after the agent would otherwise stop.

Sources: [packages/agent/src/agent-loop.ts:155-269]()

---

## The AgentEvent Taxonomy

Every observable occurrence in a run is surfaced as one of these event types. They form a strict lifecycle that observers can rely on.

| Event type | When it fires | Payload |
|---|---|---|
| `agent_start` | Once, at the very start of any run | none |
| `turn_start` | Before each LLM call (including subsequent turns for tool chains) | none |
| `message_start` | When a new message object is available (user, assistant, or toolResult) | `message: AgentMessage` |
| `message_update` | Each streaming delta from the LLM (text, thinking, toolcall chunks) | `message`, `assistantMessageEvent` |
| `message_end` | When a message is finalized and committed to context | `message: AgentMessage` |
| `tool_execution_start` | When a tool call is about to execute (before `execute()` is called) | `toolCallId`, `toolName`, `args` |
| `tool_execution_update` | Intermediate progress from a long-running tool | `toolCallId`, `toolName`, `args`, `partialResult` |
| `tool_execution_end` | When a tool call finishes (success or error) | `toolCallId`, `toolName`, `result`, `isError` |
| `turn_end` | After all tool results for that turn are ready | `message`, `toolResults` |
| `agent_end` | Once, the last event of any run | `messages: AgentMessage[]` — the full set of new messages |

The type definition is precise and exhaustive:

```typescript
// packages/agent/src/types.ts:403-418
export type AgentEvent =
  | { type: "agent_start" }
  | { type: "agent_end"; messages: AgentMessage[] }
  | { type: "turn_start" }
  | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
  | { type: "message_start"; message: AgentMessage }
  | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
  | { type: "message_end"; message: AgentMessage }
  | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
  | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
  | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean };
```

The `Agent` class consumes these events in `processEvents()` to update its mutable state. `message_end` is when a message is actually appended to `state.messages`; `message_start` only sets `state.streamingMessage`. The `tool_execution_start/end` pair maintains `state.pendingToolCalls` as a live set of in-flight tool call IDs.

Sources: [packages/agent/src/types.ts:396-418](), [packages/agent/src/agent.ts:509-556]()

### The guaranteed ordering invariant

Tests in `agent-loop.test.ts` pin down one non-obvious invariant for parallel mode: `tool_execution_end` fires in completion order (whichever tool finishes first), but `message_start/end` for the resulting `toolResult` messages always fires in the original **source order** (the order the LLM listed the tool calls):

```typescript
// packages/agent/test/agent-loop.test.ts:522-544
expect(toolExecutionEndIds).toEqual(["tool-2", "tool-1"]); // completion order
expect(toolResultIds).toEqual(["tool-1", "tool-2"]);        // source order
expect(turnToolResultIds).toEqual(["tool-1", "tool-2"]);    // source order in turn_end
```

Sources: [packages/agent/test/agent-loop.test.ts:452-545]()

---

## A Single Turn in Sequence

```text
turn_start
  │
  ├─ [inject steering messages → message_start/end for each]
  │
  ├─ LLM call (streaming)
  │     message_start (partial assistant message)
  │     message_update × N (text_delta, thinking_delta, toolcall_delta, …)
  │     message_end (final assistant message)
  │
  ├─ [if tool calls exist]
  │     tool_execution_start × M
  │     tool_execution_update × 0..N per tool
  │     tool_execution_end × M          ← completion order (parallel) or source order (sequential)
  │     message_start/end × M           ← always source order (toolResult messages)
  │
turn_end
```

Sources: [packages/agent/src/agent-loop.ts:155-269](), [packages/agent/test/agent-loop.test.ts:1051-1064]()

---

## How Tool Execution Mode Is Chosen

The loop uses a two-level decision to determine whether tool calls in a single assistant message execute sequentially or concurrently:

```typescript
// packages/agent/src/agent-loop.ts:381-388
async function executeToolCalls(...): Promise<ExecutedToolCallBatch> {
  const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
  const hasSequentialToolCall = toolCalls.some(
    (tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",
  );
  if (config.toolExecution === "sequential" || hasSequentialToolCall) {
    return executeToolCallsSequential(...);
  }
  return executeToolCallsParallel(...);
}
```

**Rule:** if `config.toolExecution` is `"sequential"` OR if **any** tool in the batch has `executionMode: "sequential"` on its definition, the entire batch runs sequentially. One "slow" tool contaminates the whole batch.

| Scenario | Result |
|---|---|
| `config.toolExecution = "parallel"` (default), all tools have no `executionMode` | Parallel |
| `config.toolExecution = "parallel"`, one tool has `executionMode: "sequential"` | Sequential |
| `config.toolExecution = "sequential"`, all tools have `executionMode: "parallel"` | Sequential |
| `config.toolExecution = "parallel"`, all tools have `executionMode: "parallel"` | Parallel |

The tests enforce this precisely. A single `executionMode: "sequential"` tool mixed with a fast tool forces sequential execution even though the config default is parallel:

```typescript
// packages/agent/test/agent-loop.test.ts:736-821
// fast tool has no executionMode (defaults to parallel)
// slow tool has executionMode: "sequential"
// config has no toolExecution (defaults to parallel)
// → execution is sequential: fast tool does NOT start before slow tool finishes
expect(executionOrder[0]).toBe("slow:a");
```

Sources: [packages/agent/src/agent-loop.ts:373-515](), [packages/agent/test/agent-loop.test.ts:653-895]()

### The parallel execution strategy in detail

In `executeToolCallsParallel`, tool calls are **prepared** (validated, `beforeToolCall` called) one at a time in source order, then **executed** concurrently via `Promise.all`. The preparation phase is always serial so that `beforeToolCall` can see a consistent context. Execution begins for each tool as soon as its preparation step completes — the first tool starts executing while the second is still being prepared.

```typescript
// packages/agent/src/agent-loop.ts:484-504
finalizedCalls.push(async () => {
  const executed = await executePreparedToolCall(preparation, signal, emit);
  const finalized = await finalizeExecutedToolCall(...);
  await emitToolExecutionEnd(finalized, emit);
  return finalized;
});
// ...
const orderedFinalizedCalls = await Promise.all(
  finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))),
);
```

Tool-result messages are then appended in source order after all tools have finished.

Sources: [packages/agent/src/agent-loop.ts:451-516]()

---

## The Tool Call Lifecycle: Prepare → Execute → Finalize

A single tool call passes through three internal phases before it becomes a `ToolResultMessage`:

1. **Prepare** (`prepareToolCall`): find the tool definition, optionally run `tool.prepareArguments()` to reshape raw LLM arguments, validate against the schema, and call `config.beforeToolCall`. If `beforeToolCall` returns `{ block: true }`, an error result is produced immediately without calling `execute`.

2. **Execute** (`executePreparedToolCall`): call `tool.execute()`. Errors thrown by the tool are caught and converted to error results — the loop does not propagate tool exceptions. Intermediate progress is emitted as `tool_execution_update` events.

3. **Finalize** (`finalizeExecutedToolCall`): call `config.afterToolCall` if set. The hook can override any field in the result: content, details, error flag, or the `terminate` hint.

The `terminate` hint is the mechanism for early loop exit: if **every** tool result in a batch sets `terminate: true`, `shouldTerminateToolBatch` returns true and the loop does not make another LLM call:

```typescript
// packages/agent/src/agent-loop.ts:544-546
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
  return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
}
```

Sources: [packages/agent/src/agent-loop.ts:562-708](), [packages/agent/test/agent-loop.test.ts:1067-1117]()

---

## runAgentLoop vs. runAgentLoopContinue

These are the two entry points into `runLoop`. They differ in exactly one thing: whether the caller is providing new messages or treating the existing context as ready.

| | `runAgentLoop` | `runAgentLoopContinue` |
|---|---|---|
| Takes new prompt messages | Yes (`prompts: AgentMessage[]`) | No |
| Emits `message_start/end` for prompts | Yes, before the first LLM call | No |
| Appends prompts to context | Yes | No (context is used as-is) |
| Returns | New messages including prompts | New messages only (not pre-existing context) |
| Precondition on context | None | Last message must not be `role: "assistant"` |
| Throws if context is empty | No | Yes |

The precondition is enforced explicitly:

```typescript
// packages/agent/src/agent-loop.ts:70-75
if (context.messages.length === 0) {
  throw new Error("Cannot continue: no messages in context");
}
if (context.messages[context.messages.length - 1].role === "assistant") {
  throw new Error("Cannot continue from message role: assistant");
}
```

The reason: the LLM expects a `user` or `toolResult` message as the last entry before it responds. The comment in the public `agentLoopContinue` wrapper calls this "caller responsibility" — the loop cannot validate it because `convertToLlm` (which maps `AgentMessage[]` to `Message[]`) might remap a custom message role at call time.

The test for `continue` confirms the invariant — only the new assistant message is returned, not the pre-existing user message that was already in context:

```typescript
// packages/agent/test/agent-loop.test.ts:1278-1287
const messages = await stream.result();
expect(messages.length).toBe(1);
expect(messages[0].role).toBe("assistant");
// Should NOT have user message events (that's the key difference from agentLoop)
```

Sources: [packages/agent/src/agent-loop.ts:31-143](), [packages/agent/test/agent-loop.test.ts:1233-1351]()

### How Agent wraps both entry points

The `Agent` class calls `runAgentLoop` from `runPromptMessages` and `runAgentLoopContinue` from `runContinuation`. The public `Agent.continue()` method contains extra logic: if the last transcript message is `assistant` and the steering or follow-up queue has items, it drains one batch from those queues and calls `runPromptMessages` instead of `runContinuation`. This prevents the "cannot continue from assistant" error while still processing queued input.

Sources: [packages/agent/src/agent.ts:338-411]()

---

## Steering and Follow-Up Message Injection

The loop polls two external queues at well-defined points:

- **Steering messages** (`getSteeringMessages`): polled after the current assistant turn finishes its tool calls, before starting the next LLM call. They are injected into context and events are emitted for them, then the loop immediately continues. This models "interrupt the agent mid-work."

- **Follow-up messages** (`getFollowUpMessages`): polled only when the inner loop would exit (no more tool calls, no pending steering). They re-enter the outer loop and force another turn. This models "queue the next request until the agent is done."

The `QueueMode` controls how many messages the `PendingMessageQueue` releases on each drain: `"all"` or `"one-at-a-time"` (the default for both queues in `Agent`):

```typescript
// packages/agent/src/agent.ts:211-213
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
```

Sources: [packages/agent/src/types.ts:39-44](), [packages/agent/src/agent.ts:118-152](), [packages/agent/src/agent-loop.ts:167-268]()

---

## The AgentMessage Abstraction Boundary

The loop works exclusively in `AgentMessage[]` — a union of LLM message types plus any custom messages an app registers via declaration merging. The translation to `Message[]` (the type the LLM provider understands) happens exactly once per turn, inside `streamAssistantResponse`, via `config.convertToLlm`. A `transformContext` hook fires before that, at the `AgentMessage` level, where operations like context-window pruning belong.

```typescript
// packages/agent/src/agent-loop.ts:283-289
let messages = context.messages;
if (config.transformContext) {
  messages = await config.transformContext(messages, signal);
}
const llmMessages = await config.convertToLlm(messages);
```

This boundary means the loop's context never leaks provider-specific types. Custom message roles (notification banners, artifact metadata, UI-only annotations) stay in `AgentMessage[]` throughout and are filtered out by `convertToLlm` before the provider sees them.

The default `convertToLlm` in `Agent` is a simple role filter: it passes through only `user`, `assistant`, and `toolResult` messages, dropping anything else.

Sources: [packages/agent/src/agent-loop.ts:275-368](), [packages/agent/src/agent.ts:31-35](), [packages/agent/test/agent-loop.test.ts:131-183]()

---

## Summary

The minimal unit of agent work is the **turn**: one LLM call and the tool executions it triggers. `runLoop` iterates turns until no tool calls remain and no queued messages are waiting. `runAgentLoop` starts a turn sequence by appending new prompt messages and emitting events for them; `runAgentLoopContinue` enters the same loop assuming the context already ends in a user or tool-result message. Tool execution mode is decided per-batch: a single `executionMode: "sequential"` tool overrides the parallel default for the entire batch. Every observable state change — streaming chunks, tool dispatch, message finalization — is expressed as a typed `AgentEvent` that the `Agent` class reduces into mutable state and forwards to application subscribers, with `agent_end` guaranteed as the final event of any run.

Sources: [packages/agent/src/agent-loop.ts:1-268]()
