fix(llminternal): re-evaluate toolsets on every step in toolProcessor - #785
nuthalapativarun wants to merge 3 commits into
Conversation
|
Hi, following up on this PR. Happy to make any changes needed — let me know if there's anything blocking review. Thanks! |
|
Hi, just following up on this PR. Happy to make any adjustments needed — let me know if there's anything blocking review. Thanks! |
752ec36 to
94e1371
Compare
|
Hi, just following up on this PR. Happy to make any adjustments needed — let me know if there's anything blocking review. Thanks! |
94e1371 to
7ab69bf
Compare
|
Hi, just following up on this PR. Happy to make any adjustments if something needs to change — let me know if there's anything blocking review. Thanks! |
7ab69bf to
c9b98da
Compare
karolpiotrowicz
left a comment
There was a problem hiding this comment.
The change itself is right, and it matches how adk-python has always behaved. _process_agent_tools there re-resolves every tool union, toolsets included, on each step (base_llm_flow.py:458, awaited from _preprocess_async at line 1435, inside the step loop), and the comment at lines 540-541 states the reason outright: "Tool sets can change between model steps, so the cache is refreshed each time." Driving a state-gated toolset through runner.Runner.Run, step 2's tool list is [activate] on main and [activate search_vehicles] with this applied, so the fix works at the public entry point. Sorry this sat unreviewed for so long.
Two things before it can merge.
The test passes on main, so it does not pin the fix
The fixture at base_flow_test.go:804 is &State{Toolsets: []tool.Toolset{ts}}, which leaves State.Tools nil, and toolsByCall[0] is nil too. Appending zero elements to a nil slice returns nil, so f.Tools is still nil after the first call and the guard you removed — if f.Tools != nil { return } — would never have fired here. I put the merged test file onto an otherwise unmodified main and it passes:
$ go test -run TestToolProcessorReEvaluatesToolsetsEachStep ./internal/llminternal/
--- PASS: TestToolProcessorReEvaluatesToolsetsEachStep (0.00s)
ok google.golang.org/adk/v2/internal/llminternal 0.117s
Giving the agent one static tool makes f.Tools non-nil after the first call, which is what the guard needs in order to trip. With this the test fails on main and passes with your change, and ./internal/llminternal/... stays green:
- agentState := &State{Toolsets: []tool.Toolset{ts}}
+ baseTool := &mockFunctionTool{name: "base_tool"}
+ agentState := &State{Tools: []tool.Tool{baseTool}, Toolsets: []tool.Toolset{ts}}
@@
- if len(f.Tools) != 0 {
- t.Errorf("after call 1: got %d tools, want 0", len(f.Tools))
+ if len(f.Tools) != 1 {
+ t.Errorf("after call 1: got %d tools, want 1", len(f.Tools))
}
@@
- if len(f.Tools) != 1 || f.Tools[0].Name() != extraTool.Name() {
+ if len(f.Tools) != 2 || f.Tools[1].Name() != extraTool.Name() {Rebase
The branch is 104 commits behind and conflicts in internal/llminternal/base_flow_test.go. It is only that both sides append tests at the end of the file, so keeping both blocks resolves it, but it does need doing.
Non-blocking, worth knowing
- Toolsets that do I/O now pay per step.
Toolset.Tools()goes from one call per run to one per model call — 11 calls for an 11-model-call run, measured. That matters formcptoolset, where every call is a live paginatedListToolswith no caching, and the loop at tools_processor.go:39 walks toolsets serially where Python runs them underasyncio.gatherspecifically to overlap those listings. Nothing to change in this PR — caching belongs inmcptoolset— but it is a consequence someone will hit. - A toolset that fails mid-run now aborts the invocation. The error return is unchanged, but it was previously only reachable before the first model call. A toolset erroring on its third evaluation used to leave the run to finish, and now ends it with earlier steps' side effects already persisted. The same holds for a toolset that starts returning a name already taken by a static tool, which surfaces as
duplicate tool: "...". Python fails closed the same way, so this looks like the intended semantics rather than something to fix — it is just worth a line in the description so it is not a surprise. - The comment overreaches slightly on the live path. tools_processor.go:27-30 says the list "must be rebuilt before each model call", but
RunLivecallspreprocessonce at base_flow.go:336, outside the reconnect loop, so toolsets are resolved once per live session both before and after this change. Issue #757's symptom survives there. That is a separate gap, not something this PR needs to close — the comment just shouldn't imply it already has. The comment it replaced describedContentRequestProcessor, so this is still a clear improvement.
toolProcessor cached f.Tools after the first call and returned early on subsequent steps within the same Flow.Run(). This prevented toolsets whose Tools() output depends on session state from updating their tool list after a tool call modified that state earlier in the same run. Remove the f.Tools guard so toolsets are re-evaluated before every model call, matching the intent of the Toolset.Tools(ctx) signature. Fixes google#757
c9b98da to
e8054d2
Compare
|
Rebased onto current |
karolpiotrowicz
left a comment
There was a problem hiding this comment.
One thing still blocks: the new test passes without the fix, so it would not catch the cache guard coming back. Before merge, the test needs to fail on main and pass with your change. The fixture-line comment has a change that does this. The full reasoning is in my earlier review, which I suspect was easy to miss, since your last comment mentions no review yet.
The branch is behind main again, with no conflicts at the moment.
Resolves the conflict in internal/llminternal/base_flow_test.go, where both sides appended tests at the end of the file. Both blocks are kept. Also makes TestToolProcessorReEvaluatesToolsetsEachStep fail without the fix, as requested in review: the agent now has one static tool, so f.Tools is non-nil after the first call and the removed cache guard would have returned early. Verified red with main's tools_processor.go and green with this change.
|
@karolpiotrowicz I pushed a commit to this branch (caf6660) that merges current With the static It passes with the change in this PR, and CI is green on the new head. I also updated the first-call comment and the second-call failure message, since both still described the old fixture with no static tool. @nuthalapativarun heads-up that your branch moved, in case you have local changes on it. |
…google#785) toolProcessor cached f.Tools after the first call and returned early on subsequent steps within the same Flow.Run(). This prevented toolsets whose Tools() output depends on session state from updating their tool list after a tool call modified that state earlier in the same run. Remove the f.Tools guard so toolsets are re-evaluated before every model call. Fixes google#757
Experiments behind the review of the native lazy tool catalog proposal: - turnproof: a discovered tool is absent from the generation that found it, so model-triggered search costs one extra model call. - dupproof: packing a tool that Tools() already returned aborts the run with a duplicate-tool error. - lazyclean: a catalog written purely through Toolset.Tools(ctx) only works if toolsets are re-evaluated per model step (google#757). - presearch: searching from UserContent before the first generation removes the extra model call when the search hits. - rank: recall of five rankers on long user messages over a 68-tool catalog, with and without ARD-style representativeQueries. lazyclean and presearch depend on google#785 and fail without it.
Link to Issue or Description of Change
Description
Problem:
toolProcessorininternal/llminternal/tools_processor.gocachedf.Toolsafter the first call and short-circuited withif f.Tools != nil { return }on every subsequent step. Since aFlowis created once perRunner.Run()and reused across allrunOneStep()iterations,Toolset.Tools(ctx)was only ever called on the first step of a run.Any toolset whose
Tools(ctx)output depends on session state modified by an earlier tool call could not surface new tools within the sameRunner.Run(). Thectxparameter onTools(ctx)implies dynamic per-step evaluation, but the caching made it effectively static for the duration of the run.Root cause:
Solution:
Remove the
if f.Tools != nil { return }guard soToolset.Tools()is called before every model step. StaticToolsfrom the agent config and dynamic toolset tools are rebuilt intof.Toolson each call, which is correct becauserunOneStepcreates a fresh*model.LLMRequesteach iteration anyway.Testing Plan
Unit Tests:
TestToolProcessorReEvaluatesToolsetsEachStepininternal/llminternal/base_flow_test.gowith adynamicToolsetthat returns no tools on the first call and one tool on the second call — verifying thattoolProcessorpicks up the change on the next step.Manual End-to-End (E2E) Tests:
The bug is deterministic: any agent with a
Toolsetthat uses session state to control which tools are returned will reproduce it. Theactivate_vehicle_toolsexample from issue #757 exercises the exact path —activate_vehicle_toolssets state, andconditionalToolset.Tools(ctx)checks that state to surface a vehicle tool. After this fix, the activated tools appear on the nextrunOneStep()within the sameRunner.Run().Checklist