AI 工具

用 Manus API 构建可审批的任务运行器:创建任务、跟踪状态与人工确认

Manus 是会自己规划、上网、写代码的 AI 智能体;它也有官方 API,可以从程序里布置任务。 这篇做 manustask.py:创建任务、轮询进度、在任务停下来提问或请求确认时安全地交给人处理,最后打印结果。重点是“不自动确认”:Agent 想发邮件、部署或执行命令时,脚本只提醒你去 Manus 里看,不替你点同意。

BINBHO · 图文教程

Manus 是会自己规划、上网、写代码的 AI 智能体;它也有官方 API,可以从程序里布置任务。 这篇做 manus_task.py:创建任务、轮询进度、在任务停下来提问或请求确认时安全地交给人处理,最后打印结果。重点是“不自动确认”:Agent 想发邮件、部署或执行命令时,脚本只提醒你去 Manus 里看,不替你点同意。

先看清成品

  • 用无效 Key 真实调用官方 API,拿到两种 401 返回,脚本能给出清楚的提示。
  • 发现一个真实问题:Key 末尾带换行时,requests 在本地就报错,且报错信息里带着整段 Key。脚本已在读取时 strip。
  • 10 项 pytest(假服务)覆盖 running/stopped/waiting/error、后台任务、429 退避,全部通过。

合规提醒:只使用 Manus 官方 API v2(https://api.manus.ai/v2/)和你自己账号创建的 API Key,不共享账号或 Key,不用任何非官方封装或网页自动化。API 任务和网页任务一样消耗积分,额度与价格以 manus.im 官网为准(本文不写价格)。Agent 发邮件、部署、执行命令这类动作必须由人确认。让 Agent 访问的网站也要遵守对方的使用条款。

运行说明:我没有 Manus API Key,所以没有创建过真实任务。真实跑过的是:用无效 Key 调用官方 API(2026-10-21 21:09–21:13,UTC+8,返回内容原样贴在文中),以及全部本地代码。正常任务的返回数据来自我写的 mock,字段照官方 v2 文档的示例和字段说明写,已在文中标出。

做完之后你会得到什么?

成果 说明
manus_task.py run 创建并等待任务;reply 回答 Agent 的提问
prompt.txt 示例任务:对比 3 款开源记账软件
test_manus_task.py 10 项测试,用我写的假 Manus 服务

需要准备哪些环境?

项目 说明
Manus 账号 在 manus.im 注册;API Key 在 设置 → Developers(manus.im/app#settings/developers)创建
Python 3.9 以上,pip install requests pytest(B、C 篇另需 cryptography)
环境变量 export MANUS_API_KEY=...,不要写进代码或提交到 Git

关键事实

事实 来源
认证头是 x-manus-api-key;v1 API 已弃用 官方 认证 文档
task.create 返回 task_id、task_title、task_url 官方 task.create
用 task.listMessages 轮询,看 status_update.agent_status:running / stopped / waiting / error 官方 任务生命周期
stopped 不一定是最终状态:task.detail 的 has_running_background_jobs 为 true 时后台还可能继续 同上
waiting 时:messageAskUser / cascadeAskUser 用 task.sendMessage 回答;动作确认用 task.confirmAction;文档明确写“不要自动确认不认识的类型” 官方 task.listMessages 字段说明
等待确认时发送任何普通消息,会跳过那个待确认的动作 官方 任务生命周期
频率限制按用户、按分钟计算;超出返回 429 rate_limited,建议指数退避加随机抖动,能用 webhook 就别频繁轮询 官方 频率限制

各接口每分钟的具体上限见官方“频率限制”页,可能调整,本文不抄数字。

文件结构

81-manus-A-src/
├── manus_task.py
├── prompt.txt
└── test_manus_task.py

第 1 步:写任务描述

请调研 3 款开源的个人记账软件(只用它们的官网和 GitHub 页面),
用表格对比:许可证、支持平台、是否支持多币种、最近一次发布日期。
每一项都给出来源链接;查不到的写“未找到”,不要猜。

第 2 步:主脚本

"""用 Manus API v2 创建任务、轮询状态、安全处理 waiting。

- 只用官方端点:task.create / task.listMessages / task.detail / task.sendMessage
- 永远不自动确认动作(发邮件、部署、执行终端命令等),交给人在 Manus 里决定
- 429 时指数退避 + 随机抖动(官方建议)
"""
import argparse, json, os, random, sys, time
import requests

BASE = "https://api.manus.ai/v2/"
UA = "binbho-tutorial/1.0"
QUESTION_TYPES = {"messageAskUser", "cascadeAskUser"}


class ManusError(Exception):
    def __init__(self, status, code, message, request_id=None):
        super().__init__(f"HTTP {status} {code}: {message} (request_id={request_id})")
        self.status, self.code = status, code


class Client:
    def __init__(self, api_key, session=None, sleep=time.sleep, max_retries=5):
        key = (api_key or "").strip()          # .env 里常带换行
        if not key:
            raise SystemExit("缺少 MANUS_API_KEY(在 manus.im 设置 → Developers 创建)")
        self.s = session or requests.Session()
        self.s.headers.update({"x-manus-api-key": key, "User-Agent": UA})
        self.sleep, self.max_retries = sleep, max_retries

    def _call(self, method, endpoint, **kw):
        for attempt in range(self.max_retries + 1):
            r = self.s.request(method, BASE + endpoint, timeout=60, **kw)
            try:
                body = r.json()
            except ValueError:
                body = {}
            if r.status_code == 429 and attempt < self.max_retries:
                wait = min(60, 2 ** attempt) + random.uniform(0, 1)
                print(f"  429 rate_limited,{wait:.1f}s 后重试", file=sys.stderr)
                self.sleep(wait)
                continue
            if r.status_code >= 400 or body.get("ok") is False:
                e = body.get("error", {})
                raise ManusError(r.status_code, e.get("code"), e.get("message"), body.get("request_id"))
            return body
        raise AssertionError("unreachable")

    def create(self, content):
        return self._call("POST", "task.create", json={"message": {"content": content}})

    def latest_status(self, task_id):
        """order=desc:最新的事件在最前面。返回 (status_update, 本页消息)。"""
        body = self._call("GET", "task.listMessages",
                          params={"task_id": task_id, "order": "desc", "limit": 20})
        msgs = body.get("messages", [])
        for m in msgs:
            if m.get("type") == "status_update":
                return m["status_update"], msgs
        return None, msgs

    def detail(self, task_id):
        return self._call("GET", "task.detail", params={"task_id": task_id})["task"]

    def send(self, task_id, text):
        if not text.strip():
            raise ValueError("不能发送空回答")
        return self._call("POST", "task.sendMessage",
                          json={"task_id": task_id, "message": {"content": text}})


def decide(status_update):
    """把一个 status_update 变成下一步动作:poll / done / ask / confirm_by_human / failed"""
    st = status_update.get("agent_status")
    if st == "running":
        return "poll", None
    if st == "stopped":
        return "done", None
    if st == "error":
        return "failed", None
    if st == "waiting":
        d = status_update.get("status_detail") or {}
        t = d.get("waiting_for_event_type")
        if t in QUESTION_TYPES:
            return "ask", d
        # 动作确认或未知类型:一律不自动确认
        return "confirm_by_human", d
    return "poll", None


def wait(client, task_id, task_url="", interval=10, max_polls=90, sleep=time.sleep):
    for _ in range(max_polls):
        su, msgs = client.latest_status(task_id)
        action, d = decide(su or {"agent_status": "running"})
        if action == "done":
            t = client.detail(task_id)
            if t.get("has_running_background_jobs"):
                print("  主 Agent 已停止,但还有后台任务,继续等", file=sys.stderr)
                sleep(interval)
                continue
            for m in msgs:   # desc:第一条 assistant_message 就是最终回答
                if m.get("type") == "assistant_message":
                    return 0, m["assistant_message"].get("content", "")
            return 0, ""
        if action == "failed":
            err = next((m["error_message"].get("content") for m in msgs
                        if m.get("type") == "error_message"), "未知错误")
            return 2, f"任务失败:{err}"
        if action == "ask":
            q = next((m["assistant_message"].get("content") for m in msgs
                      if m.get("type") == "assistant_message"), "")
            return 3, f"Agent 在提问:{q}\n用 `python manus_task.py reply {task_id} \"你的回答\"` 回复"
        if action == "confirm_by_human":
            return 4, (f"Agent 等待确认:{d.get('waiting_for_event_type')} — "
                       f"{d.get('waiting_description', '')}\n本脚本不自动确认,请人工在 Manus 中查看后决定:{task_url}")
        sleep(interval)
    return 5, f"轮询 {max_polls} 次仍未结束,稍后再查:{task_url}"


def main(argv=None):
    ap = argparse.ArgumentParser()
    sub = ap.add_subparsers(dest="cmd", required=True)
    r = sub.add_parser("run"); r.add_argument("prompt_file"); r.add_argument("--interval", type=int, default=10)
    p = sub.add_parser("reply"); p.add_argument("task_id"); p.add_argument("text")
    a = ap.parse_args(argv)
    c = Client(os.environ.get("MANUS_API_KEY"))
    try:
        if a.cmd == "reply":
            c.send(a.task_id, a.text); print("已回复,重新运行 run 前可用 task.listMessages 查看进度"); return 0
        content = open(a.prompt_file, encoding="utf-8").read()
        t = c.create(content)
        print(f"任务已创建:{t['task_id']}  {t.get('task_url', '')}")
        code, text = wait(c, t["task_id"], t.get("task_url", ""), a.interval)
        print(text)
        return code
    except ManusError as e:
        print(f"Manus API 错误:{e}", file=sys.stderr)
        if e.status == 401:
            print("检查 MANUS_API_KEY 是否正确、是否已被删除", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())

第 3 步:测试(假服务)

"""假 Manus 服务(我写的 mock,字段按官方 v2 文档),不需要网络和 API Key。"""
import json
import pytest
import manus_task as mt


class Resp:
    def __init__(self, status, body):
        self.status_code, self._b = status, body
    def json(self):
        return self._b


class FakeSession:
    def __init__(self, script):
        self.script, self.calls, self.headers = list(script), [], {}
    def request(self, method, url, timeout=None, **kw):
        self.calls.append((method, url.rsplit("/", 1)[1], kw))
        return self.script.pop(0)


def su(status, detail=None):
    m = {"id": "e1", "type": "status_update", "status_update": {"agent_status": status}}
    if detail:
        m["status_update"]["status_detail"] = detail
    return m


def page(*msgs):
    return Resp(200, {"ok": True, "request_id": "r", "task_id": "t1", "messages": list(msgs), "has_more": False})


def detail(bg=False):
    return Resp(200, {"ok": True, "request_id": "r", "task": {"id": "t1", "status": "stopped", "has_running_background_jobs": bg}})


def client(script):
    s = FakeSession(script)
    return mt.Client("k\n", session=s, sleep=lambda x: None), s


def test_key_is_stripped_and_header_used():
    c, s = client([])
    assert s.headers["x-manus-api-key"] == "k"
    assert "Authorization" not in s.headers


def test_running_then_stopped_returns_answer():
    ans = {"id": "a", "type": "assistant_message", "assistant_message": {"content": "结果表格"}}
    c, s = client([page(su("running")), page(su("stopped"), ans), detail()])
    assert mt.wait(c, "t1", sleep=lambda x: None) == (0, "结果表格")


def test_background_jobs_keep_polling():
    ans = {"id": "a", "type": "assistant_message", "assistant_message": {"content": "最终"}}
    c, s = client([page(su("stopped")), detail(bg=True), page(su("stopped"), ans), detail()])
    assert mt.wait(c, "t1", sleep=lambda x: None) == (0, "最终")


def test_action_confirmation_never_auto_confirmed():
    d = {"waiting_for_event_id": "R0", "waiting_for_event_type": "gmailSendAction",
         "waiting_description": "Send email to user@example.com",
         "confirm_input_schema": {"type": "object", "properties": {"accept": {"type": "boolean"}}, "required": ["accept"]}}
    c, s = client([page(su("waiting", d))])
    code, text = mt.wait(c, "t1", "https://manus.im/app/t1", sleep=lambda x: None)
    assert code == 4 and "不自动确认" in text
    assert all(ep != "task.confirmAction" and ep != "task.sendMessage" for _, ep, _ in s.calls)


def test_unknown_waiting_type_goes_to_human():
    assert mt.decide({"agent_status": "waiting", "status_detail": {"waiting_for_event_type": "somethingNew"}})[0] == "confirm_by_human"
    assert mt.decide({"agent_status": "waiting"})[0] == "confirm_by_human"


def test_question_returns_reply_hint():
    q = {"id": "q", "type": "assistant_message", "assistant_message": {"content": "要导出 PDF 还是 DOCX?"}}
    c, s = client([page(su("waiting", {"waiting_for_event_type": "messageAskUser", "waiting_for_event_id": "q"}), q)])
    code, text = mt.wait(c, "t1", sleep=lambda x: None)
    assert code == 3 and "PDF" in text and "reply t1" in text


def test_error_reads_error_message():
    e = {"id": "x", "type": "error_message", "error_message": {"error_type": "internal", "content": "额度不足"}}
    c, s = client([page(su("error"), e)])
    assert mt.wait(c, "t1", sleep=lambda x: None) == (2, "任务失败:额度不足")


def test_429_backoff_then_success():
    rl = Resp(429, {"ok": False, "request_id": "r", "error": {"code": "rate_limited", "message": "slow down"}})
    ok = Resp(200, {"ok": True, "request_id": "r", "task_id": "t1", "task_title": "x", "task_url": "u"})
    c, s = client([rl, rl, ok])
    assert c.create("hi")["task_id"] == "t1"
    assert len(s.calls) == 3


def test_401_raises():
    bad = Resp(401, {"error": {"code": "unauthenticated", "message": "invalid api key"}, "ok": False, "request_id": "f9"})
    c, s = client([bad])
    with pytest.raises(mt.ManusError) as ei:
        c.create("hi")
    assert ei.value.status == 401 and ei.value.code == "unauthenticated"


def test_empty_reply_rejected():
    c, s = client([])
    with pytest.raises(ValueError):
        c.send("t1", "  ")

第 4 步:运行

# 1) 先用 curl 看官方 API 的真实报错(无效 Key / 不带 Key),2026-10-21 21:09 UTC+8
$ curl -s -X POST https://api.manus.ai/v2/task.create -H 'x-manus-api-key: invalid' -H 'Content-Type: application/json' -d '{"message":{"content":"hello"}}'
{"error":{"code":"unauthenticated","message":"invalid api key"},"ok":false,"request_id":"f99ddb3a-a72e-4985-93e3-60ad6e5d767b"}   ← HTTP 401
$ curl -s -X POST https://api.manus.ai/v2/task.create -H 'Content-Type: application/json' -d '{"message":{"content":"hello"}}'
{"error":{"code":"unauthenticated","message":"missing authentication: require either API Key or Bearer Token"},"ok":false,"request_id":"2d6a7db5-3b8f-41bc-8435-a9c393768782"}   ← HTTP 401

# 2) 脚本(无效 Key、空 Key),21:11 UTC+8
$ MANUS_API_KEY=invalid-demo-key python manus_task.py run prompt.txt
Manus API 错误:HTTP 401 unauthenticated: invalid api key (request_id=ef4bad5f-ab61-4618-841f-3ae272e7ec28)
检查 MANUS_API_KEY 是否正确、是否已被删除
$ echo $?
1
$ MANUS_API_KEY= python manus_task.py run prompt.txt
缺少 MANUS_API_KEY(在 manus.im 设置 → Developers 创建)

# 3) 测试
$ python -m pytest -q
10 passed

# 4) 有真实 Key 时的预期(我没跑过):
#   任务已创建:<task_id>  https://manus.im/app/<task_id>
#   …轮询…
#   退出码 0 = 完成并打印回答;3 = Agent 提问;4 = 等待人工确认;2 = 任务失败;5 = 超过轮询次数

怎么检查结果?

  • 退出码 4 时一定去 task_url 看 Agent 想做什么(发给谁、部署到哪),再在 Manus 里决定,不要写脚本自动 confirm。
  • 结果里的每个对比项都要点开来源核对;Agent 可能把旧版本信息当成最新。
  • 在 Manus 网页的用量页面看这次任务用了多少积分,再决定要不要批量跑。

调试示例

1. Key 末尾的换行把 Key 打进了日志。 我习惯用 MANUS_API_KEY=$(cat key.txt) 或从 .env 读取,文件末尾常有换行。在本地用 requests 准备这样一个请求(不发送):

$ python -c "import requests; requests.Session().prepare_request(requests.Request('POST','https://api.manus.ai/v2/task.create',headers={'x-manus-api-key':'invalid-demo-key\n'}))"
requests.exceptions.InvalidHeader: Invalid leading whitespace, reserved character(s), or return character(s) in header value: 'invalid-demo-key\n'

请求根本没发出去,而且异常信息里带着完整的 Key——如果这段报错进了日志或 CI 输出,Key 就泄露了。修复:Client.__init__ 里先 .strip(),空字符串直接退出并提示去哪里创建。测试 test_key_is_stripped_and_header_used 固定了这个行为。

2. 两种 401 要分开提示。 真实返回里,不带 Key 是 missing authentication: require either API Key or Bearer Token,Key 错误是 invalid api key,code 都是 unauthenticated。脚本在本地先拦住“没带 Key”,服务端的 401 统一提示检查 Key,并打印 request_id,联系官方支持时用得上。

常见问题

现象 原因 处理
401 invalid api key Key 错误或已删除 到 设置 → Developers 重新创建
429 rate_limited 每分钟请求太多 脚本自动退避;加大 --interval,或改用 webhook(见 B 篇)
一直 running 任务大、Agent 还在做 到 task_url 看进度;超过轮询次数会退出码 5,稍后再查
退出码 4 Agent 请求确认动作 人工在 Manus 中决定,不要自动确认
回答“Continue”后动作没执行 等待确认时发普通消息会跳过该动作 这是官方行为;需要执行就在 Manus 里确认

模板规则

  • Key 只放环境变量,读取时 strip。
  • 轮询用 order=desc,取最新的 status_update。
  • stopped 后再查 task.detail 的后台任务。
  • 任何动作确认都交给人。
  • 429 指数退避加抖动。

项目资源

用途 地址
Manus API 文档:任务生命周期(官方) https://open.manus.ai/docs/v2/task-lifecycle
Manus API 文档:认证(官方) https://open.manus.ai/docs/v2/authentication
Manus API 文档:频率限制(官方) https://open.manus.ai/docs/v2/rate-limits
Manus 官网 https://manus.im
Manus API 文档:task.listMessages(官方) https://open.manus.ai/docs/v2/task.listMessages

先用小任务看清积分消耗和结果质量,再把它接进你的流程。