2026-02-13 09:51:47 +00:00
package tools
2026-02-11 04:28:37 +00:00
2026-02-12 12:06:53 +00:00
import (
"context"
"fmt"
2026-02-27 08:35:07 +00:00
"strings"
2026-02-12 12:06:53 +00:00
"time"
2026-02-11 04:28:37 +00:00
2026-02-12 12:06:53 +00:00
"github.com/sipeed/picoclaw/pkg/bus"
2026-02-18 11:31:15 +00:00
"github.com/sipeed/picoclaw/pkg/config"
2026-03-11 11:22:20 +00:00
"github.com/sipeed/picoclaw/pkg/constants"
2026-02-12 12:06:53 +00:00
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/utils"
)
// JobExecutor is the interface for executing cron jobs through the agent
type JobExecutor interface {
ProcessDirectWithChannel ( ctx context . Context , content , sessionKey , channel , chatID string ) ( string , error )
}
// CronTool provides scheduling capabilities for the agent
type CronTool struct {
cronService * cron . CronService
executor JobExecutor
msgBus * bus . MessageBus
2026-02-12 14:51:49 +00:00
execTool * ExecTool
2026-02-12 12:06:53 +00:00
}
// NewCronTool creates a new CronTool
2026-02-17 13:10:20 +00:00
// execTimeout: 0 means no timeout, >0 sets the timeout duration
2026-02-18 19:48:23 +00:00
func NewCronTool (
cronService * cron . CronService , executor JobExecutor , msgBus * bus . MessageBus , workspace string , restrict bool ,
execTimeout time . Duration , config * config . Config ,
2026-02-28 08:24:26 +00:00
) ( * CronTool , error ) {
execTool , err := NewExecToolWithConfig ( workspace , restrict , config )
if err != nil {
return nil , fmt . Errorf ( "unable to configure exec tool: %w" , err )
}
2026-02-18 11:31:15 +00:00
execTool . SetTimeout ( execTimeout )
2026-02-12 12:06:53 +00:00
return & CronTool {
cronService : cronService ,
executor : executor ,
msgBus : msgBus ,
2026-02-15 10:41:39 +00:00
execTool : execTool ,
2026-02-28 08:24:26 +00:00
} , nil
2026-02-12 12:06:53 +00:00
}
// Name returns the tool name
func ( t * CronTool ) Name ( ) string {
return "cron"
}
// Description returns the tool description
func ( t * CronTool ) Description ( ) string {
2026-02-12 14:51:49 +00:00
return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly."
2026-02-12 12:06:53 +00:00
}
// Parameters returns the tool parameters schema
2026-02-18 19:48:23 +00:00
func ( t * CronTool ) Parameters ( ) map [ string ] any {
return map [ string ] any {
2026-02-12 12:06:53 +00:00
"type" : "object" ,
2026-02-18 19:48:23 +00:00
"properties" : map [ string ] any {
"action" : map [ string ] any {
2026-02-12 12:06:53 +00:00
"type" : "string" ,
"enum" : [ ] string { "add" , "list" , "remove" , "enable" , "disable" } ,
"description" : "Action to perform. Use 'add' when user wants to schedule a reminder or task." ,
} ,
2026-02-18 19:48:23 +00:00
"message" : map [ string ] any {
2026-02-12 12:06:53 +00:00
"type" : "string" ,
2026-02-12 14:51:49 +00:00
"description" : "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does." ,
} ,
2026-02-18 19:48:23 +00:00
"command" : map [ string ] any {
2026-02-12 14:51:49 +00:00
"type" : "string" ,
"description" : "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands." ,
2026-02-12 12:06:53 +00:00
} ,
2026-03-11 11:22:20 +00:00
"command_confirm" : map [ string ] any {
"type" : "boolean" ,
"description" : "Required when using command=true. Must be true to explicitly confirm scheduling a shell command." ,
} ,
2026-02-18 19:48:23 +00:00
"at_seconds" : map [ string ] any {
2026-02-12 12:06:53 +00:00
"type" : "integer" ,
"description" : "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'." ,
} ,
2026-02-18 19:48:23 +00:00
"every_seconds" : map [ string ] any {
2026-02-12 12:06:53 +00:00
"type" : "integer" ,
"description" : "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'." ,
} ,
2026-02-18 19:48:23 +00:00
"cron_expr" : map [ string ] any {
2026-02-12 12:06:53 +00:00
"type" : "string" ,
"description" : "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules." ,
} ,
2026-02-18 19:48:23 +00:00
"job_id" : map [ string ] any {
2026-02-12 12:06:53 +00:00
"type" : "string" ,
"description" : "Job ID (for remove/enable/disable)" ,
} ,
2026-02-18 19:48:23 +00:00
"deliver" : map [ string ] any {
2026-02-12 12:06:53 +00:00
"type" : "boolean" ,
"description" : "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true" ,
} ,
} ,
"required" : [ ] string { "action" } ,
}
}
// Execute runs the tool with the given arguments
2026-02-18 19:48:23 +00:00
func ( t * CronTool ) Execute ( ctx context . Context , args map [ string ] any ) * ToolResult {
2026-02-12 12:06:53 +00:00
action , ok := args [ "action" ] . ( string )
if ! ok {
return ErrorResult ( "action is required" )
}
switch action {
case "add" :
2026-03-05 01:57:33 +00:00
return t . addJob ( ctx , args )
2026-02-12 12:06:53 +00:00
case "list" :
return t . listJobs ( )
case "remove" :
return t . removeJob ( args )
case "enable" :
return t . enableJob ( args , true )
case "disable" :
return t . enableJob ( args , false )
default :
return ErrorResult ( fmt . Sprintf ( "unknown action: %s" , action ) )
}
}
2026-03-05 01:57:33 +00:00
func ( t * CronTool ) addJob ( ctx context . Context , args map [ string ] any ) * ToolResult {
channel := ToolChannel ( ctx )
chatID := ToolChatID ( ctx )
2026-02-12 12:06:53 +00:00
if channel == "" || chatID == "" {
return ErrorResult ( "no session context (channel/chat_id not set). Use this tool in an active conversation." )
}
message , ok := args [ "message" ] . ( string )
if ! ok || message == "" {
return ErrorResult ( "message is required for add" )
}
var schedule cron . CronSchedule
// Check for at_seconds (one-time), every_seconds (recurring), or cron_expr
atSeconds , hasAt := args [ "at_seconds" ] . ( float64 )
everySeconds , hasEvery := args [ "every_seconds" ] . ( float64 )
cronExpr , hasCron := args [ "cron_expr" ] . ( string )
2026-03-06 12:11:08 +00:00
// Fix: type assertions return true for zero values, need additional validity checks
// This prevents LLMs that fill unused optional parameters with defaults (0) from triggering wrong type
hasAt = hasAt && atSeconds > 0
hasEvery = hasEvery && everySeconds > 0
hasCron = hasCron && cronExpr != ""
2026-02-12 12:06:53 +00:00
// Priority: at_seconds > every_seconds > cron_expr
if hasAt {
atMS := time . Now ( ) . UnixMilli ( ) + int64 ( atSeconds ) * 1000
schedule = cron . CronSchedule {
2026-02-13 09:51:47 +00:00
Kind : "at" ,
AtMS : & atMS ,
2026-02-12 12:06:53 +00:00
}
} else if hasEvery {
everyMS := int64 ( everySeconds ) * 1000
schedule = cron . CronSchedule {
Kind : "every" ,
EveryMS : & everyMS ,
}
} else if hasCron {
schedule = cron . CronSchedule {
Kind : "cron" ,
Expr : cronExpr ,
}
} else {
return ErrorResult ( "one of at_seconds, every_seconds, or cron_expr is required" )
}
// Read deliver parameter, default to true
deliver := true
if d , ok := args [ "deliver" ] . ( bool ) ; ok {
deliver = d
}
2026-03-11 11:22:20 +00:00
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel + explicit confirm.
// Non-command reminders (plain messages) remain open to all channels.
2026-02-12 14:51:49 +00:00
command , _ := args [ "command" ] . ( string )
2026-03-11 11:22:20 +00:00
commandConfirm , _ := args [ "command_confirm" ] . ( bool )
2026-02-12 14:51:49 +00:00
if command != "" {
2026-03-11 11:22:20 +00:00
if ! constants . IsInternalChannel ( channel ) {
return ErrorResult ( "scheduling command execution is restricted to internal channels" )
}
if ! commandConfirm {
return ErrorResult ( "command_confirm=true is required to schedule command execution" )
}
2026-02-12 14:51:49 +00:00
deliver = false
}
2026-02-12 12:06:53 +00:00
// Truncate message for job name (max 30 chars)
messagePreview := utils . Truncate ( message , 30 )
job , err := t . cronService . AddJob (
messagePreview ,
schedule ,
message ,
deliver ,
channel ,
chatID ,
)
if err != nil {
return ErrorResult ( fmt . Sprintf ( "Error adding job: %v" , err ) )
}
2026-02-13 09:51:47 +00:00
2026-02-12 14:51:49 +00:00
if command != "" {
job . Payload . Command = command
// Need to save the updated payload
t . cronService . UpdateJob ( job )
}
2026-02-12 12:06:53 +00:00
return SilentResult ( fmt . Sprintf ( "Cron job added: %s (id: %s)" , job . Name , job . ID ) )
}
func ( t * CronTool ) listJobs ( ) * ToolResult {
jobs := t . cronService . ListJobs ( false )
if len ( jobs ) == 0 {
return SilentResult ( "No scheduled jobs" )
}
2026-02-27 08:35:07 +00:00
var result strings . Builder
result . WriteString ( "Scheduled jobs:\n" )
2026-02-12 12:06:53 +00:00
for _ , j := range jobs {
var scheduleInfo string
if j . Schedule . Kind == "every" && j . Schedule . EveryMS != nil {
scheduleInfo = fmt . Sprintf ( "every %ds" , * j . Schedule . EveryMS / 1000 )
} else if j . Schedule . Kind == "cron" {
scheduleInfo = j . Schedule . Expr
} else if j . Schedule . Kind == "at" {
scheduleInfo = "one-time"
} else {
scheduleInfo = "unknown"
}
2026-02-27 08:35:07 +00:00
result . WriteString ( fmt . Sprintf ( "- %s (id: %s, %s)\n" , j . Name , j . ID , scheduleInfo ) )
2026-02-12 12:06:53 +00:00
}
2026-02-27 08:35:07 +00:00
return SilentResult ( result . String ( ) )
2026-02-12 12:06:53 +00:00
}
2026-02-18 19:48:23 +00:00
func ( t * CronTool ) removeJob ( args map [ string ] any ) * ToolResult {
2026-02-12 12:06:53 +00:00
jobID , ok := args [ "job_id" ] . ( string )
if ! ok || jobID == "" {
return ErrorResult ( "job_id is required for remove" )
}
if t . cronService . RemoveJob ( jobID ) {
return SilentResult ( fmt . Sprintf ( "Cron job removed: %s" , jobID ) )
}
return ErrorResult ( fmt . Sprintf ( "Job %s not found" , jobID ) )
}
2026-02-18 19:48:23 +00:00
func ( t * CronTool ) enableJob ( args map [ string ] any , enable bool ) * ToolResult {
2026-02-12 12:06:53 +00:00
jobID , ok := args [ "job_id" ] . ( string )
if ! ok || jobID == "" {
return ErrorResult ( "job_id is required for enable/disable" )
}
job := t . cronService . EnableJob ( jobID , enable )
if job == nil {
return ErrorResult ( fmt . Sprintf ( "Job %s not found" , jobID ) )
}
status := "enabled"
if ! enable {
status = "disabled"
}
return SilentResult ( fmt . Sprintf ( "Cron job '%s' %s" , job . Name , status ) )
}
// ExecuteJob executes a cron job through the agent
func ( t * CronTool ) ExecuteJob ( ctx context . Context , job * cron . CronJob ) string {
// Get channel/chatID from job payload
channel := job . Payload . Channel
chatID := job . Payload . To
// Default values if not set
if channel == "" {
channel = "cli"
}
if chatID == "" {
chatID = "direct"
}
2026-02-12 14:51:49 +00:00
// Execute command if present
if job . Payload . Command != "" {
2026-02-18 19:48:23 +00:00
args := map [ string ] any {
2026-03-11 11:22:20 +00:00
"command" : job . Payload . Command ,
"__channel" : channel ,
"__chat_id" : chatID ,
2026-02-12 14:51:49 +00:00
}
2026-02-12 17:00:26 +00:00
result := t . execTool . Execute ( ctx , args )
var output string
if result . IsError {
output = fmt . Sprintf ( "Error executing scheduled command: %s" , result . ForLLM )
2026-02-12 14:51:49 +00:00
} else {
2026-02-12 17:00:26 +00:00
output = fmt . Sprintf ( "Scheduled command '%s' executed:\n%s" , job . Payload . Command , result . ForLLM )
2026-02-12 14:51:49 +00:00
}
2026-02-23 13:34:37 +00:00
pubCtx , pubCancel := context . WithTimeout ( context . Background ( ) , 5 * time . Second )
defer pubCancel ( )
t . msgBus . PublishOutbound ( pubCtx , bus . OutboundMessage {
2026-02-12 14:51:49 +00:00
Channel : channel ,
ChatID : chatID ,
Content : output ,
} )
return "ok"
}
2026-02-12 12:06:53 +00:00
// If deliver=true, send message directly without agent processing
if job . Payload . Deliver {
2026-02-23 13:34:37 +00:00
pubCtx , pubCancel := context . WithTimeout ( context . Background ( ) , 5 * time . Second )
defer pubCancel ( )
t . msgBus . PublishOutbound ( pubCtx , bus . OutboundMessage {
2026-02-12 12:06:53 +00:00
Channel : channel ,
ChatID : chatID ,
Content : job . Payload . Message ,
} )
return "ok"
}
// For deliver=false, process through agent (for complex tasks)
sessionKey := fmt . Sprintf ( "cron-%s" , job . ID )
// Call agent with job's message
response , err := t . executor . ProcessDirectWithChannel (
ctx ,
job . Payload . Message ,
sessionKey ,
channel ,
chatID ,
)
if err != nil {
return fmt . Sprintf ( "Error: %v" , err )
}
// Response is automatically sent via MessageBus by AgentLoop
_ = response // Will be sent by AgentLoop
return "ok"
}