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))