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:
@ -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))
|
||||
|
||||
472
plugins/homeagent-mail-bridge/schedule.go
Normal file
472
plugins/homeagent-mail-bridge/schedule.go
Normal 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}},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user