Compare commits

...

13 Commits

Author SHA1 Message Date
Sasha Sobran e172811bc7 fix: unbreak client closed errors when using vertexai session service
PiperOrigin-RevId: 811911528
2025-09-26 12:16:37 -07:00
Xuan Yang da6f1d3653 chore: Release ADK 1.15.0
PiperOrigin-RevId: 811655912
2025-09-25 22:17:23 -07:00
Shangjie Chen 2c752934a8 feat: Skip running a workflow agent if it has no sub-agents
PiperOrigin-RevId: 811528166
2025-09-25 15:39:38 -07:00
Xinran (Sherry) Tang b2b80e7fa0 feat: Pause invocations on long running function calls for resumable apps
PiperOrigin-RevId: 811518771
2025-09-25 15:11:11 -07:00
Xuan Yang dd1ffad394 chore: Update google-genai version constraint
Fixes https://github.com/google/adk-python/issues/2968

PiperOrigin-RevId: 811475972
2025-09-25 13:21:45 -07:00
Shangjie Chen 8b081751ed feat: Add core checkpointing primitive for base agent
PiperOrigin-RevId: 811458903
2025-09-25 12:35:36 -07:00
Xinran (Sherry) Tang b5a65fb4f4 chore: Remove the too-detailed edge case descriptions for resumability
PiperOrigin-RevId: 811432962
2025-09-25 11:32:38 -07:00
Shangjie Chen 839d2e43bb feat: Define an AgentState to be used for resuming agent invocation
PiperOrigin-RevId: 811414736
2025-09-25 10:49:49 -07:00
Xiang (Sean) Zhou 1589fcdd86 chore: Replace github HTTP URIs with GCS HTTP URIs in static non-text content sample agent
mainly because http://github.com/robots.txt disallows `/*/raw/` path. using GCS HTTP URIs is more reliable with Gemini model.

PiperOrigin-RevId: 811409688
2025-09-25 10:38:04 -07:00
Max Ind e7528aebd4 feat(otel): adjust telemetry to follow OTLP 1.37 GenAI semconv
Changes include:
- Implementing missing attributes. e.g. 'gen_ai.agent.name'
- Specifying reasons for not filling out some conditionally required attributes. e.g. 'gen_ai.data_source.id'
- Specifying reasons for not including certain attributes which are specified in current semconv. e.g. inference attributes on agent spans

PiperOrigin-RevId: 811379706
2025-09-25 09:25:15 -07:00
Xinran (Sherry) Tang cbb6e4945a feat: Add a app level config for resumable applications
PiperOrigin-RevId: 811272046
2025-09-25 03:14:34 -07:00
Xiang (Sean) Zhou c6b6b6f3c6 chore: Add log-level parameter to cache analysis experiments
this is to allow turning on debug log for debugging if some unexpected behavior observed during running cache analysis experiments.

PiperOrigin-RevId: 811189954
2025-09-24 22:44:41 -07:00
Google Team Member c8c6cd70a4 feat: Introduce ExtendedOAuth2 scheme that auto-populates auth/token URLs
Use auto-discovered auth_endpoint and token_endpoint in CredentialManager.

PiperOrigin-RevId: 811183929
2025-09-24 22:21:07 -07:00
33 changed files with 1539 additions and 104 deletions
+55
View File
@@ -1,5 +1,60 @@
# Changelog
## [1.15.0](https://github.com/google/adk-python/compare/v1.14.1...v1.15.0) (2025-09-24)
### Features
* [Core]
* Adding the ContextFilterPlugin ([a06bf27](https://github.com/google/adk-python/commit/a06bf278cbc89f521c187ed51b032d82ffdafe2d))
* Adds plugin to save artifacts for issue [#2176](https://github.com/google/adk-python/issues/2176) ([657369c](https://github.com/google/adk-python/commit/657369cffe142ef3745cd5950d0d24a49f42f7fd))
* Expose log probs of candidates in LlmResponse ([f7bd3c1](https://github.com/google/adk-python/commit/f7bd3c111c211e880d7c1954dd4508b952704c68))
* [Context Caching]
* Support context caching ([c66245a](https://github.com/google/adk-python/commit/c66245a3b80192c16cb67ee3194f82c9a7c901e5))
- Support explicit context caching auto creation and lifecycle management.
Usage: `App(root_agent=..., plugins=..., context_cache_config=...)`
* Support non-text content in static instruction ([61213ce](https://github.com/google/adk-python/commit/61213ce4d4c10f7ecaf6ddb521672059cee27942))
* Support static instructions ([9be9cc2](https://github.com/google/adk-python/commit/9be9cc2feee92241fd2fbf9dea3a42de5a78e9ce))
- Support static instruction that won't change, put at the beginning of
the instruction.
Static instruction support inline_data and file_data as contents.
Dynamic instruction moved to the end of LlmRequest, increasing prefix
caching matching size.
Usage:
`LlmAgent(model=...,static_instruction =types.Content(parts=...), ... )`
* [Telemetry]
* Add --otel_to_cloud experimental support ([1ae0b82](https://github.com/google/adk-python/commit/1ae0b82f5602a57ad1ca975ca0b7c85003d1a28a), [b131268](https://github.com/google/adk-python/commit/b1312680f4ea9f21c3246a1d24392619643d71f5), [7870480](https://github.com/google/adk-python/commit/7870480c63bb4fc08cfb3cabc0e1f0458f0e85bd))
* Add GenAI Instrumentation if --otel_to_cloud is enabled ([cee365a](https://github.com/google/adk-python/commit/cee365a13d0d1b1f2be046c1cc29e24a8d1fdbcc))
* Support standard OTel env variables for exporter endpoints ([f157b2e](https://github.com/google/adk-python/commit/f157b2ee4caf4055e78f4657254e45913895f5de))
* Temporarily disable Cloud Monitoring integration in --otel_to_cloud ([3b80337](https://github.com/google/adk-python/commit/3b80337faf427460e4743e25dbb92578f823513f))
* [Services]
* Add endpoint to generate memory from session ([2595824](https://github.com/google/adk-python/commit/25958242db890b4d2aac8612f7f7cfbb561727fa))
* [Tools]
* Add Google Maps Grounding Tool to ADK ([6b49391](https://github.com/google/adk-python/commit/6b493915469ecb42068e24818ab547b0856e4709))
* **MCP:** Initialize tool_name_prefix in MCPToolse ([86dea5b](https://github.com/google/adk-python/commit/86dea5b53ac305367283b7e353b60d0f4515be3b))
* [Evals]
* Data model for storing App Details and data model for steps ([01923a9](https://github.com/google/adk-python/commit/01923a9227895906ca8ae32712d65b178e2cd7d5))
* Adds Rubric based final response evaluator ([5a485b0](https://github.com/google/adk-python/commit/5a485b01cd64cb49735e13ebd5e7fa3da02cd85f))
* Populate AppDetails to each Invocation ([d486795](https://github.com/google/adk-python/commit/d48679582de91050ca9c5106402319be9a8ae7e8))
* [Samples]
* Make the bigquery sample agent run with ADC out-of-the-box ([10cf377](https://github.com/google/adk-python/commit/10cf37749417856e394e62896231e41b13420f18))
### Bug Fixes
* Close runners after running eval ([86ee6e3](https://github.com/google/adk-python/commit/86ee6e3fa3690148d60358fc3dacb0e0ab40942b))
* Filter out thought parts when saving agent output to state ([632bf8b](https://github.com/google/adk-python/commit/632bf8b0bcf18ff4e4505e4e5f4c626510f366a2))
* Ignore empty function chunk in LiteLlm streaming response ([8a92fd1](https://github.com/google/adk-python/commit/8a92fd18b600da596c22fd80c6148511a136dfd0))
* Introduces a `raw_mcp_tool` method in `McpTool` to provide direct access to the underlying MCP tool ([6158075](https://github.com/google/adk-python/commit/6158075a657f8fe0835679e509face6191905403))
* Make a copy of the `columns` instead of modifying it in place ([aef1ee9](https://github.com/google/adk-python/commit/aef1ee97a55a310f3959d475b8d7d6bc3915ae48))
* Prevent escaping of Latin characters in LLM response ([c9ea80a](https://github.com/google/adk-python/commit/c9ea80af28e586c9cc1f643b365cdba82f80c700))
* Retain the consumers and transport registry when recreating the ClientFactory in remote_a2a_agent.py ([6bd33e1](https://github.com/google/adk-python/commit/6bd33e1be36f741a6ed0514197550f9f336262ed))
* Remove unsupported 'type': 'unknown' in test_common.py for fastapi 0.117.1 ([3745221](https://github.com/google/adk-python/commit/374522197fa6843f786bfd12d17ce0fc20461dfd))
### Documentation
* Correct the documentation of `after_agent_callback` ([b9735b2](https://github.com/google/adk-python/commit/b9735b2193267645781b268231d63c23c6fec654))
## [1.14.1](https://github.com/google/adk-python/compare/v1.14.0...v1.14.1) (2025-09-12)
### Bug Fixes
@@ -25,6 +25,7 @@ import argparse
import asyncio
import copy
import json
import logging
import sys
import time
from typing import Any
@@ -42,6 +43,7 @@ except ImportError:
from utils import get_test_prompts
from utils import run_experiment_batch
from google.adk.cli.utils import logs
from google.adk.runners import InMemoryRunner
from google.adk.utils.cache_performance_analyzer import CachePerformanceAnalyzer
@@ -570,9 +572,19 @@ async def main():
" 2.0)"
),
)
parser.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
default="INFO",
help="Set logging level (default: INFO)",
)
args = parser.parse_args()
# Setup logger with specified level
log_level = getattr(logging, args.log_level.upper())
logs.setup_adk_logger(log_level)
# Set default output filename based on model
if not args.output:
args.output = (
@@ -9,7 +9,7 @@ This sample demonstrates ADK's static instruction feature with non-text content
- **Gemini Files API integration**: Demonstrates uploading documents and using file_data
- **Mixed content types**: inline_data for images, file_data for documents
- **API variant detection**: Different behavior for Gemini API vs Vertex AI
- **GCS file references**: Additional GCS file support when using Vertex AI
- **GCS file references**: Support for both GCS URI and HTTPS URL access methods in Vertex AI
## Static Instruction Content
@@ -23,7 +23,7 @@ The agent includes:
**Vertex AI:**
3. **Research paper**: Gemma research paper from Google Cloud Storage via GCS file reference
4. **Contributing guide**: Gemini Cookbook contributing guide from GitHub via HTTPS file reference
4. **AI research paper**: Same research paper accessed via HTTPS URL for comparison
## Content Used
@@ -37,14 +37,14 @@ The agent includes:
- Files are automatically cleaned up after 48 hours by the Gemini API
**Vertex AI:**
- **Research Paper**: Gemma research paper (GCS file reference as `file_data`)
- Public GCS URI: `gs://cloud-samples-data/generative-ai/pdf/2403.05530.pdf`
- Demonstrates GCS file access in Vertex AI
- PDF format with technical AI research content
- **Contributing Guide**: Gemini Cookbook contributing guide (HTTPS file reference as `file_data`)
- Public GitHub URL: `https://raw.githubusercontent.com/google-gemini/cookbook/main/CONTRIBUTING.md`
- **Gemma Research Paper**: Research paper accessed via GCS URI (as `file_data`)
- GCS URI: `gs://cloud-samples-data/generative-ai/pdf/2403.05530.pdf`
- Demonstrates native GCS file access in Vertex AI
- PDF format with technical AI research content about Gemini 1.5
- **AI Research Paper**: Same research paper accessed via HTTPS URL (as `file_data`)
- HTTPS URL: `https://storage.googleapis.com/cloud-samples-data/generative-ai/pdf/2403.05530.pdf`
- Demonstrates HTTPS file access in Vertex AI
- Markdown format with development guidelines
- Agent can discover these are the same document and compare access methods
## Setup
@@ -73,7 +73,9 @@ The agent will automatically load environment variables on startup.
cd contributing/samples
python -m static_non_text_content.main
```
This runs 4 test prompts that specifically demonstrate the static content features.
This runs test prompts that demonstrate the static content features:
- **Gemini Developer API**: 4 prompts testing inline_data + Files API upload
- **Vertex AI**: 5 prompts testing inline_data + GCS/HTTPS file access comparison
### Interactive Mode
```bash
@@ -101,13 +103,17 @@ The sample automatically runs test prompts when no `--prompt` is specified:
**All API variants:**
1. "What reference materials do you have access to?"
2. "Can you describe the sample chart that was provided to you?"
3. "What does the contributing guide document say about best practices?"
4. "How do the inline image and file references in your instructions help you answer questions?"
3. "How do the inline image and file references in your instructions help you answer questions?"
**Vertex AI only (additional prompt):**
**Gemini Developer API only:**
4. "What does the contributing guide document say about best practices?"
**Vertex AI only (additional prompts):**
5. "What is the Gemma research paper about and what are its key contributions?"
6. "Can you compare the research papers you have access to? Are they related or different?"
These prompts test `inline_data`, Files API `file_data` (Gemini API), and GCS/HTTPS `file_data` (Vertex AI).
**Gemini Developer API** tests: `inline_data` (image) + Files API `file_data` (uploaded document)
**Vertex AI** tests: `inline_data` (image) + GCS URI `file_data` + HTTPS URL `file_data` (same document via different access methods)
## How It Works
@@ -75,7 +75,7 @@ def create_static_instruction_with_file_upload():
file_data_parts = []
if api_variant == GoogleLLMVariant.VERTEX_AI:
print("Using Vertex AI - adding GCS and GitHub file references")
print("Using Vertex AI - adding GCS URI and HTTPS URL references")
# Add GCS file reference
file_data_parts.append(
@@ -90,20 +90,20 @@ def create_static_instruction_with_file_upload():
)
)
# Add GitHub public file reference
# Add the same document via HTTPS URL to demonstrate both access methods
file_data_parts.append(
types.Part(
file_data=types.FileData(
file_uri="https://raw.githubusercontent.com/google-gemini/cookbook/main/CONTRIBUTING.md",
mime_type="text/markdown",
display_name="Gemini Cookbook Contributing Guide",
file_uri="https://storage.googleapis.com/cloud-samples-data/generative-ai/pdf/2403.05530.pdf",
mime_type="application/pdf",
display_name="AI Research Paper (HTTPS)",
)
)
)
additional_text = (
" You also have access to the Gemma research paper from Google Cloud"
" Storage and the Gemini Cookbook contributing guide from GitHub."
" You also have access to a Gemma research paper from GCS"
" and an AI research paper from HTTPS URL."
)
else:
@@ -187,8 +187,8 @@ def create_static_instruction_with_file_upload():
instruction_text = """
When users ask questions, you should:
1. Use the reference chart above to provide context when discussing visual data or charts
2. Reference the Gemma research paper when discussing AI research, model architectures, or technical details
3. Reference the Gemini Cookbook contributing guide when explaining best practices and guidelines
2. Reference the Gemma research paper (from GCS) when discussing AI research, model architectures, or technical details
3. Reference the AI research paper (from HTTPS) when discussing research topics
4. Be helpful and informative in your responses
5. Explain how the provided reference materials relate to their questions"""
else:
@@ -115,22 +115,33 @@ async def run_default_test_prompts(runner):
app_name=APP_NAME, user_id=USER_ID
)
# Test prompts that specifically exercise the static content features
# Common test prompts for all API variants
test_prompts = [
"What reference materials do you have access to?",
"Can you describe the sample chart that was provided to you?",
"What does the contributing guide document say about best practices?",
(
"How do the inline image and file references in your instructions "
"help you answer questions?"
),
]
# Add Vertex AI specific prompt to test GCS file reference
# Add API-specific prompts
if api_variant == GoogleLLMVariant.VERTEX_AI:
# Vertex AI has research papers instead of contributing guide
test_prompts.extend([
(
"What is the Gemma research paper about and what are its key "
"contributions?"
),
(
"Can you compare the research papers you have access to? Are they "
"related or different?"
),
])
else:
# Gemini Developer API has contributing guide document
test_prompts.append(
"What is the Gemma research paper about and what are its key "
"contributions?"
"What does the contributing guide document say about best practices?"
)
for i, prompt in enumerate(test_prompts, 1):
+3 -3
View File
@@ -38,16 +38,16 @@ dependencies = [
"google-cloud-spanner>=3.56.0, <4.0.0", # For Spanner database
"google-cloud-speech>=2.30.0, <3.0.0", # For Audio Transcription
"google-cloud-storage>=2.18.0, <3.0.0", # For GCS Artifact service
"google-genai>=1.21.1, <2.0.0", # Google GenAI SDK
"google-genai>=1.21.1, <=1.40.0, !=1.37.0, !=1.38.0, !=1.39.0", # Google GenAI SDK
"graphviz>=0.20.2, <1.0.0", # Graphviz for graph rendering
"mcp>=1.8.0, <2.0.0;python_version>='3.10'", # For MCP Toolset
"opentelemetry-api>=1.31.0, <=1.37.0", # OpenTelemetry - limit upper version for sdk and api to not risk breaking changes from unstable _logs package.
"opentelemetry-api>=1.37.0, <=1.37.0", # OpenTelemetry - limit upper version for sdk and api to not risk breaking changes from unstable _logs package.
"opentelemetry-exporter-gcp-logging>=1.9.0a0, <2.0.0",
"opentelemetry-exporter-gcp-monitoring>=1.9.0a0, <2.0.0",
"opentelemetry-exporter-gcp-trace>=1.9.0, <2.0.0",
"opentelemetry-exporter-otlp-proto-http>=1.36.0",
"opentelemetry-resourcedetector-gcp>=1.9.0a0, <2.0.0",
"opentelemetry-sdk>=1.31.0, <=1.37.0",
"opentelemetry-sdk>=1.37.0, <=1.37.0",
"pydantic>=2.0, <3.0.0", # For data validation/models
"python-dateutil>=2.9.0.post0, <3.0.0", # For Vertext AI Session Service
"python-dotenv>=1.0.0, <2.0.0", # To manage environment variables
+70 -8
View File
@@ -30,7 +30,6 @@ from typing import TypeVar
from typing import Union
from google.genai import types
from opentelemetry import trace
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field
@@ -39,17 +38,17 @@ from typing_extensions import override
from typing_extensions import TypeAlias
from ..events.event import Event
from ..events.event_actions import EventActions
from ..telemetry import tracing
from ..telemetry.tracing import tracer
from ..utils.context_utils import Aclosing
from ..utils.feature_decorator import experimental
from .base_agent_config import BaseAgentConfig
from .callback_context import CallbackContext
from .common_configs import AgentRefConfig
if TYPE_CHECKING:
from .invocation_context import InvocationContext
tracer = trace.get_tracer('gcp.vertex.agent')
_SingleAgentCallback: TypeAlias = Callable[
[CallbackContext],
Union[Awaitable[Optional[types.Content]], Optional[types.Content]],
@@ -68,6 +67,18 @@ AfterAgentCallback: TypeAlias = Union[
SelfAgent = TypeVar('SelfAgent', bound='BaseAgent')
@experimental
class BaseAgentState(BaseModel):
"""Base class for all agent states."""
model_config = ConfigDict(
extra='forbid',
)
AgentState = TypeVar('AgentState', bound=BaseAgentState)
class BaseAgent(BaseModel):
"""Base class for all agents in Agent Development Kit."""
@@ -148,6 +159,57 @@ class BaseAgent(BaseModel):
response and appended to event history as agent response.
"""
def _load_agent_state(
self,
ctx: InvocationContext,
state_type: Type[AgentState],
default_state: AgentState,
) -> tuple[AgentState, bool]:
"""Loads the agent state from the invocation context, handling resumption.
Args:
ctx: The invocation context.
state_type: The type of the agent state.
default_state: The default state to use if not resuming.
Returns:
tuple[AgentState, bool]: The current state and a boolean indicating if
resuming.
"""
if self.name not in ctx.agent_states:
return default_state, False
else:
return state_type.model_validate(ctx.agent_states.get(self.name)), True
def _create_agent_state_event(
self,
ctx: InvocationContext,
*,
state: Optional[BaseAgentState] = None,
end_of_agent: bool = False,
) -> Event:
"""Creates an event for agent state.
Args:
ctx: The invocation context.
state: The agent state to checkpoint.
end_of_agent: Whether the agent is finished running.
Returns:
An Event object representing the checkpoint.
"""
event_actions = EventActions()
if state:
event_actions.agent_state = state.model_dump(mode='json')
if end_of_agent:
event_actions.end_of_agent = True
return Event(
invocation_id=ctx.invocation_id,
author=self.name,
branch=ctx.branch,
actions=event_actions,
)
def clone(
self: SelfAgent, update: Mapping[str, Any] | None = None
) -> SelfAgent:
@@ -226,9 +288,9 @@ class BaseAgent(BaseModel):
"""
async def _run_with_trace() -> AsyncGenerator[Event, None]:
with tracer.start_as_current_span(f'agent_run [{self.name}]'):
with tracer.start_as_current_span(f'invoke_agent {self.name}') as span:
ctx = self._create_invocation_context(parent_context)
tracing.trace_agent_invocation(span, self, ctx)
if event := await self.__handle_before_agent_callback(ctx):
yield event
if ctx.end_invocation:
@@ -264,9 +326,9 @@ class BaseAgent(BaseModel):
"""
async def _run_with_trace() -> AsyncGenerator[Event, None]:
with tracer.start_as_current_span(f'agent_run [{self.name}]'):
with tracer.start_as_current_span(f'invoke_agent {self.name}') as span:
ctx = self._create_invocation_context(parent_context)
tracing.trace_agent_invocation(span, self, ctx)
if event := await self.__handle_before_agent_callback(ctx):
yield event
if ctx.end_invocation:
+55 -3
View File
@@ -14,8 +14,8 @@
from __future__ import annotations
from typing import Any
from typing import Optional
from typing import TYPE_CHECKING
import uuid
from google.genai import types
@@ -24,6 +24,7 @@ from pydantic import ConfigDict
from pydantic import Field
from pydantic import PrivateAttr
from ..apps.app import ResumabilityConfig
from ..artifacts.base_artifact_service import BaseArtifactService
from ..auth.credential_service.base_credential_service import BaseCredentialService
from ..events.event import Event
@@ -31,7 +32,6 @@ from ..memory.base_memory_service import BaseMemoryService
from ..plugins.plugin_manager import PluginManager
from ..sessions.base_session_service import BaseSessionService
from ..sessions.session import Session
from ..utils.feature_decorator import working_in_progress
from .active_streaming_tool import ActiveStreamingTool
from .base_agent import BaseAgent
from .context_cache_config import ContextCacheConfig
@@ -163,6 +163,12 @@ class InvocationContext(BaseModel):
session: Session
"""The current session of this invocation context. Readonly."""
agent_states: dict[str, dict[str, Any]] = Field(default_factory=dict)
"""The state of the agent for this invocation."""
end_of_agents: dict[str, bool] = Field(default_factory=dict)
"""The end of agent status for each agent in this invocation."""
end_invocation: bool = False
"""Whether to end this invocation.
@@ -189,6 +195,9 @@ class InvocationContext(BaseModel):
run_config: Optional[RunConfig] = None
"""Configurations for live agents under this invocation."""
resumability_config: Optional[ResumabilityConfig] = None
"""The resumability config that applies to all agents under this invocation."""
plugin_manager: PluginManager = Field(default_factory=PluginManager)
"""The manager for keeping track of plugins in this invocation."""
@@ -199,6 +208,11 @@ class InvocationContext(BaseModel):
of this invocation.
"""
def reset_agent_state(self, agent_name: str) -> None:
"""Resets the state of an agent, allowing it to be re-run."""
self.agent_states.pop(agent_name, None)
self.end_of_agents.pop(agent_name, None)
def increment_llm_call_count(
self,
):
@@ -220,7 +234,6 @@ class InvocationContext(BaseModel):
def user_id(self) -> str:
return self.session.user_id
@working_in_progress("incomplete feature, don't use yet")
def get_events(
self,
current_invocation: bool = False,
@@ -247,6 +260,45 @@ class InvocationContext(BaseModel):
results = [event for event in results if event.branch == self.branch]
return results
def should_pause_invocation(self, event: Event) -> bool:
"""Returns whether to pause the invocation right after this event.
"Pausing" an invocation is different from "ending" an invocation. A paused
invocation can be resumed later, while an ended invocation cannot.
Pausing the current agent's run will also pause all the agents that
depend on its execution, i.e. the subsequent agents in a workflow, and the
current agent's ancestors, etc.
Note that parallel sibling agents won't be affected, but their common
ancestors will be paused after all the non-blocking sub-agents finished
running.
Should meet all following conditions to pause an invocation:
1. The app is resumable.
2. The current event has a long running function call.
Args:
event: The current event.
Returns:
Whether to pause the invocation right after this event.
"""
if (
not self.resumability_config
or not self.resumability_config.is_resumable
):
return False
if not event.long_running_tool_ids or not event.get_function_calls():
return False
for fc in event.get_function_calls():
if fc.id in event.long_running_tool_ids:
return True
return False
def new_invocation_context_id() -> str:
return "e-" + str(uuid.uuid4())
+2
View File
@@ -341,6 +341,8 @@ class LlmAgent(BaseAgent):
async for event in agen:
self.__maybe_save_output_to_state(event)
yield event
if ctx.should_pause_invocation(event):
return
@override
async def _run_live_impl(
+26 -1
View File
@@ -21,7 +21,6 @@ from typing import AsyncGenerator
from typing import ClassVar
from typing import Dict
from typing import Optional
from typing import Type
from typing_extensions import override
@@ -30,10 +29,22 @@ from ..events.event import Event
from ..utils.context_utils import Aclosing
from ..utils.feature_decorator import experimental
from .base_agent import BaseAgent
from .base_agent import BaseAgentState
from .base_agent_config import BaseAgentConfig
from .loop_agent_config import LoopAgentConfig
@experimental
class LoopAgentState(BaseAgentState):
"""State for LoopAgent."""
current_sub_agent: str = ''
"""The name of the current sub-agent to run in the loop."""
times_looped: int = 0
"""The number of times the loop agent has looped."""
class LoopAgent(BaseAgent):
"""A shell agent that run its sub-agents in a loop.
@@ -55,19 +66,33 @@ class LoopAgent(BaseAgent):
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
if not self.sub_agents:
return
times_looped = 0
while not self.max_iterations or times_looped < self.max_iterations:
for sub_agent in self.sub_agents:
should_exit = False
pause_invocation = False
async with Aclosing(sub_agent.run_async(ctx)) as agen:
async for event in agen:
yield event
if event.actions.escalate:
should_exit = True
if ctx.should_pause_invocation(event):
pause_invocation = True
# Indicates that the loop agent should exist after running this
# sub-agent.
if should_exit:
return
# Indicates that the invocation should be paused after running this
# sub-agent.
if pause_invocation:
return
times_looped += 1
return
+13
View File
@@ -175,22 +175,35 @@ class ParallelAgent(BaseAgent):
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
if not self.sub_agents:
return
agent_runs = [
sub_agent.run_async(
_create_branch_ctx_for_sub_agent(self, sub_agent, ctx)
)
for sub_agent in self.sub_agents
]
pause_invocation = False
try:
# TODO remove if once Python <3.11 is no longer supported.
if sys.version_info >= (3, 11):
async with Aclosing(_merge_agent_run(agent_runs)) as agen:
async for event in agen:
yield event
if ctx.should_pause_invocation(event):
pause_invocation = True
else:
async with Aclosing(_merge_agent_run_pre_3_11(agent_runs)) as agen:
async for event in agen:
yield event
if ctx.should_pause_invocation(event):
pause_invocation = True
if pause_invocation:
return
finally:
for sub_agent_run in agent_runs:
await sub_agent_run.aclose()
+27 -1
View File
@@ -24,13 +24,23 @@ from typing_extensions import override
from ..events.event import Event
from ..utils.context_utils import Aclosing
from ..utils.feature_decorator import experimental
from .base_agent import BaseAgent
from .base_agent import BaseAgentConfig
from .base_agent import BaseAgentState
from .base_agent_config import BaseAgentConfig
from .invocation_context import InvocationContext
from .llm_agent import LlmAgent
from .sequential_agent_config import SequentialAgentConfig
@experimental
class SequentialAgentState(BaseAgentState):
"""State for SequentialAgent."""
current_sub_agent: str = ''
"""The name of the current sub-agent to run."""
class SequentialAgent(BaseAgent):
"""A shell agent that runs its sub-agents in sequence."""
@@ -41,10 +51,23 @@ class SequentialAgent(BaseAgent):
async def _run_async_impl(
self, ctx: InvocationContext
) -> AsyncGenerator[Event, None]:
# Skip if there is no sub-agent.
if not self.sub_agents:
return
for sub_agent in self.sub_agents:
pause_invocation = False
async with Aclosing(sub_agent.run_async(ctx)) as agen:
async for event in agen:
yield event
if ctx.should_pause_invocation(event):
pause_invocation = True
# Indicates the invocation should pause when receiving signal from
# the current sub_agent.
if pause_invocation:
return
@override
async def _run_live_impl(
@@ -61,6 +84,9 @@ class SequentialAgent(BaseAgent):
Args:
ctx: The invocation context of the agent.
"""
if not self.sub_agents:
return
# There is no way to know if it's using live during init phase so we have to init it here
for sub_agent in self.sub_agents:
# add tool
+2
View File
@@ -13,7 +13,9 @@
# limitations under the License.
from .app import App
from .app import ResumabilityConfig
__all__ = [
'App',
'ResumabilityConfig',
]
+27
View File
@@ -26,6 +26,27 @@ from ..plugins.base_plugin import BasePlugin
from ..utils.feature_decorator import experimental
@experimental
class ResumabilityConfig(BaseModel):
"""The config of the resumability for an application.
The "resumability" in ADK refers to the ability to:
1. pause an invocation upon a long running function call.
2. resume an invocation from the last event, if it's paused or failed midway
through.
Note: ADK resumes the invocation in a best-effort manner:
1. Tool call to resume needs to be idempotent because we only guarantee
an at-least-once behavior once resumed.
2. Any temporary / in-memory state will be lost upon resumption.
"""
is_resumable: bool = False
"""Whether the app supports agent resumption.
If enabled, the feature will be enabled for all agents in the app.
"""
@experimental
class App(BaseModel):
"""Represents an LLM-backed agentic application.
@@ -57,3 +78,9 @@ class App(BaseModel):
context_cache_config: Optional[ContextCacheConfig] = None
"""Context cache configuration that applies to all LLM agents in the app."""
resumability_config: Optional[ResumabilityConfig] = None
"""
The config of the resumability for the application.
If configured, will be applied to all agents in the app.
"""
+12
View File
@@ -12,17 +12,22 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from enum import Enum
from typing import List
from typing import Optional
from typing import Union
from fastapi.openapi.models import OAuth2
from fastapi.openapi.models import OAuthFlows
from fastapi.openapi.models import SecurityBase
from fastapi.openapi.models import SecurityScheme
from fastapi.openapi.models import SecuritySchemeType
from pydantic import Field
from ..utils.feature_decorator import experimental
class OpenIdConnectWithConfig(SecurityBase):
type_: SecuritySchemeType = Field(
@@ -65,3 +70,10 @@ class OAuthGrantType(str, Enum):
# AuthSchemeType re-exports SecuritySchemeType from OpenAPI 3.0.
AuthSchemeType = SecuritySchemeType
@experimental
class ExtendedOAuth2(OAuth2):
"""OAuth2 scheme that incorporates auto-discovery for endpoints."""
issuer_url: Optional[str] = None # Used for endpoint-discovery
+70 -1
View File
@@ -14,19 +14,26 @@
from __future__ import annotations
import logging
from typing import Optional
from fastapi.openapi.models import OAuth2
from ..agents.callback_context import CallbackContext
from ..utils.feature_decorator import experimental
from .auth_credential import AuthCredential
from .auth_credential import AuthCredentialTypes
from .auth_schemes import AuthSchemeType
from .auth_schemes import ExtendedOAuth2
from .auth_tool import AuthConfig
from .exchanger.base_credential_exchanger import BaseCredentialExchanger
from .exchanger.credential_exchanger_registry import CredentialExchangerRegistry
from .oauth2_discovery import OAuth2DiscoveryManager
from .refresher.base_credential_refresher import BaseCredentialRefresher
from .refresher.credential_refresher_registry import CredentialRefresherRegistry
logger = logging.getLogger("google_adk." + __name__)
@experimental
class CredentialManager:
@@ -74,6 +81,7 @@ class CredentialManager:
self._auth_config = auth_config
self._exchanger_registry = CredentialExchangerRegistry()
self._refresher_registry = CredentialRefresherRegistry()
self._discovery_manager = OAuth2DiscoveryManager()
# Register default exchangers and refreshers
# TODO: support service account credential exchanger
@@ -247,7 +255,14 @@ class CredentialManager:
"auth_config.raw_credential.oauth2 required for credential type "
f"{raw_credential.auth_type}"
)
# Additional validation can be added here
if self._missing_oauth_info() and not await self._populate_auth_scheme():
raise ValueError(
"OAuth scheme info is missing, and auto-discovery has failed to fill"
" them in."
)
# Additional validation can be added here
async def _save_credential(
self, callback_context: CallbackContext, credential: AuthCredential
@@ -259,3 +274,57 @@ class CredentialManager:
credential_service = callback_context._invocation_context.credential_service
if credential_service:
await callback_context.save_credential(self._auth_config)
async def _populate_auth_scheme(self) -> bool:
"""Auto-discover server metadata and populate missing auth scheme info.
Returns:
True if auto-discovery was successful, False otherwise.
"""
auth_scheme = self._auth_config.auth_scheme
if (
not isinstance(auth_scheme, ExtendedOAuth2)
or not auth_scheme.issuer_url
):
logger.warning("No issuer_url was provided for auto-discovery.")
return False
metadata = await self._discovery_manager.discover_auth_server_metadata(
auth_scheme.issuer_url
)
if not metadata:
logger.warning("Auto-discovery has failed to populate OAuth scheme info.")
return False
flows = auth_scheme.flows
if flows.implicit and not flows.implicit.authorizationUrl:
flows.implicit.authorizationUrl = metadata.authorization_endpoint
if flows.password and not flows.password.tokenUrl:
flows.password.tokenUrl = metadata.token_endpoint
if flows.clientCredentials and not flows.clientCredentials.tokenUrl:
flows.clientCredentials.tokenUrl = metadata.token_endpoint
if flows.authorizationCode and not flows.authorizationCode.authorizationUrl:
flows.authorizationCode.authorizationUrl = metadata.authorization_endpoint
if flows.authorizationCode and not flows.authorizationCode.tokenUrl:
flows.authorizationCode.tokenUrl = metadata.token_endpoint
return True
def _missing_oauth_info(self) -> bool:
"""Checks if we are missing auth/token URLs needed for OAuth."""
auth_scheme = self._auth_config.auth_scheme
if isinstance(auth_scheme, OAuth2):
flows = auth_scheme.flows
return (
flows.implicit
and not flows.implicit.authorizationUrl
or flows.password
and not flows.password.tokenUrl
or flows.clientCredentials
and not flows.clientCredentials.tokenUrl
or flows.authorizationCode
and not flows.authorizationCode.authorizationUrl
or flows.authorizationCode
and not flows.authorizationCode.tokenUrl
)
return False
+9
View File
@@ -14,6 +14,7 @@
from __future__ import annotations
from typing import Any
from typing import Optional
from google.genai.types import Content
@@ -95,3 +96,11 @@ class EventActions(BaseModel):
compaction: Optional[EventCompaction] = None
"""The compaction of the events."""
end_of_agent: Optional[bool] = None
"""If true, the current agent has finished its current run. Note that there
can be multiple events with end_of_agent=True for the same agent within one
invocation when there is a loop."""
agent_state: Optional[dict[str, Any]] = None
"""The agent state at the current event."""
+28 -8
View File
@@ -36,6 +36,7 @@ from .agents.live_request_queue import LiveRequestQueue
from .agents.llm_agent import LlmAgent
from .agents.run_config import RunConfig
from .apps.app import App
from .apps.app import ResumabilityConfig
from .artifacts.base_artifact_service import BaseArtifactService
from .artifacts.in_memory_artifact_service import InMemoryArtifactService
from .auth.credential_service.base_credential_service import BaseCredentialService
@@ -74,6 +75,8 @@ class Runner:
session_service: The session service for the runner.
memory_service: The memory service for the runner.
credential_service: The credential service for the runner.
context_cache_config: The context cache config for the runner.
resumability_config: The resumability config for the application.
"""
app_name: str
@@ -90,6 +93,10 @@ class Runner:
"""The memory service for the runner."""
credential_service: Optional[BaseCredentialService] = None
"""The credential service for the runner."""
context_cache_config: Optional[ContextCacheConfig] = None
"""The context cache config for the runner."""
resumability_config: Optional[ResumabilityConfig] = None
"""The resumability config for the application."""
def __init__(
self,
@@ -110,11 +117,11 @@ class Runner:
`ValueError`. Providing `app` is the recommended way to create a runner.
Args:
app: An optional `App` instance. If provided, `app_name` and `agent`
should not be specified.
app_name: The application name of the runner. Required if `app` is not
provided.
agent: The root agent to run. Required if `app` is not provided.
app: An optional `App` instance. If provided, `app_name` and `agent`
should not be specified.
plugins: Deprecated. A list of plugins for the runner. Please use the
`app` argument to provide plugins instead.
artifact_service: The artifact service for the runner.
@@ -126,9 +133,13 @@ class Runner:
ValueError: If `app` is provided along with `app_name` or `plugins`, or
if `app` is not provided but either `app_name` or `agent` is missing.
"""
self.app_name, self.agent, self.context_cache_config, plugins = (
self._validate_runner_params(app, app_name, agent, plugins)
)
(
self.app_name,
self.agent,
self.context_cache_config,
self.resumability_config,
plugins,
) = self._validate_runner_params(app, app_name, agent, plugins)
self.artifact_service = artifact_service
self.session_service = session_service
self.memory_service = memory_service
@@ -142,7 +153,11 @@ class Runner:
agent: Optional[BaseAgent],
plugins: Optional[List[BasePlugin]],
) -> tuple[
str, BaseAgent, Optional[ContextCacheConfig], Optional[List[BasePlugin]]
str,
BaseAgent,
Optional[ContextCacheConfig],
Optional[ResumabilityConfig],
Optional[List[BasePlugin]],
]:
"""Validates and extracts runner parameters.
@@ -153,7 +168,8 @@ class Runner:
plugins: A list of plugins for the runner.
Returns:
A tuple containing (app_name, agent, context_cache_config, plugins).
A tuple containing (app_name, agent, context_cache_config,
resumability_config, plugins).
Raises:
ValueError: If parameters are invalid.
@@ -174,12 +190,14 @@ class Runner:
agent = app.root_agent
plugins = app.plugins
context_cache_config = app.context_cache_config
resumability_config = app.resumability_config
elif not app_name or not agent:
raise ValueError(
'Either app or both app_name and agent must be provided.'
)
else:
context_cache_config = None
resumability_config = None
if plugins:
warnings.warn(
@@ -187,7 +205,7 @@ class Runner:
' to provide plugins instead.',
DeprecationWarning,
)
return app_name, agent, context_cache_config, plugins
return app_name, agent, context_cache_config, resumability_config, plugins
def run(
self,
@@ -264,6 +282,7 @@ class Runner:
user_id: The user ID of the session.
session_id: The session ID of the session.
new_message: A new message to append to the session.
state_delta: Optional state changes to apply to the session.
run_config: The run config for the agent.
Yields:
@@ -687,6 +706,7 @@ class Runner:
user_content=new_message,
live_request_queue=live_request_queue,
run_config=run_config,
resumability_config=self.resumability_config,
)
def _new_invocation_context_for_live(
@@ -371,15 +371,15 @@ class VertexAiSessionService(BaseSessionService):
) -> Optional[genai.types.HttpOptions]:
return None
def _get_api_client(self):
def _get_api_client(self) -> genai.client.BaseApiClient:
"""Instantiates an API client for the given project and location.
It needs to be instantiated inside each request so that the event loop
management can be properly propagated.
"""
api_client = genai.Client(
api_client = genai.client.BaseApiClient(
vertexai=True, project=self._project, location=self._location
)._api_client
)
if new_options := self._api_client_http_options_override():
api_client._http_options = new_options
+97 -33
View File
@@ -25,17 +25,38 @@ from __future__ import annotations
import json
from typing import Any
from typing import TYPE_CHECKING
from google.genai import types
from opentelemetry import trace
from ..agents.invocation_context import InvocationContext
from .. import version
from ..events.event import Event
from ..models.llm_request import LlmRequest
from ..models.llm_response import LlmResponse
from ..tools.base_tool import BaseTool
tracer = trace.get_tracer('gcp.vertex.agent')
# TODO: Replace with constant from opentelemetry.semconv when it reaches version 1.37 in g3.
GEN_AI_AGENT_DESCRIPTION = 'gen_ai.agent.description'
GEN_AI_AGENT_NAME = 'gen_ai.agent.name'
GEN_AI_CONVERSATION_ID = 'gen_ai.conversation.id'
GEN_AI_OPERATION_NAME = 'gen_ai.operation.name'
GEN_AI_TOOL_CALL_ID = 'gen_ai.tool.call.id'
GEN_AI_TOOL_DESCRIPTION = 'gen_ai.tool.description'
GEN_AI_TOOL_NAME = 'gen_ai.tool.name'
GEN_AI_TOOL_TYPE = 'gen_ai.tool.type'
# Needed to avoid circular imports
if TYPE_CHECKING:
from ..agents.base_agent import BaseAgent
from ..agents.invocation_context import InvocationContext
from ..models.llm_request import LlmRequest
from ..models.llm_response import LlmResponse
from ..tools.base_tool import BaseTool
tracer = trace.get_tracer(
instrumenting_module_name='gcp.vertex.agent',
instrumenting_library_version=version.__version__,
# TODO: Replace with constant from opentelemetry.semconv when it reaches version 1.37 in g3.
schema_url='https://opentelemetry.io/schemas/1.37.0',
)
def _safe_json_serialize(obj) -> str:
@@ -57,6 +78,39 @@ def _safe_json_serialize(obj) -> str:
return '<not serializable>'
def trace_agent_invocation(
span: trace.Span, agent: BaseAgent, ctx: InvocationContext
) -> None:
"""Sets span attributes immedietely available on agent invocation according to OTEL semconv version 1.37.
Args:
span: Span on which attributes are set.
agent: Agent from which attributes are gathered.
ctx: InvocationContext from which attrbiutes are gathered.
Inference related fields are not set, due to their planned removal from invoke_agent span:
https://github.com/open-telemetry/semantic-conventions/issues/2632
`gen_ai.agent.id` is not set because currently it's unclear what attributes this field should have, specifically:
- In which scope should it be unique (globally, given project, given agentic flow, given deployment).
- Should it be unchanging between deployments, and how this should this be achieved.
`gen_ai.data_source.id` is not set because it's not available.
Closest type which could contain this information is types.GroundingMetadata, which does not have an ID.
`server.*` attributes are not set pending confirmation from aabmass.
"""
# Required
span.set_attribute(GEN_AI_OPERATION_NAME, 'invoke_agent')
# Conditionally Required
span.set_attribute(GEN_AI_AGENT_DESCRIPTION, agent.description)
span.set_attribute(GEN_AI_AGENT_NAME, agent.name)
span.set_attribute(GEN_AI_CONVERSATION_ID, ctx.session.id)
def trace_tool_call(
tool: BaseTool,
args: dict[str, Any],
@@ -70,40 +124,49 @@ def trace_tool_call(
function_response_event: The event with the function response details.
"""
span = trace.get_current_span()
span.set_attribute('gen_ai.system', 'gcp.vertex.agent')
span.set_attribute('gen_ai.operation.name', 'execute_tool')
span.set_attribute('gen_ai.tool.name', tool.name)
span.set_attribute('gen_ai.tool.description', tool.description)
tool_call_id = '<not specified>'
tool_response = '<not specified>'
if function_response_event.content.parts:
function_response = function_response_event.content.parts[
0
].function_response
if function_response is not None:
tool_call_id = function_response.id
tool_response = function_response.response
span.set_attribute('gen_ai.tool.call.id', tool_call_id)
span.set_attribute(GEN_AI_OPERATION_NAME, 'execute_tool')
span.set_attribute(GEN_AI_TOOL_DESCRIPTION, tool.description)
span.set_attribute(GEN_AI_TOOL_NAME, tool.name)
# e.g. FunctionTool
span.set_attribute(GEN_AI_TOOL_TYPE, tool.__class__.__name__)
# Setting empty llm request and response (as UI expect these) while not
# applicable for tool_response.
span.set_attribute('gcp.vertex.agent.llm_request', '{}')
span.set_attribute('gcp.vertex.agent.llm_response', '{}')
if not isinstance(tool_response, dict):
tool_response = {'result': tool_response}
span.set_attribute(
'gcp.vertex.agent.tool_call_args',
_safe_json_serialize(args),
)
# Tracing tool response
tool_call_id = '<not specified>'
tool_response = '<not specified>'
if (
function_response_event.content is not None
and function_response_event.content.parts
):
response_parts = function_response_event.content.parts
function_response = response_parts[0].function_response
if function_response is not None:
if function_response.id is not None:
tool_call_id = function_response.id
if function_response.response is not None:
tool_response = function_response.response
span.set_attribute(GEN_AI_TOOL_CALL_ID, tool_call_id)
if not isinstance(tool_response, dict):
tool_response = {'result': tool_response}
span.set_attribute('gcp.vertex.agent.event_id', function_response_event.id)
span.set_attribute(
'gcp.vertex.agent.tool_response',
_safe_json_serialize(tool_response),
)
# Setting empty llm request and response (as UI expect these) while not
# applicable for tool_response.
span.set_attribute('gcp.vertex.agent.llm_request', '{}')
span.set_attribute(
'gcp.vertex.agent.llm_response',
'{}',
)
def trace_merged_tool_calls(
@@ -121,12 +184,13 @@ def trace_merged_tool_calls(
"""
span = trace.get_current_span()
span.set_attribute('gen_ai.system', 'gcp.vertex.agent')
span.set_attribute('gen_ai.operation.name', 'execute_tool')
span.set_attribute('gen_ai.tool.name', '(merged tools)')
span.set_attribute('gen_ai.tool.description', '(merged tools)')
span.set_attribute('gen_ai.tool.call.id', response_event_id)
span.set_attribute(GEN_AI_OPERATION_NAME, 'execute_tool')
span.set_attribute(GEN_AI_TOOL_NAME, '(merged tools)')
span.set_attribute(GEN_AI_TOOL_DESCRIPTION, '(merged tools)')
span.set_attribute(GEN_AI_TOOL_CALL_ID, response_event_id)
# TODO(b/441461932): See if these are still necessary
span.set_attribute('gcp.vertex.agent.tool_call_args', 'N/A')
span.set_attribute('gcp.vertex.agent.event_id', response_event_id)
try:

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