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:
parent
118a7c0f96
commit
f6aa1b2d36
10 changed files with 406 additions and 43 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,7 +753,9 @@ 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 string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type ToolDiscoveryConfig struct {
|
||||
|
|
@ -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_"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
|
|
@ -19,17 +20,19 @@ import (
|
|||
|
||||
// ToolLoopConfig configures the tool execution loop.
|
||||
type ToolLoopConfig struct {
|
||||
Provider providers.LLMProvider
|
||||
Model string
|
||||
Tools *ToolRegistry
|
||||
MaxIterations int
|
||||
LLMOptions map[string]any
|
||||
Provider providers.LLMProvider
|
||||
Model string
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -104,20 +177,32 @@ func RunToolLoop(
|
|||
|
||||
// 6. Build assistant message with tool calls
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: response.Content,
|
||||
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",
|
||||
Name: tc.Name,
|
||||
Arguments: tc.Arguments,
|
||||
Function: &providers.FunctionCall{
|
||||
Name: tc.Name,
|
||||
Arguments: string(argumentsJSON),
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue