Skip to content

Commit 723bae8

Browse files
authored
Merge branch 'main' into feat/app-name-override
2 parents e8025fa + 4b002c4 commit 723bae8

6 files changed

Lines changed: 290 additions & 103 deletions

File tree

src/google/adk/flows/llm_flows/request_confirmation.py

Lines changed: 130 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
# limitations under the License.
1414
from __future__ import annotations
1515

16-
import json
1716
import logging
1817
from typing import Any
1918
from typing import AsyncGenerator
@@ -27,68 +26,157 @@
2726
from ...agents.readonly_context import ReadonlyContext
2827
from ...events.event import Event
2928
from ...models.llm_request import LlmRequest
29+
from ...tools.base_tool import BaseTool
3030
from ...tools.tool_confirmation import ToolConfirmation
31+
from ...tools.tool_context import ToolContext
3132
from ._base_llm_processor import BaseLlmRequestProcessor
3233
from .functions import REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
3334

3435
if TYPE_CHECKING:
3536
pass
3637

3738

38-
logger = logging.getLogger('google_adk.' + __name__)
39+
logger = logging.getLogger("google_adk." + __name__)
3940

4041

4142
def _parse_tool_confirmation(response: dict[str, Any]) -> ToolConfirmation:
42-
"""Parse ToolConfirmation from a function response dict.
43+
"""Parses ToolConfirmation from a function response dict."""
44+
return ToolConfirmation.from_response_dict(response)
4345

44-
Handles both the direct dict format and the ADK client's
45-
``{'response': json_string}`` wrapper format.
4646

47-
"""
48-
if response and len(response.values()) == 1 and 'response' in response.keys():
49-
return ToolConfirmation.model_validate(json.loads(response['response']))
50-
return ToolConfirmation.model_validate(response)
51-
52-
53-
def _resolve_confirmation_targets(
47+
async def _resolve_confirmation_targets(
48+
invocation_context: InvocationContext,
5449
events: list[Event],
5550
confirmation_fc_ids: set[str],
5651
confirmations_by_fc_id: dict[str, ToolConfirmation],
52+
tools_dict: dict[str, BaseTool],
5753
) -> tuple[dict[str, ToolConfirmation], dict[str, types.FunctionCall]]:
58-
"""Find original function calls for confirmed tools.
54+
"""Find original function calls for confirmed tools and validate them.
5955
6056
Scans events for ``adk_request_confirmation`` function calls whose IDs
6157
are in *confirmation_fc_ids*, extracts the ``originalFunctionCall`` from
62-
their args, and maps each confirmation to the original FC ID.
58+
their args, validates that they are registered, actually require confirmation,
59+
and match the original function calls in history, and maps each confirmation
60+
to the original FC ID.
6361
6462
Args:
63+
invocation_context: Current invocation context.
6564
events: Session events to scan.
6665
confirmation_fc_ids: IDs of ``adk_request_confirmation`` function calls.
6766
confirmations_by_fc_id: Mapping of confirmation FC ID ->
6867
``ToolConfirmation``.
68+
tools_dict: Dictionary of registered tools.
6969
7070
Returns:
7171
Tuple of ``(tool_confirmation_dict, original_fcs_dict)`` where both
7272
are keyed by the ORIGINAL function call IDs.
73+
74+
Raises:
75+
ValueError: If validation of any confirmation target fails.
7376
"""
7477
tool_confirmation_dict: dict[str, ToolConfirmation] = {}
7578
original_fcs_dict: dict[str, types.FunctionCall] = {}
7679

80+
history_fcs = {
81+
fc.id: (fc, ev)
82+
for ev in events
83+
for fc in ev.get_function_calls()
84+
if fc.id and fc.name != REQUEST_CONFIRMATION_FUNCTION_CALL_NAME
85+
}
86+
history_fr_events = {
87+
fr.id: ev for ev in events for fr in ev.get_function_responses() if fr.id
88+
}
89+
7790
for event in events:
7891
event_function_calls = event.get_function_calls()
7992
if not event_function_calls:
8093
continue
8194

8295
for function_call in event_function_calls:
83-
if function_call.id not in confirmation_fc_ids:
96+
if not function_call.id or function_call.id not in confirmation_fc_ids:
8497
continue
8598

8699
args = function_call.args
87-
if 'originalFunctionCall' not in args:
100+
if not args or "originalFunctionCall" not in args:
88101
continue
89102
original_function_call = types.FunctionCall(
90-
**args['originalFunctionCall']
103+
**args["originalFunctionCall"]
104+
)
105+
if not original_function_call.id:
106+
raise ValueError("Original function call ID is missing.")
107+
tool_name = original_function_call.name
108+
if not tool_name:
109+
raise ValueError("Original function call name is missing.")
110+
111+
# Check 1: Is the tool registered?
112+
original_fc_info = history_fcs.get(original_function_call.id)
113+
if not original_fc_info:
114+
raise ValueError(
115+
f"Original function call for ID '{original_function_call.id}' not"
116+
" found in session history."
117+
)
118+
original_fc_in_history, original_fc_event = original_fc_info
119+
120+
# If this tool call was authored by another agent, skip it to let that
121+
# agent's processor handle it.
122+
agent = invocation_context.agent
123+
if agent and original_fc_event.author != agent.name:
124+
continue
125+
126+
tool = tools_dict.get(tool_name)
127+
if not tool:
128+
raise ValueError(
129+
f"Tool '{original_function_call.name}' is not registered."
130+
)
131+
132+
# Check 2: Does the tool require confirmation for these arguments?
133+
# We check if it is either statically required, or if it was dynamically
134+
# requested in the session history.
135+
temp_tool_context = ToolContext(
136+
invocation_context=invocation_context,
137+
function_call_id=original_function_call.id,
91138
)
139+
requires_confirmation = await tool.check_require_confirmation(
140+
original_function_call.args or {}, temp_tool_context
141+
)
142+
143+
requested_in_history = False
144+
if not requires_confirmation:
145+
# Search the history for the response event of the original tool call
146+
original_response_event = history_fr_events.get(
147+
original_function_call.id
148+
)
149+
if (
150+
original_response_event
151+
and original_response_event.actions.requested_tool_confirmations
152+
):
153+
requested_in_history = (
154+
original_function_call.id
155+
in original_response_event.actions.requested_tool_confirmations
156+
)
157+
158+
if not requires_confirmation and not requested_in_history:
159+
raise ValueError(
160+
f"Tool '{original_function_call.name}' does not require"
161+
" confirmation."
162+
)
163+
164+
# Check 3: Does the original function call match name and arguments?
165+
if original_fc_in_history.name != original_function_call.name:
166+
raise ValueError(
167+
f"Function call name mismatch for ID '{original_function_call.id}':"
168+
f" history has '{original_fc_in_history.name}', confirmation has"
169+
f" '{original_function_call.name}'."
170+
)
171+
172+
hist_args = original_fc_in_history.args or {}
173+
conf_args = original_function_call.args or {}
174+
if hist_args != conf_args:
175+
raise ValueError(
176+
"Function call arguments mismatch for ID"
177+
f" '{original_function_call.id}'."
178+
)
179+
92180
tool_confirmation_dict[original_function_call.id] = (
93181
confirmations_by_fc_id[function_call.id]
94182
)
@@ -115,10 +203,9 @@ async def run_async(
115203
# Step 1: Find the last user-authored event and parse confirmation
116204
# responses from it.
117205
confirmations_by_fc_id: dict[str, ToolConfirmation] = {}
118-
confirmation_event_index = -1
119206
for k in range(len(events) - 1, -1, -1):
120207
event = events[k]
121-
if not event.author or event.author != 'user':
208+
if not event.author or event.author != "user":
122209
continue
123210
responses = event.get_function_responses()
124211
if not responses:
@@ -127,29 +214,45 @@ async def run_async(
127214
for function_response in responses:
128215
if function_response.name != REQUEST_CONFIRMATION_FUNCTION_CALL_NAME:
129216
continue
217+
if not function_response.id or function_response.response is None:
218+
continue
130219
confirmations_by_fc_id[function_response.id] = _parse_tool_confirmation(
131220
function_response.response
132221
)
133-
confirmation_event_index = k
134222
break
135223

136224
if not confirmations_by_fc_id:
137225
return
138226

227+
# Resolve all canonical tools and build tools_dict
228+
tools_dict = {}
229+
if agent is not None and hasattr(agent, "canonical_tools"):
230+
tools_dict = {
231+
tool.name: tool
232+
for tool in await agent.canonical_tools(
233+
ReadonlyContext(invocation_context)
234+
)
235+
}
236+
139237
# Step 2: Resolve confirmation targets using extracted helper.
140238
confirmation_fc_ids = set(confirmations_by_fc_id.keys())
141239
tools_to_resume_with_confirmation, tools_to_resume_with_args = (
142-
_resolve_confirmation_targets(
143-
events, confirmation_fc_ids, confirmations_by_fc_id
240+
await _resolve_confirmation_targets(
241+
invocation_context,
242+
events,
243+
confirmation_fc_ids,
244+
confirmations_by_fc_id,
245+
tools_dict,
144246
)
145247
)
146248

147249
if not tools_to_resume_with_confirmation:
148250
return
149251

150252
# Step 3: Remove tools that have already been confirmed (dedup).
151-
for i in range(len(events) - 1, confirmation_event_index, -1):
152-
event = events[i]
253+
for event in reversed(events):
254+
if event.author == "user":
255+
break
153256
fr_list = event.get_function_responses()
154257
if not fr_list:
155258
continue
@@ -167,14 +270,9 @@ async def run_async(
167270
# Step 4: Re-execute the confirmed tools.
168271
if function_response_event := await functions.handle_function_call_list_async(
169272
invocation_context,
170-
tools_to_resume_with_args.values(),
171-
{
172-
tool.name: tool
173-
for tool in await agent.canonical_tools(
174-
ReadonlyContext(invocation_context)
175-
)
176-
},
177-
tools_to_resume_with_confirmation.keys(),
273+
list(tools_to_resume_with_args.values()),
274+
tools_dict,
275+
set(tools_to_resume_with_confirmation.keys()),
178276
tools_to_resume_with_confirmation,
179277
):
180278
yield function_response_event

src/google/adk/tools/base_tool.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ async def process_llm_request(
168168
# Use the consolidated logic in LlmRequest.append_tools
169169
llm_request.append_tools([self])
170170

171+
async def check_require_confirmation(
172+
self, args: dict[str, Any], tool_context: ToolContext
173+
) -> bool:
174+
"""Returns whether the tool requires confirmation for the given args."""
175+
return False
176+
171177
@property
172178
def _api_variant(self) -> GoogleLLMVariant:
173179
return get_google_llm_variant()

src/google/adk/tools/function_tool.py

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import logging
1919
from typing import Any
2020
from typing import Callable
21+
from typing import cast
2122
from typing import get_args
2223
from typing import get_origin
2324
from typing import get_type_hints
@@ -194,36 +195,52 @@ def _preprocess_args(self, args: dict[str, Any]) -> dict[str, Any]:
194195
args[param_name], list
195196
):
196197
item_type = get_list_inner_type(target_type)
197-
try:
198-
converted_args[param_name] = [
199-
item_type.model_validate(item)
200-
if isinstance(item, dict)
201-
else item
202-
for item in args[param_name]
203-
]
204-
except Exception as e:
205-
logger.warning(
206-
f"Failed to convert argument '{param_name}' to"
207-
f' list[{item_type.__name__}]: {e}'
208-
)
209-
pass
198+
if item_type is not None:
199+
try:
200+
converted_args[param_name] = [
201+
item_type.model_validate(item)
202+
if isinstance(item, dict)
203+
else item
204+
for item in args[param_name]
205+
]
206+
except Exception as e:
207+
logger.warning(
208+
f"Failed to convert argument '{param_name}' to"
209+
f' list[{item_type.__name__}]: {e}'
210+
)
211+
pass
210212

211213
return converted_args
212214

213-
@override
214-
async def run_async(
215-
self, *, args: dict[str, Any], tool_context: ToolContext
216-
) -> Any:
217-
# Preprocess arguments (includes Pydantic model conversion)
215+
def _prepare_invocation_args(
216+
self, args: dict[str, Any], tool_context: ToolContext
217+
) -> dict[str, Any]:
218+
"""Prepare args for function invocation (preprocesses, injects context and filters)."""
218219
args_to_call = self._preprocess_args(args)
219-
220220
signature = inspect.signature(self.func)
221-
valid_params = {param for param in signature.parameters}
221+
valid_params = set(signature.parameters.keys())
222222
if self._context_param_name in valid_params:
223223
args_to_call[self._context_param_name] = tool_context
224+
return {k: v for k, v in args_to_call.items() if k in valid_params}
224225

225-
# Filter args_to_call to only include valid parameters for the function
226-
args_to_call = {k: v for k, v in args_to_call.items() if k in valid_params}
226+
@override
227+
async def check_require_confirmation(
228+
self, args: dict[str, Any], tool_context: ToolContext
229+
) -> bool:
230+
if callable(self._require_confirmation):
231+
args_to_call = self._prepare_invocation_args(args, tool_context)
232+
return cast(
233+
bool,
234+
await self._invoke_callable(self._require_confirmation, args_to_call),
235+
)
236+
return bool(self._require_confirmation)
237+
238+
@override
239+
async def run_async(
240+
self, *, args: dict[str, Any], tool_context: ToolContext
241+
) -> Any:
242+
# Preprocess arguments (includes Pydantic model conversion)
243+
args_to_call = self._prepare_invocation_args(args, tool_context)
227244

228245
# Before invoking the function, we check for if the list of args passed in
229246
# has all the mandatory arguments or not.
@@ -242,12 +259,9 @@ async def run_async(
242259
You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters."""
243260
return {'error': error_str}
244261

245-
if isinstance(self._require_confirmation, Callable):
246-
require_confirmation = await self._invoke_callable(
247-
self._require_confirmation, args_to_call
248-
)
249-
else:
250-
require_confirmation = bool(self._require_confirmation)
262+
require_confirmation = await self.check_require_confirmation(
263+
args, tool_context
264+
)
251265

252266
if require_confirmation:
253267
if not tool_context.tool_confirmation:

0 commit comments

Comments
 (0)