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.
This commit is contained in:
Levi Neely 2026-07-30 09:36:54 +02:00
parent 6fc2e001ac
commit a640257dbf
3 changed files with 68 additions and 28 deletions

View File

@ -4,9 +4,9 @@ package skills
import ( import (
"fmt" "fmt"
"sort"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
) )
@ -32,25 +32,33 @@ type SkillInfo struct {
// Tracks which skills are loaded per session. // Tracks which skills are loaded per session.
type Registry struct { type Registry struct {
mu sync.RWMutex mu sync.RWMutex
global map[string]SkillInfo
sessions map[string]map[string]bool // sessionID → skill name → loaded sessions map[string]map[string]bool // sessionID → skill name → loaded
} }
func NewRegistry() *Registry { func NewRegistry() *Registry {
return &Registry{ return &Registry{
global: make(map[string]SkillInfo),
sessions: make(map[string]map[string]bool), 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 { 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() dir := SkillsPath()
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { 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 { for _, e := range entries {
if !e.IsDir() { if !e.IsDir() {
continue continue
@ -67,24 +75,20 @@ func (r *Registry) Discover() error {
if err != nil { if err != nil {
continue continue
} }
global[name] = SkillInfo{ skills[name] = SkillInfo{
Name: meta.Name, Name: meta.Name,
Description: meta.Description, Description: meta.Description,
Dir: skillDir, Dir: skillDir,
} }
} }
r.mu.Lock() return skills
r.global = global
r.mu.Unlock()
return nil
} }
// Summaries returns all discovered skills. // Summaries returns all discovered skills.
func (r *Registry) Summaries() []SkillInfo { func (r *Registry) Summaries() []SkillInfo {
r.mu.RLock() skills := r.discover()
defer r.mu.RUnlock() out := make([]SkillInfo, 0, len(skills))
out := make([]SkillInfo, 0, len(r.global)) for _, info := range skills {
for _, info := range r.global {
out = append(out, info) out = append(out, info)
} }
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) 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. // Load marks a skill as loaded for the given session.
func (r *Registry) Load(sessionID, name string) error { func (r *Registry) Load(sessionID, name string) error {
r.mu.RLock() skills := r.discover()
_, exists := r.global[name] if _, exists := skills[name]; !exists {
r.mu.RUnlock()
if !exists {
return fmt.Errorf("skill not found: %s", name) return fmt.Errorf("skill not found: %s", name)
} }
r.mu.Lock() r.mu.Lock()
@ -124,14 +126,15 @@ func (r *Registry) Unload(sessionID, name string) error {
// Loaded returns skills currently loaded for a session. // Loaded returns skills currently loaded for a session.
func (r *Registry) Loaded(sessionID string) []SkillInfo { func (r *Registry) Loaded(sessionID string) []SkillInfo {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock()
names, ok := r.sessions[sessionID] names, ok := r.sessions[sessionID]
r.mu.RUnlock()
if !ok { if !ok {
return nil return nil
} }
skills := r.discover()
out := make([]SkillInfo, 0, len(names)) out := make([]SkillInfo, 0, len(names))
for name := range names { for name := range names {
if info, exists := r.global[name]; exists { if info, exists := skills[name]; exists {
out = append(out, info) 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, // SkillContent reads and returns the full content of a skill's SKILL.md file,
// with front-matter stripped. Returns (content, error). // with front-matter stripped. Returns (content, error).
func (r *Registry) SkillContent(name string) (string, error) { func (r *Registry) SkillContent(name string) (string, error) {
r.mu.RLock() skills := r.discover()
info, exists := r.global[name] info, exists := skills[name]
r.mu.RUnlock()
if !exists { if !exists {
return "", fmt.Errorf("skill not found: %s", name) return "", fmt.Errorf("skill not found: %s", name)
} }

View File

@ -15,6 +15,13 @@ func ToolList(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (s
if reg == nil { if reg == nil {
return "", fmt.Errorf("tool_list: no registry available") 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() summaries := reg.Summaries()
if len(summaries) == 0 { if len(summaries) == 0 {
return "(no tools found)", nil return "(no tools found)", nil

View File

@ -80,10 +80,6 @@ func (r *Registry) Load(sessionID, name string) error {
r.sessions[sessionID] = make(map[string]ToolInfo) r.sessions[sessionID] = make(map[string]ToolInfo)
} }
if _, already := r.sessions[sessionID][name]; already {
return nil
}
r.sessions[sessionID][name] = tool r.sessions[sessionID][name] = tool
r.revisions[sessionID]++ r.revisions[sessionID]++
return nil return nil
@ -124,6 +120,41 @@ func (r *Registry) Loaded(sessionID string) []ToolInfo {
return loaded 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) { func (r *Registry) Lookup(sessionID, name string) (ToolInfo, bool) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()