Changeset 0.27.3 (#170)
This commit is contained in:
199
server/tools/task_create.go
Normal file
199
server/tools/task_create.go
Normal file
@@ -0,0 +1,199 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user