picoclaw/pkg/tools/spawn.go
2026-05-11 13:51:55 -07:00

204 lines
5.6 KiB
Go

package tools
import (
"context"
"fmt"
"strings"
)
type SpawnTool struct {
manager *SubagentManager
spawner SubTurnSpawner
defaultModel string
maxTokens int
temperature float64
allowlistCheck func(targetAgentID string) bool
}
// Compile-time check: SpawnTool implements AsyncExecutor.
var _ AsyncExecutor = (*SpawnTool)(nil)
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
if manager == nil {
return &SpawnTool{}
}
return &SpawnTool{
manager: manager,
defaultModel: manager.defaultModel,
maxTokens: manager.maxTokens,
temperature: manager.temperature,
}
}
// SetSpawner sets the SubTurnSpawner for direct sub-turn execution.
func (t *SpawnTool) SetSpawner(spawner SubTurnSpawner) {
t.spawner = spawner
if t.manager != nil && spawner != nil {
t.manager.SetSpawner(func(
ctx context.Context,
task, label, agentID string,
tools *ToolRegistry,
maxTokens int,
temperature float64,
hasMaxTokens, hasTemperature bool,
) (*ToolResult, error) {
return spawner.SpawnSubTurn(ctx, SubTurnConfig{
TargetAgentID: strings.TrimSpace(agentID),
Model: t.defaultModel,
Tools: nil,
SystemPrompt: buildSpawnSystemPrompt(task, label),
MaxTokens: maxTokens,
Temperature: temperature,
Async: false,
Critical: true,
})
})
}
}
func (t *SpawnTool) Name() string {
return "spawn"
}
func (t *SpawnTool) Description() string {
return "Spawn a subagent to handle a task in the background. Use this for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done. Optional delivery_mode controls whether the final async result goes to the user, the parent agent, or both."
}
func (t *SpawnTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"task": map[string]any{
"type": "string",
"description": "The task for subagent to complete",
},
"label": map[string]any{
"type": "string",
"description": "Optional short label for the task (for display)",
},
"agent_id": map[string]any{
"type": "string",
"description": "Optional target agent ID to delegate the task to",
},
"delivery_mode": map[string]any{
"type": "string",
"description": "Optional async result routing policy: user_only, parent_only, or user_and_parent. Defaults to user_only.",
"enum": []string{
string(AsyncDeliveryUserOnly),
string(AsyncDeliveryParentOnly),
string(AsyncDeliveryUserAndParent),
},
},
},
"required": []string{"task"},
}
}
func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
t.allowlistCheck = check
}
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
return t.execute(ctx, args, nil)
}
// ExecuteAsync implements AsyncExecutor. The callback is passed through to the
// subagent manager as a call parameter — never stored on the SpawnTool instance.
func (t *SpawnTool) ExecuteAsync(
ctx context.Context,
args map[string]any,
cb AsyncCallback,
) *ToolResult {
return t.execute(ctx, args, cb)
}
func (t *SpawnTool) execute(
ctx context.Context,
args map[string]any,
cb AsyncCallback,
) *ToolResult {
task, ok := args["task"].(string)
if !ok || strings.TrimSpace(task) == "" {
return ErrorResult("task is required and must be a non-empty string")
}
label, _ := args["label"].(string)
agentID, _ := args["agent_id"].(string)
targetAgentID := strings.TrimSpace(agentID)
deliveryMode, err := parseSpawnDeliveryMode(args["delivery_mode"])
if err != nil {
return ErrorResult(err.Error()).WithError(err)
}
// Check allowlist if targeting a specific agent
if targetAgentID != "" && t.allowlistCheck != nil {
if !t.allowlistCheck(targetAgentID) {
return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", targetAgentID))
}
}
// Preferred path: route through SubagentManager so spawn_status and
// background execution share the same task registry.
if t.manager != nil {
wrappedCallback := cb
if cb != nil {
wrappedCallback = func(cbCtx context.Context, res *ToolResult) {
if res != nil {
res.WithAsyncDelivery(deliveryMode)
}
cb(cbCtx, res)
}
}
ack, err := t.manager.Spawn(
ctx,
task,
label,
strings.TrimSpace(agentID),
ToolChannel(ctx),
ToolChatID(ctx),
wrappedCallback,
)
if err != nil {
return ErrorResult(fmt.Sprintf("Spawn failed: %v", err)).WithError(err)
}
return AsyncResult(ack)
}
// Fallback: manager not configured
return ErrorResult("Subagent manager not configured")
}
func parseSpawnDeliveryMode(raw any) (AsyncDeliveryMode, error) {
if raw == nil {
return AsyncDeliveryUserOnly, nil
}
value, ok := raw.(string)
if !ok {
return "", fmt.Errorf("delivery_mode must be a string")
}
switch AsyncDeliveryMode(strings.TrimSpace(value)) {
case AsyncDeliveryUserOnly, AsyncDeliveryParentOnly, AsyncDeliveryUserAndParent:
return AsyncDeliveryMode(strings.TrimSpace(value)), nil
case "":
return AsyncDeliveryUserOnly, nil
default:
return "", fmt.Errorf("delivery_mode must be one of: user_only, parent_only, user_and_parent")
}
}
func buildSpawnSystemPrompt(task, label string) string {
if label != "" {
return fmt.Sprintf(
`You are a spawned subagent labeled "%s" running in the background. Complete the given task independently and report back when done.
Task: %s`,
label,
task,
)
}
return fmt.Sprintf(
`You are a spawned subagent running in the background. Complete the given task independently and report back when done.
Task: %s`,
task,
)
}