Merge branch 'main' into v1.25.0

This commit is contained in:
Liang Wu
2026-02-11 21:19:38 -08:00
committed by GitHub
16 changed files with 1196 additions and 68 deletions
+9 -2
View File
@@ -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",
@@ -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.
@@ -0,0 +1,6 @@
# Weather Information
- **Location:** San Francisco, CA
- **Condition:** Sunny ☀️
- **Temperature:** 72°F (22°C)
- **Forecast:** Clear skies all day.
+373
View File
@@ -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,
)
@@ -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")
@@ -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,
+3 -2
View File
@@ -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)
+52 -2
View File
@@ -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)
+2 -2
View File
@@ -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",
]
+112
View File
@@ -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,
)
@@ -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")
+1 -25
View File
@@ -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.
+488
View File
@@ -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()])
+56
View File
@@ -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"
@@ -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
@@ -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}!"