Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@
uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1

with:
node-version: 20
node-version: 22
registry-url: "https://wombat-dressing-room.appspot.com/"

- name: Run Semantic Release
working-directory: client/web
env:
GITHUB_TOKEN: ${{ secrets.SYNCED_GITHUB_TOKEN_REPO }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_WOMBAT_TOKEN }}
Comment thread
chengfeitao-google marked this conversation as resolved.
Dismissed
NPM_TOKEN: ${{ secrets.NPM_WOMBAT_TOKEN }}
run: npm run release
NODE_PATH: ${{ github.workspace }}/client/web/node_modules
run: npx --prefix client/web semantic-release

14 changes: 14 additions & 0 deletions .github/workflows/web-ci.yml
Original file line number Diff line number Diff line change
@@ -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: Web CI

on:
Expand Down
2 changes: 1 addition & 1 deletion agent/python_agent/agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class AgentConfig:
generic_model: str = "gemini/gemini-3-flash-preview"
router_thinking_budget: int = 0
extractor_thinking_budget: int = 0
fallback_mode: FallbackMode = FallbackMode.DYNAMIC
fallback_mode: FallbackMode = FallbackMode.TEXT

def __post_init__(self):
if not isinstance(self.fallback_mode, FallbackMode):
Expand Down
38 changes: 31 additions & 7 deletions agent/python_agent/agent_with_grounding.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,15 @@
logger.warning("Skill file not found at %s", skill_path)


async def query_vertex_map(query: str) -> str:
async def query_vertex_map(
query: str,
model_id: str = "gemini-3-flash-preview",
) -> str:
"""Query Google Maps via Vertex Grounding and return cleaned response.
Args:
query: The location query or question.
model_id: The model ID to use for Vertex Grounding.
Returns:
The grounded and cleaned A2UI response string.
Expand All @@ -74,8 +78,6 @@ async def query_vertex_map(query: str) -> str:
location = "global"
logger.warning("GOOGLE_CLOUD_LOCATION is not set, defaulting to 'global'.")

model_id = "gemini-3-flash-preview"

client = genai.Client(vertexai=True, project=project_id, location=location)

# Construct instruction
Expand Down Expand Up @@ -203,8 +205,30 @@ async def query_vertex_map(query: str) -> str:
class MAUIAgentWithGrounding(MAUIAgent):
"""An agent that finds restaurants based on user criteria, using Vertex Grounding."""

def __init__(self, base_url: str):
super().__init__(base_url, agent_name="MAUI Agent with Grounding")
def __init__(
self,
base_url: str,
model_name: str = "gemini/gemini-3-flash-preview",
):
super().__init__(
base_url,
agent_name="MAUI Agent with Grounding",
model_name=model_name,
)

async def query_vertex_map(self, query: str) -> str:
"""Query Google Maps via Vertex Grounding and return cleaned response.
Args:
query: The location query or question.
Returns:
The grounded and cleaned A2UI response string.
"""
model_id = (
self._model_name.removeprefix("gemini/").removeprefix("models/")
)
return await query_vertex_map(query, model_id=model_id)

def _build_llm_agent(
self, schema_manager: A2uiSchemaManager | None = None
Expand All @@ -223,7 +247,7 @@ def _build_llm_agent(
skill_manager_tool = skill_toolset.SkillToolset(skills=skills)

# Use FunctionTool for Vertex grounding
grounding_tool = FunctionTool(func=query_vertex_map)
grounding_tool = FunctionTool(func=self.query_vertex_map)

agent_instruction = """You are a location routing agent.
Whenever the user asks a question about a location, directions, places, or maps,
Expand All @@ -245,7 +269,7 @@ def _build_llm_agent(
instruction = agent_instruction

return LlmAgent(
model=LiteLlm(model="gemini/gemini-3-flash-preview"),
model=LiteLlm(model=self._model_name),
name="maui_agent_grounding",
description=(
"An agent that can provide Google Maps UI-enriched responses using"
Expand Down
179 changes: 130 additions & 49 deletions agent/python_agent/agent_with_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

"""MAUI Agent with template-based latency optimization."""

import asyncio
import json
import logging
import pathlib
from types import SimpleNamespace
Expand All @@ -25,6 +27,7 @@
from google.adk import skills as adk_skills
from google.adk.agents import run_config
from google.adk.agents.llm_agent import LlmAgent
from google.adk.events.event import Event
from google.adk.models.lite_llm import LiteLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.runners import Runner
Expand Down Expand Up @@ -65,6 +68,17 @@
}
_SUPPORTED_INTENTS = {IntentClass.LOCAL_SEARCH, IntentClass.DIRECTIONS}

_GROUNDED_TEXT_BASE_INSTRUCTION = """\
You are an expert location and navigation assistant with access to Google Maps tools.

## Core Rules
1. **Accuracy & Grounding**: Use Google Maps tools to look up real-time places, business hours, amenities, contact details, routes, and weather. NEVER hallucinate place facts, locations, or operational details.
2. **Parallel Tool Execution**: When researching multiple entities, neighborhoods, routes, or options, emit ALL independent tool calls in parallel within your initial response turn. Only serialize calls when Step 2 strictly depends on data returned by Step 1.
3. **Minimize Round-Trips**: Gather necessary place facts efficiently and emit independent queries in parallel. Only perform follow-up tool turns when subsequent calls strictly depend on data returned from earlier steps (e.g., retrieving details for specific place IDs or searching along a computed route polyline). Avoid redundant follow-up queries for details already retrieved.
4. **Helpful & Actionable Answers**: Fully address all constraints in the user's prompt (e.g., parking, pricing, specific dietary options, bag policies). If tools return generic listings that lack specific policy details, supplement with known facts while noting any uncertainty.
5. **No A2UI Tags**: Return standard plain text/markdown only. Do NOT output A2UI tags or JSON surfaces.
"""


class MAUIAgentWithTemplates(MAUIAgent):
"""MAUI Agent extending base with server-side layout templates and query intent routing."""
Expand All @@ -74,6 +88,7 @@ def __init__(self, base_url: str, config: AgentConfig | None = None) -> None:
super().__init__(base_url=base_url, model_name=self.config.generic_model)
self.router_client = LiteLlm(model=self.config.router_model)
self.extractor_client = LiteLlm(model=self.config.template_model)
self.fallback_client = LiteLlm(model=self.config.generic_model)

def _build_runner(self, agent: LlmAgent) -> Runner:
runner = super()._build_runner(agent)
Expand Down Expand Up @@ -109,6 +124,19 @@ def _on_tool_error(
}
return None

def _load_shared_guidelines(self) -> str:
"""Loads shared conversational text style guidelines if available."""
shared_guidelines_path = (
_SHARED_INSTRUCTIONS_PATH / "shared_style_guidelines.md"
)
if shared_guidelines_path.exists():
try:
with open(shared_guidelines_path, "r", encoding="utf-8") as f:
return f.read()
except (OSError, ValueError) as e:
logger.warning("Failed to load shared style guidelines: %s", e)
return ""

def _build_dynamic_extractor_agent(
self,
skill_name: str,
Expand All @@ -118,16 +146,9 @@ def _build_dynamic_extractor_agent(
skill_dir = _SKILL_BASE_PATH / skill_name
skill = adk_skills.load_skill_from_dir(skill_dir)
skill_instructions = skill.instructions
shared_guidelines_path = (
_SHARED_INSTRUCTIONS_PATH / "shared_style_guidelines.md"
)
if shared_guidelines_path.exists():
try:
with open(shared_guidelines_path, "r", encoding="utf-8") as f:
shared_guidelines = f.read()
skill_instructions = f"{skill_instructions}\n\n{shared_guidelines}"
except (OSError, ValueError) as e:
logger.warning("Failed to load shared style guidelines: %s", e)
shared_guidelines = self._load_shared_guidelines()
if shared_guidelines:
skill_instructions = f"{skill_instructions}\n\n{shared_guidelines}"

# Extractors use template_model, generic UI uses generic_model
if skill_name.endswith("-template-response"):
Expand Down Expand Up @@ -385,9 +406,11 @@ async def stream(
else:
logger.info(
"Router matched OTHER_SPATIAL and fallback_mode is TEXT. "
"Executing fast text response flow."
"Executing grounded text fallback flow."
)
final_parts = await self._handle_grounded_text_fallback(
cleaned_query, session_id
)
final_parts = await self._handle_text_only(cleaned_query, session_id)
yield {
"is_task_complete": True,
"parts": final_parts,
Expand All @@ -410,7 +433,22 @@ async def stream(
yield part
return

# Fallback for un-implemented spatial intents
# Fallback for un-implemented spatial intents or validation failures
if self.config.fallback_mode == FallbackMode.TEXT:
logger.info(
"FallbackMode.TEXT enabled: routing intent %s to grounded text"
" handler.",
intent,
)
final_parts = await self._handle_grounded_text_fallback(
cleaned_query, session_id
)
yield {
"is_task_complete": True,
"parts": final_parts,
}
return

logger.warning(
"Intent %s not supported by template extractors. Falling back to base"
" UI stream.",
Expand Down Expand Up @@ -483,49 +521,90 @@ def _wrap_in_text_only(self, text: str, session_id: str) -> list[Part]:
)
return [create_a2ui_part(action) for action in merged_actions]

async def _handle_text_only(
self, cleaned_query: str, session_id: str
) -> list[Part]:
"""Generates a plain text response and wraps it in the text_only template.

Args:
cleaned_query: The cleaned user query.
session_id: Context session ID.
def _get_grounded_text_instruction(self) -> str:
"""Builds system instruction for grounded text responses."""
return _GROUNDED_TEXT_BASE_INSTRUCTION

Returns:
List of A2A Parts.
"""
extractor_config = {
"system_instruction": (
"You are a helpful location assistant. Answer the user's"
" question directly. Keep it relatively concise. Do NOT"
" output A2UI tags."
),
}
if self.config.extractor_thinking_budget > 0:
extractor_config["thinking_config"] = types.ThinkingConfig(
thinking_budget=self.config.extractor_thinking_budget
async def _handle_grounded_text(
self, cleaned_query: str, session_id: str, client: LiteLlm
) -> list[Part]:
"""Generates a grounded plain text response using the provided model client with GroundingLite tools."""
system_instruction = self._get_grounded_text_instruction()
generate_content_config = None
if (
client == self.extractor_client
and self.config.extractor_thinking_budget > 0
):
generate_content_config = types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(
thinking_budget=self.config.extractor_thinking_budget
)
)

answer_req = LlmRequest(
contents=[
types.Content(
role="user",
parts=[types.Part.from_text(text=cleaned_query)],
)
],
config=types.GenerateContentConfig(**extractor_config),
tools = [self.make_grounding_lite_mcp()]
agent = LlmAgent(
model=client,
name="maui_grounded_text_agent",
description="Agent for text responses with Maps grounding",
instruction=system_instruction,
tools=tools,
generate_content_config=generate_content_config,
)
runner = self._build_runner(agent)
current_message = types.Content(
role="user", parts=[types.Part.from_text(text=cleaned_query)]
)

answer_text = ""
async for res in self.extractor_client.generate_content_async(answer_req):
if res.content and res.content.parts:
for p in res.content.parts:
if p.text:
answer_text += p.text
try:
async for event in runner.run_async(
user_id=self._user_id,
session_id=session_id,
run_config=run_config.RunConfig(
streaming_mode=run_config.StreamingMode.SSE,
),
new_message=current_message,
state_delta={
"expression": "{expression}",
"base_url": self.base_url,
},
):
if event.content and event.content.parts:
if event.partial:
for p in event.content.parts:
if p.text:
answer_text += p.text
else:
answer_text = ""
for p in event.content.parts:
if p.text:
answer_text += p.text
except Exception as e:
logger.warning("Grounded text generation failed: %s", e)

if not answer_text:
answer_text = (
"I'm sorry, I encountered an issue retrieving location details"
" right now."
)

return self._wrap_in_text_only(answer_text, session_id)

async def _handle_text_only(
self, cleaned_query: str, session_id: str
) -> list[Part]:
"""Generates a plain text response for TEXT_ONLY intent using template_model with grounding."""
return await self._handle_grounded_text(
cleaned_query, session_id, client=self.extractor_client
)

async def _handle_grounded_text_fallback(
self, cleaned_query: str, session_id: str
) -> list[Part]:
"""Generates a grounded plain text response for fallback/complex spatial queries using generic_model."""
return await self._handle_grounded_text(
cleaned_query, session_id, client=self.fallback_client
)

async def _handle_extracted_intent(
self,
intent: IntentClass,
Expand Down Expand Up @@ -571,7 +650,9 @@ async def _handle_extracted_intent(
intent,
)
if not fallback_text:
final_parts = await self._handle_text_only(query, session_id)
final_parts = await self._handle_grounded_text_fallback(
query, session_id
)
else:
final_parts = self._wrap_in_text_only(fallback_text, session_id)
yield {
Expand Down
Loading
Loading