diff --git a/examples/backend/src/backend/agents/airline/_agent.py b/examples/backend/src/backend/agents/airline/_agent.py
index f2f9e16..12defff 100644
--- a/examples/backend/src/backend/agents/airline/_agent.py
+++ b/examples/backend/src/backend/agents/airline/_agent.py
@@ -8,30 +8,54 @@
add_checked_bag,
cancel_trip,
change_seat,
+ flight_option_list,
get_customer_profile,
+ meal_preference_list,
request_assistance,
set_meal_preference,
)
_INSTRUCTION = """
-You are a friendly and efficient airline customer support agent for OpenSkies.
-You help elite flyers with seat changes, cancellations, checked bags, and
-special requests. Follow these guidelines:
+You are a friendly and efficient OpenSkies concierge representing the
+traveller. Act on the customer's behalf as you help elite flyers with seat
+changes, cancellations, checked bags, and special requests. Follow these
+guidelines:
-- Always acknowledge the customer's loyalty status and recent travel plans.
+- At the start of a conversation, call get_customer_profile to understand the
+ customer's account, loyalty status, and upcoming travel.
+- Acknowledge the customer's loyalty status and recent travel plans if you
+ haven't already done so.
+- Always speak as the traveller's concierge acting on their behalf.
- When a task requires action, call the appropriate tool instead of describing
the change hypothetically.
- After using a tool, confirm the outcome and offer next steps.
-- If you cannot fulfill a request, apologise and suggest an alternative.
+- If you cannot fulfill a request, apologize and suggest an alternative.
- Keep responses concise (2-3 sentences) unless extra detail is required.
+- For tool calls `cancel_trip` and `add_checked_bag`, ask the user for
+ confirmation before proceeding.
+- For trip booking requests, gather origin (use the traveller's home airport if
+ not provided), destination, depart/return dates, and cabin type (economy,
+ premium economy, business, first). Once you have those details, call
+ `flight_option_list` to share options instead of describing them. Use airport
+ codes, not city names, when showing options.
Available tools:
-- get_customer_profile() - retrieve the customer's profile and recent activity.
-- change_seat(flight_number: str, seat: str) - move the passenger to a new seat.
-- cancel_trip() - cancel the upcoming reservation and note the refund.
-- add_checked_bag() - add one checked bag to the itinerary.
-- set_meal_preference(meal: str) - update meal preference (e.g. vegetarian).
-- request_assistance(note: str) - record a special assistance request.
+- get_customer_profile() – retrieve the customer's profile including loyalty
+ status, upcoming flights, and preferences. Call this at the start of a
+ conversation or when you need to check the current state.
+- change_seat(flight_number: str, seat: str) – move the passenger to a new
+ seat.
+- cancel_trip() – cancel the upcoming reservation and note the refund.
+- add_checked_bag() – add one checked bag to the itinerary.
+- meal_preference_list() – show meal options so the traveller can pick their
+ preference. Invoke this tool when the user requests to set or change their
+ meal preference or option.
+- set_meal_preference(meal: str) – set the traveller's meal preference. Use
+ this when you receive a [HIDDEN] message indicating the user selected a meal.
+- flight_option_list(origin?: str, destination: str, depart_date: str,
+ return_date: str, cabin: str) – present bookable flight options after the
+ key details are confirmed.
+- request_assistance(note: str) – record a special assistance request.
Only use information provided in the customer context or tool results. Do not
invent confirmation numbers or policy details.
@@ -69,7 +93,9 @@ def __init__(
change_seat,
cancel_trip,
add_checked_bag,
+ meal_preference_list,
set_meal_preference,
+ flight_option_list,
request_assistance,
],
generate_content_config=generate_content_config,
diff --git a/examples/backend/src/backend/agents/airline/_server.py b/examples/backend/src/backend/agents/airline/_server.py
index 6f16d42..f4e17a2 100644
--- a/examples/backend/src/backend/agents/airline/_server.py
+++ b/examples/backend/src/backend/agents/airline/_server.py
@@ -1,21 +1,69 @@
+from __future__ import annotations
+
+import logging
+import random
from collections.abc import AsyncIterator
+from datetime import datetime
from typing import Any
+from uuid import uuid4
-from adk_chatkit import ADKAgentContext, ADKChatKitServer, ADKContext, ADKStore, ChatkitRunConfig, stream_agent_response
+from adk_chatkit import (
+ ADKAgentContext,
+ ADKChatKitServer,
+ ADKContext,
+ ADKStore,
+ ChatkitRunConfig,
+ serialize_widget_item,
+ stream_agent_response,
+)
+from adk_chatkit._constants import CHATKIT_WIDGET_STATE_KEY
+from chatkit.actions import Action
from chatkit.types import (
+ AssistantMessageContent,
+ AssistantMessageItem,
+ ClientEffectEvent,
ClientToolCallItem,
+ ThreadItemDoneEvent,
+ ThreadItemUpdated,
ThreadMetadata,
ThreadStreamEvent,
UserMessageItem,
+ WidgetItem,
+ WidgetRootUpdated,
)
from google.adk.agents.run_config import StreamingMode
from google.adk.models.lite_llm import LiteLlm
+from google.adk.sessions.base_session_service import BaseSessionService
from google.genai import types as genai_types
+from pydantic import ValidationError
from backend._config import Settings
from backend._runner_manager import RunnerManager
from ._agent import AirlineSupportAgent
+from ._state import AirlineAgentContext, FlightSegment
+from ._title_agent import TitleAgent
+from .widgets import (
+ FLIGHT_SELECT_ACTION_TYPE,
+ SET_MEAL_PREFERENCE_ACTION_TYPE,
+ FlightOption,
+ FlightSearchRequest,
+ FlightSelectPayload,
+ SetMealPreferencePayload,
+ build_flight_options_widget,
+ build_meal_preference_widget,
+ describe_flight_option,
+ generate_flight_options,
+ meal_preference_label,
+)
+
+BOOKING_CONFIRM_ACTION_TYPE = "booking.confirm_selection"
+BOOKING_MODIFY_ACTION_TYPE = "booking.modify_request"
+UPSELL_ACCEPT_ACTION_TYPE = "upsell.accept"
+UPSELL_DECLINE_ACTION_TYPE = "upsell.decline"
+REBOOK_SELECT_ACTION_TYPE = "rebook.select_option"
+
+logger = logging.getLogger(__name__)
def _make_airline_support_agent(settings: Settings) -> AirlineSupportAgent:
@@ -28,6 +76,16 @@ def _make_airline_support_agent(settings: Settings) -> AirlineSupportAgent:
)
+def _make_title_agent(settings: Settings) -> TitleAgent:
+ return TitleAgent(
+ llm=LiteLlm(
+ model=settings.gpt41_mini_agent.llm.model_name,
+ **settings.gpt41_mini_agent.llm.provider_args,
+ ),
+ generate_content_config=settings.gpt41_mini_agent.generate_content,
+ )
+
+
def _user_message_text(item: UserMessageItem) -> str:
parts: list[str] = []
for part in item.content:
@@ -45,12 +103,412 @@ class AirlineSupportChatKitServer(ADKChatKitServer):
def __init__(
self,
store: ADKStore,
+ session_service: BaseSessionService,
runner_manager: RunnerManager,
settings: Settings,
) -> None:
super().__init__(store)
+ self._store = store
+ self._session_service = session_service
+ self._settings = settings
+
+ # Create agents and runners
agent = _make_airline_support_agent(settings)
+ title_agent = _make_title_agent(settings)
+
self._runner = runner_manager.add_runner(settings.AIRLINE_APP_NAME, agent)
+ self._title_runner = runner_manager.add_runner(f"{settings.AIRLINE_APP_NAME}_title", title_agent)
+
+ async def _get_context_from_session(
+ self,
+ context: ADKContext,
+ thread_id: str,
+ ) -> AirlineAgentContext:
+ """Get airline context from ADK session state."""
+ session = await self._session_service.get_session(
+ app_name=context.app_name,
+ user_id=context.user_id,
+ session_id=thread_id,
+ )
+ if not session:
+ return AirlineAgentContext.create_initial_context()
+
+ ctx_dict = session.state.get("context", None)
+ if ctx_dict is None:
+ return AirlineAgentContext.create_initial_context()
+ return AirlineAgentContext.model_validate(ctx_dict)
+
+ async def _save_widget_to_session(
+ self,
+ widget_item: WidgetItem,
+ context: ADKContext,
+ ) -> None:
+ """Save a widget item to the session state so it can be loaded later."""
+ session = await self._session_service.get_session(
+ app_name=context.app_name,
+ user_id=context.user_id,
+ session_id=widget_item.thread_id,
+ )
+ if not session:
+ return
+
+ timestamp = datetime.now().timestamp()
+ state_delta = {
+ CHATKIT_WIDGET_STATE_KEY: {widget_item.id: serialize_widget_item(widget_item)},
+ }
+ from google.adk.events import Event, EventActions
+
+ system_event = Event(
+ invocation_id=uuid4().hex,
+ author="system",
+ actions=EventActions(state_delta=state_delta),
+ timestamp=timestamp,
+ )
+ await self._session_service.append_event(session, system_event)
+
+ async def _save_context_to_session(
+ self,
+ ctx: AirlineAgentContext,
+ thread_id: str,
+ context: ADKContext,
+ ) -> None:
+ """Save the airline context back to session state."""
+ session = await self._session_service.get_session(
+ app_name=context.app_name,
+ user_id=context.user_id,
+ session_id=thread_id,
+ )
+ if not session:
+ return
+
+ timestamp = datetime.now().timestamp()
+ state_delta = {"context": ctx.model_dump()}
+ from google.adk.events import Event, EventActions
+
+ system_event = Event(
+ invocation_id=uuid4().hex,
+ author="system",
+ actions=EventActions(state_delta=state_delta),
+ timestamp=timestamp,
+ )
+ await self._session_service.append_event(session, system_event)
+
+ def _profile_effect(self, ctx: AirlineAgentContext) -> ClientEffectEvent:
+ return ClientEffectEvent(
+ name="customer_profile/update",
+ data={"profile": ctx.customer_profile.to_dict()},
+ )
+
+ async def action(
+ self,
+ thread: ThreadMetadata,
+ action: Action[str, Any],
+ sender: WidgetItem | None,
+ context: ADKContext,
+ ) -> AsyncIterator[ThreadStreamEvent]:
+ """Handle widget actions."""
+ if action.type == SET_MEAL_PREFERENCE_ACTION_TYPE:
+ async for event in self._handle_meal_preference_action(thread, action, sender, context):
+ yield event
+ elif action.type == FLIGHT_SELECT_ACTION_TYPE:
+ async for event in self._handle_flight_select_action(thread, action, sender, context):
+ yield event
+ elif action.type == BOOKING_CONFIRM_ACTION_TYPE:
+ async for event in self._handle_booking_confirm_action(thread, action, sender, context):
+ yield event
+ elif action.type == BOOKING_MODIFY_ACTION_TYPE:
+ async for event in self._handle_booking_modify_action(thread, action, sender, context):
+ yield event
+ elif action.type == UPSELL_ACCEPT_ACTION_TYPE:
+ async for event in self._handle_upgrade_accept_action(thread, action, sender, context):
+ yield event
+ elif action.type == UPSELL_DECLINE_ACTION_TYPE:
+ async for event in self._handle_upgrade_decline_action(thread, action, sender, context):
+ yield event
+ elif action.type == REBOOK_SELECT_ACTION_TYPE:
+ async for event in self._handle_rebook_action(thread, action, sender, context):
+ yield event
+
+ async def _handle_meal_preference_action(
+ self,
+ thread: ThreadMetadata,
+ action: Action[str, Any],
+ sender: WidgetItem | None,
+ context: ADKContext,
+ ) -> AsyncIterator[ThreadStreamEvent]:
+ payload = self._parse_meal_preference_payload(action)
+ if payload is None:
+ return
+
+ meal_label = meal_preference_label(payload.meal)
+
+ if sender is not None:
+ widget = build_meal_preference_widget(selected=payload.meal)
+ yield ThreadItemUpdated(
+ item_id=sender.id,
+ update=WidgetRootUpdated(widget=widget),
+ )
+
+ # Re-run agent with hidden message to update state
+ hidden_message = f"[HIDDEN]\nUser selected meal preference: {meal_label}"
+ async for event in self._run_agent_with_message(thread, hidden_message, context):
+ yield event
+
+ async def _handle_flight_select_action(
+ self,
+ thread: ThreadMetadata,
+ action: Action[str, Any],
+ sender: WidgetItem | None,
+ context: ADKContext,
+ ) -> AsyncIterator[ThreadStreamEvent]:
+ payload = self._parse_flight_select_payload(action)
+ if payload is None:
+ return
+
+ # Check if widget was already used
+ ctx = await self._get_context_from_session(context, thread.id)
+ if sender is not None and ctx.is_widget_consumed(sender.id):
+ return
+
+ try:
+ options = [FlightOption.model_validate(opt) for opt in payload.options]
+ except ValidationError as exc:
+ logger.warning("Invalid flight options in payload: %s", exc)
+ options = []
+
+ if not options:
+ options = generate_flight_options(payload.request)
+
+ selected = next((opt for opt in options if opt.id == payload.id), None)
+ if selected is None:
+ return
+
+ # Lock the current widget to the chosen option
+ if sender is not None:
+ selected_widget = build_flight_options_widget(
+ [selected],
+ payload.request,
+ selected_id=selected.id,
+ leg=payload.leg,
+ )
+ yield ThreadItemUpdated(
+ item_id=sender.id,
+ update=WidgetRootUpdated(widget=selected_widget),
+ )
+ # Mark widget as consumed
+ ctx.mark_widget_consumed(sender.id)
+
+ # Record the flight booking in context
+ seat_assignment = _pick_default_seat(payload.request.cabin)
+ flight_number = _generate_flight_number(payload.leg)
+ booking = ctx.record_flight_booking(
+ flight_number=flight_number,
+ date=payload.request.depart_date,
+ origin=payload.request.normalized_origin(),
+ destination=payload.request.normalized_destination(),
+ depart_time=selected.dep_time,
+ arrival_time=selected.arr_time,
+ seat=seat_assignment,
+ )
+
+ # Save context back to session
+ await self._save_context_to_session(ctx, thread.id, context)
+
+ summary = describe_flight_option(selected, payload.request)
+ action_text = (
+ "You're scheduled on that option. I'll surface a few returns now."
+ if payload.leg == "outbound"
+ else "Return scheduled. Want me to watch for upgrades?"
+ )
+ yield ThreadItemDoneEvent(
+ item=self._assistant_message(
+ thread,
+ f"Scheduled: {summary}. Seat {booking.seat} for now; {action_text}",
+ context,
+ ),
+ )
+
+ # Send profile update effect
+ yield self._profile_effect(ctx)
+
+ # Show return options for outbound leg
+ if payload.leg == "outbound":
+ return_request = FlightSearchRequest(
+ origin=payload.request.normalized_destination(),
+ destination=payload.request.normalized_origin(),
+ depart_date=payload.request.return_date,
+ return_date=payload.request.depart_date,
+ cabin=payload.request.cabin,
+ )
+ return_options = generate_flight_options(return_request)
+ yield ThreadItemDoneEvent(
+ item=self._assistant_message(
+ thread,
+ "Here are return options that line up with your trip:",
+ context,
+ ),
+ )
+ new_widget = build_flight_options_widget(
+ return_options,
+ return_request,
+ leg="return",
+ )
+ return_widget_id = uuid4().hex
+ return_widget_item = WidgetItem(
+ thread_id=thread.id,
+ id=return_widget_id,
+ created_at=datetime.now(),
+ widget=new_widget,
+ )
+ # Save widget to session so it can be loaded when user clicks on it
+ await self._save_widget_to_session(return_widget_item, context)
+ yield ThreadItemDoneEvent(item=return_widget_item)
+
+ async def _handle_booking_confirm_action(
+ self,
+ thread: ThreadMetadata,
+ action: Action[str, Any],
+ _sender: WidgetItem | None,
+ context: ADKContext,
+ ) -> AsyncIterator[ThreadStreamEvent]:
+ payload = action.payload or {}
+ destination = payload.get("destination", "the trip")
+ depart_label = payload.get("depart_label", "outbound flight")
+ return_label = payload.get("return_label", "return flight")
+
+ hidden_message = (
+ f"[HIDDEN]\n"
+ f"User confirmed booking:\n"
+ f"Destination: {destination}\n"
+ f"Outbound: {depart_label}\n"
+ f"Return: {return_label}"
+ )
+ async for event in self._run_agent_with_message(thread, hidden_message, context):
+ yield event
+
+ async def _handle_booking_modify_action(
+ self,
+ thread: ThreadMetadata,
+ _action: Action[str, Any],
+ _sender: WidgetItem | None,
+ context: ADKContext,
+ ) -> AsyncIterator[ThreadStreamEvent]:
+ yield ThreadItemDoneEvent(
+ item=self._assistant_message(
+ thread,
+ (
+ "Happy to tweak the plan. Let me know what you'd like to "
+ "change and feel free to attach a new inspiration photo "
+ "if it helps."
+ ),
+ context,
+ ),
+ )
+
+ async def _handle_upgrade_accept_action(
+ self,
+ thread: ThreadMetadata,
+ action: Action[str, Any],
+ _sender: WidgetItem | None,
+ context: ADKContext,
+ ) -> AsyncIterator[ThreadStreamEvent]:
+ async for event in self._handle_upgrade_action(thread, action, context, accepted=True):
+ yield event
+
+ async def _handle_upgrade_decline_action(
+ self,
+ thread: ThreadMetadata,
+ action: Action[str, Any],
+ _sender: WidgetItem | None,
+ context: ADKContext,
+ ) -> AsyncIterator[ThreadStreamEvent]:
+ async for event in self._handle_upgrade_action(thread, action, context, accepted=False):
+ yield event
+
+ async def _handle_upgrade_action(
+ self,
+ thread: ThreadMetadata,
+ action: Action[str, Any],
+ context: ADKContext,
+ *,
+ accepted: bool,
+ ) -> AsyncIterator[ThreadStreamEvent]:
+ cabin = (action.payload or {}).get("cabin_name", "the upgrade")
+ price = (action.payload or {}).get("price", "the quoted amount")
+
+ if accepted:
+ hidden_message = f"[HIDDEN]\nUser accepted {cabin} upgrade for {price}."
+ else:
+ hidden_message = f"[HIDDEN]\nUser declined {cabin} upgrade."
+
+ async for event in self._run_agent_with_message(thread, hidden_message, context):
+ yield event
+
+ async def _handle_rebook_action(
+ self,
+ thread: ThreadMetadata,
+ action: Action[str, Any],
+ _sender: WidgetItem | None,
+ context: ADKContext,
+ ) -> AsyncIterator[ThreadStreamEvent]:
+ payload = action.payload or {}
+ flight_number = payload.get("flight_number")
+ option_id = payload.get("option_id")
+ depart_time = payload.get("depart_time")
+ arrival_time = payload.get("arrival_time")
+ note = payload.get("option_note", "Selected alternate time")
+
+ if not flight_number or option_id is None:
+ return
+
+ if option_id == "keep":
+ yield ThreadItemDoneEvent(
+ item=self._assistant_message(
+ thread,
+ "Sounds good — we'll keep the original departure on file.",
+ context,
+ ),
+ )
+ return
+
+ hidden_message = (
+ f"[HIDDEN]\n"
+ f"User requested rebooking:\n"
+ f"Flight: {flight_number}\n"
+ f"New departure: {depart_time}\n"
+ f"New arrival: {arrival_time}\n"
+ f"Note: {note}"
+ )
+ async for event in self._run_agent_with_message(thread, hidden_message, context):
+ yield event
+
+ async def _run_agent_with_message(
+ self,
+ thread: ThreadMetadata,
+ message_text: str,
+ context: ADKContext,
+ ) -> AsyncIterator[ThreadStreamEvent]:
+ """Run agent with a hidden message to update state."""
+ agent_context = ADKAgentContext(
+ app_name=context.app_name,
+ user_id=context.user_id,
+ thread=thread,
+ )
+
+ content = genai_types.Content(
+ role="user",
+ parts=[genai_types.Part.from_text(text=message_text)],
+ )
+
+ event_stream = self._runner.run_async(
+ user_id=context.user_id,
+ session_id=thread.id,
+ new_message=content,
+ run_config=ChatkitRunConfig(streaming_mode=StreamingMode.SSE, context=agent_context),
+ )
+
+ async for event in stream_agent_response(agent_context, event_stream):
+ yield event
async def _adk_respond(
self,
@@ -68,6 +526,10 @@ async def _adk_respond(
if not message_text:
return
+ # Update thread title if needed
+ if thread.title is None:
+ await self._maybe_update_thread_title(thread, message_text, context)
+
content = genai_types.Content(
role="user",
parts=[genai_types.Part.from_text(text=message_text)],
@@ -88,3 +550,74 @@ async def _adk_respond(
async for event in stream_agent_response(agent_context, event_stream):
yield event
+
+ async def _maybe_update_thread_title(
+ self,
+ thread: ThreadMetadata,
+ message_text: str,
+ context: ADKContext,
+ ) -> None:
+ """Generate and update thread title."""
+ try:
+ if len(message_text) > 50:
+ thread.title = message_text[:47] + "..."
+ else:
+ thread.title = message_text
+ await self._store.save_thread(thread, context)
+ except Exception:
+ pass
+
+ @staticmethod
+ def _parse_meal_preference_payload(action: Action[str, Any]) -> SetMealPreferencePayload | None:
+ try:
+ return SetMealPreferencePayload.model_validate(action.payload or {})
+ except ValidationError as exc:
+ logger.warning("Invalid meal preference payload: %s", exc)
+ return None
+
+ @staticmethod
+ def _parse_flight_select_payload(action: Action[str, Any]) -> FlightSelectPayload | None:
+ try:
+ return FlightSelectPayload.model_validate(action.payload or {})
+ except ValidationError as exc:
+ logger.warning("Invalid flight selection payload: %s", exc)
+ return None
+
+ def _assistant_message(
+ self,
+ thread: ThreadMetadata,
+ text: str,
+ context: ADKContext,
+ ) -> AssistantMessageItem:
+ return AssistantMessageItem(
+ thread_id=thread.id,
+ id=uuid4().hex,
+ created_at=datetime.now(),
+ content=[AssistantMessageContent(text=text)],
+ )
+
+
+def _generate_flight_number(leg: str) -> str:
+ suffix = "1" if leg == "outbound" else "2"
+ return f"OA9{suffix}7"
+
+
+def _pick_default_seat(cabin: str) -> str:
+ """Return a randomized seat assignment biased by fare class."""
+ normalized = cabin.lower().strip()
+ seat_letters = {
+ "first": ["A", "D"],
+ "business": ["A", "C", "D", "F"],
+ "premium economy": list("ABCDEF"),
+ "economy": list("ABCDEF"),
+ }
+ row_ranges = {
+ "first": (1, 3),
+ "business": (4, 9),
+ "premium economy": (10, 19),
+ "economy": (20, 45),
+ }
+ letters = seat_letters.get(normalized, list("ABCDEF"))
+ start, end = row_ranges.get(normalized, (12, 38))
+ row = random.randint(start, end)
+ return f"{row}{random.choice(letters)}"
diff --git a/examples/backend/src/backend/agents/airline/_state.py b/examples/backend/src/backend/agents/airline/_state.py
index 33697ab..9861d19 100644
--- a/examples/backend/src/backend/agents/airline/_state.py
+++ b/examples/backend/src/backend/agents/airline/_state.py
@@ -44,18 +44,24 @@ class CustomerProfile(BaseModel):
def log(self, entry: str, kind: str = "info") -> None:
self.timeline.insert(0, {"timestamp": _now_iso(), "kind": kind, "entry": entry})
+ def to_dict(self) -> dict[str, Any]:
+ data = self.model_dump()
+ data["segments"] = [seg.model_dump() for seg in self.segments]
+ return data
+
def format(self) -> str:
- segments = []
+ """Return a formatted string for agent context."""
+ segment_lines = []
for segment in self.segments:
- segments.append(
+ segment_lines.append(
f"- {segment.flight_number} {segment.origin}->{segment.destination}"
f" on {segment.date} seat {segment.seat} ({segment.status})"
)
- summary = "\n".join(segments)
- timeline = self.timeline[:3]
- recent = "\n".join(f" * {entry['entry']} ({entry['timestamp']})" for entry in timeline)
+ summary = "\n".join(segment_lines)
+ recent_timeline = self.timeline[:3]
+ recent = "\n".join(f" * {entry['entry']} ({entry['timestamp']})" for entry in recent_timeline)
return (
- "Customer Profile\n"
+ "\n"
f"Name: {self.name} ({self.loyalty_status})\n"
f"Loyalty ID: {self.loyalty_id}\n"
f"Contact: {self.email}, {self.phone}\n"
@@ -63,20 +69,25 @@ def format(self) -> str:
f"Meal Preference: {self.meal_preference or 'Not set'}\n"
f"Special Assistance: {self.special_assistance or 'None'}\n"
"Upcoming Segments:\n"
- f"{summary}\n"
+ f"{summary or ' * No segments scheduled.'}\n"
"Recent Service Timeline:\n"
- f"{recent or ' * No service actions recorded yet.'}"
+ f"{recent or ' * No service actions recorded yet.'}\n"
+ ""
)
class AirlineAgentContext(BaseModel):
+ """Context stored in ADK session state for airline support agent."""
+
customer_profile: CustomerProfile
+ booked_widget_ids: list[str] = []
@staticmethod
def create_initial_context() -> AirlineAgentContext:
+ """Create a new context with default customer profile."""
segments = [
FlightSegment(
- flight_number="0A476",
+ flight_number="OA476",
date="2025-10-02",
origin="SFO",
destination="JFK",
@@ -85,7 +96,7 @@ def create_initial_context() -> AirlineAgentContext:
seat="14A",
),
FlightSegment(
- flight_number="0A477",
+ flight_number="OA477",
date="2025-10-10",
origin="JFK",
destination="SFO",
@@ -145,6 +156,45 @@ def set_meal(self, meal: str) -> str:
self.customer_profile.log(f"Meal preference updated to {meal}.", kind="info")
return f"We'll note {meal} as the meal preference."
+ def record_flight_booking(
+ self,
+ flight_number: str,
+ date: str,
+ origin: str,
+ destination: str,
+ depart_time: str,
+ arrival_time: str,
+ *,
+ seat: str = "TBD",
+ status: str = "Scheduled",
+ ) -> FlightSegment:
+ """Record a new flight booking on the customer's itinerary."""
+ segment = FlightSegment(
+ flight_number=flight_number,
+ date=date,
+ origin=origin,
+ destination=destination,
+ departure_time=depart_time,
+ arrival_time=arrival_time,
+ seat=seat,
+ status=status,
+ )
+ self.customer_profile.segments.append(segment)
+ self.customer_profile.log(
+ f"{status}: {flight_number} {origin}->{destination} on {date} {depart_time}-{arrival_time} seat {seat}.",
+ kind="success",
+ )
+ return segment
+
+ def mark_widget_consumed(self, widget_id: str) -> None:
+ """Mark a widget as consumed so it can't be reused."""
+ if widget_id not in self.booked_widget_ids:
+ self.booked_widget_ids.append(widget_id)
+
+ def is_widget_consumed(self, widget_id: str) -> bool:
+ """Check if a widget has already been consumed."""
+ return widget_id in self.booked_widget_ids
+
def request_assistance(self, note: str) -> str:
self.customer_profile.special_assistance = note
self.customer_profile.log(f"Special assistance noted: {note}.", kind="info")
diff --git a/examples/backend/src/backend/agents/airline/_title_agent.py b/examples/backend/src/backend/agents/airline/_title_agent.py
new file mode 100644
index 0000000..36a8bd6
--- /dev/null
+++ b/examples/backend/src/backend/agents/airline/_title_agent.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from typing import Final
+
+from google.adk.agents.llm_agent import LlmAgent
+from google.adk.models.lite_llm import LiteLlm
+from google.genai import types as genai_types
+
+_INSTRUCTIONS: Final[str] = """
+Generate a title for a conversation between an airline concierge acting on
+behalf of the traveller and the user.
+The first user message in the conversation is included below.
+Do not just repeat the user message, use your own words.
+YOU MUST respond with 2-5 words without punctuation.
+"""
+
+
+class TitleAgent(LlmAgent):
+ def __init__(
+ self,
+ llm: LiteLlm,
+ generate_content_config: genai_types.GenerateContentConfig | None = None,
+ ) -> None:
+ super().__init__(
+ name="airline_title_generator",
+ description="Generates short conversation titles for airline support chats.",
+ model=llm,
+ instruction=_INSTRUCTIONS,
+ tools=[],
+ generate_content_config=generate_content_config,
+ )
diff --git a/examples/backend/src/backend/agents/airline/_tools.py b/examples/backend/src/backend/agents/airline/_tools.py
index b1c6458..a7aa327 100644
--- a/examples/backend/src/backend/agents/airline/_tools.py
+++ b/examples/backend/src/backend/agents/airline/_tools.py
@@ -1,6 +1,40 @@
-from google.adk.tools import ToolContext
+from __future__ import annotations
+
+from adk_chatkit import stream_event, stream_widget
+from chatkit.types import ClientEffectEvent
+from google.adk.tools.tool_context import ToolContext
from ._state import AirlineAgentContext
+from .widgets import (
+ FlightSearchRequest,
+ build_flight_options_widget,
+ build_meal_preference_widget,
+ generate_flight_options,
+)
+
+
+def _get_context(tool_context: ToolContext) -> AirlineAgentContext:
+ """Get airline context from session state, creating if needed."""
+ context = tool_context.state.get("context", None)
+ if context is None:
+ ctx = AirlineAgentContext.create_initial_context()
+ tool_context.state["context"] = ctx.model_dump()
+ return ctx
+ return AirlineAgentContext.model_validate(context)
+
+
+def _save_context(tool_context: ToolContext, ctx: AirlineAgentContext) -> None:
+ """Save updated context back to session state."""
+ tool_context.state["context"] = ctx.model_dump()
+
+
+async def _sync_profile_effect(tool_context: ToolContext, ctx: AirlineAgentContext) -> None:
+ """Send profile update effect to the client."""
+ event = ClientEffectEvent(
+ name="customer_profile/update",
+ data={"profile": ctx.customer_profile.to_dict()},
+ )
+ await stream_event(event, tool_context)
def get_customer_profile(tool_context: ToolContext) -> str:
@@ -9,13 +43,11 @@ def get_customer_profile(tool_context: ToolContext) -> str:
Returns:
A string with the formatted customer profile.
"""
+ ctx = _get_context(tool_context)
+ return ctx.customer_profile.format()
- context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"])
-
- return context.customer_profile.format()
-
-def change_seat(flight_number: str, seat: str, tool_context: ToolContext) -> dict[str, str]:
+async def change_seat(flight_number: str, seat: str, tool_context: ToolContext) -> dict[str, str]:
"""Move the passenger to a different seat on a flight.
Args:
@@ -25,74 +57,103 @@ def change_seat(flight_number: str, seat: str, tool_context: ToolContext) -> dic
Returns:
A dictionary with a message confirming the seat change.
"""
-
- context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"])
-
- try:
- message = context.change_seat(flight_number, seat)
- except ValueError as exc: # translate user errors
- raise ValueError(str(exc)) from exc
-
- # Persist updated context
- tool_context.state["context"] = context.model_dump()
-
+ ctx = _get_context(tool_context)
+ message = ctx.change_seat(flight_number, seat)
+ _save_context(tool_context, ctx)
+ await _sync_profile_effect(tool_context, ctx)
return {"result": message}
-def cancel_trip(tool_context: ToolContext) -> dict[str, str]:
+async def cancel_trip(tool_context: ToolContext) -> dict[str, str]:
"""Cancel the traveller's upcoming trip and note the refund.
Returns:
A dictionary with a message confirming the cancellation.
"""
-
- context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"])
-
- message = context.cancel_trip()
-
- # Persist updated context
- tool_context.state["context"] = context.model_dump()
-
+ ctx = _get_context(tool_context)
+ message = ctx.cancel_trip()
+ _save_context(tool_context, ctx)
+ await _sync_profile_effect(tool_context, ctx)
return {"result": message}
-def add_checked_bag(tool_context: ToolContext) -> dict[str, str | int]:
+async def add_checked_bag(tool_context: ToolContext) -> dict[str, str | int]:
"""Add a checked bag to the reservation.
Returns:
A dictionary with a message confirming the addition and the total bags checked.
"""
+ ctx = _get_context(tool_context)
+ message = ctx.add_bag()
+ _save_context(tool_context, ctx)
+ await _sync_profile_effect(tool_context, ctx)
+ return {"result": message, "bags_checked": ctx.customer_profile.bags_checked}
- context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"])
-
- message = context.add_bag()
- # Persist updated context
- tool_context.state["context"] = context.model_dump()
+async def meal_preference_list(tool_context: ToolContext) -> dict[str, str]:
+ """Display the meal preference picker widget.
- return {"result": message, "bags_checked": context.customer_profile.bags_checked}
+ Returns:
+ A dictionary with a message indicating the widget was shown.
+ """
+ widget = build_meal_preference_widget()
+ await stream_widget(widget, tool_context)
+ return {"result": "Shared meal preference options with the traveller."}
-def set_meal_preference(meal: str, tool_context: ToolContext) -> dict[str, str]:
- """Record or update the passenger's meal preference.
+async def set_meal_preference(meal: str, tool_context: ToolContext) -> dict[str, str]:
+ """Set the traveller's meal preference.
Args:
meal: The meal preference to set (e.g. vegetarian).
Returns:
A dictionary with a message confirming the meal preference update.
"""
+ ctx = _get_context(tool_context)
+ message = ctx.set_meal(meal)
+ _save_context(tool_context, ctx)
+ await _sync_profile_effect(tool_context, ctx)
+ return {"result": message}
- context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"])
- message = context.set_meal(meal)
+async def flight_option_list(
+ destination: str,
+ depart_date: str,
+ return_date: str,
+ cabin: str,
+ tool_context: ToolContext,
+ origin: str | None = None,
+) -> dict[str, str]:
+ """Share specific flight options after collecting destination, dates, and cabin.
+ Returns:
+ A dictionary with a message indicating the widget was shown.
+ """
+ ctx = _get_context(tool_context)
- # Persist updated context
- tool_context.state["context"] = context.model_dump()
+ origin_airport = (origin or "SFO").strip().upper()
- return {"result": message}
+ request = FlightSearchRequest(
+ origin=origin_airport,
+ destination=destination.strip().upper(),
+ depart_date=depart_date.strip(),
+ return_date=return_date.strip(),
+ cabin=cabin.strip(),
+ )
+ ctx.customer_profile.log(
+ f"Booking request for {origin_airport} → {destination.upper()} ({depart_date} to {return_date}).",
+ kind="info",
+ )
+ _save_context(tool_context, ctx)
-def request_assistance(note: str, tool_context: ToolContext) -> dict[str, str]:
+ options = generate_flight_options(request)
+ widget = build_flight_options_widget(options, request)
+ await stream_widget(widget, tool_context)
+
+ return {"result": "Shared flight options with the traveller."}
+
+
+async def request_assistance(note: str, tool_context: ToolContext) -> dict[str, str]:
"""Note a special assistance request for airport staff.
Args:
@@ -100,12 +161,8 @@ def request_assistance(note: str, tool_context: ToolContext) -> dict[str, str]:
Returns:
A dictionary with a message confirming the assistance request.
"""
-
- context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"])
-
- message = context.request_assistance(note)
-
- # Persist updated context
- tool_context.state["context"] = context.model_dump()
-
+ ctx = _get_context(tool_context)
+ message = ctx.request_assistance(note)
+ _save_context(tool_context, ctx)
+ await _sync_profile_effect(tool_context, ctx)
return {"result": message}
diff --git a/examples/backend/src/backend/agents/airline/widgets/__init__.py b/examples/backend/src/backend/agents/airline/widgets/__init__.py
new file mode 100644
index 0000000..3322b57
--- /dev/null
+++ b/examples/backend/src/backend/agents/airline/widgets/__init__.py
@@ -0,0 +1,29 @@
+from ._flight_options_widget import (
+ FLIGHT_SELECT_ACTION_TYPE,
+ FlightOption,
+ FlightSearchRequest,
+ FlightSelectPayload,
+ build_flight_options_widget,
+ describe_flight_option,
+ generate_flight_options,
+)
+from ._meal_preferences_widget import (
+ SET_MEAL_PREFERENCE_ACTION_TYPE,
+ SetMealPreferencePayload,
+ build_meal_preference_widget,
+ meal_preference_label,
+)
+
+__all__ = [
+ "FLIGHT_SELECT_ACTION_TYPE",
+ "FlightOption",
+ "FlightSearchRequest",
+ "FlightSelectPayload",
+ "build_flight_options_widget",
+ "describe_flight_option",
+ "generate_flight_options",
+ "SET_MEAL_PREFERENCE_ACTION_TYPE",
+ "SetMealPreferencePayload",
+ "build_meal_preference_widget",
+ "meal_preference_label",
+]
diff --git a/examples/backend/src/backend/agents/airline/widgets/_flight_options_widget.py b/examples/backend/src/backend/agents/airline/widgets/_flight_options_widget.py
new file mode 100644
index 0000000..cee5a92
--- /dev/null
+++ b/examples/backend/src/backend/agents/airline/widgets/_flight_options_widget.py
@@ -0,0 +1,276 @@
+"""Flight options widget for the airline support agent."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Literal
+
+from chatkit.widgets import ListView, WidgetRoot
+from pydantic import BaseModel
+
+FlightCabin = Literal["economy", "premium economy", "business", "first"]
+
+FLIGHT_SELECT_ACTION_TYPE = "flight.select"
+FlightLeg = Literal["outbound", "return"]
+
+
+class FlightSearchRequest(BaseModel):
+ """Minimal search context used to render flight options."""
+
+ origin: str
+ destination: str
+ depart_date: str
+ return_date: str
+ cabin: str
+
+ def normalized_origin(self) -> str:
+ return _sanitize_airport_code(self.origin)
+
+ def normalized_destination(self) -> str:
+ return _sanitize_airport_code(self.destination)
+
+
+class FlightOption(BaseModel):
+ """Single flight option shown in the picker."""
+
+ id: str
+ from_airport: str
+ to_airport: str
+ dep_time: str
+ arr_time: str
+ date_label: str
+ cabin: str
+
+ model_config = {"populate_by_name": True}
+
+
+class FlightSelectPayload(BaseModel):
+ """Payload sent when the user taps a flight option."""
+
+ id: str
+ options: list[FlightOption]
+ request: FlightSearchRequest
+ leg: FlightLeg = "outbound"
+
+
+def _sanitize_airport_code(raw: str) -> str:
+ code = raw.strip().upper()
+ if len(code) == 3 and code.isalpha():
+ return code
+ return code[:3]
+
+
+def _format_date_label(raw_date: str) -> str:
+ """Convert YYYY-MM-DD into a friendly label, otherwise return the input."""
+
+ try:
+ parsed = datetime.fromisoformat(raw_date)
+ return f"{parsed.strftime('%a, %b ')}{parsed.day}"
+ except Exception:
+ return raw_date
+
+
+def generate_flight_options(
+ request: FlightSearchRequest,
+) -> list[FlightOption]:
+ """Return a small set of plausible flight options for the widget."""
+
+ date_label = _format_date_label(request.depart_date)
+ cabin_label = request.cabin.title()
+ return [
+ FlightOption(
+ id="flight-morning",
+ from_airport=request.normalized_origin(),
+ to_airport=request.normalized_destination(),
+ dep_time="08:10",
+ arr_time="16:40",
+ date_label=date_label,
+ cabin=cabin_label,
+ ),
+ FlightOption(
+ id="flight-midday",
+ from_airport=request.normalized_origin(),
+ to_airport=request.normalized_destination(),
+ dep_time="12:35",
+ arr_time="21:05",
+ date_label=date_label,
+ cabin=cabin_label,
+ ),
+ FlightOption(
+ id="flight-late",
+ from_airport=request.normalized_origin(),
+ to_airport=request.normalized_destination(),
+ dep_time="21:55",
+ arr_time="06:20 (+1)",
+ date_label=date_label,
+ cabin=cabin_label,
+ ),
+ ]
+
+
+def _serialize_option(option: FlightOption) -> dict[str, Any]:
+ """Return a template-friendly dict for a flight option."""
+
+ return {
+ "id": option.id,
+ "from_airport": option.from_airport,
+ "to_airport": option.to_airport,
+ "dep_time": option.dep_time,
+ "arr_time": option.arr_time,
+ "date_label": option.date_label,
+ "cabin": option.cabin,
+ }
+
+
+def describe_flight_option(option: FlightOption, request: FlightSearchRequest) -> str:
+ """Human-readable summary used in assistant replies and logs."""
+
+ return (
+ f"{option.cabin} {option.from_airport} → {option.to_airport} on "
+ f"{_format_date_label(request.depart_date)} departing "
+ f"{option.dep_time} (arrives {option.arr_time}); "
+ f"return on {request.return_date}"
+ )
+
+
+def build_flight_options_widget(
+ options: list[FlightOption],
+ request: FlightSearchRequest,
+ *,
+ selected_id: str | None = None,
+ leg: FlightLeg = "outbound",
+) -> WidgetRoot:
+ """Render the flight picker widget programmatically."""
+
+ items: list[dict[str, Any]] = []
+ for option in options:
+ is_selected = selected_id == option.id
+
+ item_data: dict[str, Any] = {
+ "type": "ListViewItem",
+ "key": option.id,
+ "gap": 0,
+ "align": "stretch",
+ "children": [
+ {
+ "type": "Box",
+ "width": "100%",
+ "padding": 3,
+ "border": {
+ "size": 1,
+ "color": "blue-500" if is_selected else "default",
+ "style": "solid" if is_selected else "dashed",
+ },
+ "radius": "xl",
+ "background": "surface",
+ "children": [
+ {
+ "type": "Row",
+ "gap": 3,
+ "align": "stretch",
+ "children": [
+ {
+ "type": "Box",
+ "width": 3,
+ "background": "blue-600",
+ "radius": "full",
+ },
+ {
+ "type": "Col",
+ "flex": "auto",
+ "gap": 2,
+ "children": [
+ {
+ "type": "Row",
+ "children": [
+ {
+ "type": "Title",
+ "value": f"{option.from_airport} → {option.to_airport}",
+ "size": "md",
+ },
+ {"type": "Spacer"},
+ {
+ "type": "Badge",
+ "label": option.cabin,
+ "color": "discovery",
+ "variant": "soft",
+ },
+ ],
+ },
+ {
+ "type": "Row",
+ "children": [
+ {
+ "type": "Col",
+ "children": [
+ {
+ "type": "Caption",
+ "value": "Depart",
+ "color": "secondary",
+ },
+ {
+ "type": "Title",
+ "value": option.dep_time,
+ "size": "lg",
+ },
+ ],
+ },
+ {"type": "Spacer"},
+ {
+ "type": "Col",
+ "align": "end",
+ "children": [
+ {
+ "type": "Caption",
+ "value": "Arrive",
+ "color": "secondary",
+ },
+ {
+ "type": "Title",
+ "value": option.arr_time,
+ "size": "lg",
+ },
+ ],
+ },
+ ],
+ },
+ {"type": "Divider"},
+ {
+ "type": "Row",
+ "children": [
+ {
+ "type": "Caption",
+ "value": option.date_label,
+ },
+ {"type": "Spacer"},
+ {
+ "type": "Caption",
+ "value": f"{option.from_airport} • {option.to_airport}",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ }
+
+ if not is_selected:
+ item_data["onClickAction"] = {
+ "type": FLIGHT_SELECT_ACTION_TYPE,
+ "handler": "server",
+ "payload": {
+ "id": option.id,
+ "options": [_serialize_option(opt) for opt in options],
+ "request": request.model_dump(mode="json"),
+ "leg": leg,
+ },
+ }
+
+ items.append(item_data)
+
+ widget_data = {"type": "ListView", "children": items}
+ return ListView.model_validate(widget_data)
diff --git a/examples/backend/src/backend/agents/airline/widgets/_meal_preferences_widget.py b/examples/backend/src/backend/agents/airline/widgets/_meal_preferences_widget.py
new file mode 100644
index 0000000..e4f1ca1
--- /dev/null
+++ b/examples/backend/src/backend/agents/airline/widgets/_meal_preferences_widget.py
@@ -0,0 +1,88 @@
+"""Meal preference widget for the airline support agent."""
+
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from chatkit.widgets import ListView, WidgetRoot
+from pydantic import BaseModel
+
+MealPreferenceOption = Literal[
+ "vegetarian",
+ "kosher",
+ "gluten intolerant",
+ "child",
+]
+
+SET_MEAL_PREFERENCE_ACTION_TYPE = "support.set_meal_preference"
+
+
+class SetMealPreferencePayload(BaseModel):
+ meal: MealPreferenceOption
+
+
+_MEAL_PREFERENCE_LABELS: dict[MealPreferenceOption, str] = {
+ "vegetarian": "Vegetarian",
+ "kosher": "Kosher",
+ "gluten intolerant": "Gluten intolerant",
+ "child": "Child",
+}
+
+MEAL_PREFERENCE_ORDER: tuple[MealPreferenceOption, ...] = (
+ "vegetarian",
+ "kosher",
+ "gluten intolerant",
+ "child",
+)
+
+
+def meal_preference_label(value: MealPreferenceOption) -> str:
+ return _MEAL_PREFERENCE_LABELS.get(value, value.title())
+
+
+def build_meal_preference_widget(
+ *,
+ selected: MealPreferenceOption | None = None,
+) -> WidgetRoot:
+ """Render the meal preference list widget with optional selection state."""
+
+ items: list[dict[str, Any]] = []
+ for option_value in MEAL_PREFERENCE_ORDER:
+ is_selected = selected == option_value
+ label = meal_preference_label(option_value)
+
+ item_data: dict[str, Any] = {
+ "type": "ListViewItem",
+ "key": option_value,
+ "children": [
+ {
+ "type": "Row",
+ "gap": 2,
+ "children": [
+ {
+ "type": "Icon",
+ "name": "check" if is_selected else "empty-circle",
+ "color": "secondary",
+ },
+ {
+ "type": "Text",
+ "value": label,
+ "weight": "semibold" if is_selected else "medium",
+ **({"color": "emphasis"} if is_selected else {}),
+ },
+ ],
+ },
+ ],
+ }
+
+ if not selected:
+ item_data["onClickAction"] = {
+ "type": SET_MEAL_PREFERENCE_ACTION_TYPE,
+ "handler": "server",
+ "payload": {"meal": option_value},
+ }
+
+ items.append(item_data)
+
+ widget_data = {"type": "ListView", "children": items}
+ return ListView.model_validate(widget_data)
diff --git a/examples/backend/src/backend/api/support.py b/examples/backend/src/backend/api/support.py
index 1c1ec4f..0f638ef 100644
--- a/examples/backend/src/backend/api/support.py
+++ b/examples/backend/src/backend/api/support.py
@@ -24,8 +24,6 @@ async def chatkit_endpoint(
request_server: FromDishka[AirlineSupportChatKitServer],
) -> Response:
payload = await request.body()
- print("Received payload:", payload)
-
user_id = "ksachdeva-1"
result = await request_server.process(
@@ -33,8 +31,6 @@ async def chatkit_endpoint(
ADKContext(user_id=user_id, app_name=settings.AIRLINE_APP_NAME),
)
- print(result)
-
if isinstance(result, StreamingResult):
return StreamingResponse(result, media_type="text/event-stream")
if hasattr(result, "json"):
@@ -53,13 +49,12 @@ async def customer_snapshot(
settings: FromDishka[Settings],
thread_id: str | None = Query(None, description="ChatKit thread identifier"),
) -> dict[str, Any]:
- # hard coded user id for now
- # as not doing an authentication
+ """Return customer profile for a given thread, or default if no thread."""
user_id = "ksachdeva-1"
if not thread_id:
- context = AirlineAgentContext.create_initial_context().model_dump()
- return {"customer": context["customer_profile"]}
+ initial_context = AirlineAgentContext.create_initial_context()
+ return {"customer": initial_context.customer_profile.to_dict()}
session = await session_service.get_session(
app_name=settings.AIRLINE_APP_NAME,
@@ -68,11 +63,14 @@ async def customer_snapshot(
)
if not session:
- raise ValueError(f"Session with id {thread_id} not found")
+ initial_context = AirlineAgentContext.create_initial_context()
+ return {"customer": initial_context.customer_profile.to_dict()}
- context: dict[str, Any] | None = session.state.get("context", None)
+ context_dict: dict[str, Any] | None = session.state.get("context", None)
- if context is None:
- raise ValueError(f"No context found in session {thread_id}")
+ if context_dict is None:
+ initial_context = AirlineAgentContext.create_initial_context()
+ return {"customer": initial_context.customer_profile.to_dict()}
- return {"customer": context["customer_profile"]}
+ ctx = AirlineAgentContext.model_validate(context_dict)
+ return {"customer": ctx.customer_profile.to_dict()}
diff --git a/examples/frontend/package-lock.json b/examples/frontend/package-lock.json
index 192e12f..b7e44fd 100644
--- a/examples/frontend/package-lock.json
+++ b/examples/frontend/package-lock.json
@@ -8,7 +8,7 @@
"name": "customer-support-frontend",
"version": "0.1.0",
"dependencies": {
- "@openai/chatkit-react": "^1.4.0",
+ "@openai/chatkit-react": "^1.4.3",
"@xyflow/react": "^12.7.0",
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
@@ -794,18 +794,18 @@
}
},
"node_modules/@openai/chatkit": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/@openai/chatkit/-/chatkit-1.4.0.tgz",
- "integrity": "sha512-MqNaK8EHKO5bf0flekY/JNjX55eh7Ae0Yt8Y3T9HwSN0E1ULkk5PYn2bM7Qllltb8FnO5nXNc6bD9yqrIfNlQA==",
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@openai/chatkit/-/chatkit-1.5.0.tgz",
+ "integrity": "sha512-F9G91ZNq7bmDsoP4ToDWmv1FMZx2bLZ6WHf+YsKvQ8uw+PBRdkaRGG4WlalnnRl0ekQLhVB1kNjsWcQJpzalUg==",
"license": "MIT"
},
"node_modules/@openai/chatkit-react": {
- "version": "1.4.2",
- "resolved": "https://registry.npmjs.org/@openai/chatkit-react/-/chatkit-react-1.4.2.tgz",
- "integrity": "sha512-qEA5Jw9+Fnc6H7X5RmhDUR7+bEW31D5HWN2yGSgiwBVtfML74uIlNuJxCfD39fXWDbHneDR6QJJtzMhalWD7Yg==",
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/@openai/chatkit-react/-/chatkit-react-1.4.3.tgz",
+ "integrity": "sha512-Y/isbMh2CSFTtg2oL7FbmsfAnGGS6PDESQkjH9AJ42/TNGxAF53FIZM19PmrhdUq2QLkEzAf6adQ+pu8GO0Yhw==",
"license": "MIT",
"dependencies": {
- "@openai/chatkit": "1.4.0"
+ "@openai/chatkit": "1.5.0"
},
"peerDependencies": {
"react": ">=18",
diff --git a/examples/frontend/package.json b/examples/frontend/package.json
index 60a2541..85a0d2f 100644
--- a/examples/frontend/package.json
+++ b/examples/frontend/package.json
@@ -14,7 +14,7 @@
"npm": ">=9"
},
"dependencies": {
- "@openai/chatkit-react": "^1.4.0",
+ "@openai/chatkit-react": "^1.4.3",
"@xyflow/react": "^12.7.0",
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
diff --git a/examples/frontend/src/assets/customer-support/paris.png b/examples/frontend/src/assets/customer-support/paris.png
new file mode 100644
index 0000000..6c45b58
Binary files /dev/null and b/examples/frontend/src/assets/customer-support/paris.png differ
diff --git a/examples/frontend/src/assets/customer-support/santorini.png b/examples/frontend/src/assets/customer-support/santorini.png
new file mode 100644
index 0000000..8ddc12b
Binary files /dev/null and b/examples/frontend/src/assets/customer-support/santorini.png differ
diff --git a/examples/frontend/src/assets/customer-support/tokyo.png b/examples/frontend/src/assets/customer-support/tokyo.png
new file mode 100644
index 0000000..c8bfde2
Binary files /dev/null and b/examples/frontend/src/assets/customer-support/tokyo.png differ
diff --git a/examples/frontend/src/components/ThemeToggle.tsx b/examples/frontend/src/components/ThemeToggle.tsx
index c000646..75403f5 100644
--- a/examples/frontend/src/components/ThemeToggle.tsx
+++ b/examples/frontend/src/components/ThemeToggle.tsx
@@ -1,13 +1,24 @@
import clsx from "clsx";
import { Moon, Sun } from "lucide-react";
import { useAppStore } from "../store/useAppStore";
+import type { ColorScheme } from "../hooks/useColorScheme";
+
+type ThemeToggleProps = {
+ value?: ColorScheme;
+ onChange?: (scheme: ColorScheme) => void;
+};
const buttonBase =
"inline-flex h-9 w-9 items-center justify-center rounded-full text-[0.7rem] transition-colors duration-200";
-export function ThemeToggle() {
- const scheme = useAppStore((state) => state.scheme);
- const setScheme = useAppStore((state) => state.setScheme);
+export function ThemeToggle({ value, onChange }: ThemeToggleProps) {
+ const storeScheme = useAppStore((state) => state.scheme);
+ const storeSetScheme = useAppStore((state) => state.setScheme);
+
+ // Use props if provided, otherwise fall back to store
+ const scheme = value ?? storeScheme;
+ const setScheme = onChange ?? storeSetScheme;
+
return (