Skip to content
5 changes: 5 additions & 0 deletions .changeset/cold-avocados-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@livekit/agents": patch
---

Add Agent.create method
5 changes: 5 additions & 0 deletions .changeset/gemini-provider-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents-plugin-google': minor
---

Add Gemini provider tools for Google Search, Google Maps, URL context, File Search, code execution, and Vertex RAG retrieval, and serialize them from `ToolContext` for Google LLM and realtime sessions.
16 changes: 16 additions & 0 deletions .changeset/list-syntax-toolcontext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@livekit/agents': minor
---

**BREAKING**: `Agent({ tools })` and `agent.updateTools()` now accept a flat list `(FunctionTool | ProviderTool | Toolset)[]` instead of a `Record<string, FunctionTool>` map, and `llm.tool({ ... })` requires a `name` field. `ToolContext` is now a Python-parity class with `functionTools` / `providerTools` / `toolsets` accessors, plus `flatten()`, `hasTool(id)`, `getFunctionTool(id)`, `updateTools()`, `copy()`, and `equals()`. To match the Python reference, registering two **different** function-tool instances under the same `name` now throws `duplicate function name: <name>` instead of silently overriding the earlier entry; passing the **same instance** twice is a no-op. `agent.toolCtx` returns a defensive copy so callers can no longer mutate the agent's internal state. `LLM.chat({ toolCtx })` accepts either a `ToolContext` instance or a raw `(FunctionTool | ProviderTool | Toolset)[]` array (`ToolCtxInput`) and normalizes it internally, so callers don't have to construct a `ToolContext` themselves.

Tools also expose an `id: string` field on the base `Tool` interface (parity with Python's `Tool.id` property): for `FunctionTool` it mirrors `name`, for `ProviderTool` it is the provider tool id. `ToolContext` keys and equality now use `tool.id` consistently.

**BREAKING**: Provider tools are now modeled to match Python's `ProviderTool`:

- `ProviderDefinedTool` is renamed to `ProviderTool`, and `isProviderDefinedTool` is renamed to `isProviderTool`.
- `ProviderTool` is now an **abstract class** (Python parity). Plugins must subclass it (`class WebSearch extends ProviderTool { ... }`) to attach provider-specific fields and serializers; bare `new ProviderTool(...)` is rejected at compile time.
- The `tool({ id })` factory overload is removed; `tool({ ... })` only creates function tools now. Construct provider tools by instantiating a `ProviderTool` subclass.
- The `ToolType` literal for provider tools is renamed from `'provider-defined'` to `'provider'`.

`Toolset` now carries a `TOOLSET_SYMBOL` marker and is detected via a new `isToolset()` guard (consistent with `isFunctionTool` / `isProviderTool`). Existing `instanceof Toolset` checks still work, but symbol-based detection is preferred for cross-realm safety.
5 changes: 5 additions & 0 deletions .changeset/openai-provider-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents-plugin-openai': minor
---

Add OpenAI Responses provider tools for web search, file search, and code interpreter.
5 changes: 5 additions & 0 deletions .changeset/port-end-call-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': minor
---

Add beta EndCallTool for ending calls from agent tools
5 changes: 5 additions & 0 deletions .changeset/quick-meals-breathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Adds base `Toolset` support: a stateful container for a group of tools with `setup()` / `aclose()` lifecycle hooks. Toolsets can be passed directly into `Agent({ tools: [...] })` alongside individual function tools; their tools are flattened into the agent's `ToolContext` and the runtime drives `setup()` on activity start, `aclose()` on close, and a setup/close diff when `agent.updateTools()` adds or removes Toolsets mid-session. Per-toolset `setup()` errors are logged but do not abort the activity. The `IGNORE_ON_ENTER` flag is also respected for function tools nested inside a Toolset. Every LLM and realtime plugin tool builder iterates `ToolContext.flatten()` so toolset-contributed tools are correctly advertised. Also exports `ToolCalledEvent` / `ToolCompletedEvent` payload types.
5 changes: 5 additions & 0 deletions .changeset/sarvam-stt-speech-timing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents-plugin-sarvam': patch
---

Emit Sarvam STT speech timing for streaming metrics.
7 changes: 7 additions & 0 deletions agents/src/beta/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ export {
type WarmTransferTaskOptions,
} from './workflows/index.js';
export { Instructions } from '../llm/index.js';
export {
END_CALL_DESCRIPTION,
createEndCallTool,
type EndCallToolCalledEvent,
type EndCallToolCompletedEvent,
type EndCallToolOptions,
} from './tools/index.js';
181 changes: 181 additions & 0 deletions agents/src/beta/tools/end_call.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { type EventEmitter, once } from 'node:events';
import { setTimeout as waitFor } from 'node:timers/promises';
import { getJobContext } from '../../job.js';
import {
RealtimeModel,
type ToolCalledEvent,
type ToolCompletedEvent,
Toolset,
tool,
} from '../../llm/index.js';
import { log } from '../../log.js';
import type { AgentSession, AgentSessionCallbacks } from '../../voice/agent_session.js';
import { AgentSessionEventTypes } from '../../voice/events.js';
import type { UnknownUserData } from '../../voice/run_context.js';

/** How long to wait for the agent's goodbye reply to play out before forcing shutdown. */
const END_CALL_REPLY_TIMEOUT = 5000;

/** Typed wrapper around `events.once`; abort resolves to `undefined`, other errors propagate. */
function onceEvent<E extends keyof AgentSessionCallbacks>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- callbacks don't depend on UserData
session: AgentSession<any>,
event: E,
options?: { signal?: AbortSignal },
): Promise<Parameters<AgentSessionCallbacks[E]>[0] | undefined> {
return (
once(session as unknown as EventEmitter, event, options) as Promise<
Parameters<AgentSessionCallbacks[E]>
>
).then(
([payload]) => payload,
(err) => {
if (options?.signal?.aborted) return undefined;
throw err;
},
);
}

export const END_CALL_DESCRIPTION = `
Ends the current call and disconnects immediately.

Call when:
- The user clearly indicates they are done (e.g., "that's all, bye").

Do not call when:
- The user asks to pause, hold, or transfer.
- Intent is unclear.

This is the final action the agent can take.
Once called, no further interaction is possible with the user.
Don't generate any other text or response when the tool is called.
`;

export type EndCallToolCalledEvent<UserData = UnknownUserData> = ToolCalledEvent<UserData>;

export type EndCallToolCompletedEvent<UserData = UnknownUserData> = ToolCompletedEvent<UserData>;

export type EndCallToolOptions<UserData = UnknownUserData> = {
/** Additional description to add to the end call tool. */
extraDescription?: string;
/**
* Whether to delete the room when the user ends the call.
* Deleting the room disconnects all remote users, including SIP callers.
*/
deleteRoom?: boolean;
/** Tool output to the LLM for generating the tool response. */
endInstructions?: string | null;
/** Callback to call when the tool is called. */
onToolCalled?: (event: EndCallToolCalledEvent<UserData>) => Promise<void> | void;
/** Callback to call when the tool is completed. */
onToolCompleted?: (event: EndCallToolCompletedEvent<UserData>) => Promise<void> | void;
};

/**
* Allows the agent to end the call and disconnect from the room.
*/
export function createEndCallTool<UserData = UnknownUserData>({
extraDescription = '',
deleteRoom = true,
endInstructions = 'say goodbye to the user',
onToolCalled,
onToolCompleted,
}: EndCallToolOptions<UserData> = {}): Toolset {
// For a realtime LLM that generates the goodbye reply itself, wait for that reply to play out
// (bounded by END_CALL_REPLY_TIMEOUT) before shutting down. `signal` is aborted when the call
// ends or the toolset is torn down, which cancels whichever of the two races is still pending.
const delayedSessionShutdown = async (
session: AgentSession<UserData>,
signal: AbortSignal,
): Promise<void> => {
const speech = onceEvent(session, AgentSessionEventTypes.SpeechCreated, { signal }).then(
(event) => event?.speechHandle,
);
const timeout = waitFor(END_CALL_REPLY_TIMEOUT, 'timeout' as const, { signal }).catch(
() => undefined,
);

const winner = await Promise.race([speech, timeout]);
if (signal.aborted) return; // session already closed or toolset torn down

if (winner === 'timeout') {
log().warn('tool reply timed out, shutting down session');
session.shutdown();
} else if (winner) {
await winner.waitForPlayout();
session.shutdown();
}
};

return Toolset.create({
id: 'end_call',
tools: [
tool<UserData>({
name: 'end_call',
description: `${END_CALL_DESCRIPTION}\n${extraDescription}`,
execute: async (_args, { ctx, abortSignal }) => {
log().debug('end_call tool called');
const session = ctx.session;
const llm = session.currentAgent.getActivityOrThrow().llm;

// Lifetime of this invocation: aborts when the session closes, and also when the tool
// call itself is aborted. All listeners/timers below are scoped to it.
const controller = new AbortController();
const signal = abortSignal
? AbortSignal.any([abortSignal, controller.signal])
: controller.signal;

void onceEvent(session, AgentSessionEventTypes.Close, { signal })
.then((event) => {
if (!event) return; // signal aborted before close fired
controller.abort(); // stop the delayed-shutdown race

const jobCtx = getJobContext(false);
if (!jobCtx) return;

if (deleteRoom) {
jobCtx.addShutdownCallback(async () => {
log().info('deleting the room because the user ended the call');
await jobCtx.deleteRoom();
});
}

jobCtx.shutdown(String(event.reason));
})
.catch((error) => log().error({ error }, 'error during end call shutdown'));

ctx.speechHandle.addDoneCallback(() => {
if (!(llm instanceof RealtimeModel) || !llm.capabilities.autoToolReplyGeneration) {
session.shutdown();
return;
}

void delayedSessionShutdown(session, signal).catch((error) =>
log().error({ error }, 'error during delayed session shutdown'),
);
});

if (onToolCalled) {
await onToolCalled({ ctx, arguments: {} });
}

const completedEvent = {
ctx,
output:
endInstructions === null
? undefined
: ({ type: 'output', value: endInstructions } as const),
};
if (onToolCompleted) {
await onToolCompleted(completedEvent);
}

return endInstructions ?? undefined;
},
}),
],
});
}
10 changes: 10 additions & 0 deletions agents/src/beta/tools/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
export {
END_CALL_DESCRIPTION,
createEndCallTool,
type EndCallToolCalledEvent,
type EndCallToolCompletedEvent,
type EndCallToolOptions,
} from './end_call.js';
6 changes: 2 additions & 4 deletions agents/src/beta/workflows/task_group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,7 @@ export class TaskGroup extends AgentTask<TaskGroupResult> {

const outOfScopeTool = this.buildOutOfScopeTool(taskId);
if (outOfScopeTool) {
await this._currentTask.updateTools({
...this._currentTask.toolCtx,
out_of_scope: outOfScopeTool,
});
await this._currentTask.updateTools([...this._currentTask.toolCtx.tools, outOfScopeTool]);
}

try {
Expand Down Expand Up @@ -190,6 +187,7 @@ export class TaskGroup extends AgentTask<TaskGroupResult> {
const visitedTasks = this._visitedTasks;

return tool({
name: 'out_of_scope',
description,
flags: ToolFlag.IGNORE_ON_ENTER,
parameters: z.object({
Expand Down
25 changes: 14 additions & 11 deletions agents/src/beta/workflows/warm_transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import type {
Instructions,
LLM,
RealtimeModel,
ToolContext,
ToolContextEntry,
} from '../../llm/index.js';
import { ToolError, ToolFlag, tool } from '../../llm/index.js';
import { ToolContext, ToolError, ToolFlag, tool } from '../../llm/index.js';
import { log } from '../../log.js';
import type { STT } from '../../stt/index.js';
import type { TTS } from '../../tts/index.js';
Expand Down Expand Up @@ -72,7 +72,7 @@ export interface WarmTransferTaskOptions {
instructions?: InstructionParts | string;
chatCtx?: ChatContext;
turnDetection?: TurnDetectionMode | null;
tools?: ToolContext;
tools?: readonly ToolContextEntry[];
stt?: STT | STTModelString | null;
vad?: VAD | null;
llm?: LLM | RealtimeModel | LLMModels | null;
Expand Down Expand Up @@ -171,13 +171,13 @@ export class WarmTransferTask extends AgentTask<WarmTransferResult> {
this._resolveHumanAgentFailed = resolve;
});

this._tools = {
...this._tools,
connect_to_caller: this.buildConnectToCallerTool(),
decline_transfer: this.buildDeclineTransferTool(),
voicemail_detected: this.buildVoicemailDetectedTool(),
};
this._chatCtx = this._chatCtx.copy({ toolCtx: this._tools });
this._toolCtx = new ToolContext([
...this._toolCtx.tools,
this.buildConnectToCallerTool(),
this.buildDeclineTransferTool(),
this.buildVoicemailDetectedTool(),
]);
this._chatCtx = this._chatCtx.copy({ toolCtx: this._toolCtx });

this._taskTurnDetection = turnDetection ?? undefined;
this._allowInterruptions = allowInterruptions;
Expand Down Expand Up @@ -268,6 +268,7 @@ export class WarmTransferTask extends AgentTask<WarmTransferResult> {

private buildConnectToCallerTool() {
return tool({
name: 'connect_to_caller',
description: 'Called when the human agent wants to connect to the caller.',
flags: ToolFlag.IGNORE_ON_ENTER,
execute: async () => {
Expand All @@ -288,6 +289,7 @@ export class WarmTransferTask extends AgentTask<WarmTransferResult> {

private buildDeclineTransferTool() {
return tool({
name: 'decline_transfer',
description:
'Handles the case when the human agent explicitly declines to connect to the caller.',
parameters: z.object({
Expand All @@ -304,6 +306,7 @@ export class WarmTransferTask extends AgentTask<WarmTransferResult> {

private buildVoicemailDetectedTool() {
return tool({
name: 'voicemail_detected',
description:
'Called when the call reaches voicemail. Use this tool AFTER you hear the voicemail greeting',
flags: ToolFlag.IGNORE_ON_ENTER,
Expand Down Expand Up @@ -418,7 +421,7 @@ export class WarmTransferTask extends AgentTask<WarmTransferResult> {
vad: this.vad,
llm: this.llm,
tts: this.tts,
tools: this.toolCtx,
tools: this.toolCtx.tools,
chatCtx: this._chatCtx.copy(),
turnDetection: this._taskTurnDetection,
allowInterruptions: this._allowInterruptions,
Expand Down
19 changes: 19 additions & 0 deletions agents/src/generator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
//
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from 'vitest';
import { defineAgent, isAgent } from './generator.js';

describe('generator', () => {
it('marks definitions created with defineAgent as agents', () => {
const agent = defineAgent({
entry: async () => {},
});

expect(isAgent(agent)).toBe(true);
});

it('does not treat unmarked structural objects as agents', () => {
expect(isAgent({ entry: async () => {} })).toBe(false);
});
});
Loading