# OpenManus source analysis: a general-purpose research agent stand-in that needs no invite code

> **Subject**: [FoundationAgents/OpenManus](https://github.com/FoundationAgents/OpenManus)
> **Based on commit**: `52a13f2a57d8c7f6737eefb02ccf569594d44273` (2026-01-04, `Update README.md`; `setup.py:9` version `0.1.0`)
> **Analysis date**: 2026-07-24
> **Document version**: **deepened edition** (on the existing 12-chapter frame, Chapters 5/6/7/9 are substantially thickened; each chapter adds a short “vs Kortix/Suna” note; every claim can be traced to `file:line`)
> **Audience**: beginners with a little programming. Each chapter tries to keep the rhythm “metaphor → real source → line-by-line explanation → design trade-offs.”
> **Note**: this lecture uses the same 12-chapter frame as the sibling analyses of Claude Code / opencode / hermes-agent / Raven / CodeWhale / nanobot / goose / Suna, so you can compare them sideways. All `file:line` citations are relative to `参考项目/OpenManus/` and can be checked directly at this commit; anything that cannot be found or that we are not sure about is written out explicitly — never invented. For sideways selection, see the sibling lecture *OpenManus-vs-Suna side-by-side comparison*.
> **Product axis**: OpenManus aims at the commercial product **Manus** (general-task / research / deliverable Agent), not a Claude Code–style “write code first” coding agent — so browser, search, planning flow, and HTML/chart deliverables are more visible than a grep/diff toolbox.

---

> 🗺️ **Companion architecture gallery**: this project has a dedicated figure in the [**seven Agent architecture gallery**](../架构图库.html#ch7) — **the inheritance chain as a capability partial order + dual termination defenses**.
> Every figure in the gallery first writes “what question it answers” and “the load-bearing-wall thesis,” and has gone through three review rounds plus render acceptance.

## Chapter 1 · Project overview: what it is, who builds it

### One-sentence version

**OpenManus is the MetaGPT team members’ open-source “Manus stand-in”**: a general-purpose AI Agent written in Python that you can run locally without an invite code, MIT license (`LICENSE:1-3`, copyright “manna_and_poem”). The first sentence of the README is (`README.md:16`):

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

The Chinese README is equivalent (`README_zh.md:16`): “Manus is excellent, but OpenManus can realize any idea without an invite code.”

### Understand the positioning with a metaphor

If Claude Code is a “fine-dining sushi bar” (the menu is polished around writing code), and Genspark / commercial Manus is an “all-purpose travel agency + research shop” (search the web, write itineraries, deliver HTML handbooks), then OpenManus is more like a **street-stall all-purpose travel agency that was stood up in three hours, but whose kitchen is already fully stocked**:

- The counter is extremely simple: `python main.py` in a terminal, type one sentence, and it starts working;
- The back kitchen is classic **ReAct** (think one step → do one step → observe → think again);
- The toolbox leans toward “research and deliverables” — browser, multi-engine search, Python execution, file editing, ask a human, chart visualization — not a coding agent’s `grep` / `apply_patch` / LSP full family.

The official use case directly reuses Manus’s Japan-travel prompt, asking for “detailed itinerary + simple HTML travel handbook” (`examples/use_case/readme.md:8-11`) — that nails the product axis: **long-task research + deliverables**, not “fix a bug in a repo.”

### Who builds it

- **Team lineage**: core authors [@Xinbin Liang](https://github.com/mannaandpoem) (mannaandpoem) and [@Jinyu Xiang](https://github.com/XiangJinyu), plus [@Zhaoyang Yu](https://github.com/MoshiQAQ), [@Jiayi Zhang](https://github.com/didiforgithub), [@Sirui Hong](https://github.com/stellaHSR), from **[@MetaGPT](https://github.com/geekan/MetaGPT)** (`README.md:18`; `README_zh.md:18-19`).
- **Development cadence**: the README claims “the prototype went live in 3 hours and has been iterating since” (`README.md:18`; `README_zh.md:18-19`) — which explains why the architecture reads “textbook-clean”: a short inheritance chain, many entry scripts, no heavy scheduling spine.
- **Sister project**: [OpenManus-RL](https://github.com/OpenManus/OpenManus-RL) focuses on tuning LLM Agents with RL (e.g. GRPO), a collaboration between UIUC and OpenManus (`README.md:24`).
- **Org name**: the GitHub org is **FoundationAgents** (repo URL `FoundationAgents/OpenManus`, `setup.py:15`).
- **License**: MIT (`LICENSE:1`; badge at `README.md:9`).
- **Package metadata**: `setup.py:7-12` package name `openmanus`, version `0.1.0`, description “A versatile agent that can solve various tasks using multiple tools”.

### Differences from Manus / Genspark / coding agents (one-sentence level)

| Comparison | Same | Key difference |
|---|---|---|
| Commercial Manus | general tasks, browser research, deliverables (itinerary/HTML) | OpenManus is open-source and self-hostable; no invite code; capability and polish are a “stand-in prototype,” not a commercial product |
| Genspark-class | search + synthesized research | OpenManus nests search inside the `browser_use` action chain, and also has a standalone `WebSearch` tool and PlanningFlow |
| Claude Code / coding agents | both have an Agent loop + tool use + file editing | a coding agent’s toolbox is code navigation/patches/tests; OpenManus’s toolbox is browser/search/Python/visualization; `SWEAgent` is only an optional side branch (`app/agent/swe.py`) |

### Stack at a glance

| Layer | Tech | Location / evidence |
|---|---|---|
| Runtime language | Python ≥ 3.12, asyncio | `setup.py:43`; `requirements.txt`; `main.py:36` |
| Config | TOML (`tomllib`) + Pydantic + singleton `Config` | `app/config.py:197-372` |
| LLM SDK | **directly uses `openai` AsyncOpenAI / AsyncAzureOpenAI**; also an AWS Bedrock adapter | `app/llm.py:5-12,216-225`; `app/bedrock.py` |
| Retry | `tenacity` (up to 6 times, exponential backoff) | `app/llm.py:14-19,354-360` |
| Token counting | `tiktoken` + in-house `TokenCounter` (including image-tile estimates) | `app/llm.py:45-171`; `requirements.txt:9` |
| Browser | `browser-use` + Playwright | `requirements.txt:17,25`; `app/tool/browser_use_tool.py:6-9` |
| Search | Google / Baidu / DuckDuckGo / Bing four engines + cascade fallback | `app/tool/web_search.py:193-198`; `app/tool/search/` |
| Sandbox | local Docker sandbox + Daytona cloud sandbox (SandboxManus) | `app/sandbox/`; `app/daytona/`; `sandbox_main.py` |
| MCP | official `mcp` SDK (client + FastMCP server) | `requirements.txt:31`; `app/tool/mcp.py`; `app/mcp/server.py` |
| Protocol experiment | A2A (Agent-to-Agent) thin wrapper | `protocol/a2a/app/agent.py` |
| Visualization | chart_visualization (Python + frontend render, HTML/png) | `app/tool/chart_visualization/` |

```mermaid
flowchart LR
    subgraph OM["OpenManus (this project)"]
        E1["main / run_flow / run_mcp / sandbox_main"] --> A["Agent inheritance chain<br/>Base→ReAct→ToolCall→Manus"]
        A --> L["LLM singleton<br/>ask / ask_tool / ask_with_images"]
        A --> T["Toolbox<br/>browser / python / editor / search / MCP"]
        E1 --> F["PlanningFlow<br/>long-task planning + multi-agent"]
        T --> S["Docker / Daytona sandbox"]
    end
    style OM fill:#e8f5e9
```

### Contrast with Kortix/Suna (short note)

Same axis (general tasks / research / deliverables), different bets: OpenManus bets on “a ReAct inheritance chain you can understand in three hours”; Kortix/Suna bets on “the company repo + one session, one sandbox + change request.” On license, OpenManus is MIT; Kortix is Elastic License 2.0. Details in *OpenManus-vs-Suna side-by-side comparison*.

---

## Chapter 2 · Panorama architecture: a layered map

### Metaphor: a “four-flight staircase” inheritance chain + several different front doors

OpenManus does not have a MessageBus / Spine scheduling hub the way Raven/nanobot do. Its skeleton is a **textbook Agent inheritance chain**, with **multiple entry scripts** hung outside as different doors:

- **Doors**: `main.py` (general Manus), `run_flow.py` (planning multi-agent), `run_mcp.py` (pure MCP client), `sandbox_main.py` (Daytona sandbox edition), `run_mcp_server.py` (expose itself as an MCP server);
- **Staircase**: `BaseAgent` → `ReActAgent` → `ToolCallAgent` → concrete Agents (`Manus` / `BrowserAgent` / `MCPAgent` / `DataAnalysis` / `SWEAgent` / `SandboxManus`);
- **Kitchen**: `app/tool/` tool collection;
- **Warehouse**: `Memory` (an in-memory message list, not an EverOS-style long-term memory store).

```mermaid
flowchart TB
    subgraph ENTRY["Entry layer (several doors)"]
        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 inheritance staircase"]
        B["BaseAgent<br/>run loop / state machine / stuck detection"]
        R["ReActAgent<br/>think + act = step"]
        TC["ToolCallAgent<br/>ask_tool + execute tools"]
        MAN["Manus / Browser / MCP / DataAnalysis / SWE / SandboxManus"]
        B --> R --> TC --> MAN
    end

    subgraph FLOW["Planning orchestration layer"]
        FF["FlowFactory"]
        PF["PlanningFlow<br/>create plan → dispatch step by step → finalize"]
        PT["PlanningTool<br/>in-memory plan dict"]
        FF --> PF --> PT
    end

    subgraph TOOL["Tool layer 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["Infrastructure"]
        LLM["app/llm.py LLM singleton"]
        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/` directory responsibilities (labeled one by one)

| Directory / file | Responsibility | Key files |
|---|---|---|
| `app/agent/` | Agent inheritance system and specialized Agents | `base.py`, `react.py`, `toolcall.py`, `manus.py`, `browser.py`, `mcp.py`, `data_analysis.py`, `sandbox_agent.py`, `swe.py` |
| `app/flow/` | multi-agent planning flow | `base.py`, `planning.py`, `flow_factory.py` |
| `app/tool/` | all tool implementations | `browser_use_tool.py`, `web_search.py`, `python_execute.py`, `str_replace_editor.py`, `planning.py`, `mcp.py`, `sandbox/`, `chart_visualization/` |
| `app/prompt/` | each Agent’s system / next_step prompts | `manus.py`, `browser.py`, `planning.py`, `mcp.py`, `visualization.py`, `swe.py` |
| `app/llm.py` | LLM wrapper (ask / ask_tool / multimodal / token budget) | 766 lines |
| `app/config.py` | global config singleton | LLM / browser / search / sandbox / MCP / runflow / daytona |
| `app/schema.py` | Message, Memory, AgentState, ToolCall | — |
| `app/sandbox/` | Docker sandbox core | `core/sandbox.py`, `core/manager.py`, `client.py` |
| `app/daytona/` | Daytona cloud sandbox | `sandbox.py`, `tool_base.py` |
| `app/mcp/` | expose this repo’s tools as an MCP Server | `server.py` |
| `app/bedrock.py` | AWS Bedrock client adapter | — |
| `protocol/a2a/` | Agent-to-Agent protocol thin wrapper | `app/agent.py`, `app/main.py` |

The public Agents exported by `app/agent/__init__.py:9-16` are: `BaseAgent`, `BrowserAgent`, `ReActAgent`, `SWEAgent`, `ToolCallAgent`, `MCPAgent` — note that **`Manus` / `DataAnalysis` / `SandboxManus` do not appear in `__all__`**, but the entry scripts import them directly from their own modules.

### Agent inheritance system (the key to the whole project)

```text
BaseAgent          # state, memory, max_steps main loop, stuck detection
  └─ ReActAgent    # split step() into think() + act()
       └─ ToolCallAgent  # think=ask_tool; act=execute tool_calls
            ├─ Manus           # general: Python+Browser+Editor+AskHuman+MCP
            ├─ BrowserAgent    # browser specialist
            ├─ MCPAgent        # pure MCP tools
            ├─ DataAnalysis    # data analysis/charts/reports
            ├─ SWEAgent        # Bash+Editor coding side branch
            └─ SandboxManus    # Daytona sandbox-edition tools
```

Evidence:

- `BaseAgent` defines `run` / `step` / `is_stuck` (`app/agent/base.py:13-186`)
- `ReActAgent.step` calls `think` then `act` (`app/agent/react.py:33-38`)
- `ToolCallAgent` implements full tool-calling (`app/agent/toolcall.py:18-250`)
- `Manus(ToolCallAgent)` (`app/agent/manus.py:18`)

### Three “layering disciplines”

1. **Capability is swapped by changing the Agent / toolbox, not the scheduling framework.** General tasks use `Manus`; data analysis swaps in `DataAnalysis`; sandbox swaps in `SandboxManus` — the main loop is always `BaseAgent.run`.
2. **Long-task planning is a Flow, not an Agent.** A single Agent relies on `next_step_prompt` to let the model split steps itself; cross-Agent long tasks go through `PlanningFlow` (`app/flow/planning.py`); the plan lives in the `PlanningTool.plans` dict.
3. **Config singleton + LLM singleton.** `config = Config()` (`app/config.py:372`) double-checked with a thread lock; `LLM._instances` is cached by `config_name` (`app/llm.py:175-184`). In the same process, a same-named LLM is initialized only once.

### Process / thread model

- **Single-process asyncio**: every entry is `asyncio.run(...)` (`main.py:36`, `run_flow.py:52`, `run_mcp.py:116`).
- **Python execution uses a subprocess**: `PythonExecute` uses `multiprocessing.Process` + timeout `terminate` (`app/tool/python_execute.py:55-74`).
- **The browser is a long-lived object**: `BrowserUseTool` holds `browser` / `context`, reused across tool calls until `cleanup` (`app/tool/browser_use_tool.py:124-127`).
- **Docker / Daytona sandbox**: a local Docker container or a remote Daytona sandbox, enabled by config.

### Design trade-offs

- **No message bus / session persistence layer**: one `run(prompt)` goes from IDLE to the end; history lives only in in-memory `Memory.messages`. Compared with nanobot/Raven’s session JSONL + crash recovery, OpenManus is more like a “one-shot job script.”
- **Multiple entries rather than a unified CLI**: no typer command tree; instead several top-level `.py` scripts. The benefit is that each entry is readable; the cost is that users must remember which door to open.

### Contrast with Kortix/Suna (short note)

OpenManus is an “in-process staircase”; Kortix is “API session-lifecycle → sandbox → OpenCode.” The former is good for reading the main loop; the latter is good for multi-tenant production isolation.

---

## Chapter 3 · Startup flow: from command to UI

### Metaphor: four doors into the same building

OpenManus does not have “one startup mode that rules them all.” Which door you pick decides which Agent variety you meet after entering. The real “UI” is almost just terminal `input()` — a WebUI / TUI main path is **not found** (the Hugging Face Demo is an external deployment, not in this repo’s runtime).

### Door 1: `main.py` — default general Manus

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

Key steps:

1. `argparse` optional `--prompt` (`main.py:10-14`); if absent, interactive input (`main.py:20`).
2. **`Manus.create()` factory** (`app/agent/manus.py:59-65`): construct instance → `initialize_mcp_servers()` connects the MCP configured in `config/mcp.json` → mark `_initialized=True`.
3. Empty prompt returns immediately with a warning (`main.py:21-23`).
4. `KeyboardInterrupt` is caught then warned (`main.py:28-29`); `finally` always calls `cleanup()` (`main.py:30-32`).
5. `setup.py:44-47` also registers the console script `openmanus=main:main`, but `main` is an async function — **whether setuptools can actually call it as a synchronous entry is questionable** (`main.py:8` is `async def main`); everyday docs recommend `python main.py` (`README.md:123-124`).

### Door 2: `run_flow.py` — the unstable multi-agent planning flow

The README explicitly writes “unstable multi-agent version” (`README.md:134-137`). Flow:

1. Assemble `agents = {"manus": Manus()}` (`run_flow.py:12-14`) — note this uses **`Manus()` direct construction, not `Manus.create()`**, so this path **by default does not go through the MCP initialization factory** (unless later `think` lazy-loads, see `manus.py:142-144`).
2. If `config.run_flow_config.use_data_analysis_agent` is true, also hang `DataAnalysis()` (`run_flow.py:15-16`; config item `config.example.toml:112-113`).
3. `FlowFactory.create_flow(FlowType.PLANNING, agents=...)` (`run_flow.py:24-27`; the factory only supports `PLANNING`, `app/flow/flow_factory.py:9-30`).
4. **Whole-run outer timeout 3600 seconds** (`run_flow.py:32-35`); on timeout, print a friendly hint (`run_flow.py:39-43`).

### Door 3: `run_mcp.py` — pure MCP Agent

`MCPRunner` (`run_mcp.py:11-66`) wraps three run modes:

| Mode | Trigger | Behavior |
|---|---|---|
| Single prompt | `--prompt` / `-p` | `run_single_prompt` |
| Interactive loop | `--interactive` / `-i` | loop `input`, exit on `exit/quit/q` |
| Default | none of the above | one `input` then `run` |

Connection types:

- **stdio (default)**: run `-m {server_reference}` with the current Python interpreter; default module `app.mcp.server` (`run_mcp.py:27-32`; `app/config.py:141-143`).
- **sse**: connect `--server-url` (default `http://127.0.0.1:8000/sse`, `run_mcp.py:79-83`).

### Door 4: `sandbox_main.py` — Daytona sandbox edition

The structure is almost a mirror of `main.py`; only the Agent is swapped to `SandboxManus.create()` (`sandbox_main.py:17`). `create` initializes both MCP and Daytona sandbox tools (`app/agent/sandbox_agent.py:63-70`).

### Door 5: `run_mcp_server.py` — reverse: be an MCP server yourself

Extremely short entry (`run_mcp_server.py:1-11`): parse `--transport` (currently `choices` is only `stdio`, `app/mcp/server.py:166-171`) → `MCPServer().run(transport=...)`. The server registers the bash / browser / editor / terminate four-piece set (`app/mcp/server.py:31-35`).

### Config load timing (every door steps on this first)

1. Any `from app.config import config` triggers singleton `__init__` (`app/config.py:209-215`).
2. `_get_config_path`: prefer `config/config.toml`, otherwise fall back to `config.example.toml` (`app/config.py:218-226`).
3. Parse LLM / browser / search / sandbox / mcp / runflow / daytona (`app/config.py:233-329`).
4. MCP servers additionally load from the `mcpServers` field of `config/mcp.json` (`app/config.py:149-169`).

```mermaid
sequenceDiagram
    participant U as User
    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: tools merged into available_tools
    Main->>U: input("Enter your prompt: ") (if no --prompt)
    Main->>Run: await agent.run(prompt)
    Run-->>Main: stepwise result string
    Main->>Manus: await cleanup()
```

### Design trade-offs

- **Multiple entry scripts vs a unified CLI**: slightly higher learning cost, but each path has zero magic.
- **`run_flow`’s `Manus()` vs `main`’s `Manus.create()`**: the two paths initialize inconsistently — the flow side depends on lazy init inside `think` (`manus.py:142-144`), a “it runs but is easy to step on” detail.

### Contrast with Kortix/Suna (short note)

OpenManus startup is terminal `input()`; Kortix enters a session via `kortix init/ship` and Web/CLI/Slack multi-channel. Cold-start weight differs by an order of magnitude.

---

## Chapter 4 · Input capture and fan-out: the journey of one sentence

### Metaphor: no front-desk extensions, only “stuff the slip of paper straight to the clerk”

nanobot/Raven have Channel → Bus → Lane fan-out; OpenManus **almost has no fan-out layer**. User input is a single string that becomes one user message in `Memory`.

### Main path (`main.py`)

1. Capture: `args.prompt` or `input("Enter your prompt: ")` (`main.py:20`).
2. Empty-string check: `if not prompt.strip()` → return (`main.py:21-23`).
3. Enter `agent.run(prompt)` (`main.py:26`).
4. `BaseAgent.run`: if there is a `request`, `update_memory("user", request)` (`app/agent/base.py:131-132`).
5. `update_memory` maps role through `message_map` to factories such as `Message.user_message` (`app/agent/base.py:102-114`).

### Flow path’s “second wrapping”

`PlanningFlow` does not hand the user’s original words as-is to the executing Agent; instead:

1. First generate a plan with LLM + `PlanningTool` (`app/flow/planning.py:136-211`);
2. Each step constructs a `step_prompt`, stuffing “current plan status + you are only responsible for this step” into `executor.run(step_prompt)` (`app/flow/planning.py:277-296`).

That is: **one user sentence → plan text → several rewritten sub-task prompts**. This is where OpenManus actually does “long-task fan-out” — fan-out happens in Flow, not in a Channel.

### MCP interactive path

`run_interactive` each round `input` → `agent.run(user_input)` → `print` the response (`run_mcp.py:38-46`). Exit words: `exit` / `quit` / `q` (`run_mcp.py:43-44`).

### AskHuman: human-in-the-loop recirculation inside the loop

The `ask_human` tool, when executing, again `input(f"Bot: {inquire}\n\nYou: ")` (`app/tool/ask_human.py:20-21`). This is **synchronous blocking input inside the main loop** — when the model feels it needs a human, the terminal becomes a dialog again. `Manus`’s default toolbox includes it (`app/agent/manus.py:39`).

### Fan-out capabilities **not found**

- IM platform adapters (Telegram/Feishu etc.) **not found**.
- Slash-command routing (`/stop`, `/model`, and the like) **not found**.
- Multi-session concurrent scheduling **not found**; `BaseAgent.run` requires the starting state to be `IDLE`, otherwise `RuntimeError` (`app/agent/base.py:128-129`).

### Contrast with Kortix/Suna (short note)

OpenManus almost has no fan-out layer; Kortix has channel bindings and a session lifecycle. One sentence into the Agent — both have it; “that Slack ticket from yesterday” only Kortix can continue directly.

---

## Chapter 5 · Context assembly: packing for the model

### Metaphor: before each think, staple together “job manual + work notes + this-round reminder” into one stack of paper

OpenManus has no independent `ContextBuilder` / `ContextGovernor`. Assembly logic is scattered in three places, plus “dynamic page-swap” in the browser situation:

1. **System prompt**: Agent field `system_prompt`, wrapped as `Message.system_message` when `think` calls `ask_tool` (`app/agent/toolcall.py:47-53`).
2. **History**: `self.messages` is `self.memory.messages` (`app/agent/base.py:188-196`).
3. **Next-step reminder**: at the start of each `think`, if `next_step_prompt` exists, first `Message.user_message(self.next_step_prompt)` **appended into messages** (`app/agent/toolcall.py:41-43`).
4. **Browser dynamic page**: `Manus.think` / `BrowserAgent.think` temporarily rewrite `next_step_prompt`, and can stuff a screenshot into Memory (see 5.3).

Stack these four layers and you have the “world the model sees this round.”

### 5.1 Message / Memory: paper and notebook

#### Message fields (`app/schema.py:54-62`)

| Field | Use |
|---|---|
| `role` | `system` / `user` / `assistant` / `tool` (`Role` enum, `schema.py:7-14`) |
| `content` | text body |
| `tool_calls` | list of tool calls initiated by the assistant |
| `name` / `tool_call_id` | for tool receipts |
| `base64_image` | multimodal image (browser screenshots etc.) |

Factory methods: `user_message` / `system_message` / `assistant_message` / `tool_message` / `from_tool_calls` (`schema.py:99-156`). `from_tool_calls` formats the LLM’s raw tool_calls into `{"id","function","type":"function"}` then writes them into the assistant message (`schema.py:146-155`).

`Message` also overloads `+` / `__radd__`, supporting `Message + list` or `list + Message` (`schema.py:64-82`) — that is why `toolcall.py:43` writing `self.messages += [user_msg]` works: the `messages` property returns a list; the setter writes back `memory.messages` (`base.py:188-196`).

#### Memory: sliding-window notebook (`app/schema.py:159-187`)

```python
# schema.py:163-168 simplified
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 :]
```

- Default `max_messages=100` (`schema.py:161`).
- Over limit keeps only the **last N messages** — not a summary, a hard truncate.
- Also `add_messages` / `clear` / `get_recent_messages` / `to_dict_list` (`schema.py:170-187`).

`BaseAgent.update_memory` (`base.py:84-114`) is the unified entry for writing in the notebook: pick a factory by role; the tool role additionally passes through kwargs such as `tool_call_id`.

### 5.2 System prompt and next_step_prompt: job manual vs this-round sticky note

#### Manus’s job manual (`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` is formatted with `config.workspace_root` at class-field init (`app/agent/manus.py:24`; `app/config.py:16,362-364` → `workspace/` under the project).

`NEXT_STEP_PROMPT` (`app/prompt/manus.py:6-10`) requires: actively pick tools, split complex tasks into steps, explain results after using a tool, and call `terminate` when you want to finish.

#### ToolCallAgent default prompt (thinner)

`app/prompt/toolcall.py` is only 5 lines: a minimal SYSTEM / NEXT_STEP. Concrete Agents all override it with their own prompt.

#### Key mechanism: system usually does not enter Memory

`ToolCallAgent.think` passes system as the `ask_tool(..., system_msgs=[...])` parameter (`toolcall.py:49-52`), **does not** `add_message` into memory. The next round injects it again. The exception is `MCPAgent.initialize`: it writes system + the tool-name list into memory (`app/agent/mcp.py:81-85`).

#### Key mechanism: next_step is **appended as a user message every round**

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

That means: run 20 steps, and history may contain nearly 20 almost-identical “sticky notes.” They will:

1. Eat the `Memory.max_messages=100` quota;
2. Count toward `ask_tool`’s accumulated tokens;
3. In browser mode be temporarily swapped for a longer `BrowserContextHelper` version (see below).

### 5.3 BrowserContextHelper: stuff “the page the eyes see” back into the prompt

File: `app/agent/browser.py:19-84`.

#### Init mount

- `Manus` creates the helper in `model_validator` (`manus.py:53-57`).
- `BrowserAgent` likewise (`browser.py:115-118`).

#### `get_browser_state` (`browser.py:24-45`)

1. First look for `BrowserUseTool`, then `SandboxBrowserTool` (`browser.py:25-29`).
2. Call `browser_tool.get_current_state()` (`browser_use_tool.py:479-537`).
3. If there is a `base64_image`, stash it in the helper’s `_current_base64_image` (`browser.py:38-41`).
4. `json.loads(result.output)` yields a state dict (`browser.py:42`).

Fields actually packed by `get_current_state` (`browser_use_tool.py:514-532`):

| Key | Meaning |
|---|---|
| `url` / `title` | current page |
| `tabs` | tab list |
| `interactive_elements` | clickable-element tree string |
| `scroll_info.pixels_above/below` | how many pixels remain above/below the viewport |
| `viewport_height` | viewport height |
| (also attached) `base64_image` | **full-page JPEG screenshot** (`full_page=True`, `browser_use_tool.py:507-511`) |

#### `format_next_step_prompt` (`browser.py:47-79`)

1. Pull browser_state; compose URL/title, tab count, above/below scroll-pixel description (`browser.py:53-63`).
2. **If there is a screenshot**: construct `Message.user_message(content="Current browser screenshot:", base64_image=...)` and `memory.add_message` (`browser.py:65-71`), then clear `_current_base64_image`.
3. `.format(...)` with `NEXT_STEP_PROMPT` from `app/prompt/browser.py` (`browser.py:73-79`), filling `url_placeholder` / `tabs_placeholder` / `content_above_placeholder` / `content_below_placeholder` / `results_placeholder`.

The browser-specific `NEXT_STEP_PROMPT` (`app/prompt/browser.py:73-94`) not only reminds of current state, it **directly teaches the model how to call `browser_use` actions** (go_to_url / click_element / input_text / extract_content / scroll), and again emphasizes ending with `terminate`.

The browser `SYSTEM_PROMPT` (`app/prompt/browser.py:1-71`) is extremely long: it specifies JSON response format, element-index interaction, done rules, visual context — clearly borrowing agent norms from the browser-use ecosystem. Note: although `BrowserAgent` hangs this system, the actual tool loop uses OpenAI tool-calling, not that JSON action-list protocol; system is more like a “behavior-spec manual”; real doing relies on the `browser_use` tool schema.

### 5.4 Manus.think: conditional page-swap, restore after use

`app/agent/manus.py:140-165` is a highlight of context assembly:

```python
# manus.py:140-165 logic summary
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  # restore
return result
```

Point by point:

1. **Lazy-init MCP**: when the `run_flow` path uses `Manus()` rather than `create()`, the first think is when MCP is connected (`manus.py:142-144`).
2. **Browser-in-use judgment**: only looks at whether tool_calls in the **last 3 messages** contain `browser_use` (`manus.py:147-153`). An ask_human / python_execute in the middle may flip the judgment to false, temporarily exiting browser-prompt mode.
3. **Temporarily swap prompt → super().think() → restore** (`manus.py:155-163`): avoid the long browser prompt polluting later non-browser steps.
4. `BrowserAgent` swaps every round (`browser.py:120-125`) and does not restore — because it is a browser specialist to begin with.

### 5.5 Final packing on the LLM side: `ask_tool`

`LLM.ask_tool` (`app/llm.py:644-747`) is the “stapler”:

1. Validate `tool_choice` ∈ `TOOL_CHOICE_VALUES` (`llm.py:677-678`; enum `schema.py:20-25`).
2. `format_messages`: `Message` → OpenAI dict; if the model is in `MULTIMODAL_MODELS` and the message carries `base64_image`, convert to an `image_url` content part (`llm.py:267-352,680-688`). If not on the whitelist, drop the image field (`llm.py:337-339`).
3. Count tokens of messages + tools (`llm.py:690-699`).
4. If over the accumulated `max_input_tokens` budget, raise `TokenLimitExceeded` (`llm.py:701-705`; exception `app/exceptions.py:12-13`).
5. Assemble `params`: `tools` / `tool_choice` / `timeout=300` (`llm.py:714-720`).
6. Reasoning models use `max_completion_tokens` and do not pass temperature (`llm.py:723-729`).
7. **Force `stream=False`** (`llm.py:731`) — the tool loop needs complete `tool_calls`.
8. Update accumulated tokens from `usage` in the response (`llm.py:742-745`).

`check_token_limit` (`llm.py:249-254`): only takes effect when `max_input_tokens` is configured; if not configured, always let through.

### 5.6 Context timeline of one 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: detect whether last 3 messages contain browser_use
    alt browser in use
        M->>H: format_next_step_prompt()
        H->>H: get_current_state + optional screenshot
        H->>Mem: add_message(screenshot user)
        H-->>M: browser-edition next_step_prompt
    end
    M->>TC: super().think()
    TC->>Mem: append next_step as user message
    TC->>LLM: ask_tool(messages, system_msgs, tools)
    LLM-->>TC: content + tool_calls
    TC->>Mem: add assistant / from_tool_calls
    M->>M: restore original next_step_prompt
```

### 5.7 Design trade-offs

- **next_step appended as a user message every round**: simple to implement, but history repeatedly contains similar reminders, accelerating approach to the `Memory.max_messages=100` truncate line (see Chapter 8).
- **System separated from history**: system is injected by `ask_tool` each time, not permanently stored in memory — except MCPAgent.
- **Screenshots into memory**: the browser closed loop needs vision, but also burns tokens sharply; `max_observe` cannot truncate the image itself; only the model whitelist and accumulated token budget backstop it.
- **“Last 3 messages” heuristic**: lightweight, but may mis-judge “whether the browser phase has ended.”

> 🧠 **One sentence**: the model has no memory; the “world” it sees each round is stacked temporarily — system prompt passed as a parameter, history rolling in Memory, sticky note appended every round, and browser state can still dynamically swap the page.

### Contrast with Kortix/Suna (short note)

OpenManus context is “in-process Message list + a sticky note every round”; Kortix/Suna puts long-term truth in the company repo and session sandbox files; conversation context is managed by the platform session + OpenCode. OpenManus is more transparent and easier to walk through in this file; Kortix is better for continuing a chat across days and channels.

---
## Chapter 6 · The agent main loop: the project’s heart

> This is the most detailed chapter in the lecture. OpenManus’s “heart” is not some 2000-line runner; it is a short loop stacked from **`BaseAgent.run` + `ReActAgent.step` + `ToolCallAgent.think/act`**. Below we walk the files section by section.

### Metaphor: a piece-worker’s shift

- **Shift budget**: `max_steps` (default Base 10, ToolCallAgent 30, Manus 20).
- **Each piece**: first `think` (ask the model whether to act, which tool), then `act` (really execute).
- **End-of-shift bell**: the model calls `terminate` → state `FINISHED`; or steps are exhausted; or token budget; or after getting stuck it may still continue until the budget is spent.

```mermaid
flowchart TD
    START["BaseAgent.run(request)"] --> GATE{"state == IDLE?"}
    GATE -- no --> ERR["RuntimeError"]
    GATE -- yes --> MEM["update_memory user"]
    MEM --> CTX["state_context(RUNNING)"]
    CTX --> LOOP{"current_step < max_steps<br/>AND state != FINISHED?"}
    LOOP -- no --> ENDCHK{"current_step >= max_steps?"}
    ENDCHK -- yes --> TERM["reset step=0, state=IDLE<br/>append Terminated copy"]
    ENDCHK -- no --> JOIN["join results"]
    TERM --> JOIN
    JOIN --> SBX["SANDBOX_CLIENT.cleanup()"]
    LOOP -- yes --> INC["current_step += 1"]
    INC --> STEP["ReActAgent.step"]
    STEP --> THINK["ToolCallAgent.think"]
    THINK --> ACTQ{"should_act?"}
    ACTQ -- no --> STUCK["is_stuck? → handle_stuck_state"]
    ACTQ -- yes --> ACT["act: serial execute_tool"]
    ACT --> SPEC{"special tool / terminate?"}
    SPEC -- yes --> FIN["state = FINISHED"]
    SPEC -- no --> STUCK
    FIN --> STUCK
    STUCK --> LOOP
```

---

### 6.0 First look at a real task: from “research Kyoto” to “hand over HTML”

Before walking files, first see how a concrete task finishes turning in this loop — so that later, every source section you know where it sits on the whole board.

You say to Manus: **“Help me research 3 must-see spots in Kyoto, and produce an HTML handbook.”** By default it has only 5 tools (`python_execute` / `browser_use` / `str_replace_editor` / `ask_human` / `terminate`). Watch it turn four rounds:

| Round | History sent to the model (accumulated) | Model decision (think) | Loop action (act) |
|---|---|---|---|
| Round 1 | system prompt + your task | `browser_use(action="web_search", query="京都 必去 景点")` | search + **automatically jump to the first URL** (`browser_use_tool.py:251-268`), write the result back into history |
| Round 2 | round 1 + search results | `browser_use(action="extract_content", goal="景点名称与简介")` | page → Markdown → **call the LLM once more inside the tool to extract** (`:375-444`) → write key points back into history |
| Round 3 | first two rounds + extracted points | `str_replace_editor(command="create", path=".../itinerary.html", ...)` | HTML lands on disk; receipt written back into history |
| Round 4 | all of the first three rounds | `terminate(status="success")` | special tool flips state `FINISHED` (`toolcall.py:210-227`) → exit |

A subtle detail that only Manus has is hidden between rounds 2 and 3: `Manus.think` checks **whether the last 3 messages used `browser_use`** (`manus.py:147-153`); once it finds “just using the browser,” it temporarily swaps the sticky note to the browser-specific edition, and may even stuff a full-page screenshot into this round’s context, then swaps back immediately after use. That is: **context dynamically swaps pages with “which hand is currently in use.”**

```mermaid
sequenceDiagram
    participant LP as Main loop
    participant Think as Manus.think
    participant LLM as Model
    participant Tool as Tool execution

    LP->>Think: round 2 starts
    Think->>Think: last 3 used browser_use? → yes
    Think->>Think: temporarily swap on “browser-edition sticky note” + screenshot
    Think->>LLM: ask “next step” with page state
    LLM-->>Think: extract_content(goal=...)
    Think->>Think: done; swap sticky note back
    Think->>Tool: execute extract_content
    Tool->>LLM: inside the tool, call LLM once more to extract points
    LLM-->>Tool: structured points
    Tool-->>LP: write points back into history → enter round 3
```

Read this figure in one sentence: **the same loop, because “the previous step used the browser,” temporarily gives the model a prompt that understands the browser better — this is the key action by which Manus deepens “research.”**

---

### 6.1 File-by-file walk: `app/agent/base.py`

#### Fields and validation (`base.py:20-56`)

| Field | Default | Meaning |
|---|---|---|
| `name` / `description` | required / optional | Agent identity |
| `system_prompt` / `next_step_prompt` | None | job manual / sticky note |
| `llm` | `Field(default_factory=LLM)` | model client |
| `memory` | `Memory()` | message notebook |
| `state` | `IDLE` | state machine |
| `max_steps` | 10 | shift budget |
| `current_step` | 0 | current step |
| `duplicate_threshold` | 2 | stuck-judgment threshold |

`Config.extra = "allow"` (`base.py:45-47`) lets subclasses stuff extra fields (e.g. Manus’s `mcp_clients`). `initialize_agent` validator: if llm/memory types are wrong, rebuild (`base.py:49-56`).

#### `state_context`: a state switch that looks safe (`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  # note: always returns to previous
```

**Trade-off / trap**: `finally` **restores state to the previous it entered with**. Therefore:

- After `terminate` inside the loop sets `state` to `FINISHED`, the while can exit correctly (because the exit condition was already evaluated before `finally`); but after leaving `async with`, state is changed back to the `IDLE` it entered with (`previous_state`).
- The `ERROR` set in `except` is also overwritten by `finally` back to previous.

That is: **`FINISHED` / `ERROR` mainly act on control flow inside the `run` loop**; after leaving `state_context`, the externally visible state is often IDLE again. This is easy to misunderstand when reading source, and worth being careful when changing code.

#### `run`: outer shift loop (`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"
```

| Mechanism | Behavior | Location |
|---|---|---|
| State gate | non-IDLE cannot `run` | `base.py:128-129` |
| Inject user request | `update_memory("user", request)` | `base.py:131-132` |
| Step budget | `current_step < max_steps` | `base.py:136-139` |
| Normal end | `state == FINISHED` exits while | `base.py:137` |
| Stuck detection | after each step `is_stuck` → `handle_stuck_state` | `base.py:144-145` |
| Budget exhausted | reset `current_step=0`, state back to IDLE, append Terminated copy | `base.py:149-152` |
| Sandbox cleanup | every run end `SANDBOX_CLIENT.cleanup()` | `base.py:153` |

Note: when the budget is exhausted it zeros `current_step` (`base.py:150`), so the same Agent instance can `run` again; but **Memory is not automatically cleared** — if you reuse the same instance for two consecutive tasks, history will mix flavors. PlanningFlow `executor.run(...)` every step exactly steps on this point (see Chapter 9).

#### Stuck detection (`base.py:163-186`)

`is_stuck`:

1. Fewer than 2 messages → False (`base.py:172-173`).
2. Take the last one; no content → False (`base.py:175-177`).
3. In **earlier messages**, count backwards: times `role=="assistant"` and `content` is exactly the same as the last one (`base.py:180-184`).
4. `duplicate_count >= duplicate_threshold` (default 2) → True (`base.py:186`).

`handle_stuck_state` (`base.py:163-168`): prefix

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

**onto `next_step_prompt`** — it does not force termination; it only pushes the model to change tactics. The next think will stuff the lengthened sticky note into a user message again.

---

### 6.2 File-by-file walk: `app/agent/react.py`

The whole file is only about 39 lines. `ReActAgent` inherits `BaseAgent` and splits abstract `step` into two abstract methods:

```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()
```

This is the minimal classic ReAct: **think returns a bool, deciding whether this round executes an action**. Fields (`system_prompt` / `max_steps` etc.) are redeclared in subclasses (`react.py:12-23`); under Pydantic inheritance the subclass wins.

Design trade-off: splitting “think” and “do” lets `Manus.think` only wrap context and not touch execution; it also makes it easy to mock one side in tests.

---

### 6.3 File-by-file walk: `app/agent/toolcall.py` — think

`ToolCallAgent` (`toolcall.py:18-37`) default toolbox is only `CreateChatCompletion` + `Terminate`; `tool_choices=AUTO`; `special_tool_names=[Terminate().name]`; `max_steps=30`; `max_observe` optional.

#### think decision tree (`toolcall.py:39-129`)

```mermaid
flowchart TD
    A["append next_step_prompt as user message"] --> B["llm.ask_tool(messages, system, tools)"]
    B --> C{"exception?"}
    C -- TokenLimitExceeded via RetryError.__cause__ --> D["write assistant explanation → state=FINISHED → return False"]
    C -- ValueError --> RE["raise directly"]
    C -- other --> RE2["raise"]
    B --> E["parse content + tool_calls"]
    E --> F{"tool_choices?"}
    F -- NONE --> G["if content, record message return True; else False<br/>if tool_calls, only warning"]
    F -- REQUIRED --> H["write assistant; even without tool_calls return True<br/>leave TOOL_CALL_REQUIRED for act to raise"]
    F -- AUTO --> I{"has tool_calls?"}
    I -- yes --> J["from_tool_calls write → return True"]
    I -- no but has content --> K["assistant_message → return True<br/>act will return the text directly"]
    I -- none and no content --> L["return False"]
    E --> ERR["processing exception → write Error assistant message → return False"]
```

Key details:

1. **Token-budget path** (`toolcall.py:57-72`): `ask_tool`’s `@retry` comment says Don’t retry `TokenLimitExceeded` (`llm.py:641-642`), but tenacity may still wrap the exception in `RetryError`. think unpacks with `hasattr(e, "__cause__") and isinstance(e.__cause__, TokenLimitExceeded)`; on hit, write an assistant explanation, `state=FINISHED`, `return False` — **graceful end of shift, no stack explosion**.
2. **`ValueError` re-raised directly** (`toolcall.py:57-58`) — e.g. illegal tool_choice.
3. **Logging**: prints thoughts, tool count, tool-name list, arguments of the **first** tool (`toolcall.py:81-89`) — later tools’ args are executed without preview.
4. **`response is None`**: raise `RuntimeError("No response received from the LLM")` (`toolcall.py:92-93`); `ask_tool` `return None` when choices are empty (`llm.py:737-740`).
5. **AUTO and no tool_calls but has content**: `return bool(content)` (`toolcall.py:118-119`) → enter act → act finds no tool_calls → return the last content (`toolcall.py:137-138`). This allows a “pure-text close” without forcing terminate; but state does not automatically FINISHED, so the loop continues until the model calls terminate or steps are exhausted.
6. **Inner except in think** (`toolcall.py:122-129`): swallow processing exceptions, write an Error message, `return False` — this step becomes “Thinking complete - no action needed”; the outer loop continues.

---

### 6.4 File-by-file walk: `toolcall.py` — act / execute_tool / special tools

#### `act` (`toolcall.py:131-164`)

1. No tool_calls: if REQUIRED → `raise ValueError(TOOL_CALL_REQUIRED)` (constant at `toolcall.py:15`); otherwise return the last content (`toolcall.py:133-138`).
2. **Serial** `for command in self.tool_calls` (`toolcall.py:141`) — multiple tools in the same round are not concurrent.
3. Reset `_current_base64_image = None` before each tool (`toolcall.py:142-143`).
4. If `max_observe` is set, slice the result string (`toolcall.py:147-148`; Manus=10000).
5. Write as `Message.tool_message` (including `tool_call_id` / `name` / optional `base64_image`) (`toolcall.py:155-161`).
6. Multi-tool results joined with `"\n\n".join(results)` (`toolcall.py:164`).

#### `execute_tool` (`toolcall.py:166-208`)

| Step | Behavior | On failure |
|---|---|---|
| Validate command | no name → return Error string | does not raise |
| Look up tool_map | unknown tool → Error string | does not raise |
| `json.loads(arguments)` | parse args | `JSONDecodeError` → Error string |
| `available_tools.execute` | actually execute | other Exception → Error string + `logger.exception` |
| `_handle_special_tool` | may set state FINISHED | — |
| Extract `base64_image` | store in `_current_base64_image` | — |
| Format observation | `Observed output of cmd \`{name}\` executed:\n...` | — |

Point: **tool failure by default becomes observation text written back to the model**, rather than interrupting the whole `run`. The model can still change strategy next round. This contrasts with the strict attitude of REQUIRED mode “no tool_calls then raise.”

`ToolCollection.execute` (`tool_collection.py:25-35`): `tool(**tool_input)` is `BaseTool.__call__` → `execute`; `ToolError` becomes `ToolFailure`.

#### Special tool: how `terminate` presses the end-of-shift bell (`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`: case-insensitive compare against `special_tool_names` (`toolcall.py:225-227`).
- `_should_finish_execution` defaults to **always True** (`toolcall.py:220-223`) — once it is on the special list, it ends.
- `Terminate.execute` only returns a status sentence (`app/tool/terminate.py:23-25`) — **what actually stops the loop is the state flip**, not the return value.
- `MCPAgent` override: only end when name is `terminate` (`mcp.py:167-170`); and after parent handling, process multimedia `base64_image` (`mcp.py:154-165`).

#### cleanup and run wrapper (`toolcall.py:229-250`)

- `cleanup`: walk `available_tools.tool_map`, call tools that have async `cleanup` (`toolcall.py:232-242`) — browser, MCP etc. rely on this to release.
- `ToolCallAgent.run`: `try: return await super().run()` / `finally: await self.cleanup()` (`toolcall.py:245-250`).
- `Manus.cleanup` extra: `browser_context_helper.cleanup_browser` + if `_initialized` then disconnect MCP (`manus.py:131-138`).

---

### 6.5 Step-budget comparison table

| Agent | max_steps | max_observe | Location |
|---|---|---|---|
| BaseAgent default | 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` |

Compare nanobot default 200, Raven default 40 — OpenManus’s 20 steps look more like a “single job shift”; long tasks rely on **PlanningFlow wrapping multiple `agent.run` on the outside**.

### 6.6 Cancel / timeout / failure retry

| Mechanism | Present | Evidence |
|---|---|---|
| Ctrl+C | yes | each entry `except KeyboardInterrupt` (`main.py:28-29` etc.) |
| Flow whole-task timeout | yes, 3600s | `run_flow.py:32-35` |
| LLM request timeout | ask_tool default 300s | `llm.py:648` |
| LLM API retry | tenacity up to 6 times | `llm.py:637-642` |
| Token budget | optional; on hit then FINISHED | `llm.py:701-705`; `toolcall.py:60-72` |
| Python code timeout | default 5s, process terminate | `python_execute.py:42,68-74` |
| Search all-engines-failed retry | max_retries default 3 + retry_delay | `web_search.py:251-277`; `config.py:45-52` |
| `/stop` hard cancel | **not found** | — |
| Mid-flight interjection queue | **not found** | — |
| Crash checkpoint | **not found** | — |

### 6.7 Design trade-offs

- **Short loop + outer Flow**: when a single Agent’s 20 steps are not enough, use PlanningFlow to cut the task into segments — the cost is that between segments they **by default share the same Agent instance’s Memory** (if the flow reuses the same object), or are independent (if newly created each time); OpenManus’s `run_flow` reuses a `Manus()` instance (`run_flow.py:12-14`), so Memory accumulates across steps until it hits the 100-message truncate.
- **Serial tool calls**: simple and safe; throughput is worse than concurrent frameworks.
- **Failure becomes observation**: tool-layer soft failure, good for research fault-tolerance; REQUIRED mode is stricter.
- **`state_context` finally rollback**: loop control relies on the while condition reading instantaneous FINISHED; the external lifecycle state machine does not “stick” FINISHED.

> 🔍 **Source pointers (go check the source)**: `agent/base.py:116-154` (outer shift run)｜`base.py:163-186` (stuck detection)｜`agent/react.py:33-38` (step=think+act)｜`agent/toolcall.py:39-129` (think decision tree)｜`toolcall.py:131-208` (act and execute_tool soft failure)｜`toolcall.py:210-227` (terminate flips FINISHED)｜`agent/manus.py:140-165` (browser dynamic page-swap)

> 🧠 **One sentence**: as long as the model still calls tools, the loop turns; the moment it calls `terminate`, state flips `FINISHED`, the loop stops. 20 steps are one “job shift”; long tasks wrap another Flow on the outside.

### Contrast with Kortix/Suna (short note)

OpenManus’s main loop is textbook ReAct, all in one Python process; Kortix’s “running” happens in the session-lifecycle engine + OpenCode inside the sandbox; cancel/timeout/permissions are platform-level concepts. Want to change “what to think in one step” — change OpenManus; want to change “who can open how many parallel sessions” — change Kortix.

---
## Chapter 7 · Tool system and permissions: the model’s “hands” and “tight hoop”

### Metaphor: the toolbox hangs at the waist; the permission rope is almost not tied

OpenManus’s tool layer is “unified appearance + specialized execution.” The research main axis is **browser action chain + multi-engine search fallback**; security defaults lean open; real container isolation requires explicitly turning on Docker/Daytona.

### 7.1 Unified appearance of a tool

`BaseTool` (`app/tool/base.py:78-137`):

- Fields: `name` / `description` / `parameters` (JSON Schema)
- `async __call__` → `execute` (`base.py:116-122`)
- `to_param()` → OpenAI function-calling format (`base.py:124-137`)

`ToolResult` (`base.py:38-75`) carries `output` / `error` / `base64_image` / `system`; `__str__` prefers showing error (`base.py:69-70`); supports `+` merge (`base.py:52-67`).

`ToolCollection` (`app/tool/tool_collection.py`):

| Method | Behavior | Location |
|---|---|---|
| `to_params` | list comprehension `tool.to_param()` | `tool_collection.py:22-23` |
| `execute(name, tool_input)` | look up map → `await tool(**tool_input)` | `tool_collection.py:25-35` |
| `add_tool` / `add_tools` | skip duplicate names and warn | `tool_collection.py:51-71` |
| `get_tool` | get instance by name | `tool_collection.py:48-49` |

---

### 7.2 Manus default toolbox

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

| Tool | Role | File |
|---|---|---|
| `python_execute` | execute Python in a subprocess, capture stdout | `app/tool/python_execute.py` |
| `browser_use` | browser automation (including web_search action) | `app/tool/browser_use_tool.py` |
| `str_replace_editor` | view/create/replace/insert/undo files | `app/tool/str_replace_editor.py` |
| `ask_human` | ask a human in the terminal | `app/tool/ask_human.py` |
| `terminate` | end the loop | `app/tool/terminate.py` |
| `mcp_*` (optional) | dynamically added after connecting external MCP | `app/tool/mcp.py`; `manus.py:108-112` |

Also in the repo but **not hung into Manus by default**: standalone `WebSearch`, `Bash`, `Crawl4aiTool`, `PlanningTool`, `ComputerUseTool`, the chart_visualization suite, etc. (`app/tool/__init__.py:1-24` exports some of them). Note: the `web_search` action inside the browser **reuses** a `WebSearch` class instance (`browser_use_tool.py:128`), but the standalone `web_search` tool name is by default not in Manus’s `ToolCollection` — the model usually reaches search indirectly through a `browser_use` action.

---

### 7.3 `browser_use` action-making sequence (thickened)

File: `app/tool/browser_use_tool.py`.

#### Action enum and dependencies (`browser_use_tool.py:47-121`)

The `action` enum has 16 items: `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`.

The `dependencies` field declares parameters each action needs (e.g. `web_search`→`query`, `extract_content`→`goal`) — this is a schema hint for the model, **not a runtime forced validator**; when a param is truly missing, the `execute` branch returns `ToolResult(error=...)`.

There is also `refresh`, not in the enum but handled in code (`browser_use_tool.py:247-249`) — schema and implementation are slightly inconsistent, a corner case.

#### Lifecycle: lock + lazy init (`browser_use_tool.py:124-188,223`)

- `asyncio.Lock`: the entire `execute` is wrapped in `async with self.lock` (`browser_use_tool.py:223`), so concurrent actions do not scramble the browser.
- `_ensure_browser_initialized`:
  - Default `headless=False`, `disable_security=True` (`browser_use_tool.py:144`).
  - Then overwritten by `config.browser_config` for proxy / headless / CDP / wss etc. (`browser_use_tool.py:146-170`).
  - `new_context` + `DomService` (`browser_use_tool.py:174-186`).
- Across calls, keep `browser` / `context` alive until `cleanup` (`browser_use_tool.py:541-550`).

#### Typical research-making sequence

```mermaid
sequenceDiagram
    participant A as Agent
    participant B as BrowserUseTool
    participant W as WebSearch
    participant P as Playwright page
    participant L as LLM (second extraction)

    A->>B: action=web_search, query=...
    B->>W: execute(query, fetch_content=True, num_results=1)
    W-->>B: SearchResponse (including first URL)
    B->>P: page.goto(first URL)
    B-->>A: return search_response (already navigated)
    Note over A: next Manus.think detects browser_use<br/>injects URL/screenshot next_step
    A->>B: action=extract_content, goal=...
    B->>P: page.content() → markdownify
    B->>L: ask_tool(extract function, tool_choice=required)
    L-->>B: extracted_content
    B-->>A: ToolResult text
    A->>B: click_element / scroll_* / open_tab (as needed)
    A->>A: terminate
```

#### Close read of `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
```

Points:

1. Internal search forces `fetch_content=True`, wants only 1 result.
2. **After search succeeds, automatically browser-navigate to the first URL** — not just return a link list.
3. Return value is the whole `SearchResponse` (including structured results); if `results` is empty, the next line `results[0]` throws, caught by the outer `except` into `Browser action 'web_search' failed: ...` (`browser_use_tool.py:476-477`).

#### Close read of `action=extract_content` (`browser_use_tool.py:375-444`)

1. `markdownify(await page.content())` turns HTML into Markdown (`browser_use_tool.py:381-384`).
2. Truncate to `max_content_length` (default 2000, `config`; `browser_use_tool.py:227-230,391`).
3. Construct extract prompt + a **second** `llm.ask_tool`, `tool_choice="required"`, forcing the model through the `extract_content` function (`browser_use_tool.py:431-435`).
4. Parse `extracted_content` from `tool_calls[0].function.arguments` (`browser_use_tool.py:437-442`).

This is a nested “call the LLM again inside the tool” pattern — research quality goes up; latency and token cost also go up.

#### Other actions, quick lookup

| action | Core behavior | Lines |
|---|---|---|
| `go_to_url` | `page.goto` + `wait_for_load_state` | `233-241` |
| `click_element` | DOM index → `_click_element_node`, may download | `271-283` |
| `input_text` | index + text → `_input_text_element_node` | `285-296` |
| `scroll_*` | `window.scrollBy` or default one-screen height | `298-310` |
| `scroll_to_text` | Playwright `get_by_text` + `scroll_into_view` | `312-323` |
| `send_keys` | `keyboard.press` | `325-332` |
| dropdown | xpath evaluate / `select_option` | `334-372` |
| tabs | switch / create_new_tab / close | `447-465` |
| `wait` | `asyncio.sleep`, default 3s | `468-471` |

#### `get_current_state` and cleanup

See Chapter 5; screenshot params: `full_page=True`, `type="jpeg"`, `quality=100` (`browser_use_tool.py:507-509`). `__del__` tries `asyncio.run(cleanup())` or a new event loop (`browser_use_tool.py:552-560`) — destructor-period cleanup, crude but leak-preventing.

---

### 7.4 `WebSearch`: four-engine cascade fallback (thickened)

File: `app/tool/web_search.py`.

#### Data structures

- `SearchResult`: position / url / title / description / source / raw_content (`web_search.py:22-40`).
- `SearchResponse(ToolResult)`: query / results / metadata; `populate_output` validator formats results into readable `output` (`web_search.py:53-103`); body preview truncated to 1000 characters (`web_search.py:86-88`).

#### `execute` retry loop (`web_search.py:201-288`)

```text
read config: retry_delay (default 60), max_retries (default 3), lang, country
for retry_count in range(max_retries + 1):
    results = await _try_all_engines(...)
    if results:
        optional _fetch_content_for_results
        return SearchResponse(success...)
    if retries remain:
        sleep(retry_delay)
return SearchResponse(error="All search engines failed...")
```

Note: “retry” here is **wait `retry_delay` seconds and start over after a whole round of all engines failed** (`web_search.py:272-277`), not retry inside a single engine. Single-engine calls have another tenacity: `_perform_search_with_engine` up to 3 times exponential backoff (`web_search.py:387-408`).

#### `_try_all_engines` + `_get_engine_order` (`web_search.py:290-385`)

Engine instance table (`web_search.py:193-198`): google / baidu / duckduckgo / bing.

Order:

1. `config.search_config.engine` preferred (default google);
2. `fallback_engines` list;
3. remaining engines not yet appearing are filled in (`web_search.py:360-385`).

Any engine returning non-empty `search_items` succeeds and returns; later ones are not tried (`web_search.py:304-323`). Failed engine names are recorded in `failed_engines` for logs only.

#### Body fetch `WebContentFetcher` (`web_search.py:106-153`)

- `requests.get` dumped into a thread pool (`web_search.py:127-129`).
- BeautifulSoup strips script/style/header/footer/nav (`web_search.py:141-142`).
- After collapsing whitespace, **truncate 10000 characters** (`web_search.py:148-149`).
- `_fetch_content_for_results` `asyncio.gather`s multiple results in parallel (`web_search.py:329-350`).

Config at `config.example.toml:80-93`.

---

### 7.5 `python_execute`: the subprocess-sandbox illusion

`app/tool/python_execute.py`:

1. description is explicit: **only print output is visible; return values are not captured** (`python_execute.py:13`).
2. `_run_code`: redirect `sys.stdout` to `StringIO`, `exec(code, safe_globals, safe_globals)` (`python_execute.py:25-37`).
3. `safe_globals` actually passes the full `__builtins__` (`python_execute.py:57-60`) — the comment writes safety, **builtins are not trimmed**.
4. `multiprocessing.Process` + `join(timeout)`; on timeout `terminate` (`python_execute.py:61-74`).
5. Default `timeout=5` (`python_execute.py:42`).

Real container isolation is Docker (`app/sandbox/`, default `use_sandbox=false`, `config.py:97`) or Daytona (`SandboxManus`).

---

### 7.6 MCP: dynamically grafted extra hands (thickened)

#### Client `MCPClients` (`app/tool/mcp.py`)

| Capability | Behavior | Location |
|---|---|---|
| `connect_sse` | SSE stream → `ClientSession` → list tools | `mcp.py:50-69` |
| `connect_stdio` | `StdioServerParameters` → session | `mcp.py:71-95` |
| Tool naming | `mcp_{server_id}_{original}` + sanitize | `mcp.py:107-114,128-145` |
| sanitize | illegal chars→`_`, strip consecutive underscores, max 64 | `mcp.py:128-145` |
| `MCPClientTool.execute` | `session.call_tool(original_name, kwargs)` | `mcp.py:21-34` |
| `disconnect` | `AsyncExitStack.aclose`, handle cancel scope | `mcp.py:155-194` |

`Manus.connect_mcp_server`: after connecting, only `add_tools` that server_id’s new tools into `available_tools` (`manus.py:108-112`). `disconnect_mcp_server`: rebuild ToolCollection, exclude `MCPClientTool`, then add back still-connected tools (`manus.py:122-129`).

#### `MCPAgent` specialist differences (`app/agent/mcp.py`)

- `available_tools` as a whole *is* `MCPClients` (`mcp.py:70-71`).
- On init, write tool names into a memory system message (`mcp.py:81-85`).
- Every 5 steps `_refresh_tools` (`mcp.py:35,143-149`); perceive add/delete and write a system notice (`mcp.py:117-128`).
- Session gone or tools emptied → `FINISHED` (`mcp.py:137-149`).

#### Server `MCPServer` (`app/mcp/server.py`)

Registers bash / browser / editor / terminate (`server.py:31-35`); converts `BaseTool` schema into a Python Signature then `@server.tool()` (`server.py:37-76`). Entry `run_mcp_server.py`.

---

### 7.7 File editing and AskHuman

- **`str_replace_editor`**: Anthropic-style view/create/str_replace/insert/undo_edit; long output truncated 16000 (`str_replace_editor.py:28-34,51-57`); `_get_operator` picks local or sandbox by `config.sandbox.use_sandbox` (`str_replace_editor.py:106-112`); path must be absolute (`str_replace_editor.py:170-172`).
- **`ask_human`**: `input(f"Bot: {inquire}\n\nYou: ")` (`ask_human.py:20-21`) — synchronous blocking inside the main loop.

### 7.8 Sandbox security: two implementations

**A. Local Docker** (`app/sandbox/`): memory/CPU limits; default `network_mode=none` (unless `network_enabled`) (`sandbox/core/sandbox.py:61-66`; `config.py:94-105`). Off by default.

**B. Daytona** (`SandboxManus`): `SandboxBrowserTool` / `Files` / `Shell` / `Vision` (`sandbox_agent.py:101-107`); when writing `index.html`, hints at 8080 preview (near `sb_files_tool.py:230-239`).

### 7.9 Permission model: almost no “per-call approval”

First use a contrast figure to state OpenManus’s security posture: a coding agent (e.g. Claude Code) inserts a **gate** between “the model proposes” and “really execute,” which can stop dangerous ops and ask you to nod; OpenManus by default is almost **straight-through** — real isolation only exists if you yourself explicitly turn on Docker/Daytona.

```mermaid
flowchart TD
    subgraph OM["OpenManus default (the rope is almost not tied)"]
        A1["Model proposes calling a tool"] --> A2["execute_tool"]
        A2 --> A3["Really execute<br/>read/write/run browser"]
        A2 -. only if you explicitly enable .-> A4["Docker / Daytona sandbox"]
    end
    subgraph CC["Typical coding agent (has a gate)"]
        B1["Model proposes calling a tool"] --> B2{"Dangerous?"}
        B2 -->|"read-only"| B3["Execute directly"]
        B2 -->|"write/dangerous"| B4["Pause, ask you to confirm"]
        B4 -->|"you nod"| B3
    end
    style A3 fill:#fdeded
    style B4 fill:#eaf1ff
```

Look at the list below against this figure, and you will know which gates OpenManus is “missing,” and which voluntary recirculation paths it kept:

- Interactive confirmation dialog before a tool call **not found**.
- Unified SSRF / dangerous-command regex blacklist **not found** (Bash is on the SWE/MCP Server path, not in Manus’s default box).
- **Has** Docker/Daytona resource and network limits (must be explicitly enabled).
- **Has** `ask_human` voluntarily throwing back to a human.
- Browser default `disable_security=True` (`config.py:71-72`; `browser_use_tool.py:144`).

### 7.10 Differences from a coding-agent toolbox

| Capability | OpenManus | Typical coding agent |
|---|---|---|
| Web research | first-class citizen (browser_use + multi-engine search) | usually secondary |
| Code navigation | dedicated grep/glob/LSP tools **not found** | first-class citizen |
| Patch | str_replace_editor (general text) | apply_patch / precise diff |
| Shell | Bash in SWE/MCP; Manus default python_execute | Bash is core |
| Deliverables | HTML/charts/reports | mainly code and tests |
| Planning | PlanningTool + PlanningFlow | mostly TODO / self-planning |

> 🧠 **One sentence**: the toolbox was born for “research” (browser + multi-engine search are the leads), but the safety rope is not tied by default — for real isolation, you yourself must explicitly turn on Docker / Daytona.

### Contrast with Kortix/Suna (short note)

OpenManus tools are “open the box and research”; Kortix tools run in a sandbox + server-side Executor; permissions rely on IAM/grant/policy. The browser closed loop is more direct in OpenManus; production-safety defaults are tighter in Kortix.

---
## Chapter 8 · Context compaction and memory

### One-sentence conclusion

OpenManus “memory” is an **in-process message list**; “compaction” is mainly **hard truncation**; LLM summary compaction, Dream reflection, vector long-term memory, prompt-cache breakpoints are **not found**.

### Memory implementation

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

- `messages: List[Message]`
- `max_messages: int = 100`
- `add_message` / `add_messages`: over the cap keep the **last N** (`schema.py:167-168,174-175`)
- `clear` / `get_recent_messages` / `to_dict_list`

This is a sliding window, not semantic compaction: early tool observations and plan details disappear directly. Combined with Chapter 5: each round next_step appends another user message, so effective “meaningful history” capacity is smaller than 100.

### Other “save context” means (none of them is a summarizer)

| Means | Role | Location |
|---|---|---|
| `max_observe` | truncate a single tool observation | `toolcall.py:147-148`; Manus=10000 |
| File view truncate | 16000 characters + clipped hint | `str_replace_editor.py:28-34` |
| Browser content length | `max_content_length` default 2000 | `config.py:89-91`; `browser_use_tool.py:391` |
| Search body | after fetch about 10000 characters | `web_search.py:149` |
| Search output preview | raw_content shown 1000 characters | `web_search.py:86-88` |
| Accumulated token budget | optional `max_input_tokens`; over limit ends the Agent | `llm.py:249-254`; `toolcall.py:60-72` |
| stuck prompt | does not compact; only adds a strategy reminder | `base.py:163-168` |

### Sandbox image compression (vision tools only)

`SandboxVisionTool.compress_image` (`app/tool/sandbox/sb_vision_tool.py`) compresses images to base64 — this is **media compression**, not conversation-memory compaction.

### Not found

- A module that `compress` / `summarize`s conversation history **not found**.
- `MEMORY.md` / long-term memory file pipeline **not found**.
- `prompt cache` / `cache_control` / explicit cache breakpoint **not found** (full-repo search, no match).
- Cross-process session persistence **not found** (restart and forget).

### Design trade-offs

For the positioning “three-hour prototype + one-shot terminal job,” a sliding window is enough; for “continuous multi-hour research that still remembers step-1 data,” PlanningFlow’s plan text is the only structured memory skeleton across steps; Agent Memory itself cannot carry it.

> 🧠 **One sentence**: OpenManus memory is a 100-message hard window; “compaction” is throwing away the oldest — no summary, no long-term memory, no prompt cache.

### Contrast with Kortix/Suna (short note)

Kortix uses the company repo + CR as long-term memory; OpenManus uses a 100-message hard window. Want “yesterday’s research continued today” — Kortix; want “tonight’s demo, burn after use” — OpenManus.

---

## Chapter 9 · Sub-agents and multi-agent: the full PlanningFlow path

### Metaphor: not a cloning trick; it is “project manager + outsourced specialists”

OpenManus **does not have** a nanobot-style `spawn` background sub-agent. Its multi-agent is **PlanningFlow as project manager, dispatching the task by step to specialists in a dict**. This chapter walks the full path in call order.

```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] exists?"}
    PLAN -- no --> FAIL["return Failed to create plan"]
    PLAN -- yes --> LOOP["while True"]
    LOOP --> GET["_get_current_step_info"]
    GET --> NONE{"has active step?"}
    NONE -- no --> FIN["_finalize_plan"]
    NONE -- yes --> EX["get_executor(step_type)"]
    EX --> RUN["_execute_step → agent.run(step_prompt)"]
    RUN --> MARK["_mark_step_completed"]
    MARK --> STOP{"executor.state == FINISHED?"}
    STOP -- yes --> OUT["break return result"]
    STOP -- no --> LOOP
    FIN --> OUT
```

---

### 9.1 Entry: `run_flow.py`

1. `agents = {"manus": Manus()}` (`run_flow.py:12-14`) — **direct construction, not `Manus.create()`**; MCP relies on first `think` lazy-load (`manus.py:142-144`).
2. If `config.run_flow_config.use_data_analysis_agent`, also hang `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 self-sticks the unstable label (`README.md:134-137`).

### 9.2 `FlowFactory` and `BaseFlow`

`FlowType` currently has only `PLANNING` (`flow_factory.py:9-10`); the factory dict has only one item (`flow_factory.py:22-24`). Unknown type raises `ValueError` (`flow_factory.py:27-28`).

`BaseFlow` (`app/flow/base.py`):

- On construct, unify a single Agent / list / dict into `Dict[str, BaseAgent]` (`base.py:22-28`).
- `primary_agent_key` defaults to the dict’s first key (`base.py:31-34`).
- `primary_agent` / `get_agent` / `add_agent` (`base.py:42-53`).
- Abstract `execute(input_text)` (`base.py:55-57`).

---

### 9.3 `PlanningTool`: in-memory plan dict

File: `app/tool/planning.py`.

#### Command set (`planning.py:25-34,101-118`)

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

#### Storage structure (`planning.py:69-70,145-154`)

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

- **In-process dict, not persisted to disk** — when the process ends, the plan evaporates.
- `create` sets `_current_plan_id` (`planning.py:154`).
- `mark_step` can change status ∈ `not_started|in_progress|completed|blocked` and notes (parameter schema: `planning.py:55-62`).
- On `update`, if step text at the same position is unchanged, keep old status/notes (`planning.py:192-199`).

`PlanStepStatus` enum and display marks are on the Flow side (`app/flow/planning.py:16-42`): `[✓]` / `[→]` / `[!]` / `[ ]`.

---

### 9.4 `PlanningFlow` construction (`app/flow/planning.py:45-75`)

| Field | Meaning |
|---|---|
| `llm` | Flow’s own LLM (create plan / summarize) |
| `planning_tool` | plan storage and commands |
| `executor_keys` | dispatchable agent-key list |
| `active_plan_id` | default `plan_{timestamp}` (`planning.py:51`) |
| `current_step_index` | current step |

`__init__` is compatible with `executors`→`executor_keys`, `plan_id`→`active_plan_id` (`planning.py:58-63`); if executor_keys is unspecified, use all agents keys (`planning.py:74-75`).

#### `get_executor` (`planning.py:77-92`)

1. If `step_type` hits a key in `self.agents` → use that Agent.
2. Otherwise find the first that exists in `executor_keys` order.
3. Then fallback `primary_agent`.

Note: the type parsed from step text is `.lower()`ed (`planning.py:247`), while `DataAnalysis.name` is `"Data_Analysis"` (`data_analysis.py:20`); the agents dict key in `run_flow` is `"data_analysis"` (if following README convention) — **whether routing hits depends on whether key and label are agreed**. `run_flow.py:16` actually writes `agents["data_analysis"] = DataAnalysis()`, so the label needs to look like `[DATA_ANALYSIS]` to `lower()` into `data_analysis`.

---

### 9.5 `_create_initial_plan` full path (`planning.py:136-211`)

1. Base system: “Create a concise, actionable plan...” (`planning.py:140-144`).
2. If `executor_keys` corresponds to multiple agents: append `[{name: KEY.upper(), description}]` into system, and require steps to be tagged `[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. If there are tool_calls and name==`planning`: parse JSON args, **force `args["plan_id"]=self.active_plan_id`**, `await planning_tool.execute(**args)` (`planning.py:179-198`), then return.
6. If the model did not call the tool or parse failed: warn, create a default three-step plan Analyze / Execute / Verify (`planning.py:200-211`).

At the start of `execute`, if `active_plan_id not in plans`, return a failure sentence directly (`planning.py:104-109`) — the default three-step path still writes into plans, so usually only an execute exception steps on this.

---

### 9.6 Main loop: `execute` (`planning.py:94-134`)

```python
await self._create_initial_plan(input_text)
# validate plan exists...
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
```

**Early-stop condition**: executor `state == FINISHED` after finishing a step (e.g. the specialist called terminate). Combined with Chapter 6, `state_context` finally will dial state back to IDLE — this check happens after `_execute_step` returns, before the next round of `get_current_step_info`; what it reads is the state just as `run()` ended.

Wait: `ToolCallAgent.run` → `BaseAgent.run`’s `async with state_context` `finally` will set state back to previous (the IDLE it entered `run` with). So **`executor.state == FINISHED` after `_execute_step` returns is almost always unreadable** — because cleanup/`state_context` already dialed state back to IDLE.

Check the timing again:

1. terminate sets `FINISHED` inside `execute_tool`
2. while exits because `state != FINISHED` fails
3. `state_context.finally` sets back `previous_state` (IDLE)
4. `ToolCallAgent.run`’s finally calls cleanup
5. return to PlanningFlow

Therefore the FINISHED check at `planning.py:128-129` **under the current implementation is likely dead code / rarely triggered** (unless someone changes state without going through `state_context`). The more common path that “ends the whole Flow early” is: all steps completed → `_finalize_plan`. If a specialist terminates mid-step, that step is still `_mark_step_completed` (see below), then the FINISHED check reads IDLE, **and the Flow continues to the next step**. This is a behavior worth marking when reading deeply.

---

### 9.7 `_get_current_step_info` (`planning.py:213-275`)

1. Read `planning_tool.plans[active_plan_id]` (`planning.py:227-229`).
2. Walk steps, find the first status ∈ `{not_started, in_progress}` (`planning.py:232-238`; active definition in `PlanStepStatus.get_active_statuses`, `planning.py:30-32`).
3. Extract type with regex `\[([A-Z_]+)\]`, then `.lower()` (`planning.py:245-247`).
4. `mark_step` as `in_progress`; on failure, mutate the dict directly (`planning.py:250-267`).
5. No active step → `(None, None)`.

### 9.8 `_execute_step`: stuff plan status into the specialist (`planning.py:277-304`)

Construct `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.
```

Then `await executor.run(step_prompt)` (`planning.py:296`). After success `_mark_step_completed` (`planning.py:299`). On exception, return an error string, **do not** mark complete (`planning.py:302-304`) — the next `_get_current_step_info` may still pick the same step again (if it is still in_progress/not_started).

`_get_plan_text`: prefer `planning_tool.execute(command="get")`; on failure `_generate_plan_text_from_storage` locally renders progress percentage and marks (`planning.py:337-404`).

### 9.9 `_mark_step_completed` (`planning.py:306-335`)

`mark_step` → `completed`; on failure, mutate `step_statuses[index]` directly.

### 9.10 `_finalize_plan` (`planning.py:406-442`)

1. First use the Flow’s own `llm.ask` to write a summary (`planning.py:412-424`).
2. On failure, fallback: `primary_agent.run(summary_prompt)` (`planning.py:429-439`) — another full Agent shift.
3. On further failure, return a fixed sentence (`planning.py:441-442`).

---

### 9.11 DataAnalysis: the data-analysis specialist

`app/agent/data_analysis.py:12-37`: toolbox swapped to

- `NormalPythonExecute` (encourages writing reports into workspace)
- `VisualizationPrepare`
- `DataVisualization` (png/html)
- `Terminate`

Enable: `[runflow] use_data_analysis_agent = true` (`README.md:144-148`).

### 9.12 Other Agent roles

| Agent | Role | Entry |
|---|---|---|
| Manus | general specialist | `main.py` / flow |
| BrowserAgent | browser specialist | usable directly in code; standalone CLI **not found** |
| MCPAgent | MCP specialist | `run_mcp.py` |
| SWEAgent | coding specialist | standalone CLI **not found** |
| SandboxManus | cloud-sandbox specialist | `sandbox_main.py` |
| A2AManus | A2A protocol adapter | `protocol/a2a/app/agent.py:15-30` (`stream` is directly `NotImplementedError`) |

### 9.13 Design trade-offs

- **The plan lives in tool memory**: not persisted to disk.
- **One step = one complete `agent.run`**: the project manager does not intervene at tool-level scheduling; inside the specialist it can still run up to `max_steps` (e.g. 20) micro-steps.
- **Routing by string labels**: fragile but transparent; README already marked unstable.
- **FINISHED short-circuit may be ineffective**: affected by `state_context` finally (see 9.6).
- **Memory accumulates across steps**: the same `Manus` instance is `run` repeatedly; history concatenates until the 100-message truncate — sometimes a feature (keep prior context), sometimes a noise source.

> 🧠 **One sentence**: its “multi-agent” is not a cloning trick; it is a project manager holding an in-memory plan table, dispatching each step by `[label]` to a specialist, each running one round on its own.

### Contrast with Kortix/Suna (short note)

OpenManus PlanningFlow = in-process project manager + `[AGENT]` dispatch; Kortix = many-sandbox parallelism + OpenCode subagent + CR governance. The former is good for demonstrating “how to cut a long task”; the latter is good for production load like “fifty sales Agents in parallel.”

---
## Chapter 10 · Ecosystem: commands, extensions, and plugins

### Commands / entries at a glance

| Entry | Role |
|---|---|
| `python main.py` | general Manus |
| `python run_flow.py` | PlanningFlow multi-agent |
| `python run_mcp.py` | MCP client Agent |
| `python run_mcp_server.py` | expose this repo’s tools as an MCP Server |
| `python sandbox_main.py` | Daytona SandboxManus |
| `protocol/a2a/app/main.py` | A2A service (depends on external a2a package) |

A unified typer/click command tree, slash commands, plugin marketplace are **not found**.

### MCP: bidirectional ecosystem interface

**As client (Manus / MCPAgent)**:

- Config `config/mcp.json` → `MCPSettings.load_server_config` (`config.py:149-169`).
- `MCPClients.connect_sse` / `connect_stdio` (`app/tool/mcp.py:50-95`).
- Remote tools renamed `mcp_{server_id}_{original}` and sanitized (`mcp.py:107-114,128-145`).
- `MCPAgent` refreshes the tool list every 5 steps, perceiving add/delete (`app/agent/mcp.py:35,143-149`).

**As server (MCPServer)**:

- FastMCP registers bash/browser/editor/terminate (`app/mcp/server.py:24-35,145-160`).
- Dynamically convert `BaseTool` JSON Schema into a Python Signature + docstring then `@server.tool()` (`server.py:37-76`).
- `atexit` cleans up browser (`server.py:155-156`).

### How to extend (the de-facto plugin model)

1. **Add a tool**: write a `BaseTool` subclass, put it in some Agent’s `ToolCollection`.
2. **Add an Agent**: inherit `ToolCallAgent`, change prompt + toolbox (DataAnalysis is the template).
3. **Add a Flow**: `FlowFactory`’s dict currently has only `PLANNING` (`flow_factory.py:22-24`) — the extension point is reserved, but the repo only has this one.
4. **Add an MCP server**: external process, write into `mcp.json`, Manus auto-connects at start.
5. **Swap models via config**: `[llm]` / `[llm.vision]` in `config.toml`; examples cover Anthropic/Azure/Ollama/Bedrock/Jiekou etc. (several `config.example-model-*.toml` under `config/`).

### Chart-visualization extension

`app/tool/chart_visualization/` ships its own README (multi-language) and npm frontend deps (`package.json`), used by DataAnalysis to produce interactive HTML charts — “deliverable ecosystem,” not an MCP plugin marketplace.

### Contrast with Kortix/Suna (short note)

OpenManus extends by adding Agent/tool/MCP; Kortix by yaml + marketplace + connectors. Both have a protocol door, but Kortix’s Executor is a platform-level keep-secrets boundary.

---

## Chapter 11 · Feature set: auth, models, and “thinking”

### Auth and model access

OpenManus **does not maintain a nanobot-style ~40-provider registry**; instead:

1. The user fills `model` / `base_url` / `api_key` / `api_type` in `config.toml` (`config.example.toml:1-7`).
2. `LLM.__init__` branches on `api_type` (`app/llm.py:216-225`):
   - `azure` → `AsyncAzureOpenAI`
   - `aws` → `BedrockClient()`
   - other → `AsyncOpenAI(api_key=..., base_url=...)` (OpenAI-protocol-compatible gateways / Ollama / Anthropic proxies etc.)
3. Optional `[llm.vision]` overrides the vision model (`config.example.toml:43-48`).

**Auth form**: API Key in plaintext in toml (or placeholders in examples). OAuth login flow, `provider login` command **not found**.

### Token accounting and budget

- Each call accumulates `total_input_tokens` / `total_completion_tokens` (`llm.py:238-247`).
- Optional `max_input_tokens`: accumulated input cap across requests (`config.py:24-27`; `llm.py:249-254`).
- Over limit → `TokenLimitExceeded` → Agent `FINISHED` (Chapter 6).

### “Thinking” (reasoning models)

`REASONING_MODELS = ["o1", "o3-mini"]` (`app/llm.py:34`). For these models:

- Use `max_completion_tokens` rather than `max_tokens` (`llm.py:411-417,723-729`);
- **Do not pass temperature** (same).

A general `reasoning_effort` / `thinking` blocks peel-off / multi-provider thinking-style adaptation is **not found** — far simpler than nanobot’s thinking_style matrix.

### Multimodal

- `MULTIMODAL_MODELS` whitelist (`llm.py:35-42`).
- `ask_with_images` (`llm.py:488-635`); browser helper stuffs screenshots into messages.
- Models not on the whitelist: `format_messages` drops the `base64_image` field (`llm.py:337-339`).

### Streaming output

- `ask(..., stream=True)` defaults to streaming and `print`s to the terminal (`llm.py:365,440-448`).
- `ask_tool` is **always non-streaming** (`llm.py:731`) — the tool loop prefers complete `tool_calls`.

### Other features, quick lookup

| Feature | Status | Location |
|---|---|---|
| Browser headless/proxy/CDP | yes | `config.py:69-91` |
| Search engine/language/country | yes | `config.py:39-60` |
| Docker sandbox | yes (off by default) | `config.py:94-105` |
| Daytona cloud sandbox | yes (SandboxManus) | `app/daytona/` |
| A2A protocol | experimental thin wrapper | `protocol/a2a/` |
| OpenManus-RL | external sister repo | mentioned in README |
| prompt cache | **not found** | — |
| Cost/routing layer | **not found** | — |
| Timed cron / proactivity | **not found** | — |

### Contrast with Kortix/Suna (short note)

OpenManus: TOML + OpenAI-compatible endpoint; Kortix: LLM Gateway + `reasoning_effort`. Depth of reasoning-model adaptation is a notch apart.

---

## Chapter 12 · Summary: design philosophy and trade-offs

First recap OpenManus’s three pillars with one figure:

```mermaid
flowchart TB
    GOAL["Goal: an open-source, self-hostable Manus stand-in<br/>general tasks / research / deliverables"]
    GOAL --> P1["Pillar 1 classic ReAct inheritance chain<br/>Base→ReAct→ToolCall→specialized<br/>short main loop, easy to read and change<br/>→ Chapters 2/6"]
    GOAL --> P2["Pillar 2 browser+search research closed loop<br/>browser_use action chain + multi-engine fallback<br/>screenshot fed back into next_step<br/>→ Chapters 5/7"]
    GOAL --> P3["Pillar 3 planning flow for long tasks<br/>PlanningTool in-memory plan<br/>multi-agent dispatch by [TYPE]<br/>→ Chapter 9"]
    P1 & P2 & P3 --> BASE["Foundation: TOML config + OpenAI-compatible LLM<br/>+ bidirectional MCP + Docker/Daytona sandbox<br/>→ Chapters 3/10/11"]
    style GOAL fill:#e8f5e9
    style BASE fill:#eceff1
```

### Five distinctive ideas

**① Express Agent variety with an inheritance chain, not a plugin bus.** `ToolCallAgent` implements think/act once; Manus/Browser/DataAnalysis/SWE only swap prompt and toolbox (`app/agent/`). That a three-hour prototype can stand is thanks to this restraint.

**② The browser is not an accessory tool; it is the main research sensor.** The `web_search` action searches and navigates to the first result (`browser_use_tool.py:251-268`); `extract_content` calls the LLM a second time to extract (`browser_use_tool.py:375+`); `BrowserContextHelper` hits URL/screenshot back into the next-round prompt (`browser.py:47-71`). This is the identity proof of a Manus stand-in relative to a coding agent.

**③ Long-task planning is externalized as a Flow.** When a single Agent’s 20 steps are not enough, `PlanningFlow` uses `PlanningTool` to maintain a step state machine (`planning.py` + `tool/planning.py`), re-`run`ning a specialist each step — simple, unstable (the README says so), but it lifts “planning” from a prompt wish-list into a markable data structure.

**④ MCP bidirectional door.** It can `run_mcp.py` as a client consuming external tools, and `run_mcp_server.py` contributing bash/browser/editor outward (`app/mcp/server.py`) — the ecosystem strategy is protocol access, not a self-built plugin marketplace.

**⑤ Exit is a first-class tool.** `terminate` enters `special_tool_names`; after execute it flips `AgentState.FINISHED` (`toolcall.py:210-218`). The model is explicitly taught by the prompt “when done, terminate” (`prompt/manus.py:9`) — more explicit than “if the model no longer calls tools, we are done.”

### Main trade-offs (costs)

- **No session persistence / no crash recovery**: good for demos and one-shot jobs, not for a long-resident IM assistant.
- **Memory is only a 100-message hard truncate**: no LLM compaction, no prompt cache; long research lives on Flow plan text.
- **Security defaults lean open**: browser `disable_security` default True; `python_execute` is not a container; Docker sandbox off by default — safety requires the user to explicitly turn on Daytona/Docker.
- **Multi-agent routing is fragile**: depends on `[AGENT]` labels in step strings; README already marked unstable.
- **The toolbox is not aligned with coding scenarios**: using it as Claude Code, you will feel the lack of grep/patch/test closed loop; its sweet spot is research and deliverables.

### Sideways comparison at a glance (for the series docs)

| Dimension | OpenManus |
|---|---|
| Team | MetaGPT members / FoundationAgents; MIT |
| Stack | Python 3.12 + asyncio + openai SDK + browser-use + MCP |
| Architecture | Agent inheritance chain + multiple entry scripts + PlanningFlow (no Bus/Spine) |
| Models | TOML-configured OpenAI-compatible endpoints; Azure/Bedrock branches; no in-house 40-provider registry |
| Tool scale | Manus default about 5 local tools + dynamic MCP; a dozen-plus tool modules in the repo |
| Extension | add Agent/tool/MCP; FlowFactory can expand |
| Main loop | `app/agent/base.py:116-154` + `react.py:33-38` + `toolcall.py:39-164` |
| Permissions | no per-call approval; optional Docker/Daytona; browser security off by default |
| Compaction / memory | `Memory.max_messages=100` hard truncate; no summarizer |
| prompt cache | **not found** |

**One-sentence close**: Claude Code answers “how to write the code well”; nanobot/Raven answers “how to let a personal agent live long-term in your chat app”; and **OpenManus answers “how to use a readable ReAct inheritance chain, without an invite code, to string browser research, multi-engine search, and a planning flow into an open-source Manus stand-in”** — it does not pursue long residence and self-evolution; it pursues **spreading the skeleton of a general-task Agent on the table, so that in three hours you can understand it, and in one day you can change it into your own specialist**.

### Versus Kortix/Suna: one-sentence selection

Want to understand it in three days and change out a specialist → **OpenManus**; want to self-host a multi-tenant AI workforce and company-repo governance → **Kortix/Suna**. Same axis, different scale; details in *OpenManus-vs-Suna side-by-side comparison*.

---

## Writing notes

- **Document version**: deepened edition (2026-07-24); relative to the first edition, Chapters 5/6/7/9 are substantially thickened, and each chapter-end Kortix/Suna short note is added.
- **Analysis baseline**: every conclusion in this lecture is based on OpenManus repo commit `52a13f2a57d8c7f6737eefb02ccf569594d44273` (`git -C 参考项目/OpenManus rev-parse HEAD`; commit time 2026-01-04). Version number treats `setup.py:9`’s `0.1.0` as canonical.
- **Verification method**: every `file:line` was checked by actually opening the source; relative paths are rooted at `参考项目/OpenManus/`. Wherever source cannot confirm (WebUI main path, per-call permission approval, prompt cache, conversation LLM compaction, spawn sub-agent, slash commands, etc.), the body already marks **not found**; no speculation.
- **Product contrast**: comparisons with commercial Manus / Genspark / Claude Code are inductive from README self-stated positioning and toolbox differences; they do not assert closed-source internals.

*End of lecture.*
