feat(agent): wire SubTurn into AgentLoop and Spawn Tool

- Add subTurnResults sync.Map to AgentLoop for per-session channel tracking
- Add register/unregister/dequeue methods in steering.go
- Poll SubTurn results in runLLMIteration at loop start and after each tool,
  injecting results as [SubTurn Result] messages into parent conversation
- Initialize root turnState in runAgentLoop, propagate via context
  (withTurnState/turnStateFromContext), call rootTS.Finish() on completion
- Wire Spawn Tool to spawnSubTurn via SetSpawner in registerSharedTools,
  recovering parentTS from context for proper turn hierarchy
- Refactor subagent.go to use SetSpawner pattern
- Add TestSubTurnResultChannelRegistration and TestDequeuePendingSubTurnResults
This commit is contained in:
Administrator
2026-03-16 17:27:04 +08:00
parent ae23193295
commit ceeae15d8a
5 changed files with 348 additions and 73 deletions
+111 -64
View File
@@ -21,6 +21,15 @@ type SubagentTask struct {
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
@@ -34,6 +43,7 @@ type SubagentManager struct {
hasMaxTokens bool
hasTemperature bool
nextID int
spawner SpawnSubTurnFunc
}
func NewSubagentManager(
@@ -51,6 +61,12 @@ func NewSubagentManager(
}
}
func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.spawner = spawner
}
// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
sm.mu.Lock()
@@ -112,22 +128,6 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
task.Status = "running"
task.Created = time.Now().UnixMilli()
// Build system prompt for subagent
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,
},
}
// Check if context is already canceled before starting
select {
case <-ctx.Done():
@@ -139,8 +139,8 @@ After completing the task, provide a clear summary of what was done.`
default:
}
// Run tool loop with access to tools
sm.mu.RLock()
spawner := sm.spawner
tools := sm.tools
maxIter := sm.maxIterations
maxTokens := sm.maxTokens
@@ -149,27 +149,59 @@ After completing the task, provide a clear summary of what was done.`
hasTemperature := sm.hasTemperature
sm.mu.RUnlock()
var llmOptions map[string]any
if hasMaxTokens || hasTemperature {
llmOptions = map[string]any{}
if hasMaxTokens {
llmOptions["max_tokens"] = maxTokens
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},
}
if hasTemperature {
llmOptions["temperature"] = temperature
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,
}, 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,
}
}
}
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
Provider: sm.provider,
Model: sm.defaultModel,
Tools: tools,
MaxIterations: maxIter,
LLMOptions: llmOptions,
}, messages, task.OriginChannel, task.OriginChatID)
sm.mu.Lock()
var result *ToolResult
defer func() {
sm.mu.Unlock()
// Call callback if provided and result is set
@@ -196,19 +228,7 @@ After completing the task, provide a clear summary of what was done.`
}
} else {
task.Status = "completed"
task.Result = loopResult.Content
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,
}
task.Result = result.ForLLM
}
}
@@ -231,8 +251,6 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask {
}
// SubagentTool executes a subagent task synchronously and returns the result.
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
// and returns the result directly in the ToolResult.
type SubagentTool struct {
manager *SubagentManager
}
@@ -280,7 +298,51 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
}
// Build messages for subagent
sm := t.manager
sm.mu.RLock()
spawner := sm.spawner
tools := sm.tools
maxIter := sm.maxIterations
maxTokens := sm.maxTokens
temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature
sm.mu.RUnlock()
if spawner != nil {
// Use spawner
res, err := spawner(ctx, task, label, "", tools, maxTokens, temperature, hasMaxTokens, hasTemperature)
if err != nil {
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
}
// Ensure synchronous ForUser display truncates
userContent := res.ForLLM
if res.ForUser != "" {
userContent = res.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, res.ForLLM)
return &ToolResult{
ForLLM: llmContent,
ForUser: userContent,
Silent: false,
IsError: res.IsError,
Async: false,
}
}
// Build messages for subagent fallback
messages := []providers.Message{
{
Role: "system",
@@ -292,17 +354,6 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
},
}
// Use RunToolLoop to execute with tools (same as async SpawnTool)
sm := t.manager
sm.mu.RLock()
tools := sm.tools
maxIter := sm.maxIterations
maxTokens := sm.maxTokens
temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature
sm.mu.RUnlock()
var llmOptions map[string]any
if hasMaxTokens || hasTemperature {
llmOptions = map[string]any{}
@@ -314,8 +365,6 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
}
}
// Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests)
// to preserve the same defaults as the original NewSubagentTool constructor.
channel := ToolChannel(ctx)
if channel == "" {
channel = "cli"
@@ -336,14 +385,12 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
}
// ForUser: Brief summary for user (truncated if too long)
userContent := loopResult.Content
maxUserLen := 500
if len(userContent) > maxUserLen {
userContent = userContent[:maxUserLen] + "..."
}
// ForLLM: Full execution details
labelStr := label
if labelStr == "" {
labelStr = "(unnamed)"