Skip to content

Commit 712da1b

Browse files
Jacksunweicopybara-github
authored andcommitted
feat(conformance): Integrates RecordingsPlugin into AdkWebServer to record Llm interactions and tool calls
When start the server with `--extra_plugins=google.adk.cli.plugins.recordings_plugin.RecordingsPlugin`, it will trigger recording with expected state in session. PiperOrigin-RevId: 808432022
1 parent 99405d6 commit 712da1b

File tree

4 files changed

+109
-17
lines changed

4 files changed

+109
-17
lines changed

src/google/adk/cli/adk_web_server.py

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import asyncio
1818
from contextlib import asynccontextmanager
19+
import importlib
1920
import logging
2021
import os
2122
import time
@@ -75,6 +76,7 @@
7576
from ..evaluation.eval_sets_manager import EvalSetsManager
7677
from ..events.event import Event
7778
from ..memory.base_memory_service import BaseMemoryService
79+
from ..plugins.base_plugin import BasePlugin
7880
from ..runners import Runner
7981
from ..sessions.base_session_service import BaseSessionService
8082
from ..sessions.session import Session
@@ -354,6 +356,7 @@ def __init__(
354356
eval_sets_manager: EvalSetsManager,
355357
eval_set_results_manager: EvalSetResultsManager,
356358
agents_dir: str,
359+
extra_plugins: Optional[list[str]] = None,
357360
):
358361
self.agent_loader = agent_loader
359362
self.session_service = session_service
@@ -363,39 +366,94 @@ def __init__(
363366
self.eval_sets_manager = eval_sets_manager
364367
self.eval_set_results_manager = eval_set_results_manager
365368
self.agents_dir = agents_dir
369+
self.extra_plugins = extra_plugins or []
366370
# Internal propeties we want to allow being modified from callbacks.
367371
self.runners_to_clean: set[str] = set()
368372
self.current_app_name_ref: SharedValue[str] = SharedValue(value="")
369373
self.runner_dict = {}
370374

371375
async def get_runner_async(self, app_name: str) -> Runner:
372-
"""Returns the runner for the given app."""
376+
"""Returns the cached runner for the given app."""
377+
# Handle cleanup
373378
if app_name in self.runners_to_clean:
374379
self.runners_to_clean.remove(app_name)
375380
runner = self.runner_dict.pop(app_name, None)
376381
await cleanup.close_runners(list([runner]))
377382

378-
envs.load_dotenv_for_agent(os.path.basename(app_name), self.agents_dir)
383+
# Return cached runner if exists
379384
if app_name in self.runner_dict:
380385
return self.runner_dict[app_name]
386+
387+
# Create new runner
388+
envs.load_dotenv_for_agent(os.path.basename(app_name), self.agents_dir)
381389
agent_or_app = self.agent_loader.load_agent(app_name)
382-
agentic_app = None
390+
391+
# Instantiate extra plugins if configured
392+
extra_plugins_instances = self._instantiate_extra_plugins()
393+
383394
if isinstance(agent_or_app, BaseAgent):
384395
agentic_app = App(
385396
name=app_name,
386397
root_agent=agent_or_app,
398+
plugins=extra_plugins_instances,
387399
)
388400
else:
389-
agentic_app = agent_or_app
390-
runner = Runner(
401+
# Combine existing plugins with extra plugins
402+
all_plugins = (agent_or_app.plugins or []) + extra_plugins_instances
403+
agentic_app = App(
404+
name=agent_or_app.name,
405+
root_agent=agent_or_app.root_agent,
406+
plugins=all_plugins,
407+
)
408+
409+
runner = self._create_runner(agentic_app)
410+
self.runner_dict[app_name] = runner
411+
return runner
412+
413+
def _create_runner(self, agentic_app: App) -> Runner:
414+
"""Create a runner with common services."""
415+
return Runner(
391416
app=agentic_app,
392417
artifact_service=self.artifact_service,
393418
session_service=self.session_service,
394419
memory_service=self.memory_service,
395420
credential_service=self.credential_service,
396421
)
397-
self.runner_dict[app_name] = runner
398-
return runner
422+
423+
def _instantiate_extra_plugins(self) -> list[BasePlugin]:
424+
"""Instantiate extra plugins from the configured list.
425+
426+
Returns:
427+
List of instantiated BasePlugin objects.
428+
"""
429+
extra_plugins_instances = []
430+
for qualified_name in self.extra_plugins:
431+
try:
432+
plugin_obj = self._import_plugin_object(qualified_name)
433+
if isinstance(plugin_obj, BasePlugin):
434+
extra_plugins_instances.append(plugin_obj)
435+
elif issubclass(plugin_obj, BasePlugin):
436+
extra_plugins_instances.append(plugin_obj(name=qualified_name))
437+
except Exception as e:
438+
logger.error("Failed to load plugin %s: %s", qualified_name, e)
439+
return extra_plugins_instances
440+
441+
def _import_plugin_object(self, qualified_name: str) -> Any:
442+
"""Import a plugin object (class or instance) from a fully qualified name.
443+
444+
Args:
445+
qualified_name: Fully qualified name (e.g., 'my_package.my_plugin.MyPlugin')
446+
447+
Returns:
448+
The imported object, which can be either a class or an instance.
449+
450+
Raises:
451+
ImportError: If the module cannot be imported.
452+
AttributeError: If the object doesn't exist in the module.
453+
"""
454+
module_name, obj_name = qualified_name.rsplit(".", 1)
455+
module = importlib.import_module(module_name)
456+
return getattr(module, obj_name)
399457

400458
def get_fast_api_app(
401459
self,

src/google/adk/cli/conformance/adk_web_server_client.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from typing import Any
2323
from typing import AsyncGenerator
2424
from typing import Dict
25+
from typing import Literal
2526
from typing import Optional
2627

2728
import httpx
@@ -178,34 +179,59 @@ async def delete_session(
178179
async def run_agent(
179180
self,
180181
request: RunAgentRequest,
182+
mode: Optional[Literal["record", "replay"]] = None,
183+
test_case_dir: Optional[str] = None,
184+
user_message_index: Optional[int] = None,
181185
) -> AsyncGenerator[Event, None]:
182186
"""Run an agent with streaming Server-Sent Events response.
183187
184188
Args:
185189
request: The RunAgentRequest containing agent execution parameters
190+
mode: Optional conformance mode ("record" or "replay") to trigger recording
191+
test_case_dir: Optional test case directory path for conformance recording
192+
user_message_index: Optional user message index for conformance recording
186193
187194
Yields:
188195
Event objects streamed from the agent execution
189196
190197
Raises:
198+
ValueError: If mode is provided but test_case_dir or user_message_index is None
191199
httpx.HTTPStatusError: If the request fails
192200
json.JSONDecodeError: If event data cannot be parsed
193201
"""
194-
# TODO: Prepare headers for conformance tracking
195-
headers = {}
202+
# Add recording parameters to state_delta for conformance tests
203+
if mode:
204+
if test_case_dir is None or user_message_index is None:
205+
raise ValueError(
206+
"test_case_dir and user_message_index must be provided when mode is"
207+
" specified"
208+
)
209+
210+
# Modify request state_delta in place
211+
if request.state_delta is None:
212+
request.state_delta = {}
213+
214+
if mode == "replay":
215+
request.state_delta["_adk_replay_config"] = {
216+
"dir": str(test_case_dir),
217+
"user_message_index": user_message_index,
218+
}
219+
else: # record mode
220+
request.state_delta["_adk_recordings_config"] = {
221+
"dir": str(test_case_dir),
222+
"user_message_index": user_message_index,
223+
}
196224

197225
async with self._get_client() as client:
198226
async with client.stream(
199227
"POST",
200228
"/run_sse",
201229
json=request.model_dump(by_alias=True, exclude_none=True),
202-
headers=headers,
203230
) as response:
204231
response.raise_for_status()
205232
async for line in response.aiter_lines():
206233
if line.startswith("data:") and (data := line[5:].strip()):
207-
try:
208-
event_data = json.loads(data)
209-
yield Event.model_validate(event_data)
210-
except (json.JSONDecodeError, ValueError) as exc:
211-
logger.warning("Failed to parse event data: %s", exc)
234+
event_data = json.loads(data)
235+
yield Event.model_validate(event_data)
236+
else:
237+
logger.debug("Non data line received: %s", line)

src/google/adk/cli/fast_api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def get_fast_api_app(
7070
otel_to_cloud: bool = False,
7171
reload_agents: bool = False,
7272
lifespan: Optional[Lifespan[FastAPI]] = None,
73+
extra_plugins: Optional[list[str]] = None,
7374
) -> FastAPI:
7475
# Set up eval managers.
7576
if eval_storage_uri:
@@ -187,6 +188,7 @@ def _parse_agent_engine_resource_name(agent_engine_id_or_resource_name):
187188
eval_sets_manager=eval_sets_manager,
188189
eval_set_results_manager=eval_set_results_manager,
189190
agents_dir=agents_dir,
191+
extra_plugins=extra_plugins,
190192
)
191193

192194
# Callbacks & other optional args for when constructing the FastAPI instance

src/google/adk/utils/yaml_utils.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def dump_pydantic_to_yaml(
2828
indent: int = 2,
2929
sort_keys: bool = True,
3030
exclude_none: bool = True,
31+
exclude_defaults: bool = True,
3132
) -> None:
3233
"""Dump a Pydantic model to a YAML file with multiline strings using | style.
3334
@@ -38,7 +39,11 @@ def dump_pydantic_to_yaml(
3839
sort_keys: Whether to sort dictionary keys (default: True).
3940
exclude_none: Exclude fields with None values (default: True).
4041
"""
41-
model_dict = model.model_dump(exclude_none=exclude_none, mode='json')
42+
model_dict = model.model_dump(
43+
exclude_none=exclude_none,
44+
exclude_defaults=exclude_defaults,
45+
mode='json',
46+
)
4247

4348
file_path = Path(file_path)
4449
file_path.parent.mkdir(parents=True, exist_ok=True)
@@ -55,7 +60,7 @@ def increase_indent(self, flow=False, indentless=False):
5560
return super(_MultilineDumper, self).increase_indent(flow, False)
5661

5762
def multiline_str_representer(dumper, data):
58-
if '\n' in data:
63+
if '\n' in data or '"' in data or "'" in data:
5964
return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|')
6065
return dumper.represent_scalar('tag:yaml.org,2002:str', data)
6166

@@ -70,4 +75,5 @@ def multiline_str_representer(dumper, data):
7075
indent=indent,
7176
sort_keys=sort_keys,
7277
default_flow_style=False,
78+
width=1000000, # Essentially disable text wraps
7379
)

0 commit comments

Comments
 (0)