feat(agents): serialize the inline AgentTasks awaited from one turn - #6862
feat(agents): serialize the inline AgentTasks awaited from one turn#6862u9g wants to merge 2 commits into
Conversation
| async with self._inline_task_lock: | ||
| if self._closed: | ||
| raise ToolError( | ||
| "the activity that awaited the inline task closed while an earlier " | ||
| "one was running" | ||
| ) |
There was a problem hiding this comment.
🟡 A queued task can start a new sub-conversation for a speech that was already cut off
The check that the awaiting speech is still alive happens only before queuing (speech_handle.interrupted at livekit-agents/livekit/agents/voice/agent_activity.py:1221) and is not repeated once the queued task's turn comes, so a task can start a whole agent switch for a speech that was forcibly cut off while it waited.
Impact: After a forced interruption (e.g. session close), a second sub-conversation can still be started and then be aborted mid-switch, leaving the conversation in an inconsistent state.
Why the pre-queue check is insufficient now that tasks queue
The counted interruption hold (SpeechHandle._hold_interruptions) blocks ordinary interruptions, but interrupt(force=True) bypasses it — AgentSession._aclose_impl does exactly that (livekit-agents/livekit/agents/voice/agent_session.py:1227), as does AgentTask.cancel() (livekit-agents/livekit/agents/voice/agent.py:847-848).
If that happens while a task is queued on _inline_task_lock, the task proceeds when it gets the slot: it pauses the parent activity, starts its own activity and on_enter, and waits for user input. Meanwhile SpeechHandle._cancel armed a 5s timeout that cancels the speech's tasks (livekit-agents/livekit/agents/voice/speech_handle.py:265-277), so the queued task's tool task gets cancelled mid-handoff; the cancellation lands inside the finally handoff-back block of AgentTask.__await_impl. Before this PR the pre-await check guaranteed the invariant "the parent speech is not interrupted" still held at handoff time; with queuing it no longer does.
Was this helpful? React with 👍 or 👎 to provide feedback.
a596f06 to
665b583
Compare
…nish callback Hoists the context-var reads and the already-interrupted check above the _handle_task_done definition in AgentTask.__await_impl, so every path that rejects the await outright runs before the callback is registered. The callback exists to complete the task when its asyncio.Task finishes first. An early raise was tripping it, reporting a task that never started as having finished prematurely.
An LLM turn with parallel tool calls can await an AgentTask from more than one call. Each pauses the same activity, only the last handoff survives, and the losers are never active again so nothing completes them - their function calls never return and the session hangs until close times out. Pausing an activity is now a slot on that activity, held for the lifetime of the inline task that took it, so the tasks of one turn queue and run in turn. The slot belongs to the activity because the activity is what a handoff contends for: a nested task pauses the activity of the task it is nested in, so it takes that activity's slot and never waits on the one its parent holds. Each step around the queue inverts into its own hang, which is why they live in the activity rather than at the call site: the interruption hold is taken before the queue and counted, since the queued tasks share one speech handle and a hold released between them lets one task's sub-conversation interrupt the speech the rest are anchored to; drain registration stays before the queue, or session close waits on the slot a task is still queued for; run watching moves past the queue, or a run waits for the user input the task ahead of it needs.
65208b5 to
96d94d6
Compare
Inline
AgentTasks awaited from one turn's parallel tool calls now queue for a slot on the activity they pause and take it in turn, instead of overwriting each other's handoffs and leaving the session wedged until close times out; scoping the slot to the activity is what keeps a nested task, which pauses a different one, from waiting on its parent. Supersedes #6860.