Files
goclaw/internal/tools/subagent_spawn_tool.go
T
viettranx 7aa47d1baf refactor(security): remove cross-tenant bypass, enforce strict tenant isolation
- Add RoleOwner ("owner") to permissions hierarchy (above admin)
- Replace all IsCrossTenant/client.IsCrossTenant() permission guards with
  IsOwnerRole/client.IsOwner() role-based checks (20+ locations)
- Remove cross-tenant bypass from tenantClauseN SQL helper — always adds
  AND tenant_id = $N (fail-closed)
- Add explicit unscoped store methods for startup: ListAllProviders,
  ListAllInstances, ListAllEnabled, GetByIDUnscoped, GetTeamUnscoped
- Remove CrossTenant from auth layer: owner users get role "owner" with
  concrete tenant ID (MasterTenantID fallback), no bypass flag
- Remove crossTenant field from WS Client; connect response sends
  is_owner instead of cross_tenant
- Simplify event filter from 3-mode to 2-mode (tenant-scoped + owner sees system events)
- Fix channel instance loader to use inst.TenantID for agent lookup
- Fix agent embedding goroutine to capture tenant from caller context
- Fix provider cache invalidation to use event.TenantID
- Separate skills store: ListAllSystemSkills (system only) vs
  ListAllSkills (system + tenant-scoped), StoreMissingDeps restricted
  to system skills only
- Frontend: isCrossTenant → isOwner, add "owner" to UserRole type,
  RequireAdmin uses hasMinRole for owner > admin hierarchy
2026-03-24 20:40:59 +07:00

292 lines
8.4 KiB
Go

package tools
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SpawnTool spawns subagent clones to handle tasks in the background.
//
// Routing:
// - mode="async" (default): return immediately, subagent announces result when done
// - mode="sync": block until done, return result inline
type SpawnTool struct {
subagentMgr *SubagentManager
parentID string
depth int
}
func NewSpawnTool(manager *SubagentManager, parentID string, depth int) *SpawnTool {
return &SpawnTool{
subagentMgr: manager,
parentID: parentID,
depth: depth,
}
}
func (t *SpawnTool) Name() string { return "spawn" }
func (t *SpawnTool) Description() string {
return "Spawn a subagent to handle a task in the background. The subagent runs independently and reports back when done."
}
func (t *SpawnTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"description": "'spawn' (default), 'list', 'cancel', or 'steer'",
},
"task": map[string]any{
"type": "string",
"description": "The task to complete (required for action=spawn)",
},
"mode": map[string]any{
"type": "string",
"description": "'async' (default, returns immediately) or 'sync' (blocks until done)",
},
"label": map[string]any{
"type": "string",
"description": "Short label for the task (for display)",
},
"model": map[string]any{
"type": "string",
"description": "Optional model override (e.g. 'anthropic/claude-sonnet-4-5-20250929')",
},
"id": map[string]any{
"type": "string",
"description": "Task ID for cancel/steer. For cancel: use 'all' to cancel all or 'last' for most recent",
},
"message": map[string]any{
"type": "string",
"description": "New instructions (required for action=steer)",
},
},
"required": []string{"task"},
}
}
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *Result {
action, _ := args["action"].(string)
if action == "" {
action = "spawn"
}
switch action {
case "list":
return t.executeList(ctx)
case "cancel":
return t.executeCancel(ctx, args)
case "steer":
return t.executeSteer(ctx, args)
default:
return t.executeSpawn(ctx, args)
}
}
func (t *SpawnTool) executeSpawn(ctx context.Context, args map[string]any) *Result {
// Reject legacy "agent" parameter — delegation was removed.
// Guide the LLM to use team_tasks for team coordination.
if agentKey, _ := args["agent"].(string); agentKey != "" {
return ErrorResult(fmt.Sprintf(
"spawn does not accept 'agent' parameter. spawn is for self-clone subagent only. "+
"To delegate work to team member %q, use: team_tasks(action=\"create\", subject=\"...\", description=\"...\", assignee=%q)",
agentKey, agentKey))
}
// Validate tenant isolation: callers must have a tenant in context.
// Self-clone subagents inherit caller's context (WithoutCancel), so tenant propagates automatically.
if store.TenantIDFromContext(ctx) == uuid.Nil {
return ErrorResult("spawn requires tenant context: no tenant ID found in request context")
}
task, _ := args["task"].(string)
if task == "" {
return ErrorResult("task parameter is required")
}
mode, _ := args["mode"].(string)
if mode == "sync" {
return t.executeSubagentSync(ctx, args, task)
}
return t.executeSubagentAsync(ctx, args, task)
}
// executeSubagentAsync spawns an async self-clone.
func (t *SpawnTool) executeSubagentAsync(ctx context.Context, args map[string]any, task string) *Result {
label, _ := args["label"].(string)
modelOverride, _ := args["model"].(string)
channel := ToolChannelFromCtx(ctx)
chatID := ToolChatIDFromCtx(ctx)
peerKind := ToolPeerKindFromCtx(ctx)
callback := ToolAsyncCBFromCtx(ctx)
parentID := ToolAgentKeyFromCtx(ctx)
if parentID == "" {
parentID = t.parentID
}
msg, err := t.subagentMgr.Spawn(ctx, parentID, t.depth, task, label, modelOverride,
channel, chatID, peerKind, callback)
if err != nil {
return ErrorResult(err.Error())
}
forLLM := fmt.Sprintf(`{"status":"accepted","label":%q}
%s
After all spawn tool calls in this turn are complete, briefly tell the user what tasks you've started. Subagents will announce results when done — do NOT wait or poll.`, label, msg)
return AsyncResult(forLLM)
}
// executeSubagentSync runs a sync self-clone.
func (t *SpawnTool) executeSubagentSync(ctx context.Context, args map[string]any, task string) *Result {
label, _ := args["label"].(string)
if label == "" {
label = truncate(task, 50)
}
channel := ToolChannelFromCtx(ctx)
chatID := ToolChatIDFromCtx(ctx)
parentID := ToolAgentKeyFromCtx(ctx)
if parentID == "" {
parentID = t.parentID
}
result, iterations, err := t.subagentMgr.RunSync(ctx, parentID, t.depth, task, label,
channel, chatID)
if err != nil {
return ErrorResult(fmt.Sprintf("Subagent '%s' failed: %v", label, err))
}
forUser := fmt.Sprintf("Subagent '%s' completed.", label)
if len(result) > 500 {
forUser += "\n" + result[:500] + "..."
} else {
forUser += "\n" + result
}
forLLM := fmt.Sprintf("Subagent '%s' completed in %d iterations.\n\nFull result:\n%s",
label, iterations, result)
return &Result{ForLLM: forLLM, ForUser: forUser}
}
// executeList shows active subagent tasks.
func (t *SpawnTool) executeList(ctx context.Context) *Result {
parentID := ToolAgentKeyFromCtx(ctx)
if parentID == "" {
parentID = t.parentID
}
tasks := t.subagentMgr.ListTasks(parentID)
if len(tasks) == 0 {
return &Result{ForLLM: "No active tasks found."}
}
var lines []string
running, completed, cancelled := 0, 0, 0
for _, task := range tasks {
switch task.Status {
case "running":
running++
case "completed":
completed++
case "cancelled":
cancelled++
}
line := fmt.Sprintf("- [%s] %s (id=%s, status=%s)", task.Label, truncate(task.Task, 60), task.ID, task.Status)
if task.CompletedAt > 0 {
dur := time.Duration(task.CompletedAt-task.CreatedAt) * time.Millisecond
line += fmt.Sprintf(", took %s", dur.Round(time.Millisecond))
}
lines = append(lines, line)
}
return &Result{ForLLM: fmt.Sprintf("Subagent tasks: %d running, %d completed, %d cancelled\n%s",
running, completed, cancelled, strings.Join(lines, "\n"))}
}
// executeCancel cancels a subagent task by ID.
func (t *SpawnTool) executeCancel(ctx context.Context, args map[string]any) *Result {
id, _ := args["id"].(string)
if id == "" {
return ErrorResult("id is required for action=cancel")
}
if t.subagentMgr.CancelTask(id) {
return &Result{ForLLM: fmt.Sprintf("Task '%s' cancelled.", id)}
}
return ErrorResult(fmt.Sprintf("Task '%s' not found or not running.", id))
}
// executeSteer redirects a running subagent with new instructions.
func (t *SpawnTool) executeSteer(ctx context.Context, args map[string]any) *Result {
id, _ := args["id"].(string)
if id == "" {
return ErrorResult("id is required for action=steer")
}
message, _ := args["message"].(string)
if message == "" {
return ErrorResult("message is required for action=steer")
}
msg, err := t.subagentMgr.Steer(ctx, id, message, nil)
if err != nil {
return ErrorResult(err.Error())
}
return &Result{ForLLM: msg}
}
// SetContext is a no-op; channel/chatID are now read from ctx (thread-safe).
func (t *SpawnTool) SetContext(channel, chatID string) {}
// SetPeerKind is a no-op; peerKind is now read from ctx (thread-safe).
func (t *SpawnTool) SetPeerKind(peerKind string) {}
// SetCallback is a no-op; callback is now read from ctx (thread-safe).
func (t *SpawnTool) SetCallback(cb AsyncCallback) {}
// --- Helpers moved from old subagent_tool.go ---
// FilterDenyList returns tool names from the registry excluding denied tools.
func FilterDenyList(reg *Registry, denyList []string) []string {
deny := make(map[string]bool, len(denyList))
for _, n := range denyList {
deny[n] = true
}
var allowed []string
for _, name := range reg.List() {
if !deny[name] {
allowed = append(allowed, name)
}
}
return allowed
}
// IsSubagentDenied checks if a tool name is in the subagent deny list.
func IsSubagentDenied(toolName string, depth, maxDepth int) bool {
for _, d := range SubagentDenyAlways {
if strings.EqualFold(toolName, d) {
return true
}
}
if depth >= maxDepth {
for _, d := range SubagentDenyLeaf {
if strings.EqualFold(toolName, d) {
return true
}
}
}
return false
}