From 9dccd6a69223925ed1c452189b33a922e6934643 Mon Sep 17 00:00:00 2001 From: Liang Wu Date: Wed, 11 Feb 2026 14:10:16 -0800 Subject: [PATCH 1/7] feat(conformance): read report's version info from the server Co-authored-by: Liang Wu PiperOrigin-RevId: 868837301 --- .../cli/conformance/_generate_markdown_utils.py | 16 +++++++++++----- .../adk/cli/conformance/adk_web_server_client.py | 11 +++++++++++ src/google/adk/cli/conformance/cli_test.py | 5 +++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/google/adk/cli/conformance/_generate_markdown_utils.py b/src/google/adk/cli/conformance/_generate_markdown_utils.py index 9bc41df1..ced3afbb 100644 --- a/src/google/adk/cli/conformance/_generate_markdown_utils.py +++ b/src/google/adk/cli/conformance/_generate_markdown_utils.py @@ -17,22 +17,27 @@ from __future__ import annotations from pathlib import Path +from typing import Any from typing import Optional from typing import TYPE_CHECKING import click -from ... import version - if TYPE_CHECKING: from .cli_test import _ConformanceTestSummary def generate_markdown_report( - summary: _ConformanceTestSummary, report_dir: Optional[str] + version_data: dict[str, Any], + summary: _ConformanceTestSummary, + report_dir: Optional[str], ) -> None: """Generates a Markdown report of the test results.""" - report_name = f"python_{'_'.join(version.__version__.split('.'))}_report.md" + server_version = version_data.get("version", "Unknown") + language = version_data.get("language", "Unknown") + language_version = version_data.get("language_version", "Unknown") + + report_name = f"python_{'_'.join(server_version.split('.'))}_report.md" if not report_dir: report_path = Path(report_name) else: @@ -44,7 +49,8 @@ def generate_markdown_report( # Summary f.write("## Summary\n\n") - f.write(f"- **ADK Version**: {version.__version__}\n") + f.write(f"- **ADK Version**: {server_version}\n") + f.write(f"- **Language**: {language} {language_version}\n") f.write(f"- **Total Tests**: {summary.total_tests}\n") f.write(f"- **Passed**: {summary.passed_tests}\n") f.write(f"- **Failed**: {summary.failed_tests}\n") diff --git a/src/google/adk/cli/conformance/adk_web_server_client.py b/src/google/adk/cli/conformance/adk_web_server_client.py index 541ec2c3..10bf82d1 100644 --- a/src/google/adk/cli/conformance/adk_web_server_client.py +++ b/src/google/adk/cli/conformance/adk_web_server_client.py @@ -207,6 +207,17 @@ class AdkWebServerClient: response.raise_for_status() return Session.model_validate(response.json()) + async def get_version_data(self) -> Dict[str, str]: + """Retrieve version data from the ADK web server. + + Returns: + Dictionary containing version information + """ + async with self._get_client() as client: + response = await client.get("/version") + response.raise_for_status() + return response.json() + async def run_agent( self, request: RunAgentRequest, diff --git a/src/google/adk/cli/conformance/cli_test.py b/src/google/adk/cli/conformance/cli_test.py index af3d7b61..70fbe554 100644 --- a/src/google/adk/cli/conformance/cli_test.py +++ b/src/google/adk/cli/conformance/cli_test.py @@ -328,8 +328,9 @@ async def run_conformance_test( runner = ConformanceTestRunner(test_paths, client, mode) summary = await runner.run_all_tests() - if generate_report: - generate_markdown_report(summary, report_dir) + if generate_report: + version_data = await client.get_version_data() + generate_markdown_report(version_data, summary, report_dir) _print_test_summary(summary) From bcbfeba953d46fca731b11542a00103cef374e57 Mon Sep 17 00:00:00 2001 From: Leon Ziyang Zhang Date: Wed, 11 Feb 2026 14:17:57 -0800 Subject: [PATCH 2/7] feat: pass trace context in MCP tool call's _meta field with Otel propagator PiperOrigin-RevId: 868841079 --- src/google/adk/tools/mcp_tool/mcp_tool.py | 8 +++ .../unittests/tools/mcp_tool/test_mcp_tool.py | 61 +++++++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/google/adk/tools/mcp_tool/mcp_tool.py b/src/google/adk/tools/mcp_tool/mcp_tool.py index 8c36a9c8..f31768a0 100644 --- a/src/google/adk/tools/mcp_tool/mcp_tool.py +++ b/src/google/adk/tools/mcp_tool/mcp_tool.py @@ -31,6 +31,7 @@ from fastapi.openapi.models import APIKeyIn from google.genai.types import FunctionDeclaration from mcp.shared.session import ProgressFnT from mcp.types import Tool as McpBaseTool +from opentelemetry import propagate from typing_extensions import override from ...agents.callback_context import CallbackContext @@ -313,6 +314,12 @@ class McpTool(BaseAuthenticatedTool): headers.update(dynamic_headers) final_headers = headers if headers else None + # Propagate trace context in the _meta field as sprcified by MCP protocol. + # See https://agentclientprotocol.com/protocol/extensibility#the-meta-field + trace_carrier: Dict[str, str] = {} + propagate.get_global_textmap().inject(carrier=trace_carrier) + meta_trace_context = trace_carrier if trace_carrier else None + # Get the session from the session manager session = await self._mcp_session_manager.create_session( headers=final_headers @@ -325,6 +332,7 @@ class McpTool(BaseAuthenticatedTool): self._mcp_tool.name, arguments=args, progress_callback=resolved_callback, + meta=meta_trace_context, ) return response.model_dump(exclude_none=True, mode="json") diff --git a/tests/unittests/tools/mcp_tool/test_mcp_tool.py b/tests/unittests/tools/mcp_tool/test_mcp_tool.py index 64dfac48..c4c85e77 100644 --- a/tests/unittests/tools/mcp_tool/test_mcp_tool.py +++ b/tests/unittests/tools/mcp_tool/test_mcp_tool.py @@ -25,6 +25,7 @@ from google.adk.auth.auth_credential import OAuth2Auth from google.adk.auth.auth_credential import ServiceAccount from google.adk.features import FeatureName from google.adk.features._feature_registry import temporary_feature_override +from google.adk.tools.mcp_tool import mcp_tool from google.adk.tools.mcp_tool.mcp_session_manager import MCPSessionManager from google.adk.tools.mcp_tool.mcp_tool import MCPTool from google.adk.tools.tool_context import ToolContext @@ -225,7 +226,7 @@ class TestMCPTool: ) # Fix: call_tool uses 'arguments' parameter, not positional args self.mock_session.call_tool.assert_called_once_with( - "test_tool", arguments=args, progress_callback=None + "test_tool", arguments=args, progress_callback=None, meta=None ) @pytest.mark.asyncio @@ -262,6 +263,55 @@ class TestMCPTool: headers = call_args[1]["headers"] assert headers == {"Authorization": "Bearer test_access_token"} + @patch.object(mcp_tool, "propagate", autospec=True) + @pytest.mark.asyncio + async def test_run_async_impl_with_trace_context(self, mock_propagate): + """Test running tool with trace context injection.""" + mock_propagator = Mock() + + def inject_context(carrier, context=None) -> None: + carrier["traceparent"] = ( + "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01" + ) + carrier["tracestate"] = "foo=bar" + carrier["baggage"] = "baz=qux" + + mock_propagator.inject.side_effect = inject_context + mock_propagate.get_global_textmap.return_value = mock_propagator + + tool = MCPTool( + mcp_tool=self.mock_mcp_tool, + mcp_session_manager=self.mock_session_manager, + ) + + mcp_response = CallToolResult( + content=[TextContent(type="text", text="success")] + ) + self.mock_session.call_tool = AsyncMock(return_value=mcp_response) + + tool_context = Mock(spec=ToolContext) + args = {"param1": "test_value"} + + await tool._run_async_impl( + args=args, tool_context=tool_context, credential=None + ) + + self.mock_session_manager.create_session.assert_called_once_with( + headers=None + ) + self.mock_session.call_tool.assert_called_once_with( + "test_tool", + arguments=args, + progress_callback=None, + meta={ + "traceparent": ( + "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01" + ), + "tracestate": "foo=bar", + "baggage": "baz=qux", + }, + ) + @pytest.mark.asyncio async def test_get_headers_oauth2(self): """Test header generation for OAuth2 credentials.""" @@ -778,7 +828,7 @@ class TestMCPTool: headers=expected_headers ) self.mock_session.call_tool.assert_called_once_with( - "test_tool", arguments=args, progress_callback=None + "test_tool", arguments=args, progress_callback=None, meta=None ) @pytest.mark.asyncio @@ -821,7 +871,7 @@ class TestMCPTool: "X-Tenant-ID": "test-tenant", } self.mock_session.call_tool.assert_called_once_with( - "test_tool", arguments=args, progress_callback=None + "test_tool", arguments=args, progress_callback=None, meta=None ) def test_init_with_progress_callback(self): @@ -875,7 +925,10 @@ class TestMCPTool: ) # Verify progress_callback was passed to call_tool self.mock_session.call_tool.assert_called_once_with( - "test_tool", arguments=args, progress_callback=my_progress_callback + "test_tool", + arguments=args, + progress_callback=my_progress_callback, + meta=None, ) @pytest.mark.asyncio From 34da2d5b26e82f96f1951334fe974a0444843720 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Wed, 11 Feb 2026 14:34:48 -0800 Subject: [PATCH 3/7] feat: enable dependency injection for agent loader in FastAPI app gen This enables to re-use the ADK web interface in other contexts more easily. For example, when having an own run-time the web interface can be exposed for visualization during development. Also add documentation for function. PiperOrigin-RevId: 868848338 --- src/google/adk/cli/fast_api.py | 54 ++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/google/adk/cli/fast_api.py b/src/google/adk/cli/fast_api.py index 9956fc55..553629f2 100644 --- a/src/google/adk/cli/fast_api.py +++ b/src/google/adk/cli/fast_api.py @@ -45,6 +45,7 @@ from .utils import envs from .utils import evals from .utils.agent_change_handler import AgentChangeEventHandler from .utils.agent_loader import AgentLoader +from .utils.base_agent_loader import BaseAgentLoader from .utils.service_factory import create_artifact_service_from_options from .utils.service_factory import create_memory_service_from_options from .utils.service_factory import create_session_service_from_options @@ -72,6 +73,7 @@ def __getattr__(name: str): def get_fast_api_app( *, agents_dir: str, + agent_loader: Optional[BaseAgentLoader] = None, session_service_uri: Optional[str] = None, session_db_kwargs: Optional[Mapping[str, Any]] = None, artifact_service_uri: Optional[str] = None, @@ -93,6 +95,52 @@ def get_fast_api_app( logo_image_url: Optional[str] = None, auto_create_session: bool = False, ) -> FastAPI: + """Constructs and returns a FastAPI application for serving ADK agents. + + This function orchestrates the initialization of core ADK services (Session, + Artifact, Memory, and Credential) based on the provided configuration, + configures the ADK Web Server, and optionally enables advanced features + like Agent-to-Agent (A2A) protocol support and cloud telemetry. + + Args: + agents_dir: The root directory containing agent definitions. This path is + used to discover agents, load custom service registrations (via + services.py/yaml), and as a base for local storage. + agent_loader: An optional custom loader for retrieving agent instances. If + not provided, a default AgentLoader targeting agents_dir is used. + session_service_uri: A URI defining the backend for session persistence. + Supports schemes like 'memory://', 'sqlite://', 'postgresql://', + 'mysql://', or 'agentengine://'. Defaults to per-agent local SQLite + storage if None. + session_db_kwargs: Optional keyword arguments for custom session service + initialization. These are passed to the service factory along with the + URI. + artifact_service_uri: URI for the artifact service. Uses local artifact + service if None. + memory_service_uri: URI for the memory service. Uses local memory service if + None. + use_local_storage: Whether to use local storage for session and artifacts. + eval_storage_uri: URI for evaluation storage. If provided, uses GCS + managers. + allow_origins: List of allowed origins for CORS. + web: Whether to enable the web UI and serve its assets. + a2a: Whether to enable Agent-to-Agent (A2A) protocol support. + host: Host address for the server (defaults to 127.0.0.1). + port: Port number for the server (defaults to 8000). + url_prefix: Optional prefix for all URL routes. + trace_to_cloud: Whether to export traces to Google Cloud Trace. + otel_to_cloud: Whether to export OpenTelemetry data to Google Cloud. + reload_agents: Whether to watch for file changes and reload agents. + lifespan: Optional FastAPI lifespan context manager. + extra_plugins: List of extra plugin names to load. + logo_text: Text to display in the web UI logo area. + logo_image_url: URL for an image to display in the web UI logo area. + auto_create_session: Whether to automatically create a session when + not found. + + Returns: + The configured FastAPI application instance. + """ # Set up eval managers. if eval_storage_uri: @@ -105,8 +153,10 @@ def get_fast_api_app( eval_sets_manager = LocalEvalSetsManager(agents_dir=agents_dir) eval_set_results_manager = LocalEvalSetResultsManager(agents_dir=agents_dir) - # initialize Agent Loader - agent_loader = AgentLoader(agents_dir) + # initialize Agent Loader if not passed as argument + if agent_loader is None: + agent_loader = AgentLoader(agents_dir) + # Load services.py from agents_dir for custom service registration. load_services_module(agents_dir) From 079f7a38be5c2dcae604a93a8dabc2fd60af79db Mon Sep 17 00:00:00 2001 From: George Weale Date: Wed, 11 Feb 2026 15:38:31 -0800 Subject: [PATCH 4/7] fix: Support escaped curly braces in instruction templates Close #3527 Co-authored-by: George Weale PiperOrigin-RevId: 868875061 --- src/google/adk/utils/instructions_utils.py | 26 +------------------ .../utils/test_instructions_utils.py | 26 ------------------- 2 files changed, 1 insertion(+), 51 deletions(-) diff --git a/src/google/adk/utils/instructions_utils.py b/src/google/adk/utils/instructions_utils.py index 1a5fa8dd..505b5cf1 100644 --- a/src/google/adk/utils/instructions_utils.py +++ b/src/google/adk/utils/instructions_utils.py @@ -79,16 +79,7 @@ async def inject_session_state( return ''.join(result) async def _replace_match(match) -> str: - matched_text = match.group() - if matched_text.startswith('{{') and matched_text.endswith('}}'): - # Preserve escaped non-placeholder literals (e.g. JSON snippets), - # but keep escaped placeholders as literal placeholders. - escaped_value = matched_text[2:-2] - if _is_escaped_placeholder(escaped_value): - return matched_text[1:-1] - return matched_text - - var_name = matched_text.lstrip('{').rstrip('}').strip() + var_name = match.group().lstrip('{').rstrip('}').strip() optional = False if var_name.endswith('?'): optional = True @@ -133,21 +124,6 @@ async def inject_session_state( return await _async_sub(r'{+[^{}]*}+', _replace_match, template) -def _is_escaped_placeholder(value: str) -> bool: - """Checks if escaped braces contain a supported placeholder pattern.""" - var_name = value.strip() - if not var_name: - return False - - if var_name.endswith('?'): - var_name = var_name.removesuffix('?') - - if var_name.startswith('artifact.'): - return True - - return _is_valid_state_name(var_name) - - def _is_valid_state_name(var_name): """Checks if the variable name is a valid state name. diff --git a/tests/unittests/utils/test_instructions_utils.py b/tests/unittests/utils/test_instructions_utils.py index fa4f1ab0..d76e5032 100644 --- a/tests/unittests/utils/test_instructions_utils.py +++ b/tests/unittests/utils/test_instructions_utils.py @@ -146,32 +146,6 @@ async def test_inject_session_state_with_invalid_state_name_returns_original(): assert populated_instruction == "Hello {invalid-key}!" -@pytest.mark.asyncio -async def test_inject_session_state_with_escaped_braces_returns_literal(): - instruction_template = "Code sample: {{user_name}}. Value: {user_name}." - invocation_context = await _create_test_readonly_context( - state={"user_name": "Foo"} - ) - - populated_instruction = await instructions_utils.inject_session_state( - instruction_template, invocation_context - ) - assert populated_instruction == "Code sample: {user_name}. Value: Foo." - - -@pytest.mark.asyncio -async def test_inject_session_state_with_escaped_non_placeholder_keeps_double_braces(): - instruction_template = "Literal template: {{'key2': 'value2'}}." - invocation_context = await _create_test_readonly_context( - state={"user_name": "Foo"} - ) - - populated_instruction = await instructions_utils.inject_session_state( - instruction_template, invocation_context - ) - assert populated_instruction == "Literal template: {{'key2': 'value2'}}." - - @pytest.mark.asyncio async def test_inject_session_state_with_invalid_prefix_state_name_returns_original(): instruction_template = "Hello {invalid:key}!" From 8cd22fb746f2ddac83ba31f7679138557a914925 Mon Sep 17 00:00:00 2001 From: Yifan Date: Wed, 11 Feb 2026 15:45:34 -0800 Subject: [PATCH 5/7] chore(version): Bump version and update changelog for 1.25.0 Merge https://github.com/google/adk-python/pull/4455 **Please ensure you have read the [contribution guide](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) before creating a pull request.** ### Link to Issue or Description of Change **1. Link to an existing issue (if applicable):** - Closes: #_issue_number_ - Related: #_issue_number_ **2. Or, if no issue exists, describe the change:** _If applicable, please follow the issue templates to provide as much detail as possible._ **Problem:** _A clear and concise description of what the problem is._ **Solution:** _A clear and concise description of what you want to happen and why you choose this solution._ ### Testing Plan _Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes._ **Unit Tests:** - [ ] I have added or updated unit tests for my change. - [ ] All unit tests pass locally. _Please include a summary of passed `pytest` results._ **Manual End-to-End (E2E) Tests:** _Please provide instructions on how to manually test your changes, including any necessary setup or configuration. Please provide logs or screenshots to help reviewers better understand the fix._ ### Checklist - [ ] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document. - [ ] I have performed a self-review of my own code. - [ ] I have commented my code, particularly in hard-to-understand areas. - [ ] I have added tests that prove my fix is effective or that my feature works. - [ ] New and existing unit tests pass locally with my changes. - [ ] I have manually tested my changes end-to-end. - [ ] Any dependent changes have been merged and published in downstream modules. ### Additional context _Add any other context or screenshots about the feature request here._ COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/4455 from google:release/candidate f660ec82ff4140ef1deea95182e6e0cc62100f89 PiperOrigin-RevId: 868877872 --- CHANGELOG.md | 44 +++++++++++++++++++++++++++++++++++++++ src/google/adk/version.py | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0bbd9f1..1efb2b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## [1.25.0](https://github.com/google/adk-python/compare/v1.24.1...v1.25.0) (2026-02-11) + +### Features + +* **[Core]** + * Add a demo for the simple prompt optimizer for the optimization interface ([0abf4cd](https://github.com/google/adk-python/commit/0abf4cd2c7103a071506c9398455a3bd66fe5da5)) + * Add `--auto_create_session` flag to `adk api_server` CLI ([40c15d0](https://github.com/google/adk-python/commit/40c15d059599472b40f48272a464eb3cb7345fc6)) + * Add `add_events_to_memory` facade for event-delta ([59e8897](https://github.com/google/adk-python/commit/59e88972ae4f10274444593db0607f40cfcc597e)) + * Add post-invocation token-threshold compaction with event retention ([a88e864](https://github.com/google/adk-python/commit/a88e8647558a9b9d0bfdf38d2d8de058e3ba0596)) + * Add report generation to `adk conformance test` command ([43c437e](https://github.com/google/adk-python/commit/43c437e38b9109b68a81de886d1901e4d8f87a01)) + +* **[Models]** + * Add base_url option to Gemini LLM class ([781f605](https://github.com/google/adk-python/commit/781f605a1e5de6d77b69d7e7b9835ec6fc8de4bf)) + +* **[Tools]** + * Enhance google credentials config to support externally passed access token ([3cf43e3](https://github.com/google/adk-python/commit/3cf43e3842d9987499ea70d6f63d6e1c4d4a07db)) + * Update agent simulator by improving prompts and add environment data ([7af1858](https://github.com/google/adk-python/commit/7af1858f46b66fa4471c5ba7943385f2d23d08d3)) + * Add a load MCP resource tool ([e25227d](https://github.com/google/adk-python/commit/e25227da5e91a8c1192af709f8e8bb2a471ded92)) + * Add SkillToolset to adk ([8d02792](https://github.com/google/adk-python/commit/8d0279251ce4fad6f0c84bd7777eb5a74f7ba07a)) + +* **[Web]** + * Add `/health` and `/version` endpoints to ADK web server ([25ec2c6](https://github.com/google/adk-python/commit/25ec2c6b614cf8d185ff6dbdac5697a210be68da)) + +### Bug Fixes + +* Use async iteration for VertexAiSessionService.list_sessions pagination ([758d337](https://github.com/google/adk-python/commit/758d337c76d877e3174c35f06551cc9beb1def06)) +* Fix event loop closed bug in McpSessionManager ([4aa4751](https://github.com/google/adk-python/commit/4aa475145f196fb35fe97290dd9f928548bc737f)) +* Preserve thought_signature in function call conversions for interactions API integration ([2010569](https://github.com/google/adk-python/commit/20105690100d9c2f69c061ac08be5e94c50dc39c)) +* Propagate grounding and citation metadata in streaming responses ([e6da417](https://github.com/google/adk-python/commit/e6da4172924ecc36ffc2535199c450a2a51c7bcc)) +* Add endpoints to get/list artifact version metadata ([e0b9712](https://github.com/google/adk-python/commit/e0b9712a492bf84ac17679095b333642a79b8ee6)) +* Support escaped curly braces in instruction templates ([7c7d25a](https://github.com/google/adk-python/commit/7c7d25a4a6e4389e23037e70b8efdcd5341f44ea)) +* Strip timezone for PostgreSQL timestamps in DatabaseSessionService ([19b6076](https://github.com/google/adk-python/commit/19b607684f15ce2b6ffd60382211ba5600705743)) +* Prompt token may be None in streaming mode ([32ee07d](https://github.com/google/adk-python/commit/32ee07df01f10dbee0e98ca9d412440a7fe9163d)) +* Pass invocation_id from `/run` endpoint to `Runner.run_async` ([d2dba27](https://github.com/google/adk-python/commit/d2dba27134f833e5d929fdf363ada9364cc852f9)) +* Conditionally preserve function call IDs in LLM requests ([663cb75](https://github.com/google/adk-python/commit/663cb75b3288d8d0649412e1009329502b21cbbc)) +* Migrate VertexAiMemoryBankService to use the async Vertex AI client ([64a44c2](https://github.com/google/adk-python/commit/64a44c28974de77cf8934f9c3d1bc03691b90e7b)) +* Handle list values in Gemini schema sanitization ([fd8a9e3](https://github.com/google/adk-python/commit/fd8a9e3962cca4f422beb7316cbe732edf726d51)) +* Used logger to log instead of print in MCP ([6bc70a6](https://github.com/google/adk-python/commit/6bc70a6bab79b679a4b18ad146b3450fb9014475)) + +### Improvements + +* Replace check of instance for LlmAgent with hasAttribute check ([7110336](https://github.com/google/adk-python/commit/7110336788662abb8c9bbbb0a53a50cc09130d5e)) +* Log exception details before re-raising in MCP session execution ([de79bf1](https://github.com/google/adk-python/commit/de79bf12b564a4eefc7e6a2568dbe0f08bb6efeb)) + ## [1.24.1](https://github.com/google/adk-python/compare/v1.24.0...v1.24.1) (2026-02-06) ### Bug Fixes diff --git a/src/google/adk/version.py b/src/google/adk/version.py index 12d438ed..8002d619 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__ = "1.24.1" +__version__ = "1.25.0" From b7f9110b5213e93e001f50d844c57cfdfb2ea11c Mon Sep 17 00:00:00 2001 From: Xuan Yang Date: Wed, 11 Feb 2026 16:16:04 -0800 Subject: [PATCH 6/7] chore: Introduce a unified Context Co-authored-by: Xuan Yang PiperOrigin-RevId: 868890552 --- src/google/adk/agents/context.py | 373 +++++++++++++++++++ tests/unittests/agents/test_context.py | 488 +++++++++++++++++++++++++ 2 files changed, 861 insertions(+) create mode 100644 src/google/adk/agents/context.py create mode 100644 tests/unittests/agents/test_context.py diff --git a/src/google/adk/agents/context.py b/src/google/adk/agents/context.py new file mode 100644 index 00000000..79942681 --- /dev/null +++ b/src/google/adk/agents/context.py @@ -0,0 +1,373 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Mapping +from collections.abc import Sequence +from typing import Any +from typing import TYPE_CHECKING + +from typing_extensions import override + +from .readonly_context import ReadonlyContext + +if TYPE_CHECKING: + from google.genai import types + + from ..artifacts.base_artifact_service import ArtifactVersion + from ..auth.auth_credential import AuthCredential + from ..auth.auth_tool import AuthConfig + from ..events.event import Event + from ..events.event_actions import EventActions + from ..memory.base_memory_service import SearchMemoryResponse + from ..sessions.state import State + from ..tools.tool_confirmation import ToolConfirmation + from .invocation_context import InvocationContext + + +class Context(ReadonlyContext): + """The context within an agent run.""" + + def __init__( + self, + invocation_context: InvocationContext, + *, + event_actions: EventActions | None = None, + function_call_id: str | None = None, + tool_confirmation: ToolConfirmation | None = None, + ) -> None: + """Initializes the Context. + + Args: + invocation_context: The invocation context. + event_actions: The event actions for state and artifact deltas. + function_call_id: The function call id of the current tool call. Required + for tool-specific methods like request_credential and + request_confirmation. + tool_confirmation: The tool confirmation of the current tool call. + """ + super().__init__(invocation_context) + + from ..events.event_actions import EventActions + from ..sessions.state import State + + self._event_actions = event_actions or EventActions() + self._state = State( + value=invocation_context.session.state, + delta=self._event_actions.state_delta, + ) + self._function_call_id = function_call_id + self._tool_confirmation = tool_confirmation + + @property + def function_call_id(self) -> str | None: + """The function call id of the current tool call.""" + return self._function_call_id + + @property + def tool_confirmation(self) -> ToolConfirmation | None: + """The tool confirmation of the current tool call.""" + return self._tool_confirmation + + @property + @override + def state(self) -> State: + """The delta-aware state of the current session. + + For any state change, you can mutate this object directly, + e.g. `ctx.state['foo'] = 'bar'` + """ + return self._state + + @property + def actions(self) -> EventActions: + """The event actions for the current context.""" + return self._event_actions + + # ============================================================================ + # Artifact methods + # ============================================================================ + + async def load_artifact( + self, filename: str, version: int | None = None + ) -> types.Part | None: + """Loads an artifact attached to the current session. + + Args: + filename: The filename of the artifact. + version: The version of the artifact. If None, the latest version will be + returned. + + Returns: + The artifact. + """ + if self._invocation_context.artifact_service is None: + raise ValueError("Artifact service is not initialized.") + return await self._invocation_context.artifact_service.load_artifact( + app_name=self._invocation_context.app_name, + user_id=self._invocation_context.user_id, + session_id=self._invocation_context.session.id, + filename=filename, + version=version, + ) + + async def save_artifact( + self, + filename: str, + artifact: types.Part, + custom_metadata: dict[str, Any] | None = None, + ) -> int: + """Saves an artifact and records it as delta for the current session. + + Args: + filename: The filename of the artifact. + artifact: The artifact to save. + custom_metadata: Custom metadata to associate with the artifact. + + Returns: + The version of the artifact. + """ + if self._invocation_context.artifact_service is None: + raise ValueError("Artifact service is not initialized.") + version = await self._invocation_context.artifact_service.save_artifact( + app_name=self._invocation_context.app_name, + user_id=self._invocation_context.user_id, + session_id=self._invocation_context.session.id, + filename=filename, + artifact=artifact, + custom_metadata=custom_metadata, + ) + self._event_actions.artifact_delta[filename] = version + return version + + async def get_artifact_version( + self, filename: str, version: int | None = None + ) -> ArtifactVersion | None: + """Gets artifact version info. + + Args: + filename: The filename of the artifact. + version: The version of the artifact. If None, the latest version will be + returned. + + Returns: + The artifact version info. + """ + if self._invocation_context.artifact_service is None: + raise ValueError("Artifact service is not initialized.") + return await self._invocation_context.artifact_service.get_artifact_version( + app_name=self._invocation_context.app_name, + user_id=self._invocation_context.user_id, + session_id=self._invocation_context.session.id, + filename=filename, + version=version, + ) + + async def list_artifacts(self) -> list[str]: + """Lists the filenames of the artifacts attached to the current session.""" + if self._invocation_context.artifact_service is None: + raise ValueError("Artifact service is not initialized.") + return await self._invocation_context.artifact_service.list_artifact_keys( + app_name=self._invocation_context.app_name, + user_id=self._invocation_context.user_id, + session_id=self._invocation_context.session.id, + ) + + # ============================================================================ + # Credential methods + # ============================================================================ + + async def save_credential(self, auth_config: AuthConfig) -> None: + """Saves a credential to the credential service. + + Args: + auth_config: The authentication configuration containing the credential. + """ + if self._invocation_context.credential_service is None: + raise ValueError("Credential service is not initialized.") + await self._invocation_context.credential_service.save_credential( + auth_config, self + ) + + async def load_credential( + self, auth_config: AuthConfig + ) -> AuthCredential | None: + """Loads a credential from the credential service. + + Args: + auth_config: The authentication configuration for the credential. + + Returns: + The loaded credential, or None if not found. + """ + if self._invocation_context.credential_service is None: + raise ValueError("Credential service is not initialized.") + return await self._invocation_context.credential_service.load_credential( + auth_config, self + ) + + def get_auth_response(self, auth_config: AuthConfig) -> AuthCredential | None: + """Gets the auth response credential from session state. + + This method retrieves an authentication credential that was previously + stored in session state after a user completed an OAuth flow or other + authentication process. + + Args: + auth_config: The authentication configuration for the credential. + + Returns: + The auth credential from the auth response, or None if not found. + """ + from ..auth.auth_handler import AuthHandler + + return AuthHandler(auth_config).get_auth_response(self.state) + + def request_credential(self, auth_config: AuthConfig) -> None: + """Requests a credential for the current tool call. + + This method can only be called in a tool context where function_call_id + is set. For callback contexts, use save_credential/load_credential instead. + + Args: + auth_config: The authentication configuration for the credential. + + Raises: + ValueError: If function_call_id is not set. + """ + from ..auth.auth_handler import AuthHandler + + if not self.function_call_id: + raise ValueError( + "request_credential requires function_call_id. " + "This method can only be used in a tool context, not a callback " + "context. Consider using save_credential/load_credential instead." + ) + self._event_actions.requested_auth_configs[self.function_call_id] = ( + AuthHandler(auth_config).generate_auth_request() + ) + + # ============================================================================ + # Tool methods + # ============================================================================ + + def request_confirmation( + self, + *, + hint: str | None = None, + payload: Any | None = None, + ) -> None: + """Requests confirmation for the current tool call. + + This method can only be called in a tool context where function_call_id + is set. + + Args: + hint: A hint to the user on how to confirm the tool call. + payload: The payload used to confirm the tool call. + + Raises: + ValueError: If function_call_id is not set. + """ + from ..tools.tool_confirmation import ToolConfirmation + + if not self.function_call_id: + raise ValueError( + "request_confirmation requires function_call_id. " + "This method can only be used in a tool context." + ) + self._event_actions.requested_tool_confirmations[self.function_call_id] = ( + ToolConfirmation( + hint=hint, + payload=payload, + ) + ) + + # ============================================================================ + # Memory methods + # ============================================================================ + + async def add_session_to_memory(self) -> None: + """Triggers memory generation for the current session. + + This method saves the current session's events to the memory service, + enabling the agent to recall information from past interactions. + + Raises: + ValueError: If memory service is not available. + + Example: + ```python + async def my_after_agent_callback(ctx: Context): + # Save conversation to memory at the end of each interaction + await ctx.add_session_to_memory() + ``` + """ + if self._invocation_context.memory_service is None: + raise ValueError( + "Cannot add session to memory: memory service is not available." + ) + await self._invocation_context.memory_service.add_session_to_memory( + self._invocation_context.session + ) + + async def add_events_to_memory( + self, + *, + events: Sequence[Event], + custom_metadata: Mapping[str, object] | None = None, + ) -> None: + """Adds an explicit list of events to the memory service. + + Uses this callback's current session identifiers as memory scope. + + Args: + events: Explicit events to add to memory. + custom_metadata: Optional standard metadata for memory generation. + + Raises: + ValueError: If memory service is not available. + """ + if self._invocation_context.memory_service is None: + raise ValueError( + "Cannot add events to memory: memory service is not available." + ) + await self._invocation_context.memory_service.add_events_to_memory( + app_name=self._invocation_context.session.app_name, + user_id=self._invocation_context.session.user_id, + session_id=self._invocation_context.session.id, + events=events, + custom_metadata=custom_metadata, + ) + + async def search_memory(self, query: str) -> SearchMemoryResponse: + """Searches the memory of the current user. + + Args: + query: The search query. + + Returns: + The search results from the memory service. + + Raises: + ValueError: If memory service is not available. + """ + if self._invocation_context.memory_service is None: + raise ValueError("Memory service is not available.") + return await self._invocation_context.memory_service.search_memory( + app_name=self._invocation_context.app_name, + user_id=self._invocation_context.user_id, + query=query, + ) diff --git a/tests/unittests/agents/test_context.py b/tests/unittests/agents/test_context.py new file mode 100644 index 00000000..3abff822 --- /dev/null +++ b/tests/unittests/agents/test_context.py @@ -0,0 +1,488 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +from google.adk.agents.context import Context +from google.adk.auth import auth_handler +from google.adk.auth.auth_credential import AuthCredential +from google.adk.auth.auth_credential import AuthCredentialTypes +from google.adk.auth.auth_tool import AuthConfig +from google.adk.memory.base_memory_service import SearchMemoryResponse +from google.adk.tools.tool_confirmation import ToolConfirmation +from google.genai.types import Part +import pytest + + +@pytest.fixture +def mock_invocation_context(): + """Create a mock invocation context for testing.""" + mock_context = MagicMock() + mock_context.invocation_id = "test-invocation-id" + mock_context.agent.name = "test-agent-name" + mock_context.session.state = {"key1": "value1", "key2": "value2"} + mock_context.session.id = "test-session-id" + mock_context.app_name = "test-app" + mock_context.user_id = "test-user" + mock_context.artifact_service = None + mock_context.credential_service = None + mock_context.memory_service = None + return mock_context + + +@pytest.fixture +def mock_artifact_service(): + """Create a mock artifact service for testing.""" + mock_service = AsyncMock() + mock_service.list_artifact_keys.return_value = [ + "file1.txt", + "file2.txt", + "file3.txt", + ] + return mock_service + + +@pytest.fixture +def mock_auth_config(mocker): + """Create a mock auth config for testing.""" + return mocker.create_autospec(AuthConfig, instance=True) + + +@pytest.fixture +def mock_auth_credential(mocker): + """Create a mock auth credential for testing.""" + mock_credential = mocker.create_autospec(AuthCredential, instance=True) + mock_credential.auth_type = AuthCredentialTypes.OAUTH2 + return mock_credential + + +class TestContextInitialization: + """Test Context initialization.""" + + def test_initialization_without_function_call_id( + self, mock_invocation_context + ): + """Test Context initialization without function_call_id.""" + context = Context(mock_invocation_context) + + assert context._invocation_context == mock_invocation_context + assert context._event_actions is not None + assert context._state is not None + assert context.function_call_id is None + assert context.tool_confirmation is None + + def test_initialization_with_function_call_id(self, mock_invocation_context): + """Test Context initialization with function_call_id.""" + context = Context( + mock_invocation_context, + function_call_id="test-function-call-id", + ) + + assert context.function_call_id == "test-function-call-id" + assert context.tool_confirmation is None + + def test_initialization_with_tool_confirmation(self, mock_invocation_context): + """Test Context initialization with tool_confirmation.""" + tool_confirmation = ToolConfirmation( + hint="test hint", payload={"key": "value"} + ) + context = Context( + mock_invocation_context, + function_call_id="test-function-call-id", + tool_confirmation=tool_confirmation, + ) + + assert context.function_call_id == "test-function-call-id" + assert context.tool_confirmation == tool_confirmation + assert context.tool_confirmation.hint == "test hint" + assert context.tool_confirmation.payload == {"key": "value"} + + def test_state_property(self, mock_invocation_context): + """Test that state property returns mutable state.""" + context = Context(mock_invocation_context) + + assert context.state["key1"] == "value1" + assert context.state["key2"] == "value2" + + def test_actions_property(self, mock_invocation_context): + """Test that actions property returns event_actions.""" + context = Context(mock_invocation_context) + + assert context.actions is context._event_actions + + +class TestContextListArtifacts: + """Test the list_artifacts method in Context.""" + + async def test_list_artifacts_returns_artifact_keys( + self, mock_invocation_context, mock_artifact_service + ): + """Test that list_artifacts returns the artifact keys from the service.""" + mock_invocation_context.artifact_service = mock_artifact_service + context = Context(mock_invocation_context) + + result = await context.list_artifacts() + + assert result == ["file1.txt", "file2.txt", "file3.txt"] + mock_artifact_service.list_artifact_keys.assert_called_once_with( + app_name="test-app", + user_id="test-user", + session_id="test-session-id", + ) + + async def test_list_artifacts_raises_value_error_when_service_is_none( + self, mock_invocation_context + ): + """Test that list_artifacts raises ValueError when no artifact service.""" + mock_invocation_context.artifact_service = None + context = Context(mock_invocation_context) + + with pytest.raises( + ValueError, match="Artifact service is not initialized." + ): + await context.list_artifacts() + + +class TestContextSaveLoadArtifact: + """Test save_artifact and load_artifact methods in Context.""" + + async def test_save_artifact(self, mock_invocation_context): + """Test save_artifact method.""" + artifact_service = AsyncMock() + artifact_service.save_artifact.return_value = 1 + mock_invocation_context.artifact_service = artifact_service + + context = Context(mock_invocation_context) + test_artifact = Part.from_text(text="test content") + + version = await context.save_artifact("test_file.txt", test_artifact) + + artifact_service.save_artifact.assert_called_once_with( + app_name="test-app", + user_id="test-user", + session_id="test-session-id", + filename="test_file.txt", + artifact=test_artifact, + custom_metadata=None, + ) + assert version == 1 + assert context.actions.artifact_delta["test_file.txt"] == 1 + + async def test_load_artifact(self, mock_invocation_context): + """Test load_artifact method.""" + artifact_service = AsyncMock() + test_artifact = Part.from_text(text="test content") + artifact_service.load_artifact.return_value = test_artifact + mock_invocation_context.artifact_service = artifact_service + + context = Context(mock_invocation_context) + + result = await context.load_artifact("test_file.txt") + + artifact_service.load_artifact.assert_called_once_with( + app_name="test-app", + user_id="test-user", + session_id="test-session-id", + filename="test_file.txt", + version=None, + ) + assert result == test_artifact + + async def test_load_artifact_with_version(self, mock_invocation_context): + """Test load_artifact method with specific version.""" + artifact_service = AsyncMock() + test_artifact = Part.from_text(text="test content") + artifact_service.load_artifact.return_value = test_artifact + mock_invocation_context.artifact_service = artifact_service + + context = Context(mock_invocation_context) + + result = await context.load_artifact("test_file.txt", version=2) + + artifact_service.load_artifact.assert_called_once_with( + app_name="test-app", + user_id="test-user", + session_id="test-session-id", + filename="test_file.txt", + version=2, + ) + assert result == test_artifact + + +class TestContextCredentialMethods: + """Test credential methods in Context.""" + + async def test_save_credential_with_service( + self, mock_invocation_context, mock_auth_config + ): + """Test save_credential when credential service is available.""" + credential_service = AsyncMock() + mock_invocation_context.credential_service = credential_service + + context = Context(mock_invocation_context) + await context.save_credential(mock_auth_config) + + credential_service.save_credential.assert_called_once_with( + mock_auth_config, context + ) + + async def test_save_credential_no_service( + self, mock_invocation_context, mock_auth_config + ): + """Test save_credential when credential service is not available.""" + mock_invocation_context.credential_service = None + + context = Context(mock_invocation_context) + + with pytest.raises( + ValueError, match="Credential service is not initialized" + ): + await context.save_credential(mock_auth_config) + + async def test_load_credential_with_service( + self, mock_invocation_context, mock_auth_config, mock_auth_credential + ): + """Test load_credential when credential service is available.""" + credential_service = AsyncMock() + credential_service.load_credential.return_value = mock_auth_credential + mock_invocation_context.credential_service = credential_service + + context = Context(mock_invocation_context) + result = await context.load_credential(mock_auth_config) + + credential_service.load_credential.assert_called_once_with( + mock_auth_config, context + ) + assert result == mock_auth_credential + + async def test_load_credential_no_service( + self, mock_invocation_context, mock_auth_config + ): + """Test load_credential when credential service is not available.""" + mock_invocation_context.credential_service = None + + context = Context(mock_invocation_context) + + with pytest.raises( + ValueError, match="Credential service is not initialized" + ): + await context.load_credential(mock_auth_config) + + +class TestContextGetAuthResponse: + """Test get_auth_response method in Context.""" + + def test_get_auth_response(self, mock_invocation_context, mock_auth_config): + """Test get_auth_response method.""" + context = Context(mock_invocation_context) + + with patch.object( + auth_handler, "AuthHandler", autospec=True + ) as mock_auth_handler: + mock_handler_instance = mock_auth_handler.return_value + mock_handler_instance.get_auth_response.return_value = "auth-response" + + result = context.get_auth_response(mock_auth_config) + + mock_auth_handler.assert_called_once_with(mock_auth_config) + mock_handler_instance.get_auth_response.assert_called_once_with( + context.state + ) + assert result == "auth-response" + + +class TestContextRequestCredential: + """Test request_credential method in Context.""" + + def test_request_credential_with_function_call_id( + self, mock_invocation_context, mock_auth_config + ): + """Test request_credential when function_call_id is set.""" + context = Context( + mock_invocation_context, + function_call_id="test-function-call-id", + ) + + with patch.object( + auth_handler, "AuthHandler", autospec=True + ) as mock_auth_handler: + mock_handler_instance = mock_auth_handler.return_value + mock_handler_instance.generate_auth_request.return_value = "auth-request" + + context.request_credential(mock_auth_config) + + mock_auth_handler.assert_called_once_with(mock_auth_config) + mock_handler_instance.generate_auth_request.assert_called_once() + assert ( + context.actions.requested_auth_configs["test-function-call-id"] + == "auth-request" + ) + + def test_request_credential_without_function_call_id_raises( + self, mock_invocation_context, mock_auth_config + ): + """Test request_credential raises ValueError when no function_call_id.""" + context = Context(mock_invocation_context) + + with pytest.raises( + ValueError, + match="request_credential requires function_call_id", + ): + context.request_credential(mock_auth_config) + + +class TestContextRequestConfirmation: + """Test request_confirmation method in Context.""" + + def test_request_confirmation_with_function_call_id( + self, mock_invocation_context + ): + """Test request_confirmation when function_call_id is set.""" + context = Context( + mock_invocation_context, + function_call_id="test-function-call-id", + ) + + context.request_confirmation( + hint="Please confirm", payload={"action": "delete"} + ) + + confirmation = context.actions.requested_tool_confirmations[ + "test-function-call-id" + ] + assert confirmation.hint == "Please confirm" + assert confirmation.payload == {"action": "delete"} + + def test_request_confirmation_with_only_hint(self, mock_invocation_context): + """Test request_confirmation with only hint provided.""" + context = Context( + mock_invocation_context, + function_call_id="test-function-call-id", + ) + + context.request_confirmation(hint="Confirm this action") + + confirmation = context.actions.requested_tool_confirmations[ + "test-function-call-id" + ] + assert confirmation.hint == "Confirm this action" + assert confirmation.payload is None + + def test_request_confirmation_without_function_call_id_raises( + self, mock_invocation_context + ): + """Test request_confirmation raises ValueError when no function_call_id.""" + context = Context(mock_invocation_context) + + with pytest.raises( + ValueError, + match="request_confirmation requires function_call_id", + ): + context.request_confirmation() + + +class TestContextMemoryMethods: + """Test memory methods in Context.""" + + async def test_add_session_to_memory_success(self, mock_invocation_context): + """Test that add_session_to_memory calls the memory service correctly.""" + memory_service = AsyncMock() + mock_invocation_context.memory_service = memory_service + + context = Context(mock_invocation_context) + await context.add_session_to_memory() + + memory_service.add_session_to_memory.assert_called_once_with( + mock_invocation_context.session + ) + + async def test_add_session_to_memory_no_service_raises( + self, mock_invocation_context + ): + """Test that add_session_to_memory raises ValueError when memory service is None.""" + mock_invocation_context.memory_service = None + + context = Context(mock_invocation_context) + + with pytest.raises( + ValueError, + match=( + r"Cannot add session to memory: memory service is not available\." + ), + ): + await context.add_session_to_memory() + + async def test_search_memory_success(self, mock_invocation_context, mocker): + """Test that search_memory calls the memory service correctly.""" + memory_service = AsyncMock() + mock_search_response = mocker.create_autospec( + SearchMemoryResponse, instance=True + ) + memory_service.search_memory.return_value = mock_search_response + mock_invocation_context.memory_service = memory_service + + context = Context(mock_invocation_context) + result = await context.search_memory("test query") + + memory_service.search_memory.assert_called_once_with( + app_name="test-app", + user_id="test-user", + query="test query", + ) + assert result == mock_search_response + + async def test_search_memory_no_service_raises(self, mock_invocation_context): + """Test that search_memory raises ValueError when memory service is None.""" + mock_invocation_context.memory_service = None + + context = Context(mock_invocation_context) + + with pytest.raises(ValueError, match="Memory service is not available."): + await context.search_memory("test query") + + async def test_add_events_to_memory_success(self, mock_invocation_context): + """Test that add_events_to_memory calls the memory service correctly.""" + memory_service = AsyncMock() + mock_invocation_context.memory_service = memory_service + test_event = MagicMock() + + context = Context(mock_invocation_context) + await context.add_events_to_memory( + events=[test_event], + custom_metadata={"ttl": "6000s"}, + ) + + memory_service.add_events_to_memory.assert_called_once_with( + app_name=mock_invocation_context.session.app_name, + user_id=mock_invocation_context.session.user_id, + session_id=mock_invocation_context.session.id, + events=[test_event], + custom_metadata={"ttl": "6000s"}, + ) + + async def test_add_events_to_memory_no_service_raises( + self, mock_invocation_context + ): + """Test that add_events_to_memory raises ValueError when no service.""" + mock_invocation_context.memory_service = None + + context = Context(mock_invocation_context) + + with pytest.raises( + ValueError, + match=r"Cannot add events to memory: memory service is not available\.", + ): + await context.add_events_to_memory(events=[MagicMock()]) From 9f7d5b3f1476234e552b783415527cc4bac55b39 Mon Sep 17 00:00:00 2001 From: Kathy Wu Date: Wed, 11 Feb 2026 18:10:18 -0800 Subject: [PATCH 7/7] feat: Add load_skill_from_dir() method This allows users to load skills from a directory and pass it into the SkillToolset constructor. Co-authored-by: Kathy Wu PiperOrigin-RevId: 868929937 --- contributing/samples/skills_agent/agent.py | 11 +- .../skills/weather_skill/SKILL.md | 7 ++ .../weather_skill/references/weather_info.md | 6 + src/google/adk/skills/__init__.py | 4 +- src/google/adk/skills/utils.py | 112 ++++++++++++++++++ tests/unittests/skills/test_utils.py | 56 +++++++++ 6 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 contributing/samples/skills_agent/skills/weather_skill/SKILL.md create mode 100644 contributing/samples/skills_agent/skills/weather_skill/references/weather_info.md create mode 100644 src/google/adk/skills/utils.py create mode 100644 tests/unittests/skills/test_utils.py diff --git a/contributing/samples/skills_agent/agent.py b/contributing/samples/skills_agent/agent.py index fbd4e5f4..5c2fb91c 100644 --- a/contributing/samples/skills_agent/agent.py +++ b/contributing/samples/skills_agent/agent.py @@ -14,9 +14,10 @@ """Example agent demonstrating the use of SkillToolset.""" -import inspect +import pathlib from google.adk import Agent +from google.adk.skills import load_skill_from_dir from google.adk.skills import models from google.adk.tools import skill_toolset @@ -39,7 +40,13 @@ greeting_skill = models.Skill( ), ) -my_skill_toolset = skill_toolset.SkillToolset(skills=[greeting_skill]) +weather_skill = load_skill_from_dir( + pathlib.Path(__file__).parent / "skills" / "weather_skill" +) + +my_skill_toolset = skill_toolset.SkillToolset( + skills=[greeting_skill, weather_skill] +) root_agent = Agent( model="gemini-2.5-flash", diff --git a/contributing/samples/skills_agent/skills/weather_skill/SKILL.md b/contributing/samples/skills_agent/skills/weather_skill/SKILL.md new file mode 100644 index 00000000..67d87105 --- /dev/null +++ b/contributing/samples/skills_agent/skills/weather_skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: weather-skill +description: A skill that provides weather information based on reference data. +--- + +Step 1: Check 'references/weather_info.md' for the current weather. +Step 2: Provide the weather update to the user. diff --git a/contributing/samples/skills_agent/skills/weather_skill/references/weather_info.md b/contributing/samples/skills_agent/skills/weather_skill/references/weather_info.md new file mode 100644 index 00000000..ebe6b633 --- /dev/null +++ b/contributing/samples/skills_agent/skills/weather_skill/references/weather_info.md @@ -0,0 +1,6 @@ +# Weather Information + +- **Location:** San Francisco, CA +- **Condition:** Sunny ☀️ +- **Temperature:** 72°F (22°C) +- **Forecast:** Clear skies all day. diff --git a/src/google/adk/skills/__init__.py b/src/google/adk/skills/__init__.py index 3480bc7d..73184b2b 100644 --- a/src/google/adk/skills/__init__.py +++ b/src/google/adk/skills/__init__.py @@ -18,12 +18,12 @@ from .models import Frontmatter from .models import Resources from .models import Script from .models import Skill -from .prompt import format_skills_as_xml +from .utils import load_skill_from_dir __all__ = [ "Frontmatter", "Resources", "Script", "Skill", - "format_skills_as_xml", + "load_skill_from_dir", ] diff --git a/src/google/adk/skills/utils.py b/src/google/adk/skills/utils.py new file mode 100644 index 00000000..3f357311 --- /dev/null +++ b/src/google/adk/skills/utils.py @@ -0,0 +1,112 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Utility functions for Agent Skills.""" + +from __future__ import annotations + +import pathlib +from typing import Union + +import yaml + +from . import models + + +def _load_dir(directory: pathlib.Path) -> dict[str, str]: + """Recursively load files from a directory into a dictionary. + + Args: + directory: Path to the directory to load. + + Returns: + Dictionary mapping relative file paths to their string content. + """ + files = {} + if directory.exists() and directory.is_dir(): + for file_path in directory.rglob("*"): + if file_path.is_file(): + relative_path = file_path.relative_to(directory) + files[str(relative_path)] = file_path.read_text(encoding="utf-8") + return files + + +def load_skill_from_dir(skill_dir: Union[str, pathlib.Path]) -> models.Skill: + """Load a complete skill from a directory. + + Args: + skill_dir: Path to the skill directory. + + Returns: + Skill object with all components loaded. + + Raises: + FileNotFoundError: If the skill directory or SKILL.md is not found. + ValueError: If SKILL.md is invalid. + """ + skill_dir = pathlib.Path(skill_dir).resolve() + + if not skill_dir.is_dir(): + raise FileNotFoundError(f"Skill directory '{skill_dir}' not found.") + + skill_md = None + for name in ("SKILL.md", "skill.md"): + path = skill_dir / name + if path.exists(): + skill_md = path + break + + if skill_md is None: + raise FileNotFoundError(f"SKILL.md not found in '{skill_dir}'.") + + content = skill_md.read_text(encoding="utf-8") + if not content.startswith("---"): + raise ValueError("SKILL.md must start with YAML frontmatter (---)") + + parts = content.split("---", 2) + if len(parts) < 3: + raise ValueError("SKILL.md frontmatter not properly closed with ---") + + frontmatter_str = parts[1] + body = parts[2].strip() + + try: + parsed = yaml.safe_load(frontmatter_str) + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML in frontmatter: {e}") from e + + if not isinstance(parsed, dict): + raise ValueError("SKILL.md frontmatter must be a YAML mapping") + + # Frontmatter class handles required field validation + frontmatter = models.Frontmatter(**parsed) + + references = _load_dir(skill_dir / "references") + assets = _load_dir(skill_dir / "assets") + raw_scripts = _load_dir(skill_dir / "scripts") + scripts = { + name: models.Script(src=content) for name, content in raw_scripts.items() + } + + resources = models.Resources( + references=references, + assets=assets, + scripts=scripts, + ) + + return models.Skill( + frontmatter=frontmatter, + instructions=body, + resources=resources, + ) diff --git a/tests/unittests/skills/test_utils.py b/tests/unittests/skills/test_utils.py new file mode 100644 index 00000000..d922719d --- /dev/null +++ b/tests/unittests/skills/test_utils.py @@ -0,0 +1,56 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for skill utilities.""" + +from google.adk.skills import load_skill_from_dir +import pytest + + +def test_load_skill_from_dir(tmp_path): + """Tests loading a skill from a directory.""" + skill_dir = tmp_path / "test-skill" + skill_dir.mkdir() + + skill_md_content = """--- +name: test-skill +description: Test description +--- +Test instructions +""" + (skill_dir / "SKILL.md").write_text(skill_md_content) + + # Create references + ref_dir = skill_dir / "references" + ref_dir.mkdir() + (ref_dir / "ref1.md").write_text("ref1 content") + + # Create assets + assets_dir = skill_dir / "assets" + assets_dir.mkdir() + (assets_dir / "asset1.txt").write_text("asset1 content") + + # Create scripts + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir() + (scripts_dir / "script1.sh").write_text("echo hello") + + skill = load_skill_from_dir(skill_dir) + + assert skill.name == "test-skill" + assert skill.description == "Test description" + assert skill.instructions == "Test instructions" + assert skill.resources.get_reference("ref1.md") == "ref1 content" + assert skill.resources.get_asset("asset1.txt") == "asset1 content" + assert skill.resources.get_script("script1.sh").src == "echo hello"