Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/base-toolset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@livekit/agents": minor
---

Add 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 diff on `updateTools()`. `Toolset.setup()` failures propagate (with rollback of successfully-set-up toolsets) so the agent fails explicitly rather than running with uninitialized resources. 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.
3 changes: 3 additions & 0 deletions agents/src/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ export {
ToolContext,
ToolError,
ToolFlag,
Toolset,
toToolContext,
type AgentHandoff,
type FunctionTool,
type ProviderDefinedTool,
type Tool,
type ToolCalledEvent,
type ToolChoice,
type ToolCompletedEvent,
type ToolContextEntry,
type ToolCtxInput,
type ToolOptions,
Expand Down
55 changes: 54 additions & 1 deletion 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, tool } from './tool_context.js';
import { ToolContext, type ToolOptions, Toolset, tool } from './tool_context.js';
import { createToolOptions, oaiParams } from './utils.js';

describe('Tool Context', () => {
Expand Down Expand Up @@ -523,3 +523,56 @@ describe('ToolContext', () => {
expect(new ToolContext([a, b]).equals(new ToolContext([a, c]))).toBe(false);
});
});

describe('Toolset', () => {
const makeFn = (name: string) =>
tool({
name,
description: `${name} tool`,
execute: async () => name,
});

it('exposes its id and the tools it was constructed with', () => {
const a = makeFn('a');
const b = makeFn('b');
const ts = new Toolset({ id: 'set1', tools: [a, b] });

expect(ts.id).toBe('set1');
expect(ts.tools).toEqual([a, b]);
});

it('default setup and aclose are no-ops', async () => {
const ts = new Toolset({ id: 'noop', tools: [] });
await expect(ts.setup()).resolves.toBeUndefined();
await expect(ts.aclose()).resolves.toBeUndefined();
});

it('lets subclasses override lifecycle hooks', async () => {
const events: string[] = [];
class Recording extends Toolset {
override async setup(): Promise<void> {
events.push(`setup:${this.id}`);
}
override async aclose(): Promise<void> {
events.push(`close:${this.id}`);
}
}

const ts = new Recording({ id: 'rec', tools: [] });
await ts.setup();
await ts.aclose();
expect(events).toEqual(['setup:rec', 'close:rec']);
});

it('is flattened into a ToolContext: function tools merged, toolset tracked', () => {
const a = makeFn('a');
const b = makeFn('b');
const ts = new Toolset({ id: 'set', tools: [a, b] });
const direct = makeFn('direct');

const ctx = new ToolContext([direct, ts]);

expect(Object.keys(ctx.functionTools).sort()).toEqual(['a', 'b', 'direct']);
expect(ctx.toolsets).toEqual([ts]);
});
});
88 changes: 76 additions & 12 deletions agents/src/llm/tool_context.ts
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,43 @@ export interface FunctionTool<
[FUNCTION_TOOL_SYMBOL]: true;
}

export interface ToolCalledEvent<UserData = UnknownUserData> {
ctx: RunContext<UserData>;
arguments: Record<string, unknown>;
}

export interface ToolCompletedEvent<UserData = UnknownUserData> {
ctx: RunContext<UserData>;
output?: { type: 'output'; value: unknown } | { type: 'error'; value: Error };
}

/**
* A stateful collection of tools sharing a lifecycle. Tools registered through a `Toolset` are
* flattened into the surrounding `ToolContext`, while the `Toolset` itself is tracked so its
* `setup()` / `aclose()` hooks can be invoked by the agent runtime.
*/
export class Toolset {
readonly #id: string;
readonly #tools: Tool[];

constructor({ id, tools }: { id: string; tools: readonly Tool[] }) {
this.#id = id;
this.#tools = [...tools];
}

get id(): string {
return this.#id;
}

get tools(): readonly Tool[] {
return this.#tools;
}

async setup(): Promise<void> {}

async aclose(): Promise<void> {}
}

/**
* Convenience input shape accepted by APIs that want to take a list of tools directly without
* forcing callers to wrap them in `new ToolContext(...)`.
Expand All @@ -218,23 +255,25 @@ export function toToolContext<UserData = UnknownUserData>(
}

/**
* A flat, addressable view over a heterogeneous list of `FunctionTool` and `ProviderDefinedTool`
* entries.
* A flat, addressable view over a heterogeneous list of `FunctionTool`, `ProviderDefinedTool`,
* and `Toolset` entries.
*
* Mirrors the Python `ToolContext`: the original input list is preserved on `_tools`, while
* `_functionToolsMap` and `_providerTools` denormalize it for cheap access. When two function
* tools share the same name the later entry overwrites the earlier one.
* `_functionToolsMap`, `_providerTools`, and `_toolsets` denormalize it for cheap access. Tools
* contributed by a `Toolset` are flattened into the function and provider collections; later
* function tools sharing the same name as an earlier one overwrite the earlier entry.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ToolContext entries accept any function-tool parameter/result types
export type ToolContextEntry<UserData = UnknownUserData> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
FunctionTool<any, UserData, any> | ProviderDefinedTool;
FunctionTool<any, UserData, any> | ProviderDefinedTool | Toolset;

export class ToolContext<UserData = UnknownUserData> {
private _tools: ToolContextEntry<UserData>[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ToolContext stores generic function tools
private _functionToolsMap: Map<string, FunctionTool<any, UserData, any>> = new Map();
private _providerTools: ProviderDefinedTool[] = [];
private _toolsets: Toolset[] = [];

constructor(tools: readonly ToolContextEntry<UserData>[] = []) {
this.updateTools(tools);
Expand All @@ -244,18 +283,23 @@ export class ToolContext<UserData = UnknownUserData> {
return new ToolContext([]);
}

/** A copy of all function tools in the context. */
/** A copy of all function tools in the context, including tools contributed by toolsets. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic registry over any parameter/result types
get functionTools(): Record<string, FunctionTool<any, UserData, any>> {
return Object.fromEntries(this._functionToolsMap);
}

/** A copy of all provider tools in the context. */
/** A copy of all provider tools in the context, including provider tools from toolsets. */
get providerTools(): readonly ProviderDefinedTool[] {
return [...this._providerTools];
}

/** A copy of the raw tool list this context was constructed with. */
/** A copy of the toolsets registered in the context. */
get toolsets(): readonly Toolset[] {
return [...this._toolsets];
}

/** A copy of the raw tool/toolset list this context was constructed with. */
get tools(): readonly ToolContextEntry<UserData>[] {
return [...this._tools];
}
Expand Down Expand Up @@ -286,19 +330,31 @@ export class ToolContext<UserData = UnknownUserData> {
this._tools = [...tools];
this._functionToolsMap = new Map();
this._providerTools = [];

for (const tool of tools) {
this._toolsets = [];

const addTool = (tool: ToolContextEntry<UserData> | Tool): void => {
if (tool instanceof Toolset) {
for (const inner of tool.tools) {
addTool(inner);
}
this._toolsets.push(tool);
return;
}
if (isProviderDefinedTool(tool)) {
this._providerTools.push(tool);
continue;
return;
}
if (isFunctionTool(tool)) {
// Later tool wins on duplicate names. `tool()` enforces a non-empty name at
// construction so we don't re-check here.
this._functionToolsMap.set(tool.name, tool);
continue;
return;
}
throw new Error(`unknown tool type: ${typeof tool}`);
};

for (const tool of tools) {
addTool(tool);
}
}

Expand All @@ -315,6 +371,14 @@ export class ToolContext<UserData = UnknownUserData> {
return false;
}
}
if (this._toolsets.length !== other._toolsets.length) {
return false;
}
for (let i = 0; i < this._toolsets.length; i++) {
if (this._toolsets[i] !== other._toolsets[i]) {
return false;
}
}
if (this._providerTools.length !== other._providerTools.length) {
return false;
}
Expand Down
Loading