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)
- 生产已部署
167 lines
6.5 KiB
Python
Executable File
167 lines
6.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""最小跨主机 Agent —— 只用 python 标准库,证明跨主机不需要新组件。
|
||
|
||
用途:在一台**没有装 AgentMail 任何代码**的机器上收发邮件。
|
||
如果这个脚本能跑通,「跨主机 Agent」在协议层面就已经成立 ——
|
||
不需要注册中心,因为连接方向是单向的:Agent 主动连 Gateway,Gateway 从不外呼。
|
||
|
||
用法:
|
||
|
||
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()
|