Files
MailUI4Agents/deploy/remote-agent-demo.py
JianFeeeee 9e5c557cdf feat: 跨主机 Agent 验证 + 离线邮件补投 + 400 指向具体字段
7.8「跨主机 Agent 发现」原计划(Gateway + Registry 拆分、etcd/Consul 注册)
取消,改为验证现有协议已经够用。验证过程暴露两个真实缺陷,一并修掉。

## 为什么不做注册中心

它要解决「Gateway 怎么找到 Agent」,而这个问题在本架构里不存在:
连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。
远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。
注册中心要解决的「被叫方在哪」根本没出现过。

同一个理由此前已经决定了平台会话同步走插件上报而不是 Gateway 拉取。

## 验证方式:一个纯标准库脚本

`deploy/remote-agent-demo.py` 在另一台主机(192.168.2.106)上跑,
不装 AgentMail 的任何代码。注册 / 心跳(带模型目录)/ SSE 长连 /
收件箱 / 标记已读 / 发信全通,Gateway 侧 status=online 且 last_seen 随心跳推进。
完整一轮往返跑通:admin 发给 remotebot@/tmp/remotebot-ws,脚本回信入库。

「协议层面已支持」的含义就是这个:跨主机不需要新组件,只需要三个环境变量。

## 缺陷一:SSE 只推连上之后的事件,没人补拉积压

写那个脚本时第一版只挂了 SSE,启动前发的邮件永远不会被处理。
查了才发现**两个正式插件也有这个洞** —— 原以为它们做了补拉,实际没有。
后果比明确的失败更难排查:邮件躺在收件箱里,而发件人以为 Agent 收到了。

新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。五条约束
都对应一种具体的坏行为:

- 只在**首个**心跳后补 —— 每轮都补会把「模型正在处理中、尚未标已读」的
  邮件重复投递
- 串行、一次最多 5 封 —— 每封都要起一轮模型,并发放出去等于对上游打 N 个
  并发请求,且最后几封要等前面全部跑完
- 与 SSE 共用 deliveredMails 去重 —— 心跳与 SSE 建连之间有个窗口,
  那期间到的邮件两条路都会到
- 按时间**正序**投(收件箱倒序返回)—— 倒着塞进去同一会话的上下文是乱的
- permission 类不补投 —— 原来的工具调用早随进程没了,没有可恢复的上下文

端到端两平台各验一次:停插件 → 发信 → 启插件 → 日志「补投 1 封离线期间的
邮件」→ 回信入库;随后在线再发一封确认只回一次。

## 缺陷二:400 只说 "Invalid JSON",不说是哪个字段

脚本把 `workspaces` 传成字符串数组(它要 `[{name, path}]`),
得到的只是一句固定文案,只能靠翻服务端结构体才能发现。
两个官方插件都传 `workspaces: []`,所以这个洞一直没暴露;
第三方客户端没有「翻服务端源码」这个条件。

新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去:

    {"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
    {"error": "JSON 语法错误(第 8 字节处)"}
    {"error": "请求体为空"}

刻意不回显 encoding/json 的原文 —— 它带 Go 类型名(models.Workspace),
那是本侧的实现细节,不该出现在公开 API 的响应里。期望类型用 JSON 的说法。
截断的 JSON 走 io.ErrUnexpectedEOF 而不是 json.SyntaxError,单独一条分支,
否则会落到笼统的兜底文案里(写测试时才发现)。

## 验证

- Go:13 个新测试(decode_test.go 含「不得泄漏 Go 类型名」断言)
- 插件:两侧各 10 个补投测试,共 200 个
- 共用模块同源校验通过(catchup 已纳入 check-shared-libs.sh)
- 生产已部署
2026-09-02 22:47:31 +08:00

167 lines
6.5 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""最小跨主机 Agent —— 只用 python 标准库,证明跨主机不需要新组件。
用途:在一台**没有装 AgentMail 任何代码**的机器上收发邮件。
如果这个脚本能跑通,「跨主机 Agent」在协议层面就已经成立 ——
不需要注册中心因为连接方向是单向的Agent 主动连 GatewayGateway 从不外呼。
用法:
export AGENTMAIL_GATEWAY_URL=https://mail.example.com/api/v1
export AGENTMAIL_AGENT_KEY=<在 Gateway 上建的 Agent 密钥>
export AGENTMAIL_AGENT_NAME=remotebot
export AGENTMAIL_WORKSPACE=/tmp/remotebot-ws # 可选
python3 remote-agent-demo.py [运行秒数]
它做四件事:注册 → 心跳(带模型目录)→ SSE 长连 → 收到邮件就回一封。
真正的插件还要做权限转发、会话命名回写、附件等,见 docs/PLUGIN-GUIDE.md。
"""
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.request
GW = os.environ.get("AGENTMAIL_GATEWAY_URL", "http://127.0.0.1:8180/api/v1").rstrip("/")
KEY = os.environ.get("AGENTMAIL_AGENT_KEY", "")
NAME = os.environ.get("AGENTMAIL_AGENT_NAME", "remotebot")
WORKSPACE = os.environ.get("AGENTMAIL_WORKSPACE", "/tmp/%s-ws" % NAME)
if not KEY:
sys.exit("需要 AGENTMAIL_AGENT_KEY在 Gateway 的管理页或 admin API 上建)")
def api(path, body=None, method=None):
"""打一次 Gateway。密钥走 Authorization 头,与插件完全一致。"""
req = urllib.request.Request(
GW + path,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": "Bearer " + KEY, "Content-Type": "application/json"},
method=method or ("POST" if body is not None else "GET"),
)
try:
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
# Gateway 的错误体是 {"error": "..."},把它读出来 ——
# 不读的话只剩一个 "HTTP Error 400",看不出是哪个字段不对
detail = e.read().decode(errors="replace")[:300]
raise SystemExit("%s %s -> HTTP %s %s" % (req.method, path, e.code, detail))
def register():
"""注册。两个容易踩的地方:
- 路由是 `/agent/register`**单数**),复数会 404
- `workspaces` 是对象数组 `[{name, path}]`,不是字符串数组;
传字符串会得到 400 "请求体 JSON 解析失败"
"""
return api("/agent/register", {
"name": NAME,
"platform": "stdlib-demo",
"workspaces": [{"name": "demo", "path": WORKSPACE}],
})
def heartbeat():
"""心跳。models 是这台机器上「有」的模型目录;
响应回传管理员划定的范围,真正的插件据此决定按什么顺序尝试。"""
return api("/agent/heartbeat", {
"models": [
{"provider": "stdlib", "model": "echo-1", "display_name": "Echo演示"},
],
})
def reply_to_unread(event):
"""收到 new_mail 就把未读的都回一封,然后标记已读。
标记已读不能省:不标的话下一轮会把同一批邮件再捞一次。
"""
box = api("/mail/inbox?status=unread&limit=5")
mails = box.get("mails", box if isinstance(box, list) else [])
for m in mails:
print(" 收到:", m.get("subject"), "| 工作目录:", event.get("to_workspace"))
api("/mail/send", {
"to": m.get("from_name"),
"subject": "Re: " + (m.get("subject") or ""),
"body": (
"这封回信来自另一台主机上的一个纯标准库脚本,"
"它没有安装 AgentMail 的任何代码。\n\n"
"- 收到的工作目录: `%s`\n"
"- 原邮件 ID: `%s`\n\n"
"跨主机在协议层面已经成立Agent 主动连 Gateway"
"只需要一个公网 URL 加一把密钥。"
% (event.get("to_workspace"), m.get("mail_id"))
),
"reply_to": m.get("mail_id"),
})
api("/mail/read", {"mail_ids": [m.get("mail_id")]})
print(" 已回信")
def stream():
"""SSE 长连。真正的插件还要处理断线重连与 Last-Event-ID 补投。"""
req = urllib.request.Request(
GW + "/events/stream",
headers={"Authorization": "Bearer " + KEY, "Accept": "text/event-stream"},
)
with urllib.request.urlopen(req, timeout=300) as r:
etype = None
for raw in r:
line = raw.decode(errors="replace").rstrip("\n")
if line.startswith("event: "):
etype = line[7:]
elif line.startswith("data: ") and etype:
data = json.loads(line[6:])
print("SSE <-", etype, json.dumps(data, ensure_ascii=False)[:100])
if etype == "new_mail":
try:
reply_to_unread(data)
except Exception as e: # noqa: BLE001 —— 一封失败不该断掉长连
print(" 回信失败:", e)
etype = None
def main():
os.makedirs(WORKSPACE, exist_ok=True)
print("Gateway:", GW, "| 身份:", NAME)
print("注册:", json.dumps(register(), ensure_ascii=False)[:120])
hb = heartbeat()
print("心跳: pending=%s models_synced=%s allowed=%s unrestricted=%s" % (
hb.get("pending_mails"),
hb.get("models_synced"),
[f"{m['provider']}/{m['model']}" for m in hb.get("allowed_models", [])],
hb.get("models_unrestricted"),
))
threading.Thread(target=stream, daemon=True).start()
# 启动时先补拉一次SSE 只推连上之后的事件,
# 离线期间到的邮件只能从心跳的 pending_mails 得知。
# 不补这一次的后果:重启前发的邮件永远不会被处理。
if hb.get("pending_mails"):
print("启动补拉 %s 封未读" % hb["pending_mails"])
try:
reply_to_unread({"to_workspace": WORKSPACE})
except Exception as e: # noqa: BLE001
print(" 补拉失败:", e)
seconds = float(sys.argv[1]) if len(sys.argv) > 1 else 120
deadline = time.time() + seconds
# 30 秒一次心跳Gateway 靠 last_seen 判在线
while time.time() < deadline:
time.sleep(min(30, max(1, deadline - time.time())))
try:
heartbeat()
except Exception as e: # noqa: BLE001
print("心跳失败:", e)
print("结束")
if __name__ == "__main__":
main()