toolsrv: dynamic tool discovery, drop global cache

Registry.Summaries() and Load() now scan the tools directory on every
call instead of reading from a startup cache. New tools dropped into
the directory are immediately visible without restarting the server.

When tool_list is called, the OnToolsChanged hook fires and updates
the preamble's '# Available Tools' section in the live agent runtime.
No agent reload or session restart required.
This commit is contained in:
Levi Neely 2026-07-30 09:16:01 +02:00
parent 80d7e4436d
commit 10bd60980a
7 changed files with 69 additions and 22 deletions

View File

@ -319,6 +319,35 @@ func (ag *Agent) RenamePreamble(old, new string) {
} }
} }
// refreshToolListing replaces the "# Available Tools" section in the preamble
// with an updated listing. Called when tool_list detects directory changes.
func (ag *Agent) refreshToolListing(listing string) {
if ag.runtime == nil {
return
}
const marker = "\n# Available Tools\n\n"
if idx := strings.Index(ag.runtime.Preamble, marker); idx >= 0 {
ag.runtime.Preamble = ag.runtime.Preamble[:idx]
}
if listing != "" {
ag.runtime.Preamble += marker + listing
}
}
// wireToolsChanged sets the OnToolsChanged callback on the tool server
// so the preamble stays in sync when tools are added/removed from disk.
func (ag *Agent) wireToolsChanged() {
if ag.runtime == nil || ag.runtime.ToolServer == nil {
return
}
type hasHook interface {
SetOnToolsChanged(func(string))
}
if srv, ok := ag.runtime.ToolServer.(hasHook); ok {
srv.SetOnToolsChanged(ag.refreshToolListing)
}
}
// SaveFull persists the full session state (history + metadata) to the given path. // SaveFull persists the full session state (history + metadata) to the given path.
func (ag *Agent) SaveFull(path, sessionID, cwd, remote string) error { func (ag *Agent) SaveFull(path, sessionID, cwd, remote string) error {
if ag.history == nil { if ag.history == nil {

View File

@ -203,6 +203,7 @@ func (ag *Agent) cmdAgent(_ context.Context, args []string) {
ag.runtime = rt ag.runtime = rt
ag.agentName = name ag.agentName = name
ag.history = nil ag.history = nil
ag.wireToolsChanged()
ag.notifyChange() ag.notifyChange()
for _, msg := range rt.Messages { for _, msg := range rt.Messages {
ag.emit(infoEvent(msg)) ag.emit(infoEvent(msg))

View File

@ -56,5 +56,6 @@ func NewAgent(cfg AgentCfg) *Agent {
} }
ag.changeCond = sync.NewCond(&ag.changeMu) ag.changeCond = sync.NewCond(&ag.changeMu)
ag.turnError = ag.defaultTurnError ag.turnError = ag.defaultTurnError
ag.wireToolsChanged()
return ag return ag
} }

View File

@ -28,6 +28,19 @@ func ToolList(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (s
} }
out.WriteString("\n") out.WriteString("\n")
} }
// Notify the agent that the tool listing may have changed so the
// preamble stays in sync with the directory contents.
if srv.OnToolsChanged != nil {
var listing strings.Builder
for _, s := range summaries {
if s.Description != "" {
fmt.Fprintf(&listing, "- **%s** — %s\n", s.Name, s.Description)
}
}
srv.OnToolsChanged(listing.String())
}
return strings.TrimRight(out.String(), "\n"), nil return strings.TrimRight(out.String(), "\n"), nil
} }

View File

@ -30,6 +30,11 @@ func (e *Server) InjectContent(content string) {
} }
} }
// SetOnToolsChanged sets the callback for tool directory changes.
func (e *Server) SetOnToolsChanged(fn func(string)) {
e.OnToolsChanged = fn
}
// ExecuteInSandbox runs a command in the sandbox. Exported for builtin tools. // ExecuteInSandbox runs a command in the sandbox. Exported for builtin tools.
func (e *Server) ExecuteInSandbox(ctx context.Context, cmd, language string, timeout int, sandbox string, trusted bool, stdin string, detach bool) (string, error) { func (e *Server) ExecuteInSandbox(ctx context.Context, cmd, language string, timeout int, sandbox string, trusted bool, stdin string, detach bool) (string, error) {
return e.executeWithStdin(ctx, cmd, language, timeout, sandbox, trusted, stdin, detach) return e.executeWithStdin(ctx, cmd, language, timeout, sandbox, trusted, stdin, detach)

View File

@ -2,41 +2,40 @@ package toolsrv
import ( import (
"fmt" "fmt"
"strings"
"sort"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"sync" "sync"
) )
type Registry struct { type Registry struct {
mu sync.RWMutex mu sync.RWMutex
global map[string]ToolInfo
sessions map[string]map[string]ToolInfo sessions map[string]map[string]ToolInfo
revisions map[string]uint64 revisions map[string]uint64
} }
func NewRegistry() (*Registry, error) { func NewRegistry() (*Registry, error) {
r := &Registry{ r := &Registry{
global: make(map[string]ToolInfo),
sessions: make(map[string]map[string]ToolInfo), sessions: make(map[string]map[string]ToolInfo),
revisions: make(map[string]uint64), revisions: make(map[string]uint64),
} }
if err := r.Discover(); err != nil { // Validate that the tools directory is readable.
return nil, err dir := ToolsPath()
if _, err := os.ReadDir(dir); err != nil {
return nil, fmt.Errorf("read tools dir %s: %w", dir, err)
} }
return r, nil return r, nil
} }
func (r *Registry) Discover() error { // discover scans the tools directory and returns all valid tool infos.
func (r *Registry) discover() map[string]ToolInfo {
dir := ToolsPath() dir := ToolsPath()
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
return fmt.Errorf("read tools dir %s: %w", dir, err) return nil
} }
global := make(map[string]ToolInfo) tools := make(map[string]ToolInfo)
for _, e := range entries { for _, e := range entries {
if e.IsDir() || e.Name() == "idx" || e.Name()[0] == '.' { if e.IsDir() || e.Name() == "idx" || e.Name()[0] == '.' {
continue continue
@ -49,19 +48,15 @@ func (r *Registry) Discover() error {
if !strings.Contains(script, "ollie:prompt") { if !strings.Contains(script, "ollie:prompt") {
continue continue
} }
global[e.Name()] = ParseToolInfo(e.Name(), script) tools[e.Name()] = ParseToolInfo(e.Name(), script)
} }
r.mu.Lock() return tools
r.global = global
r.mu.Unlock()
return nil
} }
func (r *Registry) Summaries() []ToolInfo { func (r *Registry) Summaries() []ToolInfo {
r.mu.RLock() tools := r.discover()
defer r.mu.RUnlock()
var summaries []ToolInfo var summaries []ToolInfo
for _, info := range r.global { for _, info := range tools {
summaries = append(summaries, ToolInfo{ summaries = append(summaries, ToolInfo{
Name: info.Name, Name: info.Name,
Description: info.Description, Description: info.Description,
@ -72,9 +67,8 @@ func (r *Registry) Summaries() []ToolInfo {
} }
func (r *Registry) Load(sessionID, name string) error { func (r *Registry) Load(sessionID, name string) error {
r.mu.RLock() tools := r.discover()
tool, exists := r.global[name] tool, exists := tools[name]
r.mu.RUnlock()
if !exists { if !exists {
return fmt.Errorf("tool not found: %s", name) return fmt.Errorf("tool not found: %s", name)
} }

View File

@ -58,6 +58,10 @@ type Server struct {
// should be injected into the agent's context. // should be injected into the agent's context.
OnInjection func(content string) OnInjection func(content string)
// OnToolsChanged is called when tool_list detects the available tools
// have changed, providing the updated preamble-format listing.
OnToolsChanged func(listing string)
// rate limiting state (per-Server) // rate limiting state (per-Server)
rateLimitMu sync.Mutex rateLimitMu sync.Mutex
validationFailures int validationFailures int