From a640257dbff03d8fd8c94c1db4e345cfe9ab168c Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 30 Jul 2026 09:36:54 +0200 Subject: [PATCH] registry: refresh schemas on tool_list and reload on tool_load tool_load always re-reads the tool from disk (reload semantics). tool_list calls RefreshLoaded to update schemas for all promoted tools. Skills registry gets the same treatment: dynamic discovery, no cache. --- skills/registry.go | 50 +++++++++++++++++++++++---------------------- tools/tool.go | 7 +++++++ toolsrv/registry.go | 39 +++++++++++++++++++++++++++++++---- 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/skills/registry.go b/skills/registry.go index 62ba2a0..7d31e0b 100644 --- a/skills/registry.go +++ b/skills/registry.go @@ -4,9 +4,9 @@ package skills import ( "fmt" - "sort" "os" "path/filepath" + "sort" "strings" "sync" ) @@ -32,25 +32,33 @@ type SkillInfo struct { // Tracks which skills are loaded per session. type Registry struct { mu sync.RWMutex - global map[string]SkillInfo sessions map[string]map[string]bool // sessionID → skill name → loaded } func NewRegistry() *Registry { return &Registry{ - global: make(map[string]SkillInfo), sessions: make(map[string]map[string]bool), } } -// Discover scans the skills directory and registers all found skills. +// Discover validates that the skills directory is readable. +// Retained for backward compatibility; discovery is now dynamic. func (r *Registry) Discover() error { + dir := SkillsPath() + if _, err := os.ReadDir(dir); err != nil { + return fmt.Errorf("read skills dir %s: %w", dir, err) + } + return nil +} + +// discover scans the skills directory and returns all found skills. +func (r *Registry) discover() map[string]SkillInfo { dir := SkillsPath() entries, err := os.ReadDir(dir) if err != nil { - return fmt.Errorf("read skills dir %s: %w", dir, err) + return nil } - global := make(map[string]SkillInfo) + skills := make(map[string]SkillInfo) for _, e := range entries { if !e.IsDir() { continue @@ -67,24 +75,20 @@ func (r *Registry) Discover() error { if err != nil { continue } - global[name] = SkillInfo{ + skills[name] = SkillInfo{ Name: meta.Name, Description: meta.Description, Dir: skillDir, } } - r.mu.Lock() - r.global = global - r.mu.Unlock() - return nil + return skills } // Summaries returns all discovered skills. func (r *Registry) Summaries() []SkillInfo { - r.mu.RLock() - defer r.mu.RUnlock() - out := make([]SkillInfo, 0, len(r.global)) - for _, info := range r.global { + skills := r.discover() + out := make([]SkillInfo, 0, len(skills)) + for _, info := range skills { out = append(out, info) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) @@ -93,10 +97,8 @@ func (r *Registry) Summaries() []SkillInfo { // Load marks a skill as loaded for the given session. func (r *Registry) Load(sessionID, name string) error { - r.mu.RLock() - _, exists := r.global[name] - r.mu.RUnlock() - if !exists { + skills := r.discover() + if _, exists := skills[name]; !exists { return fmt.Errorf("skill not found: %s", name) } r.mu.Lock() @@ -124,14 +126,15 @@ func (r *Registry) Unload(sessionID, name string) error { // Loaded returns skills currently loaded for a session. func (r *Registry) Loaded(sessionID string) []SkillInfo { r.mu.RLock() - defer r.mu.RUnlock() names, ok := r.sessions[sessionID] + r.mu.RUnlock() if !ok { return nil } + skills := r.discover() out := make([]SkillInfo, 0, len(names)) for name := range names { - if info, exists := r.global[name]; exists { + if info, exists := skills[name]; exists { out = append(out, info) } } @@ -142,9 +145,8 @@ func (r *Registry) Loaded(sessionID string) []SkillInfo { // SkillContent reads and returns the full content of a skill's SKILL.md file, // with front-matter stripped. Returns (content, error). func (r *Registry) SkillContent(name string) (string, error) { - r.mu.RLock() - info, exists := r.global[name] - r.mu.RUnlock() + skills := r.discover() + info, exists := skills[name] if !exists { return "", fmt.Errorf("skill not found: %s", name) } diff --git a/tools/tool.go b/tools/tool.go index d4ed254..c18b0b1 100644 --- a/tools/tool.go +++ b/tools/tool.go @@ -15,6 +15,13 @@ func ToolList(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (s if reg == nil { return "", fmt.Errorf("tool_list: no registry available") } + + // Refresh loaded tool schemas from disk so the model sees current definitions. + sid := srv.SessionID() + if sid != "" { + reg.RefreshLoaded(sid) + } + summaries := reg.Summaries() if len(summaries) == 0 { return "(no tools found)", nil diff --git a/toolsrv/registry.go b/toolsrv/registry.go index a444f8b..a6d4565 100644 --- a/toolsrv/registry.go +++ b/toolsrv/registry.go @@ -80,10 +80,6 @@ func (r *Registry) Load(sessionID, name string) error { r.sessions[sessionID] = make(map[string]ToolInfo) } - if _, already := r.sessions[sessionID][name]; already { - return nil - } - r.sessions[sessionID][name] = tool r.revisions[sessionID]++ return nil @@ -124,6 +120,41 @@ func (r *Registry) Loaded(sessionID string) []ToolInfo { return loaded } +// RefreshLoaded re-reads all loaded tools from disk, updating their schemas +// and metadata in place. Called from tool_list to keep schemas current. +func (r *Registry) RefreshLoaded(sessionID string) { + r.mu.RLock() + sessionTools, ok := r.sessions[sessionID] + if !ok || len(sessionTools) == 0 { + r.mu.RUnlock() + return + } + names := make([]string, 0, len(sessionTools)) + for name := range sessionTools { + names = append(names, name) + } + r.mu.RUnlock() + + fresh := r.discover() + + r.mu.Lock() + defer r.mu.Unlock() + changed := false + for _, name := range names { + if tool, exists := fresh[name]; exists { + r.sessions[sessionID][name] = tool + changed = true + } else { + // Tool was removed from disk — unload it. + delete(r.sessions[sessionID], name) + changed = true + } + } + if changed { + r.revisions[sessionID]++ + } +} + func (r *Registry) Lookup(sessionID, name string) (ToolInfo, bool) { r.mu.RLock() defer r.mu.RUnlock()