diff --git a/src/google/adk/tools/tool_confirmation.py b/src/google/adk/tools/tool_confirmation.py index c7ff3345342..80c4e4d9b6d 100644 --- a/src/google/adk/tools/tool_confirmation.py +++ b/src/google/adk/tools/tool_confirmation.py @@ -44,9 +44,12 @@ class ToolConfirmation(BaseModel): def from_response_dict(cls, response: dict[str, Any]) -> ToolConfirmation: """Parse ToolConfirmation from a function response dict. - Handles both the direct dict format and the ADK client's - ``{'response': json_string}`` wrapper format. + Handles both the direct dict format and the ``{'response': ...}`` + wrapper format, accepting a JSON string, bytes, or a parsed dict. """ if response and len(response) == 1 and "response" in response: - return cls.model_validate(json.loads(response["response"])) + raw = response["response"] + if isinstance(raw, (str, bytes, bytearray)): + return cls.model_validate(json.loads(raw)) + return cls.model_validate(raw) return cls.model_validate(response) diff --git a/src/google/adk/workflow/utils/_workflow_hitl_utils.py b/src/google/adk/workflow/utils/_workflow_hitl_utils.py index eb1572ced97..64e20b3d59b 100644 --- a/src/google/adk/workflow/utils/_workflow_hitl_utils.py +++ b/src/google/adk/workflow/utils/_workflow_hitl_utils.py @@ -63,10 +63,17 @@ def parse_tool_confirmation(response: Any) -> ToolConfirmation: 'A tool confirmation response must be a ToolConfirmation payload, but' f' got {type(response).__name__}.' ) + cleaned = {k: v for k, v in response.items() if v is not None} + if ( + len(cleaned) == 1 + and 'response' in cleaned + and isinstance(cleaned['response'], dict) + ): + cleaned['response'] = { + k: v for k, v in cleaned['response'].items() if v is not None + } try: - return ToolConfirmation.from_response_dict( - {k: v for k, v in response.items() if v is not None} - ) + return ToolConfirmation.from_response_dict(cleaned) except ValueError as e: raise WorkflowDataError( f'Invalid tool confirmation response: {response!r}' diff --git a/tests/unittests/tools/test_tool_confirmation.py b/tests/unittests/tools/test_tool_confirmation.py index 55764629d1e..17888273695 100644 --- a/tests/unittests/tools/test_tool_confirmation.py +++ b/tests/unittests/tools/test_tool_confirmation.py @@ -112,6 +112,28 @@ def test_single_response_key_is_unwrapped_and_json_decoded(self): assert confirmation.hint == "h" assert confirmation.confirmed is True + def test_single_response_key_with_parsed_dict_is_validated(self): + """The wrapper payload may already be deserialized into a dict.""" + confirmation = ToolConfirmation.from_response_dict( + {"response": {"hint": "h", "confirmed": True}} + ) + + assert confirmation.hint == "h" + assert confirmation.confirmed is True + + def test_single_response_key_with_bytes_is_unwrapped_and_json_decoded(self): + """The wrapper payload may be raw UTF-8 JSON bytes.""" + raw = json.dumps({"hint": "h", "confirmed": True}).encode("utf-8") + confirmation = ToolConfirmation.from_response_dict({"response": raw}) + + assert confirmation.hint == "h" + assert confirmation.confirmed is True + + def test_single_response_key_with_invalid_inner_value(self): + """Invalid inner value in wrapper raises ValidationError.""" + with pytest.raises(ValidationError): + ToolConfirmation.from_response_dict({"response": 123}) + def test_response_key_alongside_other_keys_is_not_unwrapped(self): """Only a lone 'response' key is the wrapper format, so this is direct.""" with pytest.raises(ValidationError): diff --git a/tests/unittests/workflow/utils/test_workflow_hitl_utils.py b/tests/unittests/workflow/utils/test_workflow_hitl_utils.py index fce1328d629..fba36da99b1 100644 --- a/tests/unittests/workflow/utils/test_workflow_hitl_utils.py +++ b/tests/unittests/workflow/utils/test_workflow_hitl_utils.py @@ -27,6 +27,7 @@ from google.adk.events.request_input import RequestInput from google.adk.tools.tool_confirmation import ToolConfirmation from google.adk.utils._function_call_names import REQUEST_EUC_FUNCTION_CALL_NAME +from google.adk.workflow._errors import WorkflowDataError from google.adk.workflow.utils._rehydration_utils import _ChildScanState from google.adk.workflow.utils._workflow_hitl_utils import apply_confirmation_resume from google.adk.workflow.utils._workflow_hitl_utils import auth_interrupt_id @@ -36,6 +37,7 @@ from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_event from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_response from google.adk.workflow.utils._workflow_hitl_utils import has_auth_credential +from google.adk.workflow.utils._workflow_hitl_utils import parse_tool_confirmation from google.adk.workflow.utils._workflow_hitl_utils import process_auth_resume from google.genai import types import pytest @@ -72,6 +74,40 @@ def test_apply_confirmation_resume(): ) +def test_apply_confirmation_resume_wrapped_parsed_dict(): + """Resume sets tool_confirmation from a wrapped parsed dict.""" + ctx = MagicMock() + ctx.node_path = "wf@1/node@1" + ctx.tool_confirmation = None + ctx.resume_inputs = { + "wf_tool_confirmation:wf@1/node@1": { + "response": { + "confirmed": True, + "payload": {"note": "approved"}, + } + } + } + + assert apply_confirmation_resume(ctx) is True + assert ctx.tool_confirmation == ToolConfirmation( + confirmed=True, payload={"note": "approved"} + ) + + +def test_parse_tool_confirmation_invalid_inner_value(): + """Invalid inner value in wrapper raises WorkflowDataError.""" + with pytest.raises( + WorkflowDataError, match="Invalid tool confirmation response" + ): + parse_tool_confirmation({"response": 123}) + + +def test_parse_tool_confirmation_wrapped_dict_with_none_field(): + """Omitted fields serialized as None inside wrapper are dropped.""" + res = parse_tool_confirmation({"response": {"confirmed": True, "hint": None}}) + assert res == ToolConfirmation(confirmed=True) + + def test_create_confirmation_request_input(): """create_confirmation_request_input builds a ToolConfirmation RequestInput.""" default_req = create_confirmation_request_input(