chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:13 +08:00
commit ec2b666284
2231 changed files with 491535 additions and 0 deletions
+507
View File
@@ -0,0 +1,507 @@
# 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.
import asyncio
import contextlib
import copy
from typing import Any
from typing import AsyncGenerator
from typing import Generator
from typing import Optional
from google.adk.agents.context import Context as WorkflowContext
from google.adk.agents.invocation_context import InvocationContext as BaseInvocationContext
from google.adk.agents.live_request_queue import LiveRequestQueue
from google.adk.agents.llm_agent import Agent
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.run_config import RunConfig
from google.adk.apps.app import App
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.events.event import Event
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.models.base_llm import BaseLlm
from google.adk.models.base_llm_connection import BaseLlmConnection
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.plugins.base_plugin import BasePlugin
from google.adk.plugins.plugin_manager import PluginManager
from google.adk.runners import InMemoryRunner as AfInMemoryRunner
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.session import Session
from google.adk.utils.context_utils import Aclosing
from google.genai import types
from google.genai.types import Part
from typing_extensions import override
def create_test_agent(name: str = 'test_agent') -> LlmAgent:
"""Create a simple test agent for use in unit tests.
Args:
name: The name of the test agent.
Returns:
A configured LlmAgent instance suitable for testing.
"""
return LlmAgent(name=name)
class UserContent(types.Content):
def __init__(self, text_or_part: str):
parts = [
types.Part.from_text(text=text_or_part)
if isinstance(text_or_part, str)
else text_or_part
]
super().__init__(role='user', parts=parts)
class ModelContent(types.Content):
def __init__(self, parts: list[types.Part]):
super().__init__(role='model', parts=parts)
async def create_invocation_context(
agent: Agent,
user_content: str = '',
run_config: RunConfig = None,
plugins: list[BasePlugin] = [],
):
invocation_id = 'test_id'
artifact_service = InMemoryArtifactService()
session_service = InMemorySessionService()
memory_service = InMemoryMemoryService()
invocation_context = BaseInvocationContext(
artifact_service=artifact_service,
session_service=session_service,
memory_service=memory_service,
plugin_manager=PluginManager(plugins=plugins),
invocation_id=invocation_id,
agent=agent,
session=await session_service.create_session(
app_name='test_app', user_id='test_user'
),
user_content=types.Content(
role='user', parts=[types.Part.from_text(text=user_content)]
),
run_config=run_config or RunConfig(),
)
if user_content:
append_user_content(
invocation_context, [types.Part.from_text(text=user_content)]
)
return invocation_context
async def create_workflow_context(
agent,
user_content='',
) -> WorkflowContext:
"""Create a WorkflowContext for isolated node testing.
Constructs the minimal InvocationContext and wraps it in a
WorkflowContext so that individual nodes can be tested in
isolation without running the full _SingleLlmAgent pipeline.
"""
invocation_context = await create_invocation_context(agent, user_content)
return WorkflowContext(
invocation_context=invocation_context,
node_path='test',
run_id='test-execution',
)
def append_user_content(
invocation_context: BaseInvocationContext, parts: list[types.Part]
) -> Event:
session = invocation_context.session
event = Event(
invocation_id=invocation_context.invocation_id,
author='user',
content=types.Content(role='user', parts=parts),
)
session.events.append(event)
return event
# Extracts the contents from the events and transform them into a list of
# (author, simplified_content) tuples.
def simplify_events(events: list[Event]) -> list[tuple[str, types.Part]]:
res = []
for event in events:
if event.content:
author = event.author
res.append((author, simplify_content(event.content)))
return res
END_OF_AGENT = 'end_of_agent'
# Extracts the contents from the events and transform them into a list of
# (author, simplified_content OR AgentState OR "end_of_agent") tuples.
#
# Could be used to compare events for testing resumability.
def simplify_resumable_app_events(
events: list[Event],
) -> list[(str, types.Part | str)]:
results = []
for event in events:
if event.content:
results.append((event.author, simplify_content(event.content)))
elif event.actions.end_of_agent:
results.append((event.author, END_OF_AGENT))
elif event.actions.agent_state is not None:
agent_state = event.actions.agent_state
if isinstance(agent_state, dict):
nodes = agent_state.get('nodes', {})
agent_state = {
'node_states': {
node_name: node_state.get('status')
for node_name, node_state in nodes.items()
}
}
results.append((event.author, agent_state))
return results
# Simplifies the contents into a list of (author, simplified_content) tuples.
def simplify_contents(contents: list[types.Content]) -> list[(str, types.Part)]:
return [(content.role, simplify_content(content)) for content in contents]
# Simplifies the content so it's easier to assert.
# - If there is only one part, return part
# - If the only part is pure text, return stripped_text
# - If there are multiple parts, return parts
# - remove function_call_id if it exists
def simplify_content(
content: types.Content,
) -> str | types.Part | list[types.Part]:
content = copy.deepcopy(content)
for part in content.parts:
if part.function_call and part.function_call.id:
part.function_call.id = None
if part.function_response and part.function_response.id:
part.function_response.id = None
if len(content.parts) == 1:
if content.parts[0].text:
return content.parts[0].text.strip()
else:
return content.parts[0]
return content.parts
def get_user_content(message: types.ContentUnion) -> types.Content:
return message if isinstance(message, types.Content) else UserContent(message)
class TestInMemoryRunner(AfInMemoryRunner):
"""InMemoryRunner that is tailored for tests, features async run method.
app_name is hardcoded as InMemoryRunner in the parent class.
"""
async def run_async_with_new_session(
self, new_message: types.ContentUnion
) -> list[Event]:
collected_events: list[Event] = []
async for event in self.run_async_with_new_session_agen(new_message):
collected_events.append(event)
return collected_events
async def run_async_with_new_session_agen(
self, new_message: types.ContentUnion
) -> AsyncGenerator[Event, None]:
session = await self.session_service.create_session(
app_name='InMemoryRunner', user_id='test_user'
)
agen = self.run_async(
user_id=session.user_id,
session_id=session.id,
new_message=get_user_content(new_message),
)
async with Aclosing(agen):
async for event in agen:
yield event
class InMemoryRunner:
"""InMemoryRunner that is tailored for tests."""
def __init__(
self,
root_agent: Optional[Agent | LlmAgent] = None,
response_modalities: list[str] = None,
plugins: list[BasePlugin] = [],
app: Optional[App] = None,
node: Any = None,
):
"""Initializes the InMemoryRunner.
Args:
root_agent: The root agent to run, won't be used if app is provided.
response_modalities: The response modalities of the runner.
plugins: The plugins to use in the runner, won't be used if app is
provided.
app: The app to use in the runner.
node: The root node to run.
"""
self._app = app
if node:
self.app_name = node.name
self.root_agent = None
self.runner = Runner(
node=node,
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
plugins=plugins,
)
elif not app:
self.app_name = 'test_app'
self.root_agent = root_agent
self.runner = Runner(
app_name='test_app',
agent=root_agent,
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
plugins=plugins,
)
else:
self.app_name = app.name
self.root_agent = app.root_agent
self.runner = Runner(
app=app,
artifact_service=InMemoryArtifactService(),
session_service=InMemorySessionService(),
memory_service=InMemoryMemoryService(),
)
self.session_id = None
@property
def session(self) -> Session:
if not self.session_id:
session = self.runner.session_service.create_session_sync(
app_name=self.app_name, user_id='test_user'
)
self.session_id = session.id
return session
return self.runner.session_service.get_session_sync(
app_name=self.app_name, user_id='test_user', session_id=self.session_id
)
def run(self, new_message: types.ContentUnion) -> list[Event]:
return list(
self.runner.run(
user_id=self.session.user_id,
session_id=self.session.id,
new_message=get_user_content(new_message),
)
)
@property
def is_resumable(self) -> bool:
"""Returns whether the app is configured for resumable HITL."""
if hasattr(self, '_app') and self._app:
cfg = getattr(self._app, 'resumability_config', None)
return cfg is not None and cfg.is_resumable
return False
async def run_async(
self,
new_message: Optional[types.ContentUnion] = None,
invocation_id: Optional[str] = None,
) -> list[Event]:
# For non-resumable apps, don't reuse invocation_id on resume.
# State reconstruction relies on scanning events from *previous*
# invocations, so the resume call must get a fresh invocation_id.
if invocation_id and not self.is_resumable:
invocation_id = None
events = []
async for event in self.runner.run_async(
user_id=self.session.user_id,
session_id=self.session.id,
invocation_id=invocation_id,
new_message=get_user_content(new_message) if new_message else None,
):
events.append(event)
return events
def run_live(
self, live_request_queue: LiveRequestQueue, run_config: RunConfig = None
) -> list[Event]:
collected_responses = []
async def consume_responses(session: Session):
run_res = self.runner.run_live(
session=session,
live_request_queue=live_request_queue,
run_config=run_config or RunConfig(),
)
async for response in run_res:
collected_responses.append(response)
# When we have enough response, we should return
if len(collected_responses) >= 1:
return
try:
session = self.session
asyncio.run(consume_responses(session))
except asyncio.TimeoutError:
print('Returning any partial results collected so far.')
return collected_responses
class MockModel(BaseLlm):
model: str = 'mock'
requests: list[LlmRequest] = []
live_blobs: list[types.Blob] = []
live_contents: list[types.Content] = []
responses: list[LlmResponse]
error: Exception | None = None
response_index: int = -1
# Whether the mock model should wait for realtime input (blobs or content)
# to be sent before yielding pre-defined responses in live mode.
wait_for_realtime_input: bool = False
@classmethod
def create(
cls,
responses: (
list[types.Part]
| list[LlmResponse]
| list[str]
| list[list[types.Part]]
),
error: Exception | None = None,
wait_for_realtime_input: bool = False,
):
if error and not responses:
return cls(
responses=[],
error=error,
wait_for_realtime_input=wait_for_realtime_input,
)
if not responses:
return cls(responses=[], wait_for_realtime_input=wait_for_realtime_input)
elif isinstance(responses[0], LlmResponse):
# responses is list[LlmResponse]
return cls(
responses=responses, wait_for_realtime_input=wait_for_realtime_input
)
else:
responses = [
LlmResponse(content=ModelContent(item))
if isinstance(item, list) and isinstance(item[0], types.Part)
# responses is list[list[Part]]
else LlmResponse(
content=ModelContent(
# responses is list[str] or list[Part]
[Part(text=item) if isinstance(item, str) else item]
)
)
for item in responses
if item
]
return cls(
responses=responses, wait_for_realtime_input=wait_for_realtime_input
)
@classmethod
@override
def supported_models(cls) -> list[str]:
return ['mock']
def generate_content(
self, llm_request: LlmRequest, stream: bool = False
) -> Generator[LlmResponse, None, None]:
if self.error is not None:
raise self.error
# Increasement of the index has to happen before the yield.
self.response_index += 1
self.requests.append(llm_request)
# yield LlmResponse(content=self.responses[self.response_index])
yield self.responses[self.response_index]
@override
async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
if self.error is not None:
raise self.error
# Increasement of the index has to happen before the yield.
self.response_index += 1
self.requests.append(llm_request)
yield self.responses[self.response_index]
@contextlib.asynccontextmanager
async def connect(self, llm_request: LlmRequest) -> BaseLlmConnection:
"""Creates a live connection to the LLM."""
self.requests.append(llm_request)
yield MockLlmConnection(
self.responses,
self,
wait_for_realtime_input=self.wait_for_realtime_input,
)
class MockLlmConnection(BaseLlmConnection):
def __init__(
self,
llm_responses: list[LlmResponse],
mock_model: MockModel,
wait_for_realtime_input: bool = False,
):
self.llm_responses = llm_responses
self.mock_model = mock_model
self.wait_for_realtime_input = wait_for_realtime_input
self._input_event = asyncio.Event()
async def send_history(self, history: list[types.Content]):
pass
async def send_content(self, content: types.Content):
self.mock_model.live_contents.append(content)
self._input_event.set()
async def send(self, data):
pass
async def send_realtime(self, blob: types.Blob):
self.mock_model.live_blobs.append(blob)
self._input_event.set()
async def receive(self) -> AsyncGenerator[LlmResponse, None]:
"""Yield each of the pre-defined LlmResponses."""
if self.wait_for_realtime_input:
await self._input_event.wait()
for response in self.llm_responses:
yield response
async def close(self):
pass