Skip to content
Open
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
1 change: 1 addition & 0 deletions agents/src/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export {
type ToolContextEntry,
type ToolCtxInput,
type ToolOptions,
type ToolsetCreateOptions,
type ToolType,
} from './tool_context.js';

Expand Down
58 changes: 57 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,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', () => {
Expand Down Expand Up @@ -633,6 +640,55 @@ 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('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');
Expand Down
79 changes: 79 additions & 0 deletions agents/src/llm/tool_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,41 @@ export class Toolset {
this.#tools = [...tools];
}

/**
* 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 Static tool list with a shared backing resource
* ```ts
* 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 new ToolsetFactory(options);
}

get id(): string {
return this.#id;
}
Expand All @@ -241,6 +276,50 @@ export class Toolset {
async aclose(): Promise<void> {}
}

/** 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<void>;
/** Invoked when the toolset is being torn down. */
aclose?: () => Promise<void>;
}

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

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

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<void> {
if (this.#setupFn) await this.#setupFn();
}

override async aclose(): Promise<void> {
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(...)`.
Expand Down