agent: toolsrv is sole owner of tool state

Remove all cached tool state from agent:
- Remove toolRegistryRevision from Runtime
- Remove wireToolsChanged (OnToolsChanged callback)
- Remove toolsNeedRefresh (revision-based cache invalidation)
- Remove refreshToolListing (redundant with RefreshTools)

Tools are now fetched fresh from toolsrv at the start of every turn.
The preamble tools section is also rebuilt at turn start. No local
caching means tool_load/unload take effect immediately on the next
turn and /tools always shows the live state from toolsrv.
This commit is contained in:
Ollie Agent 2026-08-11 10:59:31 +02:00
parent 6b4e97aaf7
commit 686a560e84
4 changed files with 12 additions and 50 deletions

View File

@ -328,7 +328,6 @@ func (ag *Agent) SwitchProfile(name string) error {
ag.runtime = rt
ag.profile = name
ag.history = nil
ag.wireToolsChanged()
ag.save()
ag.flush()
ag.notifyChange()
@ -434,49 +433,14 @@ func (ag *Agent) SetCWD(dir string) {
ag.notifyChange()
}
// refreshToolListing replaces the tools section in the preamble with an
// updated listing. Called by the tool server when tools are added/removed.
func (ag *Agent) refreshToolListing() {
if ag.runtime == nil || ag.runtime.Preamble == nil {
return
}
if ag.runtime.ToolServer != nil {
if infos, err := ag.runtime.ToolServer.ListTools(); err == nil {
ag.runtime.Preamble.Set(SectionTools, renderTools(infos))
return
}
}
ag.runtime.Preamble.Set(SectionTools, "")
}
// RefreshTools forces a refresh of the tool listing in the agent's preamble.
// Call this after loading tools via ctl to update the agent's view.
func (ag *Agent) RefreshTools() {
ag.refreshToolListing()
}
// 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 {
if ag.runtime == nil || ag.runtime.Preamble == nil || ag.runtime.ToolServer == nil {
return
}
ag.runtime.ToolServer.SetOnToolsChanged(ag.refreshToolListing)
}
// toolsNeedRefresh reports whether the tool registry has changed since the
// last time Tools was populated. If the tool server doesn't support revision
// tracking (e.g. remote Conn returns 0), this always returns true (safe fallback).
func (ag *Agent) toolsNeedRefresh() bool {
if ag.runtime.ToolServer == nil {
return true
if infos, err := ag.runtime.ToolServer.ListTools(); err == nil {
ag.runtime.Preamble.Set(SectionTools, renderTools(infos))
}
rev := ag.runtime.ToolServer.ToolRegistryRevision()
if rev == 0 || rev != ag.runtime.toolRegistryRevision {
ag.runtime.toolRegistryRevision = rev
return true
}
return false
}
// CtxSz returns a human-readable context size string.
@ -663,8 +627,8 @@ type AgentParams struct {
}
// NewAgent constructs an Agent from the given configuration.
// Post-construction wiring (SetSessionEnv, wireToolsChanged) is handled
// internally — no additional calls are required after construction.
// Post-construction wiring (SetSessionEnv) is handled internally —
// no additional calls are required after construction.
func NewAgent(cfg AgentParams) *Agent {
ag := &Agent{
history: cfg.History,
@ -687,7 +651,6 @@ func NewAgent(cfg AgentParams) *Agent {
state: "idle",
}
ag.signalCh = make(chan struct{})
ag.wireToolsChanged()
ag.SetSessionEnv(cfg.SessionID)
return ag
}

View File

@ -48,7 +48,8 @@ func TestRefreshToolListingEmpty(t *testing.T) {
ag := &Agent{runtime: &Runtime{Preamble: p}}
ag.refreshToolListing()
// With nil ToolServer, RefreshTools is a no-op. Test the preamble Set directly.
p.Set(SectionTools, "")
preamble := ag.runtime.PreambleString()

View File

@ -131,10 +131,8 @@ type Runtime struct {
Exec toolExecutor
GenParams backend.GenerationParams
MaxSteps int
// toolRegistryRevision tracks the last-seen tool registry revision.
toolRegistryRevision uint64
CompactionModel string
Messages []string
CompactionModel string
Messages []string
}
// PreambleString returns the fully rendered system prompt.

View File

@ -99,9 +99,8 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
ag.log.Debug("turn: start input=%s session=%s", auditTruncate(input), ag.sessionID)
// Refresh tool definitions only when the registry has changed (tool_load/unload).
// This avoids ListTools IPC + allocation on every turn in the common case.
if ag.toolsNeedRefresh() {
// Fetch tools from toolsrv (the sole owner of tool state).
if ag.runtime.ToolServer != nil {
ti, listErr := ag.runtime.ToolServer.ListTools()
if listErr != nil {
ag.log.Error("tool server unavailable: %v", listErr)
@ -115,6 +114,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
meta[info.Name] = info
}
ag.runtime.ToolMeta = meta
ag.runtime.Preamble.Set(SectionTools, renderTools(ti))
}
turnCtx := TurnCtx{