1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "strings"
10 "time"
11
12 "github.com/bnema/zut/packages/agent/scheduler"
13 "github.com/bnema/zut/packages/core"
14 "github.com/bnema/zut/packages/provider"
15)
16
17const scheduleToolSchema = `{"type":"object","properties":{"action":{"type":"string","enum":["add","list","cancel"],"description":"Scheduling operation."},"cron":{"type":"string","description":"Five-field cron expression, for example '0 9 * * 1-5'. Required for add."},"message":{"type":"string","description":"Prompt to run in this session when due. Required for add."},"id":{"type":"string","description":"Task ID. Required for cancel."}},"required":["action"],"additionalProperties":false}`
18
19// ScheduleToolName is the stable built-in scheduler tool name.
20const ScheduleToolName = "schedule"
21
22// ScheduleTool manages process-lifetime scheduled follow-up prompts. Its
23// host-provided session callback prevents an agent from selecting another
24// session as a task target.
25type ScheduleTool struct {
26 Engine *scheduler.Engine
27 SessionID func() string
28 Location func() *time.Location
29}
30
31type scheduleToolArgs struct {
32 Action string `json:"action"`
33 Cron string `json:"cron,omitempty"`
34 Message string `json:"message,omitempty"`
35 ID string `json:"id,omitempty"`
36}
37
38func (t *ScheduleTool) Name() string { return ScheduleToolName }
39
40func (t *ScheduleTool) Description() string {
41 return "Create, list, or cancel in-process cron follow-ups for the current session. Scheduled work runs only while this zut process remains open."
42}
43
44func (t *ScheduleTool) Schema() json.RawMessage { return json.RawMessage(scheduleToolSchema) }
45
46func (t *ScheduleTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
47 if t == nil || t.Engine == nil || t.SessionID == nil {
48 return scheduleToolError("scheduler is unavailable"), nil
49 }
50 var args scheduleToolArgs
51 decoder := json.NewDecoder(bytes.NewReader(raw))
52 decoder.DisallowUnknownFields()
53 if err := decoder.Decode(&args); err != nil {
54 return scheduleToolError("invalid arguments"), nil
55 }
56 if err := decoder.Decode(&struct{}{}); err != io.EOF {
57 return scheduleToolError("invalid arguments"), nil
58 }
59 args.Action = strings.TrimSpace(args.Action)
60 args.Cron = strings.TrimSpace(args.Cron)
61 args.Message = strings.TrimSpace(args.Message)
62 args.ID = strings.TrimSpace(args.ID)
63
64 switch args.Action {
65 case "add":
66 if args.Cron == "" || args.Message == "" {
67 return scheduleToolError("cron and message are required when adding a task"), nil
68 }
69 location := time.Local
70 if t.Location != nil && t.Location() != nil {
71 location = t.Location()
72 }
73 task, err := t.Engine.Add(scheduler.NewTaskInput{
74 SessionID: t.SessionID(),
75 Cron: args.Cron,
76 Message: args.Message,
77 Location: location,
78 })
79 if err != nil {
80 return scheduleToolError(err.Error()), nil
81 }
82 return scheduleToolResult(fmt.Sprintf("scheduled %s for %s (%s)", task.ID, task.NextRun.Format(time.RFC3339), task.Timezone))
83 case "list":
84 tasks := t.Engine.List()
85 currentSession := t.SessionID()
86 lines := make([]string, 0, len(tasks))
87 for _, task := range tasks {
88 if task.SessionID != currentSession {
89 continue
90 }
91 line := fmt.Sprintf("%s %s next %s", task.ID, task.Cron, task.NextRun.Format(time.RFC3339))
92 if task.LastError != "" {
93 line += " last error: " + task.LastError
94 }
95 lines = append(lines, line)
96 }
97 if len(lines) == 0 {
98 return scheduleToolResult("no scheduled tasks for this session")
99 }
100 return scheduleToolResult(strings.Join(lines, "\n"))
101 case "cancel":
102 if args.ID == "" {
103 return scheduleToolError("id is required when cancelling a task"), nil
104 }
105 task, ok := t.Engine.Get(args.ID)
106 if !ok || task.SessionID != t.SessionID() {
107 return scheduleToolError("scheduled task not found in this session"), nil
108 }
109 t.Engine.Cancel(args.ID)
110 return scheduleToolResult("cancelled scheduled task " + args.ID)
111 default:
112 return scheduleToolError("action must be add, list, or cancel"), nil
113 }
114}
115
116func scheduleToolResult(text string) (core.ToolResult, error) {
117 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}}, nil
118}
119
120func scheduleToolError(text string) core.ToolResult {
121 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: text}}, IsError: true}
122}