The `user_away_timeout` never switches the state t...
# ask-ai
a
The
user_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?
t
Thanks for reaching out, Ruben. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — that makes sense to troubleshoot. You’re asking why
user_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:
Copy code
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:
Copy code
@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 Documentation
a
I put the
user_away_timeout
to 3 seconds and after 3 seconds of silent, the state doesn’t change why
t
Thanks — I understand the frustration. You set
user_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"
.
a
I’m in python I want an implementation of agent that can handle silent and say if the person is still here and if we have no response so we end the call I want to use streaming mode and all the optimization for latency can you write me a good script for this ( modular)
t
Thanks — this is clear and I can help. You want a modular Python agent that uses streaming mode with latency optimizations, detects user silence, prompts to confirm presence, and ends the call when there's no response. Below is a concise, modular script that follows those requirements and uses the recommended techniques: • Uses
SpeechHandle.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.
Copy code
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.
a
python
t
Thanks — I’ve put together a clean, modular Python agent you can drop into a LiveKit Agents-based app. It: • Uses streaming/low-latency mode (hooks shown where to enable flags). • Uses
user_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):
Copy code
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