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
49 changes: 37 additions & 12 deletions examples/hotel_receptionist/book_room.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
RoomExtra,
RoomType,
Unavailable,
describe_room_options,
speak_usd,
)
from persona import COMMON_INSTRUCTIONS
Expand All @@ -24,11 +25,13 @@
from livekit.agents.voice.agent import AgentTask

_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.
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 a detail this booking already holds. The tool returns tell you what it holds; they are the record, not your memory of the conversation.

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.
This flow holds exactly ONE room - there is one stay and one room choice, and calling set_stay or choose_room again REPLACES the values, it never adds a second room. When the caller wants more than one room (a family booking two rooms, different checkout days), pick one room, carry it through confirm_booking, and let the receptionist start a fresh flow for the next room once this one has completed - never try to capture two stays in the same flow.

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.
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. When the conversation covered more than one room, take only the values the caller gave for the room this flow is holding; a value they gave for a different room is not this room's, and where they haven't given one for this room, ask. That scan covers the stay and the room; it is not a licence to skip a dialog.

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 before calling choose_room. Each option's line says what to do with its view: ask which one only when that line lists two, and state the single view as a fact when it lists one - offering a view a type doesn't have hands the caller a choice that isn't real. 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_booking() now", call it - the call IS the next action, no filler turn.

Expand Down Expand Up @@ -98,11 +101,31 @@ def _status(self) -> str:
total = (
f"total {speak_usd(self._quoted_total)} including tax, " if self._quoted_total else ""
)
dates = (
f"{self._check_in:%A, %B %-d} to {self._check_out:%A, %B %-d}"
if self._check_in and self._check_out
else "dates"
)
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."
f"({dates} - use these exact weekday names - room and extras, {total}card "
f"ending {self._card_last4}) and call confirm_booking() the moment the "
"caller agrees. Quote ONLY this total - never compute your own."
)

def _not_booked(self) -> str:
# The refusal path gets its own text; _status() is for the success and
# progress returns only. _status() on its own reads as "here is the next
# step" - nothing in it says no booking happened, and by this point in a
# multi-room call the model has usually already spoken a real "HTL-..."
# code, so it has a template to invent one from. The is_error flag is no
# help here: the OpenAI provider format drops it and sends a tool message
# whose content is this string and nothing else, so the text itself has to
# carry the outcome. Still no missing-field list, for the reason above.
return (
"NOT booked - no reservation was created and no confirmation code exists. "
"Do not tell the caller they're booked and never speak a confirmation code. "
f"Where the booking actually stands: {self._status()}"
)

@function_tool()
Expand Down Expand Up @@ -139,12 +162,14 @@ async def set_stay(
available_types = {a.type for a in avail}
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, "
f"{' or '.join(a.views)} view{'s' if len(a.views) > 1 else ''})"
for a in avail
# Weekdays come from the computed dates, never from model arithmetic - a
# hallucinated "Friday the thirteenth" survives every read-back otherwise.
return (
f"stay recorded ({check_in:%A} {check_in} to {check_out:%A} {check_out}, "
f"{guests} guests; those weekday names are computed - use them, never your "
f"own day-counting)\noptions (the views on a type's line are the only views "
f"that type has):\n{describe_room_options(avail)}\n{self._status()}"
)
return f"stay recorded ({check_in} to {check_out}, {guests} guests); options: {options} | {self._status()}"

@function_tool()
async def choose_room(
Expand Down Expand Up @@ -274,7 +299,7 @@ async def confirm_booking(self) -> str | None:
and phone
and card_last4
):
raise ToolError(self._status())
raise ToolError(self._not_booked())
try:
booking = await self._db.book_room(
room_type=room_type,
Expand Down
20 changes: 20 additions & 0 deletions examples/hotel_receptionist/hotel_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,26 @@ class RoomTypeAvailability:
views: list[str]


def describe_room_options(avail: list[RoomTypeAvailability]) -> str:
"""The available types as one line each, with the view verdict on the type's own line.

A single pipe-delimited line ("queen 2beds (city or garden views) | ... | double
queen (ocean view)") reads as one blob: the model binds a neighboring type's view
to the type the caller picked and offers a garden-view double queen, which has
never existed. One row per type keeps the binding local, and spelling out whether
the view is a question or a fact stops the offer from becoming a false choice.
"""
return "\n".join(
f"- {a.type.replace('_', ' ')}: {speak_usd(a.nightly_rate)}/night, "
+ (
f"{' or '.join(a.views)} view - ask which of those two they want"
if len(a.views) > 1
else f"{a.views[0]} view only - say so as a fact, there is no view to ask about"
)
for a in avail
)


@dataclass
class RoomBooking:
id: int
Expand Down