# Agent Loop — The Turn Cycle & Harmony Safety Layer

> How the agent loop drives LLM turns, executes tool calls, coerces malformed results, and detects GPT-5 Harmony protocol leakage — the core invariant that keeps session files valid.

- Repository: can1357/oh-my-pi
- GitHub: https://github.com/can1357/oh-my-pi
- Human wiki: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45
- Complete Markdown: https://grok-wiki.com/public/wiki/can1357-oh-my-pi-64b0ce1ccc45/llms-full.txt

## Source Files

- `packages/agent/src/agent-loop.ts`
- `packages/agent/src/harmony-leak.ts`
- `packages/agent/src/run-collector.ts`
- `packages/agent/src/telemetry.ts`
- `packages/agent/src/thinking.ts`
- `packages/agent/src/compaction.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/harmony-leak.ts](packages/agent/src/harmony-leak.ts)
- [packages/agent/src/run-collector.ts](packages/agent/src/run-collector.ts)
- [packages/agent/src/telemetry.ts](packages/agent/src/telemetry.ts)
- [packages/agent/src/thinking.ts](packages/agent/src/thinking.ts)
- [packages/agent/src/compaction/index.ts](packages/agent/src/compaction/index.ts)
</details>

# Agent Loop — The Turn Cycle & Harmony Safety Layer

The agent loop is the runtime heart of the `@oh-my-pi/pi-agent` package. It drives the repeated cycle of prompting an LLM, streaming the response, dispatching tool calls, collecting results, and deciding whether to continue — until the model stops requesting tools, no more steering messages arrive, or an abort signal fires. Layered on top of that cycle is the Harmony safety layer, a signal-fusion detector that identifies GPT-5 Codex protocol leakage in model output and either surgically recovers the contaminated tool call or aborts and retries the turn before any malformed content reaches the session file.

Understanding this code is essential for anyone extending the tool surface, tuning retry behaviour, integrating observability, or reasoning about session-file validity guarantees.

---

## Entry Points

There are two public entry points into the loop, both returning an `EventStream<AgentEvent, AgentMessage[]>`:

| Function | When to use |
|---|---|
| `agentLoop(prompts, context, config, signal?, streamFn?)` | Start a new conversation turn; the caller supplies the user messages |
| `agentLoopContinue(context, config, signal?, streamFn?)` | Resume from existing context; last message must be `user` or `toolResult` |

Both emit an `agent_start` event, one or more `turn_start`/`turn_end` pairs, and a terminal `agent_end` event. They share the same inner `runLoop` → `runLoopBody` call path. Two "detailed" wrappers (`agentLoopDetailed`, `agentLoopContinueDetailed`) intercept the `onRunEnd` telemetry hook to expose `AgentRunSummary` and `AgentRunCoverage` alongside the `AgentMessage[]` payload without changing the resolved type of `stream.result()`.

Sources: [packages/agent/src/agent-loop.ts:115-187]()

---

## The Turn Cycle

### Outer and Inner Loops

`runLoopBody` uses a two-level loop structure:

```
outer while(true):
    inner while(hasMoreToolCalls || pendingMessages.length > 0):
        inject pending steering messages
        sync context from live state (config.syncContextBeforeModelCall)
        streamAssistantResponse  → AssistantMessage
        if Harmony interruption → handle recovery or retry
        if stopReason == error|aborted → emit placeholder tool results, end stream
        extract toolCalls from message
        if toolCalls → executeToolCalls → ToolResultMessage[]
        push messages into context
        emit turn_end
        poll getSteeringMessages
    poll getFollowUpMessages
    if none → break
```

The outer loop re-enters only when `getFollowUpMessages()` returns new messages — for example, when the user types while the agent is between turns. The inner loop re-enters as long as the model is returning tool calls or pending steering messages need to be injected before the next LLM call.

Sources: [packages/agent/src/agent-loop.ts:453-605]()

### Streaming an Assistant Response

`streamAssistantResponse` is the single place where `AgentMessage[]` is converted to the wire-level `Message[]` representation the LLM provider expects. It:

1. Applies `config.transformContext` if set (optional context preprocessing, e.g. compaction).
2. Calls `config.convertToLlm(messages)` — the provider adapter boundary.
3. Strips `thinking` blocks for the Cerebras provider via `normalizeMessagesForProvider`.
4. Optionally injects the `_i` (intent) field into tool schemas via `injectIntentIntoSchema`.
5. Resolves a per-request API key and metadata (important for expiring tokens).
6. Starts a `chat` OTEL span and begins streaming via `streamSimple` or the caller-supplied `streamFn`.
7. Races each `iterator.next()` against an abort-sentinel promise — a single listener is registered once and reused for every `next()` call to avoid per-event allocation.
8. Propagates `message_start` / `message_update` / `message_end` events downstream.

If Harmony mitigation is enabled for the model, an additional `AbortController` (`harmonyAbortController`) is threaded into the request signal so the stream can be torn down mid-flight when contamination is detected.

Sources: [packages/agent/src/agent-loop.ts:628-846]()

### Tool-Call Result Coercion

The `coerceToolResult` function is the single boundary where untyped values from `tool.execute()` enter the loop. The problem it guards against is explicit in the source:

> _"Persisting a malformed result corrupts the session file (missing `content` array → crash on reload). We coerce at the single boundary where untyped results enter the agent loop, so every downstream consumer can rely on the type."_

The function checks that `content` is an array, validates each block's `type`, sanitises text via `sanitizeText`, and synthesises a descriptive error message when the result is structurally invalid. Only `text` and `image` block types pass through; anything else is dropped. The `malformed` flag triggers `isError: true` on the resulting `AgentToolResult`.

Sources: [packages/agent/src/agent-loop.ts:69-109]()

### Tool Execution and Concurrency

`executeToolCalls` runs tool calls from a single assistant message. Each tool has a `concurrency` property (`"shared"` | `"exclusive"`); the scheduler chains them accordingly:

```text
records: [A(shared), B(shared), C(exclusive), D(shared)]

A ─────────────────────┐
B ─────────────────────┤  (shared tasks run in parallel)
                        ↓
                   C ──────────  (exclusive: waits for A+B, blocks D)
                                  ↓
                            D ──────  (shared, runs after C)
```

For each tool call, the loop:
1. Extracts and strips the `_i` intent field before passing arguments to `tool.execute()`.
2. Validates arguments via `validateToolArguments` (lenient mode available per tool).
3. Calls `beforeToolCall` — can block execution by returning `{ block: true }`.
4. Calls `tool.execute(id, args, signal, partialResultCallback, toolContext)`.
5. Coerces the raw result through `coerceToolResult`.
6. Calls `afterToolCall` — can rewrite the result.
7. Emits `tool_execution_start`, `tool_execution_update`, and `tool_execution_end` events.
8. Finishes the `execute_tool` OTEL span with status `ok | error | blocked | aborted | skipped`.

Steering interruption (`getSteeringMessages`) is checked after each tool completes. When triggered, `steeringAbortController.abort()` propagates to `toolSignal` and subsequent tools are skipped with a synthetic `"Skipped due to queued user message."` result. The tail sweep after `Promise.allSettled` ensures every tool call in the batch has a corresponding `ToolResultMessage` — a hard API requirement for `tool_use / tool_result` pairing.

Sources: [packages/agent/src/agent-loop.ts:889-1217]()

---

## The Harmony Safety Layer

### Background

GPT-5 (`openai-codex` provider) has a known pathology: under certain token-distribution conditions, its internal `Harmony` protocol headers leak into the visible completion stream. Leaked content takes the form of `<|start|>`, `<|channel|>`, or `to=functions.<name>` markers embedded in text blocks, thinking blocks, or — most dangerously — tool-call argument strings. If persisted verbatim, this content corrupts session files and produces invalid arguments.

`isHarmonyLeakMitigationTarget` enables the detection path for any `openai-codex` provider model by default, so future GPT-5.x variants are covered without enumeration.

Sources: [packages/agent/src/harmony-leak.ts:107-114]()

### Signal Classes

Detection uses a set of named signal classes. A detection fires when:
- Signal class `H` (`<|start|>` / `<|end|>` / `<|channel|>` etc.) appears outside a fenced code block, **or**
- Signal class `M` (`to=functions.<name>`) appears together with **at least one** co-signal — bare `M` is exempted because legitimate documentation, bug reports, and the detection module itself carry that pattern.

| Signal | Meaning |
|---|---|
| `H` | Explicit Harmony delimiter token |
| `M` | `to=functions.<name>` marker |
| `C` | Channel-word adjacency (`analysis`, `commentary`, `assistant`, …) immediately before `M` |
| `G` | Glitch-token adjacency (`changedFiles`, `RTLU`, `Jsii`, `Japgolly`) |
| `S` | Script mismatch: non-Latin run in predominantly ASCII context near `M` |
| `B` | Body-channel cascade: `M … code … M` within 200 chars |
| `R` | Fake-result framing: `M … code_output\nCell N:` within 80 chars |
| `T` | Marker appears after the structurally-valid parse end of the argument string |

Fenced code blocks are pre-scanned in one O(n) pass (`computeFenceRanges`) so matched positions inside fences are excluded before any signal logic runs.

Sources: [packages/agent/src/harmony-leak.ts:14-66](), [packages/agent/src/harmony-leak.ts:137-188]()

### Detection Surfaces

`detectHarmonyLeakInAssistantMessage` walks all content blocks in a completed `AssistantMessage`:

- `text` blocks → `surface: "assistant_text"`
- `thinking` blocks → `surface: "assistant_thinking"`
- `toolCall` blocks → `surface: "tool_arg"` (scans `arguments.input` string directly, falls back to `JSON.stringify` for non-string args)

The first detection is returned; only one needs to fire for the recovery path to activate.

Sources: [packages/agent/src/harmony-leak.ts:191-213]()

### Recovery Strategies

The agent loop catches `HarmonyLeakInterruption` and selects a strategy based on whether the detection is recoverable:

```
HarmonyLeakInterruption thrown
    └── err.recovered?
         ├── YES (truncate-and-resume):
         │    if harmonyTruncateResumeCount >= 2 → escalate (throw)
         │    else: use recovered.message, increment counter
         │    emit audit event (action: "truncate_resume")
         └── NO (abort-and-retry):
              if harmonyRetryAttempt >= 2 → escalate (throw)
              else: increment counter, continue inner loop (re-prompt)
              emit audit event (action: "abort_retry")
```

**Truncate-and-resume** is available for `edit` and `eval` tools. The contaminated tool-call input is truncated at the line boundary just before the first signal and a `*** Abort` sentinel is appended. The `edit` tool's hashline DSL parser recognises this sentinel; `eval`'s cell parser accepts it unconditionally. The encrypted `providerPayload` (Codex reasoning blob) is dropped from the recovered message so it cannot carry the leak forward. The recovered `AssistantMessage` contains only the cleaned tool call and is injected into the loop as if the model had returned it.

**Abort-and-retry** applies to all other surfaces. The turn is discarded and the inner loop simply continues to request a new LLM response. Temperature is nudged up by `+0.05` on each retry attempt to shift the model away from the problematic token distribution.

Both strategies cap out at 2 attempts; exceeding the cap throws an escalation error with a human-readable message including the signal labels.

Every recovery or escalation event is reported to `config.onHarmonyLeak` via `createHarmonyAuditEvent`, which builds a privacy-safe `HarmonyAuditEvent` with a redacted 64-char preview (`removedPreview`), a SHA-8 of the removed content, and the full blob only when `OMP_HARMONY_DEBUG=1`.

Sources: [packages/agent/src/agent-loop.ts:489-529](), [packages/agent/src/harmony-leak.ts:227-261](), [packages/agent/src/harmony-leak.ts:283-303]()

---

## Run Collector and Telemetry

### Span Hierarchy

The loop emits a three-level OpenTelemetry span tree per `agentLoop` invocation:

```
invoke_agent {agent.name}
├── chat {model}          ← one per LLM call
├── execute_tool {name}   ← one per tool call
└── ...
```

Activation is fully opt-in. When `config.telemetry` is unset, every helper short-circuits. When set but no OTEL SDK is registered, `@opentelemetry/api` returns a no-op tracer.

Sources: [packages/agent/src/telemetry.ts:8-24]()

### AgentRunCollector

One `AgentRunCollector` instance lives per `AgentTelemetry` handle (constructed once per `agentLoop` call in `resolveTelemetry`). It buffers `ChatRecord` and `ToolRecord` entries as spans finish, then produces an immutable `AgentRunSummary` + `AgentRunCoverage` snapshot on demand.

Key design decisions:
- Span references are used as `WeakMap`-like keys (symbol properties on the span object) so memory is bounded and there is no cross-invocation leakage.
- Methods are non-throwing by contract: telemetry must never convert a successful run into a failed one.
- The `markRunEnded()` guard is idempotent; it coordinates between the success path (`buildAgentEndEvent`) and the error path (`finishInvokeAgentSpan`'s finally block) so `onRunEnd` fires exactly once.
- Tools that never produce a span (pre-run interrupt, tail sweep) are recorded via `recordOrphanTool` so `coverage.toolsInvoked` remains accurate.

`AgentRunCoverage` separately tracks `toolsAvailable` (declared by the config), `toolsInvoked` (actually called during the run), `toolsUnused` (available but never called), `modelsUsed`, and `providersUsed`. All sets are sorted and deduped so coverage values are stable for diffing and assertion in tests.

Sources: [packages/agent/src/run-collector.ts:147-302](), [packages/agent/src/run-collector.ts:418-433]()

---

## State Ownership and Boundaries

```text
AgentContext (caller-owned, mutated in place)
  messages[]   ← loop appends AssistantMessage + ToolResultMessage each turn
  tools[]      ← declared once; normalizeTools adds intent fields per call
  systemPrompt ← stable across turns

AgentLoopConfig (caller-owned, read-only from loop's perspective)
  convertToLlm       ← wire-format adapter (provider boundary)
  transformContext   ← optional pre-LLM message transform (e.g. compaction)
  getSteeringMessages / getFollowUpMessages ← hooks for live user input
  beforeToolCall / afterToolCall            ← intercept hooks
  onHarmonyLeak                             ← audit sink
  telemetry                                 ← OTEL config + run collector

EventStream<AgentEvent, AgentMessage[]>
  ← one-way push channel; loop → consumer
  ← resolves to AgentMessage[] (new messages only) on agent_end
```

The loop never mutates `AgentLoopConfig`. It does mutate `currentContext.messages` in place, which is a shallow copy of the caller's `context.messages` array (plus the new prompts). The caller's original `context.messages` array is not modified.

Sources: [packages/agent/src/agent-loop.ts:124-146]()

---

## Failure Modes

| Condition | Behaviour |
|---|---|
| `stopReason === "error"` or `"aborted"` | Placeholder `ToolResultMessage` created for each unanswered tool call; `agent_end` emitted immediately |
| Tool not found | `isError: true` result with `"Tool X not found"` message; run continues |
| `tool.execute()` returns invalid content | `coerceToolResult` normalises to error result; session file stays valid |
| `beforeToolCall` returns `{ block: true }` | `ToolCallBlockedError` thrown; span status set to `"blocked"` |
| Harmony leak, recoverable tool | Truncate-and-resume up to 2 times, then escalation error |
| Harmony leak, irrecoverable | Abort-and-retry up to 2 times (+0.05 temperature nudge), then escalation error |
| Abort signal fired mid-stream | `emitAbortedAssistantMessage` synthesises an `AssistantMessage` with `stopReason: "aborted"` |
| Steering interrupt during tool batch | `steeringAbortController.abort()` propagates to `toolSignal`; in-flight non-`nonAbortable` tools receive the signal; remaining tools get skipped results |

The tail sweep after `Promise.allSettled(tasks)` guarantees that every tool call in the batch produces a `ToolResultMessage`, maintaining the API-required `tool_use / tool_result` pairing even when the loop takes the abort or skip path.

Sources: [packages/agent/src/agent-loop.ts:533-558](), [packages/agent/src/agent-loop.ts:1200-1216]()

---

## Summary

The agent loop in `packages/agent/src/agent-loop.ts` drives LLM turns through a two-level while-loop, converts `AgentMessage[]` to provider wire format at a single boundary inside `streamAssistantResponse`, and coerces all tool results through `coerceToolResult` before they touch context state — keeping the session file structurally valid regardless of what third-party tools return. The Harmony safety layer in `packages/agent/src/harmony-leak.ts` adds a multi-signal detector that classifies GPT-5 Codex protocol leakage into recoverable (truncate-and-resume via the `edit`/`eval` sentinel mechanism) and non-recoverable (abort-and-retry with temperature nudge) categories, each capped at two attempts before escalating to a hard error. Run-level aggregation is handled non-throwingly by `AgentRunCollector` in `packages/agent/src/run-collector.ts`, which snapshots into a stable `AgentRunSummary` + `AgentRunCoverage` pair at the `agent_end` event boundary.

Sources: [packages/agent/src/agent-loop.ts:1184-1216]()
