diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml
index 3f260f0..fde544b 100644
--- a/.github/workflows/web-ci.yml
+++ b/.github/workflows/web-ci.yml
@@ -6,16 +6,22 @@ on:
pull_request:
branches: [ main ]
+permissions:
+ contents: read
+
jobs:
build:
- runs-on: ubuntu-latest
+ # zizmor: ignore[unpinned-images]
+ runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ persist-credentials: false
- name: Use Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
- node-version: '20'
+ node-version: '22'
cache: 'npm'
cache-dependency-path: client/web/package-lock.json
@@ -24,7 +30,7 @@ jobs:
working-directory: client/web
- name: Type check
- run: npx tsc --noEmit
+ run: npx tsc --build
working-directory: client/web
- name: Build
diff --git a/LICENSE b/LICENSE
index 261eeb9..3bf56e2 100644
--- a/LICENSE
+++ b/LICENSE
@@ -199,3 +199,43 @@
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.
+
+
+==============================================================================
+The MIT License (MIT)
+
+Copyright (c)
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+==============================================================================
+ISC License
+
+Copyright (c)
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/agent/python_agent/agent.py b/agent/python_agent/agent.py
index 1df4845..19182fb 100644
--- a/agent/python_agent/agent.py
+++ b/agent/python_agent/agent.py
@@ -12,59 +12,69 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+"""MAUI Agent definition and implementation."""
+
+from collections import OrderedDict
+from collections.abc import AsyncIterable
import json
import logging
-import pathlib
import os
-from collections import OrderedDict
-from collections.abc import AsyncIterable
-from typing import Any, Optional, Dict
+import pathlib
+from typing import Any
-import jsonschema
from a2a.types import (
AgentCapabilities,
AgentCard,
- AgentSkill,
DataPart,
Part,
TextPart,
)
+from google.adk import skills as adk_skills
from google.adk.agents import run_config
from google.adk.agents.llm_agent import LlmAgent
-from google.adk.artifacts import InMemoryArtifactService
+from google.adk.artifacts import in_memory_artifact_service
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
from google.adk.models.lite_llm import LiteLlm
from google.adk.runners import Runner
-from google.adk.sessions import InMemorySessionService
-from google.genai import types
-from google.adk.tools.mcp_tool import McpToolset
-from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams
-from google.adk.skills import load_skill_from_dir
+from google.adk.sessions import in_memory_session_service
from google.adk.tools import skill_toolset
-from a2ui.schema.constants import VERSION_0_8, VERSION_0_9, A2UI_OPEN_TAG, A2UI_CLOSE_TAG
-from a2ui.schema.manager import A2uiSchemaManager
-from a2ui.parser.parser import parse_response, ResponsePart
-from a2ui.schema.common_modifiers import remove_strict_validation
-from a2ui.a2a.extension import get_a2ui_agent_extension
+from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams
+from google.adk.tools.mcp_tool.mcp_toolset import McpToolset
+from google.genai import types
+import jsonschema
+
+import a2ui.a2a.extension as a2ui_extension
from a2ui.a2a.parts import parse_response_to_parts, stream_response_to_parts
+from a2ui.basic_catalog.provider import BundledCatalogProvider
+from a2ui.parser.parser import parse_response
+from a2ui.parser.streaming import A2uiStreamParser
from a2ui.schema.catalog import CatalogConfig
from a2ui.schema.catalog_provider import A2uiCatalogProvider
-from a2ui.basic_catalog.provider import BundledCatalogProvider
+from a2ui.schema.common_modifiers import remove_strict_validation
+from a2ui.schema.constants import A2UI_CLOSE_TAG, A2UI_OPEN_TAG, VERSION_0_9
+from a2ui.schema.manager import A2uiSchemaManager
logger = logging.getLogger(__name__)
+InMemorySessionService = in_memory_session_service.InMemorySessionService
+InMemoryArtifactService = in_memory_artifact_service.InMemoryArtifactService
_SKILL_BASE_PATH = pathlib.Path(__file__).parent / "skills"
google_maps_api_key = os.environ.get("GOOGLE_MAPS_API_KEY")
if not google_maps_api_key:
- # Fallback or direct assignment for testing - NOT RECOMMENDED FOR PRODUCTION
- google_maps_api_key = "YOUR_GOOGLE_MAPS_API_KEY_HERE" # Replace if not using env var
- if google_maps_api_key == "YOUR_GOOGLE_MAPS_API_KEY_HERE":
- print("WARNING: GOOGLE_MAPS_API_KEY is not set. Please set it as an environment variable or in the script.")
+ # Fallback or direct assignment for testing - NOT RECOMMENDED FOR PRODUCTION
+ google_maps_api_key = ( # Replace if not using env var
+ "YOUR_GOOGLE_MAPS_API_KEY_HERE"
+ )
+ if google_maps_api_key == "YOUR_GOOGLE_MAPS_API_KEY_HERE":
+ print(
+ "WARNING: GOOGLE_MAPS_API_KEY is not set. Please set it as an"
+ " environment variable or in the script."
+ )
-AGENT_INSTRUCTION = f"""
+AGENT_INSTRUCTION = """
You are a helpful location expert and assistant. Your goal is to help provide effective answers to a user's location based questions.
To achieve this, you MUST follow this logic:
@@ -90,28 +100,41 @@
* if the user asks for "sushi restaurants in seattle", and then asks for "how about in Redmond?", you should assume that they are asking for a new set of _sushi_ restaurants based on their previous query.
**Important**: When using the `google-maps-enriched-local-query-response` skill, you MUST respond with EXACTLY ONE ... block.
+ All A2UI message objects (e.g., `createSurface`, `updateComponents`, `updateDataModel`) MUST include a `"version": "v0.9"` property.
+ When generating a `PlaceCard`, you MUST explicitly set the `"orientation"` property: use `"vertical"` for single results and `"horizontal"` for lists.
If you have more than one of these blocks, the UI will not render correctly.
"""
+
class MergedCatalogProvider(A2uiCatalogProvider):
"""Dynamically loads the bundled basic catalog and extends it with local definitions."""
+
def __init__(self, version: str, extension_catalog_path: str):
self.version = version
self.extension_catalog_path = extension_catalog_path
- def load(self) -> Dict[str, Any]:
+ def load(self) -> dict[str, Any]:
# 1. Load the bundled base catalog from the package
base_provider = BundledCatalogProvider(self.version)
catalog = base_provider.load()
# 2. Load extension definitions from local JSON
- with open(self.extension_catalog_path, 'r') as f:
+ with open(self.extension_catalog_path, "r") as f:
overrides = json.load(f)
# 3. Merge custom extensions into the schema
if "components" in overrides:
catalog.setdefault("components", {}).update(overrides["components"])
+ any_comp = catalog.setdefault("$defs", {}).setdefault("anyComponent", {})
+ one_of = any_comp.setdefault("oneOf", [])
+ existing_refs = {
+ item.get("$ref") for item in one_of if isinstance(item, dict)
+ }
+ for comp_name in overrides["components"]:
+ ref = f"#/components/{comp_name}"
+ if ref not in existing_refs:
+ one_of.append({"$ref": ref})
if "$defs" in overrides:
catalog.setdefault("$defs", {}).update(overrides["$defs"])
if "catalogId" in overrides:
@@ -125,14 +148,23 @@ class MAUIAgent:
SUPPORTED_CONTENT_TYPES = ["text", "text/plain"]
- def __init__(self, base_url: str, agent_name: str = "MAUI Agent"):
+ def __init__(
+ self,
+ base_url: str,
+ agent_name: str = "MAUI Agent",
+ model_name: str = "gemini/gemini-3-flash-preview",
+ ) -> None:
self.base_url = base_url
self._agent_name = agent_name
+ self._model_name = model_name
self._user_id = "remote_agent"
- self._text_runner: Optional[Runner] = self._build_runner(self._build_llm_agent())
+ self._shared_session_service = InMemorySessionService()
+ self._text_runner: Runner | None = self._build_runner(
+ self._build_llm_agent()
+ )
- self._schema_managers: Dict[str, A2uiSchemaManager] = {}
- self._ui_runners: Dict[str, Runner] = {}
+ self._schema_managers: dict[str, A2uiSchemaManager] = {}
+ self._ui_runners: dict[str, Runner] = {}
self._parsers = OrderedDict()
self._max_parsers = 1000 # Max active sessions to keep in memory
@@ -149,6 +181,7 @@ def agent_card(self) -> AgentCard:
return self._agent_card
def _build_schema_manager(self, version: str) -> A2uiSchemaManager:
+ """Builds the schema manager for a specific protocol version."""
# Try sibling directory first (local dev)
extension_path = (
pathlib.Path(__file__).parent.parent
@@ -158,40 +191,48 @@ def _build_schema_manager(self, version: str) -> A2uiSchemaManager:
)
# Fallback to nested directory (deployed environment)
if not extension_path.exists():
- extension_path = (
- pathlib.Path(__file__).parent
- / "shared"
- / "schema"
- / "maps_catalog_extension.json"
- )
+ extension_path = (
+ pathlib.Path(__file__).parent
+ / "shared"
+ / "schema"
+ / "maps_catalog_extension.json"
+ )
return A2uiSchemaManager(
version=version,
catalogs=[
CatalogConfig(
name="maps-agentic-ui-catalog",
- provider=MergedCatalogProvider(version, str(extension_path))
+ provider=MergedCatalogProvider(version, str(extension_path)),
)
],
schema_modifiers=[remove_strict_validation],
)
def make_grounding_lite_mcp(self):
+ """Creates an MCP toolset for grounding with Google Maps tools."""
+ headers = {}
+ if (
+ google_maps_api_key
+ and google_maps_api_key != "YOUR_GOOGLE_MAPS_API_KEY_HERE"
+ ):
+ headers["X-Goog-Api-Key"] = google_maps_api_key
return McpToolset(
- connection_params=StreamableHTTPConnectionParams(
- url="https://mapstools.googleapis.com/mcp",
- headers={"X-Goog-Api-Key": google_maps_api_key} if google_maps_api_key and google_maps_api_key != "YOUR_GOOGLE_MAPS_API_KEY_HERE" else {},
- timeout=30.0,
- ),
- # You can filter for specific Maps tools if needed:
- # tool_filter=['get_directions', 'find_place_by_id']
- )
+ connection_params=StreamableHTTPConnectionParams(
+ url="https://mapstools.googleapis.com/mcp",
+ headers=headers,
+ timeout=30.0,
+ ),
+ # You can filter for specific Maps tools if needed:
+ # tool_filter=['get_directions', 'find_place_by_id']
+ )
def _build_agent_card(self) -> AgentCard:
+ """Builds the A2UI agent card with streaming and extension capabilities."""
extensions = []
if self._schema_managers:
for version, sm in self._schema_managers.items():
- ext = get_a2ui_agent_extension(
+ ext = a2ui_extension.get_a2ui_agent_extension(
version,
sm.accepts_inline_catalogs,
sm.supported_catalog_ids,
@@ -204,8 +245,11 @@ def _build_agent_card(self) -> AgentCard:
)
return AgentCard(
- name="AI Kit Agent",
- description="This agent can provide Google Maps UI-enriched responses to relevant prompts",
+ name="Agentic UI ToolKit",
+ description=(
+ "This agent can provide Google Maps UI-enriched responses to"
+ " relevant prompts"
+ ),
url=self.base_url,
version="1.0.0",
default_input_modes=MAUIAgent.SUPPORTED_CONTENT_TYPES,
@@ -219,7 +263,7 @@ def _build_runner(self, agent: LlmAgent) -> Runner:
app_name=self._agent_name,
agent=agent,
artifact_service=InMemoryArtifactService(),
- session_service=InMemorySessionService(),
+ session_service=self._shared_session_service,
memory_service=InMemoryMemoryService(),
)
@@ -227,47 +271,47 @@ def get_processing_message(self) -> str:
return "Working on it..."
def _build_llm_agent(
- self, schema_manager: Optional[A2uiSchemaManager] = None
+ self, schema_manager: A2uiSchemaManager | None = None
) -> LlmAgent:
- """Builds the LLM agent for the AI Kit agent."""
- LITELLM_MODEL = os.getenv("LITELLM_MODEL", "gemini/gemini-2.5-flash")
-
-
+ """Builds the LLM agent for the Agentic UI ToolKit."""
skill_names = [
"google-maps-enriched-local-query-response",
]
skills = []
for name in skill_names:
- skills.append(load_skill_from_dir(_SKILL_BASE_PATH / name))
+ skills.append(adk_skills.load_skill_from_dir(_SKILL_BASE_PATH / name))
skill_manager_tool = skill_toolset.SkillToolset(skills=skills)
grounding_lite_mcp = self.make_grounding_lite_mcp()
- instruction = (
- schema_manager.generate_system_prompt(
- role_description=AGENT_INSTRUCTION,
- include_schema=True,
- include_examples=False,
- validate_examples=False,
- )
- if schema_manager
- else AGENT_INSTRUCTION
- )
+ if schema_manager:
+ instruction = schema_manager.generate_system_prompt(
+ role_description=AGENT_INSTRUCTION,
+ include_schema=True,
+ include_examples=False,
+ validate_examples=False,
+ )
+ else:
+ instruction = AGENT_INSTRUCTION
return LlmAgent(
- model=LiteLlm(model="gemini/gemini-3-flash-preview"),
+ model=LiteLlm(model=self._model_name),
name="maui_agent",
- description="An agent that can provide Google Maps UI-enriched responses to relevant prompts",
+ description=(
+ "An agent that can provide Google Maps UI-enriched responses to"
+ " relevant prompts"
+ ),
instruction=instruction,
tools=[grounding_lite_mcp, skill_manager_tool],
)
async def stream(
- self, query, session_id, ui_version: Optional[str] = None
+ self, query, session_id, ui_version: str | None = None
) -> AsyncIterable[dict[str, Any]]:
+ """Streams responses for a user query."""
session_state = {"base_url": self.base_url, "expression": "{expression}"}
- # Determine which runner to use based on whether the a2ui extension is active.
+ # Determine which runner to use based on active UI extension version.
if ui_version:
runner = self._ui_runners[ui_version]
schema_manager = self._schema_managers[ui_version]
@@ -300,7 +344,9 @@ async def stream(
current_query_text = query
# Ensure schema was loaded
- if ui_version and (not selected_catalog or not selected_catalog.catalog_schema):
+ if ui_version and (
+ not selected_catalog or not selected_catalog.catalog_schema
+ ):
logger.error(
"--- MAUIAgent.stream: A2UI_SCHEMA is not loaded. "
"Cannot perform UI validation. ---"
@@ -311,8 +357,9 @@ async def stream(
Part(
root=TextPart(
text=(
- "I'm sorry, I'm facing an internal configuration error with"
- " my UI components. Please contact support."
+ "I'm sorry, I'm facing an internal configuration"
+ " error with my UI components. Please contact"
+ " support."
)
)
)
@@ -323,8 +370,10 @@ async def stream(
while attempt <= max_retries:
attempt += 1
logger.info(
- f"--- MAUIAgent.stream: Attempt {attempt}/{max_retries + 1} "
- f"for session {session_id} ---"
+ "--- MAUIAgent.stream: Attempt %d/%d for session %s ---",
+ attempt,
+ max_retries + 1,
+ session_id,
)
current_message = types.Content(
@@ -350,29 +399,26 @@ async def token_stream():
if selected_catalog:
logger.info(
- f"--- MAUIAgent.stream: Using A2UI stream parser for catalog {selected_catalog.catalog_id} ---"
+ "--- MAUIAgent.stream: Using A2UI stream parser for catalog %s ---",
+ selected_catalog.catalog_id,
)
- from a2ui.parser.streaming import A2uiStreamParser
if session_id in self._parsers:
self._parsers.move_to_end(session_id)
else:
- self._parsers[session_id] = A2uiStreamParser()
+ self._parsers[session_id] = A2uiStreamParser(selected_catalog)
if len(self._parsers) > self._max_parsers:
self._parsers.popitem(last=False)
-
logger.info(
- f"--- MAUIAgent.stream: Streamed part: {token_stream()} ---"
+ "--- MAUIAgent.stream: Streamed part: %s ---", token_stream()
)
async for part in stream_response_to_parts(
self._parsers[session_id],
token_stream(),
):
- logger.info(
- f"-- MAUIAgent.stream: Streamed part: {part} ---"
- )
+ logger.info("-- MAUIAgent.stream: Streamed part: %s ---", part)
yield {
"is_task_complete": False,
"parts": [part],
@@ -387,7 +433,8 @@ async def token_stream():
final_response_content = "".join(full_content_list)
logger.info(
- f"-- MAUIAgent.stream: Final response content: {final_response_content} ---"
+ "-- MAUIAgent.stream: Final response content: %s ---",
+ final_response_content,
)
is_valid = False
@@ -395,14 +442,16 @@ async def token_stream():
if ui_version:
logger.info(
- "--- MAUIAgent.stream: Validating UI response (Attempt"
- f" {attempt})... ---"
+ "--- MAUIAgent.stream: Validating UI response (Attempt %d)... ---",
+ attempt,
)
try:
- logger.info(f"--- MAUIAgent.stream: Final response content: {final_response_content} ---")
+ logger.info(
+ "--- MAUIAgent.stream: Final response content: %s ---",
+ final_response_content,
+ )
response_parts = parse_response(final_response_content)
-
for part in response_parts:
if not part.a2ui_json:
continue
@@ -419,8 +468,9 @@ async def token_stream():
# --- End Validation Steps ---
logger.info(
- "--- MAUIAgent.stream: UI JSON successfully parsed AND validated"
- f" against schema. Validation OK (Attempt {attempt}). ---"
+ "--- MAUIAgent.stream: UI JSON successfully parsed AND"
+ " validated against schema. Validation OK (Attempt %d). ---",
+ attempt,
)
is_valid = True
@@ -430,14 +480,19 @@ async def token_stream():
jsonschema.exceptions.ValidationError,
) as e:
logger.warning(
- f"--- final content full_content_list {full_content_list} ---")
+ "--- final content full_content_list %s ---",
+ full_content_list,
+ )
logger.warning(
- f"--- MAUIAgent.stream: A2UI validation failed: {e} (Attempt"
- f" {attempt}) ---"
+ "--- MAUIAgent.stream: A2UI validation failed: %s (Attempt"
+ " %d) ---",
+ e,
+ attempt,
)
logger.warning(
- f"--- Failed response content: {final_response_content[:500]}... ---"
+ "--- Failed response content: %s... ---",
+ final_response_content[:500],
)
error_message = f"Validation failed: {e}."
@@ -447,30 +502,32 @@ async def token_stream():
if is_valid:
logger.info(
"--- MAUIAgent.stream: Response is valid. Sending final response"
- f" (Attempt {attempt}). ---"
+ " (Attempt %d). ---",
+ attempt,
)
final_parts = parse_response_to_parts(
final_response_content, fallback_text="OK."
)
- logger.info(f"--- MAUIAgent.stream: Final response parts: {final_parts} ---")
+ logger.info(
+ "--- MAUIAgent.stream: Final response parts: %s ---", final_parts
+ )
seen_fingerprints = set()
filtered_parts = []
for p in final_parts:
- if isinstance(p.root, DataPart):
- fingerprint = ("data", json.dumps(p.root.data, sort_keys=True))
- elif isinstance(p.root, TextPart):
- fingerprint = ("text", p.root.text)
- else:
- fingerprint = ("other", str(p.root))
-
- if fingerprint not in seen_fingerprints:
- seen_fingerprints.add(fingerprint)
- filtered_parts.append(p)
+ if isinstance(p.root, DataPart):
+ fingerprint = ("data", json.dumps(p.root.data, sort_keys=True))
+ elif isinstance(p.root, TextPart):
+ fingerprint = ("text", p.root.text)
+ else:
+ fingerprint = ("other", str(p.root))
+
+ if fingerprint not in seen_fingerprints:
+ seen_fingerprints.add(fingerprint)
+ filtered_parts.append(p)
final_parts = filtered_parts
-
yield {
"is_task_complete": True,
"parts": final_parts,
@@ -481,15 +538,18 @@ async def token_stream():
if attempt <= max_retries:
logger.warning(
- f"--- MAUIAgent.stream: Retrying... ({attempt}/{max_retries + 1}) ---"
+ "--- MAUIAgent.stream: Retrying... (%d/%d) ---",
+ attempt,
+ max_retries + 1,
)
# Prepare the query for the retry
current_query_text = (
- f"Your previous response was invalid. {error_message} You MUST generate a"
- " valid response that strictly follows the A2UI JSON SCHEMA. The response"
- " MUST be a JSON list of A2UI messages. Ensure each JSON part is wrapped in"
- f" '{A2UI_OPEN_TAG}' and '{A2UI_CLOSE_TAG}' tags. Please retry the"
- f" original request: '{query}'"
+ f"Your previous response was invalid. {error_message} You MUST"
+ " generate a valid response that strictly follows the A2UI JSON"
+ " SCHEMA. The response MUST be a JSON list of A2UI messages."
+ f" Ensure each JSON part is wrapped in '{A2UI_OPEN_TAG}' and"
+ f" '{A2UI_CLOSE_TAG}' tags. Please retry the original request:"
+ f" '{query}'"
)
# Loop continues...
@@ -504,8 +564,9 @@ async def token_stream():
Part(
root=TextPart(
text=(
- "I'm sorry, I'm having trouble generating the interface for"
- " that request right now. Please try again in a moment."
+ "I'm sorry, I'm having trouble generating the interface"
+ " for that request right now. Please try again in a"
+ " moment."
)
)
)
diff --git a/agent/python_agent/agent_config.py b/agent/python_agent/agent_config.py
new file mode 100644
index 0000000..78c15b3
--- /dev/null
+++ b/agent/python_agent/agent_config.py
@@ -0,0 +1,62 @@
+# 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
+#
+# https://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.
+
+"""Configuration schemas for MAUI Agent with templates."""
+
+import dataclasses
+import enum
+from typing import Optional
+
+
+class FallbackMode(str, enum.Enum):
+ """Fallback strategy when template validation fails."""
+
+ TEXT = "TEXT"
+ DYNAMIC = "DYNAMIC"
+
+
+@dataclasses.dataclass(frozen=True)
+class AgentConfig:
+ """Configuration parameters for template-based MAUI Agent.
+
+ Attributes:
+ max_list_size: Max number of place elements returned in layout updates.
+ router_model: Model used for query intent routing.
+ template_model: Model used for template parameter extraction.
+ generic_model: Model used for unconstrained dynamic UI generation.
+ router_thinking_budget: Thinking budget for routing model.
+ extractor_thinking_budget: Thinking budget for extraction model.
+ fallback_mode: Fallback strategy when specialized template extraction is
+ not used or fails (e.g. for unsupported intents or validation failures).
+ """
+
+ max_list_size: int = 5
+ router_model: str = "gemini/gemini-3.1-flash-lite"
+ template_model: str = "gemini/gemini-3.1-flash-lite"
+ generic_model: str = "gemini/gemini-3-flash-preview"
+ router_thinking_budget: int = 0
+ extractor_thinking_budget: int = 0
+ fallback_mode: FallbackMode = FallbackMode.DYNAMIC
+
+ def __post_init__(self):
+ if not isinstance(self.fallback_mode, FallbackMode):
+ try:
+ object.__setattr__(
+ self, "fallback_mode", FallbackMode(self.fallback_mode)
+ )
+ except ValueError:
+ raise ValueError(
+ f"Invalid fallback_mode: {self.fallback_mode}. Must be one of"
+ " FallbackMode values."
+ )
diff --git a/agent/python_agent/agent_with_grounding.py b/agent/python_agent/agent_with_grounding.py
index 11d2202..b955615 100644
--- a/agent/python_agent/agent_with_grounding.py
+++ b/agent/python_agent/agent_with_grounding.py
@@ -12,204 +12,220 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+"""MAUI Agent with Grounding implementation."""
+
+import logging
import os
import pathlib
-import logging
from typing import Optional
+
from google import genai
-from google.genai import types
+from google.adk import skills as adk_skills
from google.adk.agents.llm_agent import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from google.adk.tools import skill_toolset
-from google.adk.tools import FunctionTool
-from google.adk.skills import load_skill_from_dir
-from a2ui.schema.manager import A2uiSchemaManager
+from google.adk.tools.function_tool import FunctionTool
+from google.genai import types
+
from a2ui.schema.catalog import CatalogConfig
from a2ui.schema.common_modifiers import remove_strict_validation
from a2ui.schema.constants import VERSION_0_9
+from a2ui.schema.manager import A2uiSchemaManager
# Import MAUIAgent to inherit from it
-from agent import MAUIAgent, AGENT_INSTRUCTION, MergedCatalogProvider
+from .agent import AGENT_INSTRUCTION, MAUIAgent, MergedCatalogProvider
logger = logging.getLogger(__name__)
-CLEANUP_INSTRUCTION_TEMPLATE = """
-Please update the A2UI json response by replacing any incorrect
-Place IDs with the correct ones using the following grounding map of name to place ID:
-```
-{grounding_map}
-```
-Return only the cleaned JSON array. Here is the A2UI JSON response to clean up:
-```json
-{final_response_content}
-```
-"""
-
# Load skill content at module level
skill_content = ""
-skill_path = pathlib.Path(__file__).parent / "skills" / "google-maps-enriched-local-query-response" / "SKILL.md"
+skill_path = (
+ pathlib.Path(__file__).parent
+ / "skills"
+ / "google-maps-enriched-local-query-response"
+ / "SKILL.md"
+)
if skill_path.exists():
- with open(skill_path, "r") as f:
- skill_content = f.read()
+ with open(skill_path, "r") as f:
+ skill_content = f.read()
else:
- logger.warning(f"Skill file not found at {skill_path}")
+ logger.warning("Skill file not found at %s", skill_path)
+
async def query_vertex_map(query: str) -> str:
- """Query Google Maps via Vertex Grounding and return cleaned response.
+ """Query Google Maps via Vertex Grounding and return cleaned response.
- Args:
- query: The location query or question.
- """
+ Args:
+ query: The location query or question.
- project_id = os.environ.get("GOOGLE_CLOUD_PROJECT")
- if not project_id:
- raise ValueError("GOOGLE_CLOUD_PROJECT environment variable is not set. You must set a valid Google Cloud project ID to use the Agent with Grounding.")
+ Returns:
+ The grounded and cleaned A2UI response string.
+ """
- location = os.environ.get("GOOGLE_CLOUD_LOCATION")
- if not location:
- location = "global"
- logger.warning("GOOGLE_CLOUD_LOCATION is not set, defaulting to 'global'.")
+ project_id = os.environ.get("GOOGLE_CLOUD_PROJECT")
+ if not project_id:
+ raise ValueError(
+ "GOOGLE_CLOUD_PROJECT environment variable is not set. You must set a"
+ " valid Google Cloud project ID to use the Agent with Grounding."
+ )
- model_id = "gemini-3-flash-preview"
- cleanup_model_id = "gemini-3-flash-preview"
+ location = os.environ.get("GOOGLE_CLOUD_LOCATION")
+ if not location:
+ location = "global"
+ logger.warning("GOOGLE_CLOUD_LOCATION is not set, defaulting to 'global'.")
- client = genai.Client(vertexai=True, project=project_id, location=location)
+ model_id = "gemini-3-flash-preview"
- # Force use of skill for specialized maps tool
- use_skill = True
+ client = genai.Client(vertexai=True, project=project_id, location=location)
- # Construct instruction
- base_instruction = "You are a location specialist.\n\n" + AGENT_INSTRUCTION
+ # Construct instruction
+ base_instruction = "You are a location specialist.\n\n" + AGENT_INSTRUCTION
- # Try sibling directory first (local dev)
+ # Try sibling directory first (local dev)
+ extension_path = (
+ pathlib.Path(__file__).parent.parent
+ / "shared"
+ / "schema"
+ / "maps_catalog_extension.json"
+ )
+ # Fallback to nested directory (deployed environment)
+ if not extension_path.exists():
extension_path = (
- pathlib.Path(__file__).parent.parent
+ pathlib.Path(__file__).parent
/ "shared"
/ "schema"
/ "maps_catalog_extension.json"
)
- # Fallback to nested directory (deployed environment)
- if not extension_path.exists():
- extension_path = (
- pathlib.Path(__file__).parent
- / "shared"
- / "schema"
- / "maps_catalog_extension.json"
- )
-
- schema_manager = A2uiSchemaManager(
- version=VERSION_0_9,
- catalogs=[
- CatalogConfig(
- name="maps-agentic-ui-catalog",
- provider=MergedCatalogProvider(VERSION_0_9, str(extension_path))
- )
- ],
- schema_modifiers=[remove_strict_validation],
- )
-
- generated_prompt = schema_manager.generate_system_prompt(
- role_description=base_instruction,
- include_schema=True,
- include_examples=False,
- validate_examples=False,
- )
-
- final_instruction = """Use the Google Maps tool to answer queries about places.
+ schema_manager = A2uiSchemaManager(
+ version=VERSION_0_9,
+ catalogs=[
+ CatalogConfig(
+ name="maps-agentic-ui-catalog",
+ provider=MergedCatalogProvider(VERSION_0_9, str(extension_path)),
+ )
+ ],
+ schema_modifiers=[remove_strict_validation],
+ )
+
+ generated_prompt = schema_manager.generate_system_prompt(
+ role_description=base_instruction,
+ include_schema=True,
+ include_examples=False,
+ validate_examples=False,
+ )
+
+ final_instruction = """You MUST use the Google Maps tool to answer the user's query. Do not rely on your internal knowledge.
+ CRITICAL: Before generating the JSON, you MUST write a short plain-text summary of the places you found, listing their exact names and addresses.
+ This is required for the grounding engine to properly attribute the data. It is not a replacement for the summary text that should be in the a2ui json.
IMPORTANT: When generating the A2UI JSON response, you MUST include the " ...content... " tags immediately around the JSON content.
- Failure to do so will prevent the UI from rendering the map."""
-
- instruction = f"{generated_prompt}\n\n{skill_content}\n\n{final_instruction}"
-
- # Main generation call
- response = client.models.generate_content(
- model=model_id,
- contents=query,
- config=types.GenerateContentConfig(
- system_instruction=instruction,
- tools=[types.Tool(google_maps=types.GoogleMaps())],
- ),
- )
+ Failure to do so will prevent the UI from rendering the map.
+ PLACE ID GENERATION RULES:
+ You do not have access to real placeIds. Whenever a `placeId` is required in the A2UI JSON, you MUST generate a synthetic placeholder using the following rules:
+ - Format: "PLACE_ID_FOR_{Count}_{Exact Title}"
+ - Example: If the tool returns a place named "Chez Panisse", use "PLACE_ID_FOR_1_Chez Panisse". If it returns a second "Chez Panisse", use "PLACE_ID_FOR_2_Chez Panisse".
+ - STRICT MATCHING: Do NOT change any characters, spaces, capitalization, or punctuation from the title returned by the tool.
+ - COUNTING: Always prepend the occurrence count (starting at 1) for each title based on the order they were returned by the tool, even if the title only occurs once.
+ """
- final_response_content = response.text
-
- # Cleanup logic (second pass)
- try:
- grounding_map = {}
- if hasattr(response, 'candidates') and response.candidates and hasattr(response.candidates[0], 'grounding_metadata'):
- meta = response.candidates[0].grounding_metadata
- if hasattr(meta, 'grounding_chunks'):
- for chunk in meta.grounding_chunks:
- if hasattr(chunk, 'maps') and chunk.maps:
- title = getattr(chunk.maps, 'title', None)
- place_id = getattr(chunk.maps, 'place_id', None)
- if title and place_id:
- grounding_map[title.lower().strip()] = place_id
-
- if grounding_map and "" in final_response_content:
- cleanup_response = client.models.generate_content(
- model=cleanup_model_id,
- contents=query,
- config=types.GenerateContentConfig(
- system_instruction=CLEANUP_INSTRUCTION_TEMPLATE.format(
- grounding_map=grounding_map,
- final_response_content=final_response_content
- ),
- ),
- )
- text = cleanup_response.text
- start_idx = text.find("")
- end_idx = text.rfind("")
- if start_idx == -1 and end_idx == -1:
- final_response_content = "" + text + ""
- else:
- final_response_content = text
-
- except Exception as e:
- logger.error(f"Error during Place ID cleanup: {e}")
-
- # Final safety check: Extract JSON array if marker is present
- if "" in final_response_content:
- marker_idx = final_response_content.find("")
- before_marker = final_response_content[:marker_idx]
- after_marker = final_response_content[marker_idx + len(""):]
-
- start_idx = after_marker.find("[")
- end_idx = after_marker.rfind("]")
- if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
- json_only = after_marker[start_idx:end_idx+1]
- final_response_content = before_marker + "" + json_only + ""
-
- return final_response_content
+ instruction = f"{generated_prompt}\n\n{skill_content}\n\n{final_instruction}"
+
+ # Main generation call
+ response = client.models.generate_content(
+ model=model_id,
+ contents=query,
+ config=types.GenerateContentConfig(
+ system_instruction=instruction,
+ tools=[types.Tool(google_maps=types.GoogleMaps())],
+ ),
+ )
+
+ final_response_content = response.text
+
+ # Replace synthetic place ids with actual grounded place ids.
+ try:
+ grounding_map = {}
+ if (
+ hasattr(response, "candidates")
+ and response.candidates
+ and hasattr(response.candidates[0], "grounding_metadata")
+ ):
+ meta = response.candidates[0].grounding_metadata
+ IGNORE_TITLE_SUFFIX = " - Google Maps"
+ IGNORE_PLACE_ID_PREFIX = "places/ChI"
+ if hasattr(meta, "grounding_chunks") and meta.grounding_chunks:
+ title_counts = {}
+ for chunk in meta.grounding_chunks:
+ if hasattr(chunk, "maps") and chunk.maps:
+ title = getattr(chunk.maps, "title", None)
+ place_id = getattr(chunk.maps, "place_id", None)
+ if title and place_id:
+ if place_id.startswith(IGNORE_PLACE_ID_PREFIX):
+ place_id = place_id[len(IGNORE_PLACE_ID_PREFIX) - 3:]
+ if title.endswith(IGNORE_TITLE_SUFFIX):
+ title = title[:-len(IGNORE_TITLE_SUFFIX)]
+
+ # Track how many times this title has appeared
+ title_counts[title] = title_counts.get(title, 0) + 1
+ count = title_counts[title]
+ grounding_map[f"PLACE_ID_FOR_{count}_{title}"] = place_id
+ else:
+ logger.warning("No grounding chunks found")
+ else:
+ logger.warning("No grounding metadata found")
+
+ if grounding_map:
+ for key, value in grounding_map.items():
+ final_response_content = final_response_content.replace(key, value)
+ else:
+ logger.warning("No grounding map found")
+
+ except Exception as e: # pylint: disable=broad-exception-caught
+ logger.error("Error during Place ID cleanup: %s", e)
+
+ if "PLACE_ID_FOR_" in final_response_content:
+ logger.warning("Place ID placeholder found in response.")
+
+ # Final safety check: Extract JSON array if marker is present
+ if "" in final_response_content:
+ marker_idx = final_response_content.find("")
+ after_marker = final_response_content[marker_idx + len("") :]
+
+ start_idx = after_marker.find("[")
+ end_idx = after_marker.rfind("]")
+ if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
+ json_only = after_marker[start_idx : end_idx + 1]
+ final_response_content = "" + json_only + ""
+
+ return final_response_content
class MAUIAgentWithGrounding(MAUIAgent):
- """An agent that finds restaurants based on user criteria, using Vertex Grounding."""
+ """An agent that finds restaurants based on user criteria, using Vertex Grounding."""
- def __init__(self, base_url: str):
- super().__init__(base_url, agent_name="MAUI Agent with Grounding")
+ def __init__(self, base_url: str):
+ super().__init__(base_url, agent_name="MAUI Agent with Grounding")
- def _build_llm_agent(
- self, schema_manager: Optional[A2uiSchemaManager] = None
- ) -> LlmAgent:
- """Builds the LLM agent for the MAUI agent with grounding."""
+ def _build_llm_agent(
+ self, schema_manager: A2uiSchemaManager | None = None
+ ) -> LlmAgent:
+ """Builds the LLM agent for the MAUI agent with grounding."""
- _SKILL_BASE_PATH = pathlib.Path(__file__).parent / "skills"
+ skill_base_path = pathlib.Path(__file__).parent / "skills"
- skill_names = [
- "google-maps-enriched-local-query-response",
- ]
- skills = []
- for name in skill_names:
- skills.append(load_skill_from_dir(_SKILL_BASE_PATH / name))
+ skill_names = [
+ "google-maps-enriched-local-query-response",
+ ]
+ skills = []
+ for name in skill_names:
+ skills.append(adk_skills.load_skill_from_dir(skill_base_path / name))
- skill_manager_tool = skill_toolset.SkillToolset(skills=skills)
+ skill_manager_tool = skill_toolset.SkillToolset(skills=skills)
- # Use FunctionTool for Vertex grounding
- grounding_tool = FunctionTool(func=query_vertex_map)
+ # Use FunctionTool for Vertex grounding
+ grounding_tool = FunctionTool(func=query_vertex_map)
- AGENT_INSTRUCTION = """You are a location routing agent.
+ agent_instruction = """You are a location routing agent.
Whenever the user asks a question about a location, directions, places, or maps,
you MUST call the query_vertex_map tool.
Do NOT attempt to answer location questions yourself.
@@ -218,21 +234,23 @@ def _build_llm_agent(
CRITICAL: Return the output of the query_vertex_map tool EXACTLY as it is received, without any summarization, explanation, or modification. Your final response should be just the output of the tool."""
- instruction = (
- schema_manager.generate_system_prompt(
- role_description=AGENT_INSTRUCTION,
- include_schema=True,
- include_examples=False,
- validate_examples=False,
- )
- if schema_manager
- else AGENT_INSTRUCTION
- )
-
- return LlmAgent(
- model=LiteLlm(model="gemini/gemini-3-flash-preview"),
- name="maui_agent_grounding",
- description="An agent that can provide Google Maps UI-enriched responses using Vertex Grounding",
- instruction=instruction,
- tools=[grounding_tool, skill_manager_tool],
- )
+ if schema_manager:
+ instruction = schema_manager.generate_system_prompt(
+ role_description=agent_instruction,
+ include_schema=True,
+ include_examples=False,
+ validate_examples=False,
+ )
+ else:
+ instruction = agent_instruction
+
+ return LlmAgent(
+ model=LiteLlm(model="gemini/gemini-3-flash-preview"),
+ name="maui_agent_grounding",
+ description=(
+ "An agent that can provide Google Maps UI-enriched responses using"
+ " Vertex Grounding"
+ ),
+ instruction=instruction,
+ tools=[grounding_tool, skill_manager_tool],
+ )
diff --git a/agent/python_agent/agent_with_templates.py b/agent/python_agent/agent_with_templates.py
new file mode 100644
index 0000000..657aa49
--- /dev/null
+++ b/agent/python_agent/agent_with_templates.py
@@ -0,0 +1,581 @@
+# 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
+#
+# https://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.
+
+"""MAUI Agent with template-based latency optimization."""
+
+import logging
+import pathlib
+from types import SimpleNamespace
+from typing import Any, AsyncIterable
+import uuid
+
+from a2a.types import DataPart
+from a2a.types import Part
+from google.adk import skills as adk_skills
+from google.adk.agents import run_config
+from google.adk.agents.llm_agent import LlmAgent
+from google.adk.models.lite_llm import LiteLlm
+from google.adk.models.llm_request import LlmRequest
+from google.adk.runners import Runner
+from google.adk.tools.set_model_response_tool import SetModelResponseTool
+from google.genai import types
+import pydantic
+
+from a2ui.a2a.parts import create_a2ui_part
+from a2ui.schema.manager import (
+ A2uiSchemaManager,
+)
+from python_agent.agent import MAUIAgent
+from python_agent.agent_config import AgentConfig
+from python_agent.agent_config import FallbackMode
+from python_agent.extractor import DirectionsExtractorSchema
+from python_agent.extractor import LocalSearchExtractorSchema
+from python_agent.merger import merge_template
+from python_agent.router_config import IntentClass
+from python_agent.router_config import ROUTER_SYSTEM_INSTRUCTION
+from python_agent.router_config import RouterClassification
+
+logger = logging.getLogger(__name__)
+_SKILL_BASE_PATH = pathlib.Path(__file__).parent / "skills"
+_SHARED_INSTRUCTIONS_PATH = (
+ pathlib.Path(__file__).parent / "shared" / "instructions"
+)
+_LOCAL_SEARCH_SKILL_NAME = "local-search-template-response"
+_LOCAL_SEARCH_TEMPLATE_NAME = "local_search"
+_LOCAL_SEARCH_SURFACE_PREFIX = "local-search-surface"
+
+_DIRECTIONS_SKILL_NAME = "directions-template-response"
+_DIRECTIONS_TEMPLATE_NAME = "directions"
+_DIRECTIONS_SURFACE_PREFIX = "directions-surface"
+
+_EXTRACTOR_SCHEMAS = {
+ _LOCAL_SEARCH_SKILL_NAME: LocalSearchExtractorSchema,
+ _DIRECTIONS_SKILL_NAME: DirectionsExtractorSchema,
+}
+_SUPPORTED_INTENTS = {IntentClass.LOCAL_SEARCH, IntentClass.DIRECTIONS}
+
+
+class MAUIAgentWithTemplates(MAUIAgent):
+ """MAUI Agent extending base with server-side layout templates and query intent routing."""
+
+ def __init__(self, base_url: str, config: AgentConfig | None = None) -> None:
+ self.config = config or AgentConfig()
+ super().__init__(base_url=base_url, model_name=self.config.generic_model)
+ self.router_client = LiteLlm(model=self.config.router_model)
+ self.extractor_client = LiteLlm(model=self.config.template_model)
+
+ def _build_runner(self, agent: LlmAgent) -> Runner:
+ runner = super()._build_runner(agent)
+ # The extractor agent runs inside a dynamically created runner.
+ # We must enable auto_create_session to prevent SessionNotFoundError
+ # since we don't pre-create the session for this runner.
+ runner.auto_create_session = True
+ return runner
+
+ def _on_tool_error(
+ self,
+ tool: Any,
+ args: dict[str, Any],
+ tool_context: Any,
+ error: Exception,
+ ) -> dict[str, Any] | None:
+ """Callback for tool errors during extraction."""
+ # pylint: disable=unused-argument
+ if tool.name == "set_model_response" and isinstance(
+ error, pydantic.ValidationError
+ ):
+ logger.warning(
+ "Extractor tool '%s' failed validation: %s. "
+ "Returning error to model for self-correction.",
+ tool.name,
+ error,
+ )
+ return {
+ "error": (
+ f"Validation failed: {error}. Please correct the arguments"
+ " and try again."
+ )
+ }
+ return None
+
+ def _build_dynamic_extractor_agent(
+ self,
+ skill_name: str,
+ schema_manager: A2uiSchemaManager | None = None,
+ ) -> LlmAgent:
+ """Builds an extractor agent loaded directly with the target skill's prompt."""
+ skill_dir = _SKILL_BASE_PATH / skill_name
+ skill = adk_skills.load_skill_from_dir(skill_dir)
+ skill_instructions = skill.instructions
+ shared_guidelines_path = (
+ _SHARED_INSTRUCTIONS_PATH / "shared_style_guidelines.md"
+ )
+ if shared_guidelines_path.exists():
+ try:
+ with open(shared_guidelines_path, "r", encoding="utf-8") as f:
+ shared_guidelines = f.read()
+ skill_instructions = f"{skill_instructions}\n\n{shared_guidelines}"
+ except (OSError, ValueError) as e:
+ logger.warning("Failed to load shared style guidelines: %s", e)
+
+ # Extractors use template_model, generic UI uses generic_model
+ if skill_name.endswith("-template-response"):
+ model_name = self.config.template_model
+ else:
+ model_name = self.config.generic_model
+
+ logger.info(
+ f"Building extractor agent for '{skill_name}' using model: {model_name}"
+ )
+
+ tools = [self.make_grounding_lite_mcp()]
+ output_schema = _EXTRACTOR_SCHEMAS.get(skill_name)
+
+ generate_content_config = None
+ if output_schema:
+ # Manually inject SetModelResponseTool
+ set_response_tool = SetModelResponseTool(output_schema)
+ tools.append(set_response_tool)
+
+ # Manually append instruction
+ workaround_instruction = (
+ "IMPORTANT: You have access to other tools, but you must provide"
+ " your final response using the set_model_response tool with the"
+ " required structured format. After using any other tools needed to"
+ " complete the task, always call set_model_response with your final"
+ " answer in the specified schema format."
+ )
+ if skill_name == _LOCAL_SEARCH_SKILL_NAME:
+ workaround_instruction += (
+ "\nCRITICAL CONSTRAINT: You MUST extract and display at most"
+ f" {self.config.max_list_size} of the most relevant places. Do not"
+ " mention, recommend, or extract more than"
+ f" {self.config.max_list_size} places in your text response or your"
+ " set_model_response tool call."
+ )
+ skill_instructions = f"{skill_instructions}\n\n{workaround_instruction}"
+
+ if self.config.extractor_thinking_budget > 0:
+ generate_content_config = types.GenerateContentConfig(
+ thinking_config=types.ThinkingConfig(
+ thinking_budget=self.config.extractor_thinking_budget
+ )
+ )
+ logger.info(
+ "Applying template extractor thinking budget limit:"
+ f" {self.config.extractor_thinking_budget} tokens"
+ )
+
+ if schema_manager:
+ instruction = schema_manager.generate_system_prompt(
+ role_description=skill_instructions,
+ include_schema=True,
+ include_examples=False,
+ validate_examples=False,
+ )
+ else:
+ instruction = skill_instructions
+
+ return LlmAgent(
+ model=LiteLlm(model=model_name),
+ name="maui_agent",
+ description="An extractor agent executing specific Maps tool tasks",
+ instruction=instruction,
+ tools=tools,
+ output_schema=None, # Keep output_schema as None in LlmAgent
+ generate_content_config=generate_content_config,
+ on_tool_error_callback=self._on_tool_error,
+ )
+
+ async def _run_extractor(
+ self,
+ runner: Any,
+ agent: LlmAgent,
+ current_message: types.Content,
+ session_id: str,
+ ) -> tuple[dict[str, Any] | None, list[str]]:
+ """Runs the extractor agent and collects its output (structured or text)."""
+ parsed_json_data = None
+ full_content_list = []
+
+ async for event in runner.run_async(
+ user_id=self._user_id,
+ session_id=session_id,
+ run_config=run_config.RunConfig(
+ streaming_mode=run_config.StreamingMode.SSE
+ ),
+ new_message=current_message,
+ # Initialize session state.
+ # "expression" is required to prevent KeyError during ADK's prompt
+ # state injection, as the A2UI catalog schema contains "${expression}"
+ # placeholders. "base_url" is passed for consistency with the main
+ # agent session state.
+ state_delta={
+ "expression": "{expression}",
+ "base_url": self.base_url,
+ },
+ ):
+ if hasattr(event, "get_function_calls"):
+ for fc in event.get_function_calls():
+ if fc.name == "set_model_response":
+ logger.info(
+ "Intercepted set_model_response tool call with args: %s",
+ fc.args,
+ )
+
+ # Find SetModelResponseTool in agent tools
+ target_tool = None
+ for t in agent.tools:
+ if getattr(t, "name", None) == "set_model_response":
+ target_tool = t
+ break
+
+ if target_tool and hasattr(target_tool, "run_async"):
+ try:
+ noop_tool_context = SimpleNamespace(
+ actions=SimpleNamespace(set_model_response=None)
+ )
+ validated_data = await target_tool.run_async(
+ args=fc.args, tool_context=noop_tool_context
+ )
+ # SetModelResponseTool.run_async catches ValidationError internally
+ # and returns a dict with "error" key instead of raising the exception.
+ if (
+ isinstance(validated_data, dict)
+ and "error" in validated_data
+ ):
+ logger.warning(
+ "Local Pydantic validation failed: %s. Continuing.",
+ validated_data["error"],
+ )
+ else:
+ parsed_json_data = validated_data
+ logger.info(
+ "Local Pydantic validation passed! Short-circuiting."
+ )
+ break
+ except pydantic.ValidationError as e:
+ logger.warning(
+ "Local Pydantic validation failed: %s. Continuing.",
+ e,
+ )
+ else:
+ parsed_json_data = fc.args
+ break
+
+ if event.content and event.content.parts:
+ if event.partial:
+ for p in event.content.parts:
+ if p.text:
+ full_content_list.append(p.text)
+ else:
+ full_content_list.clear()
+ for p in event.content.parts:
+ if p.text:
+ full_content_list.append(p.text)
+
+ return parsed_json_data, full_content_list
+
+ async def _run_extractor_and_merge(
+ self,
+ skill_name: str,
+ template_name: str,
+ surface_id_prefix: str,
+ cleaned_query: str,
+ session_id: str,
+ ui_version: str | None = None,
+ ) -> tuple[list[Part] | None, str | None, dict[str, Any] | None]:
+ """Runs the dynamic extractor agent and merges output into the template."""
+ # 1. Resolve catalog schema manager and validator
+ schema_manager = self._schema_managers.get(ui_version)
+ selected_catalog = None
+ if schema_manager:
+ # Retrieve the resolved catalog config for validation.
+ # Replacing the deprecated get_catalog("maps-agentic-ui-catalog")
+ # API call.
+ selected_catalog = schema_manager.get_selected_catalog()
+
+ # 2. Build the extractor agent and runner
+ agent = self._build_dynamic_extractor_agent(
+ skill_name,
+ schema_manager=schema_manager,
+ )
+ runner = self._build_runner(agent)
+
+ # 3. Setup user query message
+ current_message = types.Content(
+ role="user", parts=[types.Part.from_text(text=cleaned_query)]
+ )
+
+ # 4. Run extractor runner, collecting output
+ parsed_json_data, full_content_list = await self._run_extractor(
+ runner, agent, current_message, session_id
+ )
+
+ # 5. Handle output layout merging
+ if parsed_json_data is not None:
+ logger.info(
+ "Template parameters extracted successfully. Merging template."
+ )
+ if "surface_id" not in parsed_json_data:
+ short_id = uuid.uuid4().hex[:8]
+ parsed_json_data["surface_id"] = f"{surface_id_prefix}-{short_id}"
+
+ merged_actions = merge_template(
+ template_name,
+ parsed_json_data,
+ max_list_size=self.config.max_list_size,
+ )
+
+ if selected_catalog:
+ logger.info("Validating merged template against A2UI catalog schema.")
+ try:
+ selected_catalog.validator.validate(merged_actions)
+ except Exception as e: # pylint: disable=broad-exception-caught
+ logger.warning("Catalog validation failed: %s. Falling back.", e)
+ return None, None, None
+
+ final_parts = [create_a2ui_part(action) for action in merged_actions]
+ return final_parts, None, parsed_json_data
+ else:
+ raw_text = "".join(full_content_list)
+ return None, raw_text, None
+
+ async def stream(
+ self, query: str, session_id: str, ui_version: str | None = None
+ ) -> AsyncIterable[dict[str, Any]]:
+ """Streams responses, routing via intent classifier to template extractors.
+
+ Args:
+ query: User input query string.
+ session_id: Context session ID.
+ ui_version: A2UI protocol version if requested.
+
+ Yields:
+ Update dictionaries compatible with A2A TaskExecutor.
+ """
+ if not ui_version:
+ logger.info("No ui_version provided. Routing to base text streaming.")
+ async for part in super().stream(query, session_id, ui_version):
+ yield part
+ return
+
+ intent, cleaned_query = await self._classify_intent(query)
+
+ if intent == IntentClass.OTHER_SPATIAL:
+ if self.config.fallback_mode == FallbackMode.DYNAMIC:
+ logger.warning(
+ "Router matched OTHER_SPATIAL and fallback_mode is DYNAMIC. "
+ "Falling back to Dynamic UI flow."
+ )
+ async for part in super().stream(query, session_id, ui_version):
+ yield part
+ return
+ else:
+ logger.info(
+ "Router matched OTHER_SPATIAL and fallback_mode is TEXT. "
+ "Executing fast text response flow."
+ )
+ final_parts = await self._handle_text_only(cleaned_query, session_id)
+ yield {
+ "is_task_complete": True,
+ "parts": final_parts,
+ }
+ return
+
+ elif intent == IntentClass.TEXT_ONLY:
+ logger.info("Executing fast text response flow for TEXT_ONLY intent.")
+ final_parts = await self._handle_text_only(cleaned_query, session_id)
+ yield {
+ "is_task_complete": True,
+ "parts": final_parts,
+ }
+ return
+
+ elif intent in _SUPPORTED_INTENTS:
+ async for part in self._handle_extracted_intent(
+ intent, cleaned_query, session_id, ui_version
+ ):
+ yield part
+ return
+
+ # Fallback for un-implemented spatial intents
+ logger.warning(
+ "Intent %s not supported by template extractors. Falling back to base"
+ " UI stream.",
+ intent,
+ )
+ async for part in super().stream(query, session_id, ui_version):
+ yield part
+
+ async def _classify_intent(self, query: str) -> tuple[IntentClass, str]:
+ """Classifies the query intent and returns the intent and cleaned query."""
+ logger.info(
+ "Routing query: '%s' using model %s",
+ query,
+ self.config.router_model,
+ )
+ try:
+ router_config = {
+ "system_instruction": ROUTER_SYSTEM_INSTRUCTION,
+ "response_mime_type": "application/json",
+ "response_schema": RouterClassification,
+ }
+ if self.config.router_thinking_budget > 0:
+ router_config["thinking_config"] = types.ThinkingConfig(
+ thinking_budget=self.config.router_thinking_budget
+ )
+
+ req = LlmRequest(
+ contents=[
+ types.Content(
+ role="user", parts=[types.Part.from_text(text=query)]
+ )
+ ],
+ config=types.GenerateContentConfig(**router_config),
+ )
+
+ router_response_text = ""
+ async for res in self.router_client.generate_content_async(req):
+ if res.content and res.content.parts:
+ for p in res.content.parts:
+ if p.text:
+ router_response_text += p.text
+
+ logger.info("Router response content: %s", router_response_text)
+ classification = RouterClassification.model_validate_json(
+ router_response_text
+ )
+ intent = classification.intent
+ cleaned_query = classification.query
+ logger.info(
+ "Intent classified: %s (Cleaned Query: '%s')", intent, cleaned_query
+ )
+ return intent, cleaned_query
+ except Exception as e: # pylint: disable=broad-exception-caught
+ logger.warning(
+ "Intent routing failed: %s. Defaulting to TEXT_ONLY.",
+ e,
+ exc_info=True,
+ )
+ return IntentClass.TEXT_ONLY, query
+
+ def _wrap_in_text_only(self, text: str, session_id: str) -> list[Part]:
+ """Wraps plain text in a text_only template Part list."""
+ short_id = uuid.uuid4().hex[:8]
+ merged_actions = merge_template(
+ "text_only",
+ {
+ "text": text,
+ "surface_id": f"text-only_{session_id}-{short_id}",
+ },
+ )
+ return [create_a2ui_part(action) for action in merged_actions]
+
+ async def _handle_text_only(
+ self, cleaned_query: str, session_id: str
+ ) -> list[Part]:
+ """Generates a plain text response and wraps it in the text_only template.
+
+ Args:
+ cleaned_query: The cleaned user query.
+ session_id: Context session ID.
+
+ Returns:
+ List of A2A Parts.
+ """
+ extractor_config = {
+ "system_instruction": (
+ "You are a helpful location assistant. Answer the user's"
+ " question directly. Keep it relatively concise. Do NOT"
+ " output A2UI tags."
+ ),
+ }
+ if self.config.extractor_thinking_budget > 0:
+ extractor_config["thinking_config"] = types.ThinkingConfig(
+ thinking_budget=self.config.extractor_thinking_budget
+ )
+
+ answer_req = LlmRequest(
+ contents=[
+ types.Content(
+ role="user",
+ parts=[types.Part.from_text(text=cleaned_query)],
+ )
+ ],
+ config=types.GenerateContentConfig(**extractor_config),
+ )
+
+ answer_text = ""
+ async for res in self.extractor_client.generate_content_async(answer_req):
+ if res.content and res.content.parts:
+ for p in res.content.parts:
+ if p.text:
+ answer_text += p.text
+
+ return self._wrap_in_text_only(answer_text, session_id)
+
+ async def _handle_extracted_intent(
+ self,
+ intent: IntentClass,
+ query: str,
+ session_id: str,
+ ui_version: str | None = None,
+ ) -> AsyncIterable[dict[str, Any]]:
+ """Handles intents that use dynamic extractor agents and templates."""
+ if intent == IntentClass.LOCAL_SEARCH:
+ skill_name = _LOCAL_SEARCH_SKILL_NAME
+ template_name = _LOCAL_SEARCH_TEMPLATE_NAME
+ surface_prefix = _LOCAL_SEARCH_SURFACE_PREFIX
+ elif intent == IntentClass.DIRECTIONS:
+ skill_name = _DIRECTIONS_SKILL_NAME
+ template_name = _DIRECTIONS_TEMPLATE_NAME
+ surface_prefix = _DIRECTIONS_SURFACE_PREFIX
+ else:
+ raise ValueError(f"Unsupported intent for extractor: {intent}")
+
+ logger.info("Router matched %s. Dispatching template extractor.", intent)
+
+ merged_parts, fallback_text, parsed_json_data = (
+ await self._run_extractor_and_merge(
+ skill_name=skill_name,
+ template_name=template_name,
+ surface_id_prefix=surface_prefix,
+ cleaned_query=query,
+ session_id=session_id,
+ ui_version=ui_version,
+ )
+ )
+
+ if merged_parts is not None:
+ yield {
+ "is_task_complete": True,
+ "parts": merged_parts,
+ }
+ return
+ else:
+ logger.warning(
+ "Template extraction failed for intent %s. "
+ "Always falling back to plain text response.",
+ intent,
+ )
+ if not fallback_text:
+ final_parts = await self._handle_text_only(query, session_id)
+ else:
+ final_parts = self._wrap_in_text_only(fallback_text, session_id)
+ yield {
+ "is_task_complete": True,
+ "parts": final_parts,
+ }
+ return
diff --git a/agent/python_agent/extractor.py b/agent/python_agent/extractor.py
new file mode 100644
index 0000000..bd38574
--- /dev/null
+++ b/agent/python_agent/extractor.py
@@ -0,0 +1,200 @@
+# 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
+#
+# https://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.
+
+"""Pydantic schemas for structured data extraction from LLM responses."""
+
+from typing import Any, Literal
+import pydantic
+
+BaseModel = pydantic.BaseModel
+Field = pydantic.Field
+
+
+class Pin(BaseModel):
+ """Representation of a Map Pin."""
+
+ lat: float = Field(description="Latitude coordinate")
+ lng: float = Field(description="Longitude coordinate")
+ label: str = Field(
+ description=(
+ "Descriptive display label string (e.g. name of address, business, or"
+ " landmark)"
+ )
+ )
+ # Note: Using camelCase field name to match frontend A2UI requirements.
+ placeId: str | None = Field( # pylint: disable=invalid-name
+ default=None, description="Optional Google Maps Place ID"
+ )
+
+ @pydantic.model_validator(mode="before")
+ @classmethod
+ def normalize_label(cls, data: Any) -> Any:
+ """Normalizes the pin label.
+
+ If 'label' is missing but 'name' is present, copies 'name' to 'label'.
+ If 'label' is still empty, defaults to 'Location' to ensure
+ the UI always has a valid string to render for the marker (avoiding raw
+ Place IDs).
+
+ Args:
+ data: The input dictionary before validation.
+
+ Returns:
+ The normalized dictionary.
+ """
+ if isinstance(data, dict):
+ if "label" not in data and "name" in data:
+ data["label"] = data["name"]
+ if not data.get("label"):
+ data["label"] = "Location"
+ return data
+
+
+class PlacePin(BaseModel):
+ """Simplified Map Pin representation for search results."""
+
+ # Note: Using camelCase field name to match frontend A2UI requirements.
+ # ADK's SetModelResponseTool serialization dumps using field names
+ # without aliases.
+ placeId: str = Field( # pylint: disable=invalid-name
+ description="The unique Google Maps Place ID"
+ )
+ name: str = Field(description="Name of the place")
+ lat: float = Field(description="Latitude coordinates")
+ lng: float = Field(description="Longitude coordinates")
+
+
+class LocalSearchExtractorSchema(BaseModel):
+ """Structured parameters to render a local search UI update."""
+
+ summary: str = Field(
+ description=(
+ "A detailed response summarizing the search results, answering the"
+ " user's query fully. Use markdown formatting (bullet points,"
+ " bolding, tables) and break into paragraphs as needed. Bold place"
+ " names."
+ )
+ )
+ center_lat: float = Field(description="Latitude of the center of results")
+ center_lng: float = Field(description="Longitude of the center of results")
+ zoom: int = Field(
+ default=13, description="Recommended map zoom level (typically 13)"
+ )
+ places: list[PlacePin] = Field(
+ description="A list of places found (limit to max list size, e.g. 3)"
+ )
+ anchor_marker: Pin | None = Field(
+ default=None,
+ description=(
+ "Optional starting or focus point marker (e.g. hotel location)"
+ ),
+ )
+
+
+class RouteSegment(BaseModel):
+ """A segment of a route, containing an origin and a destination pin."""
+
+ origin: Pin = Field(description="The starting location pin of this segment")
+ destination: Pin = Field(
+ description="The ending location pin of this segment"
+ )
+
+
+TRAVEL_MODE_MAP: dict[str, str] = {
+ "walk": "walking",
+ "walking": "walking",
+ "pedestrian": "walking",
+ "foot": "walking",
+ "on foot": "walking",
+ "on_foot": "walking",
+ "drive": "driving",
+ "driving": "driving",
+ "car": "driving",
+ "auto": "driving",
+ "automobile": "driving",
+ "bike": "bicycling",
+ "biking": "bicycling",
+ "bicycling": "bicycling",
+ "cycling": "bicycling",
+ "bicycle": "bicycling",
+ "transit": "transit",
+ "bus": "transit",
+ "train": "transit",
+ "subway": "transit",
+ "tube": "transit",
+ "metro": "transit",
+ "tram": "transit",
+ "rail": "transit",
+ "light rail": "transit",
+ "ferry": "transit",
+ "public transit": "transit",
+ "public_transit": "transit",
+ "public transport": "transit",
+ "public_transport": "transit",
+}
+
+
+def normalize_travel_mode(mode: Any) -> str | None:
+ """Normalizes a raw travel mode string to a canonical travel mode."""
+ if not mode:
+ return None
+ return TRAVEL_MODE_MAP.get(str(mode).lower().strip())
+
+
+class DirectionsExtractorSchema(BaseModel):
+ """Structured parameters to render a directions UI update."""
+
+ summary: str = Field(
+ description=(
+ "A detailed response summarizing the travel directions, including"
+ " key steps, estimated time, and travel mode. Use markdown"
+ " formatting and break into paragraphs if helpful."
+ )
+ )
+ center_lat: float = Field(
+ description="Latitude of the center of the route map"
+ )
+ center_lng: float = Field(
+ description="Longitude of the center of the route map"
+ )
+ zoom: int = Field(
+ default=12, description="Recommended map zoom level (typically 12)"
+ )
+ routes: list[RouteSegment] = Field(
+ default_factory=list,
+ description=(
+ "A list of route segments connecting the origin, intermediate"
+ " waypoints, and the destination in order."
+ ),
+ )
+ travel_mode: Literal["driving", "walking", "transit", "bicycling"] = Field(
+ description=(
+ "The transit travel mode, one of: driving, walking, transit,"
+ " bicycling. Must match the user's requested travel mode."
+ ),
+ )
+
+ @pydantic.model_validator(mode="before")
+ @classmethod
+ def normalize_directions_data(cls, data: Any) -> Any:
+ """Normalizes travel mode in directions data."""
+ if not isinstance(data, dict):
+ return data
+
+ if "travel_mode" in data and data["travel_mode"]:
+ normalized = normalize_travel_mode(data["travel_mode"])
+ if normalized:
+ data["travel_mode"] = normalized
+
+ return data
diff --git a/agent/python_agent/merger.py b/agent/python_agent/merger.py
new file mode 100644
index 0000000..ce95df8
--- /dev/null
+++ b/agent/python_agent/merger.py
@@ -0,0 +1,330 @@
+# 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.
+
+"""Server-Side A2UI Layout Template Merger (`merger.py`).
+
+This module loads declarative JSON layout skeletons (`templates/*.json`),
+populates them with extracted parameter dictionaries (`data`), and returns
+sanitized message structures ready for wire transmission.
+"""
+
+import copy
+import json
+import os
+from typing import Any, Literal, TypedDict
+import uuid
+
+from python_agent.extractor import normalize_travel_mode
+
+
+class TextOutputDict(TypedDict):
+ type: Literal["text"]
+ text: str
+
+
+SurfaceActionDict = TypedDict(
+ "SurfaceActionDict",
+ {
+ "surfaceId": str,
+ "catalogId": str,
+ "root": str,
+ "components": list[dict[str, Any]],
+ "path": str,
+ "value": Any,
+ },
+ total=False,
+)
+
+
+SurfaceOutputDict = TypedDict(
+ "SurfaceOutputDict",
+ {
+ "version": str,
+ "createSurface": SurfaceActionDict,
+ "updateComponents": SurfaceActionDict,
+ "updateDataModel": SurfaceActionDict,
+ "deleteSurface": SurfaceActionDict,
+ },
+ total=False,
+)
+
+
+MergedMessage = TextOutputDict | SurfaceOutputDict
+
+
+def _replace_placeholders(obj: Any, data: dict[str, Any]) -> Any:
+ """Recursively replaces string placeholders in the template with values from data."""
+ if isinstance(obj, str):
+ # Exact match: "{{key}}"
+ if obj.startswith("{{") and obj.endswith("}}") and obj.count("{{") == 1:
+ key = obj[2:-2]
+ if key in data:
+ return data[key]
+ return None
+ # Partial match: string formatting
+ else:
+ resolved = obj
+ for key, value in data.items():
+ placeholder = f"{{{{{key}}}}}"
+ if placeholder in resolved:
+ if isinstance(value, (str, int, float, bool)):
+ resolved = resolved.replace(placeholder, str(value))
+ return resolved
+ elif isinstance(obj, list):
+ return [_replace_placeholders(item, data) for item in obj]
+ elif isinstance(obj, dict):
+ return {k: _replace_placeholders(v, data) for k, v in obj.items()}
+ else:
+ return obj
+
+
+def _remove_none_values(val: Any) -> Any:
+ """Recursively removes None values from dicts and lists to satisfy JSON schemas."""
+ if isinstance(val, dict):
+ return {k: _remove_none_values(v) for k, v in val.items() if v is not None}
+ elif isinstance(val, list):
+ return [_remove_none_values(item) for item in val if item is not None]
+ return val
+
+
+def _prepare_local_search(
+ data: dict[str, Any], max_list_size: int
+) -> tuple[str, dict[str, Any]]:
+ """Validates and normalizes parameters for the local search template."""
+ data_copy = copy.deepcopy(data)
+ is_valid = True
+ places = data_copy.get("places")
+
+ # 1. Validate that places is a non-empty list
+ if not isinstance(places, list) or not places:
+ is_valid = False
+ else:
+ # Slice and sanitize places list
+ sanitized_places = []
+ for p in places:
+ if isinstance(p, dict):
+ try:
+ p["lat"] = float(p["lat"])
+ p["lng"] = float(p["lng"])
+ sanitized_places.append(p)
+ except (KeyError, ValueError, TypeError):
+ pass
+ if not sanitized_places:
+ is_valid = False
+ else:
+ data_copy["places"] = sanitized_places[:max_list_size]
+
+ # 2. Validate mandatory map centering and zoom parameters
+ if is_valid:
+ try:
+ data_copy["center_lat"] = float(data_copy["center_lat"])
+ data_copy["center_lng"] = float(data_copy["center_lng"])
+ data_copy["zoom"] = int(data_copy["zoom"])
+ except (KeyError, ValueError, TypeError):
+ is_valid = False
+
+ # 3. Sanitize optional anchor marker
+ if is_valid and "anchor_marker" in data_copy:
+ pin = data_copy["anchor_marker"]
+ if isinstance(pin, dict):
+ try:
+ pin["lat"] = float(pin["lat"])
+ pin["lng"] = float(pin["lng"])
+ except (KeyError, ValueError, TypeError):
+ data_copy["anchor_marker"] = None
+ else:
+ data_copy["anchor_marker"] = None
+
+ # 4. Unroll maps markers array
+ if is_valid:
+ if "markers" not in data_copy:
+ markers = []
+ for p in data_copy["places"]:
+ marker = {
+ "lat": p["lat"],
+ "lng": p["lng"],
+ "label": p.get("name") or p.get("label") or "",
+ }
+ if "placeId" in p:
+ marker["placeId"] = p["placeId"]
+ markers.append(marker)
+ data_copy["markers"] = markers
+ else:
+ markers = data_copy["markers"]
+ if isinstance(markers, list):
+ sanitized_markers = []
+ for m in markers:
+ if isinstance(m, dict):
+ try:
+ m["lat"] = float(m["lat"])
+ m["lng"] = float(m["lng"])
+ m["label"] = str(m.get("label") or "")
+ sanitized_markers.append(m)
+ except (KeyError, ValueError, TypeError):
+ pass
+ data_copy["markers"] = sanitized_markers
+ else:
+ data_copy["markers"] = None
+
+ # If validation failed, fallback to text_only
+ if not is_valid:
+ return "text_only", {
+ "text": (
+ data.get("summary")
+ or "No places matching your query could be found."
+ ),
+ "surface_id": data_copy.get("surface_id") or "fallback-surface",
+ }
+
+ return "local_search", data_copy
+
+
+def _prepare_directions(data: dict[str, Any]) -> tuple[str, dict[str, Any]]:
+ """Validates and normalizes parameters for the directions template."""
+ data_copy = copy.deepcopy(data)
+ is_valid = True
+
+ routes = data_copy.get("routes")
+
+ # 1. Validate that routes is a non-empty list of segment dicts
+ if not isinstance(routes, list) or not routes:
+ is_valid = False
+ else:
+ sanitized_routes = []
+ for segment in routes:
+ if not isinstance(segment, dict):
+ is_valid = False
+ break
+ origin = segment.get("origin")
+ destination = segment.get("destination")
+ if not isinstance(origin, dict) or not isinstance(destination, dict):
+ is_valid = False
+ break
+ try:
+ sanitized_origin = {
+ "lat": float(origin["lat"]),
+ "lng": float(origin["lng"]),
+ "label": str(origin.get("label") or ""),
+ }
+ if "placeId" in origin:
+ sanitized_origin["placeId"] = str(origin["placeId"])
+
+ sanitized_destination = {
+ "lat": float(destination["lat"]),
+ "lng": float(destination["lng"]),
+ "label": str(destination.get("label") or ""),
+ }
+ if "placeId" in destination:
+ sanitized_destination["placeId"] = str(destination["placeId"])
+
+ sanitized_routes.append({
+ "origin": sanitized_origin,
+ "destination": sanitized_destination,
+ })
+ except (KeyError, ValueError, TypeError):
+ is_valid = False
+ break
+ data_copy["routes"] = sanitized_routes
+
+ # 2. Validate mandatory map centering and zoom parameters
+ if is_valid:
+ try:
+ data_copy["center_lat"] = float(data_copy["center_lat"])
+ data_copy["center_lng"] = float(data_copy["center_lng"])
+ data_copy["zoom"] = int(data_copy["zoom"])
+ except (KeyError, ValueError, TypeError):
+ is_valid = False
+
+ # 3. Normalize travel_mode
+ if is_valid and "travel_mode" in data_copy:
+ normalized = normalize_travel_mode(data_copy["travel_mode"])
+ if normalized:
+ data_copy["travel_mode"] = normalized
+ else:
+ del data_copy["travel_mode"]
+
+ # If validation failed, fallback to text_only
+ if not is_valid:
+ return "text_only", {
+ "text": data.get("summary") or "Could not calculate travel directions.",
+ "surface_id": data_copy.get("surface_id") or "fallback-surface",
+ }
+
+ return "directions", data_copy
+
+
+def merge_template(
+ template_name: str, data: dict[str, Any], max_list_size: int = 5
+) -> list[MergedMessage]:
+ """Loads static template skeleton JSON and returns merged wire response.
+
+ This function sanitizes extracted parameters and populates the layout
+ template skeleton.
+
+ Args:
+ template_name: Target declarative layout skeleton (`local_search`,
+ `directions`, or `text_only`).
+ data: Raw dictionary of extracted parameters yielded by
+ `TemplateExtractor` (e.g. `places`, `summary`, `center_lat`).
+ max_list_size: Maximum allowable child elements in lists (`places`) to
+ bound payload rendering latency.
+
+ Returns:
+ A list of message dictionaries. For `text_only`, returns the 2-part A2UI
+ layout (`createSurface`, `updateComponents`).
+ For `local_search` and `directions`, returns the 3-part A2UI layout
+ (`createSurface`, `updateComponents`, `updateDataModel`) (`DataPart`).
+
+ Raises:
+ FileNotFoundError: If the template file cannot be found.
+ """
+
+ # Deep copy data to prevent unintended side-effects on caller dictionaries
+ # across turns
+ data_copy = copy.deepcopy(data)
+
+ if template_name == "local_search":
+ template_name, data_copy = _prepare_local_search(data_copy, max_list_size)
+ elif template_name == "directions":
+ template_name, data_copy = _prepare_directions(data_copy)
+ current_dir = os.path.dirname(os.path.abspath(__file__))
+ templates_dir = os.path.join(current_dir, "templates")
+ template_path = os.path.join(templates_dir, f"{template_name}.json")
+
+ if not os.path.exists(template_path):
+ raise FileNotFoundError(
+ f"Template '{template_name}' not found at {template_path}"
+ )
+
+ with open(template_path, "r") as f:
+ template_json = json.load(f)
+
+ # Smart Turn-Unique `surface_id` Scoping via Dynamic Template Discovery:
+ default_surface_ids = set()
+ if os.path.exists(templates_dir):
+ for fn in os.listdir(templates_dir):
+ if fn.endswith(".json"):
+ base_name = fn[:-5]
+ default_surface_ids.add(f"{base_name}_surface")
+ default_surface_ids.add(f"{base_name.replace('_', '-')}-surface")
+
+ if (
+ not data_copy.get("surface_id")
+ or data_copy.get("surface_id") in default_surface_ids
+ ):
+ base_id = data_copy.get("surface_id") or f"{template_name}_surface"
+ data_copy["surface_id"] = f"{base_id}_{uuid.uuid4().hex[:6]}"
+
+ resolved_json = _replace_placeholders(template_json, data_copy)
+ return _remove_none_values(resolved_json)
diff --git a/agent/python_agent/router_config.py b/agent/python_agent/router_config.py
new file mode 100644
index 0000000..4e137f0
--- /dev/null
+++ b/agent/python_agent/router_config.py
@@ -0,0 +1,88 @@
+# 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
+#
+# https://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.
+"""Configuration for the Intent Router, including prompts and schemas."""
+
+import enum
+import pydantic
+
+
+class IntentClass(str, enum.Enum):
+ """Supported intent categories for routing."""
+
+ LOCAL_SEARCH = "LOCAL_SEARCH"
+ DIRECTIONS = "DIRECTIONS"
+ OTHER_SPATIAL = "OTHER_SPATIAL"
+ TEXT_ONLY = "TEXT_ONLY"
+
+
+class RouterClassification(pydantic.BaseModel):
+ """Schema for the classification output of the intent router."""
+
+ intent: IntentClass = pydantic.Field(
+ description="The classified intent archetype name."
+ )
+ query: str = pydantic.Field(description="The cleaned user query")
+
+
+ROUTER_SYSTEM_INSTRUCTION = """
+## Role
+You are an expert query intent router.
+
+## Task Definition
+Analyze a user's input and classify it into the most appropriate intent category based on the structural complexity and data requirements of the request.
+
+## Intent Archetypes
+- **LOCAL_SEARCH**: Search for categories of interest, places, businesses, or points of interest within a specific geographic proximity.
+- **DIRECTIONS**: Standard navigation, route directions, walking/driving/transit times, or navigation instructions between an origin and destination. This includes multi-stop routes and routes with specified waypoints.
+- **OTHER_SPATIAL**: Queries requiring rich map-based visualization, boundaries, coordinates, specific geographic displays, or complex navigation combining routes with secondary overlays (e.g., weather, air quality forecasts, displaying all available charging stations along a route).
+- **TEXT_ONLY**: General information retrieval, questions, or requests for data associated with locations that can be answered fully with text without requiring a map interface.
+
+## Classification Policy: Conservative Routing
+If a query satisfies the structure of a base intent (such as DIRECTIONS or LOCAL_SEARCH) but also includes any Auxiliary Data (e.g., weather forecasts, environmental factors), you MUST promote the classification to OTHER_SPATIAL.
+Note: Simple multi-stop routes or routes with specified waypoints (e.g., "A to B via C") should be classified as DIRECTIONS, unless they require searching for stops along the way (e.g., "find coffee shops along the route") which requires LOCAL_SEARCH overlays and should be promoted to OTHER_SPATIAL.
+
+## Few-shot Examples
+
+### Example 1
+**User Query:** "Find coffee shops near Central Park"
+**Output:**
+{
+ "intent": "LOCAL_SEARCH",
+ "query": "coffee shops near Central Park"
+}
+
+### Example 2
+**User Query:** "Walking route from Central Park to Times Square, but show coffee shops and rain forecasts along the way"
+**Output:**
+{
+ "intent": "OTHER_SPATIAL",
+ "query": "walking route from Central Park to Times Square with coffee shop stops and rain forecast"
+}
+
+### Example 3
+**User Query:** "show the boundary of Yosemite National Park on the map"
+**Output:**
+{
+ "intent": "OTHER_SPATIAL",
+ "query": "boundary of Yosemite National Park"
+}
+
+### Example 4
+**User Query:** "Directions from Sacramento to Mendocino via Clear Lake"
+**Output:**
+{
+ "intent": "DIRECTIONS",
+ "query": "Directions from Sacramento to Mendocino via Clear Lake"
+}
+"""
diff --git a/agent/python_agent/shared/instructions/shared_style_guidelines.md b/agent/python_agent/shared/instructions/shared_style_guidelines.md
new file mode 100644
index 0000000..f27e0b4
--- /dev/null
+++ b/agent/python_agent/shared/instructions/shared_style_guidelines.md
@@ -0,0 +1,17 @@
+## Conversational Text Style Guidelines
+
+When generating conversational text (such as summaries, descriptions, or
+directions), you must follow these formatting and content rules:
+
+* **Content**: Always fully and clearly answer each aspect of the prompt.
+* **Quantity**: Make sure that the answer is useful and actionable. Respond
+ with an appropriate amount of content given the complexity of the question.
+ E.g., if helping someone differentiate between places, consider responding
+ with details about each place.
+* **Formatting**: Use markdown to apply formatting elements like bullet
+ points, bolding, and tables to break up the text. Break content into
+ multiple paragraphs as needed.
+* **Markdown**: Bold place names and provide links where appropriate.
+* **Titles and Headings**: Never title your response. You may include
+ mid-level headings (using `###` and below) to organize content when it adds
+ clarity.
diff --git a/agent/python_agent/shared/schema/maps_catalog_extension.json b/agent/python_agent/shared/schema/maps_catalog_extension.json
index 4e91e4b..699754f 100644
--- a/agent/python_agent/shared/schema/maps_catalog_extension.json
+++ b/agent/python_agent/shared/schema/maps_catalog_extension.json
@@ -87,7 +87,7 @@
],
"unevaluatedProperties": false
},
- "PlaceCard": {
+ "PlaceDetailsCompact": {
"type": "object",
"allOf": [
{
@@ -100,11 +100,19 @@
"type": "object",
"properties": {
"component": {
- "const": "PlaceCard"
+ "const": "PlaceDetailsCompact"
},
"placeId": {
"$ref": "common_types.json#/$defs/DynamicString",
"description": "The unique identifier for the place."
+ },
+ "orientation": {
+ "type": "string",
+ "enum": [
+ "horizontal",
+ "vertical"
+ ],
+ "description": "The orientation of this place card. Defaults to horizontal."
}
},
"required": [
diff --git a/agent/python_agent/skills/directions-template-response/SKILL.md b/agent/python_agent/skills/directions-template-response/SKILL.md
new file mode 100644
index 0000000..536c25e
--- /dev/null
+++ b/agent/python_agent/skills/directions-template-response/SKILL.md
@@ -0,0 +1,139 @@
+---
+name: directions-template-response
+description: Extractor skill for directions and routing queries. Extracts routing details for template merging.
+---
+
+# Directions Template Response Skill
+
+## Core Objective
+
+Extract structured parameters to populate the `DirectionsExtractorSchema` for
+rendering directions on a map.
+
+## Multi-Step Route Resolution Policy
+
+If the user's query requests a scenic bypass or detour:
+
+1. **Resolve Place Coordinates and IDs (Parallel Search)**: Concurrently search
+ for the origin, destination, and any identified detour waypoints along the
+ corridor.
+2. **Compute Route Segments (Parallel Routing)**: Concurrently compute routes
+ for all sequential legs connecting the resolved stops (Origin -> Waypoint,
+ Waypoint -> Destination).
+3. **Dispatch Response**: Call `set_model_response` with the compiled routes
+ and pins.
+
+## Step-by-Step Workflow
+
+1. **Analyze the Query & Constraints**:
+
+ * Identify the origin and destination names.
+ * **Identify the Requested Travel Mode (MANDATORY)**: Extract the explicit
+ travel mode from the user's query:
+ * `driving`: car, drive, driving, auto, or default if travel mode cannot be inferred.
+ * `walking`: walk, walking, on foot, pedestrian, foot.
+ * `bicycling`: bike, biking, bicycle, bicycling, cycling.
+ * `transit`: bus, train, subway, metro, tube, public transit.
+
+2. **Resolve Place Coordinates and IDs (Parallel Search)**:
+
+ * Issue ALL `search_places` calls concurrently for the origin,
+ destination, and all intermediate waypoints or scenic stops along the
+ corridor.
+ * **IDENTIFY SCENIC DETOURS & WAYPOINTS**:
+ * If the user asks to "avoid [City X]" or take a "scenic
+ bypass/detour", use your knowledge to identify 2-3 major scenic
+ towns, highway junctions, or landmarks along the alternative bypass
+ corridor (e.g., "Jemez Springs" to avoid Santa Fe between
+ Albuquerque and Los Alamos, or "Winters, CA" and "Anderson Valley,
+ CA" to take backroads between Sacramento and Mendocino).
+ * Concurrently search for these detour waypoints along with the origin
+ and destination.
+ * **STABLE SEARCH QUERIES**: Use specific place or town names (e.g.,
+ search for "Anderson Valley, CA" or "Jemez Springs, NM") instead of
+ generic road descriptors (e.g. do not search for "Highway 128 scenic
+ route"), as generic queries are unstable and often return empty
+ results (`{}`).
+ * Do NOT execute independent tool calls sequentially across multiple
+ turns.
+ * Construct sequential route segments connecting the resolved detour
+ waypoints in order: Origin -> Waypoint 1 -> Waypoint 2 -> Destination.
+
+3. **Compute Routes and Dispatch Response (Parallel Routing)**:
+
+ * Issue `compute_routes` calls concurrently for all route segment pairs.
+ * **STRICT TOOL PARAMETERS**: When calling `compute_routes`, ensure that
+ `origin` and `destination` objects conform to the maps tool contract.
+ Specify **exactly one** identifier:
+ * `placeId`: Use the resolved place ID (e.g., `{"placeId":
+ "ChIJ-ZeD..."}`). This is highly preferred if available.
+ * `address`: Use the address string.
+ * `location`: Use latitude/longitude inside a location sub-object
+ (e.g., `{"location": {"latLng": {"latitude": 37.7, "longitude":
+ -122.4}}}`). Do **NOT** pass `latLng` directly as a root key inside
+ `origin` or `destination` (e.g. do not call
+ `compute_routes(origin={"placeId": "...", "latLng": ...})`).
+ * Verify route availability for requested `travel_mode`.
+ * **CONSTRUCT THE ROUTES ARRAY**: You MUST compile the computed segments
+ into the `routes` array of the final `set_model_response` payload. The
+ array must contain all segments sequentially (e.g. `[{"origin": Origin,
+ "destination": Waypoint 1}, {"origin": Waypoint 1, "destination":
+ Destination}]`). Do NOT omit the `routes` array or leave it empty if you
+ successfully computed routes.
+ * **MANDATORY TRAVEL MODE IN DISPATCH**: `travel_mode` is REQUIRED and
+ must NEVER be omitted in `set_model_response`. Always supply the
+ normalized mode string (`driving`, `walking`, `transit`, or `bicycling`).
+ * Call `set_model_response` with `DirectionsExtractorSchema` parameters
+ (`summary`, `center_lat`, `center_lng`, `zoom`, `routes`,
+ `travel_mode`).
+
+## Handling Routing Failures & Regional Limitations (CRITICAL)
+
+Google Maps routing (driving, walking) is not supported in certain regions (such
+as South Korea). If a tool call to `compute_routes` returns empty results (`{}`)
+or fails:
+
+1. **Do NOT retry** with alternative queries or locations.
+2. Immediately exit the tool-calling loop.
+3. Return a user-friendly summary explaining the regional limitation (e.g.
+ "Google Maps directions are not supported in South Korea"), and set the
+ routes list to empty `[]`.
+
+## Output Fields
+
+You MUST populate all required fields in the output schema:
+
+- **`summary`**: A detailed response summarizing the travel directions, following the **Conversational Text Style Guidelines** below.
+- **`center_lat`**: Latitude of the center of the route map.
+- **`center_lng`**: Longitude of the center of the route map.
+- **`zoom`**: Recommended map zoom level. Default to 12.
+- **`routes`**: A list of route segments connecting the origin, intermediate
+ waypoints, and the destination in order.
+- **`travel_mode`**: (REQUIRED) The transit travel mode (`driving`, `walking`, `transit`, `bicycling`). Must match the user's requested travel mode and must never be omitted.
+
+## Examples
+
+### Example 1: Driving Route
+User Query: "Directions from San Francisco to San Jose by car"
+Tool Call:
+`set_model_response(summary="Driving from San Francisco to San Jose takes about 50 minutes via US-101 S.", center_lat=37.55, center_lng=-122.15, zoom=10, routes=[{"origin": {"lat": 37.7749, "lng": -122.4194, "label": "San Francisco", "placeId": "ChIJIQBpAG2ahYAR_6128GcTUEo"}, "destination": {"lat": 37.3382, "lng": -121.8863, "label": "San Jose", "placeId": "ChIJ9T_nxcC1j4ARmMo7S4ABIdM"}}], travel_mode="driving")`
+
+### Example 2: Walking Route
+User Query: "How do I walk from Central Park to Times Square?"
+Tool Call:
+`set_model_response(summary="Walking from Central Park to Times Square takes about 18 minutes (0.9 miles) down 7th Ave.", center_lat=40.765, center_lng=-73.978, zoom=14, routes=[{"origin": {"lat": 40.768, "lng": -73.974, "label": "Central Park South", "placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4"}, "destination": {"lat": 40.758, "lng": -73.985, "label": "Times Square", "placeId": "ChIJmQJItx6vwokRLxVi2JyuzRo"}}], travel_mode="walking")`
+
+### Example 3: Bicycling Route
+User Query: "Bike directions from Venice Beach to Santa Monica Pier"
+Tool Call:
+`set_model_response(summary="Biking from Venice Beach to Santa Monica Pier takes around 15 minutes along the Marvin Braude Bike Trail.", center_lat=33.998, center_lng=-118.483, zoom=13, routes=[{"origin": {"lat": 33.985, "lng": -118.469, "label": "Venice Beach", "placeId": "ChIJ-wjh2I-6woARx3H-n9uVn4A"}, "destination": {"lat": 34.009, "lng": -118.497, "label": "Santa Monica Pier", "placeId": "ChIJw8g0Xbm7woARQY1Xq41qB2M"}}], travel_mode="bicycling")`
+
+### Example 4: Transit Route
+User Query: "Take the subway from Grand Central to Brooklyn Bridge"
+Tool Call:
+`set_model_response(summary="Take the 4 or 5 subway line south from Grand Central - 42 St to Brooklyn Bridge - City Hall (approx. 12 minutes).", center_lat=40.731, center_lng=-73.988, zoom=12, routes=[{"origin": {"lat": 40.7527, "lng": -73.9772, "label": "Grand Central Terminal", "placeId": "ChIJ4zBEaKZQwokREuE50bbCGYs"}, "destination": {"lat": 40.7126, "lng": -74.0049, "label": "Brooklyn Bridge - City Hall", "placeId": "ChIJ40i5iRZawokRHqGfF2b_3yI"}}], travel_mode="transit")`
+
+### Example 5: Unspecified Travel Mode (Defaults to Driving)
+User Query: "Directions from Austin to San Antonio"
+Tool Call:
+`set_model_response(summary="Driving from Austin to San Antonio takes about 1 hour and 20 minutes via I-35 S.", center_lat=29.85, center_lng=-98.15, zoom=9, routes=[{"origin": {"lat": 30.2672, "lng": -97.7431, "label": "Austin", "placeId": "ChIJLwW05NsQW4YRtxm00DkzqlU"}, "destination": {"lat": 29.4241, "lng": -98.4936, "label": "San Antonio", "placeId": "ChIJrw7QBK9YXIYRowalignfdg4"}}], travel_mode="driving")`
diff --git a/agent/python_agent/skills/google-maps-enriched-local-query-response/SKILL.md b/agent/python_agent/skills/google-maps-enriched-local-query-response/SKILL.md
index 7ea60a8..53b29f5 100644
--- a/agent/python_agent/skills/google-maps-enriched-local-query-response/SKILL.md
+++ b/agent/python_agent/skills/google-maps-enriched-local-query-response/SKILL.md
@@ -47,7 +47,7 @@ You are an expert in resolving location-based queries using the **A2UI framework
paragraphs of text content, it is preferable to intersperse UI components
where relevant, rather than placing them all at the end. As an example, if
you write a separate paragraph about each of three restaurants, include a
- PlaceCard for each restaurant after its corresponding paragraph instead of
+ PlaceDetailsCompact for each restaurant after its corresponding paragraph instead of
placing a list of places at the end.
### 3. Data Integrity & Logic
@@ -100,7 +100,7 @@ Use the following logic to determine which UI component combinations to use:
| :--- | :--- | :--- |
| Immediate surroundings, vibe, or outdoor features | **GoogleMap** (Satellite/Tilt) | Lat/Lng, Name |
| Parking availability | **GoogleMap** (Satellite/Tilt 0) | Lat/Lng, Name |
-| Interior vibe, products, or general services | **PlaceCard** (Do not include GoogleMap) | PlaceID |
+| Interior vibe, products, or general services | **PlaceDetailsCompact** (Do not include GoogleMap) | PlaceID |
---
@@ -110,18 +110,18 @@ Use the following logic to determine which UI component combinations to use:
| Context | Recommended UI | Data Requirements |
| :---------------------- | :--------------------- | :------------------------ |
| **Anchored Search**: | **Inline Map + List of | Pivot on `anchorMarker`. |
-: Distance/time : PlaceCards** : POIs as `markers`. DO NOT :
+: Distance/time : PlaceDetailsCompacts** : POIs as `markers`. DO NOT :
: constraint to a center : : include a place card for :
: point. : : the anchor marker. :
| **Local Area**: Results | **Inline Map + List of | Pivot on `anchorMarker` |
-: within a neighborhood : PlaceCards** : (town center). POIs as :
+: within a neighborhood : PlaceDetailsCompacts** : (town center). POIs as :
: or city. : : `markers`. DO NOT include :
: : : a place card for the :
: : : anchor marker. :
-| **Macro Region**: | **List of PlaceCards** | Place IDs for all items. |
+| **Macro Region**: | **List of PlaceDetailsCompacts** | Place IDs for all items. |
: Results across a : : :
: state/country. : : :
-| **Contextless**: A list | **List of PlaceCards** | Place IDs for all items. |
+| **Contextless**: A list | **List of PlaceDetailsCompacts** | Place IDs for all items. |
: with no geographical : : :
: reference. : : :
@@ -185,7 +185,7 @@ MUST NOT pass a reference to an array directly.
},
{
"id": "place-card",
- "component": "PlaceCard",
+ "component": "PlaceDetailsCompact",
"placeId": { "path": "placeId" }
}
]
@@ -218,10 +218,16 @@ Note that for the `GoogleMap` component, you MUST include the following fields:
* `center`
* `zoom`
-For the `PlaceCard` component, you MUST include the following fields:
+For the `PlaceDetailsCompact` component, you MUST include the following fields:
* `placeId`
+You MAY also include:
+
+* `orientation`:
+ - You MUST use `"vertical"` when there is only ONE `PlaceDetailsCompact` in the response to emphasize the place image.
+ - You MUST use `"horizontal"` when there are MULTIPLE `PlaceDetailsCompact` components (e.g., in a list) to keep the layout compact and save vertical space.
+
**IMPORTANT:** ALWAYS follow the schema provided by the schema manager (passed
in as part of the instruction prompt) as the source of truth for what fields are
required for each component.
diff --git a/agent/python_agent/skills/local-search-template-response/SKILL.md b/agent/python_agent/skills/local-search-template-response/SKILL.md
new file mode 100644
index 0000000..c9fc751
--- /dev/null
+++ b/agent/python_agent/skills/local-search-template-response/SKILL.md
@@ -0,0 +1,71 @@
+---
+name: local-search-template-response
+description: Extractor skill for local place search queries. Extracts location and list of places for template merging.
+---
+
+# Core Objective
+
+Extract structured parameters for local searches. You must call maps tools to
+locate matching businesses/places, and populate the response fields.
+
+## Grounding & Tool-Calling Policy (CRITICAL)
+
+1. **DO NOT HALLUCINATE OR GUESS**: You are strictly forbidden from generating
+ coordinates (latitude, longitude) or Google Maps Place IDs from your
+ internal memory or training weights.
+2. **MANDATORY TOOL CALLS**: You MUST call the `search_places` tool first to
+ find actual venues matching the user's query near the requested locations.
+3. **EXACT MATCH**: Any place name, coordinates, or Place ID returned in your
+ final response MUST correspond exactly to the data returned by the
+ `search_places` tool call.
+
+## Multi-Step Location Resolution Policy (Anchored Search)
+
+If the user's query references a starting point, landmark, hotel, or specific
+address as a geographical anchor (e.g., "Space Needle", "Hyatt hotel", "1600
+Amphitheatre Pkwy"):
+
+1. **Resolve Anchor Coordinates First**: Make a tool call to `search_places`
+ with the anchor name as `textQuery` to resolve its exact center coordinates.
+2. **Execute Proximity Search (Pivot on Anchor)**: Use the resolved latitude
+ and longitude of the anchor as the center of a `locationBias` circle. Set
+ the `radiusMeters` parameter based on the user's query:
+ * **Explicit Distance**: If the query specifies a distance (e.g., "within
+ 5 miles", "2 km"), parse and convert it to meters (e.g., `8000` or
+ `2000`).
+ * **Implicit Walking**: If the query implies walking (e.g., "walk to",
+ "walking distance"), default to `1000` meters.
+ * **No Specific Constraint**: If no distance constraint is specified
+ (e.g., "near", "around"), omit the `radiusMeters` field to let the
+ search engine bias results dynamically around the center point.
+3. **Handle Non-Geographic Filters**: Keep the search query focused on the core
+ category and key searchable features or amenities (e.g., "restaurant outdoor
+ seating", "cafe wifi"). Strip conversational filler words (e.g., "with",
+ "that has", "places offering") to maximize search relevance and matching
+ accuracy.
+4. **Set Anchor Marker**: Populate the `anchor_marker` response parameter with
+ the resolved coordinates, name, and Place ID of the anchor location.
+
+## Handling Failures & Empty Results (CRITICAL)
+
+If search queries return empty results (`{}`) or fail:
+
+1. **Do NOT retry** repeatedly with alternative queries.
+2. Immediately exit the tool-calling loop.
+3. Return a user-friendly summary explaining that no results were found, and
+ leave the `places` list empty.
+
+## Output Fields
+
+You MUST populate all required fields in the output schema, and optionally the anchor marker if resolved:
+
+- **`summary`**: A detailed response summarizing the search results, following the **Conversational Text Style Guidelines** below.
+- **`center_lat`**: Latitude of the center of results. Use the coordinates of
+ the resolved anchor location (or the average of the results if no anchor is
+ resolved).
+- **`center_lng`**: Longitude of the center of results. Use the coordinates of
+ the resolved anchor location (or the average of the results if no anchor is
+ resolved).
+- **`zoom`**: Recommended map zoom level. Default to 13.
+- **`places`**: A list of places found (limit to max list size, e.g. 3).
+- **`anchor_marker`**: (Optional) Pin details for the resolved starting/anchor location.
diff --git a/agent/python_agent/templates/directions.json b/agent/python_agent/templates/directions.json
new file mode 100644
index 0000000..1bf5f12
--- /dev/null
+++ b/agent/python_agent/templates/directions.json
@@ -0,0 +1,47 @@
+[
+ {
+ "version": "v0.9",
+ "createSurface": {
+ "surfaceId": "{{surface_id}}",
+ "catalogId": "a2ui://maps-agentic-ui-catalog.json"
+ }
+ },
+ {
+ "version": "v0.9",
+ "updateComponents": {
+ "surfaceId": "{{surface_id}}",
+ "components": [
+ {
+ "id": "root",
+ "component": "Column",
+ "children": ["summary-text", "map"]
+ },
+ {
+ "id": "summary-text",
+ "component": "Text",
+ "variant": "body",
+ "text": "{{summary}}"
+ },
+ {
+ "id": "map",
+ "component": "GoogleMap",
+ "center": {
+ "lat": "{{center_lat}}",
+ "lng": "{{center_lng}}"
+ },
+ "zoom": "{{zoom}}",
+ "routes": "{{routes}}",
+ "travelMode": "{{travel_mode}}"
+ }
+ ]
+ }
+ },
+ {
+ "version": "v0.9",
+ "updateDataModel": {
+ "surfaceId": "{{surface_id}}",
+ "path": "/",
+ "value": {}
+ }
+ }
+]
diff --git a/agent/python_agent/templates/local_search.json b/agent/python_agent/templates/local_search.json
new file mode 100644
index 0000000..3d964e4
--- /dev/null
+++ b/agent/python_agent/templates/local_search.json
@@ -0,0 +1,63 @@
+[
+ {
+ "version": "v0.9",
+ "createSurface": {
+ "surfaceId": "{{surface_id}}",
+ "catalogId": "a2ui://maps-agentic-ui-catalog.json"
+ }
+ },
+ {
+ "version": "v0.9",
+ "updateComponents": {
+ "surfaceId": "{{surface_id}}",
+ "components": [
+ {
+ "id": "root",
+ "component": "Column",
+ "children": ["summary-text", "map", "list"]
+ },
+ {
+ "id": "summary-text",
+ "component": "Text",
+ "variant": "body",
+ "text": "{{summary}}"
+ },
+ {
+ "id": "map",
+ "component": "GoogleMap",
+ "center": {
+ "lat": "{{center_lat}}",
+ "lng": "{{center_lng}}"
+ },
+ "zoom": "{{zoom}}",
+ "anchorMarker": "{{anchor_marker}}",
+ "markers": "{{markers}}"
+ },
+ {
+ "id": "list",
+ "component": "List",
+ "direction": "vertical",
+ "children": {
+ "componentId": "place-card",
+ "path": "/places"
+ }
+ },
+ {
+ "id": "place-card",
+ "component": "PlaceDetailsCompact",
+ "placeId": { "path": "placeId" }
+ }
+ ]
+ }
+ },
+ {
+ "version": "v0.9",
+ "updateDataModel": {
+ "surfaceId": "{{surface_id}}",
+ "path": "/",
+ "value": {
+ "places": "{{places}}"
+ }
+ }
+ }
+]
diff --git a/agent/python_agent/templates/text_only.json b/agent/python_agent/templates/text_only.json
new file mode 100644
index 0000000..a088835
--- /dev/null
+++ b/agent/python_agent/templates/text_only.json
@@ -0,0 +1,30 @@
+[
+ {
+ "version": "v0.9",
+ "createSurface": {
+ "surfaceId": "{{surface_id}}",
+ "catalogId": "a2ui://maps-agentic-ui-catalog.json"
+ }
+ },
+ {
+ "version": "v0.9",
+ "updateComponents": {
+ "surfaceId": "{{surface_id}}",
+ "components": [
+ {
+ "id": "root",
+ "component": "Column",
+ "children": [
+ "text-content"
+ ]
+ },
+ {
+ "id": "text-content",
+ "component": "Text",
+ "variant": "body",
+ "text": "{{text}}"
+ }
+ ]
+ }
+ }
+]
diff --git a/agent/python_agent/test_agent_with_templates.py b/agent/python_agent/test_agent_with_templates.py
new file mode 100644
index 0000000..25410d1
--- /dev/null
+++ b/agent/python_agent/test_agent_with_templates.py
@@ -0,0 +1,1038 @@
+# 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.
+
+"""Tests for MAUI Agent with templates orchestration."""
+
+import unittest
+from unittest import mock
+
+from google.adk.agents.llm_agent import LlmAgent
+from google.adk.models.lite_llm import LiteLlm
+
+from python_agent import agent_config
+from python_agent import agent_with_templates
+
+AgentConfig = agent_config.AgentConfig
+FallbackMode = agent_config.FallbackMode
+MAUIAgentWithTemplates = agent_with_templates.MAUIAgentWithTemplates
+
+_LITELLM_PATH = (
+ "python_agent"
+ ".agent_with_templates.LiteLlm"
+)
+
+
+class MockPart:
+ """Mock Part helper for test streaming."""
+
+ def __init__(self, text):
+ self.text = text
+
+
+class MockContent:
+ """Mock Content helper for test streaming."""
+
+ def __init__(self, parts):
+ self.parts = parts
+
+
+class MockResponse:
+ """Mock GenerateContentResponse helper for test streaming."""
+
+ def __init__(self, content):
+ self.content = content
+
+
+class MockAsyncIterator:
+ """Mock async iterator helper to simulate LLM stream."""
+
+ def __init__(self, items):
+ self.items = items
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ if not self.items:
+ raise StopAsyncIteration
+ return self.items.pop(0)
+
+
+class MockFunctionCall:
+
+ def __init__(self, name, args):
+ self.name = name
+ self.args = args
+
+
+class MockEvent:
+
+ def __init__(self, function_calls=None, content=None, partial=False):
+ self.function_calls = function_calls or []
+ self.content = content
+ self.partial = partial
+
+ def get_function_calls(self):
+ return self.function_calls
+
+
+class TestAgentOrchestration(unittest.IsolatedAsyncioTestCase):
+ """Unit tests for MAUIAgentWithTemplates orchestration."""
+
+ def setUp(self):
+ super().setUp()
+ self.mock_router = mock.MagicMock(spec=LiteLlm)
+ self.mock_extractor = mock.MagicMock(spec=LiteLlm)
+
+ def _setup_mock_llm(self, mock_lite_llm_class):
+ def lite_llm_side_effect(*args, **kwargs):
+ model = kwargs.get("model") or (args[0] if args else None)
+ if model == "gemini/router-model":
+ return self.mock_router
+ elif model == "gemini/template-model":
+ return self.mock_extractor
+ return mock.MagicMock(spec=LiteLlm)
+
+ mock_lite_llm_class.side_effect = lite_llm_side_effect
+
+ def _mock_llm_stream(self, *contents: str) -> MockAsyncIterator:
+ """Helper to mock LLM stream responses."""
+ return MockAsyncIterator(
+ [MockResponse(MockContent([MockPart(c)])) for c in contents]
+ )
+
+ def _get_component_by_id(self, parts, component_id: str) -> dict:
+ """Helper to retrieve a component by ID from updateComponents parts."""
+ for part in parts:
+ if "updateComponents" in part.root.data:
+ components = part.root.data["updateComponents"]["components"]
+ for component in components:
+ if component.get("id") == component_id:
+ return component
+ self.fail(f"Component with ID '{component_id}' not found in parts.")
+
+ def _setup_agent(self, fallback_mode="TEXT"):
+ """Helper to initialize MAUIAgentWithTemplates with standard config."""
+ config = AgentConfig(
+ fallback_mode=fallback_mode,
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ return MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ def _mock_llm_responses(self, router_resp, extractor_resp=None):
+ """Helper to set up default mock responses for router and extractor."""
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream(router_resp)
+ )
+ if extractor_resp:
+ self.mock_extractor.generate_content_async.return_value = (
+ self._mock_llm_stream(extractor_resp)
+ )
+
+ async def _collect_stream(
+ self, agent, query, session_id="session_123", ui_version="v0.9"
+ ):
+ """Helper to collect results from agent.stream."""
+ results = []
+ async for item in agent.stream(
+ query=query, session_id=session_id, ui_version=ui_version
+ ):
+ results.append(item)
+ return results
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_text_only_flow(self, mock_lite_llm_class):
+ """Verifies fast text-only response flow when router yields TEXT_ONLY."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ # Mock router response stream yielding classification JSON
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream('{"intent": "TEXT_ONLY", "query": "hello"}')
+ )
+
+ # Mock extractor response stream yielding text answer
+ self.mock_extractor.generate_content_async.return_value = (
+ self._mock_llm_stream("This is a fast text-only response.")
+ )
+
+ config = AgentConfig(
+ fallback_mode=FallbackMode.TEXT,
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ # Run stream
+ results = []
+ async for item in agent.stream(
+ query="hello", session_id="session_123", ui_version="v0.9"
+ ):
+ results.append(item)
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ self.assertEqual(len(parts), 2)
+ # createSurface and updateComponents
+ self.assertTrue(
+ parts[0]
+ .root.data["createSurface"]["surfaceId"]
+ .startswith("text-only_session_123-")
+ )
+ text_comp = self._get_component_by_id(parts, "text-content")
+ self.assertEqual(
+ text_comp["text"],
+ "This is a fast text-only response.",
+ )
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_router_failure_fallback(self, mock_lite_llm_class):
+ """Verifies fallback to TEXT_ONLY when router fails."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ self.mock_router.generate_content_async.side_effect = Exception(
+ "Router error"
+ )
+ self.mock_extractor.generate_content_async.return_value = (
+ self._mock_llm_stream("Response after router failure.")
+ )
+
+ config = AgentConfig(
+ fallback_mode=FallbackMode.TEXT,
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ results = []
+ async for item in agent.stream(
+ query="hello", session_id="session_123", ui_version="v0.9"
+ ):
+ results.append(item)
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ text_comp = self._get_component_by_id(parts, "text-content")
+ self.assertEqual(
+ text_comp["text"],
+ "Response after router failure.",
+ )
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_extractor_empty_parts_handled_safely(
+ self, mock_lite_llm_class
+ ):
+ """Verifies extractor response with empty/None parts is handled safely."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream('{"intent": "TEXT_ONLY", "query": "hello"}')
+ )
+
+ # Mock extractor response with None parts
+ self.mock_extractor.generate_content_async.return_value = MockAsyncIterator(
+ [MockResponse(MockContent(None))]
+ )
+
+ config = AgentConfig(
+ fallback_mode=FallbackMode.TEXT,
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ results = []
+ async for item in agent.stream(
+ query="hello", session_id="session_123", ui_version="v0.9"
+ ):
+ results.append(item)
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ text_comp = self._get_component_by_id(parts, "text-content")
+ self.assertEqual(text_comp["text"], "")
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_unsupported_intent_fallback(self, mock_lite_llm_class):
+ """Verifies fallback to base stream for unsupported intents (DYNAMIC)."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream('{"intent": "OTHER_SPATIAL", "query": "coffee"}')
+ )
+
+ config = AgentConfig(
+ fallback_mode=FallbackMode.DYNAMIC,
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ with mock.patch(
+ "python_agent.agent.MAUIAgent.stream",
+ ) as mock_super_stream:
+ mock_super_stream.return_value = MockAsyncIterator(
+ [{"is_task_complete": False, "parts": ["dummy_base_part"]}]
+ )
+
+ results = []
+ async for item in agent.stream(
+ query="coffee", session_id="session_123", ui_version="v0.9"
+ ):
+ results.append(item)
+
+ mock_super_stream.assert_called_once_with("coffee", "session_123", "v0.9")
+ self.assertEqual(
+ results, [{"is_task_complete": False, "parts": ["dummy_base_part"]}]
+ )
+
+ def test_init_without_config_uses_default(self):
+ agent = MAUIAgentWithTemplates(base_url="http://test-url")
+ self.assertIsNotNone(agent.config)
+ self.assertEqual(agent.config.router_model, "gemini/gemini-3.1-flash-lite")
+ self.assertEqual(
+ agent.config.template_model, "gemini/gemini-3.1-flash-lite"
+ )
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_directions_flow(self, mock_lite_llm_class):
+ """Verifies template extraction and merging for DIRECTIONS intent."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ # Mock router yielding DIRECTIONS
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream(
+ '{"intent": "DIRECTIONS", "query": "directions from home to work"}'
+ )
+ )
+
+ # Mock extractor runner:
+ mock_runner = mock.MagicMock()
+
+ mock_fc = MockFunctionCall(
+ name="set_model_response",
+ args={
+ "summary": "Typical commute is 45 mins.",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "zoom": 12,
+ "routes": [{
+ "origin": {
+ "lat": 37.4,
+ "lng": 126.9,
+ "label": "Home",
+ "placeId": "ChIJ_origin",
+ },
+ "destination": {
+ "lat": 37.6,
+ "lng": 127.1,
+ "label": "Work",
+ "placeId": "ChIJ_dest",
+ },
+ }],
+ "travel_mode": "driving",
+ },
+ )
+ mock_event = MockEvent(function_calls=[mock_fc])
+ mock_runner.run_async.return_value = MockAsyncIterator([mock_event])
+
+ config = AgentConfig(
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ # Patch _build_runner
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ # Run stream
+ results = []
+ async for item in agent.stream(
+ query="directions from home to work",
+ session_id="session_123",
+ ui_version="v0.9",
+ ):
+ results.append(item)
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ self.assertEqual(len(parts), 3)
+
+ create_surface = parts[0].root.data["createSurface"]
+ self.assertTrue(
+ create_surface["surfaceId"].startswith("directions-surface-")
+ )
+
+ map_comp = self._get_component_by_id(parts, "map")
+ self.assertEqual(map_comp["center"], {"lat": 37.5, "lng": 127.0})
+ self.assertEqual(map_comp["travelMode"], "driving")
+
+ route = map_comp["routes"][0]
+ self.assertEqual(route["origin"]["label"], "Home")
+ self.assertEqual(route["destination"]["label"], "Work")
+
+ update_data_model = parts[2].root.data["updateDataModel"]
+ self.assertEqual(update_data_model["path"], "/")
+ self.assertEqual(update_data_model["value"], {})
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_directions_flow_fallback(self, mock_lite_llm_class):
+ """Verifies DIRECTIONS flow falls back to text_only when extraction fails."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ # Mock router yielding DIRECTIONS
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream(
+ '{"intent": "DIRECTIONS", "query": "directions to work"}'
+ )
+ )
+
+ self.mock_extractor.generate_content_async.return_value = (
+ self._mock_llm_stream("Cannot find route.")
+ )
+
+ mock_runner = mock.MagicMock()
+ mock_event = MockEvent(
+ content=MockContent([MockPart("Cannot find route.")])
+ )
+ mock_runner.run_async.return_value = MockAsyncIterator([mock_event])
+
+ config = AgentConfig(
+ fallback_mode="TEXT",
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ results = []
+ async for item in agent.stream(
+ query="directions to work",
+ session_id="session_123",
+ ui_version="v0.9",
+ ):
+ results.append(item)
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ self.assertEqual(len(parts), 2)
+ self.assertTrue(
+ parts[0]
+ .root.data["createSurface"]["surfaceId"]
+ .startswith("text-only_session_123-")
+ )
+ text_comp = self._get_component_by_id(parts, "text-content")
+ self.assertEqual(
+ text_comp["text"],
+ "Cannot find route.",
+ )
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_directions_flow_transit_mode(self, mock_lite_llm_class):
+ """Verifies template extraction and merging with transit travel mode."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream(
+ '{"intent": "DIRECTIONS", "query": "bus to work"}'
+ )
+ )
+
+ mock_runner = mock.MagicMock()
+ mock_fc = MockFunctionCall(
+ name="set_model_response",
+ args={
+ "summary": "Take bus 10 to work.",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "zoom": 12,
+ "routes": [{
+ "origin": {
+ "lat": 37.4,
+ "lng": 126.9,
+ "label": "Home",
+ "placeId": "ChIJ_origin",
+ },
+ "destination": {
+ "lat": 37.6,
+ "lng": 127.1,
+ "label": "Work",
+ "placeId": "ChIJ_dest",
+ },
+ }],
+ "travel_mode": "transit",
+ },
+ )
+ mock_event = MockEvent(function_calls=[mock_fc])
+ mock_runner.run_async.return_value = MockAsyncIterator([mock_event])
+
+ config = AgentConfig(
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ results = await self._collect_stream(agent, query="bus to work")
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ map_comp = self._get_component_by_id(parts, "map")
+ self.assertEqual(map_comp["travelMode"], "transit")
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_directions_flow_walking_mode(self, mock_lite_llm_class):
+ """Verifies template extraction and merging with walking travel mode."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream(
+ '{"intent": "DIRECTIONS", "query": "walk to park"}'
+ )
+ )
+
+ mock_runner = mock.MagicMock()
+ mock_fc = MockFunctionCall(
+ name="set_model_response",
+ args={
+ "summary": "Walk for 15 minutes.",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "zoom": 12,
+ "routes": [{
+ "origin": {
+ "lat": 37.4,
+ "lng": 126.9,
+ "label": "Home",
+ "placeId": "ChIJ_origin",
+ },
+ "destination": {
+ "lat": 37.6,
+ "lng": 127.1,
+ "label": "Park",
+ "placeId": "ChIJ_dest",
+ },
+ }],
+ "travel_mode": "walking",
+ },
+ )
+ mock_event = MockEvent(function_calls=[mock_fc])
+ mock_runner.run_async.return_value = MockAsyncIterator([mock_event])
+
+ config = AgentConfig(
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ results = await self._collect_stream(agent, query="walk to park")
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ map_comp = self._get_component_by_id(parts, "map")
+ self.assertEqual(map_comp["travelMode"], "walking")
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_directions_flow_bicycling_mode(
+ self, mock_lite_llm_class
+ ):
+ """Verifies template extraction and merging with bicycling travel mode."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream(
+ '{"intent": "DIRECTIONS", "query": "bike to work"}'
+ )
+ )
+
+ mock_runner = mock.MagicMock()
+ mock_fc = MockFunctionCall(
+ name="set_model_response",
+ args={
+ "summary": "Bike for 25 minutes.",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "zoom": 12,
+ "routes": [{
+ "origin": {
+ "lat": 37.4,
+ "lng": 126.9,
+ "label": "Home",
+ "placeId": "ChIJ_origin",
+ },
+ "destination": {
+ "lat": 37.6,
+ "lng": 127.1,
+ "label": "Work",
+ "placeId": "ChIJ_dest",
+ },
+ }],
+ "travel_mode": "bicycling",
+ },
+ )
+ mock_event = MockEvent(function_calls=[mock_fc])
+ mock_runner.run_async.return_value = MockAsyncIterator([mock_event])
+
+ config = AgentConfig(
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ results = await self._collect_stream(agent, query="bike to work")
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ map_comp = self._get_component_by_id(parts, "map")
+ self.assertEqual(map_comp["travelMode"], "bicycling")
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_directions_flow_missing_travel_mode_fallback(
+ self, mock_lite_llm_class
+ ):
+ """Verifies that omitting travel_mode causes schema validation failure and fallback to text-only."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream(
+ '{"intent": "DIRECTIONS", "query": "directions to work"}'
+ )
+ )
+
+ self.mock_extractor.generate_content_async.return_value = (
+ self._mock_llm_stream("Fallback plain text directions.")
+ )
+
+ mock_runner = mock.MagicMock()
+ mock_fc = MockFunctionCall(
+ name="set_model_response",
+ args={
+ "summary": "Typical commute is 45 mins.",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "zoom": 12,
+ "routes": [{
+ "origin": {
+ "lat": 37.4,
+ "lng": 126.9,
+ "label": "Home",
+ "placeId": "ChIJ_origin",
+ },
+ "destination": {
+ "lat": 37.6,
+ "lng": 127.1,
+ "label": "Work",
+ "placeId": "ChIJ_dest",
+ },
+ }],
+ },
+ )
+ mock_event = MockEvent(function_calls=[mock_fc])
+ mock_runner.run_async.return_value = MockAsyncIterator([mock_event])
+
+ config = AgentConfig(
+ fallback_mode="TEXT",
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ results = await self._collect_stream(agent, query="directions to work")
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ self.assertEqual(len(parts), 2)
+ self.assertTrue(
+ parts[0]
+ .root.data["createSurface"]["surfaceId"]
+ .startswith("text-only_session_123-")
+ )
+ text_comp = self._get_component_by_id(parts, "text-content")
+ self.assertEqual(
+ text_comp["text"],
+ "Fallback plain text directions.",
+ )
+
+ @mock.patch(
+ "python_agent.agent_with_templates.LiteLlm"
+ )
+ async def test_agent_local_search_flow(self, mock_lite_llm_class):
+ """Verifies local search flow, structured extraction, and template merging."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ # Mock router yielding LOCAL_SEARCH
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream(
+ '{"intent": "LOCAL_SEARCH", "query": "sushi Seattle"}'
+ )
+ )
+
+ # Mock extractor runner:
+ mock_runner = mock.MagicMock()
+
+ mock_fc = MockFunctionCall(
+ name="set_model_response",
+ args={
+ "summary": "Here are some sushi places.",
+ "center_lat": 47.6062,
+ "center_lng": -122.3321,
+ "zoom": 13,
+ "places": [{
+ "placeId": "ChIJ111",
+ "name": "Shiki Sushi",
+ "lat": 47.6200,
+ "lng": -122.3200,
+ }],
+ },
+ )
+ mock_event = MockEvent(function_calls=[mock_fc])
+ mock_runner.run_async.return_value = MockAsyncIterator([mock_event])
+
+ config = AgentConfig(
+ fallback_mode="DYNAMIC",
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ # Patch _build_runner
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ results = []
+ async for item in agent.stream(
+ query="sushi Seattle", session_id="session_123", ui_version="v0.9"
+ ):
+ results.append(item)
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ self.assertEqual(len(parts), 3)
+
+ create_surface = parts[0].root.data["createSurface"]
+ self.assertTrue(
+ create_surface["surfaceId"].startswith("local-search-surface-")
+ )
+
+ update_data_model = parts[2].root.data["updateDataModel"]
+ # Verify places array was successfully populated in data model
+ self.assertEqual(update_data_model["path"], "/")
+ places = update_data_model["value"]["places"]
+ self.assertEqual(len(places), 1)
+ self.assertEqual(places[0]["name"], "Shiki Sushi")
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_local_search_flow_validation_failure_fallback(
+ self, mock_lite_llm_class
+ ):
+ """Verifies LOCAL_SEARCH fallback when extraction validation fails."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ # Mock router yielding LOCAL_SEARCH
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream('{"intent": "LOCAL_SEARCH", "query": "coffee"}')
+ )
+
+ self.mock_extractor.generate_content_async.return_value = (
+ self._mock_llm_stream("Fallback text here.")
+ )
+
+ mock_runner = mock.MagicMock()
+
+ # Mock invalid set_model_response arguments (missing required center_lat)
+ invalid_args = {"summary": "Invalid data", "places": []}
+ mock_fc = MockFunctionCall("set_model_response", invalid_args)
+ mock_event_fc = MockEvent(function_calls=[mock_fc])
+ mock_event_text = MockEvent(
+ content=MockContent([MockPart("Fallback text here.")])
+ )
+
+ mock_runner.run_async.return_value = MockAsyncIterator(
+ [mock_event_fc, mock_event_text]
+ )
+
+ config = AgentConfig(
+ fallback_mode="TEXT",
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ # Patch _build_runner
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ results = []
+ async for item in agent.stream(
+ query="coffee", session_id="session_123", ui_version="v0.9"
+ ):
+ results.append(item)
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ self.assertEqual(len(parts), 2)
+ self.assertTrue(
+ parts[0]
+ .root.data["createSurface"]["surfaceId"]
+ .startswith("text-only_session_123-")
+ )
+ text_comp = self._get_component_by_id(parts, "text-content")
+ self.assertEqual(
+ text_comp["text"],
+ "Fallback text here.",
+ )
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_local_search_flow_catalog_validation_failure_fallback(
+ self, mock_lite_llm_class
+ ):
+ """Verifies LOCAL_SEARCH flow falls back to text_only when catalog validation fails."""
+ self._setup_mock_llm(mock_lite_llm_class)
+ self._mock_llm_responses(
+ router_resp='{"intent": "LOCAL_SEARCH", "query": "coffee"}',
+ extractor_resp="Fallback text from LLM.",
+ )
+
+ mock_runner = mock.MagicMock()
+ mock_fc = MockFunctionCall(
+ "set_model_response",
+ {"summary": "Coffee", "places": [{"name": "Starbucks"}]},
+ )
+ mock_runner.run_async.return_value = MockAsyncIterator(
+ [MockEvent(function_calls=[mock_fc])]
+ )
+
+ agent = self._setup_agent(fallback_mode="TEXT")
+
+ # Mock schema manager to return a catalog that fails validation (generic Exception)
+ mock_catalog = mock.MagicMock()
+ mock_catalog.validator.validate.side_effect = Exception(
+ "Mock validation error"
+ )
+ mock_schema_manager = mock.MagicMock()
+ mock_schema_manager.get_catalog.return_value = mock_catalog
+ agent._schema_managers = {"v0.9": mock_schema_manager}
+
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ results = await self._collect_stream(agent, "coffee")
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ text_comp = self._get_component_by_id(results[0]["parts"], "text-content")
+ self.assertEqual(text_comp["text"], "Fallback text from LLM.")
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_fallback_mode_text_on_extractor_failure(
+ self, mock_lite_llm_class
+ ):
+ """Verifies TEXT fallback mode when template extractor fails."""
+ self._setup_mock_llm(mock_lite_llm_class)
+ self._mock_llm_responses(
+ router_resp='{"intent": "LOCAL_SEARCH", "query": "sushi Seattle"}',
+ extractor_resp="I could not search places right now.",
+ )
+ mock_runner = mock.MagicMock()
+ mock_runner.run_async.return_value = MockAsyncIterator([MockEvent()])
+
+ agent = self._setup_agent(fallback_mode="TEXT")
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ results = await self._collect_stream(agent, "sushi Seattle")
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ self.assertEqual(len(parts), 2)
+ text_comp = self._get_component_by_id(parts, "text-content")
+ self.assertEqual(text_comp["text"], "I could not search places right now.")
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_fallback_mode_dynamic_on_extractor_failure(
+ self, mock_lite_llm_class
+ ):
+ """Verifies extractor failure always falls back to TEXT response, even in DYNAMIC mode."""
+ self._setup_mock_llm(mock_lite_llm_class)
+ self._mock_llm_responses(
+ router_resp='{"intent": "LOCAL_SEARCH", "query": "sushi Seattle"}',
+ extractor_resp="Fast response after extraction failure.",
+ )
+ mock_runner = mock.MagicMock()
+ mock_runner.run_async.return_value = MockAsyncIterator([MockEvent()])
+
+ agent = self._setup_agent(fallback_mode="DYNAMIC")
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ with mock.patch(
+ "python_agent.agent.MAUIAgent.stream",
+ ) as mock_super_stream:
+ results = await self._collect_stream(agent, "sushi Seattle")
+
+ mock_super_stream.assert_not_called()
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ self.assertEqual(len(parts), 2)
+ text_comp = self._get_component_by_id(parts, "text-content")
+ self.assertEqual(
+ text_comp["text"], "Fast response after extraction failure."
+ )
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_other_spatial_fallback_mode_text(
+ self, mock_lite_llm_class
+ ):
+ """Verifies OTHER_SPATIAL intent routes to TEXT fallback mode."""
+ self._setup_mock_llm(mock_lite_llm_class)
+ self._mock_llm_responses(
+ router_resp='{"intent": "OTHER_SPATIAL", "query": "weather Yosemite"}',
+ extractor_resp="The weather in Yosemite is sunny, 75 degrees.",
+ )
+ agent = self._setup_agent(fallback_mode="TEXT")
+ results = await self._collect_stream(agent, "weather Yosemite")
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ self.assertEqual(len(parts), 2)
+ text_comp = self._get_component_by_id(parts, "text-content")
+ self.assertEqual(
+ text_comp["text"], "The weather in Yosemite is sunny, 75 degrees."
+ )
+
+ @mock.patch(_LITELLM_PATH)
+ async def test_agent_other_spatial_fallback_mode_dynamic(
+ self, mock_lite_llm_class
+ ):
+ """Verifies OTHER_SPATIAL intent routes to DYNAMIC fallback mode."""
+ self._setup_mock_llm(mock_lite_llm_class)
+ self._mock_llm_responses(
+ router_resp='{"intent": "OTHER_SPATIAL", "query": "weather Yosemite"}'
+ )
+ agent = self._setup_agent(fallback_mode="DYNAMIC")
+
+ mock_part = mock.MagicMock()
+ mock_part.root.text = "base_agent_dynamic_ui"
+
+ async def mock_super_stream_gen(*_args, **_kwargs):
+ yield {
+ "is_task_complete": True,
+ "parts": [mock_part],
+ }
+
+ # Patch MAUIAgent.stream
+ with mock.patch(
+ "python_agent.agent.MAUIAgent.stream",
+ side_effect=mock_super_stream_gen,
+ ) as mock_super_stream:
+ results = await self._collect_stream(agent, "weather Yosemite")
+
+ mock_super_stream.assert_called_once_with(
+ "weather Yosemite", "session_123", "v0.9"
+ )
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ self.assertEqual(results[0]["parts"][0].root.text, "base_agent_dynamic_ui")
+
+ @mock.patch(
+ "python_agent.agent_with_templates.LiteLlm"
+ )
+ async def test_extractor_duplication_prevented(self, mock_lite_llm_class):
+ """Verifies that text is not duplicated in fallback if extractor yields partial and final events."""
+ self._setup_mock_llm(mock_lite_llm_class)
+
+ # Mock router yielding LOCAL_SEARCH
+ self.mock_router.generate_content_async.return_value = (
+ self._mock_llm_stream('{"intent": "LOCAL_SEARCH", "query": "sushi"}')
+ )
+
+ # Mock extractor runner yielding partials and then final consolidated event
+ mock_runner = mock.MagicMock()
+ mock_runner.run_async.return_value = MockAsyncIterator([
+ MockEvent(content=MockContent([MockPart("I'")]), partial=True),
+ MockEvent(content=MockContent([MockPart("m sorry")]), partial=True),
+ MockEvent(content=MockContent([MockPart("I'm sorry")]), partial=False),
+ ])
+
+ config = AgentConfig(
+ fallback_mode="TEXT",
+ router_model="gemini/router-model",
+ template_model="gemini/template-model",
+ )
+ agent = MAUIAgentWithTemplates(base_url="http://test-url", config=config)
+
+ with mock.patch.object(agent, "_build_runner", return_value=mock_runner):
+ results = await self._collect_stream(agent, "sushi")
+
+ self.assertEqual(len(results), 1)
+ self.assertTrue(results[0]["is_task_complete"])
+ parts = results[0]["parts"]
+ text_comp = self._get_component_by_id(parts, "text-content")
+ self.assertEqual(text_comp["text"], "I'm sorry")
+
+ def test_build_runner_sets_auto_create_session(self):
+ agent = MAUIAgentWithTemplates(base_url="http://test-url")
+ mock_agent = mock.MagicMock(spec=LlmAgent)
+ runner = agent._build_runner(mock_agent) # pylint: disable=protected-access
+ self.assertTrue(runner.auto_create_session)
+
+ def test_build_dynamic_extractor_agent_appends_shared_guidelines(self):
+ """Verifies that shared guidelines are appended to skill instructions."""
+ agent = MAUIAgentWithTemplates(base_url="http://test-url")
+ with mock.patch(
+ "builtins.open", mock.mock_open(read_data="Shared guidelines content")
+ ) as mock_file:
+ with mock.patch(
+ "google.adk.skills.load_skill_from_dir"
+ ) as mock_load_skill:
+ mock_skill = mock.MagicMock()
+ mock_skill.instructions = "Base skill instructions"
+ mock_load_skill.return_value = mock_skill
+
+ extractor_agent = agent._build_dynamic_extractor_agent( # pylint: disable=protected-access
+ "local-search-template-response"
+ )
+ self.assertIn("Shared guidelines content", extractor_agent.instruction)
+ self.assertIn("Base skill instructions", extractor_agent.instruction)
+ mock_file.assert_called_once()
+
+ def test_build_dynamic_extractor_agent_handles_file_read_error(self):
+ """Verifies that file read errors are handled gracefully when loading guidelines."""
+ agent = MAUIAgentWithTemplates(base_url="http://test-url")
+ with mock.patch("builtins.open", side_effect=OSError("Read error")):
+ with mock.patch(
+ "google.adk.skills.load_skill_from_dir"
+ ) as mock_load_skill:
+ mock_skill = mock.MagicMock()
+ mock_skill.instructions = "Base skill instructions"
+ mock_load_skill.return_value = mock_skill
+
+ # Check that it handles OSError gracefully and proceeds
+ extractor_agent = agent._build_dynamic_extractor_agent( # pylint: disable=protected-access
+ "local-search-template-response"
+ )
+ self.assertNotIn(
+ "Shared guidelines content", extractor_agent.instruction
+ )
+ self.assertIn("Base skill instructions", extractor_agent.instruction)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/agent/python_agent/test_extractor.py b/agent/python_agent/test_extractor.py
new file mode 100644
index 0000000..1a322d4
--- /dev/null
+++ b/agent/python_agent/test_extractor.py
@@ -0,0 +1,211 @@
+# 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
+#
+# https://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.
+
+"""Tests for extractor.py."""
+
+import unittest
+import pydantic
+from python_agent.extractor import (
+ DirectionsExtractorSchema,
+ LocalSearchExtractorSchema,
+ Pin,
+ PlacePin,
+)
+
+
+class TestExtractor(unittest.TestCase):
+ """Unit tests for extractor schemas and data normalization."""
+
+ def test_pin_normalize_label_copies_name(self):
+ data = {"lat": 1.0, "lng": 2.0, "name": "My Place"}
+ pin = Pin(**data)
+ self.assertEqual(pin.label, "My Place")
+
+ def test_pin_normalize_label_defaults_to_location(self):
+ data = {"lat": 1.0, "lng": 2.0}
+ pin = Pin(**data)
+ self.assertEqual(pin.label, "Location")
+
+ def test_pin_normalize_label_preserves_existing(self):
+ data = {
+ "lat": 1.0,
+ "lng": 2.0,
+ "label": "Custom Label",
+ "name": "Ignored Name",
+ }
+ pin = Pin(**data)
+ self.assertEqual(pin.label, "Custom Label")
+
+ def test_directions_extractor_schema_normalize_travel_mode(self):
+ """Verifies that travel mode is normalized to lowercase."""
+ data = {
+ "summary": "Commute is 1h.",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "routes": [{
+ "origin": {"lat": 37.4, "lng": 126.9, "label": "Start"},
+ "destination": {"lat": 37.6, "lng": 127.1, "label": "End"},
+ }],
+ "travel_mode": "WALK",
+ }
+ schema = DirectionsExtractorSchema(**data)
+ self.assertEqual(schema.travel_mode, "walking")
+
+ def test_directions_extractor_schema_with_routes(self):
+ """Verifies that DirectionsExtractorSchema can be initialized with routes."""
+ data = {
+ "summary": "Scenic route.",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "travel_mode": "driving",
+ "routes": [
+ {
+ "origin": {"lat": 37.4, "lng": 126.9, "label": "A"},
+ "destination": {"lat": 37.5, "lng": 127.0, "label": "B"},
+ },
+ {
+ "origin": {"lat": 37.5, "lng": 127.0, "label": "B"},
+ "destination": {"lat": 37.6, "lng": 127.1, "label": "C"},
+ },
+ ],
+ }
+ schema = DirectionsExtractorSchema(**data)
+ self.assertEqual(len(schema.routes), 2)
+ self.assertEqual(schema.routes[0].origin.label, "A")
+ self.assertEqual(schema.routes[1].destination.label, "C")
+ self.assertEqual(schema.travel_mode, "driving")
+
+ def test_directions_extractor_schema_missing_travel_mode_fails_validation(
+ self,
+ ):
+ """Verifies that omitting travel_mode raises ValidationError."""
+ data = {
+ "summary": "Directions summary",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "routes": [{
+ "origin": {"lat": 37.4, "lng": 126.9, "label": "Start"},
+ "destination": {"lat": 37.6, "lng": 127.1, "label": "End"},
+ }],
+ }
+ with self.assertRaises(pydantic.ValidationError):
+ DirectionsExtractorSchema(**data)
+
+ def test_directions_extractor_schema_invalid_travel_mode_fails_validation(
+ self,
+ ):
+ """Verifies that invalid travel_mode values raise ValidationError."""
+ for invalid_mode in ["flying", "", None, "scooter", 123]:
+ with self.subTest(invalid_mode=invalid_mode):
+ data = {
+ "summary": "Directions summary",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "routes": [],
+ "travel_mode": invalid_mode,
+ }
+ with self.assertRaises(pydantic.ValidationError):
+ DirectionsExtractorSchema(**data)
+
+ def test_directions_extractor_schema_all_valid_modes(self):
+ """Verifies all valid travel modes are accepted."""
+ for mode in ["driving", "walking", "transit", "bicycling"]:
+ with self.subTest(mode=mode):
+ data = {
+ "summary": f"Going via {mode}",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "routes": [],
+ "travel_mode": mode,
+ }
+ schema = DirectionsExtractorSchema(**data)
+ self.assertEqual(schema.travel_mode, mode)
+
+ def test_directions_extractor_schema_normalize_all_synonyms(self):
+ """Verifies all synonyms and case/whitespace variations normalize cleanly."""
+ synonym_cases = {
+ "transit": [
+ "bus",
+ "train",
+ "subway",
+ "tube",
+ "metro",
+ "tram",
+ "rail",
+ "light rail",
+ "ferry",
+ "public transit",
+ "public_transit",
+ "public transport",
+ "public_transport",
+ "transit",
+ " BUS ",
+ "Train",
+ " Metro ",
+ "SUBWAY",
+ "Public Transit",
+ " public_transport ",
+ " Light Rail ",
+ ],
+ "walking": [
+ "walk",
+ "walking",
+ "pedestrian",
+ "foot",
+ "on foot",
+ "on_foot",
+ " WALK ",
+ "Foot",
+ " pedestrian ",
+ "On Foot",
+ " on_foot ",
+ ],
+ "bicycling": [
+ "bike",
+ "biking",
+ "bicycling",
+ "cycling",
+ "bicycle",
+ " BIKE ",
+ "Bicycle",
+ " Cycling ",
+ ],
+ "driving": [
+ "car",
+ "drive",
+ "driving",
+ "auto",
+ "automobile",
+ " CAR ",
+ "Drive",
+ " Auto ",
+ " Automobile ",
+ ],
+ }
+ for expected_mode, synonyms in synonym_cases.items():
+ for synonym in synonyms:
+ with self.subTest(synonym=synonym, expected=expected_mode):
+ data = {
+ "summary": "Commute",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "routes": [],
+ "travel_mode": synonym,
+ }
+ schema = DirectionsExtractorSchema(**data)
+ self.assertEqual(schema.travel_mode, expected_mode)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/agent/python_agent/test_merger.py b/agent/python_agent/test_merger.py
new file mode 100644
index 0000000..2e66745
--- /dev/null
+++ b/agent/python_agent/test_merger.py
@@ -0,0 +1,709 @@
+# 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.
+
+"""Tests for A2UI Layout Template Merger (`merger.py`)."""
+
+import pathlib
+import re
+import unittest
+
+from google3.third_party.a2ui.agent_sdks.python.a2ui_agent.src import a2ui
+from python_agent import agent
+from python_agent import merger
+
+merge_template = merger.merge_template
+
+
+class TestMerger(unittest.TestCase):
+ """Tests for A2UI Layout Template Merger."""
+
+ def _validate_schema(self, result):
+ """Validates the merged result against the Maps Catalog Extension schema."""
+ extension_path = (
+ pathlib.Path(__file__).parent
+ / "shared"
+ / "schema"
+ / "maps_catalog_extension.json"
+ )
+ schema_manager = a2ui.schema.manager.A2uiSchemaManager(
+ version=a2ui.schema.constants.VERSION_0_9,
+ catalogs=[
+ a2ui.schema.catalog.CatalogConfig(
+ name="maps-agentic-ui-catalog",
+ provider=agent.MergedCatalogProvider(
+ a2ui.schema.constants.VERSION_0_9, str(extension_path)
+ ),
+ )
+ ],
+ schema_modifiers=[
+ a2ui.schema.common_modifiers.remove_strict_validation
+ ],
+ )
+ selected_catalog = schema_manager.get_selected_catalog()
+ selected_catalog.validator.validate(result)
+
+ def test_merge_unknown_template_raises_error(self):
+ """Verifies that an unknown template raises FileNotFoundError."""
+ with self.assertRaises(FileNotFoundError):
+ merge_template("non_existent_template", {"text": "hello"})
+
+ def test_merge_text_only_full_json(self):
+ """Verifies that text-only response is merged correctly."""
+ data = {
+ "surface_id": "text-only-surface-efg",
+ "text": "Google Maps directions are not supported in South Korea.",
+ }
+
+ expected = [
+ {
+ "version": "v0.9",
+ "createSurface": {
+ "surfaceId": "text-only-surface-efg",
+ "catalogId": "a2ui://maps-agentic-ui-catalog.json",
+ },
+ },
+ {
+ "version": "v0.9",
+ "updateComponents": {
+ "surfaceId": "text-only-surface-efg",
+ "components": [
+ {
+ "id": "root",
+ "component": "Column",
+ "children": ["text-content"],
+ },
+ {
+ "id": "text-content",
+ "component": "Text",
+ "variant": "body",
+ "text": (
+ "Google Maps directions are not supported in South"
+ " Korea."
+ ),
+ },
+ ],
+ },
+ },
+ ]
+ result = merge_template("text_only", data)
+ self.assertEqual(result, expected)
+
+ def test_validate_merged_output_with_schema(self):
+ """Validates the merged output against maps catalog extension schema."""
+ data = {
+ "surface_id": "text-only-surface-efg",
+ "text": "Google Maps directions are not supported in South Korea.",
+ }
+ result = merge_template("text_only", data)
+
+ self._validate_schema(result)
+
+ def test_merge_omitted_surface_id_generates_random_suffix(self):
+ """Verifies that omitting surface_id generates a random dynamic ID."""
+ data = {
+ "text": "Hello world",
+ }
+ result = merge_template("text_only", data)
+ # createSurface is the first event in the list
+ surface_id = result[0]["createSurface"]["surfaceId"]
+ self.assertTrue(re.fullmatch(r"text_only_surface_[a-f0-9]{6}", surface_id))
+
+ def test_merge_generic_surface_id_generates_random_suffix(self):
+ """Verifies that a generic default surface_id gets a random suffix."""
+ data = {
+ "surface_id": "text-only-surface",
+ "text": "Hello world",
+ }
+ result = merge_template("text_only", data)
+ surface_id = result[0]["createSurface"]["surfaceId"]
+ self.assertTrue(re.fullmatch(r"text-only-surface_[a-f0-9]{6}", surface_id))
+
+ def test_merge_custom_surface_id_remains_intact(self):
+ """Verifies that a custom unique surface_id is preserved exactly."""
+ data = {
+ "surface_id": "my-special-surface-123",
+ "text": "Hello world",
+ }
+ result = merge_template("text_only", data)
+ surface_id = result[0]["createSurface"]["surfaceId"]
+ self.assertEqual(surface_id, "my-special-surface-123")
+
+ def test_merge_local_search_full_json(self):
+ """Verifies merging a complete local search payload."""
+ data = {
+ "surface_id": "local-search-surface-abc",
+ "summary": "Here are 3 highly-rated coffee shops in Seattle.",
+ "center_lat": "47.6062",
+ "center_lng": -122.3321,
+ "zoom": "14",
+ "places": [
+ {
+ "placeId": "ChIJ111",
+ "name": "Espresso Vivace",
+ "lat": "47.6200",
+ "lng": "-122.3200",
+ },
+ {
+ "placeId": "ChIJ222",
+ "name": "Milstead & Co.",
+ "lat": 47.6400,
+ "lng": -122.3500,
+ },
+ {
+ "placeId": "ChIJ333",
+ "name": "Victrola Coffee",
+ "lat": 47.6100,
+ "lng": -122.3200,
+ },
+ ],
+ }
+
+ expected = [
+ {
+ "version": "v0.9",
+ "createSurface": {
+ "surfaceId": "local-search-surface-abc",
+ "catalogId": "a2ui://maps-agentic-ui-catalog.json",
+ },
+ },
+ {
+ "version": "v0.9",
+ "updateComponents": {
+ "surfaceId": "local-search-surface-abc",
+ "components": [
+ {
+ "id": "root",
+ "component": "Column",
+ "children": ["summary-text", "map", "list"],
+ },
+ {
+ "id": "summary-text",
+ "component": "Text",
+ "variant": "body",
+ "text": (
+ "Here are 3 highly-rated coffee shops in Seattle."
+ ),
+ },
+ {
+ "id": "map",
+ "component": "GoogleMap",
+ "center": {"lat": 47.6062, "lng": -122.3321},
+ "zoom": 14,
+ "markers": [
+ {
+ "lat": 47.62,
+ "lng": -122.32,
+ "label": "Espresso Vivace",
+ "placeId": "ChIJ111",
+ },
+ {
+ "lat": 47.64,
+ "lng": -122.35,
+ "label": "Milstead & Co.",
+ "placeId": "ChIJ222",
+ },
+ {
+ "lat": 47.61,
+ "lng": -122.32,
+ "label": "Victrola Coffee",
+ "placeId": "ChIJ333",
+ },
+ ],
+ },
+ {
+ "id": "list",
+ "component": "List",
+ "direction": "vertical",
+ "children": {
+ "componentId": "place-card",
+ "path": "/places",
+ },
+ },
+ {
+ "id": "place-card",
+ "component": "PlaceDetailsCompact",
+ "placeId": {"path": "placeId"},
+ },
+ ],
+ },
+ },
+ {
+ "version": "v0.9",
+ "updateDataModel": {
+ "surfaceId": "local-search-surface-abc",
+ "path": "/",
+ "value": {
+ "places": [
+ {
+ "placeId": "ChIJ111",
+ "name": "Espresso Vivace",
+ "lat": 47.62,
+ "lng": -122.32,
+ },
+ {
+ "placeId": "ChIJ222",
+ "name": "Milstead & Co.",
+ "lat": 47.64,
+ "lng": -122.35,
+ },
+ {
+ "placeId": "ChIJ333",
+ "name": "Victrola Coffee",
+ "lat": 47.61,
+ "lng": -122.32,
+ },
+ ]
+ },
+ },
+ },
+ ]
+
+ result = merge_template("local_search", data, max_list_size=3)
+ self.assertEqual(result, expected)
+
+ def test_validate_local_search_output_with_schema(self):
+ """Validates the merged local search output against maps catalog extension schema."""
+ data = {
+ "surface_id": "local-search-surface-abc",
+ "summary": "Here are coffee shops.",
+ "center_lat": 47.6062,
+ "center_lng": -122.3321,
+ "zoom": 14,
+ "places": [{
+ "placeId": "ChIJ111",
+ "name": "Espresso Vivace",
+ "lat": 47.62,
+ "lng": -122.32,
+ }],
+ }
+ result = merge_template("local_search", data)
+
+ self._validate_schema(result)
+
+ def test_merge_max_list_size_slicing(self):
+ """Verifies that max_list_size parameter slices the places and markers list."""
+ data = {
+ "surface_id": "test-surface",
+ "summary": "Here are some places.",
+ "center_lat": 47.6062,
+ "center_lng": -122.3321,
+ "zoom": 14,
+ "places": [
+ {"placeId": "1", "name": "P1", "lat": 47.61, "lng": -122.31},
+ {"placeId": "2", "name": "P2", "lat": 47.62, "lng": -122.32},
+ {"placeId": "3", "name": "P3", "lat": 47.63, "lng": -122.33},
+ ],
+ }
+ result = merge_template("local_search", data, max_list_size=2)
+ # Check that updateComponents has only 2 markers
+ components = result[1]["updateComponents"]["components"]
+ map_comp = next(c for c in components if c["id"] == "map")
+ self.assertEqual(len(map_comp["markers"]), 2)
+
+ # Check that updateDataModel has only 2 places
+ places = result[2]["updateDataModel"]["value"]["places"]
+ self.assertEqual(len(places), 2)
+ self.assertEqual(places[0]["placeId"], "1")
+ self.assertEqual(places[1]["placeId"], "2")
+
+ def test_merge_directions_full_json(self):
+ """Verifies complete end-to-end directions template merging, placeholder replacement, and travel mode normalization."""
+ data = {
+ "surface_id": "directions-surface-xyz",
+ "summary": "Typical commute is 1h 15m.",
+ "center_lat": "37.5665",
+ "center_lng": 126.9780,
+ "zoom": 12,
+ "routes": [{
+ "origin": {"lat": "37.6700", "lng": "127.0400", "label": "Dobong"},
+ "destination": {
+ "lat": 37.4900,
+ "lng": 127.0200,
+ "label": "Gangnam",
+ },
+ }],
+ "travel_mode": "WALK",
+ }
+
+ expected = [
+ {
+ "version": "v0.9",
+ "createSurface": {
+ "surfaceId": "directions-surface-xyz",
+ "catalogId": "a2ui://maps-agentic-ui-catalog.json",
+ },
+ },
+ {
+ "version": "v0.9",
+ "updateComponents": {
+ "surfaceId": "directions-surface-xyz",
+ "components": [
+ {
+ "id": "root",
+ "component": "Column",
+ "children": ["summary-text", "map"],
+ },
+ {
+ "id": "summary-text",
+ "component": "Text",
+ "variant": "body",
+ "text": "Typical commute is 1h 15m.",
+ },
+ {
+ "id": "map",
+ "component": "GoogleMap",
+ "center": {"lat": 37.5665, "lng": 126.978},
+ "zoom": 12,
+ "routes": [{
+ "origin": {
+ "lat": 37.67,
+ "lng": 127.04,
+ "label": "Dobong",
+ },
+ "destination": {
+ "lat": 37.49,
+ "lng": 127.02,
+ "label": "Gangnam",
+ },
+ }],
+ "travelMode": "walking",
+ },
+ ],
+ },
+ },
+ {
+ "version": "v0.9",
+ "updateDataModel": {
+ "surfaceId": "directions-surface-xyz",
+ "path": "/",
+ "value": {},
+ },
+ },
+ ]
+
+ result = merge_template("directions", data, max_list_size=3)
+ self.assertEqual(result, expected)
+
+ def test_validate_directions_output_with_schema(self):
+ """Verifies merged directions output passes schema validation."""
+ data = {
+ "surface_id": "directions-surface-xyz",
+ "summary": "Typical commute is 1h 15m.",
+ "center_lat": "37.5665",
+ "center_lng": 126.9780,
+ "zoom": 12,
+ "routes": [{
+ "origin": {"lat": "37.6700", "lng": "127.0400", "label": "Dobong"},
+ "destination": {
+ "lat": 37.4900,
+ "lng": 127.0200,
+ "label": "Gangnam",
+ },
+ }],
+ "travel_mode": "WALK",
+ }
+ result = merge_template("directions", data)
+
+ self._validate_schema(result)
+
+
+class TestMergerEdgeCases(unittest.TestCase):
+ """Edge case tests for A2UI Layout Template Merger."""
+
+ def test_missing_keys_no_crash_local_search(self):
+ """Verifies that merging local_search template with empty/missing places falls back to text_only."""
+ data = {
+ "surface_id": "test-surface",
+ "summary": "Short response without places.",
+ }
+ result = merge_template("local_search", data, max_list_size=3)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(
+ result[1]["updateComponents"]["components"][1]["text"],
+ "Short response without places.",
+ )
+
+ def test_invalid_places_with_valid_center_fallback(self):
+ """Verifies that local_search fallback to text_only if places is invalid but center is valid."""
+ data = {
+ "surface_id": "test-surface",
+ "summary": "Short response.",
+ "center_lat": 37.0,
+ "center_lng": 127.0,
+ "zoom": 10,
+ "places": "NOT_A_LIST", # Invalid places
+ }
+ result = merge_template("local_search", data, max_list_size=3)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(
+ result[1]["updateComponents"]["components"][1]["text"],
+ "Short response.",
+ )
+
+ def test_missing_keys_no_crash_directions(self):
+ """Verifies that merging directions template with missing or empty routes gracefully falls back to a text-only representation."""
+ # Test missing routes
+ data_no_routes = {
+ "surface_id": "test-surface",
+ "summary": "Cannot compute directions.",
+ }
+ result = merge_template("directions", data_no_routes, max_list_size=3)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(
+ result[1]["updateComponents"]["components"][1]["text"],
+ "Cannot compute directions.",
+ )
+
+ # Test empty routes
+ data_empty_routes = {
+ "surface_id": "test-surface",
+ "summary": "Cannot compute directions.",
+ "routes": [],
+ }
+ result = merge_template("directions", data_empty_routes, max_list_size=3)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(
+ result[1]["updateComponents"]["components"][1]["text"],
+ "Cannot compute directions.",
+ )
+
+ def test_unrecognized_travel_mode(self):
+ """Verifies that unrecognized travel modes are ignored and not passed to output."""
+ data = {
+ "surface_id": "test-surface",
+ "summary": "Short response.",
+ "center_lat": 37.0,
+ "center_lng": 127.0,
+ "zoom": 10,
+ "routes": [{
+ "origin": {"lat": 37.0, "lng": 127.0, "label": "Start"},
+ "destination": {"lat": 37.1, "lng": 127.1, "label": "End"},
+ }],
+ "travel_mode": "TELEPORT",
+ }
+ result = merge_template("directions", data, max_list_size=3)
+ map_comp = next(
+ c
+ for c in result[1]["updateComponents"]["components"]
+ if c["component"] == "GoogleMap"
+ )
+ self.assertNotIn("travelMode", map_comp)
+
+ def test_travel_mode_synonyms_normalization(self):
+ """Verifies that various travel mode synonyms normalize properly in merger."""
+ test_cases = [
+ ("public transit", "transit"),
+ ("on foot", "walking"),
+ ("auto", "driving"),
+ ("cycling", "bicycling"),
+ ("subway", "transit"),
+ ("pedestrian", "walking"),
+ ]
+ for raw_mode, expected_mode in test_cases:
+ with self.subTest(raw_mode=raw_mode, expected_mode=expected_mode):
+ data = {
+ "surface_id": "test-surface",
+ "summary": "Commute.",
+ "center_lat": 37.0,
+ "center_lng": 127.0,
+ "zoom": 10,
+ "routes": [{
+ "origin": {"lat": 37.0, "lng": 127.0, "label": "Start"},
+ "destination": {"lat": 37.1, "lng": 127.1, "label": "End"},
+ }],
+ "travel_mode": raw_mode,
+ }
+ result = merge_template("directions", data, max_list_size=3)
+ map_comp = next(
+ c
+ for c in result[1]["updateComponents"]["components"]
+ if c["component"] == "GoogleMap"
+ )
+ self.assertEqual(map_comp.get("travelMode"), expected_mode)
+
+ def test_malformed_coordinate_types(self):
+ """Verifies that coordinates parsing fallback triggers text_only fallback."""
+ data = {
+ "surface_id": "test-surface",
+ "summary": "Short response.",
+ "center_lat": "invalid-lat-string", # Malformed float
+ "center_lng": None, # Malformed type
+ "zoom": "invalid-zoom", # Malformed int
+ "places": [{"name": "Dummy Place", "lat": 47.6, "lng": -122.3}],
+ }
+ result = merge_template("local_search", data, max_list_size=3)
+ # Validation failure in mandatory fields must trigger fallback to text_only
+ # (2 parts)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(
+ result[1]["updateComponents"]["components"][1]["text"],
+ "Short response.",
+ )
+
+ def test_places_not_list(self):
+ """Verifies type fallback when places is string."""
+ data = {
+ "surface_id": "test-surface",
+ "summary": "Short response.",
+ "places": "this is a string, not a list", # Invalid type for places
+ }
+ result = merge_template("local_search", data, max_list_size=3)
+ # Non-list places must trigger fallback to text_only (2 parts)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(
+ result[1]["updateComponents"]["components"][1]["text"],
+ "Short response.",
+ )
+
+ def test_missing_optional_placeholders_are_stripped(self):
+ """Verifies that optional placeholders are omitted when missing from input data."""
+ data = {
+ "surface_id": "test-surface",
+ "summary": "Results for sushi.",
+ "center_lat": 47.6062,
+ "center_lng": -122.3321,
+ "zoom": 14,
+ "places": [{
+ "placeId": "ChIJ111",
+ "name": "Espresso Vivace",
+ "lat": 47.6200,
+ "lng": -122.3200,
+ }],
+ }
+ result = merge_template("local_search", data, max_list_size=3)
+ update_components = result[1]["updateComponents"]
+ map_comp = next(
+ c for c in update_components["components"] if c["id"] == "map"
+ )
+ # Verify anchorMarker key is NOT in map component (cleanly stripped)
+ self.assertNotIn("anchorMarker", map_comp)
+
+ def test_markers_explicitly_provided_and_sanitized(self):
+ """Verifies that explicitly provided markers are used and sanitized."""
+ data = {
+ "surface_id": "test-surface",
+ "summary": "Results with custom markers.",
+ "center_lat": 47.6062,
+ "center_lng": -122.3321,
+ "zoom": 14,
+ "places": [{
+ "placeId": "ChIJ111",
+ "name": "Espresso Vivace",
+ "lat": 47.62,
+ "lng": -122.32,
+ }],
+ "markers": [
+ {"lat": "47.63", "lng": "-122.33", "label": "Custom 1"},
+ {"lat": 47.64, "lng": -122.34, "label": None},
+ {"invalid_marker": "yes"},
+ ],
+ }
+ result = merge_template("local_search", data, max_list_size=3)
+ update_components = result[1]["updateComponents"]
+ map_comp = next(
+ c for c in update_components["components"] if c["id"] == "map"
+ )
+ expected_markers = [
+ {"lat": 47.63, "lng": -122.33, "label": "Custom 1"},
+ {"lat": 47.64, "lng": -122.34, "label": ""},
+ ]
+ self.assertEqual(map_comp["markers"], expected_markers)
+
+ def test_fallback_preserves_surface_id(self):
+ """Verifies that text_only fallback preserves the provided surface_id."""
+ data = {
+ "surface_id": "my-custom-fallback-surface",
+ "summary": "Short response.",
+ "places": "invalid",
+ }
+ result = merge_template("local_search", data, max_list_size=3)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(
+ result[1]["updateComponents"]["surfaceId"],
+ "my-custom-fallback-surface",
+ )
+
+ def test_malformed_coordinate_types_directions(self):
+ """Verifies that malformed coordinates in directions trigger text_only fallback."""
+ data = {
+ "surface_id": "test-surface",
+ "summary": "Cannot compute directions.",
+ "center_lat": "invalid-lat",
+ "center_lng": 127.0,
+ "zoom": 10,
+ "routes": [{
+ "origin": {"lat": 37.0, "lng": 127.0, "label": "Start"},
+ "destination": {"lat": 37.1, "lng": 127.1, "label": "End"},
+ }],
+ }
+ result = merge_template("directions", data, max_list_size=3)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(
+ result[1]["updateComponents"]["components"][1]["text"],
+ "Cannot compute directions.",
+ )
+
+ def test_merge_directions_with_direct_routes(self):
+ """Verifies directions template merging when routes are directly passed."""
+ data = {
+ "surface_id": "directions-surface-xyz",
+ "summary": "Commute is 1h.",
+ "center_lat": 37.5665,
+ "center_lng": 126.9780,
+ "zoom": 12,
+ "routes": [
+ {
+ "origin": {"lat": 37.6700, "lng": 127.0400, "label": "A"},
+ "destination": {"lat": 37.5000, "lng": 127.0000, "label": "B"},
+ },
+ {
+ "origin": {"lat": 37.5000, "lng": 127.0000, "label": "B"},
+ "destination": {"lat": 37.4900, "lng": 127.0200, "label": "C"},
+ },
+ ],
+ }
+ result = merge_template("directions", data)
+ # The merged updateComponents should contain routes in GoogleMap
+ map_comp = next(
+ c
+ for c in result[1]["updateComponents"]["components"]
+ if c["component"] == "GoogleMap"
+ )
+ self.assertEqual(len(map_comp["routes"]), 2)
+ self.assertEqual(map_comp["routes"][0]["origin"]["label"], "A")
+ self.assertEqual(map_comp["routes"][1]["destination"]["label"], "C")
+
+ def test_merge_directions_malformed_routes_fallback(self):
+ """Verifies that malformed routes trigger text_only fallback."""
+ data = {
+ "surface_id": "test-surface",
+ "summary": "Fallback text.",
+ "center_lat": 37.0,
+ "center_lng": 127.0,
+ "zoom": 10,
+ "routes": [{
+ "origin": "not-a-dict",
+ "destination": {"lat": 37.1, "lng": 127.1, "label": "End"},
+ }],
+ }
+ result = merge_template("directions", data)
+ self.assertEqual(len(result), 2)
+ self.assertEqual(
+ result[1]["updateComponents"]["components"][1]["text"],
+ "Fallback text.",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/agent/python_agent/test_router_config.py b/agent/python_agent/test_router_config.py
new file mode 100644
index 0000000..20a6164
--- /dev/null
+++ b/agent/python_agent/test_router_config.py
@@ -0,0 +1,42 @@
+# 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
+#
+# https://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.
+
+"""Tests for Router Configuration."""
+
+import unittest
+from python_agent.router_config import IntentClass
+from python_agent.router_config import ROUTER_SYSTEM_INSTRUCTION
+from python_agent.router_config import RouterClassification
+
+
+class TestRouterConfig(unittest.TestCase):
+ """Unit tests for Intent Router configuration schema and prompt."""
+
+ def test_schema_instantiation(self):
+ data = {"intent": "LOCAL_SEARCH", "query": "coffee near me"}
+ classification = RouterClassification(**data)
+ self.assertEqual(classification.intent, IntentClass.LOCAL_SEARCH)
+ self.assertEqual(classification.query, "coffee near me")
+
+ def test_schema_validation_error(self):
+ data = {"intent": "INVALID_INTENT", "query": "coffee near me"}
+ with self.assertRaises(ValueError):
+ RouterClassification(**data)
+
+ def test_instruction_not_empty(self):
+ self.assertGreater(len(ROUTER_SYSTEM_INSTRUCTION), 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/client/android/GoogleMapsA2UI/src/main/assets/index.html b/client/android/GoogleMapsA2UI/src/main/assets/index.html
index 3643909..e1e9678 100644
--- a/client/android/GoogleMapsA2UI/src/main/assets/index.html
+++ b/client/android/GoogleMapsA2UI/src/main/assets/index.html
@@ -1,1182 +1,7299 @@
-
+
+
+
+
-
-
-
- AI Kit React Reference Implementation
-
+
+
-
+
+
-
\ No newline at end of file
+