schedule.go
1 package tools 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "path/filepath" 8 "strings" 9 "unicode/utf8" 10 11 "github.com/Kocoro-lab/ShanClaw/internal/agent" 12 "github.com/Kocoro-lab/ShanClaw/internal/agents" 13 "github.com/Kocoro-lab/ShanClaw/internal/config" 14 "github.com/Kocoro-lab/ShanClaw/internal/schedule" 15 ) 16 17 type ScheduleTool struct { 18 manager *schedule.Manager 19 action string 20 } 21 22 func NewScheduleTools(mgr *schedule.Manager) []agent.Tool { 23 return []agent.Tool{ 24 &ScheduleTool{manager: mgr, action: "create"}, 25 &ScheduleTool{manager: mgr, action: "list"}, 26 &ScheduleTool{manager: mgr, action: "update"}, 27 &ScheduleTool{manager: mgr, action: "remove"}, 28 } 29 } 30 31 func (t *ScheduleTool) Info() agent.ToolInfo { 32 switch t.action { 33 case "create": 34 return agent.ToolInfo{ 35 Name: "schedule_create", 36 Description: "Create a scheduled task that runs a shan agent on a cron schedule. Supports full cron syntax (ranges, steps, lists). Each run saves its result as a session (searchable via session_search).", 37 Parameters: map[string]any{ 38 "type": "object", 39 "properties": map[string]any{ 40 "agent": map[string]any{"type": "string", "description": "Agent name (from ~/.shannon/agents/). Empty for default agent."}, 41 "cron": map[string]any{"type": "string", "description": "5-field cron expression (minute hour day month weekday). Supports */5, 1-5, 1,3,5."}, 42 "prompt": map[string]any{"type": "string", "description": "The prompt to send to the agent on each run."}, 43 }, 44 }, 45 Required: []string{"cron", "prompt"}, 46 } 47 case "list": 48 return agent.ToolInfo{ 49 Name: "schedule_list", 50 Description: "List all locally scheduled tasks with their status.", 51 Parameters: map[string]any{"type": "object", "properties": map[string]any{}}, 52 } 53 case "update": 54 return agent.ToolInfo{ 55 Name: "schedule_update", 56 Description: "Update an existing scheduled task.", 57 Parameters: map[string]any{ 58 "type": "object", 59 "properties": map[string]any{ 60 "id": map[string]any{"type": "string", "description": "Schedule ID"}, 61 "cron": map[string]any{"type": "string", "description": "New cron expression"}, 62 "prompt": map[string]any{"type": "string", "description": "New prompt"}, 63 "enabled": map[string]any{"type": "boolean", "description": "Enable or disable"}, 64 }, 65 }, 66 Required: []string{"id"}, 67 } 68 case "remove": 69 return agent.ToolInfo{ 70 Name: "schedule_remove", 71 Description: "Remove a scheduled task.", 72 Parameters: map[string]any{ 73 "type": "object", 74 "properties": map[string]any{ 75 "id": map[string]any{"type": "string", "description": "Schedule ID to remove"}, 76 }, 77 }, 78 Required: []string{"id"}, 79 } 80 } 81 return agent.ToolInfo{} 82 } 83 84 func (t *ScheduleTool) Run(ctx context.Context, argsJSON string) (agent.ToolResult, error) { 85 var args map[string]any 86 if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { 87 return agent.ToolResult{Content: "invalid args: " + err.Error(), IsError: true}, nil 88 } 89 switch t.action { 90 case "create": 91 agentName, _ := args["agent"].(string) 92 cron, _ := args["cron"].(string) 93 prompt, _ := args["prompt"].(string) 94 if cron == "" || prompt == "" { 95 return agent.ToolResult{Content: "cron and prompt are required", IsError: true}, nil 96 } 97 id, err := t.manager.Create(agentName, cron, prompt) 98 if err != nil { 99 return agent.ToolResult{Content: err.Error(), IsError: true}, nil 100 } 101 // Capture and save the current conversation context so the agent 102 // can understand the task background when the schedule fires. 103 if ctxMsgs := extractConversationContext(ctx); len(ctxMsgs) > 0 { 104 if saveErr := t.manager.SaveContext(id, ctxMsgs); saveErr != nil { 105 return agent.ToolResult{Content: fmt.Sprintf("Schedule created: %s (warning: failed to save context: %v)", id, saveErr)}, nil 106 } 107 } 108 msg := fmt.Sprintf("Schedule created: %s", id) 109 if w := t.triggerConflictWarning(agentName); w != "" { 110 msg += "\n" + w 111 } 112 return agent.ToolResult{Content: msg}, nil 113 case "list": 114 list, err := t.manager.List() 115 if err != nil { 116 return agent.ToolResult{Content: err.Error(), IsError: true}, nil 117 } 118 if len(list) == 0 { 119 return agent.ToolResult{Content: "No scheduled tasks."}, nil 120 } 121 var sb strings.Builder 122 for _, s := range list { 123 agentDisplay := s.Agent 124 if agentDisplay == "" { 125 agentDisplay = "(default)" 126 } 127 ctxTag := "" 128 if t.manager.HasContext(s.ID) { 129 ctxTag = " [ctx]" 130 } 131 fmt.Fprintf(&sb, "%s | agent=%s | cron=%s | enabled=%v | sync=%s | %s%s\n", 132 s.ID, agentDisplay, s.Cron, s.Enabled, s.SyncStatus, s.Prompt, ctxTag) 133 } 134 return agent.ToolResult{Content: sb.String()}, nil 135 case "update": 136 id, _ := args["id"].(string) 137 if id == "" { 138 return agent.ToolResult{Content: "id is required", IsError: true}, nil 139 } 140 opts := &schedule.UpdateOpts{} 141 if v, ok := args["cron"].(string); ok { 142 opts.Cron = &v 143 } 144 if v, ok := args["prompt"].(string); ok { 145 opts.Prompt = &v 146 } 147 if v, ok := args["enabled"].(bool); ok { 148 opts.Enabled = &v 149 } 150 if opts.Cron == nil && opts.Prompt == nil && opts.Enabled == nil { 151 return agent.ToolResult{Content: "at least one of cron, prompt, or enabled is required", IsError: true}, nil 152 } 153 if err := t.manager.Update(id, opts); err != nil { 154 return agent.ToolResult{Content: err.Error(), IsError: true}, nil 155 } 156 msg := fmt.Sprintf("Schedule %s updated.", id) 157 // Look up the updated schedule to resolve its agent name before 158 // checking for trigger conflicts. Best-effort: lookup errors are 159 // silently swallowed since warnings are additive-only. 160 if sched, err := t.manager.Get(id); err == nil && sched != nil { 161 if w := t.triggerConflictWarning(sched.Agent); w != "" { 162 msg += "\n" + w 163 } 164 } 165 return agent.ToolResult{Content: msg}, nil 166 case "remove": 167 id, _ := args["id"].(string) 168 if id == "" { 169 return agent.ToolResult{Content: "id is required", IsError: true}, nil 170 } 171 if err := t.manager.Remove(id); err != nil { 172 return agent.ToolResult{Content: err.Error(), IsError: true}, nil 173 } 174 return agent.ToolResult{Content: fmt.Sprintf("Schedule %s removed.", id)}, nil 175 } 176 return agent.ToolResult{Content: "unknown action", IsError: true}, nil 177 } 178 179 func (t *ScheduleTool) RequiresApproval() bool { 180 return t.action != "list" 181 } 182 183 func (t *ScheduleTool) IsReadOnlyCall(string) bool { 184 return t.action == "list" 185 } 186 187 // triggerConflictWarning returns a user-facing warning line (with the leading 188 // "⚠️ Note:" marker) when the named agent has a non-zero heartbeat AND an 189 // enabled schedule referencing it. Returns an empty string on no conflict, on 190 // an empty agent name, or when lookups fail — this is visibility only, never 191 // a hard error. 192 func (t *ScheduleTool) triggerConflictWarning(agentName string) string { 193 if agentName == "" { 194 return "" 195 } 196 shanDir := config.ShannonDir() 197 if shanDir == "" { 198 return "" 199 } 200 agentsDir := filepath.Join(shanDir, "agents") 201 202 list, err := t.manager.List() 203 if err != nil { 204 return "" 205 } 206 refs := make([]agents.ScheduleRef, 0, len(list)) 207 for _, s := range list { 208 refs = append(refs, agents.ScheduleRef{ID: s.ID, Agent: s.Agent, Enabled: s.Enabled}) 209 } 210 warnings := agents.DetectTriggerConflicts(agentsDir, agentName, refs) 211 if len(warnings) == 0 { 212 return "" 213 } 214 return "⚠️ Note: " + warnings[0] 215 } 216 217 // extractConversationContext pulls a compact context from the live 218 // conversation snapshot. It keeps only plain-text user/assistant messages 219 // and skips system, tool_use, and tool_result messages. At most the last 220 // 20 messages are kept, with total text capped at 8000 runes. 221 func extractConversationContext(ctx context.Context) []schedule.ContextMessage { 222 snapshotFn := agent.ConversationSnapshotFromContext(ctx) 223 if snapshotFn == nil { 224 return nil 225 } 226 messages := snapshotFn() 227 if len(messages) == 0 { 228 return nil 229 } 230 231 // Filter: keep only plain-text user/assistant messages. 232 // 233 // For block content we concatenate ONLY the text blocks, never 234 // tool_result blocks. MessageContent.Text() merges tool_result payloads 235 // into its output, and those payloads can include spill file paths 236 // (~/.shannon/tmp/…) or other internal infrastructure text that must 237 // never surface as "conversation context". 238 var filtered []schedule.ContextMessage 239 for _, msg := range messages { 240 if msg.Role != "user" && msg.Role != "assistant" { 241 continue 242 } 243 var text string 244 if msg.Content.HasBlocks() { 245 var sb strings.Builder 246 for _, b := range msg.Content.Blocks() { 247 if b.Type == "text" && b.Text != "" { 248 if sb.Len() > 0 { 249 sb.WriteString("\n") 250 } 251 sb.WriteString(b.Text) 252 } 253 } 254 text = strings.TrimSpace(sb.String()) 255 } else { 256 text = strings.TrimSpace(msg.Content.Text()) 257 } 258 if text == "" { 259 continue 260 } 261 filtered = append(filtered, schedule.ContextMessage{ 262 Role: msg.Role, 263 Content: text, 264 }) 265 } 266 267 // Keep only the most recent 20 messages. 268 const maxMessages = 20 269 if len(filtered) > maxMessages { 270 filtered = filtered[len(filtered)-maxMessages:] 271 } 272 273 // Cap total text at 8000 runes (not bytes — Chinese is 3 bytes/char, so 274 // a byte budget would give ~2666 effective chars). Drop oldest first. 275 const maxChars = 8000 276 totalChars := 0 277 for _, m := range filtered { 278 totalChars += utf8.RuneCountInString(m.Content) 279 } 280 for totalChars > maxChars && len(filtered) > 1 { 281 totalChars -= utf8.RuneCountInString(filtered[0].Content) 282 filtered = filtered[1:] 283 } 284 285 return filtered 286 }