acoustic-pharmacist-34178
03/08/2026, 2:07 PMclass MyTask extends voice.AgentTask<string> {
constructor() {
super({instructions: 'Testing AgentTask...', allowInterruptions: true});
}
override async onEnter(): Promise<void> {
this.session.say('Hello from MyTask!', { allowInterruptions: true });
this.complete('Task completed successfully.');
}
}
export class MyAgent extends voice.Agent {
constructor() {
super({
instructions: 'You are a helpful assistant.',
tools: {
testTool: llm.tool({
description: 'Call when user request to execute to test this tool.',
parameters: {},
execute: async (_, { ctx }) => {
console.log('Executing test tool...');
await ctx.waitForPlayout();
return new MyTask().run();
}
}),
hangupTool: llm.tool({
description: 'Hang up the call when the user requests to end the call.',
parameters: {},
execute: async (_, { ctx }) => {
console.log('Executing hangup tool, ending call...');
await ctx.waitForPlayout();
const roomServiceClient = getRoomServiceClient();
await roomServiceClient.deleteRoom(getJobContext().room?.name!);
}
})
}
});
}
}
There seems to be a bug in the JS SDK when the first tool is executed and returns, it won't execute any other tools anymoretall-airline-16756
03/08/2026, 2:07 PMtall-airline-16756
03/08/2026, 2:08 PM@livekit/agents or similar and the version number).
• Reproduction steps
◦ Exact steps you take to trigger the first tool and then the subsequent tool (including what user input or event causes each tool to be invoked).
◦ Whether this happens every time or only sometimes.
• Minimal repro
◦ A small self-contained snippet or repository that reproduces the issue (the snippet you posted is helpful; if you can reduce it to the smallest possible example that demonstrates the problem, that’s ideal).
• Runtime environment
◦ Node.js version, OS, whether running locally or in a cloud/container environment.
• Logs and errors
◦ Any console logs, stack traces, or warnings shown when the first tool returns and subsequent tools fail to run.
◦ If you can enable debug-level logging (or show the output of existing logs) from the Agent runtime, include that.
• Agent/tool details
◦ How are tools being selected/invoked? (e.g., are tools invoked by user utterances parsed by the LLM, or invoked programmatically?)
◦ Are you seeing any state left behind after the first tool (e.g., tasks left running, session state not updated, promises unresolved)?
• Any differences that change behavior
◦ Do things change if you remove await ctx.waitForPlayout() calls or change allowInterruptions? Do synchronous vs. asynchronous tool returns matter?
What I can try next once you provide that:
• Reproduce with your versions and minimal example.
• Check whether waitForPlayout() or AgentTask.run() is leaving the agent in a state that prevents further tool dispatches.
• Walk through likely causes:
◦ tool selection/state machine not advancing after a task completes,
◦ unresolved promises or unhandled rejections,
◦ the Agent being marked finished after the first tool return,
◦ or event routing issues between speech/playout and the tool dispatcher.
• Suggest targeted workarounds you can try immediately (for example, returning a resolved Promise instead of calling run() directly, or ensuring task completion happens after waitForPlayout() finishes), and share a patch if we identify a fix.
If you share the version, logs, and a minimal repro, I’ll dig in and propose concrete next steps or a code fix.
Sources: Agents and handoffs | LiveKit Documentation | Agent speech and audio | LiveKit Documentation | Tasks and task groups | LiveKit Documentation | Agents (Node.js) | livekit/livekit_composite | DeepWikiacoustic-pharmacist-34178
03/08/2026, 2:09 PMtall-airline-16756
03/08/2026, 2:09 PMtestTool and hangupTool) in sequence,
• Asserts that both tool execute handlers are called and that the Room delete is invoked when hangupTool runs.
Setup notes:
• This assumes your Agent and tools are exported from a module (adjust imports accordingly).
• The tests mock ctx.waitForPlayout() and getRoomServiceClient().deleteRoom(...).
• Replace or adapt mocks for your actual SDK objects if names differ.
1) Install test deps
• vitest and any typings:
npm install -D vitest @types/node
2) Test file (tests/agent-tools.spec.ts)
import { describe, it, beforeEach, afterEach, vi, expect } from 'vitest';
// --- Replace these imports with your actual module paths ---
import { MyAgent } from '../src/my-agent'; // file that exports MyAgent
import * as agentRuntime from '@livekit/agents'; // if needed for types/mocks
// --- Mocks ---
// Mock context passed into tool execute functions
function makeCtxMock() {
return {
waitForPlayout: vi.fn().mockResolvedValue(undefined),
};
}
// Mock getRoomServiceClient and getJobContext used in hangupTool
const mockDeleteRoom = vi.fn().mockResolvedValue(undefined);
vi.mock('../src/roomService', () => ({ // adjust path to where getRoomServiceClient is implemented
getRoomServiceClient: () => ({ deleteRoom: mockDeleteRoom }),
getJobContext: () => ({ room: { name: 'test-room' } }),
}));
describe('MyAgent tools', () => {
let agent: MyAgent;
let ctx: ReturnType<typeof makeCtxMock>;
beforeEach(() => {
// instantiate your agent
agent = new MyAgent();
ctx = makeCtxMock();
mockDeleteRoom.mockClear();
ctx.waitForPlayout.mockClear();
});
it('executes testTool then hangupTool (both handlers called)', async () => {
// Find tools from agent config. This depends on how your Agent exposes tools.
// If agent stores tools under `agent.tools`, use that; otherwise adapt accordingly.
// For this example we assume `agent.tools` is a record of tool definitions.
const tools = (agent as any).tools;
expect(tools).toBeDefined();
// Call testTool.execute
const testTool = tools.testTool;
expect(typeof testTool.execute).toBe('function');
// Simulate LLM invoking the tool: execute returns whatever MyTask.run() returns.
// We mock MyTask.run() if necessary to avoid heavy runtime behavior.
// If your MyTask is internal and side-effect-free, call directly. Otherwise, stub it:
const runSpy = vi.spyOn(require('../src/my-task'), 'MyTask').mockImplementation(() => {
return {
run: vi.fn().mockResolvedValue('Task completed successfully.'),
};
});
const testRes = await testTool.execute({}, { ctx });
expect(ctx.waitForPlayout).toHaveBeenCalled();
expect(testRes).toBe('Task completed successfully.');
runSpy.mockRestore();
// Now call hangupTool.execute
const hangupTool = tools.hangupTool;
expect(typeof hangupTool.execute).toBe('function');
const hangupRes = await hangupTool.execute({}, { ctx });
expect(ctx.waitForPlayout).toHaveBeenCalledTimes(2); // once per tool if both call it
expect(mockDeleteRoom).toHaveBeenCalledWith('test-room');
});
it('handles testTool run resolving and allows subsequent tool executions', async () => {
// This test is similar but explicitly checks that a returned Task does not block further tools
const tools = (agent as any).tools;
// Let testTool.execute return a Promise that resolves after a short delay
const originalTestExecute = tools.testTool.execute;
const delay = (ms: number) => new Promise(r => setTimeout(r, ms));
const slowRunSpy = vi.spyOn(require('../src/my-task'), 'MyTask').mockImplementation(() => {
return {
run: vi.fn().mockImplementation(async () => {
await delay(20); // emulate async work
return 'Task done';
}),
};
});
// Start testTool but don't await immediately to simulate overlapping operations
const testPromise = tools.testTool.execute({}, { ctx });
// While testTool is still pending, invoke hangupTool
const hangupPromise = tools.hangupTool.execute({}, { ctx });
const [testValue, hangupValue] = await Promise.all([testPromise, hangupPromise]);
expect(testValue).toBe('Task done');
expect(hangupValue).toBeUndefined(); // hangupTool.execute may return void
expect(mockDeleteRoom).toHaveBeenCalled();
slowRunSpy.mockRestore();
});
});
3) Notes on adapting the tests
• If your MyAgent stores tools differently, replace (agent as any).tools with the correct accessor.
• If MyTask is defined inline in the same file, mock the constructor or the run method accordingly (use vi.spyOn or replace the module with vi.mock).
• If getRoomServiceClient and getJobContext live in a different module, mock that module path instead.
• If your environment requires real LiveKit Room objects, you can mock the RoomService client to avoid network calls.
4) Debugging the original bug while testing
• The second test intentionally starts testTool.execute without awaiting before invoking hangupTool.execute to mimic real concurrency. If running these tests shows the hangupTool never runs, capture logs and stack traces to see where execution stops (e.g., an unhandled rejection or an Agent state change).
• Try removing or toggling ctx.waitForPlayout() calls in the code under test to see if waitForPlayout() is causing the agent to stall.
If you want:
• I can convert these Vitest tests to Jest.
• I can produce a minimal runnable repo (package.json, tsconfig, test file) you can clone and run.
• I can adapt mocks to your exact module layout if you share the file paths where MyAgent, MyTask, getRoomServiceClient, and getJobContext live.
Which of those would you like next?
Sources: Tasks and task groups | LiveKit Documentation | Testing and evaluation | LiveKit Documentation | Agents (Node.js) | livekit/livekit_composite | DeepWiki