-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathbook_restaurant.py
More file actions
172 lines (148 loc) ยท 8.6 KB
/
Copy pathbook_restaurant.py
File metadata and controls
172 lines (148 loc) ยท 8.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
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
_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_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_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_reservation()` books the
table."""
def __init__(self, db: HotelDB, *, chat_ctx: NotGivenOr[ChatContext] = NOT_GIVEN) -> None:
self._db = db
self._date: date | None = None
self._party_size: int | None = None
self._time: time | None = None
self._notes: str | None = None
self._open_times: set[time] = set()
self._first_name: str | None = None
self._last_name: str | None = None
self._phone: str | None = None
super().__init__(
instructions=f"{COMMON_INSTRUCTIONS}\n\n{_BOOK_RESTAURANT_INSTRUCTIONS}",
chat_ctx=chat_ctx,
)
async def on_enter(self) -> None:
self.session.generate_reply(
instructions=(
"Help the caller book a table. Record anything they've already mentioned - date, "
"party size, or time - then ask only for what's still missing."
)
)
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:
return "party captured - ask which time slot, then call choose_time"
if not (self._first_name and self._last_name):
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_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. 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).
"""
if on_date < TODAY:
raise ToolError("the date can't be in the past")
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}
if not open_times:
# Same reasoning as BookRoomTask.set_stay: don't persist a
# fully-booked date - the caller needs to pick another.
return f"fully booked on {on_date.strftime('%A, %B %-d')} for {party_size} - date not recorded; ask for another date"
self._date, self._party_size = on_date, party_size
self._open_times = open_times
if self._time and self._time not in self._open_times:
self._time = None # prior slot no longer open for the new date/party
labels = ", ".join(speak_time(t) for t in sorted(self._open_times))
return f"party recorded ({on_date.strftime('%A, %B %-d')}, {party_size} guests); open times: {labels} | {self._status()}"
@function_tool()
async def choose_time(self, at_time: time, notes: str | None = None) -> str:
"""Record the chosen time slot and any special request.
Args:
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:
raise ToolError("date and party size not yet recorded")
if at_time not in self._open_times:
open_labels = ", ".join(speak_time(o) for o in sorted(self._open_times))
raise ToolError(f"{speak_time(at_time)} isn't open; offer one of: {open_labels}")
self._time = at_time
self._notes = notes
notes_part = f", notes: {notes}" if notes else ""
return f"time recorded: {speak_time(at_time)}{notes_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=speech_only(self.chat_ctx),
extra_instructions=COMMON_INSTRUCTIONS,
)
self._first_name, self._last_name = r.first_name or "", r.last_name or ""
return f"name recorded: {self._first_name} {self._last_name} | {self._status()}"
@function_tool()
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=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_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
first_name, phone = self._first_name, self._phone
if not (on_date and party_size and at_time and first_name and phone):
raise ToolError(self._status())
try:
reservation = await self._db.book_restaurant(
first_name=first_name,
last_name=self._last_name or "",
phone=phone,
party_size=party_size,
on_date=on_date,
at_time=at_time,
notes=self._notes,
)
except Unavailable:
self._time = None
return "That slot just filled up - pick another time; I've kept your details."
if not self.done():
self.complete(reservation)
return None
@function_tool(flags=ToolFlag.IGNORE_ON_ENTER)
async def give_up(self, reason: str) -> None:
"""Caller wants to abandon the reservation.
Args:
reason: short explanation.
"""
if not self.done():
self.complete(ToolError(f"reservation abandoned: {reason}"))