# 从零构建一个"AI 同事"（OpenWorker 系）· 开发全流程教程

> **这份教程教你什么**：不是教你写又一个"会写代码的终端 agent"，而是教你造一个**能无人值守、跨应用、动手前先问、跑完给成品的 AI 同事**——OpenWorker（吴恩达团队开源，包名 `coworker`）那种。
> **怎么教**：和本仓库《从零构建通用任务 Agent（Manus 系）》一样——**跟着一个真实场景，从白纸开始，每撞一堵墙就补一个零件**。你会亲眼看到"聊天机器人"是怎么一步步长成"数字同事"的。
> **对照源码**：每个零件都给出 OpenWorker 的 `文件:行号`，你可以边读边回 `参考项目/openworker/coworker/` 核对（基线 commit `db93d75`）。
> **前置**：读过《从零构建 AI 编程助手》或《Manus 系》任一份更好，但不是必须。你只需要知道"Agent 循环 = 模型说话 → 软件替它动手 → 结果喂回模型 → 直到它不再要工具"。

---

## 目录

- [先看终点：一个"数字同事"长什么样](#end)
- [第 0 步 · 场景登场：一份晨报](#s0)
- [第 1 步 · 给它一个 provider 无关的大脑](#s1)
- [第 2 步 · 给它手，并给每只手贴上风险标签](#s2)
- [第 3 步 · 让它转起来：异步主循环 + 并发/串行分流](#s3)
- [第 4 步 · 装刹车：任意状态可停，且不留孤儿](#s4)
- [第 5 步 · 装权限闸门：五档模式 + 路径围栏 + shell 防注入](#s5)
- [第 6 步 · 人不在怎么办：带外审批 + 收件箱](#s6)
- [第 7 步 · 重启不丢：持久恢复](#s7)
- [第 8 步 · 给它记忆：显式 SQLite 事实](#s8)
- [第 9 步 · 给它身份：persona 与家族](#s9)
- [第 10 步 · 接上世界：连接器 + Slack @提及即会话](#s10)
- [第 11 步 · 让它自己上班：定时自动化 + self-wake](#s11)
- [🎬 完整回放：晨报这个任务从头到尾怎么跑](#replay)
- [装成一个产品：桌面壳 + 服务侧车 + 本地密钥](#product)
- [附录：验收断言 / 十个最容易翻的车 / 源码对照索引](#appendix)

---

<h2 id="end">先看终点：一个"数字同事"长什么样</h2>

在写第一行代码前，先把要拼的东西看清楚。一个"编程 agent"和一个"AI 同事"的分界，不在模型、不在工具，而在这四件事：

1. **它替你对外动手**（发 Slack、改日历、提 PR），所以**动手前必须能问你**；
2. **你可能不在**（它在后台/定时跑），所以"问你"不能是弹窗死等，得是一条**能停下来、能从任意地方回答、重启不丢**的队列；
3. **它接的是你的应用**（GitHub/Slack/Jira/Gmail/日历……），不是一个代码库；
4. **它自己会上班**（按点跑、跑完给成品）。

把这四件事画进装配图，就是我们要一步步拼出来的东西：

```mermaid
flowchart TB
    subgraph FRONT["你能看到的（多个界面，共享一条事件流）"]
        GUI["桌面 App / 终端 / Slack 线程"]
    end
    subgraph BRAIN["同事的大脑（一个进程内）"]
        MGR["会话编排 SessionManager"]
        ENG["主循环 TurnEngine（异步）"]
        PERM["权限闸门（五档模式）"]
        INBOX["收件箱（要人时挂在这，重启不丢）"]
        SCHED["调度器（按点自己上班）"]
    end
    subgraph WORLD["你的世界（全用你的密钥）"]
        MODEL["任意 provider 的模型（含本地 Ollama）"]
        TOOLS["文件 / shell / 40 个连接器 / MCP"]
    end
    GUI -->|事件流| MGR --> ENG --> PERM
    ENG -->|要人点头| INBOX --> GUI
    SCHED -->|定时触发| ENG
    ENG -->|统一抽象| MODEL
    ENG -->|裁决后执行| TOOLS
```

记住这张图。下面每一步，都是在往里填一个方块。

---

<h2 id="s0">第 0 步 · 场景登场：一份晨报</h2>

我们的同事只需要会干一件事（先把一件事干透，再谈通用）：

> **"每天早上 8 点，把我 GitHub 上待我 review 的 PR + 今天日历上的会议，汇总成一份晨报，发到我的 Slack。遇到要发消息、改日历这种有后果的动作，先问我。"**

这一句话里，藏着我们要撞的每一堵墙：
- 要调模型 → 但我可能用 OpenAI、也可能用本地 Ollama（**第 1 步**）
- 要读 GitHub、读日历、发 Slack → 工具，而且有的只读、有的有后果（**第 2、5 步**）
- 要一步步来 → 主循环（**第 3 步**）
- 早上 8 点我还在睡 → 无人值守 + 定时（**第 6、11 步**）
- "发 Slack 前先问我"，可我在睡 → 收件箱（**第 6 步**）
- 服务半夜重启了 → 挂起的任务不能丢（**第 7 步**）

现在开始填方块。

---

<h2 id="s1">第 1 步 · 给它一个 provider 无关的大脑</h2>

最朴素的开始：把用户的话发给模型，把模型的话打印出来。

```python
history = [{"role": "user", "content": "帮我写今天的晨报"}]
reply = openai_client.chat.completions.create(model="gpt-5.6", messages=history)
print(reply.choices[0].message.content)
```

**撞第一堵墙**：用户说"我想用本地 Ollama / 用 Claude / 用 Gemini"。这三家的 SDK、消息格式、能力（能不能看图、能不能读 PDF）都不一样。如果你的历史结构和某一家绑死，换模型就得重写。

**补的零件：规范历史 + provider 路由 + 每次调用按能力适配。** OpenWorker 的做法（也是它建在吴恩达团队 **aisuite** 上的原因）：

- **历史永远存成"规范 OpenAI 形状"**，每个 provider 在**调用那一刻**自己转换成本家格式（`engine.py:878` `_outbound_messages` 是唯一的 provider 喂料出口）。
- **按 `provider:` 前缀路由**（`gpt-5.6` 走 OpenAI，`ollama:llama3` 走本地，`anthropic:claude-…` 走 Claude）——`ProviderRouter`（`agent.py:214`）。
- **能力适配放在发送前、按当前模型重判**：附件是 PDF 而模型不原生支持 → 本地抽文/转页图降级；有图而模型没视觉 → 换成占位符（`engine.py:906-961`）。因为每次都重判，**会话中途换模型也永远做对的事**（`switch_model`，`engine.py:180`）。

```mermaid
flowchart LR
    H["规范历史（OpenAI 形状）"] --> OUT["发送前 _outbound_messages"]
    OUT --> STRIP["剥掉仅显示的旁路<br/>source / _display / ts / reasoning / notice"]
    STRIP --> ADAPT["按当前模型能力适配<br/>PDF→抽文/转图 · 图→占位符"]
    ADAPT --> ROUTE["ProviderRouter 按 provider: 前缀"]
    ROUTE --> M1["OpenAI"]
    ROUTE --> M2["Anthropic 原生"]
    ROUTE --> M3["Gemini 原生"]
    ROUTE --> M4["Ollama 本地"]
```

> 🧠 **一句话**：把"历史怎么存"和"发给谁、怎么发"彻底分开。历史是规范形状，适配在发送那一刻做——"随时换模型""附件随模型升降级"就都成了免费的副产品。

**✅ 验收断言**：把历史里塞一张图，模型设成一个无视觉模型，发送前那份 payload 里图应该变成 `[image attachment — not viewable by this model]`，而**持久化的历史里图还在**（`engine.py:938-961`：只改发送副本，不动 `self.messages`）。

---

<h2 id="s2">第 2 步 · 给它手，并给每只手贴上风险标签</h2>

光会说话不算同事。它得能读 GitHub、读日历、写文件。给它工具：

```python
tools = [read_file, list_prs, list_calendar, write_file, run_shell, send_slack]
```

**撞墙**：这些手危险程度天差地别——`read_file` / `list_prs` 是只读的，`run_shell` / `send_slack` 是**有后果**的。如果一视同仁，要么全都要你点头（烦死），要么全都放行（危险）。

**补的零件：每个工具带"风险元数据"。** OpenWorker 的工具全部基于 aisuite 的 `ai.tool` + `ai.ToolMetadata`（`tools/subagent.py:129-137` 是最清楚的例子）：

```python
ai.tool(explore, metadata=ai.ToolMetadata(
    category="search", risk_level="low", requires_approval=False))
```

风险分四类（`risk.py` 的 `classify`）：**低危**（只读/搜索）、**WRITE_LOCAL**（写本地文件）、**EXEC**（跑 shell）、**EXTERNAL**（连接器：发 Slack、改日历）。这个标签后面**两处**都要用：主循环靠它决定"能不能并发"（第 3 步），权限引擎靠它决定"要不要问你"（第 5 步）。

> 🧠 **一句话**：工具的"危险程度"不是权限系统的事后判断，而是**工具自己声明的属性**——声明一次，循环和闸门都读它。

---

<h2 id="s3">第 3 步 · 让它转起来：异步主循环 + 并发/串行分流</h2>

现在把"说话 → 动手 → 喂回"接成一个循环：模型要工具就执行、把结果追加进历史、再问模型，直到它不再要工具。

**撞墙一**：模型一个回合里要了 5 个工具——3 个读 PR、1 个读日历、1 个写文件。串行跑太慢，全并发又危险（写文件和读文件并发会乱）。

**补的零件：低危并发、高危串行。** OpenWorker 的 `TurnEngine._handle_tool_calls`（`engine.py:437`）先把这一批工具**逐个鉴权**（审批是交互的，必须串行），然后：**只读/搜索（`risk_level=low` 且不需审批）用 `asyncio.gather` 并发跑**，写/shell/任何没标注的**严格按调用序一个个跑**（`_parallel_safe`，`engine.py:517`）。

**撞墙二**：模型调用是阻塞的，一跑就卡住整个异步循环，界面转不动。

**补的零件：阻塞调用包进 `to_thread` + 流式经"线程+队列"桥回来。** provider 的阻塞流跑在工作线程里，通过队列把 token 塞回主循环，主循环把"等队列"和"等 Stop"赛跑——这样一个卡住的流也拖不住整个回合（`engine.py:_astream:388`）。

```mermaid
flowchart TD
    A["模型这一回合要了 N 个工具"] --> B["逐个鉴权（串行，审批是交互的）"]
    B --> C{"按风险分流"}
    C -->|"低危：读 PR / 读日历"| D["asyncio.gather 并发跑"]
    C -->|"高危：写文件 / 发 Slack"| E["按调用序一个个跑"]
    D --> F["结果追加进历史"]
    E --> F
    F --> G{"模型还要工具?"}
    G -->|要| A
    G -->|不要| H["晨报出来了 → 回合结束"]
```

**✅ 验收断言**：让模型一次请求 3 个只读工具 + 1 个写工具，日志里 3 个只读应几乎同时 `started`，写的那个在它们之后单独 `started`（`engine.py:485-502`）。

---

<h2 id="s4">第 4 步 · 装刹车：任意状态可停，且不留孤儿</h2>

**撞墙**：晨报拉了半天，你想喊停。但"停"可能发生在任何时刻——正在流式吐字、正在跑一个 shell 命令、正在等你审批、在两次迭代之间。而且更隐蔽的坑：如果模型已经发出了 3 个 tool_call、你在第 2 个时喊停，剩下那个 tool_call **没有对应的结果**——托管的聊天模板会拒收这种"孤儿 tool_call"，下次持久恢复还会把它再弹一遍。

**补的零件：一个能从任意状态停、且给每个待跑工具补一条错误结果的中断。** OpenWorker 的 `request_interrupt`（`engine.py:120`）：设一个 `asyncio.Event`，流式中的生产线程在两 chunk 间丢流、interrupt hook 杀掉正在跑的 shell、等审批的 `await` 解析为"被中断"、迭代间检查点退出——**而每个还没答的 tool_call 都补一条 `interrupted by user` 的工具错误结果**（`_interrupted_tool`，`engine.py:504`），历史里永远没有孤儿。

> 🧠 **一句话**：中断不是"把循环 break 掉"这么简单。一个能无人值守跑的 agent，必须保证"停"之后历史是**干净、可恢复**的——没有半截的 tool_call。这条纪律，第 7 步的持久恢复会直接受益。

**✅ 验收断言**：造一个模型回合发 2 个 tool_call，在第 1 个执行时触发中断；检查历史，第 2 个 tool_call 也有了一条 `role:tool` 的错误结果，`INTERRUPTED` 事件带着 `iterations`。

---

<h2 id="s5">第 5 步 · 装权限闸门：五档模式 + 路径围栏 + shell 防注入</h2>

现在到了同事和玩具的分界。晨报要**发 Slack**（EXTERNAL 风险），这是有后果的。

**补的零件：一个只做"决策"的权限引擎。** OpenWorker 的 `PermissionEngine.evaluate`（`permissions.py:120`）输出 allow / deny / **需要问你**，五档模式（`permissions.py:37`）：

| 模式 | 行为 |
|---|---|
| `discuss` | 只读对话，不改不发 |
| `plan` | 只读 + 计划契约（探索 → 提计划 → 批准后执行） |
| `interactive`（默认） | 读自动放行，写/发/命令要问你 |
| `auto` | 全放行，但**仍受路径约束** |
| `custom` | interactive + 自动放行你配置的工具 |

三条防线值得逐字学：

- **写必须落在可写根内**（`_under_writable_root`，`permissions.py:204`）：晨报写到 `~/reports/` 可以，写到 `/etc/` 直接拒。根是可变的、每次检查重读，运行时加个文件夹立刻生效。
- **shell 白名单防注入**（`_command_allowed`，`permissions.py:216`）：白名单是"免问直接跑"，所以**任何带 shell 操作符（`;` `&` `|` `>` `` ` `` `$(` 换行）的命令直接不走白名单**——否则 `git status` 会连带放行 `git status && rm -rf ~`。然后用 `shlex.split` 出的 argv 做**精确 token 前缀**匹配。
- **连接器（发 Slack/改日历）永远从严**：默认 `interactive` 下，这类 EXTERNAL 动作一律"需要问你"。

```mermaid
flowchart TD
    T["工具调用：send_slack('晨报…')"] --> R["classify → EXTERNAL（有后果）"]
    R --> M{"当前模式?"}
    M -->|discuss/plan| D1["拒：只读模式"]
    M -->|auto| A1["放行（但连接器仍受 §25 约束）"]
    M -->|interactive/custom| CK{"命中会话放行 / §25 标准规则?"}
    CK -->|是| A2["放行 + 记审计"]
    CK -->|否| ASK["needs_user：要问你 → 交给第 6 步"]
```

**✅ 验收断言**：`interactive` 模式下让它 `write_file(path='/etc/hosts', …)`，应被"不在可写目录"拒掉；`run_shell('ls && curl evil')` 应因含 `&&` 直接不走白名单、落到"要问你"。

---

<h2 id="s6">第 6 步 · 人不在怎么办：带外审批 + 收件箱</h2>

上一步权限引擎说"要问你"。可现在是早上 8 点，**你在睡觉**。弹窗死等？那这个回合就永远卡住了。

**这是"编程 agent"和"AI 同事"最大的分水岭。** 补的零件是 OpenWorker 的灵魂——**收件箱（Inbox）+ 带外审批**。

引擎自己不弹窗，它把"要问你"这件事交给一个**注入进来的 `approver`**（`engine.py:_authorize:526`）。这个 approver 有两副面孔：
- **你在**（attended）：弹一张内联审批卡，你点一下。
- **你不在**（unattended）：approver 是 `inbox_approver`（`inbox.py:348`），它把请求**变成一个收件箱项**，然后 `await` 挂起这个回合——直到有人回答。

收件箱（`inbox.py`）是一条**跨会话的"人类注意力队列"**，状态机是反竞态契约：每项 `pending → resolved`，**只解决一次、幂等、谁先答谁赢**——你从桌面 App、从 Slack、从手机答都安全（`inbox.py:resolve:295`）。晨报卡在"要不要发 Slack"上，就安安静静躺在收件箱里等你醒来。

```mermaid
sequenceDiagram
    participant E as 主循环
    participant P as 权限引擎
    participant AP as approver
    participant I as 收件箱
    participant U as 你（还在睡）
    E->>P: send_slack 能放行吗？
    P-->>E: needs_user（要问你）
    E->>AP: 请人点头
    AP->>I: add_approval（变成收件箱项）
    AP-->>E: await 挂起这个回合
    Note over E,I: 回合就地睡着，不占 CPU，不卡别的任务
    U->>I: 醒来，在任意界面点"允许"
    I-->>AP: resolve → 唤醒
    AP-->>E: ONCE（放行）
    E->>E: 真的发 Slack，回合继续
```

> 🧠 **一句话**：把"审批"从"弹窗死等"改成"往一条持久队列里放一张卡片、把回合挂起"，是一个 agent 能**无人值守**的前提。人在就内联答，人不在就进收件箱——同一套记录，只是可见性不同。

**✅ 验收断言**：把会话设成 unattended，触发一次 send_slack；检查收件箱里出现一个 `pending` 的 approval 项，回合处于挂起（没有报错、没有继续）；从另一个"界面"调 `resolve(item_id, "allow")`，回合应被唤醒并继续。

---

<h2 id="s7">第 7 步 · 重启不丢：持久恢复</h2>

**撞墙**：晨报挂在收件箱等你醒。可半夜服务器更新重启了。回合在内存里——没了。你早上点"允许"，什么都不会发生。

**补的零件：持久恢复（durable resume）。** 两个设计合起来才成立：

1. **收件箱项是持久的、且幂等**——键是 `(session_id, tool_call_id)`（`inbox.py:133`）。
2. **回合能从"最后一条 assistant 消息里还没被回答的 tool_call"重建挂起**（`engine.py:resume:250` + `_unanswered_trailing_tool_calls:268`）。

重启后，`resume` 重新处理那条"待答"的 tool_call——审批回调一看收件箱里这个项**已经存在**（幂等），就直接复用它、不再重弹；已经回答过的调用被跳过，所以**什么都不会双跑**。你早上点的"允许"依然生效，晨报照发。

```mermaid
flowchart LR
    S1["回合挂在收件箱等审批"] --> CRASH["服务重启（内存清空）"]
    CRASH --> R["resume()：从历史找'还没答的 tool_call'"]
    R --> IDEM["审批回调查收件箱：这个 tool_call_id 已有项"]
    IDEM --> REUSE["复用已存在的项（幂等，不重弹）"]
    REUSE --> DONE["你之前/之后点的'允许'照样生效，不双跑"]
```

> 🧠 **一句话**：第 4 步"中断不留孤儿"的纪律，在这里兑现成"重启能续"。历史干净 + 收件箱项幂等 = 回合可以死而复生。这是"数字同事"区别于"聊天机器人"最硬的一块骨头。

**✅ 验收断言**：制造一个挂起回合 → 丢弃引擎对象（模拟重启）→ 用同一份持久历史 + 收件箱新建引擎并 `resume()` → 回合继续且那个 tool_call 只执行一次。

---

<h2 id="s8">第 8 步 · 给它记忆：显式 SQLite 事实</h2>

**撞墙**：你上周跟它说过"晨报里 PR 只看 `backend/` 目录的"。这周它又忘了。

**补的零件：跨会话记忆。** 注意 OpenWorker 这里的取舍很鲜明——它**不做自动上下文压缩**（不像有些 agent 把旧历史摘要丢弃），而是让记忆是**显式的、持久的 SQLite 事实**：`SQLiteMemoryStore`（`memory/sqlite_store.py`），两个作用域 GLOBAL / WORKSPACE，工具 `remember` / `memory_update` / `memory_forget`。

关键是那段**"何时记忆"的引导**（`agent.py:62`），没有它模型要么不记、要么记一堆噪音：只记用户的纠正与偏好（带原因）、推不出来的项目背景；不记代码/git 历史/约定文件里已有的；用绝对日期；存前先查已知记忆去重。

> 🧠 **一句话**：OpenWorker 宁可让人显式管理记忆（`remember`），也不让模型自动丢上下文——对一个"交付成品的同事"，**可预测比省 token 重要**。

---

<h2 id="s9">第 9 步 · 给它身份：persona 与家族</h2>

**撞墙**：晨报这个活儿要连接器 + 定时；但如果你想让同一套引擎去"改代码"，它需要的是文件/git/explorer 子 agent，两套工具完全不同。

**补的零件：persona（人设即 agent）+ 家族分装。** OpenWorker 里**persona 就是 agent**（`agents/registry.py` 委托给 `personas/registry.py`）。一个 `Agent`（`agents/base.py:28`）= 系统提示 + 工具工厂 + `family`（`code` / `knowledge`）+ `messaging` / `connectors` 标志。装配时（`agent.py:build_engine:109`）按家族发不同的手：

- **knowledge 家族**（晨报同事就是这个）：定时调度、self-wake、多目录、频道订阅、send_message/send_file。
- **code 家族**：只读 explorer 子 agent（把宽泛调研外包给一个全新上下文、强制 PLAN 只读、无审批的子 `TurnEngine`——`tools/subagent.py`）。

内置 persona：`cowork`（默认，产出交付物）、`code`、`chat`；第三方 persona 可从目录/git 安装，但**默认禁用、待你对声明的能力授权后才启用**（`personas/registry.py:340`）——一个带同意闸门的 persona 市场。

> 🧠 **一句话**：同一台引擎，换一份 persona（系统提示 + 工具工厂 + 家族），就从"晨报同事"变成"代码同事"。身份不是硬编码的分支，是一份可安装、可授权的配置。

---

<h2 id="s10">第 10 步 · 接上世界：连接器 + Slack @提及即会话</h2>

晨报要读 GitHub、读日历、发 Slack。这些是**连接器**（`connectors/`，25+ 家：GitHub/Slack/Jira/Notion/Gmail/Google Calendar/Telegram…）。令牌存**本地密钥库**，云端只撮合 OAuth 握手。

**撞墙 + 点睛之笔**：你希望在 Slack 里 @它就能用。当 @OpenWorker 在一个还没有会话的频道被 @ 到，路由器**生成一个"拥有这条线程"的会话**并回复进去（`mentions.py`，§31）。妙在 `thread_target` 这个字符串（`"slack:C0123:1700….000100"`）**一串三用**：① 线程→会话的查找键；② `send_message` 的投递目标；③ **§25 标准授权规则的 target**——每次重建引擎都从中重推 `permissions.task_rules`，所以"在这条线程里免问回复"的预授权**重启后依然有效**。

```mermaid
flowchart TD
    AT["Slack 频道 @OpenWorker"] --> NEW{"该线程已有会话?"}
    NEW -->|有| ROUTE["路由到已有会话"]
    NEW -->|没有| SPAWN["生成'拥有此线程'的会话"]
    SPAWN --> TT["thread_target 一串三用"]
    TT --> U1["① 查找键"]
    TT --> U2["② send_message 投递目标"]
    TT --> U3["③ §25 标准授权 target（重启存活）"]
```

这就把第 5 步的权限落地了：它能在**这一条**线程里免问回复（因为你 @ 它就是授权），但发到别的地方、或跑 shell，照样要问。

---

<h2 id="s11">第 11 步 · 让它自己上班：定时自动化 + self-wake</h2>

最后一块方块：早上 8 点没人喊它，它得**自己醒**。

**补的零件：调度器。** OpenWorker 的 `Scheduler`（`automation/scheduler.py:23`）跑在常驻服务里，策略是**停机期间漏的任务开机补跑一次 + 重叠跳过**（上一次没跑完就不叠），每个到期任务**只 spawn 不 await**——因为一个跑法可能挂在收件箱的审批上（就像晨报等你点"发 Slack"），**一个卡住的自动化绝不能堵住整个调度环和别的任务**。下一次触发时间用 croniter 算。

再加 **self-wake**（`selfwake.py`）：一个会话能"我先睡，到点了/某事完成了/某事件来了再叫醒我继续"——调度器每个 tick 顺便恢复到期的 wake。

```mermaid
flowchart LR
    T["每 30s tick（首次=补跑漏的）"] --> DUE["到期任务：晨报 8:00"]
    DUE --> SPAWN["只 spawn 不 await（重叠跳过）"]
    SPAWN --> RUN["跑晨报会话（第 1–10 步全用上）"]
    RUN --> WAIT{"要发 Slack，你还在睡?"}
    WAIT -->|是| PARK["挂进收件箱，不堵调度环"]
    WAIT -->|否/已授权| DELIVER["产出晨报 + 逐字记录落应用"]
    T --> WAKE["顺便恢复到期 self-wake"]
```

至此，装配图上的每个方块都填满了。

---

<h2 id="replay">🎬 完整回放：晨报这个任务从头到尾怎么跑</h2>

把十一步串起来，看那句"每天早上把晨报发我 Slack"实际怎么走完：

1. **8:00 调度器 tick**（第 11 步）→ 发现晨报任务到期 → spawn 一个 knowledge 家族的 cowork 会话（第 9 步）。
2. 会话装配（第 9 步）：provider 路由绑好你的模型（第 1 步）、装上 GitHub/日历/Slack 连接器 + 风险标签（第 2、10 步）、权限设 interactive（第 5 步）、会话设 unattended。
3. **主循环转起来**（第 3 步）：模型说"我先读 PR 和日历"→ 这俩是低危 → **并发**拉取（第 2、3 步）。
4. 模型把 PR + 会议揉成晨报草稿，说"我要 `send_slack`"。
5. **权限闸门**（第 5 步）：`send_slack` 是 EXTERNAL、interactive 模式 → `needs_user`。
6. **你在睡** → approver 是 `inbox_approver`（第 6 步）→ 晨报变成一张审批卡躺进收件箱，回合就地挂起。
7. **半夜服务重启**（第 7 步）→ 开机补跑/恢复时 `resume`，从"待答的 send_slack"重建挂起，收件箱里那张卡还在（幂等）。
8. **你 9 点醒了**，在手机上点"允许"→ 收件箱 `resolve` → 唤醒回合 → 真的把晨报发到 Slack。
9. 模型不再要工具 → 回合结束 → 这次运行的完整逐字记录落进 App（第 11 步）。

```mermaid
sequenceDiagram
    participant SC as 调度器
    participant EN as 会话/主循环
    participant PM as 权限
    participant IB as 收件箱
    participant YOU as 你
    SC->>EN: 8:00 到期，spawn 晨报会话
    EN->>EN: 并发读 PR + 日历（低危）
    EN->>PM: send_slack 放行吗？
    PM-->>EN: needs_user
    EN->>IB: 变成审批卡（你在睡）
    Note over EN,IB: 回合挂起（半夜重启也能 resume）
    YOU->>IB: 9:00 手机点"允许"
    IB-->>EN: 唤醒
    EN->>YOU: 晨报发到 Slack ✅
```

**这一趟走下来，你就明白了：一个"AI 同事"不是"更强的聊天机器人"，而是"一个能把回合安全地挂起、等你、还不怕重启的执行系统"。**

---

<h2 id="product">装成一个产品：桌面壳 + 服务侧车 + 本地密钥</h2>

引擎有了，最后包成用户能双击的东西。OpenWorker 的产品形态（`README.md` + `server/run.py` + `surfaces/`）：

- **Tauri（Rust）桌面壳**监管一个 **Python 服务侧车**（`openworker-server`，FastAPI + uvicorn）。壳一死服务就自杀（`COWORKER_EXIT_WITH_PARENT`，还要处理 PyInstaller 打包时服务是孙进程的 PID 坑，`server/run.py`）。
- **界面/终端/Slack 都是同一条事件流的消费者**——所以"桌面看得见、Slack 也答得上、无人值守还在跑"是同一个会话。
- **本地优先**：模型密钥、连接器令牌全在本地密钥库（`secrets.py`）；不登录也能用（手填凭证）。每次启动生成一次性 token 鉴权。
- 另配 **Rust STT** 侧车做语音输入、自动更新。

你不一定要 Tauri——一个 FastAPI 服务 + 一个 Web 界面（消费事件流）就够跑通全部十一步。桌面壳只是"本地优先"体验的糖衣。

---

<h2 id="appendix">附录</h2>

### 附一 · 交付前的机检清单（验收断言汇总）

把散在各步的断言收成一张"做完了没"的清单：

1. 无视觉模型下，发送 payload 里图变占位符，但持久历史里图还在（第 1 步）。
2. 3 只读 + 1 写，只读并发、写在其后单独跑（第 3 步）。
3. 回合发 2 个 tool_call，在第 1 个时中断，第 2 个也补了错误结果，无孤儿（第 4 步）。
4. `interactive` 下写 `/etc/` 被拒；`ls && curl` 因含 `&&` 不走白名单（第 5 步）。
5. unattended 下 send_slack 生成 pending 审批项，回合挂起；异界面 resolve 后回合继续（第 6 步）。
6. 挂起回合丢弃引擎后 `resume()` 继续，tool_call 只执行一次（第 7 步）。
7. 同一 `(session_id, tool_call_id)` 二次 `add` 收件箱项返回同一项（幂等，第 6/7 步）。
8. 调度器：一个卡在审批的自动化不阻塞其他到期任务（第 11 步）。

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

1. **历史和某家 provider 格式绑死** → 换模型/加本地 Ollama 就重写。存规范形状，发送时转（第 1 步）。
2. **能力适配写进持久历史** → 换模型后旧附件降级状态错乱。适配只改发送副本（第 1 步）。
3. **所有工具一个审批策略** → 要么烦死要么危险。让工具自带风险标签（第 2 步）。
4. **写工具也并发** → 竞态。只并发低危、且不需审批的（第 3 步）。
5. **中断只 break 循环** → 留下孤儿 tool_call，托管模板拒收、恢复重弹。每个待跑工具补错误结果（第 4 步）。
6. **shell 白名单用前缀匹配** → `git status && rm -rf ~` 被放行。含操作符即拒 + argv 精确前缀（第 5 步）。
7. **审批只会弹窗死等** → 无人值守直接卡死。改成收件箱挂起（第 6 步）。
8. **收件箱项不幂等** → 重启恢复重弹、动作双跑。键用 `(session_id, tool_call_id)`（第 6/7 步）。
9. **调度器 await 每个任务** → 一个卡在审批的自动化堵住全部。只 spawn 不 await（第 11 步）。
10. **第三方 persona 装了就启用** → 未授权能力直接生效。默认禁用、待同意（第 9 步）。

### 附三 · 源码对照索引（回 `参考项目/openworker/coworker/` 核对）

| 步 | 零件 | 源码 |
|---|---|---|
| 1 | provider 无关大脑 / 能力适配 | `engine.py:878`（`_outbound_messages`）、`agent.py:214`（Router）、`engine.py:180`（换模型） |
| 2 | 工具 + 风险元数据 | `tools/subagent.py:129`（`ai.tool`/`ToolMetadata`）、`risk.py`（classify） |
| 3 | 异步主循环 + 并发/串行 | `engine.py:294`（`_loop`）、`:437`（`_handle_tool_calls`）、`:517`（`_parallel_safe`）、`:388`（`_astream`） |
| 4 | 任意状态可中断不留孤儿 | `engine.py:120`（`request_interrupt`）、`:504`（`_interrupted_tool`） |
| 5 | 权限闸门 | `permissions.py:120`（evaluate）、`:204`（可写根）、`:216`（shell 防注入）、`:37`（五档模式） |
| 6 | 带外审批 + 收件箱 | `engine.py:526`（`_authorize`）、`inbox.py:295`（状态机）、`:348`（`inbox_approver`）、`:133`（幂等） |
| 7 | 持久恢复 | `engine.py:250`（`resume`）、`:268`（未答 tool_call）、`inbox.py:133` |
| 8 | SQLite 记忆 | `memory/sqlite_store.py`、`agent.py:62`（何时记引导） |
| 9 | persona / 家族 | `agents/base.py:28`、`agent.py:109`（装配）、`personas/registry.py:340`（同意闸门） |
| 10 | 连接器 + @提及 | `connectors/`、`mentions.py`（thread_target 一串三用） |
| 11 | 调度器 + self-wake | `automation/scheduler.py:23`、`selfwake.py` |
| 产品 | 桌面壳 + 侧车 | `server/run.py`、`secrets.py`、`surfaces/` |

---

## 收束

你现在拥有的，不是"又一个 agent 循环"，而是一台**能把回合安全挂起、等人、扛重启、按点自己上班、动手前先问、还绝不越权开 shell** 的执行系统。把它接上你的 Slack 和日历，它就是一个数字同事；把 persona 换成 code 家族，它又能改代码。

**核心不是模型有多强，而是围绕模型的那一圈系统——收件箱、持久恢复、带外审批、§25 分级——把"一个会调工具的模型"变成了"一个你敢让它无人值守的同事"。** 这，就是 OpenWorker 想证明的事，也是这份教程想交到你手里的东西。

> 想读它的完整源码剖析，见 `项目分析/openworker-源码分析.md`；想看它和另外八家编程 Agent 的横向对决，见 `项目分析/横向对比与总结评价.md`。
