Toolset.Tools(ctx) evaluated once per Runner.Run(), not per model step — state-driven toolsets broken
- Dominant language
- Go
- Stars
- 8.8k
- Forks
- 1k
- Avg merge
- 3d 18h
- Merged PRs (30d)
- 88
Description
## Bug
`Toolset.Tools(ctx)` is called only once per `Runner.Run()`, not before each model call. The result is cached in `Flow.Tools` and reused for all subsequent `runOneStep()` iterations. Tools activated by session state changes mid-run are never visible within that run.
### Expected behavior
`Toolset.Tools(ctx)` is re-evaluated before each model call within `Flow.Run()`, so tools can change dynamically based on session state modified by earlier tool calls in the same run.
### Actual behavior
`Toolset.Tools(ctx)` is evaluated once during the first `runOneStep()`. All subsequent steps in the same `Runner.Run()` reuse the cached tool list. A tool that writes to session state (e.g. activating a capability) cannot make new tools visible until the next `Runner.Run()`.
### Root cause
`toolProcessor` in `internal/llminternal/tools_processor.go` guards with `if f.Tools != nil { return }` and caches the result in `f.Tools`. Since `llmAgent.run()` creates one `Flow` per `Runner.Run()` (`agent/llmagent/llmagent.go:374`), the cache persists across all `runOneStep()` iterations:
```go
func toolProcessor(ctx agent.InvocationContext, req *model.LLMRequest, f *Flow) iter.Seq2[*session.Event, error] {
return func(yield func(*session.Event, error) bool) {
if f.Tools != nil {
return // cached — never re-evaluated
}
// ...
for _, toolSet := range Reveal(llmAgent).Toolsets {
tsTools, err := toolSet.Tools(icontext.NewReadonlyContext(ctx))
tools = append(tools, tsTools...)
}
f.Tools = tools // set once, reused for all subsequent steps
}
}
```
### Suggested fix
Reset `f.Tools = nil` at the start of each `runOneStep()`, or remove the `if f.Tools != nil { return }` guard so `toolProcessor` re-evaluates toolsets every step.
### Impact
Any `Toolset` whose `Tools(ctx)` output depends on session state is broken within a single `Runner.Run()`. The `ctx` parameter on `Tools(ctx)` implies dynamic per-call evaluation, but the caching makes it effectively static.
A concrete example: an agent that starts with a small bootstrap toolset and uses a `load_skill` tool to activate domain-specific tools based on user intent. The tool writes to session state, and a conditional `Toolset.Tools(ctx)` checks that state. With the current caching, the activated tools are invisible until the next `Runner.Run()`.
### Reproduction
```go
type activateTool struct{}
func (t activateTool) Name() string { return "activate_vehicle_tools" }
func (t activateTool) Description() string { return "Activate vehicle tools." }
func (t activateTool) IsLongRunning() bool { return false }
func (t activateTool) Run(ctx tool.Context, args any) (map[string]any, error) {
err := ctx.State().Set("active_skills", map[string]any{"vehicles": true})
if err != nil {
return map[string]any{"error": err.Error()}, nil
}
return map[string]any{"ok": true}, nil
}
type conditionalToolset struct {
vehicleTool tool.Tool
}
func (ts conditionalToolset) Name() string { return "vehicles" }
func (ts conditionalToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) {
v, err := ctx.ReadonlyState().Get("active_skills")
if err != nil {
return nil, nil
}
skills, ok := v.(map[string]any)
if !ok || skills["vehicles"] != true {
return nil, nil
}
return []tool.Tool{ts.vehicleTool}, nil
}
```
1. Create an agent with `activateTool` in `Tools` and `conditionalToolset` in `Toolsets`.
2. The model calls `activate_vehicle_tools` → state is written.
3. On the next `runOneStep()` within the same `Runner.Run()`, `conditionalToolset.Tools(ctx)` is never called — `f.Tools` is cached.
4. `search_vehicles` remains unavailable for the rest of the run.
### Environment
- **ADK version:** `google.golang.org/adk v1.2.0`
- **Go version:** go1.26.2 linux/amd64
- **Frequency:** Always (100%)
Contributor guide
Research direction
Start in internal/llminternal/tools_processor.go and trace how toolProcessor is invoked from runOneStep; then inspect llmAgent.run in agent/llmagent/llmagent.go:374 to confirm the Flow lifetime. Use the provided state-driven reproduction to verify that Toolset.Tools(ctx) is evaluated again after a tool changes session state, and that newly activated tools are available in the next model step.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- ai
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100