acoustic-pharmacist-34178
03/08/2026, 7:43 AMError during silence message playout: Error: Cannot call 'SpeechHandle.waitForPlayout()' from inside the function tool 'TransferCall'. This creates a circular wait: the speech handle is waiting for the function tool to complete, while the function tool is simultaneously waiting for the speech handle.
To wait for the assistant's spoken response prior to running this tool, use RunContext.wait_for_playout() instead.
I'm getting this error even after the agent is not anymore executing a tool and has exited the tool.tall-airline-16756
03/08/2026, 7:43 AMtall-airline-16756
03/08/2026, 7:43 AMSpeechHandle.waitForPlayout() from inside the TransferCall function tool and the fact that it still appears even after the agent exits the tool.
Short answer: the error is expected when a SpeechHandle is created inside a function tool and you try to await its playout from that tool. That creates a circular wait: the tool is waiting for playout to finish while the runtime is waiting for the tool to finish. Returning from the tool does not automatically sever that tie, so the runtime still treats the speech handle as tied to the tool’s lifecycle.
What you can do next (clear options):
• Use RunContext.wait_for_playout() before your tool runs
◦ If you need the agent to finish speaking before the transfer, call:
await context.wait_for_playout()
from outside the tool (for example in an on_enter handler or prior step) so the tool starts only after playout completes.
• Don’t create or await a SpeechHandle inside the tool
◦ If your tool needs to trigger speech, restructure so the tool only initiates the transfer logic. Start the speech outside the tool and wait for it with RunContext.wait_for_playout() before invoking the tool.
• If you must coordinate speech that was initiated in the tool, avoid calling SpeechHandle.wait_for_playout() inside the tool. Instead:
◦ Have the tool return the relevant identifier/state, then wait for playout in an outer context (where RunContext.wait_for_playout() is available).
◦ Or use asynchronous coordination (e.g., publish an event or set a flag the outer flow observes) rather than a direct wait that blocks the tool completion.
Quick checklist to find the cause in your code:
• Is the SpeechHandle created while inside TransferCall? If yes, move creation or the wait outside the tool.
• Are there any unawaited speech tasks that were created inside the tool and left dangling after return? Ensure handles are awaited or handled outside the tool.
• Are you calling SpeechHandle.wait_for_playout() anywhere other than in event handlers or before running a tool? If so, move that logic to RunContext.wait_for_playout() where appropriate.
If you want, share the minimal code around:
• where you start the speech (the SpeechHandle creation),
• how/when TransferCall is invoked,
• and any waits you’re calling,
and I’ll point to the exact lines to change. I can also show a small before/after snippet that restructures the flow to use context.wait_for_playout() correctly.
Sources: Agent speech and audio | LiveKit Documentation | Agents and handoffs | LiveKit Documentation | Tool definition and use | LiveKit Documentationacoustic-pharmacist-34178
03/08/2026, 7:52 AMwait_for_playout when user is away for tool long. however, the agent is not in the tool at that time. I listen to function_tools_executed to confirm that. I use an Agent Task in the tool that I'm afraid causes it not to properly be cleaned up and it thinks it is still in tool.
llm.tool({
description: 'Perform a blind (cold) transfer of the current call to the given phone number, extension.',
parameters: schema,
execute: async ({ destination, }, { ctx }) => {
return runInToolContext(async () => {
await ctx.waitForPlayout();
return new SIPTransferTask(config, destination).run();
})
},
});
SIPTransferTask is an AgentTask.
The error I see is caused in the js sdk at https://github.com/livekit/agents-js/blob/main/agents/src/voice/speech_handle.ts#L154 where the AsyncLocalStorage still has the store of the previous functiontall-airline-16756
03/08/2026, 7:52 AMctx.waitForPlayout() inside a tool that then starts an AgentTask (SIPTransferTask) via runInToolContext(...), and you observe the runtime still thinks the agent is “in the tool” because AsyncLocalStorage still has the tool store.
Summary of the problem
• Calling SpeechHandle.waitForPlayout() (or ctx.waitForPlayout()) from inside a function tool creates a circular-wait risk when the tool’s execution lifecycle is still considered active.
• Even after your tool function returns, an AsyncLocalStorage store created for that tool can remain active while an AgentTask spawned inside the tool continues running. The runtime therefore still considers the function-tool context active and will reject waitForPlout() calls accordingly.
What to do next — practical options
• Move the wait out of the tool (recommended)
◦ Start the agent speech and ensure the outer flow (before invoking the tool) calls RunContext.wait_for_playout() so the tool runs only after playout completes.
◦ This guarantees the tool never runs while the runtime is still waiting on playout and avoids touching AsyncLocalStorage inside the tool.
• If the tool must wait for playout before doing transfer logic, wait before entering the tool:
a. In outer flow/event handler: await context.wait_for_playout()
b. Then call your tool which only performs transfer logic (no ctx.waitForPlayout() inside).
◦ Example flow: play speech → wait via RunContext → invoke transfer tool → tool starts SIPTransferTask.
• Don’t keep long-running AgentTasks tied to the tool’s AsyncLocalStorage
◦ If SIPTransferTask is long-lived, don’t start it directly inside the tool’s execution context in a way that keeps the tool store alive.
◦ Options:
▪︎ Start the transfer in a background context that doesn’t inherit the tool’s AsyncLocalStorage (fire-and-forget). For example, run the transfer via an outer background runner or scheduler that lives outside runInToolContext.
▪︎ Return immediately from the tool with a lightweight instruction or task-ID, and have an outer controller pick up and run the long-lived AgentTask so the tool context can fully close.
• Use an explicit background runner API if available
◦ If your runtime provides a runInBackground, context.runBackgroundTask, or equivalent that is deliberately outside the tool store, prefer that to launching the AgentTask under the tool’s AsyncLocalStorage.
Why this happens (concise)
• The function tool uses AsyncLocalStorage to mark the active tool context. If you spawn an AgentTask inside that context and it doesn’t fully finish before the tool returns (or it inherits the store), the store can remain active until that task finishes.
• When your event listener later calls wait_for_playout (and the store still indicates a tool is/was active), the system rejects SpeechHandle.waitForPlayout() to prevent circular waits.
Concrete suggestions you can try now
• Quick change: remove await ctx.waitForPlayout(); from inside the tool and ensure the caller waits for playout before calling the tool.
• If you must start SIPTransferTask from the tool:
a. Have the tool return immediately after starting a non-blocking transfer trigger that does not inherit the tool store (explicitly spawn it in an outer/background context).
b. Or return a token/ID and let an outer loop pick up and run SIPTransferTask outside runInToolContext.
What I can help with next
• If you share the minimal surrounding code for:
◦ how/where speech is started,
◦ where you detect user-away and call wait_for_playout,
◦ the implementation of SIPTransferTask.run() and how it’s spawned,
I’ll point to the exact lines to change and show a short before/after snippet converting it to a safe background start or moving the wait out of the tool.
If you prefer, I can draft a small example that:
• 1) plays speech,
• 2) awaits context.wait_for_playout() outside the tool,
• 3) then invokes a transfer tool which starts an external background transfer task.