package tools import ( "context" "encoding/json" "fmt" "log" "time" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/taskutil" ) // ── Late Registration ──────────────────────── // RegisterTaskTools registers the task_create tool. // Called from main.go after stores are initialized. func RegisterTaskTools(stores store.Stores) { if stores.Tasks == nil { log.Println("⚠ task tools: TaskStore not available, skipping registration") return } Register(&taskCreateTool{stores: stores}) log.Println("✅ task tools registered (task_create)") } // ═══════════════════════════════════════════ // task_create // ═══════════════════════════════════════════ // // AI-invocable tool that spawns sub-tasks. Only available inside // workflow or team channels — prevents runaway task creation in // personal chats. Tasks cannot create tasks (depth limit: service // channels are blocked). type taskCreateTool struct { BaseTool stores store.Stores } // Availability: workflow or team context only — not personal chats, // not service channels (prevents recursive task spawning). func (t *taskCreateTool) Availability() Require { return func(tc ToolContext) bool { // Block in service channels (task output) — prevents recursion if tc.ChannelType == "service" { return false } // Require workflow or team context return tc.WorkflowID != "" || tc.TeamID != "" } } func (t *taskCreateTool) Definition() ToolDef { return ToolDef{ Name: "task_create", DisplayName: "Create Task", Category: "automation", Description: "Create a new scheduled or one-shot task. Use this to spawn " + "background work that runs independently — for example, a follow-up " + "analysis, a periodic check, or a notification workflow. The task runs " + "in its own service channel with its own budget.", Parameters: JSONSchema(map[string]interface{}{ "name": Prop("string", "Short descriptive name for the task"), "prompt": Prop("string", "The user prompt the task will execute"), "schedule": Prop("string", "Cron expression (e.g. '0 8 * * *' for daily at 8am) or 'once' for immediate one-shot execution"), "persona": Prop("string", "Persona handle to use for execution (optional — uses default if omitted)"), "model": Prop("string", "Model ID to use (optional — uses persona or config default)"), "max_tokens": map[string]interface{}{ "type": "integer", "description": "Maximum tokens for the task (optional — uses global default)", }, "system_prompt": Prop("string", "Optional system prompt override for the task"), }, []string{"name", "prompt", "schedule"}), } } func (t *taskCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) { var args struct { Name string `json:"name"` Prompt string `json:"prompt"` Schedule string `json:"schedule"` Persona string `json:"persona"` Model string `json:"model"` MaxTokens int `json:"max_tokens"` SystemPrompt string `json:"system_prompt"` } if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { return "", fmt.Errorf("invalid arguments: %w", err) } if args.Name == "" || args.Prompt == "" || args.Schedule == "" { return "", fmt.Errorf("name, prompt, and schedule are required") } // F5 audit fix: reject webhook schedule — webhook-triggered tasks // require API creation (trigger token generation, no cron). if args.Schedule == "webhook" { return "", fmt.Errorf("webhook-triggered tasks cannot be created via this tool — use the API directly") } // Validate cron if err := taskutil.ValidateCron(args.Schedule); err != nil { return "", fmt.Errorf("invalid schedule: %w", err) } // Depth guard: verify we're not inside a service channel (task output) if execCtx.ChannelID != "" { channelType, _, _ := t.stores.Channels.GetTypeAndTeamID(ctx, execCtx.ChannelID) if channelType == "service" { return "", fmt.Errorf("tasks cannot create tasks (depth limit)") } } // Resolve persona ID from handle var personaID *string if args.Persona != "" { pID, err := t.stores.Personas.FindActiveByHandle(ctx, args.Persona) if err == nil && pID != "" { personaID = &pID } } // Inherit scope from channel context scope := "personal" var teamID *string if execCtx.TeamID != "" { scope = "team" teamID = &execCtx.TeamID } // Build task task := &models.Task{ OwnerID: execCtx.UserID, TeamID: teamID, Name: args.Name, Scope: scope, TaskType: "prompt", PersonaID: personaID, ModelID: args.Model, SystemPrompt: args.SystemPrompt, UserPrompt: args.Prompt, Schedule: args.Schedule, Timezone: "UTC", IsActive: true, OutputMode: "channel", NotifyOnFailure: true, } if args.MaxTokens > 0 { task.MaxTokens = args.MaxTokens } // Apply global defaults for zero-value budgets cfg := taskutil.LoadTaskConfig(ctx, t.stores.GlobalConfig) cfg.ApplyDefaults(task) // Compute next_run_at if args.Schedule == "once" { now := time.Now().UTC() task.NextRunAt = &now } else { task.NextRunAt = taskutil.NextRunFromSchedule(args.Schedule, "UTC") } if err := t.stores.Tasks.Create(ctx, task); err != nil { return "", fmt.Errorf("failed to create task: %w", err) } log.Printf("[task_create] AI spawned task %s (%s) schedule=%s scope=%s", task.ID, task.Name, args.Schedule, scope) result, _ := json.Marshal(map[string]interface{}{ "task_id": task.ID, "name": task.Name, "schedule": args.Schedule, "scope": scope, "next_run_at": task.NextRunAt, "message": fmt.Sprintf("Task '%s' created successfully. It will run %s.", task.Name, scheduleDesc(args.Schedule)), }) return string(result), nil } func scheduleDesc(schedule string) string { if schedule == "once" { return "immediately (one-shot)" } return "on schedule: " + schedule }