This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/tools/task_create.go
2026-03-11 09:12:57 +00:00

200 lines
6.2 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/taskutil"
"git.gobha.me/xcaliber/chat-switchboard/webhook"
)
// ── 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")
}
// 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 != "" {
var channelType string
_ = database.DB.QueryRowContext(ctx, database.Q(
`SELECT type FROM channels WHERE id = $1`,
), execCtx.ChannelID).Scan(&channelType)
if channelType == "service" {
return "", fmt.Errorf("tasks cannot create tasks (depth limit)")
}
}
// Resolve persona ID from handle
var personaID *string
if args.Persona != "" {
var pID string
err := database.DB.QueryRowContext(ctx, database.Q(
`SELECT id FROM personas WHERE LOWER(handle) = LOWER($1) AND is_active = true`,
), args.Persona).Scan(&pID)
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)
// Generate webhook secret in case webhook is added later
task.WebhookSecret = webhook.GenerateSecret()
// 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
}