feat(hotel_receptionist): add say_goodbye_and_close_call - #6796
Conversation
Port the end-call tool from #6567. The agent no longer says goodbye itself: the tool delivers the one farewell and then closes the line after the caller goes quiet, so a caller answering the farewell isn't cut off mid-turn. A pre-hangup policy audit re-reads the standing policy against the transcript and can hand back one missing action instead of closing. It fails open and fires at most once per call.
| 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() |
There was a problem hiding this comment.
π‘ 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.
Was this helpful? React with π or π to provide feedback.
longcw
left a comment
There was a problem hiding this comment.
why not using the EndCall toolset, or add the features like find missing actions to it?
| for call in tool_calls: | ||
| if call.name == "may_end": | ||
| return None | ||
| if call.name == "missing": |
There was a problem hiding this comment.
should we implement the tool body inside the function?
There was a problem hiding this comment.
is this what you were suggesting? 6acfccfcf
The may_end / missing stubs existed only to generate a schema, and the verdict was recovered by hand-parsing the raw tool call. Define them as closures that write the verdict directly and let execute_function_call invoke them, matching voice/amd/classifier.py. json.loads is strict; the SDK's parser falls back to json_repair and strips leaked chat-template tokens, so a recoverable malformed `missing` call now yields its nudge instead of silently failing open.
| @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) |
There was a problem hiding this comment.
π‘ 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.
Was this helpful? React with π or π to provide feedback.
Ports the
say_goodbye_and_close_calltool out of #6567 so it can land on its own. That PR also rewrites the example onto a capability-loading architecture; none of that is here.Today the hotel receptionist has no way to end a call β it says goodbye in prose and the line stays open. This adds the tool that owns the wind-down.
The tool
The agent never says goodbye itself.
say_goodbye_and_close_callreturns the farewell instruction and then closes the line, and the instructions say so explicitly.The hang-up is deferred rather than immediate. Callers routinely answer a farewell ("okay, bye!"), and a session torn down under that reply leaves their turn hanging β that showed up in simulations as 60s turn timeouts. So the tool does what a receptionist does: say goodbye, let the caller hang up first, and only close once the line stays quiet. Caller speech postpones the close by the full grace period rather than cancelling it, so a caller who keeps answering the farewell can't hold the line open for the rest of the call.
A repeat close (the caller answered the goodbye and the model called the tool again) uses a shorter quiet period and does not deliver a second farewell β without that guard it produced a "Goodbye!" / "Take care!" / "Goodbye!" loop.
The pre-hangup policy audit
end_call_check.pyis one LLM call at the moment of goodbye: it re-reads the receptionist's own standing policy against the transcript and names at most one concrete action the policy still requires. The verdict comes back as a forcedmay_end/missingtool call, so there is nothing to parse.It is high-precision by construction and fails open on every failure path β no verdict, empty action, unparsable arguments, an error, a timeout, or a realtime model all let the call end. A caller stuck on a line that won't hang up is worse than a missed offer. It also fires at most once per call, so the agent can never get wedged unable to close.
Verification
ruff format --checkandruff checkpass.--strictoutput is byte-identical tomain's baseline for these files (5 pre-existing errors, none new).Example-only and Python-only β no library changes, and there's no equivalent example in
agents-js.