From d7560bf9b9a92a27cb82eaca6acd6d22baf90593 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Mon, 20 Apr 2026 14:44:08 -0400 Subject: [PATCH 1/4] feat(agent-tool-set): port ai-tool-set to @openrouter/agent-tool-set Adds a new workspace package with declarative activate/deactivate/ activateWhen/deactivateWhen for tools, with predicates that receive the SDK's ConversationState and typed shared context. Also adds an `activeTools?: readonly string[]` option to callModel so inferTools() output can be spread directly into a request. Port of ai-tool-set v1.0.0 (MIT (C) zirkelc). --- .changeset/agent-tool-set.md | 6 + packages/agent-tool-set/README.md | 64 +++ packages/agent-tool-set/package.json | 55 +++ packages/agent-tool-set/src/index.ts | 9 + packages/agent-tool-set/src/tool-set.ts | 249 ++++++++++++ packages/agent-tool-set/src/types.ts | 53 +++ .../tests/unit/tool-set.test.ts | 370 ++++++++++++++++++ packages/agent-tool-set/tsconfig.json | 8 + packages/agent-tool-set/vitest.config.ts | 44 +++ packages/agent/src/inner-loop/call-model.ts | 11 +- packages/agent/src/lib/async-params.ts | 8 + .../unit/call-model-active-tools.test.ts | 165 ++++++++ pnpm-lock.yaml | 9 + 13 files changed, 1049 insertions(+), 2 deletions(-) create mode 100644 .changeset/agent-tool-set.md create mode 100644 packages/agent-tool-set/README.md create mode 100644 packages/agent-tool-set/package.json create mode 100644 packages/agent-tool-set/src/index.ts create mode 100644 packages/agent-tool-set/src/tool-set.ts create mode 100644 packages/agent-tool-set/src/types.ts create mode 100644 packages/agent-tool-set/tests/unit/tool-set.test.ts create mode 100644 packages/agent-tool-set/tsconfig.json create mode 100644 packages/agent-tool-set/vitest.config.ts create mode 100644 packages/agent/tests/unit/call-model-active-tools.test.ts diff --git a/.changeset/agent-tool-set.md b/.changeset/agent-tool-set.md new file mode 100644 index 0000000..11d7624 --- /dev/null +++ b/.changeset/agent-tool-set.md @@ -0,0 +1,6 @@ +--- +"@openrouter/agent-tool-set": minor +"@openrouter/agent": minor +--- + +Add `@openrouter/agent-tool-set` (port of ai-tool-set v1.0.0, MIT © zirkelc): declarative activate / deactivate / activateWhen / deactivateWhen for tools with state- and context-aware predicates. Integrates with a new `activeTools?: readonly string[]` option on `callModel` that filters which tools are sent to the model for a given call. diff --git a/packages/agent-tool-set/README.md b/packages/agent-tool-set/README.md new file mode 100644 index 0000000..9f0928f --- /dev/null +++ b/packages/agent-tool-set/README.md @@ -0,0 +1,64 @@ +# @openrouter/agent-tool-set + +Declarative, state-aware activation and deactivation for tools used with `@openrouter/agent`. + +Port of [`ai-tool-set`](https://github.com/zirkelc/ai-tool-set) v1.0.0 (MIT © zirkelc), adapted for this SDK: + +- Input is an ordered array of `Tool` (as used by `callModel`), not a name-keyed record. +- Predicates receive `{ state, context }` where `state` is the SDK's `ConversationState` and `context` is the typed shared context. +- Integrates with a new `activeTools` option on `callModel` — you can spread `inferTools()` directly into the request. + +## Install + +```bash +pnpm add @openrouter/agent-tool-set +``` + +## Usage + +```ts +import { OpenRouter, tool, callModel } from '@openrouter/agent'; +import { createToolSet } from '@openrouter/agent-tool-set'; +import { z } from 'zod/v4'; + +const listOrders = tool({ + name: 'list_orders', + inputSchema: z.object({}), + execute: async () => ({ orders: [] }), +}); + +const cancelOrder = tool({ + name: 'cancel_order', + inputSchema: z.object({ id: z.string() }), + execute: async () => ({ ok: true }), +}); + +const toolSet = createToolSet({ tools: [listOrders, cancelOrder] as const }) + .activateWhen('list_orders', ({ context }) => context?.isAuthenticated === true) + .deactivateWhen('cancel_order', ({ state }) => (state?.messages?.length ?? 0) === 0); + +const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); + +const { tools, activeTools } = toolSet.inferTools({ context: { isAuthenticated: true } }); + +const result = callModel(client, { + model: 'openai/gpt-4o-mini', + input: 'List my orders.', + tools, + activeTools, +}); +``` + +## API + +- `createToolSet({ tools, mutable? })` — build a set from an ordered tool array. +- `.tools` — all tools in construction order, regardless of activation. +- `.activate(name | names[])` / `.deactivate(name | names[])` — static flip. +- `.activateWhen(name, predicate)` / `.activateWhen({ [name]: predicate, ... })` — conditional activation (defaults inactive). +- `.deactivateWhen(name, predicate)` / `.deactivateWhen({ [name]: predicate, ... })` — conditional deactivation (defaults active). +- `.inferTools(input?)` → `{ tools: Tool[]; activeTools: string[] }` — resolve against an input. +- `.clone({ mutable? })` — copy state, optionally flipping mode. + +Last-call-wins: each directive on a given tool replaces any prior one for that tool. + +Immutable by default (every mutator returns a new `ToolSet`). Pass `mutable: true` to mutate in place. diff --git a/packages/agent-tool-set/package.json b/packages/agent-tool-set/package.json new file mode 100644 index 0000000..01e972c --- /dev/null +++ b/packages/agent-tool-set/package.json @@ -0,0 +1,55 @@ +{ + "name": "@openrouter/agent-tool-set", + "version": "0.1.0", + "author": "OpenRouter", + "description": "Declarative activation/deactivation for @openrouter/agent tools. Port of ai-tool-set (MIT © zirkelc) adapted for callModel + tool().", + "keywords": [ + "openrouter", + "agent", + "tools", + "toolset", + "typescript", + "ai" + ], + "license": "Apache-2.0", + "type": "module", + "main": "./esm/index.js", + "exports": { + ".": { + "types": "./esm/index.d.ts", + "default": "./esm/index.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/OpenRouterTeam/typescript-agent.git", + "directory": "packages/agent-tool-set" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "files": [ + "esm", + "package.json", + "README.md" + ], + "scripts": { + "lint": "biome check src tests", + "lint:fix": "biome check --write src tests", + "build": "tsc", + "test": "vitest --run --project unit", + "test:e2e": "vitest --run --project e2e", + "test:watch": "vitest --watch --project unit", + "typecheck": "tsc --noEmit", + "compile": "tsc" + }, + "dependencies": { + "@openrouter/agent": "workspace:*" + }, + "peerDependencies": { + "zod": "^4.0.0" + } +} diff --git a/packages/agent-tool-set/src/index.ts b/packages/agent-tool-set/src/index.ts new file mode 100644 index 0000000..bece36b --- /dev/null +++ b/packages/agent-tool-set/src/index.ts @@ -0,0 +1,9 @@ +export { createToolSet, ToolSet } from './tool-set.js'; +export type { + ActivationInput, + ActivationPredicate, + InferActiveTools, + InferInactiveTools, + InferToolSet, + InferUIToolSet, +} from './types.js'; diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts new file mode 100644 index 0000000..d35ef64 --- /dev/null +++ b/packages/agent-tool-set/src/tool-set.ts @@ -0,0 +1,249 @@ +import type { Tool } from '@openrouter/agent'; +import type { ActivationInput, ActivationPredicate } from './types.js'; + +type Entry> = + | { + kind: 'static'; + active: boolean; + } + | { + kind: 'activateWhen'; + predicate: ActivationPredicate; + } + | { + kind: 'deactivateWhen'; + predicate: ActivationPredicate; + }; + +function toNameArray(names: string | readonly string[]): readonly string[] { + return typeof names === 'string' + ? [ + names, + ] + : names; +} + +function isPredicateMap>( + value: unknown, +): value is Record> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function buildToolsMap(tools: readonly Tool[]): Map { + const map = new Map(); + for (const t of tools) { + const name = t.function.name; + if (map.has(name)) { + throw new Error(`Duplicate tool name: "${name}"`); + } + map.set(name, t); + } + return map; +} + +export class ToolSet< + TTools extends readonly Tool[] = readonly Tool[], + TShared extends Record = Record, +> { + readonly #tools: Map; + readonly #activation: Map>; + readonly #mutable: boolean; + + private constructor( + tools: Map, + activation: Map>, + mutable: boolean, + ) { + this.#tools = tools; + this.#activation = activation; + this.#mutable = mutable; + } + + /** Internal factory. Prefer `createToolSet` for the public API. */ + static create< + T extends readonly Tool[], + S extends Record = Record, + >(opts: { tools: T; mutable?: boolean }): ToolSet { + return new ToolSet(buildToolsMap(opts.tools), new Map(), opts.mutable ?? false); + } + + /** All tools in construction order, regardless of activation state. */ + get tools(): readonly Tool[] { + return Array.from(this.#tools.values()); + } + + #assertKnown(name: string): void { + if (!this.#tools.has(name)) { + throw new Error(`Unknown tool: "${name}"`); + } + } + + #withMutation( + mutate: (activation: Map>) => void, + ): ToolSet { + if (this.#mutable) { + mutate(this.#activation); + return this; + } + const nextActivation = new Map(this.#activation); + mutate(nextActivation); + return new ToolSet(this.#tools, nextActivation, false); + } + + activate(names: string | readonly string[]): ToolSet { + const list = toNameArray(names); + for (const n of list) { + this.#assertKnown(n); + } + return this.#withMutation((activation) => { + for (const n of list) { + activation.set(n, { + kind: 'static', + active: true, + }); + } + }); + } + + deactivate(names: string | readonly string[]): ToolSet { + const list = toNameArray(names); + for (const n of list) { + this.#assertKnown(n); + } + return this.#withMutation((activation) => { + for (const n of list) { + activation.set(n, { + kind: 'static', + active: false, + }); + } + }); + } + + activateWhen(name: string, predicate: ActivationPredicate): ToolSet; + activateWhen(map: Record>): ToolSet; + activateWhen( + nameOrMap: string | Record>, + predicate?: ActivationPredicate, + ): ToolSet { + const entries = this.#normalizePredicateArg(nameOrMap, predicate); + return this.#withMutation((activation) => { + for (const [n, p] of entries) { + activation.set(n, { + kind: 'activateWhen', + predicate: p, + }); + } + }); + } + + deactivateWhen(name: string, predicate: ActivationPredicate): ToolSet; + deactivateWhen(map: Record>): ToolSet; + deactivateWhen( + nameOrMap: string | Record>, + predicate?: ActivationPredicate, + ): ToolSet { + const entries = this.#normalizePredicateArg(nameOrMap, predicate); + return this.#withMutation((activation) => { + for (const [n, p] of entries) { + activation.set(n, { + kind: 'deactivateWhen', + predicate: p, + }); + } + }); + } + + #normalizePredicateArg( + nameOrMap: string | Record>, + predicate?: ActivationPredicate, + ): Array< + [ + string, + ActivationPredicate, + ] + > { + if (typeof nameOrMap === 'string') { + if (!predicate) { + throw new Error('activateWhen/deactivateWhen requires a predicate when called with a name'); + } + this.#assertKnown(nameOrMap); + return [ + [ + nameOrMap, + predicate, + ], + ]; + } + if (!isPredicateMap(nameOrMap)) { + throw new Error('activateWhen/deactivateWhen requires a name+predicate or predicate map'); + } + const entries: Array< + [ + string, + ActivationPredicate, + ] + > = Object.entries(nameOrMap); + for (const [n] of entries) { + this.#assertKnown(n); + } + return entries; + } + + /** + * Resolve activation against an input and return the filtered active tools + * plus the parallel list of active names, both in construction order. + */ + inferTools(input?: ActivationInput): { + tools: Tool[]; + activeTools: string[]; + } { + const resolved: ActivationInput = input ?? {}; + const tools: Tool[] = []; + const activeTools: string[] = []; + for (const [name, t] of this.#tools) { + if (this.#resolveActive(name, resolved)) { + tools.push(t); + activeTools.push(name); + } + } + return { + tools, + activeTools, + }; + } + + #resolveActive(name: string, input: ActivationInput): boolean { + const entry = this.#activation.get(name); + if (!entry) { + return true; + } + if (entry.kind === 'static') { + return entry.active; + } + if (entry.kind === 'activateWhen') { + return entry.predicate(input) === true; + } + return entry.predicate(input) !== true; + } + + clone(opts?: { mutable?: boolean }): ToolSet { + return new ToolSet( + this.#tools, + new Map(this.#activation), + opts?.mutable ?? this.#mutable, + ); + } +} + +export function createToolSet(opts: { + tools: T; + mutable?: boolean; +}): ToolSet { + return ToolSet.create({ + tools: opts.tools, + ...(opts.mutable !== undefined && { + mutable: opts.mutable, + }), + }); +} diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts new file mode 100644 index 0000000..7b012a2 --- /dev/null +++ b/packages/agent-tool-set/src/types.ts @@ -0,0 +1,53 @@ +import type { + ConversationState, + InferToolEvent, + InferToolOutput, + Tool, + ToolPreliminaryResultEvent, + ToolResultEvent, +} from '@openrouter/agent'; + +export type ActivationInput = Record> = { + state?: ConversationState; + context?: TShared; +}; + +export type ActivationPredicate = Record> = + (input: ActivationInput) => boolean; + +type ToolName = T extends { + function: { + name: infer N extends string; + }; +} + ? N + : never; + +/** Maps a tool array to a record keyed by each tool's literal name. */ +export type InferToolSet = { + [K in T[number] as ToolName]: K; +}; + +/** + * Active tool partition. Without threading the activation configuration + * through the type system, this equals the full set. Exposed for API parity + * with the source library; runtime truth comes from `inferTools().activeTools`. + */ +export type InferActiveTools = InferToolSet; + +/** Inactive tool partition; see `InferActiveTools` for the caveat. */ +export type InferInactiveTools = InferToolSet; + +/** + * SDK-native analog of the source library's UI-message-part helper. + * Produces a discriminated union of streaming events keyed by tool name. + */ +export type InferUIToolSet = { + [K in T[number] as ToolName]: + | (ToolPreliminaryResultEvent> & { + toolName: ToolName; + }) + | (ToolResultEvent, InferToolEvent> & { + toolName: ToolName; + }); +}[ToolName]; diff --git a/packages/agent-tool-set/tests/unit/tool-set.test.ts b/packages/agent-tool-set/tests/unit/tool-set.test.ts new file mode 100644 index 0000000..4f7da16 --- /dev/null +++ b/packages/agent-tool-set/tests/unit/tool-set.test.ts @@ -0,0 +1,370 @@ +import type { ConversationState } from '@openrouter/agent'; +import { tool } from '@openrouter/agent'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod/v4'; +import { createToolSet } from '../../src/tool-set.js'; + +const makeTool = (name: string) => + tool({ + name, + description: `${name} tool`, + inputSchema: z.object({}), + execute: async () => ({ + name, + }), + }); + +const a = makeTool('a'); +const b = makeTool('b'); +const c = makeTool('c'); + +const minimalState = (partial?: Partial): ConversationState => ({ + id: 'conv_test', + messages: [], + status: 'complete', + createdAt: 0, + updatedAt: 0, + ...partial, +}); + +describe('createToolSet', () => { + it('preserves tool order via the .tools getter', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }); + expect(ts.tools.map((t) => t.function.name)).toEqual([ + 'a', + 'b', + 'c', + ]); + }); + + it('throws on duplicate tool names at construction', () => { + const dup = makeTool('a'); + expect(() => + createToolSet({ + tools: [ + a, + dup, + ] as const, + }), + ).toThrow(/Duplicate tool name: "a"/); + }); + + it('constructs an empty set without tools', () => { + const ts = createToolSet({ + tools: [] as const, + }); + expect(ts.tools).toEqual([]); + expect(ts.inferTools()).toEqual({ + tools: [], + activeTools: [], + }); + }); + + it('defaults all tools to active when no directives are set', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }); + const { tools, activeTools } = ts.inferTools(); + expect(tools).toEqual([ + a, + b, + ]); + expect(activeTools).toEqual([ + 'a', + 'b', + ]); + }); +}); + +describe('activate / deactivate', () => { + it('deactivates a single tool by name', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }).deactivate('b'); + expect(ts.inferTools().activeTools).toEqual([ + 'a', + 'c', + ]); + }); + + it('activates/deactivates arrays of names', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }) + .deactivate([ + 'a', + 'b', + ]) + .activate([ + 'b', + ]); + expect(ts.inferTools().activeTools).toEqual([ + 'b', + 'c', + ]); + }); + + it('throws on unknown names', () => { + const ts = createToolSet({ + tools: [ + a, + ] as const, + }); + expect(() => ts.activate('missing')).toThrow(/Unknown tool: "missing"/); + expect(() => + ts.deactivate([ + 'a', + 'missing', + ]), + ).toThrow(/Unknown tool: "missing"/); + }); +}); + +describe('activateWhen', () => { + it('defaults to inactive and flips based on predicate', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }).activateWhen('a', ({ context }) => context?.['enabled'] === true); + expect(ts.inferTools().activeTools).toEqual([ + 'b', + ]); + expect( + ts.inferTools({ + context: { + enabled: true, + }, + }).activeTools, + ).toEqual([ + 'a', + 'b', + ]); + }); + + it('accepts a predicate map', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }).activateWhen({ + a: () => true, + b: () => false, + }); + expect(ts.inferTools().activeTools).toEqual([ + 'a', + ]); + }); + + it('validates every name in the map before applying', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }); + expect(() => + ts.activateWhen({ + a: () => true, + nope: () => true, + }), + ).toThrow(/Unknown tool: "nope"/); + // original untouched + expect(ts.inferTools().activeTools).toEqual([ + 'a', + 'b', + ]); + }); +}); + +describe('deactivateWhen', () => { + it('defaults to active and flips inactive when predicate is true', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }).deactivateWhen('a', () => true); + expect(ts.inferTools().activeTools).toEqual([ + 'b', + ]); + }); + + it('accepts a predicate map', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }).deactivateWhen({ + a: () => true, + b: () => false, + }); + expect(ts.inferTools().activeTools).toEqual([ + 'b', + ]); + }); +}); + +describe('last-call-wins semantics', () => { + it('resolves to the most recent directive per tool', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }) + .activate('a') + .deactivateWhen('a', () => true); + expect(ts.inferTools().activeTools).toEqual([ + 'b', + ]); + + const ts2 = createToolSet({ + tools: [ + a, + b, + ] as const, + }) + .deactivateWhen('a', () => true) + .activate('a'); + expect(ts2.inferTools().activeTools).toEqual([ + 'a', + 'b', + ]); + }); +}); + +describe('immutability vs mutability', () => { + it('is immutable by default — mutators return a new instance', () => { + const base = createToolSet({ + tools: [ + a, + b, + ] as const, + }); + const next = base.deactivate('a'); + expect(next).not.toBe(base); + expect(base.inferTools().activeTools).toEqual([ + 'a', + 'b', + ]); + expect(next.inferTools().activeTools).toEqual([ + 'b', + ]); + }); + + it('mutates in place when mutable: true', () => { + const base = createToolSet({ + tools: [ + a, + b, + ] as const, + mutable: true, + }); + const next = base.deactivate('a'); + expect(next).toBe(base); + expect(base.inferTools().activeTools).toEqual([ + 'b', + ]); + }); +}); + +describe('clone', () => { + it('copies state and can flip mode', () => { + const immutable = createToolSet({ + tools: [ + a, + b, + ] as const, + }).deactivate('a'); + const mutableCopy = immutable.clone({ + mutable: true, + }); + mutableCopy.activate('a'); + expect(mutableCopy.inferTools().activeTools).toEqual([ + 'a', + 'b', + ]); + // original untouched + expect(immutable.inferTools().activeTools).toEqual([ + 'b', + ]); + }); + + it('inherits mode when not overridden', () => { + const mutable = createToolSet({ + tools: [ + a, + ] as const, + mutable: true, + }); + const clone = mutable.clone(); + const after = clone.deactivate('a'); + expect(after).toBe(clone); + }); +}); + +describe('inferTools input shapes', () => { + it('handles undefined and empty input', () => { + const ts = createToolSet({ + tools: [ + a, + ] as const, + }).activateWhen('a', ({ state, context }) => state === undefined && context === undefined); + expect(ts.inferTools().activeTools).toEqual([ + 'a', + ]); + expect(ts.inferTools({}).activeTools).toEqual([ + 'a', + ]); + }); + + it('passes typed state and context to the predicate', () => { + const spy = vi.fn(() => true); + const ts = createToolSet({ + tools: [ + a, + ] as const, + }).activateWhen('a', spy); + const state = minimalState({ + messages: [ + { + role: 'user', + content: 'hi', + }, + ], + }); + ts.inferTools({ + state, + context: { + foo: 'bar', + }, + }); + expect(spy).toHaveBeenCalledWith({ + state, + context: { + foo: 'bar', + }, + }); + }); +}); diff --git a/packages/agent-tool-set/tsconfig.json b/packages/agent-tool-set/tsconfig.json new file mode 100644 index 0000000..51bb3ed --- /dev/null +++ b/packages/agent-tool-set/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "esm" + }, + "include": ["src"], + "exclude": ["node_modules", "esm"] +} diff --git a/packages/agent-tool-set/vitest.config.ts b/packages/agent-tool-set/vitest.config.ts new file mode 100644 index 0000000..2963346 --- /dev/null +++ b/packages/agent-tool-set/vitest.config.ts @@ -0,0 +1,44 @@ +import { config } from 'dotenv'; +import { defineConfig } from 'vitest/config'; + +config({ + path: new URL('../../.env', import.meta.url), +}); + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + env: { + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, + }, + typecheck: { + enabled: true, + }, + projects: [ + { + extends: true, + test: { + name: 'unit', + include: [ + 'tests/unit/**/*.test.ts', + 'src/lib/**/*.test.ts', + ], + testTimeout: 10000, + hookTimeout: 10000, + }, + }, + { + extends: true, + test: { + name: 'e2e', + include: [ + 'tests/e2e/**/*.test.ts', + ], + testTimeout: 30000, + hookTimeout: 30000, + }, + }, + ], + }, +}); diff --git a/packages/agent/src/inner-loop/call-model.ts b/packages/agent/src/inner-loop/call-model.ts index 8e933b6..546ddcb 100644 --- a/packages/agent/src/inner-loop/call-model.ts +++ b/packages/agent/src/inner-loop/call-model.ts @@ -82,6 +82,7 @@ export function callModel< // Destructure state management options along with tools and stopWhen const { tools, + activeTools, stopWhen, state, requireApproval, @@ -94,8 +95,14 @@ export function callModel< ...apiRequest } = request; + // Narrow tools to the active subset (if provided) before API conversion and + // before they are registered for execution, so the model cannot call filtered + // tools and the executor does not carry orphaned definitions. + const activeSet = activeTools ? new Set(activeTools) : undefined; + const filteredTools = activeSet ? tools?.filter((t) => activeSet.has(t.function.name)) : tools; + // Convert tools to API format - no cast needed now that convertToolsToAPIFormat accepts readonly - const apiTools = tools ? convertToolsToAPIFormat(tools) : undefined; + const apiTools = filteredTools ? convertToolsToAPIFormat(filteredTools) : undefined; // Build the request with converted tools // Note: async functions are resolved later in ModelResult.executeToolsIfNeeded() @@ -123,7 +130,7 @@ export function callModel< client, request: finalRequest, options: callModelOptions, - tools, + tools: filteredTools, ...(stopWhen !== undefined && { stopWhen, }), diff --git a/packages/agent/src/lib/async-params.ts b/packages/agent/src/lib/async-params.ts index 8856346..0cb2d9e 100644 --- a/packages/agent/src/lib/async-params.ts +++ b/packages/agent/src/lib/async-params.ts @@ -59,6 +59,13 @@ type BaseCallModelInput< } & { input: FieldOrAsyncFunction | string; tools?: TTools; + /** + * Optional filter restricting which tools are exposed to the model for this + * call. Tool names not in this list are removed before the request is sent + * and are also not callable by the model. Pairs with + * `@openrouter/agent-tool-set`'s `.inferTools()` output. + */ + activeTools?: readonly string[]; stopWhen?: StopWhen; /** Typed context data passed to tools via contextSchema. Includes optional `shared` key. */ context?: ContextInput>; @@ -180,6 +187,7 @@ export async function resolveAsyncFunctions => { + const body: unknown = await request.clone().json(); + captured.raw = body; + if (isCapturedPayload(body)) { + captured.names = extractToolNames(body); + } + throw new Error(STOP_ERROR); + }; + return httpClient; +} + +async function captureOutboundTools(options: { + tools: ReadonlyArray>; + activeTools?: readonly string[]; +}): Promise { + const captured: { + names: string[] | null; + raw: unknown; + } = { + names: null, + raw: null, + }; + const httpClient = makeCapturingClient(captured); + const client = new OpenRouterCore({ + apiKey: 'test-key', + httpClient, + }); + + const result = callModel(client, { + model: 'openai/gpt-4o-mini', + input: 'hi', + tools: options.tools, + ...(options.activeTools !== undefined && { + activeTools: options.activeTools, + }), + }); + + try { + await result.getText(); + } catch (err) { + if (captured.names === null) { + throw err; + } + if (!(err instanceof Error) || err.message !== STOP_ERROR) { + // Some other error wrapped our stop error; capture already succeeded. + } + } + + if (captured.names === null) { + throw new Error(`request body was not captured; raw=${JSON.stringify(captured.raw)}`); + } + return captured.names; +} + +describe('callModel activeTools filter', () => { + const toolA = tool({ + name: 'a', + inputSchema: z.object({}), + execute: async () => ({ + ok: true, + }), + }); + const toolB = tool({ + name: 'b', + inputSchema: z.object({}), + execute: async () => ({ + ok: true, + }), + }); + + it('sends only active tools when activeTools is provided', async () => { + const names = await captureOutboundTools({ + tools: [ + toolA, + toolB, + ], + activeTools: [ + 'a', + ], + }); + expect(names).toEqual([ + 'a', + ]); + }); + + it('silently ignores unknown activeTools names', async () => { + const names = await captureOutboundTools({ + tools: [ + toolA, + toolB, + ], + activeTools: [ + 'a', + 'missing', + ], + }); + expect(names).toEqual([ + 'a', + ]); + }); + + it('sends all tools when activeTools is omitted', async () => { + const names = await captureOutboundTools({ + tools: [ + toolA, + toolB, + ], + }); + expect(names).toEqual([ + 'a', + 'b', + ]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4963f56..f701aeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,15 @@ importers: specifier: ^4.0.0 version: 4.3.6 + packages/agent-tool-set: + dependencies: + '@openrouter/agent': + specifier: workspace:* + version: link:../agent + zod: + specifier: ^4.0.0 + version: 4.3.6 + packages: '@babel/runtime@7.29.2': From ab6fd7deed669b6020cfee2b7d417d68239ddc8b Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Mon, 20 Apr 2026 14:51:37 -0400 Subject: [PATCH 2/4] refactor(agent-tool-set): rename InferUIToolSet to InferToolSet Drops the record-mapping InferToolSet and its InferActiveTools / InferInactiveTools aliases (faithful-port artifacts without true partition narrowing). The streaming-events discriminated union takes the InferToolSet name. --- packages/agent-tool-set/src/index.ts | 9 +-------- packages/agent-tool-set/src/types.ts | 21 +++------------------ 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/packages/agent-tool-set/src/index.ts b/packages/agent-tool-set/src/index.ts index bece36b..70c88bb 100644 --- a/packages/agent-tool-set/src/index.ts +++ b/packages/agent-tool-set/src/index.ts @@ -1,9 +1,2 @@ export { createToolSet, ToolSet } from './tool-set.js'; -export type { - ActivationInput, - ActivationPredicate, - InferActiveTools, - InferInactiveTools, - InferToolSet, - InferUIToolSet, -} from './types.js'; +export type { ActivationInput, ActivationPredicate, InferToolSet } from './types.js'; diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index 7b012a2..a368256 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -23,26 +23,11 @@ type ToolName = T extends { ? N : never; -/** Maps a tool array to a record keyed by each tool's literal name. */ -export type InferToolSet = { - [K in T[number] as ToolName]: K; -}; - -/** - * Active tool partition. Without threading the activation configuration - * through the type system, this equals the full set. Exposed for API parity - * with the source library; runtime truth comes from `inferTools().activeTools`. - */ -export type InferActiveTools = InferToolSet; - -/** Inactive tool partition; see `InferActiveTools` for the caveat. */ -export type InferInactiveTools = InferToolSet; - /** - * SDK-native analog of the source library's UI-message-part helper. - * Produces a discriminated union of streaming events keyed by tool name. + * Discriminated union of streaming events keyed by tool name. The SDK-native + * analog of the original library's UI-message-part helper. */ -export type InferUIToolSet = { +export type InferToolSet = { [K in T[number] as ToolName]: | (ToolPreliminaryResultEvent> & { toolName: ToolName; From 04ab529637bbe5ec9630650f4a7a056e3cc8dd2e Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 21 Apr 2026 10:48:32 -0400 Subject: [PATCH 3/4] fix(agent, agent-tool-set): handle ServerToolBase in activeTools filter and buildToolsMap Addresses review feedback on PR #31 after rebasing onto current main (PR #30 widened `Tool` to `ClientTool | ServerToolBase`). - call-model.ts: filter keeps server tools unconditionally; name matching only applies to client tools, preventing `t.function.name` access on `ServerToolBase`. - tool-set.ts: `buildToolsMap` skips server tools since they have no name to activate by; `createToolSet` remains client-tool-only while accepting mixed arrays. --- packages/agent-tool-set/src/tool-set.ts | 4 ++++ packages/agent/src/inner-loop/call-model.ts | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts index d35ef64..f3bd9a7 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent-tool-set/src/tool-set.ts @@ -1,4 +1,5 @@ import type { Tool } from '@openrouter/agent'; +import { isServerTool } from '@openrouter/agent'; import type { ActivationInput, ActivationPredicate } from './types.js'; type Entry> = @@ -32,6 +33,9 @@ function isPredicateMap>( function buildToolsMap(tools: readonly Tool[]): Map { const map = new Map(); for (const t of tools) { + if (isServerTool(t)) { + continue; + } const name = t.function.name; if (map.has(name)) { throw new Error(`Duplicate tool name: "${name}"`); diff --git a/packages/agent/src/inner-loop/call-model.ts b/packages/agent/src/inner-loop/call-model.ts index 546ddcb..cac4f17 100644 --- a/packages/agent/src/inner-loop/call-model.ts +++ b/packages/agent/src/inner-loop/call-model.ts @@ -6,6 +6,7 @@ import type { GetResponseOptions } from '../lib/model-result.js'; import { ModelResult } from '../lib/model-result.js'; import { convertToolsToAPIFormat } from '../lib/tool-executor.js'; import type { Tool } from '../lib/tool-types.js'; +import { isServerTool } from '../lib/tool-types.js'; // Re-export CallModelInput for convenience export type { CallModelInput } from '../lib/async-params.js'; @@ -99,7 +100,9 @@ export function callModel< // before they are registered for execution, so the model cannot call filtered // tools and the executor does not carry orphaned definitions. const activeSet = activeTools ? new Set(activeTools) : undefined; - const filteredTools = activeSet ? tools?.filter((t) => activeSet.has(t.function.name)) : tools; + const filteredTools = activeSet + ? tools?.filter((t) => isServerTool(t) || activeSet.has(t.function.name)) + : tools; // Convert tools to API format - no cast needed now that convertToolsToAPIFormat accepts readonly const apiTools = filteredTools ? convertToolsToAPIFormat(filteredTools) : undefined; From f52c1964ec55d3cd0337d7cca4ff60f6b40e1b28 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 21 Apr 2026 16:50:15 -0400 Subject: [PATCH 4/4] fix(agent-tool-set): preserve server tools and thread TShared generic Addresses review feedback on PR #31: - Server tools are no longer silently dropped. ToolSet now tracks the full ordered list separately from the client-tool name index, so `.tools` and `.inferTools()` return both client and server tools. Server tools are always active (no name to filter by) and never appear in the `activeTools` list returned by `inferTools()`. - `createToolSet` now exposes the `TShared` generic (`createToolSet`), so predicates type `context` as the user's context shape instead of `Record`. --- packages/agent-tool-set/README.md | 16 +- packages/agent-tool-set/src/tool-set.ts | 59 +++++--- .../tests/unit/tool-set.test.ts | 142 +++++++++++++++++- 3 files changed, 192 insertions(+), 25 deletions(-) diff --git a/packages/agent-tool-set/README.md b/packages/agent-tool-set/README.md index 9f0928f..17229a5 100644 --- a/packages/agent-tool-set/README.md +++ b/packages/agent-tool-set/README.md @@ -21,6 +21,10 @@ import { OpenRouter, tool, callModel } from '@openrouter/agent'; import { createToolSet } from '@openrouter/agent-tool-set'; import { z } from 'zod/v4'; +type AppContext = { + isAuthenticated: boolean; +}; + const listOrders = tool({ name: 'list_orders', inputSchema: z.object({}), @@ -33,7 +37,9 @@ const cancelOrder = tool({ execute: async () => ({ ok: true }), }); -const toolSet = createToolSet({ tools: [listOrders, cancelOrder] as const }) +const allTools = [listOrders, cancelOrder] as const; + +const toolSet = createToolSet({ tools: allTools }) .activateWhen('list_orders', ({ context }) => context?.isAuthenticated === true) .deactivateWhen('cancel_order', ({ state }) => (state?.messages?.length ?? 0) === 0); @@ -51,12 +57,12 @@ const result = callModel(client, { ## API -- `createToolSet({ tools, mutable? })` — build a set from an ordered tool array. -- `.tools` — all tools in construction order, regardless of activation. -- `.activate(name | names[])` / `.deactivate(name | names[])` — static flip. +- `createToolSet({ tools, mutable? })` — build a set from an ordered tool array. Optional `TShared` generic types the `context` argument passed to predicates. +- `.tools` — all tools in construction order, regardless of activation. Includes both client tools and server tools. +- `.activate(name | names[])` / `.deactivate(name | names[])` — static flip (client tools only). - `.activateWhen(name, predicate)` / `.activateWhen({ [name]: predicate, ... })` — conditional activation (defaults inactive). - `.deactivateWhen(name, predicate)` / `.deactivateWhen({ [name]: predicate, ... })` — conditional deactivation (defaults active). -- `.inferTools(input?)` → `{ tools: Tool[]; activeTools: string[] }` — resolve against an input. +- `.inferTools(input?)` → `{ tools: Tool[]; activeTools: string[] }` — resolve against an input. Server tools (which have no `function.name`) are always included in `tools` and never appear in `activeTools`; only client tools participate in activation. - `.clone({ mutable? })` — copy state, optionally flipping mode. Last-call-wins: each directive on a given tool replaces any prior one for that tool. diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts index f3bd9a7..57da730 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent-tool-set/src/tool-set.ts @@ -1,4 +1,4 @@ -import type { Tool } from '@openrouter/agent'; +import type { ClientTool, Tool } from '@openrouter/agent'; import { isServerTool } from '@openrouter/agent'; import type { ActivationInput, ActivationPredicate } from './types.js'; @@ -30,8 +30,8 @@ function isPredicateMap>( return typeof value === 'object' && value !== null && !Array.isArray(value); } -function buildToolsMap(tools: readonly Tool[]): Map { - const map = new Map(); +function indexClientTools(tools: readonly Tool[]): Map { + const map = new Map(); for (const t of tools) { if (isServerTool(t)) { continue; @@ -49,16 +49,21 @@ export class ToolSet< TTools extends readonly Tool[] = readonly Tool[], TShared extends Record = Record, > { - readonly #tools: Map; + /** All tools in construction order — both client and server. */ + readonly #orderedTools: readonly Tool[]; + /** Name → client tool lookup for activation tracking. Server tools are excluded because they have no `function.name`. */ + readonly #clientToolsByName: Map; readonly #activation: Map>; readonly #mutable: boolean; private constructor( - tools: Map, + orderedTools: readonly Tool[], + clientToolsByName: Map, activation: Map>, mutable: boolean, ) { - this.#tools = tools; + this.#orderedTools = orderedTools; + this.#clientToolsByName = clientToolsByName; this.#activation = activation; this.#mutable = mutable; } @@ -68,16 +73,21 @@ export class ToolSet< T extends readonly Tool[], S extends Record = Record, >(opts: { tools: T; mutable?: boolean }): ToolSet { - return new ToolSet(buildToolsMap(opts.tools), new Map(), opts.mutable ?? false); + return new ToolSet( + opts.tools, + indexClientTools(opts.tools), + new Map(), + opts.mutable ?? false, + ); } /** All tools in construction order, regardless of activation state. */ get tools(): readonly Tool[] { - return Array.from(this.#tools.values()); + return this.#orderedTools; } #assertKnown(name: string): void { - if (!this.#tools.has(name)) { + if (!this.#clientToolsByName.has(name)) { throw new Error(`Unknown tool: "${name}"`); } } @@ -91,7 +101,12 @@ export class ToolSet< } const nextActivation = new Map(this.#activation); mutate(nextActivation); - return new ToolSet(this.#tools, nextActivation, false); + return new ToolSet( + this.#orderedTools, + this.#clientToolsByName, + nextActivation, + false, + ); } activate(names: string | readonly string[]): ToolSet { @@ -196,7 +211,9 @@ export class ToolSet< /** * Resolve activation against an input and return the filtered active tools - * plus the parallel list of active names, both in construction order. + * plus the parallel list of active client-tool names, both in construction + * order. Server tools have no name to filter by and are always included in + * `tools` (but never appear in `activeTools`). */ inferTools(input?: ActivationInput): { tools: Tool[]; @@ -205,7 +222,12 @@ export class ToolSet< const resolved: ActivationInput = input ?? {}; const tools: Tool[] = []; const activeTools: string[] = []; - for (const [name, t] of this.#tools) { + for (const t of this.#orderedTools) { + if (isServerTool(t)) { + tools.push(t); + continue; + } + const name = t.function.name; if (this.#resolveActive(name, resolved)) { tools.push(t); activeTools.push(name); @@ -233,18 +255,19 @@ export class ToolSet< clone(opts?: { mutable?: boolean }): ToolSet { return new ToolSet( - this.#tools, + this.#orderedTools, + this.#clientToolsByName, new Map(this.#activation), opts?.mutable ?? this.#mutable, ); } } -export function createToolSet(opts: { - tools: T; - mutable?: boolean; -}): ToolSet { - return ToolSet.create({ +export function createToolSet< + T extends readonly Tool[], + TShared extends Record = Record, +>(opts: { tools: T; mutable?: boolean }): ToolSet { + return ToolSet.create({ tools: opts.tools, ...(opts.mutable !== undefined && { mutable: opts.mutable, diff --git a/packages/agent-tool-set/tests/unit/tool-set.test.ts b/packages/agent-tool-set/tests/unit/tool-set.test.ts index 4f7da16..d228a26 100644 --- a/packages/agent-tool-set/tests/unit/tool-set.test.ts +++ b/packages/agent-tool-set/tests/unit/tool-set.test.ts @@ -1,6 +1,6 @@ import type { ConversationState } from '@openrouter/agent'; -import { tool } from '@openrouter/agent'; -import { describe, expect, it, vi } from 'vitest'; +import { serverTool, tool } from '@openrouter/agent'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import { z } from 'zod/v4'; import { createToolSet } from '../../src/tool-set.js'; @@ -368,3 +368,141 @@ describe('inferTools input shapes', () => { }); }); }); + +describe('server tools', () => { + const webSearch = serverTool({ + type: 'web_search_2025_08_26', + }); + const datetime = serverTool({ + type: 'openrouter:datetime', + }); + + it('preserves server tools in .tools in construction order', () => { + const ts = createToolSet({ + tools: [ + a, + webSearch, + b, + datetime, + ] as const, + }); + expect(ts.tools).toEqual([ + a, + webSearch, + b, + datetime, + ]); + }); + + it('includes server tools in inferTools output as always-active', () => { + const ts = createToolSet({ + tools: [ + a, + webSearch, + b, + ] as const, + }).deactivate('a'); + const { tools, activeTools } = ts.inferTools(); + expect(tools).toEqual([ + webSearch, + b, + ]); + // server tools have no `function.name` and are never present in activeTools + expect(activeTools).toEqual([ + 'b', + ]); + }); + + it('keeps server tools even when all client tools are deactivated', () => { + const ts = createToolSet({ + tools: [ + a, + webSearch, + b, + ] as const, + }) + .deactivate('a') + .deactivate('b'); + const { tools, activeTools } = ts.inferTools(); + expect(tools).toEqual([ + webSearch, + ]); + expect(activeTools).toEqual([]); + }); + + it('rejects activate/deactivate attempts on server tools (they have no name)', () => { + const ts = createToolSet({ + tools: [ + a, + webSearch, + ] as const, + }); + expect(() => ts.activate('web_search_2025_08_26')).toThrow(/Unknown tool/); + }); +}); + +describe('TShared generic', () => { + type AppContext = { + isAuthenticated: boolean; + userId: string; + }; + + it('types predicate context when TShared is supplied to createToolSet', () => { + const allTools = [ + a, + ] as const; + const ts = createToolSet({ + tools: allTools, + }).activateWhen('a', ({ context }) => { + // Inside the predicate, context is typed as AppContext | undefined. + if (!context) { + return false; + } + expectTypeOf(context).toEqualTypeOf(); + return context.isAuthenticated; + }); + + expect( + ts.inferTools({ + context: { + isAuthenticated: true, + userId: 'u1', + }, + }).activeTools, + ).toEqual([ + 'a', + ]); + expect( + ts.inferTools({ + context: { + isAuthenticated: false, + userId: 'u1', + }, + }).activeTools, + ).toEqual([]); + }); + + it('defaults to Record when TShared is omitted', () => { + const ts = createToolSet({ + tools: [ + a, + ] as const, + }).activateWhen('a', ({ context }) => { + // Context defaults to Record | undefined; values are `unknown`. + if (!context) { + return false; + } + expectTypeOf(context).toEqualTypeOf>(); + return context['enabled'] === true; + }); + expect( + ts.inferTools({ + context: { + enabled: true, + }, + }).activeTools, + ).toEqual([ + 'a', + ]); + }); +});