feat(team): enhance team tool with sub-turn spawner integration and improve message handling
This commit is contained in:
parent
f6aa1b2d36
commit
89e124caf9
6 changed files with 241 additions and 172 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue