Compare commits

..

9 Commits

Author SHA1 Message Date
Wei Sun (Jack) 93a085ad87 chore(version): Bumps version to 1.24.1 patch
Co-authored-by: Wei Sun (Jack) <weisun@google.com>
PiperOrigin-RevId: 866107525
2026-02-05 14:13:02 -08:00
Google Team Member 6645aa07fd feat: Add experimental agent tool simulator
PiperOrigin-RevId: 866100611
2026-02-05 13:57:57 -08:00
Yifan Wang 3686a3a98f chore: update adk web files, updated eval dialog colors, and fixed a2ui component types
Co-authored-by: Yifan Wang <wanyif@google.com>
PiperOrigin-RevId: 866057812
2026-02-05 12:16:54 -08:00
Yifan Wang ae993e884f fix: adding back deprecated eval endpoint for web until we migrate
Co-authored-by: Yifan Wang <wanyif@google.com>
PiperOrigin-RevId: 866049699
2026-02-05 11:58:04 -08:00
George Weale bb89466623 chore: Improve type hints and handle None values in ADK utils
Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 866025998
2026-02-05 11:04:46 -08:00
Xuan Yang adbc37fea1 feat: Add progress_callback support to MCPTool and MCPToolset
Fixes: https://github.com/google/adk-python/issues/3811

Co-authored-by: Xuan Yang <xygoogle@google.com>
PiperOrigin-RevId: 866025995
2026-02-05 11:04:36 -08:00
George Weale 9b112e2d13 fix: Refactor context filtering to better handle multi-turn invocations
The definition of an "invocation" for context filtering has been updated. An invocation now starts with a user message and can include multiple model turns (like the tool calls and responses) until the next user message. The filtering logic has been rewritten to identify invocation start points based on human user messages

Close #4296

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 866023290
2026-02-05 10:57:56 -08:00
Google Team Member a08bf62b95 feat(otel): add extra attributes to span generated with opentelemetry-instrumentation-google-genai
PiperOrigin-RevId: 865825792
2026-02-05 01:58:30 -08:00
Liang Wu e752bbb756 chore: Remove unused tzlocal dependency and logging
The `tzlocal` library and the logging of the local timezone were not used in the `DatabaseSessionService` logic.

Co-authored-by: Liang Wu <wuliang@google.com>
PiperOrigin-RevId: 865677320
2026-02-04 18:49:55 -08:00
39 changed files with 2218 additions and 204 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## [1.24.1](https://github.com/google/adk-python/compare/v1.24.0...v1.24.1) (2026-02-06)
### Bug Fixes
* Add back deprecated eval endpoint for web until we migrate([ae993e8](https://github.com/google/adk-python/commit/ae993e884f44db276a4116ebb7a11a2fb586dbfe))
* Update eval dialog colors, and fix a2ui component types ([3686a3a](https://github.com/google/adk-python/commit/3686a3a98f46738549cd7a999f3773b7a6fd1182))
## [1.24.0](https://github.com/google/adk-python/compare/v1.23.0...v1.24.0) (2026-02-04)
### âš  BREAKING CHANGES
@@ -0,0 +1,15 @@
# 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 . import agent
@@ -0,0 +1,166 @@
# 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.
"""Sample agent demonstrating MCP progress callback feature.
This sample shows how to use the progress_callback parameter in McpToolset
to receive progress notifications from MCP servers during long-running tool
executions.
There are two ways to use progress callbacks:
1. Simple callback (shared by all tools):
Pass a ProgressFnT callback that receives (progress, total, message).
2. Factory function (per-tool callbacks with runtime context):
Pass a ProgressCallbackFactory that takes (tool_name, callback_context, **kwargs)
and returns a ProgressFnT or None. This allows different tools to have different
progress handling logic, and the factory can access and modify session state
via the CallbackContext. The **kwargs ensures forward compatibility for future
parameters.
IMPORTANT: Progress callbacks only work when the MCP server actually sends
progress notifications. Most simple MCP servers (like the filesystem server)
do not send progress updates. This sample uses a mock server that demonstrates
progress reporting.
Usage:
adk run contributing/samples/mcp_progress_callback_agent
Then try:
"Run the long running task with 5 steps"
"Process these items: apple, banana, cherry"
"""
import os
import sys
from typing import Any
from google.adk.agents.callback_context import CallbackContext
from google.adk.agents.llm_agent import LlmAgent
from google.adk.tools.mcp_tool import McpToolset
from google.adk.tools.mcp_tool import StdioConnectionParams
from mcp import StdioServerParameters
from mcp.shared.session import ProgressFnT
_current_dir = os.path.dirname(os.path.abspath(__file__))
_mock_server_path = os.path.join(_current_dir, "mock_progress_server.py")
# Option 1: Simple shared callback
async def simple_progress_callback(
progress: float,
total: float | None,
message: str | None,
) -> None:
"""Handle progress notifications from MCP server.
This callback is shared by all tools in the toolset.
"""
if total is not None:
percentage = (progress / total) * 100
bar_length = 20
filled = int(bar_length * progress / total)
bar = "=" * filled + "-" * (bar_length - filled)
print(f"[{bar}] {percentage:.0f}% ({progress}/{total}) {message or ''}")
else:
print(f"Progress: {progress} {f'- {message}' if message else ''}")
# Option 2: Factory function for per-tool callbacks with runtime context
def progress_callback_factory(
tool_name: str,
*,
callback_context: CallbackContext | None = None,
**kwargs: Any,
) -> ProgressFnT | None:
"""Create a progress callback for a specific tool.
This factory allows different tools to have different progress handling.
It receives a CallbackContext for accessing and modifying runtime information
like session state. The **kwargs parameter ensures forward compatibility.
Args:
tool_name: The name of the MCP tool.
callback_context: The callback context providing access to session,
state, artifacts, and other runtime information. Allows modifying
state via ctx.state['key'] = value. May be None if not available.
**kwargs: Additional keyword arguments for future extensibility.
Returns:
A progress callback function, or None if no callback is needed.
"""
# Example: Access session info from context (if available)
session_id = "unknown"
if callback_context and callback_context.session:
session_id = callback_context.session.id
async def callback(
progress: float,
total: float | None,
message: str | None,
) -> None:
# Include tool name and session info in the progress output
prefix = f"[{tool_name}][session:{session_id}]"
if total is not None:
percentage = (progress / total) * 100
bar_length = 20
filled = int(bar_length * progress / total)
bar = "=" * filled + "-" * (bar_length - filled)
print(f"{prefix} [{bar}] {percentage:.0f}% {message or ''}")
# Example: Store progress in state (callback_context allows modification)
if callback_context:
callback_context.state["last_progress"] = progress
callback_context.state["last_total"] = total
else:
print(
f"{prefix} Progress: {progress} {f'- {message}' if message else ''}"
)
return callback
root_agent = LlmAgent(
model="gemini-2.5-flash",
name="progress_demo_agent",
instruction="""\
You are a helpful assistant that can run long-running tasks.
Available tools:
- long_running_task: Simulates a task with multiple steps. You can specify
the number of steps and delay between them.
- process_items: Processes a list of items one by one with progress updates.
When the user asks you to run a task, use these tools and the progress
will be logged automatically.
Example requests:
- "Run a long task with 5 steps"
- "Process these items: apple, banana, cherry, date"
""",
tools=[
McpToolset(
connection_params=StdioConnectionParams(
server_params=StdioServerParameters(
command=sys.executable, # Use current Python interpreter
args=[_mock_server_path],
),
timeout=60,
),
# Use factory function for per-tool callbacks (Option 2)
# Or use simple_progress_callback for shared callback (Option 1)
progress_callback=progress_callback_factory,
)
],
)
@@ -0,0 +1,161 @@
# 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.
"""Mock MCP server that sends progress notifications.
This server demonstrates how MCP servers can send progress updates
during long-running tool execution.
Run this server directly:
python mock_progress_server.py
Or use it with the sample agent:
See agent_with_mock_server.py
"""
import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent
from mcp.types import Tool
server = Server("mock-progress-server")
@server.list_tools()
async def list_tools() -> list[Tool]:
"""List available tools."""
return [
Tool(
name="long_running_task",
description=(
"A simulated long-running task that reports progress. "
"Use this to test progress callback functionality."
),
inputSchema={
"type": "object",
"properties": {
"steps": {
"type": "integer",
"description": "Number of steps to simulate (default: 5)",
"default": 5,
},
"delay": {
"type": "number",
"description": (
"Delay in seconds between steps (default: 0.5)"
),
"default": 0.5,
},
},
},
),
Tool(
name="process_items",
description="Process a list of items with progress reporting.",
inputSchema={
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"type": "string"},
"description": "List of items to process",
},
},
"required": ["items"],
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
"""Handle tool calls with progress reporting."""
ctx = server.request_context
if name == "long_running_task":
steps = arguments.get("steps", 5)
delay = arguments.get("delay", 0.5)
# Get progress token from request metadata
progress_token = None
if ctx.meta and hasattr(ctx.meta, "progressToken"):
progress_token = ctx.meta.progressToken
for i in range(steps):
# Simulate work
await asyncio.sleep(delay)
# Send progress notification if client supports it
if progress_token is not None:
await ctx.session.send_progress_notification(
progress_token=progress_token,
progress=i + 1,
total=steps,
message=f"Completed step {i + 1} of {steps}",
)
return [
TextContent(
type="text",
text=f"Successfully completed {steps} steps!",
)
]
elif name == "process_items":
items = arguments.get("items", [])
total = len(items)
progress_token = None
if ctx.meta and hasattr(ctx.meta, "progressToken"):
progress_token = ctx.meta.progressToken
results = []
for i, item in enumerate(items):
# Simulate processing
await asyncio.sleep(0.3)
results.append(f"Processed: {item}")
# Send progress
if progress_token is not None:
await ctx.session.send_progress_notification(
progress_token=progress_token,
progress=i + 1,
total=total,
message=f"Processing item: {item}",
)
return [
TextContent(
type="text",
text="\n".join(results),
)
]
return [TextContent(type="text", text=f"Unknown tool: {name}")]
async def main():
"""Run the MCP server."""
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
)
if __name__ == "__main__":
asyncio.run(main())
-1
View File
@@ -219,7 +219,6 @@ asyncio_mode = "auto"
python_version = "3.10"
exclude = ["tests/", "contributing/samples/"]
plugins = ["pydantic.mypy"]
# Start with non-strict mode, and swtich to strict mode later.
strict = true
disable_error_code = ["import-not-found", "import-untyped", "unused-ignore"]
follow_imports = "skip"
+4 -2
View File
@@ -59,7 +59,9 @@ def _to_a2a_context_id(app_name: str, user_id: str, session_id: str) -> str:
)
def _from_a2a_context_id(context_id: str) -> tuple[str, str, str]:
def _from_a2a_context_id(
context_id: str | None,
) -> tuple[str, str, str] | tuple[None, None, None]:
"""Converts an A2A context id to app name, user id and session id.
if context_id is None, return None, None, None
if context_id is not None, but not in the format of
@@ -69,7 +71,7 @@ def _from_a2a_context_id(context_id: str) -> tuple[str, str, str]:
context_id: The A2A context id.
Returns:
The app name, user id and session id.
The app name, user id and session id, or (None, None, None) if invalid.
"""
if not context_id:
return None, None, None
+90
View File
@@ -946,6 +946,96 @@ class AdkWebServer:
detail=str(ve),
) from ve
# TODO - remove after migration
@deprecated(
"Please use create_eval_set instead. This will be removed in future"
" releases."
)
@app.post(
"/apps/{app_name}/eval_sets/{eval_set_id}",
response_model_exclude_none=True,
tags=[TAG_EVALUATION],
)
async def create_eval_set_legacy(
app_name: str,
eval_set_id: str,
):
"""Creates an eval set, given the id."""
await create_eval_set(
app_name=app_name,
create_eval_set_request=CreateEvalSetRequest(
eval_set=EvalSet(eval_set_id=eval_set_id, eval_cases=[])
),
)
# TODO - remove after migration
@deprecated(
"Please use list_eval_sets instead. This will be removed in future"
" releases."
)
@app.get(
"/apps/{app_name}/eval_sets",
response_model_exclude_none=True,
tags=[TAG_EVALUATION],
)
async def list_eval_sets_legacy(app_name: str) -> list[str]:
list_eval_sets_response = await list_eval_sets(app_name)
return list_eval_sets_response.eval_set_ids
# TODO - remove after migration
@deprecated(
"Please use run_eval instead. This will be removed in future releases."
)
@app.post(
"/apps/{app_name}/eval_sets/{eval_set_id}/run_eval",
response_model_exclude_none=True,
tags=[TAG_EVALUATION],
)
async def run_eval_legacy(
app_name: str, eval_set_id: str, req: RunEvalRequest
) -> list[RunEvalResult]:
run_eval_response = await run_eval(
app_name=app_name, eval_set_id=eval_set_id, req=req
)
return run_eval_response.run_eval_results
# TODO - remove after migration
@deprecated(
"Please use get_eval_result instead. This will be removed in future"
" releases."
)
@app.get(
"/apps/{app_name}/eval_results/{eval_result_id}",
response_model_exclude_none=True,
tags=[TAG_EVALUATION],
)
async def get_eval_result_legacy(
app_name: str,
eval_result_id: str,
) -> EvalSetResult:
try:
return self.eval_set_results_manager.get_eval_set_result(
app_name, eval_result_id
)
except ValueError as ve:
raise HTTPException(status_code=404, detail=str(ve)) from ve
except ValidationError as ve:
raise HTTPException(status_code=500, detail=str(ve)) from ve
# TODO - remove after migration
@deprecated(
"Please use list_eval_results instead. This will be removed in future"
" releases."
)
@app.get(
"/apps/{app_name}/eval_results",
response_model_exclude_none=True,
tags=[TAG_EVALUATION],
)
async def list_eval_results_legacy(app_name: str) -> list[str]:
list_eval_results_response = await list_eval_results(app_name)
return list_eval_results_response.eval_result_ids
@app.get(
"/apps/{app_name}/eval-sets",
response_model_exclude_none=True,
+2 -2
View File
@@ -1,6 +1,6 @@
<!doctype html>
<!--
Copyright 2025 Google LLC
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.
@@ -30,5 +30,5 @@
<style>html{--mat-sys-corner-extra-large:28px;--mat-sys-corner-extra-large-top:28px 28px 0 0;--mat-sys-corner-extra-small:4px;--mat-sys-corner-extra-small-top:4px 4px 0 0;--mat-sys-corner-full:9999px;--mat-sys-corner-large:16px;--mat-sys-corner-large-end:0 16px 16px 0;--mat-sys-corner-large-start:16px 0 0 16px;--mat-sys-corner-large-top:16px 16px 0 0;--mat-sys-corner-medium:12px;--mat-sys-corner-none:0;--mat-sys-corner-small:8px;--mat-sys-dragged-state-layer-opacity:.16;--mat-sys-focus-state-layer-opacity:.12;--mat-sys-hover-state-layer-opacity:.08;--mat-sys-pressed-state-layer-opacity:.12}html{font-family:Google Sans,Helvetica Neue,sans-serif!important}body{height:100vh;margin:0}</style><link rel="stylesheet" href="./styles-SI5RXIFC.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="./styles-SI5RXIFC.css"></noscript></head>
<body>
<app-root></app-root>
<link rel="modulepreload" href="./chunk-BX7YU7E6.js"><link rel="modulepreload" href="./chunk-W7GRJBO5.js"><script src="./polyfills-5CFQRCPP.js" type="module"></script><script src="./main-AB4G4EO6.js" type="module"></script></body>
<link rel="modulepreload" href="./chunk-BX7YU7E6.js"><link rel="modulepreload" href="./chunk-W7GRJBO5.js"><script src="./polyfills-5CFQRCPP.js" type="module"></script><script src="./main-QQBY56NS.js" type="module"></script></body>
</html>
File diff suppressed because one or more lines are too long
+6
View File
@@ -113,6 +113,12 @@ class CacheMetadata(BaseModel):
f"fingerprint={self.fingerprint[:8]}..."
)
cache_id = self.cache_name.split("/")[-1]
if self.expire_time is None:
return (
f"Cache {cache_id}: used {self.invocations_used} invocations, "
f"cached {self.contents_count} contents, "
"expires unknown"
)
time_until_expiry_minutes = (self.expire_time - time.time()) / 60
return (
f"Cache {cache_id}: used {self.invocations_used} invocations, "
+50 -23
View File
@@ -17,13 +17,11 @@ from __future__ import annotations
from collections.abc import Sequence
import logging
from typing import Callable
from typing import List
from typing import Optional
from google.genai import types
from ..agents.callback_context import CallbackContext
from ..events.event import Event
from ..models.llm_request import LlmRequest
from ..models.llm_response import LlmResponse
from .base_plugin import BasePlugin
@@ -62,21 +60,61 @@ def _adjust_split_index_to_avoid_orphaned_function_responses(
return 0
def _is_function_response_content(content: types.Content) -> bool:
"""Returns whether a content contains function responses."""
return bool(content.parts) and any(
part.function_response is not None for part in content.parts
)
def _is_human_user_content(content: types.Content) -> bool:
"""Returns whether a content represents user input (not tool output)."""
return content.role == "user" and not _is_function_response_content(content)
def _get_invocation_start_indices(
contents: Sequence[types.Content],
) -> list[int]:
"""Returns indices that begin a user-started invocation.
An invocation begins with one or more consecutive user messages. Tool outputs
(function responses) are role="user" but are *not* considered invocation
starts.
Args:
contents: Full conversation contents in chronological order.
Returns:
A list of indices where each index marks the beginning of an invocation.
"""
invocation_start_indices = []
previous_was_human_user = False
for i, content in enumerate(contents):
is_human_user = _is_human_user_content(content)
if is_human_user and not previous_was_human_user:
invocation_start_indices.append(i)
previous_was_human_user = is_human_user
return invocation_start_indices
class ContextFilterPlugin(BasePlugin):
"""A plugin that filters the LLM context to reduce its size."""
def __init__(
self,
num_invocations_to_keep: Optional[int] = None,
custom_filter: Optional[Callable[[List[Event]], List[Event]]] = None,
custom_filter: Optional[
Callable[[list[types.Content]], list[types.Content]]
] = None,
name: str = "context_filter_plugin",
):
"""Initializes the context management plugin.
Args:
num_invocations_to_keep: The number of last invocations to keep. An
invocation is defined as one or more consecutive user messages followed
by a model response.
invocation starts with one or more consecutive user messages and can
contain multiple model turns (e.g. tool calls) until the next user
message starts a new invocation.
custom_filter: A function to filter the context.
name: The name of the plugin instance.
"""
@@ -89,27 +127,16 @@ class ContextFilterPlugin(BasePlugin):
) -> Optional[LlmResponse]:
"""Filters the LLM request's context before it is sent to the model."""
try:
contents = llm_request.contents
contents: list[types.Content] = llm_request.contents
if (
self._num_invocations_to_keep is not None
and self._num_invocations_to_keep > 0
):
num_model_turns = sum(1 for c in contents if c.role == "model")
if num_model_turns >= self._num_invocations_to_keep:
model_turns_to_find = self._num_invocations_to_keep
split_index = 0
for i in range(len(contents) - 1, -1, -1):
if contents[i].role == "model":
model_turns_to_find -= 1
if model_turns_to_find == 0:
start_index = i
while (
start_index > 0 and contents[start_index - 1].role == "user"
):
start_index -= 1
split_index = start_index
break
invocation_start_indices = _get_invocation_start_indices(contents)
if len(invocation_start_indices) > self._num_invocations_to_keep:
split_index = invocation_start_indices[-self._num_invocations_to_keep]
# Adjust split_index to avoid orphaned function_responses.
split_index = (
_adjust_split_index_to_avoid_orphaned_function_responses(
@@ -122,7 +149,7 @@ class ContextFilterPlugin(BasePlugin):
contents = self._custom_filter(contents)
llm_request.contents = contents
except Exception as e:
logger.error(f"Failed to reduce context for request: {e}")
except Exception:
logger.exception("Failed to reduce context for request")
return None
@@ -35,7 +35,6 @@ from sqlalchemy.ext.asyncio import AsyncSession as DatabaseSessionFactory
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import StaticPool
from typing_extensions import override
from tzlocal import get_localzone
from . import _session_util
from ..errors.already_exists_error import AlreadyExistsError
@@ -134,10 +133,6 @@ class DatabaseSessionService(BaseSessionService):
f"Failed to create database engine for URL '{db_url}'"
) from e
# Get the local timezone
local_timezone = get_localzone()
logger.info("Local timezone: %s", local_timezone)
self.db_engine: AsyncEngine = db_engine
# DB session factory method
+4
View File
@@ -52,6 +52,7 @@ from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_A
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_INPUT_TOKENS
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_USAGE_OUTPUT_TOKENS
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GenAiSystemValues
from opentelemetry.semconv._incubating.attributes.user_attributes import USER_ID
from opentelemetry.semconv.schemas import Schemas
from opentelemetry.trace import Span
from opentelemetry.util.types import AnyValue
@@ -438,8 +439,11 @@ def use_generate_content_span(
"""
common_attributes = {
GEN_AI_AGENT_NAME: invocation_context.agent.name,
GEN_AI_CONVERSATION_ID: invocation_context.session.id,
USER_ID: invocation_context.session.user_id,
'gcp.vertex.agent.event_id': model_response_event.id,
'gcp.vertex.agent.invocation_id': invocation_context.invocation_id,
}
if (
_is_gemini_agent(invocation_context.agent)
@@ -0,0 +1,17 @@
# 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 google.adk.tools.agent_simulator.agent_simulator_factory import AgentSimulatorFactory
__all__ = ["AgentSimulator"]
@@ -0,0 +1,158 @@
# 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
import enum
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from google.genai import types as genai_types
from pydantic import BaseModel
from pydantic import Field
from pydantic import field_validator
from pydantic import model_validator
from pydantic_core import ValidationError
class InjectedError(BaseModel):
"""An error to be injected into a tool call."""
injected_http_error_code: int
"""Inject http error code to the tool call. Will present as "error_code"
in the tool response dict."""
error_message: str
"""Inject error message to the tool call. Will present as
"error_message" in the tool response dict."""
class InjectionConfig(BaseModel):
"""Injection configuration for a tool."""
injection_probability: float = 1.0
"""Probability of injecting the injected_value."""
match_args: Optional[Dict[str, Any]] = None
"""Only apply injection if the request matches the match_args.
If match_args is not provided, the injection will be applied to all
requests."""
injected_latency_seconds: float = Field(default=0.0, le=120.0)
"""Inject latency to the tool call. Please note it may not be accurate if │
the interceptor is applied as after tool callback."""
random_seed: Optional[int] = None
"""The random seed to use for this injection."""
injected_error: Optional[InjectedError] = None
"""The injected error."""
injected_response: Optional[Dict[str, Any]] = None
"""The injected response."""
@model_validator(mode="after")
def check_injected_error_or_response(self) -> Self:
"""Checks that either injected_error or injected_response is set."""
if bool(self.injected_error) == bool(self.injected_response):
raise ValueError(
"Either injected_error or injected_response must be set, but not"
" both, and not neither."
)
return self
class MockStrategy(enum.Enum):
"""Mock strategy for a tool."""
MOCK_STRATEGY_UNSPECIFIED = 0
MOCK_STRATEGY_TOOL_SPEC = 1
"""Use tool specifications to mock the tool response."""
MOCK_STRATEGY_TRACING = 2
"""Use provided tracing and tool specifications to mock the tool
response based on llm response. Need to provide tracing path in
command."""
class ToolSimulationConfig(BaseModel):
"""Simulation configuration for a single tool."""
tool_name: str
"""Name of the tool to be simulated."""
injection_configs: List[InjectionConfig] = Field(default_factory=list)
"""Injection configuration for the tool. If provided, the tool will be
injected with the injected_value with the injection_probability first,
the mock_strategy will be applied if no injection config is hit."""
mock_strategy_type: MockStrategy = MockStrategy.MOCK_STRATEGY_UNSPECIFIED
"""The mock strategy to use."""
@model_validator(mode="after")
def check_mock_strategy_type(self) -> Self:
"""Checks that mock_strategy_type is not UNSPECIFIED if no injections."""
if (
not self.injection_configs
and self.mock_strategy_type == MockStrategy.MOCK_STRATEGY_UNSPECIFIED
):
raise ValueError(
"If injection_configs is empty, mock_strategy_type cannot be"
" MOCK_STRATEGY_UNSPECIFIED."
)
return self
class AgentSimulatorConfig(BaseModel):
"""Configuration for AgentSimulator."""
tool_simulation_configs: List[ToolSimulationConfig] = Field(
default_factory=list
)
"""A list of tool simulation configurations."""
simulation_model: str = Field(default="gemini-2.5-flash")
"""The model to use for internal simulator LLM calls (tool analysis, mock responses)."""
simulation_model_configuration: genai_types.GenerateContentConfig = Field(
default_factory=lambda: genai_types.GenerateContentConfig(
thinking_config=genai_types.ThinkingConfig(
include_thoughts=True,
thinking_budget=10240,
)
),
)
"""The configuration for the internal simulator LLM calls."""
tracing_path: Optional[str] = None
"""The path to the tracing file to be used for mocking. Only used if the
mock_strategy_type is MOCK_STRATEGY_TRACING."""
@field_validator("tool_simulation_configs")
@classmethod
def check_tool_simulation_configs(cls, v: List[ToolSimulationConfig]):
"""Checks that tool_simulation_configs is not empty."""
if not v:
raise ValueError("tool_simulation_configs must be provided.")
seen_tool_names = set()
for tool_sim_config in v:
if tool_sim_config.tool_name in seen_tool_names:
raise ValueError(
f"Duplicate tool_name found: {tool_sim_config.tool_name}"
)
seen_tool_names.add(tool_sim_config.tool_name)
return v
@@ -0,0 +1,132 @@
# 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
import asyncio
import concurrent.futures
import logging
import random
import time
from typing import Any
from typing import Dict
from typing import Optional
agent_simulator_logger = logging.getLogger("agent_simulator_logger")
from google.adk.agents.llm_agent import LlmAgent
from google.adk.tools.agent_simulator.agent_simulator_config import AgentSimulatorConfig
from google.adk.tools.agent_simulator.agent_simulator_config import MockStrategy as MockStrategyEnum
from google.adk.tools.agent_simulator.agent_simulator_config import ToolSimulationConfig
from google.adk.tools.agent_simulator.strategies import base as base_mock_strategies
from google.adk.tools.agent_simulator.strategies import tool_spec_mock_strategy
from google.adk.tools.agent_simulator.tool_connection_analyzer import ToolConnectionAnalyzer
from google.adk.tools.agent_simulator.tool_connection_map import ToolConnectionMap
from google.adk.tools.base_tool import BaseTool
def _create_mock_strategy(
mock_strategy_type: MockStrategyEnum,
llm_name: str,
llm_config: genai_types.GenerateContentConfig,
) -> base_mock_strategies.MockStrategy:
"""Creates a mock strategy based on the given type."""
if mock_strategy_type == MockStrategyEnum.MOCK_STRATEGY_TOOL_SPEC:
return tool_spec_mock_strategy.ToolSpecMockStrategy(llm_name, llm_config)
if mock_strategy_type == MockStrategyEnum.MOCK_STRATEGY_TRACING:
return base_mock_strategies.TracingMockStrategy()
raise ValueError(f"Unknown mock strategy type: {mock_strategy_type}")
class AgentSimulatorEngine:
"""Core engine to handle the simulation logic."""
def __init__(self, config: AgentSimulatorConfig):
self._config = config
self._tool_sim_configs = {
c.tool_name: c for c in config.tool_simulation_configs
}
self._is_analyzed = False
self._tool_connection_map: Optional[ToolConnectionMap] = None
self._analyzer = ToolConnectionAnalyzer(
llm_name=config.simulation_model,
llm_config=config.simulation_model_configuration,
)
self._state_store = {}
self._random_generator = random.Random()
async def simulate(
self, tool: BaseTool, args: Dict[str, Any], tool_context: Any
) -> Optional[Dict[str, Any]]:
"""Simulates a tool call."""
if tool.name not in self._tool_sim_configs:
return None
tool_sim_config = self._tool_sim_configs[tool.name]
if not self._is_analyzed and any(
c.mock_strategy_type != MockStrategyEnum.MOCK_STRATEGY_UNSPECIFIED
for c in self._config.tool_simulation_configs
):
agent = tool_context._invocation_context.agent
if isinstance(agent, LlmAgent):
tools = await agent.canonical_tools(tool_context)
self._tool_connection_map = await self._analyzer.analyze(tools)
self._is_analyzed = True
for injection_config in tool_sim_config.injection_configs:
if injection_config.match_args:
if not all(
item in args.items() for item in injection_config.match_args.items()
):
continue
if injection_config.random_seed is not None:
self._random_generator.seed(injection_config.random_seed)
if (
self._random_generator.random()
< injection_config.injection_probability
):
time.sleep(injection_config.injected_latency_seconds)
if injection_config.injected_error:
return {
"error_code": (
injection_config.injected_error.injected_http_error_code
),
"error_message": injection_config.injected_error.error_message,
}
if injection_config.injected_response:
return injection_config.injected_response
# If no injection was applied, fall back to the mock strategy.
if (
tool_sim_config.mock_strategy_type
== MockStrategyEnum.MOCK_STRATEGY_UNSPECIFIED
):
agent_simulator_logger.warning(
"Tool '%s' did not hit any injection config and has no mock strategy"
" configured. Returning no-op.",
tool.name,
)
return None
mock_strategy = _create_mock_strategy(
tool_sim_config.mock_strategy_type,
self._config.simulation_model,
self._config.simulation_model_configuration,
)
return await mock_strategy.mock(
tool, args, tool_context, self._tool_connection_map, self._state_store
)
@@ -0,0 +1,71 @@
# 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 typing import Any
from typing import Awaitable
from typing import Callable
from typing import Dict
from typing import Optional
from google.adk.tools.agent_simulator.agent_simulator_config import AgentSimulatorConfig
from google.adk.tools.agent_simulator.agent_simulator_engine import AgentSimulatorEngine
from google.adk.tools.agent_simulator.agent_simulator_plugin import AgentSimulatorPlugin
from google.adk.tools.base_tool import BaseTool
from ...utils.feature_decorator import experimental
@experimental
class AgentSimulatorFactory:
"""Factory for creating AgentSimulator instances."""
@staticmethod
def create_callback(
config: AgentSimulatorConfig,
) -> Callable[
[BaseTool, Dict[str, Any], Any], Awaitable[Optional[Dict[str, Any]]]
]:
"""Creates a callback function for AgentSimulator.
Args:
config: The configuration for the AgentSimulator.
Returns:
A callable that can be used as a before_tool_callback or after_tool_callback.
"""
simulator_engine = AgentSimulatorEngine(config)
async def _agent_simulator_callback(
tool: BaseTool, args: Dict[str, Any], tool_context: Any
) -> Optional[Dict[str, Any]]:
return await simulator_engine.simulate(tool, args, tool_context)
return _agent_simulator_callback
@staticmethod
def create_plugin(
config: AgentSimulatorConfig,
) -> AgentSimulatorPlugin:
"""Creates an ADK Plugin for AgentSimulator.
Args:
config: The configuration for the AgentSimulator.
Returns:
An instance of AgentSimulatorPlugin that can be used as an ADK plugin.
"""
simulator_engine = AgentSimulatorEngine(config)
return AgentSimulatorPlugin(simulator_engine)
@@ -0,0 +1,40 @@
# Copyright 2024 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 typing import Any
from typing import Dict
from typing import Optional
from google.adk.plugins import BasePlugin
from google.adk.tools.agent_simulator.agent_simulator_config import AgentSimulatorConfig
from google.adk.tools.agent_simulator.agent_simulator_engine import AgentSimulatorEngine
from google.adk.tools.base_tool import BaseTool
from google.adk.tools.tool_context import ToolContext
class AgentSimulatorPlugin(BasePlugin):
"""ADK Plugin for AgentSimulator."""
name: str = "AgentSimulator"
def __init__(self, simulator_engine: AgentSimulatorEngine):
self._simulator_engine = simulator_engine
async def before_tool_callback(
self, tool: BaseTool, tool_args: dict[str, Any], tool_context: ToolContext
) -> Optional[Dict[str, Any]]:
"""Invokes the AgentSimulatorEngine before a tool call."""
return await self._simulator_engine.simulate(tool, tool_args, tool_context)
@@ -0,0 +1,13 @@
# 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.
@@ -0,0 +1,57 @@
# 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 typing import Any
from typing import Dict
from typing import Optional
from google.adk.tools.agent_simulator.tool_connection_map import ToolConnectionMap
class MockStrategy:
"""Base class for mock strategies."""
async def mock(
self,
tool: BaseTool,
args: Dict[str, Any],
tool_context: Any,
tool_connection_map: Optional[ToolConnectionMap],
state_store: Dict[str, Any],
) -> Dict[str, Any]:
"""Generates a mock response for a tool call."""
raise NotImplementedError()
class TracingMockStrategy(MockStrategy):
"""Mocks a tool response based on tracing and an LLM."""
def __init__(
self, llm_name: str, llm_config: genai_types.GenerateContentConfig
):
self._llm_name = llm_name
self._llm_config = llm_config
async def mock(
self,
tool: BaseTool,
args: Dict[str, Any],
tool_context: Any,
tool_connection_map: Optional[ToolConnectionMap],
state_store: Dict[str, Any],
) -> Dict[str, Any]:
# TODO: Implement tracing LLM-based mocking.
return {"status": "error", "error_message": "Not implemented"}

Some files were not shown because too many files have changed in this diff Show More