Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/google/adk/tools/tool_confirmation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
13 changes: 10 additions & 3 deletions src/google/adk/workflow/utils/_workflow_hitl_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
Expand Down
22 changes: 22 additions & 0 deletions tests/unittests/tools/test_tool_confirmation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
36 changes: 36 additions & 0 deletions tests/unittests/workflow/utils/test_workflow_hitl_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading