fix: relay 死循环防护 + DSH 工作区注册修复 + homeagent 工具集补齐 + 三插件 connect_to_server

## relay 死循环防护(两道防线)

### 主防线:免配额只给发往人类的 relay(handler/mail.go)

原设计:relay 走免配额通道(harness 搬运不该算模型自主发信)。
问题:收件方是另一个同样会自动转发的 Agent 时,整个回路里没有任何
一处在计数——生产上跑出过 41 封(会话 f3d824ce),间隔从 15 分钟
缩到 5 秒,且用了 37 封才烧掉 4/20 预算。

改为:repo.IsHumanUser(to.Name) 判定。Agent→Agent 的 relay 照样扣预算。

顺带修次序问题:原来是「先占幂等键再扣预算」,预算耗尽时幂等键
已被占用,加了额度也无法重发。现在预算失败会 ReleaseRelay 还回去。

### 兜底:hop_limit 列接通(repo/relayhops.go)

schema 里早有 hop_limit INT DEFAULT 5,从未有代码读它。
CountTrailingRelayHops 从最新邮件往前扫,遇到第一封非 relay
邮件即停(中间有一封自主发信或人类插话就归零)。

5 测试:空会话 / 只数 relay / 自主发信打断归零 / 达到上限 / 按会话独立

## DSH 工作区注册修复

问题:上一轮加的 workspaceRegistry.create(cwd) 用了兜底值 cwd(来自
resolveWorkspaceCwd,可能是 ~/.dsh/mail-sessions/mail-<uuid>),
而不是会话 header 里的真实 cwd。两者不一致时 attachSession 拒绝,
且 create 已先执行,每封邮件都往注册表里塞一条空的垃圾 workspace。

修复:读 handle.agent.session.header.cwd —— create 路径下是 meta.cwd,
resume 路径下是持久化 header 里那个。

## homeagent 插件:11 工具齐平 opencode

tools.go 新增:read_mail / forward_mail / suggest_address /
list_contacts / session_participants / read_thread / connect_to_server
+ handleConnectToServer(注册到 Gateway 前先用候选坐标试注册,
成功才写回 p.gwURL/p.key,失败不破坏原配置)

关键修:Plugin.name(插件名,homed 注册用)与 Plugin.agentName
(AgentMail 身份,Gateway 密钥绑定用)是两个命名空间。
它们混淆会导致 403:「该密钥已绑定到 Agent 'homeagent',不能用于
注册 'homeagent-mail-bridge'」。现已分开,并在 systemd drop-in
里显式设 AGENTMAIL_AGENT_NAME=homeagent。

## 三插件补齐 connect_to_server

之前只有 opencode 有。后果:Gateway 换地址或密钥需要重新登记时,
opencode 里的模型能自己修好,其他平台只能干等环境变量被人改。

DSH 版:从 GatewayClient 内部调 register(),成功后写回 client.baseURL
与 client.agentKey 当场生效。

pi 版:新导出 KEY_FILE / saveLocalKey(从 gateway.mjs),connect
工具直接用。

# 测试

relayhops_test.go 5 例
opencode 172 / dsh 188 / pi 214 全绿
check-shared-libs.sh 三方同源(rename-proposal 已纳入校验)
This commit is contained in:
2026-09-03 15:01:52 +08:00
parent fc33087982
commit f321380fa3
19 changed files with 1909 additions and 75 deletions

View File

@ -12,7 +12,7 @@ PEERS=(plugins/dsh-mail-bridge plugins/pi-mail-bridge)
fail=0
for peer in "${PEERS[@]}"; do
for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery; do
for f in relay-dedup inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal; do
if [[ ! -f "$peer/lib/$f.js" ]]; then
echo "共用模块缺失:$peer/lib/$f.js" >&2
fail=1
@ -26,7 +26,7 @@ for peer in "${PEERS[@]}"; do
done
# 测试同样要同源:共用模块的行为约定写在测试里,
# 只同步实现不同步测试,等于允许一侧偷偷放宽约定。
for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery; do
for f in inbox-format session-snapshot workspace model-scope catchup addressing discovery rename-proposal; do
if [[ ! -f "$peer/test/$f.test.mjs" ]]; then
echo "共用测试缺失:$peer/test/$f.test.mjs" >&2
fail=1

View File

@ -186,7 +186,44 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
}
var budget repo.SessionBudget
// relayFree 表示本次 relay 走免配额通道。
//
// **免配额只给发往人类的 relay。**
//
// 豁免的理由是「harness 把平台原生的权限询问与最终总结搬进邮件,
// 不该算模型的自主发信」—— 而那是**假定收件方是人**写的。
// 收件方是另一个同样会自动转发的 Agent 时,双方都不在做决定,
// 整个回路里没有任何一处在计数 —— 生产上跑出过 41 封且间隔从
// 15 分钟缩到 5 秒的无穷循环(会话 f3d824ce
//
// 因此 Agent→Agent 的 relay 照样扣会话预算max_rounds 就能截断它。
relayFree := false
if relay != "" {
human, hErr := repo.IsHumanUser(r.Context(), to.Name)
if hErr != nil {
Error(w, http.StatusInternalServerError, "Failed to resolve recipient")
return
}
relayFree = human
}
if relay != "" {
// 硬上限:一条会话里**连续**的 relay 邮件不得超过上限。
//
// 与预算无关的第二道防线:预算给得大(比如 200两个 Agent 仍能
// 烧掉 200 个来回;而故障报告这类**必须**走 relay 的邮件也需要受约束。
//
// 「连续」是关键:中间只要有一封自主发信或人类插话,计数就归零。
hops, hopErr := repo.CountTrailingRelayHops(r.Context(), sessionID)
if hopErr == nil && hops >= repo.MaxRelayHops() {
Error(w, http.StatusForbidden, fmt.Sprintf(
"本会话已连续 %d 封自动转发(上限 %d。这通常意味着两个 Agent 在互相"+
"唤醒而无人决策。若确实需要继续,请由模型主动调 send_mail不带 relay"+
"或由人类在会话里插一句话。",
hops, repo.MaxRelayHops()))
return
}
// 先占幂等键。重复则说明这条上游消息已经转过,
// 这是插件重试 / SSE 重放的正常结果,不是故障 —— 幂等地返回成功。
if cErr := repo.ClaimRelay(r.Context(), agentName, relayKey, relay); cErr != nil {
@ -202,7 +239,10 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusInternalServerError, "Failed to claim relay")
return
}
// 仅读快照用于回传,不扣预算
}
if relayFree {
// 只读快照用于回传,不扣预算
budget, _ = repo.GetSessionBudget(r.Context(), sessionID)
} else {
// 额度只看【本任务】的往返预算。
@ -212,6 +252,11 @@ func SendMail(w http.ResponseWriter, r *http.Request) {
// 靠的是新建会话速率限制resolveTarget 里)。
budget, err = repo.ConsumeSessionBudget(r.Context(), sessionID)
if errors.Is(err, repo.ErrSessionBudgetExhausted) {
// 预算耗尽时要把幂等键还回去:否则那条上游消息永远转不出来了,
// 之后管理员加了额度也无法重发。
if relay != "" {
_ = repo.ReleaseRelay(r.Context(), agentName, relayKey)
}
Error(w, http.StatusForbidden, fmt.Sprintf(
"本任务的往返预算已用尽(%d/%d。自动转发的总结与权限询问不占预算"+
"若需继续主动发信,请让人在对话页调高本任务的预算。",

View File

@ -0,0 +1,83 @@
package repo
import (
"context"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
// 连续 relay 跳数限制 —— 防止两个 Agent 靠自动转发互相唤醒到无穷。
//
// # 这是什么问题
//
// 每个插件都在「一轮结束时把模型最后那段话自动发回去」(契约 B-5
// 当收件方也是一个装了同类插件的 Agent 时,这封信唤醒对方 → 对方跑一轮 →
// 对方也自动回一封 → 循环。**双方都没有「决定继续」,因为双方都不在做决定** ——
// 发信是插件代劳的。
//
// 生产上真实发生过:会话 f3d824cedsh 与 opencode 联调 llmsproxy共 41 封,
// 最后一封人类意图的邮件之后,**每一封都是 relay:summary**
// 间隔从 15 分钟一路缩到 5 秒,内容已无新增信息。
//
// # 为什么 relay_key 拦不住
//
// 它是幂等键,职责是「同一条上游消息不重复转发」,这一点它做对了。
// 但每一轮都是**真正不同**的新消息opencode 侧是 assistant message id
// msg_0655bbf6…、msg_0656cc7fc…dsh 侧是事件计数(…:12220、…:13347
// 每次 ClaimRelay 都合法通过。
//
// # 为什么需要两道防线
//
// 主防线是「免配额只给发往人类的 relay」见 handler.SendMail
// Agent→Agent 的自动转发转而消耗会话预算max_rounds 会截断它。
//
// 但那还不够:预算给得大(比如 200两个 Agent 仍能烧掉 200 个来回;
// 而故障报告这类**必须**走 relay 的邮件也需要受约束。因此这里再加一道
// 与预算无关的硬上限:一条会话里**连续**的 relay 邮件不得超过 maxRelayHops。
//
// 「连续」是关键:只要中间有一封自主发信(模型真的决定说什么)或人类插话,
// 计数就归零。这让正常的「模型回一封、插件补一封总结」不受影响,
// 只掐住「全程无人决策」的那种回路。
//
// hop_limit 列早就在 schema 里DEFAULT 5却从没有人读它 —— 它显然
// 就是为这件事准备的。这里把它接上,取同一个默认值。
const maxRelayHops = 5
// CountTrailingRelayHops 数会话尾部**连续**的 relay 邮件数。
//
// 从最新一封往前扫,遇到第一封非 relay 邮件即停。返回值即「若本次再发一封
// relay它会是第几跳」的前一个数。
//
// 判据用 relayed_mails 的存在性而不是 mails 上的某个标记:
// relay 身份本来就记在那张表里,在 mails 上再存一份等于给同一事实留两个答案。
func CountTrailingRelayHops(ctx context.Context, sessionID uuid.UUID) (int, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT CASE WHEN r.mail_id IS NULL THEN 0 ELSE 1 END AS is_relay
FROM mails m
LEFT JOIN relayed_mails r ON r.mail_id = m.mail_id
WHERE m.session_id = $1
ORDER BY m.created_at DESC, m.mail_id DESC
`, sessionID)
if err != nil {
return 0, err
}
defer rows.Close()
hops := 0
for rows.Next() {
var isRelay int
if err := rows.Scan(&isRelay); err != nil {
return hops, err
}
if isRelay == 0 {
// 遇到一封自主发信/人类邮件:链条到此为止
break
}
hops++
}
return hops, rows.Err()
}
// MaxRelayHops 暴露上限供错误文案与测试使用。
func MaxRelayHops() int { return maxRelayHops }

View File

@ -0,0 +1,126 @@
package repo
import (
"context"
"testing"
"github.com/google/uuid"
)
// 连续 relay 跳数上限守的是一个真实事故:会话 f3d824cedsh 与 opencode 联调
// llmsproxy共 41 封,最后一封人类意图的邮件之后每一封都是 relay:summary
// 间隔从 15 分钟一路缩到 5 秒。双方都没有「决定继续」,因为双方都不在做决定 ——
// 发信是插件代劳的,而免配额通道让整个回路里没有任何一处在计数。
func TestTrailingRelayHopsEmptySession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "空会话", "/tmp/ws")
n, err := CountTrailingRelayHops(ctx, sid)
if err != nil {
t.Fatalf("空会话应正常返回: %v", err)
}
if n != 0 {
t.Fatalf("空会话跳数应为 0实为 %d", n)
}
}
func TestTrailingRelayHopsCountsOnlyRelay(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
sid, _ := CreateSession(ctx, nil, "admin", "混合", "/tmp/ws")
// 人类发一封(非 relay
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil)
// 插件连续转发三封
for i := 0; i < 3; i++ {
seedRelayMail(t, ctx, sid, "dsh", "opencode")
}
n, err := CountTrailingRelayHops(ctx, sid)
if err != nil {
t.Fatalf("数跳数: %v", err)
}
if n != 3 {
t.Fatalf("尾部连续 relay 应为 3实为 %d", n)
}
}
func TestTrailingRelayHopsResetsOnAutonomousSend(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 「连续」是这条规则的关键:中间只要有一封自主发信(模型真的决定说什么)
// 或人类插话,计数就归零。否则正常的「模型回一封、插件补一封总结」
// 会被误判成回路。
sid, _ := CreateSession(ctx, nil, "admin", "打断", "/tmp/ws")
for i := 0; i < 4; i++ {
seedRelayMail(t, ctx, sid, "dsh", "opencode")
}
// 模型亲手发了一封 —— 链条到此为止
mustMail(t, sid, "dsh", "", "opencode", "/tmp/ws", nil)
seedRelayMail(t, ctx, sid, "opencode", "dsh")
n, err := CountTrailingRelayHops(ctx, sid)
if err != nil {
t.Fatalf("数跳数: %v", err)
}
if n != 1 {
t.Fatalf("自主发信之后只剩 1 跳,实为 %d", n)
}
}
func TestTrailingRelayHopsReachesLimit(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 攒到上限:此时 handler 应当拒绝下一封 relay。
sid, _ := CreateSession(ctx, nil, "admin", "到顶", "/tmp/ws")
mustMail(t, sid, "admin", "", "dsh", "/tmp/ws", nil)
for i := 0; i < MaxRelayHops(); i++ {
seedRelayMail(t, ctx, sid, "dsh", "opencode")
}
n, _ := CountTrailingRelayHops(ctx, sid)
if n < MaxRelayHops() {
t.Fatalf("应达到上限 %d实为 %d", MaxRelayHops(), n)
}
}
func TestTrailingRelayHopsIsPerSession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
// 一条会话的回路不该影响另一条:两个 Agent 在 A 会话里刷爆了,
// B 会话的正常自动转发仍应放行。
a, _ := CreateSession(ctx, nil, "admin", "A", "/tmp/a")
b, _ := CreateSession(ctx, nil, "admin", "B", "/tmp/b")
for i := 0; i < 5; i++ {
seedRelayMail(t, ctx, a, "dsh", "opencode")
}
seedRelayMail(t, ctx, b, "dsh", "admin")
na, _ := CountTrailingRelayHops(ctx, a)
nb, _ := CountTrailingRelayHops(ctx, b)
if na != 5 || nb != 1 {
t.Fatalf("跳数应按会话独立计A=%d期望 5B=%d期望 1", na, nb)
}
}
// seedRelayMail 建一封走 relay 通道的邮件(同时占幂等键并关联 mail_id
// 复刻 handler.SendMail 的真实写入顺序。
func seedRelayMail(t *testing.T, ctx context.Context, sid uuid.UUID, from, to string) {
t.Helper()
key := "relay-" + uuid.NewString()
if err := ClaimRelay(ctx, from, key, "summary"); err != nil {
t.Fatalf("占幂等键: %v", err)
}
mid, err := CreateMail(ctx, sid, nil, from, "", to, "", "Re: 主题", "正文", nil)
if err != nil {
t.Fatalf("建邮件: %v", err)
}
if err := BindRelayMail(ctx, from, key, mid); err != nil {
t.Fatalf("关联 relay: %v", err)
}
}

View File

@ -0,0 +1,18 @@
export declare function isProposableAlias(alias: string | null | undefined): boolean;
export interface ProposalResult {
body: string;
proposed: boolean;
}
export declare function appendRenameProposal(
body: string,
alias?: string,
reason?: string,
): ProposalResult;
export declare function renameProposalNote(
serverAlias?: string,
requestedAlias?: string,
proposed?: boolean,
): string;

View File

@ -0,0 +1,109 @@
/**
* 会话改名提议 —— 所有平台插件共用。
*
* # 这是什么
*
* 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug
* 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是
* `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。
*
* # 为什么是「提议」而不是直接改
*
* 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉,
* 人上一秒记住的地址下一秒就 404`session` 位三态语义要求指向不存在的会话直接报
* 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。
*
* 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突:
*
* | | 谁发起 | 何时 | 是否打扰人 |
* |---|---|---|---|
* | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 |
* | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 |
*
* # 为什么载体是 HTML 注释
*
* `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去,
* 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由:
*
* - react-markdown 默认不解析 raw HTML万一服务端没剥掉它在页面上也只是
* 一行不显眼的转义文本,不会破版
* - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼
* - 不与 Markdown 语法冲突,格式化工具不会改写它
*
* # 为什么必须共用
*
* 标记格式是**服务端正则的镜像**`gateway/internal/handler/rename_proposal.go`)。
* 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 ——
* 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。
*/
/**
* 服务端能识别的别名字符集。
*
* 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突,
* `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)——
* 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的
* 「我提议的名字」与实际入库的不一致。
*
* @param {string} alias
* @returns {boolean}
*/
export function isProposableAlias(alias) {
const a = String(alias ?? '').trim();
if (!a) return false;
if (a === 'new') return false;
// 双引号是标记本身的定界符,含它会截断标记
if (/[.\s/@"]/.test(a)) return false;
// 服务端 VARCHAR(128),按字节算
if (Buffer.byteLength(a, 'utf8') > 128) return false;
return true;
}
/**
* 把改名提议标记追加到正文末尾。
*
* 格式必须与服务端正则逐字符对应:
* `<!-- agentmail:rename-session alias="x" reason="y" -->`
* reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由,
* 界面上的提示条就少了那句解释)。
*
* 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却
* 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。
*
* @param {string} body 原始正文
* @param {string} [alias] 提议的别名
* @param {string} [reason] 提议理由,一句话
* @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加
*/
export function appendRenameProposal(body, alias, reason) {
const text = String(body ?? '');
if (!isProposableAlias(alias)) return { body: text, proposed: false };
const a = String(alias).trim();
// 理由里的双引号会截断标记去掉而不是转义HTML 注释里没有转义机制
const r = String(reason ?? '').replace(/"/g, '').trim();
const reasonAttr = r ? ` reason="${r}"` : '';
return {
body: `${text}\n\n<!-- agentmail:rename-session alias="${a}"${reasonAttr} -->`,
proposed: true,
};
}
/**
* 提议提交后回给模型的那句话。
*
* 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里
* 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。
*
* @param {string} alias
* @param {boolean} proposed appendRenameProposal 的返回值
* @returns {string} 空串表示没有需要追加的说明
*/
export function renameProposalNote(alias, proposed) {
if (!alias) return '';
if (!proposed) {
return `(改名提议 "${alias}" 未提交:别名不可为 new不可含 . 空白 / @ 或双引号。)`;
}
return `已附上改名提议 "${alias}",等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。`;
}

View File

@ -12,7 +12,7 @@
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
@ -50,6 +50,7 @@ import {
renderContacts,
renderThread,
} from '../lib/discovery.js';
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
// ─── 凭证管理 ───
@ -628,14 +629,27 @@ export function apply(ctx: any, config: PluginConfig): void {
// 的唯一路径。不挂的后果:会话永远落在 Ungrouped人类在侧栏看不到它
// 与项目的从属关系,也无法用 DSH 的工作区级操作(批量归档、重命名等)。
//
// 失败不可怕:会话本身已经能用了,只是 GUI 分组不对。最常见失败是
// 邮件里的 cwd 在 DSH 主机上不存在(跨主机场景),此时 mkdirSync
// 建不出目录workspaceRegistry.create 也报错。
if (cwd && handle) {
// **cwd 必须取自会话 header不能用上面那个 `cwd` 变量。**
//
// 上一版就错在这里:那个变量是 `resolveWorkspaceCwd` 的结果,可能是**兜底值**
// `~/.dsh/mail-sessions/mail-<uuid>`);而 resume 路径下会话的真实 cwd 取自
// 持久化的 header两者不一致时 `attachSession` 的校验直接拒绝:
//
// cannot attach session 'mail-f3d824ce…' to workspace
// '/root/.dsh/mail-sessions/mail-f3d824ce…': its cwd resolves to '/home/program/llmsproxy'
//
// 更糟的是 `wr.create()` 已经先执行了 —— 于是每封邮件都往注册表里
// 塞一条永远为空的垃圾 workspace。先读 header 再注册就不会有这个问题。
//
// 失败不可怕:会话本身已经能用了,只是 GUI 分组不对。
if (handle) {
try {
// 真实 cwdcreate 路径下就是上面传的 meta.cwd
// resume 路径下是持久化 header 里那个。两种情形都从 session 读。
const actualCwd = String(handle.agent?.session?.header?.cwd ?? '');
const wr: any = (ctx as any).get?.('workspaceRegistry');
if (wr?.create) {
const ws = await wr.create(cwd, cwd.split('/').pop() || cwd);
if (actualCwd && wr?.create) {
const ws = await wr.create(actualCwd, actualCwd.split('/').pop() || actualCwd);
if (ws?.attachSession) {
await ws.attachSession(attemptSessionId as any);
}
@ -754,14 +768,24 @@ export function apply(ctx: any, config: PluginConfig): void {
reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' },
session_alias: { type: 'string', description: '给新会话命名' },
attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件 ID 列表' },
propose_alias: {
type: 'string',
description: '建议把当前会话改名成这个别名(例如摸清问题后从「排查登录问题」改成 fix-session-cookie-leak。这只是建议别名是人的寻址入口实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new',
},
propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any, toolCtx: any): Promise<string> {
// 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
// 拼接收在 lib/rename-proposal.js三平台共用标记格式是服务端正则的
// 镜像,各写一遍的话少个空格就静默失效 —— 邮件照常发出,提议凭空消失。
const { body, proposed } = appendRenameProposal(
args.body, args.propose_alias, args.propose_reason);
const result = await client.post('/mail/send', {
to: args.to, subject: args.subject, body: args.body,
to: args.to, subject: args.subject, body,
cc: args.cc || '', reply_to: args.reply_to || '',
session_alias: args.session_alias || '',
attachment_ids: args.attachment_ids || [],
@ -769,7 +793,10 @@ export function apply(ctx: any, config: PluginConfig): void {
noteExplicitSend(toolCtx?.sessionID, args.to, args.reply_to);
const budget = typeof result.budget_remaining === 'number'
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。` : '';
return `邮件已发送ID: ${result.mail_id}${budget}`;
// 别名取服务端回的 rename_proposed它跑过 normalizeAlias
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404
const note = renameProposalNote(result.rename_proposed, args.propose_alias, proposed);
return `邮件已发送ID: ${result.mail_id}${budget}` + (note ? `\n${note}` : '');
},
}));
@ -813,17 +840,31 @@ export function apply(ctx: any, config: PluginConfig): void {
// upload_attachment
ctx.tools.register(defineTool({
name: 'upload_attachment',
description: '上传本地文件作为邮件附件,返回 attachment_id。',
description:
'上传本地文件作为邮件附件,返回 attachment_id。' +
'拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。' +
'未随邮件发出的附件 24 小时后自动清理。',
parameters: {
file_path: { type: 'string', required: true, description: '本地文件路径' },
file_path: { type: 'string', required: true, description: '要上传的本地文件绝对路径' },
filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
// 先 stat 再读:目录和不存在的路径都要给出能行动的错误。
// 直接 readFile 的话,目录抛的 EISDIR 只会让模型重试同一个路径。
let st;
try {
st = await stat(args.file_path);
} catch {
return `文件不存在或不可读: ${args.file_path}`;
}
if (!st.isFile()) return `不是普通文件: ${args.file_path}`;
const data = await readFile(args.file_path);
const filename = args.file_path.split('/').pop() || 'file';
const filename = args.filename || args.file_path.split('/').pop() || 'file';
// **必须发真正的 multipart。**
//
// 早先这里发的是 `Content-Type: application/octet-stream` 加一个
@ -843,17 +884,18 @@ export function apply(ctx: any, config: PluginConfig): void {
const json = await res.json() as any;
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
const a = json.attachment;
return `已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}`;
return `已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}\n` +
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`;
},
}));
// download_attachment
ctx.tools.register(defineTool({
name: 'download_attachment',
description: '下载邮件附件到本地文件。',
description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。',
parameters: {
attachment_id: { type: 'string', required: true, description: '附件 ID' },
save_path: { type: 'string', required: true, description: '保存路径' },
save_path: { type: 'string', required: true, description: '保存到的本地绝对路径' },
},
output: {
schema: { type: 'string' },
@ -865,6 +907,9 @@ export function apply(ctx: any, config: PluginConfig): void {
});
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
// 父目录不存在时先建:模型经常写 ./downloads/x.pdf 这类还不存在的路径,
// 不建的话 writeFile 抛 ENOENT而那个错误看起来像「附件不存在」。
await mkdir(dirname(args.save_path), { recursive: true });
await writeFile(args.save_path, buf);
return `已保存到 ${args.save_path}${formatSize(buf.length)}`;
},
@ -1043,11 +1088,71 @@ export function apply(ctx: any, config: PluginConfig): void {
},
}));
// connect_to_server —— 连接自愈。
//
// 之前只有 opencode 侧有这个工具。后果是Gateway 换了地址、或密钥需要
// 重新登记时opencode 里的模型能自己修好,而 DSH 里的模型只能干等
// systemd 环境变量被人改 —— 同一类能力在不同平台上时有时无,
// 等于让人记住哪个平台能自己修。
//
// 失败时**把需要登记的密钥全文打出来**:密钥未登记是最常见的失败,
// 不给值的话要多走一轮「密钥无效 → 去哪拿 → 让管理员登记」。
ctx.tools.register(defineTool({
name: 'connect_to_server',
description:
'连接到 AgentMail Gateway登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。' +
'密钥若未在后台登记过,此处会返回需要登记的密钥全文。',
parameters: {
gateway_url: { type: 'string', description: 'Gateway 地址,如 https://mail.example.com省略则用当前配置' },
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' },
},
output: {
schema: { type: 'string' },
render: (_args: any, value: string) => [{ type: 'text', text: value }],
},
async execute(args: any): Promise<string> {
let key = client.agentKey;
if (args.key_token) {
key = String(args.key_token).trim();
// 管理员给的密钥落盘,重启后仍然可用
saveLocalKey(key);
} else if (!key) {
key = generateLocalKey();
}
const url = String(args.gateway_url || client.baseURL).replace(/\/+$/, '');
const res = await fetch(`${url}/api/v1/agent/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({ name: AGENT_NAME, workspaces: [], platform: 'dsh' }),
});
const data = await res.json().catch(() => ({})) as any;
if (!res.ok) {
return [
`连接失败HTTP ${res.status}${data?.error || '未知错误'}`,
``,
`若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`,
key,
``,
`密钥文件:${KEY_FILE}`,
].join('\n');
}
// 成功后把新坐标写回客户端,当场生效(不用等重启)
client.baseURL = url;
client.agentKey = key;
return `已连接 ${url},注册为 ${data?.agent_name || AGENT_NAME}`;
},
}));
return () => {
for (const n of [
'send_mail', 'read_inbox', 'read_mail', 'forward_mail',
'upload_attachment', 'download_attachment',
'suggest_address', 'list_contacts', 'session_participants', 'read_thread',
'connect_to_server',
]) {
try { ctx.tools.unregister(n); } catch {}
}

View File

@ -0,0 +1,119 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isProposableAlias,
appendRenameProposal,
renameProposalNote,
} from '../lib/rename-proposal.js';
// 这一组测试钉住的是「插件拼的标记与服务端正则逐字符对应」。
// 服务端那条正则在 gateway/internal/handler/rename_proposal.go
// <!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->
// 拼错不会报错 —— 邮件照常发出,提议凭空消失。
/** 服务端正则的等价实现,用来验证我们拼出来的标记真的能被摘出来。 */
const SERVER_RE =
/<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->/s;
test('isProposableAlias: 合法别名', () => {
assert.equal(isProposableAlias('fix-login-leak'), true);
assert.equal(isProposableAlias('修复登录态泄漏'), true, '中文别名合法');
assert.equal(isProposableAlias('v2_migration'), true);
});
test('isProposableAlias: new 是寻址保留字', () => {
// `.new` 是「强制新建会话」的动作,别名叫 new 会让地址无从解释
assert.equal(isProposableAlias('new'), false);
});
test('isProposableAlias: 拒绝与三维地址冲突的字符', () => {
// 这四个字符都会让 name@path.session 的切分产生歧义
assert.equal(isProposableAlias('a.b'), false, '. 是 session 位分隔符');
assert.equal(isProposableAlias('a/b'), false, '/ 出现在 path 位');
assert.equal(isProposableAlias('a@b'), false, '@ 是 name/path 分隔符');
assert.equal(isProposableAlias('a b'), false, '空白');
assert.equal(isProposableAlias('a\tb'), false, '制表符也算空白');
});
test('isProposableAlias: 拒绝双引号', () => {
// 双引号是标记自身的定界符,含它会把标记截断成非法形式
assert.equal(isProposableAlias('say"hi'), false);
});
test('isProposableAlias: 空与空白视为没提', () => {
assert.equal(isProposableAlias(''), false);
assert.equal(isProposableAlias(' '), false);
assert.equal(isProposableAlias(undefined), false);
assert.equal(isProposableAlias(null), false);
});
test('isProposableAlias: 超过 128 字节按字节算', () => {
// 服务端是 VARCHAR(128)。中文一个字 3 字节43 字 = 129 字节
assert.equal(isProposableAlias('a'.repeat(128)), true);
assert.equal(isProposableAlias('a'.repeat(129)), false);
assert.equal(isProposableAlias('汉'.repeat(42)), true, '126 字节');
assert.equal(isProposableAlias('汉'.repeat(43)), false, '129 字节');
});
test('标记能被服务端正则摘出来', () => {
const { body, proposed } = appendRenameProposal('已定位到问题。', 'fix-login-leak', '登录态泄漏');
assert.equal(proposed, true);
const m = SERVER_RE.exec(body);
assert.ok(m, '服务端正则必须匹配得上');
assert.equal(m[1], 'fix-login-leak');
assert.equal(m[2], '登录态泄漏');
});
test('没有理由时整个 reason 属性都不写', () => {
// 写成 reason="" 会让服务端存一个空理由,界面提示条就少了那句解释
const { body } = appendRenameProposal('正文', 'fix-leak');
assert.doesNotMatch(body, /reason=/);
const m = SERVER_RE.exec(body);
assert.equal(m[1], 'fix-leak');
assert.equal(m[2], undefined);
});
test('理由里的双引号被去掉而不是转义', () => {
// HTML 注释里没有转义机制,留着会截断标记
const { body } = appendRenameProposal('正文', 'fix-leak', '他说"这是泄漏"');
const m = SERVER_RE.exec(body);
assert.ok(m);
assert.equal(m[2], '他说这是泄漏');
});
test('原正文完整保留在标记之前', () => {
const original = '第一行\n\n第二行';
const { body } = appendRenameProposal(original, 'fix-leak');
assert.ok(body.startsWith(original), '正文不得被改写');
});
test('别名不合法时原样返回,不追加标记', () => {
const { body, proposed } = appendRenameProposal('正文', 'a.b');
assert.equal(proposed, false);
assert.equal(body, '正文');
assert.doesNotMatch(body, /agentmail:rename-session/);
});
test('不给别名时正文完全不变', () => {
const { body, proposed } = appendRenameProposal('正文', '');
assert.equal(proposed, false);
assert.equal(body, '正文');
});
test('renameProposalNote: 成功时必须说明等人确认', () => {
// 不说的话模型会以为改名已生效,接着用新别名当地址发信 —— 那个别名还不存在
const note = renameProposalNote('fix-leak', true);
assert.match(note, /fix-leak/);
assert.match(note, /确认/);
assert.match(note, /原别名/, '要明确说在那之前用哪个');
});
test('renameProposalNote: 失败时说清为什么', () => {
const note = renameProposalNote('a.b', false);
assert.match(note, /未提交/);
assert.match(note, /a\.b/);
});
test('renameProposalNote: 没提议时不产生噪音', () => {
assert.equal(renameProposalNote('', false), '');
});

View File

@ -28,13 +28,21 @@ const (
// 因此本插件不做会话映射 —— 所有邮件注入同一个事件循环,像 QQ 插件一样。
// 邮件的 context 完全靠中断消息的文本传递,不靠 platform_sessions 上报。
type Plugin struct {
name string
sdk *sdk.PluginSDK
gwURL string
key string
client *http.Client
stopCh chan struct{}
stopOnce sync.Once
// name 是**插件名**homed 注册用,如 homeagent-mail-bridge
name string
// agentName 是**AgentMail 身份**(如 homeagent
//
// 两者必须分开:密钥绑定的是 AgentMail 身份,拿插件名去注册会被拒
// 403 该密钥已绑定到 Agent "homeagent",不能用于注册 "homeagent-mail-bridge"
// 这不是 Gateway 太严格 —— 名字与人类用户名共用命名空间,
// 让一把密钥能注册任意名字等于让它能冒充任何人。
agentName string
sdk *sdk.PluginSDK
gwURL string
key string
client *http.Client
stopCh chan struct{}
stopOnce sync.Once
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -55,12 +63,28 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
if key == "" {
key = os.Getenv("AGENTMAIL_AGENT_KEY")
}
// AgentMail 身份config > 环境变量 > 从插件名去掉 -mail-bridge 后缀。
// 兜底那条让默认配置能直接跑通homeagent-mail-bridge → homeagent
// 但显式配置永远优先 —— 插件名是部署细节,不该决定对外身份。
agentName := ""
if v, ok := config["agent_name"].(string); ok {
agentName = strings.TrimSpace(v)
}
if agentName == "" {
agentName = strings.TrimSpace(os.Getenv("AGENTMAIL_AGENT_NAME"))
}
if agentName == "" {
agentName = strings.TrimSuffix(name, "-mail-bridge")
}
return &Plugin{
name: name,
gwURL: strings.TrimRight(gw, "/"),
key: key,
client: &http.Client{Timeout: 30 * time.Second},
stopCh: make(chan struct{}),
name: name,
agentName: agentName,
gwURL: strings.TrimRight(gw, "/"),
key: key,
client: &http.Client{Timeout: 30 * time.Second},
stopCh: make(chan struct{}),
}, nil
}
@ -106,6 +130,94 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleSendMail)
// 读一封的完整内容(收件箱只给摘要;要回给抄收方就得先看清发给了谁)
s.RegisterTool("read_mail", sdk.ToolDef{
Name: "read_mail",
Description: "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。",
Parameters: oneStringParam("mail_id", "邮件 ID", true),
}, p.handleReadMail)
// 转发 —— 引用原文与附件,按目标地址另行定位会话(它是一条新线索)
s.RegisterTool("forward_mail", sdk.ToolDef{
Name: "forward_mail",
Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"mail_id": map[string]interface{}{"type": "string", "description": "要转发的邮件 ID"},
"to": map[string]interface{}{"type": "string", "description": "新收件人的三维地址(先用 suggest_address 确认)"},
"comment": map[string]interface{}{"type": "string", "description": "转发说明,置于引用原文之前"},
"cc": map[string]interface{}{"type": "string", "description": "抄送,逗号分隔多个三维地址"},
"subject": map[string]interface{}{"type": "string", "description": "自定义主题;留空则自动加 Fwd: 前缀"},
"session_alias": map[string]interface{}{"type": "string", "description": "仅当目标地址以 .new 结尾时生效:给新会话命名"},
},
"required": []string{"mail_id", "to"},
},
}, p.handleForwardMail)
// ─── 寻址发现 ───
//
// 没有这一组时send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
// 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功,
// 但那不是它的工作目录,错误路径静默变成了新会话的 workspace。
s.RegisterTool("suggest_address", sdk.ToolDef{
Name: "suggest_address",
Description: "查询可用的收件人地址。不带参数给候选收件人名;带 name 给它可用的工作目录;" +
"name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,不要凭记忆拼写。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "收件人名;留空则列出所有候选收件人"},
"path": map[string]interface{}{"type": "string", "description": "工作目录;与 name 同时给出才列会话"},
},
},
}, p.handleSuggestAddress)
s.RegisterTool("list_contacts", sdk.ToolDef{
Name: "list_contacts",
Description: "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。用于回答「我还有什么没处理」。",
Parameters: oneStringParam("limit", "最多列出多少条,默认 20", false),
}, p.handleListContacts)
s.RegisterTool("session_participants", sdk.ToolDef{
Name: "session_participants",
Description: "列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。" +
"**要回给抄收方或向第三方转达时先用它拿地址**。",
Parameters: oneStringParam("session_id", "会话 ID", true),
}, p.handleSessionParticipants)
s.RegisterTool("read_thread", sdk.ToolDef{
Name: "read_thread",
Description: "查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时用它确认" +
"别人已经说了什么,避免重复提问或重复汇报。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"mail_id": map[string]interface{}{"type": "string", "description": "线索中任一封邮件的 ID"},
"offset": map[string]interface{}{"type": "number", "description": "分页偏移,续取时传上次返回的 next_offset"},
},
"required": []string{"mail_id"},
},
}, p.handleReadThread)
// connect_to_server —— 连接自愈。
//
// Gateway 换了地址、或密钥需要重新登记时,模型能自己修好而不必等人改
// 环境变量。失败时把需要登记的密钥全文打出来,省掉一轮来回。
s.RegisterTool("connect_to_server", sdk.ToolDef{
Name: "connect_to_server",
Description: "连接到 AgentMail Gateway登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。" +
"密钥若未在后台登记过,此处会返回需要登记的密钥全文。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"gateway_url": map[string]interface{}{"type": "string", "description": "Gateway 地址;省略则用当前配置"},
"key_token": map[string]interface{}{"type": "string", "description": "管理员签发的 Agent 密钥;省略则用当前密钥"},
},
},
}, p.handleConnectToServer)
// 注册输出通道 —— agent 可以主动调 output_send__homeagent 发信
s.RegisterOutputChannel("homeagent", sdk.CapText|sdk.CapFile,
"发送邮件。meta JSON 格式:{to, subject, reply_to}type: text",
@ -148,7 +260,7 @@ func (p *Plugin) heartbeatLoop() {
func (p *Plugin) register() error {
body := map[string]interface{}{
"name": p.name,
"name": p.agentName,
"platform": "homeagent",
"workspaces": []interface{}{},
}
@ -337,7 +449,7 @@ func (p *Plugin) handleNewMail(evt struct {
"**回信不用你自己发**:你把本轮工作做完、把结论说出来就行,\n"+
"插件会在这一轮结束时自动把你最后那段话作为回信发回给 %s不消耗你的发信配额。\n"+
"只有在需要主动联系其他人、或要带附件时才调用 send_mail。",
evt.FromName, evt.Subject, evt.MailID, p.name, evt.FromName,
evt.FromName, evt.Subject, evt.MailID, p.agentName, evt.FromName,
)
// 非阻塞注入 —— TrueAgent 的事件循环会处理
@ -487,9 +599,9 @@ func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error
}
payload := map[string]interface{}{
"to": to,
"subject": subj,
"body": body,
"to": to,
"subject": subj,
"body": body,
}
if cc != "" {
payload["cc"] = cc

View File

@ -0,0 +1,460 @@
package main
import (
"fmt"
"strings"
)
// ─── 工具定义(与 opencode/dsh/pi 同源逻辑Go 版本)───
//
// 所有工具都是对 Gateway REST API 的薄封装HTTP → 渲染 → 模型可读文本。
// 与 JS 插件的区别仅在 HTTP 辅助函数p.get / p.post行为完全一致。
func (p *Plugin) handleReadMail(args map[string]interface{}) (interface{}, error) {
mid, _ := args["mail_id"].(string)
if mid == "" {
return nil, fmt.Errorf("缺少 mail_id")
}
var data struct {
Mail struct {
FromName string `json:"from_name"`
ToName string `json:"to_name"`
ToWorkspace string `json:"to_workspace"`
Subject string `json:"subject"`
Body string `json:"body"`
CCList []struct {
Name string `json:"name"`
Path string `json:"path"`
Raw string `json:"raw"`
} `json:"cc_list"`
Attachments []struct {
Filename string `json:"filename"`
SizeBytes int `json:"size_bytes"`
AttachmentID string `json:"attachment_id"`
} `json:"attachments"`
} `json:"mail"`
SessionAlias string `json:"session_alias"`
ReplyAddress string `json:"reply_address"`
SelfAddress string `json:"self_address"`
Participants []struct {
Name string `json:"name"`
Path string `json:"path"`
Roles []string `json:"roles"`
Address string `json:"address"`
IsSelf bool `json:"is_self"`
} `json:"participants"`
}
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid, &data); err != nil {
return nil, err
}
var sb strings.Builder
fmt.Fprintf(&sb, "发件人: %s\n", data.Mail.FromName)
if data.Mail.ToWorkspace != "" {
fmt.Fprintf(&sb, "收件人: %s@%s\n", data.Mail.ToName, data.Mail.ToWorkspace)
} else {
fmt.Fprintf(&sb, "收件人: %s\n", data.Mail.ToName)
}
fmt.Fprintf(&sb, "主题: %s\n", data.Mail.Subject)
fmt.Fprintf(&sb, "会话: #%ssession_id: %s\n", data.SessionAlias, data.Mail.FromName)
if len(data.Mail.CCList) > 0 {
names := make([]string, 0, len(data.Mail.CCList))
for _, c := range data.Mail.CCList {
names = append(names, c.Raw)
}
fmt.Fprintf(&sb, "抄送: %s\n", strings.Join(names, "、"))
}
if len(data.Mail.Attachments) > 0 {
fmt.Fprintf(&sb, "附件:\n")
for _, a := range data.Mail.Attachments {
fmt.Fprintf(&sb, " - %s (%.1fKB, id=%s)\n", a.Filename, float64(a.SizeBytes)/1024, a.AttachmentID)
}
}
fmt.Fprintf(&sb, "\n%s\n", data.Mail.Body)
if len(data.Participants) > 0 {
fmt.Fprintf(&sb, "\n可投递地址:\n")
for _, pt := range data.Participants {
if pt.Address != "" && !pt.IsSelf {
fmt.Fprintf(&sb, " - %s (%s)\n", pt.Address, strings.Join(pt.Roles, "/"))
}
}
}
if data.ReplyAddress != "" {
fmt.Fprintf(&sb, "回信给发件人用 %s或传 reply_to=%s\n", data.ReplyAddress, data.Mail.FromName)
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleForwardMail(args map[string]interface{}) (interface{}, error) {
mid, _ := args["mail_id"].(string)
to, _ := args["to"].(string)
comment, _ := args["comment"].(string)
cc, _ := args["cc"].(string)
subj, _ := args["subject"].(string)
sa, _ := args["session_alias"].(string)
if mid == "" || to == "" {
return nil, fmt.Errorf("缺少 mail_id 和 to")
}
payload := map[string]interface{}{
"to": to,
"comment": comment,
"cc": cc,
"subject": subj,
"session_alias": sa,
}
var result map[string]interface{}
if err := p.post("/mail/"+mid+"/forward", payload, &result); err != nil {
return nil, err
}
text := fmt.Sprintf("已转发。新 Mail ID: %sSession: %s", result["mail_id"], result["session_id"])
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": text}},
}, nil
}
func (p *Plugin) handleSuggestAddress(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
path, _ := args["path"].(string)
name = strings.TrimSpace(name)
path = strings.TrimSpace(path)
qs := ""
if name != "" {
qs += "name=" + name
}
if path != "" {
if qs != "" {
qs += "&"
}
qs += "path=" + path
}
var data struct {
Kind string `json:"kind"`
Suggestions []string `json:"suggestions"`
Addresses []string `json:"addresses"`
Candidates []struct {
Alias string `json:"alias"`
Title string `json:"title"`
Unread int `json:"unread"`
Source string `json:"source"`
} `json:"candidates"`
}
if err := p.get(p.gwURL+"/api/v1/agent/contacts/suggest?"+qs, &data); err != nil {
return nil, err
}
var sb strings.Builder
switch data.Kind {
case "name":
sb.WriteString(fmt.Sprintf("可投递的收件人(%d 个):\n", len(data.Suggestions)))
for _, n := range data.Suggestions {
fmt.Fprintf(&sb, "- %s\n", n)
}
sb.WriteString("\n下一步用 suggest_address 带上 name 查它可用的工作目录path 位)。")
case "path":
if len(data.Suggestions) == 0 {
fmt.Fprintf(&sb, "%s 没有记录在案的工作目录。\npath 位可以留空。", name)
} else {
fmt.Fprintf(&sb, "%s 用过的工作目录(按最近使用排序):\n", name)
for _, p := range data.Suggestions {
fmt.Fprintf(&sb, "- %s\n", p)
}
}
default:
// session
existing := 0
for _, a := range data.Suggestions {
if a != "new" {
existing++
}
}
if existing == 0 {
fmt.Fprintf(&sb, "%s@%s 下还没有可续谈的会话。", name, path)
} else {
fmt.Fprintf(&sb, "%s@%s 下可续谈的会话:\n", name, path)
for i, alias := range data.Suggestions {
if alias == "new" {
continue
}
addr := ""
if i < len(data.Addresses) {
addr = data.Addresses[i]
}
fmt.Fprintf(&sb, "- %s\n", addr)
}
}
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleListContacts(args map[string]interface{}) (interface{}, error) {
limit := 20
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
var data struct {
Contacts []struct {
Address string `json:"address"`
Subject string `json:"subject"`
Unread int `json:"unread_count"`
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
Alias string `json:"session_alias"`
} `json:"contacts"`
}
if err := p.get(p.gwURL+"/api/v1/agent/contacts", &data); err != nil {
return nil, err
}
if len(data.Contacts) == 0 {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": "还没有任何往来会话。"}},
}, nil
}
// 按未读优先排序
for i := 0; i < len(data.Contacts)-1; i++ {
for j := i + 1; j < len(data.Contacts); j++ {
if data.Contacts[j].Unread > data.Contacts[i].Unread {
data.Contacts[i], data.Contacts[j] = data.Contacts[j], data.Contacts[i]
}
}
}
var sb strings.Builder
n := limit
if n > len(data.Contacts) {
n = len(data.Contacts)
}
fmt.Fprintf(&sb, "往来会话(共 %d 条):\n", len(data.Contacts))
for _, c := range data.Contacts[:n] {
bits := []string{}
if c.Unread > 0 {
bits = append(bits, fmt.Sprintf("%d 封未读", c.Unread))
}
if c.Subject != "" {
bits = append(bits, c.Subject)
}
if c.MaxRounds > 0 {
left := c.MaxRounds - c.UsedRounds
if left < 0 {
left = 0
}
bits = append(bits, fmt.Sprintf("剩 %d/%d 个来回", left, c.MaxRounds))
}
extra := ""
if len(bits) > 0 {
extra = fmt.Sprintf(" %s", strings.Join(bits, ""))
}
fmt.Fprintf(&sb, "- %s%s\n", c.Address, extra)
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleSessionParticipants(args map[string]interface{}) (interface{}, error) {
sid, _ := args["session_id"].(string)
if sid == "" {
return nil, fmt.Errorf("缺少 session_id")
}
var data struct {
SessionAlias string `json:"session_alias"`
Participants []struct {
Name string `json:"name"`
Path string `json:"path"`
Roles []string `json:"roles"`
IsSelf bool `json:"is_self"`
MailCount int `json:"mail_count"`
Address string `json:"address"`
} `json:"participants"`
}
if err := p.get(p.gwURL+"/api/v1/agent/sessions/"+sid+"/participants", &data); err != nil {
return nil, err
}
if len(data.Participants) == 0 {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": "该会话还没有参与方。"}},
}, nil
}
var sb strings.Builder
fmt.Fprintf(&sb, "会话 #%s 的参与方:\n", data.SessionAlias)
for _, pt := range data.Participants {
tags := []string{}
if pt.IsSelf {
tags = append(tags, "就是你")
}
if len(pt.Roles) > 0 {
tags = append(tags, strings.Join(pt.Roles, "/"))
}
if pt.MailCount == 0 && !pt.IsSelf {
tags = append(tags, "尚未回应")
}
extra := ""
if len(tags) > 0 {
extra = fmt.Sprintf(" [%s]", strings.Join(tags, ""))
}
fmt.Fprintf(&sb, "- %s %s%s\n", pt.Name, pt.Address, extra)
}
sb.WriteString("\n要联系其中某一方把它的地址原样填进 send_mail 的 to。")
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleReadThread(args map[string]interface{}) (interface{}, error) {
mid, _ := args["mail_id"].(string)
if mid == "" {
return nil, fmt.Errorf("缺少 mail_id")
}
offset := ""
if v, ok := args["offset"].(float64); ok && v > 0 {
offset = fmt.Sprintf("?offset=%d", int(v))
}
var data struct {
Total int `json:"total"`
Hidden int `json:"hidden"`
HasMore bool `json:"has_more"`
NextOff int `json:"next_offset"`
AnchorID string `json:"anchor_mail_id"`
Nodes []struct {
MailID string `json:"mail_id"`
FromName string `json:"from_name"`
ToName string `json:"to_name"`
Subject string `json:"subject"`
Depth int `json:"depth"`
Detached bool `json:"detached"`
ParentHid bool `json:"parent_hidden"`
} `json:"nodes"`
}
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid+"/thread"+offset, &data); err != nil {
return nil, err
}
if len(data.Nodes) == 0 {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": "这条线索上没有可见的邮件。"}},
}, nil
}
var sb strings.Builder
fmt.Fprintf(&sb, "线索共 %d 封", data.Total)
if data.Hidden > 0 {
fmt.Fprintf(&sb, "(另有 %d 封无权查看)", data.Hidden)
}
sb.WriteString("\n")
for _, n := range data.Nodes {
indent := ""
if n.Depth > 0 {
indent = strings.Repeat(" ", min(n.Depth, 8))
}
marks := []string{}
if n.MailID == data.AnchorID {
marks = append(marks, "当前这封")
}
if n.Detached {
if n.ParentHid {
marks = append(marks, "父邮件无权查看")
} else {
marks = append(marks, "父邮件尚未加载")
}
}
extra := ""
if len(marks) > 0 {
extra = fmt.Sprintf(" (%s)", strings.Join(marks, ""))
}
fmt.Fprintf(&sb, "%s- %s → %s: %s [%s]%s\n",
indent, n.FromName, n.ToName, n.Subject, n.MailID, extra)
}
if data.HasMore {
fmt.Fprintf(&sb, "\n还有更多用 offset=%d 继续取。\n", data.NextOff)
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// oneStringParam 给只有一个参数的工具生成 schema。
//
// 单独提出来不是为了省字数,而是因为手写 JSON Schema 字面量很容易漏掉
// `"type": "object"` 或把 required 写成字符串而不是数组 —— 那类错误不会
// 在编译期暴露,而是让模型收到一个它无法调用的工具。
func oneStringParam(name, desc string, required bool) map[string]interface{} {
schema := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
name: map[string]interface{}{"type": "string", "description": desc},
},
}
if required {
schema["required"] = []string{name}
}
return schema
}
// handleConnectToServer 重新登记密钥并注册。
//
// 成功后把新坐标写回 p当场生效 —— 不用等重启。这是这个工具存在的全部意义:
// 若还要重启才生效,人直接改环境变量就行了,不需要给模型一个工具。
func (p *Plugin) handleConnectToServer(args map[string]interface{}) (interface{}, error) {
url := p.gwURL
if v, ok := args["gateway_url"].(string); ok && strings.TrimSpace(v) != "" {
url = strings.TrimRight(strings.TrimSpace(v), "/")
}
key := p.key
if v, ok := args["key_token"].(string); ok && strings.TrimSpace(v) != "" {
key = strings.TrimSpace(v)
}
if key == "" {
return nil, fmt.Errorf("没有可用的密钥:请传 key_token或在 AGENTMAIL_AGENT_KEY 环境变量里配置")
}
// 用候选坐标试注册,成功了才写回 —— 失败时不该把原本能用的配置改坏
probe := &Plugin{agentName: p.agentName, gwURL: url, key: key, client: p.client}
if err := probe.register(); err != nil {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": strings.Join([]string{
fmt.Sprintf("连接失败:%v", err),
"",
"若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:",
key,
}, "\n")}},
}, nil
}
p.gwURL = url
p.key = key
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": fmt.Sprintf("已连接 %s注册为 %s。", url, p.agentName)}},
}, nil
}

View File

@ -33,6 +33,7 @@ import {
noteExplicitSend,
shouldSkipAutoRelay,
} from "./lib/relay-dedup.js";
import { appendRenameProposal, renameProposalNote } from "./lib/rename-proposal.js";
const GATEWAY_URL = process.env.AGENTMAIL_GATEWAY_URL || "http://127.0.0.1:8180";
const AGENT_NAME = process.env.AGENTMAIL_AGENT_NAME || "opencode";
@ -170,14 +171,11 @@ const sendMailTool = {
// 让 session.idle 的自动转发让位,避免同一件事发两封。
async execute(args, context) {
// 改名建议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
// 选注释而不是自造标记react-markdown 不解析 raw HTML
// 万一网关没剥掉,它在页面上也只是一行不显眼的转义文本而非破版内容。
let body = args.body;
if (args.propose_alias) {
const esc = (v) => String(v).replace(/"/g, ""); // 双引号是标记的定界符
const reason = args.propose_reason ? ` reason="${esc(args.propose_reason)}"` : "";
body += `\n\n<!-- agentmail:rename-session alias="${esc(args.propose_alias)}"${reason} -->`;
}
// 标记格式是服务端正则的镜像,因此拼接收在 lib/rename-proposal.js
// (三平台共用)—— 各写一遍的话少个空格就静默失效:邮件照常发出,
// 提议凭空消失,而模型以为自己提过了。
const { body, proposed } = appendRenameProposal(
args.body, args.propose_alias, args.propose_reason);
const result = await apiPost("/mail/send", {
to: args.to,
@ -205,11 +203,11 @@ const sendMailTool = {
? "预算即将用尽,请尽快给出结论;自动转发的总结不占预算。"
: "")
: "";
// 回传规范化后的别名Agent 提的名字可能含非法字符被改写过
const proposed = result.rename_proposed
? `\n已向用户提议把会话改名为 ${result.rename_proposed},等待其确认。`
: "";
return `已发送。Mail ID: ${result.mail_id}Session: ${result.session_id}${alias}${budget}${proposed}`;
// 别名取服务端回的 rename_proposed它跑过 normalizeAlias
// 回显本地值会让模型记住一个不存在的名字
const note = renameProposalNote(result.rename_proposed, args.propose_alias, proposed);
return `已发送。Mail ID: ${result.mail_id}Session: ${result.session_id}${alias}${budget}` +
(note ? `\n${note}` : "");
},
};

View File

@ -0,0 +1,130 @@
/**
* 会话改名提议 —— 所有平台插件共用。
*
* # 这是什么
*
* 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug
* 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是
* `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。
*
* # 为什么是「提议」而不是直接改
*
* 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉,
* 人上一秒记住的地址下一秒就 404`session` 位三态语义要求指向不存在的会话直接报
* 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。
*
* 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突:
*
* | | 谁发起 | 何时 | 是否打扰人 |
* |---|---|---|---|
* | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 |
* | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 |
*
* # 为什么载体是 HTML 注释
*
* `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去,
* 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由:
*
* - react-markdown 默认不解析 raw HTML万一服务端没剥掉它在页面上也只是
* 一行不显眼的转义文本,不会破版
* - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼
* - 不与 Markdown 语法冲突,格式化工具不会改写它
*
* # 为什么必须共用
*
* 标记格式是**服务端正则的镜像**`gateway/internal/handler/rename_proposal.go`)。
* 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 ——
* 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。
*/
/**
* 服务端能识别的别名字符集。
*
* 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突,
* `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)——
* 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的
* 「我提议的名字」与实际入库的不一致。
*
* @param {string} alias
* @returns {boolean}
*/
export function isProposableAlias(alias) {
const a = String(alias ?? '').trim();
if (!a) return false;
if (a === 'new') return false;
// 双引号是标记本身的定界符,含它会截断标记
if (/[.\s/@"]/.test(a)) return false;
// 服务端 VARCHAR(128),按字节算
if (Buffer.byteLength(a, 'utf8') > 128) return false;
return true;
}
/**
* 把改名提议标记追加到正文末尾。
*
* 格式必须与服务端正则逐字符对应:
* `<!-- agentmail:rename-session alias="x" reason="y" -->`
* reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由,
* 界面上的提示条就少了那句解释)。
*
* 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却
* 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。
*
* @param {string} body 原始正文
* @param {string} [alias] 提议的别名
* @param {string} [reason] 提议理由,一句话
* @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加
*/
export function appendRenameProposal(body, alias, reason) {
const text = String(body ?? '');
if (!isProposableAlias(alias)) return { body: text, proposed: false };
const a = String(alias).trim();
// 理由里的双引号会截断标记去掉而不是转义HTML 注释里没有转义机制
const r = String(reason ?? '').replace(/"/g, '').trim();
const reasonAttr = r ? ` reason="${r}"` : '';
return {
body: `${text}\n\n<!-- agentmail:rename-session alias="${a}"${reasonAttr} -->`,
proposed: true,
};
}
/**
* 提议提交后回给模型的那句话。
*
* **别名取服务端回的 `rename_proposed`,不是本地提议的那个。** 服务端会跑
* `normalizeAlias` —— 非法字符换成 `-`、`new` 变 `session-new`、超长按 UTF-8
* 边界截断。回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。
*
* 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里
* 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。
*
* @param {string} [serverAlias] 服务端 `/mail/send` 响应里的 `rename_proposed`
* @param {string} [requestedAlias] 本地提议的别名,仅用于「未提交」时的说明
* @param {boolean} [proposed] appendRenameProposal 的返回值
* @returns {string} 空串表示没有需要追加的说明
*/
export function renameProposalNote(serverAlias, requestedAlias, proposed) {
const server = String(serverAlias ?? '').trim();
const wanted = String(requestedAlias ?? '').trim();
// 服务端确认收到了:用它给的最终值
if (server) {
const changed = wanted && wanted !== server
? `(你提的 "${wanted}" 被规范化成了这个)`
: '';
return `已附上改名提议 "${server}"${changed},等用户在界面上确认后生效 —— ` +
`在那之前继续用原别名寻址。`;
}
if (!wanted) return '';
// 本地就判定不合法,标记没发出去
if (!proposed) {
return `(改名提议 "${wanted}" 未提交:别名不可为 new不可含 . 空白 / @ 或双引号。)`;
}
// 标记发出去了但服务端没回 rename_proposed它那侧的校验也拒了
return `(改名提议 "${wanted}" 未被服务端接受,会话别名不变。)`;
}

View File

@ -0,0 +1,136 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isProposableAlias,
appendRenameProposal,
renameProposalNote,
} from '../lib/rename-proposal.js';
// 这一组测试钉住的是「插件拼的标记与服务端正则逐字符对应」。
// 服务端那条正则在 gateway/internal/handler/rename_proposal.go
// <!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->
// 拼错不会报错 —— 邮件照常发出,提议凭空消失。
/** 服务端正则的等价实现,用来验证我们拼出来的标记真的能被摘出来。 */
const SERVER_RE =
/<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->/s;
test('isProposableAlias: 合法别名', () => {
assert.equal(isProposableAlias('fix-login-leak'), true);
assert.equal(isProposableAlias('修复登录态泄漏'), true, '中文别名合法');
assert.equal(isProposableAlias('v2_migration'), true);
});
test('isProposableAlias: new 是寻址保留字', () => {
// `.new` 是「强制新建会话」的动作,别名叫 new 会让地址无从解释
assert.equal(isProposableAlias('new'), false);
});
test('isProposableAlias: 拒绝与三维地址冲突的字符', () => {
// 这四个字符都会让 name@path.session 的切分产生歧义
assert.equal(isProposableAlias('a.b'), false, '. 是 session 位分隔符');
assert.equal(isProposableAlias('a/b'), false, '/ 出现在 path 位');
assert.equal(isProposableAlias('a@b'), false, '@ 是 name/path 分隔符');
assert.equal(isProposableAlias('a b'), false, '空白');
assert.equal(isProposableAlias('a\tb'), false, '制表符也算空白');
});
test('isProposableAlias: 拒绝双引号', () => {
// 双引号是标记自身的定界符,含它会把标记截断成非法形式
assert.equal(isProposableAlias('say"hi'), false);
});
test('isProposableAlias: 空与空白视为没提', () => {
assert.equal(isProposableAlias(''), false);
assert.equal(isProposableAlias(' '), false);
assert.equal(isProposableAlias(undefined), false);
assert.equal(isProposableAlias(null), false);
});
test('isProposableAlias: 超过 128 字节按字节算', () => {
// 服务端是 VARCHAR(128)。中文一个字 3 字节43 字 = 129 字节
assert.equal(isProposableAlias('a'.repeat(128)), true);
assert.equal(isProposableAlias('a'.repeat(129)), false);
assert.equal(isProposableAlias('汉'.repeat(42)), true, '126 字节');
assert.equal(isProposableAlias('汉'.repeat(43)), false, '129 字节');
});
test('标记能被服务端正则摘出来', () => {
const { body, proposed } = appendRenameProposal('已定位到问题。', 'fix-login-leak', '登录态泄漏');
assert.equal(proposed, true);
const m = SERVER_RE.exec(body);
assert.ok(m, '服务端正则必须匹配得上');
assert.equal(m[1], 'fix-login-leak');
assert.equal(m[2], '登录态泄漏');
});
test('没有理由时整个 reason 属性都不写', () => {
// 写成 reason="" 会让服务端存一个空理由,界面提示条就少了那句解释
const { body } = appendRenameProposal('正文', 'fix-leak');
assert.doesNotMatch(body, /reason=/);
const m = SERVER_RE.exec(body);
assert.equal(m[1], 'fix-leak');
assert.equal(m[2], undefined);
});
test('理由里的双引号被去掉而不是转义', () => {
// HTML 注释里没有转义机制,留着会截断标记
const { body } = appendRenameProposal('正文', 'fix-leak', '他说"这是泄漏"');
const m = SERVER_RE.exec(body);
assert.ok(m);
assert.equal(m[2], '他说这是泄漏');
});
test('原正文完整保留在标记之前', () => {
const original = '第一行\n\n第二行';
const { body } = appendRenameProposal(original, 'fix-leak');
assert.ok(body.startsWith(original), '正文不得被改写');
});
test('别名不合法时原样返回,不追加标记', () => {
const { body, proposed } = appendRenameProposal('正文', 'a.b');
assert.equal(proposed, false);
assert.equal(body, '正文');
assert.doesNotMatch(body, /agentmail:rename-session/);
});
test('不给别名时正文完全不变', () => {
const { body, proposed } = appendRenameProposal('正文', '');
assert.equal(proposed, false);
assert.equal(body, '正文');
});
test('renameProposalNote: 用服务端回的别名,不是本地提议的', () => {
// 服务端跑 normalizeAlias非法字符换 -、new 变 session-new、超长截断。
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404。
const note = renameProposalNote('fix-login-leak', 'fix.login.leak', true);
assert.match(note, /fix-login-leak/);
assert.match(note, /规范化/, '要告诉模型名字被改写过');
assert.match(note, /确认/);
assert.match(note, /原别名/, '要明确说在那之前用哪个');
});
test('renameProposalNote: 服务端别名与提议一致时不提规范化', () => {
const note = renameProposalNote('fix-leak', 'fix-leak', true);
assert.match(note, /fix-leak/);
assert.doesNotMatch(note, /规范化/);
});
test('renameProposalNote: 本地判非法时说清为什么', () => {
const note = renameProposalNote('', 'a.b', false);
assert.match(note, /未提交/);
assert.match(note, /a\.b/);
});
test('renameProposalNote: 标记发出但服务端没接受', () => {
// 本地校验比服务端宽的情况(例如服务端加了新约束)——
// 不能沉默,否则模型以为提议成功了
const note = renameProposalNote('', 'somealias', true);
assert.match(note, /未被服务端接受/);
assert.match(note, /somealias/);
});
test('renameProposalNote: 没提议时不产生噪音', () => {
assert.equal(renameProposalNote('', '', false), '');
assert.equal(renameProposalNote(undefined, undefined, false), '');
});

View File

@ -0,0 +1,109 @@
/**
* 会话改名提议 —— 所有平台插件共用。
*
* # 这是什么
*
* 模型干完活后可能觉得当前别名不贴切:会话建立时叫 `witty-planet`(平台随机 slug
* 或 `排查登录问题`(人写的邮件主题),摸清问题后它知道这其实是
* `fix-session-cookie-leak`。改名提议就是让它把这个判断说出来。
*
* # 为什么是「提议」而不是直接改
*
* 别名是**人**的寻址入口 —— `name@path.<别名>` 里那一段。Agent 干到一半自己改掉,
* 人上一秒记住的地址下一秒就 404`session` 位三态语义要求指向不存在的会话直接报
* 「无法送达」,不会静默新建)。所以提议入库、由人在界面上点「接受」才真正生效。
*
* 这与平台命名自动同步(`POST /sessions/{id}/sync`)互补,两者不冲突:
*
* | | 谁发起 | 何时 | 是否打扰人 |
* |---|---|---|---|
* | 自动同步 | 平台的命名机制 | 每轮结束 | 不,后台静默生效 |
* | 改名提议 | 模型的主动判断 | 它认为有必要时 | 是,界面上出提示条 |
*
* # 为什么载体是 HTML 注释
*
* `/mail/send` 没有 `propose_alias` 字段 —— 提议**搭在正文里**发出去,
* 服务端用正则摘出来再把标记从入库正文中剥掉。选 HTML 注释的三个理由:
*
* - react-markdown 默认不解析 raw HTML万一服务端没剥掉它在页面上也只是
* 一行不显眼的转义文本,不会破版
* - 纯文本邮件客户端里是一行不碍事的注释,不像自造标记那样显眼
* - 不与 Markdown 语法冲突,格式化工具不会改写它
*
* # 为什么必须共用
*
* 标记格式是**服务端正则的镜像**`gateway/internal/handler/rename_proposal.go`)。
* 各平台各写一遍拼接,某一处少个空格或把双引号写成单引号,服务端匹配不上 ——
* 而失败是静默的:邮件照常发出,提议凭空消失,模型以为自己提过了。
*/
/**
* 服务端能识别的别名字符集。
*
* 与 `validateSessionAlias` 一致:`. 空白 / @` 会与三维地址解析冲突,
* `new` 是寻址保留字。这里**不做规范化**(不把非法字符替换成 `-`)——
* 规范化是服务端 `normalizeAlias` 的职责,插件擅自改写会让模型看到的
* 「我提议的名字」与实际入库的不一致。
*
* @param {string} alias
* @returns {boolean}
*/
export function isProposableAlias(alias) {
const a = String(alias ?? '').trim();
if (!a) return false;
if (a === 'new') return false;
// 双引号是标记本身的定界符,含它会截断标记
if (/[.\s/@"]/.test(a)) return false;
// 服务端 VARCHAR(128),按字节算
if (Buffer.byteLength(a, 'utf8') > 128) return false;
return true;
}
/**
* 把改名提议标记追加到正文末尾。
*
* 格式必须与服务端正则逐字符对应:
* `<!-- agentmail:rename-session alias="x" reason="y" -->`
* reason 可选,为空时**整个属性都不写**(写成 `reason=""` 服务端会存一个空理由,
* 界面上的提示条就少了那句解释)。
*
* 别名不合法时**原样返回正文**,不追加标记:与其发一个服务端匹配得上却
* 被 `validateSessionAlias` 拒掉的标记,不如当它没提 —— 调用方据此告诉模型。
*
* @param {string} body 原始正文
* @param {string} [alias] 提议的别名
* @param {string} [reason] 提议理由,一句话
* @returns {{body: string, proposed: boolean}} proposed=false 表示别名不合法,未追加
*/
export function appendRenameProposal(body, alias, reason) {
const text = String(body ?? '');
if (!isProposableAlias(alias)) return { body: text, proposed: false };
const a = String(alias).trim();
// 理由里的双引号会截断标记去掉而不是转义HTML 注释里没有转义机制
const r = String(reason ?? '').replace(/"/g, '').trim();
const reasonAttr = r ? ` reason="${r}"` : '';
return {
body: `${text}\n\n<!-- agentmail:rename-session alias="${a}"${reasonAttr} -->`,
proposed: true,
};
}
/**
* 提议提交后回给模型的那句话。
*
* 必须说明「等人确认」。不说的话模型会以为改名已经生效,接着在后续邮件里
* 用新别名当地址发信 —— 而那个别名此刻还不存在,投递会失败。
*
* @param {string} alias
* @param {boolean} proposed appendRenameProposal 的返回值
* @returns {string} 空串表示没有需要追加的说明
*/
export function renameProposalNote(alias, proposed) {
if (!alias) return '';
if (!proposed) {
return `(改名提议 "${alias}" 未提交:别名不可为 new不可含 . 空白 / @ 或双引号。)`;
}
return `已附上改名提议 "${alias}",等用户在界面上确认后生效 —— 在那之前继续用原别名寻址。`;
}

View File

@ -11,9 +11,24 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail');
const KEY_FILE = join(CONFIG_DIR, 'agent.key');
export const KEY_FILE = join(CONFIG_DIR, 'agent.key');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
/**
* 把管理员给的密钥落盘0600
*
* connect_to_server 工具靠它:模型拿到一把新密钥后必须落盘,
* 否则重启后又回到无法连接的状态 —— 而那正是这个工具要解决的问题。
*/
export function saveLocalKey(token) {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
writeFileSync(
KEY_FILE,
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2),
{ mode: 0o600 },
);
}
/** 读取本地密钥文件;不存在或损坏时返回 null。 */
export function readLocalKey() {
try {
@ -228,6 +243,28 @@ export class GatewayClient {
});
}
/**
* 换 Gateway 地址或换密钥。
*
* 守护进程不能靠重启来应用新配置 —— connect_to_server 是模型在**运行中**
* 调的,它期望调完就能收信。所以这里除了改字段还要重置断点:
* `lastEventID` 是**旧** Gateway 环形缓冲里的序号,拿去问新 Gateway 会
* 命中一段完全无关的历史(或直接被拒),得到的事件属于别人的会话。
*
* @returns {boolean} 是否真的变了(没变就不必重连 SSE省一次断流
*/
reconfigure({ url, agentKey }) {
const nextURL = url ? String(url).replace(/\/+$/, '') : this.baseURL;
const nextKey = agentKey || this.agentKey;
const changed = nextURL !== this.baseURL || nextKey !== this.agentKey;
if (!changed) return false;
if (nextURL !== this.baseURL) this.lastEventID = '';
this.baseURL = nextURL;
this.agentKey = nextKey;
return true;
}
stopSSE() {
this.sseAbort?.abort();
this.sseAbort = null;

View File

@ -10,8 +10,8 @@
* 「工具能拿到当前会话 id」自动转发去重B-5.3)靠它把发信记到正确的会话上。
*/
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import { basename, dirname } from 'node:path';
import {
renderInbox,
idsToMarkRead,
@ -28,6 +28,8 @@ import {
renderThread,
} from '../lib/discovery.js';
import { noteExplicitSend } from '../lib/relay-dedup.js';
import { appendRenameProposal, renameProposalNote } from '../lib/rename-proposal.js';
import { saveLocalKey, generateLocalKey, saveConfig, KEY_FILE } from './gateway.mjs';
const text = (s) => ({ content: [{ type: 'text', text: s }] });
@ -37,8 +39,10 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] });
* @param {(msg: string) => void} deps.log
* @param {string} [deps.agentName] 自己的 Agent 名。收件箱渲染靠它判定
* 「我是收件人还是抄送方」并给出可投递地址。
* @param {() => void} [deps.onReconnect] connect_to_server 换了坐标后调用,
* 由入口重连 SSE。不给则只改客户端字段下次重连时生效
*/
export function createMailTools({ client, log, agentName = '' }) {
export function createMailTools({ client, log, agentName = '', onReconnect }) {
const sendMail = {
name: 'send_mail',
label: 'SendMail',
@ -59,15 +63,29 @@ export function createMailTools({ client, log, agentName = '' }) {
items: { type: 'string' },
description: '附件 ID 列表(先用 upload_attachment 取得)',
},
propose_alias: {
type: 'string',
description:
'建议把当前会话改名成这个别名(例如摸清问题后从「排查登录问题」改成 ' +
'fix-session-cookie-leak。这只是建议别名是人的寻址入口实际改名由用户' +
'在界面上确认。不可含 . / @ 空白,不可为 new',
},
propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' },
},
required: ['to', 'subject', 'body'],
additionalProperties: false,
},
async execute(_id, params, _signal, _onUpdate, ctx) {
// 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。
// 拼接收在 lib/rename-proposal.js三平台共用标记格式是服务端正则的
// 镜像,各写一遍的话少个空格就静默失效 —— 邮件照常发出,提议凭空消失。
const { body, proposed } = appendRenameProposal(
params.body, params.propose_alias, params.propose_reason);
const result = await client.post('/mail/send', {
to: params.to,
subject: params.subject,
body: params.body,
body,
cc: params.cc || '',
reply_to: params.reply_to || '',
session_alias: params.session_alias || '',
@ -81,7 +99,11 @@ export function createMailTools({ client, log, agentName = '' }) {
const budget = typeof result.budget_remaining === 'number'
? ` 本任务剩余 ${result.budget_remaining}/${result.budget_max} 个来回。`
: '';
return text(`邮件已发送ID: ${result.mail_id}${budget}`);
// 别名取服务端回的 rename_proposed它跑过 normalizeAlias
// 回显本地值会让模型记住一个不存在的名字,之后拿它寻址就 404
const note = renameProposalNote(result.rename_proposed, params.propose_alias, proposed);
return text(
`邮件已发送ID: ${result.mail_id}${budget}` + (note ? `\n${note}` : ''));
},
};
@ -159,20 +181,38 @@ export function createMailTools({ client, log, agentName = '' }) {
const uploadAttachment = {
name: 'upload_attachment',
label: 'UploadAttachment',
description: '上传本地文件作为邮件附件,返回 attachment_id。',
description:
'上传本地文件作为邮件附件,返回 attachment_id。' +
'拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。' +
'未随邮件发出的附件 24 小时后自动清理。',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: '本地文件绝对路径' },
file_path: { type: 'string', description: '要上传的本地文件绝对路径' },
filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' },
},
required: ['file_path'],
additionalProperties: false,
},
async execute(_id, params) {
// 先 stat 再读:目录和不存在的路径都要给出能行动的错误。
// 直接 readFile 的话,目录会抛 EISDIR —— 模型看到那个 errno
// 只会重试同一个路径,而不是去改参数。
let st;
try {
st = await stat(params.file_path);
} catch {
return text(`文件不存在或不可读: ${params.file_path}`);
}
if (!st.isFile()) return text(`不是普通文件: ${params.file_path}`);
// 附件上限 25MB一次性读入内存可接受。上限放宽的话这里要改成流式 multipart。
const buf = await readFile(params.file_path);
const a = await client.uploadFile(buf, basename(params.file_path) || 'file');
const name = params.filename || basename(params.file_path) || 'file';
const a = await client.uploadFile(buf, name);
return text(
`已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}`,
`已上传 ${a.filename}${formatSize(a.size_bytes)}。attachment_id: ${a.attachment_id}\n` +
`在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。`,
);
},
};
@ -180,18 +220,21 @@ export function createMailTools({ client, log, agentName = '' }) {
const downloadAttachment = {
name: 'download_attachment',
label: 'DownloadAttachment',
description: '下载邮件附件到本地文件。',
description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。',
parameters: {
type: 'object',
properties: {
attachment_id: { type: 'string', description: '附件 IDread_inbox 的清单里给出)' },
save_path: { type: 'string', description: '保存路径' },
save_path: { type: 'string', description: '保存到的本地绝对路径' },
},
required: ['attachment_id', 'save_path'],
additionalProperties: false,
},
async execute(_id, params) {
const buf = await client.downloadFile(params.attachment_id);
// 父目录不存在时先建:模型经常写 ./downloads/x.pdf 这类还不存在的路径,
// 不建的话 writeFile 抛 ENOENT而那个错误看起来像「附件不存在」。
await mkdir(dirname(params.save_path), { recursive: true });
await writeFile(params.save_path, buf);
return text(`已保存到 ${params.save_path}${formatSize(buf.length)}`);
},
@ -343,6 +386,73 @@ export function createMailTools({ client, log, agentName = '' }) {
},
};
// connect_to_server —— 连接自愈。
//
// 之前只有 opencode 侧有。后果是Gateway 换了地址、或密钥需要重新登记时,
// opencode 里的模型能自己修好,其他平台只能干等环境变量被人改 ——
// 同一类能力在不同平台上时有时无,等于让人记住哪个平台能自己修。
//
// 失败时**把需要登记的密钥全文打出来**:密钥未登记是最常见的失败,
// 不给值的话要多走一轮「密钥无效 → 去哪拿 → 让管理员登记」。
const connectToServer = {
name: 'connect_to_server',
label: 'ConnectToServer',
description:
'连接到 AgentMail Gateway登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。' +
'密钥若未在后台登记过,此处会返回需要登记的密钥全文。',
parameters: {
type: 'object',
properties: {
gateway_url: { type: 'string', description: 'Gateway 地址;省略则用当前配置' },
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' },
},
additionalProperties: false,
},
async execute(_id, params) {
let key = client.agentKey;
if (params.key_token) {
key = String(params.key_token).trim();
// 管理员给的密钥落盘,重启后仍然可用
saveLocalKey(key);
} else if (!key) {
key = generateLocalKey(log);
}
const url = String(params.gateway_url || client.baseURL).replace(/\/+$/, '');
const res = await fetch(`${url}/api/v1/agent/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({ name: client.agentName, workspaces: [], platform: 'pi' }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
return text([
`连接失败HTTP ${res.status}${data?.error || '未知错误'}`,
``,
`若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:`,
key,
``,
`密钥文件:${KEY_FILE}`,
].join('\n'));
}
// 成功。桥是守护进程,不能靠重启来应用新坐标 —— 模型调这个工具时
// 期望调完就能收信,所以要当场改客户端并重连 SSE。
// reconfigure 顺带清掉 lastEventID那是旧 Gateway 缓冲里的序号。
const changed = client.reconfigure({ url, agentKey: key });
saveConfig({ gateway_url: url, agent_name: client.agentName, registered_at: new Date().toISOString() });
if (changed && onReconnect) {
onReconnect();
log(`connect_to_server 换了坐标SSE 已重连到 ${url}`);
}
return text(
`已连接 ${url},注册为 ${data?.agent_name || client.agentName}` +
(changed ? '事件流已切到新地址。' : ''));
},
};
// 故意**没有** request_permissionN-1 / T-7
// 权限询问由 tool_call 钩子接管 —— 模型可能忘了调,也可能在不需要时乱调,
// 而真正被 pi 拦下的那一次才是事实。
@ -351,5 +461,7 @@ export function createMailTools({ client, log, agentName = '' }) {
uploadAttachment, downloadAttachment,
// 寻址发现:让模型选地址而不是拼地址
suggestAddress, listContacts, sessionParticipants, readThread,
// 连接自愈
connectToServer,
];
}

View File

@ -0,0 +1,119 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isProposableAlias,
appendRenameProposal,
renameProposalNote,
} from '../lib/rename-proposal.js';
// 这一组测试钉住的是「插件拼的标记与服务端正则逐字符对应」。
// 服务端那条正则在 gateway/internal/handler/rename_proposal.go
// <!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->
// 拼错不会报错 —— 邮件照常发出,提议凭空消失。
/** 服务端正则的等价实现,用来验证我们拼出来的标记真的能被摘出来。 */
const SERVER_RE =
/<!--\s*agentmail:rename-session\s+alias="([^"]*)"(?:\s+reason="([^"]*)")?\s*-->/s;
test('isProposableAlias: 合法别名', () => {
assert.equal(isProposableAlias('fix-login-leak'), true);
assert.equal(isProposableAlias('修复登录态泄漏'), true, '中文别名合法');
assert.equal(isProposableAlias('v2_migration'), true);
});
test('isProposableAlias: new 是寻址保留字', () => {
// `.new` 是「强制新建会话」的动作,别名叫 new 会让地址无从解释
assert.equal(isProposableAlias('new'), false);
});
test('isProposableAlias: 拒绝与三维地址冲突的字符', () => {
// 这四个字符都会让 name@path.session 的切分产生歧义
assert.equal(isProposableAlias('a.b'), false, '. 是 session 位分隔符');
assert.equal(isProposableAlias('a/b'), false, '/ 出现在 path 位');
assert.equal(isProposableAlias('a@b'), false, '@ 是 name/path 分隔符');
assert.equal(isProposableAlias('a b'), false, '空白');
assert.equal(isProposableAlias('a\tb'), false, '制表符也算空白');
});
test('isProposableAlias: 拒绝双引号', () => {
// 双引号是标记自身的定界符,含它会把标记截断成非法形式
assert.equal(isProposableAlias('say"hi'), false);
});
test('isProposableAlias: 空与空白视为没提', () => {
assert.equal(isProposableAlias(''), false);
assert.equal(isProposableAlias(' '), false);
assert.equal(isProposableAlias(undefined), false);
assert.equal(isProposableAlias(null), false);
});
test('isProposableAlias: 超过 128 字节按字节算', () => {
// 服务端是 VARCHAR(128)。中文一个字 3 字节43 字 = 129 字节
assert.equal(isProposableAlias('a'.repeat(128)), true);
assert.equal(isProposableAlias('a'.repeat(129)), false);
assert.equal(isProposableAlias('汉'.repeat(42)), true, '126 字节');
assert.equal(isProposableAlias('汉'.repeat(43)), false, '129 字节');
});
test('标记能被服务端正则摘出来', () => {
const { body, proposed } = appendRenameProposal('已定位到问题。', 'fix-login-leak', '登录态泄漏');
assert.equal(proposed, true);
const m = SERVER_RE.exec(body);
assert.ok(m, '服务端正则必须匹配得上');
assert.equal(m[1], 'fix-login-leak');
assert.equal(m[2], '登录态泄漏');
});
test('没有理由时整个 reason 属性都不写', () => {
// 写成 reason="" 会让服务端存一个空理由,界面提示条就少了那句解释
const { body } = appendRenameProposal('正文', 'fix-leak');
assert.doesNotMatch(body, /reason=/);
const m = SERVER_RE.exec(body);
assert.equal(m[1], 'fix-leak');
assert.equal(m[2], undefined);
});
test('理由里的双引号被去掉而不是转义', () => {
// HTML 注释里没有转义机制,留着会截断标记
const { body } = appendRenameProposal('正文', 'fix-leak', '他说"这是泄漏"');
const m = SERVER_RE.exec(body);
assert.ok(m);
assert.equal(m[2], '他说这是泄漏');
});
test('原正文完整保留在标记之前', () => {
const original = '第一行\n\n第二行';
const { body } = appendRenameProposal(original, 'fix-leak');
assert.ok(body.startsWith(original), '正文不得被改写');
});
test('别名不合法时原样返回,不追加标记', () => {
const { body, proposed } = appendRenameProposal('正文', 'a.b');
assert.equal(proposed, false);
assert.equal(body, '正文');
assert.doesNotMatch(body, /agentmail:rename-session/);
});
test('不给别名时正文完全不变', () => {
const { body, proposed } = appendRenameProposal('正文', '');
assert.equal(proposed, false);
assert.equal(body, '正文');
});
test('renameProposalNote: 成功时必须说明等人确认', () => {
// 不说的话模型会以为改名已生效,接着用新别名当地址发信 —— 那个别名还不存在
const note = renameProposalNote('fix-leak', true);
assert.match(note, /fix-leak/);
assert.match(note, /确认/);
assert.match(note, /原别名/, '要明确说在那之前用哪个');
});
test('renameProposalNote: 失败时说清为什么', () => {
const note = renameProposalNote('a.b', false);
assert.match(note, /未提交/);
assert.match(note, /a\.b/);
});
test('renameProposalNote: 没提议时不产生噪音', () => {
assert.equal(renameProposalNote('', false), '');
});

View File

@ -108,7 +108,7 @@ export default function AdminUsersPage() {
</TabButton>
<TabButton active={tab === 'quotas'} onClick={() => setTab('quotas')}>
<BotIcon className="w-4 h-4" />
Agent
</TabButton>
<TabButton active={tab === 'models'} onClick={() => setTab('models')}>
<CpuIcon className="w-4 h-4" />

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/client';
import { BotIcon, CheckIcon, CloseIcon } from './icons';
import { BotIcon, CheckIcon, ArchiveIcon } from './icons';
/**
* Agent 管理面板(管理员):默认预算 + 停用/恢复。
@ -14,6 +14,7 @@ export default function QuotaPanel() {
const [busy, setBusy] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [confirming, setConfirming] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(async () => {
try {
@ -50,14 +51,17 @@ export default function QuotaPanel() {
const toggleStatus = async (name: string, currentDisabled: boolean) => {
setBusy(name);
setError(null);
setNotice(null);
try {
const result = await api.adminSetAgentStatus(name, !currentDisabled);
await load();
setConfirming(null);
// 停用时显示撤销了几把密钥,这是操作结果里最有信息量的部分
if (result.detail) setError(null); // 清掉旧错误,让 detail 独占提示区
setBusy(null);
return;
// 撤销了几把密钥是操作结果里最有信息量的部分 —— 恢复后要重新签发几把,
// 只说「已停用」的话用户不知道还有这一步。
const revoked = typeof result.keys_revoked === 'number' && result.keys_revoked > 0
? `(已撤销 ${result.keys_revoked} 把密钥)`
: '';
setNotice(`${name} ${result.disabled ? '已停用' : '已恢复'}${revoked}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@ -85,13 +89,22 @@ export default function QuotaPanel() {
</div>
<p className="text-[11px] text-gray-500">
Agent 0 =
<br />
<strong className="font-medium text-gray-600"></strong> Agent 0 =
<br />
<strong className="font-medium text-gray-600"></strong> Agent
<span className="text-gray-400"></span>
<br />
<span className="text-gray-400">
Agent
</span>
</p>
{error && <div className="text-[11px] text-gray-600 bg-gray-50 rounded px-2 py-1.5">{error}</div>}
{error && <div className="text-[11px] text-red-600 bg-red-50 rounded px-2 py-1.5">{error}</div>}
{notice && <div className="text-[11px] text-green-700 bg-green-50 rounded px-2 py-1.5">{notice}</div>}
{stats.length === 0 ? (
<div className="text-xs text-gray-400 py-3"> Agent</div>
@ -159,14 +172,17 @@ export default function QuotaPanel() {
<button
onClick={() => setConfirming(s.agent_name)}
disabled={busy === s.agent_name}
title={isDisabled ? '恢复此 Agent密钥需重新签发' : '停用此 Agent撤销全部密钥可恢复'}
className={`tap shrink-0 ${
title={isDisabled
? '恢复此 Agent恢复为离线密钥需重新签发'
: '停用此 Agent撤销全部密钥并从补全里隐藏邮件与会话保留可恢复'}
className={`tap shrink-0 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded border ${
isDisabled
? 'text-gray-400 hover:text-green-600'
: 'text-gray-400 hover:text-red-500'
? 'border-green-200 text-green-700 hover:bg-green-50'
: 'border-gray-200 text-gray-500 hover:text-red-600 hover:border-red-200'
} disabled:opacity-30`}
>
<CloseIcon className="w-3.5 h-3.5" />
<ArchiveIcon className="w-3 h-3" />
{isDisabled ? '恢复' : '停用'}
</button>
)}
</div>