feat(homeagent): 四个日程工具 + 工具计数不再硬编码

create_schedule / list_schedules / update_schedule / delete_schedule,
插件从 11 个工具变 15 个。

时间格式是这组工具最容易出错的地方,parseEventTime 接受三种形态:
完整 RFC3339(推荐)、无时区(按本机时区解释)、只有日期(当天 9 点,
比零点有用 —— 零点的提醒没人看)。

**刻意不接受自然语言**(「明天九点」):解析它需要知道模型以为今天是哪天,
而它上下文里那个日期常常是错的 —— 一个静默偏移一天的提醒比报错糟得多。
解析失败的报文里带上当前本机时间,让它能自己算。

其他细节:
- recipients 用逗号分隔的 string 而非数组:几个平台对数组参数的 schema
  支持不一致,而逗号分隔在所有平台上都是普通 string。中英文逗号都收 ——
  中文输入法下打出「,」是常态。
- argInt 同时收 float64 与字符串:"30" 带引号会让「提前 30 分钟」静默失效。
- update 的工具描述里写明「只传要改的字段」:模型的默认倾向是回传它记得的
  全部字段,记错一个就覆盖掉原有的提醒正文或收件方。
- list 在接近上限(>=75%)时主动提示清理,而不是等撞墙 —— 撞墙那一刻
  模型往往正在做别的事,没有余裕去清理。
- scheduleEvent.effectiveRecipients() 与服务端同一条兜底链:只读
  recipients 会让旧数据显示成「发给:」后面空白。

**工具计数从 RegisterTool 的调用数派生,不硬编码。**
之前写死 13 而实际注册 11 —— 排查「工具没生效」时日志说 13、平台说 11,
两个数字都不可信,白花了一轮时间。加工具时忘改常量是必然的,
所以让它没有机会写错。

plugin.go 另补 put/delete 两个 HTTP 辅助(原来只有 get/post)。

已部署验证:homed 日志「注册完成(15 个工具 + 1 个输出通道)」,
模型实际调用 list_schedules 成功。
This commit is contained in:
2026-09-04 06:28:55 +08:00
parent 2ae4df0e98
commit 5b76ac5c57
2 changed files with 622 additions and 12 deletions

View File

@ -168,7 +168,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
// ─── 注册工具 ───
s.RegisterTool("read_inbox", sdk.ToolDef{
// registerTool 包一层只为计数:日志里的工具数必须与实际注册数一致。
registeredToolCount := 0
registerTool := func(name string, def sdk.ToolDef, h func(map[string]interface{}) (interface{}, error)) {
registeredToolCount++
s.RegisterTool(name, def, h)
}
registerTool("read_inbox", sdk.ToolDef{
Name: "read_inbox",
Description: "查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。每封含 mail_id、发件人、主题、正文与附件清单。",
Parameters: map[string]interface{}{
@ -180,13 +187,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleReadInbox)
s.RegisterTool("read_mail", sdk.ToolDef{
registerTool("read_mail", sdk.ToolDef{
Name: "read_mail",
Description: "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。",
Parameters: oneStringParam("mail_id", "邮件 ID", true),
}, p.handleReadMail)
s.RegisterTool("send_mail", sdk.ToolDef{
registerTool("send_mail", sdk.ToolDef{
Name: "send_mail",
Description: "发送邮件。三维地址 name@path.session。回复来信请传 reply_to。",
Parameters: map[string]interface{}{
@ -202,7 +209,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleSendMail)
s.RegisterTool("forward_mail", sdk.ToolDef{
registerTool("forward_mail", sdk.ToolDef{
Name: "forward_mail",
Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。",
Parameters: map[string]interface{}{
@ -219,7 +226,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleForwardMail)
s.RegisterTool("upload_attachment", sdk.ToolDef{
registerTool("upload_attachment", sdk.ToolDef{
Name: "upload_attachment",
Description: "上传本地文件作为邮件附件。返回 attachment_id填入 send_mail 的 attachments 字段。",
Parameters: map[string]interface{}{
@ -231,7 +238,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleUploadAttachment)
s.RegisterTool("download_attachment", sdk.ToolDef{
registerTool("download_attachment", sdk.ToolDef{
Name: "download_attachment",
Description: "下载附件到本地。",
Parameters: map[string]interface{}{
@ -244,7 +251,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleDownloadAttachment)
s.RegisterTool("suggest_address", sdk.ToolDef{
registerTool("suggest_address", sdk.ToolDef{
Name: "suggest_address",
Description: "查询可用收件人地址。不带参数给候选收件人;带 name 给工作目录name+path 都带则给会话别名。发信前应先用它确认地址。",
Parameters: map[string]interface{}{
@ -256,19 +263,19 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleSuggestAddress)
s.RegisterTool("list_contacts", sdk.ToolDef{
registerTool("list_contacts", sdk.ToolDef{
Name: "list_contacts",
Description: "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。",
Parameters: oneStringParam("limit", "最多列出多少条,默认 20", false),
}, p.handleListContacts)
s.RegisterTool("session_participants", sdk.ToolDef{
registerTool("session_participants", sdk.ToolDef{
Name: "session_participants",
Description: "列出某条会话的全部参与方与各自的可投递地址,并标出谁还没回应。",
Parameters: oneStringParam("session_id", "会话 ID", true),
}, p.handleSessionParticipants)
s.RegisterTool("read_thread", sdk.ToolDef{
registerTool("read_thread", sdk.ToolDef{
Name: "read_thread",
Description: "查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。",
Parameters: map[string]interface{}{
@ -281,7 +288,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleReadThread)
s.RegisterTool("connect_to_server", sdk.ToolDef{
registerTool("connect_to_server", sdk.ToolDef{
Name: "connect_to_server",
Description: "连接到 AgentMail Gateway登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。",
Parameters: map[string]interface{}{
@ -293,12 +300,91 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleConnectToServer)
// ─── 日程 / 待办 ───
//
// 这一组的价值不在「记事」而在**跨进程的时间**:模型自己没法让一个进程
// 在未来某刻醒来,插件里的定时器也随 homed 重启一起消失。交给 Gateway
// 之后由数据库与调度器保证,到点发一封邮件把收件方唤起来。
registerTool("create_schedule", sdk.ToolDef{
Name: "create_schedule",
Description: "创建一条日程提醒。到点时 Gateway 会发一封邮件给收件方(默认是你自己)," +
"因此它能跨进程重启生效 —— 比你自己记着时间可靠。" +
"典型用法:稍后检查某件事、提醒另一个 Agent 交东西、周期性巡检。" +
scheduleFieldsHint,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"title": map[string]interface{}{"type": "string", "description": "日程标题,会成为提醒邮件的主题"},
"event_time": map[string]interface{}{"type": "string", "description": "事件时间RFC3339 带时区2026-09-10T09:00:00+08:00。不支持「明天」这类相对表述"},
"description": map[string]interface{}{"type": "string", "description": "补充说明,可作为 {description} 变量填入提醒正文"},
"reminder_text": map[string]interface{}{"type": "string", "description": "提醒邮件正文模板,支持 {title} {time} {description} 三个变量。留空用默认模板"},
"remind_before": map[string]interface{}{"type": "number", "description": "提前多少分钟提醒,默认 0到点才提醒"},
"recurrence": map[string]interface{}{"type": "string", "description": "重复规则,默认 none。农历用 lunar_monthly / lunar_yearly"},
"recurrence_end": map[string]interface{}{"type": "string", "description": "重复到什么时候为止,留空 = 一直重复"},
"recipients": map[string]interface{}{"type": "string", "description": "收件方,逗号分隔的三维地址(如 dsh,pi@/home/x。省略 = 发给自己。不能设给人类用户 —— 要通知人请直接 send_mail"},
"delivery_mode": map[string]interface{}{"type": "string", "description": "多收件人时separate默认各自独立会话互不可见或 together首个为主收件人其余抄送共享一条线索"},
},
"required": []string{"title", "event_time"},
},
}, p.handleCreateSchedule)
registerTool("list_schedules", sdk.ToolDef{
Name: "list_schedules",
Description: "列出你建的日程(只能看到自己建的)。返回每条的 ID、时间、重复规则与收件方。" +
"改时间或删除前先用它查 ID。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"status": map[string]interface{}{"type": "string", "description": "过滤 active默认/paused/cancelled/all"},
"from": map[string]interface{}{"type": "string", "description": "起始时间,默认昨天"},
"to": map[string]interface{}{"type": "string", "description": "结束时间,默认三个月后"},
},
},
}, p.handleListSchedules)
registerTool("update_schedule", sdk.ToolDef{
Name: "update_schedule",
Description: "改一条日程。**只传要改的字段**,省略的保持原值 —— 不要回传全部字段," +
"记错一个就会覆盖掉原有的提醒正文或收件方。" +
"暂停提醒传 status=paused。" + scheduleFieldsHint,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"event_id": map[string]interface{}{"type": "string", "description": "要改哪条(用 list_schedules 查)"},
"title": map[string]interface{}{"type": "string", "description": "新标题"},
"event_time": map[string]interface{}{"type": "string", "description": "新时间RFC3339 带时区"},
"description": map[string]interface{}{"type": "string", "description": "新说明"},
"reminder_text": map[string]interface{}{"type": "string", "description": "新的提醒正文模板"},
"remind_before": map[string]interface{}{"type": "number", "description": "新的提前分钟数"},
"recurrence": map[string]interface{}{"type": "string", "description": "新的重复规则"},
"recurrence_end": map[string]interface{}{"type": "string", "description": "新的重复终止时间"},
"recipients": map[string]interface{}{"type": "string", "description": "新收件方,逗号分隔。不能改成空"},
"delivery_mode": map[string]interface{}{"type": "string", "description": "separate 或 together"},
"status": map[string]interface{}{"type": "string", "description": "active / paused暂停提醒/ cancelled"},
},
"required": []string{"event_id"},
},
}, p.handleUpdateSchedule)
registerTool("delete_schedule", sdk.ToolDef{
Name: "delete_schedule",
Description: "删掉一条日程,之后不再提醒。只想临时停掉请用 update_schedule 传 status=paused。",
Parameters: oneStringParam("event_id", "要删哪条(用 list_schedules 查)", true),
}, p.handleDeleteSchedule)
// 注册输出通道
s.RegisterOutputChannel("homeagent", sdk.CapText|sdk.CapFile,
"发送邮件。meta JSON 格式:{to, subject, reply_to}type: text",
sdk.ChannelDef{}, p.handleOutputChannel)
log.Printf("[homeagent-mail-bridge] 注册完成(%d 个工具),等待 Gateway SSE", 13)
// 数量从 RegisterTool 的调用数派生,不硬编码。
//
// 之前这里写死 13而实际注册的是 11 —— 排查「工具没生效」时日志说 13、
// 平台说 11两个数字都不可信白花了一轮时间。加工具时忘改常量是必然的
// 所以让它没有机会写错。
log.Printf("[homeagent-mail-bridge] 注册完成(%d 个工具 + 1 个输出通道),等待 Gateway SSE",
registeredToolCount)
// 启动心跳 + SSE后台 goroutine
go p.heartbeatLoop()
@ -1048,6 +1134,58 @@ func (p *Plugin) post(path string, payload interface{}, out interface{}) error {
return nil
}
func (p *Plugin) put(path string, payload interface{}, out interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
url := path
if !strings.HasPrefix(path, "http") {
url = p.gwURL + "/api/v1" + path
}
req, err := http.NewRequest("PUT", url, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.key)
resp, err := p.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("PUT %s HTTP %d: %s", path, resp.StatusCode, string(body))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
func (p *Plugin) delete(path string) error {
url := path
if !strings.HasPrefix(path, "http") {
url = p.gwURL + "/api/v1" + path
}
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+p.key)
resp, err := p.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("DELETE %s HTTP %d: %s", path, resp.StatusCode, string(body))
}
return nil
}
func (p *Plugin) markRead(ids []string) {
data, _ := json.Marshal(map[string]interface{}{"mail_ids": ids})
req, err := http.NewRequest("POST", p.gwURL+"/api/v1/mail/read", bytes.NewReader(data))

View File

@ -0,0 +1,472 @@
package main
import (
"fmt"
"strconv"
"strings"
"time"
)
// 日程与待办工具。
//
// # 为什么要有这一组
//
// 在这之前「明天九点提醒我看 CI 结果」只能靠插件进程里的定时器 ——
// homed 一重启定时器就消失,那条提醒静默不见且无处留痕。
// 交给 Gateway 之后由数据库与调度器保证:插件重启、换机器都不影响。
//
// 更重要的是它让**跨 Agent 的任务交接**成立:模型可以给 dsh 设一条
// 「明天交周报」的提醒,到点由 Gateway 发一封邮件把 dsh 唤起来。
// 这件事模型自己做不到 —— 它没法让另一个进程在未来某刻醒来。
//
// # 时间格式是这组工具最容易出错的地方
//
// 模型写时间有三种典型错法:只给「明天九点」这样的自然语言、给了
// RFC3339 但忘了时区、年份写成过去。前两种在这里被 parseEventTime 兜住,
// 第三种由服务端拦(一次性日程设在过去会立刻触发,几乎总是笔误)。
//
// 刻意**不**接受自然语言:解析「下周三」需要知道模型以为的今天是哪天,
// 而它的上下文里那个日期可能是错的。要求显式时间戳会让它先去查当前时间,
// 那一步本身就消除了大部分歧义。
// scheduleFieldsHint 是几个工具共用的参数说明。
//
// 抽出来是因为这段话必须逐字一致:模型看到 create 与 update 的描述不同时,
// 会以为两者语义有别,于是在 update 里重复传所有字段(那正是我们想避免的)。
const scheduleFieldsHint = "时间用 RFC3339如 2026-09-10T09:00:00+08:00必须带时区偏移。" +
"recurrence 可选 none/daily/weekly/monthly/yearly/lunar_monthly/lunar_yearly" +
"其中 lunar_* 按农历推进(每农历月同一日 / 每农历年同月同日,适合农历生日与祭日)。"
// handleCreateSchedule 建一条日程提醒。
func (p *Plugin) handleCreateSchedule(args map[string]interface{}) (interface{}, error) {
title := strings.TrimSpace(argString(args, "title"))
if title == "" {
return nil, fmt.Errorf("必须给 title")
}
when, err := parseEventTime(argString(args, "event_time"))
if err != nil {
return nil, err
}
payload := map[string]interface{}{
"title": title,
"event_time": when.Format(time.RFC3339),
}
if v := strings.TrimSpace(argString(args, "description")); v != "" {
payload["description"] = v
}
if v := strings.TrimSpace(argString(args, "reminder_text")); v != "" {
payload["reminder_text"] = v
}
if v := strings.TrimSpace(argString(args, "recurrence")); v != "" {
payload["recurrence"] = v
}
if n, ok := argInt(args, "remind_before"); ok {
payload["remind_before"] = n
}
// 收件人:逗号分隔的地址列表。省略 = 发给自己(最常见的用法,
// 每次都要求写出自己的名字只会让模型忘记然后拿到 400
recipients := parseRecipients(argString(args, "recipients"))
if len(recipients) > 0 {
payload["recipients"] = recipients
}
if v := strings.TrimSpace(argString(args, "delivery_mode")); v != "" {
payload["delivery_mode"] = v
}
if v := strings.TrimSpace(argString(args, "recurrence_end")); v != "" {
end, eErr := parseEventTime(v)
if eErr != nil {
return nil, fmt.Errorf("recurrence_end 解析失败:%w", eErr)
}
payload["recurrence_end"] = end.Format(time.RFC3339)
}
var ev scheduleEvent
if err := p.post("/agent/calendar/events", payload, &ev); err != nil {
return nil, err
}
lines := []string{
fmt.Sprintf("日程已创建:%s", ev.Title),
fmt.Sprintf(" 时间:%s", formatEventWhen(ev)),
fmt.Sprintf(" 提醒发给:%s", strings.Join(ev.effectiveRecipients(), ", ")),
}
if ev.Recurrence != "" && ev.Recurrence != "none" {
lines = append(lines, fmt.Sprintf(" 重复:%s", describeRecurrence(ev.Recurrence)))
}
if len(ev.effectiveRecipients()) > 1 {
lines = append(lines, fmt.Sprintf(" 投递方式:%s", describeDelivery(ev.DeliveryMode)))
}
// 回传 id改时间或删掉都要用它不给的话模型只能再 list 一次
lines = append(lines, fmt.Sprintf(" 日程 ID%s", ev.EventID))
return textResult(strings.Join(lines, "\n")), nil
}
// handleListSchedules 列出自己建的日程。
func (p *Plugin) handleListSchedules(args map[string]interface{}) (interface{}, error) {
q := "?status=" + argStringOr(args, "status", "active")
if v := strings.TrimSpace(argString(args, "from")); v != "" {
if t, err := parseEventTime(v); err == nil {
q += "&from=" + t.Format(time.RFC3339)
}
}
if v := strings.TrimSpace(argString(args, "to")); v != "" {
if t, err := parseEventTime(v); err == nil {
q += "&to=" + t.Format(time.RFC3339)
}
}
var res struct {
Events []scheduleEvent `json:"events"`
ActiveLimit int `json:"active_limit"`
}
if err := p.get(p.gwURL+"/api/v1/agent/calendar/events"+q, &res); err != nil {
return nil, err
}
if len(res.Events) == 0 {
return textResult("你还没有建过日程。用 create_schedule 建一条。"), nil
}
var sb strings.Builder
fmt.Fprintf(&sb, "你的日程(%d 条", len(res.Events))
// 接近上限时主动说出来。只在撞墙时才报错等于让模型一直蒙在鼓里,
// 而它撞墙那一刻往往正在做别的事,没有余裕去清理。
if res.ActiveLimit > 0 {
fmt.Fprintf(&sb, ",生效上限 %d", res.ActiveLimit)
if len(res.Events)*4 >= res.ActiveLimit*3 {
sb.WriteString(" —— 已接近上限,建议删掉不需要的")
}
}
sb.WriteString("\n")
for _, ev := range res.Events {
fmt.Fprintf(&sb, "\n· %s\n", ev.Title)
fmt.Fprintf(&sb, " 时间:%s\n", formatEventWhen(ev))
if ev.Recurrence != "" && ev.Recurrence != "none" {
fmt.Fprintf(&sb, " 重复:%s\n", describeRecurrence(ev.Recurrence))
}
if r := ev.effectiveRecipients(); len(r) > 0 {
fmt.Fprintf(&sb, " 发给:%s", strings.Join(r, ", "))
if len(r) > 1 {
fmt.Fprintf(&sb, "%s", describeDelivery(ev.DeliveryMode))
}
sb.WriteString("\n")
}
if ev.Status != "" && ev.Status != "active" {
fmt.Fprintf(&sb, " 状态:%s\n", describeStatus(ev.Status))
}
fmt.Fprintf(&sb, " ID%s\n", ev.EventID)
}
return textResult(sb.String()), nil
}
// handleUpdateSchedule 改一条日程。
//
// **只传要改的字段**:服务端按字段是否出现决定改不改,省略的保持原值。
// 这一点必须在工具描述里写清楚 —— 模型的默认倾向是回传它记得的全部字段,
// 而它记错一个就会把提醒正文或收件人覆盖掉,且没有任何报错。
func (p *Plugin) handleUpdateSchedule(args map[string]interface{}) (interface{}, error) {
id := strings.TrimSpace(argString(args, "event_id"))
if id == "" {
return nil, fmt.Errorf("必须给 event_id用 list_schedules 查)")
}
payload := map[string]interface{}{}
if v, ok := args["title"].(string); ok {
payload["title"] = strings.TrimSpace(v)
}
if v, ok := args["description"].(string); ok {
payload["description"] = v
}
if v, ok := args["reminder_text"].(string); ok {
payload["reminder_text"] = v
}
if v, ok := args["recurrence"].(string); ok && strings.TrimSpace(v) != "" {
payload["recurrence"] = strings.TrimSpace(v)
}
if v, ok := args["status"].(string); ok && strings.TrimSpace(v) != "" {
payload["status"] = strings.TrimSpace(v)
}
if v, ok := args["delivery_mode"].(string); ok && strings.TrimSpace(v) != "" {
payload["delivery_mode"] = strings.TrimSpace(v)
}
if n, ok := argInt(args, "remind_before"); ok {
payload["remind_before"] = n
}
if v, ok := args["event_time"].(string); ok && strings.TrimSpace(v) != "" {
t, err := parseEventTime(v)
if err != nil {
return nil, err
}
payload["event_time"] = t.Format(time.RFC3339)
}
if v, ok := args["recurrence_end"].(string); ok && strings.TrimSpace(v) != "" {
t, err := parseEventTime(v)
if err != nil {
return nil, fmt.Errorf("recurrence_end 解析失败:%w", err)
}
payload["recurrence_end"] = t.Format(time.RFC3339)
}
if v, ok := args["recipients"].(string); ok && strings.TrimSpace(v) != "" {
payload["recipients"] = parseRecipients(v)
}
if len(payload) == 0 {
return nil, fmt.Errorf("没有要改的字段。只传需要修改的那几个即可(其余保持原值)")
}
var ev scheduleEvent
if err := p.put("/agent/calendar/events/"+id, payload, &ev); err != nil {
return nil, err
}
return textResult(strings.Join([]string{
fmt.Sprintf("日程已更新:%s", ev.Title),
fmt.Sprintf(" 时间:%s", formatEventWhen(ev)),
fmt.Sprintf(" 重复:%s", describeRecurrence(ev.Recurrence)),
fmt.Sprintf(" 发给:%s", strings.Join(ev.effectiveRecipients(), ", ")),
fmt.Sprintf(" 状态:%s", describeStatus(ev.Status)),
}, "\n")), nil
}
// handleDeleteSchedule 删一条日程。
func (p *Plugin) handleDeleteSchedule(args map[string]interface{}) (interface{}, error) {
id := strings.TrimSpace(argString(args, "event_id"))
if id == "" {
return nil, fmt.Errorf("必须给 event_id用 list_schedules 查)")
}
if err := p.delete("/agent/calendar/events/" + id); err != nil {
return nil, err
}
return textResult(fmt.Sprintf("日程 %s 已删除,不再提醒。", id)), nil
}
// ─── 数据结构与格式化 ───
// scheduleEvent 是 Gateway 返回的事件。
//
// 只声明用到的字段:多声明的字段一旦服务端改名会静默变成零值,
// 而少声明的字段 json 解码本来就忽略。
type scheduleEvent struct {
EventID string `json:"event_id"`
Title string `json:"title"`
Description string `json:"description"`
ReminderText string `json:"reminder_text"`
AgentName string `json:"agent_name"`
ToAddress string `json:"to_address"`
Recipients []string `json:"recipients"`
DeliveryMode string `json:"delivery_mode"`
EventTime string `json:"event_time"`
RemindBefore int `json:"remind_before"`
Recurrence string `json:"recurrence"`
Status string `json:"status"`
}
// effectiveRecipients 与服务端 EffectiveRecipients() 同一条兜底链。
//
// 必须有:服务端对旧数据会退回 to_address/agent_name
// 这里若只读 recipients那些事件会显示成「发给」后面空白。
func (e scheduleEvent) effectiveRecipients() []string {
out := make([]string, 0, len(e.Recipients))
for _, r := range e.Recipients {
if r = strings.TrimSpace(r); r != "" {
out = append(out, r)
}
}
if len(out) > 0 {
return out
}
if v := strings.TrimSpace(e.ToAddress); v != "" {
return []string{v}
}
if v := strings.TrimSpace(e.AgentName); v != "" {
return []string{v}
}
return nil
}
// formatEventWhen 把事件时间渲染成本地时间,并写出实际提醒时刻。
//
// 提醒时刻必须单独写出来:模型设了「提前 30 分钟」之后,
// 只看事件时间会以为那才是收到通知的时候。
func formatEventWhen(e scheduleEvent) string {
t, err := time.Parse(time.RFC3339, e.EventTime)
if err != nil {
return e.EventTime
}
local := t.Local()
out := local.Format("2006-01-02 15:04 (Mon)")
if e.RemindBefore > 0 {
remind := local.Add(-time.Duration(e.RemindBefore) * time.Minute)
out += fmt.Sprintf(",提前 %s 提醒(%s",
describeMinutes(e.RemindBefore), remind.Format("01-02 15:04"))
}
return out
}
func describeMinutes(min int) string {
switch {
case min <= 0:
return "0 分钟"
case min < 60:
return strconv.Itoa(min) + " 分钟"
case min%60 == 0:
return strconv.Itoa(min/60) + " 小时"
default:
return fmt.Sprintf("%d 小时 %d 分钟", min/60, min%60)
}
}
func describeRecurrence(r string) string {
switch r {
case "", "none":
return "不重复"
case "daily":
return "每天"
case "weekly":
return "每周"
case "monthly":
return "每月(公历同一日)"
case "yearly":
return "每年(公历同月日)"
case "lunar_monthly":
return "每农历月(同一日)"
case "lunar_yearly":
return "每农历年(同月同日)"
default:
return r
}
}
func describeDelivery(m string) string {
if m == "together" {
return "一起发送:首个是主收件人,其余抄送,共享同一条线索"
}
return "分别发送:各自独立会话,互相看不到"
}
func describeStatus(s string) string {
switch s {
case "paused":
return "已暂停(不再触发提醒)"
case "cancelled":
return "已取消"
default:
return "生效中"
}
}
// ─── 参数解析 ───
// parseEventTime 解析时间字符串。
//
// 接受三种形态,按严格程度递减:
//
// 1. 完整 RFC3339 带时区(`2026-09-10T09:00:00+08:00`)—— 推荐
// 2. 不带时区(`2026-09-10T09:00:00` / `2026-09-10 09:00`)—— 按**本机时区**解释
// 3. 只有日期(`2026-09-10`)—— 当天 09:00
//
// 第 2 种要兜住是因为模型经常忘时区,而拒绝它只会让它重试同样的写法。
// 按本机时区解释是唯一合理的猜测Agent 与 Gateway 跑在同一台机器上,
// 人说「九点」指的就是这台机器上的九点。
//
// **刻意不接受自然语言**(「明天九点」):解析它需要知道模型以为今天是哪天,
// 而它上下文里那个日期常常是错的 —— 一个静默偏移一天的提醒比报错糟得多。
func parseEventTime(raw string) (time.Time, error) {
s := strings.TrimSpace(raw)
if s == "" {
return time.Time{}, fmt.Errorf("必须给 event_time例如 2026-09-10T09:00:00+08:00")
}
// 带时区的标准形态
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
// 无时区:按本机时区解释
for _, layout := range []string{
"2006-01-02T15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"2006-01-02 15:04",
} {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return t, nil
}
}
// 只有日期:默认上午九点(比零点有用 —— 零点的提醒没人看)
if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil {
return t.Add(9 * time.Hour), nil
}
return time.Time{}, fmt.Errorf(
"时间 %q 无法解析。请用 RFC3339 带时区,例如 2026-09-10T09:00:00+08:00"+
"当前本机时间是 %s。不支持「明天」「下周三」这类相对表述 —— "+
"请先确认当前日期再算出具体时间戳。",
s, time.Now().Format(time.RFC3339))
}
// parseRecipients 把逗号分隔的地址串切成列表。
//
// 用字符串而不是数组参数:几个平台的工具参数 schema 对数组的支持不一致,
// 而逗号分隔在所有平台上都是一个普通 string。中英文逗号都接受 ——
// 中文输入法下打出「,」是常态,因为这个报错去改提示词不值得。
func parseRecipients(raw string) []string {
s := strings.TrimSpace(raw)
if s == "" {
return nil
}
s = strings.ReplaceAll(s, "", ",")
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, x := range parts {
x = strings.TrimSpace(x)
if x == "" || seen[x] {
continue
}
seen[x] = true
out = append(out, x)
}
return out
}
func argString(args map[string]interface{}, key string) string {
if v, ok := args[key].(string); ok {
return v
}
return ""
}
func argStringOr(args map[string]interface{}, key, fallback string) string {
if v := strings.TrimSpace(argString(args, key)); v != "" {
return v
}
return fallback
}
// argInt 读一个整数参数。
//
// JSON 数字解码成 float64但模型也常传字符串"30")——
// 两种都收,否则「提前 30 分钟」会因为引号而静默失效。
func argInt(args map[string]interface{}, key string) (int, bool) {
switch v := args[key].(type) {
case float64:
return int(v), true
case int:
return v, true
case string:
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return n, true
}
}
return 0, false
}
// textResult 包一个纯文本工具结果。
func textResult(text string) map[string]interface{} {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": text}},
}
}