Skip to content

feat(hotel_receptionist): add say_goodbye_and_close_call - #6796

Open
u9g wants to merge 2 commits into
mainfrom
feat/hotel-say-goodbye-close-call
Open

feat(hotel_receptionist): add say_goodbye_and_close_call#6796
u9g wants to merge 2 commits into
mainfrom
feat/hotel-say-goodbye-close-call

Conversation

@u9g

@u9g u9g commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Ports the say_goodbye_and_close_call tool 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_call returns 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.py is 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 forced may_end/missing tool 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

  • Tool registers with a clean no-arg schema; the agent constructs and imports.
  • ruff format --check and ruff check pass.
  • mypy --strict output is byte-identical to main's baseline for these files (5 pre-existing errors, none new).
  • Ad-hoc checks over the five fail-open paths, the once-per-call nudge, system-prompt exclusion from the audit transcript, one-farewell-per-call, and the watchdog's fire/postpone/supersede semantics.

Example-only and Python-only β€” no library changes, and there's no equivalent example in agents-js.

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.
@u9g
u9g requested a review from a team as a code owner August 11, 2026 19:45

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +69 to +79
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()

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.

@longcw longcw left a comment

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.

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":

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.

should we implement the tool body inside the function?

Copy link
Copy Markdown
Contributor Author

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

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.

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 new potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +710 to +721
@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)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants