Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
27 changes: 23 additions & 4 deletions examples/hotel_receptionist/book_restaurant.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@
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.
"""

_RESTAURANT_PHONE_INSTRUCTIONS = """\
Caller cannot provide the phone number or does not have it handy: call
`decline_phone_number_capture` immediately. Do not say or imply that the table or
reservation is booked.
"""


class RestaurantReservationNotCreatedError(ToolError):
"""The restaurant flow ended before a reservation was written."""


class BookRestaurantTask(AgentTask[RestaurantReservation]):
"""Restaurant booking as one focused task, mirroring BookRoomTask: `set_party`
Expand Down Expand Up @@ -135,11 +145,20 @@ async def open_name_dialog(self) -> str:
return f"name recorded: {self._first_name} {self._last_name} | {self._status()}"

@function_tool()
async def open_phone_dialog(self) -> str:
async def open_phone_dialog(self) -> str | None:
"""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
)
try:
r = await beta.workflows.GetPhoneNumberTask(
chat_ctx=speech_only(self.chat_ctx),
extra_instructions=(f"{COMMON_INSTRUCTIONS}\n\n{_RESTAURANT_PHONE_INSTRUCTIONS}"),
)
except beta.workflows.PhoneNumberCaptureDeclinedError:
error = RestaurantReservationNotCreatedError(
"reservation not created: a phone number is required"
)
if not self.done():
self.complete(error)
return f"{error} | never tell the caller the table is reserved"
self._phone = r.phone_number
return f"phone recorded: {self._phone} | {self._status()}"

Expand Down
17 changes: 13 additions & 4 deletions examples/hotel_receptionist/tools_restaurant.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

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

from book_restaurant import BookRestaurantTask
from book_restaurant import BookRestaurantTask, RestaurantReservationNotCreatedError
from common import Userdata, _speak_code
from context import speech_only
from hotel_db import (
Expand Down Expand Up @@ -49,9 +49,18 @@ async def check_restaurant_availability(
@function_tool
async def start_restaurant_booking(self, ctx: RunContext[Userdata]) -> str | None:
"""Start the restaurant-reservation flow. Call it the moment the caller wants a table - the flow collects date, party size, time, name, and phone itself. Its return is the FINAL result of the reservation: relay it and move on - nothing further to confirm or call afterwards."""
reservation = await BookRestaurantTask(
db=ctx.userdata.db, chat_ctx=speech_only(self.chat_ctx)
)
try:
reservation = await BookRestaurantTask(
db=ctx.userdata.db, chat_ctx=speech_only(self.chat_ctx)
)
except RestaurantReservationNotCreatedError:
return (
"No reservation was created because a phone number is required. "
"| tell the caller the table is not reserved; do not use success wording. "
"If they ask to hold the table or make an exception without a number: say "
"no table is held and invite them to call back with one - do not offer to "
"connect or transfer them to the restaurant."
)
return (
f"You're set for {speak_time(reservation.time)} on "
f"{reservation.date.strftime('%A, %B %-d')} for "
Expand Down
7 changes: 6 additions & 1 deletion livekit-agents/livekit/agents/beta/workflows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
from .dtmf_inputs import GetDtmfResult, GetDtmfTask
from .email_address import GetEmailResult, GetEmailTask
from .name import GetNameResult, GetNameTask
from .phone_number import GetPhoneNumberResult, GetPhoneNumberTask
from .phone_number import (
GetPhoneNumberResult,
GetPhoneNumberTask,
PhoneNumberCaptureDeclinedError,
)
from .task_group import TaskCompletedEvent, TaskGroup, TaskGroupResult
from .utils import WorkflowInstructions
from .warm_transfer import WarmTransferResult, WarmTransferTask
Expand All @@ -25,6 +29,7 @@
"GetNameResult",
"GetPhoneNumberTask",
"GetPhoneNumberResult",
"PhoneNumberCaptureDeclinedError",
"TaskCompletedEvent",
"TaskGroup",
"TaskGroupResult",
Expand Down
12 changes: 11 additions & 1 deletion livekit-agents/livekit/agents/beta/workflows/phone_number.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ class GetPhoneNumberResult:
phone_number: str


class PhoneNumberCaptureDeclinedError(ToolError):
def __init__(self, reason: str) -> None:
super().__init__(f"couldn't get the phone number: {reason}")
self._reason = reason

@property
def reason(self) -> str:
return self._reason
Comment on lines +62 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New public error type ships without documentation

The newly exported error class for a declined phone number is added to the public workflows API without any docstring (PhoneNumberCaptureDeclinedError at livekit-agents/livekit/agents/beta/workflows/phone_number.py:62-69), so the generated API docs describe it with nothing.
Impact: Users of the public API see an undocumented error type in the reference docs and can't tell when it is raised.

Repository rule requiring docs for new public classes/methods

CONTRIBUTING.md states: "If writing new methods/enums/classes, document them. This project uses pdoc3 for automatic API documentation generation, and every new addition has to be properly documented." The class is exported in livekit-agents/livekit/agents/beta/workflows/__init__.py:7-11 and listed in __all__, so it is part of the public surface; neither the class nor its reason property carries a docstring. (RestaurantReservationNotCreatedError in the example does carry one.)

Suggested change
class PhoneNumberCaptureDeclinedError(ToolError):
def __init__(self, reason: str) -> None:
super().__init__(f"couldn't get the phone number: {reason}")
self._reason = reason
@property
def reason(self) -> str:
return self._reason
class PhoneNumberCaptureDeclinedError(ToolError):
"""Raised when the user explicitly declines to provide a phone number.
`GetPhoneNumberTask` completes with this error (instead of a generic
`ToolError`) so callers can branch on an explicit refusal.
"""
def __init__(self, reason: str) -> None:
super().__init__(f"couldn't get the phone number: {reason}")
self._reason = reason
@property
def reason(self) -> str:
"""The short explanation of why the user declined."""
return self._reason
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



class GetPhoneNumberTask(AgentTask[GetPhoneNumberResult]):
def __init__(
self,
Expand Down Expand Up @@ -179,7 +189,7 @@ async def decline_phone_number_capture(self, reason: str) -> None:
reason: A short explanation of why the user declined to provide the phone number
"""
if not self.done():
self.complete(ToolError(f"couldn't get the phone number: {reason}"))
self.complete(PhoneNumberCaptureDeclinedError(reason))

def _confirmation_required(self, ctx: RunContext) -> bool:
if is_given(self._require_confirmation):
Expand Down
82 changes: 82 additions & 0 deletions tests/test_phone_number_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any

import pytest

from livekit.agents import Agent, beta

pytestmark = pytest.mark.unit

_HOTEL_EXAMPLE = Path(__file__).parents[1] / "examples" / "hotel_receptionist"
sys.path.insert(0, str(_HOTEL_EXAMPLE))

import tools_restaurant # noqa: E402
from book_restaurant import ( # noqa: E402
BookRestaurantTask,
RestaurantReservationNotCreatedError,
)
from fake_data.seed import build_seed_bytes # noqa: E402
from hotel_db import TODAY, HotelDB # noqa: E402
from tools_restaurant import RestaurantToolsMixin # noqa: E402


class _DeclinedPhoneTask:
def __init__(self, **kwargs: Any) -> None:
pass

def __await__(self): # type: ignore[no-untyped-def]
async def _decline() -> None:
raise beta.workflows.PhoneNumberCaptureDeclinedError("caller declined")

return _decline().__await__()


class _FailedRestaurantTask:
def __init__(self, **kwargs: Any) -> None:
pass

def __await__(self): # type: ignore[no-untyped-def]
async def _fail() -> None:
raise RestaurantReservationNotCreatedError("phone number required")

return _fail().__await__()


class _RestaurantAgent(RestaurantToolsMixin, Agent):
def __init__(self) -> None:
super().__init__(instructions="test")


async def test_restaurant_phone_refusal_ends_without_reservation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(beta.workflows, "GetPhoneNumberTask", _DeclinedPhoneTask)
db = HotelDB.from_bytes(build_seed_bytes(TODAY))
before = db.connection.execute("SELECT count(*) FROM restaurant_reservations").fetchone()
task = BookRestaurantTask(db)

output = await task.open_phone_dialog()

after = db.connection.execute("SELECT count(*) FROM restaurant_reservations").fetchone()
assert before == after
assert output is not None
assert task.done()
task_error = task._AgentTask__fut.exception() # type: ignore[attr-defined]
assert isinstance(task_error, RestaurantReservationNotCreatedError)
await db.aclose()


async def test_restaurant_tool_reports_refusal_as_not_reserved(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(tools_restaurant, "BookRestaurantTask", _FailedRestaurantTask)
agent = _RestaurantAgent()
context = SimpleNamespace(userdata=SimpleNamespace(db=object()))

output = await agent.start_restaurant_booking(context) # type: ignore[arg-type]

assert output is not None