diff --git a/src/google/adk/examples/example_util.py b/src/google/adk/examples/example_util.py index 2a94c7e05dd..54f9b596f75 100644 --- a/src/google/adk/examples/example_util.py +++ b/src/google/adk/examples/example_util.py @@ -16,7 +16,9 @@ """Utility functions for converting examples to a string that can be used in system instructions in the prompt.""" +import json import logging +from typing import Any from .base_example_provider import BaseExampleProvider from .example import Example @@ -40,6 +42,23 @@ _FUNCTION_RESPONSE_SUFFIX = "\n```\n" +def _function_response_json(response: Any) -> str: + """Renders a function response as the JSON string the example promises. + + ``FunctionResponse.__dict__`` is the model's internal storage, so it leaks + every unset optional field as ``None`` and renders nested values as their + Python repr, which for a pydantic model spans several lines and breaks the + fenced block around the example. Serializing the fields instead keeps the + example to the response shape the model is being shown. + """ + try: + return json.dumps(response.model_dump(exclude_none=True), default=str) + except (TypeError, ValueError): + # Values the JSON encoder cannot represent fall back to their string form + # rather than failing the whole system-instruction build. + return str(response.model_dump(exclude_none=True)) + + def convert_examples_to_text(examples: list[Example], model: str | None) -> str: """Converts a list of examples to a string that can be used in a system instruction.""" examples_str = "" @@ -78,7 +97,10 @@ def convert_examples_to_text(examples: list[Example], model: str | None) -> str: # Convert function response part to json string elif part.function_response: prefix = _FUNCTION_PREFIX if is_gemini else _FUNCTION_RESPONSE_PREFIX - output += f"{prefix}{part.function_response.__dict__}{_FUNCTION_RESPONSE_SUFFIX}" + output += ( + f"{prefix}{_function_response_json(part.function_response)}" + f"{_FUNCTION_RESPONSE_SUFFIX}" + ) elif part.text: output += f"{part.text}\n" diff --git a/tests/unittests/examples/test_example_util.py b/tests/unittests/examples/test_example_util.py index 7db0aed4704..2bf584a547a 100644 --- a/tests/unittests/examples/test_example_util.py +++ b/tests/unittests/examples/test_example_util.py @@ -339,7 +339,8 @@ def test_example_conversion_with_function_response(model): f"{example_util._EXAMPLE_START.format(1)}" f"{example_util._USER_PREFIX}test_input\n" f"{example_util._MODEL_PREFIX}{prefix}" - f"{test_function_response.__dict__}" + '{"name": "test_function", "response": {"test_string_argument":' + ' "test_value", "test_int_argument": 1}}' f"{example_util._FUNCTION_RESPONSE_SUFFIX}" f"{example_util._EXAMPLE_END}" f"{example_util._EXAMPLES_END}" @@ -403,7 +404,8 @@ def test_example_conversion_with_function_call_response(model): "test_function(test_string_argument='test_value', test_int_argument=1)" f"{example_util._FUNCTION_CALL_SUFFIX}" f"{response_prefix}" - f"{test_function_response.__dict__}" + '{"name": "test_function", "response": {"test_string_argument":' + ' "test_value", "test_int_argument": 1}}' f"{example_util._FUNCTION_RESPONSE_SUFFIX}" f"{example_util._EXAMPLE_END}" f"{example_util._EXAMPLES_END}" @@ -469,7 +471,8 @@ def test_example_conversion_with_text_and_function_call_response(model): "test_function(test_string_argument='test_value', test_int_argument=1)" f"{example_util._FUNCTION_CALL_SUFFIX}" f"{response_prefix}" - f"{test_function_response.__dict__}" + '{"name": "test_function", "response": {"test_string_argument":' + ' "test_value", "test_int_argument": 1}}' f"{example_util._FUNCTION_RESPONSE_SUFFIX}" f"{example_util._EXAMPLE_END}" f"{example_util._EXAMPLES_END}" @@ -543,3 +546,72 @@ def test_building_si_from_base_example_provider(model): ) == expected_output ) + + +def test_function_response_renders_json_without_unset_fields(): + """A function response example is JSON and carries no unset optional fields. + + ``FunctionResponse.__dict__`` used to leak ``will_continue``, ``scheduling`` + and ``parts`` as ``None`` into every example and render nested values as a + multi-line Python repr. The example now shows the response shape only. + """ + test_function_response = types.FunctionResponse( + name="test_function", + id="call_1", + response={"test_string_argument": "test_value", "test_int_argument": 1}, + ) + output_content = [ + types.Content( + role="model", + parts=[types.Part(function_response=test_function_response)], + ) + ] + test_example = example.Example(input=BASIC_INPUT, output=output_content) + + rendered = example_util.convert_examples_to_text([test_example], "gemini") + + assert ( + '{"id": "call_1", "name": "test_function", "response":' + ' {"test_string_argument": "test_value", "test_int_argument": 1}}' + in rendered + ) + # Unset optional fields must not reach the example. + assert "will_continue" not in rendered + assert "scheduling" not in rendered + assert "'parts': None" not in rendered + # It is JSON, not a Python repr. + assert "None" not in rendered + + +def test_function_response_nested_values_render_as_json(): + """Nested values inside a response do not introduce raw newlines. + + A pydantic model in the response dict used to be rendered through its repr, + which spans several lines and breaks the fenced block around the example. + """ + test_function_response = types.FunctionResponse( + name="test_function", + response={"nested": types.Part(text="nested_value")}, + ) + output_content = [ + types.Content( + role="model", + parts=[types.Part(function_response=test_function_response)], + ) + ] + test_example = example.Example(input=BASIC_INPUT, output=output_content) + + rendered = example_util.convert_examples_to_text([test_example], "gemini") + + assert '{"nested": {"text": "nested_value"}}' in rendered + # The rendered example must stay on one line inside the fence. + assert "Part(" not in rendered + payload_lines = [ + line + for line in rendered.split("```")[1].splitlines() + if line.startswith("{") + ] + assert payload_lines == [ + '{"name": "test_function", "response": {"nested": {"text":' + ' "nested_value"}}}' + ]