feat(team): enhance team tool with sub-turn spawner integration and improve message handling

This commit is contained in:
Administrator 2026-03-23 22:49:46 +08:00
parent f6aa1b2d36
commit 89e124caf9
6 changed files with 241 additions and 172 deletions

View file

@ -268,22 +268,18 @@ func registerSharedTools(
} }
} }
// Team and spawn_sub_agent tools // Team tool
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Candidates, agent.Workspace, cfg.Tools.Team, msgBus) teamSubagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Candidates, agent.Workspace, cfg.Tools.Team, msgBus)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) teamSubagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
teamTool := tools.NewTeamTool(subagentManager, cfg) teamTool := tools.NewTeamTool(teamSubagentManager, cfg)
if cfg.Tools.IsToolEnabled("team") { if cfg.Tools.IsToolEnabled("team") {
teamTool.SetSpawner(NewSubTurnSpawner(al))
agent.Tools.Register(teamTool) agent.Tools.Register(teamTool)
} }
spawnSubAgentTool := tools.NewSpawnSubAgentTool(subagentManager) // Share the fully-built registry back to team subagent manager
if cfg.Tools.IsToolEnabled("spawn_sub_agent") { teamSubagentManager.SetTools(agent.Tools)
agent.Tools.Register(spawnSubAgentTool)
}
// Share the fully-built registry back to subagent manager
subagentManager.SetTools(agent.Tools)
// Spawn and spawn_status tools share a SubagentManager. // Spawn and spawn_status tools share a SubagentManager.
// Construct it when either tool is enabled (both require subagent). // Construct it when either tool is enabled (both require subagent).
@ -2595,6 +2591,7 @@ turnLoop:
finalContent: finalContent, finalContent: finalContent,
status: turnStatus, status: turnStatus,
followUps: append([]bus.InboundMessage(nil), ts.followUps...), followUps: append([]bus.InboundMessage(nil), ts.followUps...),
messages: append([]providers.Message(nil), messages...),
}, nil }, nil
} }

View file

@ -102,7 +102,9 @@ type subTurnRuntimeConfig struct {
// // Parent turn will poll and process it in a later iteration // // Parent turn will poll and process it in a later iteration
type SubTurnConfig struct { type SubTurnConfig struct {
Model string Model string
Provider providers.LLMProvider // non-nil overrides the child agent's provider
Tools []tools.Tool Tools []tools.Tool
EmptyTools bool // true: child agent gets an empty ToolRegistry (overrides Tools)
SystemPrompt string SystemPrompt string
MaxTokens int MaxTokens int
@ -220,7 +222,9 @@ func (s *AgentLoopSpawner) SpawnSubTurn(
// Convert tools.SubTurnConfig to agent.SubTurnConfig // Convert tools.SubTurnConfig to agent.SubTurnConfig
agentCfg := SubTurnConfig{ agentCfg := SubTurnConfig{
Model: cfg.Model, Model: cfg.Model,
Provider: cfg.Provider,
Tools: cfg.Tools, Tools: cfg.Tools,
EmptyTools: cfg.EmptyTools,
SystemPrompt: cfg.SystemPrompt, SystemPrompt: cfg.SystemPrompt,
ActualSystemPrompt: cfg.ActualSystemPrompt, ActualSystemPrompt: cfg.ActualSystemPrompt,
InitialMessages: cfg.InitialMessages, InitialMessages: cfg.InitialMessages,
@ -232,6 +236,17 @@ func (s *AgentLoopSpawner) SpawnSubTurn(
MaxContextRunes: cfg.MaxContextRunes, MaxContextRunes: cfg.MaxContextRunes,
} }
// Resolve model → provider when only a model name is given (no explicit provider).
// This enables heterogeneous model routing from tool-layer callers (e.g. subagent tool).
if agentCfg.Provider == nil && agentCfg.Model != "" {
if modelCfg, err := s.al.GetConfig().GetModelConfig(agentCfg.Model); err == nil {
if p, m, err := providers.CreateProviderFromConfig(modelCfg); err == nil {
agentCfg.Provider = p
agentCfg.Model = m
}
}
}
return spawnSubTurn(ctx, s.al, parentTS, agentCfg) return spawnSubTurn(ctx, s.al, parentTS, agentCfg)
} }
@ -344,9 +359,25 @@ func spawnSubTurn(
ephemeralStore := newEphemeralSession(nil) ephemeralStore := newEphemeralSession(nil)
agent := *baseAgent // shallow copy agent := *baseAgent // shallow copy
agent.Sessions = ephemeralStore agent.Sessions = ephemeralStore
// Apply model/provider override for heterogeneous agents.
if cfg.Model != "" {
agent.Model = cfg.Model
}
if cfg.Provider != nil {
agent.Provider = cfg.Provider
}
// Clone the tool registry so child turn's tool registrations // Clone the tool registry so child turn's tool registrations
// don't pollute the parent's registry. // don't pollute the parent's registry.
if baseAgent.Tools != nil { if cfg.EmptyTools {
agent.Tools = tools.NewToolRegistry()
} else if cfg.Tools != nil {
// Tools override will be applied via processOptions below.
// Clone parent registry as base, then replace with cfg.Tools entries.
agent.Tools = tools.NewToolRegistry()
for _, t := range cfg.Tools {
agent.Tools.Register(t)
}
} else if baseAgent.Tools != nil {
agent.Tools = baseAgent.Tools.Clone() agent.Tools = baseAgent.Tools.Clone()
} }
@ -479,6 +510,7 @@ func spawnSubTurn(
result = &tools.ToolResult{ result = &tools.ToolResult{
ForLLM: turnRes.finalContent, ForLLM: turnRes.finalContent,
ForUser: turnRes.finalContent, ForUser: turnRes.finalContent,
Messages: turnRes.messages,
} }
} }

View file

@ -43,6 +43,7 @@ type turnResult struct {
finalContent string finalContent string
status TurnEndStatus status TurnEndStatus
followUps []bus.InboundMessage followUps []bus.InboundMessage
messages []providers.Message // ephemeral session history after execution (for state continuation)
} }
type turnState struct { type turnState struct {

View file

@ -1,124 +0,0 @@
package tools
import (
"context"
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/providers"
)
// SpawnSubAgentTool executes a customized subagent task synchronously using Anthology-style single worker delegation.
type SpawnSubAgentTool struct {
manager *SubagentManager
originChannel string
originChatID string
}
func NewSpawnSubAgentTool(manager *SubagentManager) *SpawnSubAgentTool {
return &SpawnSubAgentTool{
manager: manager,
originChannel: "cli",
originChatID: "direct",
}
}
func (t *SpawnSubAgentTool) Name() string {
return "spawn_sub_agent"
}
func (t *SpawnSubAgentTool) Description() string {
base := "Directly delegate a specific task to a new, isolated sub-agent. You (the main agent) should autonomously determine the appropriate expert role and specific task based on the user's high-level request. It will execute independently and return the final result."
if t.manager != nil {
if hint := t.manager.ModelCapabilityHint(); hint != "" {
return base + "\n\n" + hint
}
}
return base
}
func (t *SpawnSubAgentTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"task": map[string]any{
"type": "string",
"description": "The specific task the sub-agent needs to accomplish.",
},
"role": map[string]any{
"type": "string",
"description": "The system prompt/role assignment for the sub-agent (e.g., 'You are an expert code reviewer').",
},
"model": map[string]any{
"type": "string",
"description": "Optional specific LLM model ID to route this task to (e.g., 'gpt-4o' for vision, 'claude-3-5-sonnet' for logic). If omitted, inherits the parent's model.",
},
},
"required": []string{"task", "role"},
}
}
func (t *SpawnSubAgentTool) SetContext(channel, chatID string) {
t.originChannel = channel
t.originChatID = chatID
}
func (t *SpawnSubAgentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string)
if !ok || strings.TrimSpace(task) == "" {
return ErrorResult("task is required").WithError(fmt.Errorf("task parameter is required"))
}
role, ok := args["role"].(string)
if !ok || strings.TrimSpace(role) == "" {
return ErrorResult("role is required").WithError(fmt.Errorf("role parameter is required"))
}
if t.manager == nil {
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
}
// 1. Isolation: Each SubAgent gets a completely fresh message set
messages := []providers.Message{
{
Role: "system",
Content: role,
},
{
Role: "user",
Content: task,
},
}
// 2. Base Configuration (Timeout & LLM constraints)
config := t.manager.BuildBaseWorkerConfig(ctx)
// 2.1 Model Override (Heterogeneous Agents)
if modelParam, ok := args["model"].(string); ok && strings.TrimSpace(modelParam) != "" {
requestedModel := strings.TrimSpace(modelParam)
if !t.manager.IsModelAllowed(requestedModel) {
return ErrorResult(fmt.Sprintf("requested model '%s' is not in the allowed fallback candidates list for this agent workspace", requestedModel)).WithError(fmt.Errorf("model %s not allowed", requestedModel))
}
config.Model = requestedModel
}
// Note: For MVP, we pass the current ToolRegistry unmodified.
// To enforce strict sandboxing later, we can construct a new ToolRegistry here based on args['allowed_tools'].
loopResult, err := RunToolLoop(ctx, config, messages, t.originChannel, t.originChatID)
if err != nil {
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
}
// Return full details to LLM
llmContent := fmt.Sprintf("Subagent (Role: %s) task completed:\nIterations: %d\nResult: %s",
role, loopResult.Iterations, loopResult.Content)
return &ToolResult{
ForLLM: llmContent,
ForUser: "Sub-agent finished task.",
Silent: false,
IsError: false,
Async: false,
}
}

View file

@ -22,7 +22,9 @@ type SubTurnSpawner interface {
// SubTurnConfig holds configuration for spawning a sub-turn. // SubTurnConfig holds configuration for spawning a sub-turn.
type SubTurnConfig struct { type SubTurnConfig struct {
Model string Model string
Provider providers.LLMProvider // non-nil overrides the child agent's provider
Tools []Tool Tools []Tool
EmptyTools bool // true: child agent gets an empty ToolRegistry (overrides Tools)
SystemPrompt string SystemPrompt string
MaxTokens int MaxTokens int
Temperature float64 Temperature float64
@ -436,6 +438,8 @@ type SubagentTool struct {
defaultModel string defaultModel string
maxTokens int maxTokens int
temperature float64 temperature float64
isModelAllowed func(string) bool // nil means no allowlist check
modelHint func() string // nil means no model hint in description
} }
func NewSubagentTool(manager *SubagentManager) *SubagentTool { func NewSubagentTool(manager *SubagentManager) *SubagentTool {
@ -446,6 +450,8 @@ func NewSubagentTool(manager *SubagentManager) *SubagentTool {
defaultModel: manager.defaultModel, defaultModel: manager.defaultModel,
maxTokens: manager.maxTokens, maxTokens: manager.maxTokens,
temperature: manager.temperature, temperature: manager.temperature,
isModelAllowed: manager.IsModelAllowed,
modelHint: manager.ModelCapabilityHint,
} }
} }
@ -459,7 +465,13 @@ func (t *SubagentTool) Name() string {
} }
func (t *SubagentTool) Description() string { 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." base := "Execute a subagent task synchronously and return the result. Use this for delegating specific tasks to an independent agent instance with an optional role (system prompt) and model. Returns execution summary to user and full details to LLM."
if t.modelHint != nil {
if hint := t.modelHint(); hint != "" {
return base + "\n\n" + hint
}
}
return base
} }
func (t *SubagentTool) Parameters() map[string]any { func (t *SubagentTool) Parameters() map[string]any {
@ -474,6 +486,14 @@ func (t *SubagentTool) Parameters() map[string]any {
"type": "string", "type": "string",
"description": "Optional short label for the task (for display)", "description": "Optional short label for the task (for display)",
}, },
"role": map[string]any{
"type": "string",
"description": "Optional system prompt / role assignment for the subagent (e.g. 'You are an expert code reviewer'). If omitted, a default subagent prompt is used.",
},
"model": map[string]any{
"type": "string",
"description": "Optional specific LLM model ID to route this task to. If omitted, inherits the parent's model.",
},
}, },
"required": []string{"task"}, "required": []string{"task"},
} }
@ -486,15 +506,35 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
} }
label, _ := args["label"].(string) label, _ := args["label"].(string)
role, _ := args["role"].(string)
modelParam, _ := args["model"].(string)
modelParam = strings.TrimSpace(modelParam)
// Build system prompt for subagent // Validate model against allowlist if provided
if modelParam != "" && t.isModelAllowed != nil && !t.isModelAllowed(modelParam) {
return ErrorResult(fmt.Sprintf("requested model '%s' is not in the allowed models list", modelParam)).
WithError(fmt.Errorf("model %s not allowed", modelParam))
}
// Determine the model to use
targetModel := t.defaultModel
if modelParam != "" {
targetModel = modelParam
}
// Build ActualSystemPrompt: prefer explicit role, fall back to auto-generated prompt
var actualSystemPrompt string
if role != "" {
actualSystemPrompt = role
}
// Build SystemPrompt (task description, becomes first user message in sub-turn)
systemPrompt := fmt.Sprintf( systemPrompt := fmt.Sprintf(
`You are a subagent. Complete the given task independently and provide a clear, concise result. `You are a subagent. Complete the given task independently and provide a clear, concise result.
Task: %s`, Task: %s`,
task, task,
) )
if label != "" { if label != "" {
systemPrompt = fmt.Sprintf( systemPrompt = fmt.Sprintf(
`You are a subagent labeled "%s". Complete the given task independently and provide a clear, concise result. `You are a subagent labeled "%s". Complete the given task independently and provide a clear, concise result.
@ -508,9 +548,10 @@ Task: %s`,
// Use spawner if available (direct SpawnSubTurn call) // Use spawner if available (direct SpawnSubTurn call)
if t.spawner != nil { if t.spawner != nil {
result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{ result, err := t.spawner.SpawnSubTurn(ctx, SubTurnConfig{
Model: t.defaultModel, Model: targetModel,
Tools: nil, // Will inherit from parent via context Tools: nil, // Will inherit from parent via context
SystemPrompt: systemPrompt, SystemPrompt: systemPrompt,
ActualSystemPrompt: actualSystemPrompt,
MaxTokens: t.maxTokens, MaxTokens: t.maxTokens,
Temperature: t.temperature, Temperature: t.temperature,
Async: false, // Synchronous execution Async: false, // Synchronous execution

View file

@ -15,6 +15,7 @@ import (
type TeamTool struct { type TeamTool struct {
manager *SubagentManager manager *SubagentManager
spawner SubTurnSpawner
cfg *config.Config cfg *config.Config
originChannel string originChannel string
originChatID string originChatID string
@ -38,6 +39,11 @@ func NewTeamTool(manager *SubagentManager, cfg *config.Config) *TeamTool {
} }
} }
// SetSpawner sets the SubTurnSpawner used to execute team members as sub-turns.
func (t *TeamTool) SetSpawner(spawner SubTurnSpawner) {
t.spawner = spawner
}
func (t *TeamTool) Name() string { func (t *TeamTool) Name() string {
return "team" return "team"
} }
@ -214,11 +220,11 @@ func (t *TeamTool) maybeRunAutoReviewer(
"model": teamConfig.ReviewerModel, "model": teamConfig.ReviewerModel,
}) })
loopResult, err := RunToolLoop(ctx, reviewerConfig, reviewerMessages, t.originChannel, t.originChatID) loopContent, _, err := t.spawnWorker(ctx, reviewerConfig, reviewerMessages, nil)
if err != nil { if err != nil {
return fmt.Sprintf("[Auto-Reviewer] Failed to run: %v", err) return fmt.Sprintf("[Auto-Reviewer] Failed to run: %v", err)
} }
return "[Auto-Reviewer Result]\n" + loopResult.Content return "[Auto-Reviewer Result]\n" + loopContent
} }
func (t *TeamTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *TeamTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
@ -412,7 +418,121 @@ func upgradeRegistryForConcurrency(original *ToolRegistry) *ToolRegistry {
return upgraded return upgraded
} }
// buildWorkerConfig creates a ToolLoopConfig for a specific team member, // spawnWorker executes a single team member's turn, routing through SubTurnSpawner when available.
// Returns (content, messages, error). The messages slice is non-nil only for stateful workers
// (evaluator_optimizer) and can be passed as InitialMessages for the next iteration.
func (t *TeamTool) spawnWorker(ctx context.Context, cfg ToolLoopConfig, messages []providers.Message, budget *atomic.Int64) (string, []providers.Message, error) {
if t.spawner == nil {
// Fallback: direct RunToolLoop (no turnState integration)
res, err := RunToolLoop(ctx, cfg, messages, t.originChannel, t.originChatID)
if err != nil {
return "", nil, err
}
return res.Content, res.Messages, nil
}
// Convert ToolLoopConfig + messages into SubTurnConfig for SubTurnSpawner.
var toolSlice []Tool
if cfg.Tools != nil {
for _, name := range cfg.Tools.ListTools() {
if tool, ok := cfg.Tools.Get(name); ok {
toolSlice = append(toolSlice, tool)
}
}
}
// Extract system prompt and non-system messages
var actualSystemPrompt string
var initialMessages []providers.Message
for _, msg := range messages {
if msg.Role == "system" {
actualSystemPrompt = msg.Content
} else {
initialMessages = append(initialMessages, msg)
}
}
maxTokens, temperature := getLLMOptionsFromConfig(cfg)
subCfg := SubTurnConfig{
Model: cfg.Model,
Provider: cfg.Provider,
Tools: toolSlice,
ActualSystemPrompt: actualSystemPrompt,
InitialMessages: initialMessages,
MaxTokens: maxTokens,
Temperature: temperature,
Async: false,
InitialTokenBudget: budget,
}
res, err := t.spawner.SpawnSubTurn(ctx, subCfg)
if err != nil {
return "", nil, err
}
return res.ForLLM, res.Messages, nil
}
// spawnWorkerEmptyTools is like spawnWorker but forces an empty tool registry on the sub-turn.
// Used for the evaluator in evaluator_optimizer to prevent side effects.
func (t *TeamTool) spawnWorkerEmptyTools(ctx context.Context, cfg ToolLoopConfig, messages []providers.Message) (string, error) {
if t.spawner == nil {
// Fallback: direct RunToolLoop with empty registry
emptyConfig := cfg
emptyConfig.Tools = NewToolRegistry()
res, err := RunToolLoop(ctx, emptyConfig, messages, t.originChannel, t.originChatID)
if err != nil {
return "", err
}
return res.Content, nil
}
var actualSystemPrompt string
var initialMessages []providers.Message
for _, msg := range messages {
if msg.Role == "system" {
actualSystemPrompt = msg.Content
} else {
initialMessages = append(initialMessages, msg)
}
}
maxTokens, temperature := getLLMOptionsFromConfig(cfg)
subCfg := SubTurnConfig{
Model: cfg.Model,
Provider: cfg.Provider,
EmptyTools: true,
ActualSystemPrompt: actualSystemPrompt,
InitialMessages: initialMessages,
MaxTokens: maxTokens,
Temperature: temperature,
Async: false,
}
res, err := t.spawner.SpawnSubTurn(ctx, subCfg)
if err != nil {
return "", err
}
return res.ForLLM, nil
}
// getLLMOptionsFromConfig extracts MaxTokens and Temperature from a ToolLoopConfig's LLMOptions map.
func getLLMOptionsFromConfig(cfg ToolLoopConfig) (int, float64) {
var maxTokens int
var temperature float64
if cfg.LLMOptions != nil {
if v, ok := cfg.LLMOptions["max_tokens"].(int); ok {
maxTokens = v
}
if v, ok := cfg.LLMOptions["temperature"].(float64); ok {
temperature = v
}
}
return maxTokens, temperature
}
// potentially overriding the model based on the member's definition. // potentially overriding the model based on the member's definition.
func (t *TeamTool) buildWorkerConfig(baseConfig ToolLoopConfig, registry *ToolRegistry, m TeamMember) (ToolLoopConfig, error) { func (t *TeamTool) buildWorkerConfig(baseConfig ToolLoopConfig, registry *ToolRegistry, m TeamMember) (ToolLoopConfig, error) {
cfg := baseConfig cfg := baseConfig
@ -487,14 +607,14 @@ func (t *TeamTool) executeSequential(ctx context.Context, baseConfig ToolLoopCon
return ErrorResult(errStr).WithError(err) return ErrorResult(errStr).WithError(err)
} }
loopResult, err := RunToolLoop(ctx, workerConfig, messages, t.originChannel, t.originChatID) content, _, err := t.spawnWorker(ctx, workerConfig, messages, baseConfig.RemainingTokenBudget)
if err != nil { if err != nil {
errStr := fmt.Sprintf("Phase %d (Role: %s) failed: %v", i+1, m.Role, err) errStr := fmt.Sprintf("Phase %d (Role: %s) failed: %v", i+1, m.Role, err)
finalOutput.WriteString(errStr + "\n") finalOutput.WriteString(errStr + "\n")
return ErrorResult(errStr).WithError(err) // Fail fast return ErrorResult(errStr).WithError(err) // Fail fast
} }
previousResult = loopResult.Content previousResult = content
finalOutput.WriteString(fmt.Sprintf("### Phase %d completed by Role: [%s]\n%s\n\n", i+1, m.Role, previousResult)) finalOutput.WriteString(fmt.Sprintf("### Phase %d completed by Role: [%s]\n%s\n\n", i+1, m.Role, previousResult))
} }
@ -537,13 +657,13 @@ func (t *TeamTool) executeParallel(ctx context.Context, baseConfig ToolLoopConfi
return return
} }
loopResult, err := RunToolLoop(ctx, workerConfig, messages, t.originChannel, t.originChatID) content, _, err := t.spawnWorker(ctx, workerConfig, messages, baseConfig.RemainingTokenBudget)
if err != nil { if err != nil {
resultsChan <- workResult{index: index, role: member.Role, err: err} resultsChan <- workResult{index: index, role: member.Role, err: err}
return return
} }
resultsChan <- workResult{index: index, role: member.Role, res: loopResult.Content} resultsChan <- workResult{index: index, role: member.Role, res: content}
logger.InfoCF("team", fmt.Sprintf("[%s] Parallel worker finished", member.Role), map[string]any{ logger.InfoCF("team", fmt.Sprintf("[%s] Parallel worker finished", member.Role), map[string]any{
"member_index": index, "member_index": index,
}) })
@ -648,7 +768,7 @@ func (t *TeamTool) executeEvaluatorOptimizer(ctx context.Context, baseConfig Too
logger.InfoCF("team", fmt.Sprintf("Evaluator-Optimizer attempt %d/%d", attempt, maxLoops), map[string]any{}) logger.InfoCF("team", fmt.Sprintf("Evaluator-Optimizer attempt %d/%d", attempt, maxLoops), map[string]any{})
// 2. Trigger Worker (resumes from its exact previous state!) // 2. Trigger Worker (resumes from its exact previous state!)
workerResult, err := RunToolLoop(ctx, workerConfig, workerMessages, t.originChannel, t.originChatID) workerContent, workerMsgs, err := t.spawnWorker(ctx, workerConfig, workerMessages, baseConfig.RemainingTokenBudget)
if err != nil { if err != nil {
errStr := fmt.Sprintf("Worker failed on attempt %d: %v", attempt, err) errStr := fmt.Sprintf("Worker failed on attempt %d: %v", attempt, err)
finalOutput.WriteString(errStr + "\n") finalOutput.WriteString(errStr + "\n")
@ -656,31 +776,33 @@ func (t *TeamTool) executeEvaluatorOptimizer(ctx context.Context, baseConfig Too
} }
// Save the worker's cognitive state so it remembers its thought process for the next loop // Save the worker's cognitive state so it remembers its thought process for the next loop
workerMessages = workerResult.Messages if workerMsgs != nil {
workerMessages = workerMsgs
}
finalOutput.WriteString(fmt.Sprintf("### Worker Output:\n%s\n\n", workerResult.Content)) finalOutput.WriteString(fmt.Sprintf("### Worker Output:\n%s\n\n", workerContent))
// 3. Trigger Evaluator (Ephemeral, stateless evaluation) // 3. Trigger Evaluator (Ephemeral, stateless evaluation)
// The evaluator only needs to reason about text — give it no tools to avoid // The evaluator only needs to reason about text — give it no tools to avoid
// unnecessary tool calls, wasted tokens, and potential side effects. // unnecessary tool calls, wasted tokens, and potential side effects.
evalContext := fmt.Sprintf("%s\n\n--- Worker's Output to Evaluate ---\n%s\n\nIf the output is completely correct and fulfills the task, you MUST reply starting with strictly '[PASS]'. Otherwise, explain the issues in detail.", evaluator.Task, truncateContextN(workerResult.Content, contextLimit)) evalContext := fmt.Sprintf("%s\n\n--- Worker's Output to Evaluate ---\n%s\n\nIf the output is completely correct and fulfills the task, you MUST reply starting with strictly '[PASS]'. Otherwise, explain the issues in detail.", evaluator.Task, truncateContextN(workerContent, contextLimit))
evalMessages := []providers.Message{ evalMessages := []providers.Message{
{Role: "system", Content: evaluator.Role}, {Role: "system", Content: evaluator.Role},
{Role: "user", Content: evalContext}, {Role: "user", Content: evalContext},
} }
evalResult, err := RunToolLoop(ctx, evalConfig, evalMessages, t.originChannel, t.originChatID) evalContent, err := t.spawnWorkerEmptyTools(ctx, evalConfig, evalMessages)
if err != nil { if err != nil {
errStr := fmt.Sprintf("Evaluator failed on attempt %d: %v", attempt, err) errStr := fmt.Sprintf("Evaluator failed on attempt %d: %v", attempt, err)
finalOutput.WriteString(errStr + "\n") finalOutput.WriteString(errStr + "\n")
return ErrorResult(errStr).WithError(err) return ErrorResult(errStr).WithError(err)
} }
finalOutput.WriteString(fmt.Sprintf("### Evaluator Feedback:\n%s\n\n", evalResult.Content)) finalOutput.WriteString(fmt.Sprintf("### Evaluator Feedback:\n%s\n\n", evalContent))
// 4. Check for PASS condition // 4. Check for PASS condition
if strings.HasPrefix(strings.TrimSpace(evalResult.Content), "[PASS]") { if strings.HasPrefix(strings.TrimSpace(evalContent), "[PASS]") {
finalOutput.WriteString("✅ Evaluation Passed! Loop finished successfully.\n") finalOutput.WriteString("✅ Evaluation Passed! Loop finished successfully.\n")
logger.InfoCF("team", "Evaluator-Optimizer passed", map[string]any{"attempt": attempt}) logger.InfoCF("team", "Evaluator-Optimizer passed", map[string]any{"attempt": attempt})
return &ToolResult{ return &ToolResult{
@ -693,7 +815,7 @@ func (t *TeamTool) executeEvaluatorOptimizer(ctx context.Context, baseConfig Too
// 5. If not passed, and not the last attempt, inject feedback into Worker's stateful memory // 5. If not passed, and not the last attempt, inject feedback into Worker's stateful memory
if attempt < maxLoops { if attempt < maxLoops {
injection := fmt.Sprintf("The evaluator rejected your previous attempt. Please fix the issues based on this feedback:\n\n%s", evalResult.Content) injection := fmt.Sprintf("The evaluator rejected your previous attempt. Please fix the issues based on this feedback:\n\n%s", evalContent)
workerMessages = append(workerMessages, providers.Message{ workerMessages = append(workerMessages, providers.Message{
Role: "user", Role: "user",
Content: injection, Content: injection,
@ -834,7 +956,7 @@ func (t *TeamTool) executeDAG(ctx context.Context, cancel context.CancelFunc, ba
return return
} }
loopResult, err := RunToolLoop(ctx, workerConfig, messages, t.originChannel, t.originChatID) content, _, err := t.spawnWorker(ctx, workerConfig, messages, baseConfig.RemainingTokenBudget)
if err != nil { if err != nil {
masterErrMu.Lock() masterErrMu.Lock()
@ -848,11 +970,11 @@ func (t *TeamTool) executeDAG(ctx context.Context, cancel context.CancelFunc, ba
// Store result for final output // Store result for final output
finalResultsMu.Lock() finalResultsMu.Lock()
finalResults[id] = loopResult.Content finalResults[id] = content
finalResultsMu.Unlock() finalResultsMu.Unlock()
// Pass result to dependents // Pass result to dependents
resultChan <- nodeResult{id: id, res: loopResult.Content} resultChan <- nodeResult{id: id, res: content}
}(memberID) }(memberID)
case res := <-resultChan: case res := <-resultChan: