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
2 changes: 2 additions & 0 deletions agents/src/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export {
type ToolType,
} from './tool_context.js';

export { type ToolsetCreateOptions } from './toolset_factory.js';

export {
AgentHandoffItem,
AgentConfigUpdate,
Expand Down
29 changes: 29 additions & 0 deletions agents/src/llm/tool_context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
18 changes: 18 additions & 0 deletions agents/src/llm/tool_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`:
Expand Down Expand Up @@ -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;
}
Expand Down
45 changes: 45 additions & 0 deletions agents/src/llm/toolset_factory.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
/** Invoked when the toolset is being torn down. */
aclose?: () => Promise<void>;
}

// 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<void>;

readonly #acloseFn?: () => Promise<void>;

constructor({ id, tools, setup, aclose }: ToolsetCreateOptions) {
super({ id, tools });
this.#setupFn = setup;
this.#acloseFn = aclose;
}

override async setup(): Promise<void> {
if (this.#setupFn) await this.#setupFn();
}

override async aclose(): Promise<void> {
if (this.#acloseFn) await this.#acloseFn();
}
}

return new ToolsetFactory(options);
}