Skip to content
Merged
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
864 changes: 22 additions & 842 deletions examples/hotel_receptionist/agent.py
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions examples/hotel_receptionist/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@
"guest_messages",
"wakeup_calls",
"tour_bookings",
"spa_bookings",
"business_center_bookings",
"florist_orders",
"emails_sent",
"transfer_calls",
"waitlist",
"do_not_disturb",
"flight_reconfirmations",
"airport_cars",
"emergency_dispatches",
Expand All @@ -46,10 +53,6 @@
"code",
"case_number",
"booking_code",
# booking math: tool-computed by compute_invoice from type + dates +
# extras, which are all already compared — so asserting it would grade the
# mock's arithmetic, not the agent. (It also varies with which room of a
# type book_room picks, since seed rates differ within a type.)
"total",
"subtotal",
"taxes",
Expand Down
21 changes: 14 additions & 7 deletions examples/hotel_receptionist/book_restaurant.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@ async def on_enter(self) -> None:
)

def _status(self) -> str:
# See book_room.py - status describes the next *action*, not a list
# of missing field names. A "still need: phone" string gets parroted
# as "What's your phone number?".
if self._date is None:
return "no party yet - ask the caller for date and party size, then call set_party"
if self._time is None:
Expand All @@ -71,17 +68,27 @@ def _status(self) -> str:
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:
async def set_party(self, on_date: date, party_size: Annotated[int, Field(ge=1)]) -> str:
"""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").
party_size: Number of guests (must be >= 1; ask the caller if not specified).
party_size: Number of guests, exactly as the caller stated it - never shrink it to fit; if it's too big to seat, that's handled below.
"""
if on_date < TODAY:
raise ToolError("the date can't be in the past")
if party_size > MAX_PARTY_SIZE:
# The largest table seats MAX_PARTY_SIZE; a bigger party (and the
# private-room / set-menu asks that come with it) is the restaurant's
# to arrange, not a desk table booking. Bail out of this flow and
# transfer rather than quietly booking a too-small table.
raise ToolError(
f"{party_size} guests is beyond a normal table - we seat up to {MAX_PARTY_SIZE}. "
"Don't book it here and don't reduce the number to fit: this is a large-party / "
"private-dining request the restaurant handles directly. Call give_up, then tell "
"the caller you'll put them on hold to connect them and, once they agree, "
"transfer_call(destination='restaurant') with a one-line summary."
)

slots = await self._db.list_restaurant_availability(on_date=on_date, party_size=party_size)
open_times = {s.time for s in slots if s.available_table_ids}
Expand Down
42 changes: 42 additions & 0 deletions examples/hotel_receptionist/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

import os
import sys
from dataclasses import dataclass, field

sys.path.append(os.path.dirname(os.path.abspath(__file__)))

from hotel_db import HotelDB, RoomBooking

from livekit.agents import llm


@dataclass
class Userdata:
db: HotelDB
# Departments already transferred to this call - guards against a duplicate transfer
# row when the agent re-calls transfer_call after the caller's reaction.
transferred_to: set[str] = field(default_factory=set)
# The refund outcome from the last room cancellation, and the caller-turn count when it
# happened - so a re-invoked cancel (no caller input since) re-surfaces that answer
# instead of re-verifying into a confusing "already cancelled" dead end.
last_cancel_message: str = ""
caller_turns_at_last_cancel: int = -1
verified_booking: RoomBooking | None = None
# The most recent completed room booking, and the caller-turn count at the moment
# it completed - together they catch a model that re-runs the booking flow with no
# caller input since, which would silently double-book the guest.
last_room_booking: RoomBooking | None = None
caller_turns_at_last_booking: int = 0


def _speak_code(code: str) -> str:
# Spell character by character, with "-" spoken as the single word "dash" -
# NOT spelled D, A, S, H (that reads as four more code characters).
return ", ".join("dash" if c == "-" else c for c in code.upper())


def _count_caller_turns(chat_ctx: llm.ChatContext) -> int:
"""How many times the caller has spoken so far - the signal for whether a
booking flow was actually driven by the caller or silently re-run by the model."""
return sum(1 for it in chat_ctx.items if it.type == "message" and it.role == "user")
27 changes: 27 additions & 0 deletions examples/hotel_receptionist/fake_data/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,24 @@
("Marcus", "Johnson", "m.johnson@outlook.com", "+1 628 555 0199", "CD34", "205", 9, 3, 4, ["breakfast", "valet"], "1881", "confirmed"),
# Smoking room (203 is the only smoking-permitted room)
("Mei", "Chen", "mei.chen@gmail.com", "+1 415 555 0222", "MN42", "203", 14, 2, 2, ["breakfast"], "4477", "confirmed"),
# --- Completely sold out one night (offset 25 = Fri Jul 3, July-4th weekend) ---
# Every one of the 13 rooms is taken for this single night, so a fresh
# booking inquiry for that date honestly comes up empty: the "we're full,
# politely deny the walk-in" scenario. One-nighters (nights=1) so the
# block doesn't bleed into adjacent dates or other scenarios.
("Owen", "Carver", "owen.carver@gmail.com", "+1 415 555 0501", "SO01", "201", 25, 1, 2, [], "1101", "confirmed"),
("Bianca", "Ross", "bianca.ross@gmail.com", "+1 415 555 0502", "SO02", "202", 25, 1, 2, [], "1102", "confirmed"),
("Caleb", "Nguyen", "caleb.nguyen@gmail.com", "+1 415 555 0503", "SO03", "203", 25, 1, 2, [], "1103", "confirmed"),
("Delia", "Brooks", "delia.brooks@gmail.com", "+1 415 555 0504", "SO04", "204", 25, 1, 3, [], "1104", "confirmed"),
("Ezra", "Flynn", "ezra.flynn@gmail.com", "+1 415 555 0505", "SO05", "205", 25, 1, 3, [], "1105", "confirmed"),
("Farah", "Haddad", "farah.haddad@gmail.com", "+1 415 555 0506", "SO06", "206", 25, 1, 4, [], "1106", "confirmed"),
("Gideon", "Park", "gideon.park@gmail.com", "+1 415 555 0507", "SO07", "301", 25, 1, 2, [], "1107", "confirmed"),
("Helena", "Cruz", "helena.cruz@gmail.com", "+1 415 555 0508", "SO08", "302", 25, 1, 2, [], "1108", "confirmed"),
("Ivan", "Sokolov", "ivan.sokolov@gmail.com", "+1 415 555 0509", "SO09", "303", 25, 1, 3, [], "1109", "confirmed"),
("Jana", "Novak", "jana.novak@gmail.com", "+1 415 555 0510", "SO10", "304", 25, 1, 4, [], "1110", "confirmed"),
("Kofi", "Mensah", "kofi.mensah@gmail.com", "+1 415 555 0511", "SO11", "401", 25, 1, 4, [], "1111", "confirmed"),
("Lara", "Conti", "lara.conti@gmail.com", "+1 415 555 0512", "SO12", "402", 25, 1, 2, [], "1112", "confirmed"),
("Mateo", "Rivas", "mateo.rivas@gmail.com", "+1 415 555 0513", "SO13", "PH", 25, 1, 5, [], "1113", "confirmed"),
# Departed (last week / weeks ago) - source of disputes + invoice lookups
("Daniel", "Lee", "daniel.lee@gmail.com", "+1 415 555 0104", "GH78", "302", -6, 2, 2, ["late_checkout"], "9999", "confirmed"),
("Olivia", "Brandt", "olivia.brandt@me.com", "+1 415 555 0288", "QT55", "204", -10, 3, 2, ["breakfast"], "6677", "confirmed"),
Expand Down Expand Up @@ -146,6 +164,11 @@
# Open / unresolved
("DSP-2H6T", "HTL-ZP19", "Room service", 8800, "room_service_restaurant", "Charged for a dinner they didn't order.", "escalated_to_manager", 0, "open"),
]
# (last_name, preferences) - read-only guest history for returning-guest personalization.
GUEST_HISTORY = [
("Lee", "Prefers a high, quiet floor away from the elevator, and feather-free "
"(hypoallergenic) pillows. Had a noise complaint on a previous stay."),
]
# fmt: on


Expand All @@ -161,6 +184,10 @@ def populate(db: HotelDB, today: date) -> None:
"INSERT INTO restaurant_tables (label, capacity, location, description) VALUES (?,?,?,?)",
TABLES,
)
conn.executemany(
"INSERT INTO guest_history (last_name, preferences) VALUES (?,?)",
GUEST_HISTORY,
)

for (
first,
Expand Down
Loading
Loading