package main import ( "fmt" "net/url" "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 列出自己建的日程。 // // 查询串必须走 url.Values 编码,**不能手拼**。 // // RFC3339 的时区偏移带一个 `+`(2026-12-11T09:00:00+08:00),而 `+` 在查询串里 // 正是空格的转义形式:手拼进 URL 后服务端 ParseQuery 把它还原成空格, // time.Parse(RFC3339) 随即失败 —— 而 AgentListCalendarEvents 解析失败时是 // **静默退回默认区间**(now-24h ~ now+3月),不报错。 // 于是这里传的 from/to 全部无效,表现为「明明有日程却说一条都没有」。 // 真实踩过一次:先怀疑了数据库、密钥和权限,最后才发现是这个加号。 func (p *Plugin) handleListSchedules(args map[string]interface{}) (interface{}, error) { q := url.Values{} q.Set("status", argStringOr(args, "status", "active")) if v := strings.TrimSpace(argString(args, "from")); v != "" { if t, err := parseEventTime(v); err == nil { q.Set("from", t.Format(time.RFC3339)) } } if v := strings.TrimSpace(argString(args, "to")); v != "" { if t, err := parseEventTime(v); err == nil { q.Set("to", t.Format(time.RFC3339)) } } else { // 默认往后 14 个月,而不是沿用服务端的 3 个月。 // // 生日、纪念日这类 yearly / lunar_yearly 的下一次触发随时会落在 // 3 个月之外。窗口太窄时模型问「我建过什么」得到的是空 —— // 然后它会照着空结果再建一条重复的。14 个月保证任何年度重复 // 都至少露一次脸。 q.Set("to", time.Now().AddDate(1, 2, 0).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.Encode(), &res); err != nil { return nil, err } if len(res.Events) == 0 { // 不能说「你还没有建过日程」——窗口外或别的 status 下还有事件时 // 这是假话,而模型会照着这句话去建一条重复的。 // 把实际查询区间说出来,让它知道该往哪放宽。 from := q.Get("from") if from == "" { from = "默认起点(昨天)" } return textResult(fmt.Sprintf( "这个区间内没有日程:%s ~ %s,status=%s。\n"+ "想找更早/更远的请放宽 from/to,或用 status=all 看已暂停与已取消的。\n"+ "确认真的没有再用 create_schedule 建 —— 别照着空结果建重复的。", from, q.Get("to"), q.Get("status"))), 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}}, } }