```Error during silence message playout: Error: Ca...
# ask-ai
a
Copy code
Error 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.
t
Thanks for reaching out, Ahron. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — this is a helpful report. I’m responding to the error you pasted about calling
SpeechHandle.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:
Copy code
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 Documentation
a
I have a an event listener that calls
wait_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.
Copy code
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 function
t
Thanks — this is a clear, useful follow-up. I’m responding to your example where you call
ctx.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.