From 94cefbbbc7e0f98745df70350c15726c10b0cf18 Mon Sep 17 00:00:00 2001 From: Yi Liu Date: Wed, 26 Aug 2026 09:41:10 -0700 Subject: [PATCH 1/7] fix: lazy-load evaluation dependencies in AgentEvaluator Avoid top-level imports of metric_evaluator_registry, which caused ModuleNotFoundError in bare environments where optional evaluation dependencies are not installed. Also fix typo in pandas import in _print_details. Co-authored-by: Yi Liu PiperOrigin-RevId: 971336455 --- src/google/adk/evaluation/agent_evaluator.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/google/adk/evaluation/agent_evaluator.py b/src/google/adk/evaluation/agent_evaluator.py index 28723e9a66f..4f48addbba6 100644 --- a/src/google/adk/evaluation/agent_evaluator.py +++ b/src/google/adk/evaluation/agent_evaluator.py @@ -28,6 +28,7 @@ from typing import List from typing import Optional from typing import Protocol +from typing import TYPE_CHECKING from typing import Union import uuid @@ -60,11 +61,11 @@ from .evaluator import EvalStatus from .in_memory_eval_sets_manager import InMemoryEvalSetsManager from .local_eval_sets_manager import convert_eval_set_to_pydantic_schema -from .metric_evaluator_registry import DEFAULT_METRIC_EVALUATOR_REGISTRY -from .metric_evaluator_registry import MetricEvaluatorRegistry -from .metric_evaluator_registry import register_custom_metrics_from_config from .simulation.user_simulator_provider import UserSimulatorProvider +if TYPE_CHECKING: + from .metric_evaluator_registry import MetricEvaluatorRegistry # pylint: disable=g-import-not-at-top + logger = logging.getLogger("google_adk." + __name__) @@ -208,6 +209,12 @@ async def evaluate_eval_set( # on the default registry resolvable, which is the only way to plug in a # custom `Evaluator` subclass since an eval config can only name a scoring # function. + try: + from .metric_evaluator_registry import DEFAULT_METRIC_EVALUATOR_REGISTRY # pylint: disable=g-import-not-at-top + from .metric_evaluator_registry import register_custom_metrics_from_config # pylint: disable=g-import-not-at-top + except ModuleNotFoundError as e: + raise ModuleNotFoundError(MISSING_EVAL_DEPENDENCIES_MESSAGE) from e + metric_evaluator_registry = register_custom_metrics_from_config( eval_config, DEFAULT_METRIC_EVALUATOR_REGISTRY.fork() ) @@ -546,8 +553,8 @@ def _print_details( threshold: float, ) -> None: try: - from pandas import pandas as pd - from tabulate import tabulate + import pandas as pd # pylint: disable=g-import-not-at-top + from tabulate import tabulate # pylint: disable=g-import-not-at-top except ModuleNotFoundError as e: raise ModuleNotFoundError(MISSING_EVAL_DEPENDENCIES_MESSAGE) from e print( From 2956083874c9d0cae36469b937dffa3978ced5cf Mon Sep 17 00:00:00 2001 From: George Weale Date: Wed, 26 Aug 2026 11:26:37 -0700 Subject: [PATCH 2/7] fix: remove an internal link from a shipped comment and guard against more Co-authored-by: George Weale PiperOrigin-RevId: 971398777 --- scripts/compliance_checks.py | 17 ++++++++++ src/google/adk/telemetry/tracing.py | 8 ++--- .../scripts/test_compliance_checks.py | 34 +++++++++++++++++++ tests/unittests/test_import_loading.py | 8 +++-- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/scripts/compliance_checks.py b/scripts/compliance_checks.py index 6d5a70309b1..ef892bc6fff 100755 --- a/scripts/compliance_checks.py +++ b/scripts/compliance_checks.py @@ -85,6 +85,16 @@ def check_cli_import(content: str, filename: str) -> bool: return not pattern.search(content) +# An internal shortlink resolves for nobody reading this repository. Anchored +# so that a public URL with a '/go/' path segment, or a Go file name, is not +# mistaken for one. +_INTERNAL_LINK_RE = re.compile(r'(? bool: + return not _INTERNAL_LINK_RE.search(content) + + def check_mtls(content: str, filename: str) -> bool: if filename in _EXCLUDED_FROM_MTLS: return True @@ -145,6 +155,13 @@ def main() -> None: ) failed = True + if not check_internal_links(content): + print( + f'❌ {f}: Found an internal shortlink, which resolves for nobody' + ' reading this repository. Say what it says in plain words instead.' + ) + failed = True + if failed: sys.exit(1) sys.exit(0) diff --git a/src/google/adk/telemetry/tracing.py b/src/google/adk/telemetry/tracing.py index 2826ff76546..ed2a712ed79 100644 --- a/src/google/adk/telemetry/tracing.py +++ b/src/google/adk/telemetry/tracing.py @@ -106,10 +106,10 @@ # Event name for the log record of one HTTP exchange with an MCP server. OTel's # MCP conventions define attributes but no event for the transport hop, so this -# one is ADK's own, and lives in the `adk.experimental.*` namespace of -# go/orcas-rfc-1014: emitted only under `ADK_EXPERIMENTAL_TELEMETRY`, and free -# to be renamed or dropped the moment a standard covers it -- either a generic -# HTTP client response-end event, or body capture in +# one is ADK's own, and lives in the `adk.experimental.*` namespace: emitted +# only under `ADK_EXPERIMENTAL_TELEMETRY`, and free to be renamed or dropped +# the moment a standard covers it -- either a generic HTTP client +# response-end event, or body capture in # `opentelemetry-instrumentation-httpx`. The attributes on it are the # semconv-defined ones. _ADK_EXPERIMENTAL_MCP_HTTP_RESPONSE_END_EVENT: Final[str] = ( diff --git a/tests/unittests/scripts/test_compliance_checks.py b/tests/unittests/scripts/test_compliance_checks.py index 5485b89f94f..19d5487cffb 100644 --- a/tests/unittests/scripts/test_compliance_checks.py +++ b/tests/unittests/scripts/test_compliance_checks.py @@ -55,3 +55,37 @@ def test_mtls_exclusions_are_all_still_needed() -> None: 'These files pass the mTLS check on their own; drop them from' f' _EXCLUDED_FROM_MTLS: {redundant}' ) + + +# Assembled rather than written out, so that this file does not trip the very +# check it is testing. +_INTERNAL_LINK = 'go' + '/some-design-doc' + + +def test_check_internal_links_detects_a_shortlink() -> None: + content = f'# lives in the experimental namespace of {_INTERNAL_LINK}\n' + assert not compliance_checks.check_internal_links(content) + + +def test_check_internal_links_allows_a_public_url_with_a_go_path() -> None: + content = 'url = "https://example.com/go/somewhere"\n' + assert compliance_checks.check_internal_links(content) + + +def test_check_internal_links_allows_a_go_file_name() -> None: + content = 'path = "internal/registry.go/../main.go"\n' + assert compliance_checks.check_internal_links(content) + + +def test_no_shipped_source_file_has_an_internal_link() -> None: + offenders = [ + str(path.relative_to(_REPO_ROOT)) + for path in sorted((_REPO_ROOT / 'src').rglob('*.py')) + if not compliance_checks.check_internal_links( + path.read_text(encoding='utf-8') + ) + ] + assert not offenders, ( + 'These files ship an internal shortlink that no reader outside Google' + f' can resolve: {offenders}' + ) diff --git a/tests/unittests/test_import_loading.py b/tests/unittests/test_import_loading.py index 1aad5590596..d2edd8486a8 100644 --- a/tests/unittests/test_import_loading.py +++ b/tests/unittests/test_import_loading.py @@ -81,9 +81,10 @@ 'typing_extensions', 'typing_inspection', 'zstandard', - # google.genai.types annotates optional fields with aiohttp and Pillow - # types and imports whichever of the two the environment happens to have. - # No ADK module imports either one, so these are absent in some installs. + # google.genai.types annotates optional fields with aiohttp, Pillow and + # httpx2 types, and imports whichever of them the environment happens to + # have. No ADK module imports any of them, so these are absent in some + # installs and present in others depending on what else is installed. 'PIL', 'aiohappyeyeballs', 'aiohttp', @@ -91,6 +92,7 @@ 'attr', 'defusedxml', 'frozenlist', + 'httpx2', 'multidict', 'propcache', 'yarl', From 1bc001e44e07e177ca1d772eb0085ebb30824a09 Mon Sep 17 00:00:00 2001 From: George Weale Date: Wed, 26 Aug 2026 15:35:42 -0700 Subject: [PATCH 3/7] fix(sessions): admit the stdlib data types legacy session state holds Co-authored-by: George Weale PiperOrigin-RevId: 971539820 --- src/google/adk/sessions/_restricted_pickle.py | 28 ++++- .../sessions/test_dynamic_pickle_type.py | 103 ++++++++++++++++++ 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/google/adk/sessions/_restricted_pickle.py b/src/google/adk/sessions/_restricted_pickle.py index 336579b6412..5dcaae39e0f 100644 --- a/src/google/adk/sessions/_restricted_pickle.py +++ b/src/google/adk/sessions/_restricted_pickle.py @@ -25,9 +25,9 @@ are inert data types: unpickling one only calls `__setstate__` or the enum constructor. Deriving them keeps the set correct as the ADK and `google.genai` models gain fields, which a hand-written list does not. -* A static part, for globals the walk cannot see: primitives and `datetime` - types, which are not Pydantic models, and model classes reachable only by - subclassing rather than through an annotation. +* A static part, for globals the walk cannot see: the builtin and stdlib data + types a `state_delta` holds, which are not Pydantic models, and model + classes reachable only by subclassing rather than through an annotation. Anything else - notably arbitrary callables that older versions allowed into `state_delta` - is refused rather than resolved. See `_RestrictedUnpickler`. @@ -62,9 +62,31 @@ ("builtins", "int"), ("builtins", "float"), ("builtins", "bool"), + ("builtins", "complex"), + # Stdlib data types. Each reconstructs by calling the type on plain data, + # so resolving one cannot run attacker-chosen code. A `defaultdict`'s + # factory is only stored while loading, never called, and it has to be a + # global this allowlist already admits. + ("collections", "OrderedDict"), + ("collections", "defaultdict"), + ("datetime", "date"), ("datetime", "datetime"), + ("datetime", "time"), ("datetime", "timedelta"), ("datetime", "timezone"), + ("decimal", "Decimal"), + ("uuid", "UUID"), + # Python 3.13 moved the concrete `pathlib` classes into a private + # submodule, and a payload names whichever module the interpreter that + # wrote it recorded. + ("pathlib", "PurePosixPath"), + ("pathlib", "PureWindowsPath"), + ("pathlib", "PosixPath"), + ("pathlib", "WindowsPath"), + ("pathlib._local", "PurePosixPath"), + ("pathlib._local", "PureWindowsPath"), + ("pathlib._local", "PosixPath"), + ("pathlib._local", "WindowsPath"), # Auth models reachable only by subclassing or as a union base, so the # annotation walk below does not reach them. ("fastapi.openapi.models", "OAuthFlow"), diff --git a/tests/unittests/sessions/test_dynamic_pickle_type.py b/tests/unittests/sessions/test_dynamic_pickle_type.py index 67a7b89e493..3742566c1cd 100644 --- a/tests/unittests/sessions/test_dynamic_pickle_type.py +++ b/tests/unittests/sessions/test_dynamic_pickle_type.py @@ -14,9 +14,14 @@ from __future__ import annotations +import collections import datetime +import decimal +import os +import pathlib import pickle from unittest import mock +import uuid from google.adk.auth.auth_credential import AuthCredential from google.adk.auth.auth_credential import AuthCredentialTypes @@ -75,6 +80,39 @@ def __reduce__(self): return (_detonate, ()) +def _call_global_payload(module: str, name: str, argument: str) -> bytes: + """Handcrafts a pickle that calls `module.name(argument)` when loaded. + + `pickle.dumps` cannot express a global the writing process does not hold, and + it resolves `os.system` to its `posix` alias, so the payloads an attacker + would actually write have to be assembled by hand. + + Args: + module: The module the payload resolves the callable from. + name: The callable's name within that module. + argument: The single string argument the payload passes. + + Returns: + The handcrafted pickle payload. + """ + + def short_unicode(value: str) -> bytes: + encoded = value.encode() + return pickle.SHORT_BINUNICODE + bytes([len(encoded)]) + encoded + + return b"".join([ + pickle.PROTO, + b"\x04", + short_unicode(module), + short_unicode(name), + pickle.STACK_GLOBAL, + short_unicode(argument), + pickle.TUPLE1, + pickle.REDUCE, + pickle.STOP, + ]) + + def _fully_populated_event_actions() -> EventActions: """Builds an `EventActions` exercising every field it can hold. @@ -176,6 +214,14 @@ def _fully_populated_event_actions() -> EventActions: "set": {1, 2}, "datetime": datetime.datetime.now(datetime.timezone.utc), "timedelta": datetime.timedelta(seconds=1), + "date": datetime.date(2026, 1, 1), + "time": datetime.time(12, 30, tzinfo=datetime.timezone.utc), + "ordered_dict": collections.OrderedDict(a=1, b=2), + "default_dict": collections.defaultdict(list, a=[1]), + "uuid": uuid.UUID("12345678-1234-5678-1234-567812345678"), + "decimal": decimal.Decimal("1.5"), + "path": pathlib.PurePosixPath("/data/artifact.txt"), + "complex": complex(1, 2), }, artifact_delta={"artifact.txt": 1}, transfer_to_agent="another_agent", @@ -585,3 +631,60 @@ def test_allowed_globals_are_derived_from_the_model_tree( _restricted_pickle._STATIC_ALLOWED_GLOBALS ) assert (module_name, class_name) in _restricted_pickle._allowed_globals() + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(collections.OrderedDict(a=1, b=2), id="ordered_dict"), + pytest.param(collections.defaultdict(list, a=[1]), id="default_dict"), + pytest.param(datetime.date(2026, 1, 1), id="date"), + pytest.param(datetime.time(12, 30), id="time"), + pytest.param( + datetime.time(12, 30, tzinfo=datetime.timezone.utc), id="time_tz" + ), + pytest.param( + uuid.UUID("12345678-1234-5678-1234-567812345678"), id="uuid" + ), + pytest.param(decimal.Decimal("1.5"), id="decimal"), + pytest.param(pathlib.PurePosixPath("/data/x.txt"), id="pure_path"), + pytest.param(pathlib.Path("/data/x.txt"), id="path"), + pytest.param(complex(1, 2), id="complex"), + ], +) +def test_plain_stdlib_state_values_still_load(value): + """State written by an earlier ADK holds these, so they must keep loading.""" + assert _restricted_pickle.loads(pickle.dumps(value)) == value + + +@pytest.mark.parametrize( + "module_name,attribute_name", + [ + pytest.param("os", "system", id="os_system"), + pytest.param("builtins", "eval", id="builtins_eval"), + pytest.param("pathlib", "os.system", id="via_pathlib"), + pytest.param("uuid", "os.system", id="via_uuid"), + pytest.param("collections", "OrderedDict.fromkeys", id="via_ordered"), + ], +) +def test_dangerous_globals_stay_refused(module_name, attribute_name): + """Widening the allowlist must not reach a callable through a module on it.""" + payload = _call_global_payload(module_name, attribute_name, "echo unreached") + + with pytest.raises(pickle.UnpicklingError): + _restricted_pickle.loads(payload) + + +def test_call_global_payload_would_execute_unrestricted(): + """Guards the adversarial cases above from silently becoming inert.""" + # The unrestricted load is the assertion: it proves the handcrafted payload + # really does reach a callable, so the restricted loader refusing it above + # means something. The payload evaluates "1 + 1" and touches nothing else. + payload = _call_global_payload("builtins", "eval", "1 + 1") + assert pickle.loads(payload) == 2 # pylint: disable=g-unsafe-pickle-load + + +def test_defaultdict_factory_must_itself_be_allowlisted(): + """A `defaultdict` carries a callable, which the allowlist must cover too.""" + with pytest.raises(pickle.UnpicklingError): + _restricted_pickle.loads(pickle.dumps(collections.defaultdict(os.system))) From 401cd6dd7018a6639819b9de6e2460db62655271 Mon Sep 17 00:00:00 2001 From: George Weale Date: Wed, 26 Aug 2026 15:42:50 -0700 Subject: [PATCH 4/7] fix: keep transport options and scoped state keys out of the debug log Co-authored-by: George Weale PiperOrigin-RevId: 971543415 --- src/google/adk/models/google_llm.py | 24 ++-- .../adk/plugins/debug_logging_plugin.py | 90 ++++++++++-- tests/unittests/models/test_google_llm.py | 45 ++++-- .../plugins/test_debug_logging_plugin.py | 130 ++++++++++++++++++ 4 files changed, 250 insertions(+), 39 deletions(-) diff --git a/src/google/adk/models/google_llm.py b/src/google/adk/models/google_llm.py index 40c557a84a5..1ce781fcf04 100644 --- a/src/google/adk/models/google_llm.py +++ b/src/google/adk/models/google_llm.py @@ -732,21 +732,17 @@ def _build_request_log(req: LlmRequest) -> str: exclude={ 'system_instruction': True, 'tools': tools_exclusion if req.config.tools else True, - # `http_options` carries caller-supplied credentials: + # `http_options` is excluded whole, not field by field. # `headers` commonly holds an Authorization bearer token, - # and `extra_body` / `*client_args` are free-form - # passthroughs that can hold auth material too. None of - # it may reach a debug log. Mirrors the same exclusion - # applied to trace spans in telemetry/tracing.py. - 'http_options': { - 'httpx_client': True, - 'httpx_async_client': True, - 'aiohttp_client': True, - 'headers': True, - 'extra_body': True, - 'client_args': True, - 'async_client_args': True, - }, + # `extra_body` and the `*client_args` passthroughs can hold + # auth material, and `base_url` carries the credential + # itself when the caller points at a signed endpoint or an + # authenticating proxy. Naming the sensitive fields instead + # would also start logging every field the genai SDK adds + # later. The live path excludes it the same way; the trace + # spans built in telemetry/tracing.py still name the fields + # one by one. + 'http_options': True, }, ) ) diff --git a/src/google/adk/plugins/debug_logging_plugin.py b/src/google/adk/plugins/debug_logging_plugin.py index 839a1d5b13d..9b212905b27 100644 --- a/src/google/adk/plugins/debug_logging_plugin.py +++ b/src/google/adk/plugins/debug_logging_plugin.py @@ -23,6 +23,7 @@ import logging import os from pathlib import Path +import re from typing import Any from typing import TYPE_CHECKING @@ -99,6 +100,49 @@ "x_goog_signature", }) +# Substrings that name a secret wherever they sit in a key. Matched as +# substrings, not whole keys, so that the spellings the exact set above cannot +# enumerate are covered too: `openai_api_key`, `secret_key`, +# `service_account_credentials`. +_SENSITIVE_SUBSTRINGS = ( + "api_key", + "credentials", + "passwd", + "password", + "private_key", + "secret", +) + +# A key ending in one of these names a secret: `bearer_token`, +# `session_token`. Matched as a suffix rather than as a substring so that the +# usage counters, `prompt_token_count` and its siblings, keep their values. +_SENSITIVE_SUFFIXES = ("_token",) + +# Session state keys are namespaced by scope. The scope says nothing about +# whether the value is a secret, so it is stripped before matching, otherwise +# `api_key` is redacted while `user:api_key` is written out. +_STATE_PREFIXES = (State.APP_PREFIX, State.USER_PREFIX) + +# Splits a camel-cased key so that `apiKey` and `XApiKey` normalize to the +# same `api_key` that `api-key` and `api_key` do. +_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") + +# A credential pasted into session state or a tool argument arrives as one +# long string, and a service account file keeps its private key inside that +# string, so no key name identifies it. Only an armored private key block is +# looked for. It is unambiguous, where a general secret scan would be both slow +# and prone to blanking ordinary text. The armor header is matched as a unit so +# that prose quoting one of its fragments is left alone, and only the block +# itself is replaced, so the rest of the string stays readable. A block whose +# footer never arrives is redacted to the end of the string rather than left in +# place. +_PRIVATE_KEY_BLOCK = re.compile( + r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----" + r".*?" + r"(-----END [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----|\Z)", + re.DOTALL, +) + # The debug file is written with the process umask otherwise, which commonly # leaves it world-readable. _OUTPUT_FILE_MODE = 0o600 @@ -112,14 +156,30 @@ def _is_sensitive_key(key: Any) -> bool: """Whether a mapping key names a credential-bearing value.""" if not isinstance(key, str): return False - # `str.__str__` drops a subclass override of `lower`; hyphens are folded so - # that header spellings such as `X-Api-Key` match, as the analytics plugin - # does. - normalized = str.__str__(key).lower().replace("-", "_") - # ADK stores exchanged auth credentials under a `temp:`-prefixed state key. - return normalized in _SENSITIVE_KEYS or normalized.startswith( - State.TEMP_PREFIX + # `str.__str__` drops a subclass override of `lower`; hyphens and case + # boundaries are folded so that `X-Api-Key` and `apiKey` match the same way + # `api_key` does. + normalized = ( + _CAMEL_BOUNDARY.sub("_", str.__str__(key)).lower().replace("-", "_") ) + # ADK stores exchanged auth credentials under a `temp:`-prefixed state key. + if normalized.startswith(State.TEMP_PREFIX): + return True + for prefix in _STATE_PREFIXES: + if normalized.startswith(prefix): + normalized = normalized[len(prefix) :] + break + if normalized in _SENSITIVE_KEYS or normalized.endswith(_SENSITIVE_SUFFIXES): + return True + return any(marker in normalized for marker in _SENSITIVE_SUBSTRINGS) + + +def _redact_private_keys(value: str) -> str: + """Blanks any armored private key block, leaving the rest of the string.""" + # `str.__str__` drops a subclass override of `__contains__`, which `in` + # would otherwise dispatch to, the same way `_is_sensitive_key` drops one + # of `lower`. + return _PRIVATE_KEY_BLOCK.sub(_REDACTED, str.__str__(value)) def _model_items(model: BaseModel) -> list[tuple[str, Any]]: @@ -190,9 +250,11 @@ class DebugLoggingPlugin(BasePlugin): owner and is not safe to hand around. Redaction covers credential models wherever they appear, mapping keys that - name a secret, and every `temp:`-prefixed state key. That last rule blanks - all temporary state, not only credentials, so an intermediate value passed - between agents under a `temp:` key reads as `[REDACTED]` here. + name a secret with the `app:` or `user:` state scope stripped first, an + armored private key block found inside any string, and every + `temp:`-prefixed state key. That last rule blanks all temporary state, not + only credentials, so an intermediate value passed between agents under a + `temp:` key reads as `[REDACTED]` here. Example: >>> debug_plugin = DebugLoggingPlugin(output_path="/tmp/adk_debug.yaml") @@ -292,7 +354,9 @@ def _safe_serialize(self, obj: Any, depth: int = 0) -> Any: A credential model is replaced with a redaction marker wherever it sits: at the top level, or nested inside a dict, list, tuple or another model, under any key name. Mapping keys that name a secret are redacted too, for - credentials that arrive already dumped to a plain dict. + credentials that arrive already dumped to a plain dict. An armored private + key block, which no key name identifies, is cut out of whatever string it + sits in. """ if obj is None: return None @@ -308,7 +372,9 @@ def _safe_serialize(self, obj: Any, depth: int = 0) -> Any: # below unchanged, and then reaches `yaml.dump` as a Python object, # which writes a `!!python/object` tag that `yaml.safe_load` refuses. return self._safe_serialize(obj.value, child_depth) - if isinstance(obj, (str, int, float, bool)): + if isinstance(obj, str): + return _redact_private_keys(obj) + if isinstance(obj, (int, float, bool)): return obj if isinstance(obj, (date, time)): return obj.isoformat() diff --git a/tests/unittests/models/test_google_llm.py b/tests/unittests/models/test_google_llm.py index c6c31a74828..91a3445701c 100644 --- a/tests/unittests/models/test_google_llm.py +++ b/tests/unittests/models/test_google_llm.py @@ -2589,8 +2589,8 @@ def mock_model_dump(*args, **kwargs): assert "" in log_output -def test_build_request_log_redacts_http_options_credentials(): - """Test that _build_request_log redacts sensitive fields in http_options.""" +def test_build_request_log_omits_http_options(): + """Test that _build_request_log keeps all of http_options out of the log.""" llm_request = LlmRequest( model="gemini-2.5-flash", contents=[Content(role="user", parts=[Part.from_text(text="Hello")])], @@ -2601,7 +2601,7 @@ def test_build_request_log_redacts_http_options_credentials(): extra_body={"secret_key": "some_secret"}, client_args={"token": "arg_secret"}, async_client_args={"token": "async_secret"}, - base_url="https://example.com/api", + base_url="https://signed.example.com/api?sig=url_secret", ), ), ) @@ -2609,20 +2609,39 @@ def test_build_request_log_redacts_http_options_credentials(): log_output = _build_request_log(llm_request) assert "Config:" in log_output - # base_url should be present - assert "https://example.com/api" in log_output - # sensitive http_options fields should NOT be present in log_output + assert "'temperature': 0.7" in log_output + # No field of http_options reaches the log, named or not. base_url is + # included in that: it is where the credential sits when the caller points + # at a signed endpoint or an authenticating proxy. + assert "'http_options'" not in log_output + assert "url_secret" not in log_output + assert "signed.example.com" not in log_output assert "secret_token" not in log_output assert "secret_key" not in log_output assert "arg_secret" not in log_output assert "async_secret" not in log_output - assert "'headers'" not in log_output - assert "'extra_body'" not in log_output - assert "'client_args'" not in log_output - assert "'async_client_args'" not in log_output - assert "'httpx_client'" not in log_output - assert "'httpx_async_client'" not in log_output - assert "'aiohttp_client'" not in log_output + + +def test_build_request_log_omits_http_options_fields_the_sdk_may_add(): + """No field name of http_options reaches the log, listed or not.""" + llm_request = LlmRequest( + model="gemini-2.5-flash", + contents=[Content(role="user", parts=[Part.from_text(text="Hello")])], + config=types.GenerateContentConfig( + http_options=types.HttpOptions( + base_url="https://proxy.example.com", + api_version="v1beta", + timeout=1234, + ), + ), + ) + + log_output = _build_request_log(llm_request) + + config_section = log_output.split("Config:")[1].split("---")[0] + for field_name in types.HttpOptions.model_fields: + assert field_name not in config_section + assert "1234" not in config_section @pytest.mark.asyncio diff --git a/tests/unittests/plugins/test_debug_logging_plugin.py b/tests/unittests/plugins/test_debug_logging_plugin.py index ae518bfc811..a4fb864ce16 100644 --- a/tests/unittests/plugins/test_debug_logging_plugin.py +++ b/tests/unittests/plugins/test_debug_logging_plugin.py @@ -48,6 +48,9 @@ _SENTINEL_CLIENT_SECRET = "sentinel-client-secret-b58d6e" _SENTINEL_AUTH_CODE = "sentinel-auth-code-2ad914" _SENTINEL_CODE_VERIFIER = "sentinel-code-verifier-7be055" +_SENTINEL_PRIVATE_KEY = ( + "-----BEGIN PRIVATE KEY-----\nsentinel-key-body\n-----END PRIVATE KEY-----" +) def _oauth_credential() -> AuthCredential: @@ -823,6 +826,133 @@ def test_oauth_authorization_code_keys_are_redacted(self): assert oauth2["code_verifier"] == "[REDACTED]" assert oauth2["client_id"] == "test-client-id" + def test_scoped_state_keys_are_redacted(self): + """A state scope prefix says nothing about whether the value is a secret.""" + plugin = DebugLoggingPlugin() + + result = plugin._safe_serialize({ + "api_key": _SENTINEL_CLIENT_SECRET, + "user:api_key": _SENTINEL_CLIENT_SECRET, + "app:client_secret": _SENTINEL_CLIENT_SECRET, + "user:profile": {"name": "test-user"}, + }) + + assert result["api_key"] == "[REDACTED]" + assert result["user:api_key"] == "[REDACTED]" + assert result["app:client_secret"] == "[REDACTED]" + assert result["user:profile"] == {"name": "test-user"} + + def test_key_spelling_variants_are_redacted(self): + """Camel case and compound names name the same secrets.""" + plugin = DebugLoggingPlugin() + + result = plugin._safe_serialize({ + "apiKey": _SENTINEL_CLIENT_SECRET, + "secret_key": _SENTINEL_CLIENT_SECRET, + "bearer_token": _SENTINEL_ACCESS_TOKEN, + "credentials": _SENTINEL_CLIENT_SECRET, + "serviceAccountCredentials": _SENTINEL_CLIENT_SECRET, + }) + + assert set(result.values()) == {"[REDACTED]"} + + def test_usage_counters_survive_key_matching(self): + """Counters end in the word `token` and are the point of the log.""" + plugin = DebugLoggingPlugin() + + result = plugin._safe_serialize({ + "usage_metadata": { + "prompt_token_count": 12, + "candidates_token_count": 34, + "total_token_count": 46, + }, + "max_output_tokens": 1024, + "cache_key": "abc", + }) + + assert result["usage_metadata"]["prompt_token_count"] == 12 + assert result["usage_metadata"]["total_token_count"] == 46 + assert result["max_output_tokens"] == 1024 + assert result["cache_key"] == "abc" + + def test_private_key_in_a_string_value_is_redacted(self): + """A service account file pasted into state has no telling key name.""" + plugin = DebugLoggingPlugin() + + result = plugin._safe_serialize({ + "user:uploaded_file": ( + '{"type": "service_account", "client_email": "a@b.example.com",' + f' "private_key": "{_SENTINEL_PRIVATE_KEY}"}}' + ), + "notes": ["harmless", _SENTINEL_PRIVATE_KEY], + }) + + assert result["notes"] == ["harmless", "[REDACTED]"] + assert "sentinel-key-body" not in str(result) + + def test_only_the_private_key_block_is_cut_from_the_string(self): + """The surrounding prompt is what the log exists to show.""" + plugin = DebugLoggingPlugin() + + result = plugin._safe_serialize( + f"here is my key {_SENTINEL_PRIVATE_KEY} please rotate it" + ) + + assert result == "here is my key [REDACTED] please rotate it" + + def test_armor_header_variants_are_redacted(self): + """The header is matched as a unit, not as loose fragments.""" + plugin = DebugLoggingPlugin() + + result = plugin._safe_serialize({ + "pgp": ( + "-----BEGIN PGP PRIVATE KEY BLOCK-----\nsentinel-key-body\n" + "-----END PGP PRIVATE KEY BLOCK-----" + ), + "rsa": ( + "-----BEGIN RSA PRIVATE KEY-----\nsentinel-key-body\n" + "-----END RSA PRIVATE KEY-----" + ), + "unterminated": "-----BEGIN PRIVATE KEY-----\nsentinel-key-body\n", + }) + + assert set(result.values()) == {"[REDACTED]"} + + def test_prose_quoting_armor_fragments_is_kept(self): + """Two fragments in any order are not a key block.""" + plugin = DebugLoggingPlugin() + prose = "notes about a PRIVATE KEY----- and -----BEGIN elsewhere" + + assert plugin._safe_serialize(prose) == prose + + def test_none_and_scalars_pass_through_unchanged(self): + """Redaction runs over whatever the callbacks hand it.""" + plugin = DebugLoggingPlugin() + + assert plugin._safe_serialize(None) is None + assert plugin._safe_serialize("plain") == "plain" + assert plugin._safe_serialize(7) == 7 + + def test_a_secret_nested_in_a_list_is_redacted(self): + """A callback payload is commonly a list of dicts.""" + plugin = DebugLoggingPlugin() + + result = plugin._safe_serialize([None, {"token": _SENTINEL_ACCESS_TOKEN}]) + + assert result == [None, {"token": "[REDACTED]"}] + + def test_the_walk_depth_bound_truncates_instead_of_recursing(self): + """A self-referential object would otherwise never terminate.""" + plugin = DebugLoggingPlugin() + deep: Any = {"api_key": _SENTINEL_CLIENT_SECRET} + for _ in range(60): + deep = {"level": deep} + + result = plugin._safe_serialize(deep) + + assert "" in str(result) + assert _SENTINEL_CLIENT_SECRET not in str(result) + def test_non_credential_values_are_not_redacted(self): """Redaction must not swallow ordinary debug data.""" plugin = DebugLoggingPlugin() From 0880bbed4968bbf188e650321f29d5bd66a37b87 Mon Sep 17 00:00:00 2001 From: Kathy Wu <108756731+wukath@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:22:10 -0700 Subject: [PATCH 5/7] fix: end a ParallelAgent early only when a direct sub-agent escalates (cherry-pick to release/candidate) (#6918) Co-authored-by: George Weale --- src/google/adk/agents/parallel_agent.py | 30 +++++-- tests/unittests/agents/test_parallel_agent.py | 79 ++++++++++++++++++- 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/src/google/adk/agents/parallel_agent.py b/src/google/adk/agents/parallel_agent.py index 82d0446bcd9..0a31d989b73 100644 --- a/src/google/adk/agents/parallel_agent.py +++ b/src/google/adk/agents/parallel_agent.py @@ -55,9 +55,21 @@ def _create_branch_ctx_for_sub_agent( return invocation_context -def _has_escalate_action(event: Event) -> bool: - """Returns whether the event asks the parent workflow to exit early.""" - return bool(event.actions.escalate) +def _asks_this_agent_to_exit(event: Event, sub_agent_names: set[str]) -> bool: + """Returns whether the event asks this parallel agent to exit early. + + An escalation ends the workflow that directly encloses the escalating agent, + and that workflow re-yields the event while unwinding, so only an escalation + authored by a direct sub-agent is addressed to this one. + + Args: + event: The event to inspect. + sub_agent_names: Names of this agent's direct sub-agents. + + Returns: + Whether this parallel agent should stop its remaining branches. + """ + return bool(event.actions.escalate) and event.author in sub_agent_names def _cancel_tasks(tasks: list[asyncio.Task[None]]) -> None: @@ -69,6 +81,7 @@ def _cancel_tasks(tasks: list[asyncio.Task[None]]) -> None: async def _merge_agent_run( agent_runs: list[AsyncGenerator[Event, None]], + sub_agent_names: set[str], ) -> AsyncGenerator[Event, None]: """Merges agent runs using asyncio.TaskGroup on Python 3.11+.""" sentinel = _AgentRunComplete() @@ -121,7 +134,7 @@ async def process_an_agent( raise payload else: yield event - if _has_escalate_action(event): + if _asks_this_agent_to_exit(event, sub_agent_names): _cancel_tasks(tasks) return # Signal to agent that it should generate next event. @@ -135,6 +148,7 @@ async def process_an_agent( # TODO - remove once Python <3.11 is no longer supported. async def _merge_agent_run_pre_3_11( agent_runs: list[AsyncGenerator[Event, None]], + sub_agent_names: set[str], ) -> AsyncGenerator[Event, None]: """Merges agent runs for Python 3.10 without asyncio.TaskGroup. @@ -143,6 +157,7 @@ async def _merge_agent_run_pre_3_11( Args: agent_runs: Async generators that yield events from each agent. + sub_agent_names: Names of the parallel agent's direct sub-agents. Yields: Event: The next event from the merged generator. @@ -190,7 +205,7 @@ async def process_an_agent( raise payload else: yield event - if _has_escalate_action(event): + if _asks_this_agent_to_exit(event, sub_agent_names): _cancel_tasks(tasks) return # Signal to agent that event has been processed by runner and it can @@ -246,6 +261,7 @@ async def _run_async_impl( yield self._create_agent_state_event(ctx) agent_runs = [] + sub_agent_names = {sub_agent.name for sub_agent in self.sub_agents} # Prepare and collect async generators for each sub-agent. for sub_agent in self.sub_agents: sub_agent_ctx = _create_branch_ctx_for_sub_agent(self, sub_agent, ctx) @@ -261,10 +277,10 @@ async def _run_async_impl( if sys.version_info >= (3, 11) else _merge_agent_run_pre_3_11 ) - async with Aclosing(merge_func(agent_runs)) as agen: + async with Aclosing(merge_func(agent_runs, sub_agent_names)) as agen: async for event in agen: yield event - if _has_escalate_action(event): + if _asks_this_agent_to_exit(event, sub_agent_names): escalated = True if ctx.should_pause_invocation(event): pause_invocation = True diff --git a/tests/unittests/agents/test_parallel_agent.py b/tests/unittests/agents/test_parallel_agent.py index 924cc3f87c5..5d9dac30dc7 100644 --- a/tests/unittests/agents/test_parallel_agent.py +++ b/tests/unittests/agents/test_parallel_agent.py @@ -23,6 +23,7 @@ from google.adk.agents.base_agent import BaseAgent from google.adk.agents.base_agent import BaseAgentState from google.adk.agents.invocation_context import InvocationContext +from google.adk.agents.loop_agent import LoopAgent from google.adk.agents.parallel_agent import _merge_agent_run_pre_3_11 from google.adk.agents.parallel_agent import ParallelAgent from google.adk.agents.sequential_agent import SequentialAgent @@ -461,7 +462,7 @@ async def test_sub_agent_failure_reaches_busy_caller( async def test_merge_agent_run_pre_3_11_surfaces_failure_without_events(): """A branch failing before it emits anything must not look successful.""" with pytest.raises(ValueError, match='simulated sub-agent failure'): - async for _ in _merge_agent_run_pre_3_11([_failing_agent()]): + async for _ in _merge_agent_run_pre_3_11([_failing_agent()], set()): pass @@ -475,7 +476,7 @@ async def test_merge_agent_run_pre_3_11_no_aclose_error_on_failure(): agent_runs = [_slow_agent_with_cleanup_delay(), _failing_agent()] with pytest.raises(ValueError, match='simulated sub-agent failure'): - async for _ in _merge_agent_run_pre_3_11(agent_runs): + async for _ in _merge_agent_run_pre_3_11(agent_runs, set()): pass # If tasks were not properly awaited, aclose() on a still-running generator @@ -705,3 +706,77 @@ def mock_should_pause(event: Event) -> bool: assert events[0].author == fast_agent.name assert events[1].author == escalating_agent.name assert events[1].actions.escalate + + +@pytest.mark.asyncio +@pytest.mark.parametrize('use_pre_3_11_merge', [False, True]) +async def test_run_async_keeps_siblings_when_a_nested_loop_ends_itself( + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, + use_pre_3_11_merge: bool, +): + """A sub-agent ending its own LoopAgent must not cancel sibling branches.""" + + ticks: dict[str, int] = {} + + class _LoopingAgent(_TestingAgent): + """Escalates on its `escalate_on`-th run to end its enclosing loop.""" + + escalate_on: int = 1 + + @override + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + await asyncio.sleep(self.delay) + ticks[self.name] = ticks.get(self.name, 0) + 1 + escalating = ticks[self.name] >= self.escalate_on + yield self.event( + ctx, + text=f'{self.name}#{ticks[self.name]}', + actions=EventActions(escalate=True) if escalating else EventActions(), + ) + + if use_pre_3_11_merge: + monkeypatch.setattr( + parallel_agent_module, + 'sys', + SimpleNamespace(version_info=(3, 10)), + ) + + fast_agent = _LoopingAgent( + name=f'{request.function.__name__}_test_fast_agent', + escalate_on=1, + ) + slow_agent = _LoopingAgent( + name=f'{request.function.__name__}_test_slow_agent', + delay=0.05, + escalate_on=3, + ) + parallel_agent = ParallelAgent( + name=f'{request.function.__name__}_test_parallel_agent', + sub_agents=[ + LoopAgent( + name=f'{request.function.__name__}_test_fast_loop', + sub_agents=[fast_agent], + max_iterations=5, + ), + LoopAgent( + name=f'{request.function.__name__}_test_slow_loop', + sub_agents=[slow_agent], + max_iterations=5, + ), + ], + ) + parent_ctx = await _create_parent_invocation_context( + request.function.__name__, parallel_agent + ) + + events = [e async for e in parallel_agent.run_async(parent_ctx)] + + assert [event.content.parts[0].text for event in events] == [ + f'{fast_agent.name}#1', + f'{slow_agent.name}#1', + f'{slow_agent.name}#2', + f'{slow_agent.name}#3', + ] From 2174b84eb9fc3a2dd72d6ca537c60b5ae5d8c243 Mon Sep 17 00:00:00 2001 From: adk-bot Date: Wed, 26 Aug 2026 16:23:06 -0700 Subject: [PATCH 6/7] chore(release/candidate): release 2.8.0 (#6900) Co-authored-by: Kathy Wu <108756731+wukath@users.noreply.github.com> --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 169 ++++++++++++++++++++++++++ src/google/adk/version.py | 2 +- 3 files changed, 171 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index 984e5f0f9a5..7a5647237a8 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.7.1" + ".": "2.8.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1a8e9c8e6..b447efd84b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,174 @@ # Changelog +## [2.8.0](https://github.com/google/adk-python/compare/v2.7.1...v2.8.0) (2026-08-25) + + +### Features + +* **a2a:** add native task mode support to RemoteA2aAgent ([72f3ff5](https://github.com/google/adk-python/commit/72f3ff5cfb11fdf7432c6c2faa01befcc350ad7e)) +* add ADK_MAX_LLM_CALLS environment variable to configure max LLM calls limit ([75679db](https://github.com/google/adk-python/commit/75679db3fa42521a9316d1f8325225e51f5e9090)) +* add create_data_agent tool to data_agent toolset ([5cd38a0](https://github.com/google/adk-python/commit/5cd38a0b3d944eb3e5ac252140f0dc01ab6d5fd0)) +* add delete_data_agent tool to data_agent toolset ([9b451fc](https://github.com/google/adk-python/commit/9b451fca3b4b81ed5c3bba64594caeb64ac1d214)) +* add location parameter to list_accessible_data_agents in data_agent toolset ([393ec08](https://github.com/google/adk-python/commit/393ec0858baa61c5ebb8b733cd29e1ff673ead19)) +* add Model Armor guardrail plugin ([11efc3f](https://github.com/google/adk-python/commit/11efc3f8e9bcc00660740ad4807e406d97dd5dd3)) +* add nvidia nim integration sample ([c6bba8d](https://github.com/google/adk-python/commit/c6bba8dcc8cee4b78a36a751416dbbff284a146f)) +* add sample for skill state injection via adk_inject_state ([9384127](https://github.com/google/adk-python/commit/93841270bc479028bbe0209f3291d82e84276698)) +* add spreadsheet mime types to load_artifact_tool ([370027a](https://github.com/google/adk-python/commit/370027a770b413ab7991afa3395dbf8be1eb89e5)) +* add update_data_agent tool to data_agent toolset ([92d00aa](https://github.com/google/adk-python/commit/92d00aa937a50bdd35a24df856dbdc513b9bed4f)) +* allow configuring Vertex AI API version ([ede87c2](https://github.com/google/adk-python/commit/ede87c26edc716069eb760bbe0fdb046048084fd)), closes [#3246](https://github.com/google/adk-python/issues/3246) +* allow streaming tools to set response scheduling per yield ([bf2efd7](https://github.com/google/adk-python/commit/bf2efd71d6f9433673b8e7465a9dcc7f045a3dc4)) +* **antigravity:** capture client-side tool outcomes for a function_response ([4599a52](https://github.com/google/adk-python/commit/4599a52659f9c3655263b3aabd39a189c692c0d2)) +* **cli:** Support Cloud Build worker pools for Agent Engine deploy ([e577c30](https://github.com/google/adk-python/commit/e577c301d5f4a50b2a3c827631c53463268bb5fc)) +* **eval:** support custom metrics in AgentEvaluator ([babb11c](https://github.com/google/adk-python/commit/babb11c83c4b4c21c7d7af8b8e3ecf50c20b34a9)) +* guard against SQL injection vulnerabilities in BigQuery tools ([d6290a0](https://github.com/google/adk-python/commit/d6290a0b2e344a92b8879acfeab02e4252d1b47c)) +* **live:** Add VIDEO to the allowed modalities in the ADK API server ([dd998a7](https://github.com/google/adk-python/commit/dd998a7d5352225b944806abfe0fc7f463abbf38)) +* **live:** let a live streaming tool send messages to the user directly ([98896eb](https://github.com/google/adk-python/commit/98896eb2aaafb57577c02fc00b62db4367cb003c)) +* **live:** run before and after model callbacks in live mode ([ef2d680](https://github.com/google/adk-python/commit/ef2d68080a05f2fc3e00634c4f6c4d3d43c2a7f1)) +* **live:** surface live `interaction_status` on LlmResponse and Event ([b858904](https://github.com/google/adk-python/commit/b8589042c50986fb5cb446952f0b1bd8631ffa96)) +* **live:** use RunConfig.session_resumption.handle when opening a live session ([eac32c3](https://github.com/google/adk-python/commit/eac32c3ccc64a1ee4df80d6e4ffe9f1d33033c18)) +* **mcp:** report MCP HTTP exchanges as OpenTelemetry log records ([85a6fa1](https://github.com/google/adk-python/commit/85a6fa1e44f4d66a0e3a2be754355aa8be9b7af4)) +* parallelize LLM-as-judge evaluation using asyncio.gather() ([0dbb88c](https://github.com/google/adk-python/commit/0dbb88c7c43c004a00d71458e30e54c7d5a9a6b4)) +* preserve field descriptions in set_model_response schema ([a30858b](https://github.com/google/adk-python/commit/a30858b11ce87b51be49c67021b892af16a2a311)), closes [#6707](https://github.com/google/adk-python/issues/6707) +* record context cache state on the LLM call span ([c0614d6](https://github.com/google/adk-python/commit/c0614d65806be5e7525b72e662e0987378996b53)) +* resolve auth for RemoteA2aAgent in AgentRegistry ([d695494](https://github.com/google/adk-python/commit/d6954946df8b156ab27554ca02d2d6234c8595a1)) +* support auth_scheme and auth_credential in RemoteA2aAgent ([d42c634](https://github.com/google/adk-python/commit/d42c634bd6ef4041440d833327d63969a39315be)) +* support injecting custom LLM clients into Gemini and AnthropicLlm ([a01d516](https://github.com/google/adk-python/commit/a01d516a6bcf93f12d26a083af587455d0bb2592)), closes [#5027](https://github.com/google/adk-python/issues/5027) +* support memory_id and allowed_topics in Vertex Memory Bank ([bf73360](https://github.com/google/adk-python/commit/bf733602b61f2542add406235706fc9b1b7bfed2)) +* support opt-in session retention in MultimodalToolResultsPlugin ([775c1bd](https://github.com/google/adk-python/commit/775c1bd36e205eec65e207ad29fdc4cf2184e47e)), closes [#6695](https://github.com/google/adk-python/issues/6695) +* support sub-agent escalation event in ParallelAgent ([0fd681e](https://github.com/google/adk-python/commit/0fd681e7d203d2bc7b86c72e4b539de6c735c889)), closes [#5104](https://github.com/google/adk-python/issues/5104) +* **telemetry:** add per-invocation token spend metrics for invoke_agent ([dc00db5](https://github.com/google/adk-python/commit/dc00db51bc5dc195a21633b295aa9eac33732ccf)) +* **telemetry:** add per-workflow inference and tool call counts ([6e6c4a5](https://github.com/google/adk-python/commit/6e6c4a505e29bb0ba632c92add3a0e1be1bbaf00)) +* **telemetry:** add per-workflow token spend metrics ([dd8797e](https://github.com/google/adk-python/commit/dd8797e77f7e7dbac6fd5bfffc945ae20d3cd611)) +* **telemetry:** Expand telemetry for `load_skill_resource` span ([6e0facf](https://github.com/google/adk-python/commit/6e0facf9370261c788149a5330bb5632985e3531)) +* **telemetry:** honor RunConfig.telemetry at the runner's invocation span ([00fc6eb](https://github.com/google/adk-python/commit/00fc6ebd1990bd53b8615cdbd309702d50e51e62)) + + +### Bug Fixes + +* **a2a:** keep file payloads out of the debug log line ([ef158d3](https://github.com/google/adk-python/commit/ef158d32e974e5e794aca3daf89bc60e55663edf)) +* **agents:** report which toolset an agent lost when one fails to load ([66930e6](https://github.com/google/adk-python/commit/66930e65265e6bbf7f669575bb3c02dd8180ac35)) +* **artifacts:** Publish file artifact versions atomically ([94475c9](https://github.com/google/adk-python/commit/94475c9a76c7c71246d6f5e4b083b3c3ee6869c0)) +* **auth:** take the auth scheme from the request, not the client's response ([8989aea](https://github.com/google/adk-python/commit/8989aeadced4eda437f3435653fd60f11109c0e0)) +* avoid O(n^2) deep event comparison during rehydration ([b8dd086](https://github.com/google/adk-python/commit/b8dd08604c997b1e91e038f96eacb01eea878872)), closes [#6657](https://github.com/google/adk-python/issues/6657) +* block yaml and ruamel deserialization in agent-config code references ([924d802](https://github.com/google/adk-python/commit/924d802f5bb232a294df8e5a5c036d000077e19a)) +* cache read write token counts in LiteLLM and Anthropic models ([d0b33a0](https://github.com/google/adk-python/commit/d0b33a0569c940be0364cd4e0d4317a99bf52330)), closes [#5835](https://github.com/google/adk-python/issues/5835) +* check every function response, not just the first, when inferring which invocation to resume ([5449314](https://github.com/google/adk-python/commit/54493140a6697af5b82e03b9d7ecb77c15df4eb6)) +* **cli:** clean up pytest subprocesses on test-client disconnect ([a84a4b5](https://github.com/google/adk-python/commit/a84a4b52aa90eeed5bb6819e8c4d69542b8b52db)) +* **cli:** Preserve non-ASCII text in `adk test --rebuild`, Web UI test saving, and CLI JSONL ([dc735bd](https://github.com/google/adk-python/commit/dc735bd9534ff30c98db47743223815954d55e9f)) +* **cli:** report env var names instead of values when overriding env_vars ([b0c599f](https://github.com/google/adk-python/commit/b0c599f21fab3ca1bf1d4984e2b0298b8bbc027d)) +* count tool call and response chars in compaction ([66908e4](https://github.com/google/adk-python/commit/66908e4c613ff3686e85374696640e84c4d0f20f)) +* declare a2a-sdk[http-server] so the a2a extra can serve ([65234e7](https://github.com/google/adk-python/commit/65234e761bc055a45425e8950b614efc4e2a1954)) +* deduplicate events in InMemorySessionService.append_event ([4d74774](https://github.com/google/adk-python/commit/4d747747ee0313d7b30db1eed1b723ff329279d3)), closes [#5723](https://github.com/google/adk-python/issues/5723) +* deliver parallel sub-agent failures to the caller ([ece924c](https://github.com/google/adk-python/commit/ece924c2dd6bd5124a70e5bbc4164bf886ceb852)), closes [#5455](https://github.com/google/adk-python/issues/5455) +* detect MCP tool errors under either field spelling ([d18df2f](https://github.com/google/adk-python/commit/d18df2fa1c9cd8ceaad7e54c79a5acbcff95b812)) +* disable Windows glob expansion for CLI args ([2638155](https://github.com/google/adk-python/commit/26381552c0a00bf4d5bbb0e0c9dbd5d6384f93a1)), closes [#6248](https://github.com/google/adk-python/issues/6248) +* emit Anthropic prompt cache breakpoints for ContextCacheConfig ([811d379](https://github.com/google/adk-python/commit/811d379fdf5b302861a237bfbae34e5f4ddfbf47)), closes [#5395](https://github.com/google/adk-python/issues/5395) +* fence relayed agent output so it cannot pose as instructions ([9ffe8be](https://github.com/google/adk-python/commit/9ffe8be6f92fae76541cc948c38c5ef5dc3755b9)) +* filter thought parts from A2A client user-facing response ([8963a04](https://github.com/google/adk-python/commit/8963a0484662d27548fcdbe4dcf31a3c60a11542)), closes [#4676](https://github.com/google/adk-python/issues/4676) +* Fix built-in search tools in agent hierarchy when transfer_to_agent is present ([f3250bd](https://github.com/google/adk-python/commit/f3250bd9650a3654e3699c1e615b14e7b134fae0)) +* Fix crashes and cross-invocation leaks from copying RunConfig.http_options ([67ca98c](https://github.com/google/adk-python/commit/67ca98c2c0b467ed397357eef2b2941c92dac40c)) +* **flows:** pair a function response with the call it answers ([deee6d2](https://github.com/google/adk-python/commit/deee6d2c474ccb9710e0b25a0b290bb76cc54c45)), closes [#6761](https://github.com/google/adk-python/issues/6761) +* **flows:** sort function_call.args when rendering cross-agent context ([735402d](https://github.com/google/adk-python/commit/735402d01aaacf64eaf5034bf0517981c4886ce7)) +* Guard `invocation_context.branch` assignment against mock `SessionService` instances ([9917922](https://github.com/google/adk-python/commit/99179223d01e7c86ca1fcb245b232eb00cb9b813)) +* guard against Content with no parts in _content_to_message_param ([1ed8d48](https://github.com/google/adk-python/commit/1ed8d486203e240767251f2ca6d270cb8e5e9de3)) +* handle read-only .git files in deploy cleanup on Windows ([c93fcc0](https://github.com/google/adk-python/commit/c93fcc09304209a11fe2d5647a7b0cb5cb727353)) +* honor before_run_callback early-exit for Workflow runs ([dac1869](https://github.com/google/adk-python/commit/dac18699b9ce423f7e98d1cb245cfdde20b18147)), closes [#6013](https://github.com/google/adk-python/issues/6013) +* honor ContextCacheConfig on the LiteLLM path ([b1c984b](https://github.com/google/adk-python/commit/b1c984baa236887bb53eace6fa4d9cb483baff09)) +* improve clarity and actionable context in error messages ([7c2af5f](https://github.com/google/adk-python/commit/7c2af5f9c1774474b316948b70b8215db230255f)) +* include grounding metadata in rubric judge prompt ([b0cdecf](https://github.com/google/adk-python/commit/b0cdecfc3f846e8124f0a964e64174c12a696a2e)), closes [#5831](https://github.com/google/adk-python/issues/5831) +* **integrations:** send api registry credentials only to google api endpoints ([cc275f0](https://github.com/google/adk-python/commit/cc275f0c75bc4d84a5fc315dd6f6bd8a82cb1155)) +* keep non-text static_instruction as a stable request prefix ([deda5b3](https://github.com/google/adk-python/commit/deda5b30e8ff40cb2f6dcb8d4d2b19bc97c5ea57)), closes [#6652](https://github.com/google/adk-python/issues/6652) +* keep session identity across single-turn node contexts ([c602065](https://github.com/google/adk-python/commit/c6020659a09a7d823f59205bde12f948342a3413)), closes [#6691](https://github.com/google/adk-python/issues/6691) +* keep tool calls at most once ([5bcf5cd](https://github.com/google/adk-python/commit/5bcf5cdd18127bb53156c5e7902d998a91fe261b)) +* **live:** stop background tool tasks when a live agent run ends ([0088abb](https://github.com/google/adk-python/commit/0088abbe6651da6a6c644cace087a79d6a674821)) +* **live:** stop live runs from writing back into the caller's RunConfig ([0b39e72](https://github.com/google/adk-python/commit/0b39e7280a14dd61d0faa3efef9c95206363f60c)) +* make SqliteSessionService state merges use dict.update() semantics ([e4ba704](https://github.com/google/adk-python/commit/e4ba7040fb12f9a3ea468052567ec174dc31d443)), closes [#6728](https://github.com/google/adk-python/issues/6728) +* **mcp:** evict idle sessions from the MCP session pool ([69a3ca5](https://github.com/google/adk-python/commit/69a3ca5e119a821bc375246f0bfa2e9e2cfefc79)) +* only attach default credentials to https endpoints ([5d3d152](https://github.com/google/adk-python/commit/5d3d1524debbff4158274f28e2798435bde13b0d)) +* only emit --gemini_enterprise_app_name for adk_version >= 2.2.0 ([00932e6](https://github.com/google/adk-python/commit/00932e617e3772381f16dbc9b9ddd06b50b2a5cb)) +* pass file metadata tuple to Azure for PDF uploads ([ff4567d](https://github.com/google/adk-python/commit/ff4567df38d0cc5f1cd4f9423e416910cbe6ff2c)), closes [#6539](https://github.com/google/adk-python/issues/6539) +* percent-encode path parameter values in RestApiTool ([25f53bd](https://github.com/google/adk-python/commit/25f53bdc81f2e350285c91679885e8d8ba740f72)) +* point to Gemini Enterprise registration docs after agent_engine deploy ([1d2d1ed](https://github.com/google/adk-python/commit/1d2d1eda3c9b795cd90ad643390f4da5a8cd27bf)), closes [#6633](https://github.com/google/adk-python/issues/6633) +* populate developer_instructions when invocation_events is empty ([69c9090](https://github.com/google/adk-python/commit/69c909000f73e47272fcab508e96f184c5cd81b3)), closes [#5593](https://github.com/google/adk-python/issues/5593) +* prefer the function response's own invocation over a caller-supplied id on the node path ([4d0d63c](https://github.com/google/adk-python/commit/4d0d63c74cf161f90ef33c7b310b6fd6641d3249)) +* prevent contextvars leak across async generators ([bb86bdd](https://github.com/google/adk-python/commit/bb86bdd737c1792d40e05c1d388d9ad4020aa702)), closes [#5722](https://github.com/google/adk-python/issues/5722) +* prevent duplicate function execution when support_cfc is enabled ([c986ff0](https://github.com/google/adk-python/commit/c986ff0fceedef2107485cf136dc3b70acec32d8)) +* prevent duplicate OAuth prompts and fix tool resumption ([eaad2f8](https://github.com/google/adk-python/commit/eaad2f83b93d7e0c678336923ca5be7a3b9d1260)) +* prevent duplicate synthetic user event on single-turn agent resumption ([e753651](https://github.com/google/adk-python/commit/e753651b7df26febe00bde2cb043225e644cd207)) +* prevent prompt injection via GitHub event data in workflows ([4f558f1](https://github.com/google/adk-python/commit/4f558f19d4dc0916437269dade3b03554e9ab0f4)) +* rank and bound the results of InMemoryMemoryService.search_memory ([f3fae72](https://github.com/google/adk-python/commit/f3fae72e6a52f33c4fca4a7b90d37270c64990be)) +* re-raise agent errors from the synchronous Runner.run() ([b55000d](https://github.com/google/adk-python/commit/b55000d9dce8960eeca8834dec7921597c532e0c)) +* read long-running function name from data, not metadata ([029c17b](https://github.com/google/adk-python/commit/029c17b3384f4ad584c4b4f6f83335be98a04f02)), closes [#6295](https://github.com/google/adk-python/issues/6295) +* redact credentials from DebugLoggingPlugin output file ([a86bd25](https://github.com/google/adk-python/commit/a86bd2525042e806e422c8b0c3e78dba9d3e6547)) +* redact credentials from generate_content_config.http_options in debug logs ([1cd6f46](https://github.com/google/adk-python/commit/1cd6f464e5b8ececa957928ca67d65145be558ab)) +* reject negative recent-event limits ([26110c7](https://github.com/google/adk-python/commit/26110c75596cb7743e0614eebb8267221003303a)) +* reject tool confirmations arriving over A2A ([9e9eaa6](https://github.com/google/adk-python/commit/9e9eaa69bdcc16f004af9c63f40f1dae6404c29b)), closes [#6461](https://github.com/google/adk-python/issues/6461) +* report root_agent type mismatch instead of 'No root_agent found' ([4f07c93](https://github.com/google/adk-python/commit/4f07c93b6ef760626906a2851b8f151500d13982)), closes [#6606](https://github.com/google/adk-python/issues/6606) +* resolve Claude 5 model names in the LLM registry ([42a4a5f](https://github.com/google/adk-python/commit/42a4a5f0e723ce71d0a1f34ae9c55c85a419ab67)) +* resolve NameError in legacy create-eval-set route ([023f45c](https://github.com/google/adk-python/commit/023f45c3e5846c3e72525b53f16ef018b5ecdaa6)) +* resolve nested provider behind litellm_proxy prefix ([602e58d](https://github.com/google/adk-python/commit/602e58db7171a49086d0c5b2b3e0fd8a8bd6d681)), closes [#6538](https://github.com/google/adk-python/issues/6538) +* resolve server disconnect logs ([3f2d399](https://github.com/google/adk-python/commit/3f2d399cd9e3945e4015b78ade8ecf3a6c2603e3)) +* resolve systemic parts[0] indexing bugs ([2109ea9](https://github.com/google/adk-python/commit/2109ea96e1c7431225e57fb6db6c4c116e632867)), closes [#6616](https://github.com/google/adk-python/issues/6616) +* Restore `invocation_context.branch` from session history when executing a non-root agent node or resuming a sub-agent in `Runner` ([676d1e7](https://github.com/google/adk-python/commit/676d1e76457db9dd49ca23d49770136e5aef10a1)) +* restrict unpickling of legacy v0 session event actions ([97c2af0](https://github.com/google/adk-python/commit/97c2af0bc294aa77744ddf5fb0c207f60ebf19fc)), closes [#5634](https://github.com/google/adk-python/issues/5634) +* resume an invocation whose user message leads with a non-text part ([6d8c588](https://github.com/google/adk-python/commit/6d8c588e12870ddeacc884bb3ee992d6464301d3)) +* revert the A2A guard that broke every HITL tool confirmation ([9a32eba](https://github.com/google/adk-python/commit/9a32eba1e271981fd079bdee489b9159c6ecc72a)) +* route workflow IDs and retry jitter through platform seams ([8f85107](https://github.com/google/adk-python/commit/8f85107cca7fa9d88eea5ca60e32b85173b4ec7c)) +* **samples:** widen numeric tool parameters to match their docstrings ([0c67410](https://github.com/google/adk-python/commit/0c67410a66275aa989f3c10d4d083bff5f8d6139)) +* sanitize state deltas before writing them to JSON state columns ([3f9e6be](https://github.com/google/adk-python/commit/3f9e6bec37cd66315d619a2dbb339f545c86e09a)) +* send the required list on a CrewAI or LangChain function declaration ([186d9f7](https://github.com/google/adk-python/commit/186d9f739d1dc3655d88bffd96c943db05a87d7d)) +* **sessions:** dedupe InMemorySessionService events by equality not id ([c9323d5](https://github.com/google/adk-python/commit/c9323d586153eab8cae31bc33f1114c8849cdd80)) +* **sessions:** load legacy v0 event actions with the restricted unpickler ([fb15710](https://github.com/google/adk-python/commit/fb15710548e36a25605f0d0e3b346123e7c88356)) +* **sessions:** stop dropping event actions on v0 PostgreSQL migration ([e3ae4ac](https://github.com/google/adk-python/commit/e3ae4ac2b431c589887e9bf2d70b452e8360f0c5)) +* **skills:** skip search results that fail frontmatter validation ([3c977bc](https://github.com/google/adk-python/commit/3c977bc2ef31cf0fd7d94592f660cec5fc68b722)), closes [#6838](https://github.com/google/adk-python/issues/6838) +* skip non-agent directories in AgentLoader.list_agents() ([caac070](https://github.com/google/adk-python/commit/caac070837e9001367fd90846e896577cba3a92e)) +* skip non-numeric response keys in generate_return_doc ([920bc3e](https://github.com/google/adk-python/commit/920bc3e14b5e2b3ee61d3809c42d8c8f7d972bfb)), closes [#6174](https://github.com/google/adk-python/issues/6174) +* stop re-sending thought summaries as conversation history ([e4f5900](https://github.com/google/adk-python/commit/e4f59004f74c4151d2ba5c6f02872634637de77c)) +* stop re-sending thought summaries as conversation history ([3c1b1bc](https://github.com/google/adk-python/commit/3c1b1bc236122a81fc5a70e30d5d6b48d1b2c029)) +* stop RemoteA2aAgent forwarding credential requests to the remote peer ([2aea859](https://github.com/google/adk-python/commit/2aea8595fb1c5e0fddef7893a1985dc96dc82692)) +* stop the session liveness probe from crashing when the SDK moves its streams ([d9f4d3d](https://github.com/google/adk-python/commit/d9f4d3d288257593471fc708a9ab851779005ca5)) +* strip internal planning tags from PlanReActPlanner output ([ac8dad2](https://github.com/google/adk-python/commit/ac8dad258065ff58e91a1ca44afc72368250d241)), closes [#3378](https://github.com/google/adk-python/issues/3378) +* **telemetry:** End the inference span when the inference ends ([ecc730c](https://github.com/google/adk-python/commit/ecc730ceac33c2f71fdecdb55b6c095241888914)) +* **telemetry:** record call_llm span attributes when a plugin short-circuits the model call ([0321136](https://github.com/google/adk-python/commit/0321136775fac560142dc1135bec34f00e88a69a)) +* **tools:** report no match instead of raising on empty retrieval ([99660d9](https://github.com/google/adk-python/commit/99660d90308f37d377efcbb08318dbac1ec1fba9)) +* **tools:** run AgentTool nested agent in unary mode ([0d5752b](https://github.com/google/adk-python/commit/0d5752bb5d7d33b94462082a00157b60213286a9)) +* **tools:** run an agent tool under the caller's RunConfig ([983c280](https://github.com/google/adk-python/commit/983c28056f664b6229a541bcbd442cd20c1b6345)) +* trigger Gemini 3.x Live response after sending conversation history ([7616c78](https://github.com/google/adk-python/commit/7616c78a1e94a8ff6751617ea5176a05e94e3939)) +* update unit guide skill to exclude internal implementation details ([0c0296c](https://github.com/google/adk-python/commit/0c0296cfcf60bbc87ff75b19e46550269ab8a1da)) +* use OAuth2 client-credentials scheme for OpenAPI SA helpers ([9897217](https://github.com/google/adk-python/commit/989721746aba65e90f644e51606375699175f709)), closes [#6656](https://github.com/google/adk-python/issues/6656) +* use Unicode-aware keyword extraction in InMemoryMemoryService ([b8ea1e8](https://github.com/google/adk-python/commit/b8ea1e8eba45c191776529392d808ab3485c6b6f)), closes [#5501](https://github.com/google/adk-python/issues/5501) +* validate session initialization events ([3fa71b6](https://github.com/google/adk-python/commit/3fa71b6349dbabb47dff5a3e9dea689cae6904e9)), closes [#5290](https://github.com/google/adk-python/issues/5290) +* validate SQL identifiers in Spanner search tool ([8d2f277](https://github.com/google/adk-python/commit/8d2f2779e6143aaa75460ca698476b4114759d5b)), closes [#5913](https://github.com/google/adk-python/issues/5913) +* **workflow:** cancel parallel worker items when the worker is cancelled ([dec729f](https://github.com/google/adk-python/commit/dec729f15721bcc78aac75f133aa6e153e9e4af0)) +* **workflow:** declare abc.ABC on BaseNode so abstract subclasses type-check ([f3b59fd](https://github.com/google/adk-python/commit/f3b59fd62e51cb684d853995b5b3b42b4b11041f)) +* **workflow:** do not act on function calls in partial events ([816ada5](https://github.com/google/adk-python/commit/816ada531870e36c57e3f100e96c63bbf94ad222)), closes [#6583](https://github.com/google/adk-python/issues/6583) +* **workflow:** honor START as a satisfied JoinNode predecessor ([d8df6fd](https://github.com/google/adk-python/commit/d8df6fd1fe8fa52ed9d6c275f4c5deb8464079a4)) +* **workflow:** resume node auth from the node's own auth config ([5b59139](https://github.com/google/adk-python/commit/5b59139e0ec944a8618d4a1df4182db464337566)) + + +### Performance Improvements + +* buffer streamed function call arguments instead of concatenating ([f604de2](https://github.com/google/adk-python/commit/f604de2b960513ab0f1eff7f6bad9088d6210497)) +* key the Pub/Sub publisher cache on its options' value ([51231cd](https://github.com/google/adk-python/commit/51231cd4acd8c5a28fc6db1960c4fa83e242a4d6)) +* remove redundant event scans in prompt assembly ([d7adc1c](https://github.com/google/adk-python/commit/d7adc1ce3e9dab246e5baacd8b4579b23ebecd54)) +* remove redundant event scans in prompt assembly ([e66fdd2](https://github.com/google/adk-python/commit/e66fdd2b2be25ddc740a988042c1dd91e6ac6662)) +* resolve the optional id-pairing providers once, not per request ([203461c](https://github.com/google/adk-python/commit/203461cdc407eb6f7864847529dfc3ae7c3d72d4)) +* run local code execution in a plain child interpreter ([c244a9c](https://github.com/google/adk-python/commit/c244a9c8330589d93046823ea21da80ae33a1406)) +* run the Google credential refresh off the event loop ([2aa2b46](https://github.com/google/adk-python/commit/2aa2b469b062cf94718edd6b2ebdfb52cfcad076)) + + +### Documentation + +* add audio_stream_end documentation for realtime input ([17cb265](https://github.com/google/adk-python/commit/17cb2657cef9503b94598348e13cebdcbc1a2d3a)) +* correct node schema docstrings about which variants validate ([e649a38](https://github.com/google/adk-python/commit/e649a382aae18ba0a495b837e009ca20ec6a6570)) +* correct the documented contract of the invocation-subtree event filter ([be8fcf4](https://github.com/google/adk-python/commit/be8fcf4dd692d8098f4954ceee9f2ea2f3409b44)) +* fix typos and grammar in README ([ffe518a](https://github.com/google/adk-python/commit/ffe518aff062932671ee78adabb4a5f55aca23c6)) +* **guides:** add developer unit guide for BaseCodeExecutor ([b370fc0](https://github.com/google/adk-python/commit/b370fc00d1a54f4629b73b74ce70fae13ac75304)) +* **guides:** add developer unit guide for BasePlanner ([ea23a89](https://github.com/google/adk-python/commit/ea23a89d734eead60f69c0b7f0fb6b13c9624c7a)) +* **guides:** add developer unit guide for Runner and Runner Live Streaming ([1d89e0f](https://github.com/google/adk-python/commit/1d89e0ff8dd00ce499e194089903ca183495ed44)) +* repair the workflow guides and document BaseNode ([7c7d2ba](https://github.com/google/adk-python/commit/7c7d2baec4316b5689a16d2b19c51d1c5a0d4298)) +* tell sample creator skill not to prefix dirs with `$category` or suffix with `_agent` ([50c3edf](https://github.com/google/adk-python/commit/50c3edf1f6487ee0e7b649f63cf993a4fc1255aa)) + ## [2.7.1](https://github.com/google/adk-python/compare/v2.7.0...v2.7.1) (2026-08-17) diff --git a/src/google/adk/version.py b/src/google/adk/version.py index 4e8136e9a1b..4f43df2be24 100644 --- a/src/google/adk/version.py +++ b/src/google/adk/version.py @@ -13,4 +13,4 @@ # limitations under the License. # version: major.minor.patch -__version__ = "2.7.1" +__version__ = "2.8.0" From 76a96e6221f1e2758a1ff82fde199cd079e9c654 Mon Sep 17 00:00:00 2001 From: adk-bot <223368873+adk-bot@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:23:20 +0000 Subject: [PATCH 7/7] chore: update last-release-sha for next main release --- .github/release-please-config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/release-please-config.json b/.github/release-please-config.json index 13a40187cdf..c48641ee0bf 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -57,5 +57,5 @@ ] } }, - "last-release-sha": "703cf43f6b03550ec2b02def526f3f4ba98f7abd" + "last-release-sha": "4d0d63c74cf161f90ef33c7b310b6fd6641d3249" }