> For the complete documentation index, see [llms.txt](https://www.openclawbook.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://www.openclawbook.xyz/en/ch15-piagent-runtime-core/15.2-agent-loop-end-to-end-analysis.md).

# 15.2 Agent Loop (Agent Cycle) End-to-End Analysis

> **Generated by**: Claude Opus 4.6 (initial draft) / Claude Opus 5 (`v2026.3.9` revision) **Token usage**: Input \~350,000 tokens, Output \~28,000 tokens (chapter total) **Source baseline**: `v2026.3.9` (`v2026.3.8-170-g665f67726`)

***

When a user message passes through channels and routing and finally needs an AI response, it triggers a complete **Agent Loop**. This loop is one of the most complex processes in OpenClaw, involving parameter validation, model resolution, credential preparation, queue serialization, LLM calls, error recovery, and multiple other stages. This section will trace end-to-end the complete path of a message from entering Gateway to producing an AI response.

## 15.2.1 Loop Entry: Gateway's `agent` RPC Method

The entry point of the Agent Loop is Gateway's `agent` RPC method. When a client (channel adapter, Web console, or TUI) needs an AI response, it sends a request like this via WebSocket:

```json
{
  "type": "req",
  "id": "req-001",
  "method": "agent",
  "params": {
    "message": "Help me analyze the performance issues in this code",
    "sessionKey": "agent:main:telegram:default:dm:12345",
    "channel": "telegram",
    "deliver": true,
    "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000"
  }
}
```

The `agent` RPC handler (`src/gateway/server-methods/agent.ts`) uses an **asynchronous dual-response** design:

```typescript
// src/gateway/server-methods/agent.ts (simplified)
agent: async ({ params, respond, context, client }) => {
  // 1. Parameter validation
  if (!validateAgentParams(params)) {
    respond(false, undefined, errorShape(...));
    return;
  }

  // 2. Idempotency deduplication
  const cached = context.dedupe.get(`agent:${idem}`);
  if (cached) {
    respond(cached.ok, cached.payload, cached.error, { cached: true });
    return;
  }

  // 3. Session resolution and delivery plan
  const deliveryPlan = resolveAgentDeliveryPlan({ ... });

  // 4. Immediate response "accepted" (first res frame)
  const accepted = { runId, status: "accepted", acceptedAt: Date.now() };
  context.dedupe.set(`agent:${idem}`, { ts: Date.now(), ok: true, payload: accepted });
  respond(true, accepted, undefined, { runId });

  // 5. Asynchronously execute Agent loop (non-blocking WebSocket)
  void agentCommand({ message, sessionKey, ... })
    .then((result) => {
      // Second res frame: completion
      respond(true, { runId, status: "ok", result }, undefined, { runId });
    })
    .catch((err) => {
      // Second res frame: error
      respond(false, { runId, status: "error", summary: String(err) }, ...);
    });
};
```

> **Sidebar**: **Dual-response mode** is an important design in OpenClaw's protocol. For long-running operations (like AI conversations that may last tens of seconds), immediately returning an "accepted" response lets the client know the request has been processed, and then sending a second response frame when the operation completes. This prevents clients from resending requests due to timeout, while the `idempotencyKey` mechanism ensures that even if resent, it won't execute twice.

## 15.2.2 Step 1: Parameter Validation and Session Resolution

In `agentCommand` (`src/commands/agent.ts`), the first step is parameter validation and session resolution:

```typescript
// src/commands/agent.ts (simplified)
export async function agentCommand(opts: AgentCommandOpts) {
  const body = (opts.message ?? "").trim();
  if (!body) throw new Error("Message (--message) is required");

  const cfg = loadConfig();

  // Verify agentId exists
  if (agentIdOverride) {
    const knownAgents = listAgentIds(cfg);
    if (!knownAgents.includes(agentIdOverride)) {
      throw new Error(`Unknown agent id "${agentIdOverride}".`);
    }
  }

  // Resolve session
  const { sessionId, sessionKey, sessionEntry, storePath, isNewSession } =
    resolveSession({ cfg, to: opts.to, sessionId: opts.sessionId, sessionKey: opts.sessionKey });

  // Resolve timeout
  const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideSeconds: timeoutSecondsRaw });
  // ...
}
```

The output of session resolution (`resolveSession`) determines the context of Agent execution:

* `sessionId`—the unique UUID of the session, corresponding to the JSONL transcript file on disk
* `sessionKey`—composite key (e.g., `agent:main:telegram:default:dm:12345`), uniquely identifying a conversation context
* `sessionEntry`—metadata record in session storage, containing thinking level, model override, skill snapshot, etc.
* `isNewSession`—whether it's a brand new session (affects whether skill snapshot needs to be loaded)

## 15.2.3 Step 2: `agentCommand` — Model Resolution, Skill Snapshot Loading

After session resolution completes, `agentCommand` enters the model resolution and skill preparation phase:

### Model Resolution

Model resolution determines the final provider and model to use according to the following priority:

```typescript
// src/commands/agent.ts (simplified)
// 1. Read global default model
const { provider: defaultProvider, model: defaultModel } = resolveConfiguredModelRef({
  cfg: cfgForModelSelection,
  defaultProvider: DEFAULT_PROVIDER,  // "anthropic"
  defaultModel: DEFAULT_MODEL,        // "claude-sonnet-4-20250514"
});

// 2. Check session-level model override
const storedModelOverride = sessionEntry?.modelOverride;
if (storedModelOverride) {
  // Verify override model is in allowlist
  const key = modelKey(candidateProvider, storedModelOverride);
  if (allowedModelKeys.has(key)) {
    provider = candidateProvider;
    model = storedModelOverride;
  }
}

// 3. Resolve thinking level
resolvedThinkLevel = resolveThinkingDefault({ cfg, provider, model, catalog });
```

The priority chain is: **session override > agent config > global default**. If a model override is stored in the session (set via `/model` command) and that model is in the allowlist, the overridden model is used.

### Skill Snapshot Loading

For new sessions or cases where skill snapshot is missing, the system scans the workspace directory to build a skill snapshot:

```typescript
// src/commands/agent.ts
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
const skillsSnapshot = needsSkillsSnapshot
  ? buildWorkspaceSkillSnapshot(workspaceDir, {
      config: cfg,
      eligibility: { remote: getRemoteSkillEligibility() },
      snapshotVersion: skillsSnapshotVersion,
      skillFilter: resolveAgentSkillsFilter(cfg, sessionAgentId),
    })
  : sessionEntry?.skillsSnapshot;
```

The skill snapshot (`SkillSnapshot`) records all skills available to the Agent and their current state. The `skillFilter` parameter supports filtering skills by agent—in multi-agent scenarios, different Agents can only see their own subset of skills.

## 15.2.4 Step 3: `runEmbeddedPiAgent` — Queue Serialization, Auth Profile Resolution, Pi Session Construction

This is the most critical step in the Agent Loop. The `runEmbeddedPiAgent` function (`src/agents/pi-embedded-runner/run.ts`) performs the following key operations:

### Queue Serialization

The first thing the function does is enqueue the task into a two-level queue:

```typescript
// src/agents/pi-embedded-runner/run.ts
export async function runEmbeddedPiAgent(params) {
  const sessionLane = resolveSessionLane(params.sessionKey);  // "session:agent:main:..."
  const globalLane = resolveGlobalLane(params.lane);           // "main" or "subagent"

  return enqueueSession(() =>         // Level 1: Session lane (serialization)
    enqueueGlobal(async () => {       // Level 2: Global lane (rate limiting)
      // ... core logic ...
    }),
  );
}
```

The meaning of the two-level queue:

* **Session lane**—requests in the same session must execute serially (avoid concurrent modification of the same conversation history)
* **Global lane**—limits the number of concurrent LLM calls across the entire system (avoid exceeding API rate limits)

### Auth Profile Resolution

Each LLM call requires a valid API key. OpenClaw supports multiple Auth Profiles and selects them by priority:

```typescript
// src/agents/pi-embedded-runner/run.ts (simplified)
const profileOrder = resolveAuthProfileOrder({
  cfg: params.config,
  store: authStore,
  provider,
  preferredProfile: preferredProfileId,
});

// Skip profiles in cooldown
while (profileIndex < profileCandidates.length) {
  const candidate = profileCandidates[profileIndex];
  if (candidate && isProfileInCooldown(authStore, candidate)) {
    profileIndex += 1;
    continue;
  }
  await applyApiKeyInfo(candidate);
  break;
}
```

> **Sidebar**: **Cooldown** is a rate-limit recovery mechanism. When an Auth Profile is marked as failed due to rate limiting, authentication failure, or billing error, the system puts it into a cooldown period during which that Profile won't be tried again. After the cooldown period expires, it automatically recovers. This avoids repeatedly using credentials known to have failed.

### Context Window Protection

Before sending the request, the system checks if the model's context window is sufficient:

```typescript
// src/agents/pi-embedded-runner/run.ts
const ctxInfo = resolveContextWindowInfo({
  cfg: params.config,
  provider,
  modelId,
  modelContextWindow: model.contextWindow,
  defaultTokens: DEFAULT_CONTEXT_TOKENS,
});
const ctxGuard = evaluateContextWindowGuard({
  info: ctxInfo,
  warnBelowTokens: CONTEXT_WINDOW_WARN_BELOW_TOKENS,
  hardMinTokens: CONTEXT_WINDOW_HARD_MIN_TOKENS,
});
if (ctxGuard.shouldBlock) {
  throw new FailoverError(
    `Model context window too small (${ctxGuard.tokens} tokens).`,
    { reason: "unknown", provider, model: modelId }
  );
}
```

### Core Loop: Try → Error Recovery → Retry

The body of `runEmbeddedPiAgent` is a `while (true)` loop implementing multi-layer error recovery:

```typescript
// src/agents/pi-embedded-runner/run.ts (simplified)
while (true) {
  // Execute one LLM call
  const attempt = await runEmbeddedAttempt({ ... });
  const { aborted, promptError, timedOut } = attempt;

  if (promptError && !aborted) {
    // Error recovery branch
    if (isContextOverflowError(errorText)) {
      // Try automatic session history compaction
      if (overflowCompactionAttempts < MAX_OVERFLOW_COMPACTION_ATTEMPTS) {
        await compactEmbeddedPiSessionDirect({ ... });
        continue;  // Retry after compaction
      }
      return { payloads: [{ text: "Context overflow...", isError: true }], ... };
    }

    if (isFailoverErrorMessage(errorText) && await advanceAuthProfile()) {
      continue;  // Retry after switching Auth Profile
    }

    const fallbackThinking = pickFallbackThinkingLevel({ message: errorText, attempted });
    if (fallbackThinking) {
      thinkLevel = fallbackThinking;
      continue;  // Retry after lowering thinking level
    }
  }

  // Successfully completed
  if (lastProfileId) {
    await markAuthProfileGood({ store, provider, profileId: lastProfileId });
  }
  return { payloads, meta: { durationMs, agentMeta } };
}
```

Error recovery strategies are attempted in priority order:

| Order | Error Type                 | Recovery Strategy                                              |
| ----- | -------------------------- | -------------------------------------------------------------- |
| 1     | Context overflow           | Automatically compact session history (max 3 times)            |
| 2     | Auth/rate limit error      | Switch to next Auth Profile                                    |
| 3     | Thinking level unsupported | Downgrade thinking level (e.g., xhigh → high)                  |
| 4     | Timeout                    | Switch Auth Profile (timeout may be caused by rate limiting)   |
| 5     | Other failover errors      | Throw FailoverError, handled by outer model fallback mechanism |

## 15.2.5 Step 4: `subscribeEmbeddedPiSession` — Event Bridging

Inside `runEmbeddedAttempt`, Pi Agent's events are bridged to Gateway's event system. Event bridging converts Pi Agent's low-level events into high-level events that Gateway can broadcast:

```typescript
// Event bridging illustration (conceptual level)
Pi Agent Events                  Gateway Events
───────────────                  ──────────────
tool.start                  →    chat { stream: "tool", phase: "start" }
tool.result                 →    chat { stream: "tool", phase: "result" }
assistant.delta              →    chat { stream: "assistant", delta: "..." }
assistant.message_end       →    chat { stream: "assistant", phase: "end" }
lifecycle.start             →    chat { stream: "lifecycle", phase: "start" }
lifecycle.end               →    chat { stream: "lifecycle", phase: "end" }
```

These bridged events are pushed in real-time to all subscribing clients through Gateway's WebSocket broadcast mechanism, enabling:

* **Web console** to display AI's character-by-character output in real-time (streaming delta)
* **TUI terminal** to show tool call progress
* **Native applications** to update message bubble status

The active run registry (`runs.ts`) manages all currently executing Agent runs:

```typescript
// src/agents/pi-embedded-runner/runs.ts
const ACTIVE_EMBEDDED_RUNS = new Map<string, EmbeddedPiQueueHandle>();

export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
  ACTIVE_EMBEDDED_RUNS.set(sessionId, handle);
}

export function isEmbeddedPiRunActive(sessionId: string): boolean {
  return ACTIVE_EMBEDDED_RUNS.has(sessionId);
}

export function queueEmbeddedPiMessage(sessionId: string, text: string): boolean {
  const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
  if (!handle || !handle.isStreaming()) return false;
  void handle.queueMessage(text);
  return true;
}
```

`queueEmbeddedPiMessage` is the core of the **message steering** (Steer) mechanism—if Agent is in the middle of streaming output, new messages can be injected into the current conversation loop rather than queued for later.

## 15.2.6 Step 5: Result Aggregation, Usage Statistics, Session Persistence

When the Agent Loop completes (whether successfully or with failure), `agentCommand` performs cleanup work:

### Lifecycle Event Emission

```typescript
// src/commands/agent.ts
if (!lifecycleEnded) {
  emitAgentEvent({
    runId,
    stream: "lifecycle",
    data: {
      phase: "end",
      startedAt,
      endedAt: Date.now(),
      aborted: result.meta.aborted ?? false,
    },
  });
}
```

The `end` phase of lifecycle events triggers multiple downstream operations: completion detection in the subagent registry, notification of waiters for `agent.wait`, etc.

### Session Storage Update

```typescript
// src/commands/agent.ts
if (sessionStore && sessionKey) {
  await updateSessionStoreAfterAgentRun({
    cfg,
    sessionId,
    sessionKey,
    storePath,
    sessionStore,
    defaultProvider: provider,
    defaultModel: model,
    fallbackProvider,
    fallbackModel,
    result,
  });
}
```

Session storage update includes:

* **Token usage**—accumulate input/output token counts
* **Model information**—record the actual provider and model used (may differ from requested after failover)
* **Session ID**—ensure session ID consistency

### Result Delivery

```typescript
// src/commands/agent.ts
return await deliverAgentCommandResult({
  cfg,
  deps,
  runtime,
  opts,
  sessionEntry,
  result,
  payloads,
});
```

`deliverAgentCommandResult` sends the Agent's reply text to the user through the configured channel. If the Agent has already sent the message directly via a messaging tool (like Telegram tool, Slack tool), the `didSendViaMessagingTool` flag suppresses duplicate delivery.

### Model Fallback Wrapping

The entire `runEmbeddedPiAgent` call is wrapped in `runWithModelFallback`:

```typescript
// src/commands/agent.ts
const fallbackResult = await runWithModelFallback({
  cfg,
  provider,
  model,
  agentDir,
  fallbacksOverride: resolveAgentModelFallbacksOverride(cfg, sessionAgentId),
  run: (providerOverride, modelOverride) => {
    return runEmbeddedPiAgent({ ...params, provider: providerOverride, model: modelOverride });
  },
});
```

If the primary model throws a `FailoverError`, `runWithModelFallback` sequentially tries configured fallback models, forming a **three-level failover chain**:

```
Primary Model (e.g., anthropic/claude-sonnet-4-20250514)
  ↓ FailoverError
Fallback Model 1 (e.g., openai/gpt-4o)
  ↓ FailoverError  
Fallback Model 2 (e.g., google/gemini-2.5-pro)
  ↓ FailoverError
Final Error
```

***

## Summary of This Section

1. **Agent Loop starts from the `agent` RPC method**, using asynchronous dual-response mode—immediately return "accepted", execute asynchronously in background, and send second response when complete.
2. **Parameter validation and session resolution** determine the execution context, including session ID, session key, timeout settings, and skill snapshot.
3. **Model resolution** determines the model to use according to the priority "session override > agent config > global default".
4. **`runEmbeddedPiAgent`** is the core execution function, implementing two-level queue serialization (session + global), Auth Profile rotation, and multi-layer error recovery.
5. **Event bridging** converts Pi Agent's low-level events into Gateway broadcast events, supporting real-time display of streaming output to clients.
6. **Cleanup phase** includes lifecycle event emission, token usage statistics, session persistence, and result delivery, with outer model fallback mechanism providing additional fault tolerance.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://www.openclawbook.xyz/en/ch15-piagent-runtime-core/15.2-agent-loop-end-to-end-analysis.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
