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-02-04 11:06:13 +00:00
|
|
|
"sync"
|
2026-02-10 05:18:23 +00:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-02-13 07:05:16 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
2026-02-04 11:06:13 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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()
|
2026-03-01 04:00:26 +00:00
|
|
|
name := tool.Name()
|
|
|
|
|
if _, exists := r.tools[name]; exists {
|
|
|
|
|
logger.WarnCF("tools", "Tool registration overwrites existing tool",
|
|
|
|
|
map[string]any{"name": name})
|
|
|
|
|
}
|
|
|
|
|
r.tools[name] = tool
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
|
|
|
|
r.mu.RLock()
|
|
|
|
|
defer r.mu.RUnlock()
|
|
|
|
|
tool, ok := r.tools[name]
|
|
|
|
|
return tool, ok
|
|
|
|
|
}
|
|
|
|
|
|
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-02-12 15:51:52 +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-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()
|
|
|
|
|
if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil {
|
|
|
|
|
logger.DebugCF("tool", "Executing async tool via ExecuteAsync",
|
2026-02-18 19:48:23 +00:00
|
|
|
map[string]any{
|
2026-02-12 11:42:24 +00:00
|
|
|
"tool": name,
|
|
|
|
|
})
|
2026-03-05 01:57:33 +00:00
|
|
|
result = asyncExec.ExecuteAsync(ctx, args, asyncCallback)
|
|
|
|
|
} else {
|
|
|
|
|
result = tool.Execute(ctx, args)
|
2026-02-12 11:42:24 +00:00
|
|
|
}
|
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-02-12 11:28:56 +00:00
|
|
|
"result_length": len(result.ForLLM),
|
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 {
|
|
|
|
|
definitions = append(definitions, ToolToSchema(r.tools[name]))
|
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 {
|
|
|
|
|
tool := r.tools[name]
|
2026-02-13 07:05:16 +00:00
|
|
|
schema := ToolToSchema(tool)
|
|
|
|
|
|
|
|
|
|
// 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-02-13 07:05:16 +00:00
|
|
|
|
|
|
|
|
definitions = append(definitions, providers.ToolDefinition{
|
|
|
|
|
Type: "function",
|
|
|
|
|
Function: providers.ToolFunctionDefinition{
|
|
|
|
|
Name: name,
|
|
|
|
|
Description: desc,
|
|
|
|
|
Parameters: params,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return definitions
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 {
|
|
|
|
|
tool := r.tools[name]
|
2026-02-10 15:33:28 +00:00
|
|
|
summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description()))
|
|
|
|
|
}
|
|
|
|
|
return summaries
|
|
|
|
|
}
|