Current guide for the Cymbal Coffee Google ADK 2.0 chat implementation.
The chat implementation is a Litestar-hosted ADK 2.0 workflow:
CoffeeChatController
-> ADKRunner.stream_request()
-> SQLSpecSessionService
-> response cache lookup
-> FlashLiteIntentClassifier
-> Product RAG direct path OR Runner(node=coffee_workflow, ...)
-> final event, with SSE deltas only for model-streamed non-RAG turns
The ADK workflow graph is still available for model-streamed conversational turns:
START -> intent FunctionNode -> JoinNode
START -> LlmAgent coffee_turn -> JoinNode
JoinNode -> classify_and_respond FunctionNode
max_concurrency=2 lets the Flash-Lite classifier and coffee response run in
parallel inside the workflow path.
For product/menu/RAG turns, the runner classifies first and bypasses speculative
model streaming. It searches the menu through AgentToolsService, may ask
Gemini for structured selection among returned product ids, validates that
selection, renders the answer from Cymbal Coffee rows in Python, and yields a
single final event. This prevents the browser from showing an ungrounded model
delta and prevents model-authored product facts from reaching the user.
ADKRunner is an APP-scoped Dishka dependency. It owns:
SQLSpecSessionServiceFlashLiteIntentClassifierPersonaManager
It does not own the request-scoped database driver. Each request passes an
AgentToolsService into stream_request() or process_request(). That service
holds the current SQLSpec driver and the product/cache/metrics/store services.
This keeps ADK sessions durable while keeping domain database work aligned to Litestar request scope.
The browser session and ADK session are separate stores:
- Litestar server-side session table:
app_session - ADK session/event tables:
adk_session,adk_event - Optional ADK memory table:
adk_memory
app.domain.chat.session.adk_session_identity() bridges them by storing:
adk_session_idadk_user_id
inside the Litestar session, then passing those identifiers to ADK.
Do not treat Litestar's session object as the ADK session. It is only the web identity anchor.
GET / uses that same bridge to hydrate chat history. The page asks
ADKRunner.get_history() for display messages. The runner first reads
session.state["display_history"], then falls back to persisted ADK events when
that display history is missing. If neither exists, the template shows the
static welcome message.
The sidebar Clear chat button calls POST /api/chat/session/clear. That
deletes the current ADK session/events and removes the Litestar bridge keys. It
does not clear products, metrics, response cache, or embedding cache.
src/app/ioc.py wires the SQLSpec ADK extension:
@provide(scope=Scope.APP)
def provide_adk_store(self, config: OracleAsyncConfig) -> OracleAsyncADKStore:
return OracleAsyncADKStore(config=config)
@provide(scope=Scope.APP)
def provide_session_service(self, store: OracleAsyncADKStore) -> SQLSpecSessionService:
return SQLSpecSessionService(store)The Oracle-specific table names and INMEMORY flags live in DatabaseSettings:
"adk": {
"enable_memory": settings.db.ADK_ENABLE_MEMORY,
"include_memory_migration": settings.db.ADK_ENABLE_MEMORY,
"in_memory": settings.db.ADK_IN_MEMORY,
}Starting with sqlspec >= 0.52.0, the Oracle ADK store also supports advanced table options such as partitioning (e.g. hash or range strategies) and compression (enabled/algorithm options) to optimize storage for high-volume session and event data. These are defined under the same "adk" configuration mapping.
Use SQLSpec's ADK store for session persistence instead of an in-memory ADK session service in application code.
The LLM node receives plain async Python callables created per request:
async def search_products_by_vector(
query: str,
limit: int = 5,
similarity_threshold: float = 0.7,
) -> dict[str, Any]:
result = await tools_service.search_products_by_vector(
query, limit, similarity_threshold
)
...
return resultThe callable docstrings are part of the model contract. Keep them explicit about when the model should call each tool.
For Cymbal Coffee, search_products_by_vector must mention menu/catalog/flavor,
recommendation, availability, substitution, and idiomatic preference requests.
That text is what makes "wake me up", "what is good today", and "do you have
decaf" eligible for product RAG.
For PRODUCT_RAG turns, the final answer must be grounded to the products
returned by Cymbal Coffee tools. If the LLM node skips tool use or emits an
internal tool-schema message, run the product search fallback and format the
final answer from returned menu rows rather than trusting the speculative model
text.
The current preferred path is classifier-first:
request text
-> response cache lookup
-> FlashLiteIntentClassifier
-> if PRODUCT_RAG:
search_products_by_vector(query, 3, 0.5)
optionally select returned product ids with Gemini structured output
validate ids and render final answer from returned products only
persist intent + display_history in ADK session state
cache response
yield final event only
-> otherwise:
run ADK workflow with SSE streaming
yield model deltas
yield final event
This keeps menu answers deterministic and menu-grounded while preserving model streaming for genuine conversation.
Intent classification is a separate Gemini Flash-Lite call using
response_mime_type="text/x.enum":
response = await client.aio.models.generate_content(
model="gemini-3.1-flash-lite",
contents=phrase,
config=types.GenerateContentConfig(
response_mime_type="text/x.enum",
response_schema={"type": "STRING", "enum": INTENT_VALUES},
system_instruction=_SYSTEM_INSTRUCTION,
temperature=0,
),
)Labels:
PRODUCT_RAGPRODUCT_AVAILABILITYGENERAL_CONVERSATIONSTORE_LOCATIONORDER_STATUS
The classifier instructions intentionally include concrete menu and idiom examples. Keep those examples when changing labels; removing them makes the classifier under-route obvious menu questions and idiomatic requests.
To support conversational continuity, product availability queries (PRODUCT_AVAILABILITY) are context-aware:
- Session-State Tracking: When answering a
PRODUCT_RAGquery (e.g. recommending items), the runner extracts the names of the recommended products and stores them in the ADK session state under thelast_productskey. - Pronoun Resolution: In
_product_availability_event, if the cleaned query does not contain a specific product name (e.g. "Is that in stock?"), the system retrieves thelast_productsfrom the session state and uses the primary recommended product as the target query. - Vector Fallback Resolution: When searching stock via
find_stores_with_product, the system first attempts an exact match on name/SKU/ID. If the lookup returns no results, it falls back to embedding the product query and running a vector similarity search (threshold 0.6) to resolve the query to the closest actual menu product (e.g. matching "Gemini" to "Gemini Rush").
This combination allows the user to seamlessly ask follow-up questions about recommended items without repeating their names, while remaining tolerant to minor name variations.
The current browser path streams:
pages/chat.html.j2posts to/api/chat/stream.CoffeeChatController.stream_chat_message()returnsServerSentEvent.ADKRunner.stream_request()runs ADK withRunConfig(streaming_mode=StreamingMode.SSE).src/resources/main.jsparses SSE blocks fromfetch()and updates the pending message.
process_request() exists for JSON/non-streaming callers and drains
stream_request() until the final event.
When debugging slow chat, verify the browser is using /api/chat/stream, not
/api/chat.
Do not emit Product RAG deltas before grounding. For Product RAG, the only browser-visible response should be the final grounded payload rendered from validated product rows. Non-RAG turns may stream partial model text.
credential_guard_callback() short-circuits model execution when local AI config
is missing. ADKRunner.stream_request() also preflights placeholder Vertex
project IDs before ADK starts.
Expected local misconfiguration should return a clean 503 through the controller, not a stack trace from ADK internals.
Every final chat result must include:
answersession_idresponse_time_msintent_detectedsearch_metricsfrom_cacheembedding_cache_hitsql_phases
Keep this shape stable for HTMX partials, JSON clients, and tests.
Chat response cache keys include model, persona, and normalized query text. Embedding cache state is reported separately from response cache state.
When product search runs, the tool records:
vector_queryembedding_msoracle_mstool_msresults_countproducts_foundembedding_cache_hitgrounded_answer_modegrounded_answer_ms
The UI reads these fields for message-level telemetry badges and for "did RAG happen?" debugging. Preserve the vector query and phase timings when changing ADK tools, because the demo is meant to make intent routing, Oracle vector lookup, embedding cache hits, and response cache hits visible in the chat message itself.
Search metric inserts must use the app serializer boundary:
schema_dump(metrics, wire_format=False). Database writes need Python field
names such as result_count; wire-format/camelized names such as resultCount
produce invalid Oracle identifiers.
Menu question not using RAG:
Check the classifier system instruction and the search_products_by_vector
docstring first. Both must include menu, catalog, recommendation, and idiomatic
preference examples.
Workflow coffee_workflow: cancelling leftover tasks:
Look for exceptions or early returns while consuming ADK events. The expected
path should drain the async event stream or return only after the final event.
Exception caught after response started on /api/chat/stream:
An exception escaped the SSE generator after headers were sent. Keep exception
handling inside the generator, log the real exception there, and yield a
sanitized error event for the browser.
can't compare offset-naive and offset-aware datetimes during cache lookup:
Do not compare cache expiry datetimes in Python during the read path. Filter
fresh cache rows in SQL with CURRENT_TIMESTAMP, return the typed
ResponseCache via schema_type, and keep expired-row deletion behind the
explicit cache cleanup operation.
Session state missing:
Confirm app.domain.chat.session.adk_session_identity() stores IDs in the
Litestar session and that SQLSpecSessionService is backed by
OracleAsyncADKStore, not an in-memory ADK session service.
No streaming in the UI:
Confirm the form action is /api/chat/stream, the request accepts
text/event-stream, and src/resources/main.js is loaded by Vite.