feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是 「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制, 数据库默认内置 SQLite,systemd 托管。 核心设计 - 三维寻址 name@path.session,按最后一个 . 切分;session 位三态: 省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达) - 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的 标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖 - 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构, 再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载 - 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重, 且路径与用户 filename 无关,杜绝 ../ 穿越 - 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与 最终总结走免配额通道,靠上游消息 id 做幂等键而非计数 - 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过 后端 gateway/ - models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL) 共用一份 repo 层 SQL,差异集中在 internal/db - 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界) - 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文 只从客户端流向服务器一次 - 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、 附件挂载),并发下不会刷穿 前端 web/ - 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件 - 全站纯 SVG 图标,不使用 emoji - api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts 插件 plugins/opencode-mail-bridge/ - 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、 session.idle 时转发本轮总结)
This commit is contained in:
282
gateway/internal/repo/quota.go
Normal file
282
gateway/internal/repo/quota.go
Normal file
@ -0,0 +1,282 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/agentmail/gateway/internal/db"
|
||||
"github.com/agentmail/gateway/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ---------- 配额 ----------
|
||||
//
|
||||
// 配额限制的是 Agent「主动发信」的次数,不限制收信:
|
||||
// 收信是被动的,卡住收信只会让邮件凭空消失;卡住发信才能真正阻止 Agent 无限自我循环。
|
||||
//
|
||||
// agents.max_rounds = 0 表示不限额。used_rounds 单调递增,由管理员显式重置。
|
||||
|
||||
// ErrQuotaExhausted 表示 Agent 的发信配额已用尽。
|
||||
var ErrQuotaExhausted = errors.New("quota exhausted")
|
||||
|
||||
// Quota 是一个 Agent 的配额快照。
|
||||
type Quota struct {
|
||||
AgentName string `json:"agent_name"`
|
||||
Max int `json:"max_rounds"` // 0 = 不限
|
||||
Used int `json:"used_rounds"`
|
||||
Remaining int `json:"remaining"` // 不限时为 -1
|
||||
Unlimited bool `json:"unlimited"`
|
||||
}
|
||||
|
||||
func makeQuota(name string, max, used int) Quota {
|
||||
q := Quota{AgentName: name, Max: max, Used: used, Unlimited: max <= 0}
|
||||
if q.Unlimited {
|
||||
q.Remaining = -1
|
||||
return q
|
||||
}
|
||||
if r := max - used; r > 0 {
|
||||
q.Remaining = r
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// GetQuota 读取某 Agent 的配额状态。
|
||||
func GetQuota(ctx context.Context, agentName string) (Quota, error) {
|
||||
var max, used int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT max_rounds, used_rounds FROM agents WHERE agent_name = $1`, agentName).Scan(&max, &used)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
|
||||
}
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
return makeQuota(agentName, max, used), nil
|
||||
}
|
||||
|
||||
// ConsumeQuota 原子地占用一次发信配额,返回占用后的快照。
|
||||
//
|
||||
// 判断与自增必须在同一条 UPDATE 里完成(WHERE used_rounds < max_rounds),
|
||||
// 否则并发发信会双双通过检查再各自 +1,把配额刷穿。
|
||||
// 配额耗尽时返回 ErrQuotaExhausted,同时给出快照供调用方生成提示。
|
||||
func ConsumeQuota(ctx context.Context, agentName string) (Quota, error) {
|
||||
tag, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE agents SET used_rounds = used_rounds + 1
|
||||
WHERE agent_name = $1
|
||||
AND (max_rounds <= 0 OR used_rounds < max_rounds)
|
||||
`, agentName)
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
n, _ := tag.RowsAffected()
|
||||
if n == 0 {
|
||||
// 要么 Agent 不存在,要么配额用尽——用快照区分
|
||||
q, qErr := GetQuota(ctx, agentName)
|
||||
if qErr != nil {
|
||||
return Quota{}, qErr
|
||||
}
|
||||
return q, ErrQuotaExhausted
|
||||
}
|
||||
return GetQuota(ctx, agentName)
|
||||
}
|
||||
|
||||
// SetQuota 设置某 Agent 的配额上限(0 = 不限)。
|
||||
func SetQuota(ctx context.Context, agentName string, max int) (Quota, error) {
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET max_rounds = $2 WHERE agent_name = $1`, agentName, max)
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
|
||||
}
|
||||
return GetQuota(ctx, agentName)
|
||||
}
|
||||
|
||||
// ResetQuota 把已用次数归零(配额上限不变)。
|
||||
func ResetQuota(ctx context.Context, agentName string) (Quota, error) {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE agents SET used_rounds = 0 WHERE agent_name = $1`, agentName)
|
||||
if err != nil {
|
||||
return Quota{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return Quota{}, fmt.Errorf("agent %q 不存在", agentName)
|
||||
}
|
||||
return GetQuota(ctx, agentName)
|
||||
}
|
||||
|
||||
// ListQuotas 列出所有 Agent 的配额(管理员视图)。
|
||||
func ListQuotas(ctx context.Context) ([]Quota, error) {
|
||||
rows, err := db.DB.QueryContext(ctx,
|
||||
`SELECT agent_name, max_rounds, used_rounds FROM agents ORDER BY agent_name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Quota{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var max, used int
|
||||
if err := rows.Scan(&name, &max, &used); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, makeQuota(name, max, used))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 转发 ----------
|
||||
|
||||
// ForwardSource 是被转发邮件的必要信息。
|
||||
type ForwardSource struct {
|
||||
Mail *models.Mail
|
||||
Session uuid.UUID
|
||||
}
|
||||
|
||||
// LoadForwardSource 读取待转发的邮件,并校验转发者确实参与过该邮件
|
||||
//(收件人、发件人或被抄送方之一)。防止凭 mail_id 转发别人的邮件。
|
||||
func LoadForwardSource(ctx context.Context, mailID uuid.UUID, actor string) (*models.Mail, error) {
|
||||
m, err := GetMailByID(ctx, mailID)
|
||||
if err != nil {
|
||||
return nil, ErrMailNotFound
|
||||
}
|
||||
|
||||
if m.FromName == actor || m.ToName == actor {
|
||||
return m, nil
|
||||
}
|
||||
for _, cc := range m.CCList {
|
||||
if cc.Name == actor {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrForwardNotAllowed
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrMailNotFound 待转发的邮件不存在
|
||||
ErrMailNotFound = errors.New("mail not found")
|
||||
// ErrForwardNotAllowed 转发者未参与该邮件
|
||||
ErrForwardNotAllowed = errors.New("not a participant of that mail")
|
||||
)
|
||||
|
||||
// ---------- 会话级往返预算 ----------
|
||||
//
|
||||
// 配额的真实语义是「这件事值得多少个来回」——那是**任务**的属性,不是 Agent 的属性。
|
||||
// 只有 agents.max_rounds 一个全局计数器时有两个问题:
|
||||
// 1. 两个并行任务互相抢额度:给紧急任务留的份被另一条线索吃掉
|
||||
// 2. used_rounds 单调递增,跑满就得管理员手工重置才能再干活
|
||||
// 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
|
||||
//
|
||||
// **两层都要过**:会话预算 + Agent 全局配额。少了后者,Agent 自己 `.new` 开一串会话
|
||||
// 每条都是全新预算,全局上限形同虚设;少了前者,就回到抢额度的老问题。
|
||||
|
||||
// SessionBudget 是一个会话的往返预算快照。
|
||||
type SessionBudget struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
Max int `json:"max_rounds"` // 0 = 本会话不限
|
||||
Used int `json:"used_rounds"`
|
||||
Remaining int `json:"remaining"` // 不限时为 -1
|
||||
Unlimited bool `json:"unlimited"`
|
||||
}
|
||||
|
||||
func makeSessionBudget(id uuid.UUID, max, used int) SessionBudget {
|
||||
b := SessionBudget{SessionID: id, Max: max, Used: used, Unlimited: max <= 0}
|
||||
if b.Unlimited {
|
||||
b.Remaining = -1
|
||||
return b
|
||||
}
|
||||
if r := max - used; r > 0 {
|
||||
b.Remaining = r
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ErrSessionBudgetExhausted 表示该会话的往返预算已用尽。
|
||||
var ErrSessionBudgetExhausted = errors.New("session budget exhausted")
|
||||
|
||||
// GetSessionBudget 读取会话预算。
|
||||
func GetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
var max, used int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(max_rounds,0), COALESCE(used_rounds,0) FROM sessions WHERE session_id = $1`,
|
||||
id).Scan(&max, &used)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
return makeSessionBudget(id, max, used), nil
|
||||
}
|
||||
|
||||
// ConsumeSessionBudget 原子地占用会话的一次往返。
|
||||
//
|
||||
// 与 ConsumeQuota 同理:判断与自增必须在同一条 UPDATE 里(WHERE used_rounds < max_rounds),
|
||||
// 否则并发发信会双双通过检查再各自 +1,把预算刷穿。
|
||||
func ConsumeSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
tag, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) + 1
|
||||
WHERE session_id = $1
|
||||
AND (COALESCE(max_rounds,0) <= 0 OR COALESCE(used_rounds,0) < max_rounds)
|
||||
`, id)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
b, bErr := GetSessionBudget(ctx, id)
|
||||
if bErr != nil {
|
||||
return SessionBudget{}, bErr
|
||||
}
|
||||
return b, ErrSessionBudgetExhausted
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// SetSessionBudget 设置会话预算上限(0 = 不限)。
|
||||
//
|
||||
// 允许把上限调到低于已用次数:那表示「就到这里为止」,是人的合法意图,
|
||||
// 不该因为算不出正的剩余量就拒绝。此时 Remaining 为 0,下次发信即被拦。
|
||||
func SetSessionBudget(ctx context.Context, id uuid.UUID, max int) (SessionBudget, error) {
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET max_rounds = $2, updated_at = NOW() WHERE session_id = $1`, id, max)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// ResetSessionBudget 把该会话的已用次数归零(上限不变)。
|
||||
func ResetSessionBudget(ctx context.Context, id uuid.UUID) (SessionBudget, error) {
|
||||
tag, err := db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET used_rounds = 0, updated_at = NOW() WHERE session_id = $1`, id)
|
||||
if err != nil {
|
||||
return SessionBudget{}, err
|
||||
}
|
||||
if n, _ := tag.RowsAffected(); n == 0 {
|
||||
return SessionBudget{}, fmt.Errorf("会话 %s 不存在", id)
|
||||
}
|
||||
return GetSessionBudget(ctx, id)
|
||||
}
|
||||
|
||||
// RefundSessionBudget 退还一次往返。
|
||||
//
|
||||
// 会话预算先扣、Agent 全局配额后扣,全局那层拦下时必须把会话这次还回去,
|
||||
// 否则会话预算白掉一格 —— 那次往返实际上没有发生。
|
||||
func RefundSessionBudget(ctx context.Context, id uuid.UUID) {
|
||||
_, _ = db.DB.ExecContext(ctx,
|
||||
`UPDATE sessions SET used_rounds = COALESCE(used_rounds,0) - 1
|
||||
WHERE session_id = $1 AND COALESCE(used_rounds,0) > 0`, id)
|
||||
}
|
||||
Reference in New Issue
Block a user