# OpenManus 源码分析：一台"无需邀请码"的通用调研 Agent 平替

> **分析对象**：[FoundationAgents/OpenManus](https://github.com/FoundationAgents/OpenManus)
> **基于 commit**：`52a13f2a57d8c7f6737eefb02ccf569594d44273`（2026-01-04，`Update README.md`；`setup.py:9` 版本号 `0.1.0`）
> **分析日期**：2026-07-24
> **文档版本**：**加深版**（在既有 12 章结构上显著加厚第 5/6/7/9 章；补「与 Kortix/Suna 对照」短注；主张均可回溯 `文件:行号`）
> **读者设定**：有一点编程基础的初学者。每章尽量遵循「比喻 → 真实源码 → 逐行解释 → 设计取舍」的节奏。
> **说明**：本文与同目录下《Claude Code / opencode / hermes-agent / Raven / CodeWhale / nanobot / goose / Suna 源码分析》采用同一套 12 章结构，方便横向对比。文中所有 `文件:行号` 均相对 `参考项目/OpenManus/` 目录，可在该 commit 下直接核对；凡是找不到 / 不确定的功能，都会明确写出，绝不编造。横向选型另见同目录《OpenManus-vs-Suna-横向对比》。
> **产品轴心**：OpenManus 对标的是商业产品 **Manus**（通用任务 / 调研 / 交付物 Agent），不是 Claude Code 类"写代码优先"的编程 Agent——所以浏览器、搜索、规划流、HTML/图表交付物会比 grep/diff 工具箱更显眼。

---

> 🗺️ **配套架构图**：本项目在[**七大 Agent 架构图库**](../架构图库.html#ch7)里有一张专门的图——**继承链作为能力偏序 + 双重终止防线**。
> 图库的每张图都先写清「回答什么问题」和「承重墙论点」，并经三轮审阅与渲染验收。

## 第 1 章 项目概览：它是什么，谁在做

### 一句话版本

**OpenManus 是 MetaGPT 团队成员开源的"Manus 平替"**：一个用 Python 写的、无需邀请码即可本地跑的通用 AI Agent，MIT 协议（`LICENSE:1-3`，版权归 "manna_and_poem"）。README 第一句的原话是（`README.md:16`）：

> Manus is incredible, but OpenManus can achieve any idea without an *Invite Code* 🛫!

中文 README 同义（`README_zh.md:16`）：「Manus 非常棒，但 OpenManus 无需邀请码即可实现任何创意」。

### 用比喻理解定位

如果说 Claude Code 是"精装寿司店"（菜单围绕写代码打磨到极致），Genspark / 商业 Manus 是"全能旅行社 + 调研社"（搜网页、写 itinerary、交 HTML 手册），那 OpenManus 更像一家**三小时搭起来、但厨房已经齐活的路边摊版全能旅行社**：

- 柜台极简：终端里 `python main.py`，敲一句话就开始干；
- 后厨是经典 **ReAct**（想一步 → 动手一步 → 观察 → 再想）；
- 工具箱偏"调研与交付"——浏览器、多引擎搜索、Python 执行、文件编辑、问人、图表可视化——而不是编程 Agent 那种 `grep` / `apply_patch` / LSP 全家桶。

官方 use case 直接复用了 Manus 的日本旅行提示词，要求输出「detailed itinerary + simple HTML travel handbook」（`examples/use_case/readme.md:8-11`）——这把产品轴心钉死了：**长任务调研 + 可交付物**，不是"在仓库里改 bug"。

### 谁在做

- **团队血统**：核心作者 [@Xinbin Liang](https://github.com/mannaandpoem)（mannaandpoem）与 [@Jinyu Xiang](https://github.com/XiangJinyu)，以及 [@Zhaoyang Yu](https://github.com/MoshiQAQ)、[@Jiayi Zhang](https://github.com/didiforgithub)、[@Sirui Hong](https://github.com/stellaHSR)，来自 **[@MetaGPT](https://github.com/geekan/MetaGPT)**（`README.md:18`；`README_zh.md:18-19`）。
- **开发节奏**：README 自称「原型在 3 小时内上线并持续迭代」（`README.md:18`；`README_zh.md:18-19`）——解释了为什么架构读起来"教科书般干净"：继承链短、入口脚本多、没有重型调度脊骨。
- **姊妹项目**：[OpenManus-RL](https://github.com/OpenManus/OpenManus-RL) 专注用 RL（如 GRPO）调 LLM Agent，由 UIUC 与 OpenManus 合作（`README.md:24`）。
- **组织名**：GitHub 组织是 **FoundationAgents**（仓库 URL `FoundationAgents/OpenManus`，`setup.py:15`）。
- **License**：MIT（`LICENSE:1`；badge 见 `README.md:9`）。
- **包元数据**：`setup.py:7-12` 包名 `openmanus`，版本 `0.1.0`，描述 "A versatile agent that can solve various tasks using multiple tools"。

### 与 Manus / Genspark / 编程 Agent 的差异（一句话级）

| 对比对象 | 相同点 | 关键差异 |
|---|---|---|
| 商业 Manus | 通用任务、浏览器调研、交付物（行程/HTML） | OpenManus 开源可自托管；无邀请码；能力与打磨度是"平替原型"而非商业成品 |
| Genspark 类 | 搜索 + 综合调研 | OpenManus 把搜索嵌进 `browser_use` 动作链，并另有独立 `WebSearch` 工具与 PlanningFlow |
| Claude Code / 编程 Agent | 都有 Agent 循环 + 工具调用 + 文件编辑 | 编程 Agent 工具箱是代码导航/补丁/测试；OpenManus 工具箱是浏览器/搜索/Python/可视化，`SWEAgent` 只是可选旁支（`app/agent/swe.py`） |

### 技术栈速览

| 层 | 技术 | 位置 / 证据 |
|---|---|---|
| 运行时语言 | Python ≥ 3.12，asyncio | `setup.py:43`；`requirements.txt`；`main.py:36` |
| 配置 | TOML（`tomllib`）+ Pydantic + 单例 `Config` | `app/config.py:197-372` |
| LLM SDK | **直接用 `openai` AsyncOpenAI / AsyncAzureOpenAI**；另有 AWS Bedrock 适配 | `app/llm.py:5-12,216-225`；`app/bedrock.py` |
| 重试 | `tenacity`（最多 6 次、指数退避） | `app/llm.py:14-19,354-360` |
| Token 计数 | `tiktoken` + 自研 `TokenCounter`（含图片 tile 估算） | `app/llm.py:45-171`；`requirements.txt:9` |
| 浏览器 | `browser-use` + Playwright | `requirements.txt:17,25`；`app/tool/browser_use_tool.py:6-9` |
| 搜索 | Google / Baidu / DuckDuckGo / Bing 四引擎 + 级联回退 | `app/tool/web_search.py:193-198`；`app/tool/search/` |
| 沙箱 | Docker 本地沙箱 + Daytona 云沙箱（SandboxManus） | `app/sandbox/`；`app/daytona/`；`sandbox_main.py` |
| MCP | 官方 `mcp` SDK（客户端 + FastMCP 服务端） | `requirements.txt:31`；`app/tool/mcp.py`；`app/mcp/server.py` |
| 协议实验 | A2A（Agent-to-Agent）薄封装 | `protocol/a2a/app/agent.py` |
| 可视化 | chart_visualization（Python + 前端渲染，HTML/png） | `app/tool/chart_visualization/` |

```mermaid
flowchart LR
    subgraph OM["OpenManus（本项目）"]
        E1["main / run_flow / run_mcp / sandbox_main"] --> A["Agent 继承链<br/>Base→ReAct→ToolCall→Manus"]
        A --> L["LLM 单例<br/>ask / ask_tool / ask_with_images"]
        A --> T["工具箱<br/>browser / python / editor / search / MCP"]
        E1 --> F["PlanningFlow<br/>长任务规划+多 Agent"]
        T --> S["Docker / Daytona 沙箱"]
    end
    style OM fill:#e8f5e9
```

### 与 Kortix/Suna 对照（短注）

同轴心（通用任务 / 调研 / 交付物），但赌注不同：OpenManus 赌「三小时能看懂的 ReAct 继承链」；Kortix/Suna 赌「公司仓库 + 一会话一沙箱 + change request」。许可上 OpenManus 是 MIT，Kortix 是 Elastic License 2.0。细节见《OpenManus-vs-Suna-横向对比》。

---

## 第 2 章 全景架构：一张分层地图

### 比喻：一条"四层楼梯"的继承链 + 几扇不同的大门

OpenManus 不像 Raven/nanobot 那样有 MessageBus / Spine 调度中枢。它的骨架是**教科书式的 Agent 继承链**，外面挂着**多个入口脚本**当不同大门：

- **大门**：`main.py`（通用 Manus）、`run_flow.py`（规划多 Agent）、`run_mcp.py`（纯 MCP 客户端）、`sandbox_main.py`（Daytona 沙箱版）、`run_mcp_server.py`（把自己当 MCP 服务端暴露出去）；
- **楼梯**：`BaseAgent` → `ReActAgent` → `ToolCallAgent` → 具体 Agent（`Manus` / `BrowserAgent` / `MCPAgent` / `DataAnalysis` / `SWEAgent` / `SandboxManus`）；
- **厨房**：`app/tool/` 工具集合；
- **仓库**：`Memory`（内存消息列表，不是 EverOS 那种长期记忆库）。

```mermaid
flowchart TB
    subgraph ENTRY["入口层（多扇门）"]
        M["main.py → Manus.create()"]
        RF["run_flow.py → PlanningFlow"]
        RM["run_mcp.py → MCPAgent"]
        SM["sandbox_main.py → SandboxManus"]
        RS["run_mcp_server.py → MCPServer"]
        A2A["protocol/a2a → A2AManus"]
    end

    subgraph AGENT["Agent 继承楼梯"]
        B["BaseAgent<br/>run 循环 / 状态机 / stuck 检测"]
        R["ReActAgent<br/>think + act = step"]
        TC["ToolCallAgent<br/>ask_tool + 执行工具"]
        MAN["Manus / Browser / MCP / DataAnalysis / SWE / SandboxManus"]
        B --> R --> TC --> MAN
    end

    subgraph FLOW["规划编排层"]
        FF["FlowFactory"]
        PF["PlanningFlow<br/>create plan → 逐步派发 → finalize"]
        PT["PlanningTool<br/>内存中的计划字典"]
        FF --> PF --> PT
    end

    subgraph TOOL["工具层 app/tool/"]
        BU["browser_use"]
        PY["python_execute"]
        ED["str_replace_editor"]
        WS["web_search"]
        MCP["mcp clients"]
        CV["chart_visualization"]
        TERM["terminate / ask_human"]
    end

    subgraph INFRA["基础设施"]
        LLM["app/llm.py LLM 单例"]
        CFG["app/config.py"]
        SBX["app/sandbox Docker"]
        DAY["app/daytona"]
        SCH["app/schema Message/Memory/AgentState"]
    end

    M & RF & RM & SM & A2A --> AGENT
    RF --> FLOW
    MAN --> TOOL
    MAN --> LLM
    TOOL --> SBX & DAY
    AGENT --> SCH
    ENTRY --> CFG
```

### `app/` 目录职责（逐一标注）

| 目录 / 文件 | 职责 | 关键文件 |
|---|---|---|
| `app/agent/` | Agent 继承体系与各特化 Agent | `base.py`、`react.py`、`toolcall.py`、`manus.py`、`browser.py`、`mcp.py`、`data_analysis.py`、`sandbox_agent.py`、`swe.py` |
| `app/flow/` | 多 Agent 规划流 | `base.py`、`planning.py`、`flow_factory.py` |
| `app/tool/` | 全部工具实现 | `browser_use_tool.py`、`web_search.py`、`python_execute.py`、`str_replace_editor.py`、`planning.py`、`mcp.py`、`sandbox/`、`chart_visualization/` |
| `app/prompt/` | 各 Agent 的 system / next_step 提示词 | `manus.py`、`browser.py`、`planning.py`、`mcp.py`、`visualization.py`、`swe.py` |
| `app/llm.py` | LLM 封装（ask / ask_tool / 多模态 / token 限额） | 766 行 |
| `app/config.py` | 全局配置单例 | LLM / browser / search / sandbox / MCP / runflow / daytona |
| `app/schema.py` | Message、Memory、AgentState、ToolCall | — |
| `app/sandbox/` | Docker 沙箱核心 | `core/sandbox.py`、`core/manager.py`、`client.py` |
| `app/daytona/` | Daytona 云沙箱 | `sandbox.py`、`tool_base.py` |
| `app/mcp/` | 把本仓库工具暴露为 MCP Server | `server.py` |
| `app/bedrock.py` | AWS Bedrock 客户端适配 | — |
| `protocol/a2a/` | Agent-to-Agent 协议薄封装 | `app/agent.py`、`app/main.py` |

`app/agent/__init__.py:9-16` 导出的公开 Agent 是：`BaseAgent`、`BrowserAgent`、`ReActAgent`、`SWEAgent`、`ToolCallAgent`、`MCPAgent`——注意 **`Manus` / `DataAnalysis` / `SandboxManus` 没有出现在 `__all__` 里**，但入口脚本直接从各自模块 import。

### Agent 继承体系（理解全项目的钥匙）

```text
BaseAgent          # 状态、记忆、max_steps 主循环、stuck 检测
  └─ ReActAgent    # 把 step() 拆成 think() + act()
       └─ ToolCallAgent  # think=ask_tool；act=执行 tool_calls
            ├─ Manus           # 通用：Python+Browser+Editor+AskHuman+MCP
            ├─ BrowserAgent    # 浏览器专用
            ├─ MCPAgent        # 纯 MCP 工具
            ├─ DataAnalysis    # 数据分析/图表/报告
            ├─ SWEAgent        # Bash+Editor 编程旁支
            └─ SandboxManus    # Daytona 沙箱版工具
```

证据：

- `BaseAgent` 定义 `run` / `step` / `is_stuck`（`app/agent/base.py:13-186`）
- `ReActAgent.step` 调用 `think` 再 `act`（`app/agent/react.py:33-38`）
- `ToolCallAgent` 实现完整 tool-calling（`app/agent/toolcall.py:18-250`）
- `Manus(ToolCallAgent)`（`app/agent/manus.py:18`）

### 三个"分层纪律"

1. **能力靠换 Agent / 换工具箱，不靠换调度框架**。通用任务用 `Manus`，数据分析换 `DataAnalysis`，沙箱换 `SandboxManus`——主循环始终是 `BaseAgent.run`。
2. **长任务规划是 Flow，不是 Agent**。单 Agent 靠 `next_step_prompt` 让模型自己拆步；跨 Agent 长任务走 `PlanningFlow`（`app/flow/planning.py`），计划存在 `PlanningTool.plans` 字典里。
3. **配置单例 + LLM 单例**。`config = Config()`（`app/config.py:372`）线程锁双检；`LLM._instances` 按 `config_name` 缓存（`app/llm.py:175-184`）。同一进程里同名 LLM 只初始化一次。

### 进程 / 线程模型

- **单进程 asyncio**：所有入口都是 `asyncio.run(...)`（`main.py:36`、`run_flow.py:52`、`run_mcp.py:116`）。
- **Python 执行用子进程**：`PythonExecute` 用 `multiprocessing.Process` + 超时 `terminate`（`app/tool/python_execute.py:55-74`）。
- **浏览器是长生命周期对象**：`BrowserUseTool` 持有 `browser` / `context`，跨 tool call 复用，直到 `cleanup`（`app/tool/browser_use_tool.py:124-127`）。
- **Docker / Daytona 沙箱**：本地 Docker 容器或远程 Daytona sandbox，按配置启用。

### 设计取舍

- **没有消息总线 / 会话持久化层**：一次 `run(prompt)` 从 IDLE 跑到结束，历史只活在内存 `Memory.messages` 里。对比 nanobot/Raven 的 session JSONL + 崩溃恢复，OpenManus 更像"一次性作业脚本"。
- **多入口而非统一 CLI**：没有 typer 命令树，而是多个顶层 `.py` 脚本。好处是每个入口读得懂；代价是用户要记住该开哪扇门。

### 与 Kortix/Suna 对照（短注）

OpenManus 是「进程内楼梯」；Kortix 是「API session-lifecycle → 沙箱 → OpenCode」。前者适合读懂主循环，后者适合多租户生产隔离。

---

## 第 3 章 启动流程：从命令到界面

### 比喻：四扇门进同一栋楼

OpenManus 没有"一种启动方式打天下"。你选哪扇门，决定了进楼后见到的 Agent 品种。真正的"界面"几乎就是终端 `input()`——**未找到** WebUI / TUI 主路径（Hugging Face Demo 是外部部署，不在本仓库运行时里）。

### 门 1：`main.py` —— 默认通用 Manus

```python
# main.py:8-36 精简逻辑
agent = await Manus.create()
prompt = args.prompt if args.prompt else input("Enter your prompt: ")
await agent.run(prompt)
await agent.cleanup()
```

关键步骤：

1. `argparse` 可选 `--prompt`（`main.py:10-14`）；没有就交互输入（`main.py:20`）。
2. **`Manus.create()` 工厂**（`app/agent/manus.py:59-65`）：构造实例 → `initialize_mcp_servers()` 连接 `config/mcp.json` 里配置的 MCP → 标记 `_initialized=True`。
3. 空 prompt 直接 warning 返回（`main.py:21-23`）。
4. `KeyboardInterrupt` 捕获后 warning（`main.py:28-29`）；`finally` 必调 `cleanup()`（`main.py:30-32`）。
5. `setup.py:44-47` 还注册了 console script `openmanus=main:main`，但 `main` 是 async 函数——**实际能否被 setuptools 直接当同步入口调用存在疑问**（`main.py:8` 是 `async def main`）；日常文档推荐的是 `python main.py`（`README.md:123-124`）。

### 门 2：`run_flow.py` —— 不稳定的多 Agent 规划流

README 明确写 "unstable multi-agent version"（`README.md:134-137`）。流程：

1. 组装 `agents = {"manus": Manus()}`（`run_flow.py:12-14`）——注意这里用的是 **`Manus()` 直接构造，不是 `Manus.create()`**，因此这条路径**默认不会走 MCP 初始化工厂**（除非后续 `think` 里懒加载，见 `manus.py:142-144`）。
2. 若 `config.run_flow_config.use_data_analysis_agent` 为真，再挂上 `DataAnalysis()`（`run_flow.py:15-16`；配置项 `config.example.toml:112-113`）。
3. `FlowFactory.create_flow(FlowType.PLANNING, agents=...)`（`run_flow.py:24-27`；工厂仅支持 `PLANNING` 一种，`app/flow/flow_factory.py:9-30`）。
4. **整次执行外层超时 3600 秒**（`run_flow.py:32-35`），超时打印友好提示（`run_flow.py:39-43`）。

### 门 3：`run_mcp.py` —— 纯 MCP Agent

`MCPRunner`（`run_mcp.py:11-66`）封装三种运行模式：

| 模式 | 触发 | 行为 |
|---|---|---|
| 单次 prompt | `--prompt` / `-p` | `run_single_prompt` |
| 交互循环 | `--interactive` / `-i` | 循环 `input`，`exit/quit/q` 退出 |
| 默认 | 无上述参数 | 一次 `input` 后 `run` |

连接类型：

- **stdio（默认）**：用当前 Python 解释器跑 `-m {server_reference}`，默认模块 `app.mcp.server`（`run_mcp.py:27-32`；`app/config.py:141-143`）。
- **sse**：连 `--server-url`（默认 `http://127.0.0.1:8000/sse`，`run_mcp.py:79-83`）。

### 门 4：`sandbox_main.py` —— Daytona 沙箱版

结构几乎与 `main.py` 镜像，只是 Agent 换成 `SandboxManus.create()`（`sandbox_main.py:17`）。`create` 会同时初始化 MCP 与 Daytona 沙箱工具（`app/agent/sandbox_agent.py:63-70`）。

### 门 5：`run_mcp_server.py` —— 反向：自己当 MCP 服务

极短入口（`run_mcp_server.py:1-11`）：解析 `--transport`（目前 choices 只有 `stdio`，`app/mcp/server.py:166-171`）→ `MCPServer().run(transport=...)`。服务端注册 bash / browser / editor / terminate 四件套（`app/mcp/server.py:31-35`）。

### 配置加载时序（任何门都会先踩到）

1. 任意 `from app.config import config` 触发单例 `__init__`（`app/config.py:209-215`）。
2. `_get_config_path`：优先 `config/config.toml`，否则回退 `config.example.toml`（`app/config.py:218-226`）。
3. 解析 LLM / browser / search / sandbox / mcp / runflow / daytona（`app/config.py:233-329`）。
4. MCP servers 额外从 `config/mcp.json` 的 `mcpServers` 字段加载（`app/config.py:149-169`）。

```mermaid
sequenceDiagram
    participant U as 用户
    participant Main as main.py
    participant Manus as Manus.create()
    participant MCP as MCP servers
    participant Run as BaseAgent.run

    U->>Main: python main.py [--prompt ...]
    Main->>Manus: await Manus.create()
    Manus->>MCP: initialize_mcp_servers()
    MCP-->>Manus: 工具并入 available_tools
    Main->>U: input("Enter your prompt: ")（若无 --prompt）
    Main->>Run: await agent.run(prompt)
    Run-->>Main: 逐步结果字符串
    Main->>Manus: await cleanup()
```

### 设计取舍

- **多入口脚本 vs 统一 CLI**：学习成本略高，但每条路径零魔法。
- **`run_flow` 的 Manus() 与 `main` 的 Manus.create()**：两条路径初始化不一致——flow 侧依赖 `think` 里的懒初始化（`manus.py:142-144`），属于"能跑但容易踩坑"的细节。

### 与 Kortix/Suna 对照（短注）

OpenManus 启动即终端 `input()`；Kortix 经 `kortix init/ship` 与 Web/CLI/Slack 多渠道进会话。冷启动重量差一个数量级。

---

## 第 4 章 输入捕获与分流：一句话的旅程

### 比喻：没有前台分机，只有"把纸条直接塞给办事员"

nanobot/Raven 有 Channel → Bus → Lane 的分流；OpenManus **几乎没有分流层**。用户输入就是一根字符串，直接变成 `Memory` 里的一条 user 消息。

### 主路径（`main.py`）

1. 捕获：`args.prompt` 或 `input("Enter your prompt: ")`（`main.py:20`）。
2. 空串检查：`if not prompt.strip()` → return（`main.py:21-23`）。
3. 进入 `agent.run(prompt)`（`main.py:26`）。
4. `BaseAgent.run`：若有 `request`，`update_memory("user", request)`（`app/agent/base.py:131-132`）。
5. `update_memory` 通过 `message_map` 把 role 映射到 `Message.user_message` 等工厂（`app/agent/base.py:102-114`）。

### Flow 路径的"二次包装"

`PlanningFlow` 不会把用户原话原样交给执行 Agent，而是：

1. 先用 LLM + `PlanningTool` 生成计划（`app/flow/planning.py:136-211`）；
2. 每一步构造 `step_prompt`，把「当前计划状态 + 你只负责这一步」塞给 `executor.run(step_prompt)`（`app/flow/planning.py:277-296`）。

也就是说：**用户一句话 → 计划文本 → 多个被重写过的子任务提示**。这是 OpenManus 做"长任务分流"的真正位置——分流发生在 Flow，不在 Channel。

### MCP 交互路径

`run_interactive` 每轮 `input` → `agent.run(user_input)` → `print` 响应（`run_mcp.py:38-46`）。退出词：`exit` / `quit` / `q`（`run_mcp.py:43-44`）。

### AskHuman：循环中的人机回流

工具 `ask_human` 在执行时再次 `input(f"Bot: {inquire}\n\nYou: ")`（`app/tool/ask_human.py:20-21`）。这是**主循环内部的同步阻塞输入**——模型觉得需要人时，终端再次变成对话框。`Manus` 默认工具箱包含它（`app/agent/manus.py:39`）。

### 未找到的分流能力

- **未找到** IM 平台适配（Telegram/飞书等）。
- **未找到** 斜杠命令路由（`/stop`、`/model` 之类）。
- **未找到** 多会话并发调度；`BaseAgent.run` 要求起始状态必须是 `IDLE`，否则 `RuntimeError`（`app/agent/base.py:128-129`）。

### 与 Kortix/Suna 对照（短注）

OpenManus 几乎无分流层；Kortix 有渠道绑定与 session 生命周期。一句话进 Agent——两边都有；「昨天 Slack 里那单」只有 Kortix 能直接续。

---

## 第 5 章 上下文组装：给模型打包

### 比喻：每次思考前，把「岗位手册 + 工作笔记 + 本轮提醒」订成一叠纸

OpenManus 没有独立的 `ContextBuilder` / `ContextGovernor`。组装逻辑分散在三处，外加浏览器情境下的「动态换页」：

1. **System prompt**：Agent 字段 `system_prompt`，在 `think` 时包成 `Message.system_message` 传给 `ask_tool`（`app/agent/toolcall.py:47-53`）。
2. **历史**：`self.messages` 即 `self.memory.messages`（`app/agent/base.py:188-196`）。
3. **Next-step 提醒**：每轮 `think` 开头，若存在 `next_step_prompt`，先 `Message.user_message(self.next_step_prompt)` **追加进 messages**（`app/agent/toolcall.py:41-43`）。
4. **浏览器动态页**：`Manus.think` / `BrowserAgent.think` 临时改写 `next_step_prompt`，并可把截图塞进 Memory（见 5.3）。

把这四层叠起来，就是模型「这一轮看到的世界」。

### 5.1 Message / Memory：纸张与笔记本

#### Message 字段（`app/schema.py:54-62`）

| 字段 | 用途 |
|---|---|
| `role` | `system` / `user` / `assistant` / `tool`（`Role` 枚举，`schema.py:7-14`） |
| `content` | 文本正文 |
| `tool_calls` | assistant 发起的工具调用列表 |
| `name` / `tool_call_id` | tool 回执用 |
| `base64_image` | 多模态图片（浏览器截图等） |

工厂方法：`user_message` / `system_message` / `assistant_message` / `tool_message` / `from_tool_calls`（`schema.py:99-156`）。`from_tool_calls` 会把 LLM 返回的 raw tool_calls 格式化成 `{"id","function","type":"function"}` 再写入 assistant 消息（`schema.py:146-155`）。

`Message` 还重载了 `+` / `__radd__`，支持 `Message + list` 或 `list + Message`（`schema.py:64-82`）——这就是 `toolcall.py:43` 写 `self.messages += [user_msg]` 能工作的原因：`messages` 属性返回 list，setter 写回 `memory.messages`（`base.py:188-196`）。

#### Memory：滑动窗口笔记本（`app/schema.py:159-187`）

```python
# schema.py:163-168 精简
def add_message(self, message: Message) -> None:
    self.messages.append(message)
    if len(self.messages) > self.max_messages:
        self.messages = self.messages[-self.max_messages :]
```

- 默认 `max_messages=100`（`schema.py:161`）。
- 超限只保留**最后 N 条**——不是摘要，是硬截断。
- 另有 `add_messages` / `clear` / `get_recent_messages` / `to_dict_list`（`schema.py:170-187`）。

`BaseAgent.update_memory`（`base.py:84-114`）是往笔记本写字的统一入口：按 role 选工厂，tool 角色额外透传 `tool_call_id` 等 kwargs。

### 5.2 System prompt 与 next_step_prompt：岗位手册 vs 本轮便利贴

#### Manus 的岗位手册（`app/prompt/manus.py:1-4`）

> You are OpenManus, an all-capable AI assistant... Whether it's programming, information retrieval, file processing, web browsing, or human interaction (only for extreme cases)... The initial directory is: {directory}

`directory` 在类字段初始化时用 `config.workspace_root` format（`app/agent/manus.py:24`；`app/config.py:16,362-364` → 项目下 `workspace/`）。

`NEXT_STEP_PROMPT`（`app/prompt/manus.py:6-10`）要求：主动选工具、复杂任务分步、用完工具后解释结果，想结束就调 `terminate`。

#### ToolCallAgent 默认提示（更薄）

`app/prompt/toolcall.py` 只有 5 行：极简 SYSTEM / NEXT_STEP。具体 Agent 都会覆盖成自己的 prompt。

#### 关键机制：system 通常不进 Memory

`ToolCallAgent.think` 把 system 作为 `ask_tool(..., system_msgs=[...])` 参数传入（`toolcall.py:49-52`），**不** `add_message` 进 memory。下一轮再注入一次。例外是 `MCPAgent.initialize`：会把 system + 工具名列表写进 memory（`app/agent/mcp.py:81-85`）。

#### 关键机制：next_step **每轮追加为 user 消息**

```python
# toolcall.py:41-43
if self.next_step_prompt:
    user_msg = Message.user_message(self.next_step_prompt)
    self.messages += [user_msg]
```

这意味着：跑 20 步，历史里可能出现近 20 条几乎相同的「便利贴」。它们会：

1. 吃掉 `Memory.max_messages=100` 的配额；
2. 计入 `ask_tool` 的 token 累计；
3. 在浏览器模式下被临时换成更长的 `BrowserContextHelper` 版本（见下）。

### 5.3 BrowserContextHelper：把「眼睛看到的页面」塞回提示词

文件：`app/agent/browser.py:19-84`。

#### 初始化挂载

- `Manus` 在 `model_validator` 里创建 helper（`manus.py:53-57`）。
- `BrowserAgent` 同理（`browser.py:115-118`）。

#### `get_browser_state`（`browser.py:24-45`）

1. 先找 `BrowserUseTool`，找不到再找 `SandboxBrowserTool`（`browser.py:25-29`）。
2. 调 `browser_tool.get_current_state()`（`browser_use_tool.py:479-537`）。
3. 若有 `base64_image`，暂存在 helper 的 `_current_base64_image`（`browser.py:38-41`）。
4. `json.loads(result.output)` 得到状态 dict（`browser.py:42`）。

`get_current_state` 实际打包的字段（`browser_use_tool.py:514-532`）：

| 键 | 含义 |
|---|---|
| `url` / `title` | 当前页 |
| `tabs` | 标签页列表 |
| `interactive_elements` | 可点击元素树字符串 |
| `scroll_info.pixels_above/below` | 视口上下还有多少像素 |
| `viewport_height` | 视口高度 |
| （另附）`base64_image` | **整页 JPEG 截图**（`full_page=True`，`browser_use_tool.py:507-511`） |

#### `format_next_step_prompt`（`browser.py:47-79`）

1. 拉取 browser_state，拼出 URL/标题、tab 数量、上下滚动像素说明（`browser.py:53-63`）。
2. **若有截图**：构造 `Message.user_message(content="Current browser screenshot:", base64_image=...)` 并 `memory.add_message`（`browser.py:65-71`），然后清空 `_current_base64_image`。
3. 用 `app/prompt/browser.py` 的 `NEXT_STEP_PROMPT` 做 `.format(...)`（`browser.py:73-79`），填入 `url_placeholder` / `tabs_placeholder` / `content_above_placeholder` / `content_below_placeholder` / `results_placeholder`。

浏览器专用 `NEXT_STEP_PROMPT`（`app/prompt/browser.py:73-94`）不仅提醒当前状态，还**直接教模型怎么调 `browser_use` 的 action**（go_to_url / click_element / input_text / extract_content / scroll），并再次强调用 `terminate` 结束。

浏览器 `SYSTEM_PROMPT`（`app/prompt/browser.py:1-71`）极长：规定 JSON 响应格式、元素索引交互、done 规则、视觉上下文——明显借鉴 browser-use 生态的 agent 规范。注意：`BrowserAgent` 虽挂了这段 system，但实际工具循环走的是 OpenAI tool-calling，不是那段 JSON action list 协议；system 更像「行为规范说明书」，真正动手靠 `browser_use` 工具 schema。

### 5.4 Manus.think：条件换页，用完还原

`app/agent/manus.py:140-165` 是上下文组装的一个亮点：

```python
# manus.py:140-165 逻辑摘要
if not self._initialized:
    await self.initialize_mcp_servers()
    self._initialized = True

original_prompt = self.next_step_prompt
recent_messages = self.memory.messages[-3:] if self.memory.messages else []
browser_in_use = any(
    tc.function.name == BrowserUseTool().name
    for msg in recent_messages
    if msg.tool_calls
    for tc in msg.tool_calls
)

if browser_in_use:
    self.next_step_prompt = await self.browser_context_helper.format_next_step_prompt()

result = await super().think()
self.next_step_prompt = original_prompt  # 恢复
return result
```

逐点：

1. **懒初始化 MCP**：`run_flow` 路径用 `Manus()` 而非 `create()` 时，第一次 think 才连 MCP（`manus.py:142-144`）。
2. **浏览器在用判定**：只看**最近 3 条**消息里的 tool_calls 是否含 `browser_use`（`manus.py:147-153`）。中间夹了 ask_human / python_execute 可能让判定变 false，从而暂时退出浏览器提示模式。
3. **临时换 prompt → super().think() → 恢复**（`manus.py:155-163`）：避免浏览器长提示污染后续非浏览器步骤。
4. `BrowserAgent` 则每轮都换（`browser.py:120-125`），不做还原——因为它本来就是浏览器专员。

### 5.5 LLM 侧的最终打包：`ask_tool`

`LLM.ask_tool`（`app/llm.py:644-747`）是「订书钉」：

1. 校验 `tool_choice` ∈ `TOOL_CHOICE_VALUES`（`llm.py:677-678`；枚举 `schema.py:20-25`）。
2. `format_messages`：`Message` → OpenAI dict；若模型在 `MULTIMODAL_MODELS` 且消息带 `base64_image`，转成 `image_url` content part（`llm.py:267-352,680-688`）。不在白名单则丢掉图片字段（`llm.py:337-339`）。
3. 统计 messages + tools 的 token（`llm.py:690-699`）。
4. 超 `max_input_tokens` 累计限额则抛 `TokenLimitExceeded`（`llm.py:701-705`；异常 `app/exceptions.py:12-13`）。
5. 组装 `params`：`tools` / `tool_choice` / `timeout=300`（`llm.py:714-720`）。
6. reasoning 模型用 `max_completion_tokens` 且不传 temperature（`llm.py:723-729`）。
7. **强制 `stream=False`**（`llm.py:731`）——工具循环要完整 `tool_calls`。
8. 用响应里的 `usage` 更新累计 token（`llm.py:742-745`）。

`check_token_limit`（`llm.py:249-254`）：仅当配置了 `max_input_tokens` 才生效；未配置则永远放行。

### 5.6 一轮 think 的上下文时间线

```mermaid
sequenceDiagram
    participant M as Manus.think
    participant H as BrowserContextHelper
    participant TC as ToolCallAgent.think
    participant LLM as LLM.ask_tool
    participant Mem as Memory

    M->>M: 检测最近 3 条是否含 browser_use
    alt 浏览器在用
        M->>H: format_next_step_prompt()
        H->>H: get_current_state + 可选截图
        H->>Mem: add_message(截图 user)
        H-->>M: 浏览器版 next_step_prompt
    end
    M->>TC: super().think()
    TC->>Mem: append next_step 为 user 消息
    TC->>LLM: ask_tool(messages, system_msgs, tools)
    LLM-->>TC: content + tool_calls
    TC->>Mem: add assistant / from_tool_calls
    M->>M: 恢复原 next_step_prompt
```

### 5.7 设计取舍

- **next_step 每轮追加为 user 消息**：实现简单，但会让历史里反复出现相似提醒，加速逼近 `Memory.max_messages=100` 的截断线（见第 8 章）。
- **system 与历史分离**：system 每次由 `ask_tool` 注入，不永久存进 memory——但 MCPAgent 例外。
- **截图进 memory**：浏览器闭环需要视觉，但也急剧消耗 token；`max_observe` 截不断图片本身，只能靠模型白名单与累计 token 限额兜底。
- **「最近 3 条」启发式**：轻量，但可能误判「浏览器阶段是否结束」。

> 🧠 **一句话**：模型没有记忆，它每一轮看到的「世界」都是临时叠出来的——系统提示当参数传、历史滚在 Memory 里、便利贴每轮追加、浏览器状态还会动态换页。

### 与 Kortix/Suna 对照（短注）

OpenManus 的上下文是「进程内 Message 列表 + 每轮便利贴」；Kortix/Suna 把长期真相放在公司仓库与 session 沙箱文件里，对话上下文由平台 session + OpenCode 管理。OpenManus 更透明、更易在本文件里走读；Kortix 更适合跨天、跨渠道续聊。

---
## 第 6 章 Agent 主循环：项目的心脏

> 本章是全文最详细的一章。OpenManus 的「心脏」不是某一个 2000 行的 runner，而是 **`BaseAgent.run` + `ReActAgent.step` + `ToolCallAgent.think/act`** 三层叠出来的短循环。下面按文件逐段走读。

### 比喻：计件工人的班次

- **班次预算**：`max_steps`（默认 Base 10，ToolCallAgent 30，Manus 20）。
- **每一件**：先 `think`（问模型要不要动手、动哪个工具），再 `act`（真执行）。
- **下班铃**：模型调用 `terminate` → 状态 `FINISHED`；或步数用尽；或 token 限额；或卡住后仍可能继续直到预算耗尽。

```mermaid
flowchart TD
    START["BaseAgent.run(request)"] --> GATE{"state == IDLE?"}
    GATE -- 否 --> ERR["RuntimeError"]
    GATE -- 是 --> MEM["update_memory user"]
    MEM --> CTX["state_context(RUNNING)"]
    CTX --> LOOP{"current_step < max_steps<br/>AND state != FINISHED?"}
    LOOP -- 否 --> ENDCHK{"current_step >= max_steps?"}
    ENDCHK -- 是 --> TERM["重置 step=0, state=IDLE<br/>追加 Terminated 文案"]
    ENDCHK -- 否 --> JOIN["拼 results"]
    TERM --> JOIN
    JOIN --> SBX["SANDBOX_CLIENT.cleanup()"]
    LOOP -- 是 --> INC["current_step += 1"]
    INC --> STEP["ReActAgent.step"]
    STEP --> THINK["ToolCallAgent.think"]
    THINK --> ACTQ{"should_act?"}
    ACTQ -- 否 --> STUCK["is_stuck? → handle_stuck_state"]
    ACTQ -- 是 --> ACT["act：串行 execute_tool"]
    ACT --> SPEC{"special tool / terminate?"}
    SPEC -- 是 --> FIN["state = FINISHED"]
    SPEC -- 否 --> STUCK
    FIN --> STUCK
    STUCK --> LOOP
```

---

### 6.0 先看一个真实任务：从「调研京都」到「交出 HTML」

在逐文件走读之前，先看一个具体任务是怎么在这个循环里转完的——这样后面每一段源码你都知道它在整盘棋里的位置。

你对 Manus 说：**「帮我调研京都必去的 3 个景点，产出一份 HTML 手册。」** 它默认只有 5 件工具（`python_execute` / `browser_use` / `str_replace_editor` / `ask_human` / `terminate`）。看它转四圈：

| 圈 | 发给模型的历史（累积） | 模型决定（think） | 循环动作（act） |
|---|---|---|---|
| 第 1 圈 | 系统提示 + 你的任务 | `browser_use(action="web_search", query="京都 必去 景点")` | 搜索 + **自动跳转首条 URL**（`browser_use_tool.py:251-268`），把结果写回历史 |
| 第 2 圈 | 第 1 圈 + 搜索结果 | `browser_use(action="extract_content", goal="景点名称与简介")` | 页面转 Markdown → **在工具里再调一次 LLM 抽取**（`:375-444`）→ 要点写回历史 |
| 第 3 圈 | 前两圈 + 抽取要点 | `str_replace_editor(command="create", path=".../itinerary.html", ...)` | HTML 落盘，回执写回历史 |
| 第 4 圈 | 前三圈全部 | `terminate(status="success")` | 特殊工具翻状态 `FINISHED`（`toolcall.py:210-227`）→ 退出 |

有个只有 Manus 才有的精妙细节藏在第 2、3 圈之间：`Manus.think` 会检查**最近 3 条消息里有没有用过 `browser_use`**（`manus.py:147-153`）；一旦发现「刚在用浏览器」，它就临时把便利贴换成浏览器专用版、还可能把整页截图塞进这一轮上下文，用完立刻换回来。也就是说，**上下文会随「手上正在用哪只手」动态换页**。

```mermaid
sequenceDiagram
    participant LP as 主循环
    participant Think as Manus.think
    participant LLM as 模型
    participant Tool as 工具执行

    LP->>Think: 第2圈开始
    Think->>Think: 最近3条用过 browser_use？→ 是
    Think->>Think: 临时换上「浏览器版便利贴」+ 截图
    Think->>LLM: 带着页面状态问「下一步」
    LLM-->>Think: extract_content(goal=...)
    Think->>Think: 用完，把便利贴换回原样
    Think->>Tool: 执行 extract_content
    Tool->>LLM: 工具内部再调一次 LLM 抽取要点
    LLM-->>Tool: 结构化要点
    Tool-->>LP: 要点写回历史 → 进入第3圈
```

一句话看懂这张图：**同一个循环，会因为「上一步用了浏览器」而临时给模型换一份更懂浏览器的提示词——这是 Manus 把「调研」做深的关键动作。**

---

### 6.1 逐文件走读：`app/agent/base.py`

#### 字段与校验（`base.py:20-56`）

| 字段 | 默认 | 含义 |
|---|---|---|
| `name` / `description` | 必填 / 可选 | Agent 身份 |
| `system_prompt` / `next_step_prompt` | None | 岗位手册 / 便利贴 |
| `llm` | `Field(default_factory=LLM)` | 模型客户端 |
| `memory` | `Memory()` | 消息笔记本 |
| `state` | `IDLE` | 状态机 |
| `max_steps` | 10 | 班次预算 |
| `current_step` | 0 | 当前步 |
| `duplicate_threshold` | 2 | stuck 判定阈值 |

`Config.extra = "allow"`（`base.py:45-47`）允许子类塞额外字段（如 Manus 的 `mcp_clients`）。`initialize_agent` validator：若 llm/memory 类型不对就重建（`base.py:49-56`）。

#### `state_context`：看起来安全的状态切换（`base.py:58-82`）

```python
previous_state = self.state
self.state = new_state
try:
    yield
except Exception as e:
    self.state = AgentState.ERROR
    raise e
finally:
    self.state = previous_state  # 注意：总会回到 previous
```

**取舍 / 陷阱**：`finally` 会把状态**恢复成进入前的 previous**。因此：

- 循环内 `terminate` 把 `state` 设为 `FINISHED` 后，while 能正确退出（因为退出条件在 `finally` 之前已判定）；但离开 `async with` 后，状态会被改回进入时的 `IDLE`（`previous_state`）。
- `except` 里设的 `ERROR` 也会被 `finally` 覆盖回 previous。

也就是说：**`FINISHED` / `ERROR` 主要作用于 `run` 循环内部的控制流**，出了 `state_context` 后对外可见状态往往又是 IDLE。这是读源码时容易误解的一点，值得在改代码时小心。

#### `run`：外层班次循环（`base.py:116-154`）

```python
if self.state != AgentState.IDLE:
    raise RuntimeError(...)
if request:
    self.update_memory("user", request)

results: List[str] = []
async with self.state_context(AgentState.RUNNING):
    while self.current_step < self.max_steps and self.state != AgentState.FINISHED:
        self.current_step += 1
        logger.info(f"Executing step {self.current_step}/{self.max_steps}")
        step_result = await self.step()
        if self.is_stuck():
            self.handle_stuck_state()
        results.append(f"Step {self.current_step}: {step_result}")
    if self.current_step >= self.max_steps:
        self.current_step = 0
        self.state = AgentState.IDLE
        results.append(f"Terminated: Reached max steps ({self.max_steps})")
await SANDBOX_CLIENT.cleanup()
return "\n".join(results) if results else "No steps executed"
```

| 机制 | 行为 | 位置 |
|---|---|---|
| 状态门禁 | 非 IDLE 不能 `run` | `base.py:128-129` |
| 注入用户请求 | `update_memory("user", request)` | `base.py:131-132` |
| 步数预算 | `current_step < max_steps` | `base.py:136-139` |
| 正常结束 | `state == FINISHED` 退出 while | `base.py:137` |
| stuck 检测 | 每步后 `is_stuck` → `handle_stuck_state` | `base.py:144-145` |
| 预算耗尽 | 重置 `current_step=0`，状态回 IDLE，追加 Terminated 文案 | `base.py:149-152` |
| 沙箱清理 | 每次 run 结束 `SANDBOX_CLIENT.cleanup()` | `base.py:153` |

注意：预算耗尽时把 `current_step` 归零（`base.py:150`），方便同一 Agent 实例再次 `run`；但 **Memory 不会自动 clear**——若复用同一实例连续跑两个任务，历史会串味。PlanningFlow 每步都 `executor.run(...)`，正踩在这个点上（见第 9 章）。

#### stuck 检测（`base.py:163-186`）

`is_stuck`：

1. 消息少于 2 条 → False（`base.py:172-173`）。
2. 取最后一条；无 content → False（`base.py:175-177`）。
3. 在**更早的消息**里倒序统计：`role=="assistant"` 且 `content` 与最后一条完全相同的次数（`base.py:180-184`）。
4. `duplicate_count >= duplicate_threshold`（默认 2）→ True（`base.py:186`）。

`handle_stuck_state`（`base.py:163-168`）：把

> Observed duplicate responses. Consider new strategies and avoid repeating ineffective paths already attempted.

**前缀拼到 `next_step_prompt`**——不强制终止，只逼模型换招。下一轮 think 会把加长后的便利贴再塞进 user 消息。

---

### 6.2 逐文件走读：`app/agent/react.py`

整个文件只有约 39 行。`ReActAgent` 继承 `BaseAgent`，把抽象 `step` 拆成两个抽象方法：

```python
# react.py:25-38
@abstractmethod
async def think(self) -> bool:
    """Process current state and decide next action"""

@abstractmethod
async def act(self) -> str:
    """Execute decided actions"""

async def step(self) -> str:
    should_act = await self.think()
    if not should_act:
        return "Thinking complete - no action needed"
    return await self.act()
```

这是经典 ReAct 的最小实现：**思考返回 bool，决定本轮是否执行动作**。字段（`system_prompt` / `max_steps` 等）在子类会再声明一遍（`react.py:12-23`），Pydantic 继承下以子类为准。

设计取舍：把「想」和「做」拆开，方便 `Manus.think` 只包装上下文、不碰执行；也方便测试时 mock 其中一侧。

---

### 6.3 逐文件走读：`app/agent/toolcall.py`——think

`ToolCallAgent`（`toolcall.py:18-37`）默认工具箱只有 `CreateChatCompletion` + `Terminate`；`tool_choices=AUTO`；`special_tool_names=[Terminate().name]`；`max_steps=30`；`max_observe` 可选。

#### think 决策树（`toolcall.py:39-129`）

```mermaid
flowchart TD
    A["追加 next_step_prompt 为 user 消息"] --> B["llm.ask_tool(messages, system, tools)"]
    B --> C{"异常?"}
    C -- TokenLimitExceeded via RetryError.__cause__ --> D["写 assistant 说明 → state=FINISHED → return False"]
    C -- ValueError --> RE["直接 raise"]
    C -- 其它 --> RE2["raise"]
    B --> E["解析 content + tool_calls"]
    E --> F{"tool_choices?"}
    F -- NONE --> G["有 content 则记消息 return True；否则 False<br/>有 tool_calls 仅 warning"]
    F -- REQUIRED --> H["写入 assistant；即使无 tool_calls 也 return True<br/>留给 act 抛 TOOL_CALL_REQUIRED"]
    F -- AUTO --> I{"有 tool_calls?"}
    I -- 有 --> J["from_tool_calls 写入 → return True"]
    I -- 无但有 content --> K["assistant_message → return True<br/>act 将直接返回文本"]
    I -- 无且无 content --> L["return False"]
    E --> ERR["处理过程异常 → 写 Error assistant 消息 → return False"]
```

关键细节：

1. **Token 限额路径**（`toolcall.py:57-72`）：`ask_tool` 的 `@retry` 注释写明 Don't retry `TokenLimitExceeded`（`llm.py:641-642`），但 tenacity 仍可能把异常包进 `RetryError`。think 用 `hasattr(e, "__cause__") and isinstance(e.__cause__, TokenLimitExceeded)` 拆包；命中则写 assistant 说明、`state=FINISHED`、`return False`——**优雅下班，不炸栈**。
2. **`ValueError` 直接再抛**（`toolcall.py:57-58`）——例如非法 tool_choice。
3. **日志**：打印 thoughts、工具数量、工具名列表、**第一个**工具的 arguments（`toolcall.py:81-89`）——多工具时后面的参数只执行不预览。
4. **`response is None`**：抛 `RuntimeError("No response received from the LLM")`（`toolcall.py:92-93`）；`ask_tool` 在 choices 空时会 `return None`（`llm.py:737-740`）。
5. **AUTO 且无 tool_calls 但有 content**：`return bool(content)`（`toolcall.py:118-119`）→ 进入 act → act 发现无 tool_calls → 返回最后一条 content（`toolcall.py:137-138`）。这允许「纯文本收尾」而不强制 terminate；但状态不会自动 FINISHED，循环会继续直到模型调 terminate 或步数耗尽。
6. **think 内层 except**（`toolcall.py:122-129`）：吞掉处理异常，写 Error 消息，`return False`——本步变成「Thinking complete - no action needed」，外层循环继续。

---

### 6.4 逐文件走读：`toolcall.py`——act / execute_tool / special tools

#### `act`（`toolcall.py:131-164`）

1. 无 tool_calls：若 REQUIRED → `raise ValueError(TOOL_CALL_REQUIRED)`（常量见 `toolcall.py:15`）；否则返回最后一条 content（`toolcall.py:133-138`）。
2. **串行** `for command in self.tool_calls`（`toolcall.py:141`）——同一轮多个工具不并发。
3. 每个工具前重置 `_current_base64_image = None`（`toolcall.py:142-143`）。
4. `max_observe` 若设了，对结果字符串切片（`toolcall.py:147-148`；Manus=10000）。
5. 写成 `Message.tool_message`（含 `tool_call_id` / `name` / 可选 `base64_image`）（`toolcall.py:155-161`）。
6. 多工具结果用 `"\n\n".join(results)` 返回（`toolcall.py:164`）。

#### `execute_tool`（`toolcall.py:166-208`）

| 步骤 | 行为 | 失败时 |
|---|---|---|
| 校验 command | 无 name → 返回 Error 字符串 | 不抛 |
| 查 tool_map | 未知工具 → Error 字符串 | 不抛 |
| `json.loads(arguments)` | 解析参数 | `JSONDecodeError` → Error 字符串 |
| `available_tools.execute` | 真正执行 | 其它 Exception → Error 字符串 + `logger.exception` |
| `_handle_special_tool` | 可能把 state 设 FINISHED | — |
| 提取 `base64_image` | 存到 `_current_base64_image` | — |
| 格式化 observation | `Observed output of cmd \`{name}\` executed:\n...` | — |

要点：**工具失败默认变成观察文本写回模型**，而不是中断整个 `run`。模型下一轮还能换策略。这与 `REQUIRED` 模式下「无 tool_calls 就抛」的严格态度形成对比。

`ToolCollection.execute`（`tool_collection.py:25-35`）：`tool(**tool_input)` 即 `BaseTool.__call__` → `execute`；`ToolError` 转成 `ToolFailure`。

#### 特殊工具：`terminate` 如何按下下班铃（`toolcall.py:210-227`）

```python
async def _handle_special_tool(self, name, result, **kwargs):
    if not self._is_special_tool(name):
        return
    if self._should_finish_execution(name=name, result=result, **kwargs):
        logger.info(f"🏁 Special tool '{name}' has completed the task!")
        self.state = AgentState.FINISHED
```

- `_is_special_tool`：大小写不敏感比对 `special_tool_names`（`toolcall.py:225-227`）。
- `_should_finish_execution` 默认**恒 True**（`toolcall.py:220-223`）——只要进了 special 列表就会结束。
- `Terminate.execute` 只返回一句状态文案（`app/tool/terminate.py:23-25`）——**真正停循环的是状态翻转**，不是返回值。
- `MCPAgent` 覆写：仅当 name 是 `terminate` 才结束（`mcp.py:167-170`）；并在父类处理后再处理多媒体 `base64_image`（`mcp.py:154-165`）。

#### cleanup 与 run 包装（`toolcall.py:229-250`）

- `cleanup`：遍历 `available_tools.tool_map`，对有 async `cleanup` 的工具调用（`toolcall.py:232-242`）——浏览器、MCP 等靠这条释放。
- `ToolCallAgent.run`：`try: return await super().run()` / `finally: await self.cleanup()`（`toolcall.py:245-250`）。
- `Manus.cleanup` 额外：`browser_context_helper.cleanup_browser` + 若 `_initialized` 则断开 MCP（`manus.py:131-138`）。

---

### 6.5 步数预算对照表

| Agent | max_steps | max_observe | 位置 |
|---|---|---|---|
| BaseAgent 默认 | 10 | — | `base.py:40` |
| ToolCallAgent | 30 | Optional | `toolcall.py:36-37` |
| Manus | 20 | 10000 | `manus.py:27-28` |
| BrowserAgent | 20 | 10000 | `browser.py:101-102` |
| MCPAgent | 20 | — | `mcp.py:30` |
| DataAnalysis | 20 | 15000 | `data_analysis.py:26-27` |
| SWEAgent | 20 | — | `swe.py:24` |
| SandboxManus | 20 | 10000 | `sandbox_agent.py:30-31` |

对比 nanobot 默认 200、Raven 默认 40——OpenManus 的 20 步更像「单次作业班次」，长任务依赖 **PlanningFlow 外层再套多轮 `agent.run`**。

### 6.6 取消 / 超时 / 失败重试

| 机制 | 有无 | 证据 |
|---|---|---|
| Ctrl+C | 有 | 各入口 `except KeyboardInterrupt`（`main.py:28-29` 等） |
| Flow 整任务超时 | 有，3600s | `run_flow.py:32-35` |
| LLM 请求 timeout | ask_tool 默认 300s | `llm.py:648` |
| LLM API 重试 | tenacity 最多 6 次 | `llm.py:637-642` |
| Token 限额 | 可选；命中则 FINISHED | `llm.py:701-705`；`toolcall.py:60-72` |
| Python 代码超时 | 默认 5s，进程 terminate | `python_execute.py:42,68-74` |
| 搜索全引擎失败重试 | max_retries 默认 3 + retry_delay | `web_search.py:251-277`；`config.py:45-52` |
| `/stop` 硬取消 | **未找到** | — |
| 中途插话队列 | **未找到** | — |
| 崩溃 checkpoint | **未找到** | — |

### 6.7 设计取舍

- **短循环 + 外层 Flow**：单 Agent 20 步不够时，用 PlanningFlow 把任务切成多段——代价是段与段之间**默认共享同一个 Agent 实例的 Memory**（若 flow 里复用同一对象），或各自独立（若每次新建）；OpenManus 的 `run_flow` 是复用 `Manus()` 实例（`run_flow.py:12-14`），故 Memory 会跨步累积，直到触达 100 条截断。
- **串行工具调用**：简单安全，吞吐不如并发框架。
- **失败变观察**：工具层软失败，利于调研容错；REQUIRED 模式则偏严格。
- **`state_context` finally 回滚**：循环控制靠 while 条件读取瞬时 FINISHED；对外生命周期状态机并不「粘住」FINISHED。

> 🔍 **源码指路**：`agent/base.py:116-154`（外层班次 run）｜`base.py:163-186`（stuck 检测）｜`agent/react.py:33-38`（step=think+act）｜`agent/toolcall.py:39-129`（think 决策树）｜`toolcall.py:131-208`（act 与 execute_tool 软失败）｜`toolcall.py:210-227`（terminate 翻 FINISHED）｜`agent/manus.py:140-165`（浏览器动态换页）

> 🧠 **一句话**：只要模型还调工具，循环就转；它一调 `terminate`，状态翻 `FINISHED`，循环就停。20 步是一次「作业班次」，长任务靠外面再套 Flow。

### 与 Kortix/Suna 对照（短注）

OpenManus 主循环是教科书 ReAct，全部在一个 Python 进程里；Kortix 的「跑起来」发生在 session-lifecycle 引擎 + 沙箱内 OpenCode，取消/超时/权限是平台级概念。想改「一步里想什么」——改 OpenManus；想改「谁能开多少并行会话」——改 Kortix。

---
## 第 7 章 工具系统与权限：模型的「手」和「紧箍咒」

### 比喻：工具箱挂在腰间，权限绳几乎没系紧

OpenManus 的工具层是「统一长相 + 特化执行」。调研主轴是 **浏览器动作链 + 多引擎搜索回退**；安全默认偏开放，真正的容器隔离要显式打开 Docker/Daytona。

### 7.1 工具的统一长相

`BaseTool`（`app/tool/base.py:78-137`）：

- 字段：`name` / `description` / `parameters`（JSON Schema）
- `async __call__` → `execute`（`base.py:116-122`）
- `to_param()` → OpenAI function calling 格式（`base.py:124-137`）

`ToolResult`（`base.py:38-75`）携带 `output` / `error` / `base64_image` / `system`；`__str__` 优先展示 error（`base.py:69-70`）；支持 `+` 合并（`base.py:52-67`）。

`ToolCollection`（`app/tool/tool_collection.py`）：

| 方法 | 行为 | 位置 |
|---|---|---|
| `to_params` | 列表推导 `tool.to_param()` | `tool_collection.py:22-23` |
| `execute(name, tool_input)` | 查 map → `await tool(**tool_input)` | `tool_collection.py:25-35` |
| `add_tool` / `add_tools` | 重名跳过并 warning | `tool_collection.py:51-71` |
| `get_tool` | 按名取实例 | `tool_collection.py:48-49` |

---

### 7.2 Manus 默认工具箱

`app/agent/manus.py:34-41`：

| 工具 | 作用 | 文件 |
|---|---|---|
| `python_execute` | 子进程执行 Python，捕获 stdout | `app/tool/python_execute.py` |
| `browser_use` | 浏览器自动化（含 web_search 动作） | `app/tool/browser_use_tool.py` |
| `str_replace_editor` | 查看/创建/替换/插入/撤销文件 | `app/tool/str_replace_editor.py` |
| `ask_human` | 终端里问人 | `app/tool/ask_human.py` |
| `terminate` | 结束循环 | `app/tool/terminate.py` |
| `mcp_*`（可选） | 连接外部 MCP 后动态加入 | `app/tool/mcp.py`；`manus.py:108-112` |

仓库内还有但**未默认挂进 Manus** 的：独立 `WebSearch`、`Bash`、`Crawl4aiTool`、`PlanningTool`、`ComputerUseTool`、chart_visualization 套件等（`app/tool/__init__.py:1-24` 导出了一部分）。注意：浏览器动作里的 `web_search` **复用**了 `WebSearch` 类实例（`browser_use_tool.py:128`），但独立 `web_search` 工具名默认不在 Manus 的 `ToolCollection` 里——模型通常通过 `browser_use` 的 action 间接触达搜索。

---

### 7.3 `browser_use` 动作作成序列（重点加厚）

文件：`app/tool/browser_use_tool.py`。

#### 动作枚举与依赖（`browser_use_tool.py:47-121`）

`action` enum 共 16 项：`go_to_url`、`click_element`、`input_text`、`scroll_down`/`scroll_up`、`scroll_to_text`、`send_keys`、`get_dropdown_options`、`select_dropdown_option`、`go_back`、`web_search`、`wait`、`extract_content`、`switch_tab`、`open_tab`、`close_tab`。

`dependencies` 字段声明各动作所需参数（如 `web_search`→`query`，`extract_content`→`goal`）——这是给模型看的 schema 提示，**不是运行时强制校验器**；真正缺参时在 `execute` 分支里返回 `ToolResult(error=...)`。

另有未进 enum 但代码处理了的 `refresh`（`browser_use_tool.py:247-249`）——schema 与实现略有不一致，属边角。

#### 生命周期：锁 + 懒初始化（`browser_use_tool.py:124-188,223`）

- `asyncio.Lock`：整个 `execute` 包在 `async with self.lock` 里（`browser_use_tool.py:223`），避免并发动作打乱浏览器。
- `_ensure_browser_initialized`：
  - 默认 `headless=False`、`disable_security=True`（`browser_use_tool.py:144`）。
  - 再被 `config.browser_config` 覆盖 proxy / headless / CDP / wss 等（`browser_use_tool.py:146-170`）。
  - `new_context` + `DomService`（`browser_use_tool.py:174-186`）。
- 跨调用保持 `browser` / `context` 存活，直到 `cleanup`（`browser_use_tool.py:541-550`）。

#### 典型调研作成序列

```mermaid
sequenceDiagram
    participant A as Agent
    participant B as BrowserUseTool
    participant W as WebSearch
    participant P as Playwright 页面
    participant L as LLM（二次抽取）

    A->>B: action=web_search, query=...
    B->>W: execute(query, fetch_content=True, num_results=1)
    W-->>B: SearchResponse（含首条 URL）
    B->>P: page.goto(首条 URL)
    B-->>A: 返回 search_response（同时已跳转）
    Note over A: 下一轮 Manus.think 检测到 browser_use<br/>注入 URL/截图 next_step
    A->>B: action=extract_content, goal=...
    B->>P: page.content() → markdownify
    B->>L: ask_tool(抽取 function, tool_choice=required)
    L-->>B: extracted_content
    B-->>A: ToolResult 文本
    A->>B: click_element / scroll_* / open_tab（按需）
    A->>A: terminate
```

#### `action=web_search` 精读（`browser_use_tool.py:251-268`）

```python
search_response = await self.web_search_tool.execute(
    query=query, fetch_content=True, num_results=1
)
first_search_result = search_response.results[0]
url_to_navigate = first_search_result.url
page = await context.get_current_page()
await page.goto(url_to_navigate)
await page.wait_for_load_state()
return search_response
```

要点：

1. 内部搜索强制 `fetch_content=True`、只要 1 条结果。
2. **搜索成功后自动浏览器跳转到首条 URL**——不只是返回链接列表。
3. 返回值是整个 `SearchResponse`（含结构化 results）；若 `results` 为空，下一行 `results[0]` 会抛异常，被外层 `except` 收成 `Browser action 'web_search' failed: ...`（`browser_use_tool.py:476-477`）。

#### `action=extract_content` 精读（`browser_use_tool.py:375-444`）

1. `markdownify(await page.content())` 把 HTML 转 Markdown（`browser_use_tool.py:381-384`）。
2. 截断到 `max_content_length`（默认 2000，`config`；`browser_use_tool.py:227-230,391`）。
3. 构造抽取 prompt + **二次** `llm.ask_tool`，`tool_choice="required"`，强制模型走 `extract_content` function（`browser_use_tool.py:431-435`）。
4. 解析 `tool_calls[0].function.arguments` 里的 `extracted_content`（`browser_use_tool.py:437-442`）。

这是「工具里再调一次 LLM」的嵌套模式——调研质量上升，延迟与 token 成本也上升。

#### 其它动作速查

| action | 核心行为 | 行号 |
|---|---|---|
| `go_to_url` | `page.goto` + `wait_for_load_state` | `233-241` |
| `click_element` | DOM index → `_click_element_node`，可能下载 | `271-283` |
| `input_text` | index + text → `_input_text_element_node` | `285-296` |
| `scroll_*` | `window.scrollBy` 或默认一屏高度 | `298-310` |
| `scroll_to_text` | Playwright `get_by_text` + `scroll_into_view` | `312-323` |
| `send_keys` | `keyboard.press` | `325-332` |
| 下拉框 | xpath evaluate / `select_option` | `334-372` |
| 标签页 | switch / create_new_tab / close | `447-465` |
| `wait` | `asyncio.sleep`，默认 3s | `468-471` |

#### `get_current_state` 与 cleanup

见第 5 章；截图参数：`full_page=True`、`type="jpeg"`、`quality=100`（`browser_use_tool.py:507-509`）。`__del__` 里尝试 `asyncio.run(cleanup())` 或新建 event loop（`browser_use_tool.py:552-560`）——析构期清理，粗暴但防泄漏。

---

### 7.4 `WebSearch`：四引擎级联回退（重点加厚）

文件：`app/tool/web_search.py`。

#### 数据结构

- `SearchResult`：position / url / title / description / source / raw_content（`web_search.py:22-40`）。
- `SearchResponse(ToolResult)`：query / results / metadata；`populate_output` validator 把结果格式化成可读 `output`（`web_search.py:53-103`），正文预览截到 1000 字符（`web_search.py:86-88`）。

#### `execute` 重试环（`web_search.py:201-288`）

```text
读取 config：retry_delay（默认 60）、max_retries（默认 3）、lang、country
for retry_count in range(max_retries + 1):
    results = await _try_all_engines(...)
    if results:
        可选 _fetch_content_for_results
        return SearchResponse(success...)
    if 还有重试次数:
        sleep(retry_delay)
return SearchResponse(error="All search engines failed...")
```

注意：这里的「重试」是**整轮所有引擎都失败后**再等 `retry_delay` 秒重来（`web_search.py:272-277`），不是单个引擎内重试。单引擎调用另有 tenacity：`_perform_search_with_engine` 最多 3 次指数退避（`web_search.py:387-408`）。

#### `_try_all_engines` + `_get_engine_order`（`web_search.py:290-385`）

引擎实例表（`web_search.py:193-198`）：google / baidu / duckduckgo / bing。

顺序：

1. `config.search_config.engine` 首选（默认 google）；
2. `fallback_engines` 列表；
3. 其余未出现的引擎补齐（`web_search.py:360-385`）。

任一引擎返回非空 `search_items` 即成功并 return，不再试后续（`web_search.py:304-323`）。失败引擎名记入 `failed_engines` 仅用于日志。

#### 正文抓取 `WebContentFetcher`（`web_search.py:106-153`）

- `requests.get` 丢进线程池（`web_search.py:127-129`）。
- BeautifulSoup 去 script/style/header/footer/nav（`web_search.py:141-142`）。
- 文本压空白后 **截断 10000 字符**（`web_search.py:148-149`）。
- `_fetch_content_for_results` 对多结果 `asyncio.gather` 并行抓（`web_search.py:329-350`）。

配置见 `config.example.toml:80-93`。

---

### 7.5 `python_execute`：子进程沙箱错觉

`app/tool/python_execute.py`：

1. description 明确：**只有 print 输出可见，return 值不捕获**（`python_execute.py:13`）。
2. `_run_code`：重定向 `sys.stdout` 到 `StringIO`，`exec(code, safe_globals, safe_globals)`（`python_execute.py:25-37`）。
3. `safe_globals` 实际传入完整 `__builtins__`（`python_execute.py:57-60`）——注释写 safety，**并非裁剪 builtins**。
4. `multiprocessing.Process` + `join(timeout)`；超时 `terminate`（`python_execute.py:61-74`）。
5. 默认 `timeout=5`（`python_execute.py:42`）。

真正的容器隔离看 Docker（`app/sandbox/`，默认 `use_sandbox=false`，`config.py:97`）或 Daytona（`SandboxManus`）。

---

### 7.6 MCP：动态外接手（重点加厚）

#### 客户端 `MCPClients`（`app/tool/mcp.py`）

| 能力 | 行为 | 位置 |
|---|---|---|
| `connect_sse` | SSE 流 → `ClientSession` → list tools | `mcp.py:50-69` |
| `connect_stdio` | `StdioServerParameters` → session | `mcp.py:71-95` |
| 工具命名 | `mcp_{server_id}_{original}` + sanitize | `mcp.py:107-114,128-145` |
| sanitize | 非法字符→`_`，去连续下划线，最长 64 | `mcp.py:128-145` |
| `MCPClientTool.execute` | `session.call_tool(original_name, kwargs)` | `mcp.py:21-34` |
| `disconnect` | `AsyncExitStack.aclose`，处理 cancel scope | `mcp.py:155-194` |

`Manus.connect_mcp_server`：连上后只把该 `server_id` 的新工具 `add_tools` 进 `available_tools`（`manus.py:108-112`）。`disconnect_mcp_server`：重建 ToolCollection，排除 `MCPClientTool` 再加回仍连接的工具（`manus.py:122-129`）。

#### `MCPAgent` 专员差异（`app/agent/mcp.py`）

- `available_tools` 整个就是 `MCPClients`（`mcp.py:70-71`）。
- 初始化时把工具名写进 memory system 消息（`mcp.py:81-85`）。
- 每 5 步 `_refresh_tools`（`mcp.py:35,143-149`）；感知增删并写 system 通知（`mcp.py:117-128`）。
- 会话没了或工具清空 → `FINISHED`（`mcp.py:137-149`）。

#### 服务端 `MCPServer`（`app/mcp/server.py`）

注册 bash / browser / editor / terminate（`server.py:31-35`）；把 `BaseTool` schema 转成 Python Signature 再 `@server.tool()`（`server.py:37-76`）。入口 `run_mcp_server.py`。

---

### 7.7 文件编辑与 AskHuman

- **`str_replace_editor`**：Anthropic 风格 view/create/str_replace/insert/undo_edit；长输出截断 16000（`str_replace_editor.py:28-34,51-57`）；`_get_operator` 按 `config.sandbox.use_sandbox` 选本地或沙箱（`str_replace_editor.py:106-112`）；路径须绝对路径（`str_replace_editor.py:170-172`）。
- **`ask_human`**：`input(f"Bot: {inquire}\n\nYou: ")`（`ask_human.py:20-21`）——主循环内同步阻塞。

### 7.8 沙箱安全：两套实现

**A. 本地 Docker**（`app/sandbox/`）：内存/CPU 限制；默认 `network_mode=none`（除非 `network_enabled`）（`sandbox/core/sandbox.py:61-66`；`config.py:94-105`）。默认关。

**B. Daytona**（`SandboxManus`）：`SandboxBrowserTool` / `Files` / `Shell` / `Vision`（`sandbox_agent.py:101-107`）；写 `index.html` 时提示 8080 预览（`sb_files_tool.py:230-239` 附近）。

### 7.9 权限模型：几乎没有「逐次审批」

先用一张对照图说清 OpenManus 的安全姿态：编程 Agent（如 Claude Code）在「模型提议」和「真正执行」之间，塞了一道会拦下危险操作、请你点头的**闸门**；而 OpenManus 默认几乎是**直通**的——真正的隔离要你自己显式打开 Docker/Daytona 才有。

```mermaid
flowchart TD
    subgraph OM["OpenManus 默认（绳子几乎没系紧）"]
        A1["模型提议调用工具"] --> A2["execute_tool"]
        A2 --> A3["真的执行<br/>读/写/跑浏览器"]
        A2 -. 仅当你显式开启 .-> A4["Docker / Daytona 沙箱"]
    end
    subgraph CC["编程 Agent 典型（有闸门）"]
        B1["模型提议调用工具"] --> B2{"危险吗？"}
        B2 -->|"只读"| B3["直接执行"]
        B2 -->|"写/危险"| B4["暂停，请你确认"]
        B4 -->|"你点头"| B3
    end
    style A3 fill:#fdeded
    style B4 fill:#eaf1ff
```

对着这张图看下面的清单，就知道 OpenManus「缺了哪几道闸、留了哪几条自愿的回流」：

- **未找到** 工具调用前的交互确认弹窗。
- **未找到** 统一的 SSRF / 危险命令正则黑名单（Bash 在 SWE/MCP Server 路径，非 Manus 默认箱）。
- **有** Docker/Daytona 资源与网络限制（需显式启用）。
- **有** `ask_human` 自愿抛回人类。
- 浏览器默认 `disable_security=True`（`config.py:71-72`；`browser_use_tool.py:144`）。

### 7.10 与编程 Agent 工具箱差异

| 能力 | OpenManus | 典型编程 Agent |
|---|---|---|
| 网页调研 | 一等公民（browser_use + 多引擎搜索） | 通常次要 |
| 代码导航 | **未找到** grep/glob/LSP 专用工具 | 一等公民 |
| 补丁 | str_replace_editor（通用文本） | apply_patch / 精确 diff |
| Shell | Bash 在 SWE/MCP；Manus 默认 python_execute | Bash 为核心 |
| 交付物 | HTML/图表/报告 | 主要是代码与测试 |
| 规划 | PlanningTool + PlanningFlow | 多为 TODO / 自规划 |

> 🧠 **一句话**：工具箱是为「调研」而生的（浏览器 + 多引擎搜索是主角），但安全绳默认没系紧——要真隔离，得自己显式打开 Docker / Daytona。

### 与 Kortix/Suna 对照（短注）

OpenManus 工具「开箱即调研」；Kortix 工具跑在沙箱 + 服务端 Executor，权限靠 IAM/grant/policy。浏览器闭环 OpenManus 更直；生产安全默认 Kortix 更紧。

---
## 第 8 章 上下文压缩与记忆

### 一句话结论

OpenManus 的「记忆」是**进程内的消息列表**；「压缩」主要是**硬截断**，**未找到** LLM 摘要压缩、Dream 反思、向量长期记忆、prompt cache 断点。

### Memory 实现

`app/schema.py:159-187`：

- `messages: List[Message]`
- `max_messages: int = 100`
- `add_message` / `add_messages`：超过上限保留**最后 N 条**（`schema.py:167-168,174-175`）
- `clear` / `get_recent_messages` / `to_dict_list`

这是滑动窗口，不是语义压缩：早期工具观察、计划细节会直接消失。结合第 5 章：每轮 next_step 再追加一条 user，有效「有意义历史」容量比 100 更小。

### 其它「省上下文」手段（都不是摘要器）

| 手段 | 作用 | 位置 |
|---|---|---|
| `max_observe` | 截断单次工具观察 | `toolcall.py:147-148`；Manus=10000 |
| 文件 view 截断 | 16000 字符 + clipped 提示 | `str_replace_editor.py:28-34` |
| 浏览器 content 长度 | `max_content_length` 默认 2000 | `config.py:89-91`；`browser_use_tool.py:391` |
| 搜索正文 | fetch 后约 10000 字符 | `web_search.py:149` |
| 搜索 output 预览 | raw_content 展示 1000 字符 | `web_search.py:86-88` |
| Token 累计限额 | `max_input_tokens` 可选；超限结束 Agent | `llm.py:249-254`；`toolcall.py:60-72` |
| stuck prompt | 不压缩，只加策略提醒 | `base.py:163-168` |

### 沙箱图片压缩（仅视觉工具）

`SandboxVisionTool.compress_image`（`app/tool/sandbox/sb_vision_tool.py`）压缩图片到 base64——这是**媒体压缩**，不是对话记忆压缩。

### 未找到

- **未找到** `compress` / `summarize` 对话历史的模块。
- **未找到** `MEMORY.md` / 长期记忆文件管线。
- **未找到** `prompt cache` / `cache_control` / 显式 cache breakpoint（全仓库检索无匹配）。
- **未找到** 跨进程会话持久化（重启即忘）。

### 设计取舍

对「三小时原型 + 单次终端作业」这一定位，滑动窗口足够；对「连续多小时调研还要记得第 1 步的数据」，PlanningFlow 的计划文本是唯一跨步骤的结构化记忆骨架，Agent Memory 本身扛不住。

> 🧠 **一句话**：OpenManus 的记忆是 100 条硬窗口，「压缩」就是扔掉最旧的——没有摘要、没有长期记忆、没有 prompt cache。

### 与 Kortix/Suna 对照（短注）

Kortix 用公司仓库 + CR 当长期记忆；OpenManus 用 100 条硬窗口。要「昨天的调研今天续」——Kortix；要「今晚演示完即焚」——OpenManus。

---

## 第 9 章 子 agent 与多 agent：PlanningFlow 全路径

### 比喻：不是分身术，是「项目经理 + 外包专员」

OpenManus **未找到** nanobot 式 `spawn` 后台子代理。它的多 Agent 是 **PlanningFlow 当项目经理，按步骤把任务派给字典里的专员**。本章按调用顺序走完整路径。

```mermaid
flowchart TD
    RF["run_flow.py"] --> FF["FlowFactory.create_flow(PLANNING)"]
    FF --> PF["PlanningFlow.execute(input)"]
    PF --> CI["_create_initial_plan"]
    CI --> LLM1["llm.ask_tool + PlanningTool"]
    LLM1 --> PLAN{"plans[plan_id] 存在?"}
    PLAN -- 否 --> FAIL["return Failed to create plan"]
    PLAN -- 是 --> LOOP["while True"]
    LOOP --> GET["_get_current_step_info"]
    GET --> NONE{"有 active step?"}
    NONE -- 否 --> FIN["_finalize_plan"]
    NONE -- 是 --> EX["get_executor(step_type)"]
    EX --> RUN["_execute_step → agent.run(step_prompt)"]
    RUN --> MARK["_mark_step_completed"]
    MARK --> STOP{"executor.state == FINISHED?"}
    STOP -- 是 --> OUT["break 返回 result"]
    STOP -- 否 --> LOOP
    FIN --> OUT
```

---

### 9.1 入口：`run_flow.py`

1. `agents = {"manus": Manus()}`（`run_flow.py:12-14`）——**直接构造，不是 `Manus.create()`**，MCP 靠首次 `think` 懒加载（`manus.py:142-144`）。
2. 若 `config.run_flow_config.use_data_analysis_agent`，再挂 `DataAnalysis()`（`run_flow.py:15-16`；`config.example.toml:112-113`）。
3. `FlowFactory.create_flow(FlowType.PLANNING, agents=agents)`（`run_flow.py:24-27`）。
4. `asyncio.wait_for(flow.execute(prompt), timeout=3600)`（`run_flow.py:32-35`）。
5. README 自贴 unstable 标签（`README.md:134-137`）。

### 9.2 `FlowFactory` 与 `BaseFlow`

`FlowType` 目前只有 `PLANNING`（`flow_factory.py:9-10`）；工厂 dict 仅一项（`flow_factory.py:22-24`）。未知类型抛 `ValueError`（`flow_factory.py:27-28`）。

`BaseFlow`（`app/flow/base.py`）：

- 构造时把单个 Agent / list / dict 统一成 `Dict[str, BaseAgent]`（`base.py:22-28`）。
- `primary_agent_key` 默认取 dict 第一个 key（`base.py:31-34`）。
- `primary_agent` / `get_agent` / `add_agent`（`base.py:42-53`）。
- 抽象 `execute(input_text)`（`base.py:55-57`）。

---

### 9.3 `PlanningTool`：内存里的计划字典

文件：`app/tool/planning.py`。

#### 命令集（`planning.py:25-34,101-118`）

`create` / `update` / `list` / `get` / `set_active` / `mark_step` / `delete`。

#### 存储结构（`planning.py:69-70,145-154`）

```python
plans: dict = {}  # plan_id -> plan
# 单个 plan：
{
  "plan_id": ...,
  "title": ...,
  "steps": [...],
  "step_statuses": ["not_started"] * n,
  "step_notes": [""] * n,
}
```

- **进程内 dict，不落盘**——进程结束计划蒸发。
- `create` 时设 `_current_plan_id`（`planning.py:154`）。
- `mark_step` 可改 status ∈ `not_started|in_progress|completed|blocked` 与 notes（参数 schema：`planning.py:55-62`）。
- `update` 时若同位置步骤文本不变，保留旧 status/notes（`planning.py:192-199`）。

`PlanStepStatus` 枚举与展示标记在 Flow 侧（`app/flow/planning.py:16-42`）：`[✓]` / `[→]` / `[!]` / `[ ]`。

---

### 9.4 `PlanningFlow` 构造（`app/flow/planning.py:45-75`）

| 字段 | 含义 |
|---|---|
| `llm` | Flow 自用 LLM（建计划 / 总结） |
| `planning_tool` | 计划存储与命令 |
| `executor_keys` | 可派发的 agent key 列表 |
| `active_plan_id` | 默认 `plan_{timestamp}`（`planning.py:51`） |
| `current_step_index` | 当前步 |

`__init__` 兼容 `executors`→`executor_keys`、`plan_id`→`active_plan_id`（`planning.py:58-63`）；若未指定 executor_keys，则用全部 agents keys（`planning.py:74-75`）。

#### `get_executor`（`planning.py:77-92`）

1. 若 `step_type` 命中 `self.agents` 的 key → 用该 Agent。
2. 否则按 `executor_keys` 顺序找第一个存在的。
3. 再 fallback `primary_agent`。

注意：步骤文本里解析出的 type 会 `.lower()`（`planning.py:247`），而 `DataAnalysis.name` 是 `"Data_Analysis"`（`data_analysis.py:20`），agents dict key 在 `run_flow` 里是 `"data_analysis"`（若按 README 惯例）——**路由能否命中取决于 key 与标签是否约定一致**。`run_flow.py:16` 实际写的是 `agents["data_analysis"] = DataAnalysis()`，标签需形如 `[DATA_ANALYSIS]` 才会 `lower()` 成 `data_analysis`。

---

### 9.5 `_create_initial_plan` 全路径（`planning.py:136-211`）

1. 基础 system：「Create a concise, actionable plan...」（`planning.py:140-144`）。
2. 若 `executor_keys` 对应多个 agent：把 `[{name: KEY.upper(), description}]` 追加进 system，并要求步骤用 `[agent_name]` 标注（`planning.py:145-160`）。
3. user：`Create a reasonable plan with clear steps to accomplish the task: {request}`（`planning.py:166-168`）。
4. `llm.ask_tool(..., tools=[planning_tool.to_param()], tool_choice=AUTO)`（`planning.py:171-176`）。
5. 若有 tool_calls 且 name==`planning`：解析 JSON args，**强制 `args["plan_id"]=self.active_plan_id`**，`await planning_tool.execute(**args)`（`planning.py:179-198`），然后 return。
6. 若模型没调工具或解析失败：打 warning，创建默认三步计划 Analyze / Execute / Verify（`planning.py:200-211`）。

`execute` 开头若 `active_plan_id not in plans`，直接返回失败文案（`planning.py:104-109`）——默认三步路径仍会写入 plans，故通常只有 execute 异常才会踩到。

---

### 9.6 主循环：`execute`（`planning.py:94-134`）

```python
await self._create_initial_plan(input_text)
# 校验 plan 存在...
result = ""
while True:
    self.current_step_index, step_info = await self._get_current_step_info()
    if self.current_step_index is None:
        result += await self._finalize_plan()
        break
    step_type = step_info.get("type") if step_info else None
    executor = self.get_executor(step_type)
    step_result = await self._execute_step(executor, step_info)
    result += step_result + "\n"
    if hasattr(executor, "state") and executor.state == AgentState.FINISHED:
        break
return result
```

**提前终止条件**：executor 在跑完一步后 `state == FINISHED`（例如专员调了 terminate）。结合第 6 章 `state_context` finally 会把状态拨回 IDLE——这里检查发生在 `_execute_step` 返回后、下一轮 `get_current_step_info` 前，读到的是 `run()` 刚结束时的状态。

等等：`ToolCallAgent.run` → `BaseAgent.run` 的 `async with state_context` 的 `finally` 会把 state 设回 previous（进入 run 时的 IDLE）。所以 **`executor.state == FINISHED` 在 `_execute_step` 返回后几乎总是读不到**——因为 cleanup/`state_context` 已把状态拨回 IDLE。

再核对时序：

1. terminate 在 `execute_tool` 里设 `FINISHED`
2. while 因 `state != FINISHED` 失败而退出
3. `state_context.finally` 设回 `previous_state`（IDLE）
4. `ToolCallAgent.run` 的 finally 调 cleanup
5. 返回到 PlanningFlow

因此 `planning.py:128-129` 的 FINISHED 检查**在当前实现下很可能是死代码/极少触发**（除非有人不经过 `state_context` 改状态）。实际「提前结束整个 Flow」更常见的路径是：步骤全部 completed → `_finalize_plan`。若专员在某步中途 terminate，该步仍会被 `_mark_step_completed`（见下），然后 FINISHED 检查读到 IDLE，**Flow 会继续下一步**。这是加深阅读时值得标出的行为。

---

### 9.7 `_get_current_step_info`（`planning.py:213-275`）

1. 读 `planning_tool.plans[active_plan_id]`（`planning.py:227-229`）。
2. 遍历 steps，找第一个 status ∈ `{not_started, in_progress}`（`planning.py:232-238`；active 定义见 `PlanStepStatus.get_active_statuses`，`planning.py:30-32`）。
3. 用正则 `\[([A-Z_]+)\]` 提取 type，再 `.lower()`（`planning.py:245-247`）。
4. `mark_step` 为 `in_progress`；失败则直接改 dict（`planning.py:250-267`）。
5. 无 active step → `(None, None)`。

### 9.8 `_execute_step`：把计划状态塞进专员（`planning.py:277-304`）

构造 `step_prompt`：

```text
CURRENT PLAN STATUS:
{plan_status}

YOUR CURRENT TASK:
You are now working on step {index}: "{step_text}"

Please only execute this current step using the appropriate tools.
When you're done, provide a summary of what you accomplished.
```

然后 `await executor.run(step_prompt)`（`planning.py:296`）。成功后 `_mark_step_completed`（`planning.py:299`）。异常则返回错误字符串，**不**标记完成（`planning.py:302-304`）——下一步 `_get_current_step_info` 仍可能再次选中同一步（若仍是 in_progress/not_started）。

`_get_plan_text`：优先 `planning_tool.execute(command="get")`，失败则 `_generate_plan_text_from_storage` 本地渲染进度百分比与标记（`planning.py:337-404`）。

### 9.9 `_mark_step_completed`（`planning.py:306-335`）

`mark_step` → `completed`；失败则直接改 `step_statuses[index]`。

### 9.10 `_finalize_plan`（`planning.py:406-442`）

1. 先用 Flow 自己的 `llm.ask` 写总结（`planning.py:412-424`）。
2. 失败则 fallback：`primary_agent.run(summary_prompt)`（`planning.py:429-439`）——又开一整轮 Agent 班次。
3. 再失败返回固定文案（`planning.py:441-442`）。

---

### 9.11 DataAnalysis：数据分析专员

`app/agent/data_analysis.py:12-37`：工具箱换成

- `NormalPythonExecute`（鼓励写报告到 workspace）
- `VisualizationPrepare`
- `DataVisualization`（png/html）
- `Terminate`

启用：`[runflow] use_data_analysis_agent = true`（`README.md:144-148`）。

### 9.12 其它 Agent 角色

| Agent | 角色 | 入口 |
|---|---|---|
| Manus | 通用专员 | `main.py` / flow |
| BrowserAgent | 浏览器专员 | 代码可直接用；**未找到**独立 CLI |
| MCPAgent | MCP 专员 | `run_mcp.py` |
| SWEAgent | 编程专员 | **未找到**独立 CLI |
| SandboxManus | 云沙箱专员 | `sandbox_main.py` |
| A2AManus | A2A 协议适配 | `protocol/a2a/app/agent.py:15-30`（`stream` 直接 `NotImplementedError`） |

### 9.13 设计取舍

- **计划在工具内存里**：不落盘。
- **一步 = 一次完整 `agent.run`**：项目经理不介入工具级调度；专员内部最多再跑 `max_steps`（如 20）微步。
- **路由靠字符串标签**：脆弱但透明；README 已标 unstable。
- **FINISHED 短路可能无效**：受 `state_context` finally 影响（见 9.6）。
- **Memory 跨步累积**：同一 `Manus` 实例被反复 `run`，历史串联直到 100 条截断——有时是特性（保留上文），有时是噪声源。

> 🧠 **一句话**：它的「多 Agent」不是分身术，是一个项目经理拿着内存里的计划表，按 `[标签]` 把每一步派给一个专员、各自单独跑一轮。

### 与 Kortix/Suna 对照（短注）

OpenManus PlanningFlow = 进程内项目经理 + `[AGENT]` 派单；Kortix = 多沙箱并行 + OpenCode subagent + CR 治理。前者适合演示「长任务怎么切」；后者适合「五十个销售 Agent 并行」类生产负载。

---
## 第 10 章 生态：命令、扩展与插件

### 命令 / 入口一览

| 入口 | 作用 |
|---|---|
| `python main.py` | 通用 Manus |
| `python run_flow.py` | PlanningFlow 多 Agent |
| `python run_mcp.py` | MCP 客户端 Agent |
| `python run_mcp_server.py` | 暴露本仓库工具为 MCP Server |
| `python sandbox_main.py` | Daytona SandboxManus |
| `protocol/a2a/app/main.py` | A2A 服务（依赖外部 a2a 包） |

**未找到** 统一的 typer/click 命令树、斜杠命令、插件市场。

### MCP：双向生态接口

**作为客户端（Manus / MCPAgent）**：

- 配置 `config/mcp.json` → `MCPSettings.load_server_config`（`config.py:149-169`）。
- `MCPClients.connect_sse` / `connect_stdio`（`app/tool/mcp.py:50-95`）。
- 远程工具改名为 `mcp_{server_id}_{original}` 并 sanitize（`mcp.py:107-114,128-145`）。
- `MCPAgent` 每 5 步刷新工具列表，感知增删（`app/agent/mcp.py:35,143-149`）。

**作为服务端（MCPServer）**：

- FastMCP 注册 bash/browser/editor/terminate（`app/mcp/server.py:24-35,145-160`）。
- 动态把 `BaseTool` 的 JSON Schema 转成 Python Signature + docstring 再 `@server.tool()`（`server.py:37-76`）。
- `atexit` 清理 browser（`server.py:155-156`）。

### 扩展方式（事实上的插件模型）

1. **加工具**：写 `BaseTool` 子类，放进某个 Agent 的 `ToolCollection`。
2. **加 Agent**：继承 `ToolCallAgent`，改 prompt + 工具箱（DataAnalysis 是范本）。
3. **加 Flow**：`FlowFactory` 的 dict 目前只有 `PLANNING`（`flow_factory.py:22-24`）——扩展点预留了，但仓库里只有这一种。
4. **加 MCP server**：外部进程，写进 `mcp.json`，Manus 启动时自动连。
5. **配置换模型**：`config.toml` 的 `[llm]` / `[llm.vision]`；示例覆盖 Anthropic/Azure/Ollama/Bedrock/Jiekou 等（`config/` 下多个 `config.example-model-*.toml`）。

### 图表可视化扩展

`app/tool/chart_visualization/` 自带 README（多语言）与 npm 前端依赖（`package.json`），用于 DataAnalysis 产出交互 HTML 图表——属于"交付物生态"，不是 MCP 插件市场。

### 与 Kortix/Suna 对照（短注）

OpenManus 扩展靠加 Agent/工具/MCP；Kortix 靠 yaml + marketplace + connectors。协议门两边都有，但 Kortix 的 Executor 是平台级守秘边界。

---

## 第 11 章 功能特性：认证、模型与"思考"

### 认证与模型接入

OpenManus **不维护 nanobot 那种 ~40 家 provider 注册表**，而是：

1. 用户在 `config.toml` 填 `model` / `base_url` / `api_key` / `api_type`（`config.example.toml:1-7`）。
2. `LLM.__init__` 按 `api_type` 分支（`app/llm.py:216-225`）：
   - `azure` → `AsyncAzureOpenAI`
   - `aws` → `BedrockClient()`
   - 其它 → `AsyncOpenAI(api_key=..., base_url=...)`（兼容 OpenAI 协议的网关 / Ollama / Anthropic 代理等）
3. 可选 `[llm.vision]` 覆盖视觉模型（`config.example.toml:43-48`）。

**认证形态**：API Key 明文写在 toml（或示例里的占位符）。**未找到** OAuth 登录流、`provider login` 命令。

### Token 会计与限额

- 每次调用累计 `total_input_tokens` / `total_completion_tokens`（`llm.py:238-247`）。
- 可选 `max_input_tokens`：跨请求累计输入上限（`config.py:24-27`；`llm.py:249-254`）。
- 超限 → `TokenLimitExceeded` → Agent `FINISHED`（第 6 章）。

### "思考"（reasoning models）

`REASONING_MODELS = ["o1", "o3-mini"]`（`app/llm.py:34`）。对这些模型：

- 使用 `max_completion_tokens` 而非 `max_tokens`（`llm.py:411-417,723-729`）；
- **不传 temperature**（同上）。

**未找到** 通用的 `reasoning_effort` / `thinking` blocks 剥离 / 多 provider 思考风格适配——远比 nanobot 的 thinking_style 矩阵简单。

### 多模态

- `MULTIMODAL_MODELS` 白名单（`llm.py:35-42`）。
- `ask_with_images`（`llm.py:488-635`）；browser helper 把截图塞进消息。
- 不在白名单的模型：`format_messages` 会丢掉 `base64_image` 字段（`llm.py:337-339`）。

### 流式输出

- `ask(..., stream=True)` 默认流式并 `print` 到终端（`llm.py:365,440-448`）。
- `ask_tool` **永远非流式**（`llm.py:731`）——工具循环优先要完整 `tool_calls`。

### 其它特性速查

| 特性 | 状态 | 位置 |
|---|---|---|
| 浏览器 headless/proxy/CDP | 有 | `config.py:69-91` |
| 搜索引擎/语言/国家 | 有 | `config.py:39-60` |
| Docker 沙箱 | 有（默认关） | `config.py:94-105` |
| Daytona 云沙箱 | 有（SandboxManus） | `app/daytona/` |
| A2A 协议 | 实验性薄封装 | `protocol/a2a/` |
| OpenManus-RL | 外部姊妹仓库 | README 提及 |
| prompt cache | **未找到** | — |
| 成本/路由层 | **未找到** | — |
| 定时 cron / 主动性 | **未找到** | — |

### 与 Kortix/Suna 对照（短注）

OpenManus：TOML + OpenAI 兼容端点；Kortix：LLM Gateway + `reasoning_effort`。思考模型适配深度差一截。

---

## 第 12 章 总结：设计哲学与取舍

先用一张图复盘 OpenManus 的三根支柱：

```mermaid
flowchart TB
    GOAL["目标：开源、可自托管的 Manus 平替<br/>通用任务 / 调研 / 交付物"]
    GOAL --> P1["支柱1 经典 ReAct 继承链<br/>Base→ReAct→ToolCall→特化<br/>短主循环、好读好改<br/>→ 第2/6章"]
    GOAL --> P2["支柱2 浏览器+搜索调研闭环<br/>browser_use 动作链 + 多引擎回退<br/>截图回灌 next_step<br/>→ 第5/7章"]
    GOAL --> P3["支柱3 规划流做长任务<br/>PlanningTool 内存计划<br/>多 Agent 按 [TYPE] 派单<br/>→ 第9章"]
    P1 & P2 & P3 --> BASE["地基：TOML 配置 + OpenAI 兼容 LLM<br/>+ MCP 双向 + Docker/Daytona 沙箱<br/>→ 第3/10/11章"]
    style GOAL fill:#e8f5e9
    style BASE fill:#eceff1
```

### 五个独特想法

**① 用继承链表达 Agent 品种，而不是用插件总线。** `ToolCallAgent` 一次实现 think/act，Manus/Browser/DataAnalysis/SWE 只换 prompt 和工具箱（`app/agent/`）。三小时原型能成立，靠的就是这层克制。

**② 浏览器不是附属工具，而是调研主传感器。** `web_search` 动作会搜索并跳转首结果（`browser_use_tool.py:251-268`），`extract_content` 二次调用 LLM 抽取（`browser_use_tool.py:375+`），`BrowserContextHelper` 把 URL/截图打回下一轮提示（`browser.py:47-71`）。这是 Manus 平替相对编程 Agent 的身份证明。

**③ 长任务规划外置为 Flow。** 单 Agent 20 步不够时，`PlanningFlow` 用 `PlanningTool` 维护步骤状态机（`planning.py` + `tool/planning.py`），每步重新 `run` 一个专员——简单、不稳定（README 直言），但把"规划"从 prompt 愿望清单提升成可标记的数据结构。

**④ MCP 双向门。** 既能 `run_mcp.py` 当客户端消费外部工具，也能 `run_mcp_server.py` 把 bash/browser/editor 贡献出去（`app/mcp/server.py`）——生态策略是协议接入，不是自建插件市场。

**⑤ 退出是一等工具。** `terminate` 进入 `special_tool_names`，执行后翻转 `AgentState.FINISHED`（`toolcall.py:210-218`）。模型被 prompt 明确教导"做完就 terminate"（`prompt/manus.py:9`）——比靠"模型不再调工具就算完"更显式。

### 主要取舍（代价）

- **无会话持久化 / 无崩溃恢复**：适合演示与单次作业，不适合常驻 IM 助手。
- **记忆只有 100 条硬截断**：无 LLM 压缩、无 prompt cache，长调研靠 Flow 计划文本续命。
- **安全默认偏开放**：浏览器 `disable_security` 默认 True；`python_execute` 非容器；Docker 沙箱默认关——要安全需用户显式打开 Daytona/Docker。
- **多 Agent 路由脆弱**：依赖步骤字符串里的 `[AGENT]` 标签；README 已标 unstable。
- **工具箱不对齐编程场景**：想当 Claude Code 用会觉得缺 grep/补丁/测试闭环；它的甜区是调研与交付物。

### 横向对比速览（对系列文档）

| 维度 | OpenManus |
|---|---|
| 团队 | MetaGPT 成员 / FoundationAgents；MIT |
| 技术栈 | Python 3.12 + asyncio + openai SDK + browser-use + MCP |
| 架构 | Agent 继承链 + 多入口脚本 + PlanningFlow（无 Bus/Spine） |
| 模型 | TOML 配置的 OpenAI 兼容端点；Azure/Bedrock 分支；无自研 40 家注册表 |
| 工具量级 | Manus 默认约 5 本地工具 + 动态 MCP；仓库内工具模块十余个 |
| 扩展 | 加 Agent/工具/MCP；FlowFactory 可扩 |
| 主循环 | `app/agent/base.py:116-154` + `react.py:33-38` + `toolcall.py:39-164` |
| 权限 | 无逐次审批；可选 Docker/Daytona；浏览器安全默认关闭 |
| 压缩记忆 | `Memory.max_messages=100` 硬截断；无摘要器 |
| prompt cache | **未找到** |

**一句话收束**：Claude Code 回答"怎么把代码写好"，nanobot/Raven 回答"怎么让个人 agent 长期住在你的聊天软件里"，而 **OpenManus 回答的是"怎么用一份可读的 ReAct 继承链，在无需邀请码的情况下，把浏览器调研、多引擎搜索和规划流串成一个开源的 Manus 平替"**——它不追求常驻与自进化，而追求**把通用任务 Agent 的骨架摊在桌面上，让你三小时也能看懂、一天也能改出自己的专员**。

### 与 Kortix/Suna：一句话选型

想三天读懂并改出专员 → **OpenManus**；想自托管多租户 AI 劳动力与公司仓库治理 → **Kortix/Suna**。二者同轴心、不同量级，详见《OpenManus-vs-Suna-横向对比》。

---

## 写作说明

- **文档版本**：加深版（2026-07-24）；相对初版显著加厚第 5/6/7/9 章，并补各章末 Kortix/Suna 短注。
- **分析基准**：本文全部结论基于 OpenManus 仓库 commit `52a13f2a57d8c7f6737eefb02ccf569594d44273`（`git -C 参考项目/OpenManus rev-parse HEAD`；提交时间 2026-01-04）。版本号以 `setup.py:9` 的 `0.1.0` 为准。
- **核对方式**：所有 `文件:行号` 均通过实际打开源码核对，相对路径以 `参考项目/OpenManus/` 为根。凡无法从源码确证之处（WebUI 主路径、逐次权限审批、prompt cache、对话 LLM 压缩、spawn 子代理、斜杠命令等），已在正文标注「未找到」，未做臆测。
- **产品对照**：与商业 Manus / Genspark / Claude Code 的对比基于 README 自述定位与工具箱差异归纳，不代表对各闭源产品内部实现的断言。

*全文完。*
