- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
195 lines
6.1 KiB
Go
195 lines
6.1 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"switchboard-core/models"
|
|
"switchboard-core/store"
|
|
"switchboard-core/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
|
|
}
|