From d1099526ad9009510dd17b56b337a65e6a35c99f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 16:06:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=AF=BB=E5=9D=80):=20flatten=20=E7=9A=84?= =?UTF-8?q?=E5=80=99=E9=80=89**=E9=80=90=E6=9D=A1**=E6=A0=87=E6=B3=A8=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E7=AC=AC=E4=B8=80=E7=89=88=E6=8A=8A=20?= =?UTF-8?q?=C2=A7C=20=E5=99=AA=E5=A3=B0=E6=94=BE=E8=BF=9B=E4=BA=86?= =?UTF-8?q?=E6=96=B0=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 缺口(部署后实测才发现,是我自己引入的) 第一版 flatten 只在响应的 `paths[]` 数组里标注。实测: 222 条候选,其中 37 条(16%)落在桥内部目录(/root/.pi/mail-sessions/) 而标注在**另一个数组** —— 模型必须自己把 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 条相对路径被标出 --- .../internal/handler/address_flatten_test.go | 102 ++++++++++++++++++ server/internal/handler/agent_discovery.go | 32 ++---- server/internal/mcp/support.go | 13 ++- server/internal/repo/platform_sessions.go | 59 +++++++++- 4 files changed, 178 insertions(+), 28 deletions(-) diff --git a/server/internal/handler/address_flatten_test.go b/server/internal/handler/address_flatten_test.go index f9af2e0..d147f25 100644 --- a/server/internal/handler/address_flatten_test.go +++ b/server/internal/handler/address_flatten_test.go @@ -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) + } +} diff --git a/server/internal/handler/agent_discovery.go b/server/internal/handler/agent_discovery.go index 834f4b2..164938f 100644 --- a/server/internal/handler/agent_discovery.go +++ b/server/internal/handler/agent_discovery.go @@ -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 { diff --git a/server/internal/mcp/support.go b/server/internal/mcp/support.go index 297715f..f450be7 100644 --- a/server/internal/mcp/support.go +++ b/server/internal/mcp/support.go @@ -250,8 +250,17 @@ func summarizeSuggest(payload map[string]any) string { 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"), + // ★ 逐条把「这个工作区不该选」标出来(实测 222 条候选里 37 条 + // 落在桥内部目录)。标注只在 paths[] 里的话,模型得自己把两个 + // 数组对照才认得出 —— 那就是 §C 噪声换个位置复活。 + warn := "" + if k := str(m, "path_kind"); k == "bridge-internal" { + warn = " ⚠ 桥内部目录,不是项目工作区" + } else if note := str(m, "path_note"); note != "" { + warn = " ⚠ " + note + } + fmt.Fprintf(&b, "· %s\n 工作区:%s%s\n 标题:%s%s\n", + str(m, "address"), str(m, "path"), warn, firstNonEmpty(str(m, "title"), "(无标题)"), flag) } if paths, ok := payload["paths"].([]any); ok && len(paths) > 0 { diff --git a/server/internal/repo/platform_sessions.go b/server/internal/repo/platform_sessions.go index 41a047d..0564adb 100644 --- a/server/internal/repo/platform_sessions.go +++ b/server/internal/repo/platform_sessions.go @@ -526,9 +526,18 @@ type AddressedCandidate struct { Path string `json:"path"` // Address 是可直接投递的三维地址(name@path.alias)。 Address string `json:"address"` - // PathSource 说明这个 path 是从哪学来的(投递历史 / 平台镜像 / 注册自报), - // 供调用方判断可信度与新鲜度。 - PathSource string `json:"path_source,omitempty"` + // PathKind 与 PathNote 复用 handler 层的 pathCandidate 分类。 + // + // ★ 为什么必须**逐候选**带,而不只是靠响应里那个 paths[] 数组: + // 实测 flatten 一次给 222 条候选,其中 37 条(16%)落在桥内部目录 + // (/root/.pi/mail-sessions/)。标注只放在 paths[] 里的话, + // 模型必须自己把 candidates 与 paths 两个数组对照才认得出 —— + // 而这正是 §C「噪声淹没信号」的翻版,只是换了位置。 + // 逐条自带标注,扫一眼列表就知道该跳过哪些。 + PathKind string `json:"path_kind,omitempty"` + PathNote string `json:"path_note,omitempty"` + // IsAbsolutePath 标出相对路径(`root` 与 `/root` 是两个不同工作区)。 + IsAbsolutePath bool `json:"is_absolute_path"` } // SuggestAddressesForPeer 一次列出「我能投递的、属于 peerName 的全部地址」。 @@ -587,6 +596,9 @@ func SuggestAddressesForPeer(ctx context.Context, forUser, peerName string) ([]A SessionCandidate: c, Path: p, Address: models.FormatAddress(peerName, p, c.Alias), + PathKind: classifyPathKind(p), + PathNote: classifyPathNote(p), + IsAbsolutePath: strings.HasPrefix(p, "/"), }) } } @@ -595,3 +607,44 @@ func SuggestAddressesForPeer(ctx context.Context, forUser, peerName string) ([]A }) return out, nil } + +// classifyPathKind / classifyPathNote 把「这个 path 是不是项目工作区」讲清楚。 +// +// ★ 为什么放在 repo 层而不是只在 handler 层:标注要**逐候选**随行(见 +// AddressedCandidate 的注释),所以 repo 拼候选时就得能分类。 +// 判定口径与 handler 的 classifyPath 逐字一致(同一批 marker)。 +var bridgeInternalMarkers = []string{ + "/.pi/mail-sessions/", + "/mail-sessions/", + "/.agentmail/sessions/", + "/.zcode/mail-sessions/", + "/.dsh/", +} + +// ClassifyPathKind 导出给 handler 层复用(marker 只有这一份)。 +func ClassifyPathKind(p string) string { return classifyPathKind(p) } + +// ClassifyPathNote 同上。 +func ClassifyPathNote(p string) string { return classifyPathNote(p) } + +func classifyPathKind(p string) string { + for _, m := range bridgeInternalMarkers { + if strings.Contains(p, m) { + return "bridge-internal" + } + } + return "workspace" +} + +func classifyPathNote(p string) string { + var notes []string + if classifyPathKind(p) == "bridge-internal" { + notes = append(notes, + "这是 Agent 桥的内部会话存储目录,不是项目工作区;投到这里的信会把会话 cwd 变成它") + } + if !strings.HasPrefix(p, "/") { + notes = append(notes, + "这是相对路径,与 /"+strings.TrimPrefix(p, "/")+" 是两个不同工作区") + } + return strings.Join(notes, " ") +}