Subject: FoundationAgents/OpenManus Based on commit:
52a13f2a57d8c7f6737eefb02ccf569594d44273(2026-01-04,Update README.md;setup.py:9version0.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 tofile: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. Allfile:linecitations 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.
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.pyin 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 atREADME.md:9). - Package metadata:
setup.py:7-12package nameopenmanus, version0.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.
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:
BaseAgentdefinesrun/step/is_stuck(app/agent/base.py:13-186)ReActAgent.stepcallsthinkthenact(app/agent/react.py:33-38)ToolCallAgentimplements full tool-calling (app/agent/toolcall.py:18-250)Manus(ToolCallAgent)(app/agent/manus.py:18)
Three “layering disciplines”
- Capability is swapped by changing the Agent / toolbox, not the scheduling framework. General tasks use
Manus; data analysis swaps inDataAnalysis; sandbox swaps inSandboxManus— the main loop is alwaysBaseAgent.run. - Long-task planning is a Flow, not an Agent. A single Agent relies on
next_step_promptto let the model split steps itself; cross-Agent long tasks go throughPlanningFlow(app/flow/planning.py); the plan lives in thePlanningTool.plansdict. - Config singleton + LLM singleton.
config = Config()(app/config.py:372) double-checked with a thread lock;LLM._instancesis cached byconfig_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:
PythonExecuteusesmultiprocessing.Process+ timeoutterminate(app/tool/python_execute.py:55-74). - The browser is a long-lived object:
BrowserUseToolholdsbrowser/context, reused across tool calls untilcleanup(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-memoryMemory.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
.pyscripts. 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
# 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:
argparseoptional--prompt(main.py:10-14); if absent, interactive input (main.py:20).Manus.create()factory (app/agent/manus.py:59-65): construct instance →initialize_mcp_servers()connects the MCP configured inconfig/mcp.json→ mark_initialized=True.- Empty prompt returns immediately with a warning (
main.py:21-23). KeyboardInterruptis caught then warned (main.py:28-29);finallyalways callscleanup()(main.py:30-32).setup.py:44-47also registers the console scriptopenmanus=main:main, butmainis an async function — whether setuptools can actually call it as a synchronous entry is questionable (main.py:8isasync def main); everyday docs recommendpython 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:
- Assemble
agents = {"manus": Manus()}(run_flow.py:12-14) — note this usesManus()direct construction, notManus.create(), so this path by default does not go through the MCP initialization factory (unless laterthinklazy-loads, seemanus.py:142-144). - If
config.run_flow_config.use_data_analysis_agentis true, also hangDataAnalysis()(run_flow.py:15-16; config itemconfig.example.toml:112-113). FlowFactory.create_flow(FlowType.PLANNING, agents=...)(run_flow.py:24-27; the factory only supportsPLANNING,app/flow/flow_factory.py:9-30).- 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 moduleapp.mcp.server(run_mcp.py:27-32;app/config.py:141-143). - sse: connect
--server-url(defaulthttp://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)
- Any
from app.config import configtriggers singleton__init__(app/config.py:209-215). _get_config_path: preferconfig/config.toml, otherwise fall back toconfig.example.toml(app/config.py:218-226).- Parse LLM / browser / search / sandbox / mcp / runflow / daytona (
app/config.py:233-329). - MCP servers additionally load from the
mcpServersfield ofconfig/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’sManus()vsmain’sManus.create(): the two paths initialize inconsistently — the flow side depends on lazy init insidethink(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)
- Capture:
args.promptorinput("Enter your prompt: ")(main.py:20). - Empty-string check:
if not prompt.strip()→ return (main.py:21-23). - Enter
agent.run(prompt)(main.py:26). BaseAgent.run: if there is arequest,update_memory("user", request)(app/agent/base.py:131-132).update_memorymaps role throughmessage_mapto factories such asMessage.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:
- First generate a plan with LLM +
PlanningTool(app/flow/planning.py:136-211); - Each step constructs a
step_prompt, stuffing “current plan status + you are only responsible for this step” intoexecutor.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.runrequires the starting state to beIDLE, otherwiseRuntimeError(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:
- System prompt: Agent field
system_prompt, wrapped asMessage.system_messagewhenthinkcallsask_tool(app/agent/toolcall.py:47-53). - History:
self.messagesisself.memory.messages(app/agent/base.py:188-196). - Next-step reminder: at the start of each
think, ifnext_step_promptexists, firstMessage.user_message(self.next_step_prompt)appended into messages (app/agent/toolcall.py:41-43). - Browser dynamic page:
Manus.think/BrowserAgent.thinktemporarily rewritenext_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:
- Eat the
Memory.max_messages=100quota; - Count toward
ask_tool’s accumulated tokens; - In browser mode be temporarily swapped for a longer
BrowserContextHelperversion (see below).
5.3 BrowserContextHelper: stuff “the page the eyes see” back into the prompt
File: app/agent/browser.py:19-84.
Init mount
Manuscreates the helper inmodel_validator(manus.py:53-57).BrowserAgentlikewise (browser.py:115-118).
get_browser_state (browser.py:24-45)
- First look for
BrowserUseTool, thenSandboxBrowserTool(browser.py:25-29). - Call
browser_tool.get_current_state()(browser_use_tool.py:479-537). - If there is a
base64_image, stash it in the helper’s_current_base64_image(browser.py:38-41). 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)
- Pull browser_state; compose URL/title, tab count, above/below scroll-pixel description (
browser.py:53-63). - If there is a screenshot: construct
Message.user_message(content="Current browser screenshot:", base64_image=...)andmemory.add_message(browser.py:65-71), then clear_current_base64_image. .format(...)withNEXT_STEP_PROMPTfromapp/prompt/browser.py(browser.py:73-79), fillingurl_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:
- Lazy-init MCP: when the
run_flowpath usesManus()rather thancreate(), the first think is when MCP is connected (manus.py:142-144). - 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. - Temporarily swap prompt → super().think() → restore (
manus.py:155-163): avoid the long browser prompt polluting later non-browser steps. BrowserAgentswaps 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”:
- Validate
tool_choice∈TOOL_CHOICE_VALUES(llm.py:677-678; enumschema.py:20-25). format_messages:Message→ OpenAI dict; if the model is inMULTIMODAL_MODELSand the message carriesbase64_image, convert to animage_urlcontent part (llm.py:267-352,680-688). If not on the whitelist, drop the image field (llm.py:337-339).- Count tokens of messages + tools (
llm.py:690-699). - If over the accumulated
max_input_tokensbudget, raiseTokenLimitExceeded(llm.py:701-705; exceptionapp/exceptions.py:12-13). - Assemble
params:tools/tool_choice/timeout=300(llm.py:714-720). - Reasoning models use
max_completion_tokensand do not pass temperature (llm.py:723-729). - Force
stream=False(llm.py:731) — the tool loop needs completetool_calls. - Update accumulated tokens from
usagein 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=100truncate line (see Chapter 8). - System separated from history: system is injected by
ask_tooleach time, not permanently stored in memory — except MCPAgent. - Screenshots into memory: the browser closed loop needs vision, but also burns tokens sharply;
max_observecannot 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), thenact(really execute). - End-of-shift bell: the model calls
terminate→ stateFINISHED; 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
terminateinside the loop setsstatetoFINISHED, the while can exit correctly (because the exit condition was already evaluated beforefinally); but after leavingasync with, state is changed back to theIDLEit entered with (previous_state). - The
ERRORset inexceptis also overwritten byfinallyback 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:
- Fewer than 2 messages → False (
base.py:172-173). - Take the last one; no content → False (
base.py:175-177). - In earlier messages, count backwards: times
role=="assistant"andcontentis exactly the same as the last one (base.py:180-184). 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:
- Token-budget path (
toolcall.py:57-72):ask_tool’s@retrycomment says Don’t retryTokenLimitExceeded(llm.py:641-642), but tenacity may still wrap the exception inRetryError. think unpacks withhasattr(e, "__cause__") and isinstance(e.__cause__, TokenLimitExceeded); on hit, write an assistant explanation,state=FINISHED,return False— graceful end of shift, no stack explosion. ValueErrorre-raised directly (toolcall.py:57-58) — e.g. illegal tool_choice.- Logging: prints thoughts, tool count, tool-name list, arguments of the first tool (
toolcall.py:81-89) — later tools’ args are executed without preview. response is None: raiseRuntimeError("No response received from the LLM")(toolcall.py:92-93);ask_toolreturn Nonewhen choices are empty (llm.py:737-740).- 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. - 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)
- No tool_calls: if REQUIRED →
raise ValueError(TOOL_CALL_REQUIRED)(constant attoolcall.py:15); otherwise return the last content (toolcall.py:133-138). - Serial
for command in self.tool_calls(toolcall.py:141) — multiple tools in the same round are not concurrent. - Reset
_current_base64_image = Nonebefore each tool (toolcall.py:142-143). - If
max_observeis set, slice the result string (toolcall.py:147-148; Manus=10000). - Write as
Message.tool_message(includingtool_call_id/name/ optionalbase64_image) (toolcall.py:155-161). - 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 againstspecial_tool_names(toolcall.py:225-227)._should_finish_executiondefaults to always True (toolcall.py:220-223) — once it is on the special list, it ends.Terminate.executeonly returns a status sentence (app/tool/terminate.py:23-25) — what actually stops the loop is the state flip, not the return value.MCPAgentoverride: only end when name isterminate(mcp.py:167-170); and after parent handling, process multimediabase64_image(mcp.py:154-165).
cleanup and run wrapper (toolcall.py:229-250)
cleanup: walkavailable_tools.tool_map, call tools that have asynccleanup(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.cleanupextra:browser_context_helper.cleanup_browser+ if_initializedthen 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_flowreuses aManus()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_contextfinally 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 flipsFINISHED, 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 entireexecuteis wrapped inasync 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_configfor proxy / headless / CDP / wss etc. (browser_use_tool.py:146-170). new_context+DomService(browser_use_tool.py:174-186).- Across calls, keep
browser/contextalive untilcleanup(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:
- Internal search forces
fetch_content=True, wants only 1 result. - After search succeeds, automatically browser-navigate to the first URL — not just return a link list.
- Return value is the whole
SearchResponse(including structured results); ifresultsis empty, the next lineresults[0]throws, caught by the outerexceptintoBrowser action 'web_search' failed: ...(browser_use_tool.py:476-477).
Close read of action=extract_content (browser_use_tool.py:375-444)
markdownify(await page.content())turns HTML into Markdown (browser_use_tool.py:381-384).- Truncate to
max_content_length(default 2000,config;browser_use_tool.py:227-230,391). - Construct extract prompt + a second
llm.ask_tool,tool_choice="required", forcing the model through theextract_contentfunction (browser_use_tool.py:431-435). - Parse
extracted_contentfromtool_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_outputvalidator formats results into readableoutput(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:
config.search_config.enginepreferred (default google);fallback_engineslist;- 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.getdumped 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_resultsasyncio.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:
- description is explicit: only print output is visible; return values are not captured (
python_execute.py:13). _run_code: redirectsys.stdouttoStringIO,exec(code, safe_globals, safe_globals)(python_execute.py:25-37).safe_globalsactually passes the full__builtins__(python_execute.py:57-60) — the comment writes safety, builtins are not trimmed.multiprocessing.Process+join(timeout); on timeoutterminate(python_execute.py:61-74).- 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_toolsas a whole isMCPClients(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_operatorpicks local or sandbox byconfig.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_humanvoluntarily 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 = 100add_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.
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
agents = {"manus": Manus()}(run_flow.py:12-14) — direct construction, notManus.create(); MCP relies on firstthinklazy-load (manus.py:142-144).- If
config.run_flow_config.use_data_analysis_agent, also hangDataAnalysis()(run_flow.py:15-16;config.example.toml:112-113). FlowFactory.create_flow(FlowType.PLANNING, agents=agents)(run_flow.py:24-27).asyncio.wait_for(flow.execute(prompt), timeout=3600)(run_flow.py:32-35).- 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_keydefaults 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.
createsets_current_plan_id(planning.py:154).mark_stepcan change status ∈not_started|in_progress|completed|blockedand 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)
- If
step_typehits a key inself.agents→ use that Agent. - Otherwise find the first that exists in
executor_keysorder. - 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)
- Base system: “Create a concise, actionable plan...” (
planning.py:140-144). - If
executor_keyscorresponds to multiple agents: append[{name: KEY.upper(), description}]into system, and require steps to be tagged[agent_name](planning.py:145-160). - user:
Create a reasonable plan with clear steps to accomplish the task: {request}(planning.py:166-168). llm.ask_tool(..., tools=[planning_tool.to_param()], tool_choice=AUTO)(planning.py:171-176).- If there are tool_calls and name==
planning: parse JSON args, forceargs["plan_id"]=self.active_plan_id,await planning_tool.execute(**args)(planning.py:179-198), then return. - 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:
- terminate sets
FINISHEDinsideexecute_tool - while exits because
state != FINISHEDfails state_context.finallysets backprevious_state(IDLE)ToolCallAgent.run’s finally calls cleanup- 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)
- Read
planning_tool.plans[active_plan_id](planning.py:227-229). - Walk steps, find the first status ∈
{not_started, in_progress}(planning.py:232-238; active definition inPlanStepStatus.get_active_statuses,planning.py:30-32). - Extract type with regex
\[([A-Z_]+)\], then.lower()(planning.py:245-247). mark_stepasin_progress; on failure, mutate the dict directly (planning.py:250-267).- 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)
- First use the Flow’s own
llm.askto write a summary (planning.py:412-424). - On failure, fallback:
primary_agent.run(summary_prompt)(planning.py:429-439) — another full Agent shift. - 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)VisualizationPrepareDataVisualization(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 tomax_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_contextfinally (see 9.6). - Memory accumulates across steps: the same
Manusinstance isrunrepeatedly; 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). MCPAgentrefreshes 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
BaseToolJSON Schema into a Python Signature + docstring then@server.tool()(server.py:37-76). atexitcleans up browser (server.py:155-156).
How to extend (the de-facto plugin model)
- Add a tool: write a
BaseToolsubclass, put it in some Agent’sToolCollection. - Add an Agent: inherit
ToolCallAgent, change prompt + toolbox (DataAnalysis is the template). - Add a Flow:
FlowFactory’s dict currently has onlyPLANNING(flow_factory.py:22-24) — the extension point is reserved, but the repo only has this one. - Add an MCP server: external process, write into
mcp.json, Manus auto-connects at start. - Swap models via config:
[llm]/[llm.vision]inconfig.toml; examples cover Anthropic/Azure/Ollama/Bedrock/Jiekou etc. (severalconfig.example-model-*.tomlunderconfig/).
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:
- The user fills
model/base_url/api_key/api_typeinconfig.toml(config.example.toml:1-7). LLM.__init__branches onapi_type(app/llm.py:216-225):azure→AsyncAzureOpenAIaws→BedrockClient()- other →
AsyncOpenAI(api_key=..., base_url=...)(OpenAI-protocol-compatible gateways / Ollama / Anthropic proxies etc.) - 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→ AgentFINISHED(Chapter 6).
“Thinking” (reasoning models)
REASONING_MODELS = ["o1", "o3-mini"] (app/llm.py:34). For these models:
- Use
max_completion_tokensrather thanmax_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_MODELSwhitelist (llm.py:35-42).ask_with_images(llm.py:488-635); browser helper stuffs screenshots into messages.- Models not on the whitelist:
format_messagesdrops thebase64_imagefield (llm.py:337-339).
Streaming output
ask(..., stream=True)defaults to streaming andprints to the terminal (llm.py:365,440-448).ask_toolis always non-streaming (llm.py:731) — the tool loop prefers completetool_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:
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_securitydefault True;python_executeis 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 treatssetup.py:9’s0.1.0as canonical. - Verification method: every
file:linewas 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.