Files
MailUI4Agents/gateway/internal/models/models.go
JianFeeeee 784192d8c4 Agent→Agent 不自动转发 + 提示词区分新活/回复/补投
## 设计规则:Agent 之间不自动转发

自动转发存在的理由是「人不该等模型记得调 send_mail」—— 收件方是人时这是
纯收益。**收件方是另一个 Agent 时这个理由不成立,而且有害**:双方的插件都
会自动回一封,于是两个模型都以为「我只要把话说完就行」,实际在持续互相唤醒。
生产实测 pi 与 dsh 客套 6 轮直到撞上连续 relay 跳数上限。

规则现在写死在共用模块 `lib/relay-policy.js`(三平台逐字节相同):
- `autoRelayDecision` — 插件该不该替模型开口
- `replyInstruction` — 提示词怎么跟模型说(人类 vs Agent 各一套措辞)
- `inboundHeadline` — 进来的是新活、回复、还是补投

`from_human` 缺失时保守按 Agent 处理:宁可让模型多调一次 send_mail,
也不能承诺一个不会发生的自动回信让发件方白等。

## Gateway 侧:`in_reply_to` + `from_human`

- `notify.Mail` 新增 `ParentMailID`(非空 = 这是对收件方某封信的回复)
- `notify.Mail` 新增 `FromHuman`(走 `repo.IsHumanUser`)
- SSE payload 里叫 `in_reply_to` / `from_human`
- 四个调用点全部传入:handler/mail(转发后产出的邮件,parentMailID 从
  resolveTarget 取)、handler/me(同理)、handler/forward(传空串,
  因为对收件方而言那封原邮件不在它的线索里)、scheduler/calendar(传空串)
- `ListInbox` 的 SELECT 加 `EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name)`
  → `models.Mail.FromHuman`,让补拉路径也有这个信号

## 提示词分流

三种处境各一套标题:
- 新活(人类):「你收到一封新邮件」+ 「回信不用你自己发:…」
- 新活(Agent):「你收到一封新邮件(对方是一个 Agent)」+ 「插件不会替你
  回信。需要回复时你必须自己调 send_mail…请先判断是否真的需要回复」
- 回复到了:「你上一封信的回复到了。**这不是新任务**。」
- 补投:在标题里说明「离线期间积压」

## homeagent 特殊处理

Go 插件不能直接 `import('../lib/relay-policy.js')`,因此新增 `relay_policy.go`
(Go 对应物)+ `relay_policy_test.go`(11 例,逐条对齐 Node 侧判据)。
`sseLoop` / `catchUp` 两条路径都接上。

## `mailEvent` 命名类型

homeagent 的 SSE 事件解析 / handleNewMail / handlePermissionDecision 三处
原来各写一遍匿名 struct(字段列表几乎相同),加 `from_human` / `in_reply_to`
时漏改一处 → 编译报错但错误信息是两串几乎相同的字段列表,极难定位。
提成 `mailEvent` 命名类型:一处改、三处跟着走。

## 测试

- `lib/relay-policy.test.mjs`(Node)16 例:含「replyInstruction 与
  autoRelayDecision 不得互相矛盾」「Agent 来信的标题要点名且回复要明确反对」
- `relay_policy_test.go`(Go)11 例:逐条对齐 Node 侧
- `turn.test.mjs` +3 例:from_human 缺失时按 Agent 处理 / Agent 来信时改口 /
  回复到了说「不是新任务」;删掉两条旧的「必定自动转发」断言
- 共用脚本 `check-shared-libs.sh` +1 个文件(relay-policy)
- pi 288 / dsh 241 / opencode 217 / homeagent 14 / gateway 8 包全绿
2026-09-04 23:52:52 +08:00

271 lines
10 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 models
import (
"strings"
"time"
"github.com/google/uuid"
)
// Agent 代表一个已注册的 Agent 实例
type Agent struct {
ID uuid.UUID `json:"agent_id"`
Name string `json:"agent_name"`
Secret string `json:"-"`
HostURL string `json:"host_url"`
Workspaces []Workspace `json:"workspaces"`
Platform string `json:"platform"`
Status string `json:"status"`
// DefaultRounds 是派给该 Agent 的新任务默认多少个来回0 = 不限)。
// 真正的额度在每条会话上sessions.max_rounds这里只是默认值。
DefaultRounds int `json:"default_rounds"`
// UsedRounds 是累计发信数,纯统计,不拦请求。
// 它原本是「终身额度」——那种额度跑满要人工重置才能再干活,
// 而 Agent 是长期在线的,所以已降级为观测数据。
UsedRounds int `json:"used_rounds"`
LastSeen *time.Time `json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
}
// Workspace 是 Agent 管理的项目工作区
type Workspace struct {
Name string `json:"name"`
Path string `json:"path"`
}
// Session 是有明确边界的任务会话
type Session struct {
ID uuid.UUID `json:"session_id"`
Alias *string `json:"session_alias"`
FromAgent string `json:"from_agent"`
Subject string `json:"subject"`
Status string `json:"status"`
OwnerUserID *uuid.UUID `json:"owner_user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MailCount int `json:"mail_count,omitempty"`
// RenameDismissed 是用户驳回过的改名提议。
// 记下来才能让提示条不再反复弹同一个建议。
RenameDismissed string `json:"rename_dismissed,omitempty"`
// AliasSource 记录别名是谁定的:
// platform = Agent 平台自动同步来的,后续同步可以覆盖
// manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
// 没有这个区分,平台下一次 session.updated 会把人刚定的名字冲掉。
AliasSource string `json:"alias_source,omitempty"`
// MaxRounds/UsedRounds 是本任务的往返预算0 = 本会话不限)。
// 配额的语义是「这件事值得多少个来回」,那是任务的属性而非 Agent 的属性,
// 所以在写信时给、在对话页里随时调。
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
}
// User 是人类用户(多用户账号体系)
type User struct {
ID uuid.UUID `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
PasswordHash string `json:"-"`
Role string `json:"role"` // admin / user
Status string `json:"status"` // active / disabled
CreatedAt time.Time `json:"created_at"`
LastLogin *time.Time `json:"last_login"`
// 权限边界:空切片 = 不限
AllowedAgents []string `json:"allowed_agents"`
AllowedPaths []string `json:"allowed_paths"`
}
// IsAdmin 判断是否管理员
func (u User) IsAdmin() bool { return u.Role == "admin" }
// CanUseAgent 判断用户是否可向指定 Agent 发信
// 空白名单(或为空) = 不限;管理员不受限;收件方是人类用户时不走此限制
func (u User) CanUseAgent(agentName string) bool {
if u.IsAdmin() || len(u.AllowedAgents) == 0 {
return true
}
for _, a := range u.AllowedAgents {
if a == agentName {
return true
}
}
return false
}
// CanUsePath 判断用户是否可访问指定工作区。
// 空白名单 = 不限;管理员不受限;空 path人类地址总是允许。
// 匹配规则:完全相等,或白名单项作为目录前缀(/program 允许 /program/sub
func (u User) CanUsePath(path string) bool {
if u.IsAdmin() || len(u.AllowedPaths) == 0 || path == "" {
return true
}
for _, p := range u.AllowedPaths {
if p == "" {
continue
}
if path == p {
return true
}
prefix := p
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
// Mail 是会话中的一封邮件
type Mail struct {
ID uuid.UUID `json:"mail_id"`
SessionID uuid.UUID `json:"session_id"`
ParentMailID *uuid.UUID `json:"parent_mail_id"`
FromName string `json:"from_name"`
FromWorkspace string `json:"from_workspace"`
ToName string `json:"to_name"`
ToWorkspace string `json:"to_workspace"`
CCList []Address `json:"cc_list"`
Subject string `json:"subject"`
Body string `json:"body"`
MailType string `json:"mail_type"`
PermOptions []string `json:"permission_options,omitempty"`
PermResult string `json:"permission_result,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
HopLimit int `json:"hop_limit"`
SessionAlias string `json:"session_alias,omitempty"`
// SessionWorkspace 是**这条会话**的工作目录sessions.workspace
//
// 为什么不能用 FromWorkspace / ToWorkspace 代替:
// - 人 → Agentto_workspace 是真路径from_workspace 为空(人没有工作目录)
// - Agent → 人to_workspace 为空,而 **from_workspace 存的是 Agent 名
// 而不是路径**(历史遗留,见 db/migrate.go 的 sessions.workspace 注释)
//
// 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取 ——
// 前端要靠它拼出 `name@path.alias` 这个可投递地址(界面上曾显示成
// `dsh@dsh`,就是拿 from_workspace 当路径拼出来的)。
SessionWorkspace string `json:"session_workspace,omitempty"`
BodyPreview string `json:"body_preview,omitempty"`
// Attachments 仅在读取单封邮件/会话线程时填充;列表接口为省带宽留空
Attachments []Attachment `json:"attachments,omitempty"`
// RenameAlias / RenameReason 是 Agent 在本封正文里提议的新会话别名。
// 存在邮件上而非会话上:邮件是不可篡改的历史记录,
// 「谁在哪一封里提了什么」应当留痕。
RenameAlias string `json:"rename_alias,omitempty"`
RenameReason string `json:"rename_reason,omitempty"`
// FromHuman 表示发件方是人类用户而不是 Agent。
//
// 插件靠它判「要不要自动转发本轮结论」Agent 之间不自动回,
// 否则两边都以为对方的插件会代它开口,持续互相唤醒(生产实测 6 轮)。
//
// **补拉路径必须有它**SSE 事件里叫 `from_human`,而插件重启后走
// `GET /mail/inbox` 补投 —— 那条路径上没有这个字段的话,补投的邮件会被
// 保守当成 Agent 来信,于是人发的那封失去自动回信。
FromHuman bool `json:"from_human"`
}
// PermissionRequest 是 Agent 向人类发起的权限请求
type PermissionRequest struct {
ID uuid.UUID `json:"request_id"`
MailID uuid.UUID `json:"mail_id"`
SessionID uuid.UUID `json:"session_id"`
AgentName string `json:"agent_name"`
Question string `json:"question"`
Options []string `json:"options"`
Context string `json:"context"`
Result *string `json:"result"`
DecidedAt *time.Time `json:"decided_at"`
CreatedAt time.Time `json:"created_at"`
}
// SSE 事件类型
const (
EventNewMail = "new_mail"
EventPermissionDecision = "permission_decision"
EventSessionUpdate = "session_update"
EventAgentOnline = "agent_online"
)
// ---------- 密钥认证 ----------
// 密钥类型:签发时决定其生命周期
const (
// KeyPermanent 永不过期,可重复使用(正式部署的 Agent 用这个)
KeyPermanent = "permanent"
// KeyOneTime 首次验证后即失效(用于把 Agent 首次接入的窗口压到最小)
KeyOneTime = "one_time"
// KeyTimed 到 ExpiresAt 之后失效
KeyTimed = "timed"
)
// ValidKeyType 判断密钥类型是否受支持
func ValidKeyType(t string) bool {
return t == KeyPermanent || t == KeyOneTime || t == KeyTimed
}
// AgentKey 是管理员签发的 Agent 接入密钥。
// AgentName 为空表示「待绑定」——密钥有效但还没指定属于哪个 Agent
// 首次注册时由注册请求里的 name 落定。
type AgentKey struct {
ID uuid.UUID `json:"key_id"`
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
TokenHint string `json:"token_hint"` // 前 8 位 + 省略号,用于列表展示
AgentName *string `json:"agent_name"`
KeyType string `json:"key_type"`
Label string `json:"label"`
ExpiresAt *time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at"`
CreatedBy *uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
// UserKey 是用户自助签发的客户端连接密钥,只能用于 /me/* 人类邮箱接口。
type UserKey struct {
ID uuid.UUID `json:"key_id"`
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
TokenHint string `json:"token_hint"`
UserID uuid.UUID `json:"user_id"`
Label string `json:"label"`
KeyType string `json:"key_type"`
ExpiresAt *time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at"`
CreatedAt time.Time `json:"created_at"`
}
// TokenHint 返回密钥的展示形式:只露前 8 位。
// 密钥全文仅在创建响应里出现一次,之后任何列表接口都只给 hint。
func TokenHint(token string) string {
if len(token) <= 8 {
return token
}
return token[:8] + "…"
}
// ---------- 附件 ----------
// Attachment 是一封邮件的附件元数据。文件内容存磁盘,按 sha256 内容寻址。
//
// MailID 为空表示「已上传、尚未挂到邮件上」:上传与发信是两步操作
// Agent 侧工具走 JSON无法在发信请求里带 multipart中间态必须允许存在。
type Attachment struct {
ID uuid.UUID `json:"attachment_id"`
MailID *uuid.UUID `json:"mail_id"`
Uploader string `json:"uploader"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
CreatedAt time.Time `json:"created_at"`
}