Files
MailUI4Agents/server/internal/handler/agent_discovery.go
JianFeeeee d1099526ad 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 条相对路径被标出
2026-10-02 16:06:00 +08:00

598 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"log"
"net/http"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
"github.com/google/uuid"
)
// Agent 侧的寻址发现与线索读取。
//
// # 为什么需要这一组端点
//
// 在这之前,Agent 能读的只有自己的收件箱。`/agents`、`/contacts`、
// `/contacts/suggest`、`/mail/{id}/thread`、`/sessions/{id}` 全部挂在
// `middleware.UserAuth` 后面,Agent 密钥一律 401。后果是 `send_mail` 的 `to`
// 成了一个**只能靠记忆拼写的自由文本字段**:
//
// - 想回给抄送方,只能从收件箱渲染出的 `抄送: opencode@/home.new` 里抄一段,
// 而 `.new` 是一次性的,抄过去只会再建一条会话;
// - 想知道对方接受哪个工作目录,无从查询,只能猜。生产上真实发生过一次:
// dsh 猜了 `opencode@/home`,地址解析通过、投递成功,但 `/home` 不是
// opencode 的工作目录 —— **猜错比报错更糟,它会静默变成新会话的 workspace**。
//
// 人类侧从来没有这个问题:`AddressInput` 三段式逐段查 `/contacts/suggest`,
// name / path / session 每一段都从活数据里选。这一组端点就是把同一份能力
// 给 Agent。
//
// # 为什么不直接给 Agent 复用人类那几条路由
//
// 两条理由:
//
// 1. **作用域不同。** 人类侧 `ListContactsFor(scope=username)` 的 scope 是
// 「我参与过的会话」,管理员还能 `?all=true` 看全部。Agent 没有管理员概念,
// 也不该看到自己没参与过的线索。把 AgentAuth 加进人类路由组,等于让
// `middleware.GetUser` 返回 nil 的请求走进一堆假定 user 非空的 handler。
// 2. **审计与演进。** Agent 能读什么是插件契约的一部分(PLUGIN-CONTRACT 的
// 能力矩阵),独立成组才能在一处看全。
//
// # 一律只读
//
// 这里没有任何写端点。归档、改别名、决策权限都仍然只有人能做 ——
// Agent 可以「看见并寻址」,但不能替人整理邮箱。
// agentScope 取「调用方当前所在的那条邮件会话」(查询串 `session_id`)。
//
// # 这一维是干什么的
//
// 请求里原先**根本没有**「我现在在哪个工作区」这个事实 —— 而隔离判据需要它。
// 这里的立场是:不接受调用方直接声明工作区(那等于自己给自己发通行证),
// 只接受一条**会话 id**,由服务端反查它的 `workspace`。
//
// # 三种返回
//
// - 声明了且合法 → 返回该 id
// - 未声明 → 返回 nil(旧语义:放行),并记一条警告
// - 声明了但不合法 → 写 400 并返回 ok=false
// (不静默忽略:静默忽略会让调用方以为自己收窄了,而实际是全量)
func agentScope(w http.ResponseWriter, r *http.Request, agentName string) (*uuid.UUID, bool) {
raw := strings.TrimSpace(r.URL.Query().Get("session_id"))
if raw == "" {
// 还未接线的桥/脚本/浏览器会走这里。放行是迁移期的妥协,
// 警告是收尾用的抓手:按日志把没接线的调用方找全。
log.Printf("[agent-scope] %s 读了 %s 但没声明 session_id(旧语义放行:未按工作区隔离)",
agentName, r.URL.Path)
return nil, true
}
id, err := uuid.Parse(raw)
if err != nil {
Error(w, http.StatusBadRequest, "非法的 session_id")
return nil, false
}
return &id, true
}
// canReadSession 是五个读端点共用的那道闸门,失败时自己写响应。
//
// 两类拒绝的文案刻意不同:`not-participant` 说"不是你的线索",
// `cross-workspace` 说"你的工作区不对" —— 但**不报出对方的工作区**
// (那本身就是跨工作区信息)。调用方能看见的只有自己那个工作区名。
func canReadSession(w http.ResponseWriter, r *http.Request, agentName string, scope *uuid.UUID, target uuid.UUID) bool {
ok, reason, err := repo.AgentMayReadSession(r.Context(), agentName, scope, target)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return false
}
if ok {
return true
}
switch reason {
case "not-your-session":
// ★ 2026-09-15 用户订正:读信这条路只认会话 ——
// 「是应该发到对应 session 的信,因为 agent 多 session 架构,不同 session 的
// 记忆是隔离的」。所以**不再拿工作区当理由**(旧文案就是 cross-workspace,
// 它让 zcode 推断出"那五位 agent 不存在",把错误结论写进了给人看的信里)。
// 报出"我当前在哪条会话"是安全的(那是调用方自己的);**不报**目标会话在哪。
self := ""
if scope != nil {
self = (*scope).String()
}
Error(w, http.StatusForbidden,
"这封信不在你当前所在的那条会话里:每个会话各有各的收件箱(会话之间的记忆是隔离的)。"+
"你当前在会话 "+self+"。要读它,就在它那条会话里读 —— 每封来信都会把 worker 唤醒到"+
"它自己那条会话上,那时你看到的就是那条会话的收件箱。")
default:
Error(w, http.StatusForbidden, "无权访问该会话")
}
return false
}
// GET /api/v1/agent/session/default —— 问出「我的默认会话是哪条」
//
// ★ 2026-10-02 新增。它是 `AgentMayReadSession` 收严后的**配套出口**:
// 未声明 session_id 的读信请求现在一律 403,而桥在**非邮件驱动轮次**
// (对话/自主调用)手里确实没有任何会话可声明 —— `session_id` 是 AgentMail
// 会话的 UUID,进程 cwd 给不出它(这点与 workspace 不同,workspace 能回落到 cwd)。
//
// 所以这里把**已有的**默认会话语义开放成一个可查询入口:
// `name@path` 省略 session 位时投递到的那条会话(`repo.FindOrCreateDefaultSession`)。
// 那个函数早就在(8 个测试覆盖它),只是没有「只查询」的 HTTP 形状。
//
// # 为什么默认工作区是 /tmp
//
// 非邮件轮次没有真实工作目录可依(桥进程 cwd 是插件目录,不是用户项目)。
// /tmp 是**明确的中性落点**:它不属于任何一个真实项目,
// 所以落进去的会话不会在任何人的工作区里混进项目邮件,
// 而 `UnreadWorkspaces` 要求 workspace 形如 `/%`(要能被寻址补投),/tmp 满足。
//
// # 安全边界:这个端点**不是**绕过 session 闸的万能钥匙
//
// 它只回答「你自己 `name@/tmp` 的默认会话是哪条」,不授予读任何会话的能力。
// 拿到 UUID 后仍要过 `AgentMayReadSession`:读别的会话照样 403。
// 换句话说:它把「非法」变成「合法但窄」—— 默认会话只装发给**你自己**的信。
func AgentDefaultSession(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
ws := strings.TrimSpace(r.URL.Query().Get("workspace"))
if ws == "" {
ws = DefaultFallbackWorkspace
}
if !strings.HasPrefix(ws, "/") {
Error(w, http.StatusBadRequest, "workspace 必须是绝对路径,收到: "+ws)
return
}
// 纯只读:没有就返回空,**不建会话**。
//
// 我第一版直接调 FindOrCreateDefaultSessionCreated,判据当场报它**每次都新建**
//(连着两次问拿到两个不同 UUID)。改「先查后建」还不够 —— 已建的空会话仍不满足
// 那个函数的复用条件(`EXISTS (SELECT 1 FROM mails …)`),于是下一次依然「没找到」。
//
// 而想深一层:**根本不该建**。非邮件轮次读信时,若 `name@/tmp` 一封都没通过,
// 那个收件箱本来就应该是空的 —— 不需要一条会话 id 才能表达「空」。
// 要让默认会话真的存在,只需要**往 /tmp 发一封信**,那是发信路径的事。
//
// GET 端点有副作用本身就是坏味道:它会被桥每轮调一次,
// 预建会话等于让「问一次」在会话列表里留一条垃圾。
id, err := repo.FindExistingDefaultSession(r.Context(), agentName, ws)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to resolve default session")
return
}
// 没找到时返回 session_id=null(而不是 uuid.Nil 字符串):
// 桥看到 null 就知道「默认会话还不存在,收件箱按空处理」,
// 而不是拿着一个不存在的 id 去声明、然后拿到一个莫名其妙的 403。
var sid any
self := ""
if id != uuid.Nil {
sid = id
self = models.FormatAddress(agentName, ws, repo.SessionAliasOf(r.Context(), id))
}
JSON(w, http.StatusOK, map[string]any{
"session_id": sid,
"workspace": ws,
"exists": id != uuid.Nil,
"self_address": self,
})
}
// DefaultFallbackWorkspace 是非邮件轮次的默认落点(理由见 AgentDefaultSession)。
const DefaultFallbackWorkspace = "/tmp"
// GET /api/v1/agent/contacts
//
// 本 Agent 参与过的会话,每条给出可直接投递的 `address`。
// 与人类侧 `/contacts` 同源(`repo.ListContactsFor`),scope 固定为自己。
//
// 声明了 `session_id`(= 调用方当前所在那条会话)时只列**同工作区**的会话:
// 一个 Agent 同时服务所有工作区,不收窄的话在 TrueAgent 里干活的 worker 会拿到
// agentmail 的会话标题/别名/未读计数。
func AgentListContacts(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
// 仍走 agentScope:它负责校验 session_id 格式、并在未声明时记一条警告。
// 但它的返回值**不再是过滤器** —— 列表与寻址都不再按工作区收窄(见下)。
if _, ok := agentScope(w, r, agentName); !ok {
return
}
archived := r.URL.Query().Get("archived") == "true"
// ★ 2026-09-15 用户:「任意 agent 的寻址是任意的,而不是按工作区区分,去落实吧」。
// 列表 = **我参与过的会话**(这是事实,不是授权),不再按工作区收窄。
// 旧口径让一个 agent 只看得见"同工作区的会话标题",zcode 2026-09-15 正是据此
// 认定"那五位 agent 不存在"。工作区那条轴只留给 cwd/沙箱。
contacts, err := repo.ListContactsFor(r.Context(), agentName, archived)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list contacts")
return
}
// 联系人条目里的 agent_name 是「会话对面那个人」,但 ListContactsFor 取的是
// 会话首封邮件的 to_name(人类侧视角:对面是 Agent)。Agent 自己调用时,
// 首封邮件的 to_name 往往就是自己,对面反而是 from_name。
// 因此这里补一个 peer 字段明确「该跟谁说话」,不改原字段以免动到前端。
out := make([]map[string]any, 0, len(contacts))
for _, c := range contacts {
peer := c.AgentName
if peer == agentName {
peer = c.LastFrom
}
out = append(out, map[string]any{
"session_id": c.SessionID,
"session_alias": c.SessionAlias,
"subject": c.Subject,
"path": c.Path,
"status": c.Status,
"mail_count": c.MailCount,
"unread_count": c.UnreadCount,
"last_activity": c.LastActivity,
"last_from": c.LastFrom,
"max_rounds": c.MaxRounds,
"used_rounds": c.UsedRounds,
// peer 是这条会话里可与之通信的另一方
"peer": peer,
// address 是投回这条会话的现成地址。别名为空的老会话给不出可寻址的
// 形式,此时置空而不是拼一个 `.new` —— 那会开新线索而不是续谈。
"address": addressForSession(peer, c.Path, c.SessionAlias),
})
}
JSON(w, http.StatusOK, map[string]any{"contacts": out})
}
// addressForSession 拼「投回这条会话」的地址;无别名时返回空串。
//
// 刻意不退化成 `name@path`(默认会话):默认会话是「该 name@path 当前最活跃的
// 那条」,与调用方想回的那条不一定是同一条。给一个看着能用其实指向别处的地址,
// 比给空串危险。
func addressForSession(name, path, alias string) string {
if alias == "" {
return ""
}
return models.FormatAddress(name, path, alias)
}
// GET /api/v1/agent/contacts/suggest?name=&path=
//
// 三段式寻址补全,与人类侧 `/contacts/suggest` 同一套语义:
//
// 不带 name → 候选收件人名(在线 Agent + 活跃用户,去掉自己)
// 带 name 不带 path → 该 name 用过的工作目录
// name + path 都带 → 该 name@path 下可续谈的会话别名,`new` 永远在最后
//
// **这是「精准发信」的关键一环**:模型不再拼地址,而是逐段选。
func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
name := strings.TrimSpace(r.URL.Query().Get("name"))
path := strings.TrimSpace(r.URL.Query().Get("path"))
// flatten=1:一次给出该 name 的**全部**可投递地址(各 path 下的会话都带上
// 自己的 path 与完整地址)。见下方那一段的说明。
flatten := r.URL.Query().Get("flatten") == "1" || r.URL.Query().Get("flatten") == "true"
if name == "" {
agents, err := repo.ListAgents(r.Context(), "")
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list agents")
return
}
users, _ := repo.ListActiveUsernames(r.Context())
names := make([]string, 0, len(agents)+len(users))
for _, a := range agents {
if a.Name == agentName {
continue // 不建议给自己发信
}
names = append(names, a.Name)
}
names = append(names, users...)
JSON(w, http.StatusOK, map[string]any{
"kind": "name",
"suggestions": emptySlice(names),
})
return
}
// flatten:把三段补全压成一次调用。
//
// ★ 2026-10-02(DSH 侧报告 A):原来的形状构成一个闭合的环 ——
// 给 name 只给 path,要再调一次才知道有哪些会话;而要枚举会话又必须先
// 知道 path,path 却只能猜(且 `root` 与 `/root` 会静默落到不同工作区)。
// 报告实测:投 `pi@root` 返回 200,落进一条标题为「拓展坞实测硬件正常…」
// 的无关会话 —— 投递成功,所以调用方不知道自己投错了。
//
// 可见性口径**不放宽**:与下面 path+name 那一支完全一致
//(「我参与过 + 与该 name 匹配」)。报告本身也确认问题不在权限。
if flatten {
if _, ok := agentScope(w, r, agentName); !ok {
return
}
cands, err := repo.SuggestAddressesForPeer(r.Context(), agentName, name)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list addresses")
return
}
addresses := make([]string, 0, len(cands))
for _, c := range cands {
addresses = append(addresses, c.Address)
}
JSON(w, http.StatusOK, map[string]any{
"kind": "session_flat",
"addresses": emptySlice(addresses),
"candidates": emptySlice(cands),
// paths 一并给回:调用方若要按 path 逐个展开,不必再调一次。
"paths": pathCandidates(mustPaths(r, name)),
})
return
}
if path == "" {
paths, _ := repo.SuggestPaths(r.Context(), name)
JSON(w, http.StatusOK, map[string]any{
"kind": "path",
"suggestions": emptySlice(paths),
// ★ 2026-10-02(DSH 侧报告 B/C):path 候选里混着**桥的内部会话目录**
// (如 /root/.pi/mail-sessions/<uuid>),实测 64 条候选里 33 条是它。
// 而 `root` 与 `/root` 在数据里真的是两个不同工作区(实测 1 条 vs 17 条),
// 外观只差一个斜杠。调用方无从区分,选中即静默投进错误线索。
//
// 所以这里**标注**而不是隐藏:隐藏会让人以为那些工作区不存在,
// 而它们确实是某些会话的真实 cwd(只是不该出现在「工作区」候选里)。
"paths": pathCandidates(paths),
})
return
}
// 可见性传自己的名字:只提示自己参与过的会话。
// 传空会把别人的私下线索也列出来,那是越权。
// ★ 2026-09-15 用户:「任意 agent 的寻址是任意的,而不是按工作区区分,去落实吧」。
// 原先这里"声明了 session_id 就只列同工作区的会话"——那是把**寻址**当成**权限**了:
// 寻址就该是任意的(谁都能给谁发),工作区只决定 cwd/沙箱。
if _, ok := agentScope(w, r, agentName); !ok {
return
}
// 候选会话只按"我参与过 + 与这个 name/path 匹配"来列。 —— 候选会话只按"我参与过 + 与这个名字/路径匹配"来列,
// 这样 zcode 那种"想给别的 agent 发信"的场景才能拿到真实的候选。
sessions, err := repo.SuggestSessionCandidates(r.Context(), agentName, name, path)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to suggest sessions")
return
}
aliases := make([]string, 0, len(sessions)+1)
addresses := make([]string, 0, len(sessions)+1)
for _, c := range sessions {
aliases = append(aliases, c.Alias)
addresses = append(addresses, models.FormatAddress(name, path, c.Alias))
}
// new 总在最后:它不是一条已存在的会话。排在前面会让模型在想续谈时
// 顺手开出一条新线索 —— 生产上已经发生过。
aliases = append(aliases, "new")
addresses = append(addresses, models.FormatAddress(name, path, "new"))
sessions = append(sessions, repo.SessionCandidate{
Alias: "new", Source: "new", Title: "新建会话",
})
JSON(w, http.StatusOK, map[string]any{
"kind": "session",
"suggestions": emptySlice(aliases),
// addresses 与 suggestions 同序,可直接塞进 send_mail 的 to
"addresses": emptySlice(addresses),
"candidates": emptySlice(sessions),
})
}
// GET /api/v1/agent/mail/{id}/thread
//
// 与人类侧 `/mail/{id}/thread` 同一份实现,可见性判据换成
// 「本 Agent 参与过该会话**且工作区相同**」(见 repo.AgentMayReadSession)。
// 抄送协作要靠它回答「谁已经回了、谁还没回」。
func AgentGetMailThread(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
scope, ok := agentScope(w, r, agentName)
if !ok {
return
}
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
// 丢掉 reason 而不是改 serveMailThread 的签名:人类侧 `/mail/{id}/thread`
// 与这里共用同一份实现,只为一个调用点的文案去改它不划算。
// 拒绝原因(跨工作区 / 没参与过)在 403 文案上合流成同一句,
// 反而少泄一点信息。
allowed, _, err := repo.AgentMayReadSession(r.Context(), agentName, scope, sid)
return allowed, err
})
}
// GET /api/v1/agent/mail/{id}
//
// 读单封邮件全文(含抄送清单与附件)。收件箱只给摘要,
// 而要回给抄送方就必须先看清这封信到底发给了谁。
func AgentGetMail(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
scope, ok := agentScope(w, r, agentName)
if !ok {
return
}
mailID, ok := pathUUID(w, r, "id")
if !ok {
return
}
mail, err := repo.GetMailByID(r.Context(), mailID)
if err != nil {
Error(w, http.StatusNotFound, "Mail not found")
return
}
if !canReadSession(w, r, agentName, scope, mail.SessionID) {
return
}
fillAttachments(r, mail)
alias := repo.SessionAliasOf(r.Context(), mail.SessionID)
JSON(w, http.StatusOK, map[string]any{
"mail": mail,
"session_alias": alias,
// 回信地址与「我这个身份」都给现成的,省得插件自己拼。
// mail.ToWorkspace 是收件方那个地址的 path 位。
"reply_address": models.FormatAddress(mail.FromName, "", alias),
"self_address": models.FormatAddress(agentName, mail.ToWorkspace, alias),
"participants": participantsOf(mail, alias),
})
}
// GET /api/v1/agent/sessions/{id}/participants
//
// 列出该会话的全部参与方及各自的可投递地址。
//
// 这是「发送给抄收方 / 转发方」缺的最后一块:知道有谁、以及**用什么地址找到他**。
// 逐封邮件扫收件人与抄送,因为参与方是随往来变化的(一封转发就多一个人)。
func AgentSessionParticipants(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
sessionID, ok := pathUUID(w, r, "id")
if !ok {
return
}
scope, ok := agentScope(w, r, agentName)
if !ok {
return
}
if !canReadSession(w, r, agentName, scope, sessionID) {
return
}
parts, err := repo.SessionParticipants(r.Context(), sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list participants")
return
}
alias := repo.SessionAliasOf(r.Context(), sessionID)
out := make([]map[string]any, 0, len(parts))
for _, p := range parts {
out = append(out, map[string]any{
"name": p.Name,
"path": p.Path,
"roles": p.Roles, // from / to / cc 的并集
"is_self": p.Name == agentName,
"mail_count": p.MailCount,
// address 用**该参与方自己的 path**,不是调用方的:
// 抄送给 opencode@/a 与主发给 dsh@/b 是两个工作区,
// 用错 path 会让对方在别人的目录里开会话。
"address": addressForSession(p.Name, p.Path, alias),
})
}
JSON(w, http.StatusOK, map[string]any{
"session_id": sessionID,
"session_alias": alias,
"participants": out,
})
}
// participantsOf 从单封邮件里摘出参与方地址,供 AgentGetMail 直接返回。
// 与 SessionParticipants 的区别:这里只看这一封(发件人 + 收件人 + 抄送),
// 用于「回这封信时该带上谁」;那里看整条会话。
func participantsOf(m *models.Mail, alias string) []map[string]any {
out := []map[string]any{}
add := func(role, name, path string) {
if name == "" {
return
}
out = append(out, map[string]any{
"role": role,
"name": name,
"path": path,
"address": addressForSession(name, path, alias),
})
}
// from_workspace 对 Agent 存的是 Agent 名而非路径(历史遗留),
// 拿它当 path 会拼出错地址,所以发件人一侧留空 path 走默认。
add("from", m.FromName, "")
add("to", m.ToName, m.ToWorkspace)
for _, c := range m.CCList {
add("cc", c.Name, c.Path)
}
return out
}
// pathCandidate 是一个 path 候选,外加**它是不是真工作区**的标注。
//
// ★ 2026-10-02(DSH 侧报告 B/C)。为什么必须标注而不是直接过滤:
//
// /root/.pi/mail-sessions/<uuid> 这类**桥内部目录**不是工作区,
// 但它确实是某些会话的真实 cwd
// (报告实测:投 pi@root 落进的那条会话
// workspace='root',uuid 正是其中一个)
// 滤掉它 ⇒ 那些会话在这个 name 下彻底不可见,调用方连"存在这样的线索"都不知道
// 留下不标 ⇒ 64 条候选里 33 条是噪声,模型选中即静默投错
//
// 所以两条信息都给:suggestions 保持原样(向后兼容,各桥按它取 path),
// paths[] 里带 kind 标注,让调用方能降权或跳过。
type pathCandidate struct {
Path string `json:"path"`
// Kind 是 "workspace" 或 "bridge-internal"。
Kind string `json:"kind"`
// Note 只在 kind != "workspace" 时给,一句话说清它是什么。
Note string `json:"note,omitempty"`
// IsAbsolute 标出**相对路径**。`root` 与 `/root` 在数据里是两个不同工作区,
// 而外观只差一个开头的斜杠 —— 三维地址的 path 位会被原样当作 cwd。
IsAbsolute bool `json:"is_absolute"`
}
// classifyPath 把一个 path 候选渲染成带标注的形状。
//
// 判定委托给 repo 层的同名函数(marker 只有一份)——
// 两处各写一份 marker,改了一处忘了另一处 ⇒ 同一目录在 path 列表里被标成
// 「工作区」、在候选列表里却没标,而那两个数组是同一次调用返回的。
func classifyPath(p string) pathCandidate {
return pathCandidate{
Path: p,
Kind: repo.ClassifyPathKind(p),
Note: repo.ClassifyPathNote(p),
IsAbsolute: strings.HasPrefix(p, "/"),
}
}
func pathCandidates(paths []string) []pathCandidate {
out := make([]pathCandidate, 0, len(paths))
for _, p := range paths {
out = append(out, classifyPath(p))
}
return out
}
func mustPaths(r *http.Request, name string) []string {
paths, err := repo.SuggestPaths(r.Context(), name)
if err != nil {
return nil
}
return paths
}