# MINIMAL-TRACE —— 用最短路径读懂 agent 主循环

> 教学导读。本项目首要目标是「讲透一个 agent 从输入 → 工具循环 → 落盘」。
> 但 `src/loop.ts` 已含不少**生产硬化**（4 种压缩 / 反应式自救 / 并行事件队列 / 截断警告…），
> 初学者直接读会被淹没。这篇带你**第一遍只看主干**，把硬化层全部圈出来跳过。

## 一句话本质

```
用户输入 → 调 LLM → LLM 想调工具就执行 → 结果喂回 → 再调 LLM → … → 不调工具了，输出最终回答
```

整个 agent 就是这个循环。它写在 `src/loop.ts::runQuery`（一个 `async function*`，逐步 `yield` 事件给 UI）。

## 最小可读路径（第一遍只看这 6 步）

打开 `src/loop.ts`，找到 `runQuery`，顺着看这几段——**只看主干，遇到下面「进阶层」列出的东西先跳过**：

1. **用户消息入栈**：把 `{ role:'user', content: userInput }` push 进 `history`，`yield` 一个 `user_message_committed` 让 UI 先把它显示出来。`history` 是一个会被原地 mutate 的数组（调用方和 loop 共享同一引用）。

2. **进入 `while (turn < maxTurns)`**：每一轮 = 一次「调 LLM（可能带工具）」。`maxTurns`（默认 50）是失控保护。

3. **调 LLM、把流式响应拼成一条 assistant 消息**：`for await (const ev of chat({...}))` 逐块消费——`text_delta` 累积成答案文本并 `yield {type:'text'}`；`tool_call_delta` 累积成工具调用（OpenAI 协议下工具调用也是分片返回的，用 `mergeToolCallDelta` 合并）。循环结束后组装出 `assistantMsg` 推入 `history`、`yield assistant_message`。

4. **没有工具调用 → 这一轮就是最终回答，结束**：`if (toolCallsByIndex.length === 0)` → `yield {type:'turn_done'}` 然后 `return`。这是循环的正常出口。

5. **有工具调用 → 逐个执行、把结果按顺序回填 `history`**：对每个工具调用，`yield tool_start` → 调 `executeToolFn(name, argsJson, signal)` → `yield tool_end`，再把结果作为 `{ role:'tool', tool_call_id }` 消息 push 回 `history`。**关键**：tool 消息必须严格按 assistant 的 `tool_calls` 顺序回填（OpenAI 协议要求一一对应）。

6. **回到 `while` 顶部继续下一轮**：模型这次能看到上一轮的工具结果，于是接着推理——要么再调工具，要么给出最终答案（回到第 4 步出口）。

读完这 6 步，你就掌握了 agent 的全部本质。下面的都是「让它在生产环境不出岔子」的加固，第一遍可以完全不看。

## 进阶层（第一遍请跳过）

这些都是 `runQuery` 里为了**健壮/体验**加的料，每个解决一个具体问题，但都不影响你理解主循环：

| 你会看到 | 它解决什么 | 第一遍 |
|---|---|---|
| `autoCompactIfNeeded`（每轮开头） | 上下文快撑爆时，把旧对话压成摘要，防止超 contextWindow | 跳过 |
| `reactiveCompactIfApplicable`（catch 里） | 真撞上 prompt-too-long 时，压缩历史后**同轮重发**自救 | 跳过 |
| `snipCompactIfNeeded`（压缩失败兜底） | 连压缩都失败时，硬砍最老消息释放空间再继续 | 跳过 |
| `microCompact`（回填 tool 结果时） | 把单个超长工具输出就地压短，省 token | 跳过 |
| `allConcurrencySafe` 分支 + 事件队列 | 本轮工具**全是只读**才并行执行，否则串行（杜绝并行写同一文件） | 跳过 |
| `lastFinishReason === 'length'` 警告 | 模型答案被 max_tokens 截断时提醒用户「这是半截」 | 跳过 |
| `reasoningContent / reasoning / reasoning_details` | 累积思维链（DeepSeek-R1 / MiniMax 等），下一轮原样回传防「失忆」 | 跳过 |
| `error` 事件的 `code`（llm_error / max_turns / …） | 让 `-p --output-format json` 把终结原因映射成稳定的 stop_reason（给宿主 spawn 用） | 跳过 |
| `options.chat` / `options.executeTool` | 依赖注入：测试时传 stub，零进程污染；生产走真实模块单例 | 跳过 |

## 时序图（主干）

```
用户输入
   │
   ▼
runQuery  ──push user 消息──►  history
   │
   ├─◄─────────────────────────┐  （每轮 turn）
   ▼                           │
 chat(LLM)  ──流式 text / tool_call──►  拼成 assistant 消息 → history
   │                           │
   ├── 无 tool_calls ──► turn_done ──► return（最终回答）
   │                           │
   └── 有 tool_calls           │
        │                      │
        ▼                      │
   executeTool ×N ──► tool 结果 → history ──┘（回到下一轮，模型看到结果再推理）
```

## 延伸阅读（按需，第几步用到再看）

- **`src/types.ts`** —— 类型字典。先看 `Message`（4 种 role）、`ToolCall`、`LlmStreamEvent`（LLM 流式块）、`LoopEvent`（loop 给 UI 的事件）。读主循环前先扫一遍这四个就够。
- **`src/llm/client.ts`** —— `chat()` 的实现：把 provider 的 SSE 流解析成 `LlmStreamEvent`。对应主干第 3 步。
- **`src/tools/index.ts`** —— `ALL_TOOLS`（工具清单）+ `executeTool()`：参数防线（JSON.parse → Zod 校验 → 调 `tool.call()`，**工具失败不抛异常**，把 error 喂回 LLM 让它重试）。对应主干第 5 步。
- **`src/context/compact.ts`** —— 自动压缩入口（进阶层）。想搞懂「上下文怎么不撑爆」再看。
- **`src/loop.ts`** —— 主角本身。读完本文按上面 6 步对照源码，再回头看进阶层。

> 一句话给初学者：**先把 6 步主干跑通，把进阶层当黑盒**；等你能复述「输入怎么变成最终回答」了，再一个个揭开硬化层——每揭开一个，你就多懂一点「为什么生产级 agent 不只是一个 while 循环」。
