billowy-hairdresser-80384
03/16/2026, 9:06 AM{speech_id: "speech_d8471d201819"
timeout: 5
code.file.path: "/app/.venv/lib/python3.13/site-packages/livekit/agents/voice/speech_handle.py"
code.function.name: "_on_timeout"
code.line.number: 187n
room_id: "RM_T7eRGcLnYWvQ"
job_id: "AJ_ggCo5QZzXDbp"
logger.name: "livekit.agents"
lk.id: "a9370337-b178-695b-7a83-c7bec2faba27"
}
I am getting this issue, please help me heretall-airline-16756
03/16/2026, 9:07 AMtall-airline-16756
03/16/2026, 9:07 AMSpeechHandle._on_timeout with a 5s timeout for room RM_T7eRGcLnYWvQ and job AJ_ggCo5QZzXDbp. I’ll help you troubleshoot this.
What this means
• The speech operation was interrupted (for example, user input or another event) and did not finish or get explicitly cancelled within the configured timeout (5 seconds), so the system forcibly cancelled it.
• This is coming from the SpeechHandle timeout flow that prevents speech-related tasks from stalling after an interruption.
Common causes
• A long-running tool call or task continued to run while speech was interrupted and was not cancelled.
• Your code awaited session.say() or session.generate_reply() inside a tool call or long-running operation, which can block proper interruption handling.
• Your logic didn’t check SpeechHandle.interrupted or didn’t cancel outstanding tasks when an interruption occurred.
Recommended next steps (practical fixes)
1. Stop awaiting speech inside long-running tool calls
◦ Avoid directly awaiting session.say() or session.generate_reply() from inside tool calls that can be interrupted. That can keep the tool running and prevent the speech handle from being cleaned up promptly.
1. Use context.wait_for_playout() to coordinate playback
◦ Use context.wait_for_playout() to let playback finish or to wait in a way that respects interruptions instead of blocking the tool.
1. Cancel long-running tasks when interrupted
◦ Capture the SpeechHandle and, when you detect interrupted (or when an interruption event arrives), cancel any associated tool or task rather than letting it run to completion.
◦ Example pattern: start the tool/task, store a reference, and on interruption call the task’s cancel method (or otherwise stop the operation).
1. Check and tune interruption thresholds if needed
◦ There are interruption-related settings such as min_interruption_duration (default 0.5s) and min_interruption_words (default 0). If your flows commonly produce very short interruptions, review these settings so valid interruptions are recognized and handled as you expect.
Questions to narrow this down
• Are you calling session.say() or session.generate_reply() inside any tool implementations or long-running jobs?
• Do you have logic that checks SpeechHandle.interrupted and cancels in-flight tool calls?
• Can you share (sanitized) snippets where you start speech and where any tool calls run concurrently? Even a few lines will help me point to exact changes.
If you prefer, paste a minimal example of the flow that triggers this timeout and I’ll point out where to add interruption checks/cancellation and how to use context.wait_for_playout() correctly.
Sources: How to detect when an agent has finished speaking | examples/voice_agents | livekit/livekit_composite | DeepWiki | Agent speech and audio | LiveKit Documentation | Function tool definition | LiveKit Documentationbillowy-hairdresser-80384
03/16/2026, 9:14 AMtall-airline-16756
03/16/2026, 9:14 AMsession.say() followed by asyncio.sleep() inside a tool with allow_interruptions=False blocks cleanup when an interruption occurs, triggering the 5s SpeechHandle timeout and an arbitrary cancel.
• Your change to stop using asyncio.sleep() and to await the returned SpeechHandle.wait_for_playout() is the right direction.
Why your fix is correct
• session.say() returns a SpeechHandle. Awaiting speech_handle.wait_for_playout() waits for the new speech’s playout to finish rather than sleeping an estimated duration. This avoids keeping the tool alive unnecessarily and lets the SDK handle interruptions cleanly.
• The SDK’s guard that prevents awaiting the same speech handle that owns the tool does not block this pattern, since session.say() returns a different/new speech handle. So it’s safe to call wait_for_playout() for the new handle from inside the tool.
• Handling asyncio.CancelledError and logging exceptions around wait_for_playout() is appropriate and will help avoid unhandled cancellations that can prevent cleanup hooks (like log uploads) from running.
Additional recommendations to make the fix more robust
• Consider allow_interruptions=True when appropriate:
◦ If the closing message can be interrupted by the user (e.g., they try to speak), allowing interruption may reduce race conditions. If you must ensure the message completes for compliance/verification reasons, keep False but ensure proper cancellation handling.
• Explicitly cancel any in-flight tasks started by the tool on interruption:
◦ If the tool launches background tasks, keep references and call their cancel methods when speech_handle.interrupted is set or when wait_for_playout() raises CancelledError.
• Add more logging around interruption state:
◦ Log speech_handle.interrupted or when you catch CancelledError, including `job_id`/`room_id` to correlate with observability logs.
• Ensure cleanup path runs even on unexpected exceptions:
◦ Wrap higher-level tool logic in try/except/finally so cleanup/ResourceCleanupHandler logic isn't skipped by an unhandled exception.
• Test these cases explicitly:
◦ Normal flow (no interruption).
◦ Short accidental user input that should interrupt.
◦ Intentional user input immediately after session.say() starts.
◦ Tool-level exceptions and cancellation during playout.
• Review min_interruption_duration / min_interruption_words values:
◦ If you see many spurious interruptions, tuning these can reduce false positives or let real interruptions be recognized reliably.
Small nit: shutdown placement
• Your change calls job_ctx.shutdown(reason=...) after speech completion, which is fine. Just confirm the shutdown won’t attempt to await other things that may re-trigger speech or otherwise race with finalization. If shutdown triggers more speech, ensure those flows are interruption-safe.
If you want, I can:
• Review a sanitized snippet of the updated end_call.py to confirm exception and cancellation handling is covered end-to-end.
• Suggest unit or integration test scenarios (with steps) you can run to validate the fix in your environment.
Nice work on the RCA and the fix — it addresses the core problem and should prevent the 5s forced cancels once the cancellation/wait handling and logging are in place.billowy-hairdresser-80384
03/16/2026, 9:15 AMtall-airline-16756
03/16/2026, 9:15 AMjob_ctx.shutdown()), the room will be deleted regardless of whether the speech was interrupted.
More detail and what to do
• Allowing interruptions (allow_interruptions=True) lets the SDK cancel the speech cleanly when the user speaks, avoiding the 5s forced timeout and arbitrary cancel. That prevents the timeout error you saw.
• Room deletion (your call to job_ctx.shutdown(...) or other cleanup code) is controlled by your application logic, not by allow_interruptions. If you call shutdown unconditionally after wait_for_playout() (or after session.say()), the room will be deleted even when the user interrupted the speech.
How to ensure the desired behavior
• Decide whether an interruption should abort the end-call sequence or still lead to shutdown.
◦ If interruption should cancel the shutdown: check speech_handle.interrupted or catch asyncio.CancelledError and skip or postpone job_ctx.shutdown(...).
◦ If interruption should NOT stop shutdown (i.e., you always close the call): keep the shutdown call but ensure it runs after you properly handle/catch cancellation so cleanup completes gracefully.
Recommended pattern
1. Start the speech and capture the handle:
◦ speech_handle = ctx.session.say(..., allow_interruptions=True)
1. Await playout with error handling:
◦ try: await speech_handle.wait_for_playout()
◦ except asyncio.CancelledError: <http://logger.info|logger.info>("speech interrupted")
◦ except Exception as e: logger.error(...)
1. Make shutdown conditional or unconditional based on interruption:
◦ If conditional:
▪︎ if not speech_handle.interrupted: job_ctx.shutdown(...)
▪︎ else: log, take alternative action (e.g., retry, prompt user, or schedule shutdown)
◦ If unconditional:
▪︎ call job_ctx.shutdown(...) but ensure any cancellation exceptions are handled so cleanup runs.
Extra tips
• If you need to ensure the user’s interruption (e.g., they want to continue interacting) prevents deletion, use the conditional path and perhaps add a short grace period or confirmation before shutdown.
• Add clear logging when you decide to skip or proceed with shutdown so you can correlate with observability logs (include `room_id`/`job_id`).
• Test both flows: interrupting immediately and not interrupting to confirm shutdown behavior matches expectations.
If you tell me which behavior you want (interrupt = cancel shutdown, or interrupt = still delete room), I can give a concise code snippet showing the exact conditional logic to implement.billowy-hairdresser-80384
03/16/2026, 9:20 AMtall-airline-16756
03/16/2026, 9:20 AMSpeechHandle.
• Waits for playout with proper error handling.
• If speech_handle.interrupted is true (or wait_for_playout() raises CancelledError), it skips job_ctx.shutdown(...) and logs the decision.
• If not interrupted, it proceeds with shutdown.
Suggested code pattern (Python, asyncio):
# inside your end_call tool
<http://logger.info|logger.info>(f"Call Ending | Outcome: {outcome} | Reason: {reason} | Spoke: {message_to_speak}")
# Start speech and get the new SpeechHandle
speech_handle = ctx.session.say(message_to_speak, allow_interruptions=True)
try:
# Wait for the TTS audio to finish playing out
await speech_handle.wait_for_playout()
except asyncio.CancelledError:
<http://logger.info|logger.info>("End-call speech was cancelled during playout (CancelledError). Will NOT proceed to shutdown.")
# Skip shutdown because user interrupted
return {"outcome": "interrupted", "reason": "speech_cancelled_by_user"}
except Exception as e:
logger.error(f"Error while waiting for playout: {e}")
# Decide policy on errors: conservative approach is to skip shutdown or perform safe shutdown
return {"outcome": "failure", "reason": f"playout_error: {e}"}
# After successful playout, check interrupted flag as a safety net
if getattr(speech_handle, "interrupted", False):
<http://logger.info|logger.info>("Speech handle indicates interruption. Skipping shutdown.")
return {"outcome": "interrupted", "reason": "speech_handle_interrupted"}
# No interruption: proceed with shutdown
job_ctx = get_job_context()
if job_ctx:
<http://logger.info|logger.info>("Speech completed without interruption. Initiating shutdown.")
job_ctx.shutdown(reason=f"Agent ended call: {reason}")
return {"outcome": "success", "reason": "call_ended_normally"}
Notes and best practices
• Use allow_interruptions=True so interruptions are detected and handled cleanly. If you must keep False for regulatory reasons, you’ll need extra cancellation logic—this pattern assumes interruptions are allowed.
• Catch asyncio.CancelledError specifically since cancellations from the runtime will raise that.
• Check speech_handle.interrupted after playout to cover any races where interruption occurred but didn’t raise during wait_for_playout().
• If your tool spawns background tasks, cancel them when skipping shutdown so nothing leaks.
• Log `room_id`/`job_id` with messages to help correlate with observability traces.
• Consider a short grace period (e.g., a small debounce) if you want to avoid skipping shutdown for accidental, very brief interruptions.
If you want, paste a sanitized snippet of your current end_call tool and I’ll adapt this pattern directly into it (including the exact variable names you use).billowy-hairdresser-80384
03/16/2026, 9:21 AMtall-airline-16756
03/16/2026, 9:21 AMend_call.py and it currently always shuts down in the finally block, so it will delete the room even when the speech was interrupted. Below I explain why that happens and show a small, safe change that implements the behavior you asked for: if the closing speech is interrupted, skip the shutdown.
Why your current code still deletes the room
• The finally block always runs whether wait_for_playout() completed normally, raised CancelledError, or any other exception. Because you call job_ctx.shutdown(...) inside finally, the room will be deleted even when speech was cancelled.
Recommended changes (concise)
• Allow interruptions on the TTS so the SDK can detect them cleanly.
• Await the returned SpeechHandle.wait_for_playout() and handle CancelledError.
• Move shutdown into the non-interrupted success path (or make it conditional based on speech_handle.interrupted).
• Return a result when skipping shutdown so the caller can act accordingly and you log the decision.
Here’s an adapted version of your tool implementing this pattern:
import asyncio
from livekit.agents import RunContext, function_tool, get_job_context
from loguru import logger
@function_tool
async def end_call(ctx: RunContext, message_to_speak: str, outcome: str, reason: str):
"""
Ends the call unless the closing speech was interrupted by the user.
"""
<http://logger.info|logger.info>(f"Call Ending | Outcome: {outcome} | Reason: {reason} | Spoke: {message_to_speak}")
# Start speech with interruptions allowed so we can detect user speech
speech_handle = ctx.session.say(message_to_speak, allow_interruptions=True)
try:
await speech_handle.wait_for_playout()
except asyncio.CancelledError:
<http://logger.info|logger.info>("End-call speech was cancelled during playout. Skipping shutdown.")
# Optional: cancel any tool-local background tasks here
return {"outcome": "interrupted", "reason": "speech_cancelled_by_user"}
except Exception as e:
logger.error(f"Error during end call speech playout: {e}")
# Conservative choice: skip shutdown so we don't accidentally delete the room on error
return {"outcome": "failure", "reason": f"playout_error: {e}"}
# Safety check: if interrupted flag is set, skip shutdown
if getattr(speech_handle, "interrupted", False):
<http://logger.info|logger.info>("Speech handle indicates interruption. Skipping shutdown.")
return {"outcome": "interrupted", "reason": "speech_handle_interrupted"}
# No interruption: proceed with shutdown
job_ctx = get_job_context()
if job_ctx:
<http://logger.info|logger.info>("Speech completed without interruption. Initiating shutdown.")
job_ctx.shutdown(reason=f"Agent ended call: {reason}")
return {"outcome": "success", "reason": "call_ended_normally"}
Additional suggestions
• If your tool spawns background tasks, explicitly cancel them when you skip shutdown.
• Add room_id/job_id to logs for traceability.
• Consider a short debounce (e.g., 0.5s) before deciding interruption should skip shutdown if you want to ignore accidental, very brief user noises.
• Test these flows: no interruption, immediate interruption, and playout error.
If you want, paste any background tasks you spawn in this tool and I’ll show exactly where to cancel them when skipping shutdown.billowy-hairdresser-80384
03/16/2026, 9:23 AMtall-airline-16756
03/16/2026, 9:24 AMend_call.py tool and explain how the interrupting input is surfaced.
Quick summary of behavior
• When the user speaks during agent playback, LiveKit stops playback, begins STT, and that transcribed user input is added to the conversation as a user turn.
• You can detect the interruption using the returned SpeechHandle (speech_handle.interrupted) or by catching asyncio.CancelledError from wait_for_playout().
• To avoid deleting the room when interrupted, skip job_ctx.shutdown(...) when the handle indicates interruption.
• The user’s transcribed chunk is available in the session/chat context (and via events/hooks) so you can include it in the follow-up prompt or tool logic.
Pattern to implement (adapt this into your tool)
1. Start the closing speech with interruptions allowed and capture the handle.
2. Await playout and catch cancellations.
3. If interrupted, read the interrupted user input from the chat context or event, and return/use it rather than shutting down.
4. If not interrupted, proceed with shutdown.
Example adapted for your end_call.py (keeps your names & style):
import asyncio
from livekit.agents import RunContext, function_tool, get_job_context
from loguru import logger
@function_tool
async def end_call(ctx: RunContext, message_to_speak: str, outcome: str, reason: str):
<http://logger.info|logger.info>(f"Call Ending | Outcome: {outcome} | Reason: {reason} | Spoke: {message_to_speak}")
# 1) Start speech and allow interruptions so we can detect and capture user's chunk
speech_handle = ctx.session.say(message_to_speak, allow_interruptions=True)
try:
# 2) Wait for playout; CancelledError will be raised if interrupted
await speech_handle.wait_for_playout()
except asyncio.CancelledError:
<http://logger.info|logger.info>("End-call speech was cancelled during playout. Will NOT proceed to shutdown.")
# 3) Retrieve the interrupting user chunk from the session/chat context
# The user input should already be transcribed and added to the chat context after turn completion.
# Access the latest user message (sanitized to your schema). Example approach:
interrupted_text = None
try:
# ctx.session.chat_ctx or similar holds conversation messages; pick latest user message
chat_ctx = ctx.session.chat_ctx
# Walk messages from newest to oldest to find the most recent user message
for msg in reversed(chat_ctx.messages):
if msg.role == "user":
interrupted_text = msg.text_content
break
except Exception:
logger.exception("Failed to read interrupted user text from chat context.")
# Return the interrupted chunk so the caller can process it (or you can call generate_reply)
return {
"outcome": "interrupted",
"reason": "speech_cancelled_by_user",
"interrupted_user_text": interrupted_text,
}
except Exception as e:
logger.error(f"Error during end call speech playout: {e}")
return {"outcome": "failure", "reason": f"playout_error: {e}"}
# 4) Safety check after successful playout
if getattr(speech_handle, "interrupted", False):
<http://logger.info|logger.info>("Speech handle indicates interruption. Skipping shutdown.")
# Fetch interrupted user input similarly if needed
return {"outcome": "interrupted", "reason": "speech_handle_interrupted", "interrupted_user_text": None}
# 5) No interruption: proceed with shutdown
job_ctx = get_job_context()
if job_ctx:
<http://logger.info|logger.info>("Speech completed without interruption. Initiating shutdown.")
job_ctx.shutdown(reason=f"Agent ended call: {reason}")
return {"outcome": "success", "reason": "call_ended_normally"}
Notes on where the interrupted chunk comes from
• LiveKit records and transcribes the user audio that interrupts playback. That transcription is added to your conversation/chat context as a user message once the system completes the turn.
• You can access it in multiple ways:
◦ Read the latest user message from ctx.session.chat_ctx.messages (example in snippet).
◦ Use the user_input_transcribed event handler to process the transcript as it arrives.
◦ Inspect on_user_turn_completed hook if you want to mutate or route the interrupted text before it reaches the LLM.
Best-practice tips
• Include room_id and job_id in your logs to correlate interruptions with observability traces.
• If you spawn background tasks inside the tool, cancel them when skipping shutdown to avoid leaks.
• Optionally add a short debounce (e.g., 0.5s) before skipping shutdown to ignore accidental micro-noise interruptions.
• If you want the agent to immediately respond to the interrupted user chunk, call ctx.session.generate_reply() after capturing the text so the conversation continues naturally.
If you’d like, paste a sanitized sample of your chat_ctx.messages structure (or confirm how you store messages) and I’ll tailor the snippet to extract the interrupted chunk exactly as your project represents messages.
Sources: Agent speech and audio | LiveKit Documentation | Turns overview | LiveKit Documentation | Pipeline nodes and hooks | LiveKit Documentation | Function tool definition | LiveKit Documentation