We're hitting a deadlock when users speak while an...
# ask-ai
b
We're hitting a deadlock when users speak while an agent transition is in progress: 1. Our transfer tool sets a synchronous lock (
_transition_in_progress
= True) and returns
Agent
2.
update_agent()
starts async, but user speaks before it completes 3. New LLM turn on old agent tries to transfer → blocked by our lock → returns
None
4.
drain()
waits for new turn, transition never completes → deadlock What's the recommended pattern to handle this? Should we block input during transitions, or use a different handoff mechanism?
t
Thanks for reaching out, Masayuki. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
You're describing the classic race where a synchronous lock plus an async update creates a window where a new user turn can race and block forever. I recommend avoiding that pattern and using one of two safer approaches depending on your UX needs: Option A — Atomic handoff (recommended) • Have your transfer tool return the new
Agent
(the handoff) as part of the current turn, so the switch happens atomically before the turn completes. • This removes the need for a long-lived synchronous lock and prevents the race: the new agent is active immediately for subsequent turns. Option B — Controlled input gating (if you need explicit pause) • Immediately disable user input when transition starts: e.g.
session.input.set_audio_enabled(False)
. • Call
update_agent()
and wait for confirmation the new agent is active. • Re-enable input only after the new agent is ready and `drain()`/turn processing is in a stable state. • Keep this window as short as possible; avoid holding locks across await points. Additional guidance • Never hold a synchronous lock across async boundaries that wait for external input — that’s the root cause of the deadlock. • Prefer atomic operations or short-lived gating so
drain()
can progress normally. • If you want, tell me which handoff method your transfer tool currently uses (returns
Agent
vs calls
update_agent()
), and I’ll suggest concrete code/flow changes. Sources: Agents and handoffs | LiveKit docs | Text and transcriptions | LiveKit docs | Agent session | LiveKit docs | Turn detection and interruptions | LiveKit Docs
b
We added the
_transition_in_progress
lock because we were getting
RuntimeError: cannot start agent: an activity is already running
when the LLM called our transfer tool twice (returning the same target agent both times). The lock was our workaround to prevent duplicate
update_agent()
calls. But now it causes the deadlock when user input arrives during the async transition. What's the recommended way to handle duplicate transfer calls without holding a lock across the async boundary?
t
You're right to avoid holding a synchronous lock across awaits — that causes the deadlock. Here are concise patterns to prevent duplicate transfers without blocking async progress. Pick the one that fits your UX. 1) Idempotent/atomic handoff (best) • Make the transfer tool return the new
Agent
instance (or an agreed handoff token) as part of the same turn so the handoff is applied atomically before the turn completes. • If the LLM calls the tool twice with the same target, the second call simply returns the already-created `Agent`/token and no new
update_agent()
is triggered. • This removes the need for cross-await locking. 2) Single-flight dedupe (non-blocking) • Use an in-memory single-flight map keyed by target agent (or handoff token). When a transfer starts, store a Future/Promise for that transfer. • Subsequent transfer calls for the same key await that same Future rather than triggering a new
update_agent()
. • Resolve the Future when
update_agent()
completes or fails. No synchronous lock is held across awaits. 3) Short-lived async gating (if you must gate) • On transfer start set an async-aware flag like
_transition_future = asyncio.create_task(do_transition())
. • New transfers check `_transition_future`: if present and matching target, await it; if different target, handle according to your policy (queue, reject, or cancel). • Do not use a synchronous boolean lock that blocks other threads; use the Future/task to coordinate. 4) Defensive
start
semantics • Make `update_agent()`/agent.start() idempotent server-side: if an agent is already active or starting for the same target, return a no-op success instead of raising. • Combine with single-flight so callers get a consistent success path without raising. 5) UX-level short mute (only if needed) • If you must prevent user audio during the switch, disable input briefly (
session.input.set_audio_enabled(False)
), start the async transition, await confirmation, then re-enable. Keep this window minimal and coordinate using a Future (not a sync lock). Which pattern would you prefer to explore? Tell me whether your transfer tool currently returns the Agent (atomic) or calls
update_agent()
separately — I’ll provide a concrete code sketch for that path.