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/quiet-handoff-tasks.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 18 additions & 1 deletion agents/src/voice/agent_activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -178,7 +179,8 @@ describe('AgentActivity parked preemptive generation', () => {
override async onExit(): Promise<void> {
const activity = session._activity! as ActivityInternals;
startPreemptiveGeneration(activity);
expect(activity._preemptiveGeneration).toBeDefined();
expect(activity._preemptiveGeneration).toBeUndefined();
expect(chat).not.toHaveBeenCalled();
}
}

Expand All @@ -193,6 +195,7 @@ describe('AgentActivity parked preemptive generation', () => {
} finally {
llm.release();
await session.close().catch(() => {});
chat.mockRestore();
}
});
});
1 change: 1 addition & 0 deletions agents/src/voice/agent_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1503,6 +1503,7 @@ export class AgentSession<
newActivity: this.nextActivity,
});
} else {
prevActivityObj.blockNewTurns();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
reusableResources = await prevActivityObj.drain({
newActivity: this.nextActivity,
});
Expand Down
5 changes: 5 additions & 0 deletions agents/src/voice/agent_session_handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
78 changes: 77 additions & 1 deletion agents/src/voice/agent_task_close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<unknown>();
const failure = new ToolError('task failed');
const task = AgentTask.create<string>({
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();
}
});
Loading
Loading