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 (
"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)
}

View File

@ -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

View File

@ -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()