How to read this tutorial: we do not hand you a vague checklist. We walk one real task from start to finish. The user said “summarize this URL for me”—at the start our agent can do nothing. Every time it hits a wall, we add one part; after the part is in, it takes one step forward. At the end you hold an agent that can research on its own, deliver on its own, and close on its own.
Mirrors: at each part I will point you at what it looks like in a real project— OpenManus (commit
52a13f2a, Python, the “heart”) + Kortix/Suna (5862e956, TypeScript, the “skeleton”). Everyfile:linecan be checked directly under参考项目/.Two promises: ① every part gets an acceptance assertion you can actually run—not “it feels written,” but “this command went green.” ② every part tells you the crash it is most likely to have.
Assumed reader: a beginner with a little programming. Python / TypeScript both work. Pseudocode in the text stays close to OpenManus naming so you can compare.
Contents
- See the finish line first: the thing we assemble looks like this
- Step 0 · The task enters: one URL, one summary
- Step 1 · Give it a brain (but it can only speak)
- Step 2 · Give it a hand (but who actually acts?)
- Step 3 · Make it turn: the heartbeat loop
- Step 4 · Teach it to clock out: terminate
- Step 5 · Give it memory (the model is actually amnesiac)
- Step 6 · Give it a manual: turn a general model into a specialist
- Step 7 · Make it actually research: search → open → extract
- Step 8 · Make the deliverable look presentable: templates
- Step 9 · Fit the brakes: network down, over budget, stuck
- Step 10 · Clone a new specialist: swap only the manual
- 🎬 Full replay: how this task runs from start to finish
- Gate 2 · From “can work” to “can work a lot, safely, together”
- Pain A · Two tasks overwrite each other’s files → workstation isolation
- Pain B · Secrets leak into logs → secret gateway
- Pain C · A broken prompt must be revertible → change discipline
- Pain D · Trigger from phone / Slack → channel delivery
- Appendix 1 · Schedule: string these steps into a rhythm
- Appendix 2 · Golden tasks: the machine-check list before you ship
- Appendix 3 · Ten ways it most easily blows up
- Appendix 4 · Comparative reading index
See the finish line first: the thing we assemble looks like this
Before you start, put the finish line in front of you. The figure below is what we will assemble piece by piece. Green is “Gate 1”—make it able to work; blue is “Gate 2”—make it able to work a lot, safely, with other people.
flowchart TB
U(["🧑 用户:帮我总结这个网址"]) --> BRAIN
subgraph L1["第一关 · 让它能干活(单机就够)"]
BRAIN["🧠 大脑
大模型,只会输出文字"]
HAND["🖐️ 手 / 工具
读网页 · 写文件 · 搜索"]
LOOP["🔁 心跳循环
想一步 → 做一步 → 看结果"]
MEM["📓 记忆
每圈把经过重新喂回去"]
BOOK["📜 说明书
把通用模型变成专员"]
BELL["🔔 下班铃 terminate
做完就显式收尾"]
BRAIN --- LOOP
HAND --- LOOP
MEM --- LOOP
BOOK --- LOOP
BELL --- LOOP
end
LOOP --> OUT(["📄 workspace/summary.md
交付物落盘"])
subgraph L2["第二关 · 能安全地一起干很多活(要上线才需要)"]
ISO["🚧 工位隔离
一个任务一个工位,互不踩"]
GATE["🔐 密钥网关
Agent 永远看不到真钥匙"]
GIT["📋 变更纪律
改坏了能一键回滚"]
CH["📡 渠道投递
手机 / Slack 也能触发"]
end
OUT -. 当你要上线 .-> L2
style L1 fill:#e7f8f1,stroke:#12b981
style L2 fill:#eaf1ff,stroke:#2f6bff
style U fill:#fff3df
style OUT fill:#fff3df
One sentence for the whole figure:
An agent that can work on its own = a brain + hands + a loop that keeps turning, plus memory, a manual, and a clock-out bell. When you go live, add: workstations, a key vault, rollback, and multiple entries.
This entire tutorial is filling that figure, step by step, from the leftmost “can do nothing” state.
Step 0 · The task enters: one URL, one summary
First nail the task we will run alongside. The whole tutorial turns around it:
Script α: the user throws a URL and says “summarize https://example.com for me, write it as an abstract.” The result we want:
workspace/summary.mdlands on disk (≥200 characters, includes the original URL), then the agent itself says “I am done.”
Why nail one concrete task first? Because “whether this one script can run through” is the only standard that inspects every later part. Any capability that sounds impressive, if it cannot run this most plain script, is a castle in the air.
While we are here, say clearly what we are not building, so the work does not drift:
| What we build (Manus lineage: general task / research / deliverable) | Claude Code (coding agent) | |
|---|---|---|
| What success looks like | A deliverable on disk (an MD / HTML / table) | A code patch merged + tests green |
| Main “hands” | Search / browser / fetch a page | File tree / grep / code jump |
| How it closes | Explicitly call terminate, state “I am done” |
Often relies on “task complete” text or a test gate |
| One session | One-shot job: give a task, hand something in | Long session: keep editing inside one repo |
🔑 Copy the table above into your README. It is the referee every time you later wrestle with “should I build this feature”—if it does not serve “research + deliver,” put it down for now.
Right now our agent is a blank page. The user’s sentence has already been said. Start adding parts.
Step 1 · Give it a brain (but it can only speak)
Where the task is stuck: the user said “summarize this URL,” and we have nothing in our hands. To let it “understand” that sentence and “think,” the first thing is to attach a large model—that is its brain.
Intuition: the brain is responsible for thinking and deciding. But remember one counter-intuitive fact that will run through the whole tutorial—the only thing the brain can do is emit text. It cannot read your files, cannot run commands, cannot even go online by itself. After you attach it, the most it can do is chat with you about “you should go read that URL”—it cannot touch a single byte.
So this step we build only one thing: a client that hands speech to the large model and takes the reply back.
What you write: the single entry chat
chat(messages, tools, *, system=None, tool_choice="auto") ->
{ content: str | None, # 模型说的话
tool_calls: [{id, name, arguments: dict}] # 模型想调用的工具(可能为空)
}
messages: the dialogue history so far (who said what).tools: this turn, tell the model “which tools you have in your hands” (the next step will use this; leave the slot now).tool_calls[].arguments: must already be a dictionary afterjson.loads. Do not pass the raw string downstream—this is one of the most common blow-ups.
Compare with real source
OpenManus’s entry at this layer is LLM.ask_tool (app/llm.py). Three details are worth copying its wisdom:
- Give timeout enough room: default 300 seconds (
llm.py:648). Research tasks often wait a long time for the model to think; a timeout that is too short will kill it by mistake. - Do not retry quota errors in a death loop: network jitter can retry, but an error such as “token exceeded” will not help no matter how many times you retry. OpenManus specifically comments Don’t retry
TokenLimitExceeded(llm.py:641-642). - Empty replies must error: the model occasionally returns empty
choices; then you must throw explicitly (llm.py:737-740). You must not silently treat it as “nothing to say”—otherwise the loop will spin empty.
Acceptance (after this step, run these)
断言 1:不给任何工具,纯聊天,能拿到 content。
断言 2:给它一个「结束工具」的说明,然后 prompt「请直接结束」,它能返回一个 name=terminate 的 tool_call。
断言 3:故意填错 API key → 抛错或记下可读的错误,绝不静默空转。
How it most easily blows up
- Pass
argumentsto the tool as a raw string → execution downstream will explode.json.loadsit at this client layer. - Reach for “streaming” immediately, then execute a tool call whose arguments arrived only half-formed → for Gate 1, first use non-streaming, get a complete reply, then talk about the rest.
🧠 One sentence: the brain can only speak. It can give you a perfect suggestion, and it cannot change a single byte—so next, we have to give it a hand.
Step 2 · Give it a hand (but who actually acts?)
Where the task is stuck: the brain understood “summarize the URL.” In its head it says “I have to fetch this page first.” But—it cannot read it itself. Who goes and reads?
Intuition: this is the most critical, and most easily misunderstood, point in the whole agent; it deserves its own figure. The model does not execute anything. It only says a sentence: “I want to call fetch_url, with this URL as the argument.” What actually goes and reads the page is the program we wrote outside the model—we call it the harness.
flowchart LR
subgraph BRAIN["🧠 大脑(模型)· 只会说话"]
SAY["「我想调用
fetch_url(url=...)」"]
end
subgraph HARNESS["🖐️ harness(我们写的外壳)· 真正动手"]
RUN["真的去把网页读回来"]
BACK["把读到的内容
包成一条「观察」塞回历史"]
end
SAY -->|"模型只是'点单'"| RUN
RUN --> BACK
BACK -->|"下一圈,模型才'看见'结果"| SAY
style BRAIN fill:#eef2f7
style HARNESS fill:#eaf1ff
Remember the division of labor: the model is responsible for “decide,” the harness is responsible for “execute.” The model orders; the kitchen plates. That boundary is the root of agent safety—because the power to “approve or not approve doing a thing” always sits in the harness’s hand; the model cannot steal it (Gate 2 will use this).
What a tool looks like: one mold for all
Every “hand” is a tool object that looks the same:
Tool:
name: str # 模型按这个名字点单
description: str # 告诉模型「这只手能干嘛、什么时候用」
parameters: JSONSchema # 规定参数格式
async execute(**kwargs) -> ToolResult # 真正干活的函数
ToolCollection(工具箱):
to_params() # 把所有工具的「说明书」打包给模型看
execute(name, arguments) # 按名字找到对应工具、跑它
Compare OpenManus: BaseTool.to_param (app/tool/base.py:124-137) turns a tool into a format the model can read; ToolCollection.execute (tool_collection.py:25-35) dispatches by name.
First build the two plainest hands; script α is enough
Hand one: fetch_url — read a URL
parameters: { url: string, max_chars?: number = 20000 }
行为:
- 只允许 http / https
- 超时 15 秒
- 剥掉 <script>/<style>,正文转成干净文本
- 太长就切断,并注明「已截断」
失败: 返回 ToolResult(error="..."),不要抛异常炸穿循环
Hand two: write_file — write the summary to disk
parameters: { path: string, content: string }
行为:
- 算出真实路径后,必须仍在 workspace 目录内(防止 ../../ 穿越出去)
- 自动创建父目录
- 返回「已写入 N 字节到 ...」
失败: 路径穿越 / 磁盘错误 → 返回 error 字符串
One iron rule: tool failure must become an “observation”; it must not blow up the whole task
This is the core of why a research agent can survive dirty pages and bad links. When you execute any hand, walk this pipe:
flowchart TD
CALL["模型点单:调用某个工具"] --> N{"这个工具存在吗?"}
N -->|"不存在"| E1["返回一句 Error 文本"]
N -->|"存在"| J{"参数能解析成 JSON 吗?"}
J -->|"不能"| E2["返回一句 Error 文本"]
J -->|"能"| X{"执行时抛异常了吗?"}
X -->|"抛了"| E3["返回一句 Error 文本 + 记日志"]
X -->|"没抛"| OK["返回执行结果"]
E1 --> OBS["全都变成一条『观察』
写回对话历史"]
E2 --> OBS
E3 --> OBS
OK --> OBS
OBS --> NEXT["模型下一圈看到它,自己换招"]
style OBS fill:#fff3df
style NEXT fill:#e7f8f1
Principle: tool failure, by default, becomes an observation text fed back to the model, so it can change tactics next turn. Do not write it as “one failure blows up the whole task.” Compare OpenManus’s execute_tool (toolcall.py:166-208)—unknown tool, JSON parse failure, execution exception, all are collected into an Error: ... string and returned, not thrown out.
Acceptance
断言:调用一个不存在的工具 → 循环继续,历史末尾是一条 error 观察。
断言:给 write_file 传 path="../../etc/passwd" → 返回 error,且你电脑上那个文件没被动。
断言:工具箱的 to_params() 能被真实的 OpenAI SDK 接受(拿真 API 打一轮)。
How it most easily blows up
write_filedoes not validate paths → one sentence from the model can write a file anywhere on your whole disk. After you compute the absolute path, you must confirm it is still inside workspace.- The tool
raises on first failure → one bad link can blow up the whole task. Soft-fail, always soft-fail.
🖐️ One sentence: the model orders, the harness plates. Tool failure is not an accident; it is “one observation”—let the model change tactics itself.
Step 3 · Make it turn: the heartbeat loop
Where the task is stuck: now the brain can think and the hand can act. They are not yet connected—the brain says “read the page,” the hand finishes reading, and then? The fetched content has to be shown to the brain again, so it can decide “is it enough? should I keep going?” That circle of “think → do → see the result → think again” is the agent’s heartbeat.
Intuition: do not make it complicated. Its core is a while loop:
As long as the model still wants to use a tool, the loop does not stop; the moment the model does not call a tool and just speaks, the task is over.
Skeleton of the loop
flowchart TD
START(["进入循环:历史里已经有用户那句话"]) --> GATE{"还没超步数上限?
而且还没喊停?"}
GATE -->|"否"| BUDGET["预算耗尽 → 收尾走人"]
GATE -->|"是"| THINK["🧠 think:把历史发给模型
问它下一步干嘛"]
THINK --> Q{"模型这一圈
调工具了吗?"}
Q -->|"没调,只说了话"| MAYBE["它是不是想收尾了?
(见第 4 步)"]
Q -->|"调了"| ACT["🖐️ act:真去执行工具
把结果写回历史"]
ACT --> STOP{"刚才那个工具
是 terminate 吗?"}
STOP -->|"是"| FIN["翻状态 = 完成 → 退出"]
STOP -->|"否"| GATE
MAYBE --> GATE
style THINK fill:#eef2f7
style ACT fill:#eaf1ff
style FIN fill:#e7f8f1
Compare with real source: three thin layers stacked
OpenManus’s heart is not some 2000-line mega-function. It is three small files stacked:
| Layer | What it does | Source |
|---|---|---|
BaseAgent.run |
Outer shift: gate check → loop → cleanup | base.py:116-154 |
ReActAgent.step |
Split one step into think() + act() |
react.py:33-38 |
ToolCallAgent.think / act |
think asks the model, act executes tools | toolcall.py:39-164 |
step is short enough to understand at a glance:
# react.py:33-38
async def step(self) -> str:
should_act = await self.think() # 想:要不要动手?
if not should_act:
return "Thinking complete - no action needed"
return await self.act() # 做:真去执行
The outer run is also a plain while (base.py:116-154; here we grab the skeleton):
if self.state != AgentState.IDLE: # ① 门禁:非空闲不许跑
raise RuntimeError(...)
if request:
self.update_memory("user", request) # ② 把用户那句话记进历史
while self.current_step < self.max_steps and self.state != AgentState.FINISHED:
self.current_step += 1
await self.step() # ③ 想一步、做一步
if self.is_stuck(): # ④ 卡住了?(第 9 步讲)
self.handle_stuck_state()
Three disciplines you must copy
- Gate: if the state is not “idle,” it may not start (
base.py:128-129). Prevents the same agent from being triggered concurrently at random. - Tools execute serially: if the model ordered several tools in the same turn, one by one, do not concurrent (
toolcall.py:141). Gate 1 wants stability, not speed. - Cleanup must always run: success, failure, or exception, browser and connections and those resources must be cleaned at the end (
cleanupattoolcall.py:229-250).
On the “step budget”
Giving the loop a max_steps is the safety rope that stops it from burning money forever. In OpenManus, Manus defaults to 20 steps (manus.py:27-28).
- Running golden tasks: set 12–20 steps.
- Want to test the “budget exhausted” path: deliberately set it to 3 steps.
Compare other projects and you will see what magnitude 20 is: some agents default to 40, even 200 steps. OpenManus’s 20 steps reveal its position—one “homework shift.” Long tasks get another planning layer outside, rather than stretching one loop to hundreds of steps.
Acceptance
断言:max_steps=1,任务又确实需要用工具 → 结果是「预算耗尽」,行为稳定可复现。
断言:同一圈里模型点了两个工具 → 它们按顺序执行(看日志时间戳是递增的)。
断言:清理函数在「成功 / 失败 / 抛异常」三条路径下都会跑到(用一个计数器 mock 验证)。
How it most easily blows up
- The loop has no step budget → the model can wander into a dead end and burn your wallet empty.
- Week one you already want “several tools in parallel” → concurrency bugs will keep you debugging at the moment you most need to verify the main logic. Serial first.
🔁 One sentence: think one step, do one step, stuff the result back, think again—this circle turning, turn after turn, is the agent’s entire life.
Step 4 · Teach it to clock out: terminate
Where the task is stuck: the loop is turning. But—how does it know “the summary is written, time to stop”? If nobody tells it “stop,” it will keep turning until the step budget is gone, wasting several turns for nothing.
Intuition: you might think “the model stopped calling tools and started speaking; that means it is done”—exactly not. This is a pit that is especially easy to step in.
Look at OpenManus’s real behavior (toolcall.py:118-119, 133-138): in the default mode, when the model only speaks and does not call a tool in a turn, the loop does not therefore consider the task finished; it keeps turning. That is: “the model started speaking human” ≠ “clocked out.”
So we need an explicit clock-out bell: a tool named terminate. The model must actively call it before the job is formally closed.
flowchart LR
A["模型这一圈
调用了 terminate(status)"] --> B["harness 执行它"]
B --> C["翻转状态
state = FINISHED"]
C --> D["下一次循环判断条件
不满足 → 退出"]
style A fill:#eef2f7
style C fill:#e7f8f1
Notice a subtlety: the terminate tool itself almost does nothing—its execute only returns a sentence “ended, status xxx” (OpenManus terminate.py:23-25). What actually stops the loop is the harness detecting “this is a special tool” and flipping state to FINISHED (toolcall.py:210-227). The tool is only a signal; flipping state is the real action.
What you write: the terminate contract
{
"name": "terminate",
"description": "任务完成、或你无法继续时,调用它来结束工作。",
"parameters": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["success", "failure"] },
"summary": { "type": "string", "description": "给用户的一段话,说清你干了什么" }
},
"required": ["status"]
}
}
Compare OpenManus app/tool/terminate.py—it has only the one enum field status. We add a summary on top of that, so when the model closes it can hand the user one sentence of human speech.
A tool is not enough; you have to teach it in the “manual”
The model will not be born knowing “after you write the file, call terminate.” You have to say it in the system prompt (step 6 will expand this; remember this hard rule first):
When the thing to deliver already exists under
workspace/, you must callterminate(success). Do not keep chatting.
Compare OpenManus’s prompt (prompt/manus.py:9): “If you want to stop the interaction at any point, use the terminate tool.”
Acceptance
断言:prompt「什么都别做,直接结束」→ 头一两步内就出现 terminate 调用。
断言:terminate 之后不再发起任何模型调用(看日志,步数停住了)。
断言:status=failure 时,进程的退出码 ≠ 0(或返回结果里 ok=false)。
How it most easily blows up
- Expect “the model went quiet = finished” → it will empty-spin until the budget is gone. You must have explicit terminate.
- Treat terminate’s “return value” as the stop signal → stopping depends on flipping state, not on that return text.
🔔 One sentence: clocking out requires punching the card. The model saying “I am done” does not count; it has to press the terminate bell with its own hand.
Step 5 · Give it memory (the model is actually amnesiac)
Where the task is stuck: when the loop reaches turn 2, the model has to write the summary “based on the page content it read in turn 1.” Here is the problem—every reply from the model is amnesiac; it does not remember what happened last turn. How does turn 2 pick up?
Intuition: we re-feed, every turn, the entire history so far, complete. Your question, the pages it read, every tool result… sent again, unchanged. That blob is called context. The model’s “memory” is an illusion; we feed it by hand every turn.
flowchart TD
subgraph MEM["📓 记忆 = 一个消息列表,越滚越长"]
direction TB
M1["user:帮我总结这个网址"]
M2["assistant:我要调 fetch_url"]
M3["tool:(网页正文)"]
M4["assistant:我要调 write_file"]
M5["tool:已写入 summary.md"]
end
MEM -->|"每一圈,把整个列表
重新打包发给模型"| LLM["🧠 模型"]
LLM -->|"它的新回复
又追加到列表末尾"| MEM
style MEM fill:#eef2f7
style LLM fill:#e7f8f1
What you write: Message + Memory
Message(一条消息):
role: system | user | assistant | tool # 谁说的
content: str | None # 说了什么
tool_calls?: [...] # assistant 发起的工具调用
tool_call_id? / name? # tool 回执用
base64_image?: str # 可选,调研看图时用
Memory(记忆本):
messages: list[Message]
max_messages: int = 100 # 上限
add_message() / clear() / get_recent(n)
Compare OpenManus app/schema.py:54-62 (Message) and :159-187 (Memory).
What if memory will not fit? Gate 1: hard cut
The model’s context has a capacity limit. History rolls longer and longer; eventually it will not fit. The plainest method is keep only the last N messages:
# schema.py:167-168
if len(self.messages) > self.max_messages:
self.messages = self.messages[-self.max_messages:]
Hard cut, no summary—OpenManus does exactly this at Gate 1, default keep the last 100 (schema.py:161). Do not rush to the advanced game of “use a large model to compact history.” Get it running first.
A discipline table for “what goes into memory”
This table is where beginners most easily dirty the context. Follow it:
| Content | Into memory? | What OpenManus does |
|---|---|---|
| system prompt (the manual) | Usually not | Passed separately as a parameter every turn, not stuffed into history (toolcall.py:47-53) |
| The user’s task | Yes | At start, update_memory("user", ...) |
| Tool calls the model initiated | Yes | from_tool_calls |
| Observations the tools returned | Yes | Message.tool_message |
| Each turn’s “this-turn reminder” | Yes, but be careful | See the blow-up below |
How it most easily blows up (OpenManus itself stepped in this pit)
Every turn OpenManus appends a “this-turn reminder” (the next_step sticky note) into memory (toolcall.py:41-43). Run 20 turns and history may be stacked with nearly 20 almost identical sticky notes, eating the 100-message quota for nothing.
Your improvement, pick one:
- Recommended: this-turn reminder is sent to the model only as a “temporary message,” not written permanently into memory.
- Writing it in is also fine, but raise the cap to 200, and mark “truncation happened” in the log.
Two more pits:
- The same agent instance runs two tasks in a row, you forgot
memory.clear()→ the second task will “taste of the first,” reading the first task’s history (when OpenManus exhausts the budget it resets the step count, but does not clear memory,base.py:149-152). - Large blobs such as browser screenshots stacked directly into memory → tokens explode fast.
Acceptance
断言:连续跑 30 步后,记忆条数 ≤ max_messages。
断言:同一个 Agent 实例第二次跑之前,必须先 memory.clear()——否则串味。
断言(若你选了「说明书不进记忆」策略):导出记忆,里面不该有 role=system 的条目。
📓 One sentence: the model is amnesiac. That it can pick up each turn is entirely because we re-feed the whole history—that is context, and it is also why long conversations get slower and more expensive.
Step 6 · Give it a manual: turn a general model into a specialist
Where the task is stuck: by here the agent can turn, can use tools, has memory. It is still a “general model”—if you do not tell it “who you are, what you have in your hands, how you should work,” it may finish reading the page and start chatting, not know it should write a file, still less know that after writing it should terminate.
Intuition: the secret is a manual—a paragraph written dead at the very start of the conversation, in the jargon a system prompt. The same large model, swap one manual, can become the one that writes summaries, the one that books itineraries, or the one that does data analysis. The manual decides its “persona” and its way of working.
A Manus-lineage manual usually has two layers, with different jobs:
| Layer | What it governs | How often it changes |
|---|---|---|
| system prompt (the job handbook) | Identity, working directory, delivery contract, safety floor | One per specialist, almost never changes |
| next_step prompt (this-turn sticky note) | This turn’s strategy reminder, current browser state | Can change almost every step |
Compare OpenManus: Manus’s SYSTEM_PROMPT carries a {directory} placeholder (prompt/manus.py); NEXT_STEP_PROMPT teaches the model how to pick tools and not forget terminate.
What you write: Gate 1’s system template (usable as-is)
你是 {name},一个「调研并交付」的 Agent。
工作目录:{workspace 的绝对路径}
你只能在这个目录下面产出文件。
本次要交付的东西:
{deliverable_spec} # 例:一份 summary.md,≥200 字,含原网址
规矩:
1. 优先用工具,别靠猜。缺数据就去抓、去搜。
2. 交付物在硬盘上出现后,立刻调 terminate(status="success", summary=...)。
3. 卡住了(断网/付费墙),调 terminate(status="failure", summary=...)。
4. 绝不要把 API key 写进任何文件或摘要里。
This template formally writes step 4’s hard rule—“after writing, you must terminate”—into the manual.
Why two layers?
Because some reminders are “identity that never changes” (who you are, which directory you may write) and some are “the temporary situation of this second” (which page the browser is on now). Mix them in one file and changing one disturbs the other. Split them and you can swap only one layer—when step 10 “clones a new specialist,” you will see that swapping only the system layer produces a brand-new specialist, without changing one line of loop code.
Acceptance
断言:同一套循环,只换 system prompt 文件,行为就从「写摘要」变成「订行程」。
断言:日志能打印出本轮实际用的 next_step 是哪一版,方便回放调试。
📜 One sentence: the manual decides the persona. Swap one manual and the same brain goes from “chat contestant” to “your specialist.”
Step 7 · Make it actually research: search → open → extract
Where the task is stuck: in script α the user handed over a URL; fetch_url is enough. Real research tasks are often “look up which sights Kyoto has”—there is no ready URL. The agent has to search on its own, open the most relevant from the results, then extract useful information. This closed loop of “search → open → extract” is the Manus lineage’s identity papers relative to a coding agent.
Intuition: think of it as the three moves a person makes researching on the web—first search keywords with a search engine, click the most credible link, read it and write down the points. Missing any one ring is not research.
sequenceDiagram
participant A as 🧠 Agent
participant S as 🔍 搜索
participant P as 🌐 网页
participant L as 🧠 二次抽取
A->>S: ① 搜「京都 必去景点」
S-->>A: 一串标题 + 链接
A->>P: ② 打开最相关的那条链接
P-->>A: 网页正文(一大坨 HTML/文本)
A->>L: ③ 「从这坨正文里,抽出对'景点'有用的要点」
L-->>A: 结构化要点(干净的清单)
Note over A: 信息还不够?
换个更窄的关键词,再来一轮
A->>A: 够了 → 写交付物 → terminate
Contracts of the three stages
| Stage | Input | Output | How to implement |
|---|---|---|---|
| Search search | Keywords | Title + link list (≥3 items) | Attach a search API; advanced can do “multi-engine cascade fallback” |
| Open open | One link | Page body | Step 2’s fetch_url is enough; advanced uses a real browser |
| Extract extract | Goal + body | Structured points | Call the model once more (no tools, pure distillation), or use simple rules |
Compare OpenManus: this closed loop hides in the action chain of the browser_use tool (browser_use_tool.py). One key detail is worth noticing—its web_search action searches and then jumps straight to the first result (browser_use_tool.py:251-268), gluing “search” and “open” together. And the “extract” step (extract_content, :375-444) calls the model once more inside the tool to distill the body—“a model nested inside a tool,” a common method for raising research quality, at the cost of slower and more tokens burned.
You can run the loop without a real browser first
Do not reach for Playwright on day one. First use the plainest four-piece set to prove the closed-loop semantics are connected:
web_search(query) -> [{title, url}] # 搜
fetch_url(url) -> 干净正文 # 开(复用第 2 步)
extract_facts(goal, text) -> 要点清单 # 抽(内部再调一次模型)
write_file(...) + terminate # 交付 + 收尾
First prove “search → open → extract → deliver” strings together, then consider swapping in a real browser.
⚠️ When you go to a real browser, notice: OpenManus defaults
disable_security=True(browser_use_tool.py:144)—it turned off the browser’s security restrictions to save trouble. This configuration must never be taken to a public deployment; before you go live you must turn it off and audit yourself.
Acceptance
断言:跑一个「查城市景点」的任务,日志里至少有 1 次 search、1 次 open、1 次 write_file、1 次 terminate。
断言:产出的文件里 ≥ 3 个真实链接。
断言:故意让搜索 API 失效 → 得到一条 error 观察,Agent 要么换引擎成功、要么最终 terminate(failure),不许空转。
🔍 One sentence: search, open, extract; missing one is not research. This closed loop is where a “general-task agent” differs from a “chatbot.”
Step 8 · Make the deliverable look presentable: templates
Where the task is stuck: if the task must produce HTML (for example a travel booklet), letting the model hand-write HTML + CSS from zero has two problems: it wastes several turns writing styles; and the structure it generates is different every time, so you cannot automatically check whether it is right.
Intuition: split the deliverable into “a fixed shell + the meat the model fills.” You write styles and skeleton dead; the model is only responsible for filling content. Faster, more stable, easier to machine-check.
render_itinerary(city, places: [{name, url, note}]) -> 写出 workspace/itinerary.html
The model is only responsible for giving the places data. It does not touch CSS. The shell looks like this (styles written dead; the model is forbidden to change them):
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"/><title>{{city}} 行程</title>
<style>/* 固定样式 */ body{font-family:sans-serif;max-width:720px;margin:2rem auto}</style>
</head>
<body>
<h1>{{city}} 一日游</h1>
<section id="places">{{places_html}}</section>
<footer>Generated by my-agent</footer>
</body>
</html>
This step does not need a figure—it is essentially “fill in the blanks”; a piece of code is clearer than a picture.
Acceptance
断言:输出的 HTML 里含那句固定的 footer 字符串。
断言:places ≥ 3,且每一项都带 url。
🧱 One sentence: you decide the shell; it fills the meat. Do not let the model waste effort writing CSS.
Step 9 · Fit the brakes: network down, over budget, stuck
Where the task is stuck: so far we have only considered “everything goes well.” In reality the network dies, the budget overruns, the model gets stuck on the same thought and cannot get out. Without brakes, these cases make the agent either crash or empty-spin until the budget is burned.
Intuition: besides the normal terminate, the agent also needs several ways to “clock out abnormally.”
Besides terminate, three more clock-out bells
| Situation | Outcome | Compare |
|---|---|---|
The model called terminate |
Success / failure close | toolcall.py:210-218 |
Step count filled max_steps |
Budget exhausted | base.py:149-152 |
| Token cumulative over limit | Budget exhausted | toolcall.py:60-72 |
| The user hit Ctrl-C | Cancel | Each entry catches it |
“Stuck” detection: the model started repeating
Sometimes the model will say the exact same thing for several turns in a row—it is stuck in one thought and cannot get out. OpenManus’s detection is plain (base.py:163-186): if this latest assistant message has appeared, character-for-character identical, ≥2 times among earlier ones, judge stuck.
The handling is also restrained—do not force-terminate; only add one sentence to the “this-turn reminder,” forcing it to change tactics:
“Noticed you are repeating. Change strategy; do not walk a dead road you already tried.”
Compare handle_stuck_state (base.py:163-168). You can strengthen: stuck 3 times in a row and still not changing, then terminate(failure).
Leave one log line per step
Debugging depends on it. Drop one JSON line per step (one line, one step):
{"step":3, "phase":"act", "tool":"fetch_url", "ok":true, "ms":812, "args":{"url":"https://..."}}
Never print a complete secret or a whole page of original text into the log (truncate to 500 characters). This log piece does not get a figure—it is line after line of text; a table and an example are clearer than a picture.
Acceptance
断言:断网跑剧本 α → 日志里出现 ok:false;进程不无限 while。
断言:max_steps=3 → 结局是「预算耗尽」,且 workspace 里可以有半成品。
断言:人为让模型复读同一句话 → 出现「卡住」日志,或本轮提醒里加进了那句换招提醒。
🛑 One sentence: network down, over budget, repeating—you need all three brakes, or the agent will crash or empty-spin and burn money.
Step 10 · Clone a new specialist: swap only the manual
Where the task is stuck: the summary specialist is built. Now the boss says “give me one that books itineraries.” Do you have to rewrite the whole loop, tools, memory?
Intuition: not at all. Every earlier part—loop, memory, tool pipe, terminate—is generic. To build a new specialist you only need to swap three things: manual + tool subset + deliverable spec. Not one line of loop code changes.
AgentSpec(一个专员 = 一张配置):
name: str
system_prompt: str # 换这个
tools: [工具名子集] # 换这个
deliverable_spec: str # 换这个
max_steps: int
loop.run(spec, user_request) # ← 唯一的循环实现,所有专员共用
Two specialists, one loop:
| Specialist | Toolbox | Deliverable |
|---|---|---|
| Summary specialist | fetch_url, write_file, terminate | summary.md |
| Itinerary specialist | web_search, fetch_url, render_itinerary, terminate | itinerary.html |
Compare OpenManus: it swaps varieties by “inheritance” (BrowserAgent / DataAnalysis are each a subclass, swapping prompt + toolbox). For your Gate 1, “configuration” is enough, lighter than inheritance.
Acceptance (this is Gate 1’s graduation exam)
断言:git diff 显示循环那个文件『零改动』,只新增了 specs/ 和 prompts/ 下的文件。
断言:两个专员都能独立跑通各自的黄金题。
Deliberately do not do yet
- A “planning flow” where several agents dispatch each other—OpenManus itself marked it unstable; Gate 1 does not touch it.
- Moving the whole MCP plugin ecosystem in—when you need it, then talk.
👥 One sentence: the loop is the generic chassis; a specialist is just a swapped manual. That is why adding a new specialist is almost zero cost.
🎬 Full replay: how this task runs from start to finish
The parts are all in. Now run step 0’s task—“summarize https://example.com for me”—all the way through, and you will see how each earlier part meshes.
sequenceDiagram
participant U as 🧑 用户
participant LP as 🔁 循环
participant Brain as 🧠 模型
participant Hand as 🖐️ 工具
participant Disk as 📄 workspace
U->>LP: 帮我总结 https://example.com,写成摘要
Note over LP: 把这句话记进「记忆」
rect rgb(238, 242, 247)
LP->>Brain: 第1圈:说明书 + 记忆(就这一句)
Brain-->>LP: 我要调 fetch_url(url=example.com)
LP->>Hand: 执行 fetch_url
Hand-->>LP: (网页正文)→ 记进记忆
end
rect rgb(234, 241, 255)
LP->>Brain: 第2圈:记忆(多了网页正文)
Brain-->>LP: 我要调 write_file(summary.md, 内容...)
LP->>Hand: 执行 write_file
Hand->>Disk: 写入 summary.md ✓
Hand-->>LP: 已写入 → 记进记忆
end
rect rgb(231, 248, 241)
LP->>Brain: 第3圈:记忆(多了「已写入」)
Brain-->>LP: 摘要已完成,我要调 terminate(success)
LP->>LP: 翻状态 = 完成 → 退出循环
end
LP-->>U: ✅ 摘要写好了,在 summary.md
Three turns, done. Look turn by turn; every part is present:
| Turn | What is sent to the model (accumulating memory) | Model decides | Harness action | Parts used |
|---|---|---|---|---|
| 1 | Manual + the user’s sentence | Call fetch_url |
Read the page, append the body into memory | Brain · hand · memory |
| 2 | All of turn 1 + page body | Call write_file |
Summary lands on disk, append the receipt into memory | Loop · hand |
| 3 | All of the first two turns + disk receipt | Call terminate |
Flip state, exit | Clock-out bell |
Notice memory rolls longer and longer: the request on turn 3 contains everything from the first two turns. That is why long tasks get slower and more expensive as they run—and it is the entire reason the “memory” step exists.
Here, Gate 1 is cleared. Your agent can think, can act, can loop, has memory, knows how to close, and can become a new specialist by swapping a manual. For the vast majority of “give a task, hand something in” scenes, it is already enough.
Gate 2 · From “can work” to “can work a lot, safely, together”
That Gate 1 agent, on your own machine, one job at a time, is very usable. When you want to take it live—let several tasks run at once, let colleagues use it, attach real third-party accounts—four new troubles will surface together.
The key reminder: every part in Gate 2 is forced out by a concrete pain. If you have not hit that pain, do not build it early. Building these too early will only drown you in complexity before the main logic even runs.
This gate’s mirror switches to Kortix/Suna—a production platform that “runs the company as a Git repository.” We borrow only its four hardest ideas.
flowchart LR
P1["😱 两个任务
互相改文件"] --> S1["🚧 工位隔离"]
P2["😱 密钥
漏进了日志"] --> S2["🔐 密钥网关"]
P3["😱 Agent 改坏了
默认 prompt"] --> S3["📋 变更纪律"]
P4["😱 同事想在
手机上触发"] --> S4["📡 渠道投递"]
style P1 fill:#fdeded
style P2 fill:#fdeded
style P3 fill:#fdeded
style P4 fill:#fdeded
style S1 fill:#eaf1ff
style S2 fill:#eaf1ff
style S3 fill:#eaf1ff
style S4 fill:#eaf1ff
Pain A · Two tasks overwrite each other’s files → workstation isolation
Where it hurts: two tasks run at once, both write workspace/summary.md—they overwrite each other, a mess.
Fix: when each task starts, issue a unique run_id, give it a dedicated workstation. One task, one directory; nobody steps on anybody.
flowchart TB
RUN["新任务进来"] --> ID["发一个唯一 run_id
run_id = 'r_' + 随机串"]
ID --> WS["专属工位
./workspaces/{run_id}/"]
ID --> BR["(可选)专属 git 分支
agent/{run_id}"]
WS --> W1["任务A 只在自己工位里写"]
WS --> W2["任务B 只在自己工位里写"]
W1 -. 井水不犯河水 .- W2
style WS fill:#eaf1ff
Kortix pushes this idea to the extreme—it has an iron rule: session = sandbox = git branch, three in one, and once this identity is established it cannot be changed. Its source comment is hard (runtime-identity.ts): a sandbox holding user data is an “immutable identity boundary”—even if it crashes, you can only stop it, never swap in a new one to take its identity. Why so strict? Because once identity can be stolen and swapped, it means “task A’s artifact may be pinned on task B”—fatal for a multi-tenant platform.
Naming warning: in Kortix “conversation id” and “workstation id” are two different things; do not mix them. From day one, log these two fields separately.
验收:并行跑两个任务 → 它们的 workspace 路径不同;
在 A 的工位里,找不到任何属于 B 的内容。
🚧 One sentence: one task, one workstation, one identity that cannot be stolen and swapped. That is the foundation of multi-task not fighting.
Pain B · Secrets leak into logs → secret gateway
Where it hurts: the task has to call a third-party service that needs an API key (send Slack, query Notion). If you let the agent itself hold the key and call, that key will eventually appear in some log, some deliverable—leak is only a matter of time.
Fix: build a wall in front of the key. The agent never gets the real key; it can only knock on a gateway carrying a request “I want to call X”; the gateway secretly fills in the key on its side, calls, and hands the result back (redacted).
flowchart LR
subgraph SBX["Agent(跑在沙箱里)· 拿不到真钥匙"]
REQ["「帮我调 slack.send,
参数是这些」"]
end
subgraph GW["网关(可信进程)· 保管钥匙"]
SEC["从保险箱取出真钥匙"]
CALL["拿钥匙调第三方"]
RED["把结果脱敏后返回"]
end
REQ -->|"只带任务id + 请求,
不带钥匙"| SEC
SEC --> CALL --> RED
RED -->|"干净的结果"| REQ
style SBX fill:#fdeded
style GW fill:#eaf1ff
Compare Kortix’s Executor: the real credential is attached only on the side of the trusted API process; the sandbox running the agent holds only a restricted temporary token (executor/execute.ts, credentials.ts).
This wall also brings a side benefit—permission tiers. Since every third-party call must pass the gateway, the gateway can incidentally judge “should this operation be allowed.” Kortix’s policy engine (executor/policy.ts) splits actions into three bins, and the default is set cleverly:
| Action type | Default treatment |
|---|---|
| Read (read) | Allow directly |
| Write / dangerous (write / destructive) | Needs a human nod |
This “read allowed, write needs approval” default (called default_mode = risk in source) is much safer than “allow everything,” and will not annoy you into confirming every read.
验收:在所有日志和交付物里搜 'sk-' / 'api_key' / 'Bearer' → 零命中。
网关单测:不带任务凭证去请求 → 返回 401。
🔐 One sentence: the agent only hands over a request; the key is locked in the gateway. If it can act, it can cause trouble—so dangerous actions by default need a human nod.
Pain C · A broken prompt must be revertible → change discipline
Where it hurts: you (or the agent itself) changed the default manual, and the next day every task runs crooked. How do you go back?
Fix: you do not need to invent a complex system. Put all manuals, configs, policy files into git; if it is broken, git revert once and it is back.
- Manuals
prompts/, configsspecs/, policiespolicies/— all into git. - The agent by default may only write its own workstation; changing a manual must go through a human PR.
- Add a “config format check” in CI; wrong format is a red light; bad config cannot enter the trunk.
Compare Kortix: it puts artifacts and config into git; merge walks a change_requests review process. Your poor-person version—one GitHub PR is enough; you need not invent a review UI. This step is pure process discipline; there is no mechanism worth drawing.
验收:故意提交一个格式非法的配置 → CI 红灯。
把一次改坏的说明书 revert 掉 → 剧本 α 恢复成功。
📋 One sentence: config goes into git; a bad change can roll back. Do not let “change one sentence of prompt” become an accident you cannot undo.
Pain D · Trigger from phone / Slack → channel delivery
Where it hurts: the Gate 1 agent can only be started by typing a command in your own terminal. A colleague wants to use it, or you want to send a task from your phone. Then what?
Fix: wrap an HTTP entry around the outside of the loop. Every channel (web, Slack, phone) eventually becomes a call to the same loop.run—the loop itself does not change at all.
flowchart LR
C1["💻 网页"] --> API
C2["💬 Slack"] --> API
C3["📱 手机"] --> API
API["POST /v1/runs
{message, spec_name}"] --> Q["建 run_id + 工位
丢进后台队列"]
Q --> LOOP["还是那个 loop.run
(一行没改)"]
LOOP --> POLL["GET /v1/runs/{id}
查状态 + 下载产物"]
style API fill:#eaf1ff
style LOOP fill:#e7f8f1
Do not invent a complex queue yet—one SQLite + a single background worker is enough to verify “a colleague can send a task from a phone.”
验收:用 curl 创建一个任务 → 轮询到「成功」→ 下载 summary.md → 通过剧本 α 的机检。
📡 One sentence: a thousand channels, one loop. Every entry finally converges on the same
loop.run.
Appendix 1 · Schedule: string these steps into a rhythm
This is only an orchestration suggestion; the real depth is in each step above. If you are in a hurry, 2–3 weeks finishing only Gate 1 (steps 1–10) is enough to hand in something usable.
| Week | Which steps | Deliverable |
|---|---|---|
| 1 | Steps 1–5 | A loop that can terminate + memory + logs |
| 2 | Steps 6–8 | Script α (URL → summary) runs stably |
| 3–4 | Thicken steps 7, 8 | Script β (city → itinerary) runs stably |
| 5 | Step 9 | Network-down / over-budget / stuck behavior all pass |
| 6 | Step 10 | Two specialists + graduation exam pass |
| 7+ | Gate 2 | Only when the corresponding pain has actually appeared |
Appendix 2 · Golden tasks: the machine-check list before you ship
“Golden tasks” are a few scripts written dead. Every time you change code you run them once; green means you did not regress.
# α · 网址摘要
./golden.sh alpha https://example.com
# 期望:退出码 0;summary.md 存在;文件里含 'example.com';日志含 terminate + success
# β · 城市行程
./golden.sh beta "京都"
# 期望:itinerary.html 存在;里面 ≥3 个 http 链接;日志里有 search
# γ · 断网(把网络代理指到一个死端口)
HTTP_PROXY=http://127.0.0.1:1 ./golden.sh alpha https://example.com
# 期望:非成功收工;日志里有 ok:false;不许步数跑满还在空转
# δ · 预算
MAX_STEPS=3 ./golden.sh alpha https://example.com
# 期望:结局是「预算耗尽」,工位里可以有半成品
# ε · 隔离(第二关)
./golden.sh parallel_beta
# 期望:两个任务的工位目录,文件互不交叉
Appendix 3 · Ten ways it most easily blows up
Each row corresponds to some step above; if you step in it, go back and review that step.
| # | Blow-up | Which step it wounds |
|---|---|---|
| 1 | Pass tool arguments downstream as a raw string, no json.loads |
Step 1 · brain |
| 2 | write_file does not validate paths; the model can write through the whole disk |
Step 2 · hand |
| 3 | The tool raises on first failure; one bad link blows up the whole task |
Step 2 · hand |
| 4 | The loop has no step budget; a dead-end burns the budget empty | Step 3 · loop |
| 5 | Expect “the model went quiet = finished,” then empty-spin to exhaustion | Step 4 · terminate |
| 6 | Reuse an agent instance and forget memory.clear(); tasks taste of each other |
Step 5 · memory |
| 7 | Every turn’s sticky note is stacked into memory, eating quota for nothing | Step 5 · memory |
| 8 | Week one you already put on “multi-agent dispatch,” drown in complexity | Step 10 · specialist |
| 9 | Take browser disable_security=True onto the public net |
Step 7 · research |
| 10 | Secrets entered memory / logs / deliverables | Pain B · gateway |
License reminder: OpenManus is MIT (you may freely fork commercially; keeping attribution is recommended); Kortix/Suna is Elastic License 2.0 (internal use is usually fine, but you must read the terms before hosting it as a competing service).
Appendix 4 · Comparative reading index
When you want to go deeper on a step, first read the matching analysis chapter, then open the real source.
| Step | Read the analysis first | Then read source (参考项目/OpenManus/) |
|---|---|---|
| Step 1 brain | OpenManus source analysis chapter 5 | ask_tool in app/llm.py |
| Step 2 hand | Chapter 7 | tool/base.py; tool_collection.py |
| Step 3 loop | Chapter 6 (the most detailed in the whole text) | agent/base.py, react.py, toolcall.py |
| Step 4 terminate | Section 6.4 | tool/terminate.py; toolcall.py:210-227 |
| Step 5 memory | Chapters 5, 8 | Memory in schema.py |
| Step 6 manual | Section 5.2 | prompt/manus.py |
| Step 7 research | Section 7.3 | tool/browser_use_tool.py |
| Gate 2 · all | Suna source analysis chapters 3/6/7/10 | runtime-identity.ts; executor/policy.ts, execute.ts |
The “from-scratch build” zone in the learning site openmanus-suna-learn/ corresponds one-to-one with this tutorial; you can click along.
Close
Every step having a runnable acceptance assertion is building; a pile of slogans and a schedule is empty talk.
Look back at the road you walked: a blank page that could do nothing, onto which you added a brain, a hand, a loop, memory, a manual, a clock-out bell—and it can fetch a page on its own, write a summary, and know how to close (Gate 1). When you really push it live, fill in workstations, a key vault, rollback, and multiple entries according to the pains (Gate 2).
Next: stop reading, open a repo. Today run through that smallest assertion—“let the model actively call terminate once.” The rest, one step at a time.
End of text.