-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat(hotel_receptionist): add say_goodbye_and_close_call #6796
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) |
| 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 | ||
|
|
@@ -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, | ||
|
|
@@ -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() | ||
| 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 | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( Why a repeat close duplicates teardown workThe tool is explicitly designed to be called more than once per call (the "repeat close" path, The consequences are mild because A simple fix is to guard registration with a flag on Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| return _farewell_instruction(ctx.userdata) | ||
There was a problem hiding this comment.
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_GRACEatexamples/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 aconversation_item_addedlistener that pushesdeadlineout 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 — triggerssession.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
Was this helpful? React with 👍 or 👎 to provide feedback.