Skip to content

feat(agents): serialize the inline AgentTasks awaited from one turn - #6862

Open
u9g wants to merge 2 commits into
mainfrom
jason/serialize-inline-agent-tasks
Open

feat(agents): serialize the inline AgentTasks awaited from one turn#6862
u9g wants to merge 2 commits into
mainfrom
jason/serialize-inline-agent-tasks

Conversation

@u9g

@u9g u9g commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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.

@u9g
u9g requested a review from a team as a code owner August 14, 2026 21:04

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread livekit-agents/livekit/agents/voice/agent_activity.py
Comment on lines +1231 to +1236
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"
)

@devin-ai-integration devin-ai-integration Bot Aug 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@u9g
u9g force-pushed the jason/serialize-inline-agent-tasks branch from a596f06 to 665b583 Compare August 18, 2026 19:32
@CLAassistant

CLAassistant commented Aug 18, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@u9g
u9g changed the base branch from jason/inline-agent-task-concurrency-guard to main August 18, 2026 19:37
devin-ai-integration[bot]

This comment was marked as resolved.

u9g added 2 commits August 18, 2026 18:13
…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.
@u9g
u9g force-pushed the jason/serialize-inline-agent-tasks branch from 65208b5 to 96d94d6 Compare August 18, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants