diff --git a/.changeset/quiet-handoff-tasks.md b/.changeset/quiet-handoff-tasks.md new file mode 100644 index 0000000000..945d7fbb18 --- /dev/null +++ b/.changeset/quiet-handoff-tasks.md @@ -0,0 +1,5 @@ +--- +'@livekit/agents': patch +--- + +Reject late inline AgentTasks on an outgoing activity so handoff and shutdown do not deadlock while draining non-cancellable tools. diff --git a/agents/src/voice/agent_activity.ts b/agents/src/voice/agent_activity.ts index 153ef000ed..c6482d4fdc 100644 --- a/agents/src/voice/agent_activity.ts +++ b/agents/src/voice/agent_activity.ts @@ -4997,6 +4997,15 @@ export class AgentActivity implements RecognitionHooks { if (this.closed || this.agentSession._closing) { throw new ToolError('the activity that awaited the inline task is closing'); } + // A handoff holds the session transition lock while draining this task's owner. + // Check after acquiring the slot so a preceding inline task can resume us first. + if (this.newTurnsBlocked) { + throw new ToolError( + 'An agent transition is in progress, so this tool call cannot continue. ' + + 'Wait until the transition is complete before retrying, if the tool is ' + + 'available to the new agent.', + ); + } // A run must only watch this task once it has the slot. Otherwise it would wait // for user input needed by the task currently ahead of it. @@ -5147,7 +5156,15 @@ export class AgentActivity implements RecognitionHooks { this.cancelPreemptiveGeneration(); - await this._onExitTask.result; + try { + await this._onExitTask.result; + } catch (error) { + if (this._onExitTask.cancelled) throw error; + this.logger.error( + { 'lk.pii.error': error instanceof Error ? error.message : String(error) }, + 'error in agent onExit', + ); + } await this._pauseSchedulingTask([]); // detach after speech tasks are done but before _closeSessionResources diff --git a/agents/src/voice/agent_activity_preemptive_pause_deadlock.test.ts b/agents/src/voice/agent_activity_preemptive_pause_deadlock.test.ts index 94f82adf14..9c46d5bd77 100644 --- a/agents/src/voice/agent_activity_preemptive_pause_deadlock.test.ts +++ b/agents/src/voice/agent_activity_preemptive_pause_deadlock.test.ts @@ -165,8 +165,9 @@ describe('AgentActivity parked preemptive generation', () => { } }); - it('does not let a reply parked while pausing block the handoff', async () => { + it('does not start a parked preemptive reply during onExit', async () => { const llm = new GatedLLM(); + const chat = vi.spyOn(llm, 'chat'); const session = new AgentSession({ llm }); session.output.setAudioEnabled(false); @@ -178,7 +179,8 @@ describe('AgentActivity parked preemptive generation', () => { override async onExit(): Promise { const activity = session._activity! as ActivityInternals; startPreemptiveGeneration(activity); - expect(activity._preemptiveGeneration).toBeDefined(); + expect(activity._preemptiveGeneration).toBeUndefined(); + expect(chat).not.toHaveBeenCalled(); } } @@ -193,6 +195,7 @@ describe('AgentActivity parked preemptive generation', () => { } finally { llm.release(); await session.close().catch(() => {}); + chat.mockRestore(); } }); }); diff --git a/agents/src/voice/agent_session.ts b/agents/src/voice/agent_session.ts index 2a1faf9145..138197c455 100644 --- a/agents/src/voice/agent_session.ts +++ b/agents/src/voice/agent_session.ts @@ -1503,6 +1503,7 @@ export class AgentSession< newActivity: this.nextActivity, }); } else { + prevActivityObj.blockNewTurns(); reusableResources = await prevActivityObj.drain({ newActivity: this.nextActivity, }); diff --git a/agents/src/voice/agent_session_handoff.test.ts b/agents/src/voice/agent_session_handoff.test.ts index c0585ccfc0..a0e4e2ec85 100644 --- a/agents/src/voice/agent_session_handoff.test.ts +++ b/agents/src/voice/agent_session_handoff.test.ts @@ -61,6 +61,7 @@ describe('AgentSession reusable resources handoff', () => { const nextAgent = new Agent({ instructions: 'new' }); const previousActivity = { agent: previousAgent, + blockNewTurns: vi.fn(), drain: vi.fn(async () => resources), close: vi.fn(async () => {}), pause: vi.fn(async () => resources), @@ -95,6 +96,7 @@ describe('AgentSession reusable resources handoff', () => { const nextAgent = new Agent({ instructions: 'new' }); const previousActivity = { agent: previousAgent, + blockNewTurns: vi.fn(), drain: vi.fn(async () => resources), close: vi.fn(async () => {}), pause: vi.fn(async () => resources), @@ -132,6 +134,7 @@ describe('AgentSession reusable resources handoff', () => { const nextAgent = new Agent({ instructions: 'new' }); const previousActivity = { agent: previousAgent, + blockNewTurns: vi.fn(), drain: vi.fn(async () => resources), close: vi.fn(async () => {}), pause: vi.fn(async () => resources), @@ -195,6 +198,7 @@ describe('AgentSession reusable resources handoff', () => { const nextAgent = new Agent({ instructions: 'new' }); const previousActivity = { agent: previousAgent, + blockNewTurns: vi.fn(), drain: vi.fn(async () => undefined), close: vi.fn(async () => {}), pause: vi.fn(async () => undefined), @@ -228,6 +232,7 @@ describe('AgentSession reusable resources handoff', () => { const nextAgent = new Agent({ instructions: 'new' }); const previousActivity = { agent: previousAgent, + blockNewTurns: vi.fn(), drain: vi.fn(async () => undefined), close: vi.fn(async () => {}), pause: vi.fn(async () => undefined), diff --git a/agents/src/voice/agent_task_close.test.ts b/agents/src/voice/agent_task_close.test.ts index 836b42efc0..aa7b0f90be 100644 --- a/agents/src/voice/agent_task_close.test.ts +++ b/agents/src/voice/agent_task_close.test.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 import { expect, it, vi } from 'vitest'; import { ToolError, tool } from '../llm/tool_context.js'; -import { Future } from '../utils.js'; +import { Future, Task } from '../utils.js'; import { Agent, AgentTask } from './agent.js'; import { AgentActivity } from './agent_activity.js'; import { AgentSession } from './agent_session.js'; @@ -219,3 +219,79 @@ it.each([ }, 5_000, ); + +it.each(['success', 'failure'] as const)( + 'resumes the parent after task onExit throws (result=%s)', + async (outcome) => { + const result = new Future(); + const failure = new ToolError('task failed'); + const task = AgentTask.create({ + instructions: 'finish the task', + onEnter: () => task.complete(outcome === 'success' ? 'done' : failure), + onExit: async () => { + throw new Error('onExit failed'); + }, + }); + const answered = vi.fn(); + const agent = Agent.create({ + instructions: 'parent', + tools: [ + tool({ + name: 'transfer', + description: 'Run the task.', + execute: async () => { + try { + const value = await task.run(); + result.resolve(value); + return value; + } catch (error) { + result.resolve(error); + throw error; + } + }, + }), + tool({ name: 'answer', description: 'Answer the next turn.', execute: answered }), + ], + }); + const session = new AgentSession({ + llm: new FakeLLM([ + { input: 'transfer', toolCalls: [{ name: 'transfer', args: {} }] }, + { input: 'next turn', toolCalls: [{ name: 'answer', args: {} }] }, + ]), + turnHandling: { turnDetection: 'manual' }, + }); + try { + await session.start({ agent }); + const parentActivity = agent._agentActivity!; + session.generateReply({ userInput: 'transfer' }); + expect(await result.await).toBe(outcome === 'success' ? 'done' : failure); + expect(session._activity).toBe(parentActivity); + expect(parentActivity.schedulingPaused).toBe(false); + expect(task._agentActivity).toBeUndefined(); + await vi.waitFor(() => expect(parentActivity.currentSpeech).toBeUndefined()); + session.generateReply({ userInput: 'next turn' }); + await vi.waitFor(() => expect(answered).toHaveBeenCalledOnce()); + } finally { + await session.close(); + } + }, +); + +it('propagates onExit cancellation from drain', async () => { + const cancellation = new Error('onExit cancelled'); + cancellation.name = 'AbortError'; + const onExit = vi.fn().mockImplementationOnce(() => { + Task.current()!.cancel(); + throw cancellation; + }); + const agent = Agent.create({ instructions: 'parent', onExit }); + const session = new AgentSession({ llm: new FakeLLM([]) }); + try { + await session.start({ agent }); + const activity = agent._agentActivity!; + await expect(activity.drain()).rejects.toBe(cancellation); + expect(activity.schedulingPaused).toBe(false); + } finally { + await session.close(); + } +}); diff --git a/agents/src/voice/agent_task_handoff_drain.test.ts b/agents/src/voice/agent_task_handoff_drain.test.ts new file mode 100644 index 0000000000..a703464b2e --- /dev/null +++ b/agents/src/voice/agent_task_handoff_drain.test.ts @@ -0,0 +1,410 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import { expect, it, onTestFinished, vi } from 'vitest'; +import { FunctionCall } from '../llm/chat_context.js'; +import { ToolError, handoff, tool } from '../llm/tool_context.js'; +import { Future } from '../utils.js'; +import { Agent, AgentTask } from './agent.js'; +import { AgentSession } from './agent_session.js'; +import { FakeLLM } from './testing/fake_llm.js'; + +const transitionError = + 'An agent transition is in progress, so this tool call cannot continue. ' + + 'Wait until the transition is complete before retrying, if the tool is ' + + 'available to the new agent.'; + +// The two turns must not reuse FakeLLM's default call ID while a tool is still running. +class UniqueCallLLM extends FakeLLM { + private generation = 0; + + override chat(options: Parameters[0]) { + const stream = super.chat(options); + const generation = ++this.generation; + const next = stream.next.bind(stream); + stream.next = async () => { + const result = await next(); + if (!result.done && result.value.delta?.toolCalls) { + result.value.delta.toolCalls = result.value.delta.toolCalls.map((call) => + FunctionCall.create({ ...call, callId: `${generation}_${call.callId}` }), + ); + } + return result; + }; + return stream; + } +} + +function createFixture(toolErrorGate?: Future) { + const admission = new Future(); + const started = new Future(); + const result = new Future(); + const exiting = new Future(); + const finishExit = new Future(); + const switchStarted = new Future(); + const finishSwitch = new Future(); + const taskEntered = vi.fn(); + const targetEntered = vi.fn(); + const target = Agent.create({ instructions: 'target', onEnter: targetEntered }); + const task = AgentTask.create({ + instructions: 'complete immediately', + onEnter: () => { + taskEntered(); + task.complete('done'); + }, + }); + const agent = Agent.create({ + instructions: 'source', + onExit: async () => { + exiting.resolve(); + await finishExit.await; + }, + tools: [ + tool({ + name: 'transfer', + description: 'Run an inline task after admission.', + execute: async (_args, { ctx }) => { + ctx.speechHandle.allowInterruptions = false; + started.resolve(); + await admission.await; + try { + const value = await task.run(); + result.resolve(value); + return value; + } catch (error) { + result.resolve(error); + if (toolErrorGate) await toolErrorGate.await; + throw error; + } + }, + }), + tool({ + name: 'switch', + description: 'Hand off to the target.', + execute: async () => { + switchStarted.resolve(); + await finishSwitch.await; + return handoff({ agent: target }); + }, + }), + ], + }); + const llm = new UniqueCallLLM([ + { input: 'transfer', toolCalls: [{ name: 'transfer', args: {} }] }, + { input: 'switch', toolCalls: [{ name: 'switch', args: {} }] }, + ]); + const session = new AgentSession({ llm, turnHandling: { turnDetection: 'manual' } }); + onTestFinished(async () => { + admission.resolve(); + finishExit.resolve(); + toolErrorGate?.resolve(); + finishSwitch.resolve(); + await session.close(); + expect(agent._agentActivity).toBeUndefined(); + expect(task._agentActivity).toBeUndefined(); + expect(target._agentActivity).toBeUndefined(); + }); + return { + session, + agent, + target, + taskEntered, + targetEntered, + llm, + admission, + started, + result, + exiting, + finishExit, + switchStarted, + finishSwitch, + }; +} + +type Fixture = ReturnType; + +async function startPendingTool(f: Fixture) { + await f.session.start({ agent: f.agent }); + f.session.generateReply({ userInput: 'transfer', allowInterruptions: false }); + await f.started.await; + await vi.waitFor(() => expect(f.agent._agentActivity!.currentSpeech).toBeUndefined()); +} + +async function expectRejected(f: Fixture) { + expect(await f.result.await).toBeInstanceOf(ToolError); + expect(await f.result.await).toHaveProperty('message', transitionError); + expect(f.taskEntered).not.toHaveBeenCalled(); +} + +async function expectHandoff(f: Fixture) { + await vi.waitFor(() => expect(f.targetEntered).toHaveBeenCalledOnce()); + expect(f.session.currentAgent).toBe(f.target); + await f.session.close(); + const calls = f.agent.chatCtx.items.filter( + (item) => item.type === 'function_call' && item.name === 'transfer', + ); + expect(calls).toHaveLength(1); + const outputs = f.agent.chatCtx.items.filter( + (item) => item.type === 'function_call_output' && item.callId === calls[0]!.callId, + ); + expect(outputs).toHaveLength(1); + expect(outputs[0]).toMatchObject({ isError: true, output: transitionError }); +} + +it('completes an inline task before handoff', async () => { + const f = createFixture(); + await startPendingTool(f); + f.admission.resolve(); + expect(await f.result.await).toBe('done'); + expect(f.taskEntered).toHaveBeenCalledOnce(); + f.finishSwitch.resolve(); + f.finishExit.resolve(); + f.session.generateReply({ userInput: 'switch' }); + await vi.waitFor(() => expect(f.targetEntered).toHaveBeenCalledOnce()); + expect(f.session.currentAgent).toBe(f.target); +}); + +it('rejects an inline task while handoff waits for the transition lock', async () => { + const f = createFixture(); + await startPendingTool(f); + const unlock = await f.session['activityLock'].lock(); + try { + f.session.updateAgent(f.target); + f.admission.resolve(); + await expectRejected(f); + expect(f.exiting.done).toBe(false); + expect(f.agent._agentActivity!.schedulingPaused).toBe(false); + } finally { + unlock(); + } + f.finishExit.resolve(); + await expectHandoff(f); +}); + +it('rejects an inline task during a direct activity transition', async () => { + const f = createFixture(); + await startPendingTool(f); + const transition = f.session._updateActivity(f.target, { waitOnEnter: false }); + try { + await f.exiting.await; + expect(f.agent._agentActivity!.schedulingPaused).toBe(false); + f.admission.resolve(); + await expectRejected(f); + } finally { + f.admission.resolve(); + f.finishExit.resolve(); + await transition; + } + await expectHandoff(f); +}); + +it('rejects a tool that starts after handoff drain has begun', async () => { + const f = createFixture(); + const generationStarted = new Future(); + const emitTool = new Future(); + const chat = f.llm.chat.bind(f.llm); + const chatSpy = vi.spyOn(f.llm, 'chat').mockImplementation((options) => { + const stream = chat(options); + const next = stream.next.bind(stream); + stream.next = async () => { + const chunk = await next(); + if (!chunk.done && chunk.value.delta?.toolCalls?.some((call) => call.name === 'transfer')) { + generationStarted.resolve(); + await emitTool.await; + } + return chunk; + }; + return stream; + }); + try { + await f.session.start({ agent: f.agent }); + f.session.generateReply({ userInput: 'switch' }); + await f.switchStarted.await; + await vi.waitFor(() => expect(f.agent._agentActivity!.currentSpeech).toBeUndefined()); + f.session.generateReply({ userInput: 'transfer', allowInterruptions: false }); + await generationStarted.await; + f.finishSwitch.resolve(); + f.finishExit.resolve(); + await vi.waitFor(() => expect(f.agent._agentActivity!.schedulingPaused).toBe(true)); + expect(f.started.done).toBe(false); + emitTool.resolve(); + await f.started.await; + f.admission.resolve(); + await expectRejected(f); + await expectHandoff(f); + } finally { + emitTool.resolve(); + chatSpy.mockRestore(); + } +}); + +it.each([false, true])( + 'rejects an inline task during handoff onExit (shutdown=%s)', + async (shutdown) => { + const finishTool = new Future(); + const f = createFixture(shutdown ? finishTool : undefined); + await startPendingTool(f); + f.finishSwitch.resolve(); + f.session.generateReply({ userInput: 'switch' }); + await f.exiting.await; + expect(f.agent._agentActivity!.schedulingPaused).toBe(false); + f.admission.resolve(); + await expectRejected(f); + const closing = shutdown ? f.session.close() : undefined; + finishTool.resolve(); + f.finishExit.resolve(); + if (closing) { + await closing; + expect(f.targetEntered).not.toHaveBeenCalled(); + } else { + await expectHandoff(f); + } + }, +); + +it.each([false, true])( + 'rejects an inline task during handoff drain (shutdown=%s)', + async (shutdown) => { + const finishTool = new Future(); + const f = createFixture(shutdown ? finishTool : undefined); + await startPendingTool(f); + f.finishSwitch.resolve(); + f.finishExit.resolve(); + f.session.generateReply({ userInput: 'switch' }); + await vi.waitFor(() => expect(f.agent._agentActivity!.schedulingPaused).toBe(true)); + f.admission.resolve(); + await expectRejected(f); + const closing = shutdown ? f.session.close() : undefined; + finishTool.resolve(); + if (closing) { + await closing; + expect(f.targetEntered).not.toHaveBeenCalled(); + } else { + await expectHandoff(f); + } + }, +); + +it.each([false, true])( + 'rejects an inline task on a queued intermediate agent (shutdown=%s)', + async (shutdown) => { + const finishTool = new Future(); + const f = createFixture(shutdown ? finishTool : undefined); + const root = Agent.create({ instructions: 'root' }); + const enter = vi.spyOn(f.agent, 'onEnter').mockImplementation(async () => { + f.session.generateReply({ userInput: 'transfer' }); + }); + try { + await f.session.start({ agent: root }); + // Both requests initially block root, not the intermediate source activity. + f.session.updateAgent(f.agent); + f.session.updateAgent(f.target); + await f.started.await; + await vi.waitFor(() => expect(f.agent._agentActivity!.currentSpeech).toBeUndefined()); + await f.exiting.await; + expect(f.agent._agentActivity!.schedulingPaused).toBe(false); + f.admission.resolve(); + await expectRejected(f); + const closing = shutdown ? f.session.close() : undefined; + finishTool.resolve(); + f.finishExit.resolve(); + if (closing) { + await closing; + expect(f.targetEntered).not.toHaveBeenCalled(); + } else { + await expectHandoff(f); + } + expect(root._agentActivity).toBeUndefined(); + } finally { + enter.mockRestore(); + } + }, +); + +it.each([false, true])( + 'allows an in-flight tool to await an AgentTask during standalone drain (generationPending=%s)', + async (generationPending) => { + const started = new Future(); + const releaseTool = new Future(); + const releaseGeneration = new Future(); + const completed = new Future(); + let drained = false; + let draining: Promise | undefined; + const task = AgentTask.create({ + instructions: 'complete immediately', + onEnter: () => { + expect(drained).toBe(true); + task.complete('done'); + }, + }); + const agent = Agent.create({ + instructions: 'source', + tools: [ + tool({ + name: 'transfer', + description: 'Run an inline task.', + execute: async (_args, { ctx }) => { + ctx.speechHandle.allowInterruptions = false; + started.resolve(); + await releaseTool.await; + try { + const value = await task.run(); + completed.resolve(value); + return value; + } catch (error) { + completed.resolve(error); + throw error; + } + }, + }), + ], + }); + const llm = new UniqueCallLLM([ + { input: 'transfer', toolCalls: [{ name: 'transfer', args: {} }] }, + ]); + const chat = llm.chat.bind(llm); + llm.chat = (options) => { + const stream = chat(options); + const next = stream.next.bind(stream); + stream.next = async () => { + const chunk = await next(); + if (chunk.done) await releaseGeneration.await; + return chunk; + }; + return stream; + }; + const session = new AgentSession({ llm, turnHandling: { turnDetection: 'manual' } }); + try { + await session.start({ agent }); + const activity = agent._agentActivity!; + const speech = session.generateReply({ userInput: 'transfer' }); + await started.await; + if (!generationPending) { + releaseGeneration.resolve(); + await vi.waitFor(() => expect(activity.currentSpeech).toBeUndefined()); + } + + // Node has no public session.drain(); exercise the same activity drain directly. + draining = activity.drain().then(() => { + drained = true; + }); + await vi.waitFor(() => expect(activity.schedulingPaused).toBe(true)); + if (generationPending) expect(activity.currentSpeech).toBe(speech); + releaseTool.resolve(); + if (generationPending) { + await vi.waitFor(() => expect(activity['_drainBlockedTasks'].size).toBeGreaterThan(0)); + expect(drained).toBe(false); + releaseGeneration.resolve(); + } + await draining; + expect(await completed.await).toBe('done'); + expect(session._activity).toBe(activity); + expect(activity.schedulingPaused).toBe(false); + } finally { + releaseTool.resolve(); + releaseGeneration.resolve(); + await draining; + await session.close(); + } + }, +);