diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml
new file mode 100644
index 0000000..2905bd5
--- /dev/null
+++ b/.github/workflows/android-ci.yml
@@ -0,0 +1,48 @@
+# 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.
+
+name: Android CI
+
+on:
+ pull_request:
+ branches: [ main ]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Android CI / build
+ # zizmor: ignore[unpinned-images]
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ persist-credentials: false
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+ cache: 'gradle'
+
+ - name: Build and test library
+ run: |
+ chmod +x ./gradlew
+ ./gradlew test assembleRelease --no-daemon
+ working-directory: client/android/GoogleMapsA2UI
diff --git a/.github/workflows/cleanup-stale-prs.yml b/.github/workflows/cleanup-stale-prs.yml
new file mode 100644
index 0000000..d238b98
--- /dev/null
+++ b/.github/workflows/cleanup-stale-prs.yml
@@ -0,0 +1,84 @@
+# 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.
+
+name: Cleanup Stale Draft PRs
+
+on:
+ schedule:
+ - cron: '0 2 * * *' # Daily at 02:00 UTC
+ workflow_dispatch:
+ inputs:
+ older_than_days:
+ description: 'Close draft PRs older than N days'
+ required: false
+ default: '3'
+ type: string
+ dry_run:
+ description: 'Dry run (simulate without closing PRs or deleting branches)'
+ required: false
+ default: false
+ type: boolean
+
+permissions:
+ pull-requests: write
+ contents: write
+
+jobs:
+ cleanup:
+ name: Cleanup Draft PRs
+ # zizmor: ignore[unpinned-images]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Close stale draft PRs and delete branches
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ INPUT_DAYS: ${{ inputs.older_than_days }}
+ INPUT_DRY_RUN: ${{ inputs.dry_run }}
+ run: |
+ DAYS="${INPUT_DAYS:-3}"
+ DRY_RUN="${INPUT_DRY_RUN:-false}"
+
+ echo "Searching for open draft PRs with head branch matching 'test_*' older than $DAYS day(s)..."
+
+ CUTOFF_EPOCH=$(date -d "$DAYS days ago" +%s)
+ echo "Cutoff timestamp: $CUTOFF_EPOCH ($(date -d "@$CUTOFF_EPOCH" --utc --iso-8601=seconds))"
+
+ PRS_JSON=$(gh pr list --repo "$GH_REPO" --state open --draft --json number,headRefName,updatedAt)
+
+ echo "$PRS_JSON" | jq -c '.[]' | while read -r pr; do
+ PR_NUMBER=$(echo "$pr" | jq -r '.number')
+ HEAD_REF=$(echo "$pr" | jq -r '.headRefName')
+ UPDATED_AT=$(echo "$pr" | jq -r '.updatedAt')
+
+ # Only target Copybara presubmit branches (prefix test_)
+ if [[ ! "$HEAD_REF" =~ ^test_ ]]; then
+ echo "Skipping PR #$PR_NUMBER (head branch '$HEAD_REF' does not match 'test_*')"
+ continue
+ fi
+
+ PR_EPOCH=$(date -d "$UPDATED_AT" +%s)
+ if [ "$PR_EPOCH" -lt "$CUTOFF_EPOCH" ]; then
+ echo "PR #$PR_NUMBER ($HEAD_REF, updated at $UPDATED_AT) is older than $DAYS day(s)."
+ if [ "$DRY_RUN" = "true" ]; then
+ echo "[DRY RUN] Would close PR #$PR_NUMBER and delete branch '$HEAD_REF'"
+ else
+ echo "Closing PR #$PR_NUMBER and deleting branch '$HEAD_REF'..."
+ gh pr close "$PR_NUMBER" --repo "$GH_REPO" --comment "Automatically closing stale presubmit draft PR and cleaning up branch." --delete-branch || \
+ gh pr close "$PR_NUMBER" --repo "$GH_REPO" --comment "Automatically closing stale presubmit draft PR."
+ fi
+ else
+ echo "Keeping PR #$PR_NUMBER ($HEAD_REF, updated at $UPDATED_AT) - active within $DAYS day(s)."
+ fi
+ done
diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml
new file mode 100644
index 0000000..6c8352a
--- /dev/null
+++ b/.github/workflows/ios-ci.yml
@@ -0,0 +1,45 @@
+# 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.
+
+name: iOS CI
+
+on:
+ pull_request:
+ branches: [ main ]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: iOS CI / build
+ # Pinned to macos-15 so the bundled Xcode and iOS Simulator lineup stay stable.
+ # zizmor: ignore[unpinned-images]
+ runs-on: macos-15
+ timeout-minutes: 30
+
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ persist-credentials: false
+
+ - name: Build and test package
+ run: |
+ xcodebuild test \
+ -scheme GoogleMapsA2UI \
+ -destination 'platform=iOS Simulator,name=iPhone 16' \
+ -skipPackagePluginValidation \
+ CODE_SIGNING_ALLOWED=NO
+ working-directory: client/ios/GoogleMapsA2UI
diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml
index 8ee8f3b..4693853 100644
--- a/.github/workflows/python-ci.yml
+++ b/.github/workflows/python-ci.yml
@@ -22,7 +22,10 @@ on:
jobs:
build:
+ name: Python CI / build
runs-on: ubuntu-latest
+ permissions:
+ contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fc62731..165d705 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,6 +18,11 @@
on:
workflow_dispatch:
+ inputs:
+ dry_run:
+ description: "Run in dry-run mode (no tags, no publish)"
+ type: boolean
+ default: true
permissions:
contents: write
@@ -45,7 +50,7 @@ jobs:
- name: Install dependencies
working-directory: client/web
- run: npm ci
+ run: npm install
- name: Setup Node for Publishing
uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1
@@ -60,5 +65,15 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_WOMBAT_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_WOMBAT_TOKEN }}
NODE_PATH: ${{ github.workspace }}/client/web/node_modules
- run: npx --prefix client/web semantic-release
+ DRY_RUN: ${{ inputs.dry_run }}
+ REF_NAME: ${{ github.ref_name }}
+ run: |
+ EXTRA_ARGS=""
+ if [ "$DRY_RUN" != "false" ]; then
+ EXTRA_ARGS="--dry-run"
+ fi
+ if [ "$REF_NAME" != "main" ]; then
+ EXTRA_ARGS="$EXTRA_ARGS --branches $REF_NAME"
+ fi
+ npx --prefix client/web semantic-release $EXTRA_ARGS
diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml
index a55bc4d..5272949 100644
--- a/.github/workflows/web-ci.yml
+++ b/.github/workflows/web-ci.yml
@@ -25,6 +25,7 @@ permissions:
jobs:
build:
+ name: Web CI / build
# zizmor: ignore[unpinned-images]
runs-on: ubuntu-24.04
steps:
diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml
index 8c4f481..5f48f4e 100644
--- a/.github/workflows/zizmor.yml
+++ b/.github/workflows/zizmor.yml
@@ -1,3 +1,17 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
name: Zizmor
on:
@@ -24,3 +38,6 @@ jobs:
- name: Run zizmor
uses: zizmorcore/zizmor-action@195d10ad90f31d8cd6ea1efd6ecc12969ddbe73f # v0.5.1
+ with:
+ args: --ignore insufficient-cooldown
+
diff --git a/.releaserc.json b/.releaserc.json
index 089c315..15c8354 100644
--- a/.releaserc.json
+++ b/.releaserc.json
@@ -3,7 +3,34 @@
"main"
],
"plugins": [
- "@semantic-release/commit-analyzer",
+ [
+ "@semantic-release/commit-analyzer",
+ {
+ "preset": "angular",
+ "releaseRules": [
+ {
+ "breaking": true,
+ "release": "patch"
+ },
+ {
+ "type": "feat",
+ "release": "patch"
+ },
+ {
+ "type": "fix",
+ "release": "patch"
+ },
+ {
+ "type": "perf",
+ "release": "patch"
+ },
+ {
+ "type": "refactor",
+ "release": "patch"
+ }
+ ]
+ }
+ ],
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
[
diff --git a/README.md b/README.md
index a153511..fad53d2 100644
--- a/README.md
+++ b/README.md
@@ -297,7 +297,6 @@ Agentic UI Toolkit requires an API Key to use Google Maps Platform products. To
Your API Key must have the following APIs enabled in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials):
-* Geocoding API
* Maps JavaScript API
* Places UI Kit
* Routes API
diff --git a/agent/python_agent/README.md b/agent/python_agent/README.md
index a56a9c1..57f5d65 100644
--- a/agent/python_agent/README.md
+++ b/agent/python_agent/README.md
@@ -14,6 +14,9 @@ AI Maps Grounding.
`DIRECTIONS`) and structured parameter extraction for low latency.
* `agent_with_grounding.py`: Contains `MAUIAgentWithGrounding`, extending the
base agent with Vertex AI Grounding capabilities.
+* `template_tool.py`: Contains standard ADK `BaseTool` implementations
+ (`RenderLocalSearchTemplateTool`, `RenderDirectionsTemplateTool`,
+ `RenderTextOnlyTemplateTool`, and `TemplateToolset`) for template rendering.
* `agent_config.py`: Contains `AgentConfig` and `FallbackMode` configurations
(`TEXT` vs `DYNAMIC`).
* `extractor.py` & `merger.py`: Parameter extraction schemas and template
diff --git a/agent/python_agent/__init__.py b/agent/python_agent/__init__.py
index 11eecd8..067827d 100644
--- a/agent/python_agent/__init__.py
+++ b/agent/python_agent/__init__.py
@@ -1 +1,21 @@
-# GMP A2UI Python Agent Package
+# 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.
+
+from template_tool import (
+ BaseTemplateTool,
+ RenderDirectionsTemplateTool,
+ RenderLocalSearchTemplateTool,
+ RenderTextOnlyTemplateTool,
+ TemplateToolset,
+)
diff --git a/agent/python_agent/after_tools_callback.py b/agent/python_agent/after_tools_callback.py
new file mode 100644
index 0000000..ee46ef7
--- /dev/null
+++ b/agent/python_agent/after_tools_callback.py
@@ -0,0 +1,100 @@
+# 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.
+
+"""After-tool callback for grounding tools in MAUI Agent."""
+
+import logging
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+# Maximum number of recent content tokens to retain in session state.
+#
+# Trade-offs / Considerations:
+# - Pros of larger values:
+# - Retains grounding tokens across longer multi-turn conversations where
+# older tool calls returned entities that are still referenced or
+# rendered in UI widgets.
+# - Prevents premature eviction of valid tokens, ensuring Place Widget
+# requests can successfully waive billing even after multiple subsequent
+# tool turns.
+# - Cons of larger values:
+# - Increases session state size and payload memory footprint across
+# requests.
+# - Increases serialized metadata size attached to message parts and RPCs.
+# - Adds backend processing overhead when downstream services must decrypt
+# and validate a larger list of candidate tokens.
+# - Since tokens have an expiration TTL (e.g. 30 minutes), retaining too
+# many historical tokens increases stale/expired tokens in the payload.
+MAX_CONTENT_TOKENS: int = 10
+
+
+def after_tools_callback(
+ tool: Any,
+ args: dict[str, Any],
+ tool_context: Any,
+ tool_response: Any,
+ **kwargs: Any,
+) -> Any:
+ """Callback to aggregate grounding_content_token into session state."""
+ # pylint: disable=unused-argument
+ if not tool_response or not isinstance(tool_response, dict):
+ return None
+
+ after_maps_tools_callback(tool_context, tool_response)
+
+ return None
+
+
+def after_maps_tools_callback(
+ tool_context: Any,
+ tool_response: Any,
+) -> Any:
+ """Callback to aggregate content_token from Maps Tools into session state."""
+ # pylint: disable=unused-argument
+ if tool_context is None or getattr(tool_context, "state", None) is None:
+ return None
+
+ token = tool_response.get("content_token")
+ if isinstance(token, str) and token:
+ content_tokens = tool_context.state.get("maps_tools_content_tokens", [])
+ # If content_tokens is not a list, initialize it to an empty list.
+ if not isinstance(content_tokens, list):
+ content_tokens = []
+ if token not in content_tokens:
+ content_tokens.append(token)
+ # Keep only the last MAX_CONTENT_TOKENS tokens.
+ if len(content_tokens) > MAX_CONTENT_TOKENS:
+ content_tokens = content_tokens[-MAX_CONTENT_TOKENS:]
+ tool_context.state["maps_tools_content_tokens"] = content_tokens
+ logger.info(
+ "--- after_maps_tools_callback: Aggregated content token into"
+ " content_tokens. ---"
+ )
+
+ return None
+
+
+def _add_maps_tools_tokens_to_part(part: Any, session: Any) -> None:
+ """Adds maps_tools_content_tokens from session state to part metadata."""
+ if session is None or getattr(session, "state", None) is None:
+ return
+ maps_tools_content_tokens = session.state.get("maps_tools_content_tokens")
+ if maps_tools_content_tokens:
+ if getattr(part, "root", None) is not None:
+ if getattr(part.root, "metadata", None) is None:
+ part.root.metadata = {}
+ part.root.metadata["maps_tools_content_tokens"] = (
+ maps_tools_content_tokens
+ )
diff --git a/agent/python_agent/agent.py b/agent/python_agent/agent.py
index 19182fb..8329ae5 100644
--- a/agent/python_agent/agent.py
+++ b/agent/python_agent/agent.py
@@ -51,9 +51,22 @@
from a2ui.schema.catalog import CatalogConfig
from a2ui.schema.catalog_provider import A2uiCatalogProvider
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.parser.constants import (
+ MSG_TYPE_CREATE_SURFACE,
+ MSG_TYPE_DELETE_SURFACE,
+ MSG_TYPE_UPDATE_COMPONENTS,
+ MSG_TYPE_UPDATE_DATA_MODEL,
+)
+from a2ui.schema.constants import (
+ A2UI_CLOSE_TAG,
+ A2UI_OPEN_TAG,
+ A2UI_SURFACE_ID_KEY,
+ VERSION_0_9,
+)
from a2ui.schema.manager import A2uiSchemaManager
+from .after_tools_callback import _add_maps_tools_tokens_to_part, after_tools_callback
+
logger = logging.getLogger(__name__)
InMemorySessionService = in_memory_session_service.InMemorySessionService
@@ -143,6 +156,24 @@ def load(self) -> dict[str, Any]:
return catalog
+def extract_surface_id(data: Any) -> str | None:
+ """Extracts the surface ID from an A2UI payload dictionary or part."""
+ if not isinstance(data, dict):
+ return None
+ for key in (
+ MSG_TYPE_CREATE_SURFACE,
+ MSG_TYPE_UPDATE_COMPONENTS,
+ MSG_TYPE_UPDATE_DATA_MODEL,
+ MSG_TYPE_DELETE_SURFACE,
+ ):
+ target = data.get(key)
+ if isinstance(target, dict):
+ surface_id = target.get(A2UI_SURFACE_ID_KEY)
+ if surface_id:
+ return str(surface_id)
+ return None
+
+
class MAUIAgent:
"""An agent that finds restaurants based on user criteria."""
@@ -159,6 +190,7 @@ def __init__(
self._model_name = model_name
self._user_id = "remote_agent"
self._shared_session_service = InMemorySessionService()
+ self._after_tool_callback = after_tools_callback
self._text_runner: Runner | None = self._build_runner(
self._build_llm_agent()
)
@@ -303,6 +335,7 @@ def _build_llm_agent(
),
instruction=instruction,
tools=[grounding_lite_mcp, skill_manager_tool],
+ after_tool_callback=self._after_tool_callback,
)
async def stream(
@@ -414,15 +447,28 @@ async def 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("-- MAUIAgent.stream: Streamed part: %s ---", part)
- yield {
- "is_task_complete": False,
- "parts": [part],
- }
+ session_surface_id = None
+ # Wrap stream parsing in try/except to prevent A2uiValidatorError from crashing the ASGI app.
+ # This ensures execution falls through to the deleteSurface/retry loop below.
+ try:
+ async for part in stream_response_to_parts(
+ self._parsers[session_id],
+ token_stream(),
+ ):
+ _add_maps_tools_tokens_to_part(part, session)
+ logger.info("-- MAUIAgent.stream: Streamed part: %s ---", part)
+ # TODO(b/553539577): Remove this workaround once A2UI fixes the stream parser state issue.
+ if isinstance(part.root, DataPart):
+ s_id = extract_surface_id(part.root.data)
+ if s_id:
+ session_surface_id = s_id
+ logger.info("[WORKAROUND] Sniffed surfaceId '%s' from streamed part", session_surface_id)
+ yield {
+ "is_task_complete": False,
+ "parts": [part],
+ }
+ except Exception as e:
+ logger.warning("--- MAUIAgent.stream: Error during stream parsing (will fall through to retry loop): %s ---", e)
else:
async for token in token_stream():
yield {
@@ -528,6 +574,9 @@ async def token_stream():
filtered_parts.append(p)
final_parts = filtered_parts
+ for p in final_parts:
+ _add_maps_tools_tokens_to_part(p, session)
+
yield {
"is_task_complete": True,
"parts": final_parts,
@@ -542,6 +591,26 @@ async def token_stream():
attempt,
max_retries + 1,
)
+
+ # Extract surfaceId to clear the failed UI card on the client
+ surface_id = session_surface_id or getattr(self._parsers.get(session_id), "surface_id", None)
+
+ if surface_id:
+ logger.info("--- MAUIAgent.stream: Sending deleteSurface for '%s' to clear failed attempt ---", surface_id)
+ yield {
+ "is_task_complete": False,
+ "parts": [
+ Part(
+ root=DataPart(
+ data={
+ "version": "v0.9",
+ "deleteSurface": {"surfaceId": surface_id},
+ }
+ )
+ )
+ ],
+ }
+
# Prepare the query for the retry
current_query_text = (
f"Your previous response was invalid. {error_message} You MUST"
@@ -573,3 +642,5 @@ async def token_stream():
],
}
# --- End: UI Validation and Retry Logic ---
+
+
diff --git a/agent/python_agent/agent_with_grounding.py b/agent/python_agent/agent_with_grounding.py
index 3d38e6a..cd32846 100644
--- a/agent/python_agent/agent_with_grounding.py
+++ b/agent/python_agent/agent_with_grounding.py
@@ -31,6 +31,8 @@
from a2ui.schema.common_modifiers import remove_strict_validation
from a2ui.schema.constants import VERSION_0_9
from a2ui.schema.manager import A2uiSchemaManager
+import place_id_resolution
+
# Import MAUIAgent to inherit from it
from agent import AGENT_INSTRUCTION, MAUIAgent, MergedCatalogProvider
@@ -116,18 +118,15 @@ async def query_vertex_map(
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.
+ 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.
- 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.
"""
+ + place_id_resolution.PROMPT_RULES
+ )
instruction = f"{generated_prompt}\n\n{skill_content}\n\n{final_instruction}"
@@ -145,48 +144,22 @@ async def query_vertex_map(
# 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")
-
+ attribution_sources = place_id_resolution.extract_attribution_sources(
+ response
+ )
+ final_response_content, unresolved_placeholders = (
+ place_id_resolution.resolve_place_ids(
+ final_response_content, attribution_sources
+ )
+ )
+ if unresolved_placeholders:
+ logger.warning(
+ "%d Place ID placeholder(s) remain in the response.",
+ unresolved_placeholders,
+ )
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("")
@@ -276,4 +249,5 @@ def _build_llm_agent(
),
instruction=instruction,
tools=[grounding_tool, skill_manager_tool],
+ after_tool_callback=self._after_tool_callback,
)
diff --git a/agent/python_agent/agent_with_templates.py b/agent/python_agent/agent_with_templates.py
index 0b2ec0a..9c96f5a 100644
--- a/agent/python_agent/agent_with_templates.py
+++ b/agent/python_agent/agent_with_templates.py
@@ -15,6 +15,7 @@
"""MAUI Agent with template-based latency optimization."""
import asyncio
+import inspect
import json
import logging
import pathlib
@@ -31,7 +32,6 @@
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
@@ -42,12 +42,18 @@
from agent import MAUIAgent
from agent_config import AgentConfig
from agent_config import FallbackMode
-from extractor import DirectionsExtractorSchema
-from extractor import LocalSearchExtractorSchema
from merger import merge_template
from router_config import IntentClass
from router_config import ROUTER_SYSTEM_INSTRUCTION
from router_config import RouterClassification
+from template_tool import (
+ BaseTemplateTool,
+ RenderDirectionsTemplateTool,
+ RenderLocalSearchTemplateTool,
+ RenderTextOnlyTemplateTool,
+ STATE_RENDERED_A2UI_DATA,
+ STATE_RENDERED_A2UI_PARTS,
+)
logger = logging.getLogger(__name__)
_SKILL_BASE_PATH = pathlib.Path(__file__).parent / "skills"
@@ -62,10 +68,6 @@
_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}
_GROUNDED_TEXT_BASE_INSTRUCTION = """\
@@ -107,9 +109,12 @@ def _on_tool_error(
) -> 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
- ):
+ if tool.name in (
+ "render_local_search_template",
+ "render_directions_template",
+ "render_text_only_template",
+ "set_model_response",
+ ) and isinstance(error, pydantic.ValidationError):
logger.warning(
"Extractor tool '%s' failed validation: %s. "
"Returning error to model for self-correction.",
@@ -161,21 +166,28 @@ def _build_dynamic_extractor_agent(
)
tools = [self.make_grounding_lite_mcp()]
- output_schema = _EXTRACTOR_SCHEMAS.get(skill_name)
+ target_tool = None
+ if skill_name == _LOCAL_SEARCH_SKILL_NAME:
+ target_tool = RenderLocalSearchTemplateTool(
+ schema_manager=schema_manager,
+ max_list_size=self.config.max_list_size,
+ surface_id_prefix=_LOCAL_SEARCH_SURFACE_PREFIX,
+ )
+ elif skill_name == _DIRECTIONS_SKILL_NAME:
+ target_tool = RenderDirectionsTemplateTool(
+ schema_manager=schema_manager,
+ max_list_size=self.config.max_list_size,
+ surface_id_prefix=_DIRECTIONS_SURFACE_PREFIX,
+ )
generate_content_config = None
- if output_schema:
- # Manually inject SetModelResponseTool
- set_response_tool = SetModelResponseTool(output_schema)
- tools.append(set_response_tool)
+ if target_tool:
+ tools.append(target_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."
+ "IMPORTANT: After using any other tools needed to complete the task,"
+ f" you MUST call {target_tool.name} to render the final response"
+ " interface."
)
if skill_name == _LOCAL_SEARCH_SKILL_NAME:
workaround_instruction += (
@@ -183,7 +195,7 @@ def _build_dynamic_extractor_agent(
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."
+ f" {target_tool.name} tool call."
)
skill_instructions = f"{skill_instructions}\n\n{workaround_instruction}"
@@ -217,6 +229,7 @@ def _build_dynamic_extractor_agent(
output_schema=None, # Keep output_schema as None in LlmAgent
generate_content_config=generate_content_config,
on_tool_error_callback=self._on_tool_error,
+ after_tool_callback=self._after_tool_callback,
)
async def _run_extractor(
@@ -225,9 +238,10 @@ async def _run_extractor(
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
+ ) -> tuple[list[Part] | None, list[str], dict[str, Any] | None]:
+ """Runs the extractor agent and collects its output (rendered parts or text)."""
+ rendered_parts: list[Part] | None = None
+ rendered_data: dict[str, Any] | None = None
full_content_list = []
async for event in runner.run_async(
@@ -238,10 +252,6 @@ async def _run_extractor(
),
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,
@@ -249,51 +259,49 @@ async def _run_extractor(
):
if hasattr(event, "get_function_calls"):
for fc in event.get_function_calls():
- if fc.name == "set_model_response":
+ if fc.name in (
+ "render_local_search_template",
+ "render_directions_template",
+ "render_text_only_template",
+ "set_model_response",
+ ):
logger.info(
- "Intercepted set_model_response tool call with args: %s",
+ "--- AGENT_WITH_TEMPLATES: Observed %s tool call with args:"
+ " %s ---",
+ fc.name,
fc.args,
)
- # Find SetModelResponseTool in agent tools
target_tool = None
for t in agent.tools:
- if getattr(t, "name", None) == "set_model_response":
+ if getattr(t, "name", None) == fc.name:
target_tool = t
break
if target_tool and hasattr(target_tool, "run_async"):
+ tool_ctx = SimpleNamespace(state={})
try:
- noop_tool_context = SimpleNamespace(
- actions=SimpleNamespace(set_model_response=None)
+ tool_result = await target_tool.run_async(
+ args=fc.args, tool_context=tool_ctx
)
- 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
+ isinstance(tool_result, dict)
+ and "error" not in tool_result
+ and STATE_RENDERED_A2UI_PARTS in tool_ctx.state
):
- logger.warning(
- "Local Pydantic validation failed: %s. Continuing.",
- validated_data["error"],
- )
- else:
- parsed_json_data = validated_data
+ rendered_parts = tool_ctx.state[STATE_RENDERED_A2UI_PARTS]
+ rendered_data = tool_ctx.state.get(STATE_RENDERED_A2UI_DATA)
logger.info(
- "Local Pydantic validation passed! Short-circuiting."
+ "--- AGENT_WITH_TEMPLATES: Template tool %s succeeded!"
+ " Captured %d rendered parts. ---",
+ fc.name,
+ len(rendered_parts),
)
break
- except pydantic.ValidationError as e:
+ except Exception as e: # pylint: disable=broad-exception-caught
logger.warning(
- "Local Pydantic validation failed: %s. Continuing.",
- e,
+ "--- AGENT_WITH_TEMPLATES: Tool execution error: %s ---", e
)
- else:
- parsed_json_data = fc.args
- break
if event.content and event.content.parts:
if event.partial:
@@ -306,7 +314,24 @@ async def _run_extractor(
if p.text:
full_content_list.append(p.text)
- return parsed_json_data, full_content_list
+ if rendered_parts is None and getattr(runner, "session_service", None):
+ get_session_fn = getattr(runner.session_service, "get_session", None)
+ if callable(get_session_fn):
+ try:
+ res = get_session_fn(
+ app_name=getattr(runner, "app_name", ""),
+ user_id=self._user_id,
+ session_id=session_id,
+ )
+ if inspect.isawaitable(res):
+ session = await res
+ if session and getattr(session, "state", None):
+ rendered_parts = session.state.get(STATE_RENDERED_A2UI_PARTS)
+ rendered_data = session.state.get(STATE_RENDERED_A2UI_DATA)
+ except Exception as e: # pylint: disable=broad-exception-caught
+ logger.debug("Could not retrieve session from session_service: %s", e)
+
+ return rendered_parts, full_content_list, rendered_data
async def _run_extractor_and_merge(
self,
@@ -317,15 +342,10 @@ async def _run_extractor_and_merge(
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
+ """Runs the dynamic extractor agent and returns rendered template parts."""
+ del template_name, surface_id_prefix
+ # 1. Resolve catalog schema manager
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(
@@ -340,35 +360,12 @@ async def _run_extractor_and_merge(
)
# 4. Run extractor runner, collecting output
- parsed_json_data, full_content_list = await self._run_extractor(
- runner, agent, current_message, session_id
+ rendered_parts, full_content_list, rendered_data = (
+ 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
+ if rendered_parts is not None:
+ return rendered_parts, None, rendered_data
else:
raw_text = "".join(full_content_list)
return None, raw_text, None
diff --git a/agent/python_agent/extractor.py b/agent/python_agent/extractor.py
index 53749a0..ea5473b 100644
--- a/agent/python_agent/extractor.py
+++ b/agent/python_agent/extractor.py
@@ -21,6 +21,22 @@
Field = pydantic.Field
+PlacePrimaryType = Literal[
+ "food_and_drink",
+ "retail",
+ "outdoor",
+ "service",
+ "lodging",
+ "emergency",
+ "entertainment",
+ "ev",
+ "airport",
+ "parking",
+ "closed",
+ "generic",
+]
+
+
class Pin(BaseModel):
"""Representation of a Map Pin."""
@@ -36,6 +52,10 @@ class Pin(BaseModel):
placeId: str | None = Field( # pylint: disable=invalid-name
default=None, description="Optional Google Maps Place ID"
)
+ placePrimaryType: PlacePrimaryType | None = Field( # pylint: disable=invalid-name
+ default=None,
+ description="Optional primary POI category type string",
+ )
@pydantic.model_validator(mode="before")
@classmethod
@@ -73,19 +93,41 @@ class PlacePin(BaseModel):
name: str = Field(description="Name of the place")
lat: float = Field(description="Latitude coordinates")
lng: float = Field(description="Longitude coordinates")
+ placePrimaryType: PlacePrimaryType | None = Field( # pylint: disable=invalid-name
+ default=None,
+ description="Optional primary POI category type string",
+ )
class LocalSearchExtractorSchema(BaseModel):
"""Structured parameters to render a local search UI update."""
+ heading: str = Field(
+ description=(
+ "A concise, constraint-confirming primary heading in sentence case"
+ " that starts with or includes the exact number of places provided"
+ " in the UI response, reflecting the prompt and primary reference"
+ " location (e.g. '5 vegetarian restaurants near The Plaza Hotel',"
+ " '5 transit stops near Seattle Center'). Plain text only; do"
+ " NOT include markdown hashtags or conversational filler."
+ ),
+ )
summary: str = Field(
description=(
- "A detailed response summarizing the search results that fully and"
- " clearly answers all aspects of the user's prompt (including"
- " qualitative criteria, preferences, and comparisons). Use markdown"
- " formatting (bullet points, bolding, tables) and break into"
- " paragraphs as needed. Bold place names."
- )
+ "A concise 1-paragraph overview that covers all returned places by"
+ " weaving them into natural, contrasting groups (e.g., pairing"
+ " lively group-friendly spots vs. intimate neighborhood bistros)"
+ " rather than listing them one by one. Broadly characterize the"
+ " dining or activity landscape near the reference location using"
+ " concrete, sensory details, bolding every place name (e.g.,"
+ " **Carmine's** and **Tony's Di Napoli**), and directly addressing"
+ " any prompt constraints. For nearby places, never describe"
+ " distances as numbers (e.g., do not say '0.3 miles' or '500"
+ " meters'); instead generalize (e.g., 'a short walk', 'just steps"
+ " away', 'a quick stroll'). Plain text with markdown bolding only;"
+ " do NOT include conversational greetings ('Sure!', 'Here are...')"
+ " and do NOT list place names in bullet points."
+ ),
)
center_lat: float = Field(description="Latitude of the center of results")
center_lng: float = Field(description="Longitude of the center of results")
@@ -93,7 +135,7 @@ class LocalSearchExtractorSchema(BaseModel):
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)"
+ description="A list of places found (limit to max list size, e.g. 5)"
)
anchor_marker: Pin | None = Field(
default=None,
@@ -156,12 +198,18 @@ def normalize_travel_mode(mode: Any) -> str | None:
class DirectionsExtractorSchema(BaseModel):
"""Structured parameters to render a directions UI update."""
+ heading: str = Field(
+ description=(
+ "A concise, constraint-confirming primary heading for the response."
+ " Plain text only (e.g., 'Walking route from Seattle Center to Pike"
+ " Place Market', 'Driving directions to JFK Airport')."
+ )
+ )
summary: str = Field(
description=(
- "A detailed response summarizing the travel directions and route"
- " options that fully answers all user questions, route comparisons,"
- " and travel context requested in the prompt. Use markdown formatting"
- " and break into paragraphs if helpful."
+ "A natural, direct resolution of the route prompt describing"
+ " approximate travel duration and distance (e.g. 'Driving from"
+ " [Origin] to [Destination] takes about 19 minutes (14 miles).')."
)
)
center_lat: float = Field(
diff --git a/agent/python_agent/merger.py b/agent/python_agent/merger.py
index ffdd9b8..624717f 100644
--- a/agent/python_agent/merger.py
+++ b/agent/python_agent/merger.py
@@ -22,6 +22,7 @@
import copy
import json
import os
+import re
from typing import Any, Literal, TypedDict
import uuid
@@ -104,9 +105,22 @@ def _prepare_local_search(
"""Validates and normalizes parameters for the local search template."""
data_copy = copy.deepcopy(data)
is_valid = True
+
+ # 1. Normalize heading
+ heading = data_copy.get("heading")
+ if heading and isinstance(heading, str):
+ clean_heading = re.sub(r"^#+\s*", "", heading).strip()
+ else:
+ anchor = data_copy.get("anchor_marker")
+ if isinstance(anchor, dict) and anchor.get("label"):
+ clean_heading = f"Places near {anchor['label']}"
+ else:
+ clean_heading = "Nearby Places"
+ data_copy["heading"] = clean_heading
+
places = data_copy.get("places")
- # 1. Validate that places is a non-empty list
+ # 2. Validate that places is a non-empty list
if not isinstance(places, list) or not places:
is_valid = False
else:
@@ -158,6 +172,8 @@ def _prepare_local_search(
}
if "placeId" in p:
marker["placeId"] = p["placeId"]
+ if "placePrimaryType" in p:
+ marker["placePrimaryType"] = p["placePrimaryType"]
markers.append(marker)
data_copy["markers"] = markers
else:
@@ -197,7 +213,30 @@ def _prepare_directions(data: dict[str, Any]) -> tuple[str, dict[str, Any]]:
routes = data_copy.get("routes")
- # 1. Validate that routes is a non-empty list of segment dicts
+ # 1. Normalize heading
+ heading = data_copy.get("heading")
+ if heading and isinstance(heading, str):
+ clean_heading = re.sub(r"^#+\s*", "", heading).strip()
+ else:
+ clean_heading = ""
+
+ if not clean_heading:
+ clean_heading = "Directions"
+ if isinstance(routes, list) and routes and isinstance(routes[0], dict):
+ origin = routes[0].get("origin")
+ destination = routes[-1].get("destination")
+ orig_label = origin.get("label") if isinstance(origin, dict) else None
+ dest_label = (
+ destination.get("label") if isinstance(destination, dict) else None
+ )
+ if orig_label and dest_label:
+ clean_heading = f"Route from {orig_label} to {dest_label}"
+ elif dest_label:
+ clean_heading = f"Directions to {dest_label}"
+
+ data_copy["heading"] = clean_heading
+
+ # 2. Validate that routes is a non-empty list of segment dicts
if not isinstance(routes, list) or not routes:
is_valid = False
else:
diff --git a/agent/python_agent/place_id_resolution.py b/agent/python_agent/place_id_resolution.py
new file mode 100644
index 0000000..c1c819d
--- /dev/null
+++ b/agent/python_agent/place_id_resolution.py
@@ -0,0 +1,173 @@
+# 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.
+
+"""Place ID resolution shared by the grounded agents.
+
+Grounding with Google Maps does not let the model see real Place IDs while it
+is generating, and asking it to recall them from parametric memory produces
+wrong IDs. Instead the system instruction tells the model to emit a
+placeholder, and this module rewrites those placeholders using the Place
+IDs that Grounding actually returned.
+
+Both `agent_with_grounding` (free-form A2UI) and `vertex_grounding_extractor`
+(template parameters) use this module so the two paths cannot drift apart.
+
+Placeholder format, which the prompt and this module must agree on exactly:
+
+ PLACE_ID_FOR_{count}_{title}
+
+`title` is the Maps source title with the " - Google Maps" branding suffix
+removed, in its original casing. `count` is the 1-based occurrence index of
+the source within that title, so five sources sharing a title get placeholders
+1 through 5.
+"""
+
+import dataclasses
+import logging
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+PLACEHOLDER_PREFIX = "PLACE_ID_FOR_"
+
+# Kept beside the parser on purpose. The placeholder format is a contract
+# between this module and the model, and a prompt edit that drifts from
+# build_placeholder_index fails silently: every key misses and raw
+# placeholders ship to the client.
+PROMPT_RULES = """PLACE ID GENERATION RULES:
+You do not have access to real placeIds. Whenever a `placeId` is required, 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."""
+
+_BRANDED_TITLE_SUFFIX = " - Google Maps"
+
+# Slicing at len(prefix) - 3 keeps the "ChI" that every Place ID starts with.
+_PREFIXED_PLACE_ID = "places/ChI"
+
+
+@dataclasses.dataclass(frozen=True)
+class AttributionSource:
+ """A Maps attribution source from grounding metadata."""
+
+ title: str
+ place_id: str
+
+
+def normalize_place_id(raw_place_id: str) -> str:
+ """Strips the 'places/' resource prefix, which A2UI components do not want."""
+ if raw_place_id.startswith(_PREFIXED_PLACE_ID):
+ return raw_place_id[len(_PREFIXED_PLACE_ID) - 3 :]
+ return raw_place_id
+
+
+def canonical_place_title(raw_title: str) -> str:
+ """Drops the branding suffix the model never sees, keeping original casing."""
+ if raw_title.endswith(_BRANDED_TITLE_SUFFIX):
+ return raw_title[: -len(_BRANDED_TITLE_SUFFIX)]
+ return raw_title
+
+
+def extract_attribution_sources(response: Any) -> list[AttributionSource]:
+ """Pulls the Maps attribution sources out of a genai response.
+
+ Missing attributes are tolerated so callers do not have to guard every
+ access. Arrival order is load-bearing: it is what the ordinals count over.
+
+ Args:
+ response: A `google.genai` GenerateContentResponse, or anything shaped
+ like one.
+
+ Returns:
+ Sources that carry both a title and a Place ID, in arrival order.
+ """
+ candidates = getattr(response, "candidates", None)
+ metadata = (
+ getattr(candidates[0], "grounding_metadata", None) if candidates else None
+ )
+ raw_chunks = getattr(metadata, "grounding_chunks", None) if metadata else None
+
+ sources = []
+ for raw_chunk in raw_chunks or []:
+ maps_source = getattr(raw_chunk, "maps", None)
+ title = getattr(maps_source, "title", None)
+ place_id = getattr(maps_source, "place_id", None)
+ if title and place_id:
+ sources.append(AttributionSource(title=title, place_id=place_id))
+ return sources
+
+
+def _build_placeholder_index(
+ attribution_sources: list[AttributionSource],
+) -> dict[str, str]:
+ """Builds the placeholder-to-Place-ID substitution map.
+
+ Ordinals count source occurrences within a canonical title, which is the
+ COUNTING rule `PROMPT_RULES` gives the model. The model and this function
+ derive their ordinals independently, so they can disagree. A disagreement
+ usually leaves the placeholder unresolved, which is visible in the UI.
+
+ Args:
+ attribution_sources: Attribution sources in arrival order.
+
+ Returns:
+ Mapping from placeholder key to canonical Place ID.
+ """
+ placeholder_index = {}
+ title_counts = {}
+ for source in attribution_sources:
+ title = canonical_place_title(source.title)
+ place_id = normalize_place_id(source.place_id)
+ if not title or not place_id:
+ continue
+ title_counts[title] = title_counts.get(title, 0) + 1
+ placeholder_index[f"{PLACEHOLDER_PREFIX}{title_counts[title]}_{title}"] = (
+ place_id
+ )
+ return placeholder_index
+
+
+def resolve_place_ids(
+ text: str,
+ attribution_sources: list[AttributionSource],
+) -> tuple[str, int]:
+ """Replaces Place ID placeholders in a payload with grounded Place IDs.
+
+ Placeholders with no matching source are deliberately left in place rather
+ than substituted with a nearby ID. A visible placeholder fails loudly in the
+ UI, whereas a plausible wrong Place ID renders a confidently wrong venue.
+
+ Args:
+ text: Serialized payload containing placeholders.
+ attribution_sources: Attribution sources in arrival order.
+
+ Returns:
+ Tuple of the rewritten payload and the count left unresolved.
+ """
+ if not text:
+ return text, 0
+
+ placeholder_index = _build_placeholder_index(attribution_sources)
+ for placeholder, place_id in placeholder_index.items():
+ text = text.replace(placeholder, place_id)
+
+ unresolved = text.count(PLACEHOLDER_PREFIX)
+ if unresolved:
+ logger.warning(
+ "%d Place ID placeholder(s) unresolved against %d source(s).",
+ unresolved,
+ len(placeholder_index),
+ )
+ return text, unresolved
diff --git a/agent/python_agent/shared/instructions/shared_style_guidelines.md b/agent/python_agent/shared/instructions/shared_style_guidelines.md
index 1c29c12..26b83b5 100644
--- a/agent/python_agent/shared/instructions/shared_style_guidelines.md
+++ b/agent/python_agent/shared/instructions/shared_style_guidelines.md
@@ -1,25 +1,34 @@
-## Conversational Text Style Guidelines
+## Response Text Guidelines
-When generating conversational text (such as summaries, descriptions, or
-directions), you must follow these formatting and content rules:
+### Role & Tone
-* **Content & Completeness**: Always fully and clearly answer each aspect of
- the user's prompt. Address all explicit constraints, qualitative criteria,
- comparisons, preferences, and sub-questions asked. Explain *why* places or
- routes fit the user's specific needs rather than providing a bare listing.
-* **Quantity & Nuance**: Make sure the answer is substantive, useful, and
- actionable. Respond with an appropriate depth of detail given the complexity
- of the question:
- * If comparing places or route alternatives, explicitly analyze their
- trade-offs (e.g. transit vs driving, travel time, convenience, cost, or
- atmosphere).
- * If the user asks about commute, context, or travel conditions, describe
- relevant timing and real-world nuances (e.g. rush-hour delays,
- navigation landmarks).
-* **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.
+- **Voice**: Warm local expert. Show warmth through highly relevant logistics,
+ NEVER conversational filler.
+- **Style**: Vivid, objective, and sensory (e.g., "low-lit basement"). NEVER
+ use empty hype words ("amazing", "charming").
+- **Perspective**: NEVER use first-person ("I recommend", "I found").
+ Attribute subjective claims to public consensus or facts (e.g., "Locals
+ praise...").
+
+### Execution & Formatting
+
+- **Headings**: Always use sentence case. Plain text only - NO markdown.
+- **Primary headings**: A concise, constraint-confirming title reflecting the
+ prompt and primary reference location. Use only the primary reference
+ location without redundant city/state nesting.
+ - **Place Searches**: Always start with or include the exact number of
+ places provided in the UI response (e.g., '5 vegetarian restaurants near
+ The Plaza Hotel', '5 transit stops near Seattle Center').
+ - **Directions**: Provide a concise route title confirming the travel mode
+ and endpoints (e.g., 'Walking route from Seattle Center to Pike Place
+ Market', 'Driving directions to JFK Airport').
+- **Precision**: Fully answer the prompt and strictly satisfy all constraints.
+- **Count matching**: If the prompt requests a specific number of places
+ (e.g., "3 hidden gem activities", "top 2 cafes", "four places to visit"),
+ ALWAYS respond with that exact number of grounded places in the `places`
+ array when possible.
+- **Differentiate places**: Describe places by mentioning unique features,
+ specialties, and review highlights.
+- **Reviews**: Never hallucinate place reviews. Only describe user sentiment
+ in aggregate from a grounded source.
+- **Addresses**: Never state full addresses in a response.
diff --git a/agent/python_agent/shared/schema/maps_catalog_extension.json b/agent/python_agent/shared/schema/maps_catalog_extension.json
index 699754f..b1b0274 100644
--- a/agent/python_agent/shared/schema/maps_catalog_extension.json
+++ b/agent/python_agent/shared/schema/maps_catalog_extension.json
@@ -44,7 +44,7 @@
"description": "The map mode."
},
"anchorMarker": {
- "$ref": "#/$defs/DynamicLatLng",
+ "$ref": "#/$defs/AnchorMarker",
"description": "The anchor marker location."
},
"markers": {
@@ -148,6 +148,52 @@
}
]
},
+ "AnchorMarker": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "lat": {
+ "type": "number"
+ },
+ "lng": {
+ "type": "number"
+ },
+ "label": {
+ "type": "string"
+ },
+ "placeId": {
+ "type": "string"
+ },
+ "placePrimaryType": {
+ "type": "string",
+ "enum": [
+ "food_and_drink",
+ "retail",
+ "outdoor",
+ "service",
+ "lodging",
+ "emergency",
+ "entertainment",
+ "ev",
+ "airport",
+ "parking",
+ "closed",
+ "generic"
+ ]
+ }
+ },
+ "required": [
+ "lat",
+ "lng"
+ ],
+ "additionalProperties": false
+ },
+ {
+ "$ref": "common_types.json#/$defs/DataBinding"
+ }
+ ]
+ },
"MapPin": {
"type": "object",
"properties": {
@@ -162,6 +208,23 @@
},
"placeId": {
"type": "string"
+ },
+ "placePrimaryType": {
+ "type": "string",
+ "enum": [
+ "food_and_drink",
+ "retail",
+ "outdoor",
+ "service",
+ "lodging",
+ "emergency",
+ "entertainment",
+ "ev",
+ "airport",
+ "parking",
+ "closed",
+ "generic"
+ ]
}
},
"required": [
diff --git a/agent/python_agent/skills/directions-template-response/SKILL.md b/agent/python_agent/skills/directions-template-response/SKILL.md
index 536c25e..8e2d181 100644
--- a/agent/python_agent/skills/directions-template-response/SKILL.md
+++ b/agent/python_agent/skills/directions-template-response/SKILL.md
@@ -20,8 +20,8 @@ If the user's query requests a scenic bypass or detour:
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.
+3. **Dispatch Response**: Call `render_directions_template` with the compiled
+ routes and pins.
## Step-by-Step Workflow
@@ -73,19 +73,23 @@ If the user's query requests a scenic bypass or detour:
-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": ...})`).
+ * **GROUNDED ROUTING CONSTRAINT**: NEVER use model knowledge to assume
+ roads used or live traffic. Always rely only on data from
+ `compute_routes`.
* 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.
+ into the `routes` array of the final `render_directions_template`
+ 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`).
+ must NEVER be omitted in `render_directions_template`. Always supply the
+ normalized mode string (`driving`, `walking`, `transit`, or
+ `bicycling`).
+ * Call `render_directions_template` with `DirectionsExtractorSchema`
+ parameters (`heading`, `summary`, `center_lat`, `center_lng`, `zoom`,
+ `routes`, `travel_mode`).
## Handling Routing Failures & Regional Limitations (CRITICAL)
@@ -103,7 +107,19 @@ or fails:
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.
+- **`heading`**: (REQUIRED) A concise, constraint-confirming primary heading
+ for the response. Plain text only (e.g., 'Walking route from Seattle Center
+ to Pike Place Market', 'Driving directions to JFK Airport'). Use sentence
+ case; do NOT include markdown hashtags or conversational filler.
+- **`summary`**: (REQUIRED) A natural, direct resolution of the route prompt
+ (e.g. 'Driving from [Origin] to [Destination] takes about 19 minutes (14
+ miles).', 'Walking from Seattle Center to Pike Place Market takes about 20
+ minutes (1 mile).'). Describe distance using units appropriate to the
+ location (miles vs. km). For driving and public transit modes, always round
+ distance to a whole number. NEVER describe time in seconds or decimals.
+ Always round seconds to the nearest minute. If it rounds to 0 minutes,
+ describe it as "less than a minute". Always describe time as
+ approximate (e.g. about, around, approximately).
- **`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.
@@ -114,26 +130,53 @@ You MUST populate all required fields in the output schema:
## 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")`
+
+User Query: "Directions from San Francisco to San Jose by car" Tool Call:
+`render_directions_template(heading="Driving directions from San Francisco to San Jose", 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")`
+
+User Query: "How do I walk from Central Park to Times Square?" Tool Call:
+`render_directions_template(heading="Walking route from Central Park to Times Square", 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")`
+
+User Query: "Bike directions from Venice Beach to Santa Monica Pier" Tool Call:
+`render_directions_template(heading="Biking route from Venice Beach to Santa Monica Pier", 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")`
+
+User Query: "Take the subway from Grand Central to Brooklyn Bridge" Tool Call:
+`render_directions_template(heading="Transit directions from Grand Central to Brooklyn Bridge", 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")`
+
+User Query: "Directions from Austin to San Antonio" Tool Call:
+`render_directions_template(heading="Driving directions from Austin to San Antonio", 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 53b29f5..b971c4e 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
@@ -58,6 +58,20 @@ You are an expert in resolving location-based queries using the **A2UI framework
* **Pins**:
* `anchorMarker`: Use for the "main" focus (e.g., a hotel).
* `markers`: Use for related results (e.g., surrounding restaurants).
+ * **POI Types (`placePrimaryType`)**: Determine `placePrimaryType` using the descriptions or categories in the tool response. If insufficient, infer it from the user prompt and place title.
+ Supported categories:
+ - `food_and_drink`: Restaurants, cafes, bars, bakeries, coffee shops, dining.
+ - `retail`: Stores, shops, boutiques, supermarkets, malls, markets.
+ - `outdoor`: Parks, trails, gardens, natural landmarks, beaches, scenic spots.
+ - `service`: Banks, salons, repair, gas stations, dry cleaners, post offices.
+ - `lodging`: Hotels, resorts, motels, hostels, B&Bs.
+ - `emergency`: Hospitals, urgent care, police, fire stations.
+ - `entertainment`: Theaters, museums, cinemas, stadiums, amusement parks, venues.
+ - `ev`: EV charging stations.
+ - `airport`: Airports.
+ - `parking`: Parking lots and garages.
+ - `closed`: Permanently closed businesses.
+ - `generic`: Default fallback when ambiguous or not clearly matching above categories.
* **References**: Refer to items in the data model via `path` for dynamic content.
* **Child Components**: When using a Column or Row layout, ensure that each child component referenced in the `children` array is also included in the `surfaceUpdate` as its own component definition.
diff --git a/agent/python_agent/skills/local-search-template-response/SKILL.md b/agent/python_agent/skills/local-search-template-response/SKILL.md
index c9fc751..185ee5f 100644
--- a/agent/python_agent/skills/local-search-template-response/SKILL.md
+++ b/agent/python_agent/skills/local-search-template-response/SKILL.md
@@ -6,7 +6,8 @@ description: Extractor skill for local place search queries. Extracts location a
# Core Objective
Extract structured parameters for local searches. You must call maps tools to
-locate matching businesses/places, and populate the response fields.
+locate matching businesses/places, and call `render_local_search_template` to
+render the results.
## Grounding & Tool-Calling Policy (CRITICAL)
@@ -15,9 +16,9 @@ locate matching businesses/places, and populate the response fields.
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.
+3. **EXACT MATCH & PLACE TYPES**: 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.
+ Determine `placePrimaryType` using the descriptions or categories in the tool response. If insufficient, infer it from the user prompt and place title.
## Multi-Step Location Resolution Policy (Anchored Search)
@@ -57,9 +58,11 @@ If search queries return empty results (`{}`) or fail:
## Output Fields
-You MUST populate all required fields in the output schema, and optionally the anchor marker if resolved:
+You MUST call `render_local_search_template` with all required fields in the
+schema, and optionally the anchor marker if resolved:
-- **`summary`**: A detailed response summarizing the search results, following the **Conversational Text Style Guidelines** below.
+- **`heading`**: A concise, constraint-confirming primary heading in sentence case that starts with or includes the exact number of places provided in the UI response, reflecting the prompt and primary reference location (e.g., '5 vegetarian restaurants near The Plaza Hotel', '5 transit stops near Seattle Center'). Use only the primary reference location without redundant city/state nesting. Plain text only; do NOT include markdown hashtags or conversational filler.
+- **`summary`**: A concise 1-paragraph overview that covers all returned places by weaving them into natural, contrasting groups (e.g., pairing lively group-friendly spots vs. intimate neighborhood bistros) rather than listing them one by one. Broadly characterize the dining or activity landscape near the reference location using concrete, sensory details, bolding every place name (e.g., **Carmine's** and **Tony's Di Napoli**), and directly addressing any prompt constraints. For nearby places, never describe distances as numbers (e.g., do not say "0.3 miles" or "500 meters"). Instead, generalize (e.g., "a short walk", "just steps away", or "a quick stroll"). Do NOT include conversational greetings ('Sure!', 'Here are...') and do NOT list place names in bullet points (individual place cards handle individual places).
- **`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).
@@ -67,5 +70,5 @@ You MUST populate all required fields in the output schema, and optionally the a
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).
+- **`places`**: Return 5 grounded places in the 'places' array by default. If the user prompt explicitly specifies a number of places, return exactly that number in the 'places' array if possible. For each place, determine `placePrimaryType` using the descriptions or categories in the tool response (or infer it from the user prompt and place title) matching supported types (`food_and_drink`, `retail`, `outdoor`, `service`, `lodging`, `entertainment`, `ev`, `airport`, `parking`, `closed`, `emergency`, `generic`).
- **`anchor_marker`**: (Optional) Pin details for the resolved starting/anchor location.
diff --git a/agent/python_agent/template_tool.py b/agent/python_agent/template_tool.py
new file mode 100644
index 0000000..1253ec0
--- /dev/null
+++ b/agent/python_agent/template_tool.py
@@ -0,0 +1,360 @@
+# 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.
+
+"""ADK Tools for MAUI template population and rendering.
+
+This file contains a set of tools for rendering the MAUI A2UI templates.
+The tools are used by the MAUI agent to render the templates based on the
+user's query and the agent's extracted information.
+
+The currently supported templates are:
+- Local Search: Used to show a list of local places and a map.
+- Directions: Used to show a route on a map.
+- Text-only: Used to render a text-only response inside an A2UI surface.
+
+Tools are built dynamically based on their Pydantic schema to ensure
+type safety and accurate function declarations.
+"""
+
+from __future__ import annotations
+
+import copy
+import inspect
+import logging
+import time
+from typing import Any, Optional, Union
+import uuid
+
+from a2a.types import Part
+from google.adk.agents.readonly_context import ReadonlyContext
+from google.adk.tools._automatic_function_calling_util import build_function_declaration
+from google.adk.tools.base_tool import BaseTool
+from google.adk.tools.base_toolset import BaseToolset, ToolPredicate
+from google.adk.tools.set_model_response_tool import _merge_json_schema_descriptions
+from google.adk.tools.tool_context import ToolContext
+from google.genai import types
+import pydantic
+
+from a2ui.a2a.parts import create_a2ui_part
+from a2ui.schema.manager import A2uiSchemaManager
+from extractor import DirectionsExtractorSchema
+from extractor import LocalSearchExtractorSchema
+from merger import merge_template
+
+logger = logging.getLogger(__name__)
+
+STATE_RENDERED_A2UI_PARTS = "rendered_a2ui_parts"
+STATE_RENDERED_A2UI_DATA = "rendered_a2ui_data"
+
+
+class BaseTemplateTool(BaseTool):
+ """Base class for ADK tools that populate and render A2UI templates."""
+
+ def __init__(
+ self,
+ *,
+ name: str,
+ description: str,
+ template_name: str,
+ schema_class: type[pydantic.BaseModel] | None = None,
+ schema_manager: A2uiSchemaManager | None = None,
+ max_list_size: int = 5,
+ surface_id_prefix: str | None = None,
+ ) -> None:
+ super().__init__(name=name, description=description)
+ self.template_name = template_name
+ self.schema_class = schema_class
+ self.schema_manager = schema_manager
+ self.max_list_size = max_list_size
+ self.surface_id_prefix = surface_id_prefix or f"{template_name}-surface"
+ self._func = self._build_handler_func()
+
+ def _build_handler_func(self) -> Any:
+ """Builds the callable signature used for FunctionDeclaration generation."""
+ if self.schema_class is not None:
+ schema_fields = self.schema_class.model_fields
+ params = []
+ for field_name, field_info in schema_fields.items():
+ param = inspect.Parameter(
+ field_name,
+ inspect.Parameter.KEYWORD_ONLY,
+ annotation=field_info.annotation,
+ default=(
+ inspect.Parameter.empty
+ if field_info.is_required()
+ else field_info.get_default(call_default_factory=True)
+ ),
+ )
+ params.append(param)
+
+ def dynamic_tool_func(**kwargs: Any) -> str:
+ del kwargs
+ return f"Rendered {self.template_name} template."
+
+ new_sig = inspect.Signature(parameters=params)
+ setattr(dynamic_tool_func, "__signature__", new_sig)
+ setattr(dynamic_tool_func, "__name__", self.name)
+ setattr(dynamic_tool_func, "__doc__", self.description)
+ return dynamic_tool_func
+ else:
+
+ def text_only_tool_func(text: str) -> str:
+ """Render a text-only UI response."""
+ del text
+ return f"Rendered {self.template_name} template."
+
+ setattr(text_only_tool_func, "__name__", self.name)
+ setattr(text_only_tool_func, "__doc__", self.description)
+ return text_only_tool_func
+
+ def _preserve_schema_descriptions(
+ self, function_decl: types.FunctionDeclaration
+ ) -> None:
+ """Restores field descriptions from Pydantic schema onto FunctionDeclaration."""
+ if self.schema_class is not None:
+ source_schema = self.schema_class.model_json_schema()
+ if function_decl.parameters_json_schema is not None:
+ _merge_json_schema_descriptions(
+ function_decl.parameters_json_schema, source_schema
+ )
+ elif function_decl.parameters is not None:
+ from google.adk.tools.set_model_response_tool import ( # pylint: disable=g-import-not-at-top
+ _apply_descriptions_to_schema_properties,
+ )
+
+ _apply_descriptions_to_schema_properties(
+ function_decl.parameters.properties,
+ self.schema_class.model_fields,
+ )
+
+ def _get_declaration(self) -> Optional[types.FunctionDeclaration]:
+ """Gets OpenAPI FunctionDeclaration specification for this tool."""
+ function_decl = types.FunctionDeclaration.model_validate(
+ build_function_declaration(
+ func=self._func,
+ ignore_params=[],
+ variant=self._api_variant,
+ )
+ )
+ self._preserve_schema_descriptions(function_decl)
+ return function_decl
+
+ async def run_async(
+ self, *, args: dict[str, Any], tool_context: ToolContext
+ ) -> dict[str, Any]:
+ """Executes the template tool: validates args, merges template, and saves A2UI parts."""
+ start_time = time.perf_counter()
+ logger.info("--- TEMPLATE_TOOL: Invoked '%s' ---", self.name)
+ logger.info(" Tool: %s (template: %s)", self.name, self.template_name)
+ logger.info(" Parameters: %s", args)
+ validated_data = copy.deepcopy(args)
+
+ # 1. Validate arguments against Pydantic schema
+ if self.schema_class is not None:
+ try:
+ model_instance = self.schema_class.model_validate(args)
+ validated_data = model_instance.model_dump(exclude_none=True)
+ except pydantic.ValidationError as e:
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
+ logger.warning(
+ "--- TEMPLATE_TOOL: Validation failed for '%s' in %.2f ms: %s ---",
+ self.name,
+ elapsed_ms,
+ e,
+ )
+ return {
+ "error": (
+ f"Validation failed for tool '{self.name}': {e}. "
+ "Please fix the parameters and call the tool again."
+ )
+ }
+
+ # 2. Ensure unique surface_id
+ if not validated_data.get("surface_id"):
+ short_id = uuid.uuid4().hex[:8]
+ validated_data["surface_id"] = f"{self.surface_id_prefix}-{short_id}"
+
+ # 3. Merge template
+ try:
+ merged_actions = merge_template(
+ self.template_name,
+ validated_data,
+ max_list_size=self.max_list_size,
+ )
+ except Exception as e: # pylint: disable=broad-exception-caught
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
+ logger.warning(
+ "--- TEMPLATE_TOOL: Failed to merge template '%s' in %.2f ms: %s ---",
+ self.template_name,
+ elapsed_ms,
+ e,
+ )
+ return {"error": f"Failed to merge template '{self.template_name}': {e}"}
+
+ # 4. Catalog schema validation
+ if self.schema_manager:
+ selected_catalog = self.schema_manager.get_selected_catalog()
+ if selected_catalog:
+ try:
+ selected_catalog.validator.validate(merged_actions)
+ except Exception as e: # pylint: disable=broad-exception-caught
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
+ logger.warning(
+ "--- TEMPLATE_TOOL: Catalog validation failed for '%s' in %.2f"
+ " ms: %s ---",
+ self.template_name,
+ elapsed_ms,
+ e,
+ )
+ return {
+ "error": (
+ f"A2UI catalog schema validation failed: {e}. "
+ "Please fix the parameters and retry."
+ )
+ }
+
+ # 5. Convert to A2A Parts and persist to session state
+ rendered_parts: list[Part] = [
+ create_a2ui_part(action) for action in merged_actions
+ ]
+ if tool_context and getattr(tool_context, "state", None) is not None:
+ tool_context.state[STATE_RENDERED_A2UI_PARTS] = rendered_parts
+ tool_context.state[STATE_RENDERED_A2UI_DATA] = validated_data
+
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
+ logger.info(
+ "--- TEMPLATE_TOOL: Successfully rendered '%s' (surface_id: %s) in %.2f"
+ " ms (%d parts) ---",
+ self.template_name,
+ validated_data["surface_id"],
+ elapsed_ms,
+ len(rendered_parts),
+ )
+
+ return {
+ "status": "success",
+ "surface_id": validated_data["surface_id"],
+ "template": self.template_name,
+ "latency_ms": round(elapsed_ms, 2),
+ "message": f"Successfully rendered {self.template_name} UI interface.",
+ }
+
+
+class RenderLocalSearchTemplateTool(BaseTemplateTool):
+ """ADK Tool that validates and renders a local search map layout."""
+
+ def __init__(
+ self,
+ *,
+ schema_manager: A2uiSchemaManager | None = None,
+ max_list_size: int = 5,
+ surface_id_prefix: str = "local-search-surface",
+ ) -> None:
+ super().__init__(
+ name="render_local_search_template",
+ description=(
+ "Renders an interactive Google Maps local search UI component"
+ " populated with places, map markers, and a summary response."
+ ),
+ template_name="local_search",
+ schema_class=LocalSearchExtractorSchema,
+ schema_manager=schema_manager,
+ max_list_size=max_list_size,
+ surface_id_prefix=surface_id_prefix,
+ )
+
+
+class RenderDirectionsTemplateTool(BaseTemplateTool):
+ """ADK Tool that validates and renders a directions and route map layout."""
+
+ def __init__(
+ self,
+ *,
+ schema_manager: A2uiSchemaManager | None = None,
+ max_list_size: int = 5,
+ surface_id_prefix: str = "directions-surface",
+ ) -> None:
+ super().__init__(
+ name="render_directions_template",
+ description=(
+ "Renders an interactive Google Maps directions and routing UI"
+ " component populated with route segments, travel mode, and a"
+ " summary response."
+ ),
+ template_name="directions",
+ schema_class=DirectionsExtractorSchema,
+ schema_manager=schema_manager,
+ max_list_size=max_list_size,
+ surface_id_prefix=surface_id_prefix,
+ )
+
+
+class RenderTextOnlyTemplateTool(BaseTemplateTool):
+ """ADK Tool that renders a text-only response inside an A2UI surface container."""
+
+ def __init__(
+ self,
+ *,
+ schema_manager: A2uiSchemaManager | None = None,
+ surface_id_prefix: str = "text-only-surface",
+ ) -> None:
+ super().__init__(
+ name="render_text_only_template",
+ description=(
+ "Renders a text response formatted inside an A2UI surface"
+ " container."
+ ),
+ template_name="text_only",
+ schema_class=None,
+ schema_manager=schema_manager,
+ max_list_size=1,
+ surface_id_prefix=surface_id_prefix,
+ )
+
+
+class TemplateToolset(BaseToolset):
+ """Toolset bundling all A2UI template population tools."""
+
+ def __init__(
+ self,
+ *,
+ schema_manager: A2uiSchemaManager | None = None,
+ max_list_size: int = 5,
+ tool_filter: Optional[Union[ToolPredicate, list[str]]] = None,
+ tool_name_prefix: Optional[str] = None,
+ ) -> None:
+ super().__init__(tool_filter=tool_filter, tool_name_prefix=tool_name_prefix)
+ self.schema_manager = schema_manager
+ self.max_list_size = max_list_size
+ self._tools: list[BaseTool] = [
+ RenderLocalSearchTemplateTool(
+ schema_manager=self.schema_manager,
+ max_list_size=self.max_list_size,
+ ),
+ RenderDirectionsTemplateTool(
+ schema_manager=self.schema_manager,
+ max_list_size=self.max_list_size,
+ ),
+ RenderTextOnlyTemplateTool(
+ schema_manager=self.schema_manager,
+ ),
+ ]
+
+ async def get_tools(
+ self,
+ readonly_context: Optional[ReadonlyContext] = None,
+ ) -> list[BaseTool]:
+ """Returns the template tools exposed by this toolset."""
+ del readonly_context
+ return list(self._tools)
diff --git a/agent/python_agent/templates/directions.json b/agent/python_agent/templates/directions.json
index 1bf5f12..f0343bb 100644
--- a/agent/python_agent/templates/directions.json
+++ b/agent/python_agent/templates/directions.json
@@ -14,13 +14,13 @@
{
"id": "root",
"component": "Column",
- "children": ["summary-text", "map"]
+ "children": ["heading-text", "map", "summary-text"]
},
{
- "id": "summary-text",
+ "id": "heading-text",
"component": "Text",
"variant": "body",
- "text": "{{summary}}"
+ "text": "### {{heading}}"
},
{
"id": "map",
@@ -32,6 +32,12 @@
"zoom": "{{zoom}}",
"routes": "{{routes}}",
"travelMode": "{{travel_mode}}"
+ },
+ {
+ "id": "summary-text",
+ "component": "Text",
+ "variant": "body",
+ "text": "{{summary}}"
}
]
}
diff --git a/agent/python_agent/templates/local_search.json b/agent/python_agent/templates/local_search.json
index 3d964e4..0ee30e0 100644
--- a/agent/python_agent/templates/local_search.json
+++ b/agent/python_agent/templates/local_search.json
@@ -14,7 +14,13 @@
{
"id": "root",
"component": "Column",
- "children": ["summary-text", "map", "list"]
+ "children": ["heading-text", "summary-text", "map", "list"]
+ },
+ {
+ "id": "heading-text",
+ "component": "Text",
+ "variant": "body",
+ "text": "### {{heading}}"
},
{
"id": "summary-text",
@@ -30,6 +36,8 @@
"lng": "{{center_lng}}"
},
"zoom": "{{zoom}}",
+ "tilt": 0,
+ "mode": "roadmap",
"anchorMarker": "{{anchor_marker}}",
"markers": "{{markers}}"
},
diff --git a/agent/python_agent/test_after_tools_callback.py b/agent/python_agent/test_after_tools_callback.py
new file mode 100644
index 0000000..d56e5f3
--- /dev/null
+++ b/agent/python_agent/test_after_tools_callback.py
@@ -0,0 +1,307 @@
+# 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 after_tools_callback."""
+
+import unittest
+from unittest import mock
+
+from after_tools_callback import _add_maps_tools_tokens_to_part, after_maps_tools_callback, after_tools_callback
+
+
+class TestAfterToolsCallback(unittest.TestCase):
+
+ def test_after_tool_callback_aggregates_maps_tools_content_tokens(self):
+ mock_tool_context = mock.MagicMock()
+ mock_tool_context.state = {}
+
+ tool_response_1 = {
+ "content_token": "token_abc_123",
+ }
+ after_tools_callback(
+ tool=None,
+ args={},
+ tool_context=mock_tool_context,
+ tool_response=tool_response_1,
+ )
+ self.assertEqual(
+ mock_tool_context.state.get("maps_tools_content_tokens"),
+ ["token_abc_123"],
+ )
+
+ tool_response_2 = {
+ "content_token": "token_def_456",
+ }
+ after_tools_callback(
+ tool=None,
+ args={},
+ tool_context=mock_tool_context,
+ tool_response=tool_response_2,
+ )
+ self.assertEqual(
+ mock_tool_context.state.get("maps_tools_content_tokens"),
+ ["token_abc_123", "token_def_456"],
+ )
+
+ # Calling again with duplicate should not add duplicates
+ after_tools_callback(
+ tool=None,
+ args={},
+ tool_context=mock_tool_context,
+ tool_response={"content_token": "token_abc_123"},
+ )
+ self.assertEqual(
+ mock_tool_context.state.get("maps_tools_content_tokens"),
+ ["token_abc_123", "token_def_456"],
+ )
+
+ def test_after_tool_callback_limits_maps_tools_content_tokens(self):
+ mock_tool_context = mock.MagicMock()
+ mock_tool_context.state = {}
+
+ for i in range(15):
+ after_tools_callback(
+ tool=None,
+ args={},
+ tool_context=mock_tool_context,
+ tool_response={"content_token": f"token_{i}"},
+ )
+
+ tokens = mock_tool_context.state.get("maps_tools_content_tokens")
+ self.assertEqual(len(tokens), 10)
+ self.assertEqual(tokens[0], "token_5")
+ self.assertEqual(tokens[-1], "token_14")
+
+ def test_after_tool_callback_with_kwargs(self):
+ mock_tool_context = mock.MagicMock()
+ mock_tool_context.state = {}
+
+ after_tools_callback(
+ tool="mock_tool",
+ args={"query": "test"},
+ tool_context=mock_tool_context,
+ tool_response={"content_token": "token_xyz"},
+ extra_param="unused",
+ )
+ self.assertEqual(
+ mock_tool_context.state.get("maps_tools_content_tokens"),
+ ["token_xyz"],
+ )
+
+ def test_after_tools_callback_none_or_empty_response(self):
+ mock_tool_context = mock.MagicMock()
+ mock_tool_context.state = {}
+
+ # None tool response
+ result = after_tools_callback(
+ tool=None,
+ args={},
+ tool_context=mock_tool_context,
+ tool_response=None,
+ )
+ self.assertIsNone(result)
+ self.assertEqual(mock_tool_context.state, {})
+
+ # Empty dict tool response
+ result = after_tools_callback(
+ tool=None,
+ args={},
+ tool_context=mock_tool_context,
+ tool_response={},
+ )
+ self.assertIsNone(result)
+ self.assertEqual(mock_tool_context.state, {})
+
+ # Non-dict tool response
+ result = after_tools_callback(
+ tool=None,
+ args={},
+ tool_context=mock_tool_context,
+ tool_response="not a dict",
+ )
+ self.assertIsNone(result)
+ self.assertEqual(mock_tool_context.state, {})
+
+ result = after_tools_callback(
+ tool=None,
+ args={},
+ tool_context=mock_tool_context,
+ tool_response=["list_not_dict"],
+ )
+ self.assertIsNone(result)
+ self.assertEqual(mock_tool_context.state, {})
+
+ def test_after_maps_tools_callback_none_or_missing_context(self):
+ # None tool_context
+ result = after_maps_tools_callback(
+ tool_context=None,
+ tool_response={"content_token": "token_1"},
+ )
+ self.assertIsNone(result)
+
+ # tool_context with state=None
+ mock_context_no_state = mock.MagicMock()
+ mock_context_no_state.state = None
+ result = after_maps_tools_callback(
+ tool_context=mock_context_no_state,
+ tool_response={"content_token": "token_1"},
+ )
+ self.assertIsNone(result)
+
+ # tool_context without state attribute
+ class DummyContext:
+ pass
+
+ result = after_maps_tools_callback(
+ tool_context=DummyContext(),
+ tool_response={"content_token": "token_1"},
+ )
+ self.assertIsNone(result)
+
+ def test_after_maps_tools_callback_invalid_token_values(self):
+ mock_tool_context = mock.MagicMock()
+ mock_tool_context.state = {}
+
+ # None token
+ after_maps_tools_callback(
+ tool_context=mock_tool_context,
+ tool_response={"content_token": None},
+ )
+ self.assertEqual(mock_tool_context.state, {})
+
+ # Empty string token
+ after_maps_tools_callback(
+ tool_context=mock_tool_context,
+ tool_response={"content_token": ""},
+ )
+ self.assertEqual(mock_tool_context.state, {})
+
+ # Non-string token (int)
+ after_maps_tools_callback(
+ tool_context=mock_tool_context,
+ tool_response={"content_token": 12345},
+ )
+ self.assertEqual(mock_tool_context.state, {})
+
+ # Missing content_token key
+ after_maps_tools_callback(
+ tool_context=mock_tool_context,
+ tool_response={"places": []},
+ )
+ self.assertEqual(mock_tool_context.state, {})
+
+ def test_after_maps_tools_callback_non_list_state_content_tokens(self):
+ mock_tool_context = mock.MagicMock()
+
+ # If state['maps_tools_content_tokens'] is not a list (e.g. a string)
+ mock_tool_context.state = {"maps_tools_content_tokens": "invalid_string"}
+ after_maps_tools_callback(
+ tool_context=mock_tool_context,
+ tool_response={"content_token": "token_1"},
+ )
+ self.assertEqual(
+ mock_tool_context.state.get("maps_tools_content_tokens"),
+ ["token_1"],
+ )
+
+ # If state['maps_tools_content_tokens'] is None
+ mock_tool_context.state = {"maps_tools_content_tokens": None}
+ after_maps_tools_callback(
+ tool_context=mock_tool_context,
+ tool_response={"content_token": "token_2"},
+ )
+ self.assertEqual(
+ mock_tool_context.state.get("maps_tools_content_tokens"),
+ ["token_2"],
+ )
+
+
+class TestAddMapsToolsTokensToPart(unittest.TestCase):
+
+ def test_add_tokens_session_none_or_missing_state(self):
+ part = mock.MagicMock()
+ part.root.metadata = None
+
+ # session is None
+ _add_maps_tools_tokens_to_part(part, None)
+ self.assertIsNone(part.root.metadata)
+
+ # session.state is None
+ mock_session = mock.MagicMock()
+ mock_session.state = None
+ _add_maps_tools_tokens_to_part(part, mock_session)
+ self.assertIsNone(part.root.metadata)
+
+ def test_add_tokens_empty_tokens_in_session(self):
+ part = mock.MagicMock()
+ part.root.metadata = None
+
+ # maps_tools_content_tokens is not in state
+ session = mock.MagicMock()
+ session.state = {}
+ _add_maps_tools_tokens_to_part(part, session)
+ self.assertIsNone(part.root.metadata)
+
+ # maps_tools_content_tokens is empty list
+ session.state = {"maps_tools_content_tokens": []}
+ _add_maps_tools_tokens_to_part(part, session)
+ self.assertIsNone(part.root.metadata)
+
+ # maps_tools_content_tokens is None
+ session.state = {"maps_tools_content_tokens": None}
+ _add_maps_tools_tokens_to_part(part, session)
+ self.assertIsNone(part.root.metadata)
+
+ def test_add_tokens_with_metadata_none(self):
+ part = mock.MagicMock()
+ part.root.metadata = None
+
+ session = mock.MagicMock()
+ session.state = {"maps_tools_content_tokens": ["token_1", "token_2"]}
+
+ _add_maps_tools_tokens_to_part(part, session)
+ self.assertEqual(
+ part.root.metadata,
+ {"maps_tools_content_tokens": ["token_1", "token_2"]},
+ )
+
+ def test_add_tokens_with_existing_metadata(self):
+ part = mock.MagicMock()
+ part.root.metadata = {"existing_field": "existing_value"}
+
+ session = mock.MagicMock()
+ session.state = {"maps_tools_content_tokens": ["token_1"]}
+
+ _add_maps_tools_tokens_to_part(part, session)
+ self.assertEqual(
+ part.root.metadata,
+ {
+ "existing_field": "existing_value",
+ "maps_tools_content_tokens": ["token_1"],
+ },
+ )
+
+ def test_add_tokens_with_none_root(self):
+ part = mock.MagicMock()
+ part.root = None
+
+ session = mock.MagicMock()
+ session.state = {"maps_tools_content_tokens": ["token_1"]}
+
+ # Should not raise AttributeError
+ _add_maps_tools_tokens_to_part(part, session)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/agent/python_agent/test_agent.py b/agent/python_agent/test_agent.py
new file mode 100644
index 0000000..1f9f723
--- /dev/null
+++ b/agent/python_agent/test_agent.py
@@ -0,0 +1,44 @@
+# 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.
+
+import unittest
+from agent import extract_surface_id
+
+class SurfaceIdExtractionTest(unittest.TestCase):
+
+ def test_extract_from_create_surface(self):
+ data = {"createSurface": {"surfaceId": "map_surface_1", "catalogId": "maps"}}
+ self.assertEqual(extract_surface_id(data), "map_surface_1")
+
+ def test_extract_from_update_components(self):
+ data = {"updateComponents": {"surfaceId": "details_card_2", "components": []}}
+ self.assertEqual(extract_surface_id(data), "details_card_2")
+
+ def test_extract_from_update_data_model(self):
+ data = {"updateDataModel": {"surfaceId": "weather_card_3", "dataModel": {}}}
+ self.assertEqual(extract_surface_id(data), "weather_card_3")
+
+ def test_extract_from_delete_surface(self):
+ data = {"deleteSurface": {"surfaceId": "old_surface_4"}}
+ self.assertEqual(extract_surface_id(data), "old_surface_4")
+
+ def test_extract_non_matching_or_malformed_data(self):
+ self.assertIsNone(extract_surface_id({"text": "hello"}))
+ self.assertIsNone(extract_surface_id(None))
+ self.assertIsNone(extract_surface_id("not_a_dict"))
+ self.assertIsNone(extract_surface_id({"createSurface": "malformed_shape"}))
+ self.assertIsNone(extract_surface_id({"createSurface": {}}))
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/agent/python_agent/test_agent_with_templates.py b/agent/python_agent/test_agent_with_templates.py
index 5072e61..3865b52 100644
--- a/agent/python_agent/test_agent_with_templates.py
+++ b/agent/python_agent/test_agent_with_templates.py
@@ -339,8 +339,9 @@ async def test_agent_directions_flow(self, mock_lite_llm_class):
mock_runner = mock.MagicMock()
mock_fc = MockFunctionCall(
- name="set_model_response",
+ name="render_directions_template",
args={
+ "heading": "Directions from home to work",
"summary": "Typical commute is 45 mins.",
"center_lat": 37.5,
"center_lng": 127.0,
@@ -470,8 +471,9 @@ async def test_agent_directions_flow_transit_mode(self, mock_lite_llm_class):
mock_runner = mock.MagicMock()
mock_fc = MockFunctionCall(
- name="set_model_response",
+ name="render_directions_template",
args={
+ "heading": "Bus directions to work",
"summary": "Take bus 10 to work.",
"center_lat": 37.5,
"center_lng": 127.0,
@@ -524,8 +526,9 @@ async def test_agent_directions_flow_walking_mode(self, mock_lite_llm_class):
mock_runner = mock.MagicMock()
mock_fc = MockFunctionCall(
- name="set_model_response",
+ name="render_directions_template",
args={
+ "heading": "Walking route to park",
"summary": "Walk for 15 minutes.",
"center_lat": 37.5,
"center_lng": 127.0,
@@ -580,8 +583,9 @@ async def test_agent_directions_flow_bicycling_mode(
mock_runner = mock.MagicMock()
mock_fc = MockFunctionCall(
- name="set_model_response",
+ name="render_directions_template",
args={
+ "heading": "Biking route to work",
"summary": "Bike for 25 minutes.",
"center_lat": 37.5,
"center_lng": 127.0,
@@ -639,8 +643,9 @@ async def test_agent_directions_flow_missing_travel_mode_fallback(
)
mock_fc = MockFunctionCall(
- name="set_model_response",
+ name="render_directions_template",
args={
+ "heading": "Directions to work",
"summary": "Typical commute is 45 mins.",
"center_lat": 37.5,
"center_lng": 127.0,
@@ -721,8 +726,9 @@ async def test_agent_local_search_flow(self, mock_lite_llm_class):
mock_runner = mock.MagicMock()
mock_fc = MockFunctionCall(
- name="set_model_response",
+ name="render_local_search_template",
args={
+ "heading": "Top Sushi Places in Seattle",
"summary": "Here are some sushi places.",
"center_lat": 47.6062,
"center_lng": -122.3321,
@@ -763,6 +769,20 @@ async def test_agent_local_search_flow(self, mock_lite_llm_class):
create_surface["surfaceId"].startswith("local-search-surface-")
)
+ update_components = parts[1].root.data["updateComponents"]
+ heading_comp = next(
+ comp
+ for comp in update_components["components"]
+ if comp["id"] == "heading-text"
+ )
+ self.assertEqual(heading_comp["text"], "### Top Sushi Places in Seattle")
+
+ map_comp = next(
+ comp for comp in update_components["components"] if comp["id"] == "map"
+ )
+ self.assertEqual(map_comp["tilt"], 0)
+ self.assertEqual(map_comp["mode"], "roadmap")
+
update_data_model = parts[2].root.data["updateDataModel"]
# Verify places array was successfully populated in data model
self.assertEqual(update_data_model["path"], "/")
@@ -788,9 +808,9 @@ async def test_agent_local_search_flow_validation_failure_fallback(
mock_runner = mock.MagicMock()
- # Mock invalid set_model_response arguments (missing required center_lat)
+ # Mock invalid render_local_search_template arguments (missing required center_lat)
invalid_args = {"summary": "Invalid data", "places": []}
- mock_fc = MockFunctionCall("set_model_response", invalid_args)
+ mock_fc = MockFunctionCall("render_local_search_template", invalid_args)
mock_event_fc = MockEvent(function_calls=[mock_fc])
mock_event_text = MockEvent(
content=MockContent([MockPart("Fallback text here.")])
@@ -843,8 +863,19 @@ async def test_agent_local_search_flow_catalog_validation_failure_fallback(
mock_runner = mock.MagicMock()
mock_fc = MockFunctionCall(
- "set_model_response",
- {"summary": "Coffee", "places": [{"name": "Starbucks"}]},
+ "render_local_search_template",
+ {
+ "heading": "Coffee Shops",
+ "summary": "Coffee",
+ "center_lat": 47.6,
+ "center_lng": -122.3,
+ "places": [{
+ "placeId": "1",
+ "name": "Starbucks",
+ "lat": 47.6,
+ "lng": -122.3,
+ }],
+ },
)
mock_runner.run_async.return_value = MockAsyncIterator(
[MockEvent(function_calls=[mock_fc])]
@@ -858,7 +889,7 @@ async def test_agent_local_search_flow_catalog_validation_failure_fallback(
"Mock validation error"
)
mock_schema_manager = mock.MagicMock()
- mock_schema_manager.get_catalog.return_value = mock_catalog
+ mock_schema_manager.get_selected_catalog.return_value = mock_catalog
agent._schema_managers = {"v0.9": mock_schema_manager}
mock_fallback_runner = mock.MagicMock()
@@ -1097,10 +1128,23 @@ def test_build_dynamic_extractor_agent_handles_file_read_error(self):
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)
+
+ def test_build_dynamic_extractor_agent_directions_loads_skill_instructions(
+ self,
+ ):
+ """Verifies that directions skill instructions from disk are loaded into the extractor agent."""
+ agent = MAUIAgentWithTemplates(base_url="http://test-url")
+ extractor_agent = agent._build_dynamic_extractor_agent( # pylint: disable=protected-access
+ "directions-template-response"
+ )
+ self.assertIn("less than a minute", extractor_agent.instruction)
+ self.assertIn(
+ "Always round seconds to the nearest minute",
+ extractor_agent.instruction,
+ )
+ tool_names = [t.name for t in extractor_agent.tools if hasattr(t, "name")]
+ self.assertIn("render_directions_template", tool_names)
+
if __name__ == "__main__":
unittest.main()
diff --git a/agent/python_agent/test_extractor.py b/agent/python_agent/test_extractor.py
index f4d77b9..a3e53ba 100644
--- a/agent/python_agent/test_extractor.py
+++ b/agent/python_agent/test_extractor.py
@@ -37,6 +37,27 @@ def test_pin_normalize_label_defaults_to_location(self):
pin = Pin(**data)
self.assertEqual(pin.label, "Location")
+ def test_pin_with_place_primary_type(self):
+ data = {
+ "lat": 1.0,
+ "lng": 2.0,
+ "label": "Coffee Shop",
+ "placePrimaryType": "food_and_drink",
+ }
+ pin = Pin(**data)
+ self.assertEqual(pin.placePrimaryType, "food_and_drink")
+
+ def test_place_pin_with_place_primary_type(self):
+ data = {
+ "placeId": "ChIJ123",
+ "name": "Coffee Shop",
+ "lat": 1.0,
+ "lng": 2.0,
+ "placePrimaryType": "food_and_drink",
+ }
+ pin = PlacePin(**data)
+ self.assertEqual(pin.placePrimaryType, "food_and_drink")
+
def test_pin_normalize_label_preserves_existing(self):
data = {
"lat": 1.0,
@@ -50,6 +71,7 @@ def test_pin_normalize_label_preserves_existing(self):
def test_directions_extractor_schema_normalize_travel_mode(self):
"""Verifies that travel mode is normalized to lowercase."""
data = {
+ "heading": "Commute Route",
"summary": "Commute is 1h.",
"center_lat": 37.5,
"center_lng": 127.0,
@@ -65,6 +87,7 @@ def test_directions_extractor_schema_normalize_travel_mode(self):
def test_directions_extractor_schema_with_routes(self):
"""Verifies that DirectionsExtractorSchema can be initialized with routes."""
data = {
+ "heading": "Scenic Route",
"summary": "Scenic route.",
"center_lat": 37.5,
"center_lng": 127.0,
@@ -91,6 +114,7 @@ def test_directions_extractor_schema_missing_travel_mode_fails_validation(
):
"""Verifies that omitting travel_mode raises ValidationError."""
data = {
+ "heading": "Directions Route",
"summary": "Directions summary",
"center_lat": 37.5,
"center_lng": 127.0,
@@ -109,6 +133,7 @@ def test_directions_extractor_schema_invalid_travel_mode_fails_validation(
for invalid_mode in ["flying", "", None, "scooter", 123]:
with self.subTest(invalid_mode=invalid_mode):
data = {
+ "heading": "Directions Route",
"summary": "Directions summary",
"center_lat": 37.5,
"center_lng": 127.0,
@@ -123,6 +148,7 @@ def test_directions_extractor_schema_all_valid_modes(self):
for mode in ["driving", "walking", "transit", "bicycling"]:
with self.subTest(mode=mode):
data = {
+ "heading": f"Going via {mode}",
"summary": f"Going via {mode}",
"center_lat": 37.5,
"center_lng": 127.0,
@@ -197,6 +223,7 @@ def test_directions_extractor_schema_normalize_all_synonyms(self):
for synonym in synonyms:
with self.subTest(synonym=synonym, expected=expected_mode):
data = {
+ "heading": "Commute",
"summary": "Commute",
"center_lat": 37.5,
"center_lng": 127.0,
@@ -206,6 +233,88 @@ def test_directions_extractor_schema_normalize_all_synonyms(self):
schema = DirectionsExtractorSchema(**data)
self.assertEqual(schema.travel_mode, expected_mode)
+ def test_directions_extractor_schema_with_heading(self):
+ """Verifies that DirectionsExtractorSchema validates with heading."""
+ data = {
+ "heading": "Walking route from Seattle Center to Pike Place Market",
+ "summary": "Walking takes about 25 minutes (1 mile).",
+ "center_lat": 47.6205,
+ "center_lng": -122.3493,
+ "travel_mode": "walking",
+ "routes": [{
+ "origin": {
+ "lat": 47.6205,
+ "lng": -122.3493,
+ "label": "Seattle Center",
+ },
+ "destination": {
+ "lat": 47.6097,
+ "lng": -122.3422,
+ "label": "Pike Place Market",
+ },
+ }],
+ }
+ schema = DirectionsExtractorSchema(**data)
+ self.assertEqual(
+ schema.heading, "Walking route from Seattle Center to Pike Place Market"
+ )
+
+ def test_directions_extractor_schema_missing_heading_fails_validation(self):
+ """Verifies that omitting heading raises ValidationError."""
+ data = {
+ "summary": "Walking takes about 25 minutes (1 mile).",
+ "center_lat": 47.6205,
+ "center_lng": -122.3493,
+ "travel_mode": "walking",
+ "routes": [{
+ "origin": {
+ "lat": 47.6205,
+ "lng": -122.3493,
+ "label": "Seattle Center",
+ },
+ "destination": {
+ "lat": 47.6097,
+ "lng": -122.3422,
+ "label": "Pike Place Market",
+ },
+ }],
+ }
+ with self.assertRaises(pydantic.ValidationError):
+ DirectionsExtractorSchema(**data)
+
+ def test_local_search_extractor_schema_with_heading(self):
+ """Verifies that LocalSearchExtractorSchema validates with heading."""
+ data = {
+ "heading": "5 Transit Stops Near Seattle Center",
+ "summary": "Here are 5 transit stops.",
+ "center_lat": 47.6205,
+ "center_lng": -122.3493,
+ "places": [{
+ "placeId": "ChIJ111",
+ "name": "Stop 1",
+ "lat": 47.62,
+ "lng": -122.35,
+ }],
+ }
+ schema = LocalSearchExtractorSchema(**data)
+ self.assertEqual(schema.heading, "5 Transit Stops Near Seattle Center")
+
+ def test_local_search_extractor_schema_missing_heading_fails_validation(self):
+ """Verifies that omitting heading raises ValidationError."""
+ data = {
+ "summary": "Here are 5 transit stops.",
+ "center_lat": 47.6205,
+ "center_lng": -122.3493,
+ "places": [{
+ "placeId": "ChIJ111",
+ "name": "Stop 1",
+ "lat": 47.62,
+ "lng": -122.35,
+ }],
+ }
+ with self.assertRaises(pydantic.ValidationError):
+ LocalSearchExtractorSchema(**data)
+
if __name__ == "__main__":
unittest.main()
diff --git a/agent/python_agent/test_merger.py b/agent/python_agent/test_merger.py
index f5e4f01..d17a31b 100644
--- a/agent/python_agent/test_merger.py
+++ b/agent/python_agent/test_merger.py
@@ -143,6 +143,7 @@ def test_merge_local_search_full_json(self):
"""Verifies merging a complete local search payload."""
data = {
"surface_id": "local-search-surface-abc",
+ "heading": "Top Coffee Shops in Seattle",
"summary": "Here are 3 highly-rated coffee shops in Seattle.",
"center_lat": "47.6062",
"center_lng": -122.3321,
@@ -185,7 +186,18 @@ def test_merge_local_search_full_json(self):
{
"id": "root",
"component": "Column",
- "children": ["summary-text", "map", "list"],
+ "children": [
+ "heading-text",
+ "summary-text",
+ "map",
+ "list",
+ ],
+ },
+ {
+ "id": "heading-text",
+ "component": "Text",
+ "variant": "body",
+ "text": "### Top Coffee Shops in Seattle",
},
{
"id": "summary-text",
@@ -200,6 +212,8 @@ def test_merge_local_search_full_json(self):
"component": "GoogleMap",
"center": {"lat": 47.6062, "lng": -122.3321},
"zoom": 14,
+ "tilt": 0,
+ "mode": "roadmap",
"markers": [
{
"lat": 47.62,
@@ -308,7 +322,7 @@ def test_merge_max_list_size_slicing(self):
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")
+ map_comp = next(comp for comp in components if comp["id"] == "map")
self.assertEqual(len(map_comp["markers"]), 2)
# Check that updateDataModel has only 2 places
@@ -317,10 +331,57 @@ def test_merge_max_list_size_slicing(self):
self.assertEqual(places[0]["placeId"], "1")
self.assertEqual(places[1]["placeId"], "2")
+ def test_merge_local_search_heading_normalization(self):
+ """Verifies that heading is cleaned of markdown headers or synthesized from anchor."""
+ # Case 1: Heading with leading markdown hashtags
+ data_with_hash = {
+ "surface_id": "test-surface",
+ "heading": "### Best Bakeries",
+ "summary": "Here are bakeries.",
+ "center_lat": 47.6,
+ "center_lng": -122.3,
+ "zoom": 13,
+ "places": [{"placeId": "p1", "name": "B1", "lat": 47.6, "lng": -122.3}],
+ }
+ result = merge_template("local_search", data_with_hash)
+ comps = result[1]["updateComponents"]["components"]
+ heading_comp = next(comp for comp in comps if comp["id"] == "heading-text")
+ self.assertEqual(heading_comp["text"], "### Best Bakeries")
+
+ # Case 2: Missing heading with anchor marker
+ data_with_anchor = {
+ "surface_id": "test-surface",
+ "summary": "Here are bakeries.",
+ "center_lat": 47.6,
+ "center_lng": -122.3,
+ "zoom": 13,
+ "anchor_marker": {"lat": 47.6, "lng": -122.3, "label": "Space Needle"},
+ "places": [{"placeId": "p1", "name": "B1", "lat": 47.6, "lng": -122.3}],
+ }
+ result = merge_template("local_search", data_with_anchor)
+ comps = result[1]["updateComponents"]["components"]
+ heading_comp = next(comp for comp in comps if comp["id"] == "heading-text")
+ self.assertEqual(heading_comp["text"], "### Places near Space Needle")
+
+ # Case 3: Missing heading and no anchor
+ data_no_heading = {
+ "surface_id": "test-surface",
+ "summary": "Here are bakeries.",
+ "center_lat": 47.6,
+ "center_lng": -122.3,
+ "zoom": 13,
+ "places": [{"placeId": "p1", "name": "B1", "lat": 47.6, "lng": -122.3}],
+ }
+ result = merge_template("local_search", data_no_heading)
+ comps = result[1]["updateComponents"]["components"]
+ heading_comp = next(comp for comp in comps if comp["id"] == "heading-text")
+ self.assertEqual(heading_comp["text"], "### Nearby Places")
+
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",
+ "heading": "Walking Route from Dobong to Gangnam",
"summary": "Typical commute is 1h 15m.",
"center_lat": "37.5665",
"center_lng": 126.9780,
@@ -352,13 +413,13 @@ def test_merge_directions_full_json(self):
{
"id": "root",
"component": "Column",
- "children": ["summary-text", "map"],
+ "children": ["heading-text", "map", "summary-text"],
},
{
- "id": "summary-text",
+ "id": "heading-text",
"component": "Text",
"variant": "body",
- "text": "Typical commute is 1h 15m.",
+ "text": "### Walking Route from Dobong to Gangnam",
},
{
"id": "map",
@@ -379,6 +440,12 @@ def test_merge_directions_full_json(self):
}],
"travelMode": "walking",
},
+ {
+ "id": "summary-text",
+ "component": "Text",
+ "variant": "body",
+ "text": "Typical commute is 1h 15m.",
+ },
],
},
},
@@ -395,6 +462,50 @@ def test_merge_directions_full_json(self):
result = merge_template("directions", data, max_list_size=3)
self.assertEqual(result, expected)
+ def test_merge_directions_heading_fallback(self):
+ """Verifies that missing heading is synthesized from route endpoints."""
+ # Case 1: Heading with leading markdown hashtags
+ data_with_hash = {
+ "surface_id": "test-surface",
+ "heading": "### Driving Route",
+ "summary": "About 15 minutes.",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "zoom": 12,
+ "routes": [{
+ "origin": {"lat": 37.5, "lng": 127.0, "label": "Origin"},
+ "destination": {"lat": 37.6, "lng": 127.1, "label": "Dest"},
+ }],
+ }
+ result = merge_template("directions", data_with_hash)
+ comps = result[1]["updateComponents"]["components"]
+ heading_comp = next(c for c in comps if c["id"] == "heading-text")
+ self.assertEqual(heading_comp["text"], "### Driving Route")
+
+ # Case 2: Missing heading with origin and destination labels
+ data_missing = {
+ "surface_id": "test-surface",
+ "summary": "About 15 minutes.",
+ "center_lat": 37.5,
+ "center_lng": 127.0,
+ "zoom": 12,
+ "routes": [{
+ "origin": {"lat": 37.5, "lng": 127.0, "label": "Seattle Center"},
+ "destination": {
+ "lat": 37.6,
+ "lng": 127.1,
+ "label": "Pike Place Market",
+ },
+ }],
+ }
+ result = merge_template("directions", data_missing)
+ comps = result[1]["updateComponents"]["components"]
+ heading_comp = next(c for c in comps if c["id"] == "heading-text")
+ self.assertEqual(
+ heading_comp["text"],
+ "### Route from Seattle Center to Pike Place Market",
+ )
+
def test_validate_directions_output_with_schema(self):
"""Verifies merged directions output passes schema validation."""
data = {
@@ -584,7 +695,7 @@ def test_missing_optional_placeholders_are_stripped(self):
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"
+ comp for comp in update_components["components"] if comp["id"] == "map"
)
# Verify anchorMarker key is NOT in map component (cleanly stripped)
self.assertNotIn("anchorMarker", map_comp)
@@ -612,7 +723,7 @@ def test_markers_explicitly_provided_and_sanitized(self):
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"
+ comp for comp in update_components["components"] if comp["id"] == "map"
)
expected_markers = [
{"lat": 47.63, "lng": -122.33, "label": "Custom 1"},
diff --git a/agent/python_agent/test_place_id_resolution.py b/agent/python_agent/test_place_id_resolution.py
new file mode 100644
index 0000000..69458d4
--- /dev/null
+++ b/agent/python_agent/test_place_id_resolution.py
@@ -0,0 +1,333 @@
+# 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.
+
+"""Unit tests for Place ID placeholder substitution."""
+
+import unittest
+from unittest import mock
+
+import place_id_resolution
+
+
+def _create_mock_response(
+ chunks: list[tuple[str, str, str]] | None = None,
+) -> mock.MagicMock:
+ """Builds a mock genai response carrying Maps attribution sources.
+
+ The shape mirrors production: every Maps source arrives with a non-empty
+ `place_id`, which is what makes placeholder substitution viable at all.
+
+ Args:
+ chunks: List of (title, place_id) tuples.
+
+ Returns:
+ Mock response matching the Google GenAI SDK candidate structure.
+ """
+ mock_chunks = []
+ for title, place_id in chunks or []:
+ mock_chunk = mock.MagicMock()
+ mock_chunk.maps.title = title
+ mock_chunk.maps.place_id = place_id
+ mock_chunks.append(mock_chunk)
+
+ mock_meta = mock.MagicMock()
+ mock_meta.grounding_chunks = mock_chunks
+ mock_candidate = mock.MagicMock()
+ mock_candidate.grounding_metadata = mock_meta
+ mock_response = mock.MagicMock()
+ mock_response.candidates = [mock_candidate]
+ return mock_response
+
+
+def _chunk(title: str, place_id: str) -> place_id_resolution.AttributionSource:
+ """Shorthand for constructing an attribution source."""
+ return place_id_resolution.AttributionSource(title=title, place_id=place_id)
+
+
+class NormalizePlaceIdTest(unittest.TestCase):
+
+ def test_strips_places_resource_prefix(self):
+ self.assertEqual(
+ place_id_resolution.normalize_place_id(
+ "places/ChIJN1t_tDeuEmsRUsoyG83frY4"
+ ),
+ "ChIJN1t_tDeuEmsRUsoyG83frY4",
+ )
+
+ def test_leaves_bare_id_untouched(self):
+ self.assertEqual(
+ place_id_resolution.normalize_place_id("ChIJN1t_tDeuEmsRUsoyG83frY4"),
+ "ChIJN1t_tDeuEmsRUsoyG83frY4",
+ )
+
+ def test_leaves_prefixed_id_untouched_when_it_is_not_a_chi_id(self):
+ # Pins shipped behavior. The prefix check demands "places/ChI", so an ID
+ # from any other family keeps its resource prefix.
+ self.assertEqual(
+ place_id_resolution.normalize_place_id("places/GhIJabc"),
+ "places/GhIJabc",
+ )
+
+
+class CanonicalPlaceTitleTest(unittest.TestCase):
+
+ def test_strips_google_maps_branding_suffix(self):
+ self.assertEqual(
+ place_id_resolution.canonical_place_title("Chez Panisse - Google Maps"),
+ "Chez Panisse",
+ )
+
+ def test_leaves_branding_suffix_written_with_an_en_dash(self):
+ # Pins shipped behavior. Only the hyphen form is recognized.
+ self.assertEqual(
+ place_id_resolution.canonical_place_title(
+ "Chez Panisse \u2013 Google Maps"
+ ),
+ "Chez Panisse \u2013 Google Maps",
+ )
+
+ def test_preserves_original_casing(self):
+ # The prompt tells the model to copy the title character for character, so
+ # lowercasing here would make every substitution key miss.
+ self.assertEqual(
+ place_id_resolution.canonical_place_title("Starbucks Coffee Company"),
+ "Starbucks Coffee Company",
+ )
+
+ def test_leaves_review_prefix_in_place(self):
+ # Pins shipped behavior. Grounding emits user reviews as their own sources,
+ # and they canonicalize to a title distinct from the venue's.
+ self.assertEqual(
+ place_id_resolution.canonical_place_title(
+ "Review of Joe's Pizza - Google Maps"
+ ),
+ "Review of Joe's Pizza",
+ )
+
+ def test_leaves_unbranded_title_untouched(self):
+ self.assertEqual(
+ place_id_resolution.canonical_place_title("Joe's Pizza"), "Joe's Pizza"
+ )
+
+
+class ExtractAttributionSourcesTest(unittest.TestCase):
+
+ def test_extracts_title_and_place_id_in_arrival_order(self):
+ response = _create_mock_response([
+ ("Joe's Pizza - Google Maps", "places/ChIJ_joe"),
+ ("Prince St. Pizza - Google Maps", "places/ChIJ_prince"),
+ ])
+
+ chunks = place_id_resolution.extract_attribution_sources(response)
+
+ self.assertEqual(
+ chunks,
+ [
+ _chunk("Joe's Pizza - Google Maps", "places/ChIJ_joe"),
+ _chunk("Prince St. Pizza - Google Maps", "places/ChIJ_prince"),
+ ],
+ )
+
+ def test_returns_empty_when_response_has_no_candidates(self):
+ response = mock.MagicMock()
+ response.candidates = []
+ self.assertEqual(
+ place_id_resolution.extract_attribution_sources(response), []
+ )
+
+ def test_returns_empty_when_grounding_metadata_absent(self):
+ response = mock.MagicMock()
+ candidate = mock.MagicMock()
+ candidate.grounding_metadata = None
+ response.candidates = [candidate]
+ self.assertEqual(
+ place_id_resolution.extract_attribution_sources(response), []
+ )
+
+ def test_skips_sources_missing_a_place_id(self):
+ response = _create_mock_response([
+ ("Joe's Pizza - Google Maps", "places/ChIJ_joe"),
+ ("A Web Result", ""),
+ ])
+
+ chunks = place_id_resolution.extract_attribution_sources(response)
+
+ self.assertEqual(len(chunks), 1)
+ self.assertEqual(chunks[0].title, "Joe's Pizza - Google Maps")
+
+
+class BuildPlaceholderIndexTest(unittest.TestCase):
+
+ def test_five_same_titled_venues_get_five_distinct_ids(self):
+ chunks = [
+ _chunk("Starbucks - Google Maps", f"places/ChIJ_sbux_{i}")
+ for i in range(1, 6)
+ ]
+
+ placeholder_index = place_id_resolution._build_placeholder_index(chunks)
+
+ self.assertEqual(
+ placeholder_index,
+ {
+ "PLACE_ID_FOR_1_Starbucks": "ChIJ_sbux_1",
+ "PLACE_ID_FOR_2_Starbucks": "ChIJ_sbux_2",
+ "PLACE_ID_FOR_3_Starbucks": "ChIJ_sbux_3",
+ "PLACE_ID_FOR_4_Starbucks": "ChIJ_sbux_4",
+ "PLACE_ID_FOR_5_Starbucks": "ChIJ_sbux_5",
+ },
+ )
+
+ def test_review_source_lands_in_its_own_title_bucket(self):
+ # Pins shipped behavior. "Review of X" canonicalizes to a title distinct
+ # from "X", so it gets its own counter and produces a key the model never
+ # emits. Venue ordinals are only disturbed when a venue surfaces
+ # exclusively as a review source.
+ chunks = [
+ _chunk("Starbucks - Google Maps", "places/ChIJ_sbux_1"),
+ _chunk("Review of Starbucks - Google Maps", "places/ChIJ_sbux_1"),
+ _chunk("Starbucks - Google Maps", "places/ChIJ_sbux_2"),
+ ]
+
+ placeholder_index = place_id_resolution._build_placeholder_index(chunks)
+
+ self.assertEqual(
+ placeholder_index,
+ {
+ "PLACE_ID_FOR_1_Starbucks": "ChIJ_sbux_1",
+ "PLACE_ID_FOR_1_Review of Starbucks": "ChIJ_sbux_1",
+ "PLACE_ID_FOR_2_Starbucks": "ChIJ_sbux_2",
+ },
+ )
+
+ def test_distinct_titles_each_start_at_ordinal_one(self):
+ chunks = [
+ _chunk("Joe's Pizza - Google Maps", "places/ChIJ_joe"),
+ _chunk("Prince St. Pizza - Google Maps", "places/ChIJ_prince"),
+ ]
+
+ placeholder_index = place_id_resolution._build_placeholder_index(chunks)
+
+ self.assertEqual(
+ placeholder_index,
+ {
+ "PLACE_ID_FOR_1_Joe's Pizza": "ChIJ_joe",
+ "PLACE_ID_FOR_1_Prince St. Pizza": "ChIJ_prince",
+ },
+ )
+
+ def test_returns_empty_map_for_no_sources(self):
+ self.assertEqual(place_id_resolution._build_placeholder_index([]), {})
+
+
+class ResolvePlaceIdsTest(unittest.TestCase):
+
+ def test_rewrites_placeholders_with_grounded_place_ids(self):
+ text = (
+ '{"places": [{"placeId": "PLACE_ID_FOR_1_Joe\'s Pizza"},'
+ ' {"placeId": "PLACE_ID_FOR_1_Prince St. Pizza"}]}'
+ )
+ chunks = [
+ _chunk("Joe's Pizza - Google Maps", "places/ChIJ_joe"),
+ _chunk("Prince St. Pizza - Google Maps", "places/ChIJ_prince"),
+ ]
+
+ result, unresolved = place_id_resolution.resolve_place_ids(text, chunks)
+
+ self.assertEqual(
+ result,
+ '{"places": [{"placeId": "ChIJ_joe"}, {"placeId": "ChIJ_prince"}]}',
+ )
+ self.assertEqual(unresolved, 0)
+
+ def test_shorter_key_shadows_the_longer_one_sharing_its_prefix(self):
+ # Pins shipped behavior. Substitution walks the index in arrival order, so
+ # PLACE_ID_FOR_1_Joe's Pizza fires first and strands " Express" on a
+ # now-real Place ID. Observed live on "Grand Central" versus "Grand Central
+ # Terminal".
+ text = "PLACE_ID_FOR_1_Joe's Pizza Express"
+ chunks = [
+ _chunk("Joe's Pizza - Google Maps", "places/ChIJ_joe"),
+ _chunk("Joe's Pizza Express - Google Maps", "places/ChIJ_express"),
+ ]
+
+ result, unresolved = place_id_resolution.resolve_place_ids(text, chunks)
+
+ self.assertEqual(result, "ChIJ_joe Express")
+ self.assertEqual(unresolved, 0)
+
+ def test_leaves_placeholder_in_place_when_sources_run_short(self):
+ # Grounding cited fewer venues than the model named. A visible placeholder
+ # breaks the card loudly; a nearby Place ID would render a wrong venue with
+ # full confidence.
+ text = "PLACE_ID_FOR_1_Starbucks and PLACE_ID_FOR_2_Starbucks"
+ chunks = [_chunk("Starbucks - Google Maps", "places/ChIJ_sbux_1")]
+
+ with self.assertLogs(place_id_resolution.__name__, level="WARNING") as logs:
+ result, unresolved = place_id_resolution.resolve_place_ids(text, chunks)
+
+ self.assertEqual(result, "ChIJ_sbux_1 and PLACE_ID_FOR_2_Starbucks")
+ self.assertEqual(unresolved, 1)
+ self.assertIn("unresolved", logs.output[0])
+
+ def test_warns_and_passes_text_through_when_no_sources_returned(self):
+ text = "PLACE_ID_FOR_1_Starbucks"
+
+ with self.assertLogs(place_id_resolution.__name__, level="WARNING") as logs:
+ result, unresolved = place_id_resolution.resolve_place_ids(text, [])
+
+ self.assertEqual(result, text)
+ self.assertEqual(unresolved, 1)
+ self.assertIn("unresolved against 0 source(s)", logs.output[0])
+
+ def test_empty_text_is_a_no_op(self):
+ chunks = [_chunk("Starbucks - Google Maps", "places/ChIJ_sbux_1")]
+ self.assertEqual(place_id_resolution.resolve_place_ids("", chunks), ("", 0))
+
+ def test_text_without_placeholders_is_unchanged(self):
+ text = '{"places": [{"placeId": "ChIJ_already_real"}]}'
+ chunks = [_chunk("Starbucks - Google Maps", "places/ChIJ_sbux_1")]
+
+ result, unresolved = place_id_resolution.resolve_place_ids(text, chunks)
+
+ self.assertEqual(result, text)
+ self.assertEqual(unresolved, 0)
+
+
+class PromptContractTest(unittest.TestCase):
+
+ def test_prompt_rules_describe_the_format_the_parser_builds(self):
+ # The prompt and build_placeholder_index must agree on the placeholder
+ # format. If they drift, every key misses and raw placeholders ship to
+ # the client, so pin the shared prefix and the ordinal example here.
+ self.assertIn(
+ f"{place_id_resolution.PLACEHOLDER_PREFIX}{{Count}}_{{Exact Title}}",
+ place_id_resolution.PROMPT_RULES,
+ )
+
+ built = place_id_resolution._build_placeholder_index([
+ _chunk("Chez Panisse - Google Maps", "places/ChIJ_cp_1"),
+ _chunk("Chez Panisse - Google Maps", "places/ChIJ_cp_2"),
+ ])
+
+ for example in (
+ f"{place_id_resolution.PLACEHOLDER_PREFIX}1_Chez Panisse",
+ f"{place_id_resolution.PLACEHOLDER_PREFIX}2_Chez Panisse",
+ ):
+ with self.subTest(placeholder=example):
+ self.assertIn(example, place_id_resolution.PROMPT_RULES)
+ self.assertIn(example, built)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/agent/python_agent/test_template_tool.py b/agent/python_agent/test_template_tool.py
new file mode 100644
index 0000000..486fd8c
--- /dev/null
+++ b/agent/python_agent/test_template_tool.py
@@ -0,0 +1,323 @@
+# 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.
+
+"""Unit tests for template_tool.py ADK tools."""
+
+import pathlib
+from types import SimpleNamespace
+import unittest
+
+from a2a.types import DataPart
+from google.adk.tools.tool_context import ToolContext
+
+import a2ui
+import agent
+import template_tool
+
+BaseTemplateTool = template_tool.BaseTemplateTool
+RenderLocalSearchTemplateTool = template_tool.RenderLocalSearchTemplateTool
+RenderDirectionsTemplateTool = template_tool.RenderDirectionsTemplateTool
+RenderTextOnlyTemplateTool = template_tool.RenderTextOnlyTemplateTool
+TemplateToolset = template_tool.TemplateToolset
+STATE_RENDERED_A2UI_PARTS = template_tool.STATE_RENDERED_A2UI_PARTS
+
+
+def _create_schema_manager():
+ extension_path = (
+ pathlib.Path(__file__).parent
+ / "shared"
+ / "schema"
+ / "maps_catalog_extension.json"
+ )
+ return 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],
+ )
+
+
+class MockToolContext:
+
+ def __init__(self):
+ self.state = {}
+ self.actions = SimpleNamespace()
+
+
+class TestTemplateTools(unittest.IsolatedAsyncioTestCase):
+ """Unit tests for ADK template tools."""
+
+ def setUp(self):
+ super().setUp()
+ self.schema_manager = _create_schema_manager()
+ self.tool_context = MockToolContext()
+
+ async def test_render_local_search_template_success(self):
+ tool = RenderLocalSearchTemplateTool(
+ schema_manager=self.schema_manager, max_list_size=3
+ )
+
+ args = {
+ "heading": "Nearby Places",
+ "summary": "Here are 2 coffee shops.",
+ "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,
+ },
+ ],
+ }
+
+ result = await tool.run_async(args=args, tool_context=self.tool_context)
+
+ self.assertEqual(result["status"], "success")
+ self.assertEqual(result["template"], "local_search")
+ self.assertIn("surface_id", result)
+
+ # Verify session state was populated with A2A parts
+ self.assertIn(STATE_RENDERED_A2UI_PARTS, self.tool_context.state)
+ parts = self.tool_context.state[STATE_RENDERED_A2UI_PARTS]
+ self.assertEqual(len(parts), 3)
+
+ create_surface_data = parts[0].root.data["createSurface"]
+ self.assertTrue(
+ create_surface_data["surfaceId"].startswith("local-search-surface-")
+ )
+
+ update_components = parts[1].root.data["updateComponents"]["components"]
+ heading_comp = next(
+ comp for comp in update_components if comp["id"] == "heading-text"
+ )
+ self.assertEqual(heading_comp["text"], "### Nearby Places")
+ map_comp = next(comp for comp in update_components if comp["id"] == "map")
+ self.assertEqual(len(map_comp["markers"]), 2)
+
+ update_data_model = parts[2].root.data["updateDataModel"]["value"]
+ self.assertEqual(len(update_data_model["places"]), 2)
+
+ async def test_render_local_search_template_with_heading(self):
+ tool = RenderLocalSearchTemplateTool(
+ schema_manager=self.schema_manager, max_list_size=3
+ )
+
+ args = {
+ "heading": "Top Coffee Shops in Seattle",
+ "summary": "Here are 2 coffee shops.",
+ "center_lat": 47.6062,
+ "center_lng": -122.3321,
+ "zoom": 14,
+ "places": [
+ {
+ "placeId": "ChIJ111",
+ "name": "Espresso Vivace",
+ "lat": 47.6200,
+ "lng": -122.3200,
+ },
+ ],
+ }
+
+ result = await tool.run_async(args=args, tool_context=self.tool_context)
+ self.assertEqual(result["status"], "success")
+
+ parts = self.tool_context.state[STATE_RENDERED_A2UI_PARTS]
+ update_components = parts[1].root.data["updateComponents"]["components"]
+ heading_comp = next(
+ comp for comp in update_components if comp["id"] == "heading-text"
+ )
+ self.assertEqual(heading_comp["text"], "### Top Coffee Shops in Seattle")
+
+ async def test_render_local_search_template_validation_failure(self):
+ tool = RenderLocalSearchTemplateTool(schema_manager=self.schema_manager)
+
+ # Missing mandatory center_lat and center_lng
+ args = {
+ "summary": "Places without center coordinates",
+ "places": [{"placeId": "1", "name": "P1", "lat": 1.0, "lng": 2.0}],
+ }
+
+ result = await tool.run_async(args=args, tool_context=self.tool_context)
+ self.assertIn("error", result)
+ self.assertIn("Validation failed for tool", result["error"])
+
+ async def test_render_local_search_template_missing_heading_validation_failure(
+ self,
+ ):
+ tool = RenderLocalSearchTemplateTool(schema_manager=self.schema_manager)
+
+ # Missing mandatory heading
+ args = {
+ "summary": "Places without heading",
+ "center_lat": 47.6062,
+ "center_lng": -122.3321,
+ "places": [{"placeId": "1", "name": "P1", "lat": 1.0, "lng": 2.0}],
+ }
+
+ result = await tool.run_async(args=args, tool_context=self.tool_context)
+ self.assertIn("error", result)
+ self.assertIn("Validation failed for tool", result["error"])
+
+ async def test_render_directions_template_success(self):
+ tool = RenderDirectionsTemplateTool(schema_manager=self.schema_manager)
+
+ args = {
+ "heading": "Driving directions from San Francisco to Oakland",
+ "summary": "Commute is 30 minutes.",
+ "center_lat": 37.7749,
+ "center_lng": -122.4194,
+ "zoom": 12,
+ "routes": [{
+ "origin": {
+ "lat": 37.7749,
+ "lng": -122.4194,
+ "label": "San Francisco",
+ "placeId": "ChIJ_SF",
+ },
+ "destination": {
+ "lat": 37.8044,
+ "lng": -122.2712,
+ "label": "Oakland",
+ "placeId": "ChIJ_OAK",
+ },
+ }],
+ "travel_mode": "driving",
+ }
+
+ result = await tool.run_async(args=args, tool_context=self.tool_context)
+
+ self.assertEqual(result["status"], "success")
+ self.assertEqual(result["template"], "directions")
+
+ self.assertIn(STATE_RENDERED_A2UI_PARTS, self.tool_context.state)
+ parts = self.tool_context.state[STATE_RENDERED_A2UI_PARTS]
+ self.assertEqual(len(parts), 3)
+
+ update_components = parts[1].root.data["updateComponents"]["components"]
+ root_comp = next(comp for comp in update_components if comp["id"] == "root")
+ self.assertEqual(
+ root_comp["children"], ["heading-text", "map", "summary-text"]
+ )
+ heading_comp = next(
+ comp for comp in update_components if comp["id"] == "heading-text"
+ )
+ self.assertEqual(
+ heading_comp["text"],
+ "### Driving directions from San Francisco to Oakland",
+ )
+ map_comp = next(comp for comp in update_components if comp["id"] == "map")
+ self.assertEqual(map_comp["travelMode"], "driving")
+ self.assertEqual(len(map_comp["routes"]), 1)
+
+ async def test_render_directions_template_missing_heading_fails(self):
+ tool = RenderDirectionsTemplateTool(schema_manager=self.schema_manager)
+
+ args = {
+ "summary": "Commute is 30 minutes.",
+ "center_lat": 37.7749,
+ "center_lng": -122.4194,
+ "zoom": 12,
+ "routes": [{
+ "origin": {"lat": 37.7749, "lng": -122.4194, "label": "A"},
+ "destination": {"lat": 37.8044, "lng": -122.2712, "label": "B"},
+ }],
+ "travel_mode": "driving",
+ }
+
+ result = await tool.run_async(args=args, tool_context=self.tool_context)
+ self.assertIn("error", result)
+ self.assertIn("Validation failed for tool", result["error"])
+
+ async def test_render_directions_template_invalid_travel_mode_fails(self):
+ tool = RenderDirectionsTemplateTool(schema_manager=self.schema_manager)
+
+ args = {
+ "summary": "Commute",
+ "center_lat": 37.7,
+ "center_lng": -122.4,
+ "zoom": 12,
+ "routes": [{
+ "origin": {"lat": 37.7, "lng": -122.4, "label": "A"},
+ "destination": {"lat": 37.8, "lng": -122.3, "label": "B"},
+ }],
+ "travel_mode": "ROCKET_SHIP", # Invalid mode
+ }
+
+ result = await tool.run_async(args=args, tool_context=self.tool_context)
+ self.assertIn("error", result)
+
+ async def test_render_text_only_template_success(self):
+ tool = RenderTextOnlyTemplateTool(schema_manager=self.schema_manager)
+
+ args = {
+ "text": "Hello world from text-only template.",
+ }
+
+ result = await tool.run_async(args=args, tool_context=self.tool_context)
+
+ self.assertEqual(result["status"], "success")
+ self.assertEqual(result["template"], "text_only")
+
+ self.assertIn(STATE_RENDERED_A2UI_PARTS, self.tool_context.state)
+ parts = self.tool_context.state[STATE_RENDERED_A2UI_PARTS]
+ self.assertEqual(len(parts), 2)
+ text_comp = parts[1].root.data["updateComponents"]["components"][1]
+ self.assertEqual(text_comp["text"], "Hello world from text-only template.")
+
+ async def test_template_toolset_returns_tools(self):
+ toolset = TemplateToolset(
+ schema_manager=self.schema_manager, max_list_size=3
+ )
+ tools = await toolset.get_tools()
+
+ self.assertEqual(len(tools), 3)
+ tool_names = [t.name for t in tools]
+ self.assertIn("render_local_search_template", tool_names)
+ self.assertIn("render_directions_template", tool_names)
+ self.assertIn("render_text_only_template", tool_names)
+
+ def test_tool_declarations_valid(self):
+ tool_ls = RenderLocalSearchTemplateTool(schema_manager=self.schema_manager)
+ decl_ls = tool_ls._get_declaration()
+ self.assertIsNotNone(decl_ls)
+ self.assertEqual(decl_ls.name, "render_local_search_template")
+
+ tool_dir = RenderDirectionsTemplateTool(schema_manager=self.schema_manager)
+ decl_dir = tool_dir._get_declaration()
+ self.assertIsNotNone(decl_dir)
+ self.assertEqual(decl_dir.name, "render_directions_template")
+
+ tool_text = RenderTextOnlyTemplateTool(schema_manager=self.schema_manager)
+ decl_text = tool_text._get_declaration()
+ self.assertIsNotNone(decl_text)
+ self.assertEqual(decl_text.name, "render_text_only_template")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/client/android/GoogleMapsA2UI/build.gradle b/client/android/GoogleMapsA2UI/build.gradle
index 702c9a0..be243f9 100644
--- a/client/android/GoogleMapsA2UI/build.gradle
+++ b/client/android/GoogleMapsA2UI/build.gradle
@@ -1,3 +1,17 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
plugins {
id 'com.android.library' version '9.0.0'
id 'maven-publish'
@@ -31,6 +45,16 @@ android {
}
}
+ // Robolectric needs the AGP-merged manifest, resources and assets. Without
+ // this, `context.assets` is empty and Robolectric cannot read `targetSdk`,
+ // so it falls back to its minimum supported SDK where API 23/24 WebViewClient
+ // overloads do not exist.
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
+
publishing {
singleVariant('release')
}
@@ -52,6 +76,9 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.11.1'
+ testImplementation 'com.google.truth:truth:1.4.2'
+ testImplementation 'org.mockito:mockito-core:5.11.0'
+ testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1'
}
afterEvaluate {
diff --git a/client/android/GoogleMapsA2UI/src/main/assets/index.html b/client/android/GoogleMapsA2UI/src/main/assets/index.html
index 0f35689..c544692 100644
--- a/client/android/GoogleMapsA2UI/src/main/assets/index.html
+++ b/client/android/GoogleMapsA2UI/src/main/assets/index.html
@@ -31,1202 +31,7468 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
-
+