Skip to content

Commit 3d1d12d

Browse files
DeanChensjcopybara-github
authored andcommitted
refactor(tools): move the tool confirmation gate into the tools layer
The agent pipeline answers a call whose tool requires confirmation with a gate in `flows/llm_flows/tools/_caller.py`: it asks for confirmation through `tool_context.request_confirmation()`, answers a rejected call with `{'error': 'This tool call is rejected.'}`, and lets approved calls through. Workflow tool nodes need the same gate. Rather than import a private helper from the agent flow, move it to `tools/_confirmation_utils.py` as `apply_confirmation_gate`, next to the tool types it depends on, so both runners can share it. No behavior change: the gate is moved as is and `_caller.py` calls it from its new location. Tests: unit tests for the gate covering calls that need no confirmation, unanswered, approved and rejected calls, a per-args confirmation predicate, and the rule that only a True answer from `check_require_confirmation` holds a call. Co-authored-by: Shangjie Chen <[email protected]> PiperOrigin-RevId: 991346819
1 parent ffc2494 commit 3d1d12d

3 files changed

Lines changed: 220 additions & 46 deletions

File tree

‎src/google/adk/flows/llm_flows/tools/_caller.py‎

Lines changed: 6 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from ....live._active_streaming_tool import ActiveStreamingTool
4646
from ....live.live_request_queue import LiveRequestQueue
4747
from ....telemetry import _instrumentation
48+
from ....tools._confirmation_utils import apply_confirmation_gate
4849
from ....tools.base_tool import BaseTool
4950
from ....tools.function_tool import _use_sync_callable_runner
5051
from ....tools.function_tool import FunctionTool
@@ -728,51 +729,6 @@ async def _prepare_single(
728729
)
729730

730731

731-
async def _apply_confirmation_gate(
732-
tool: BaseTool,
733-
function_args: dict[str, Any],
734-
tool_context: ToolContext,
735-
) -> dict[str, str] | None:
736-
"""Answers a call whose tool is waiting on a human, instead of running it.
737-
738-
Args:
739-
tool: The tool the call names.
740-
function_args: The arguments the call carries.
741-
tool_context: The context the call will run in.
742-
743-
Returns:
744-
The response to answer the call with, or None if the call may proceed.
745-
"""
746-
requires_confirmation = await tool.check_require_confirmation(
747-
function_args, tool_context
748-
)
749-
# Holding a call back from the model is restrictive, and the hook is declared
750-
# to answer with a bool, so anything other than True lets the call through.
751-
if requires_confirmation is not True:
752-
return None
753-
754-
confirmation = tool_context.tool_confirmation
755-
if confirmation is None:
756-
tool_context.request_confirmation(
757-
hint=(
758-
f'Please approve or reject the tool call {tool.name}() by'
759-
' responding with a FunctionResponse with an expected'
760-
' ToolConfirmation payload.'
761-
),
762-
)
763-
# The pause is not a tool result for the model to summarize; without this
764-
# the flow re-invokes the model, which calls the tool again.
765-
tool_context.actions.skip_summarization = True
766-
return {
767-
'error': (
768-
'This tool call requires confirmation, please approve or reject.'
769-
)
770-
}
771-
if not confirmation.confirmed:
772-
return {'error': 'This tool call is rejected.'}
773-
return None
774-
775-
776732
async def _execute_single_prepared_call(
777733
invocation_context: InvocationContext,
778734
prepared_call: _PreparedFunctionCall,
@@ -866,11 +822,15 @@ async def _run_with_trace() -> Event | None:
866822
# that raises.
867823
if function_response is None:
868824
try:
869-
function_response = await _apply_confirmation_gate(
825+
function_response = await apply_confirmation_gate(
870826
tool, function_args, tool_context
871827
)
872828
if function_response is None:
873829
function_response = await tool_runner()
830+
elif tool_context.tool_confirmation is None:
831+
# The pause is not a tool result for the model to summarize; without
832+
# this the flow re-invokes the model, which calls the tool again.
833+
tool_context.actions.skip_summarization = True
874834
except Exception as tool_error:
875835
error_response = await _tool_error_handler.run_on_tool_error_callbacks(
876836
invocation_context=invocation_context,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Helpers for asking the user to confirm a tool call."""
16+
17+
from __future__ import annotations
18+
19+
from typing import Any
20+
21+
from .base_tool import BaseTool
22+
from .tool_context import ToolContext
23+
24+
25+
async def apply_confirmation_gate(
26+
tool: BaseTool,
27+
function_args: dict[str, Any],
28+
tool_context: ToolContext,
29+
) -> dict[str, str] | None:
30+
"""Answers a call whose tool is waiting on a human, instead of running it.
31+
32+
A call that still needs confirmation records the request on
33+
`tool_context.actions.requested_tool_confirmations`, the same way a tool
34+
that calls `tool_context.request_confirmation()` itself does.
35+
36+
Args:
37+
tool: The tool the call names.
38+
function_args: The arguments the call carries.
39+
tool_context: The context the call will run in.
40+
41+
Returns:
42+
The response to answer the call with, or None if the call may proceed.
43+
44+
Raises:
45+
ValueError: If `tool_context.function_call_id` is missing or empty when
46+
requesting confirmation.
47+
"""
48+
requires_confirmation = await tool.check_require_confirmation(
49+
function_args, tool_context
50+
)
51+
# Holding a call back from the model is restrictive, and the hook is declared
52+
# to answer with a bool, so anything other than True lets the call through.
53+
if requires_confirmation is not True:
54+
return None
55+
56+
confirmation = tool_context.tool_confirmation
57+
if confirmation is None:
58+
tool_context.request_confirmation(
59+
hint=(
60+
f'Please approve or reject the tool call {tool.name}() by'
61+
' responding with a FunctionResponse with an expected'
62+
' ToolConfirmation payload.'
63+
),
64+
)
65+
return {
66+
'error': (
67+
'This tool call requires confirmation, please approve or reject.'
68+
)
69+
}
70+
if not confirmation.confirmed:
71+
return {'error': 'This tool call is rejected.'}
72+
return None
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for the shared tool confirmation gate.
16+
17+
Verifies that the gate holds back a call that needs confirmation, records the
18+
confirmation request, and lets approved or unguarded calls through.
19+
"""
20+
21+
from typing import Any
22+
from unittest.mock import MagicMock
23+
24+
from google.adk.agents.invocation_context import InvocationContext
25+
from google.adk.sessions.session import Session
26+
from google.adk.tools._confirmation_utils import apply_confirmation_gate
27+
from google.adk.tools.base_tool import BaseTool
28+
from google.adk.tools.function_tool import FunctionTool
29+
from google.adk.tools.tool_confirmation import ToolConfirmation
30+
from google.adk.tools.tool_context import ToolContext
31+
32+
_FUNCTION_CALL_ID = 'call-1'
33+
34+
35+
def _make_tool_context(
36+
tool_confirmation: ToolConfirmation | None = None,
37+
) -> ToolContext:
38+
invocation_context = MagicMock(spec=InvocationContext)
39+
invocation_context._state_schema = None
40+
invocation_context.session = MagicMock(spec=Session)
41+
invocation_context.session.state = {}
42+
return ToolContext(
43+
invocation_context=invocation_context,
44+
function_call_id=_FUNCTION_CALL_ID,
45+
tool_confirmation=tool_confirmation,
46+
)
47+
48+
49+
def _delete_file(path: str) -> dict[str, str]:
50+
return {'deleted': path}
51+
52+
53+
async def test_tool_without_confirmation_proceeds():
54+
"""A tool that does not require confirmation is let through untouched."""
55+
tool = FunctionTool(func=_delete_file)
56+
tool_context = _make_tool_context()
57+
58+
response = await apply_confirmation_gate(tool, {'path': 'a'}, tool_context)
59+
60+
assert response is None
61+
assert not tool_context.actions.requested_tool_confirmations
62+
assert not tool_context.actions.skip_summarization
63+
64+
65+
async def test_unanswered_call_requests_confirmation():
66+
"""A call that needs confirmation is held back and records the request."""
67+
tool = FunctionTool(func=_delete_file, require_confirmation=True)
68+
tool_context = _make_tool_context()
69+
70+
response = await apply_confirmation_gate(tool, {'path': 'a'}, tool_context)
71+
72+
assert response == {
73+
'error': 'This tool call requires confirmation, please approve or reject.'
74+
}
75+
requested = tool_context.actions.requested_tool_confirmations
76+
assert list(requested) == [_FUNCTION_CALL_ID]
77+
assert requested[_FUNCTION_CALL_ID].hint == (
78+
'Please approve or reject the tool call _delete_file() by responding'
79+
' with a FunctionResponse with an expected ToolConfirmation payload.'
80+
)
81+
assert not tool_context.actions.skip_summarization
82+
83+
84+
async def test_confirmed_call_proceeds():
85+
"""An approved call is let through without a new request."""
86+
tool = FunctionTool(func=_delete_file, require_confirmation=True)
87+
tool_context = _make_tool_context(ToolConfirmation(confirmed=True))
88+
89+
response = await apply_confirmation_gate(tool, {'path': 'a'}, tool_context)
90+
91+
assert response is None
92+
assert not tool_context.actions.requested_tool_confirmations
93+
94+
95+
async def test_rejected_call_is_answered_with_rejection():
96+
"""A rejected call is answered with the rejection error."""
97+
tool = FunctionTool(func=_delete_file, require_confirmation=True)
98+
tool_context = _make_tool_context(ToolConfirmation(confirmed=False))
99+
100+
response = await apply_confirmation_gate(tool, {'path': 'a'}, tool_context)
101+
102+
assert response == {'error': 'This tool call is rejected.'}
103+
assert not tool_context.actions.requested_tool_confirmations
104+
105+
106+
async def test_confirmation_predicate_sees_call_args():
107+
"""The tool's confirmation predicate decides per call from its arguments."""
108+
tool = FunctionTool(
109+
func=_delete_file,
110+
require_confirmation=lambda path: path.startswith('/prod'),
111+
)
112+
113+
dev_response = await apply_confirmation_gate(
114+
tool, {'path': '/dev/a'}, _make_tool_context()
115+
)
116+
prod_response = await apply_confirmation_gate(
117+
tool, {'path': '/prod/a'}, _make_tool_context()
118+
)
119+
120+
assert dev_response is None
121+
assert prod_response == {
122+
'error': 'This tool call requires confirmation, please approve or reject.'
123+
}
124+
125+
126+
async def test_non_bool_confirmation_answer_proceeds():
127+
"""Only a True answer from check_require_confirmation holds the call."""
128+
129+
class _TruthyTool(BaseTool):
130+
131+
async def check_require_confirmation(
132+
self, args: dict[str, Any], tool_context: ToolContext
133+
) -> Any:
134+
return 'yes'
135+
136+
tool = _TruthyTool(name='truthy', description='Answers with a string.')
137+
tool_context = _make_tool_context()
138+
139+
response = await apply_confirmation_gate(tool, {}, tool_context)
140+
141+
assert response is None
142+
assert not tool_context.actions.requested_tool_confirmations

0 commit comments

Comments
 (0)