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
8 changes: 8 additions & 0 deletions examples/hotel_receptionist/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ class Userdata:
# caller input since, which would silently double-book the guest.
last_room_booking: RoomBooking | None = None
caller_turns_at_last_booking: int = 0
# Whether the pre-hangup policy audit already handed the agent its one nudge -
# after that, say_goodbye_and_close_call always closes, so the agent can never
# get stuck unable to hang up.
end_call_nudged: bool = False
# Whether the farewell was already delivered on this call - repeat close attempts
# (the caller answered the goodbye, the model called the tool again) must not
# produce a "Goodbye!" / "Take care!" / "Goodbye!" loop.
goodbye_said: bool = False


def _speak_code(code: str) -> str:
Expand Down
126 changes: 126 additions & 0 deletions examples/hotel_receptionist/end_call_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
from __future__ import annotations

import asyncio
import logging
from typing import TYPE_CHECKING

from livekit.agents import llm
from livekit.agents.llm import function_tool

if TYPE_CHECKING:
from common import Userdata

logger = logging.getLogger("hotel-receptionist.end-call-check")

# The pre-hangup policy audit: one LLM call that re-reads the receptionist's own
# standing policy against the transcript and names at most ONE concrete action the
# policy still requires before the line closes. The verdict comes back as a forced
# tool call (may_end / missing), not free text, so there's nothing to parse. High
# precision by construction - no verdict call, an empty action, errors, and slow
# responses all fail OPEN (the call may end): a caller stuck on a line that won't
# hang up is worse than a missed offer.
_AUDIT_SYSTEM_PROMPT = """\
You audit a hotel receptionist's phone call at the moment the receptionist is about \
to say goodbye. You are given the receptionist's standing policy and the call \
transcript. Decide whether the policy EXPLICITLY requires one more concrete action \
or offer that has not yet happened on this call.

Respond with exactly one tool call: missing, only when the policy clearly requires \
the action for THIS call and the transcript shows it never happened; otherwise \
may_end. When in doubt, call may_end."""

_NUDGE_TEMPLATE = (
"Do NOT say goodbye or close the call yet - your standing policy still requires "
"one thing on this call: {missing} Handle that with the caller now, in your own "
"words, then call this tool again once they're done."
)


def _render_transcript(chat_ctx: llm.ChatContext) -> str:
"""Caller/Agent turns plus tool names - no system prompt, no tool outputs."""
lines: list[str] = []
for item in chat_ctx.items:
if item.type == "message" and item.role in ("user", "assistant"):
text = item.text_content
if text:
speaker = "Caller" if item.role == "user" else "Agent"
lines.append(f"{speaker}: {text}")
elif item.type == "function_call":
lines.append(f"[tool] {item.name}")
return "\n".join(lines)


async def find_missing_action(
llm_v: llm.LLM,
*,
instructions: str,
chat_ctx: llm.ChatContext,
timeout: float = 10.0,
) -> str | None:
"""Audit the call against the policy; return the one missing action, or None."""
audit_ctx = llm.ChatContext.empty()
audit_ctx.add_message(role="system", content=_AUDIT_SYSTEM_PROMPT)
audit_ctx.add_message(
role="user",
content=(
f"RECEPTIONIST POLICY:\n{instructions}\n\n"
f"CALL TRANSCRIPT:\n{_render_transcript(chat_ctx)}"
),
)

verdict: str | None = None

async def may_end() -> None:
"""The policy requires nothing further; the call may end now."""

async def missing(action: str) -> None:
"""The policy still requires one concrete action or offer on this call.

Args:
action: One short imperative instruction for the receptionist.
"""
nonlocal verdict
verdict = action.strip() or None

stream = llm_v.chat(
chat_ctx=audit_ctx,
tools=[function_tool(may_end), function_tool(missing)],
tool_choice="required",
)
try:
response = await asyncio.wait_for(stream.collect(), timeout=timeout)
# the prompt asks for exactly one call, so extras are ignored;
# execute_function_call never raises, so an unusable verdict leaves
# `verdict` unset and the call ends
if response.tool_calls:
await llm.execute_function_call(response.tool_calls[0], llm.ToolContext(stream.tools))
except Exception:
logger.exception("end-call policy audit failed; allowing the call to end")
return None

return verdict


async def run_goodbye_gate(
userdata: Userdata,
llm_v: llm.LLM | llm.RealtimeModel | None,
*,
instructions: str,
chat_ctx: llm.ChatContext,
) -> str | None:
"""One-shot gate in front of the goodbye: the nudge instruction, or None to close.

At most one nudge per call - once given, every later attempt closes
unconditionally, so the agent can never get stuck unable to hang up.
"""
if userdata.end_call_nudged:
return None
if not isinstance(llm_v, llm.LLM):
# realtime model or no LLM: skip the audit rather than block the goodbye
return None
missing = await find_missing_action(llm_v, instructions=instructions, chat_ctx=chat_ctx)
if missing is None:
return None
userdata.end_call_nudged = True
logger.info("end-call audit nudge: %s", missing)
return _NUDGE_TEMPLATE.format(missing=missing)
1 change: 1 addition & 0 deletions examples/hotel_receptionist/instructions.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def build_instructions() -> str:
- Charge or billing dispute on an existing stay ("you charged me for a room I never used", "I cancelled but was still charged", "I was double-billed", a fee they don't recognize): verify and pull up the actual record FIRST - lookup_invoice to see the line items - then dispute_charge with the category that fits and the disputed line exactly as it appears on the invoice. Explain the position from what's on record; only escalate AFTER you've looked it up, never on the caller's say-so. A no-show ("I never showed up", "I thought I cancelled") where there's no cancellation on record and the booking was card-guaranteed is category="no_show" on the room line: it's a guaranteed charge you explain calmly, then escalate to a manager if they press - never imply a refund, waiver, or that they should dispute it with their bank, none of which policy supports here.
- Group of 15 or more guests: that's a group block, not an individual booking. lookup_policy(topic="group_bookings") gives you the terms to quote (rate, tour-leader comp, credit approval, cancellation); collect the details and call record_group_inquiry. Nothing gets confirmed on this call - the group desk confirms after credit review, even if the caller pushes to lock it in now.
- Detail beyond the quick facts: lookup_policy. Its topic index covers hotel detail (location and transport, rooms and amenities, accessibility, guest services), restaurant detail (menu, dietary, dining, room service), payments and currency exchange, and group bookings. Look the topic up before answering - don't improvise policy.
- Caller indicates they're done ("that's all", "thanks, that's everything", a goodbye): call say_goodbye_and_close_call - NEVER say goodbye or wrap up the call yourself; the tool delivers the farewell and closes the line. It may hand back one last thing this call still needs - handle that with the caller in your own words, then call it again. (A call you've transferred ends with the one-line hand-off instead - don't call this tool after a transfer.)

# Things you can't book directly - use record_followup
You don't actually have the power to do everything a guest might ask. When the caller wants any of these, call record_followup with the right kind so a human can follow up. NEVER say "someone will follow up" without making this tool call - that's how requests get lost.
Expand Down
146 changes: 145 additions & 1 deletion examples/hotel_receptionist/tools_services.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import asyncio
import logging
import os
import sys
Expand All @@ -9,6 +10,7 @@
sys.path.append(os.path.dirname(os.path.abspath(__file__)))

from common import Userdata, _speak_code
from end_call_check import run_goodbye_gate
from hotel_db import (
MAX_PARTY_SIZE,
FollowupKind,
Expand All @@ -19,10 +21,97 @@
)
from pydantic import Field

from livekit.agents import RunContext, ToolError, function_tool
from livekit.agents import (
Agent,
AgentSession,
CloseEvent,
RunContext,
ToolError,
function_tool,
get_job_context,
)

logger = logging.getLogger("hotel-receptionist")

# Strong refs to in-flight post-goodbye shutdown tasks (see say_goodbye_and_close_call).
_pending_shutdowns: set[asyncio.Task[None]] = set()
# At most one close watchdog may own a session. A newer close supersedes the old
# timer so stale work can never shut down a later turn.
_close_watchdogs: dict[AgentSession, asyncio.Task[None]] = {}

# How long the line stays quiet after the goodbye before the agent hangs up itself.
# Callers usually hang up within a couple of seconds of the farewell; this is only
# the fallback for the ones who don't.
_CALLER_HANGUP_GRACE = 10.0

# The shorter quiet period for a REPEAT close: the farewell already happened and
# the caller has indicated twice that they're done. New caller speech postpones this
# timer by the full _CALLER_HANGUP_GRACE rather than cancelling it.
_REPEAT_CLOSE_GRACE = 3.0


def _arm_close_watchdog(session: AgentSession, *, grace: float) -> asyncio.Task[None]:
"""Hang up after `grace` seconds of caller silence.

Caller speech postpones the close, it never cancels it: the farewell is already
spoken, so a caller who keeps answering it must not be able to hold the line open
for the rest of the call. Postponement is always the full caller-hangup grace, which
outlasts the reply to that utterance, so the session is never torn down mid-answer.
shutdown() is idempotent, so a caller who hangs up during the wait is a no-op.
"""

if previous := _close_watchdogs.get(session):
previous.cancel()

loop = asyncio.get_running_loop()
deadline = loop.time() + grace

def _on_item_added(ev: object) -> None:
nonlocal deadline
item = getattr(ev, "item", None)
if item is not None and getattr(item, "role", None) == "user":
deadline = loop.time() + _CALLER_HANGUP_GRACE

async def _close_after_silence() -> None:
try:
while (remaining := deadline - loop.time()) > 0:
await asyncio.sleep(remaining)
session.shutdown()
Comment on lines +69 to +79

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.

🟡 Caller who brings up a new request after the goodbye can be hung up on mid-conversation

Once the farewell is delivered, the pending hang-up is only pushed back by ten seconds each time the caller speaks (deadline = loop.time() + _CALLER_HANGUP_GRACE at examples/hotel_receptionist/tools_services.py:73) and never called off, so a caller who re-opens a real request is disconnected after a short silence, even while the agent is still helping them.
Impact: A caller who says "wait, one more thing" after goodbye can have the line dropped on them mid-request whenever they pause (e.g. to find a confirmation number).

Watchdog deadline is only ever extended, never cancelled, and only by caller speech

_arm_close_watchdog (examples/hotel_receptionist/tools_services.py:53-92) registers a conversation_item_added listener that pushes deadline out by _CALLER_HANGUP_GRACE (10s) for user items only. Nothing else resets it: agent turns, tool calls, and thinking time do not extend it, and there is no path that cancels the pending close other than a newer close superseding it. So if the conversation genuinely restarts after the goodbye (a common pattern: "actually, can I add breakfast?"), any 10-second gap in caller speech — looking up a booking code, listening to a long answer plus a pause — triggers session.shutdown() (examples/hotel_receptionist/tools_services.py:79).

The tool's own comment documents the opposite contract: "On the FIRST close anything the caller says re-opens the conversation and cancels the pending close" (examples/hotel_receptionist/tools_services.py:694-696), while the implementation only postpones. One of the two is wrong; at minimum the first close should cancel (or greatly extend) the watchdog when the caller clearly starts a new request rather than merely acknowledging the farewell.

Prompt for agents
In examples/hotel_receptionist/tools_services.py, _arm_close_watchdog only postpones the deferred hang-up when the caller speaks; it never cancels it. That means a caller who re-opens the conversation after the goodbye (a new request, not just answering the farewell) gets disconnected after 10 seconds of silence, potentially mid-flow while the agent is waiting on them. The tool's inline comment at the close path claims the opposite ('anything the caller says ... cancels the pending close'), so code and documentation disagree. Decide on the intended semantics and make them consistent: e.g. on the FIRST close, cancel the watchdog when the caller's new turn triggers actual agent work (a tool call or a substantive reply), and keep pure postponement only for repeat closes; or at least extend the deadline on agent activity as well as caller speech so an in-progress exchange can't be cut off.
Open in Devin Review

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

except asyncio.CancelledError:
pass # superseded by a newer close
finally:
session.off("conversation_item_added", _on_item_added)
if _close_watchdogs.get(session) is task:
del _close_watchdogs[session]

session.on("conversation_item_added", _on_item_added)
task = asyncio.create_task(_close_after_silence())
_close_watchdogs[session] = task
_pending_shutdowns.add(task)
task.add_done_callback(_pending_shutdowns.discard)
return task


def _farewell_instruction(userdata: Userdata) -> str:
"""The close path's reply instruction: one farewell per call, ever.

Callers routinely answer a goodbye ("you too!"), and the model then calls the
close tool again - without this guard that produced a "Goodbye!" / "Take care!" /
"Goodbye!" loop. The first close delivers the farewell; every later one answers
real questions only and otherwise stays quiet while the line closes on its own.
"""
if not userdata.goodbye_said:
userdata.goodbye_said = True
return (
"The line closes right after your next utterance. Give ONE short, warm "
"goodbye now - no questions, no new information."
)
return (
"You've already said goodbye - do NOT give another farewell, sign-off, or "
"filler. If the caller just asked a real question, answer it in one short "
"sentence; otherwise say nothing. The line closes on its own once they stop."
)


class ServicesToolsMixin:
@function_tool
Expand Down Expand Up @@ -577,3 +666,58 @@ async def add_to_waitlist(
"for those dates and you'll reach out if something opens up - make clear nothing is "
"held and it's not a guarantee."
)

@function_tool
async def say_goodbye_and_close_call(self, ctx: RunContext[Userdata]) -> str:
"""End the call once the caller indicates they're finished ("that's all", "thanks, bye"). NEVER say goodbye yourself - this tool delivers the farewell and then closes the line. It may instead hand back one last thing your standing policy still requires on this call: handle that with the caller first, then call this tool again. Don't call it when the caller is only pausing, holding, or mid-request."""
# Pre-hangup policy audit: re-read the standing policy against the transcript
# and, at most once per call, hand the agent back the one thing it still owes
# the caller instead of closing - the "offer before wind-down" policy grounded
# in a guaranteed action. Skipped on repeat closes (the farewell already
# happened; the call is winding down, not re-opening).
if not ctx.userdata.goodbye_said:
agent_instructions = self.instructions if isinstance(self, Agent) else ""
nudge = await run_goodbye_gate(
ctx.userdata,
ctx.session.llm,
instructions=agent_instructions if isinstance(agent_instructions, str) else "",
chat_ctx=ctx.session.history,
)
if nudge is not None:
return nudge

# Close path: the goodbye is this tool's reply, reusing the current speech
# handle. Don't hang up right after it - callers routinely answer a farewell
# ("okay, bye!"), and a session torn down under that reply leaves their turn
# hanging (observed in simulations as 60s turn timeouts). Do what a real
# receptionist does: say goodbye, give the caller the chance to hang up
# first, and only close the line after it stays quiet. On the FIRST close
# anything the caller says re-opens the conversation and cancels the pending
# close. Repeat closes use a shorter quiet period, but caller speech still
# cancels the old timer so it cannot shut down a newer active turn. When that
# turn finishes, a repeat close replaces the timer.
session = ctx.session
repeat_close = ctx.userdata.goodbye_said

def _arm_after_reply(_: object) -> None:
_arm_close_watchdog(
session,
grace=_REPEAT_CLOSE_GRACE if repeat_close else _CALLER_HANGUP_GRACE,
)

ctx.speech_handle.add_done_callback(_arm_after_reply)

@ctx.session.once("close")
def _on_close(ev: CloseEvent) -> None:
try:
job_ctx = get_job_context()
except RuntimeError:
return # no job to shut down (console / tests)

async def _delete_room() -> None:
await job_ctx.delete_room()

job_ctx.add_shutdown_callback(_delete_room)
job_ctx.shutdown(reason=ev.reason.value)
Comment on lines +710 to +721

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.

🟡 Ending the call twice queues duplicate room-deletion and job-shutdown requests

A fresh end-of-call listener is registered every time the closing tool runs (ctx.session.once("close") at examples/hotel_receptionist/tools_services.py:710), so a second close attempt on the same call stacks another listener that repeats the room deletion and job shutdown when the line finally closes.
Impact: Redundant teardown requests are sent for the same call, producing duplicate delete-room API traffic and extra shutdown noise in logs.

Why a repeat close duplicates teardown work

The tool is explicitly designed to be called more than once per call (the "repeat close" path, examples/hotel_receptionist/tools_services.py:700). Each invocation that reaches the close path registers a brand-new _on_close closure via once("close"); once only guarantees a given callback fires once, not that only one callback exists. When the session closes, every registered _on_close runs, each one calling job_ctx.add_shutdown_callback(_delete_room) and job_ctx.shutdown(...).

The consequences are mild because JobContext.delete_room swallows NOT_FOUND (livekit-agents/livekit/agents/job.py:638-640) and the shutdown future set is guarded by contextlib.suppress(asyncio.InvalidStateError) (livekit-agents/livekit/agents/ipc/job_proc_lazy_main.py:292-298), but the duplicate registration is still unintended. The library's own equivalent (livekit-agents/livekit/agents/beta/tools/end_call.py:88) registers a bound method, so repeated registration of the same callable is deduplicated by the emitter.

A simple fix is to guard registration with a flag on Userdata (e.g. only register on the first close path) or to hoist the handler to a module-level/bound function so re-registration is idempotent.

Prompt for agents
In examples/hotel_receptionist/tools_services.py, say_goodbye_and_close_call registers a new `once("close")` handler on every invocation. Because the tool is intentionally called more than once per call (the repeat-close path), the session ends up with multiple identical close handlers, each adding a delete-room shutdown callback and calling job_ctx.shutdown. Make the registration happen at most once per session/call — e.g. track it on Userdata alongside goodbye_said, or register a single stable callable (module-level function or bound method) so the emitter deduplicates it.
Open in Devin Review

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


return _farewell_instruction(ctx.userdata)