2026-02-04 11:06:13 +00:00
|
|
|
package tools
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
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.
2026-02-25 02:34:54 +00:00
|
|
|
"sort"
|
2026-03-29 11:58:19 +00:00
|
|
|
"strings"
|
2026-02-04 11:06:13 +00:00
|
|
|
"sync"
|
2026-03-09 17:21:49 +00:00
|
|
|
"sync/atomic"
|
2026-02-10 05:18:23 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-03-22 11:05:28 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
2026-02-13 07:05:16 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
2026-02-04 11:06:13 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-09 17:21:49 +00:00
|
|
|
type ToolEntry struct {
|
|
|
|
|
Tool Tool
|
|
|
|
|
IsCore bool
|
|
|
|
|
TTL int
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
type ToolRegistry struct {
|
2026-03-22 11:05:28 +00:00
|
|
|
tools map[string]*ToolEntry
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation
|
|
|
|
|
mediaStore media.MediaStore
|
2026-03-29 11:58:19 +00:00
|
|
|
allowlist map[string]struct{}
|
2026-03-22 11:05:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type mediaStoreAware interface {
|
|
|
|
|
SetMediaStore(store media.MediaStore)
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewToolRegistry() *ToolRegistry {
|
|
|
|
|
return &ToolRegistry{
|
2026-03-09 17:21:49 +00:00
|
|
|
tools: make(map[string]*ToolEntry),
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 11:58:19 +00:00
|
|
|
// SetAllowlist restricts registrations to the provided runtime tool names.
|
|
|
|
|
// A nil slice means "allow all". An empty-but-non-nil slice means "allow none".
|
|
|
|
|
func (r *ToolRegistry) SetAllowlist(names []string) {
|
|
|
|
|
r.mu.Lock()
|
|
|
|
|
defer r.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if names == nil {
|
|
|
|
|
r.allowlist = nil
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
allowlist := make(map[string]struct{}, len(names))
|
|
|
|
|
for _, name := range names {
|
2026-03-29 20:43:20 +00:00
|
|
|
trimmed := strings.ToLower(strings.TrimSpace(name))
|
2026-03-29 11:58:19 +00:00
|
|
|
if trimmed == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
allowlist[trimmed] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
r.allowlist = allowlist
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
func (r *ToolRegistry) Register(tool Tool) {
|
|
|
|
|
r.mu.Lock()
|
|
|
|
|
defer r.mu.Unlock()
|
2026-03-01 04:00:26 +00:00
|
|
|
name := tool.Name()
|
2026-03-29 11:58:19 +00:00
|
|
|
if !r.toolAllowedLocked(name) {
|
|
|
|
|
logger.DebugCF(
|
|
|
|
|
"tools",
|
|
|
|
|
"Skipped core tool registration by agent allowlist",
|
|
|
|
|
map[string]any{"name": name},
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-01 04:00:26 +00:00
|
|
|
if _, exists := r.tools[name]; exists {
|
|
|
|
|
logger.WarnCF("tools", "Tool registration overwrites existing tool",
|
|
|
|
|
map[string]any{"name": name})
|
|
|
|
|
}
|
2026-03-09 17:21:49 +00:00
|
|
|
r.tools[name] = &ToolEntry{
|
|
|
|
|
Tool: tool,
|
|
|
|
|
IsCore: true,
|
|
|
|
|
TTL: 0, // Core tools do not use TTL
|
|
|
|
|
}
|
2026-03-22 11:05:28 +00:00
|
|
|
if aware, ok := tool.(mediaStoreAware); ok && r.mediaStore != nil {
|
|
|
|
|
aware.SetMediaStore(r.mediaStore)
|
|
|
|
|
}
|
2026-03-09 17:21:49 +00:00
|
|
|
r.version.Add(1)
|
|
|
|
|
logger.DebugCF("tools", "Registered core tool", map[string]any{"name": name})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RegisterHidden saves hidden tools (visible only via TTL)
|
|
|
|
|
func (r *ToolRegistry) RegisterHidden(tool Tool) {
|
|
|
|
|
r.mu.Lock()
|
|
|
|
|
defer r.mu.Unlock()
|
|
|
|
|
name := tool.Name()
|
2026-03-29 11:58:19 +00:00
|
|
|
if !r.toolAllowedLocked(name) {
|
|
|
|
|
logger.DebugCF(
|
|
|
|
|
"tools",
|
|
|
|
|
"Skipped hidden tool registration by agent allowlist",
|
|
|
|
|
map[string]any{"name": name},
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-09 17:21:49 +00:00
|
|
|
if _, exists := r.tools[name]; exists {
|
|
|
|
|
logger.WarnCF("tools", "Hidden tool registration overwrites existing tool",
|
|
|
|
|
map[string]any{"name": name})
|
|
|
|
|
}
|
|
|
|
|
r.tools[name] = &ToolEntry{
|
|
|
|
|
Tool: tool,
|
|
|
|
|
IsCore: false,
|
|
|
|
|
TTL: 0,
|
|
|
|
|
}
|
2026-03-22 11:05:28 +00:00
|
|
|
if aware, ok := tool.(mediaStoreAware); ok && r.mediaStore != nil {
|
|
|
|
|
aware.SetMediaStore(r.mediaStore)
|
|
|
|
|
}
|
2026-03-09 17:21:49 +00:00
|
|
|
r.version.Add(1)
|
|
|
|
|
logger.DebugCF("tools", "Registered hidden tool", map[string]any{"name": name})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 11:05:28 +00:00
|
|
|
// SetMediaStore injects a MediaStore into all registered tools that can
|
|
|
|
|
// consume it, and remembers it for future registrations.
|
|
|
|
|
func (r *ToolRegistry) SetMediaStore(store media.MediaStore) {
|
|
|
|
|
r.mu.Lock()
|
|
|
|
|
defer r.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
r.mediaStore = store
|
|
|
|
|
for _, entry := range r.tools {
|
|
|
|
|
if aware, ok := entry.Tool.(mediaStoreAware); ok {
|
|
|
|
|
aware.SetMediaStore(store)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 17:21:49 +00:00
|
|
|
// PromoteTools atomically sets the TTL for multiple non-core tools.
|
|
|
|
|
// This prevents a concurrent TickTTL from decrementing between promotions.
|
|
|
|
|
func (r *ToolRegistry) PromoteTools(names []string, ttl int) {
|
|
|
|
|
r.mu.Lock()
|
|
|
|
|
defer r.mu.Unlock()
|
|
|
|
|
promoted := 0
|
|
|
|
|
for _, name := range names {
|
|
|
|
|
if entry, exists := r.tools[name]; exists {
|
|
|
|
|
if !entry.IsCore {
|
|
|
|
|
entry.TTL = ttl
|
|
|
|
|
promoted++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
logger.DebugCF(
|
|
|
|
|
"tools",
|
|
|
|
|
"PromoteTools completed",
|
|
|
|
|
map[string]any{"requested": len(names), "promoted": promoted, "ttl": ttl},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TickTTL decreases TTL only for non-core tools
|
|
|
|
|
func (r *ToolRegistry) TickTTL() {
|
|
|
|
|
r.mu.Lock()
|
|
|
|
|
defer r.mu.Unlock()
|
|
|
|
|
for _, entry := range r.tools {
|
|
|
|
|
if !entry.IsCore && entry.TTL > 0 {
|
|
|
|
|
entry.TTL--
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Version returns the current registry version (atomically).
|
|
|
|
|
func (r *ToolRegistry) Version() uint64 {
|
|
|
|
|
return r.version.Load()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 11:58:19 +00:00
|
|
|
func (r *ToolRegistry) toolAllowedLocked(name string) bool {
|
|
|
|
|
if r.allowlist == nil {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-05-08 07:18:14 +00:00
|
|
|
if isToolDiscoveryToolName(name) {
|
|
|
|
|
// Discovery tools are part of the MCP control plane: they must remain
|
|
|
|
|
// available whenever configured so deferred MCP tools can still be
|
|
|
|
|
// unlocked. Per-agent allowlists still apply to the hidden MCP tools
|
|
|
|
|
// themselves during RegisterHidden.
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-03-29 20:43:20 +00:00
|
|
|
_, ok := r.allowlist[strings.ToLower(strings.TrimSpace(name))]
|
2026-03-29 11:58:19 +00:00
|
|
|
return ok
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 12:01:43 +00:00
|
|
|
// HasRegistered reports whether a tool name is present in the registry,
|
|
|
|
|
// including hidden tools whose TTL is currently zero.
|
|
|
|
|
func (r *ToolRegistry) HasRegistered(name string) bool {
|
|
|
|
|
r.mu.RLock()
|
|
|
|
|
defer r.mu.RUnlock()
|
|
|
|
|
_, ok := r.tools[name]
|
|
|
|
|
return ok
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 17:21:49 +00:00
|
|
|
// HiddenToolSnapshot holds a consistent snapshot of hidden tools and the
|
|
|
|
|
// registry version at which it was taken. Used by BM25SearchTool cache.
|
|
|
|
|
type HiddenToolSnapshot struct {
|
|
|
|
|
Docs []HiddenToolDoc
|
|
|
|
|
Version uint64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HiddenToolDoc is a lightweight representation of a hidden tool for search indexing.
|
|
|
|
|
type HiddenToolDoc struct {
|
|
|
|
|
Name string
|
|
|
|
|
Description string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SnapshotHiddenTools returns all non-core tools and the current registry
|
|
|
|
|
// version under a single read-lock, guaranteeing consistency between the
|
|
|
|
|
// two values.
|
|
|
|
|
func (r *ToolRegistry) SnapshotHiddenTools() HiddenToolSnapshot {
|
|
|
|
|
r.mu.RLock()
|
|
|
|
|
defer r.mu.RUnlock()
|
|
|
|
|
docs := make([]HiddenToolDoc, 0, len(r.tools))
|
|
|
|
|
for name, entry := range r.tools {
|
|
|
|
|
if !entry.IsCore {
|
|
|
|
|
docs = append(docs, HiddenToolDoc{
|
|
|
|
|
Name: name,
|
|
|
|
|
Description: entry.Tool.Description(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return HiddenToolSnapshot{
|
|
|
|
|
Docs: docs,
|
|
|
|
|
Version: r.version.Load(),
|
|
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
|
|
|
|
r.mu.RLock()
|
|
|
|
|
defer r.mu.RUnlock()
|
2026-03-09 17:21:49 +00:00
|
|
|
entry, ok := r.tools[name]
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
// Hidden tools with expired TTL are not callable.
|
|
|
|
|
if !entry.IsCore && entry.TTL <= 0 {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return entry.Tool, true
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-18 19:48:23 +00:00
|
|
|
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult {
|
2026-02-12 11:42:24 +00:00
|
|
|
return r.ExecuteWithContext(ctx, name, args, "", "", nil)
|
2026-02-11 04:28:37 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-12 11:42:24 +00:00
|
|
|
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
|
2026-03-05 01:57:33 +00:00
|
|
|
// If the tool implements AsyncExecutor and a non-nil callback is provided,
|
|
|
|
|
// ExecuteAsync is called instead of Execute — the callback is a parameter,
|
|
|
|
|
// never stored as mutable state on the tool.
|
2026-02-18 19:48:23 +00:00
|
|
|
func (r *ToolRegistry) ExecuteWithContext(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
name string,
|
|
|
|
|
args map[string]any,
|
|
|
|
|
channel, chatID string,
|
|
|
|
|
asyncCallback AsyncCallback,
|
|
|
|
|
) *ToolResult {
|
2026-02-10 05:18:23 +00:00
|
|
|
logger.InfoCF("tool", "Tool execution started",
|
2026-02-18 19:48:23 +00:00
|
|
|
map[string]any{
|
2026-02-10 05:18:23 +00:00
|
|
|
"tool": name,
|
|
|
|
|
"args": args,
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
tool, ok := r.Get(name)
|
|
|
|
|
if !ok {
|
2026-02-10 05:18:23 +00:00
|
|
|
logger.ErrorCF("tool", "Tool not found",
|
2026-02-18 19:48:23 +00:00
|
|
|
map[string]any{
|
2026-02-10 05:18:23 +00:00
|
|
|
"tool": name,
|
|
|
|
|
})
|
2026-03-29 11:58:19 +00:00
|
|
|
return ErrorResult(
|
|
|
|
|
fmt.Sprintf("tool %q not found", name),
|
|
|
|
|
).WithError(fmt.Errorf("tool not found"))
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
2026-02-10 05:18:23 +00:00
|
|
|
|
2026-03-24 10:35:56 +00:00
|
|
|
// Validate arguments against the tool's declared schema.
|
|
|
|
|
if err := validateToolArgs(tool.Parameters(), args); err != nil {
|
|
|
|
|
logger.WarnCF("tool", "Tool argument validation failed",
|
|
|
|
|
map[string]any{"tool": name, "error": err.Error()})
|
|
|
|
|
return ErrorResult(fmt.Sprintf("invalid arguments for tool %q: %s", name, err)).
|
|
|
|
|
WithError(fmt.Errorf("argument validation failed: %w", err))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 01:57:33 +00:00
|
|
|
// Inject channel/chatID into ctx so tools read them via ToolChannel(ctx)/ToolChatID(ctx).
|
|
|
|
|
// Always inject — tools validate what they require.
|
|
|
|
|
ctx = WithToolContext(ctx, channel, chatID)
|
2026-02-11 04:28:37 +00:00
|
|
|
|
2026-03-05 01:57:33 +00:00
|
|
|
// If tool implements AsyncExecutor and callback is provided, use ExecuteAsync.
|
|
|
|
|
// The callback is a call parameter, not mutable state on the tool instance.
|
|
|
|
|
var result *ToolResult
|
|
|
|
|
start := time.Now()
|
2026-03-18 16:10:26 +00:00
|
|
|
|
|
|
|
|
// Use recover to catch any panics during tool execution
|
|
|
|
|
// This prevents tool crashes from killing the entire agent
|
|
|
|
|
func() {
|
|
|
|
|
defer func() {
|
|
|
|
|
if re := recover(); re != nil {
|
2026-04-01 15:26:49 +00:00
|
|
|
logger.RecoverPanicNoExit(re)
|
2026-03-18 16:10:26 +00:00
|
|
|
errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re)
|
|
|
|
|
logger.ErrorCF("tool", "Tool execution panic recovered",
|
|
|
|
|
map[string]any{
|
|
|
|
|
"tool": name,
|
|
|
|
|
"panic": fmt.Sprintf("%v", re),
|
|
|
|
|
})
|
|
|
|
|
result = &ToolResult{
|
|
|
|
|
ForLLM: errMsg,
|
|
|
|
|
ForUser: errMsg,
|
|
|
|
|
IsError: true,
|
|
|
|
|
Err: fmt.Errorf("panic: %v", re),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil {
|
|
|
|
|
logger.DebugCF("tool", "Executing async tool via ExecuteAsync",
|
|
|
|
|
map[string]any{
|
|
|
|
|
"tool": name,
|
|
|
|
|
})
|
|
|
|
|
result = asyncExec.ExecuteAsync(ctx, args, asyncCallback)
|
|
|
|
|
} else {
|
|
|
|
|
result = tool.Execute(ctx, args)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Handle nil result (should not happen, but defensive)
|
|
|
|
|
if result == nil {
|
|
|
|
|
result = &ToolResult{
|
|
|
|
|
ForLLM: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name),
|
|
|
|
|
ForUser: fmt.Sprintf("Tool '%s' returned nil result unexpectedly", name),
|
|
|
|
|
IsError: true,
|
|
|
|
|
Err: fmt.Errorf("nil result from tool"),
|
|
|
|
|
}
|
2026-02-12 11:42:24 +00:00
|
|
|
}
|
2026-03-18 16:10:26 +00:00
|
|
|
|
2026-03-22 11:05:28 +00:00
|
|
|
result = normalizeToolResult(result, name, r.mediaStore, channel, chatID)
|
|
|
|
|
|
2026-02-10 05:18:23 +00:00
|
|
|
duration := time.Since(start)
|
|
|
|
|
|
2026-02-12 11:28:56 +00:00
|
|
|
// Log based on result type
|
|
|
|
|
if result.IsError {
|
2026-02-10 05:18:23 +00:00
|
|
|
logger.ErrorCF("tool", "Tool execution failed",
|
2026-02-18 19:48:23 +00:00
|
|
|
map[string]any{
|
2026-02-10 05:18:23 +00:00
|
|
|
"tool": name,
|
|
|
|
|
"duration": duration.Milliseconds(),
|
2026-02-12 11:28:56 +00:00
|
|
|
"error": result.ForLLM,
|
|
|
|
|
})
|
|
|
|
|
} else if result.Async {
|
|
|
|
|
logger.InfoCF("tool", "Tool started (async)",
|
2026-02-18 19:48:23 +00:00
|
|
|
map[string]any{
|
2026-02-12 11:28:56 +00:00
|
|
|
"tool": name,
|
|
|
|
|
"duration": duration.Milliseconds(),
|
2026-02-10 05:18:23 +00:00
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
logger.InfoCF("tool", "Tool execution completed",
|
2026-02-18 19:48:23 +00:00
|
|
|
map[string]any{
|
2026-02-10 05:18:23 +00:00
|
|
|
"tool": name,
|
|
|
|
|
"duration_ms": duration.Milliseconds(),
|
2026-03-22 11:05:28 +00:00
|
|
|
"result_length": len(result.ContentForLLM()),
|
2026-02-10 05:18:23 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 11:28:56 +00:00
|
|
|
return result
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
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.
2026-02-25 02:34:54 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 19:48:23 +00:00
|
|
|
func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
2026-02-04 11:06:13 +00:00
|
|
|
r.mu.RLock()
|
|
|
|
|
defer r.mu.RUnlock()
|
|
|
|
|
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
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.
2026-02-25 02:34:54 +00:00
|
|
|
sorted := r.sortedToolNames()
|
|
|
|
|
definitions := make([]map[string]any, 0, len(sorted))
|
|
|
|
|
for _, name := range sorted {
|
2026-03-09 17:21:49 +00:00
|
|
|
entry := r.tools[name]
|
|
|
|
|
|
|
|
|
|
if !entry.IsCore && entry.TTL <= 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
definitions = append(definitions, ToolToSchema(r.tools[name].Tool))
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
return definitions
|
|
|
|
|
}
|
2026-02-10 08:05:23 +00:00
|
|
|
|
2026-02-13 07:05:16 +00:00
|
|
|
// 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()
|
|
|
|
|
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
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.
2026-02-25 02:34:54 +00:00
|
|
|
sorted := r.sortedToolNames()
|
|
|
|
|
definitions := make([]providers.ToolDefinition, 0, len(sorted))
|
|
|
|
|
for _, name := range sorted {
|
2026-03-09 17:21:49 +00:00
|
|
|
entry := r.tools[name]
|
|
|
|
|
|
|
|
|
|
if !entry.IsCore && entry.TTL <= 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
schema := ToolToSchema(entry.Tool)
|
2026-02-13 07:05:16 +00:00
|
|
|
|
|
|
|
|
// Safely extract nested values with type checks
|
2026-02-18 19:48:23 +00:00
|
|
|
fn, ok := schema["function"].(map[string]any)
|
2026-02-13 07:05:16 +00:00
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
name, _ := fn["name"].(string)
|
|
|
|
|
desc, _ := fn["description"].(string)
|
2026-02-18 19:48:23 +00:00
|
|
|
params, _ := fn["parameters"].(map[string]any)
|
2026-04-24 11:36:46 +00:00
|
|
|
metadata := promptMetadataForTool(entry.Tool)
|
2026-02-13 07:05:16 +00:00
|
|
|
|
|
|
|
|
definitions = append(definitions, providers.ToolDefinition{
|
|
|
|
|
Type: "function",
|
|
|
|
|
Function: providers.ToolFunctionDefinition{
|
|
|
|
|
Name: name,
|
|
|
|
|
Description: desc,
|
|
|
|
|
Parameters: params,
|
|
|
|
|
},
|
2026-04-24 11:36:46 +00:00
|
|
|
PromptLayer: metadata.Layer,
|
|
|
|
|
PromptSlot: metadata.Slot,
|
|
|
|
|
PromptSource: metadata.Source,
|
2026-02-13 07:05:16 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return definitions
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 11:36:46 +00:00
|
|
|
func promptMetadataForTool(tool Tool) PromptMetadata {
|
|
|
|
|
metadata := PromptMetadata{
|
|
|
|
|
Layer: ToolPromptLayerCapability,
|
|
|
|
|
Slot: ToolPromptSlotTooling,
|
|
|
|
|
Source: ToolPromptSourceRegistry,
|
|
|
|
|
}
|
|
|
|
|
if provider, ok := tool.(PromptMetadataProvider); ok {
|
|
|
|
|
provided := provider.PromptMetadata()
|
|
|
|
|
if provided.Layer != "" {
|
|
|
|
|
metadata.Layer = provided.Layer
|
|
|
|
|
}
|
|
|
|
|
if provided.Slot != "" {
|
|
|
|
|
metadata.Slot = provided.Slot
|
|
|
|
|
}
|
|
|
|
|
if provided.Source != "" {
|
|
|
|
|
metadata.Source = provided.Source
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return metadata
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 08:05:23 +00:00
|
|
|
// List returns a list of all registered tool names.
|
|
|
|
|
func (r *ToolRegistry) List() []string {
|
|
|
|
|
r.mu.RLock()
|
|
|
|
|
defer r.mu.RUnlock()
|
|
|
|
|
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
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.
2026-02-25 02:34:54 +00:00
|
|
|
return r.sortedToolNames()
|
2026-02-10 08:05:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 16:17:16 +00:00
|
|
|
// Clone creates an independent copy of the registry containing the same tool
|
|
|
|
|
// entries (shallow copy of each ToolEntry). This is used to give subagents a
|
|
|
|
|
// snapshot of the parent agent's tools without sharing the same registry —
|
|
|
|
|
// tools registered on the parent after cloning (e.g. spawn, spawn_status)
|
|
|
|
|
// will NOT be visible to the clone, preventing recursive subagent spawning.
|
|
|
|
|
// The version counter is reset to 0 in the clone as it's a new independent registry.
|
|
|
|
|
func (r *ToolRegistry) Clone() *ToolRegistry {
|
|
|
|
|
r.mu.RLock()
|
|
|
|
|
defer r.mu.RUnlock()
|
|
|
|
|
clone := &ToolRegistry{
|
2026-03-22 11:05:28 +00:00
|
|
|
tools: make(map[string]*ToolEntry, len(r.tools)),
|
|
|
|
|
mediaStore: r.mediaStore,
|
2026-03-18 16:17:16 +00:00
|
|
|
}
|
2026-03-29 11:58:19 +00:00
|
|
|
if r.allowlist != nil {
|
|
|
|
|
clone.allowlist = make(map[string]struct{}, len(r.allowlist))
|
|
|
|
|
for name := range r.allowlist {
|
|
|
|
|
clone.allowlist[name] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-18 16:17:16 +00:00
|
|
|
for name, entry := range r.tools {
|
|
|
|
|
clone.tools[name] = &ToolEntry{
|
|
|
|
|
Tool: entry.Tool,
|
|
|
|
|
IsCore: entry.IsCore,
|
|
|
|
|
TTL: entry.TTL,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return clone
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 08:05:23 +00:00
|
|
|
// Count returns the number of registered tools.
|
|
|
|
|
func (r *ToolRegistry) Count() int {
|
|
|
|
|
r.mu.RLock()
|
|
|
|
|
defer r.mu.RUnlock()
|
|
|
|
|
return len(r.tools)
|
|
|
|
|
}
|
2026-02-10 15:33:28 +00:00
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
|
fix: cache system prompt with mtime-based auto-invalidation (#607)
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.
2026-02-25 02:34:54 +00:00
|
|
|
sorted := r.sortedToolNames()
|
|
|
|
|
summaries := make([]string, 0, len(sorted))
|
|
|
|
|
for _, name := range sorted {
|
2026-03-09 17:21:49 +00:00
|
|
|
entry := r.tools[name]
|
|
|
|
|
|
|
|
|
|
if !entry.IsCore && entry.TTL <= 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 11:58:19 +00:00
|
|
|
summaries = append(
|
|
|
|
|
summaries,
|
|
|
|
|
fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description()),
|
|
|
|
|
)
|
2026-02-10 15:33:28 +00:00
|
|
|
}
|
|
|
|
|
return summaries
|
|
|
|
|
}
|
2026-03-17 04:50:32 +00:00
|
|
|
|
|
|
|
|
// GetAll returns all registered tools (both core and non-core with TTL > 0).
|
|
|
|
|
// Used by SubTurn to inherit parent's tool set.
|
|
|
|
|
func (r *ToolRegistry) GetAll() []Tool {
|
|
|
|
|
r.mu.RLock()
|
|
|
|
|
defer r.mu.RUnlock()
|
|
|
|
|
|
|
|
|
|
sorted := r.sortedToolNames()
|
|
|
|
|
tools := make([]Tool, 0, len(sorted))
|
|
|
|
|
for _, name := range sorted {
|
|
|
|
|
entry := r.tools[name]
|
|
|
|
|
|
|
|
|
|
// Include core tools and non-core tools with active TTL
|
|
|
|
|
if entry.IsCore || entry.TTL > 0 {
|
|
|
|
|
tools = append(tools, entry.Tool)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return tools
|
|
|
|
|
}
|