-
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 1 commit
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,146 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import json | ||
| 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." | ||
| ) | ||
|
|
||
|
|
||
| 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. | ||
| """ | ||
|
|
||
|
|
||
| _VERDICT_TOOLS: list[llm.Tool] = [ | ||
| function_tool(_may_end, name="may_end"), | ||
| function_tool(_missing, name="missing"), | ||
| ] | ||
|
|
||
|
|
||
| def _verdict_from_tool_calls(tool_calls: list[llm.FunctionToolCall]) -> str | None: | ||
| """First may_end/missing call wins; anything unusable fails open (may end).""" | ||
| for call in tool_calls: | ||
| if call.name == "may_end": | ||
| return None | ||
| if call.name == "missing": | ||
| try: | ||
| arguments = json.loads(call.arguments or "{}") | ||
| except json.JSONDecodeError: | ||
| return None | ||
| action = str(arguments.get("action") or "").strip() | ||
| return action or None | ||
| return None | ||
|
|
||
|
|
||
| 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)}" | ||
| ), | ||
| ) | ||
|
|
||
| async def _collect() -> list[llm.FunctionToolCall]: | ||
| tool_calls: list[llm.FunctionToolCall] = [] | ||
| async with llm_v.chat( | ||
| chat_ctx=audit_ctx, tools=_VERDICT_TOOLS, tool_choice="required" | ||
| ) as stream: | ||
| async for chunk in stream: | ||
| if chunk.delta and chunk.delta.tool_calls: | ||
| tool_calls.extend(chunk.delta.tool_calls) | ||
| return tool_calls | ||
|
|
||
| try: | ||
| tool_calls = await asyncio.wait_for(_collect(), timeout=timeout) | ||
| except Exception: | ||
| logger.exception("end-call policy audit failed; allowing the call to end") | ||
| return None | ||
|
|
||
| return _verdict_from_tool_calls(tool_calls) | ||
|
|
||
|
|
||
| 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() | ||
|
Comment on lines
+69
to
+79
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. 🟡 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 ( Watchdog deadline is only ever extended, never cancelled, and only by caller speech
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" ( Prompt for agentsWas 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 | ||
|
|
@@ -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.
should we implement the tool body inside the function?
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.
is this what you were suggesting? 6acfccfcf