mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
c47f5fd2c4
When TargetAgentID is set, spawnSubTurn resolves the target AgentInstance from the registry and uses it as the base for the child turn. This gives the child turn the target's workspace, model, tools, and system prompt instead of inheriting from the caller. Model validation is relaxed: empty Model is accepted when TargetAgentID provides the model implicitly via the resolved agent instance. Ref: #2148
457 lines
12 KiB
Go
457 lines
12 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
)
|
|
|
|
// SubTurnSpawner is an interface for spawning sub-turns.
|
|
// This avoids circular dependency between tools and agent packages.
|
|
type SubTurnSpawner interface {
|
|
SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*ToolResult, error)
|
|
}
|
|
|
|
// SubTurnConfig holds configuration for spawning a sub-turn.
|
|
type SubTurnConfig struct {
|
|
Model string
|
|
Tools []Tool
|
|
SystemPrompt string
|
|
MaxTokens int
|
|
Temperature float64
|
|
Async bool // true for async (spawn), false for sync (subagent)
|
|
Critical bool // continue running after parent finishes gracefully
|
|
Timeout time.Duration // 0 = use default (5 minutes)
|
|
MaxContextRunes int // 0 = auto, -1 = no limit, >0 = explicit limit
|
|
ActualSystemPrompt string
|
|
InitialMessages []providers.Message
|
|
InitialTokenBudget *atomic.Int64 // Shared token budget for team members; nil if no budget
|
|
TargetAgentID string // If set, run as this agent (its workspace, model, tools)
|
|
}
|
|
|
|
type SubagentTask struct {
|
|
ID string
|
|
Task string
|
|
Label string
|
|
AgentID string
|
|
OriginChannel string
|
|
OriginChatID string
|
|
Status string
|
|
Result string
|
|
Created int64
|
|
}
|
|
|
|
type SpawnSubTurnFunc func(
|
|
ctx context.Context,
|
|
task, label, agentID string,
|
|
tools *ToolRegistry,
|
|
maxTokens int,
|
|
temperature float64,
|
|
hasMaxTokens, hasTemperature bool,
|
|
) (*ToolResult, error)
|
|
|
|
type SubagentManager struct {
|
|
tasks map[string]*SubagentTask
|
|
mu sync.RWMutex
|
|
provider providers.LLMProvider
|
|
defaultModel string
|
|
workspace string
|
|
tools *ToolRegistry
|
|
maxIterations int
|
|
maxTokens int
|
|
temperature float64
|
|
hasMaxTokens bool
|
|
hasTemperature bool
|
|
nextID int
|
|
spawner SpawnSubTurnFunc
|
|
|
|
// mediaResolver resolves media:// refs in tool-loop messages before
|
|
// each LLM call in the legacy RunToolLoop fallback path.
|
|
// This lets subagents reuse the same media handling behavior as the
|
|
// main agent loop without importing pkg/agent and creating a cycle.
|
|
mediaResolver func([]providers.Message) []providers.Message
|
|
}
|
|
|
|
func NewSubagentManager(
|
|
provider providers.LLMProvider,
|
|
defaultModel, workspace string,
|
|
) *SubagentManager {
|
|
return &SubagentManager{
|
|
tasks: make(map[string]*SubagentTask),
|
|
provider: provider,
|
|
defaultModel: defaultModel,
|
|
workspace: workspace,
|
|
tools: NewToolRegistry(),
|
|
maxIterations: 10,
|
|
nextID: 1,
|
|
}
|
|
}
|
|
|
|
func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
sm.spawner = spawner
|
|
}
|
|
|
|
// SetMediaResolver injects a message preprocessor that resolves media:// refs
|
|
// into LLM-ready content before each tool-loop iteration.
|
|
// This is only used by the legacy RunToolLoop fallback path.
|
|
func (sm *SubagentManager) SetMediaResolver(
|
|
resolver func([]providers.Message) []providers.Message,
|
|
) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
sm.mediaResolver = resolver
|
|
}
|
|
|
|
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
|
|
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
sm.maxTokens = maxTokens
|
|
sm.hasMaxTokens = true
|
|
sm.temperature = temperature
|
|
sm.hasTemperature = true
|
|
}
|
|
|
|
// SetTools sets the tool registry for subagent execution.
|
|
// If not set, subagent will have access to the provided tools.
|
|
func (sm *SubagentManager) SetTools(tools *ToolRegistry) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
sm.tools = tools
|
|
}
|
|
|
|
// RegisterTool registers a tool for subagent execution.
|
|
func (sm *SubagentManager) RegisterTool(tool Tool) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
sm.tools.Register(tool)
|
|
}
|
|
|
|
func (sm *SubagentManager) Spawn(
|
|
ctx context.Context,
|
|
task, label, agentID, originChannel, originChatID string,
|
|
callback AsyncCallback,
|
|
) (string, error) {
|
|
sm.mu.Lock()
|
|
defer sm.mu.Unlock()
|
|
|
|
taskID := fmt.Sprintf("subagent-%d", sm.nextID)
|
|
sm.nextID++
|
|
|
|
subagentTask := &SubagentTask{
|
|
ID: taskID,
|
|
Task: task,
|
|
Label: label,
|
|
AgentID: agentID,
|
|
OriginChannel: originChannel,
|
|
OriginChatID: originChatID,
|
|
Status: "running",
|
|
Created: time.Now().UnixMilli(),
|
|
}
|
|
sm.tasks[taskID] = subagentTask
|
|
|
|
// Start task in background with context cancellation support
|
|
go sm.runTask(ctx, subagentTask, callback)
|
|
|
|
if label != "" {
|
|
return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil
|
|
}
|
|
return fmt.Sprintf("Spawned subagent for task: %s", task), nil
|
|
}
|
|
|
|
func (sm *SubagentManager) runTask(
|
|
ctx context.Context,
|
|
task *SubagentTask,
|
|
callback AsyncCallback,
|
|
) {
|
|
task.Status = "running"
|
|
task.Created = time.Now().UnixMilli()
|
|
// TODO(eventbus): once subagents are modeled as child turns inside
|
|
// pkg/agent, emit SubTurnEnd and SubTurnResultDelivered from the parent
|
|
// AgentLoop instead of this legacy manager.
|
|
|
|
// Check if context is already canceled before starting
|
|
select {
|
|
case <-ctx.Done():
|
|
sm.mu.Lock()
|
|
task.Status = "canceled"
|
|
task.Result = "Task canceled before execution"
|
|
sm.mu.Unlock()
|
|
return
|
|
default:
|
|
}
|
|
|
|
sm.mu.RLock()
|
|
spawner := sm.spawner
|
|
tools := sm.tools
|
|
maxIter := sm.maxIterations
|
|
maxTokens := sm.maxTokens
|
|
temperature := sm.temperature
|
|
hasMaxTokens := sm.hasMaxTokens
|
|
hasTemperature := sm.hasTemperature
|
|
mediaResolver := sm.mediaResolver
|
|
sm.mu.RUnlock()
|
|
|
|
var result *ToolResult
|
|
var err error
|
|
|
|
if spawner != nil {
|
|
result, err = spawner(
|
|
ctx,
|
|
task.Task,
|
|
task.Label,
|
|
task.AgentID,
|
|
tools,
|
|
maxTokens,
|
|
temperature,
|
|
hasMaxTokens,
|
|
hasTemperature,
|
|
)
|
|
} else {
|
|
// Fallback to legacy RunToolLoop
|
|
systemPrompt := `You are a subagent. Complete the given task independently and report the result.
|
|
You have access to tools - use them as needed to complete your task.
|
|
After completing the task, provide a clear summary of what was done.`
|
|
|
|
messages := []providers.Message{
|
|
{Role: "system", Content: systemPrompt},
|
|
{Role: "user", Content: task.Task},
|
|
}
|
|
|
|
var llmOptions map[string]any
|
|
if hasMaxTokens || hasTemperature {
|
|
llmOptions = map[string]any{}
|
|
if hasMaxTokens {
|
|
llmOptions["max_tokens"] = maxTokens
|
|
}
|
|
if hasTemperature {
|
|
llmOptions["temperature"] = temperature
|
|
}
|
|
}
|
|
|
|
var loopResult *ToolLoopResult
|
|
loopResult, err = RunToolLoop(ctx, ToolLoopConfig{
|
|
Provider: sm.provider,
|
|
Model: sm.defaultModel,
|
|
Tools: tools,
|
|
MaxIterations: maxIter,
|
|
LLMOptions: llmOptions,
|
|
MediaResolver: mediaResolver,
|
|
}, messages, task.OriginChannel, task.OriginChatID)
|
|
|
|
if err == nil {
|
|
result = &ToolResult{
|
|
ForLLM: fmt.Sprintf(
|
|
"Subagent '%s' completed (iterations: %d): %s",
|
|
task.Label,
|
|
loopResult.Iterations,
|
|
loopResult.Content,
|
|
),
|
|
ForUser: loopResult.Content,
|
|
Silent: false,
|
|
IsError: false,
|
|
Async: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
sm.mu.Lock()
|
|
defer func() {
|
|
sm.mu.Unlock()
|
|
// Call callback if provided and result is set
|
|
if callback != nil && result != nil {
|
|
callback(ctx, result)
|
|
}
|
|
}()
|
|
|
|
if err != nil {
|
|
task.Status = "failed"
|
|
task.Result = fmt.Sprintf("Error: %v", err)
|
|
// Check if it was canceled
|
|
if ctx.Err() != nil {
|
|
task.Status = "canceled"
|
|
task.Result = "Task canceled during execution"
|
|
}
|
|
result = &ToolResult{
|
|
ForLLM: task.Result,
|
|
ForUser: "",
|
|
Silent: false,
|
|
IsError: true,
|
|
Async: false,
|
|
Err: err,
|
|
}
|
|
} else {
|
|
task.Status = "completed"
|
|
task.Result = result.ForLLM
|
|
}
|
|
}
|
|
|
|
func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
task, ok := sm.tasks[taskID]
|
|
return task, ok
|
|
}
|
|
|
|
// GetTaskCopy returns a copy of the task with the given ID, taken under the
|
|
// read lock, so the caller receives a consistent snapshot with no data race.
|
|
func (sm *SubagentManager) GetTaskCopy(taskID string) (SubagentTask, bool) {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
task, ok := sm.tasks[taskID]
|
|
if !ok {
|
|
return SubagentTask{}, false
|
|
}
|
|
return *task, true
|
|
}
|
|
|
|
func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
|
|
tasks := make([]*SubagentTask, 0, len(sm.tasks))
|
|
for _, task := range sm.tasks {
|
|
tasks = append(tasks, task)
|
|
}
|
|
return tasks
|
|
}
|
|
|
|
// ListTaskCopies returns value copies of all tasks, taken under the read lock,
|
|
// so callers receive consistent snapshots with no data race.
|
|
func (sm *SubagentManager) ListTaskCopies() []SubagentTask {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
|
|
copies := make([]SubagentTask, 0, len(sm.tasks))
|
|
for _, task := range sm.tasks {
|
|
copies = append(copies, *task)
|
|
}
|
|
return copies
|
|
}
|
|
|
|
// SubagentTool executes a subagent task synchronously and returns the result.
|
|
// It directly calls SubTurnSpawner with Async=false for synchronous execution.
|
|
type SubagentTool struct {
|
|
spawner SubTurnSpawner
|
|
defaultModel string
|
|
maxTokens int
|
|
temperature float64
|
|
}
|
|
|
|
func NewSubagentTool(manager *SubagentManager) *SubagentTool {
|
|
if manager == nil {
|
|
return &SubagentTool{}
|
|
}
|
|
return &SubagentTool{
|
|
defaultModel: manager.defaultModel,
|
|
maxTokens: manager.maxTokens,
|
|
temperature: manager.temperature,
|
|
}
|
|
}
|
|
|
|
// SetSpawner sets the SubTurnSpawner for direct sub-turn execution.
|
|
func (t *SubagentTool) SetSpawner(spawner SubTurnSpawner) {
|
|
t.spawner = spawner
|
|
}
|
|
|
|
func (t *SubagentTool) Name() string {
|
|
return "subagent"
|
|
}
|
|
|
|
func (t *SubagentTool) Description() string {
|
|
return "Execute a subagent task synchronously and return the result. Use this for delegating specific tasks to an independent agent instance. Returns execution summary to user and full details to LLM."
|
|
}
|
|
|
|
func (t *SubagentTool) 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)",
|
|
},
|
|
},
|
|
"required": []string{"task"},
|
|
}
|
|
}
|
|
|
|
func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
|
task, ok := args["task"].(string)
|
|
if !ok {
|
|
return ErrorResult("task is required").WithError(fmt.Errorf("task parameter is required"))
|
|
}
|
|
|
|
label, _ := args["label"].(string)
|
|
|
|
// Build system prompt for subagent
|
|
systemPrompt := fmt.Sprintf(
|
|
`You are a subagent. Complete the given task independently and provide a clear, concise result.
|
|
|
|
Task: %s`,
|
|
task,
|
|
)
|
|
|
|
if label != "" {
|
|
systemPrompt = fmt.Sprintf(
|
|
`You are a subagent labeled "%s". Complete the given task independently and provide a clear, concise result.
|
|
|
|
Task: %s`,
|
|
label,
|
|
task,
|
|
)
|
|
}
|
|
|
|
// Use spawner if available (direct SpawnSubTurn call)
|
|
if t.spawner != nil {
|
|
result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{
|
|
Model: t.defaultModel,
|
|
Tools: nil, // Will inherit from parent via context
|
|
SystemPrompt: systemPrompt,
|
|
MaxTokens: t.maxTokens,
|
|
Temperature: t.temperature,
|
|
Async: false, // Synchronous execution
|
|
})
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
|
|
}
|
|
|
|
// Format result for display
|
|
userContent := result.ForLLM
|
|
if result.ForUser != "" {
|
|
userContent = result.ForUser
|
|
}
|
|
maxUserLen := 500
|
|
if len(userContent) > maxUserLen {
|
|
userContent = userContent[:maxUserLen] + "..."
|
|
}
|
|
|
|
labelStr := label
|
|
if labelStr == "" {
|
|
labelStr = "(unnamed)"
|
|
}
|
|
llmContent := fmt.Sprintf("Subagent task completed:\nLabel: %s\nResult: %s",
|
|
labelStr, result.ForLLM)
|
|
|
|
return &ToolResult{
|
|
ForLLM: llmContent,
|
|
ForUser: userContent,
|
|
Silent: false,
|
|
IsError: result.IsError,
|
|
Async: false,
|
|
}
|
|
}
|
|
|
|
// Fallback: spawner not configured
|
|
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("spawner not set"))
|
|
}
|