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 成功。
473 lines
16 KiB
Go
473 lines
16 KiB
Go
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}},
|
||
}
|
||
}
|