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 各条边界)。
This commit is contained in:
2026-09-04 06:28:33 +08:00
parent 069bf03ae2
commit 2ae4df0e98
5 changed files with 750 additions and 16 deletions

View File

@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strconv"
"strings"
"github.com/agentmail/gateway/internal/middleware"
@ -88,7 +89,7 @@ func AdminSetAgentModels(w http.ResponseWriter, r *http.Request) {
}
if len(req.Models) > maxAllowedModels {
Error(w, http.StatusBadRequest,
"选定的模型过多(上限 "+itoa(maxAllowedModels)+" 个)")
"选定的模型过多(上限 "+strconv.Itoa(maxAllowedModels)+" 个)")
return
}
@ -113,18 +114,3 @@ func AdminSetAgentModels(w http.ResponseWriter, r *http.Request) {
// 降级尝试是串行的:选 50 个意味着最坏情况下一封邮件要等 50 次模型调用超时。
// 十个已经足够表达「主力 + 几个备选」。
const maxAllowedModels = 10
// itoa 避免为一个数字引入 strconv 导入(本文件只此一处用到)。
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}