You've already forked adk-python
mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
Merge branch 'main' into v1.25.0
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()])
|
||||
@@ -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}!"
|
||||
|
||||
Reference in New Issue
Block a user