diff --git a/README.md b/README.md index 99283688cc..5b14b8c21e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ ______________________________________________________________________ ## 🔥 What's new +- **Stateless Tiered Elicitation**: Added support for stateless, interactive ambiguity resolution. Agents can now request clarification from the user using a standardized, tool-based signal (`TriggerElicitationTool`) without requiring backend state persistence, featuring first-class `allow_elicitation` and `elicitation_max_turns` configuration properties on `LlmAgent`. This feature immensely improves conversational accuracy in scenarios like **NL2SQL query generation** (by clarifying ambiguous database filters) and forms processing. See the new [Stateless Elicitation Sample](contributing/samples/stateless_elicitation/). + - **Custom Service Registration**: Add a service registry to provide a generic way to register custom service implementations to be used in FastAPI server. See [short instruction](https://github.com/google/adk-python/discussions/3175#discussioncomment-14745120). ([391628f](https://github.com/google/adk-python/commit/391628fcdc7b950c6835f64ae3ccab197163c990)) - **Rewind**: Add the ability to rewind a session to before a previous invocation ([9dce06f](https://github.com/google/adk-python/commit/9dce06f9b00259ec42241df4f6638955e783a9d1)). diff --git a/contributing/samples/stateless_elicitation/README.md b/contributing/samples/stateless_elicitation/README.md new file mode 100644 index 0000000000..123f70d8e3 --- /dev/null +++ b/contributing/samples/stateless_elicitation/README.md @@ -0,0 +1,100 @@ +# Stateless Tiered Elicitation Sample + +This sample demonstrates how to use the **Stateless Tiered Elicitation Flow** in the Google ADK to resolve ambiguous user requests interactively without relying on backend state stores. + +## Overview + +When building conversational agents, users often provide ambiguous or incomplete requests. For example, in travel booking, a user might ask to *"Book a hotel in Tokyo"* without specifying a check-in date or passenger name. In data analysis and **NL2SQL solutions**, a user might request *"Show me active customers"* without clarifying what defines an "active" customer (e.g., registered within the last 30 days, or having placed an order recently), which is critical to generating accurate, non-speculative SQL queries. + +The **Stateless Tiered Elicitation** feature solves this problem. It allows the agent to request clarification by returning an interactive prompt to the client along with a `hidden_context` payload containing the session's current parameters. When the user provides the missing details, the ADK transparently rehydrates this state on the subsequent turn to complete the execution. This drastically improves conversational accuracy and execution reliability in data pipelines, forms processing, and booking assistants, all without requiring any server-side session storage. + + +## Core Components + +1. **Hotel Booking Assistant** (`agent.py`) + - Configured with first-class parameters: `allow_elicitation=True` and `elicitation_max_turns=3`. + - Features instructions detailing that `location`, `checkin_date`, and `guest_name` are all required. + - Has a `book_hotel` tool to perform the booking. +2. **Elicitation Tooling** (`elicitation_tool.py`) + - The `TriggerElicitationTool` is conditionally injected by the runner because `allow_elicitation` is enabled. + - The model invokes this tool when parameters are missing. +3. **Runner Orchestration** (`main.py`) + - Orchestrates the multi-turn conversation using `InMemoryRunner` and prints out the `hidden_context` at each turn to illustrate how stateless persistence works. + +## How It Works + +The flow begins when a user issues a hotel booking request missing essential details like the check-in date and guest name. Recognizing these omissions, the Gemini model calls the automatically injected `TriggerElicitationTool` to request clarification, which the ADK runner intercepts to generate an interactive prompt and package existing parameters into a `hidden_context` snapshot sent to the client. When the user responds with the missing information, the runner transparently rehydrates the previous turn's state from `hidden_context` and passes the complete package back to the agent, enabling successful tool execution without server-side state storage. + +![Stateless Elicitation Sequence Diagram](elicitation_sample.png) + +## Understanding Elicitation & Ambiguity Resolution + +In typical conversational AI applications, users rarely provide all the required information in their initial prompt. For instance, they might say *"Book a hotel in Tokyo"* without specifying the dates or the guest name. + +Without elicitation, the agent would either: +- **Fail outright** due to missing arguments. +- **Invent placeholder/fake values** (hallucinating details). +- **Ask a generic text clarification** that requires the developer to maintain state on the server to remember what the user was trying to book when they reply. + +**Elicitation** solves this by introducing a structured, turn-based mechanism to actively gather missing fields. By utilizing the `TriggerElicitationTool`, the agent can formally flag exactly which parameters are missing. The ADK runner intercepts this, packages the already gathered parameters into a `hidden_context` state snapshot, and prompts the user for the rest. When the user answers, the next execution turn rehydrates the `hidden_context` transparently, allowing the model to complete the transaction as if it had all the details from the beginning. + +--- + +## Best Practices & Security Guidelines + +Stateless elicitation is powerful, but passing conversational state through client-side roundtrips requires careful design to prevent security risks and ensure high reliability. + +### 1. Preventing PII and Sensitive Data Leakage + +> [!WARNING] +> **The `hidden_context` payload is returned directly to the client/UI layer.** Any information stored within the `context_snapshot` is visible to the client and can be inspected, intercepted, or tampered with. + +- **Do Not Store Raw PII**: Avoid keeping raw, unmasked PII (e.g., Social Security Numbers, credit card numbers, passwords) inside the `context_snapshot`. +- **Implement Data Redaction**: Sanitize or redact sensitive information from arguments before they are packed into the `context_snapshot`. If your workflow gathers highly sensitive data, consider keeping it in a secure, temporary server-side cache, using only a masked reference ID in the `hidden_context`. +- **Limit Scope**: Only include parameters in the snapshot that are strictly required to execute the downstream tools. + +### 2. Ensuring State Integrity & Tampering Prevention + +Since the client manages the state snapshot, a malicious user or application could modify the `hidden_context` payload before sending it back (e.g., changing `price=10` to `price=0`). + +- **Cryptographic Signatures (Recommended)**: If deploying to an untrusted client environment (e.g., public web or mobile apps), sign the `hidden_context` payload on your server gateway using an `HMAC-SHA256` signature. When the client returns the payload, verify the signature before processing the request. If the signatures don't match, reject the turn. +- **Payload Encryption**: For high-security environments, encrypt the `hidden_context` payload completely on the server-side before passing it down, so that the client cannot read or alter the contents. + +### 3. Effective Usage & Loop Termination + +- **Explicit Tool Parameter Descriptions**: Always provide highly descriptive docstrings for your tools and their arguments. The model relies entirely on these descriptions to evaluate whether parameters are missing. +- **Set Realistic Turn Limits**: Always set a reasonable `elicitation_max_turns` parameter (recommended default: `3`). This prevents the LLM from entering infinite conversational loops if the user continuously provides invalid or unrelated answers. +- **Graceful Fallbacks**: When `elicitation_max_turns` is exceeded, the ADK will raise a `RuntimeError`. Ensure your application catches this and routes the user to a human operator or presents a fallback interface. + +--- + +## Prerequisites + +Ensure you have set your Gemini API credentials in your environment or a local `.env` file: +```bash +export GOOGLE_API_KEY="your-api-key" +``` + +## Running the Sample + +To run the sample locally, execute the following command from the repository root: +```bash +PYTHONPATH=src .venv/bin/python contributing/samples/stateless_elicitation/main.py +``` + +## Walkthrough Output + +When run successfully, the console output will show the following turns: + +### Turn 1: Ambiguous Query +User says: `"Book a hotel in Tokyo"` +* **Agent Action**: The model recognizes that `checkin_date` and `guest_name` are missing and calls the `trigger_elicitation` tool. +* **Agent Response**: *Interactive prompt asking for missing details.* +* **Stateless Hidden Context**: Contains the serialized `ElicitationData` snapshot with: + * `turn_count: 1` + * `context_snapshot: {"location": "Tokyo"}` + +### Turn 2: Providing Missing Details +User says: `"I am John Doe, and my check-in date is 2026-07-01"` +* **Agent Action**: The ADK rehydrates the context (`location: Tokyo`) from the `hidden_context`, combines it with the new input, and successfully invokes the real `book_hotel` tool. +* **Agent Response**: `"Successfully booked hotel in Tokyo on 2026-07-01 for John Doe."` diff --git a/contributing/samples/stateless_elicitation/__init__.py b/contributing/samples/stateless_elicitation/__init__.py new file mode 100644 index 0000000000..4015e47d6e --- /dev/null +++ b/contributing/samples/stateless_elicitation/__init__.py @@ -0,0 +1,15 @@ +# 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. + +from . import agent diff --git a/contributing/samples/stateless_elicitation/agent.py b/contributing/samples/stateless_elicitation/agent.py new file mode 100644 index 0000000000..c4e11b86a3 --- /dev/null +++ b/contributing/samples/stateless_elicitation/agent.py @@ -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. + +from google.adk import Agent + +def book_hotel(location: str, checkin_date: str, guest_name: str) -> str: + """Book a hotel reservation for a guest at a specific location and check-in date. + + Args: + location: The city or location of the hotel. + checkin_date: The check-in date (YYYY-MM-DD). + guest_name: The full name of the guest booking the hotel. + + Returns: + A string confirmation of the booking. + """ + return f"Successfully booked hotel in {location} on {checkin_date} for {guest_name}." + +root_agent = Agent( + model='gemini-2.5-flash', + name='hotel_booking_agent', + allow_elicitation=True, + elicitation_max_turns=3, + instruction=""" + You are a helpful hotel booking assistant. + If the user asks you to book a hotel, you MUST gather all three required parameters: + 1. location + 2. checkin_date + 3. guest_name + + If the user does not specify ALL three parameters, you MUST call the `trigger_elicitation` tool + to request the missing information. Do not attempt to book a hotel without all three details. + + Once you have gathered all three details, call the `book_hotel` tool to complete the booking. + """, + tools=[book_hotel] +) diff --git a/contributing/samples/stateless_elicitation/elicitation_sample.png b/contributing/samples/stateless_elicitation/elicitation_sample.png new file mode 100644 index 0000000000..0b7b7980d9 Binary files /dev/null and b/contributing/samples/stateless_elicitation/elicitation_sample.png differ diff --git a/contributing/samples/stateless_elicitation/main.py b/contributing/samples/stateless_elicitation/main.py new file mode 100644 index 0000000000..9cf96f330f --- /dev/null +++ b/contributing/samples/stateless_elicitation/main.py @@ -0,0 +1,82 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import os +import sys + +import agent +from dotenv import load_dotenv +from google.adk.runners import InMemoryRunner +from google.adk.sessions.session import Session +from google.genai import types + +load_dotenv(override=True) + +async def main(): + # Check for API credentials to fail gracefully with a clear message + if 'GOOGLE_API_KEY' not in os.environ and 'GOOGLE_CLOUD_PROJECT' not in os.environ: + print("Error: Missing required LLM credentials.") + print("Please set the GOOGLE_API_KEY environment variable before running this sample.") + sys.exit(1) + + app_name = 'stateless_elicitation_app' + user_id = 'user_example' + + runner = InMemoryRunner( + agent=agent.root_agent, + app_name=app_name, + ) + + session = await runner.session_service.create_session( + app_name=app_name, user_id=user_id + ) + + async def run_turn(prompt: str): + print(f"\n=== User Turn: '{prompt}' ===") + content = types.Content( + role='user', + parts=[types.Part.from_text(text=prompt)] + ) + + async for event in runner.run_async( + user_id=user_id, + session_id=session.id, + new_message=content, + ): + if event.content.parts: + for part in event.content.parts: + if part.text: + print(f"** Agent Response: {part.text.strip()}") + if part.function_call: + print(f"** Agent Tool Call: {part.function_call.name}") + print(f" Arguments: {part.function_call.args}") + + # Fetch the updated session to inspect the stateless hidden_context + updated_session = await runner.session_service.get_session( + app_name=app_name, user_id=user_id, session_id=session.id + ) + if updated_session.hidden_context: + print("\n--- Stateless Hidden Context ---") + print(updated_session.hidden_context) + print("---------------------------------") + + # Turn 1: Ambiguous query (triggers elicitation) + await run_turn("Book a hotel in Tokyo") + + # Turn 2: Resolving query (completes the flow) + await run_turn("I am John Doe, and my check-in date is 2026-07-01") + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/src/google/adk/a2a/schemas/a2a.py b/src/google/adk/a2a/schemas/a2a.py new file mode 100644 index 0000000000..4435adb290 --- /dev/null +++ b/src/google/adk/a2a/schemas/a2a.py @@ -0,0 +1,28 @@ +# 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. + +from enum import Enum +from typing import List, Optional +from pydantic import BaseModel, Field + +class AgentResponseStatus(str, Enum): + SUCCESS = 'SUCCESS' + ERROR = 'ERROR' + ELICITATION_REQUIRED = 'ELICITATION_REQUIRED' + +class ElicitationData(BaseModel): + question: str = Field(..., description="The clarification question to ask the user.") + options: Optional[List[str]] = Field(None, description="Optional list of selectable options for the user.") + missing_entities: List[str] = Field(..., description="List of parameters or entities that are missing or ambiguous.") + context_snapshot: Optional[dict] = Field(None, description="Snapshot of the state to be rehydrated.") diff --git a/src/google/adk/agents/llm_agent.py b/src/google/adk/agents/llm_agent.py index b41b7f4eff..1ec7be368a 100644 --- a/src/google/adk/agents/llm_agent.py +++ b/src/google/adk/agents/llm_agent.py @@ -212,6 +212,12 @@ class LlmAgent(BaseAgent): config_type: ClassVar[Type[BaseAgentConfig]] = LlmAgentConfig """The config type for this agent.""" + allow_elicitation: bool = False + """Whether the agent is allowed to perform elicitation to resolve ambiguity.""" + + elicitation_max_turns: int = 3 + """The maximum number of elicitation turns allowed before failing.""" + instruction: Union[str, InstructionProvider] = '' """Dynamic instructions for the LLM model, guiding the agent's behavior. diff --git a/src/google/adk/agents/llm_agent_config.py b/src/google/adk/agents/llm_agent_config.py index 93ca718094..c7ff4eb9aa 100644 --- a/src/google/adk/agents/llm_agent_config.py +++ b/src/google/adk/agents/llm_agent_config.py @@ -123,8 +123,14 @@ def _validate_model_sources(self) -> LlmAgentConfig: description='Optional. LlmAgent.disallow_transfer_to_parent.', ) - disallow_transfer_to_peers: Optional[bool] = Field( - default=None, description='Optional. LlmAgent.disallow_transfer_to_peers.' + allow_elicitation: Optional[bool] = Field( + default=False, + description='Optional. Whether the agent is allowed to perform elicitation to resolve ambiguity.', + ) + + elicitation_max_turns: Optional[int] = Field( + default=3, + description='Optional. The maximum number of elicitation turns allowed before failing.', ) input_schema: Optional[CodeConfig] = Field( diff --git a/src/google/adk/flows/llm_flows/elicitation.py b/src/google/adk/flows/llm_flows/elicitation.py new file mode 100644 index 0000000000..b538810d54 --- /dev/null +++ b/src/google/adk/flows/llm_flows/elicitation.py @@ -0,0 +1,152 @@ +# 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. + +"""Handles Stateless Elicitation Flow.""" + +from __future__ import annotations + +import json +import logging +from typing import AsyncGenerator +from typing_extensions import override + +from google.genai import types + +from ...agents.invocation_context import InvocationContext +from ...events.event import Event +from ...events.event_actions import EventActions +from ...models.llm_request import LlmRequest +from ...models.llm_response import LlmResponse +from ._base_llm_processor import BaseLlmRequestProcessor +from ._base_llm_processor import BaseLlmResponseProcessor +from ...a2a.schemas.a2a import AgentResponseStatus, ElicitationData +from ...tools.elicitation_tool import TriggerElicitationTool + +logger = logging.getLogger('google_adk.' + __name__) + +ELICITATION_STATE_KEY = 'elicitation_state' + +class _ElicitationRequestProcessor(BaseLlmRequestProcessor): + """Processes elicitation requests by rehydrating state.""" + + @override + async def run_async( + self, invocation_context: InvocationContext, llm_request: LlmRequest + ) -> AsyncGenerator[Event, None]: + agent = invocation_context.agent + # Check if agent is allowed to perform elicitation + if not getattr(agent, 'allow_elicitation', False): + return + + # Inject the tool + llm_request.append_tools([TriggerElicitationTool()]) + + instruction = ( + 'IMPORTANT: You have access to a tool called `trigger_elicitation`. ' + 'Use this tool when you need to ask the user for clarification or missing information ' + 'before you can proceed with the task. Do not ask questions in free text if you can ' + 'use this tool to structure the elicitation request.' + ) + llm_request.append_instructions([instruction]) + + agent_name = agent.name + state = invocation_context.agent_states.get(agent_name, {}) + elicitation_state = state.get(ELICITATION_STATE_KEY) + + if not elicitation_state: + return + + logger.info(f"Rehydrating elicitation state for agent {agent_name}") + + context_snapshot = elicitation_state.get('context_snapshot') + if context_snapshot: + # Append snapshot as instructions to rehydrate context. + instructions = [f"Rehydrated Context: {json.dumps(context_snapshot)}"] + llm_request.append_instructions(instructions) + + turn_count = elicitation_state.get('turn_count', 0) + 1 + elicitation_state['turn_count'] = turn_count + + max_turns = getattr(agent, 'elicitation_max_turns', 3) + if turn_count > max_turns: + logger.error(f"Elicitation turn count exceeded limit for agent {agent_name}.") + raise RuntimeError(f"Elicitation turn limit exceeded for agent {agent_name}") + + # Maintain async generator behavior. + if False: + yield Event(invocation_id=invocation_context.invocation_id, author=agent_name) + +request_processor = _ElicitationRequestProcessor() + +class _ElicitationResponseProcessor(BaseLlmResponseProcessor): + """Processes elicitation responses by intercepting signals.""" + + @override + async def run_async( + self, invocation_context: InvocationContext, llm_response: LlmResponse + ) -> AsyncGenerator[Event, None]: + if llm_response.partial: + return + + if not llm_response.content or not llm_response.content.parts: + return + + elicitation_data = None + for part in llm_response.content.parts: + if part.function_call and part.function_call.name == "trigger_elicitation": + logger.info("Elicitation triggered by model via tool call.") + args = part.function_call.args + elicitation_data = ElicitationData( + question=args.get("question"), + options=args.get("options"), + missing_entities=args.get("missing_entities"), + context_snapshot=args.get("context_snapshot") + ) + break + + if elicitation_data: + agent_name = invocation_context.agent.name + state = invocation_context.agent_states.setdefault(agent_name, {}) + + current_state = state.get(ELICITATION_STATE_KEY, {}) + turn_count = current_state.get('turn_count', 0) + + state[ELICITATION_STATE_KEY] = { + 'context_snapshot': elicitation_data.context_snapshot, + 'turn_count': turn_count + } + + # Format output to UI contract (JSON string payload). + ui_response = { + "ui_response_type": "interactive_prompt", + "status": AgentResponseStatus.ELICITATION_REQUIRED, + "data": elicitation_data.model_dump(), + "hidden_context": { + "agent_state": state[ELICITATION_STATE_KEY] + } + } + + llm_response.content = types.Content( + role='model', + parts=[types.Part(text=json.dumps(ui_response))] + ) + + yield Event( + invocation_id=invocation_context.invocation_id, + author=agent_name, + branch=invocation_context.branch, + actions=EventActions(state_delta={ELICITATION_STATE_KEY: state[ELICITATION_STATE_KEY]}) + ) + +response_processor = _ElicitationResponseProcessor() diff --git a/src/google/adk/flows/llm_flows/single_flow.py b/src/google/adk/flows/llm_flows/single_flow.py index 932a265ed1..d3770962d8 100644 --- a/src/google/adk/flows/llm_flows/single_flow.py +++ b/src/google/adk/flows/llm_flows/single_flow.py @@ -25,6 +25,7 @@ from . import compaction from . import contents from . import context_cache_processor +from . import elicitation from . import identity from . import instructions from . import interactions_processor @@ -48,6 +49,8 @@ def _create_request_processors(): # in the model request context. compaction.request_processor, contents.request_processor, + # Elicitation processor rehydrates state from hidden_context. + elicitation.request_processor, # Context cache processor sets up cache config and finds # existing cache metadata. context_cache_processor.request_processor, @@ -73,6 +76,7 @@ def _create_response_processors(): return [ _nl_planning.response_processor, _code_execution.response_processor, + elicitation.response_processor, ] diff --git a/src/google/adk/tools/elicitation_tool.py b/src/google/adk/tools/elicitation_tool.py new file mode 100644 index 0000000000..3a12ee398e --- /dev/null +++ b/src/google/adk/tools/elicitation_tool.py @@ -0,0 +1,82 @@ +# 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. + +"""Tool for triggering elicitation flow.""" + +from __future__ import annotations + +import inspect +from typing import Any, Optional +from google.genai import types +from typing_extensions import override + +from .base_tool import BaseTool +from .tool_context import ToolContext +from ._automatic_function_calling_util import build_function_declaration + +class TriggerElicitationTool(BaseTool): + """Tool used by the model to trigger the elicitation flow. + + The model should call this tool when it detects missing information or + ambiguity that requires user clarification. + """ + + def __init__(self): + def trigger_elicitation( + question: str, + options: Optional[list[str]] = None, + missing_entities: Optional[list[str]] = None, + context_snapshot: Optional[dict[str, Any]] = None, + ) -> str: + """Triggers the elicitation flow to ask the user for clarification. + + Use this tool when you need to ask a question to resolve ambiguity or + request missing parameters before you can proceed. + + Args: + question: The clarification question to ask the user. + options: Optional list of suggested options for the user to choose from. + missing_entities: Optional list of keys or parameters that are missing. + context_snapshot: Optional state snapshot to be passed back in hidden_context. + """ + return "Elicitation triggered." + + self.func = trigger_elicitation + super().__init__( + name=self.func.__name__, + description=self.func.__doc__.strip() if self.func.__doc__ else '', + ) + + @override + def _get_declaration(self) -> Optional[types.FunctionDeclaration]: + """Gets the OpenAPI specification of this tool.""" + function_decl = types.FunctionDeclaration.model_validate( + build_function_declaration( + func=self.func, + ignore_params=[], + variant=self._api_variant, + ) + ) + return function_decl + + @override + async def run_async( + self, *, args: dict[str, Any], tool_context: ToolContext + ) -> Any: + """Process the tool call. + + This tool is a signal and does not perform any external action. + It returns the arguments passed by the model. + """ + return args diff --git a/tests/integration/fixture/elicitation_agent/__init__.py b/tests/integration/fixture/elicitation_agent/__init__.py new file mode 100644 index 0000000000..4015e47d6e --- /dev/null +++ b/tests/integration/fixture/elicitation_agent/__init__.py @@ -0,0 +1,15 @@ +# 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. + +from . import agent diff --git a/tests/integration/fixture/elicitation_agent/agent.py b/tests/integration/fixture/elicitation_agent/agent.py new file mode 100644 index 0000000000..0388d90060 --- /dev/null +++ b/tests/integration/fixture/elicitation_agent/agent.py @@ -0,0 +1,49 @@ +# 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. + +from google.adk import Agent + +def book_flight(destination: str, date: str, passenger_name: str) -> str: + """Book a flight to a destination for a passenger on a specific date. + + Args: + destination: The destination city of the flight. + date: The date of the flight (YYYY-MM-DD). + passenger_name: The full name of the passenger. + + Returns: + A string confirming the booking. + """ + return f"Successfully booked flight to {destination} on {date} for {passenger_name}." + +root_agent = Agent( + model='gemini-2.5-flash', + name='booking_assistant', + allow_elicitation=True, + elicitation_max_turns=3, + instruction=""" + You are a flight booking assistant. + If the user asks you to book a flight, you MUST gather all three required parameters: + 1. destination + 2. date + 3. passenger_name + + If the user does not specify ALL three parameters, you MUST call the `trigger_elicitation` tool + to request the missing information from the user. Do not attempt to book a flight without + all three details. + + Once you have all three details, call the `book_flight` tool. + """, + tools=[book_flight] +) diff --git a/tests/integration/fixture/elicitation_agent/elicitation_flow.test.json b/tests/integration/fixture/elicitation_agent/elicitation_flow.test.json new file mode 100644 index 0000000000..a734299a03 --- /dev/null +++ b/tests/integration/fixture/elicitation_agent/elicitation_flow.test.json @@ -0,0 +1,77 @@ +{ + "eval_set_id": "elicitation-integration-test-set", + "name": "elicitation-integration-test-set", + "description": "Integration test for stateless elicitation feature.", + "eval_cases": [ + { + "eval_id": "tests/integration/fixture/elicitation_agent/elicitation_flow.test.json", + "conversation": [ + { + "invocation_id": "turn-1-elicitation-trigger", + "user_content": { + "parts": [ + { + "text": "Book a flight to London" + } + ], + "role": "user" + }, + "final_response": { + "parts": [ + { + "text": null + } + ], + "role": "model" + }, + "intermediate_data": { + "tool_uses": [ + { + "id": null, + "args": { + "missing_entities": ["date", "passenger_name"], + "elicitation_prompt": "To book your flight to London, please provide the date of travel and the passenger's full name." + }, + "name": "trigger_elicitation" + } + ], + "intermediate_responses": [] + } + }, + { + "invocation_id": "turn-2-elicitation-resolve", + "user_content": { + "parts": [ + { + "text": "The traveler is Alice and the date is 2026-05-15" + } + ], + "role": "user" + }, + "final_response": { + "parts": [ + { + "text": "Successfully booked flight to London on 2026-05-15 for Alice." + } + ], + "role": "model" + }, + "intermediate_data": { + "tool_uses": [ + { + "id": null, + "args": { + "destination": "London", + "date": "2026-05-15", + "passenger_name": "Alice" + }, + "name": "book_flight" + } + ], + "intermediate_responses": [] + } + } + ] + } + ] +} diff --git a/tests/integration/fixture/elicitation_agent/test_config.json b/tests/integration/fixture/elicitation_agent/test_config.json new file mode 100644 index 0000000000..24d0072243 --- /dev/null +++ b/tests/integration/fixture/elicitation_agent/test_config.json @@ -0,0 +1,6 @@ +{ + "criteria": { + "tool_trajectory_avg_score": 1.0, + "response_match_score": 0.0 + } +} diff --git a/tests/integration/test_elicitation_flow.py b/tests/integration/test_elicitation_flow.py new file mode 100644 index 0000000000..c294c91958 --- /dev/null +++ b/tests/integration/test_elicitation_flow.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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import pytest + +from google.adk.evaluation.agent_evaluator import AgentEvaluator + +try: + import pandas as pd + HAS_EVAL_DEPS = True +except ImportError: + HAS_EVAL_DEPS = False + +HAS_CREDENTIALS = ( + "GOOGLE_API_KEY" in os.environ + or ("GOOGLE_CLOUD_PROJECT" in os.environ and "GOOGLE_CLOUD_LOCATION" in os.environ) +) + +pytestmark = pytest.mark.skipif( + not (HAS_EVAL_DEPS and HAS_CREDENTIALS), + reason="Integration test requires 'google-adk[eval]' dependencies and LLM API credentials.", +) + + +@pytest.mark.asyncio +async def test_elicitation_flow(): + """Test the full multi-turn stateless elicitation flow end-to-end.""" + await AgentEvaluator.evaluate( + agent_module="tests.integration.fixture.elicitation_agent", + eval_dataset_file_path_or_dir="tests/integration/fixture/elicitation_agent/elicitation_flow.test.json", + num_runs=2, + ) diff --git a/tests/unittests/flows/llm_flows/test_elicitation.py b/tests/unittests/flows/llm_flows/test_elicitation.py new file mode 100644 index 0000000000..96e1ce947a --- /dev/null +++ b/tests/unittests/flows/llm_flows/test_elicitation.py @@ -0,0 +1,185 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import pytest +from google.genai import types +from pydantic import ValidationError + +from google.adk.a2a.schemas.a2a import AgentResponseStatus, ElicitationData +from google.adk.agents.llm_agent import Agent +from google.adk.events.event import Event +from google.adk.events.event_actions import EventActions +from google.adk.flows.llm_flows import elicitation +from google.adk.models.llm_request import LlmRequest +from google.adk.models.llm_response import LlmResponse +from ... import testing_utils # Assuming testing_utils is accessible as in other tests + +@pytest.mark.asyncio +async def test_elicitation_data_validation(): + """Test that ElicitationData validates fields correctly.""" + # Valid data + data = ElicitationData( + question="What is your name?", + missing_entities=["name"], + context_snapshot={"step": 1} + ) + assert data.question == "What is your name?" + assert data.missing_entities == ["name"] + assert data.context_snapshot == {"step": 1} + + # Invalid data (missing required field) + with pytest.raises(ValidationError): + ElicitationData(missing_entities=["name"]) + +@pytest.mark.asyncio +async def test_request_processor_disallowed(): + """Test that the request processor does nothing when elicitation is not allowed.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + # allow_elicitation defaults to False + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context(agent=agent) + + async for _ in elicitation.request_processor.run_async(invocation_context, llm_request): + pass + + assert not llm_request.config.system_instruction + +@pytest.mark.asyncio +async def test_request_processor_no_state(): + """Test that the request processor injects tool when allowed but no state exists.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + agent.allow_elicitation = True + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context(agent=agent) + + # Run the processor + async for _ in elicitation.request_processor.run_async(invocation_context, llm_request): + pass + + # Verify instructions were appended (tool instructions) + assert llm_request.config.system_instruction + assert "trigger_elicitation" in llm_request.config.system_instruction + +@pytest.mark.asyncio +async def test_request_processor_with_state(): + """Test that the request processor rehydrates state and increments turn count.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + agent.allow_elicitation = True + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context(agent=agent) + + # Set up elicitation state + agent_name = agent.name + invocation_context.agent_states[agent_name] = { + elicitation.ELICITATION_STATE_KEY: { + 'context_snapshot': {'step': 2, 'param': 'val'}, + 'turn_count': 1 + } + } + + # Run the processor + async for _ in elicitation.request_processor.run_async(invocation_context, llm_request): + pass + + # Verify instructions were appended + assert llm_request.config.system_instruction + assert "Rehydrated Context" in llm_request.config.system_instruction + assert '{"step": 2, "param": "val"}' in llm_request.config.system_instruction + + # Verify turn count was incremented + state = invocation_context.agent_states[agent_name][elicitation.ELICITATION_STATE_KEY] + assert state['turn_count'] == 2 + +@pytest.mark.asyncio +async def test_request_processor_limit_exceeded(): + """Test that the request processor raises error when limit is exceeded.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + agent.allow_elicitation = True + agent.elicitation_max_turns = 2 + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context(agent=agent) + + agent_name = agent.name + invocation_context.agent_states[agent_name] = { + elicitation.ELICITATION_STATE_KEY: { + 'turn_count': 2 + } + } + + with pytest.raises(RuntimeError, match="Elicitation turn limit exceeded"): + async for _ in elicitation.request_processor.run_async(invocation_context, llm_request): + pass + +@pytest.mark.asyncio +async def test_response_processor_no_signal(): + """Test that the response processor does nothing when no signal is present.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + invocation_context = await testing_utils.create_invocation_context(agent=agent) + + llm_response = LlmResponse( + content=types.Content(role="model", parts=[types.Part(text="Normal response")]) + ) + + # Run the processor + events = [] + async for event in elicitation.response_processor.run_async(invocation_context, llm_response): + events.append(event) + + # Verify no events were yielded and content was not modified to JSON + assert not events + assert llm_response.content.parts[0].text == "Normal response" + +@pytest.mark.asyncio +async def test_response_processor_with_signal(): + """Test that the response processor intercepts signal and formats response.""" + agent = Agent(model="gemini-2.5-flash", name="test_agent") + invocation_context = await testing_utils.create_invocation_context(agent=agent) + + llm_response = LlmResponse( + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + name="trigger_elicitation", + args={ + "question": "Could you please provide the missing parameter X?", + "missing_entities": ["parameter_x"], + "context_snapshot": {"current_step": 2} + } + ) + ) + ] + ) + ) + + # Run the processor + events = [] + async for event in elicitation.response_processor.run_async(invocation_context, llm_response): + events.append(event) + + # Verify an event was yielded with state delta + assert len(events) == 1 + assert events[0].actions.state_delta + assert elicitation.ELICITATION_STATE_KEY in events[0].actions.state_delta + + # Verify response content was replaced with UI contract JSON + assert llm_response.content.parts[0].text + ui_response = json.loads(llm_response.content.parts[0].text) + assert ui_response["ui_response_type"] == "interactive_prompt" + assert ui_response["status"] == AgentResponseStatus.ELICITATION_REQUIRED + assert ui_response["data"]["question"] == "Could you please provide the missing parameter X?" + assert ui_response["data"]["missing_entities"] == ["parameter_x"] + assert ui_response["data"]["context_snapshot"] == {"current_step": 2}