mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
1f7cbd9164
Avoid rebuilding the entire system prompt on every BuildMessages() call by caching the static portion (identity, bootstrap, skills summary, memory) and only recomputing it when workspace source files change. Key changes: - ContextBuilder caches the static prompt behind an RWMutex with double-checked locking. Source file changes are detected via cheap os.Stat mtime checks so no explicit invalidation is needed. - Track file existence at cache time (existedAtCache map) so that newly created or deleted bootstrap/memory files also trigger a rebuild — the old modifiedSince() silently returned false on os.IsNotExist. - Walk the skills directory recursively with filepath.WalkDir to catch content-only edits at any nesting depth; directory mtime alone misses in-place file modifications on most filesystems. - ToolRegistry.sortedToolNames() sorts tool names before iteration, ensuring deterministic tool definition order across calls — a prerequisite for LLM-side prefix/KV cache reuse. - Merge all context (static + dynamic + summary) into a single system message for provider compatibility: the Anthropic adapter extracts messages[0] as the top-level system parameter, and Codex reads only the first system message as instructions. - Fix a data race in BuildMessages() where cachedSystemPrompt was read without holding the lock in a debug log statement. - Add tests: single system message invariant, mtime auto-invalidation, new-file creation detection, skill file content change, explicit InvalidateCache, cache stability, concurrent access (20 goroutines x 50 iterations, passes go test -race), and a benchmark.
199 lines
5.0 KiB
Go
199 lines
5.0 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
)
|
|
|
|
type ToolRegistry struct {
|
|
tools map[string]Tool
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func NewToolRegistry() *ToolRegistry {
|
|
return &ToolRegistry{
|
|
tools: make(map[string]Tool),
|
|
}
|
|
}
|
|
|
|
func (r *ToolRegistry) Register(tool Tool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.tools[tool.Name()] = tool
|
|
}
|
|
|
|
func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
tool, ok := r.tools[name]
|
|
return tool, ok
|
|
}
|
|
|
|
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult {
|
|
return r.ExecuteWithContext(ctx, name, args, "", "", nil)
|
|
}
|
|
|
|
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
|
|
// If the tool implements AsyncTool and a non-nil callback is provided,
|
|
// the callback will be set on the tool before execution.
|
|
func (r *ToolRegistry) ExecuteWithContext(
|
|
ctx context.Context,
|
|
name string,
|
|
args map[string]any,
|
|
channel, chatID string,
|
|
asyncCallback AsyncCallback,
|
|
) *ToolResult {
|
|
logger.InfoCF("tool", "Tool execution started",
|
|
map[string]any{
|
|
"tool": name,
|
|
"args": args,
|
|
})
|
|
|
|
tool, ok := r.Get(name)
|
|
if !ok {
|
|
logger.ErrorCF("tool", "Tool not found",
|
|
map[string]any{
|
|
"tool": name,
|
|
})
|
|
return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found"))
|
|
}
|
|
|
|
// If tool implements ContextualTool, set context
|
|
if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" {
|
|
contextualTool.SetContext(channel, chatID)
|
|
}
|
|
|
|
// If tool implements AsyncTool and callback is provided, set callback
|
|
if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil {
|
|
asyncTool.SetCallback(asyncCallback)
|
|
logger.DebugCF("tool", "Async callback injected",
|
|
map[string]any{
|
|
"tool": name,
|
|
})
|
|
}
|
|
|
|
start := time.Now()
|
|
result := tool.Execute(ctx, args)
|
|
duration := time.Since(start)
|
|
|
|
// Log based on result type
|
|
if result.IsError {
|
|
logger.ErrorCF("tool", "Tool execution failed",
|
|
map[string]any{
|
|
"tool": name,
|
|
"duration": duration.Milliseconds(),
|
|
"error": result.ForLLM,
|
|
})
|
|
} else if result.Async {
|
|
logger.InfoCF("tool", "Tool started (async)",
|
|
map[string]any{
|
|
"tool": name,
|
|
"duration": duration.Milliseconds(),
|
|
})
|
|
} else {
|
|
logger.InfoCF("tool", "Tool execution completed",
|
|
map[string]any{
|
|
"tool": name,
|
|
"duration_ms": duration.Milliseconds(),
|
|
"result_length": len(result.ForLLM),
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// sortedToolNames returns tool names in sorted order for deterministic iteration.
|
|
// This is critical for KV cache stability: non-deterministic map iteration would
|
|
// produce different system prompts and tool definitions on each call, invalidating
|
|
// the LLM's prefix cache even when no tools have changed.
|
|
func (r *ToolRegistry) sortedToolNames() []string {
|
|
names := make([]string, 0, len(r.tools))
|
|
for name := range r.tools {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
sorted := r.sortedToolNames()
|
|
definitions := make([]map[string]any, 0, len(sorted))
|
|
for _, name := range sorted {
|
|
definitions = append(definitions, ToolToSchema(r.tools[name]))
|
|
}
|
|
return definitions
|
|
}
|
|
|
|
// ToProviderDefs converts tool definitions to provider-compatible format.
|
|
// This is the format expected by LLM provider APIs.
|
|
func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
sorted := r.sortedToolNames()
|
|
definitions := make([]providers.ToolDefinition, 0, len(sorted))
|
|
for _, name := range sorted {
|
|
tool := r.tools[name]
|
|
schema := ToolToSchema(tool)
|
|
|
|
// Safely extract nested values with type checks
|
|
fn, ok := schema["function"].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
name, _ := fn["name"].(string)
|
|
desc, _ := fn["description"].(string)
|
|
params, _ := fn["parameters"].(map[string]any)
|
|
|
|
definitions = append(definitions, providers.ToolDefinition{
|
|
Type: "function",
|
|
Function: providers.ToolFunctionDefinition{
|
|
Name: name,
|
|
Description: desc,
|
|
Parameters: params,
|
|
},
|
|
})
|
|
}
|
|
return definitions
|
|
}
|
|
|
|
// List returns a list of all registered tool names.
|
|
func (r *ToolRegistry) List() []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
return r.sortedToolNames()
|
|
}
|
|
|
|
// Count returns the number of registered tools.
|
|
func (r *ToolRegistry) Count() int {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return len(r.tools)
|
|
}
|
|
|
|
// GetSummaries returns human-readable summaries of all registered tools.
|
|
// Returns a slice of "name - description" strings.
|
|
func (r *ToolRegistry) GetSummaries() []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
sorted := r.sortedToolNames()
|
|
summaries := make([]string, 0, len(sorted))
|
|
for _, name := range sorted {
|
|
tool := r.tools[name]
|
|
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description()))
|
|
}
|
|
return summaries
|
|
}
|