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.
func (ag *Agent) SaveFull(path, sessionID, cwd, remote string) error {
if ag.history == nil {

View File

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

View File

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

View File

@ -28,6 +28,19 @@ func ToolList(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (s
}
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
}

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.
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)

View File

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