feat(team): merge team functionality from old version

- Add team.go (947 lines) with complete team coordination features
- Add spawn_sub_agent.go for sub-agent spawning
- Add team_test.go with comprehensive test coverage
- Update subagent.go: add teamConfig and bus fields
- Update loop.go: fix NewSubagentManager calls with new signature
- Update filesystem.go: add ConcurrentFS, EditFile, and Open methods
- Update config.go: add TeamToolsConfig and LogLevel documentation
- Update toolloop.go: merge context memory fixes
- Fix all test files to use updated NewSubagentManager signature

All tests passing, build successful.
This commit is contained in:
Administrator 2026-03-23 18:25:18 +08:00
parent 118a7c0f96
commit f6aa1b2d36
10 changed files with 406 additions and 43 deletions

View file

@ -105,6 +105,8 @@ Your workspace is at: %s
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
5. **Team delegation** - For any task that is non-trivial, multi-step, or involves distinct concerns (e.g. "convert React to Vue", "build a feature", "analyze and report"), you MUST use the 'team' tool to delegate and parallelize. Do NOT attempt to handle complex tasks inline by calling tools one by one yourself. Decompose first, delegate second, then report the outcome.
%s`,
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
}

View file

@ -268,12 +268,36 @@ func registerSharedTools(
}
}
// Team and spawn_sub_agent tools
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Candidates, agent.Workspace, cfg.Tools.Team, msgBus)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
teamTool := tools.NewTeamTool(subagentManager, cfg)
if cfg.Tools.IsToolEnabled("team") {
agent.Tools.Register(teamTool)
}
spawnSubAgentTool := tools.NewSpawnSubAgentTool(subagentManager)
if cfg.Tools.IsToolEnabled("spawn_sub_agent") {
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.
// Construct it when either tool is enabled (both require subagent).
spawnEnabled := cfg.Tools.IsToolEnabled("spawn")
spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status")
if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager := tools.NewSubagentManager(
provider,
agent.Model,
agent.Candidates,
agent.Workspace,
cfg.Tools.Team,
msgBus,
)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
// Set the spawner that links into AgentLoop's turnState

View file

@ -76,6 +76,24 @@ func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
return nil
}
type TeamModelConfig struct {
Name string `json:"name"`
Tags []string `json:"tags,omitempty"`
}
type TeamToolsConfig struct {
ToolConfig
MaxMembers int `json:"max_members"`
MaxTeamTokens int `json:"max_team_tokens"`
MaxEvaluatorLoops int `json:"max_evaluator_loops"`
MaxTimeoutMinutes int `json:"max_timeout_minutes"`
MaxContextRunes int `json:"max_context_runes"`
DisableAutoReviewer bool `json:"disable_auto_reviewer"`
ReviewerModel string `json:"reviewer_model"`
AllowedStrategies []string `json:"allowed_strategies"`
AllowedModels []TeamModelConfig `json:"allowed_models"`
}
type Config struct {
Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
@ -735,6 +753,8 @@ type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
// LogLevel controls the logging verbosity for the gateway server.
// Valid values: "debug", "info", "warn", "error", "fatal" (default: "fatal")
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
}
@ -879,6 +899,8 @@ type ToolsConfig struct {
SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
SpawnSubAgent ToolConfig `json:"spawn_sub_agent" envPrefix:"PICOCLAW_TOOLS_SPAWN_SUB_AGENT_"`
Team TeamToolsConfig `json:"team" envPrefix:"PICOCLAW_TOOLS_TEAM_"`
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
}

View file

@ -12,6 +12,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/fileutil"
@ -306,6 +307,13 @@ func (t *ReadFileTool) Parameters() map[string]any {
}
}
func (t *ReadFileTool) UpgradeToConcurrent() Tool {
return &ReadFileTool{
fs: &ConcurrentFS{baseFS: t.fs},
maxSize: t.maxSize,
}
}
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
@ -521,6 +529,12 @@ func (t *WriteFileTool) Parameters() map[string]any {
}
}
func (t *WriteFileTool) UpgradeToConcurrent() Tool {
return &WriteFileTool{
fs: &ConcurrentFS{baseFS: t.fs},
}
}
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
@ -610,6 +624,7 @@ func formatDirEntries(entries []os.DirEntry) *ToolResult {
type fileSystem interface {
ReadFile(path string) ([]byte, error)
WriteFile(path string, data []byte) error
EditFile(path string, editFn func([]byte) ([]byte, error)) error
ReadDir(path string) ([]os.DirEntry, error)
Open(path string) (fs.File, error)
}
@ -655,6 +670,18 @@ func (h *hostFs) Open(path string) (fs.File, error) {
return f, nil
}
func (h *hostFs) EditFile(path string, editFn func([]byte) ([]byte, error)) error {
data, err := h.ReadFile(path)
if err != nil {
return err
}
newData, err := editFn(data)
if err != nil {
return err
}
return h.WriteFile(path, newData)
}
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
type sandboxFs struct {
workspace string
@ -786,6 +813,18 @@ func (r *sandboxFs) Open(path string) (fs.File, error) {
return f, err
}
func (r *sandboxFs) EditFile(path string, editFn func([]byte) ([]byte, error)) error {
data, err := r.ReadFile(path)
if err != nil {
return err
}
newData, err := editFn(data)
if err != nil {
return err
}
return r.WriteFile(path, newData)
}
// whitelistFs wraps a sandboxFs and allows access to specific paths outside
// the workspace when they match any of the provided patterns.
type whitelistFs struct {
@ -826,6 +865,13 @@ func (w *whitelistFs) Open(path string) (fs.File, error) {
return w.sandbox.Open(path)
}
func (w *whitelistFs) EditFile(path string, editFn func([]byte) ([]byte, error)) error {
if w.matches(path) {
return w.host.EditFile(path, editFn)
}
return w.sandbox.EditFile(path, editFn)
}
// buildFs returns the appropriate fileSystem implementation based on restriction
// settings and optional path whitelist patterns.
func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem {
@ -860,3 +906,56 @@ func getSafeRelPath(workspace, path string) (string, error) {
return rel, nil
}
// ConcurrencyUpgradeable indicates a Tool operates on files and can be upgraded
// to use a thread-safe locking proxy backend (`ConcurrentFS`) for Parallel or DAG agent teams.
type ConcurrencyUpgradeable interface {
UpgradeToConcurrent() Tool
}
// Global file locks explicitly for concurrent agent strategies
var globalFileLocks sync.Map // map[string]*sync.RWMutex
func getPathLock(path string) *sync.RWMutex {
cleanPath := filepath.Clean(path)
actual, _ := globalFileLocks.LoadOrStore(cleanPath, &sync.RWMutex{})
return actual.(*sync.RWMutex)
}
// ConcurrentFS is a lightweight proxy wrapper around any `fileSystem`.
// It guarantees thread-safe, race-condition-free access by locking the absolute file path globally.
type ConcurrentFS struct {
baseFS fileSystem
}
func (c *ConcurrentFS) ReadFile(path string) ([]byte, error) {
lock := getPathLock(path)
lock.RLock()
defer lock.RUnlock()
return c.baseFS.ReadFile(path)
}
func (c *ConcurrentFS) WriteFile(path string, data []byte) error {
lock := getPathLock(path)
lock.Lock()
defer lock.Unlock()
return c.baseFS.WriteFile(path, data)
}
func (c *ConcurrentFS) EditFile(path string, editFn func([]byte) ([]byte, error)) error {
lock := getPathLock(path)
lock.Lock()
defer lock.Unlock()
return c.baseFS.EditFile(path, editFn)
}
func (c *ConcurrentFS) ReadDir(path string) ([]os.DirEntry, error) {
return c.baseFS.ReadDir(path)
}
func (c *ConcurrentFS) Open(path string) (fs.File, error) {
lock := getPathLock(path)
lock.RLock()
defer lock.RUnlock()
return c.baseFS.Open(path)
}

View file

@ -150,6 +150,17 @@ func (r *ToolRegistry) Get(name string) (Tool, bool) {
return entry.Tool, true
}
// ListTools returns a slice of all registered tool names.
func (r *ToolRegistry) ListTools() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.tools))
for name := range r.tools {
names = append(names, name)
}
return names
}
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult {
return r.ExecuteWithContext(ctx, name, args, "", "", nil)
}

View file

@ -6,12 +6,14 @@ import (
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestSpawnStatusTool_Name(t *testing.T) {
provider := &MockLLMProvider{}
workspace := t.TempDir()
manager := NewSubagentManager(provider, "test-model", workspace)
manager := NewSubagentManager(provider, "test-model", nil, workspace, config.TeamToolsConfig{}, nil)
tool := NewSpawnStatusTool(manager)
if tool.Name() != "spawn_status" {
@ -22,7 +24,7 @@ func TestSpawnStatusTool_Name(t *testing.T) {
func TestSpawnStatusTool_Description(t *testing.T) {
provider := &MockLLMProvider{}
workspace := t.TempDir()
manager := NewSubagentManager(provider, "test-model", workspace)
manager := NewSubagentManager(provider, "test-model", nil, workspace, config.TeamToolsConfig{}, nil)
tool := NewSpawnStatusTool(manager)
desc := tool.Description()
@ -37,7 +39,7 @@ func TestSpawnStatusTool_Description(t *testing.T) {
func TestSpawnStatusTool_Parameters(t *testing.T) {
provider := &MockLLMProvider{}
workspace := t.TempDir()
manager := NewSubagentManager(provider, "test-model", workspace)
manager := NewSubagentManager(provider, "test-model", nil, workspace, config.TeamToolsConfig{}, nil)
tool := NewSpawnStatusTool(manager)
params := tool.Parameters()
@ -64,7 +66,7 @@ func TestSpawnStatusTool_NilManager(t *testing.T) {
func TestSpawnStatusTool_Empty(t *testing.T) {
provider := &MockLLMProvider{}
workspace := t.TempDir()
manager := NewSubagentManager(provider, "test-model", workspace)
manager := NewSubagentManager(provider, "test-model", nil, workspace, config.TeamToolsConfig{}, nil)
tool := NewSpawnStatusTool(manager)
result := tool.Execute(context.Background(), map[string]any{})
@ -79,7 +81,7 @@ func TestSpawnStatusTool_Empty(t *testing.T) {
func TestSpawnStatusTool_ListAll(t *testing.T) {
provider := &MockLLMProvider{}
workspace := t.TempDir()
manager := NewSubagentManager(provider, "test-model", workspace)
manager := NewSubagentManager(provider, "test-model", nil, workspace, config.TeamToolsConfig{}, nil)
now := time.Now().UnixMilli()
manager.mu.Lock()
@ -140,7 +142,7 @@ func TestSpawnStatusTool_ListAll(t *testing.T) {
func TestSpawnStatusTool_GetByID(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
manager.mu.Lock()
manager.tasks["subagent-42"] = &SubagentTask{
@ -175,7 +177,7 @@ func TestSpawnStatusTool_GetByID(t *testing.T) {
func TestSpawnStatusTool_GetByID_NotFound(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSpawnStatusTool(manager)
result := tool.Execute(context.Background(), map[string]any{"task_id": "nonexistent-999"})
@ -189,7 +191,7 @@ func TestSpawnStatusTool_GetByID_NotFound(t *testing.T) {
func TestSpawnStatusTool_TaskID_NonString(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSpawnStatusTool(manager)
for _, badVal := range []any{42, 3.14, true, map[string]any{"x": 1}, []string{"a"}} {
@ -205,7 +207,7 @@ func TestSpawnStatusTool_TaskID_NonString(t *testing.T) {
func TestSpawnStatusTool_ResultTruncation(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
longResult := strings.Repeat("X", 500)
manager.mu.Lock()
@ -234,7 +236,7 @@ func TestSpawnStatusTool_ResultTruncation(t *testing.T) {
func TestSpawnStatusTool_ResultTruncation_Unicode(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
// Each CJK rune is 3 bytes; 400 runes = 1200 bytes — well over the 300-rune limit.
cjkChar := string(rune(0x5b57))
@ -265,7 +267,7 @@ func TestSpawnStatusTool_ResultTruncation_Unicode(t *testing.T) {
func TestSpawnStatusTool_StatusCounts(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
manager.mu.Lock()
for i, status := range []string{"running", "running", "completed", "failed", "canceled"} {
@ -290,7 +292,7 @@ func TestSpawnStatusTool_StatusCounts(t *testing.T) {
func TestSpawnStatusTool_SortByCreatedTimestamp(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
now := time.Now().UnixMilli()
manager.mu.Lock()
@ -325,7 +327,7 @@ func TestSpawnStatusTool_SortByCreatedTimestamp(t *testing.T) {
func TestSpawnStatusTool_ChannelFiltering_ListAll(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
manager.mu.Lock()
manager.tasks["subagent-1"] = &SubagentTask{
@ -357,7 +359,7 @@ func TestSpawnStatusTool_ChannelFiltering_ListAll(t *testing.T) {
func TestSpawnStatusTool_ChannelFiltering_GetByID(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
manager.mu.Lock()
manager.tasks["subagent-99"] = &SubagentTask{
@ -379,7 +381,7 @@ func TestSpawnStatusTool_ChannelFiltering_GetByID(t *testing.T) {
func TestSpawnStatusTool_ChannelFiltering_NoContext(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
manager.mu.Lock()
manager.tasks["subagent-1"] = &SubagentTask{

View file

@ -4,6 +4,8 @@ import (
"context"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
// mockSpawner implements SubTurnSpawner for testing
@ -26,7 +28,7 @@ func (m *mockSpawner) SpawnSubTurn(ctx context.Context, cfg SubTurnConfig) (*Too
func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSpawnTool(manager)
ctx := context.Background()
@ -60,7 +62,7 @@ func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
func TestSpawnTool_Execute_ValidTask(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSpawnTool(manager)
tool.SetSpawner(&mockSpawner{})

View file

@ -3,10 +3,13 @@ package tools
import (
"context"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
@ -32,6 +35,28 @@ type SubTurnConfig struct {
InitialTokenBudget *atomic.Int64 // Shared token budget for team members; nil if no budget
}
// ModelTag constants define the recognized capability labels for models in config.json.
// These are set via `"tags": ["vision", "code"]` under each model in the model list.
const (
ModelTagVision = "vision" // Supports image/screenshot input (multimodal)
ModelTagImageGen = "image-gen" // Supports image generation output (e.g. DALL-E, Stable Diffusion)
ModelTagCode = "code" // Specialized for code generation and analysis
ModelTagFast = "fast" // Low-latency model, suited for lightweight tasks
ModelTagLongContext = "long-context" // Supports very long context windows (>100k tokens)
ModelTagReasoning = "reasoning" // Strong logical/math reasoning (e.g., o1, deepseek-r1)
)
// modelTagDescriptions provides LLM-readable explanations of each known tag,
// injected at runtime into the tool description to guide model selection.
var modelTagDescriptions = map[string]string{
ModelTagVision: "can analyze images and screenshots (multimodal input)",
ModelTagImageGen: "can generate images from text descriptions (e.g. DALL-E, Stable Diffusion)",
ModelTagCode: "specialized in code generation and debugging",
ModelTagFast: "fast and lightweight, ideal for simple or high-frequency tasks",
ModelTagLongContext: "handles very long inputs (>100k tokens)",
ModelTagReasoning: "excels at logical reasoning, math, and multi-step planning",
}
type SubagentTask struct {
ID string
Task string
@ -58,8 +83,11 @@ type SubagentManager struct {
mu sync.RWMutex
provider providers.LLMProvider
defaultModel string
allowedModels []providers.FallbackCandidate
bus *bus.MessageBus
workspace string
tools *ToolRegistry
teamConfig config.TeamToolsConfig
maxIterations int
maxTokens int
temperature float64
@ -71,12 +99,19 @@ type SubagentManager struct {
func NewSubagentManager(
provider providers.LLMProvider,
defaultModel, workspace string,
defaultModel string,
candidates []providers.FallbackCandidate,
workspace string,
teamConfig config.TeamToolsConfig,
bus *bus.MessageBus,
) *SubagentManager {
return &SubagentManager{
tasks: make(map[string]*SubagentTask),
provider: provider,
defaultModel: defaultModel,
allowedModels: candidates,
teamConfig: teamConfig,
bus: bus,
workspace: workspace,
tools: NewToolRegistry(),
maxIterations: 10,
@ -84,6 +119,65 @@ func NewSubagentManager(
}
}
// IsModelAllowed checks if a specific requested model exists in the permitted candidates list.
func (sm *SubagentManager) IsModelAllowed(model string) bool {
// If the user requested the default model directly, that's automatically allowed
if model == sm.defaultModel {
return true
}
// 1. Check against explicitly allowed models in team config
for _, cand := range sm.teamConfig.AllowedModels {
if cand.Name == model {
return true
}
}
// 2. Otherwise, check against the resolved candidates (primary + fallbacks + explicitly configured)
// If teamConfig.AllowedModels is set, we strictly enforce it and DO NOT fall back to candidates
// unless the candidate model has tags that overlap with AllowedTags. But since AllowedTags
// was not implemented yet, just check fallback for backwards compatibility if teamConfig is empty.
if len(sm.teamConfig.AllowedModels) > 0 {
return false
}
for _, cand := range sm.allowedModels {
if cand.Model == model {
return true
}
}
return false
}
// ModelCapabilityHint generates a human-readable summary of allowed models and their tags.
// This is injected into the coordinator's tool descriptions so the LLM can make better routing decisions.
func (sm *SubagentManager) ModelCapabilityHint() string {
if len(sm.allowedModels) == 0 {
return ""
}
var modelLines []string
for _, cand := range sm.allowedModels {
modelLines = append(modelLines, fmt.Sprintf(" - %s (general purpose)", cand.Model))
}
hint := "When selecting a 'model' for sub-agents, use ONLY these configured models:\n"
if len(sm.teamConfig.AllowedModels) > 0 {
for _, cand := range sm.teamConfig.AllowedModels {
tagsStr := ""
if len(cand.Tags) > 0 {
tagsStr = fmt.Sprintf(" [%s]", strings.Join(cand.Tags, ", "))
}
hint += fmt.Sprintf(" - %s%s\n", cand.Name, tagsStr)
}
} else {
hint += strings.Join(modelLines, "\n")
}
hint += "\nIf a task requires vision/image analysis, you MUST select a model with the 'vision' tag. If no suitable model is available, omit the 'model' field to use the default."
return hint
}
func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) {
sm.mu.Lock()
defer sm.mu.Unlock()
@ -154,9 +248,6 @@ func (sm *SubagentManager) runTask(
) {
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 {
@ -244,7 +335,6 @@ After completing the task, provide a clear summary of what was done.`
sm.mu.Lock()
defer func() {
sm.mu.Unlock()
// Call callback if provided and result is set
if callback != nil && result != nil {
callback(ctx, result)
}
@ -253,7 +343,6 @@ After completing the task, provide a clear summary of what was done.`
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"
@ -315,6 +404,31 @@ func (sm *SubagentManager) ListTaskCopies() []SubagentTask {
return copies
}
// BuildBaseWorkerConfig returns a base ToolLoopConfig that can be customized for isolated workers.
func (sm *SubagentManager) BuildBaseWorkerConfig(ctx context.Context) ToolLoopConfig {
sm.mu.RLock()
defer sm.mu.RUnlock()
var llmOptions map[string]any
if sm.hasMaxTokens || sm.hasTemperature {
llmOptions = map[string]any{}
if sm.hasMaxTokens {
llmOptions["max_tokens"] = sm.maxTokens
}
if sm.hasTemperature {
llmOptions["temperature"] = sm.temperature
}
}
return ToolLoopConfig{
Provider: sm.provider,
Model: sm.defaultModel,
Tools: sm.tools,
MaxIterations: sm.maxIterations,
LLMOptions: llmOptions,
}
}
// SubagentTool executes a subagent task synchronously and returns the result.
// It directly calls SubTurnSpawner with Async=false for synchronous execution.
type SubagentTool struct {

View file

@ -5,6 +5,7 @@ import (
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
@ -46,7 +47,7 @@ func (m *MockLLMProvider) GetContextWindow() int {
func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
manager.SetLLMOptions(2048, 0.6)
// Verify options are set on manager
@ -67,7 +68,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
// TestSubagentTool_Name verifies tool name
func TestSubagentTool_Name(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSubagentTool(manager)
if tool.Name() != "subagent" {
@ -78,7 +79,7 @@ func TestSubagentTool_Name(t *testing.T) {
// TestSubagentTool_Description verifies tool description
func TestSubagentTool_Description(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSubagentTool(manager)
desc := tool.Description()
@ -93,7 +94,7 @@ func TestSubagentTool_Description(t *testing.T) {
// TestSubagentTool_Parameters verifies tool parameters schema
func TestSubagentTool_Parameters(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSubagentTool(manager)
params := tool.Parameters()
@ -143,7 +144,7 @@ func TestSubagentTool_Parameters(t *testing.T) {
// TestSubagentTool_Execute_Success tests successful execution
func TestSubagentTool_Execute_Success(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSubagentTool(manager)
tool.SetSpawner(&mockSpawner{})
@ -198,7 +199,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) {
// TestSubagentTool_Execute_NoLabel tests execution without label
func TestSubagentTool_Execute_NoLabel(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSubagentTool(manager)
tool.SetSpawner(&mockSpawner{})
@ -222,7 +223,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
// TestSubagentTool_Execute_MissingTask tests error handling for missing task
func TestSubagentTool_Execute_MissingTask(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSubagentTool(manager)
ctx := context.Background()
@ -272,7 +273,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
// TestSubagentTool_Execute_ContextPassing verifies context is properly used
func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSubagentTool(manager)
tool.SetSpawner(&mockSpawner{})
@ -298,7 +299,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
func TestSubagentTool_ForUserTruncation(t *testing.T) {
// Create a mock provider that returns very long content
provider := &MockLLMProvider{}
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager := NewSubagentManager(provider, "test-model", nil, "/tmp/test", config.TeamToolsConfig{}, nil)
tool := NewSubagentTool(manager)
tool.SetSpawner(&mockSpawner{})

View file

@ -11,6 +11,7 @@ import (
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
@ -24,12 +25,14 @@ type ToolLoopConfig struct {
Tools *ToolRegistry
MaxIterations int
LLMOptions map[string]any
RemainingTokenBudget *atomic.Int64
}
// ToolLoopResult contains the result of running the tool loop.
type ToolLoopResult struct {
Content string
Iterations int
Messages []providers.Message // Allows caller to retain stateful context across executions
}
// RunToolLoop executes the LLM + tool call iteration loop.
@ -74,14 +77,84 @@ func RunToolLoop(
return nil, fmt.Errorf("LLM call failed: %w", err)
}
// 3.5 Token Budget: Soft enforcement with graceful degradation.
// Budget exhaustion is NOT a hard error — workers get a chance to wrap up gracefully.
if response.Usage != nil && config.RemainingTokenBudget != nil {
newBudget := config.RemainingTokenBudget.Add(-int64(response.Usage.TotalTokens))
originalBudget := newBudget + int64(response.Usage.TotalTokens)
if newBudget <= 0 {
// Budget exhausted: signal the worker to wrap up and return partial result.
logger.WarnCF("toolloop", "Token budget exhausted, injecting wrap-up signal",
map[string]any{
"deficit": -newBudget,
"iteration": iteration,
})
finalContent = response.Content
messages = append(messages, providers.Message{
Role: "assistant",
Content: response.Content,
ReasoningContent: response.ReasoningContent, // [Fix] Preserve reasoning content to maintain context
})
messages = append(messages, providers.Message{
Role: "user",
Content: "[SYSTEM] Token budget has been exhausted. Stop all tool calls immediately and return the best result you have completed so far. Do not call any more tools.",
})
// One final LLM call to get a summary/wrap-up from the model
if finalResp, err := config.Provider.Chat(ctx, messages, nil, config.Model, config.LLMOptions); err == nil {
finalContent = finalResp.Content
}
break
} else if originalBudget > 0 && newBudget < originalBudget/2 {
// Budget below 50%: soft warning injected into next iteration's context.
logger.WarnCF("toolloop", "Token budget below 50%, injecting advisory",
map[string]any{"remaining": newBudget, "iteration": iteration})
messages = append(messages, providers.Message{
Role: "user",
Content: "[SYSTEM] Advisory: token budget is running low. Please prioritize completing the most critical parts of your task and avoid unnecessary tool calls.",
})
}
}
// 3.6 Truncation Recovery: LLM response was cut off (max_tokens hit or malformed JSON).
// Inject a recovery message so the LLM knows to retry with a shorter, complete response.
if response.FinishReason == "truncated" {
logger.WarnCF("toolloop", "LLM response was truncated (max_tokens hit), injecting recovery message",
map[string]any{"iteration": iteration})
messages = append(messages, providers.Message{
Role: "assistant",
Content: response.Content,
ReasoningContent: response.ReasoningContent, // [Fix] Preserve reasoning content to prevent broken chain of thought
})
messages = append(messages, providers.Message{
Role: "user",
Content: "[SYSTEM] Your previous response was cut off because it exceeded the token limit. Please retry by producing a shorter, complete response. If you were about to call a tool, make sure the full JSON arguments are included without truncation.",
})
continue
}
// 4. If no tool calls, we're done
if len(response.ToolCalls) == 0 {
finalContent = response.Content
// [Fix] Fallback for models (like Gemini 2.0 Pro Thinking) that put output in reasoning block
if finalContent == "" && response.ReasoningContent != "" {
finalContent = response.ReasoningContent
}
logger.InfoCF("toolloop", "LLM response without tool calls (direct answer)",
map[string]any{
"iteration": iteration,
"content_chars": len(finalContent),
})
// [Fix] Append the final answer to the messages array!
// Essential for Team's evaluator_optimizer strategy to retain state in the next loop.
messages = append(messages, providers.Message{
Role: "assistant",
Content: finalContent,
ReasoningContent: response.ReasoningContent,
})
break
}
@ -106,9 +179,18 @@ func RunToolLoop(
assistantMsg := providers.Message{
Role: "assistant",
Content: response.Content,
ReasoningContent: response.ReasoningContent, // [Fix] Include ReasoningContent
}
for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
// [Fix] Preserve ThoughtSignature and ExtraContent for compatibility with models like Gemini 2.0/3.0
extraContent := tc.ExtraContent
thoughtSignature := ""
if tc.Function != nil {
thoughtSignature = tc.Function.ThoughtSignature
}
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
ID: tc.ID,
Type: "function",
@ -117,7 +199,10 @@ func RunToolLoop(
Function: &providers.FunctionCall{
Name: tc.Name,
Arguments: string(argumentsJSON),
ThoughtSignature: thoughtSignature, // [Fix] Preserve thought signature
},
ExtraContent: extraContent, // [Fix] Preserve extra content
ThoughtSignature: thoughtSignature, // [Fix] Preserve thought signature
})
}
messages = append(messages, assistantMsg)
@ -175,5 +260,6 @@ func RunToolLoop(
return &ToolLoopResult{
Content: finalContent,
Iterations: iteration,
Messages: messages,
}, nil
}