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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
100 changes: 100 additions & 0 deletions contributing/samples/stateless_elicitation/README.md
Original file line number Diff line number Diff line change
@@ -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."`
15 changes: 15 additions & 0 deletions contributing/samples/stateless_elicitation/__init__.py
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions contributing/samples/stateless_elicitation/agent.py
Original file line number Diff line number Diff line change
@@ -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]
)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
82 changes: 82 additions & 0 deletions contributing/samples/stateless_elicitation/main.py
Original file line number Diff line number Diff line change
@@ -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())
28 changes: 28 additions & 0 deletions src/google/adk/a2a/schemas/a2a.py
Original file line number Diff line number Diff line change
@@ -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.")
6 changes: 6 additions & 0 deletions src/google/adk/agents/llm_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 8 additions & 2 deletions src/google/adk/agents/llm_agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading