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
1 change: 1 addition & 0 deletions examples/hotel_receptionist/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ fake_data/hotel.db
fake_data/hotel.db-shm
fake_data/hotel.db-wal
__pycache__/
.env.local
566 changes: 464 additions & 102 deletions examples/hotel_receptionist/agent.py

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion examples/hotel_receptionist/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@
"restaurant_reservations",
"hotel_followups",
"hotel_disputes",
"group_inquiries",
"guest_messages",
"wakeup_calls",
"tour_bookings",
"flight_reconfirmations",
"airport_cars",
"emergency_dispatches",
"walk_arrangements",
)

# The only columns excluded from comparison, by reason:
Expand All @@ -51,12 +59,14 @@
"caller_note",
"notes",
"late_arrival_note",
"message",
"situation",
}
)

# Resolve FK surrogate -> stable attribute (correlated subquery, single table).
FK_RESOLVE: dict[tuple[str, str], str] = {
("hotel_bookings", "room_id"): "(SELECT type FROM hotel_rooms WHERE id = room_id) AS room_type",
("hotel_bookings", "room_id"): "(SELECT type || '/' || room_view FROM hotel_rooms WHERE id = room_id) AS room_type_view",
(
"restaurant_reservations",
"table_id",
Expand Down
21 changes: 12 additions & 9 deletions examples/hotel_receptionist/book_restaurant.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
from __future__ import annotations

from datetime import date, time
from typing import Annotated

from hotel_db import MAX_PARTY_SIZE, TODAY, HotelDB, RestaurantReservation, Unavailable, speak_time
from context import speech_only
from persona import COMMON_INSTRUCTIONS
from pydantic import Field

from livekit.agents import NOT_GIVEN, NotGivenOr, beta
from livekit.agents.llm import ChatContext
from livekit.agents.llm.tool_context import ToolError, ToolFlag, function_tool
from livekit.agents.voice.agent import AgentTask

Check failure on line 14 in examples/hotel_receptionist/book_restaurant.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (I001)

examples/hotel_receptionist/book_restaurant.py:1:1: I001 Import block is un-sorted or un-formatted help: Organize imports

_BOOK_RESTAURANT_INSTRUCTIONS = """\
You're handling a restaurant reservation from start to finish. Collect details in whatever order the caller offers them - don't follow a fixed script, and never re-ask something already given.

Before asking anything, scan the conversation so far. If date, party size, time, or special-request notes were already discussed, call the matching recording tools (set_party, choose_time) right away with those values - don't re-ask the caller for details they already gave.

Run set_party before choose_time - open slots depend on the date and party size. Before calling confirm, make sure you've collected the date, party, time, and the caller's name and phone.
Run set_party before choose_time - open slots depend on the date and party size. Before calling confirm_reservation, make sure you've collected the date, party, time, and the caller's name and phone - then read the reservation back in one short sentence (date, time, party size, name) and let the caller agree. confirm_reservation only fires once they've agreed to the read-back.

Each tool's return ends with a directive for the next action (e.g. "next: call open_phone_dialog"). Follow that directive immediately - don't narrate what the tool just did. When the directive says "call confirm() now", call it - the call IS the next action, no filler turn.
Each tool's return ends with a directive for the next action (e.g. "next: call open_phone_dialog"). Follow that directive immediately - don't narrate what the tool just did. When the directive says "call confirm_reservation() now", call it - the call IS the next action, no filler turn.

Never speak the same question twice in a row. If a field was just captured ("name recorded", "time recorded"), it is DONE - asking for it again stalls the call; the only valid next move is the directive in the last tool return.
"""


class BookRestaurantTask(AgentTask[RestaurantReservation]):
"""Restaurant booking as one focused task, mirroring BookRoomTask: `set_party`
/ `choose_time` handle the date <-> slot-availability coupling, the
`open_*_dialog` tools capture each detail the moment it's offered (stored on
the draft so a later hiccup never re-asks it), and `confirm()` books the
the draft so a later hiccup never re-asks it), and `confirm_reservation()` books the
table."""

def __init__(self, db: HotelDB, *, chat_ctx: NotGivenOr[ChatContext] = NOT_GIVEN) -> None:
Expand Down Expand Up @@ -65,13 +68,13 @@
return "party and time captured - next: call open_name_dialog"
if not self._phone:
return "name captured - next: call open_phone_dialog"
return "all required details captured - call confirm() now to finalize the reservation"
return "all required details captured - call confirm_reservation() now to finalize the reservation"

@function_tool()
async def set_party(
self, on_date: date, party_size: Annotated[int, Field(ge=1, le=MAX_PARTY_SIZE)]
) -> str:
"""Record the date + party size; returns the open time slots for them.
"""Record the date + party size. The return lists the open time slots - offer them to the caller and let them pick; don't choose a slot yourself.

Args:
on_date: Reservation date in ISO YYYY-MM-DD format (e.g. "2026-01-20").
Expand Down Expand Up @@ -99,7 +102,7 @@
"""Record the chosen time slot and any special request.

Args:
at_time: Slot time - one of the open slots returned by set_party.
at_time: The slot the CALLER picked, from the open times set_party returned.
notes: Optional special request (allergy, anniversary...), or null.
"""
if self._date is None:
Expand All @@ -118,7 +121,7 @@
r = await beta.workflows.GetNameTask(
first_name=True,
last_name=True,
chat_ctx=self.chat_ctx,
chat_ctx=speech_only(self.chat_ctx),
extra_instructions=COMMON_INSTRUCTIONS,
)
self._first_name, self._last_name = r.first_name or "", r.last_name or ""
Expand All @@ -128,13 +131,13 @@
async def open_phone_dialog(self) -> str:
"""Open the phone dialog. It collects the guest's phone number (read back and confirmed) from the caller."""
r = await beta.workflows.GetPhoneNumberTask(
chat_ctx=self.chat_ctx, extra_instructions=COMMON_INSTRUCTIONS
chat_ctx=speech_only(self.chat_ctx), extra_instructions=COMMON_INSTRUCTIONS
)
self._phone = r.phone_number
return f"phone recorded: {self._phone} | {self._status()}"

@function_tool()
async def confirm(self) -> str | None:
async def confirm_reservation(self) -> str | None:
"""Finalize once the date, party, time, and the caller's details are all captured: book
the table."""
on_date, party_size, at_time = self._date, self._party_size, self._time
Expand Down
91 changes: 72 additions & 19 deletions examples/hotel_receptionist/book_room.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,42 @@
from __future__ import annotations

from datetime import date
from typing import Annotated

from hotel_db import (
MAX_PARTY_SIZE,
TODAY,
HotelDB,
RoomBooking,
RoomExtra,
RoomType,
Unavailable,
speak_usd,
)
from context import speech_only
from get_card import GetCardTask
from persona import COMMON_INSTRUCTIONS
from pydantic import Field

from livekit.agents import NOT_GIVEN, NotGivenOr, beta
from livekit.agents.llm import ChatContext
from livekit.agents.llm.tool_context import ToolError, ToolFlag, function_tool
from livekit.agents.voice.agent import AgentTask

Check failure on line 24 in examples/hotel_receptionist/book_room.py

View workflow job for this annotation

GitHub Actions / ruff

ruff (I001)

examples/hotel_receptionist/book_room.py:1:1: I001 Import block is un-sorted or un-formatted help: Organize imports

_BOOK_ROOM_INSTRUCTIONS = """\
You're handling a room booking from start to finish. Collect details in whatever order the caller offers them - don't follow a fixed script, and never re-ask something already given.

Before asking anything, scan the conversation so far. If dates, room type, party size, or smoking preference were already discussed, call the matching recording tools (set_stay, choose_room) right away with those values - don't re-ask the caller for details they already gave.

Run set_stay before choose_room - available rooms depend on the dates. Before calling confirm, make sure you've collected the stay, the room choice, plus the caller's name, email, phone, and card.
Run set_stay before choose_room - available rooms depend on the dates. set_stay's options are for YOU to offer, not to act on: name the room types to the caller and let them pick (ask about any preference they've hinted at, like a view) before calling choose_room. Before calling confirm_booking, make sure you've collected the stay, the room choice, plus the caller's name, email, phone, and card - then read the whole booking back in one short sentence (dates, room type and extras, total, card last four) and let the caller say "go ahead" or correct something. confirm_booking only fires once they've agreed to the read-back.

Each tool's return ends with a directive for the next action (e.g. "next: call open_email_dialog"). Follow that directive immediately - don't narrate what the tool just did. When the directive says "call confirm() now", call it - the call IS the next action, no filler turn.
Each tool's return ends with a directive for the next action (e.g. "next: call open_email_dialog"). Follow that directive immediately - don't narrate what the tool just did. When the directive says "call confirm_booking() now", call it - the call IS the next action, no filler turn.

If the room sells out at the last second, just pick another - everything else stays captured.

A booking is not complete unless "confirm_booking" is called. Bookings are only valid once you call "confirm_booking."

Never speak the same question twice in a row. If a field was just captured ("name recorded", "email recorded"), it is DONE - asking for it again stalls the call; the only valid next move is the directive in the last tool return.
"""


Expand All @@ -39,14 +45,15 @@
handle the part with real coupling - dates <-> availability <-> room - and the
`open_*_dialog` tools capture each independent detail the moment it's
offered, storing it on the draft so a later hiccup never re-asks it.
`confirm()` takes the card, writes the booking, and completes with it."""
`confirm_booking()` takes the card, writes the booking, and completes with it."""

def __init__(self, db: HotelDB, *, chat_ctx: NotGivenOr[ChatContext] = NOT_GIVEN) -> None:
self._db = db
self._check_in: date | None = None
self._check_out: date | None = None
self._guests: int | None = None
self._room_type: RoomType | None = None
self._view: str | None = None
self._extras: list[RoomExtra] = []
# Smoking defaults to non-smoking: it's industry-standard opt-in, not
# a value the caller has to volunteer. choose_room flips it when the
Expand All @@ -57,6 +64,7 @@
self._email: str | None = None
self._phone: str | None = None
self._card_last4: str | None = None
self._quoted_total: int | None = None
super().__init__(
instructions=f"{COMMON_INSTRUCTIONS}\n\n{_BOOK_ROOM_INSTRUCTIONS}",
chat_ctx=chat_ctx,
Expand Down Expand Up @@ -87,7 +95,13 @@
return "email captured - next: call open_phone_dialog"
if not self._card_last4:
return "phone captured - next: call open_credit_card_dialog"
return "all required details captured - call confirm() now to finalize the booking"
total = f"total {speak_usd(self._quoted_total)} including tax, " if self._quoted_total else ""
return (
"all required details captured - read the booking back in one sentence "
f"(dates, room and extras, {total}card ending {self._card_last4}) and call "
"confirm_booking() the moment the caller agrees. Quote ONLY this total - "
"never compute your own."
)

@function_tool()
async def set_stay(
Expand All @@ -96,7 +110,7 @@
check_out: date,
guests: Annotated[int, Field(ge=1, le=MAX_PARTY_SIZE)],
) -> str:
"""Record the stay dates + party size; returns each available room type with rate and view, so you can answer "how much?" / "what's the cheapest?" without leaving the flow.
"""Record the stay dates + party size. The return lists each available room type with rate and view - that is reference material for answering "how much?" / "what's the cheapest?" and for OFFERING the choice to the caller. Never act on it by picking a type yourself; the next step after this tool is a question, not another tool call.

Args:
check_in: Check-in date in ISO YYYY-MM-DD format (e.g. "2026-01-20").
Expand Down Expand Up @@ -124,21 +138,29 @@
if self._room_type and self._room_type not in available_types:
self._room_type = None # prior choice no longer fits the new dates
options = " | ".join(
f"{a.type.replace('_', ' ')} ({speak_usd(a.nightly_rate)}/night, {a.sample_view})"
f"{a.type.replace('_', ' ')} ({speak_usd(a.nightly_rate)}/night, "
f"{' or '.join(a.views)} view{'s' if len(a.views) > 1 else ''})"
for a in avail
)
return f"stay recorded ({check_in} to {check_out}, {guests} guests); options: {options} | {self._status()}"

@function_tool()
async def choose_room(
self, room_type: RoomType, extras: list[RoomExtra], smoking_room: bool = False
self,
room_type: RoomType,
extras: list[RoomExtra],
smoking_room: bool = False,
view: str | None = None,
) -> str:
"""Record the chosen room type, extras, and smoking preference.
"""Record the room type the caller chose from the options set_stay returned, plus any view they asked for.

Call ONLY after the caller has named a room type (a stated view narrows WHICH room of that type they get - it doesn't pick the type). If the caller asks for a view, pass it here; if that view isn't available for the type, this errors with where the view IS available - relay that and let them choose. Never guess a type from a preference.

Args:
room_type: One of the room types returned by set_stay.
room_type: The room type exactly as the caller chose it.
extras: Any of breakfast / valet / late_checkout / pets; empty list if none.
smoking_room: True if the caller wants a smoking-permitted room.
view: The view the caller asked for (city / garden / ocean), ONLY if they stated one - omit entirely otherwise.
"""
if self._check_in is None or self._check_out is None or self._guests is None:
raise ToolError("stay dates and guest count not yet recorded")
Expand All @@ -155,19 +177,51 @@
kind = "smoking " if smoking_room else ""
offer = ", ".join(sorted(a.type for a in avail)) or "nothing for those dates"
raise ToolError(f"no {kind}{room_type} available; offer one of: {offer}")
# Models sometimes send placeholder strings for optional args they
# should omit - normalize those to "no view preference".
if view is not None:
view = view.strip().casefold()
if view in ("", "null", "none", "any", "no preference", "unspecified"):
view = None
if view is not None and view not in chosen.views:
where = ", ".join(
f"{a.type.replace('_', ' ')} ({' or '.join(a.views)})" for a in avail
)
raise ToolError(
f"no {view}-view {room_type.replace('_', ' ')} for those dates - "
f"the views by room type are: {where}. Tell the caller and let them choose."
)
self._room_type = room_type
self._view = view
self._extras = list(extras)
self._smoking = smoking_room
# The exact total (with tax) for the room that will be booked - quoted
# here so the read-back uses the real number, never per-night arithmetic.
self._quoted_total = await self._db.peek_stay_total(
room_type=room_type,
smoking=smoking_room,
guests=self._guests,
check_in=self._check_in,
check_out=self._check_out,
view=view,
extras=extras,
)
view_part = f" with a {view} view" if view else ""
extras_part = f", extras: {', '.join(extras)}" if extras else ""
return f"room recorded: {room_type.replace('_', ' ')}{extras_part} | {self._status()}"
total_part = (
f"; total for the stay {speak_usd(self._quoted_total)} including tax"
if self._quoted_total
else ""
)
return f"room recorded: {room_type.replace('_', ' ')}{view_part}{extras_part}{total_part} | {self._status()}"

@function_tool()
async def open_name_dialog(self) -> str:
"""Open the name dialog. It collects the guest's first and last name (read back and confirmed) from the caller."""
r = await beta.workflows.GetNameTask(
first_name=True,
last_name=True,
chat_ctx=self.chat_ctx,
chat_ctx=speech_only(self.chat_ctx),
extra_instructions=COMMON_INSTRUCTIONS,
)
self._first_name, self._last_name = r.first_name or "", r.last_name or ""
Expand All @@ -177,7 +231,7 @@
async def open_email_dialog(self) -> str:
"""Open the email dialog. It collects the guest's email address (read back and confirmed) from the caller."""
r = await beta.workflows.GetEmailTask(
chat_ctx=self.chat_ctx, extra_instructions=COMMON_INSTRUCTIONS
chat_ctx=speech_only(self.chat_ctx), extra_instructions=COMMON_INSTRUCTIONS
)
self._email = r.email_address
return f"email recorded: {self._email} | {self._status()}"
Expand All @@ -186,23 +240,21 @@
async def open_phone_dialog(self) -> str:
"""Open the phone dialog. It collects the guest's phone number (read back and confirmed) from the caller."""
r = await beta.workflows.GetPhoneNumberTask(
chat_ctx=self.chat_ctx, extra_instructions=COMMON_INSTRUCTIONS
chat_ctx=speech_only(self.chat_ctx), extra_instructions=COMMON_INSTRUCTIONS
)
self._phone = r.phone_number
return f"phone recorded: {self._phone} | {self._status()}"

@function_tool()
async def open_credit_card_dialog(self) -> str:
"""Open the credit-card dialog. It collects the card number, expiry, security code, and cardholder name from the caller one at a time."""
card = await beta.workflows.GetCreditCardTask(
chat_ctx=self.chat_ctx, extra_instructions=COMMON_INSTRUCTIONS
)
"""Open the credit-card dialog. It collects the card number, expiry, security code, and cardholder name from the caller in one focused step."""
card = await GetCardTask(chat_ctx=speech_only(self.chat_ctx))
self._card_last4 = card.card_number[-4:]
return f"card recorded (ending {self._card_last4}) | {self._status()}"

@function_tool()
async def confirm(self) -> str | None:
"""Finalize the booking. All details (stay, room, name, email, phone, card) must already be captured."""
async def confirm_booking(self) -> str | None:
"""Finalize the booking and charge the card. Call ONLY after every detail is captured AND the caller has agreed to your read-back (dates, room and extras, total, card last four). Returns the final confirmation - relay it to the caller; the booking flow ends with this call."""
check_in, check_out, guests, room_type = (
self._check_in,
self._check_out,
Expand All @@ -227,6 +279,7 @@
booking = await self._db.book_room(
room_type=room_type,
smoking=self._smoking,
view=self._view,
guests=guests,
check_in=check_in,
check_out=check_out,
Expand Down
18 changes: 18 additions & 0 deletions examples/hotel_receptionist/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Chat-context hygiene for task handoffs."""

from __future__ import annotations

from livekit.agents import llm


def speech_only(chat_ctx: llm.ChatContext) -> llm.ChatContext:
"""The conversation without tool mechanics, for handing to a sub-task.

Tool calls in the history are scoped to the agent that made them. A
sub-task whose schema doesn't include those tools will still see them
being called and imitate them - smaller models invent similar-sounding
tool names instead of using the ones they actually have. Hand every
sub-task the words only; anything that matters from a tool result was
spoken to the caller and survives in the messages.
"""
return chat_ctx.copy(exclude_function_call=True, exclude_handoff=True)
Loading
Loading