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.runtime = rt
ag.profile = name ag.profile = name
ag.history = nil ag.history = nil
ag.wireToolsChanged()
ag.save() ag.save()
ag.flush() ag.flush()
ag.notifyChange() ag.notifyChange()
@ -434,50 +433,15 @@ func (ag *Agent) SetCWD(dir string) {
ag.notifyChange() ag.notifyChange()
} }
// refreshToolListing replaces the tools section in the preamble with an // RefreshTools forces a refresh of the tool listing in the agent's preamble.
// updated listing. Called by the tool server when tools are added/removed. func (ag *Agent) RefreshTools() {
func (ag *Agent) refreshToolListing() { if ag.runtime == nil || ag.runtime.Preamble == nil || ag.runtime.ToolServer == nil {
if ag.runtime == nil || ag.runtime.Preamble == nil {
return return
} }
if ag.runtime.ToolServer != nil {
if infos, err := ag.runtime.ToolServer.ListTools(); err == nil { if infos, err := ag.runtime.ToolServer.ListTools(); err == nil {
ag.runtime.Preamble.Set(SectionTools, renderTools(infos)) 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 {
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
}
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. // CtxSz returns a human-readable context size string.
func (ag *Agent) CtxSz() string { func (ag *Agent) CtxSz() string {
@ -663,8 +627,8 @@ type AgentParams struct {
} }
// NewAgent constructs an Agent from the given configuration. // NewAgent constructs an Agent from the given configuration.
// Post-construction wiring (SetSessionEnv, wireToolsChanged) is handled // Post-construction wiring (SetSessionEnv) is handled internally —
// internally — no additional calls are required after construction. // no additional calls are required after construction.
func NewAgent(cfg AgentParams) *Agent { func NewAgent(cfg AgentParams) *Agent {
ag := &Agent{ ag := &Agent{
history: cfg.History, history: cfg.History,
@ -687,7 +651,6 @@ func NewAgent(cfg AgentParams) *Agent {
state: "idle", state: "idle",
} }
ag.signalCh = make(chan struct{}) ag.signalCh = make(chan struct{})
ag.wireToolsChanged()
ag.SetSessionEnv(cfg.SessionID) ag.SetSessionEnv(cfg.SessionID)
return ag return ag
} }

View File

@ -48,7 +48,8 @@ func TestRefreshToolListingEmpty(t *testing.T) {
ag := &Agent{runtime: &Runtime{Preamble: p}} 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() preamble := ag.runtime.PreambleString()

View File

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

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) 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). // Fetch tools from toolsrv (the sole owner of tool state).
// This avoids ListTools IPC + allocation on every turn in the common case. if ag.runtime.ToolServer != nil {
if ag.toolsNeedRefresh() {
ti, listErr := ag.runtime.ToolServer.ListTools() ti, listErr := ag.runtime.ToolServer.ListTools()
if listErr != nil { if listErr != nil {
ag.log.Error("tool server unavailable: %v", listErr) 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 meta[info.Name] = info
} }
ag.runtime.ToolMeta = meta ag.runtime.ToolMeta = meta
ag.runtime.Preamble.Set(SectionTools, renderTools(ti))
} }
turnCtx := TurnCtx{ turnCtx := TurnCtx{