improvements and optimizations

This commit is contained in:
afjcjsbx 2026-03-08 16:03:38 +01:00
parent 097df699eb
commit 725bec8247
8 changed files with 237 additions and 51 deletions

View file

@ -21,7 +21,8 @@ type ContextBuilder struct {
workspace string workspace string
skillsLoader *skills.SkillsLoader skillsLoader *skills.SkillsLoader
memory *MemoryStore memory *MemoryStore
toolDiscovery bool toolDiscoveryBM25 bool
toolDiscoveryRegex bool
// Cache for system prompt to avoid rebuilding on every call. // Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context. // This fixes issue #607: repeated reprocessing of the entire context.
@ -42,8 +43,9 @@ type ContextBuilder struct {
skillFilesAtCache map[string]time.Time skillFilesAtCache map[string]time.Time
} }
func (cb *ContextBuilder) WithToolDiscovery(enabled bool) *ContextBuilder { func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
cb.toolDiscovery = enabled cb.toolDiscoveryBM25 = useBM25
cb.toolDiscoveryRegex = useRegex
return cb return cb
} }
@ -104,10 +106,22 @@ Your workspace is at: %s
} }
func (cb *ContextBuilder) getDiscoveryRule() string { func (cb *ContextBuilder) getDiscoveryRule() string {
if cb.toolDiscovery { if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex {
return `5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the "tool_search_tool_bm25" or "tool_search_tool_regex" tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`
}
return "" return ""
}
var toolNames []string
if cb.toolDiscoveryBM25 {
toolNames = append(toolNames, `"tool_search_tool_bm25"`)
}
if cb.toolDiscoveryRegex {
toolNames = append(toolNames, `"tool_search_tool_regex"`)
}
return fmt.Sprintf(
`5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`,
strings.Join(toolNames, " or "),
)
} }
func (cb *ContextBuilder) BuildSystemPrompt() string { func (cb *ContextBuilder) BuildSystemPrompt() string {

View file

@ -96,7 +96,10 @@ func NewAgentInstance(
sessionsDir := filepath.Join(workspace, "sessions") sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir) sessionsManager := session.NewSessionManager(sessionsDir)
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(cfg.Tools.MCP.ToolConfig.Discovery.Enabled) contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
cfg.Tools.MCP.Discovery.Enabled && cfg.Tools.MCP.Discovery.UseBM25,
cfg.Tools.MCP.Discovery.Enabled && cfg.Tools.MCP.Discovery.UseRegex,
)
agentID := routing.DefaultAgentID agentID := routing.DefaultAgentID
agentName := "" agentName := ""

View file

@ -11,7 +11,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
@ -285,7 +284,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
if al.cfg.Tools.MCP.ToolConfig.Enabled { if al.cfg.Tools.MCP.Discovery.Enabled {
agent.Tools.RegisterHidden(mcpTool) agent.Tools.RegisterHidden(mcpTool)
} else { } else {
agent.Tools.Register(mcpTool) agent.Tools.Register(mcpTool)
@ -315,10 +314,10 @@ func (al *AgentLoop) Run(ctx context.Context) error {
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25 useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
// Fail fast: If discovery is enabled but no tool is turned on, break the app // Fail fast: If discovery is enabled but no search method is turned on
if !useBM25 && !useRegex { if !useBM25 && !useRegex {
log.Fatalf( return fmt.Errorf(
"Critical error: tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration.", "tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
) )
} }
@ -328,6 +327,9 @@ func (al *AgentLoop) Run(ctx context.Context) error {
} }
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
if maxSearchResults <= 0 {
maxSearchResults = 5 // Default value
}
for _, agentID := range agentIDs { for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID) agent, ok := al.registry.GetAgent(agentID)
@ -931,9 +933,6 @@ func (al *AgentLoop) runLLMIteration(
"max": agent.MaxIterations, "max": agent.MaxIterations,
}) })
// Scale down the TTL of the discovered tools with each new round of the LLM
agent.Tools.TickTTL()
// Build tool definitions // Build tool definitions
providerToolDefs := agent.Tools.ToProviderDefs() providerToolDefs := agent.Tools.ToProviderDefs()
@ -1299,6 +1298,11 @@ func (al *AgentLoop) runLLMIteration(
// Save tool result message to session // Save tool result message to session
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg) agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
} }
// Tick down TTL of discovered tools after processing tool results.
// Only reached when tool calls were made (the loop continues);
// the break on no-tool-call responses skips this.
agent.Tools.TickTTL()
} }
return finalContent, iteration, nil return finalContent, iteration, nil

View file

@ -569,7 +569,6 @@ type ToolDiscoveryConfig struct {
type ToolConfig struct { type ToolConfig struct {
Enabled bool `json:"enabled" env:"ENABLED"` Enabled bool `json:"enabled" env:"ENABLED"`
Discovery ToolDiscoveryConfig `json:"discovery"`
} }
type BraveConfig struct { type BraveConfig struct {
@ -720,7 +719,8 @@ type MCPServerConfig struct {
// MCPConfig defines configuration for all MCP servers // MCPConfig defines configuration for all MCP servers
type MCPConfig struct { type MCPConfig struct {
ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"` ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
Discovery ToolDiscoveryConfig ` json:"discovery"`
// Servers is a map of server name to server configuration // Servers is a map of server name to server configuration
Servers map[string]MCPServerConfig `json:"servers,omitempty"` Servers map[string]MCPServerConfig `json:"servers,omitempty"`
} }

View file

@ -410,6 +410,7 @@ func DefaultConfig() *Config {
MCP: MCPConfig{ MCP: MCPConfig{
ToolConfig: ToolConfig{ ToolConfig: ToolConfig{
Enabled: false, Enabled: false,
},
Discovery: ToolDiscoveryConfig{ Discovery: ToolDiscoveryConfig{
Enabled: false, Enabled: false,
TTL: 5, TTL: 5,
@ -417,7 +418,6 @@ func DefaultConfig() *Config {
UseBM25: true, UseBM25: true,
UseRegex: false, UseRegex: false,
}, },
},
Servers: map[string]MCPServerConfig{}, Servers: map[string]MCPServerConfig{},
}, },
AppendFile: ToolConfig{ AppendFile: ToolConfig{

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"sort" "sort"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
@ -20,6 +21,7 @@ type ToolEntry struct {
type ToolRegistry struct { type ToolRegistry struct {
tools map[string]*ToolEntry tools map[string]*ToolEntry
mu sync.RWMutex mu sync.RWMutex
version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation
} }
func NewToolRegistry() *ToolRegistry { func NewToolRegistry() *ToolRegistry {
@ -41,6 +43,7 @@ func (r *ToolRegistry) Register(tool Tool) {
IsCore: true, IsCore: true,
TTL: 0, // Core tools do not use TTL TTL: 0, // Core tools do not use TTL
} }
r.version.Add(1)
} }
// RegisterHidden saves hidden tools (visible only via TTL) // RegisterHidden saves hidden tools (visible only via TTL)
@ -48,22 +51,30 @@ func (r *ToolRegistry) RegisterHidden(tool Tool) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
name := tool.Name() name := tool.Name()
if _, exists := r.tools[name]; exists {
logger.WarnCF("tools", "Hidden tool registration overwrites existing tool",
map[string]any{"name": name})
}
r.tools[name] = &ToolEntry{ r.tools[name] = &ToolEntry{
Tool: tool, Tool: tool,
IsCore: false, IsCore: false,
TTL: 0, TTL: 0,
} }
r.version.Add(1)
} }
// PromoteTool imposta il TTL solo se il tool NON è un core tool // PromoteTools atomically sets the TTL for multiple non-core tools.
func (r *ToolRegistry) PromoteTool(name string, ttl int) { // This prevents a concurrent TickTTL from decrementing between promotions.
func (r *ToolRegistry) PromoteTools(names []string, ttl int) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
for _, name := range names {
if entry, exists := r.tools[name]; exists { if entry, exists := r.tools[name]; exists {
if !entry.IsCore { if !entry.IsCore {
entry.TTL = ttl entry.TTL = ttl
} }
} }
}
} }
// TickTTL decreases TTL only for non-core tools // TickTTL decreases TTL only for non-core tools
@ -84,6 +95,10 @@ func (r *ToolRegistry) Get(name string) (Tool, bool) {
if !ok { if !ok {
return nil, false return nil, false
} }
// Hidden tools with expired TTL are not callable.
if !entry.IsCore && entry.TTL <= 0 {
return nil, false
}
return entry.Tool, true return entry.Tool, true
} }

View file

@ -6,10 +6,15 @@ import (
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
"sync"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
const (
MaxRegexPatternLength = 200
)
type RegexSearchTool struct { type RegexSearchTool struct {
registry *ToolRegistry registry *ToolRegistry
ttl int ttl int
@ -49,6 +54,11 @@ func (t *RegexSearchTool) Execute(ctx context.Context, args map[string]any) *Too
return ErrorResult("Missing or invalid 'pattern' argument. Must be a non-empty string.") return ErrorResult("Missing or invalid 'pattern' argument. Must be a non-empty string.")
} }
if len(pattern) > MaxRegexPatternLength {
// Limit on length to avoid catastrophic patterns
return ErrorResult(fmt.Sprintf("Pattern too long: max %d characters allowed", MaxRegexPatternLength))
}
res, err := t.registry.SearchRegex(pattern, t.maxSearchResults) res, err := t.registry.SearchRegex(pattern, t.maxSearchResults)
if err != nil { if err != nil {
return ErrorResult(fmt.Sprintf("Invalid regex pattern syntax: %v. Please fix your regex and try again.", err)) return ErrorResult(fmt.Sprintf("Invalid regex pattern syntax: %v. Please fix your regex and try again.", err))
@ -61,6 +71,11 @@ type BM25SearchTool struct {
registry *ToolRegistry registry *ToolRegistry
ttl int ttl int
maxSearchResults int maxSearchResults int
// Cache: rebuilt only when the registry version changes.
cacheMu sync.Mutex
cachedEngine *bm25CachedEngine
cacheVersion uint64
} }
func NewBM25SearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *BM25SearchTool { func NewBM25SearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *BM25SearchTool {
@ -96,17 +111,40 @@ func (t *BM25SearchTool) Execute(ctx context.Context, args map[string]any) *Tool
return ErrorResult("Missing or invalid 'query' argument. Must be a non-empty string.") return ErrorResult("Missing or invalid 'query' argument. Must be a non-empty string.")
} }
return formatDiscoveryResponse(t.registry, t.registry.SearchBM25(query, t.maxSearchResults), t.ttl) cached := t.getOrBuildEngine()
if cached == nil {
return SilentResult("No tools found matching the query.")
}
ranked := cached.engine.Search(query, t.maxSearchResults)
if len(ranked) == 0 {
return SilentResult("No tools found matching the query.")
}
results := make([]ToolSearchResult, len(ranked))
for i, r := range ranked {
results[i] = ToolSearchResult{
Name: r.Document.Name,
Description: r.Document.Description,
}
}
return formatDiscoveryResponse(t.registry, results, t.ttl)
} }
// ToolSearchResult represents the result returned to the LLM. // ToolSearchResult represents the result returned to the LLM.
// Parameters are omitted from the JSON response to save context tokens;
// the LLM will see full schemas via ToProviderDefs after promotion.
type ToolSearchResult struct { type ToolSearchResult struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
} }
func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]ToolSearchResult, error) { func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]ToolSearchResult, error) {
if maxSearchResults <= 0 {
return nil, nil
}
regex, err := regexp.Compile("(?i)" + pattern) regex, err := regexp.Compile("(?i)" + pattern)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to compile regex pattern %q: %w", pattern, err) return nil, fmt.Errorf("failed to compile regex pattern %q: %w", pattern, err)
@ -117,7 +155,9 @@ func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]Tool
var results []ToolSearchResult var results []ToolSearchResult
for name, entry := range r.tools { // Iterate in sorted order for deterministic results across calls.
for _, name := range r.sortedToolNames() {
entry := r.tools[name]
// Search only among the hidden tools (Core tools are already visible) // Search only among the hidden tools (Core tools are already visible)
if !entry.IsCore { if !entry.IsCore {
// Directly call interface methods! No reflection/unmarshalling needed. // Directly call interface methods! No reflection/unmarshalling needed.
@ -127,7 +167,6 @@ func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]Tool
results = append(results, ToolSearchResult{ results = append(results, ToolSearchResult{
Name: name, Name: name,
Description: desc, Description: desc,
Parameters: entry.Tool.Parameters(),
}) })
if len(results) >= maxSearchResults { if len(results) >= maxSearchResults {
break // Stop searching once we hit the max! Saves CPU. break // Stop searching once we hit the max! Saves CPU.
@ -144,9 +183,11 @@ func formatDiscoveryResponse(registry *ToolRegistry, results []ToolSearchResult,
return SilentResult("No tools found matching the query.") return SilentResult("No tools found matching the query.")
} }
for _, r := range results { names := make([]string, len(results))
registry.PromoteTool(r.Name, ttl) for i, r := range results {
names[i] = r.Name
} }
registry.PromoteTools(names, ttl)
b, err := json.Marshal(results) b, err := json.Marshal(results)
if err != nil { if err != nil {
@ -162,20 +203,64 @@ func formatDiscoveryResponse(registry *ToolRegistry, results []ToolSearchResult,
return SilentResult(msg) return SilentResult(msg)
} }
// Lightweight internal type // Lightweight internal type used as corpus document for BM25.
type searchDoc struct { type searchDoc struct {
Name string Name string
Description string Description string
Tool Tool // Hold the interface reference }
// bm25CachedEngine wraps a BM25Engine with its corpus snapshot.
type bm25CachedEngine struct {
engine *utils.BM25Engine[searchDoc]
}
// getOrBuildEngine returns a cached BM25 engine, rebuilding it only when
// the registry version has changed (new tools registered).
func (t *BM25SearchTool) getOrBuildEngine() *bm25CachedEngine {
currentVersion := t.registry.version.Load()
t.cacheMu.Lock()
defer t.cacheMu.Unlock()
if t.cachedEngine != nil && t.cacheVersion == currentVersion {
return t.cachedEngine
}
t.registry.mu.RLock()
snapshot := make([]searchDoc, 0, len(t.registry.tools))
for name, entry := range t.registry.tools {
if !entry.IsCore {
snapshot = append(snapshot, searchDoc{
Name: name,
Description: entry.Tool.Description(),
})
}
}
t.registry.mu.RUnlock()
if len(snapshot) == 0 {
t.cachedEngine = nil
t.cacheVersion = currentVersion
return nil
}
engine := utils.NewBM25Engine(
snapshot,
func(doc searchDoc) string {
return doc.Name + " " + doc.Description
},
)
cached := &bm25CachedEngine{engine: engine}
t.cachedEngine = cached
t.cacheVersion = currentVersion
return cached
} }
// SearchBM25 ranks hidden tools against query using BM25 via utils.BM25Engine. // SearchBM25 ranks hidden tools against query using BM25 via utils.BM25Engine.
// The corpus snapshot is built under the registry read-lock, then released // The corpus snapshot is built under the registry read-lock, then released
// before scoring so the lock is not held during CPU-intensive work. // before scoring so the lock is not held during CPU-intensive work.
func (r *ToolRegistry) SearchBM25(query string, maxSearchResults int) []ToolSearchResult { func (r *ToolRegistry) SearchBM25(query string, maxSearchResults int) []ToolSearchResult {
// We copy only the lightweight searchDoc values (name, description,
// Tool interface reference). This keeps the lock window short and avoids
// holding it during BM25 indexing and scoring.
r.mu.RLock() r.mu.RLock()
snapshot := make([]searchDoc, 0, len(r.tools)) snapshot := make([]searchDoc, 0, len(r.tools))
for name, entry := range r.tools { for name, entry := range r.tools {
@ -183,7 +268,6 @@ func (r *ToolRegistry) SearchBM25(query string, maxSearchResults int) []ToolSear
snapshot = append(snapshot, searchDoc{ snapshot = append(snapshot, searchDoc{
Name: name, Name: name,
Description: entry.Tool.Description(), Description: entry.Tool.Description(),
Tool: entry.Tool,
}) })
} }
} }
@ -193,7 +277,6 @@ func (r *ToolRegistry) SearchBM25(query string, maxSearchResults int) []ToolSear
return nil return nil
} }
// Delegate scoring to the generic BM25 engine
engine := utils.NewBM25Engine( engine := utils.NewBM25Engine(
snapshot, snapshot,
func(doc searchDoc) string { func(doc searchDoc) string {
@ -211,7 +294,6 @@ func (r *ToolRegistry) SearchBM25(query string, maxSearchResults int) []ToolSear
out[i] = ToolSearchResult{ out[i] = ToolSearchResult{
Name: r.Document.Name, Name: r.Document.Name,
Description: r.Document.Description, Description: r.Document.Description,
Parameters: r.Document.Tool.Parameters(),
} }
} }
return out return out

View file

@ -138,6 +138,74 @@ func TestBM25SearchTool_Execute(t *testing.T) {
}) })
} }
func TestRegexSearchTool_PatternTooLong(t *testing.T) {
reg := setupPopulatedRegistry()
tool := NewRegexSearchTool(reg, 5, 10)
ctx := context.Background()
longPattern := strings.Repeat("a", MaxRegexPatternLength+1)
res := tool.Execute(ctx, map[string]any{"pattern": longPattern})
if !res.IsError || !strings.Contains(res.ForLLM, "Pattern too long") {
t.Errorf("Expected pattern too long error, got: %v", res.ForLLM)
}
}
func TestSearchRegex_ZeroMaxResults(t *testing.T) {
reg := setupPopulatedRegistry()
res, err := reg.SearchRegex("mcp", 0)
if err != nil {
t.Fatalf("SearchRegex failed: %v", err)
}
if len(res) != 0 {
t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res))
}
}
func TestSearchBM25_ZeroMaxResults(t *testing.T) {
reg := setupPopulatedRegistry()
res := reg.SearchBM25("read file", 0)
if len(res) != 0 {
t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res))
}
}
func TestSearchRegex_DeterministicOrder(t *testing.T) {
reg := NewToolRegistry()
for i := 0; i < 20; i++ {
reg.RegisterHidden(&mockSearchableTool{
name: fmt.Sprintf("tool_%02d", i),
desc: "searchable tool",
})
}
// Run the same search multiple times and verify order is stable
var firstRun []string
for attempt := 0; attempt < 10; attempt++ {
res, err := reg.SearchRegex("searchable", 20)
if err != nil {
t.Fatalf("SearchRegex failed: %v", err)
}
names := make([]string, len(res))
for i, r := range res {
names[i] = r.Name
}
if attempt == 0 {
firstRun = names
} else {
for i, name := range names {
if name != firstRun[i] {
t.Fatalf("Non-deterministic order at attempt %d, index %d: got %q, want %q",
attempt, i, name, firstRun[i])
}
}
}
}
}
func TestToolRegistry_SearchLimitsAndCoreFiltering(t *testing.T) { func TestToolRegistry_SearchLimitsAndCoreFiltering(t *testing.T) {
reg := NewToolRegistry() reg := NewToolRegistry()