google / google/adk-go

Toolset.Tools(ctx) evaluated once per Runner.Run(), not per model step — state-driven toolsets broken

Open
#757 2 comments 2 reactions 0 assignees View on GitHub
bug
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.