From 06e631126e5917d354fbb1ad09a3f8b53c28129b Mon Sep 17 00:00:00 2001 From: Aryan Patil Date: Thu, 22 Oct 2026 22:13:29 +0530 Subject: [PATCH 1/3] fix(tools): accept pre-parsed dict in ToolConfirmation response ToolConfirmation.from_response_dict expected response['response'] to always be a JSON-encoded string. When the payload is already deserialized into a dict (e.g. by an HTTP framework or test runner), calling json.loads raised TypeError. Check whether the wrapped payload is a string or bytes before attempting to decode it with json.loads, allowing pre-parsed dictionaries to be validated directly. --- src/google/adk/tools/tool_confirmation.py | 5 ++++- .../unittests/tools/test_tool_confirmation.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/google/adk/tools/tool_confirmation.py b/src/google/adk/tools/tool_confirmation.py index c7ff3345342..4c400799721 100644 --- a/src/google/adk/tools/tool_confirmation.py +++ b/src/google/adk/tools/tool_confirmation.py @@ -48,5 +48,8 @@ def from_response_dict(cls, response: dict[str, Any]) -> ToolConfirmation: ``{'response': json_string}`` wrapper format. """ 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/tests/unittests/tools/test_tool_confirmation.py b/tests/unittests/tools/test_tool_confirmation.py index 55764629d1e..68df345f8d5 100644 --- a/tests/unittests/tools/test_tool_confirmation.py +++ b/tests/unittests/tools/test_tool_confirmation.py @@ -112,6 +112,24 @@ 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.""" + confirmation = ToolConfirmation.from_response_dict({ + "response": json.dumps({"hint": "h", "confirmed": True}).encode("utf-8") + }) + + assert confirmation.hint == "h" + assert confirmation.confirmed is True + 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): From 3f190e9acde7788548eceefab1a03d0dc2bf59bc Mon Sep 17 00:00:00 2001 From: Aryan Patil Date: Fri, 23 Oct 2026 14:22:52 +0530 Subject: [PATCH 2/3] test(tools): add tests for non-dict response and workflow HITL resume --- .../unittests/tools/test_tool_confirmation.py | 5 ++++ .../utils/test_workflow_hitl_utils.py | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/tests/unittests/tools/test_tool_confirmation.py b/tests/unittests/tools/test_tool_confirmation.py index 68df345f8d5..a51fc20726c 100644 --- a/tests/unittests/tools/test_tool_confirmation.py +++ b/tests/unittests/tools/test_tool_confirmation.py @@ -130,6 +130,11 @@ def test_single_response_key_with_bytes_is_unwrapped_and_json_decoded(self): assert confirmation.hint == "h" assert confirmation.confirmed is True + def test_single_response_key_with_invalid_inner_value_raises_validation_error(self): + """A wrapper whose inner value is not a valid dict or string 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..5b238a7f79d 100644 --- a/tests/unittests/workflow/utils/test_workflow_hitl_utils.py +++ b/tests/unittests/workflow/utils/test_workflow_hitl_utils.py @@ -28,7 +28,9 @@ 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.utils._rehydration_utils import _ChildScanState +from google.adk.workflow._errors import WorkflowDataError from google.adk.workflow.utils._workflow_hitl_utils import apply_confirmation_resume +from google.adk.workflow.utils._workflow_hitl_utils import parse_tool_confirmation from google.adk.workflow.utils._workflow_hitl_utils import auth_interrupt_id from google.adk.workflow.utils._workflow_hitl_utils import confirmation_interrupt_id from google.adk.workflow.utils._workflow_hitl_utils import create_auth_request_event @@ -72,6 +74,32 @@ def test_apply_confirmation_resume(): ) +def test_apply_confirmation_resume_wrapped_parsed_dict(): + """apply_confirmation_resume sets ctx.tool_confirmation from wrapped pre-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_raises_workflow_data_error(): + """Invalid inner value in response wrapper raises WorkflowDataError, not TypeError.""" + with pytest.raises(WorkflowDataError, match="Invalid tool confirmation response"): + parse_tool_confirmation({"response": 123}) + + def test_create_confirmation_request_input(): """create_confirmation_request_input builds a ToolConfirmation RequestInput.""" default_req = create_confirmation_request_input( From ee2aa1e5e4fc9262e41abd3956766a26915f19fc Mon Sep 17 00:00:00 2001 From: Aryan Patil Date: Sat, 24 Oct 2026 23:13:20 +0530 Subject: [PATCH 3/3] fix(workflow): strip null fields in wrapped confirmation and format tests per pyink --- src/google/adk/tools/tool_confirmation.py | 4 ++-- .../workflow/utils/_workflow_hitl_utils.py | 13 +++++++++--- .../unittests/tools/test_tool_confirmation.py | 9 ++++----- .../utils/test_workflow_hitl_utils.py | 20 +++++++++++++------ 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/google/adk/tools/tool_confirmation.py b/src/google/adk/tools/tool_confirmation.py index 4c400799721..80c4e4d9b6d 100644 --- a/src/google/adk/tools/tool_confirmation.py +++ b/src/google/adk/tools/tool_confirmation.py @@ -44,8 +44,8 @@ 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: raw = response["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 a51fc20726c..17888273695 100644 --- a/tests/unittests/tools/test_tool_confirmation.py +++ b/tests/unittests/tools/test_tool_confirmation.py @@ -123,15 +123,14 @@ def test_single_response_key_with_parsed_dict_is_validated(self): def test_single_response_key_with_bytes_is_unwrapped_and_json_decoded(self): """The wrapper payload may be raw UTF-8 JSON bytes.""" - confirmation = ToolConfirmation.from_response_dict({ - "response": json.dumps({"hint": "h", "confirmed": True}).encode("utf-8") - }) + 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_raises_validation_error(self): - """A wrapper whose inner value is not a valid dict or string raises ValidationError.""" + 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}) diff --git a/tests/unittests/workflow/utils/test_workflow_hitl_utils.py b/tests/unittests/workflow/utils/test_workflow_hitl_utils.py index 5b238a7f79d..fba36da99b1 100644 --- a/tests/unittests/workflow/utils/test_workflow_hitl_utils.py +++ b/tests/unittests/workflow/utils/test_workflow_hitl_utils.py @@ -27,10 +27,9 @@ 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.utils._rehydration_utils import _ChildScanState 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 parse_tool_confirmation from google.adk.workflow.utils._workflow_hitl_utils import auth_interrupt_id from google.adk.workflow.utils._workflow_hitl_utils import confirmation_interrupt_id from google.adk.workflow.utils._workflow_hitl_utils import create_auth_request_event @@ -38,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 @@ -75,7 +75,7 @@ def test_apply_confirmation_resume(): def test_apply_confirmation_resume_wrapped_parsed_dict(): - """apply_confirmation_resume sets ctx.tool_confirmation from wrapped pre-parsed dict.""" + """Resume sets tool_confirmation from a wrapped parsed dict.""" ctx = MagicMock() ctx.node_path = "wf@1/node@1" ctx.tool_confirmation = None @@ -94,12 +94,20 @@ def test_apply_confirmation_resume_wrapped_parsed_dict(): ) -def test_parse_tool_confirmation_invalid_inner_value_raises_workflow_data_error(): - """Invalid inner value in response wrapper raises WorkflowDataError, not TypeError.""" - with pytest.raises(WorkflowDataError, match="Invalid tool confirmation response"): +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(