English lecture · source-traced

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; e

EN
lecture
source
file:line
no
embellishment
中文
sibling

Subject: 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 — 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.

Part 1

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 (mannaandpoem) and @Jinyu Xiang, plus @Zhaoyang Yu, @Jiayi Zhang, @Sirui Hong, from @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 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/
flowchart LR
    subgraph OM["OpenManus (this project)"]
        E1["main / run_flow / run_mcp / sandbox_main"] --> A["Agent inheritance chain
Base→ReAct→ToolCall→Manus"] A --> L["LLM singleton
ask / ask_tool / ask_with_images"] A --> T["Toolbox
browser / python / editor / search / MCP"] E1 --> F["PlanningFlow
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.


Part 2

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).
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
run loop / state machine / stuck detection"] R["ReActAgent
think + act = step"] TC["ToolCallAgent
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
create plan → dispatch step by step → finalize"] PT["PlanningTool
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)

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.


Part 3

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

# 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).
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.


Part 4

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.


Part 5

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)

# 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

# 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:

# 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

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.


Part 6

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.
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
AND state != FINISHED?"} LOOP -- no --> ENDCHK{"current_step >= max_steps?"} ENDCHK -- yes --> TERM["reset step=0, state=IDLE
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.”

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)

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)

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:

# 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)

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
if tool_calls, only warning"] F -- REQUIRED --> H["write assistant; even without tool_calls return True
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
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)

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.


Part 7

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

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

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)

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.gathers 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.

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


Part 8

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 / summarizes 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.


Part 9

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.

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)

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)

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:

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.”


Part 10

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.


Part 11

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):
  3. azure → AsyncAzureOpenAI
  4. aws → BedrockClient()
  5. other → AsyncOpenAI(api_key=..., base_url=...) (OpenAI-protocol-compatible gateways / Ollama / Anthropic proxies etc.)
  6. 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 prints 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.


Part 12

Chapter 12 · Summary: design philosophy and trade-offs

First recap OpenManus’s three pillars with one figure:

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


Part 13

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.

↑