# 从零构建通用任务 Agent（Manus 系）：跟着一个任务，把它一块块拼出来

> **这份教程怎么读**：我们不列一张空泛的清单，而是**领着一个真实任务从头跑到尾**。
> 用户说了一句「帮我总结这个网址」——一开始我们的 Agent 啥也不会。它每撞上一堵墙，我们就补一个零件；补完，它就往前走一步。走到最后，你手里就有一个能自己调研、自己交付、自己收尾的 Agent。
>
> **镜子**：读到每个零件时，我都会指给你看它在真实项目里长什么样——
> OpenManus（commit `52a13f2a`，Python，「心脏」）＋ Kortix/Suna（`5862e956`，TypeScript，「骨架」）。所有 `文件:行号` 都能在 `参考项目/` 下直接核对。
>
> **两个承诺**：① 每个零件都给你**可以直接跑的验收断言**——不是「感觉写好了」，是「这条命令绿了才算过」。② 每个零件都告诉你**最容易翻的那个车**。
>
> **读者设定**：有一点编程基础的初学者。语言 Python / TypeScript 都行，文中伪代码贴近 OpenManus 命名，方便对照。

---

## 目录

- [先看终点：我们要拼出来的东西长这样](#先看终点我们要拼出来的东西长这样)
- [第 0 步 · 任务登场：一个网址，一份摘要](#第-0-步--任务登场一个网址一份摘要)
- [第 1 步 · 给它一个大脑（可它只会说话）](#第-1-步--给它一个大脑可它只会说话)
- [第 2 步 · 给它一只手（可谁去动手？）](#第-2-步--给它一只手可谁去动手)
- [第 3 步 · 让它转起来：心跳循环](#第-3-步--让它转起来心跳循环)
- [第 4 步 · 教它下班：terminate](#第-4-步--教它下班terminate)
- [第 5 步 · 给它记忆（模型其实是失忆的）](#第-5-步--给它记忆模型其实是失忆的)
- [第 6 步 · 给它一份说明书：把通用模型变成专员](#第-6-步--给它一份说明书把通用模型变成专员)
- [第 7 步 · 让它真会调研：搜 → 开 → 抽](#第-7-步--让它真会调研搜--开--抽)
- [第 8 步 · 让交付物长得体面：模板](#第-8-步--让交付物长得体面模板)
- [第 9 步 · 装刹车：断网、超预算、卡住](#第-9-步--装刹车断网超预算卡住)
- [第 10 步 · 复制一个新专员：只换说明书](#第-10-步--复制一个新专员只换说明书)
- [🎬 完整回放：这个任务从头到尾怎么跑的](#-完整回放这个任务从头到尾怎么跑的)
- [第二关 · 从「能干活」到「能安全地一起干很多活」](#第二关--从能干活到能安全地一起干很多活)
  - [痛点 A · 两个任务互相改文件 → 工位隔离](#痛点-a--两个任务互相改文件--工位隔离)
  - [痛点 B · 密钥漏进日志 → 密钥网关](#痛点-b--密钥漏进日志--密钥网关)
  - [痛点 C · 改坏了 prompt 要能回滚 → 变更纪律](#痛点-c--改坏了-prompt-要能回滚--变更纪律)
  - [痛点 D · 想在手机/Slack 上触发 → 渠道投递](#痛点-d--想在手机slack-上触发--渠道投递)
- [附一 · 排期：把这些步骤串成节奏](#附一--排期把这些步骤串成节奏)
- [附二 · 黄金题：交付前的机检清单](#附二--黄金题交付前的机检清单)
- [附三 · 十个最容易翻的车](#附三--十个最容易翻的车)
- [附四 · 对照阅读索引](#附四--对照阅读索引)

---

## 先看终点：我们要拼出来的东西长这样

在动手之前，先把终点摆在眼前。下面这张图，就是我们要一块块拼出来的东西。**绿色**是「第一关」——让它能干活；**蓝色**是「第二关」——让它能安全地、和别人一起干很多活。

```mermaid
flowchart TB
    U(["🧑 用户：帮我总结这个网址"]) --> BRAIN

    subgraph L1["第一关 · 让它能干活（单机就够）"]
        BRAIN["🧠 大脑<br/>大模型，只会输出文字"]
        HAND["🖐️ 手 / 工具<br/>读网页 · 写文件 · 搜索"]
        LOOP["🔁 心跳循环<br/>想一步 → 做一步 → 看结果"]
        MEM["📓 记忆<br/>每圈把经过重新喂回去"]
        BOOK["📜 说明书<br/>把通用模型变成专员"]
        BELL["🔔 下班铃 terminate<br/>做完就显式收尾"]
        BRAIN --- LOOP
        HAND --- LOOP
        MEM --- LOOP
        BOOK --- LOOP
        BELL --- LOOP
    end

    LOOP --> OUT(["📄 workspace/summary.md<br/>交付物落盘"])

    subgraph L2["第二关 · 能安全地一起干很多活（要上线才需要）"]
        ISO["🚧 工位隔离<br/>一个任务一个工位，互不踩"]
        GATE["🔐 密钥网关<br/>Agent 永远看不到真钥匙"]
        GIT["📋 变更纪律<br/>改坏了能一键回滚"]
        CH["📡 渠道投递<br/>手机 / Slack 也能触发"]
    end

    OUT -. 当你要上线 .-> L2

    style L1 fill:#e7f8f1,stroke:#12b981
    style L2 fill:#eaf1ff,stroke:#2f6bff
    style U fill:#fff3df
    style OUT fill:#fff3df
```

一句话记住整张图：

> **一个能自己干活的 Agent ＝ 大脑 ＋ 手 ＋ 一个不停转的循环，外加记忆、说明书和下班铃。上线时再加：工位、钥匙保险箱、回滚、和多个入口。**

这一整份教程，就是从最左边那个「啥也不会」的状态，一步步把这张图填满。

---

## 第 0 步 · 任务登场：一个网址，一份摘要

先把我们要陪跑的任务钉死。整份教程都围着它转：

> **剧本 α**：用户丢来一个网址，说「帮我把 https://example.com 总结一下，写成一份摘要」。
> 我们要的结果是：`workspace/summary.md` 落到硬盘上（≥200 字、含原网址），然后 Agent **主动说「我干完了」**。

为什么先钉死一个具体任务？因为**「能不能跑通这一个剧本」，是检验后面每一个零件的唯一标准**。任何听起来很厉害的能力，如果跑不通这个最朴素的剧本，都是空中楼阁。

顺便说清楚我们**不是**在造什么，免得越做越歪：

| | 我们造的（Manus 系：通用任务 / 调研 / 交付物） | Claude Code（编程 Agent） |
|---|---|---|
| 成功长什么样 | 交付物落盘（一份 MD / HTML / 表格） | 代码补丁合入 + 测试变绿 |
| 主要的「手」 | 搜索 / 浏览器 / 抓网页 | 文件树 / grep / 代码跳转 |
| 怎么收尾 | 显式调 `terminate`，明确「我做完了」 | 常靠「任务完成」的文本或测试门 |
| 一次会话 | 一次性作业：给个任务，交个东西 | 长会话：在一个仓库里持续改 |

> 🔑 **把上面这张表抄进你的 README。** 它是你以后每次纠结「这个功能要不要做」时的裁判——只要它不为「调研 + 交付」服务，就先放一放。

现在，我们的 Agent 还是一张白纸。用户那句话已经说出口了。开始补零件。

---

## 第 1 步 · 给它一个大脑（可它只会说话）

**任务卡在哪**：用户说「帮我总结这个网址」，可我们手上什么都没有。要让它「懂」这句话、能「想」，第一件事是给它接上一个大模型——这就是它的**大脑**。

**直觉**：大脑负责思考和做决定。但记住一件反直觉的事，它会贯穿整份教程——**大脑唯一能做的事，是输出文字**。它不能读你的文件，不能跑命令，甚至不能自己上网。你给它接上以后，它顶多能跟你聊「你应该去读一下那个网址」——但它一个字节都碰不到。

所以这一步我们只造一样东西：一个**把话递给大模型、再把回复接回来**的客户端。

### 你要写的：唯一入口 `chat`

```text
chat(messages, tools, *, system=None, tool_choice="auto") ->
  { content: str | None,                      # 模型说的话
    tool_calls: [{id, name, arguments: dict}]  # 模型想调用的工具（可能为空）
  }
```

- `messages`：到目前为止的对话历史（谁说了什么）。
- `tools`：这一轮告诉模型「你手上有哪些工具」（下一步才会用到，先留着）。
- `tool_calls[].arguments`：**必须是已经 `json.loads` 好的字典**，不要把原始字符串直接往下传——这是最常见的翻车点之一。

### 对照真实源码

OpenManus 的这层入口是 `LLM.ask_tool`（`app/llm.py`）。有三个细节值得照抄它的智慧：

- **超时给足**：默认 300 秒（`llm.py:648`）。调研任务经常要等模型想很久，超时太短会误杀。
- **限额别死循环重试**：网络抖动可以重试，但「token 超限」这种错误重试多少次都没用。OpenManus 特意注释了 Don't retry `TokenLimitExceeded`（`llm.py:641-642`）。
- **空回复要报错**：模型偶尔返回空 `choices`，这时要明确抛错（`llm.py:737-740`），**不能**静默地当成「没话说」——否则循环会空转。

### 验收（这一步做完，跑这几条）

```text
断言 1：不给任何工具，纯聊天，能拿到 content。
断言 2：给它一个「结束工具」的说明，然后 prompt「请直接结束」，它能返回一个 name=terminate 的 tool_call。
断言 3：故意填错 API key → 抛错或记下可读的错误，绝不静默空转。
```

### 最容易翻的车

- 把 `arguments` 当字符串原样塞给工具 → 后面执行必炸。**在客户端这一层就 `json.loads` 好**。
- 一上来就用「流式」传输，结果工具调用的参数只收到半截就拿去执行 → 第一关请先用**非流式**，拿到完整回复再说。

> 🧠 **一句话**：大脑只会说话。它能给你完美的建议，却一个字节都改不了——所以下一步，我们得给它一只手。

---

## 第 2 步 · 给它一只手（可谁去动手？）

**任务卡在哪**：大脑看懂了「总结网址」，它在心里说「我得先把这个网页读回来」。可是——**它自己读不了**。谁去读？

**直觉**：这是整个 Agent 里最关键、也最容易被误解的一点，值得单独画一张图。模型**不会执行任何东西**。它只会说一句话：「我想调用 `fetch_url`，参数是这个网址。」真正跑去读网页的，是**模型外面那层我们自己写的程序**——我们叫它 harness（外壳）。

```mermaid
flowchart LR
    subgraph BRAIN["🧠 大脑（模型）· 只会说话"]
        SAY["「我想调用<br/>fetch_url(url=...)」"]
    end
    subgraph HARNESS["🖐️ harness（我们写的外壳）· 真正动手"]
        RUN["真的去把网页读回来"]
        BACK["把读到的内容<br/>包成一条「观察」塞回历史"]
    end
    SAY -->|"模型只是'点单'"| RUN
    RUN --> BACK
    BACK -->|"下一圈，模型才'看见'结果"| SAY
    style BRAIN fill:#eef2f7
    style HARNESS fill:#eaf1ff
```

记住这个分工：**模型负责「决定」，harness 负责「执行」。** 模型点菜，厨房上菜。这条边界，就是 Agent 安全的根——因为「批不批准做某件事」的权力，永远握在 harness 手里，模型抢不走（第二关会用到这一点）。

### 工具长什么样：统一的模子

每一只「手」都是一个长得一样的工具对象：

```text
Tool:
  name: str                 # 模型按这个名字点单
  description: str          # 告诉模型「这只手能干嘛、什么时候用」
  parameters: JSONSchema    # 规定参数格式
  async execute(**kwargs) -> ToolResult   # 真正干活的函数

ToolCollection（工具箱）:
  to_params()               # 把所有工具的「说明书」打包给模型看
  execute(name, arguments)  # 按名字找到对应工具、跑它
```

对照 OpenManus：`BaseTool.to_param`（`app/tool/base.py:124-137`）把工具转成模型能读的格式；`ToolCollection.execute`（`tool_collection.py:25-35`）按名字派发。

### 先造两只最朴素的手，剧本 α 就够用了

**手一：`fetch_url` —— 读一个网址**

```text
parameters: { url: string, max_chars?: number = 20000 }
行为:
  - 只允许 http / https
  - 超时 15 秒
  - 剥掉 <script>/<style>，正文转成干净文本
  - 太长就切断，并注明「已截断」
失败: 返回 ToolResult(error="...")，不要抛异常炸穿循环
```

**手二：`write_file` —— 把摘要写到硬盘**

```text
parameters: { path: string, content: string }
行为:
  - 算出真实路径后，必须仍在 workspace 目录内（防止 ../../ 穿越出去）
  - 自动创建父目录
  - 返回「已写入 N 字节到 ...」
失败: 路径穿越 / 磁盘错误 → 返回 error 字符串
```

### 一条铁律：工具失败，要变成「观察」，不能崩掉整个任务

这是调研型 Agent 能扛住脏网页、坏链接的核心。执行任何一只手时，按这个管道走：

```mermaid
flowchart TD
    CALL["模型点单：调用某个工具"] --> N{"这个工具存在吗？"}
    N -->|"不存在"| E1["返回一句 Error 文本"]
    N -->|"存在"| J{"参数能解析成 JSON 吗？"}
    J -->|"不能"| E2["返回一句 Error 文本"]
    J -->|"能"| X{"执行时抛异常了吗？"}
    X -->|"抛了"| E3["返回一句 Error 文本 + 记日志"]
    X -->|"没抛"| OK["返回执行结果"]
    E1 --> OBS["全都变成一条『观察』<br/>写回对话历史"]
    E2 --> OBS
    E3 --> OBS
    OK --> OBS
    OBS --> NEXT["模型下一圈看到它，自己换招"]
    style OBS fill:#fff3df
    style NEXT fill:#e7f8f1
```

**原则：工具失败，默认变成一条观察文本喂回模型，让它下一轮换个招。** 不要写成「一失败就崩掉整个任务」。对照 OpenManus 的 `execute_tool`（`toolcall.py:166-208`）——未知工具、JSON 解析失败、执行异常，全都被收成 `Error: ...` 字符串返回，而不是抛出去。

### 验收

```text
断言：调用一个不存在的工具 → 循环继续，历史末尾是一条 error 观察。
断言：给 write_file 传 path="../../etc/passwd" → 返回 error，且你电脑上那个文件没被动。
断言：工具箱的 to_params() 能被真实的 OpenAI SDK 接受（拿真 API 打一轮）。
```

### 最容易翻的车

- `write_file` 不校验路径 → 模型一句话就能把文件写到你整个硬盘任何地方。**算完绝对路径，必须确认它还在 workspace 里面。**
- 工具一失败就 `raise` → 一个坏链接就能让整个任务崩掉。软失败，永远软失败。

> 🖐️ **一句话**：模型点单，harness 上菜。工具失败不叫事故，叫「一条观察」——让模型自己换招。

---

## 第 3 步 · 让它转起来：心跳循环

**任务卡在哪**：现在大脑能想、手能动了。可它俩还没连起来——大脑说「读网页」，手读完了，然后呢？读回来的内容得**再给大脑看一眼**，让它决定「够了吗？要不要接着干？」。这个「想 → 做 → 看结果 → 再想」的圈，就是 Agent 的**心跳**。

**直觉**：别把它想复杂。它的核心就是一个 `while` 循环：

> **只要模型还想用工具，循环就不停；模型什么时候不调工具、直接说话了，任务就结束了。**

### 循环的骨架

```mermaid
flowchart TD
    START(["进入循环：历史里已经有用户那句话"]) --> GATE{"还没超步数上限？<br/>而且还没喊停？"}
    GATE -->|"否"| BUDGET["预算耗尽 → 收尾走人"]
    GATE -->|"是"| THINK["🧠 think：把历史发给模型<br/>问它下一步干嘛"]
    THINK --> Q{"模型这一圈<br/>调工具了吗？"}
    Q -->|"没调，只说了话"| MAYBE["它是不是想收尾了？<br/>（见第 4 步）"]
    Q -->|"调了"| ACT["🖐️ act：真去执行工具<br/>把结果写回历史"]
    ACT --> STOP{"刚才那个工具<br/>是 terminate 吗？"}
    STOP -->|"是"| FIN["翻状态 = 完成 → 退出"]
    STOP -->|"否"| GATE
    MAYBE --> GATE
    style THINK fill:#eef2f7
    style ACT fill:#eaf1ff
    style FIN fill:#e7f8f1
```

### 对照真实源码：三层薄薄地叠出来

OpenManus 的心脏不是某个 2000 行的大函数，而是三个小文件叠出来的：

| 层 | 干什么 | 源码 |
|---|---|---|
| `BaseAgent.run` | 外层班次：门禁检查 → 循环 → 收尾清理 | `base.py:116-154` |
| `ReActAgent.step` | 把一步拆成 `think()` + `act()` | `react.py:33-38` |
| `ToolCallAgent.think / act` | think 问模型，act 执行工具 | `toolcall.py:39-164` |

`step` 短到只有几行，一看就懂：

```python
# react.py:33-38
async def step(self) -> str:
    should_act = await self.think()          # 想：要不要动手？
    if not should_act:
        return "Thinking complete - no action needed"
    return await self.act()                  # 做：真去执行
```

外层 `run` 也就是一个朴素的 while（`base.py:116-154`，这里抓骨架）：

```python
if self.state != AgentState.IDLE:            # ① 门禁：非空闲不许跑
    raise RuntimeError(...)
if request:
    self.update_memory("user", request)      # ② 把用户那句话记进历史
while self.current_step < self.max_steps and self.state != AgentState.FINISHED:
    self.current_step += 1
    await self.step()                        # ③ 想一步、做一步
    if self.is_stuck():                      # ④ 卡住了？（第 9 步讲）
        self.handle_stuck_state()
```

### 三个必须照抄的纪律

1. **门禁**：不是「空闲」状态就不许开跑（`base.py:128-129`）。防止同一个 Agent 被并发乱触发。
2. **工具串行执行**：同一圈里模型若点了好几个工具，**一个一个来**，别并发（`toolcall.py:141`）。第一关求稳不求快。
3. **收尾一定要跑**：不管成功、失败还是异常，浏览器、连接这些资源都得在最后清理掉（`toolcall.py:229-250` 的 `cleanup`）。

### 关于「步数上限」

给循环一个 `max_steps`，是**防止它无限烧钱**的安全绳。OpenManus 里 Manus 默认 20 步（`manus.py:27-28`）。

- 跑黄金题：设 12–20 步。
- 想测「预算耗尽」这条路径：故意设成 3 步。

对照一下别的项目就知道 20 是什么量级：有的 Agent 默认 40、甚至 200 步。OpenManus 的 20 步透露了它的定位——**一次「作业班次」**，长任务靠外面再套一层规划，而不是把一个循环拉到几百步。

### 验收

```text
断言：max_steps=1，任务又确实需要用工具 → 结果是「预算耗尽」，行为稳定可复现。
断言：同一圈里模型点了两个工具 → 它们按顺序执行（看日志时间戳是递增的）。
断言：清理函数在「成功 / 失败 / 抛异常」三条路径下都会跑到（用一个计数器 mock 验证）。
```

### 最容易翻的车

- 循环里不设步数上限 → 模型钻进死胡同能烧光你的钱包。
- 第一步就想上「多个工具并行」→ 并发 bug 会让你在最该验证主逻辑的时候忙着 debug。**先串行。**

> 🔁 **一句话**：想一步、做一步、把结果塞回去、再想——这个圈一圈圈转，就是 Agent 的全部生命。

---

## 第 4 步 · 教它下班：terminate

**任务卡在哪**：循环转起来了。可是——它怎么知道「摘要写完了，该停了」？如果没人告诉它「停」，它会一直转到步数耗尽为止，白白浪费好几圈。

**直觉**：你可能觉得「模型不调工具、开始说人话了，不就代表它做完了吗？」——**恰恰不行**。这是一个特别容易踩的坑。

看 OpenManus 的真实行为（`toolcall.py:118-119, 133-138`）：在默认模式下，模型某一圈只说话、不调工具时，循环**不会**因此就认为任务结束，它会继续转下去。也就是说：**「模型开始说人话」≠「下班」**。

所以我们要一个显式的下班铃：一个叫 `terminate` 的**工具**。模型必须**主动调用它**，才算正式收工。

```mermaid
flowchart LR
    A["模型这一圈<br/>调用了 terminate(status)"] --> B["harness 执行它"]
    B --> C["翻转状态<br/>state = FINISHED"]
    C --> D["下一次循环判断条件<br/>不满足 → 退出"]
    style A fill:#eef2f7
    style C fill:#e7f8f1
```

注意一个精妙之处：`terminate` 这个工具**本身几乎什么都不干**——它的 `execute` 只返回一句「已结束，状态 xxx」（OpenManus `terminate.py:23-25`）。**真正让循环停下来的，是 harness 检测到「这是个特殊工具」，顺手把状态翻成了 FINISHED**（`toolcall.py:210-227`）。工具只是个信号，翻状态才是真动作。

### 你要写的：terminate 的契约

```json
{
  "name": "terminate",
  "description": "任务完成、或你无法继续时，调用它来结束工作。",
  "parameters": {
    "type": "object",
    "properties": {
      "status": { "type": "string", "enum": ["success", "failure"] },
      "summary": { "type": "string", "description": "给用户的一段话，说清你干了什么" }
    },
    "required": ["status"]
  }
}
```

对照 OpenManus `app/tool/terminate.py`——它只有 `status` 一个枚举字段。我们比它多加一个 `summary`，让模型收尾时顺手给用户一句人话交代。

### 光有工具还不够，得在「说明书」里明着教它

模型不会天生知道「写完文件就该调 terminate」。你得在 system prompt 里明说（这一点第 6 步会展开，先记住这条硬规矩）：

> 当 `workspace/` 里已经有了要交付的东西，你**必须**调用 `terminate(success)`。不要继续闲聊。

对照 OpenManus 的 prompt（`prompt/manus.py:9`）：「If you want to stop the interaction at any point, use the `terminate` tool.」

### 验收

```text
断言：prompt「什么都别做，直接结束」→ 头一两步内就出现 terminate 调用。
断言：terminate 之后不再发起任何模型调用（看日志，步数停住了）。
断言：status=failure 时，进程的退出码 ≠ 0（或返回结果里 ok=false）。
```

### 最容易翻的车

- 指望「模型不说话了 = 结束」→ 它会空转到步数耗尽。**一定要显式 terminate。**
- 把 terminate 的「返回值」当成停止信号 → 停止靠的是**翻状态**，不是那句返回文本。

> 🔔 **一句话**：下班要打卡。模型说「我做完了」不算数，它得亲手按下 terminate 那个铃。

---

## 第 5 步 · 给它记忆（模型其实是失忆的）

**任务卡在哪**：循环转到第 2 圈时，模型要「基于第 1 圈读到的网页内容」来写摘要。可问题来了——**模型每次回答都是失忆的**，它根本不记得上一圈发生过什么。那第 2 圈它怎么接得上？

**直觉**：靠我们**每一圈都把到目前为止的全部经过，重新完整地喂给它一次**。你的问题、读过的网页、每个工具的结果……原封不动再发一遍。这一大坨内容，就叫**上下文**。模型的「记忆」是假象，是我们每圈手动喂出来的。

```mermaid
flowchart TD
    subgraph MEM["📓 记忆 = 一个消息列表，越滚越长"]
        direction TB
        M1["user：帮我总结这个网址"]
        M2["assistant：我要调 fetch_url"]
        M3["tool：（网页正文）"]
        M4["assistant：我要调 write_file"]
        M5["tool：已写入 summary.md"]
    end
    MEM -->|"每一圈，把整个列表<br/>重新打包发给模型"| LLM["🧠 模型"]
    LLM -->|"它的新回复<br/>又追加到列表末尾"| MEM
    style MEM fill:#eef2f7
    style LLM fill:#e7f8f1
```

### 你要写的：消息 + 记忆

```text
Message（一条消息）:
  role: system | user | assistant | tool   # 谁说的
  content: str | None                       # 说了什么
  tool_calls?: [...]                         # assistant 发起的工具调用
  tool_call_id? / name?                      # tool 回执用
  base64_image?: str                         # 可选，调研看图时用

Memory（记忆本）:
  messages: list[Message]
  max_messages: int = 100                    # 上限
  add_message() / clear() / get_recent(n)
```

对照 OpenManus `app/schema.py:54-62`（Message）和 `:159-187`（Memory）。

### 记忆装不下了怎么办？第一关：硬切

模型的上下文有容量上限。历史越滚越长，迟早装不下。最朴素的办法就是**只保留最后 N 条**：

```python
# schema.py:167-168
if len(self.messages) > self.max_messages:
    self.messages = self.messages[-self.max_messages:]
```

**硬切，不做摘要**——OpenManus 第一关就这么干，默认留最后 100 条（`schema.py:161`）。别急着上「用大模型压缩历史」那种高级玩法，先跑通再说。

### 一张「什么该进记忆」的纪律表

这张表是新手最容易搞脏上下文的地方，照着来：

| 内容 | 进记忆吗？ | OpenManus 怎么做 |
|---|---|---|
| system prompt（说明书） | **通常不进** | 每圈作为参数单独传，不塞进历史（`toolcall.py:47-53`） |
| 用户的任务 | 进 | 开跑时 `update_memory("user", ...)` |
| 模型发起的工具调用 | 进 | `from_tool_calls` |
| 工具返回的观察 | 进 | `Message.tool_message` |
| 每圈的「本轮提醒」 | 进，但小心 | 见下方翻车点 |

### 最容易翻的车（这个坑 OpenManus 自己也踩了）

OpenManus 每一圈都把一条「本轮提醒」（next_step 便利贴）**追加进记忆**（`toolcall.py:41-43`）。跑 20 圈，历史里就可能堆着近 20 条几乎一样的便利贴，白白吃掉那 100 条的配额。

你的改进，二选一：

1. **推荐**：本轮提醒只作为「临时消息」发给模型，**不**永久写进记忆。
2. 要写进去也行，但把上限提到 200，并在日志里标出「发生了截断」。

另外两个坑：

- 同一个 Agent 实例连着跑两个任务，忘了 `memory.clear()` → 第二个任务会「串味」，读到第一个任务的历史（OpenManus 预算耗尽时会重置步数，但**不清记忆**，`base.py:149-152`）。
- 浏览器截图这类大块内容直接堆进记忆 → token 爆得飞快。

### 验收

```text
断言：连续跑 30 步后，记忆条数 ≤ max_messages。
断言：同一个 Agent 实例第二次跑之前，必须先 memory.clear()——否则串味。
断言（若你选了「说明书不进记忆」策略）：导出记忆，里面不该有 role=system 的条目。
```

> 📓 **一句话**：模型是失忆的。它每一圈能接上，全靠我们把整段经过重新喂一遍——这叫上下文，也是长对话越来越慢、越来越贵的根源。

---

## 第 6 步 · 给它一份说明书：把通用模型变成专员

**任务卡在哪**：到这儿，Agent 已经能转、能用工具、有记忆了。但它还是个「通用模型」——你不告诉它「你是干嘛的、手上有什么、该怎么干活」，它可能读完网页就开始瞎聊，不知道要写文件、更不知道写完要 terminate。

**直觉**：秘密是一份**说明书**——在对话最开头就写死的一段话，术语叫 system prompt。同一个大模型，换一份说明书，就能变成写摘要的、订行程的、或做数据分析的。说明书决定了它的「人设」和工作方式。

Manus 系的说明书通常分两层，分工不同：

| 层 | 管什么 | 多久变一次 |
|---|---|---|
| **system prompt**（岗位手册） | 身份、工作目录、交付约定、安全底线 | 每个专员一份，几乎不变 |
| **next_step prompt**（本轮便利贴） | 这一轮的策略提醒、当前浏览器状态 | 几乎每步都可变 |

对照 OpenManus：Manus 的 `SYSTEM_PROMPT` 里带 `{directory}` 占位符（`prompt/manus.py`），`NEXT_STEP_PROMPT` 教模型怎么选工具、别忘了 terminate。

### 你要写的：第一关的 system 模板（可直接用）

```text
你是 {name}，一个「调研并交付」的 Agent。
工作目录：{workspace 的绝对路径}
你只能在这个目录下面产出文件。

本次要交付的东西：
{deliverable_spec}   # 例：一份 summary.md，≥200 字，含原网址

规矩：
1. 优先用工具，别靠猜。缺数据就去抓、去搜。
2. 交付物在硬盘上出现后，立刻调 terminate(status="success", summary=...)。
3. 卡住了（断网/付费墙），调 terminate(status="failure", summary=...)。
4. 绝不要把 API key 写进任何文件或摘要里。
```

这份模板把第 4 步那条「写完必须 terminate」的硬规矩，正式写进了说明书。

### 为什么分两层？

因为有些提醒是「一辈子不变的身份」（你是谁、只能写哪个目录），有些是「这一秒的临时情况」（你现在浏览器停在哪个页面）。混在一起写，改起来会互相干扰。分开，就能只换其中一层——第 10 步「复制新专员」时你会看到，**只换 system 这一层，就能造出一个全新的专员，循环代码一行不改**。

### 验收

```text
断言：同一套循环，只换 system prompt 文件，行为就从「写摘要」变成「订行程」。
断言：日志能打印出本轮实际用的 next_step 是哪一版，方便回放调试。
```

> 📜 **一句话**：说明书决定人设。换一份说明书，同一个大脑就从「闲聊选手」变成「你的专员」。

---

## 第 7 步 · 让它真会调研：搜 → 开 → 抽

**任务卡在哪**：剧本 α 里用户直接给了网址，`fetch_url` 就够了。但真实的调研任务往往是「帮我查查京都有哪些景点」——**没有现成网址**。Agent 得自己去**搜**，从结果里**打开**最相关的，再**抽取**出有用的信息。这套「搜 → 开 → 抽」的闭环，是 Manus 系相对编程 Agent 的**身份证明**。

**直觉**：把它想成一个人做网上调研的三个动作——先用搜索引擎搜关键词、点开最靠谱的那个链接、读完把要点记下来。缺了任何一环都不叫调研。

```mermaid
sequenceDiagram
    participant A as 🧠 Agent
    participant S as 🔍 搜索
    participant P as 🌐 网页
    participant L as 🧠 二次抽取

    A->>S: ① 搜「京都 必去景点」
    S-->>A: 一串标题 + 链接
    A->>P: ② 打开最相关的那条链接
    P-->>A: 网页正文（一大坨 HTML/文本）
    A->>L: ③ 「从这坨正文里，抽出对'景点'有用的要点」
    L-->>A: 结构化要点（干净的清单）
    Note over A: 信息还不够？<br/>换个更窄的关键词，再来一轮
    A->>A: 够了 → 写交付物 → terminate
```

### 三个阶段的契约

| 阶段 | 输入 | 输出 | 怎么实现 |
|---|---|---|---|
| **搜** search | 关键词 | 标题 + 链接列表（≥3 条） | 接一个搜索 API；进阶可做「多引擎级联回退」 |
| **开** open | 一个链接 | 网页正文 | 用第 2 步的 `fetch_url` 就行；进阶用真浏览器 |
| **抽** extract | 目标 + 正文 | 结构化要点 | **再调一次模型**（无工具，纯让它提炼），或用简单规则 |

对照 OpenManus：这套闭环藏在 `browser_use` 工具的动作链里（`browser_use_tool.py`）。有个关键细节值得注意——它的 `web_search` 动作会**搜索完直接跳转到第一条结果**（`browser_use_tool.py:251-268`），把「搜」和「开」粘在了一起。而「抽」这一步（`extract_content`，`:375-444`）会**在工具内部再调一次模型**去提炼正文——「工具里再套一个模型」，这是提升调研质量的常见手法，代价是更慢、更烧 token。

### 没有真浏览器也能先跑通

不用一上来就上 Playwright。先用最朴素的四件套证明闭环语义是通的：

```text
web_search(query)         -> [{title, url}]     # 搜
fetch_url(url)            -> 干净正文            # 开（复用第 2 步）
extract_facts(goal, text) -> 要点清单           # 抽（内部再调一次模型）
write_file(...) + terminate                     # 交付 + 收尾
```

先证明「搜 → 开 → 抽 → 交付」串得起来，再考虑换真浏览器。

> ⚠️ **上真浏览器时注意**：OpenManus 默认 `disable_security=True`（`browser_use_tool.py:144`）——图省事关掉了浏览器的安全限制。**这个配置绝不能带到公网部署**，上线前必须关掉并自审。

### 验收

```text
断言：跑一个「查城市景点」的任务，日志里至少有 1 次 search、1 次 open、1 次 write_file、1 次 terminate。
断言：产出的文件里 ≥ 3 个真实链接。
断言：故意让搜索 API 失效 → 得到一条 error 观察，Agent 要么换引擎成功、要么最终 terminate(failure)，不许空转。
```

> 🔍 **一句话**：搜、开、抽，缺一不成调研。这套闭环，才是「通用任务 Agent」区别于「聊天机器人」的地方。

---

## 第 8 步 · 让交付物长得体面：模板

**任务卡在哪**：如果任务要产出一份 HTML（比如旅行手册），让模型**从零手写 HTML + CSS**，会有两个问题：白白浪费好几圈在写样式上；而且每次生成的结构都不一样，没法自动检查对不对。

**直觉**：把交付物拆成「**固定的壳** + **模型填的肉**」。样式、骨架你写死，模型只负责往里填内容。这样又快、又稳、又好机检。

```text
render_itinerary(city, places: [{name, url, note}]) -> 写出 workspace/itinerary.html
```

模型只负责给出 `places` 这份数据，**不碰 CSS**。壳长这样（样式写死，禁止模型改）：

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"/><title>{{city}} 行程</title>
  <style>/* 固定样式 */ body{font-family:sans-serif;max-width:720px;margin:2rem auto}</style>
</head>
<body>
  <h1>{{city}} 一日游</h1>
  <section id="places">{{places_html}}</section>
  <footer>Generated by my-agent</footer>
</body>
</html>
```

这一步不需要配图——它本质就是「填空」，一段代码比一张图讲得更清楚。

### 验收

```text
断言：输出的 HTML 里含那句固定的 footer 字符串。
断言：places ≥ 3，且每一项都带 url。
```

> 🧱 **一句话**：壳你定，肉它填。别让模型把力气浪费在写 CSS 上。

---

## 第 9 步 · 装刹车：断网、超预算、卡住

**任务卡在哪**：目前为止我们只考虑了「一切顺利」。可现实里网会断、预算会超、模型会卡在同一个念头里出不来。没有刹车，这些情况会让 Agent 要么崩溃、要么烧光预算空转。

**直觉**：除了正常的 terminate，Agent 还需要几种「非正常下班」的方式。

### 除了 terminate，还有三种下班铃

| 什么情况 | 结局 | 对照 |
|---|---|---|
| 模型调了 `terminate` | 成功 / 失败收工 | `toolcall.py:210-218` |
| 步数用满 `max_steps` | 预算耗尽 | `base.py:149-152` |
| token 累计超限 | 预算耗尽 | `toolcall.py:60-72` |
| 用户按了 Ctrl-C | 取消 | 各入口捕获 |

### 「卡住」检测：模型开始复读了

有时模型会连着好几圈说一模一样的话——它卡在一个念头里出不来了。OpenManus 的检测很朴素（`base.py:163-186`）：**如果最近这条 assistant 消息，和更早的某条一字不差地重复出现了 ≥2 次，就判定卡住**。

处理方式也很克制——**不强制终止**，只是往「本轮提醒」里加一句话，逼它换个思路：

> 「注意到你在重复。换个策略，别再走已经试过的死路。」

对照 `handle_stuck_state`（`base.py:163-168`）。你可以增强：连续卡住 3 次还不改，就 terminate(failure)。

### 每一步都留一行日志

调试全靠它。每步落一行 JSON（一行一步）：

```json
{"step":3, "phase":"act", "tool":"fetch_url", "ok":true, "ms":812, "args":{"url":"https://..."}}
```

**绝不要把完整密钥、整篇网页原文打进日志**（截断到 500 字符）。日志这块不配图——它就是一行行文本，表格和例子比图清楚。

### 验收

```text
断言：断网跑剧本 α → 日志里出现 ok:false；进程不无限 while。
断言：max_steps=3 → 结局是「预算耗尽」，且 workspace 里可以有半成品。
断言：人为让模型复读同一句话 → 出现「卡住」日志，或本轮提醒里加进了那句换招提醒。
```

> 🛑 **一句话**：断网、超预算、复读——三种刹车都得有，Agent 才不会崩掉或空转烧钱。

---

## 第 10 步 · 复制一个新专员：只换说明书

**任务卡在哪**：摘要专员造好了。现在老板说「再来个订行程的」。难道要把整套循环、工具、记忆重写一遍？

**直觉**：完全不用。前面所有零件——循环、记忆、工具管道、terminate——都是**通用**的。造一个新专员，只需要换三样东西：**说明书 + 工具子集 + 交付物规格**。循环代码一行不改。

```text
AgentSpec（一个专员 = 一张配置）:
  name: str
  system_prompt: str        # 换这个
  tools: [工具名子集]        # 换这个
  deliverable_spec: str     # 换这个
  max_steps: int

loop.run(spec, user_request)  # ← 唯一的循环实现，所有专员共用
```

两个专员，同一套循环：

| 专员 | 工具箱 | 交付物 |
|---|---|---|
| 摘要专员 | fetch_url, write_file, terminate | summary.md |
| 行程专员 | web_search, fetch_url, render_itinerary, terminate | itinerary.html |

对照 OpenManus：它用「继承」换品种（`BrowserAgent` / `DataAnalysis` 各是一个子类，换 prompt + 工具箱）。你第一关用「配置」就够了，比继承更轻。

### 验收（这是第一关的结业考）

```text
断言：git diff 显示循环那个文件『零改动』，只新增了 specs/ 和 prompts/ 下的文件。
断言：两个专员都能独立跑通各自的黄金题。
```

### 刻意先别做

- 多个 Agent 互相派单的「规划流」——OpenManus 自己都标了 unstable，第一关不碰。
- 把整套 MCP 插件生态搬进来——需要时再说。

> 👥 **一句话**：循环是通用底盘，专员只是换了张说明书。这就是为什么加一个新专员，几乎是零成本。

---

## 🎬 完整回放：这个任务从头到尾怎么跑的

零件全齐了。现在把第 0 步那个任务——「帮我总结 https://example.com」——完整跑一遍，你会看到前面每个零件是怎么咬合在一起的。

```mermaid
sequenceDiagram
    participant U as 🧑 用户
    participant LP as 🔁 循环
    participant Brain as 🧠 模型
    participant Hand as 🖐️ 工具
    participant Disk as 📄 workspace

    U->>LP: 帮我总结 https://example.com，写成摘要
    Note over LP: 把这句话记进「记忆」

    rect rgb(238, 242, 247)
    LP->>Brain: 第1圈：说明书 + 记忆（就这一句）
    Brain-->>LP: 我要调 fetch_url(url=example.com)
    LP->>Hand: 执行 fetch_url
    Hand-->>LP: （网页正文）→ 记进记忆
    end

    rect rgb(234, 241, 255)
    LP->>Brain: 第2圈：记忆（多了网页正文）
    Brain-->>LP: 我要调 write_file(summary.md, 内容...)
    LP->>Hand: 执行 write_file
    Hand->>Disk: 写入 summary.md ✓
    Hand-->>LP: 已写入 → 记进记忆
    end

    rect rgb(231, 248, 241)
    LP->>Brain: 第3圈：记忆（多了「已写入」）
    Brain-->>LP: 摘要已完成，我要调 terminate(success)
    LP->>LP: 翻状态 = 完成 → 退出循环
    end

    LP-->>U: ✅ 摘要写好了，在 summary.md
```

三圈，就干完了。逐圈对着看，每个零件都在场：

| 圈 | 发给模型的（累积的记忆） | 模型决定 | harness 动作 | 用到的零件 |
|---|---|---|---|---|
| 1 | 说明书 + 用户那句话 | 调 `fetch_url` | 读网页，正文追加进记忆 | 大脑 · 手 · 记忆 |
| 2 | 第 1 圈全部 + 网页正文 | 调 `write_file` | 摘要落盘，回执追加进记忆 | 循环 · 手 |
| 3 | 前两圈全部 + 落盘回执 | 调 `terminate` | 翻状态，退出 | 下班铃 |

注意记忆**越滚越长**：第 3 圈那次请求，包含了前两圈的全部内容。这就是为什么长任务越跑越慢、越贵——也是「记忆」这一步存在的全部意义。

**到这里，第一关通关了。** 你的 Agent 能思考、能动手、会循环、有记忆、懂收尾，还能换张说明书就变出新专员。对绝大多数「给个任务、交个东西」的场景，它已经够用了。

---

## 第二关 · 从「能干活」到「能安全地一起干很多活」

第一关那个 Agent，放在你自己电脑上，一次干一个活，很好用。但当你想**把它上线**——让好几个任务同时跑、让同事也能用、接上真实的第三方账号——四个新麻烦会一起冒出来。

**关键提醒：第二关的每个零件，都是被具体痛点逼出来的。没撞上那个痛点，就别提前做。** 过早上这些，只会让你在还没跑通主逻辑时就淹死在复杂度里。

这一关的镜子换成 **Kortix/Suna**——一个把「公司当成 Git 仓库来跑」的生产级平台。我们只借它最硬核的四个想法。

```mermaid
flowchart LR
    P1["😱 两个任务<br/>互相改文件"] --> S1["🚧 工位隔离"]
    P2["😱 密钥<br/>漏进了日志"] --> S2["🔐 密钥网关"]
    P3["😱 Agent 改坏了<br/>默认 prompt"] --> S3["📋 变更纪律"]
    P4["😱 同事想在<br/>手机上触发"] --> S4["📡 渠道投递"]
    style P1 fill:#fdeded
    style P2 fill:#fdeded
    style P3 fill:#fdeded
    style P4 fill:#fdeded
    style S1 fill:#eaf1ff
    style S2 fill:#eaf1ff
    style S3 fill:#eaf1ff
    style S4 fill:#eaf1ff
```

### 痛点 A · 两个任务互相改文件 → 工位隔离

**痛在哪**：两个任务同时跑，都往 `workspace/summary.md` 写——互相覆盖，一团糟。

**解法**：每个任务开跑时，发一个唯一的 `run_id`，给它一个**专属工位**。一个任务一个目录，谁也踩不到谁。

```mermaid
flowchart TB
    RUN["新任务进来"] --> ID["发一个唯一 run_id<br/>run_id = 'r_' + 随机串"]
    ID --> WS["专属工位<br/>./workspaces/{run_id}/"]
    ID --> BR["（可选）专属 git 分支<br/>agent/{run_id}"]
    WS --> W1["任务A 只在自己工位里写"]
    WS --> W2["任务B 只在自己工位里写"]
    W1 -. 井水不犯河水 .- W2
    style WS fill:#eaf1ff
```

Kortix 把这个想法推到了极致——它有一条**铁律**：`会话 = 沙箱 = git 分支`，三位一体，而且**这个身份一旦建立就不可更改**。它的源码注释说得很硬（`runtime-identity.ts`）：一个装着用户数据的沙箱，是「不可变的身份边界」——就算它宕机了，也只能停掉、绝不能换一个新的顶替它的身份。为什么这么严？因为身份一旦能被偷换，就意味着「A 任务的产物可能被安到 B 任务头上」——对多租户平台是致命的。

**命名警告**：Kortix 里「对话 id」和「工位 id」是两个不同的东西，别混用。从第一天起，日志里就把这两个字段分开打。

```text
验收：并行跑两个任务 → 它们的 workspace 路径不同；
     在 A 的工位里，找不到任何属于 B 的内容。
```

> 🚧 **一句话**：一个任务，一个工位，一个不可偷换的身份。这是多任务不打架的地基。

### 痛点 B · 密钥漏进日志 → 密钥网关

**痛在哪**：任务要调一个需要 API key 的第三方服务（比如发 Slack、查 Notion）。如果让 Agent 自己拿着钥匙去调，那把钥匙迟早会出现在某条日志、某个交付物里——**泄露只是时间问题**。

**解法**：给钥匙修一道墙。Agent **永远拿不到真钥匙**，它只能带着「我想调用 X」的请求去敲一个网关；网关在自己这边偷偷补上钥匙、调完、把结果（脱敏后）递回来。

```mermaid
flowchart LR
    subgraph SBX["Agent（跑在沙箱里）· 拿不到真钥匙"]
        REQ["「帮我调 slack.send，<br/>参数是这些」"]
    end
    subgraph GW["网关（可信进程）· 保管钥匙"]
        SEC["从保险箱取出真钥匙"]
        CALL["拿钥匙调第三方"]
        RED["把结果脱敏后返回"]
    end
    REQ -->|"只带任务id + 请求，<br/>不带钥匙"| SEC
    SEC --> CALL --> RED
    RED -->|"干净的结果"| REQ
    style SBX fill:#fdeded
    style GW fill:#eaf1ff
```

对照 Kortix 的 Executor：真凭证是在**可信的 API 进程**那一侧才附加上去的，跑 Agent 的沙箱手里只有一个受限的临时令牌（`executor/execute.ts`、`credentials.ts`）。

这道墙还顺带带来一个好处——**权限分级**。既然所有第三方调用都得过网关，网关就能顺手判断「这个操作该不该放行」。Kortix 的策略引擎（`executor/policy.ts`）把动作分三档，而且默认值定得很聪明：

| 动作类型 | 默认待遇 |
|---|---|
| 读操作（read） | 直接放行 |
| 写 / 危险操作（write / destructive） | **要人点头批准** |

这套「读放行、写要批」的默认（源码里叫 `default_mode = risk`），比「全部放行」安全得多，又不会烦到每次读取都要你确认。

```text
验收：在所有日志和交付物里搜 'sk-' / 'api_key' / 'Bearer' → 零命中。
     网关单测：不带任务凭证去请求 → 返回 401。
```

> 🔐 **一句话**：Agent 只递请求，钥匙锁在网关里。能动手就能闯祸——所以危险动作默认要人点头。

### 痛点 C · 改坏了 prompt 要能回滚 → 变更纪律

**痛在哪**：你（或者 Agent 自己）改了默认说明书，结果第二天发现所有任务都跑歪了。怎么退回去？

**解法**：不需要自研什么复杂系统。把**所有说明书、配置、策略文件全部放进 git**，改坏了 `git revert` 一下就回来了。

- 说明书 `prompts/`、配置 `specs/`、策略 `policies/` —— 全进 git。
- Agent 默认**只能写自己的工位**；想改说明书，必须走人工 PR。
- CI 里加一道「配置格式校验」，格式不对就红灯，坏配置进不了主干。

对照 Kortix：它把成果和配置都进 git，合并走一套 `change_requests` 审查流程。你的穷人版——一个 GitHub PR 就够了，不必自研审查界面。这一步纯是流程纪律，没有值得画图的机制。

```text
验收：故意提交一个格式非法的配置 → CI 红灯。
     把一次改坏的说明书 revert 掉 → 剧本 α 恢复成功。
```

> 📋 **一句话**：配置进 git，改坏能回滚。别让「改一句 prompt」变成没法撤销的事故。

### 痛点 D · 想在手机/Slack 上触发 → 渠道投递

**痛在哪**：第一关的 Agent 只能在你自己的终端里敲命令启动。同事想用，或者你想在手机上随手发个任务，怎么办？

**解法**：在循环外面包一层 HTTP 入口。所有渠道（网页、Slack、手机）最终都变成对同一个 `loop.run` 的调用——**循环本身一点不用改**。

```mermaid
flowchart LR
    C1["💻 网页"] --> API
    C2["💬 Slack"] --> API
    C3["📱 手机"] --> API
    API["POST /v1/runs<br/>{message, spec_name}"] --> Q["建 run_id + 工位<br/>丢进后台队列"]
    Q --> LOOP["还是那个 loop.run<br/>（一行没改）"]
    LOOP --> POLL["GET /v1/runs/{id}<br/>查状态 + 下载产物"]
    style API fill:#eaf1ff
    style LOOP fill:#e7f8f1
```

先别自研复杂队列——一个 SQLite + 单个后台 worker，就足够验证「同事能从手机发任务」这件事了。

```text
验收：用 curl 创建一个任务 → 轮询到「成功」→ 下载 summary.md → 通过剧本 α 的机检。
```

> 📡 **一句话**：渠道千万条，循环只一个。所有入口最后都汇到同一个 `loop.run`。

---

## 附一 · 排期：把这些步骤串成节奏

这只是**编排建议**，真正的深度在上面每一步里。赶时间的话，2–3 周只做完第一关（第 1–10 步）就能交一个能用的东西。

| 周 | 做哪几步 | 交付 |
|---|---|---|
| 1 | 第 1–5 步 | 能 terminate 的循环 + 记忆 + 日志 |
| 2 | 第 6–8 步 | 剧本 α（网址→摘要）稳定跑通 |
| 3–4 | 第 7、8 步加厚 | 剧本 β（城市→行程）稳定跑通 |
| 5 | 第 9 步 | 断网 / 超预算 / 卡住 行为都合格 |
| 6 | 第 10 步 | 两个专员 + 结业考通过 |
| 7+ | 第二关 | **仅当**对应痛点真的出现了才做 |

---

## 附二 · 黄金题：交付前的机检清单

「黄金题」就是几个写死的剧本，每次改完代码都跑一遍，绿了才算没退化。

```bash
# α · 网址摘要
./golden.sh alpha https://example.com
#   期望：退出码 0；summary.md 存在；文件里含 'example.com'；日志含 terminate + success

# β · 城市行程
./golden.sh beta "京都"
#   期望：itinerary.html 存在；里面 ≥3 个 http 链接；日志里有 search

# γ · 断网（把网络代理指到一个死端口）
HTTP_PROXY=http://127.0.0.1:1 ./golden.sh alpha https://example.com
#   期望：非成功收工；日志里有 ok:false；不许步数跑满还在空转

# δ · 预算
MAX_STEPS=3 ./golden.sh alpha https://example.com
#   期望：结局是「预算耗尽」，工位里可以有半成品

# ε · 隔离（第二关）
./golden.sh parallel_beta
#   期望：两个任务的工位目录，文件互不交叉
```

---

## 附三 · 十个最容易翻的车

每一条都对应上面某一步，踩了回去复习那一步。

| # | 翻车 | 伤到哪一步 |
|---|---|---|
| 1 | 把工具参数当字符串原样塞下去，不 `json.loads` | 第 1 步 · 大脑 |
| 2 | `write_file` 不校验路径，模型能写穿整个硬盘 | 第 2 步 · 手 |
| 3 | 工具一失败就 `raise`，一个坏链接崩掉全任务 | 第 2 步 · 手 |
| 4 | 循环不设步数上限，钻死胡同烧光预算 | 第 3 步 · 循环 |
| 5 | 指望「模型不说话了 = 结束」，结果空转到耗尽 | 第 4 步 · terminate |
| 6 | 复用 Agent 实例忘了 `memory.clear()`，任务串味 | 第 5 步 · 记忆 |
| 7 | 每圈的便利贴都堆进记忆，白吃配额 | 第 5 步 · 记忆 |
| 8 | 第一周就上「多 Agent 派单」，淹死在复杂度里 | 第 10 步 · 专员 |
| 9 | 把浏览器 `disable_security=True` 带上公网 | 第 7 步 · 调研 |
| 10 | 密钥进了记忆 / 日志 / 交付物 | 痛点 B · 网关 |

**许可提醒**：OpenManus 是 MIT（可自由分叉商用，建议保留署名）；Kortix/Suna 是 Elastic License 2.0（内部用通常没问题，但托管成竞品服务前必须读条款）。

---

## 附四 · 对照阅读索引

每一步想深挖时，先读对应的分析章节，再翻真实源码。

| 步骤 | 先读分析 | 再读源码（`参考项目/OpenManus/`） |
|---|---|---|
| 第 1 步 大脑 | 《OpenManus 源码分析》第 5 章 | `app/llm.py` 的 `ask_tool` |
| 第 2 步 手 | 第 7 章 | `tool/base.py`；`tool_collection.py` |
| 第 3 步 循环 | 第 6 章（全文最详细） | `agent/base.py`、`react.py`、`toolcall.py` |
| 第 4 步 terminate | 第 6.4 节 | `tool/terminate.py`；`toolcall.py:210-227` |
| 第 5 步 记忆 | 第 5、8 章 | `schema.py` 的 Memory |
| 第 6 步 说明书 | 第 5.2 节 | `prompt/manus.py` |
| 第 7 步 调研 | 第 7.3 节 | `tool/browser_use_tool.py` |
| 第二关 · 全部 | 《Suna 源码分析》第 3/6/7/10 章 | `runtime-identity.ts`；`executor/policy.ts`、`execute.ts` |

学习网站 `openmanus-suna-learn/` 里的「从零构建」区，和这份教程一一对应，可以对着点。

---

## 收束

> **每一步都有一个可以跑的验收断言，才叫在构建；只有一堆口号和排期表，叫在空谈。**

回头看看你走过的路：一个啥也不会的白纸，被你补上了大脑、手、循环、记忆、说明书、下班铃——它就能自己读网页、写摘要、懂收尾了（第一关）。等你真要把它推上线，再按痛点补上工位、钥匙保险箱、回滚和多入口（第二关）。

**下一步**：别读了，去开个仓库。今天就跑通那条最小的断言——「让模型能主动调一次 terminate」。剩下的，一步一步来。

*全文完。*
