/ internal / tools / schedule.go
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  }