Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 12 additions & 1 deletion .changeset/list-syntax-toolcontext.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,15 @@
'@livekit/agents': minor
---

**BREAKING**: `Agent({ tools })` and `agent.updateTools()` now accept a flat list `(FunctionTool | ProviderDefinedTool | 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(name)`, `getFunctionTool(name)`, `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 | ProviderDefinedTool | Toolset)[]` array (`ToolCtxInput`) and normalizes it internally, so callers don't have to construct a `ToolContext` themselves.
**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: 3 additions & 2 deletions agents/src/llm/chat_context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
isInstructions,
renderInstructions,
} from './chat_context.js';
import { ToolContext, tool } from './tool_context.js';
import { ProviderTool, ToolContext, tool } from './tool_context.js';

initializeLogger({ pretty: false, level: 'error' });

Expand Down Expand Up @@ -1498,7 +1498,8 @@ describe('ChatContext.copy with toolCtx filter', () => {
});

it('keeps provider-tool calls when the ToolContext holds a matching provider tool id', () => {
const provider = tool({ id: 'code_runner', config: {} });
class CodeRunner extends ProviderTool {}
const provider = new CodeRunner({ id: 'code_runner' });
const ctx = new ChatContext([
FunctionCall.create({ callId: 'p1', name: 'code_runner', args: '{}' }),
FunctionCall.create({ callId: 'p2', name: 'other', args: '{}' }),
Expand Down
5 changes: 3 additions & 2 deletions agents/src/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
export {
handoff,
isFunctionTool,
isProviderDefinedTool,
isProviderTool,
isTool,
isToolset,
ProviderTool,
tool,
ToolContext,
ToolError,
Expand All @@ -14,7 +16,6 @@ export {
toToolContext,
type AgentHandoff,
type FunctionTool,
type ProviderDefinedTool,
type Tool,
type ToolCalledEvent,
type ToolChoice,
Expand Down
2 changes: 1 addition & 1 deletion agents/src/llm/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export abstract class LLM extends (EventEmitter as new () => TypedEmitter<LLMCal
chatCtx: ChatContext;
/**
* Tools to advertise to the LLM. Accepts either a `ToolContext` instance or a raw
* `(FunctionTool | ProviderDefinedTool)[]` array — the array form is normalized into a
* `(FunctionTool | ProviderTool)[]` array — the array form is normalized into a
* `ToolContext` internally so callers don't have to construct one themselves.
*/
toolCtx?: ToolCtxInput;
Expand Down
32 changes: 22 additions & 10 deletions agents/src/llm/tool_context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import * as z3 from 'zod/v3';
import * as z4 from 'zod/v4';
import { ToolContext, type ToolOptions, Toolset, tool } from './tool_context.js';
import { ProviderTool, ToolContext, type ToolOptions, Toolset, tool } from './tool_context.js';
import { createToolOptions, oaiParams } from './utils.js';

describe('Tool Context', () => {
Expand Down Expand Up @@ -448,8 +448,20 @@ describe('tool() name requirement', () => {
});
expect(t.name).toBe('doStuff');
});

it('exposes id mirroring the function tool name', () => {
const t = tool({
name: 'doStuff',
description: 'd',
execute: async () => 'x',
});
expect(t.id).toBe('doStuff');
expect(t.id).toBe(t.name);
});
});

class TestProviderTool extends ProviderTool {}

describe('ToolContext', () => {
const makeFn = (name: string) =>
tool({
Expand Down Expand Up @@ -497,7 +509,7 @@ describe('ToolContext', () => {

it('separates provider tools from function tools', () => {
const fnA = makeFn('a');
const provider = tool({ id: 'code', config: { language: 'python' } });
const provider = new TestProviderTool({ id: 'code' });
const ctx = new ToolContext([fnA, provider]);

expect(ctx.functionTools).toEqual({ a: fnA });
Expand Down Expand Up @@ -537,7 +549,7 @@ describe('ToolContext', () => {

it('equals() is reflexive', () => {
const a = makeFn('a');
const provider = tool({ id: 'code', config: { language: 'python' } });
const provider = new TestProviderTool({ id: 'code' });
const ctx = new ToolContext([a, provider]);
expect(ctx.equals(ctx)).toBe(true);
});
Expand All @@ -547,22 +559,22 @@ describe('ToolContext', () => {
// that hold the same provider-tool identities in different order are still equal so
// realtime-session / preemptive-generation reuse fast paths are not invalidated.
const a = makeFn('a');
const p1 = tool({ id: 'code', config: { language: 'python' } });
const p2 = tool({ id: 'browser', config: {} });
const p1 = new TestProviderTool({ id: 'code' });
const p2 = new TestProviderTool({ id: 'browser' });
expect(new ToolContext([a, p1, p2]).equals(new ToolContext([a, p2, p1]))).toBe(true);
});

it('equals() supports contexts with only provider tools', () => {
const p1 = tool({ id: 'code', config: {} });
const p2 = tool({ id: 'browser', config: {} });
const p1 = new TestProviderTool({ id: 'code' });
const p2 = new TestProviderTool({ id: 'browser' });
expect(new ToolContext([p1, p2]).equals(new ToolContext([p1, p2]))).toBe(true);
const p3 = tool({ id: 'code', config: {} }); // distinct identity, same id
const p3 = new TestProviderTool({ id: 'code' }); // distinct identity, same id
expect(new ToolContext([p1]).equals(new ToolContext([p3]))).toBe(false);
});

it('hasTool() matches function tools by name and provider tools by id', () => {
const a = makeFn('a');
const provider = tool({ id: 'code_runner', config: {} });
const provider = new TestProviderTool({ id: 'code_runner' });
const ctx = new ToolContext([a, provider]);

expect(ctx.hasTool('a')).toBe(true);
Expand All @@ -574,7 +586,7 @@ describe('ToolContext', () => {
// Matches Python's `flatten()`: list(self._fnc_tools_map.values()) + self._provider_tools.
const a = makeFn('a');
const b = makeFn('b');
const provider = tool({ id: 'code', config: {} });
const provider = new TestProviderTool({ id: 'code' });
const ctx = new ToolContext([b, provider, a]);

expect(ctx.flatten()).toEqual([b, a, provider]);
Expand Down
Loading