From 0407c4b78579c3d5a0f0fe0954b082a6ae701af2 Mon Sep 17 00:00:00 2001 From: Brian Yin Date: Fri, 22 May 2026 15:52:52 -0400 Subject: [PATCH 1/2] add Toolset.create --- agents/src/llm/index.ts | 2 ++ agents/src/llm/tool_context.test.ts | 29 +++++++++++++++++++ agents/src/llm/tool_context.ts | 18 ++++++++++++ agents/src/llm/toolset_factory.ts | 45 +++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 agents/src/llm/toolset_factory.ts diff --git a/agents/src/llm/index.ts b/agents/src/llm/index.ts index 0c7a42679..d1d0e1e7f 100644 --- a/agents/src/llm/index.ts +++ b/agents/src/llm/index.ts @@ -26,6 +26,8 @@ export { type ToolType, } from './tool_context.js'; +export { type ToolsetCreateOptions } from './toolset_factory.js'; + export { AgentHandoffItem, AgentConfigUpdate, diff --git a/agents/src/llm/tool_context.test.ts b/agents/src/llm/tool_context.test.ts index 4cfd24c3e..ea69f72f9 100644 --- a/agents/src/llm/tool_context.test.ts +++ b/agents/src/llm/tool_context.test.ts @@ -633,6 +633,35 @@ describe('Toolset', () => { expect(events).toEqual(['setup:rec', 'close:rec']); }); + it('Toolset.create() composes lifecycle callbacks without subclassing', async () => { + const a = makeFn('a'); + const events: string[] = []; + const ts = Toolset.create({ + id: 'composed', + tools: [a], + setup: async () => { + events.push('setup'); + }, + aclose: async () => { + events.push('close'); + }, + }); + + expect(ts).toBeInstanceOf(Toolset); + expect(ts.id).toBe('composed'); + expect(ts.tools).toEqual([a]); + + await ts.setup(); + await ts.aclose(); + expect(events).toEqual(['setup', 'close']); + }); + + it('Toolset.create() defaults setup and aclose to no-ops when callbacks are omitted', async () => { + const ts = Toolset.create({ id: 'bare', tools: [] }); + await expect(ts.setup()).resolves.toBeUndefined(); + await expect(ts.aclose()).resolves.toBeUndefined(); + }); + it('is flattened into a ToolContext: function tools merged, toolset tracked', () => { const a = makeFn('a'); const b = makeFn('b'); diff --git a/agents/src/llm/tool_context.ts b/agents/src/llm/tool_context.ts index aa2ec3cdc..acb55fc0d 100644 --- a/agents/src/llm/tool_context.ts +++ b/agents/src/llm/tool_context.ts @@ -5,6 +5,7 @@ import type { JSONSchema7 } from 'json-schema'; import { z } from 'zod'; import type { Agent } from '../voice/agent.js'; import type { RunContext, UnknownUserData } from '../voice/run_context.js'; +import { type ToolsetCreateOptions, createToolsetFactory } from './toolset_factory.js'; import { isZodObjectSchema, isZodSchema } from './zod-utils.js'; // heavily inspired by Vercel AI's `tool()`: @@ -228,6 +229,23 @@ export class Toolset { this.#tools = [...tools]; } + /** + * Compose a `Toolset` from a flat options object with inline lifecycle hooks. + * + * @example + * ```ts + * const mcpToolset = Toolset.create({ + * id: 'mcp_filesystem', + * tools: [readFile, writeFile], + * setup: async () => { await mcpClient.connect(); }, + * aclose: async () => { await mcpClient.disconnect(); }, + * }); + * ``` + */ + static create(options: ToolsetCreateOptions): Toolset { + return createToolsetFactory(Toolset, options); + } + get id(): string { return this.#id; } diff --git a/agents/src/llm/toolset_factory.ts b/agents/src/llm/toolset_factory.ts new file mode 100644 index 000000000..e8b034d71 --- /dev/null +++ b/agents/src/llm/toolset_factory.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import type { Tool, Toolset } from './tool_context.js'; + +/** Options accepted by `Toolset.create()` — id + tools plus optional lifecycle hooks. */ +export interface ToolsetCreateOptions { + id: string; + tools: readonly Tool[]; + /** Invoked when the toolset becomes active in an `AgentActivity`. */ + setup?: () => Promise; + /** Invoked when the toolset is being torn down. */ + aclose?: () => Promise; +} + +// tool_context.ts passes the runtime base class in to avoid a circular runtime import. +type ToolsetCtor = new (options: { id: string; tools: readonly Tool[] }) => Toolset; + +/** @internal — backing implementation for `Toolset.create()`. */ +export function createToolsetFactory( + ToolsetBase: ToolsetCtor, + options: ToolsetCreateOptions, +): Toolset { + class ToolsetFactory extends ToolsetBase { + readonly #setupFn?: () => Promise; + + readonly #acloseFn?: () => Promise; + + constructor({ id, tools, setup, aclose }: ToolsetCreateOptions) { + super({ id, tools }); + this.#setupFn = setup; + this.#acloseFn = aclose; + } + + override async setup(): Promise { + if (this.#setupFn) await this.#setupFn(); + } + + override async aclose(): Promise { + if (this.#acloseFn) await this.#acloseFn(); + } + } + + return new ToolsetFactory(options); +} From 15eb5ba4c9fa507551038afaf26f2a00aea37b3f Mon Sep 17 00:00:00 2001 From: Brian Yin Date: Fri, 22 May 2026 16:10:06 -0400 Subject: [PATCH 2/2] simplify + support dynamic tools --- agents/src/llm/index.ts | 3 +- agents/src/llm/tool_context.test.ts | 29 ++++++++++- agents/src/llm/tool_context.ts | 81 +++++++++++++++++++++++++---- agents/src/llm/toolset_factory.ts | 45 ---------------- 4 files changed, 100 insertions(+), 58 deletions(-) delete mode 100644 agents/src/llm/toolset_factory.ts diff --git a/agents/src/llm/index.ts b/agents/src/llm/index.ts index d1d0e1e7f..098a84fca 100644 --- a/agents/src/llm/index.ts +++ b/agents/src/llm/index.ts @@ -23,11 +23,10 @@ export { type ToolContextEntry, type ToolCtxInput, type ToolOptions, + type ToolsetCreateOptions, type ToolType, } from './tool_context.js'; -export { type ToolsetCreateOptions } from './toolset_factory.js'; - export { AgentHandoffItem, AgentConfigUpdate, diff --git a/agents/src/llm/tool_context.test.ts b/agents/src/llm/tool_context.test.ts index ea69f72f9..c42380478 100644 --- a/agents/src/llm/tool_context.test.ts +++ b/agents/src/llm/tool_context.test.ts @@ -5,7 +5,14 @@ import { describe, expect, it } from 'vitest'; import { z } from 'zod'; import * as z3 from 'zod/v3'; import * as z4 from 'zod/v4'; -import { ProviderTool, ToolContext, type ToolOptions, Toolset, tool } from './tool_context.js'; +import { + ProviderTool, + type Tool, + ToolContext, + type ToolOptions, + Toolset, + tool, +} from './tool_context.js'; import { createToolOptions, oaiParams } from './utils.js'; describe('Tool Context', () => { @@ -662,6 +669,26 @@ describe('Toolset', () => { await expect(ts.aclose()).resolves.toBeUndefined(); }); + it('Toolset.create() accepts a tools thunk, re-evaluated on every access (dynamic)', () => { + const a = makeFn('a'); + const b = makeFn('b'); + const current: Tool[] = [a]; + let calls = 0; + const ts = Toolset.create({ + id: 'dynamic', + tools: () => { + calls += 1; + return current; + }, + }); + // Each access re-invokes the thunk so the toolset reflects the current source-of-truth. + expect(ts.tools).toEqual([a]); + expect(calls).toBe(1); + current.push(b); + expect(ts.tools).toEqual([a, b]); + expect(calls).toBe(2); + }); + it('is flattened into a ToolContext: function tools merged, toolset tracked', () => { const a = makeFn('a'); const b = makeFn('b'); diff --git a/agents/src/llm/tool_context.ts b/agents/src/llm/tool_context.ts index acb55fc0d..284d5d7cf 100644 --- a/agents/src/llm/tool_context.ts +++ b/agents/src/llm/tool_context.ts @@ -5,7 +5,6 @@ import type { JSONSchema7 } from 'json-schema'; import { z } from 'zod'; import type { Agent } from '../voice/agent.js'; import type { RunContext, UnknownUserData } from '../voice/run_context.js'; -import { type ToolsetCreateOptions, createToolsetFactory } from './toolset_factory.js'; import { isZodObjectSchema, isZodSchema } from './zod-utils.js'; // heavily inspired by Vercel AI's `tool()`: @@ -230,20 +229,38 @@ export class Toolset { } /** - * Compose a `Toolset` from a flat options object with inline lifecycle hooks. + * Compose a `Toolset` with inline `setup` / `aclose` hooks instead of subclassing. `tools` + * may also be a thunk that is re-evaluated on every `.tools` access, so the toolset can + * expose a dynamic list that changes after `setup()` runs. * - * @example + * @example Static tool list with a shared backing resource * ```ts - * const mcpToolset = Toolset.create({ - * id: 'mcp_filesystem', - * tools: [readFile, writeFile], - * setup: async () => { await mcpClient.connect(); }, - * aclose: async () => { await mcpClient.disconnect(); }, - * }); + * function createPostgresToolset(connectionUrl: string): Toolset { + * const pool = new pg.Pool({ connectionString: connectionUrl }); + * return Toolset.create({ + * id: 'postgres', + * tools: [queryOrders, queryCustomers], + * setup: () => pool.connect(), + * aclose: () => pool.end(), + * }); + * } + * ``` + * + * @example Dynamic tool list + * ```ts + * function createMcpToolset(url: string): Toolset { + * const client = new MCPClient({ url }); + * return Toolset.create({ + * id: 'mcp_remote', + * tools: () => client.getTools(), + * setup: () => client.connect(), + * aclose: () => client.disconnect(), + * }); + * } * ``` */ static create(options: ToolsetCreateOptions): Toolset { - return createToolsetFactory(Toolset, options); + return new ToolsetFactory(options); } get id(): string { @@ -259,6 +276,50 @@ export class Toolset { async aclose(): Promise {} } +/** Options accepted by `Toolset.create()` — id + tools plus optional lifecycle hooks. */ +export interface ToolsetCreateOptions { + id: string; + /** + * Either a static list of tools, or a thunk re-evaluated on every `tools` access — useful + * when the underlying source (e.g. an MCP discovery loop) can produce a dynamic tool list. + */ + tools: readonly Tool[] | (() => readonly Tool[]); + /** Invoked when the toolset becomes active in an `AgentActivity`. */ + setup?: () => Promise; + /** Invoked when the toolset is being torn down. */ + aclose?: () => Promise; +} + +/** Backing implementation of `Toolset.create()`. Kept private so callers go through the factory. */ +class ToolsetFactory extends Toolset { + readonly #toolsSource: readonly Tool[] | (() => readonly Tool[]); + + readonly #setupFn?: () => Promise; + + readonly #acloseFn?: () => Promise; + + constructor({ id, tools, setup, aclose }: ToolsetCreateOptions) { + // Pass [] to super and override the `tools` getter so a thunk can be re-evaluated on + // every access (lets callers expose a dynamic tool list). + super({ id, tools: [] }); + this.#toolsSource = tools; + this.#setupFn = setup; + this.#acloseFn = aclose; + } + + override get tools(): readonly Tool[] { + return typeof this.#toolsSource === 'function' ? this.#toolsSource() : this.#toolsSource; + } + + override async setup(): Promise { + if (this.#setupFn) await this.#setupFn(); + } + + override async aclose(): Promise { + if (this.#acloseFn) await this.#acloseFn(); + } +} + /** * Convenience input shape accepted by APIs that want to take a list of tools directly without * forcing callers to wrap them in `new ToolContext(...)`. diff --git a/agents/src/llm/toolset_factory.ts b/agents/src/llm/toolset_factory.ts deleted file mode 100644 index e8b034d71..000000000 --- a/agents/src/llm/toolset_factory.ts +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-FileCopyrightText: 2026 LiveKit, Inc. -// -// SPDX-License-Identifier: Apache-2.0 -import type { Tool, Toolset } from './tool_context.js'; - -/** Options accepted by `Toolset.create()` — id + tools plus optional lifecycle hooks. */ -export interface ToolsetCreateOptions { - id: string; - tools: readonly Tool[]; - /** Invoked when the toolset becomes active in an `AgentActivity`. */ - setup?: () => Promise; - /** Invoked when the toolset is being torn down. */ - aclose?: () => Promise; -} - -// tool_context.ts passes the runtime base class in to avoid a circular runtime import. -type ToolsetCtor = new (options: { id: string; tools: readonly Tool[] }) => Toolset; - -/** @internal — backing implementation for `Toolset.create()`. */ -export function createToolsetFactory( - ToolsetBase: ToolsetCtor, - options: ToolsetCreateOptions, -): Toolset { - class ToolsetFactory extends ToolsetBase { - readonly #setupFn?: () => Promise; - - readonly #acloseFn?: () => Promise; - - constructor({ id, tools, setup, aclose }: ToolsetCreateOptions) { - super({ id, tools }); - this.#setupFn = setup; - this.#acloseFn = aclose; - } - - override async setup(): Promise { - if (this.#setupFn) await this.#setupFn(); - } - - override async aclose(): Promise { - if (this.#acloseFn) await this.#acloseFn(); - } - } - - return new ToolsetFactory(options); -}