Files
MailUI4Agents/gateway/internal/handler/models_scope.go
JianFeeeee 2ae4df0e98 feat(agent-calendar): Agent 侧日历端点(可写,只能动自己建的)
在这之前 /calendar/* 全挂在 UserAuth 后面,Agent 密钥一律 401。
于是「明天九点提醒我看 CI」只能靠插件进程里的 setTimeout —— 进程一重启
定时器就消失,那条提醒静默不见且无处留痕。放进 Gateway 后由数据库与
调度器保证:插件重启、Agent 换机器、甚至换平台都不影响。

更重要的是它让**跨 Agent 的任务交接**成立:模型可以给 dsh 设一条
「明天交周报」的提醒。这件事模型自己做不到 —— 它没法让另一个进程在未来
某刻醒来。

三处收紧:

**1. 只看/只改自己建的。**
别人的日程里可能有它无权知道的会议与地址。不存在与不属于我都回 **404**
而非 403 —— 后者会泄漏「这个 id 存在」,让 Agent 能枚举出别人有多少条日程。

**2. 不能设给人类。**
理由是投递通道不对等。Agent 之间的提醒是任务信号:收到就干活、干完回信。
发给人的提醒是打扰 —— 进收件箱、触发未读徽标,而人**无法回信让它停下**
(提醒是日历实体不是对话),只能去 WebUI 里找出那条事件删掉。
一个 Agent 建条「每 10 分钟提醒 jianf 检查进度」的代价远大于收益
(它其实可以直接 send_mail)。

**3. 速率 + 总量双闸。**
速率(20 次/小时,独立桶)压住「短时间暴建」,压不住「每小时建 19 条、
连建一周」—— 而日历事件是**长效**的,一条每日重复提醒会一直发下去。
攒下 300 条之后即使停止建新的,每天仍有 300 封提醒涌出来。
所以加 maxActiveEventsPerAgent=50,并在列表响应里回传 active_limit:
模型看到 42/50 就知道该清理,只在撞墙时才报错等于让它一直蒙在鼓里。

其他设计点:
- **PUT 是部分更新**(人类端点是整体替换)。调用方是模型 —— 要求它每次
  回传全部字段,漏一个就把提醒正文或收件人清空,而那种破坏没有任何报错。
  全部字段用 *T,nil = 没传 = 保持原值。
- 一次性事件设在过去拦掉(会立刻触发,几乎总是时区或年份写错);
  重复事件不拦 —— 「每天 9 点」从昨天开始是合理写法。
- 收件人省略时默认给自己:最常见的用法,每次要求写出自己的名字只会让
  模型忘记然后拿到 400。

顺带:两个 handler 文件各写了一份手写 itoa(models_scope 那份还漏了负数),
统一成 strconv.Itoa。

测试 repo 9 例:创建者过滤、收件人≠创建者不返回、总量计数、
速率桶与会话桶独立、按 Agent 隔离、失败归还名额。
生产实测 11 项全过(含 403/400/404/429 各条边界)。
2026-09-04 06:28:33 +08:00

117 lines
4.2 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"
"strconv"
"strings"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/repo"
"github.com/go-chi/chi/v5"
)
// ---------- 邮件场景下的可用模型 ----------
//
// GET /agent/models/allowed 读取被允许的模型Agent 凭证)
// GET /admin/agents/{name}/models 管理员读目录 + 已选
// PUT /admin/agents/{name}/models 管理员保存选择与优先级
//
// **目录上报走心跳**(见 agents.go 的 heartbeatRequest.Models不另设端点
// 模型清单会在运行中变(换 provider 配置、上游上下线、换 API key
// 心跳本来就是 30 秒一次的现成通道。另设一个 POST 等于给「目录是谁写的」
// 这个问题留两个答案,排查时要同时看两处。
//
// 生效的模型范围同样随心跳响应回传allowed_models因此插件通常不需要调
// 下面这个 GET —— 它是给非插件的第三方客户端(没有心跳循环)与排查用的。
// GET /api/v1/agent/models/allowed —— 插件读取被允许的模型
//
// 返回按优先级排序的列表。空列表表示**不限定**,插件应回退到平台自己的默认模型
// —— 与「一个都不许用」不同,后者等于让 Agent 彻底哑掉,不该是一次误配的后果。
func GetAllowedModels(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
models, err := repo.ListAllowedModels(r.Context(), agentName)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list allowed models")
return
}
JSON(w, http.StatusOK, map[string]any{
"models": models,
// unrestricted 明确表达「没配 = 不限」,省得插件自己去判断空数组的含义
"unrestricted": len(models) == 0,
})
}
// GET /api/v1/admin/agents/{name}/models —— 管理员读目录(带已选标记)
func AdminListAgentModels(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(chi.URLParam(r, "name"))
if name == "" {
Error(w, http.StatusBadRequest, "Missing agent name")
return
}
catalog, err := repo.ListModelCatalog(r.Context(), name)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list model catalog")
return
}
// 已选但已不在目录里的模型要单独给出来:平台可能临时下线了某个模型,
// 界面上不显示的话管理员会以为自己没选过它,而它其实还在被插件尝试。
stale, err := repo.ListStaleAllowedModels(r.Context(), name)
if err != nil {
stale = []repo.ModelRef{}
}
JSON(w, http.StatusOK, map[string]any{
"agent_name": name,
"catalog": catalog,
"stale": stale,
})
}
// PUT /api/v1/admin/agents/{name}/models —— 管理员保存选择
//
// 入参顺序即优先级rank。插件按这个顺序逐个尝试全部失败才回一封失败邮件。
func AdminSetAgentModels(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(chi.URLParam(r, "name"))
if name == "" {
Error(w, http.StatusBadRequest, "Missing agent name")
return
}
var req struct {
Models []repo.ModelRef `json:"models"`
}
if !DecodeBody(w, r, &req) {
return
}
if len(req.Models) > maxAllowedModels {
Error(w, http.StatusBadRequest,
"选定的模型过多(上限 "+strconv.Itoa(maxAllowedModels)+" 个)")
return
}
if err := repo.SetAllowedModels(r.Context(), name, req.Models); err != nil {
Error(w, http.StatusInternalServerError, "Failed to save allowed models")
return
}
// 回传保存后的实际结果而不是回显入参repo 层会跳过重复项与空字段,
// 回显入参会让前端以为那些也存下来了。
saved, err := repo.ListAllowedModels(r.Context(), name)
if err != nil {
saved = []repo.ModelRef{}
}
JSON(w, http.StatusOK, map[string]any{
"status": "saved",
"models": saved,
})
}
// maxAllowedModels 限制管理员能选多少个模型。
//
// 降级尝试是串行的:选 50 个意味着最坏情况下一封邮件要等 50 次模型调用超时。
// 十个已经足够表达「主力 + 几个备选」。
const maxAllowedModels = 10