fix(寻址)★★: 补「按 name 直出全部可投递地址」+ 标注 path 候选里的坑

## 起因

DSH 侧 Agent 报了一份寻址缺口(2026-10-02,全部结论有 API 实测复现)。
三段式寻址 `name@path.session` 里 session 段是**人的寻址入口**,而枚举它
必须先知道 path —— 但 path 恰恰是调用方无从得知的:

    给 name      → 只给 path(要再调一次才知道有哪些会话)
    给 name+path → 给会话别名(但 path 得先猜对)

于是一个闭合的环。报告实测的踩坑:投 `pi@root` 返回 **200**,落进一条标题
为「拓展坞实测硬件正常…」的无关会话 —— 投递成功,所以调用方不知道自己投错了。

## 修法

**① A 项:`flatten=1` 一次给出全部可投递地址**

`SuggestAddressesForPeer` + `suggest?name=&flatten=1`。每个候选自带
`path` 与可直接塞进 send_mail 的 `address` —— 调用方不必自己拼,
拼错就是那个「猜错比报错更糟」。

可见性口径**不放宽**,与原 name+path 那一支逐条一致(「我参与过 + 与该 name
匹配」)。报告本身也确认问题不在权限:同一批数据给了 path 就能列出 17 条。

按 path 分组平铺而非嵌套:嵌套时调用方要发一封「不知道在哪个 path」的信
仍得遍历全部组;平铺一次给全,模型不必做「先猜 path 再枚举」两步。

**② B/C 项:标注而非隐藏**

`paths[]` 每项带 `kind`(workspace / bridge-internal)与 `is_absolute`。

选标注不选过滤的理由:桥内部目录(`/root/.pi/mail-sessions/<uuid>`)
确实**是某些会话的真实 cwd**(实测那条 workspace='root' 的会话 uuid 正是
其中之一)—— 滤掉等于让那些会话彻底不可见;而留着不标,64 条候选里 33 条
是噪声,模型选中即静默投错(实测 64 条中 33 条是它)。

`suggestions` 保持原样与原顺序 —— SuggestPaths 按最近使用倒序
(刚用过的那个几乎总是下一封想用的),排序被打乱等于让模型取最老的那个。

## ★★ 顺带修掉一个生产级缺陷(实测撞出来的)

给 `SessionCandidate` 加 `LastActivity` 时用了:

    COALESCE(s.updated_at, '0001-01-01 00:00:00+00')

COALESCE 让驱动返回 **string**,扫进 time.Time 报 `unsupported Scan`
⇒ 命中 `return out, err` ⇒ **整个候选列表变空**(实测一条都列不出)。

生产影响:`updated_at` 为 NULL 的历史会话会全部静默消失。
而那个错误信息里**没有任何线索**指向「是你加的 COALESCE 害的」——
本次是我自己加的列触发的,排查花了几步。

改为扫进 `sql.NullTime`(NULL 即零值),平台镜像那条同理。
注释里写明为什么不能 COALESCE 兜底,免得下次有人再加回去。

## MCP 侧同步

`suggest_address` 加 `flatten` 参数,且**渲染必须单独写**:
flatten 的响应没有 `suggestions` 字段,走原来的分支只会回一句
「(没有 session_flat 建议)」—— 模型拿不到任何地址,等于白问一次。

path 形状的渲染把两类坑直接顶到眼前:桥内部目录、相对路径
(`root` 与 `/root` 在数据里是两个不同工作区,实测 1 条 vs 17 条)。

## 判据(8 格)

含「address 必须与候选自身 path/alias 一致」(那正是静默投错的解药)、
「两个工作区都要出现」(原形状缺的就是这一维)、
「不带 flatten 时行为一字未变」(各桥与 WebUI 都走那一支)、
「flatten 不得把 new 混在候选里」(没有真实会话时它看起来像出路)。

**变异验证**:

    COALESCE 兜底(那个真 bug)          → 红 1 ✓
    flatten 段放回 path=="" 之后(顺序 bug)→ 红 1 ✓(kind 变回 "path")

## 实测校准了一处报告里的数字

报告写「近似写法返回 0 条」,实测返回 **1 条,内容是 `new`** ——
服务端在任何 path 下都追加的新建占位。所以选错 path 时调用方看到的不是
「空」,而是「只有 new 可选」:**看起来像一条出路**,于是顺着它新建,
恰好落进猜错的那个工作区。比报 0 更危险(0 会让人停下,new 会让人继续)。

§E 无需修:`validateSessionAlias` 已拒绝别名含 `.`。

全量 14 包绿。
This commit is contained in:
2026-10-02 15:59:56 +08:00
parent 560c462768
commit 1b810a4898
5 changed files with 592 additions and 11 deletions

View File

@ -231,6 +231,61 @@ func summarizeUpload(payload map[string]any) string {
func summarizeSuggest(payload map[string]any) string {
kind := str(payload, "kind")
// flatten 出来的形状(2026-10-02 寻址报告 A):addresses + candidates。
// 必须单独渲染 —— 它没有 suggestions 字段,走下面的分支只会回一句
// 「(没有 session_flat 建议)」,模型拿不到任何地址,等于白问。
if kind == "session_flat" {
cands, _ := payload["candidates"].([]any)
if len(cands) == 0 {
return "没有可列出的会话(对方可能从未与你通信过)。" +
"若要开新线索,用 send_mail 指定 **.new** 结尾的地址 —— " +
"但请先确认工作区:root 与 /root 是两个不同工作区。"
}
var b strings.Builder
fmt.Fprintf(&b, "%d 条可投递地址(每条自带工作区,直接放进 send_mail 的 to):\n\n", len(cands))
for _, it := range cands {
m, _ := it.(map[string]any)
flag := ""
if numberOf(m["unread"]) > 0 {
flag = fmt.Sprintf(" ★%d 未读", int(numberOf(m["unread"])))
}
fmt.Fprintf(&b, "· %s\n 工作区:%s\n 标题:%s%s\n",
str(m, "address"), str(m, "path"),
firstNonEmpty(str(m, "title"), "(无标题)"), flag)
}
if paths, ok := payload["paths"].([]any); ok && len(paths) > 0 {
if notes := pathWarnings(paths); notes != "" {
b.WriteString("\n⚠ 工作区候选里的坑(别选它们):\n" + notes)
}
}
return b.String()
}
// path 形状:把「这不是工作区」与「这是相对路径」标出来。
if kind == "path" {
if paths, ok := payload["paths"].([]any); ok && len(paths) > 0 {
var b strings.Builder
b.WriteString("可选工作目录:\n")
for _, it := range paths {
m, _ := it.(map[string]any)
mark := ""
if str(m, "kind") == "bridge-internal" {
mark = " ⚠ 桥内部目录,不是项目工作区"
}
if note := str(m, "note"); note != "" && mark == "" {
mark = " ⚠ " + note
}
fmt.Fprintf(&b, "· %s%s\n", str(m, "path"), mark)
}
if notes := pathWarnings(paths); notes != "" {
b.WriteString("\n" + notes)
}
b.WriteString("\n不知道该用哪个 → 加 flatten=true 一次拿到该收件人的全部可投递地址。")
return b.String()
}
}
suggestions, _ := payload["suggestions"].([]any)
if len(suggestions) == 0 {
return fmt.Sprintf("(没有%s建议)", kind)
@ -370,3 +425,37 @@ func invokeDownload(ctx context.Context, target, id, savePath string) (int, map[
"description": fmt.Sprintf("已保存 %s(%s)", savePath, humanSize(len(body))),
}, ""
}
// pathWarnings 汇总「path 候选里哪些不该选」。
//
// 为什么值得单独做(2026-10-02 寻址报告 B/C 实测):64 条候选里 33 条是
// 桥的内部会话目录,且 root 与 /root 外观只差一个斜杠却是两个不同工作区。
// 混在列表里给模型挑,选中即**静默**投进错误线索(投递返回 200)。
func pathWarnings(paths []any) string {
var internal, relative []string
for _, it := range paths {
m, _ := it.(map[string]any)
if str(m, "kind") == "bridge-internal" {
internal = append(internal, str(m, "path"))
} else if ok, isBool := m["is_absolute"].(bool); isBool && !ok {
relative = append(relative, str(m, "path"))
}
}
var b strings.Builder
if len(internal) > 0 {
fmt.Fprintf(&b, "· %d 条是 Agent 桥的内部会话目录(投到那里会把会话的工作区变成它):%s\n",
len(internal), firstFew(internal, 3))
}
if len(relative) > 0 {
fmt.Fprintf(&b, "· %d 条是相对路径(与同名绝对路径是两个不同工作区):%s\n",
len(relative), firstFew(relative, 3))
}
return b.String()
}
func firstFew(items []string, n int) string {
if len(items) <= n {
return strings.Join(items, ", ")
}
return strings.Join(items[:n], ", ") + fmt.Sprintf(" …(共 %d 条)", len(items))
}

View File

@ -276,18 +276,30 @@ func (t *Tools) SuggestAddress() Tool {
return fnTool{
schema: ToolSchema{
Name: "suggest_address",
Description: "查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的工作目录;" +
"name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,不要凭记忆拼写 —— 拼错不会报错,只会投到别的会话。",
Description: "查询可用的收件人地址,用于精准发信。" +
"★ **发信前必须先问它**,不要凭记忆拼 `name@path.session` —— 拼错不会报错," +
"只会静默投进另一条会话(实测:`pi@root` 与 `pi@/root` 是两个不同工作区," +
"选错后投递返回 200 但落进无关线索)。三种用法:" +
"只给 name(列出该 name 用过的工作目录,并标注哪些是桥内部目录、哪些是相对路径);" +
"给 name + flatten=true(**一次拿到该 name 的全部可投递地址**,含各自的工作区 —— " +
"不知道在哪个工作区时用这个);" +
"给 name + path(列该工作区下可续谈的会话)。",
Annotations: readOnly,
InputSchema: objSchema(map[string]any{
"name": strProp("收件人名;留空则列出所有候选收件人"),
"path": strProp("工作目录;与 name 同时给出才列会话"),
"name": strProp("收件人名;留空则列出所有候选收件人"),
"path": strProp("工作目录;与 name 同时给出才列会话"),
"flatten": boolProp("★ 只给 name 时置 true:一次返回该 name 的全部可投递地址(含各自工作区)"),
}),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
flat := "0"
if argBool(args, "flatten") {
flat = "1"
}
target := withQuery("/api/v1/agent/contacts/suggest", map[string]string{
"name": strings.TrimSpace(argStr(args, "name")),
"path": strings.TrimSpace(argStr(args, "path")),
"flatten": flat,
"name": strings.TrimSpace(argStr(args, "name")),
"path": strings.TrimSpace(argStr(args, "path")),
})
code, payload, raw := invoke(ctx, handler.AgentSuggestAddress, newRequest(ctx, http.MethodGet, target, nil))
return render("suggest_address", code, payload, raw)