Files
MailUI4Agents/server/internal/handler/agent_discovery.go

331 lines
12 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 (
"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 可以「看见并寻址」,但不能替人整理邮箱。
// GET /api/v1/agent/contacts
//
// 本 Agent 参与过的全部会话,每条给出可直接投递的 `address`。
// 与人类侧 `/contacts` 同源(`repo.ListContactsFor`scope 固定为自己。
func AgentListContacts(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
archived := r.URL.Query().Get("archived") == "true"
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"))
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
}
if path == "" {
paths, _ := repo.SuggestPaths(r.Context(), name)
JSON(w, http.StatusOK, map[string]any{
"kind": "path",
"suggestions": emptySlice(paths),
})
return
}
// 可见性传自己的名字:只提示自己参与过的会话。
// 传空会把别人的私下线索也列出来,那是越权。
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 参与过该会话」。抄送协作要靠它回答「谁已经回了、谁还没回」。
func AgentGetMailThread(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
return repo.AgentCanAccessSession(r.Context(), agentName, sid)
})
}
// 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
}
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
}
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, mail.SessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权访问该邮件")
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
}
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to check permission")
return
}
if !allowed {
Error(w, http.StatusForbidden, "无权访问该会话")
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
}