agreeable-advantage-5095
02/26/2026, 9:48 AMuser_away_timeout never switches the state to "away", even after the agent has finished speaking.
Is there an option or event that allows us to detect when the agent has finished speaking?
Right now, it also does not transition back to "listening" after the agent finishes talking. Is there something specific that needs to be enabled for the state machine to update correctly?tall-airline-16756
02/26/2026, 9:49 AMtall-airline-16756
02/26/2026, 9:49 AMuser_away_timeout never switches to "away" after the agent finishes speaking, how to detect when the agent has finished speaking, and whether anything must be enabled so the state machine returns to "listening". I’ll explain what’s happening and give reliable ways to detect speech end so the state transitions correctly.
Why user_away_timeout didn’t fire
• The user_away_timeout only fires when both the user and the agent are silent for the full timeout period. If the agent is speaking (or its audio is still playing), the conversation is considered active, so the user state will not change to "away".
• Finishing transcription or an internal speech-complete event does not necessarily mean audio playout has finished; playback still blocks the timeout.
How to reliably detect when the agent has finished speaking Use one of these methods (recommended order):
1. SpeechHandle.wait_for_playout() (recommended)
• Call wait_for_playout() on the SpeechHandle returned by your `say()`/play method. This resolves only when audio playout has actually finished, so you can be confident there is no ongoing agent audio.
Example:
handle = session.say("Hello")
await handle.wait_for_playout()
# safe to consider agent finished speaking
1. Listen for agent_state_changed events
• Subscribe to the agent_state_changed event. After playout finishes the agent should transition from "speaking" back to "listening". Use new_state == "listening" as the signal the agent is ready for user input.
Example:
@session.on("agent_state_changed")
def on_agent_state_changed(ev):
if ev.new_state == "listening":
print("Agent finished speaking and is now listening")
Why it might not transition back to "listening"
• If allow_interruptions=False (or similar configuration), the agent may not update state as you expect until playout handling completes or is awaited.
• Network or processing delays can delay the event that switches state.
• Not awaiting the playout or not subscribing to the agent state events means your code may miss the exact moment the agent becomes "listening".
Practical steps to fix your flow
1. When starting agent speech, capture the SpeechHandle.
2. Await handle.wait_for_playout() before assuming audio is done or before starting any logic that depends on the agent being silent.
3. Also subscribe to agent_state_changed to handle asynchronous state updates and to recover from occasional network delays.
4. If you need the user_away_timeout to fire sooner, ensure there is truly no agent audio (e.g., await playout) and that the configured timeout is appropriate.
If you’d like, tell me:
• Which SDK/language you’re using (Python, JS, etc.), and
• Whether you’re using allow_interruptions or any custom speech/playout settings.
I can then give a short, exact snippet tuned to your environment and help confirm the state transitions you should expect.
Sources: How to detect and handle silence during calls | LiveKit | How to implement call greeting retries in voice agents | LiveKit | How to detect when an agent has finished speaking | LiveKit | Turns overview | LiveKit Documentation | Agent speech and audio | LiveKit Documentationagreeable-advantage-5095
02/26/2026, 9:53 AMuser_away_timeout to 3 seconds and after 3 seconds of silent, the state doesn’t change
whytall-airline-16756
02/26/2026, 9:54 AMuser_away_timeout to 3 seconds but the state didn’t change after 3 seconds of silence. Here’s the most likely reason and a few targeted troubleshooting steps.
Why it didn’t change
• The user_away_timeout only fires when both the user and the agent are silent for the full timeout. If the agent is still speaking or its audio is still playing, the timeout will not trigger even if the user is silent.
• Finishing transcription or a speech-generation completion event does not guarantee audio playout is finished. Playout must finish for the conversation to be considered fully silent.
Troubleshooting steps to try now
1. Confirm agent audio has stopped:
◦ If you start agent speech with a SpeechHandle, await handle.wait_for_playout() before expecting the timeout to run.
1. Listen for agent state changes:
◦ Subscribe to agent_state_changed and verify the agent transitions to "listening". If it’s still "speaking", the timeout won’t fire.
1. Check interruption/config settings:
◦ If you use a setting like allow_interruptions=False, that can delay or change state transitions. Note this may keep the agent considered active until playout completes.
1. Watch for delayed events:
◦ Network or processing delay can postpone the state change event; add logs for when playout completes and when agent_state_changed fires to correlate timing.
1. Reproduce in isolation:
◦ Temporarily stop all agent speech and verify that 3 seconds of silence (no user audio and no playout) triggers the away state.
Quick clarifying questions so I can give an exact snippet:
• Which SDK / language are you using (Python, JS, etc.)?
• Do you start agent speech with a SpeechHandle and are you awaiting wait_for_playout()?
• Are you seeing the agent state remain "speaking" after you expect speech to finish?
Tell me those and I’ll give a short, exact code example and next steps to get this reliably switching to "away".agreeable-advantage-5095
02/26/2026, 9:57 AMtall-airline-16756
02/26/2026, 9:57 AMSpeechHandle.wait_for_playout() to reliably detect when agent audio has finished.
• Listens for agent_state_changed to detect when the agent goes to "listening".
• Uses user_away_timeout logic: the user is considered "away" only when both user and agent are silent for the configured timeout.
• Keeps pieces modular so you can swap speech generation, prompt text, or tweak timeouts/latency settings.
Note: plug in your own LiveKit session/client initialization and speech generation API where indicated.
import asyncio
from typing import Optional
# ---- Configuration ----
USER_AWAY_TIMEOUT = 3 # seconds to consider user away when both silent
CONFIRMATION_PROMPT = "Are you still there?"
MAX_NO_RESPONSE_ROUNDS = 2 # how many confirm prompts before ending call
LATENCY_OPTIMIZATIONS = {
"streaming_mode": True,
# add any SDK-specific low-latency flags here
}
# ---- Stubs / Integration Points (replace with your SDK calls) ----
class Session:
# Minimal session interface used by this script
def __init__(self):
self.on_agent_state_changed = None
self.on_user_speech_activity = None
self.on_agent_speech_activity = None
async def say(self, text: str, allow_interruptions: bool = True):
"""
Start agent speech. Return a SpeechHandle-like object with wait_for_playout().
Replace with actual SDK call that returns a handle.
"""
return SpeechHandle(text)
def subscribe_agent_state(self, callback):
self.on_agent_state_changed = callback
def subscribe_user_activity(self, callback):
self.on_user_speech_activity = callback
def subscribe_agent_activity(self, callback):
self.on_agent_speech_activity = callback
class SpeechHandle:
# Simulate a playout handle. Replace with actual handle type.
def __init__(self, text: str, playout_time: float = 0.5):
self._playout_time = playout_time
self._done = asyncio.Event()
async def wait_for_playout(self):
# In real SDK this resolves when playout fully finishes
await asyncio.sleep(self._playout_time)
self._done.set()
# ---- Agent Implementation ----
class PresenceAgent:
def __init__(self, session: Session):
self.session = session
self.user_speaking = False
self.agent_speaking = False
self.no_response_rounds = 0
self._away_task: Optional[asyncio.Task] = None
# Subscribe to events
session.subscribe_agent_state(self._on_agent_state_changed)
session.subscribe_user_activity(self._on_user_activity)
session.subscribe_agent_activity(self._on_agent_activity)
# Event handlers (hook these to the SDK events)
def _on_agent_state_changed(self, ev):
# ev.new_state could be "speaking", "listening", etc.
self.agent_speaking = (ev.new_state == "speaking")
# When agent becomes listening, we may start the away timer if user silent
if ev.new_state == "listening":
self._maybe_start_away_timer()
def _on_user_activity(self, is_speaking: bool):
self.user_speaking = is_speaking
if is_speaking:
# user returned — reset counters and stop away timer
self.no_response_rounds = 0
self._cancel_away_timer()
else:
# user silence — maybe start timer
self._maybe_start_away_timer()
def _on_agent_activity(self, is_speaking: bool):
self.agent_speaking = is_speaking
if is_speaking:
self._cancel_away_timer()
else:
self._maybe_start_away_timer()
def _maybe_start_away_timer(self):
# Start the away timer only if both user and agent are silent
if not self.user_speaking and not self.agent_speaking:
if self._away_task is None or self._away_task.done():
self._away_task = asyncio.create_task(self._away_countdown())
else:
self._cancel_away_timer()
def _cancel_away_timer(self):
if self._away_task and not self._away_task.done():
self._away_task.cancel()
self._away_task = None
async def _away_countdown(self):
try:
await asyncio.sleep(USER_AWAY_TIMEOUT)
except asyncio.CancelledError:
return
# If we reach here, both were silent for USER_AWAY_TIMEOUT
await self._handle_user_away_timeout()
async def _handle_user_away_timeout(self):
# Confirm presence flow
if self.no_response_rounds >= MAX_NO_RESPONSE_ROUNDS:
await self._end_call()
return
# Prompt the user to confirm presence
self.no_response_rounds += 1
handle = await self.session.say(CONFIRMATION_PROMPT, allow_interruptions=True)
# Wait for playout to finish so we are certain agent audio is done
await handle.wait_for_playout()
# After playout finishes, if user still silent, the away countdown will restart via event handlers
# (no direct sleep here; rely on event-driven user activity hooks)
# If we want, we can start an explicit short wait window here:
# await asyncio.sleep(USER_AWAY_TIMEOUT)
# But relying on the common code above avoids duplicating logic.
async def _end_call(self):
# End the call gracefully — replace with SDK hangup/terminate logic
print("No response after prompts — ending call.")
# perform hangup/cleanup here
# ---- Example wiring / main loop ----
async def main():
# Initialize session (replace with real SDK initialization, including streaming/latency flags)
session = Session()
agent = PresenceAgent(session)
# Example: connect and start streaming optimizations here
# e.g., session.enable_streaming_mode(LATENCY_OPTIMIZATIONS) # pseudo-code
# The following is illustrative: simulate events
class Ev:
def __init__(self, new_state):
self.new_state = new_state
# Simulate agent speaking then listening then silence
session.on_agent_state_changed(Ev("speaking"))
await asyncio.sleep(0.6)
session.on_agent_state_changed(Ev("listening"))
# No user activity afterwards; agent listening triggers away timer
# Wait enough time to let the agent confirm and end the call
await asyncio.sleep(10)
if __name__ == "__main__":
asyncio.run(main())
Suggested next steps and tuning
• Replace the Session/SpeechHandle stubs with your actual LiveKit client/session and speech generation/playout API.
• Ensure you call and await the SDK's returned playout handle (use wait_for_playout()).
• Keep allow_interruptions=True for confirmation prompts so user speech cancels prompts quickly.
• For lowest latency in streaming mode: enable any SDK flags for streaming/low-latency, and minimize playout buffer sizes as supported by your environment.
• Add robust logging around agent state events, playout completion, and user activity so you can verify the timing of state transitions.
If you want, I can:
• Convert the stubbed integrations into a concrete example for the specific Python SDK you’re using (paste your session initialization snippet or SDK name).
• Add unit-testable hooks so you can simulate events and assert behavior.
Which SDK/client are you using for LiveKit in Python (official LiveKit client, a wrapper, or a custom integration)? I’ll produce a version tied to that SDK with real calls.agreeable-advantage-5095
02/26/2026, 1:52 PMtall-airline-16756
02/26/2026, 1:52 PMuser_away_timeout + user_state_changed to detect when the user is marked away.
• Uses SpeechHandle.wait_for_playout() to ensure the agent finished playing audio before counting silence.
• Prompts the user up to N times, then ends the call if still no response.
• Is modular so you can swap STT/TTS/LLM plugins or tweak timeouts.
Notes before you run it
• Replace the plugin initializers (STT/TTS/LLM/VAD) with the exact plugin calls you use.
• Ensure you call the real LiveKit AgentSession start/connect code in your environment.
• Prewarm models (VAD/tts) if you want lower first-response latency.
Script (replace plugin and session init lines with your actual imports/initialization):
import asyncio
import logging
from typing import Optional
from livekit.agents import (
AgentServer,
Agent,
AgentSession,
JobContext,
UserStateChangedEvent,
)
# Replace these with the plugins you use:
from livekit.plugins import silero, deepgram, cartesia, openai
logger = logging.getLogger("presence-agent")
server = AgentServer()
# Configuration
USER_AWAY_TIMEOUT = 3.0 # seconds required for both parties to be silent
CONFIRM_PROMPT = "Are you still there?"
MAX_RETRIES = 2 # total confirm attempts before hangup
CONFIRM_WAIT = 5.0 # seconds to wait for user response after prompt
@server.rtc_session(agent_name="presence-agent")
async def entrypoint(ctx: JobContext):
# Create agent session with VAD/STT/LLM/TTS and away timeout.
# Swap the plugin initializers for your environment.
session = AgentSession(
vad=silero.VAD.load(), # VAD used to detect user speech activity
stt=deepgram.STT(), # speech-to-text plugin
llm=openai.LLM(model="gpt-4o-mini"),
tts=cartesia.TTS(),
user_away_timeout=USER_AWAY_TIMEOUT,
)
# State
inactivity_task: Optional[asyncio.Task] = None
retry_count = 0
async def confirm_presence_flow():
nonlocal retry_count, inactivity_task
retry_count = 0
while retry_count < MAX_RETRIES:
retry_count += 1
# Use generate_reply to produce TTS via the agent pipeline.
# This returns a SpeechHandle-like object in the real SDK.
handle = await session.generate_reply(instructions=CONFIRM_PROMPT)
# Wait for actual audio playout to finish before starting response timer.
# The real SpeechHandle from LiveKit provides wait_for_playout().
await handle.wait_for_playout()
# Wait for a short window for user activity. We rely on user_state_changed events
# to cancel this wait early if the user speaks.
try:
await asyncio.wait_for(_wait_for_user_speaking(), timeout=CONFIRM_WAIT)
# If user spoke, handlers clear retry_count and we exit
return
except asyncio.TimeoutError:
# No user activity in CONFIRM_WAIT seconds; loop to prompt again
continue
# After retries exhausted, say goodbye and shut down
await session.generate_reply(instructions="Goodbye.")
# Ensure final audio plays before shutdown
# If generate_reply returns handle, await its wait_for_playout() as above.
await asyncio.sleep(1.0)
<http://logger.info|logger.info>("No response after retries. Shutting down session.")
session.shutdown()
# Helper: a coroutine that returns when user is detected speaking.
# The real code will be event-driven; here we use an event to be set by the user_state handler.
user_spoke_event = asyncio.Event()
async def _wait_for_user_speaking():
await user_spoke_event.wait()
user_spoke_event.clear()
# Event handler for user state changes
@session.on("user_state_changed")
def on_user_state_changed(ev: UserStateChangedEvent):
nonlocal inactivity_task, retry_count
# ev.new_state can be "speaking", "listening", "away"
if ev.new_state == "speaking":
# user returned — stop any inactivity flows
if inactivity_task and not inactivity_task.done():
inactivity_task.cancel()
inactivity_task = None
retry_count = 0
user_spoke_event.set()
elif ev.new_state == "away":
# When user becomes away, start the confirm-presence flow if not already running
if inactivity_task is None or inactivity_task.done():
inactivity_task = asyncio.create_task(confirm_presence_flow())
else:
# "listening" or other states: no immediate action
pass
# Optionally listen for agent_state_changed if you need to know when agent is speaking
@session.on("agent_state_changed")
def on_agent_state_changed(ev):
# ev.new_state can be used if you want to block away-timers while agent is speaking.
# The AgentSession handles user_away_timeout only when both are silent.
pass
# Start the session + agent
agent = Agent(instructions="You are a helpful assistant.")
await session.start(agent=agent, room=ctx.room)
await ctx.connect(auto_subscribe=True)
# Initial greeting
handle = await session.generate_reply(instructions="Hello! How can I help you today?")
await handle.wait_for_playout()
# Keep the entrypoint alive until the session ends
await session.wait_closed()
if __name__ == "__main__":
logging.basicConfig(level=<http://logging.INFO|logging.INFO>)
server.run()
Important operational details
• user_away_timeout fires only when both user and agent are silent for the configured duration. If the agent is playing audio, the user will not be marked "away" until playout finishes.
• Always await the SpeechHandle.wait_for_playout() on any TTS output you rely on for timing — finishing generation or transcription is not the same as audio playout finishing.
• Keep event handlers lightweight. Use short-lived tasks to run confirmation logic.
• Prewarm heavy models (VAD/tts) during startup to minimize first-response latency.
If you want, I can:
• Convert this into a complete runnable example using your exact plugin instances and connection details.
• Add unit-test hooks or a simulated event harness so you can validate the silence/confirm/end logic locally.
Which would you prefer: a fully runnable example wired to your exact LiveKit setup, or a test harness you can use to simulate state changes?
Sources: How to detect and handle silence during calls | LiveKit | How to detect when an agent has finished speaking | LiveKit | Simple Call Answering Agent | LiveKit Documentation