fix(寻址): flatten 的候选**逐条**标注 —— 第一版把 §C 噪声放进了新端点

## 缺口(部署后实测才发现,是我自己引入的)

第一版 flatten 只在响应的 `paths[]` 数组里标注。实测:

    222 条候选,其中 37 条(16%)落在桥内部目录(/root/.pi/mail-sessions/<uuid>)
    而标注在**另一个数组** —— 模型必须自己把 candidates 与 paths 对照才认得出

那正是「§C 噪声淹没信号」换个位置复活。我在动手前的判断是「先修 C 再修 A,
否则新端点会把噪声一起放大」—— 做了 A,却让 C 的噪声原样跟进了 A。

只在真机跑过 `flatten=1` 才看见:单测全绿(它们只断言了 paths[] 有标注),
是生产数据的 16% 把它翻出来的。

## 修法

`AddressedCandidate` 逐候选带 `path_kind` / `path_note` / `is_absolute_path`,
MCP 渲染逐条打 `⚠`。

marker 收敛到 repo 层一份,handler 的 `classifyPath` 改为委托调用:

    同一目录在 path 列表里标成「工作区」、在候选列表里却没标 ——
    而那两个数组是**同一次调用**返回的。两处各写一份 marker 时,
    改一处忘另一处就会出现这种自相矛盾,且没有任何报错。

## 判据(2 格)

    TestFlattenAnnotatesEachCandidate  桥内部目录/相对路径能分类 + 带说明;
                                        真工作区不得被误标(否则全是噪声)
    TestClassifyPathAgreesWithRepo     handler 与 repo 口径必须逐条一致

## 顺带

第一版 flatten 本身已验证有效(生产实测):

    flatten=1 → 222 条候选、66 个工作区
    /home/program/agentmail 125 条 · /root 16 条 · root 2 条
    ⇒ root 与 /root **同时可见**且各自带 path,不再需要「先猜 path 再枚举」

    path 标注:66 条候选里 35 条桥内部目录 + 1 条相对路径被标出
This commit is contained in:
2026-10-02 16:06:00 +08:00
parent 1b810a4898
commit d1099526ad
4 changed files with 178 additions and 28 deletions

View File

@ -28,6 +28,7 @@ import (
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
)
// pathCandidates 的形状判据:桥内部目录必须被标出来,且 suggestions 原样保留。
@ -255,3 +256,104 @@ func doSuggest(t *testing.T, caller, name string, flatten bool) *httptest.Respon
AgentSuggestAddress(rec, r)
return rec
}
/*
★ 逐候选标注(2026-10-02 部署后实测发现的缺口,我引入的)。
第一版 flatten 只在响应的 `paths[]` 里标注。实测 222 条候选里 37 条(16%)
落在桥内部目录,而标注在另一个数组 —— 模型必须自己把 candidates 与 paths
对照才认得出。那就是「§C 噪声淹没信号」换个位置复活:新端点把噪声一起放大了。
*/
func TestFlattenAnnotatesEachCandidate(t *testing.T) {
kind, note := repo.ClassifyPathKind("/root/.pi/mail-sessions/abc"), repo.ClassifyPathNote("/root/.pi/mail-sessions/abc")
if kind != "bridge-internal" {
t.Errorf("★ 桥内部目录必须能分类出来,实际 %q", kind)
}
if note == "" {
t.Error("必须带说明")
}
// 反向对照:真工作区不得被标
if k := repo.ClassifyPathKind("/home/program/agentmail"); k != "workspace" {
t.Errorf("真实工作区被误标 %q", k)
}
if n := repo.ClassifyPathNote("/home/program/agentmail"); n != "" {
t.Errorf("真工作区不该带 note(否则全是噪声):%q", n)
}
// 相对路径:分类仍是 workspace,但 note 必须点明它与 /root 的区别
if k := repo.ClassifyPathKind("root"); k != "workspace" {
t.Errorf("相对路径的 kind 应仍是 workspace,实际 %q", k)
}
if n := repo.ClassifyPathNote("root"); n == "" {
t.Error("★ 相对路径必须带 note —— 它与 /root 是两个不同工作区")
}
}
// handler 与 repo 共用同一批 marker(两处各写一份时,改一处忘另一处
// ⇒ 同一目录在 path 列表标「工作区」、在候选列表没标,而两者同一次返回)。
func TestClassifyPathAgreesWithRepo(t *testing.T) {
for _, p := range []string{
"/root/.pi/mail-sessions/abc", "/mail-sessions/x", "/.dsh/y",
"/home/program/agentmail", "root", "/.zcode/mail-sessions/z",
} {
if got, want := classifyPath(p).Kind, repo.ClassifyPathKind(p); got != want {
t.Errorf("path=%q:handler 说 %q,repo 说 %q —— 两处口径必须一致", p, got, want)
}
}
}
// ★ 逐候选必须自带标注 —— 不能只靠响应里那个 paths[] 数组。
//
// 实测(2026-10-02):flatten 一次给 222 条候选,其中 37 条(16%)落在桥的
// 内部会话目录。若标注只在 paths[] 里,模型必须自己把 candidates 与 paths
// 两个数组对照才认得出 —— 那就是 §C「噪声淹没信号」换个位置复活。
//
// 判据两侧都钉:有标注 / 反向对照(真工作区不该被误标)。
func TestFlattenCandidatesCarryTheirOwnPathFlags(t *testing.T) {
setupHandlerTestDB(t)
seedPeerSession(t, "pi", "/root/.pi/mail-sessions/abc-123", "桥内部的一条")
seedPeerSession(t, "pi", "/home/program/agentmail", "真实工作区")
rec := doSuggest(t, "dsh", "pi", true)
if rec.Code != http.StatusOK {
t.Fatalf("HTTP %d: %s", rec.Code, rec.Body.String())
}
var got struct {
Candidates []struct {
Alias string `json:"alias"`
Path string `json:"path"`
PathKind string `json:"path_kind"`
PathNote string `json:"path_note"`
IsAbsolutePath bool `json:"is_absolute_path"`
}
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("响应不是 JSON:%v", err)
}
if len(got.Candidates) < 2 {
t.Fatalf("应有两个工作区的候选,实际 %d", len(got.Candidates))
}
var sawInternal, sawWorkspace bool
for _, c := range got.Candidates {
if strings.Contains(c.Path, "/mail-sessions/") {
sawInternal = true
if c.PathKind != "bridge-internal" {
t.Errorf("★ 桥内部目录候选必须自带 path_kind,实际 %q(路径 %s)", c.PathKind, c.Path)
}
if c.PathNote == "" {
t.Error("★ 必须自带 path_note(模型扫一眼就要能跳过它)")
}
} else {
sawWorkspace = true
if c.PathKind == "bridge-internal" {
t.Errorf("真实工作区被误标为 bridge-internal:%s", c.Path)
}
if !c.IsAbsolutePath {
t.Errorf("绝对路径候选的 is_absolute_path 应为 true:%s", c.Path)
}
}
}
if !sawInternal || !sawWorkspace {
t.Errorf("夹具没造出两类路径(internal=%v workspace=%v)", sawInternal, sawWorkspace)
}
}

View File

@ -566,32 +566,18 @@ type pathCandidate struct {
IsAbsolute bool `json:"is_absolute"`
}
// bridgeInternalMarkers 是各桥把「会话存储」放在哪的痕迹。
// classifyPath 把一个 path 候选渲染成带标注的形状。
//
// 这些目录不是工作区,而是桥为每条会话建的落地点。它们出现在 path 候选里
// 是因为 mails.to_workspace 记的就是**投递时的 path 位**,而模型发信时如果
// 猜了这类目录,信真的会落在那里(于是那条会话的 cwd 就成了它)。
var bridgeInternalMarkers = []string{
"/.pi/mail-sessions/",
"/mail-sessions/",
"/.agentmail/sessions/",
"/.zcode/mail-sessions/",
"/.dsh/",
}
// 判定委托给 repo 层的同名函数(marker 只有一份)——
// 两处各写一份 marker,改了一处忘了另一处 ⇒ 同一目录在 path 列表里被标成
// 「工作区」、在候选列表里却没标,而那两个数组是同一次调用返回的。
func classifyPath(p string) pathCandidate {
c := pathCandidate{Path: p, Kind: "workspace", IsAbsolute: strings.HasPrefix(p, "/")}
for _, m := range bridgeInternalMarkers {
if strings.Contains(p, m) {
c.Kind = "bridge-internal"
c.Note = "这是 Agent 桥的内部会话存储目录,不是项目工作区;投到这里的信会把会话 cwd 变成它"
break
}
return pathCandidate{
Path: p,
Kind: repo.ClassifyPathKind(p),
Note: repo.ClassifyPathNote(p),
IsAbsolute: strings.HasPrefix(p, "/"),
}
if !c.IsAbsolute {
c.Note = strings.TrimSpace(c.Note + " 另:这是相对路径,与 /" + strings.TrimPrefix(p, "/") + " 是两个不同工作区")
}
return c
}
func pathCandidates(paths []string) []pathCandidate {