Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/guard-agent-task-handoff-run-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Fix `session.run()` stalling or racing the activity transition when an AgentTask handoff is triggered by a speech that predates the run (e.g. created in `onEnter`): the blocked handoff tasks are now watched by the active run for the duration of the transition.
43 changes: 32 additions & 11 deletions agents/src/voice/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,13 @@ export class AgentTask<ResultT = unknown, UserData = any> extends Agent<UserData
blockedTasks.push(onEnterTask);
}

const activeRunState = session._globalRunState;
if (activeRunState && !activeRunState.done()) {
for (const task of blockedTasks) {
activeRunState._watchHandle(task);
}
}
Comment thread
toubatbrian marked this conversation as resolved.

if (
taskInfo.functionCall &&
oldActivity.llm instanceof RealtimeModel &&
Expand All @@ -695,23 +702,35 @@ export class AgentTask<ResultT = unknown, UserData = any> extends Agent<UserData
);
}

const suspendedHandles: Array<SpeechHandle | Task<void>> = [];

await session._updateActivity(this, {
previousActivity: 'pause',
newActivity: 'start',
blockedTasks,
});

let runState = session._globalRunState;
if (speechHandle && runState && !runState.done()) {
// Only unwatch the parent speech handle if there are other handles keeping the run alive.
// When watchedHandleCount is 1 (only the parent), unwatching would drop it to 0 and
// mark the run done prematurely — before function_call_output and assistant message arrive.
if (runState._watchedHandleCount() > 1) {
runState._unwatchHandle(speechHandle);
if (runState && !runState.done()) {
Comment thread
toubatbrian marked this conversation as resolved.
if (speechHandle && runState._unwatchHandle(speechHandle)) {
suspendedHandles.push(speechHandle);
}

for (const task of blockedTasks) {
if (runState._unwatchHandle(task)) {
suspendedHandles.push(task);
}
}

// It is OK to call _markDoneIfNeeded here: _updateActivity has started
// the AgentTask activity, and any onEnter-generated speech is now watched.
// Only call it when something was actually suspended — a run created
// mid-transition has no watched handles yet (its generateReply is still
// deferred behind the activity lock), and marking done on an empty
// handle set would resolve it before it produced any events.
if (suspendedHandles.length > 0) {
runState._markDoneIfNeeded();
}
// it is OK to call _markDoneIfNeeded here, the above _updateActivity will call onEnter
// and newly added handles keep the run alive.
runState._markDoneIfNeeded();
}
Comment thread
toubatbrian marked this conversation as resolved.

try {
Expand All @@ -727,8 +746,10 @@ export class AgentTask<ResultT = unknown, UserData = any> extends Agent<UserData
);
await oldActivity.close();
} else {
if (speechHandle && runState && !runState.done()) {
runState._watchHandle(speechHandle);
if (runState && !runState.done()) {
for (const handle of suspendedHandles) {
runState._watchHandle(handle);
}
}

const mergedChatCtx = oldAgent._chatCtx.merge(this._chatCtx, {
Expand Down
135 changes: 135 additions & 0 deletions agents/src/voice/agent_task_prerun_handoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
//
// Regression test for AgentTask handoffs triggered by a speech that predates
// the active run (livekit/agents#6313, ported from livekit/agents#6315).
//
// When AgentSession.start() runs onEnter and its reply calls a tool that
// awaits an AgentTask, nothing watches the tasks driving that handoff. The
// first session.run() then completes as soon as its own reply finishes —
// while the handoff is still mid-transition — and the run hangs or the next
// run races the transition. The fix watches the blocked handoff tasks on the
// active run for the duration of the activity transition.
//
// Without the fix, run('hi') below never resolves (the session stalls
// mid-handoff) and the test times out.
// Ref: python tests/test_nested_agent_task.py - test_handoff_from_pre_run_speech
import { describe, expect, it } from 'vitest';
import { tool } from '../llm/tool_context.js';
import { initializeLogger } from '../log.js';
import { Agent, AgentTask } from './agent.js';
import { AgentSession } from './agent_session.js';
import { FakeLLM } from './testing/fake_llm.js';

let taskOnEnterCompletedAt = 0;

class SimpleTask extends AgentTask<null> {
constructor() {
super({
instructions: 'simple task',
tools: [
tool({
name: 'finish',
description: 'Called to complete the task.',
execute: async () => {
this.complete(null);
return 'done';
},
}),
],
});
}

async onEnter(): Promise<void> {
// Widen the mid-transition window (old activity paused, new activity
// still starting) so a run completing early is deterministically caught.
await new Promise((r) => setTimeout(r, 500));
this.session.generateReply({ userInput: 'task_greeting' });
taskOnEnterCompletedAt = Date.now();
}
}

class EnterHandoffAgent extends Agent {
constructor() {
super({
instructions: 'root agent',
tools: [
tool({
name: 'start_task',
description: 'Transitions into SimpleTask.',
execute: async () => {
await new SimpleTask().run();
return 'task completed';
},
}),
],
});
}

async onEnter(): Promise<void> {
// This speech predates any session.run(), so no run watches it.
const handle = this.session.generateReply({ userInput: 'enter_greeting' });
await handle.waitForPlayout();
}
}

function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
return Promise.race([
p,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), ms),
),
]);
}

describe('AgentTask handoff from pre-run speech', () => {
initializeLogger({ pretty: false, level: 'silent' });

it('keeps the run alive until the handoff settles', async () => {
const llm = new FakeLLM([
// onEnter reply -> calls start_task; slow enough that run('hi') starts
// before the tool call lands
{
input: 'enter_greeting',
content: '',
ttft: 1000,
duration: 1000,
toolCalls: [{ name: 'start_task', args: {} }],
},
// user says "hi" while the handoff is in flight; the only speech the
// first run watches on its own
{ input: 'hi', content: 'hello!', ttft: 1000, duration: 2000 },
// SimpleTask onEnter greeting
{ input: 'task_greeting', content: 'hello from task' },
// user says "bye" -> LLM calls finish
{
input: 'bye',
content: '',
toolCalls: [{ name: 'finish', args: {} }],
},
// after start_task tool output, LLM responds
{ input: 'task completed', content: 'all done' },
]);

const session = new AgentSession({ llm });

try {
await session.start({ agent: new EnterHandoffAgent() });

await withTimeout(session.run({ userInput: 'hi' }).wait(), 10_000, "run('hi')");
const runResolvedAt = Date.now();
expect(session.currentAgent).toBeInstanceOf(SimpleTask);

// the run must not complete mid-handoff: the new activity's onEnter
// must already have finished when the run resolves
expect(taskOnEnterCompletedAt).toBeGreaterThan(0);
expect(runResolvedAt).toBeGreaterThanOrEqual(taskOnEnterCompletedAt);

await withTimeout(session.run({ userInput: 'bye' }).wait(), 10_000, "run('bye')");
expect(session.currentAgent).toBeInstanceOf(EnterHandoffAgent);
} finally {
await session.close().catch(() => {});
}
}, 30_000);
});
6 changes: 4 additions & 2 deletions agents/src/voice/testing/run_result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,8 @@ export class RunResult<T = unknown> {
* @internal
* Unwatch a handle.
*/
_unwatchHandle(handle: SpeechHandle | Task<void>): void {
this.handles.delete(handle);
_unwatchHandle(handle: SpeechHandle | Task<void>): boolean {
const wasWatched = this.handles.delete(handle);
const doneCallback = this.doneCallbacks.get(handle);

if (doneCallback) {
Expand All @@ -207,6 +207,8 @@ export class RunResult<T = unknown> {
if (isSpeechHandle(handle)) {
handle._removeItemAddedCallback(this.itemAddedCallback);
}

return wasWatched;
}

/** @internal */
Expand Down
Loading