From ad1c356d337a00b99f5f0a2461edeab8c520dbba Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Mon, 20 Apr 2026 14:44:08 -0400 Subject: [PATCH 01/31] 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 00000000..11d76248 --- /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 00000000..9f0928f3 --- /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 00000000..01e972ce --- /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 00000000..bece36b0 --- /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 00000000..d35ef64c --- /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 00000000..7b012a22 --- /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 00000000..4f7da166 --- /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 00000000..51bb3edc --- /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 00000000..2963346d --- /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 6c1ff25a..5e136316 100644 --- a/packages/agent/src/inner-loop/call-model.ts +++ b/packages/agent/src/inner-loop/call-model.ts @@ -96,6 +96,7 @@ export function callModel< // Destructure state management options along with tools and stopWhen const { tools, + activeTools, stopWhen, state, requireApproval, @@ -116,8 +117,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; // Append the single universal `task` tool when any long-running tool is // registered (and check-ins aren't disabled): ONE static wire definition @@ -158,7 +165,7 @@ export function callModel< client, request: finalRequest, options: callModelOptions, - tools, + tools: filteredTools, stopWhen, state, requireApproval, diff --git a/packages/agent/src/lib/async-params.ts b/packages/agent/src/lib/async-params.ts index 9cabd7c1..015a153d 100644 --- a/packages/agent/src/lib/async-params.ts +++ b/packages/agent/src/lib/async-params.ts @@ -62,6 +62,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>; @@ -309,6 +316,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 8d118b7e..892756ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,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/helper-string-parser@7.29.7': From b489fcd1e66a72fb45f6f44e45220b56ea6a6234 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Mon, 20 Apr 2026 14:51:37 -0400 Subject: [PATCH 02/31] 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 bece36b0..70c88bba 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 7b012a22..a3682566 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 77ddb3874553c74029bd118edbbc93bee3a112db Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 21 Apr 2026 10:48:32 -0400 Subject: [PATCH 03/31] 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 d35ef64c..f3bd9a7b 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 5e136316..49a93e66 100644 --- a/packages/agent/src/inner-loop/call-model.ts +++ b/packages/agent/src/inner-loop/call-model.ts @@ -8,6 +8,7 @@ import { ModelResult } from '../lib/model-result.js'; import { buildTaskToolApiDefinition, needsTaskTool } from '../lib/tool-check.js'; import { convertToolsToAPIFormat, convertZodToJsonSchema } 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'; @@ -121,7 +122,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 01ed1559435152e90350d51287cc2034d6ddb277 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Tue, 21 Apr 2026 16:50:15 -0400 Subject: [PATCH 04/31] 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 9f0928f3..17229a5e 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 f3bd9a7b..57da7306 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 4f7da166..d228a26a 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', + ]); + }); +}); From 53b85bea8bd4737d8735edabe1e69e0e462ab356 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:50:07 -0500 Subject: [PATCH 05/31] chore(agent-tool-set): prepare initial package release --- .changeset/agent-tool-set.md | 2 +- packages/agent-tool-set/README.md | 2 +- .../agent-tool-set/THIRD_PARTY_NOTICES.md | 25 +++++++++++++++++++ packages/agent-tool-set/package.json | 7 +++--- pnpm-lock.yaml | 18 ++++++------- 5 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 packages/agent-tool-set/THIRD_PARTY_NOTICES.md diff --git a/.changeset/agent-tool-set.md b/.changeset/agent-tool-set.md index 11d76248..5181f9e3 100644 --- a/.changeset/agent-tool-set.md +++ b/.changeset/agent-tool-set.md @@ -3,4 +3,4 @@ "@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. +Add `@openrouter/agent-tool-set` (port of ai-tool-set v1.0.0, MIT © Chris Cook): 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 index 17229a5e..52a3726e 100644 --- a/packages/agent-tool-set/README.md +++ b/packages/agent-tool-set/README.md @@ -2,7 +2,7 @@ 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: +Port of [`ai-tool-set`](https://github.com/zirkelc/ai-tool-set) v1.0.0 (MIT © Chris Cook), adapted for this SDK. See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md). - 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. diff --git a/packages/agent-tool-set/THIRD_PARTY_NOTICES.md b/packages/agent-tool-set/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..417083d7 --- /dev/null +++ b/packages/agent-tool-set/THIRD_PARTY_NOTICES.md @@ -0,0 +1,25 @@ +# Third-Party Notices + +`@openrouter/agent-tool-set` is adapted from [`ai-tool-set` v1.0.0](https://github.com/zirkelc/ai-tool-set/tree/v1.0.0), which is licensed under the MIT License: + +> MIT License +> +> Copyright (c) 2024 Chris +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. diff --git a/packages/agent-tool-set/package.json b/packages/agent-tool-set/package.json index 01e972ce..5bb47bd6 100644 --- a/packages/agent-tool-set/package.json +++ b/packages/agent-tool-set/package.json @@ -1,8 +1,8 @@ { "name": "@openrouter/agent-tool-set", - "version": "0.1.0", + "version": "0.0.0", "author": "OpenRouter", - "description": "Declarative activation/deactivation for @openrouter/agent tools. Port of ai-tool-set (MIT © zirkelc) adapted for callModel + tool().", + "description": "Declarative activation/deactivation for @openrouter/agent tools. Port of ai-tool-set (MIT © Chris Cook) adapted for callModel + tool().", "keywords": [ "openrouter", "agent", @@ -34,7 +34,8 @@ "files": [ "esm", "package.json", - "README.md" + "README.md", + "THIRD_PARTY_NOTICES.md" ], "scripts": { "lint": "biome check src tests", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 892756ea..924c9783 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,11 +48,8 @@ importers: specifier: ^4.0.0 version: 4.3.6 - packages/mcp: + packages/agent-tool-set: dependencies: - '@modelcontextprotocol/client': - specifier: ^2.0.0 - version: 2.0.0 '@openrouter/agent': specifier: workspace:* version: link:../agent @@ -60,8 +57,11 @@ importers: specifier: ^4.0.0 version: 4.3.6 - packages/agent-tool-set: + packages/mcp: dependencies: + '@modelcontextprotocol/client': + specifier: ^2.0.0 + version: 2.0.0 '@openrouter/agent': specifier: workspace:* version: link:../agent @@ -833,8 +833,8 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - jose@6.2.4: - resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1517,7 +1517,7 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - jose: 6.2.4 + jose: 6.2.3 pkce-challenge: 5.0.1 zod: 4.3.6 @@ -1908,7 +1908,7 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - jose@6.2.4: {} + jose@6.2.3: {} js-tokens@10.0.0: {} From 21b807880989c95a6b1543311d41eebea07cdeea Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:56:21 -0500 Subject: [PATCH 06/31] feat(agent): preserve tool names in typed events --- packages/agent/src/index.ts | 7 + packages/agent/src/lib/model-result.ts | 89 ++-- packages/agent/src/lib/tool-types.ts | 164 ++++++- packages/agent/src/lib/tool.ts | 16 +- .../unit/mcp-result-discrimination.test-d.ts | 9 +- .../unit/tool-name-correlation.test-d.ts | 145 ++++++ .../agent/tests/unit/tool-name-events.test.ts | 438 ++++++++++++++++++ 7 files changed, 796 insertions(+), 72 deletions(-) create mode 100644 packages/agent/tests/unit/tool-name-correlation.test-d.ts create mode 100644 packages/agent/tests/unit/tool-name-events.test.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 3b612660..3eb05b51 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -245,6 +245,12 @@ export type { ClientTool, ConversationState, ConversationStatus, + CorrelatedResponseStreamEvent, + CorrelatedToolEventUnion, + CorrelatedToolPreliminaryResultEvent, + CorrelatedToolResultEvent, + CorrelatedToolStreamEvent, + CorrelatedToolStreamPreliminaryUnion, DeferOptions, DeferredHandle, HasApprovalTools, @@ -253,6 +259,7 @@ export type { InferToolEvent, InferToolEventsUnion, InferToolInput, + InferToolName, InferToolOutput, InferToolOutputsUnion, ManualTool, diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index e8d07757..b270b662 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -97,11 +97,12 @@ import { import type { ConversationState, ConversationStatus, + CorrelatedResponseStreamEvent, + CorrelatedToolStreamEvent, InferToolEventsUnion, InferToolOutputsUnion, ParsedToolCall, PendingAsyncTool, - ResponseStreamEvent, ServerToolResultItem, StateAccessor, StopWhen, @@ -111,7 +112,6 @@ import type { ToolCallOutputEvent, ToolContextMapWithShared, ToolResultItem, - ToolStreamEvent, TurnContext, TurnEndEvent, TurnStartEvent, @@ -567,11 +567,13 @@ export class ModelResult< | { type: 'preliminary_result'; toolCallId: string; + toolName: string; result: InferToolEventsUnion; } | { type: 'tool_result'; toolCallId: string; + toolName: string; source: 'client' | 'mcp'; result: InferToolOutputsUnion; preliminaryResults?: InferToolEventsUnion[]; @@ -621,9 +623,8 @@ export class ModelResult< private isResumingFromApproval = false; // Unified turn broadcaster for multi-turn streaming - private turnBroadcaster: ToolEventBroadcaster< - ResponseStreamEvent, InferToolOutputsUnion> - > | null = null; + private turnBroadcaster: ToolEventBroadcaster> | null = + null; private initialStreamPipeStarted = false; private initialPipePromise: Promise | null = null; @@ -853,9 +854,7 @@ export class ModelResult< * Get or create the unified turn broadcaster (lazy initialization). * Broadcasts all API stream events, tool events, and turn delimiters across turns. */ - private ensureTurnBroadcaster(): ToolEventBroadcaster< - ResponseStreamEvent, InferToolOutputsUnion> - > { + private ensureTurnBroadcaster(): ToolEventBroadcaster> { if (!this.turnBroadcaster) { this.turnBroadcaster = new ToolEventBroadcaster(); } @@ -966,6 +965,7 @@ export class ModelResult< */ private broadcastToolResult( toolCallId: string, + toolName: string, source: 'client' | 'mcp', result: InferToolOutputsUnion, preliminaryResults?: InferToolEventsUnion[], @@ -973,6 +973,7 @@ export class ModelResult< this.toolEventBroadcaster?.push({ type: 'tool_result' as const, toolCallId, + toolName, source, result, ...(preliminaryResults?.length && { @@ -982,13 +983,14 @@ export class ModelResult< this.turnBroadcaster?.push({ type: 'tool.result' as const, toolCallId, + toolName, source, result, timestamp: Date.now(), ...(preliminaryResults?.length && { preliminaryResults, }), - }); + } as CorrelatedResponseStreamEvent); } /** @@ -997,19 +999,22 @@ export class ModelResult< */ private broadcastPreliminaryResult( toolCallId: string, + toolName: string, result: InferToolEventsUnion, ): void { this.toolEventBroadcaster?.push({ type: 'preliminary_result' as const, toolCallId, + toolName, result, }); this.turnBroadcaster?.push({ type: 'tool.preliminary_result' as const, toolCallId, + toolName, result, timestamp: Date.now(), - }); + } as CorrelatedResponseStreamEvent); } /** @@ -1017,9 +1022,7 @@ export class ModelResult< * Used by stream methods that need to iterate over all turns. */ private startTurnBroadcasterExecution(): { - consumer: AsyncIterableIterator< - ResponseStreamEvent, InferToolOutputsUnion> - >; + consumer: AsyncIterableIterator>; executionPromise: Promise; } { const broadcaster = this.ensureTurnBroadcaster(); @@ -2767,7 +2770,7 @@ export class ModelResult< ); if (hookOutcome.type === 'parse_error') { - this.broadcastToolResult(tc.id, isMcpTool(tool) ? 'mcp' : 'client', { + this.broadcastToolResult(tc.id, String(tc.name), isMcpTool(tool) ? 'mcp' : 'client', { error: hookOutcome.errorMessage, } as InferToolOutputsUnion); return createRejectedResult(tc.id, String(tc.name), hookOutcome.errorMessage); @@ -3086,6 +3089,7 @@ export class ModelResult< if (task.status === 'completed') { this.broadcastToolResult( task.callId, + task.name, this.toolSourceByName(task.name), task.result as InferToolOutputsUnion, ); @@ -3287,7 +3291,7 @@ export class ModelResult< ? (callId: string, resultValue: unknown) => { const typedResult = resultValue as InferToolEventsUnion; preliminaryResultsForCall.push(typedResult); - this.broadcastPreliminaryResult(callId, typedResult); + this.broadcastPreliminaryResult(callId, String(toolCall.name), typedResult); } : undefined; @@ -3360,7 +3364,7 @@ export class ModelResult< } if (executed.type === 'parse_error') { - this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', { + this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', { error: executed.errorMessage, } as InferToolOutputsUnion); return executed; @@ -3431,7 +3435,7 @@ export class ModelResult< preliminaryResultsForCall: InferToolEventsUnion[]; } { const message = `Tool "${toolCall.name}" timed out after ${timeoutMs}ms`; - this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', { + this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', { error: message, } as InferToolOutputsUnion); return { @@ -3565,6 +3569,7 @@ export class ModelResult< // `runToolWithHooks` is the single point of emission for PostToolUseFailure. this.broadcastToolResult( originalToolCall.id, + originalToolCall.name, this.toolSourceByName(originalToolCall.name), { error: errorMessage, @@ -3639,6 +3644,7 @@ export class ModelResult< ) as InferToolOutputsUnion; this.broadcastToolResult( value.toolCall.id, + String(value.toolCall.name), isMcpTool(value.tool) ? 'mcp' : 'client', toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined, @@ -3807,6 +3813,7 @@ export class ModelResult< if (settled.outcome === 'ok') { this.broadcastToolResult( toolCall.id, + String(toolCall.name), source, settled.result as InferToolOutputsUnion, ); @@ -3828,7 +3835,7 @@ export class ModelResult< } const message = settled.error instanceof Error ? settled.error.message : String(settled.error); - this.broadcastToolResult(toolCall.id, source, { + this.broadcastToolResult(toolCall.id, String(toolCall.name), source, { error: message, } as InferToolOutputsUnion); return { @@ -3886,7 +3893,7 @@ export class ModelResult< return null; } const message = `Tool "${toolCall.name}": ctx.defer() taskId "${taskId}" is already in use by another pending task in this conversation (call ${duplicate.callId}). Task ids must be unique per conversation — include a per-call component (e.g. the ticket id plus your callId).`; - this.broadcastToolResult(toolCall.id, source, { + this.broadcastToolResult(toolCall.id, String(toolCall.name), source, { error: message, } as InferToolOutputsUnion); return { @@ -4120,7 +4127,12 @@ export class ModelResult< const taskTool = buildTaskToolStub(); const answer = (result: unknown, error?: Error) => { if (error === undefined) { - this.broadcastToolResult(toolCall.id, 'client', result as InferToolOutputsUnion); + this.broadcastToolResult( + toolCall.id, + String(toolCall.name), + 'client', + result as InferToolOutputsUnion, + ); } return { type: 'execution' as const, @@ -5584,9 +5596,14 @@ export class ModelResult< ); if (hookOutcome.type === 'parse_error') { - this.broadcastToolResult(callId, this.toolSourceByName(String(toolCall.name)), { - error: hookOutcome.errorMessage, - } as InferToolOutputsUnion); + this.broadcastToolResult( + callId, + String(toolCall.name), + this.toolSourceByName(String(toolCall.name)), + { + error: hookOutcome.errorMessage, + } as InferToolOutputsUnion, + ); unsentResults.push( createRejectedResult(callId, String(toolCall.name), hookOutcome.errorMessage), ); @@ -6326,9 +6343,7 @@ export class ModelResult< * Multiple consumers can iterate over this stream concurrently. * Includes API events, tool events, and turn.start/turn.end delimiters. */ - getFullResponsesStream(): AsyncIterableIterator< - ResponseStreamEvent, InferToolOutputsUnion> - > { + getFullResponsesStream(): AsyncIterableIterator> { return async function* (this: ModelResult) { await this.initStreamGuarded(); @@ -6726,7 +6741,7 @@ export class ModelResult< * - Tool call argument deltas as { type: "delta", content: string } * - Preliminary results as { type: "preliminary_result", toolCallId, result } */ - getToolStream(): AsyncIterableIterator>> { + getToolStream(): AsyncIterableIterator> { return async function* (this: ModelResult) { await this.initStreamGuarded(); @@ -6765,19 +6780,17 @@ export class ModelResult< continue; } if (event.type === 'tool.preliminary_result') { + const prelim = event as { + toolCallId: string; + toolName: string; + result: InferToolEventsUnion; + }; yield { type: 'preliminary_result' as const, - toolCallId: ( - event as { - toolCallId: string; - } - ).toolCallId, - result: ( - event as { - result: InferToolEventsUnion; - } - ).result, - }; + toolCallId: prelim.toolCallId, + toolName: prelim.toolName, + result: prelim.result, + } as CorrelatedToolStreamEvent; } } diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 0ea4140d..93717bda 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -109,9 +109,10 @@ export type ContextFromSchema> = : zodInfer & Record; /** - * Extract tool name from a tool definition + * Extract tool name from a tool definition. + * Preserves literal names when present; falls back to `string`. */ -type InferToolName = T extends { +export type InferToolName = T extends { function: { name: infer N extends string; }; @@ -418,12 +419,14 @@ export type ToModelOutputFunction = { * Base tool function interface with inputSchema * @template TInput - Zod schema for tool input * @template TCtx - Zod schema for tool context (optional; default = erased wide type) + * @template TName - Literal tool name (default `string` keeps wide assignability) */ export interface BaseToolFunction< TInput extends $ZodObject<$ZodShape>, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TName extends string = string, > { - name: string; + name: TName; description?: string; inputSchema: TInput; /** @@ -490,7 +493,7 @@ export interface ToolFunctionWithExecute< TContext extends Record = Record, TName extends string = string, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, -> extends BaseToolFunction { +> extends BaseToolFunction { outputSchema?: TOutput; /** * Absent on regular tools. Declared as `undefined`-only so @@ -540,7 +543,7 @@ export interface ToolFunctionWithGenerator< TContext extends Record = Record, TName extends string = string, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, -> extends BaseToolFunction { +> extends BaseToolFunction { eventSchema: TEvent; outputSchema: TOutput; // Method syntax for bivariant param checking — see ToolFunctionWithExecute. @@ -559,7 +562,8 @@ export interface ManualToolFunction< TInput extends $ZodObject<$ZodShape>, TOutput extends $ZodType = $ZodType, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, -> extends BaseToolFunction { + TName extends string = string, +> extends BaseToolFunction { outputSchema?: TOutput; } @@ -581,7 +585,7 @@ export interface HITLToolFunction< TContext extends Record = Record, TName extends string = string, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, -> extends BaseToolFunction { +> extends BaseToolFunction { /** * Required for HITL tools. Used to validate both the `onToolCalled` return * value (when non-null) and the caller-supplied response that comes back via @@ -740,9 +744,10 @@ export type ToolWithExecute< TOutput extends $ZodType = $ZodType, TContext extends Record = Record, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TName extends string = string, > = { type: ToolType.Function; - function: ToolFunctionWithExecute; + function: ToolFunctionWithExecute; }; /** @@ -755,9 +760,10 @@ export type ToolWithGenerator< TOutput extends $ZodType = $ZodType, TContext extends Record = Record, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TName extends string = string, > = { type: ToolType.Function; - function: ToolFunctionWithGenerator; + function: ToolFunctionWithGenerator; }; /** @@ -768,9 +774,10 @@ export type ManualTool< TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, TOutput extends $ZodType = $ZodType, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TName extends string = string, > = { type: ToolType.Function; - function: ManualToolFunction; + function: ManualToolFunction; }; /** @@ -782,9 +789,10 @@ export type HITLTool< TOutput extends $ZodType = $ZodType, TContext extends Record = Record, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TName extends string = string, > = { type: ToolType.Function; - function: HITLToolFunction; + function: HITLToolFunction; }; /** @@ -1295,10 +1303,13 @@ export interface APITool { /** * Tool preliminary result event emitted during generator tool execution * @template TEvent - The event type from the tool's eventSchema + * @template TName - The tool's name (literal when known) */ -export type ToolPreliminaryResultEvent = { +export type ToolPreliminaryResultEvent = { type: 'tool.preliminary_result'; toolCallId: string; + /** Name of the tool that produced this preliminary result */ + toolName: TName; result: TEvent; timestamp: number; }; @@ -1308,10 +1319,17 @@ export type ToolPreliminaryResultEvent = { * Contains the final result and any preliminary results that were emitted * @template TResult - The result type from the tool's outputSchema * @template TPreliminaryResults - The event type from generator tools' eventSchema + * @template TName - The tool's name (literal when known) */ -export type ToolResultEvent = { +export type ToolResultEvent< + TResult = unknown, + TPreliminaryResults = unknown, + TName extends string = string, +> = { type: 'tool.result'; toolCallId: string; + /** Name of the tool that produced this result */ + toolName: TName; /** * Origin of the tool: `'mcp'` for tools wrapped from a remote MCP server * (whose `result` is `unknown`), `'client'` for locally-defined tools. Lets @@ -1324,6 +1342,67 @@ export type ToolResultEvent = preliminaryResults?: TPreliminaryResults[]; }; +/** + * Name-correlated preliminary result event for one concrete tool. + * Narrowing on `toolName` recovers this tool's event payload type. + */ +export type CorrelatedToolPreliminaryResultEvent = ToolPreliminaryResultEvent< + InferToolEvent, + InferToolName +>; + +/** + * Name-correlated final result event for one concrete tool. + * Narrowing on `toolName` recovers this tool's result (and preliminary) types. + */ +export type CorrelatedToolResultEvent = ToolResultEvent< + T extends { + readonly _mcp: true; + } + ? unknown + : [ + Tool, + ] extends [ + T, + ] + ? unknown + : T extends + | ToolWithExecute<$ZodObject<$ZodShape>, infer O> + | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, infer O> + | HITLTool<$ZodObject<$ZodShape>, infer O> + ? zodInfer + : InferToolOutput, + T extends ToolWithGenerator<$ZodObject<$ZodShape>, infer E> ? zodInfer : never, + InferToolName +> & { + source: ToolSource; +}; + +/** + * Discriminated union of name-correlated tool events across a tools tuple. + * Checking `event.toolName === 'my_tool'` narrows `result` to that tool's output. + */ +export type CorrelatedToolEventUnion = { + [K in keyof T]: T[K] extends Tool + ? CorrelatedToolPreliminaryResultEvent | CorrelatedToolResultEvent + : never; +}[number]; + +/** + * Discriminated union of name-correlated preliminary stream events + * (legacy `getToolStream` shape) across a tools tuple. + */ +export type CorrelatedToolStreamPreliminaryUnion = { + [K in keyof T]: T[K] extends Tool + ? { + type: 'preliminary_result'; + toolCallId: string; + toolName: InferToolName; + result: InferToolEvent; + } + : never; +}[number]; + /** * Tool call output event carrying the fully-formed FunctionCallOutputItem. * Broadcast by executeToolRound so passive consumers (getItemsStream) can yield @@ -1394,11 +1473,29 @@ export type TurnEndEvent = { * and turn delimiter events for multi-turn streaming * @template TEvent - The event type from generator tools * @template TResult - The result type from tool execution + * @template TName - Tool name (literal when known) */ -export type ResponseStreamEvent = +export type ResponseStreamEvent< + TEvent = unknown, + TResult = unknown, + TName extends string = string, +> = | StreamEvents - | ToolPreliminaryResultEvent - | ToolResultEvent + | ToolPreliminaryResultEvent + | ToolResultEvent + | ToolCallOutputEvent + | TurnStartEvent + | TurnEndEvent; + +/** + * Name-correlated stream events for a concrete tools tuple. + * Prefer this (or {@link ModelResult.getFullResponsesStream}) when callers need + * `event.toolName` narrowing; the default {@link ResponseStreamEvent} keeps a + * wide, backward-compatible shape. + */ +export type CorrelatedResponseStreamEvent = + | StreamEvents + | CorrelatedToolEventUnion | ToolCallOutputEvent | ToolAsyncStartedEvent | ToolAsyncSettledEvent @@ -1426,18 +1523,22 @@ export function isToolAsyncSettledEvent( /** * Type guard to check if an event is a tool preliminary result event */ -export function isToolPreliminaryResultEvent( - event: ResponseStreamEvent, -): event is ToolPreliminaryResultEvent { +export function isToolPreliminaryResultEvent( + event: ResponseStreamEvent, +): event is ToolPreliminaryResultEvent { return event.type === 'tool.preliminary_result'; } /** * Type guard to check if an event is a tool result event */ -export function isToolResultEvent( - event: ResponseStreamEvent, -): event is ToolResultEvent { +export function isToolResultEvent< + TResult = unknown, + TPreliminaryResults = unknown, + TName extends string = string, +>( + event: ResponseStreamEvent, +): event is ToolResultEvent { return event.type === 'tool.result'; } @@ -1466,8 +1567,9 @@ export function isTurnEndEvent(event: ResponseStreamEvent): event is TurnEndEven * Tool stream event types for getToolStream * Includes both argument deltas and preliminary results * @template TEvent - The event type from generator tools + * @template TName - Tool name (literal when known) */ -export type ToolStreamEvent = +export type ToolStreamEvent = | { type: 'delta'; content: string; @@ -1475,15 +1577,28 @@ export type ToolStreamEvent = | { type: 'preliminary_result'; toolCallId: string; + toolName: TName; result: TEvent; }; +/** + * Name-correlated tool stream events for a concrete tools tuple. + * Checking `event.toolName` on a `preliminary_result` narrows `result`. + */ +export type CorrelatedToolStreamEvent = + | { + type: 'delta'; + content: string; + } + | CorrelatedToolStreamPreliminaryUnion; + /** * Chat stream event types for getFullChatStream * Includes content deltas, completion events, and tool preliminary results * @template TEvent - The event type from generator tools + * @template TName - Tool name (literal when known) */ -export type ChatStreamEvent = +export type ChatStreamEvent = | { type: 'content.delta'; delta: string; @@ -1495,6 +1610,7 @@ export type ChatStreamEvent = | { type: 'tool.preliminary_result'; toolCallId: string; + toolName: TName; result: TEvent; } | { diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index 3731e676..3c47c644 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -145,8 +145,9 @@ type GeneratorToolConfig< type ManualToolConfig< TInput extends $ZodObject<$ZodShape>, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TName extends string = string, > = { - name: string; // Manual tools don't use TName since they have no execute + name: TName; description?: string; inputSchema: TInput; /** Strict schema adherence for tool-call generation — see {@link BaseToolFunction.strict} */ @@ -447,7 +448,7 @@ export function tool< TName extends string = string, >( config: GeneratorToolConfig, -): ToolWithGenerator, TCtx>; +): ToolWithGenerator, TCtx, TName>; // Overload for HITL tools (when onToolCalled is provided) export function tool< @@ -457,13 +458,16 @@ export function tool< TName extends string = string, >( config: HITLToolConfig, -): HITLTool, TCtx>; +): HITLTool, TCtx, TName>; // Overload for manual tools (execute: false) export function tool< TInput extends $ZodObject<$ZodShape>, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, ->(config: ManualToolConfig): ManualTool, TCtx>; + TName extends string = string, +>( + config: ManualToolConfig, +): ManualTool, TCtx, TName>; // Overload for regular tools with outputSchema export function tool< @@ -473,7 +477,7 @@ export function tool< TName extends string = string, >( config: RegularToolConfigWithOutput, -): ToolWithExecute, TCtx>; +): ToolWithExecute, TCtx, TName>; // Overload for regular tools without outputSchema (infers return type) export function tool< @@ -483,7 +487,7 @@ export function tool< TName extends string = string, >( config: RegularToolConfigWithoutOutput, -): ToolWithExecute, Record, TCtx>; +): ToolWithExecute, Record, TCtx, TName>; // Overload for explicit TShared: tool({...}) // When a non-ZodObject type is provided as the first generic, diff --git a/packages/agent/tests/unit/mcp-result-discrimination.test-d.ts b/packages/agent/tests/unit/mcp-result-discrimination.test-d.ts index 62c185df..46a366fc 100644 --- a/packages/agent/tests/unit/mcp-result-discrimination.test-d.ts +++ b/packages/agent/tests/unit/mcp-result-discrimination.test-d.ts @@ -10,10 +10,11 @@ * `ToolWithExecute<…, infer O>`. Using real factory values is the point — it * mirrors what callers (and `wrapMcpTool`) actually build. * - * Note on `toolName`: the `tool()` factory also widens the `name` literal to - * `string` (the same widening documented in has-approval-tools.test-d.ts), so - * discrimination is by `source`, not by `toolName`. That is exactly why the - * discriminant added to the result types is `source`. + * Note on `toolName`: the `tool()` factory now preserves name literals, so + * stream event unions can narrow by `toolName`. MCP tools still use `source` + * as the primary discriminant because the MCP brand (not the name) marks + * result opacity — a client tool named like an MCP tool must not be treated + * as unknown. */ import { expectTypeOf } from 'vitest'; diff --git a/packages/agent/tests/unit/tool-name-correlation.test-d.ts b/packages/agent/tests/unit/tool-name-correlation.test-d.ts new file mode 100644 index 00000000..91f8c345 --- /dev/null +++ b/packages/agent/tests/unit/tool-name-correlation.test-d.ts @@ -0,0 +1,145 @@ +/** + * Type-level tests: `tool()` preserves literal names, and name-correlated + * stream/result event unions narrow `result` from `event.toolName`. + */ + +import { expectTypeOf } from 'vitest'; +import * as z from 'zod'; +import { tool } from '../../src/lib/tool.js'; +import type { + CorrelatedResponseStreamEvent, + CorrelatedToolEventUnion, + CorrelatedToolResultEvent, + CorrelatedToolStreamEvent, + InferToolName, + Tool, + ToolWithExecute, +} from '../../src/lib/tool-types.js'; + +const weather = tool({ + name: 'weather', + inputSchema: z.object({ + city: z.string(), + }), + outputSchema: z.object({ + tempC: z.number(), + }), + execute: async () => ({ + tempC: 20, + }), +}); + +const progress = tool({ + name: 'progress_tool', + inputSchema: z.object({ + n: z.number(), + }), + eventSchema: z.object({ + stage: z.string(), + }), + outputSchema: z.object({ + done: z.boolean(), + }), + execute: async function* () { + yield { + stage: 'start', + }; + yield { + done: true, + }; + }, +}); + +const manual = tool({ + name: 'manual_tool', + inputSchema: z.object({ + id: z.string(), + }), + execute: false, +}); + +const hitl = tool({ + name: 'hitl_tool', + inputSchema: z.object({ + q: z.string(), + }), + outputSchema: z.object({ + answer: z.string(), + }), + onToolCalled: async () => null, +}); + +// --- Literal names survive the factory -------------------------------------- +expectTypeOf(weather.function.name).toEqualTypeOf<'weather'>(); +expectTypeOf(progress.function.name).toEqualTypeOf<'progress_tool'>(); +expectTypeOf(manual.function.name).toEqualTypeOf<'manual_tool'>(); +expectTypeOf(hitl.function.name).toEqualTypeOf<'hitl_tool'>(); + +expectTypeOf>().toEqualTypeOf<'weather'>(); +expectTypeOf>().toEqualTypeOf<'progress_tool'>(); +expectTypeOf>().toEqualTypeOf<'manual_tool'>(); +expectTypeOf>().toEqualTypeOf<'hitl_tool'>(); + +// Wide defaults still assign to Tool +expectTypeOf(weather).toExtend(); +expectTypeOf(progress).toExtend(); +expectTypeOf(manual).toExtend(); +expectTypeOf(hitl).toExtend(); +expectTypeOf().toExtend(); + +type Tools = readonly [ + typeof weather, + typeof progress, + typeof manual, + typeof hitl, +]; + +type Events = CorrelatedToolEventUnion; +type Stream = CorrelatedResponseStreamEvent; +type ToolStream = CorrelatedToolStreamEvent; + +// --- Narrowing tool.result by toolName -------------------------------------- +declare const correlated: Events; +if (correlated.type === 'tool.result' && correlated.toolName === 'weather') { + expectTypeOf(correlated.result).toEqualTypeOf<{ + tempC: number; + }>(); + expectTypeOf(correlated.toolName).toEqualTypeOf<'weather'>(); +} +if (correlated.type === 'tool.result' && correlated.toolName === 'progress_tool') { + expectTypeOf(correlated.result).toEqualTypeOf<{ + done: boolean; + }>(); +} +if (correlated.type === 'tool.result' && correlated.toolName === 'hitl_tool') { + expectTypeOf(correlated.result).toEqualTypeOf<{ + answer: string; + }>(); +} +if (correlated.type === 'tool.preliminary_result' && correlated.toolName === 'progress_tool') { + expectTypeOf(correlated.result).toEqualTypeOf<{ + stage: string; + }>(); +} + +// Stream method view uses the same correlated union for tool events +declare const streamEvent: Stream; +if (streamEvent.type === 'tool.result' && streamEvent.toolName === 'weather') { + expectTypeOf(streamEvent.result).toEqualTypeOf<{ + tempC: number; + }>(); +} + +// Legacy getToolStream preliminary events carry toolName + correlated result +declare const toolStreamEvent: ToolStream; +if (toolStreamEvent.type === 'preliminary_result' && toolStreamEvent.toolName === 'progress_tool') { + expectTypeOf(toolStreamEvent.result).toEqualTypeOf<{ + stage: string; + }>(); +} + +// Per-tool correlated result helper +expectTypeOf['toolName']>().toEqualTypeOf<'weather'>(); +expectTypeOf['result']>().toEqualTypeOf<{ + tempC: number; +}>(); diff --git a/packages/agent/tests/unit/tool-name-events.test.ts b/packages/agent/tests/unit/tool-name-events.test.ts new file mode 100644 index 00000000..cf216409 --- /dev/null +++ b/packages/agent/tests/unit/tool-name-events.test.ts @@ -0,0 +1,438 @@ +import type { OpenRouterCore } from '@openrouter/sdk/core'; +import type * as models from '@openrouter/sdk/models'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; +import type { GetResponseOptions } from '../../src/lib/model-result.js'; +import { ModelResult } from '../../src/lib/model-result.js'; +import { tool } from '../../src/lib/tool.js'; +import type { Tool } from '../../src/lib/tool-types.js'; +import { isToolPreliminaryResultEvent, isToolResultEvent } from '../../src/lib/tool-types.js'; + +type Internal = { + currentState: { + id: string; + messages: models.BaseInputsUnion[]; + status: 'in_progress'; + createdAt: number; + updatedAt: number; + } | null; + initPromise: Promise | null; + getInitialResponse: () => Promise; + makeFollowupRequest: (...args: unknown[]) => Promise; + shouldStopExecution: () => Promise; + executeToolsIfNeeded: () => Promise; + turnBroadcaster: { + createConsumer: () => AsyncIterableIterator; + } | null; + toolEventBroadcaster: { + createConsumer: () => AsyncIterableIterator; + push: (event: unknown) => void; + complete: () => void; + } | null; + ensureTurnBroadcaster: () => { + createConsumer: () => AsyncIterableIterator; + push: (event: unknown) => void; + complete: () => void; + }; +}; + +function makeResponseWithToolCalls( + calls: Array<{ + id: string; + name: string; + arguments: string; + }>, +): models.OpenResponsesResult { + return { + id: 'resp_test', + object: 'response', + createdAt: 0, + model: 'test-model', + status: 'completed', + output: calls.map((c) => ({ + type: 'function_call' as const, + id: c.id, + callId: c.id, + name: c.name, + arguments: c.arguments, + status: 'completed' as const, + })), + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + }, + } as unknown as models.OpenResponsesResult; +} + +function makeFinalResponse(): models.OpenResponsesResult { + return { + id: 'resp_final', + object: 'response', + createdAt: 0, + model: 'test-model', + status: 'completed', + output: [ + { + type: 'message', + id: 'msg_1', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text: 'done', + }, + ], + }, + ], + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + }, + } as unknown as models.OpenResponsesResult; +} + +function buildModelResult(tools: readonly Tool[]): { + result: ModelResult; + internal: Internal; +} { + const config: GetResponseOptions = { + request: { + model: 'test-model', + input: 'hello', + }, + client: {} as OpenRouterCore, + tools, + }; + const result = new ModelResult(config); + const internal = result as unknown as Internal; + internal.currentState = { + id: 'conv', + messages: [], + status: 'in_progress', + createdAt: 0, + updatedAt: 0, + }; + internal.initPromise = Promise.resolve(); + internal.shouldStopExecution = async () => false; + return { + result, + internal, + }; +} + +async function collectAsyncIterable(consumer: AsyncIterableIterator): Promise { + const events: unknown[] = []; + for await (const event of consumer) { + events.push(event); + } + return events; +} + +describe('toolName on runtime tool events', () => { + it('includes toolName on tool.result for regular execute tools', async () => { + const regular = tool({ + name: 'echo', + inputSchema: z.object({ + text: z.string(), + }), + outputSchema: z.object({ + text: z.string(), + }), + execute: async (params) => ({ + text: params.text, + }), + }); + + const { internal } = buildModelResult([ + regular, + ]); + internal.getInitialResponse = async () => + makeResponseWithToolCalls([ + { + id: 'call_echo', + name: 'echo', + arguments: JSON.stringify({ + text: 'hi', + }), + }, + ]); + internal.makeFollowupRequest = async () => makeFinalResponse(); + + const broadcaster = internal.ensureTurnBroadcaster(); + const consumer = broadcaster.createConsumer(); + const eventsPromise = collectAsyncIterable(consumer); + + await internal.executeToolsIfNeeded(); + broadcaster.complete(); + const events = await eventsPromise; + + const toolResults = events.filter(isToolResultEvent); + expect(toolResults).toHaveLength(1); + expect(toolResults[0]).toMatchObject({ + type: 'tool.result', + toolCallId: 'call_echo', + toolName: 'echo', + source: 'client', + result: { + text: 'hi', + }, + }); + }); + + it('includes toolName on preliminary and final generator events', async () => { + const generator = tool({ + name: 'progress_tool', + inputSchema: z.object({}), + eventSchema: z.object({ + stage: z.string(), + }), + outputSchema: z.object({ + done: z.boolean(), + }), + execute: async function* () { + yield { + stage: 'one', + }; + yield { + stage: 'two', + }; + yield { + done: true, + }; + }, + }); + + const { internal } = buildModelResult([ + generator, + ]); + internal.getInitialResponse = async () => + makeResponseWithToolCalls([ + { + id: 'call_progress', + name: 'progress_tool', + arguments: '{}', + }, + ]); + internal.makeFollowupRequest = async () => makeFinalResponse(); + + const turn = internal.ensureTurnBroadcaster(); + // Mirror the legacy tool-event broadcaster path used by getToolStream consumers. + const { ToolEventBroadcaster } = await import('../../src/lib/tool-event-broadcaster.js'); + const legacy = new ToolEventBroadcaster<{ + type: 'preliminary_result' | 'tool_result'; + toolCallId: string; + toolName: string; + result?: unknown; + source?: 'client' | 'mcp'; + preliminaryResults?: unknown[]; + }>(); + internal.toolEventBroadcaster = legacy; + + const turnConsumer = turn.createConsumer(); + const legacyConsumer = legacy.createConsumer(); + + const turnEventsPromise = collectAsyncIterable(turnConsumer); + const legacyEventsPromise = collectAsyncIterable(legacyConsumer); + + await internal.executeToolsIfNeeded(); + turn.complete(); + legacy.complete(); + + const turnEvents = await turnEventsPromise; + const legacyEvents = await legacyEventsPromise; + + const prelims = turnEvents.filter(isToolPreliminaryResultEvent); + expect(prelims).toHaveLength(2); + expect(prelims[0]).toMatchObject({ + type: 'tool.preliminary_result', + toolCallId: 'call_progress', + toolName: 'progress_tool', + result: { + stage: 'one', + }, + }); + expect(prelims[1]).toMatchObject({ + toolName: 'progress_tool', + result: { + stage: 'two', + }, + }); + + const finals = turnEvents.filter(isToolResultEvent); + expect(finals).toHaveLength(1); + expect(finals[0]).toMatchObject({ + type: 'tool.result', + toolName: 'progress_tool', + result: { + done: true, + }, + preliminaryResults: [ + { + stage: 'one', + }, + { + stage: 'two', + }, + ], + }); + + const legacyPrelims = legacyEvents.filter( + ( + e, + ): e is { + type: 'preliminary_result'; + toolCallId: string; + toolName: string; + result: unknown; + } => e.type === 'preliminary_result', + ); + expect(legacyPrelims).toHaveLength(2); + expect(legacyPrelims[0]?.toolName).toBe('progress_tool'); + expect(legacyPrelims[1]?.toolName).toBe('progress_tool'); + + // getToolStream-shaped projection carries toolName too + const projected = prelims.map((event) => ({ + type: 'preliminary_result' as const, + toolCallId: event.toolCallId, + toolName: event.toolName, + result: event.result, + })); + expect(projected[0]?.toolName).toBe('progress_tool'); + }); + + it('includes toolName on tool.result for HITL auto-resolve path', async () => { + const hitl = tool({ + name: 'hitl_tool', + inputSchema: z.object({ + q: z.string(), + }), + outputSchema: z.object({ + answer: z.string(), + }), + onToolCalled: async () => ({ + answer: '42', + }), + }); + + const { internal } = buildModelResult([ + hitl, + ]); + internal.getInitialResponse = async () => + makeResponseWithToolCalls([ + { + id: 'call_hitl', + name: 'hitl_tool', + arguments: JSON.stringify({ + q: 'life?', + }), + }, + ]); + internal.makeFollowupRequest = async () => makeFinalResponse(); + + const broadcaster = internal.ensureTurnBroadcaster(); + const consumer = broadcaster.createConsumer(); + const eventsPromise = collectAsyncIterable(consumer); + + await internal.executeToolsIfNeeded(); + broadcaster.complete(); + const events = await eventsPromise; + + const toolResults = events.filter(isToolResultEvent); + expect(toolResults).toHaveLength(1); + expect(toolResults[0]).toMatchObject({ + toolName: 'hitl_tool', + result: { + answer: '42', + }, + }); + }); + + it('includes toolName on rejected/error tool.result events', async () => { + const boom = tool({ + name: 'boom', + inputSchema: z.object({}), + outputSchema: z.object({ + ok: z.boolean(), + }), + execute: async () => { + throw new Error('explode'); + }, + }); + + const { internal } = buildModelResult([ + boom, + ]); + internal.getInitialResponse = async () => + makeResponseWithToolCalls([ + { + id: 'call_boom', + name: 'boom', + arguments: '{}', + }, + ]); + internal.makeFollowupRequest = async () => makeFinalResponse(); + + const broadcaster = internal.ensureTurnBroadcaster(); + const consumer = broadcaster.createConsumer(); + const eventsPromise = collectAsyncIterable(consumer); + + await internal.executeToolsIfNeeded(); + broadcaster.complete(); + const events = await eventsPromise; + + const toolResults = events.filter(isToolResultEvent); + expect(toolResults).toHaveLength(1); + expect(toolResults[0]).toMatchObject({ + type: 'tool.result', + toolCallId: 'call_boom', + toolName: 'boom', + result: { + error: 'explode', + }, + }); + }); + + it('preserves literal names on manual/regular/generator/HITL factory tools', () => { + const regular = tool({ + name: 'alpha', + inputSchema: z.object({}), + execute: async () => 1, + }); + const generator = tool({ + name: 'beta', + inputSchema: z.object({}), + eventSchema: z.object({ + n: z.number(), + }), + outputSchema: z.object({ + ok: z.boolean(), + }), + execute: async function* () { + yield { + ok: true, + }; + }, + }); + const manual = tool({ + name: 'gamma', + inputSchema: z.object({}), + execute: false, + }); + const hitl = tool({ + name: 'delta', + inputSchema: z.object({}), + outputSchema: z.object({ + done: z.boolean(), + }), + onToolCalled: async () => null, + }); + + expect(regular.function.name).toBe('alpha'); + expect(generator.function.name).toBe('beta'); + expect(manual.function.name).toBe('gamma'); + expect(hitl.function.name).toBe('delta'); + }); +}); From 0af78f9c54505449eabd45cf0533175b3d4bd980 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:05:44 -0500 Subject: [PATCH 07/31] feat(agent-tool-set): typed partition state machine and situation snapshots Add a compile-time enabled/disabled/conditional ID partition on ToolSet, exhaustive resolve()/resolveSituation() snapshots with statusByTool, and declarative defineSituations. Server tools get stable IDs (default server:${type}, overridable via serverTool options) so they participate in activation. InferToolSet now aliases CorrelatedToolEventUnion. --- packages/agent-tool-set/README.md | 178 ++++- packages/agent-tool-set/src/index.ts | 37 +- packages/agent-tool-set/src/tool-set.ts | 609 +++++++++++++++--- packages/agent-tool-set/src/types.ts | 333 +++++++++- .../tests/unit/tool-set.test.ts | 607 +++++++++++++++-- packages/agent/src/index.ts | 7 +- packages/agent/src/lib/tool-types.ts | 14 +- packages/agent/src/lib/tool.ts | 23 +- packages/agent/tests/unit/server-tool.test.ts | 15 + 9 files changed, 1647 insertions(+), 176 deletions(-) diff --git a/packages/agent-tool-set/README.md b/packages/agent-tool-set/README.md index 52a3726e..841629c2 100644 --- a/packages/agent-tool-set/README.md +++ b/packages/agent-tool-set/README.md @@ -2,11 +2,17 @@ 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 © Chris Cook), adapted for this SDK. See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md). +Port of [`ai-tool-set`](https://github.com/zirkelc/ai-tool-set) (MIT © Chris Cook), adapted for this SDK's ordered `Tool[]` / `callModel` model. See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md). -- 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. +## What it adds + +- **Stable tool-set IDs** for every addressable tool: + - client tools → `function.name` + - server tools → `server:${config.type}` by default (overridable via `serverTool(config, { id })`) +- A **typed three-way partition** of those IDs: definitely enabled, definitely disabled, conditional. +- **Exhaustive runtime snapshots** from `resolve()` / `resolveSituation()` — every ID appears in `statusByTool`. +- **Named declarative situations** with compile-time exact tool tuples when the situation is fully static. +- Integration with `callModel`'s `activeTools` option (spread `resolve()` directly). ## Install @@ -17,12 +23,19 @@ pnpm add @openrouter/agent-tool-set ## Usage ```ts -import { OpenRouter, tool, callModel } from '@openrouter/agent'; -import { createToolSet } from '@openrouter/agent-tool-set'; +import { OpenRouter, tool, serverTool, callModel } from '@openrouter/agent'; +import { + createToolSet, + type InferEnabledIds, + type InferDisabledIds, + type InferConditionalIds, + type InferAllIds, +} from '@openrouter/agent-tool-set'; import { z } from 'zod/v4'; type AppContext = { isAuthenticated: boolean; + isAdmin: boolean; }; const listOrders = tool({ @@ -37,34 +50,159 @@ const cancelOrder = tool({ execute: async () => ({ ok: true }), }); -const allTools = [listOrders, cancelOrder] as const; +const login = tool({ + name: 'login', + inputSchema: z.object({}), + execute: async () => ({ token: '…' }), +}); + +const webSearch = serverTool({ type: 'web_search_2025_08_26' }); +// id defaults to 'server:web_search_2025_08_26' + +const allTools = [listOrders, cancelOrder, login, webSearch] as const; const toolSet = createToolSet({ tools: allTools }) + .deactivate('cancel_order') .activateWhen('list_orders', ({ context }) => context?.isAuthenticated === true) - .deactivateWhen('cancel_order', ({ state }) => (state?.messages?.length ?? 0) === 0); + .defineSituations({ + guest: { + enabled: ['login', 'server:web_search_2025_08_26'], + disabled: ['list_orders', 'cancel_order'], + }, + authenticated: { + enabled: ['list_orders', 'server:web_search_2025_08_26'], + disabled: ['login'], + conditional: { + cancel_order: ({ context }) => context?.isAdmin === true, + }, + }, + }); + +// Compile-time partition of the *base* set (before a situation overlay): +type All = InferAllIds; +// 'list_orders' | 'cancel_order' | 'login' | 'server:web_search_2025_08_26' +type Enabled = InferEnabledIds; // excludes cancel_order + list_orders (conditional) +type Disabled = InferDisabledIds; // 'cancel_order' +type Conditional = InferConditionalIds; // 'list_orders' const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY }); -const { tools, activeTools } = toolSet.inferTools({ context: { isAuthenticated: true } }); +// Named static situation → exact tool tuple at compile time +const guest = toolSet.resolveSituation('guest'); +// guest.tools is exactly [login, webSearch] +// guest.enabled / guest.disabled / guest.statusByTool are exhaustive const result = callModel(client, { model: 'openai/gpt-4o-mini', input: 'List my orders.', - tools, - activeTools, + ...toolSet.resolveSituation('authenticated', { + context: { isAuthenticated: true, isAdmin: false }, + }), }); ``` +## Identity + +| Kind | Tool-set ID | +| --- | --- | +| Client `tool({ name: 'x' })` | `'x'` | +| `serverTool({ type: 'web_search_2025_08_26' })` | `'server:web_search_2025_08_26'` | +| `serverTool(config, { id: 'server:public_search' })` | `'server:public_search'` | + +Duplicate IDs throw at `createToolSet` construction. Activation methods accept only known IDs. + +## Compile-time vs runtime exactness + +| Resolution style | Developer-time knowledge | Runtime knowledge | +| --- | --- | --- | +| Static `activate` / `deactivate` | Exact partition | Exact snapshot | +| Named static situation (`enabled`/`disabled` only) | Exact filtered tool tuple | Exact snapshot | +| `activateWhen` / `deactivateWhen` / situation `conditional` | Upper bound (`enabled ∪ conditional`) | Exact snapshot after predicates | +| Mutable `ToolSet` | Partition types may widen | Exact snapshot | + +The type system cannot execute predicates. Conditional IDs therefore expand the compile-time upper bound of active tools; after `resolve`, the returned arrays and `statusByTool` are always exhaustive and exact. + ## API -- `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. 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. +### `createToolSet({ tools, mutable? })` + +Build a set from an ordered tool array. Optional `TShared` types the `context` argument on predicates. Defaults to immutable. + +### `.tools` + +Concrete tools tuple in construction order (client + server), regardless of activation. + +### `.activate(id | id[])` / `.deactivate(id | id[])` + +Static flip (last-call-wins). Accepts client names **and** server IDs. Updates the compile-time partition. + +### `.activateWhen(id, predicate)` / `.activateWhen({ [id]: predicate })` + +Conditional activation — defaults inactive, becomes active when predicate returns `true`. Moves the ID into the conditional partition. + +### `.deactivateWhen(id, predicate)` / `.deactivateWhen({ [id]: predicate })` + +Conditional deactivation — defaults active, becomes inactive when predicate returns `true`. Also moves the ID into the conditional partition. + +Predicate input: `{ state?: ConversationState; context?: TShared }`. + +### `.defineSituations({ [name]: config })` + +Declarative named situations. Each config may include: + +- `enabled?: readonly Id[]` — statically on +- `disabled?: readonly Id[]` — statically off +- `conditional?: { [id]: predicate | { mode?, predicate } }` — runtime rules + +Situation overlays the base partition for every ID it mentions; unmentioned IDs keep the base state. Unknown, duplicate, or conflicting IDs within one situation throw. + +### `.resolve(input?)` → snapshot + +```ts +{ + tools: /* active tools, construction order, concrete types */; + activeTools: /* active *client* names for callModel */; + enabled: /* every active ID (client + server) */; + disabled: /* every inactive ID */; + statusByTool: { + [id]: { + enabled: boolean; + reason: 'default' | 'activate' | 'deactivate' | 'activateWhen' | 'deactivateWhen' | 'situation'; + directive?: 'activate' | 'deactivate' | 'activateWhen' | 'deactivateWhen'; + predicate?: boolean; // true when a runtime predicate decided the result + }; + }; +} +``` + +### `.resolveSituation(name, input?)` → snapshot + +Same shape as `resolve`, with the named situation overlay applied first. + +### `.inferTools(input?)` + +Back-compat alias for `resolve`. Prefer `resolve` in new code. + +### `.clone({ mutable? })` + +Copy state, optionally flipping mode. + +### Inference utilities + +```ts +type All = InferAllIds; +type Enabled = InferEnabledIds; +type Disabled = InferDisabledIds; +type Conditional = InferConditionalIds; +``` + +### `InferToolSet` + +Alias of the agent's `CorrelatedToolEventUnion` — name-correlated preliminary/result stream events based on a tools tuple. -Last-call-wins: each directive on a given tool replaces any prior one for that tool. +## Notes -Immutable by default (every mutator returns a new `ToolSet`). Pass `mutable: true` to mutate in place. +- Immutable by default (every mutator returns a new `ToolSet` with refined partition types). +- `mutable: true` mutates in place. Partition type parameters may widen for soundness; runtime state is still exact. +- Last-call-wins: each directive on a given ID replaces any prior one for that ID. +- Server tools participate fully in activation once they have an ID. When active they appear in `tools` (and `enabled` / `statusByTool`) but **not** in `activeTools`, which remains the client-name list expected by `callModel`. diff --git a/packages/agent-tool-set/src/index.ts b/packages/agent-tool-set/src/index.ts index 70c88bba..28601207 100644 --- a/packages/agent-tool-set/src/index.ts +++ b/packages/agent-tool-set/src/index.ts @@ -1,2 +1,37 @@ export { createToolSet, ToolSet } from './tool-set.js'; -export type { ActivationInput, ActivationPredicate, InferToolSet } from './types.js'; +export type { + ActivatePartition, + ActivationInput, + ActivationPredicate, + ApplySituationPartition, + ClientToolName, + ClientToolNamesOfTuple, + ConditionalPartition, + DeactivatePartition, + EmptyPartition, + EmptySituations, + FilterToolsByIds, + InferAllIds, + InferConditionalIds, + InferDisabledIds, + InferEnabledIds, + InferSituationEntry, + InferSituationMap, + InferToolSet, + InitialPartition, + Partition, + ResolvedToolSnapshot, + ServerToolIdOf, + ServerToolIdsOfTuple, + SituationConditionalRule, + SituationConfig, + SituationMap, + SituationNames, + StatusByToolMap, + StatusReason, + ToolById, + ToolIdOf, + ToolIdsOfTuple, + ToolSetLike, + ToolStatusEntry, +} from './types.js'; diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts index 57da7306..93c463ce 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent-tool-set/src/tool-set.ts @@ -1,22 +1,68 @@ -import type { ClientTool, Tool } from '@openrouter/agent'; +import type { ServerToolBase, Tool } from '@openrouter/agent'; import { isServerTool } from '@openrouter/agent'; -import type { ActivationInput, ActivationPredicate } from './types.js'; +import type { + ActivatePartition, + ActivationInput, + ActivationPredicate, + ApplySituationPartition, + ClientToolNamesOfTuple, + ConditionalPartition, + DeactivatePartition, + EmptySituations, + FilterToolsByIds, + InferSituationMap, + InitialPartition, + Partition, + ResolvedToolSnapshot, + ServerToolIdsOfTuple, + SituationConditionalRule, + SituationConfig, + SituationMap, + SituationNames, + StatusByToolMap, + StatusReason, + ToolIdOf, + ToolIdsOfTuple, + ToolStatusEntry, +} from './types.js'; -type Entry> = +type ActivationEntry> = | { kind: 'static'; active: boolean; + source: 'default' | 'activate' | 'deactivate' | 'situation'; } | { kind: 'activateWhen'; predicate: ActivationPredicate; + source: 'activateWhen' | 'situation'; } | { kind: 'deactivateWhen'; predicate: ActivationPredicate; + source: 'deactivateWhen' | 'situation'; }; -function toNameArray(names: string | readonly string[]): readonly string[] { +type SituationRuntime> = { + enabled: readonly string[]; + disabled: readonly string[]; + conditional: ReadonlyArray<{ + id: string; + mode: 'activateWhen' | 'deactivateWhen'; + predicate: ActivationPredicate; + }>; +}; + +type IndexedTools = { + orderedTools: TTools; + /** Every known ID in construction order. */ + orderedIds: readonly ToolIdsOfTuple[]; + toolById: Map; + clientNames: Set; + serverIds: Set; +}; + +function toIdArray(names: string | readonly string[]): readonly string[] { return typeof names === 'string' ? [ names, @@ -30,41 +76,114 @@ function isPredicateMap>( return typeof value === 'object' && value !== null && !Array.isArray(value); } -function indexClientTools(tools: readonly Tool[]): Map { - const map = new Map(); +function defaultServerId(tool: ServerToolBase): string { + return typeof tool.id === 'string' && tool.id.length > 0 ? tool.id : `server:${tool.config.type}`; +} + +function toolId(tool: Tool): string { + if (isServerTool(tool)) { + return defaultServerId(tool); + } + // After the ServerToolBase narrow, remaining tools are client tools with function.name. + return (tool as Exclude).function.name; +} + +function indexTools(tools: TTools): IndexedTools { + const toolById = new Map(); + const orderedIds: string[] = []; + const clientNames = new Set(); + const serverIds = new Set(); + for (const t of tools) { - if (isServerTool(t)) { - continue; + const id = toolId(t); + if (toolById.has(id)) { + throw new Error(`Duplicate tool ID: "${id}"`); } - const name = t.function.name; - if (map.has(name)) { - throw new Error(`Duplicate tool name: "${name}"`); + toolById.set(id, t); + orderedIds.push(id); + if (isServerTool(t)) { + serverIds.add(id); + } else { + clientNames.add(id); } - map.set(name, t); } - return map; + + return { + orderedTools: tools, + orderedIds: orderedIds as unknown as readonly ToolIdsOfTuple[], + toolById, + clientNames, + serverIds, + }; +} + +function cloneActivationMap>( + activation: Map>, +): Map> { + return new Map(activation); +} + +function cloneSituationsMap>( + situations: Map>, +): Map> { + return new Map(situations); +} + +function normalizeConditionalRule>( + rule: SituationConditionalRule, +): { + mode: 'activateWhen' | 'deactivateWhen'; + predicate: ActivationPredicate; +} { + if (typeof rule === 'function') { + return { + mode: 'activateWhen', + predicate: rule, + }; + } + return { + mode: rule.mode ?? 'activateWhen', + predicate: rule.predicate, + }; } +/** + * Immutable-by-default stateful set of tools with a three-way static + * partition (enabled / disabled / conditional) and optional named situations. + * + * @typeParam TTools - Concrete ordered tools tuple + * @typeParam TShared - Shared context shape for predicates + * @typeParam P - Compile-time partition of tool-set IDs + * @typeParam Sit - Named situation registry + */ export class ToolSet< TTools extends readonly Tool[] = readonly Tool[], TShared extends Record = Record, + P extends Partition = InitialPartition, + Sit extends SituationMap = EmptySituations, > { - /** 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 #index: IndexedTools; + readonly #activation: Map>; + readonly #situations: Map>; readonly #mutable: boolean; + /** + * Phantom carriers so inference utilities can recover partition/situation + * generics from a concrete instance type. + */ + readonly _partition?: P; + readonly _situations?: Sit; + readonly _shared?: TShared; + private constructor( - orderedTools: readonly Tool[], - clientToolsByName: Map, - activation: Map>, + index: IndexedTools, + activation: Map>, + situations: Map>, mutable: boolean, ) { - this.#orderedTools = orderedTools; - this.#clientToolsByName = clientToolsByName; + this.#index = index; this.#activation = activation; + this.#situations = situations; this.#mutable = mutable; } @@ -72,109 +191,139 @@ export class ToolSet< static create< T extends readonly Tool[], S extends Record = Record, - >(opts: { tools: T; mutable?: boolean }): ToolSet { - return new ToolSet( - opts.tools, - indexClientTools(opts.tools), + >(opts: { tools: T; mutable?: boolean }): ToolSet, EmptySituations> { + return new ToolSet, EmptySituations>( + indexTools(opts.tools), + new Map(), new Map(), opts.mutable ?? false, ); } /** All tools in construction order, regardless of activation state. */ - get tools(): readonly Tool[] { - return this.#orderedTools; + get tools(): TTools { + return this.#index.orderedTools; } - #assertKnown(name: string): void { - if (!this.#clientToolsByName.has(name)) { - throw new Error(`Unknown tool: "${name}"`); + #assertKnown(id: string): void { + if (!this.#index.toolById.has(id)) { + throw new Error(`Unknown tool: "${id}"`); } } - #withMutation( - mutate: (activation: Map>) => void, - ): ToolSet { + #withPartitionMutation( + mutate: (activation: Map>) => void, + ): ToolSet { if (this.#mutable) { + // Mutable mode deliberately does not refine partition type params — + // successive mutations would leave stale compile-time brands. Runtime state still updates. mutate(this.#activation); - return this; + return this as unknown as ToolSet; } - const nextActivation = new Map(this.#activation); + const nextActivation = cloneActivationMap(this.#activation); mutate(nextActivation); - return new ToolSet( - this.#orderedTools, - this.#clientToolsByName, + return new ToolSet( + this.#index, nextActivation, + this.#situations, false, ); } - activate(names: string | readonly string[]): ToolSet { - const list = toNameArray(names); + activate>( + names: N | readonly N[], + ): ToolSet, Sit> { + const list = toIdArray(names as string | readonly string[]); for (const n of list) { this.#assertKnown(n); } - return this.#withMutation((activation) => { + return this.#withPartitionMutation>((activation) => { for (const n of list) { activation.set(n, { kind: 'static', active: true, + source: 'activate', }); } }); } - deactivate(names: string | readonly string[]): ToolSet { - const list = toNameArray(names); + deactivate>( + names: N | readonly N[], + ): ToolSet, Sit> { + const list = toIdArray(names as string | readonly string[]); for (const n of list) { this.#assertKnown(n); } - return this.#withMutation((activation) => { + return this.#withPartitionMutation>((activation) => { for (const n of list) { activation.set(n, { kind: 'static', active: false, + source: 'deactivate', }); } }); } - activateWhen(name: string, predicate: ActivationPredicate): ToolSet; - activateWhen(map: Record>): ToolSet; + activateWhen>( + name: N, + predicate: ActivationPredicate, + ): ToolSet, Sit>; + activateWhen>( + map: { + readonly [K in N]?: ActivationPredicate; + }, + ): ToolSet, Sit>; activateWhen( - nameOrMap: string | Record>, + nameOrMap: unknown, predicate?: ActivationPredicate, - ): ToolSet { - const entries = this.#normalizePredicateArg(nameOrMap, predicate); - return this.#withMutation((activation) => { + ): ToolSet { + const entries = this.#normalizePredicateArg( + nameOrMap as string | Partial>>, + predicate, + ); + return this.#withPartitionMutation((activation) => { for (const [n, p] of entries) { activation.set(n, { kind: 'activateWhen', predicate: p, + source: 'activateWhen', }); } }); } - deactivateWhen(name: string, predicate: ActivationPredicate): ToolSet; - deactivateWhen(map: Record>): ToolSet; + deactivateWhen>( + name: N, + predicate: ActivationPredicate, + ): ToolSet, Sit>; + deactivateWhen>( + map: { + readonly [K in N]?: ActivationPredicate; + }, + ): ToolSet, Sit>; deactivateWhen( - nameOrMap: string | Record>, + nameOrMap: unknown, predicate?: ActivationPredicate, - ): ToolSet { - const entries = this.#normalizePredicateArg(nameOrMap, predicate); - return this.#withMutation((activation) => { + ): ToolSet { + const entries = this.#normalizePredicateArg( + nameOrMap as string | Partial>>, + predicate, + ); + return this.#withPartitionMutation((activation) => { for (const [n, p] of entries) { activation.set(n, { kind: 'deactivateWhen', predicate: p, + source: 'deactivateWhen', }); } }); } #normalizePredicateArg( - nameOrMap: string | Record>, + nameOrMap: string | Partial>>, predicate?: ActivationPredicate, ): Array< [ @@ -202,7 +351,14 @@ export class ToolSet< string, ActivationPredicate, ] - > = Object.entries(nameOrMap); + > = Object.entries(nameOrMap).filter( + ( + entry, + ): entry is [ + string, + ActivationPredicate, + ] => typeof entry[1] === 'function', + ); for (const [n] of entries) { this.#assertKnown(n); } @@ -210,63 +366,337 @@ export class ToolSet< } /** - * Resolve activation against an input and return the filtered active tools - * 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`). + * Register named declarative situations. Each situation overlays the base + * partition — ids it does not mention keep whatever the base set declares. + * + * Replaces any previously defined situations (last-call-wins at the registry level). + */ + defineSituations< + const M extends { + readonly [K in string]: SituationConfig, TShared>; + }, + >(situations: M): ToolSet & SituationMap> { + const next = new Map>(); + + for (const [name, config] of Object.entries(situations) as Array< + [ + string, + SituationConfig, + ] + >) { + const enabled = config.enabled ?? []; + const disabled = config.disabled ?? []; + const conditionalEntries = Object.entries(config.conditional ?? {}) as Array< + [ + string, + SituationConditionalRule, + ] + >; + + const seen = new Set(); + const record = (id: string, bucket: string): void => { + this.#assertKnown(id); + if (seen.has(id)) { + throw new Error( + `Situation "${name}" lists tool "${id}" more than once (across enabled/disabled/conditional)`, + ); + } + seen.add(id); + void bucket; + }; + + for (const id of enabled) { + record(id, 'enabled'); + } + for (const id of disabled) { + record(id, 'disabled'); + } + for (const [id] of conditionalEntries) { + record(id, 'conditional'); + } + + next.set(name, { + enabled: [ + ...enabled, + ], + disabled: [ + ...disabled, + ], + conditional: conditionalEntries.map(([id, rule]) => { + const normalized = normalizeConditionalRule(rule); + return { + id, + mode: normalized.mode, + predicate: normalized.predicate, + }; + }), + }); + } + + if (this.#mutable) { + this.#situations.clear(); + for (const [k, v] of next) { + this.#situations.set(k, v); + } + return this as unknown as ToolSet & SituationMap>; + } + + return new ToolSet & SituationMap>( + this.#index, + cloneActivationMap(this.#activation), + next, + false, + ); + } + + /** + * Resolve against the base partition (no situation overlay). + * When the partition is purely static, the active tool tuple is exact at + * compile time. Conditional ids expand the compile-time upper bound. + */ + resolve(input?: ActivationInput): ResolvedToolSnapshot< + TTools, + P, + [ + P['conditional'], + ] extends [ + never, + ] + ? P['enabled'] + : P['enabled'] | P['conditional'] + > { + return this.#resolveWithActivation(this.#activation, input) as unknown as ResolvedToolSnapshot< + TTools, + P, + [ + P['conditional'], + ] extends [ + never, + ] + ? P['enabled'] + : P['enabled'] | P['conditional'] + >; + } + + /** + * Back-compat alias for {@link resolve}. Prefer `resolve` for new code. */ inferTools(input?: ActivationInput): { tools: Tool[]; activeTools: string[]; + enabled: readonly string[]; + disabled: readonly string[]; + statusByTool: StatusByToolMap; } { - const resolved: ActivationInput = input ?? {}; + const snapshot = this.resolve(input); + return { + tools: [ + ...snapshot.tools, + ], + activeTools: [ + ...snapshot.activeTools, + ], + enabled: snapshot.enabled, + disabled: snapshot.disabled, + statusByTool: snapshot.statusByTool, + }; + } + + /** + * Resolve a previously-defined named situation. Static situations return + * exact filtered tool / name tuples at compile time; situations with + * conditional rules return the sound upper bound, while runtime arrays and + * `statusByTool` remain exact. + */ + resolveSituation>( + name: Name, + input?: ActivationInput, + ): ResolvedToolSnapshot< + TTools, + ApplySituationPartition, + [ + ApplySituationPartition['conditional'], + ] extends [ + never, + ] + ? ApplySituationPartition['enabled'] + : + | ApplySituationPartition['enabled'] + | ApplySituationPartition['conditional'] + > { + const situation = this.#situations.get(name); + if (!situation) { + throw new Error(`Unknown situation: "${String(name)}"`); + } + + const activation = cloneActivationMap(this.#activation); + for (const id of situation.enabled) { + activation.set(id, { + kind: 'static', + active: true, + source: 'situation', + }); + } + for (const id of situation.disabled) { + activation.set(id, { + kind: 'static', + active: false, + source: 'situation', + }); + } + for (const entry of situation.conditional) { + activation.set(entry.id, { + kind: entry.mode, + predicate: entry.predicate, + source: 'situation', + }); + } + + return this.#resolveWithActivation(activation, input) as unknown as ResolvedToolSnapshot< + TTools, + ApplySituationPartition, + [ + ApplySituationPartition['conditional'], + ] extends [ + never, + ] + ? ApplySituationPartition['enabled'] + : + | ApplySituationPartition['enabled'] + | ApplySituationPartition['conditional'] + >; + } + + #resolveWithActivation( + activation: Map>, + input?: ActivationInput, + ): { + tools: Tool[]; + activeTools: string[]; + enabled: string[]; + disabled: string[]; + statusByTool: Record; + } { + const resolvedInput: ActivationInput = input ?? {}; const tools: Tool[] = []; const activeTools: string[] = []; - for (const t of this.#orderedTools) { - if (isServerTool(t)) { - tools.push(t); + const enabled: string[] = []; + const disabled: string[] = []; + const statusByTool: Record = {}; + + for (const id of this.#index.orderedIds) { + const tool = this.#index.toolById.get(id); + if (!tool) { continue; } - const name = t.function.name; - if (this.#resolveActive(name, resolved)) { - tools.push(t); - activeTools.push(name); + + const { active, entry } = this.#evaluate(id, activation, resolvedInput); + const status = this.#toStatusEntry(active, entry); + statusByTool[id] = status; + + if (active) { + tools.push(tool); + enabled.push(id); + if (!isServerTool(tool)) { + activeTools.push(id); + } + } else { + disabled.push(id); } } + return { tools, activeTools, + enabled, + disabled, + statusByTool, }; } - #resolveActive(name: string, input: ActivationInput): boolean { - const entry = this.#activation.get(name); + #evaluate( + id: string, + activation: Map>, + input: ActivationInput, + ): { + active: boolean; + entry: ActivationEntry | undefined; + } { + const entry = activation.get(id); if (!entry) { - return true; + return { + active: true, + entry: undefined, + }; } if (entry.kind === 'static') { - return entry.active; + return { + active: entry.active, + entry, + }; } if (entry.kind === 'activateWhen') { - return entry.predicate(input) === true; + return { + active: entry.predicate(input) === true, + entry, + }; } - return entry.predicate(input) !== true; + return { + active: entry.predicate(input) !== true, + entry, + }; + } + + #toStatusEntry(active: boolean, entry: ActivationEntry | undefined): ToolStatusEntry { + if (!entry) { + return { + enabled: active, + reason: 'default', + }; + } + + if (entry.kind === 'static') { + const directive = entry.active ? ('activate' as const) : ('deactivate' as const); + const reason: StatusReason = + entry.source === 'situation' + ? 'situation' + : entry.source === 'default' + ? 'default' + : directive; + return { + enabled: active, + reason, + directive, + }; + } + + const directive = entry.kind; + const reason: StatusReason = entry.source === 'situation' ? 'situation' : directive; + return { + enabled: active, + reason, + directive, + predicate: true, + }; } - clone(opts?: { mutable?: boolean }): ToolSet { - return new ToolSet( - this.#orderedTools, - this.#clientToolsByName, - new Map(this.#activation), + clone(opts?: { mutable?: boolean }): ToolSet { + return new ToolSet( + this.#index, + cloneActivationMap(this.#activation), + cloneSituationsMap(this.#situations), opts?.mutable ?? this.#mutable, ); } } export function createToolSet< - T extends readonly Tool[], + const T extends readonly Tool[], TShared extends Record = Record, ->(opts: { tools: T; mutable?: boolean }): ToolSet { +>(opts: { + tools: T; + mutable?: boolean; +}): ToolSet, EmptySituations> { return ToolSet.create({ tools: opts.tools, ...(opts.mutable !== undefined && { @@ -274,3 +704,12 @@ export function createToolSet< }), }); } + +// Re-export commonly needed type helpers used at call sites without a separate import. +export type { + ClientToolNamesOfTuple, + FilterToolsByIds, + ServerToolIdsOfTuple, + ToolIdOf, + ToolIdsOfTuple, +}; diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index a3682566..6c2f2977 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -1,12 +1,138 @@ import type { + ClientTool, ConversationState, - InferToolEvent, - InferToolOutput, + CorrelatedToolEventUnion, + ServerToolBase, Tool, - ToolPreliminaryResultEvent, - ToolResultEvent, } from '@openrouter/agent'; +// ─── identity ─────────────────────────────────────────────────────────────── + +/** Client tool: `function.name` literal. */ +export type ClientToolName = T extends { + function: { + name: infer N extends string; + }; +} + ? N + : never; + +/** + * Server-tool stable ID. + * Prefixed so it can never collide with a client function name. + * Prefers an explicit `id` on the tool; falls back to `server:${config.type}`. + */ +export type ServerToolIdOf = T extends { + readonly id: infer Id extends string; +} + ? string extends Id + ? T extends { + readonly config: { + type: infer K extends string; + }; + } + ? `server:${K}` + : never + : Id + : T extends { + readonly config: { + type: infer K extends string; + }; + } + ? `server:${K}` + : never; + +/** Union of every addressable id for one tool. */ +export type ToolIdOf = T extends ServerToolBase + ? ServerToolIdOf + : ClientToolName; + +export type ToolIdsOfTuple = ToolIdOf; + +export type ClientToolNamesOfTuple = ClientToolName< + Extract +>; + +export type ServerToolIdsOfTuple = ServerToolIdOf< + Extract +>; + +/** Lookup tool by id inside a tuple (preserves concrete member type). */ +export type ToolById = Extract< + { + [I in keyof T]: T[I] extends Tool ? (ToolIdOf extends Id ? T[I] : never) : never; + }[number], + Tool +>; + +/** Keep tuple order; drop members whose id is not in Active. */ +export type FilterToolsByIds< + T extends readonly Tool[], + Active extends string, +> = T extends readonly [ + infer H extends Tool, + ...infer R extends readonly Tool[], +] + ? ToolIdOf extends Active + ? readonly [ + H, + ...FilterToolsByIds, + ] + : FilterToolsByIds + : readonly []; + +// ─── three-way compile-time partition ─────────────────────────────────────── + +/** + * Enabled = statically on (default, or .activate / situation.enabled) + * Disabled = statically off (.deactivate / situation.disabled) + * Conditional = runtime predicate (.activateWhen / .deactivateWhen / situation rules) + * + * Invariants (enforced by mutators via Exclude): + * Enabled ∩ Disabled = ∅ + * Enabled ∩ Conditional = ∅ + * Disabled ∩ Conditional = ∅ + * Enabled ∪ Disabled ∪ Conditional = all known IDs + */ +export type Partition = { + enabled: string; + disabled: string; + conditional: string; +}; + +export type EmptyPartition = { + enabled: never; + disabled: never; + conditional: never; +}; + +/** Default construction: every tool ID enabled. */ +export type InitialPartition = { + enabled: ToolIdsOfTuple; + disabled: never; + conditional: never; +}; + +export type ActivatePartition

= { + enabled: P['enabled'] | Name; + disabled: Exclude; + conditional: Exclude; +}; + +export type DeactivatePartition

= { + enabled: Exclude; + disabled: P['disabled'] | Name; + conditional: Exclude; +}; + +export type ConditionalPartition

= { + enabled: Exclude; + disabled: Exclude; + conditional: P['conditional'] | Name; +}; + +// ─── activation input ─────────────────────────────────────────────────────── + export type ActivationInput = Record> = { state?: ConversationState; context?: TShared; @@ -15,24 +141,189 @@ export type ActivationInput = Record = Record> = (input: ActivationInput) => boolean; -type ToolName = T extends { - function: { - name: infer N extends string; +// ─── situations ───────────────────────────────────────────────────────────── + +export type SituationConditionalRule< + TShared extends Record = Record, +> = + | ActivationPredicate + | { + mode?: 'activateWhen' | 'deactivateWhen'; + predicate: ActivationPredicate; + }; + +/** + * Declarative fixed partition overlay for one named situation. + * Keys not listed keep the base ToolSet partition after the overlay. + */ +export type SituationConfig< + TIds extends string = string, + TShared extends Record = Record, +> = { + /** Statically on in this situation. */ + enabled?: readonly TIds[]; + /** Statically off in this situation. */ + disabled?: readonly TIds[]; + /** + * Conditional tools for this situation. + * Default mode is activateWhen (inactive until predicate is true). + * Use `{ mode: 'deactivateWhen', predicate }` for the reverse default. + */ + conditional?: { + readonly [K in TIds]?: SituationConditionalRule; }; -} - ? N - : never; +}; + +/** Situations registry accumulated on the ToolSet type. */ +export type SituationMap = Record< + string, + { + enabled: string; + disabled: string; + conditional: string; + } +>; + +export type EmptySituations = Record; + +export type SituationNames = keyof S & string; + +/** + * Infer the static partition contribution of a situation config object. + * Missing fields contribute `never`. + */ +export type InferSituationEntry = { + enabled: C extends { + enabled: readonly (infer E extends string)[]; + } + ? E + : never; + disabled: C extends { + disabled: readonly (infer D extends string)[]; + } + ? D + : never; + conditional: C extends { + conditional: infer Cond; + } + ? keyof Cond & string + : never; +}; + +export type InferSituationMap = { + [K in keyof M]: InferSituationEntry; +}; + +/** + * Apply a situation overlay onto a base partition. + * Situation wins for every id it mentions; others stay from base. + */ +export type ApplySituationPartition< + Base extends Partition, + Sit extends { + enabled: string; + disabled: string; + conditional: string; + }, +> = { + enabled: + | Exclude + | Sit['enabled']; + disabled: + | Exclude + | Sit['disabled']; + conditional: + | Exclude + | Sit['conditional']; +}; + +// ─── runtime resolved snapshot (exhaustive) ───────────────────────────────── + +export type StatusReason = + | 'default' + | 'activate' + | 'deactivate' + | 'activateWhen' + | 'deactivateWhen' + | 'situation'; + +export type ToolStatusEntry = { + readonly enabled: boolean; + readonly reason: StatusReason; + /** + * The last applicable directive for this tool before predicates ran, if any. + * Absent when the tool is still at its construction default. + */ + readonly directive?: 'activate' | 'deactivate' | 'activateWhen' | 'deactivateWhen'; + /** True when the final state depended on evaluating a runtime predicate. */ + readonly predicate?: boolean; +}; + +export type StatusByToolMap = { + readonly [K in TIds]: ToolStatusEntry; +}; + +/** + * What resolve() / resolveSituation() returns. + * + * For static-only partitions (`conditional = never`), TActive is exactly + * `P['enabled']` and the snapshot is fully known at compile time. + * When conditional ≠ never, TActive is the sound upper bound + * `P['enabled'] | P['conditional']`; runtime arrays/status are exact. + */ +export type ResolvedToolSnapshot< + TTools extends readonly Tool[], + P extends Partition, + TActive extends string = P['enabled'] | P['conditional'], +> = { + /** Active tools only, construction order preserved, concrete member types kept. */ + readonly tools: FilterToolsByIds>; + /** Active client names only (`callModel.activeTools` wire format). Server ids omitted. */ + readonly activeTools: readonly Extract>[]; + /** IDs that resolved active (client + server). */ + readonly enabled: readonly (TActive & ToolIdsOfTuple)[]; + /** IDs that resolved inactive. */ + readonly disabled: readonly Exclude, TActive & ToolIdsOfTuple>[]; + /** Exhaustive id → status entry. Every ToolIdsOfTuple key present. */ + readonly statusByTool: StatusByToolMap>; +}; + +// ─── ToolSet structural eraser + inference utilities ──────────────────────── + +/** + * Structural shape for extracting partition/situation generics from either + * mutable or immutable `ToolSet` instances. + */ +export type ToolSetLike< + TTools extends readonly Tool[] = readonly Tool[], + TShared extends Record = Record, + P extends Partition = Partition, + Sit extends SituationMap = SituationMap, +> = { + readonly tools: TTools; + readonly _partition?: P; + readonly _situations?: Sit; + readonly _shared?: TShared; +}; + +/** Every known tool-set ID. */ +export type InferAllIds = + TS extends ToolSetLike ? ToolIdsOfTuple : never; + +/** Definitely-enabled IDs (static). */ +export type InferEnabledIds = + TS extends ToolSetLike ? P['enabled'] : never; + +/** Definitely-disabled IDs (static). */ +export type InferDisabledIds = + TS extends ToolSetLike ? P['disabled'] : never; + +/** Conditionally-activated IDs (runtime predicate). */ +export type InferConditionalIds = + TS extends ToolSetLike ? P['conditional'] : never; /** - * Discriminated union of streaming events keyed by tool name. The SDK-native - * analog of the original library's UI-message-part helper. + * Name-correlated streaming events for a tools tuple. + * Delegates to the agent's core {@link CorrelatedToolEventUnion}. */ -export type InferToolSet = { - [K in T[number] as ToolName]: - | (ToolPreliminaryResultEvent> & { - toolName: ToolName; - }) - | (ToolResultEvent, InferToolEvent> & { - toolName: ToolName; - }); -}[ToolName]; +export type InferToolSet = CorrelatedToolEventUnion; 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 d228a26a..38ad1eeb 100644 --- a/packages/agent-tool-set/tests/unit/tool-set.test.ts +++ b/packages/agent-tool-set/tests/unit/tool-set.test.ts @@ -1,8 +1,15 @@ -import type { ConversationState } from '@openrouter/agent'; +import type { ConversationState, CorrelatedToolEventUnion } from '@openrouter/agent'; 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'; +import type { + InferAllIds, + InferConditionalIds, + InferDisabledIds, + InferEnabledIds, + InferToolSet, +} from '../../src/index.js'; +import { createToolSet } from '../../src/index.js'; const makeTool = (name: string) => tool({ @@ -36,11 +43,18 @@ describe('createToolSet', () => { c, ] as const, }); - expect(ts.tools.map((t) => t.function.name)).toEqual([ + expect(ts.tools.map((t) => ('function' in t ? t.function.name : t.id))).toEqual([ 'a', 'b', 'c', ]); + expectTypeOf(ts.tools).toEqualTypeOf< + readonly [ + typeof a, + typeof b, + typeof c, + ] + >(); }); it('throws on duplicate tool names at construction', () => { @@ -52,7 +66,7 @@ describe('createToolSet', () => { dup, ] as const, }), - ).toThrow(/Duplicate tool name: "a"/); + ).toThrow(/Duplicate tool ID: "a"/); }); it('constructs an empty set without tools', () => { @@ -60,9 +74,12 @@ describe('createToolSet', () => { tools: [] as const, }); expect(ts.tools).toEqual([]); - expect(ts.inferTools()).toEqual({ + expect(ts.resolve()).toMatchObject({ tools: [], activeTools: [], + enabled: [], + disabled: [], + statusByTool: {}, }); }); @@ -73,7 +90,7 @@ describe('createToolSet', () => { b, ] as const, }); - const { tools, activeTools } = ts.inferTools(); + const { tools, activeTools, enabled, disabled, statusByTool } = ts.resolve(); expect(tools).toEqual([ a, b, @@ -82,6 +99,21 @@ describe('createToolSet', () => { 'a', 'b', ]); + expect(enabled).toEqual([ + 'a', + 'b', + ]); + expect(disabled).toEqual([]); + expect(statusByTool).toEqual({ + a: { + enabled: true, + reason: 'default', + }, + b: { + enabled: true, + reason: 'default', + }, + }); }); }); @@ -94,10 +126,13 @@ describe('activate / deactivate', () => { c, ] as const, }).deactivate('b'); - expect(ts.inferTools().activeTools).toEqual([ + expect(ts.resolve().activeTools).toEqual([ 'a', 'c', ]); + expect(ts.resolve().disabled).toEqual([ + 'b', + ]); }); it('activates/deactivates arrays of names', () => { @@ -115,7 +150,7 @@ describe('activate / deactivate', () => { .activate([ 'b', ]); - expect(ts.inferTools().activeTools).toEqual([ + expect(ts.resolve().activeTools).toEqual([ 'b', 'c', ]); @@ -127,11 +162,11 @@ describe('activate / deactivate', () => { a, ] as const, }); - expect(() => ts.activate('missing')).toThrow(/Unknown tool: "missing"/); + expect(() => ts.activate('missing' as 'a')).toThrow(/Unknown tool: "missing"/); expect(() => ts.deactivate([ 'a', - 'missing', + 'missing' as 'a', ]), ).toThrow(/Unknown tool: "missing"/); }); @@ -145,11 +180,11 @@ describe('activateWhen', () => { b, ] as const, }).activateWhen('a', ({ context }) => context?.['enabled'] === true); - expect(ts.inferTools().activeTools).toEqual([ + expect(ts.resolve().activeTools).toEqual([ 'b', ]); expect( - ts.inferTools({ + ts.resolve({ context: { enabled: true, }, @@ -170,7 +205,7 @@ describe('activateWhen', () => { a: () => true, b: () => false, }); - expect(ts.inferTools().activeTools).toEqual([ + expect(ts.resolve().activeTools).toEqual([ 'a', ]); }); @@ -185,11 +220,12 @@ describe('activateWhen', () => { expect(() => ts.activateWhen({ a: () => true, + // @ts-expect-error unknown id nope: () => true, }), ).toThrow(/Unknown tool: "nope"/); // original untouched - expect(ts.inferTools().activeTools).toEqual([ + expect(ts.resolve().activeTools).toEqual([ 'a', 'b', ]); @@ -204,7 +240,7 @@ describe('deactivateWhen', () => { b, ] as const, }).deactivateWhen('a', () => true); - expect(ts.inferTools().activeTools).toEqual([ + expect(ts.resolve().activeTools).toEqual([ 'b', ]); }); @@ -219,7 +255,7 @@ describe('deactivateWhen', () => { a: () => true, b: () => false, }); - expect(ts.inferTools().activeTools).toEqual([ + expect(ts.resolve().activeTools).toEqual([ 'b', ]); }); @@ -235,7 +271,7 @@ describe('last-call-wins semantics', () => { }) .activate('a') .deactivateWhen('a', () => true); - expect(ts.inferTools().activeTools).toEqual([ + expect(ts.resolve().activeTools).toEqual([ 'b', ]); @@ -247,7 +283,7 @@ describe('last-call-wins semantics', () => { }) .deactivateWhen('a', () => true) .activate('a'); - expect(ts2.inferTools().activeTools).toEqual([ + expect(ts2.resolve().activeTools).toEqual([ 'a', 'b', ]); @@ -264,11 +300,11 @@ describe('immutability vs mutability', () => { }); const next = base.deactivate('a'); expect(next).not.toBe(base); - expect(base.inferTools().activeTools).toEqual([ + expect(base.resolve().activeTools).toEqual([ 'a', 'b', ]); - expect(next.inferTools().activeTools).toEqual([ + expect(next.resolve().activeTools).toEqual([ 'b', ]); }); @@ -283,7 +319,7 @@ describe('immutability vs mutability', () => { }); const next = base.deactivate('a'); expect(next).toBe(base); - expect(base.inferTools().activeTools).toEqual([ + expect(base.resolve().activeTools).toEqual([ 'b', ]); }); @@ -301,12 +337,12 @@ describe('clone', () => { mutable: true, }); mutableCopy.activate('a'); - expect(mutableCopy.inferTools().activeTools).toEqual([ + expect(mutableCopy.resolve().activeTools).toEqual([ 'a', 'b', ]); // original untouched - expect(immutable.inferTools().activeTools).toEqual([ + expect(immutable.resolve().activeTools).toEqual([ 'b', ]); }); @@ -324,17 +360,17 @@ describe('clone', () => { }); }); -describe('inferTools input shapes', () => { +describe('resolve / 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([ + expect(ts.resolve().activeTools).toEqual([ 'a', ]); - expect(ts.inferTools({}).activeTools).toEqual([ + expect(ts.resolve({}).activeTools).toEqual([ 'a', ]); }); @@ -354,7 +390,7 @@ describe('inferTools input shapes', () => { }, ], }); - ts.inferTools({ + ts.resolve({ state, context: { foo: 'bar', @@ -367,6 +403,134 @@ describe('inferTools input shapes', () => { }, }); }); + + it('keeps inferTools as a back-compat alias of resolve', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }).deactivate('a'); + const viaResolve = ts.resolve(); + const viaInfer = ts.inferTools(); + expect(viaInfer.tools).toEqual(viaResolve.tools); + expect(viaInfer.activeTools).toEqual(viaResolve.activeTools); + expect(viaInfer.enabled).toEqual(viaResolve.enabled); + expect(viaInfer.disabled).toEqual(viaResolve.disabled); + expect(viaInfer.statusByTool).toEqual(viaResolve.statusByTool); + }); +}); + +describe('exhaustive statusByTool snapshot', () => { + it('includes every ID with reason/directive/predicate metadata', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }) + .deactivate('b') + .activateWhen('c', () => true); + + const { statusByTool, enabled, disabled } = ts.resolve(); + expect(Object.keys(statusByTool).sort()).toEqual([ + 'a', + 'b', + 'c', + ]); + expect(statusByTool.a).toEqual({ + enabled: true, + reason: 'default', + }); + expect(statusByTool.b).toEqual({ + enabled: false, + reason: 'deactivate', + directive: 'deactivate', + }); + expect(statusByTool.c).toEqual({ + enabled: true, + reason: 'activateWhen', + directive: 'activateWhen', + predicate: true, + }); + expect(enabled).toEqual([ + 'a', + 'c', + ]); + expect(disabled).toEqual([ + 'b', + ]); + }); +}); + +describe('compile-time partition inference', () => { + it('tracks static activate/deactivate transitions', () => { + const base = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }); + expectTypeOf>().toEqualTypeOf<'a' | 'b' | 'c'>(); + expectTypeOf>().toEqualTypeOf<'a' | 'b' | 'c'>(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + + const afterDeactivate = base.deactivate('b'); + expectTypeOf>().toEqualTypeOf<'a' | 'c'>(); + expectTypeOf>().toEqualTypeOf<'b'>(); + expectTypeOf>().toEqualTypeOf(); + + const afterActivate = afterDeactivate.activate('b'); + expectTypeOf>().toEqualTypeOf<'a' | 'b' | 'c'>(); + expectTypeOf>().toEqualTypeOf(); + }); + + it('moves IDs into conditional via activateWhen/deactivateWhen', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }) + .deactivate('b') + .activateWhen('a', () => true) + .deactivateWhen('c', () => false); + + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'b'>(); + expectTypeOf>().toEqualTypeOf<'a' | 'c'>(); + + // Static-only resolve is exact; with conditional IDs, tools is the upper bound. + const snapshot = ts.resolve(); + expectTypeOf(snapshot.enabled).toEqualTypeOf(); + expectTypeOf(snapshot.disabled).toEqualTypeOf(); + }); + + it('returns an exactly-typed active tool tuple for static partitions', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }).deactivate('b'); + const { tools, activeTools } = ts.resolve(); + expectTypeOf(tools).toEqualTypeOf< + readonly [ + typeof a, + typeof c, + ] + >(); + expectTypeOf(activeTools).toEqualTypeOf(); + expect(tools).toEqual([ + a, + c, + ]); + }); }); describe('server tools', () => { @@ -376,6 +540,21 @@ describe('server tools', () => { const datetime = serverTool({ type: 'openrouter:datetime', }); + const publicSearch = serverTool( + { + type: 'web_search_2025_08_26', + }, + { + id: 'server:public_search', + }, + ); + + it('assigns default server IDs from config.type', () => { + expect(webSearch.id).toBe('server:web_search_2025_08_26'); + expect(datetime.id).toBe('server:openrouter:datetime'); + expectTypeOf(webSearch.id).toEqualTypeOf<'server:web_search_2025_08_26'>(); + expectTypeOf(publicSearch.id).toEqualTypeOf<'server:public_search'>(); + }); it('preserves server tools in .tools in construction order', () => { const ts = createToolSet({ @@ -392,9 +571,12 @@ describe('server tools', () => { b, datetime, ]); + expectTypeOf>().toEqualTypeOf< + 'a' | 'b' | 'server:web_search_2025_08_26' | 'server:openrouter:datetime' + >(); }); - it('includes server tools in inferTools output as always-active', () => { + it('includes active server tools in tools/enabled/statusByTool but not activeTools', () => { const ts = createToolSet({ tools: [ a, @@ -402,42 +584,313 @@ describe('server tools', () => { b, ] as const, }).deactivate('a'); - const { tools, activeTools } = ts.inferTools(); + const { tools, activeTools, enabled, statusByTool } = ts.resolve(); expect(tools).toEqual([ webSearch, b, ]); - // server tools have no `function.name` and are never present in activeTools expect(activeTools).toEqual([ 'b', ]); + expect(enabled).toEqual([ + 'server:web_search_2025_08_26', + 'b', + ]); + expect(statusByTool['server:web_search_2025_08_26']).toEqual({ + enabled: true, + reason: 'default', + }); }); - it('keeps server tools even when all client tools are deactivated', () => { + it('can deactivate server tools by stable ID', () => { const ts = createToolSet({ tools: [ a, webSearch, b, ] as const, - }) - .deactivate('a') - .deactivate('b'); - const { tools, activeTools } = ts.inferTools(); + }).deactivate('server:web_search_2025_08_26'); + const { tools, enabled, disabled, statusByTool } = ts.resolve(); expect(tools).toEqual([ - webSearch, + a, + b, ]); - expect(activeTools).toEqual([]); + expect(enabled).toEqual([ + 'a', + 'b', + ]); + expect(disabled).toEqual([ + 'server:web_search_2025_08_26', + ]); + expect(statusByTool['server:web_search_2025_08_26']).toEqual({ + enabled: false, + reason: 'deactivate', + directive: 'deactivate', + }); }); - it('rejects activate/deactivate attempts on server tools (they have no name)', () => { + it('supports override IDs and rejects duplicates', () => { + const ts = createToolSet({ + tools: [ + a, + publicSearch, + ] as const, + }); + expectTypeOf>().toEqualTypeOf<'a' | 'server:public_search'>(); + expect(ts.resolve().enabled).toEqual([ + 'a', + 'server:public_search', + ]); + + expect(() => + createToolSet({ + tools: [ + webSearch, + serverTool({ + type: 'web_search_2025_08_26', + }), + ] as const, + }), + ).toThrow(/Duplicate tool ID: "server:web_search_2025_08_26"/); + }); + + it('rejects activate/deactivate attempts on unknown / raw type strings', () => { const ts = createToolSet({ tools: [ a, webSearch, ] as const, }); - expect(() => ts.activate('web_search_2025_08_26')).toThrow(/Unknown tool/); + expect(() => ts.activate('web_search_2025_08_26' as 'a')).toThrow(/Unknown tool/); + }); +}); + +describe('defineSituations / resolveSituation', () => { + it('overlays static enabled/disabled and returns exact tuples', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }) + .deactivate('c') + .defineSituations({ + guest: { + enabled: [ + 'a', + ], + disabled: [ + 'b', + 'c', + ], + }, + full: { + enabled: [ + 'a', + 'b', + 'c', + ], + }, + }); + + const guest = ts.resolveSituation('guest'); + expect(guest.tools).toEqual([ + a, + ]); + expect(guest.activeTools).toEqual([ + 'a', + ]); + expect(guest.enabled).toEqual([ + 'a', + ]); + expect(guest.disabled).toEqual([ + 'b', + 'c', + ]); + expect(guest.statusByTool).toEqual({ + a: { + enabled: true, + reason: 'situation', + directive: 'activate', + }, + b: { + enabled: false, + reason: 'situation', + directive: 'deactivate', + }, + c: { + enabled: false, + reason: 'situation', + directive: 'deactivate', + }, + }); + expectTypeOf(guest.tools).toEqualTypeOf< + readonly [ + typeof a, + ] + >(); + + const full = ts.resolveSituation('full'); + expect(full.tools).toEqual([ + a, + b, + c, + ]); + expectTypeOf(full.tools).toEqualTypeOf< + readonly [ + typeof a, + typeof b, + typeof c, + ] + >(); + }); + + it('supports conditional situation rules with runtime-exact status', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }).defineSituations({ + authed: { + enabled: [ + 'a', + ], + disabled: [ + 'b', + ], + conditional: { + c: ({ context }) => context?.['admin'] === true, + }, + }, + }); + + const denied = ts.resolveSituation('authed', { + context: { + admin: false, + }, + }); + expect(denied.tools).toEqual([ + a, + ]); + expect(denied.enabled).toEqual([ + 'a', + ]); + expect(denied.disabled).toEqual([ + 'b', + 'c', + ]); + expect(denied.statusByTool.c).toMatchObject({ + enabled: false, + reason: 'situation', + directive: 'activateWhen', + predicate: true, + }); + + const allowed = ts.resolveSituation('authed', { + context: { + admin: true, + }, + }); + expect(allowed.tools).toEqual([ + a, + c, + ]); + expect(allowed.enabled).toEqual([ + 'a', + 'c', + ]); + }); + + it('validates unknown / duplicate / conflicting IDs in a situation', () => { + const base = createToolSet({ + tools: [ + a, + b, + ] as const, + }); + + expect(() => + base.defineSituations({ + bad: { + enabled: [ + // @ts-expect-error unknown id + 'nope', + ], + }, + }), + ).toThrow(/Unknown tool: "nope"/); + + expect(() => + base.defineSituations({ + bad: { + enabled: [ + 'a', + ], + disabled: [ + 'a', + ], + }, + }), + ).toThrow(/lists tool "a" more than once/); + + expect(() => + base.defineSituations({ + bad: { + enabled: [ + 'a', + ], + conditional: { + a: () => true, + }, + }, + }), + ).toThrow(/lists tool "a" more than once/); + }); + + it('throws on unknown situation names at resolve time', () => { + const ts = createToolSet({ + tools: [ + a, + ] as const, + }).defineSituations({ + guest: { + enabled: [ + 'a', + ], + }, + }); + expect(() => ts.resolveSituation('missing' as 'guest')).toThrow(/Unknown situation: "missing"/); + }); + + it('leaves unmentioned IDs on the base partition', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }) + .deactivate('c') + .defineSituations({ + onlyB: { + disabled: [ + 'b', + ], + }, + }); + + const snapshot = ts.resolveSituation('onlyB'); + // a stays default-enabled, b disabled by situation, c disabled by base + expect(snapshot.enabled).toEqual([ + 'a', + ]); + expect(snapshot.disabled).toEqual([ + 'b', + 'c', + ]); }); }); @@ -454,7 +907,6 @@ describe('TShared generic', () => { const ts = createToolSet({ tools: allTools, }).activateWhen('a', ({ context }) => { - // Inside the predicate, context is typed as AppContext | undefined. if (!context) { return false; } @@ -463,7 +915,7 @@ describe('TShared generic', () => { }); expect( - ts.inferTools({ + ts.resolve({ context: { isAuthenticated: true, userId: 'u1', @@ -473,7 +925,7 @@ describe('TShared generic', () => { 'a', ]); expect( - ts.inferTools({ + ts.resolve({ context: { isAuthenticated: false, userId: 'u1', @@ -488,7 +940,6 @@ describe('TShared generic', () => { a, ] as const, }).activateWhen('a', ({ context }) => { - // Context defaults to Record | undefined; values are `unknown`. if (!context) { return false; } @@ -496,7 +947,7 @@ describe('TShared generic', () => { return context['enabled'] === true; }); expect( - ts.inferTools({ + ts.resolve({ context: { enabled: true, }, @@ -506,3 +957,71 @@ describe('TShared generic', () => { ]); }); }); + +describe('InferToolSet / event narrowing', () => { + it('aliases CorrelatedToolEventUnion from @openrouter/agent', () => { + const weather = tool({ + name: 'weather', + inputSchema: z.object({ + city: z.string(), + }), + outputSchema: z.object({ + temp: z.number(), + }), + execute: async () => ({ + temp: 72, + }), + }); + const tools = [ + weather, + ] as const; + + type FromHelper = InferToolSet; + type FromCore = CorrelatedToolEventUnion; + expectTypeOf().toEqualTypeOf(); + + const ts = createToolSet({ + tools, + }); + const resolved = ts.resolve(); + // Spreading into callModel keeps the concrete tools tuple + expectTypeOf(resolved.tools).toEqualTypeOf< + readonly [ + typeof weather, + ] + >(); + }); +}); + +describe('callModel-oriented spread shape', () => { + it('produces tools + activeTools suitable for callModel spread', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }).deactivate('b'); + const snapshot = ts.resolve(); + + // Structural match for BaseCallModelInput's tools/activeTools fields + const forCallModel: { + tools: readonly [ + typeof a, + typeof c, + ]; + activeTools: readonly ('a' | 'c')[]; + } = { + tools: snapshot.tools, + activeTools: snapshot.activeTools, + }; + expect(forCallModel.tools).toEqual([ + a, + c, + ]); + expect(forCallModel.activeTools).toEqual([ + 'a', + 'c', + ]); + }); +}); diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 3eb05b51..9a6f5681 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -216,7 +216,11 @@ export { getUnsupportedContentSummary, hasUnsupportedContent, } from './lib/stream-transformers.js'; -export type { BuiltDeferredTool, DeferredToolMethods } from './lib/tool.js'; +export type { + BuiltDeferredTool, + DeferredToolMethods, + ServerToolOptions, +} from './lib/tool.js'; // Tool creation helpers (tool also carries tool.background / tool.deferred) export { markMcp, serverTool, tool } from './lib/tool.js'; // Universal task-tool helpers @@ -272,6 +276,7 @@ export type { ResponseStreamEvent, ResponseStreamEvent as EnhancedResponseStreamEvent, ServerTool, + ServerToolBase, ServerToolConfig, ServerToolResultItem, ServerToolType, diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 93717bda..8e242930 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -855,6 +855,11 @@ export type ServerToolType = ServerToolConfig['type']; export interface ServerToolBase { readonly _brand: 'server-tool'; readonly config: ServerToolConfig; + /** + * Stable tool-set identity used by `@openrouter/agent-tool-set` activation. + * Defaults to `server:${config.type}` when constructed via {@link serverTool}. + */ + readonly id: string; } /** @@ -866,14 +871,19 @@ export interface ServerToolBase { * (and hence to `Tool`) regardless of `T`. * * @template T The specific server-tool type literal (narrows `config`). + * @template TId Stable tool-set ID (defaults to `server:${T}`). */ -export interface ServerTool extends ServerToolBase { +export interface ServerTool< + T extends ServerToolType = ServerToolType, + TId extends string = `server:${T}`, +> extends ServerToolBase { readonly config: Extract< ServerToolConfig, { type: T; } >; + readonly id: TId; } /** @@ -1005,7 +1015,7 @@ export type InferToolEventsUnion = { * `ClientTool` lacks. `'_brand' in tool` narrows the union to the server * branch structurally, so `tool._brand` is reachable without a cast. */ -export function isServerTool(tool: Tool): tool is ServerTool { +export function isServerTool(tool: Tool): tool is ServerToolBase { if (typeof tool !== 'object' || tool === null) { return false; } diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index 3c47c644..313f7b8f 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -990,6 +990,18 @@ tool.agent = agentToolBuilder; //#region serverTool() Factory +/** + * Options for {@link serverTool}. + * @template TId Stable tool-set identity used by `@openrouter/agent-tool-set`. + */ +export type ServerToolOptions = { + /** + * Override the default tool-set ID (`server:${config.type}`). + * Useful when two server tools of the same type need distinct activation IDs. + */ + id?: TId; +}; + /** * Creates an OpenRouter server-executed tool. OpenRouter runs the tool (web * search, datetime, image generation, etc.) and returns the output item in @@ -1001,26 +1013,33 @@ tool.agent = agentToolBuilder; * in this SDK. Provide the `type` literal and the remaining fields narrow * to match the chosen tool. * + * Each server tool carries a stable tool-set `id` (default `server:${type}`) + * so activation APIs can address it. Override via the optional second argument. + * * @example * ```typescript * const tools = [ * serverTool({ type: 'web_search_2025_08_26', engine: 'exa', maxResults: 10 }), * serverTool({ type: 'openrouter:datetime', parameters: { timezone: 'UTC' } }), * serverTool({ type: 'image_generation', size: '1024x1024', quality: 'high' }), + * serverTool({ type: 'web_search_2025_08_26' }, { id: 'server:public_search' }), * ]; * ``` */ -export function serverTool( +export function serverTool( config: Extract< ServerToolConfig, { type: T; } >, -): ServerTool { + options?: ServerToolOptions, +): ServerTool { + const id = (options?.id ?? (`server:${config.type}` as const)) as TId; return { _brand: 'server-tool', config, + id, }; } diff --git a/packages/agent/tests/unit/server-tool.test.ts b/packages/agent/tests/unit/server-tool.test.ts index da555529..695cc0c1 100644 --- a/packages/agent/tests/unit/server-tool.test.ts +++ b/packages/agent/tests/unit/server-tool.test.ts @@ -15,10 +15,25 @@ describe('serverTool()', () => { }); expect(t._brand).toBe('server-tool'); expect(t.config.type).toBe('web_search_2025_08_26'); + expect(t.id).toBe('server:web_search_2025_08_26'); + expectTypeOf(t.id).toEqualTypeOf<'server:web_search_2025_08_26'>(); expect(isServerTool(t)).toBe(true); expect(isClientTool(t)).toBe(false); }); + it('allows overriding the stable tool-set id', () => { + const t = serverTool( + { + type: 'web_search_2025_08_26', + }, + { + id: 'server:public_search', + }, + ); + expect(t.id).toBe('server:public_search'); + expectTypeOf(t.id).toEqualTypeOf<'server:public_search'>(); + }); + it('narrows config shape based on the chosen type literal', () => { const dt = serverTool({ type: 'openrouter:datetime', From dd97fc62377cc758a5b4a5bfb8661cc9de05783c Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:27:58 -0500 Subject: [PATCH 08/31] fix(agent-tool-set): tighten typed snapshot integration --- packages/agent-tool-set/README.md | 11 ++- packages/agent-tool-set/src/tool-set.ts | 14 +++- packages/agent-tool-set/src/types.ts | 5 ++ .../tests/unit/tool-set.test.ts | 74 +++++++++++++++++-- packages/agent/src/lib/stream-transformers.ts | 9 +-- packages/agent/src/lib/tool-types.ts | 4 +- packages/agent/src/lib/tool.ts | 19 ++++- packages/agent/tests/unit/server-tool.test.ts | 13 ++++ .../unit/tool-name-correlation.test-d.ts | 11 +++ 9 files changed, 136 insertions(+), 24 deletions(-) diff --git a/packages/agent-tool-set/README.md b/packages/agent-tool-set/README.md index 841629c2..c44072d4 100644 --- a/packages/agent-tool-set/README.md +++ b/packages/agent-tool-set/README.md @@ -12,7 +12,7 @@ Port of [`ai-tool-set`](https://github.com/zirkelc/ai-tool-set) (MIT © Chris Co - A **typed three-way partition** of those IDs: definitely enabled, definitely disabled, conditional. - **Exhaustive runtime snapshots** from `resolve()` / `resolveSituation()` — every ID appears in `statusByTool`. - **Named declarative situations** with compile-time exact tool tuples when the situation is fully static. -- Integration with `callModel`'s `activeTools` option (spread `resolve()` directly). +- Integration with `callModel`'s `activeTools` option via the snapshot's spread-safe `.callModel` input. ## Install @@ -92,12 +92,14 @@ const guest = toolSet.resolveSituation('guest'); // guest.tools is exactly [login, webSearch] // guest.enabled / guest.disabled / guest.statusByTool are exhaustive +const authenticated = toolSet.resolveSituation('authenticated', { + context: { isAuthenticated: true, isAdmin: false }, +}); + const result = callModel(client, { model: 'openai/gpt-4o-mini', input: 'List my orders.', - ...toolSet.resolveSituation('authenticated', { - context: { isAuthenticated: true, isAdmin: false }, - }), + ...authenticated.callModel, }); ``` @@ -162,6 +164,7 @@ Situation overlays the base partition for every ID it mentions; unmentioned IDs { tools: /* active tools, construction order, concrete types */; activeTools: /* active *client* names for callModel */; + callModel: { tools, activeTools }; // safe to spread into callModel() enabled: /* every active ID (client + server) */; disabled: /* every inactive ID */; statusByTool: { diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts index 93c463ce..49731d51 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent-tool-set/src/tool-set.ts @@ -375,7 +375,7 @@ export class ToolSet< const M extends { readonly [K in string]: SituationConfig, TShared>; }, - >(situations: M): ToolSet & SituationMap> { + >(situations: M): ToolSet> { const next = new Map>(); for (const [name, config] of Object.entries(situations) as Array< @@ -438,10 +438,10 @@ export class ToolSet< for (const [k, v] of next) { this.#situations.set(k, v); } - return this as unknown as ToolSet & SituationMap>; + return this as unknown as ToolSet>; } - return new ToolSet & SituationMap>( + return new ToolSet>( this.#index, cloneActivationMap(this.#activation), next, @@ -573,6 +573,10 @@ export class ToolSet< ): { tools: Tool[]; activeTools: string[]; + callModel: { + tools: Tool[]; + activeTools: string[]; + }; enabled: string[]; disabled: string[]; statusByTool: Record; @@ -608,6 +612,10 @@ export class ToolSet< return { tools, activeTools, + callModel: { + tools, + activeTools, + }, enabled, disabled, statusByTool, diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index 6c2f2977..3a8455ab 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -280,6 +280,11 @@ export type ResolvedToolSnapshot< readonly tools: FilterToolsByIds>; /** Active client names only (`callModel.activeTools` wire format). Server ids omitted. */ readonly activeTools: readonly Extract>[]; + /** Spread-safe input for `callModel`; snapshot metadata is intentionally excluded. */ + readonly callModel: { + readonly tools: FilterToolsByIds>; + readonly activeTools: readonly Extract>[]; + }; /** IDs that resolved active (client + server). */ readonly enabled: readonly (TActive & ToolIdsOfTuple)[]; /** IDs that resolved inactive. */ 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 38ad1eeb..f93a0449 100644 --- a/packages/agent-tool-set/tests/unit/tool-set.test.ts +++ b/packages/agent-tool-set/tests/unit/tool-set.test.ts @@ -746,6 +746,22 @@ describe('defineSituations / resolveSituation', () => { >(); }); + it('keeps situation names literal at compile time', () => { + const ts = createToolSet({ + tools: [ + a, + ] as const, + }).defineSituations({ + guest: { + enabled: [ + 'a', + ], + }, + }); + + expectTypeOf(ts.resolveSituation).parameter(0).toEqualTypeOf<'guest'>(); + }); + it('supports conditional situation rules with runtime-exact status', () => { const ts = createToolSet({ tools: [ @@ -804,6 +820,41 @@ describe('defineSituations / resolveSituation', () => { ]); }); + it('supports deactivateWhen situation rules', () => { + const ts = createToolSet({ + tools: [ + a, + ] as const, + }).defineSituations({ + guarded: { + conditional: { + a: { + mode: 'deactivateWhen', + predicate: ({ context }) => context?.['blocked'] === true, + }, + }, + }, + }); + + expect(ts.resolveSituation('guarded').enabled).toEqual([ + 'a', + ]); + const blocked = ts.resolveSituation('guarded', { + context: { + blocked: true, + }, + }); + expect(blocked.disabled).toEqual([ + 'a', + ]); + expect(blocked.statusByTool.a).toEqual({ + enabled: false, + reason: 'situation', + directive: 'deactivateWhen', + predicate: true, + }); + }); + it('validates unknown / duplicate / conflicting IDs in a situation', () => { const base = createToolSet({ tools: [ @@ -980,6 +1031,22 @@ describe('InferToolSet / event narrowing', () => { type FromCore = CorrelatedToolEventUnion; expectTypeOf().toEqualTypeOf(); + const mixedTools = [ + weather, + serverTool({ + type: 'openrouter:datetime', + }), + ] as const; + type MixedEvent = InferToolSet; + const assertMixedEvent = (mixedEvent: MixedEvent): void => { + if (mixedEvent.type === 'tool.result' && mixedEvent.toolName === 'weather') { + expectTypeOf(mixedEvent.result).toEqualTypeOf<{ + temp: number; + }>(); + } + }; + void assertMixedEvent; + const ts = createToolSet({ tools, }); @@ -1004,17 +1071,14 @@ describe('callModel-oriented spread shape', () => { }).deactivate('b'); const snapshot = ts.resolve(); - // Structural match for BaseCallModelInput's tools/activeTools fields + // Snapshot metadata stays available without leaking into the API request. const forCallModel: { tools: readonly [ typeof a, typeof c, ]; activeTools: readonly ('a' | 'c')[]; - } = { - tools: snapshot.tools, - activeTools: snapshot.activeTools, - }; + } = snapshot.callModel; expect(forCallModel.tools).toEqual([ a, c, diff --git a/packages/agent/src/lib/stream-transformers.ts b/packages/agent/src/lib/stream-transformers.ts index ff00c567..a6e24f95 100644 --- a/packages/agent/src/lib/stream-transformers.ts +++ b/packages/agent/src/lib/stream-transformers.ts @@ -283,13 +283,8 @@ type InferServerToolOutputsUnion = InferServerTo * `true extends (distributed-check)` so distribution over a union yields * `true` when any member matches (not `boolean`). */ -type HasClientTool = true extends ( - TTools[number] extends ClientTool - ? true - : never -) - ? true - : false; +type HasClientTool = + Extract extends never ? false : true; /** * Widest possible streamable output — every item type the API can emit diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 8e242930..1047709e 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -1393,7 +1393,7 @@ export type CorrelatedToolResultEvent = ToolResultEvent< * Checking `event.toolName === 'my_tool'` narrows `result` to that tool's output. */ export type CorrelatedToolEventUnion = { - [K in keyof T]: T[K] extends Tool + [K in keyof T]: T[K] extends ClientTool ? CorrelatedToolPreliminaryResultEvent | CorrelatedToolResultEvent : never; }[number]; @@ -1403,7 +1403,7 @@ export type CorrelatedToolEventUnion = { * (legacy `getToolStream` shape) across a tools tuple. */ export type CorrelatedToolStreamPreliminaryUnion = { - [K in keyof T]: T[K] extends Tool + [K in keyof T]: T[K] extends ClientTool ? { type: 'preliminary_result'; toolCallId: string; diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index 313f7b8f..6099415b 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -493,9 +493,19 @@ export function tool< // When a non-ZodObject type is provided as the first generic, // the specific overloads above won't match (constraint mismatch), // so TypeScript falls through to this catch-all. -export function tool>( - config: ToolConfigWithSharedContext, -): Tool; +export function tool< + TShared extends Record, + TName extends string = string, + TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, +>( + config: ToolConfigWithSharedContext & { + name: TName; + }, +): Tool & { + function: { + name: TName; + }; +}; // Implementation export function tool( @@ -1035,6 +1045,9 @@ export function serverTool, options?: ServerToolOptions, ): ServerTool { + if (options?.id === '') { + throw new Error('Server tool ID must not be empty'); + } const id = (options?.id ?? (`server:${config.type}` as const)) as TId; return { _brand: 'server-tool', diff --git a/packages/agent/tests/unit/server-tool.test.ts b/packages/agent/tests/unit/server-tool.test.ts index 695cc0c1..a37d39ee 100644 --- a/packages/agent/tests/unit/server-tool.test.ts +++ b/packages/agent/tests/unit/server-tool.test.ts @@ -34,6 +34,19 @@ describe('serverTool()', () => { expectTypeOf(t.id).toEqualTypeOf<'server:public_search'>(); }); + it('rejects an empty stable tool-set id', () => { + expect(() => + serverTool( + { + type: 'openrouter:datetime', + }, + { + id: '', + }, + ), + ).toThrow(/must not be empty/); + }); + it('narrows config shape based on the chosen type literal', () => { const dt = serverTool({ type: 'openrouter:datetime', diff --git a/packages/agent/tests/unit/tool-name-correlation.test-d.ts b/packages/agent/tests/unit/tool-name-correlation.test-d.ts index 91f8c345..cc181325 100644 --- a/packages/agent/tests/unit/tool-name-correlation.test-d.ts +++ b/packages/agent/tests/unit/tool-name-correlation.test-d.ts @@ -58,6 +58,14 @@ const manual = tool({ execute: false, }); +const shared = tool<{ + userId: string; +}>({ + name: 'shared_tool', + inputSchema: z.object({}), + execute: async (_params, ctx) => ctx?.shared.userId ?? '', +}); + const hitl = tool({ name: 'hitl_tool', inputSchema: z.object({ @@ -73,17 +81,20 @@ const hitl = tool({ expectTypeOf(weather.function.name).toEqualTypeOf<'weather'>(); expectTypeOf(progress.function.name).toEqualTypeOf<'progress_tool'>(); expectTypeOf(manual.function.name).toEqualTypeOf<'manual_tool'>(); +expectTypeOf(shared.function.name).toEqualTypeOf<'shared_tool'>(); expectTypeOf(hitl.function.name).toEqualTypeOf<'hitl_tool'>(); expectTypeOf>().toEqualTypeOf<'weather'>(); expectTypeOf>().toEqualTypeOf<'progress_tool'>(); expectTypeOf>().toEqualTypeOf<'manual_tool'>(); +expectTypeOf>().toEqualTypeOf<'shared_tool'>(); expectTypeOf>().toEqualTypeOf<'hitl_tool'>(); // Wide defaults still assign to Tool expectTypeOf(weather).toExtend(); expectTypeOf(progress).toExtend(); expectTypeOf(manual).toExtend(); +expectTypeOf(shared).toExtend(); expectTypeOf(hitl).toExtend(); expectTypeOf().toExtend(); From 7bcdf25e5a2517d534d2be237e56647413d4d270 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:20:58 -0500 Subject: [PATCH 09/31] fix(agent-tool-set): skip nonexistent e2e suite --- packages/agent-tool-set/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/agent-tool-set/package.json b/packages/agent-tool-set/package.json index 5bb47bd6..ff311168 100644 --- a/packages/agent-tool-set/package.json +++ b/packages/agent-tool-set/package.json @@ -42,7 +42,6 @@ "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" From 78f823f13fb1c58b6e94ffc27c0a647990bdbeb4 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:28:43 -0500 Subject: [PATCH 10/31] docs(agent-tool-set): clarify active tool coupling --- packages/agent-tool-set/README.md | 2 ++ packages/agent-tool-set/vitest.config.ts | 11 ----------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/agent-tool-set/README.md b/packages/agent-tool-set/README.md index c44072d4..b2bee2eb 100644 --- a/packages/agent-tool-set/README.md +++ b/packages/agent-tool-set/README.md @@ -209,3 +209,5 @@ Alias of the agent's `CorrelatedToolEventUnion` — name-correlated prel - `mutable: true` mutates in place. Partition type parameters may widen for soundness; runtime state is still exact. - Last-call-wins: each directive on a given ID replaces any prior one for that ID. - Server tools participate fully in activation once they have an ID. When active they appear in `tools` (and `enabled` / `statusByTool`) but **not** in `activeTools`, which remains the client-name list expected by `callModel`. +- Keep a snapshot's `tools` and `activeTools` together by spreading `.callModel`; `callModel` cannot verify `activeTools` against an unrelated tools array. +- `callModel` ignores names in `activeTools` that are not present in `tools`. Tool-set snapshots avoid stale names by deriving both arrays from the same set. diff --git a/packages/agent-tool-set/vitest.config.ts b/packages/agent-tool-set/vitest.config.ts index 2963346d..c64e27f0 100644 --- a/packages/agent-tool-set/vitest.config.ts +++ b/packages/agent-tool-set/vitest.config.ts @@ -28,17 +28,6 @@ export default defineConfig({ hookTimeout: 10000, }, }, - { - extends: true, - test: { - name: 'e2e', - include: [ - 'tests/e2e/**/*.test.ts', - ], - testTimeout: 30000, - hookTimeout: 30000, - }, - }, ], }, }); From 294a7f71a3eb119ee26e471d9762493b19f08c2b Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:51:30 -0500 Subject: [PATCH 11/31] fix(agent): don't collapse tool event unions for generic readonly Tool[] CorrelatedToolEventUnion and CorrelatedToolStreamPreliminaryUnion checked `T[K] extends ClientTool` inside a mapped type over T's indexed access, which doesn't distribute for the wide `T = readonly Tool[]` case (only a naked type parameter distributes over a union). This collapsed the whole union to `never`, silently dropping `tool.result`/`tool.preliminary_result` from CorrelatedResponseStreamEvent and `preliminary_result` from CorrelatedToolStreamEvent -- regressing getFullResponsesStream/getToolStream for any caller whose tools value isn't a fixed tuple (e.g. @openrouter/mcp's `readonly Tool[]` handle). Apply the `readonly Tool[] extends T ? : ` idiom already used by StreamableOutputItem in stream-transformers.ts, so the wide case falls back to the pre-existing backward-compatible shapes while concrete tuples keep full toolName narrowing. Adds type tests in tool-name-correlation.test-d.ts covering both the wide fallback (tool.result/tool.preliminary_result/preliminary_result present, not never) and the narrow tuple case (toolName narrowing unaffected). Addresses PR #31 review thread PRRT_kwDORynLp86VKkcY. Co-Authored-By: Claude --- packages/agent/src/lib/tool-types.ts | 71 ++++++++++++++----- .../unit/tool-name-correlation.test-d.ts | 47 ++++++++++++ 2 files changed, 100 insertions(+), 18 deletions(-) diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 1047709e..de1e176b 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -1388,30 +1388,65 @@ export type CorrelatedToolResultEvent = ToolResultEvent< source: ToolSource; }; +/** + * Widest backward-compatible shape for {@link CorrelatedToolEventUnion} when + * `T` is the generic `readonly Tool[]` (e.g. a tool handle from + * `@openrouter/mcp`, whose concrete tuple isn't known at the type level). + * Mirrors the pre-existing {@link ToolPreliminaryResultEvent} / + * {@link ToolResultEvent} default shapes. + */ +type WidestCorrelatedToolEvent = ToolPreliminaryResultEvent | ToolResultEvent; + /** * Discriminated union of name-correlated tool events across a tools tuple. * Checking `event.toolName === 'my_tool'` narrows `result` to that tool's output. - */ -export type CorrelatedToolEventUnion = { - [K in keyof T]: T[K] extends ClientTool - ? CorrelatedToolPreliminaryResultEvent | CorrelatedToolResultEvent - : never; -}[number]; + * + * For the generic `readonly Tool[]` case, falls back to the widest + * backward-compatible shape instead of collapsing to `never`: the mapped-type + * check `T[K] extends ClientTool` is a non-distributive check on the indexed + * access `T[K]` (only a *naked* type parameter distributes over a union), so + * when `T[K]` resolves to the full `Tool` union (`ClientTool | ServerToolBase`) + * the check fails as a monolithic comparison rather than narrowing per-member. + */ +export type CorrelatedToolEventUnion = readonly Tool[] extends T + ? WidestCorrelatedToolEvent + : { + [K in keyof T]: T[K] extends ClientTool + ? CorrelatedToolPreliminaryResultEvent | CorrelatedToolResultEvent + : never; + }[number]; + +/** + * Widest backward-compatible shape for {@link CorrelatedToolStreamPreliminaryUnion} + * when `T` is the generic `readonly Tool[]`. Mirrors the pre-existing + * {@link ToolStreamEvent} preliminary-result shape. + */ +type WidestCorrelatedToolStreamPreliminary = { + type: 'preliminary_result'; + toolCallId: string; + toolName: string; + result: unknown; +}; /** * Discriminated union of name-correlated preliminary stream events - * (legacy `getToolStream` shape) across a tools tuple. - */ -export type CorrelatedToolStreamPreliminaryUnion = { - [K in keyof T]: T[K] extends ClientTool - ? { - type: 'preliminary_result'; - toolCallId: string; - toolName: InferToolName; - result: InferToolEvent; - } - : never; -}[number]; + * (legacy `getToolStream` shape) across a tools tuple. Falls back to the + * widest backward-compatible shape for the generic `readonly Tool[]` case; + * see {@link CorrelatedToolEventUnion} for why the naive mapped check collapses. + */ +export type CorrelatedToolStreamPreliminaryUnion = + readonly Tool[] extends T + ? WidestCorrelatedToolStreamPreliminary + : { + [K in keyof T]: T[K] extends ClientTool + ? { + type: 'preliminary_result'; + toolCallId: string; + toolName: InferToolName; + result: InferToolEvent; + } + : never; + }[number]; /** * Tool call output event carrying the fully-formed FunctionCallOutputItem. diff --git a/packages/agent/tests/unit/tool-name-correlation.test-d.ts b/packages/agent/tests/unit/tool-name-correlation.test-d.ts index cc181325..7488c3f8 100644 --- a/packages/agent/tests/unit/tool-name-correlation.test-d.ts +++ b/packages/agent/tests/unit/tool-name-correlation.test-d.ts @@ -11,6 +11,7 @@ import type { CorrelatedToolEventUnion, CorrelatedToolResultEvent, CorrelatedToolStreamEvent, + CorrelatedToolStreamPreliminaryUnion, InferToolName, Tool, ToolWithExecute, @@ -154,3 +155,49 @@ expectTypeOf['toolName']>().toEqualTyp expectTypeOf['result']>().toEqualTypeOf<{ tempC: number; }>(); + +// --- Generic `readonly Tool[]` must not collapse to `never` ----------------- +// +// A tool handle whose concrete tuple isn't known at the type level (e.g. an +// `@openrouter/mcp` tool array typed as `readonly Tool[]`) must still produce +// a usable, backward-compatible event shape instead of `never`. The mapped +// check `T[K] extends ClientTool` doesn't distribute over the indexed access +// `T[K]` when `T` is the wide `readonly Tool[]`, so these types fall back to +// the widest shape (matching the pre-existing, non-tuple-parameterized +// `ToolPreliminaryResultEvent`/`ToolResultEvent`/`ToolStreamEvent` defaults). +type WideEvents = CorrelatedToolEventUnion; +type WideStream = CorrelatedResponseStreamEvent; +type WideToolStream = CorrelatedToolStreamEvent; +type WidePreliminaryUnion = CorrelatedToolStreamPreliminaryUnion; + +expectTypeOf().not.toBeNever(); +expectTypeOf().not.toBeNever(); +expectTypeOf().not.toBeNever(); +expectTypeOf().not.toBeNever(); + +// The wide shapes still carry `tool.result` / `tool.preliminary_result` / +// `preliminary_result` variants (not silently dropped). +expectTypeOf>().not.toBeNever(); +expectTypeOf>().not.toBeNever(); +expectTypeOf>().not.toBeNever(); +expectTypeOf>().not.toBeNever(); +expectTypeOf>().not.toBeNever(); +expectTypeOf>().not.toBeNever(); + +// `toolName`/`result` degrade gracefully to `string`/`unknown` for the wide +// case (no correlation possible without a concrete tuple). +declare const wideResult: Extract; +expectTypeOf(wideResult.toolName).toEqualTypeOf(); +expectTypeOf(wideResult.result).toEqualTypeOf(); + +// --- Concrete tuples still retain full name correlation ---------------------- +// +// Passing a real tuple (not the wide `readonly Tool[]`) must keep narrowing +// `result` from a literal `toolName`, proving the wide-case fallback above +// doesn't regress tuple correlation. +declare const narrowResult: Extract, { type: 'tool.result' }>; +if (narrowResult.toolName === 'weather') { + expectTypeOf(narrowResult.result).toEqualTypeOf<{ + tempC: number; + }>(); +} From 6b23d458c2c2b7c0339acafead44213dab0cf8d4 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:53:14 -0500 Subject: [PATCH 12/31] style(agent): format wide event type tests Co-Authored-By: Claude --- .../unit/tool-name-correlation.test-d.ts | 68 ++++++++++++++++--- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/packages/agent/tests/unit/tool-name-correlation.test-d.ts b/packages/agent/tests/unit/tool-name-correlation.test-d.ts index 7488c3f8..efde2167 100644 --- a/packages/agent/tests/unit/tool-name-correlation.test-d.ts +++ b/packages/agent/tests/unit/tool-name-correlation.test-d.ts @@ -177,16 +177,63 @@ expectTypeOf().not.toBeNever(); // The wide shapes still carry `tool.result` / `tool.preliminary_result` / // `preliminary_result` variants (not silently dropped). -expectTypeOf>().not.toBeNever(); -expectTypeOf>().not.toBeNever(); -expectTypeOf>().not.toBeNever(); -expectTypeOf>().not.toBeNever(); -expectTypeOf>().not.toBeNever(); -expectTypeOf>().not.toBeNever(); +expectTypeOf< + Extract< + WideEvents, + { + type: 'tool.result'; + } + > +>().not.toBeNever(); +expectTypeOf< + Extract< + WideEvents, + { + type: 'tool.preliminary_result'; + } + > +>().not.toBeNever(); +expectTypeOf< + Extract< + WideStream, + { + type: 'tool.result'; + } + > +>().not.toBeNever(); +expectTypeOf< + Extract< + WideStream, + { + type: 'tool.preliminary_result'; + } + > +>().not.toBeNever(); +expectTypeOf< + Extract< + WideToolStream, + { + type: 'preliminary_result'; + } + > +>().not.toBeNever(); +expectTypeOf< + Extract< + WidePreliminaryUnion, + { + type: 'preliminary_result'; + } + > +>().not.toBeNever(); // `toolName`/`result` degrade gracefully to `string`/`unknown` for the wide // case (no correlation possible without a concrete tuple). -declare const wideResult: Extract; +declare const wideResult: Extract< + WideEvents, + { + type: 'tool.result'; + } +>; expectTypeOf(wideResult.toolName).toEqualTypeOf(); expectTypeOf(wideResult.result).toEqualTypeOf(); @@ -195,7 +242,12 @@ expectTypeOf(wideResult.result).toEqualTypeOf(); // Passing a real tuple (not the wide `readonly Tool[]`) must keep narrowing // `result` from a literal `toolName`, proving the wide-case fallback above // doesn't regress tuple correlation. -declare const narrowResult: Extract, { type: 'tool.result' }>; +declare const narrowResult: Extract< + CorrelatedToolEventUnion, + { + type: 'tool.result'; + } +>; if (narrowResult.toolName === 'weather') { expectTypeOf(narrowResult.result).toEqualTypeOf<{ tempC: number; From 6081b2510f0434d0b638515aad6e83176b0126c2 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:00:13 -0500 Subject: [PATCH 13/31] fix(agent): omit tools key instead of sending empty array when activeTools filters out all tools When activeTools filters out every tool (or a fully-deactivated tool set from inferTools()/.resolve()), callModel now collapses the filtered list to undefined so the outbound request omits the tools key entirely instead of sending tools: []. Several providers reject an explicit empty tools array outright. ModelResult already treats undefined tools as its no-tools state, so this keeps behavior consistent end to end. Adds a regression test using the existing capturing-client harness that asserts the outbound request body has no tools property at all in this case. Co-Authored-By: Claude --- packages/agent/src/inner-loop/call-model.ts | 11 ++++- .../unit/call-model-active-tools.test.ts | 44 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/inner-loop/call-model.ts b/packages/agent/src/inner-loop/call-model.ts index 49a93e66..e00c95be 100644 --- a/packages/agent/src/inner-loop/call-model.ts +++ b/packages/agent/src/inner-loop/call-model.ts @@ -122,10 +122,19 @@ 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 + const activeFilteredTools = activeSet ? tools?.filter((t) => isServerTool(t) || activeSet.has(t.function.name)) : tools; + // Collapse a filtered-to-empty (or explicitly empty) tools list to + // `undefined` so a fully-deactivated tool set (a first-class output of + // `inferTools()`/`.resolve()`) omits the outbound `tools` key entirely + // instead of sending `tools: []` — several providers reject an empty + // array outright. `ModelResult` treats `undefined` as its no-tools state + // (see the `?.length` / truthiness checks throughout), so this also keeps + // the engine's tool-execution machinery correctly disabled. + const filteredTools = activeFilteredTools?.length ? activeFilteredTools : undefined; + // Convert tools to API format - no cast needed now that convertToolsToAPIFormat accepts readonly const apiTools = filteredTools ? convertToolsToAPIFormat(filteredTools) : undefined; diff --git a/packages/agent/tests/unit/call-model-active-tools.test.ts b/packages/agent/tests/unit/call-model-active-tools.test.ts index 319a57da..d14312f2 100644 --- a/packages/agent/tests/unit/call-model-active-tools.test.ts +++ b/packages/agent/tests/unit/call-model-active-tools.test.ts @@ -162,4 +162,48 @@ describe('callModel activeTools filter', () => { 'b', ]); }); + + it('omits the tools key entirely (not an empty array) when activeTools filters out every tool', async () => { + 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: [ + toolA, + toolB, + ], + activeTools: [ + 'missing', + ], + }); + + try { + await result.getText(); + } catch (err) { + if (captured.raw === null) { + throw err; + } + } + + if (captured.raw === null) { + throw new Error('request body was not captured'); + } + expect(isCapturedPayload(captured.raw)).toBe(true); + // The bug this guards against: sending `tools: []` instead of omitting the + // key. Several providers reject an explicit empty tools array outright, so + // the outbound request must not have a `tools` property at all. + expect(captured.raw).not.toHaveProperty('tools'); + }); }); From d7b9686b62cbf2638b576a4808e682399c2f1202 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:03:46 -0500 Subject: [PATCH 14/31] docs(changeset): add agent tool set API example Co-Authored-By: Claude --- .changeset/agent-tool-set.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.changeset/agent-tool-set.md b/.changeset/agent-tool-set.md index 5181f9e3..01581a95 100644 --- a/.changeset/agent-tool-set.md +++ b/.changeset/agent-tool-set.md @@ -4,3 +4,31 @@ --- Add `@openrouter/agent-tool-set` (port of ai-tool-set v1.0.0, MIT © Chris Cook): 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. + +```ts +import { callModel, OpenRouter, serverTool, tool } 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: [] }), +}); +// override the default `server:${type}` id +const search = serverTool({ type: 'web_search_2025_08_26' }, { id: 'public_search' }); + +const toolSet = createToolSet({ tools: [listOrders, search] as const }).deactivate( + 'list_orders', +); + +const client = new OpenRouter({ apiKey: process.env['OPENROUTER_API_KEY'] }); +const resolved = toolSet.resolve(); + +// resolved.callModel is `{ tools, activeTools }` — spread it straight in +const result = callModel(client, { + model: 'openai/gpt-4o-mini', + input: 'Search for OpenRouter pricing.', + ...resolved.callModel, +}); +``` From cf03c09f7a09907f975bada185d479f0234640b2 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:03:42 -0500 Subject: [PATCH 15/31] fix(agent): keep legacy ServerToolBase/toolName shapes source-compatible Devin flagged that ServerToolBase.id and the toolName field on the wide event types (ToolPreliminaryResultEvent, ToolResultEvent, ToolStreamEvent's preliminary_result branch, ChatStreamEvent's tool.preliminary_result branch) became required, breaking compilation for hand-constructed legacy values even though this PR ships as a minor bump. Make those base fields optional for source compatibility, while keeping serverTool() output and the per-tool "correlated" helpers (CorrelatedToolPreliminaryResultEvent, CorrelatedToolResultEvent) strongly typed with required literal id/toolName via an explicit Omit & { field: Literal } override. Add type tests proving both halves: legacy literals still compile, and the factory/correlated types still reject a missing or loosely-typed id/toolName. Co-Authored-By: Claude --- packages/agent/src/lib/tool-types.ts | 102 +++++++++----- .../unit/tool-name-correlation.test-d.ts | 129 +++++++++++++++++- 2 files changed, 199 insertions(+), 32 deletions(-) diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index de1e176b..9071ae43 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -858,8 +858,13 @@ export interface ServerToolBase { /** * Stable tool-set identity used by `@openrouter/agent-tool-set` activation. * Defaults to `server:${config.type}` when constructed via {@link serverTool}. + * + * Optional here for source compatibility with legacy hand-constructed + * `ServerToolBase` values that predate this field. `ServerTool` + * (the type returned by {@link serverTool}) still requires it as a + * literal `TId` via interface narrowing below. */ - readonly id: string; + readonly id?: string; } /** @@ -1318,8 +1323,13 @@ export interface APITool { export type ToolPreliminaryResultEvent = { type: 'tool.preliminary_result'; toolCallId: string; - /** Name of the tool that produced this preliminary result */ - toolName: TName; + /** + * Name of the tool that produced this preliminary result. + * Optional for source compatibility with legacy hand-constructed events + * that predate this field; {@link CorrelatedToolPreliminaryResultEvent} + * re-requires it as a literal for a concrete tool. + */ + toolName?: TName; result: TEvent; timestamp: number; }; @@ -1338,8 +1348,13 @@ export type ToolResultEvent< > = { type: 'tool.result'; toolCallId: string; - /** Name of the tool that produced this result */ - toolName: TName; + /** + * Name of the tool that produced this result. + * Optional for source compatibility with legacy hand-constructed events + * that predate this field; {@link CorrelatedToolResultEvent} re-requires + * it as a literal for a concrete tool. + */ + toolName?: TName; /** * Origin of the tool: `'mcp'` for tools wrapped from a remote MCP server * (whose `result` is `unknown`), `'client'` for locally-defined tools. Lets @@ -1355,36 +1370,52 @@ export type ToolResultEvent< /** * Name-correlated preliminary result event for one concrete tool. * Narrowing on `toolName` recovers this tool's event payload type. - */ -export type CorrelatedToolPreliminaryResultEvent = ToolPreliminaryResultEvent< - InferToolEvent, - InferToolName ->; + * + * `toolName` is optional on the underlying {@link ToolPreliminaryResultEvent} + * base (for legacy source compatibility), so it's overridden back to a + * required literal here via `Omit<...> & {...}` — parameterizing the base + * type alone does not re-require an optional field. + */ +export type CorrelatedToolPreliminaryResultEvent = Omit< + ToolPreliminaryResultEvent, InferToolName>, + 'toolName' +> & { + toolName: InferToolName; +}; /** * Name-correlated final result event for one concrete tool. * Narrowing on `toolName` recovers this tool's result (and preliminary) types. - */ -export type CorrelatedToolResultEvent = ToolResultEvent< - T extends { - readonly _mcp: true; - } - ? unknown - : [ - Tool, - ] extends [ - T, - ] + * + * `toolName` is optional on the underlying {@link ToolResultEvent} base (for + * legacy source compatibility), so it's overridden back to a required + * literal here via `Omit<...> & {...}` — parameterizing the base type alone + * does not re-require an optional field. + */ +export type CorrelatedToolResultEvent = Omit< + ToolResultEvent< + T extends { + readonly _mcp: true; + } ? unknown - : T extends - | ToolWithExecute<$ZodObject<$ZodShape>, infer O> - | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, infer O> - | HITLTool<$ZodObject<$ZodShape>, infer O> - ? zodInfer - : InferToolOutput, - T extends ToolWithGenerator<$ZodObject<$ZodShape>, infer E> ? zodInfer : never, - InferToolName + : [ + Tool, + ] extends [ + T, + ] + ? unknown + : T extends + | ToolWithExecute<$ZodObject<$ZodShape>, infer O> + | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, infer O> + | HITLTool<$ZodObject<$ZodShape>, infer O> + ? zodInfer + : InferToolOutput, + T extends ToolWithGenerator<$ZodObject<$ZodShape>, infer E> ? zodInfer : never, + InferToolName + >, + 'toolName' > & { + toolName: InferToolName; source: ToolSource; }; @@ -1622,7 +1653,12 @@ export type ToolStreamEvent = | { type: 'preliminary_result'; toolCallId: string; - toolName: TName; + /** + * Optional for source compatibility with legacy hand-constructed + * events; {@link CorrelatedToolStreamEvent} re-requires it as a + * literal for a concrete tool. + */ + toolName?: TName; result: TEvent; }; @@ -1655,7 +1691,11 @@ export type ChatStreamEvent = | { type: 'tool.preliminary_result'; toolCallId: string; - toolName: TName; + /** + * Optional for source compatibility with legacy hand-constructed + * events that predate this field. + */ + toolName?: TName; result: TEvent; } | { diff --git a/packages/agent/tests/unit/tool-name-correlation.test-d.ts b/packages/agent/tests/unit/tool-name-correlation.test-d.ts index efde2167..12ef88f2 100644 --- a/packages/agent/tests/unit/tool-name-correlation.test-d.ts +++ b/packages/agent/tests/unit/tool-name-correlation.test-d.ts @@ -5,15 +5,22 @@ import { expectTypeOf } from 'vitest'; import * as z from 'zod'; -import { tool } from '../../src/lib/tool.js'; +import { serverTool, tool } from '../../src/lib/tool.js'; import type { + ChatStreamEvent, CorrelatedResponseStreamEvent, CorrelatedToolEventUnion, + CorrelatedToolPreliminaryResultEvent, CorrelatedToolResultEvent, CorrelatedToolStreamEvent, CorrelatedToolStreamPreliminaryUnion, InferToolName, + ServerTool, + ServerToolBase, Tool, + ToolPreliminaryResultEvent, + ToolResultEvent, + ToolStreamEvent, ToolWithExecute, } from '../../src/lib/tool-types.js'; @@ -253,3 +260,123 @@ if (narrowResult.toolName === 'weather') { tempC: number; }>(); } +// --- Source compatibility: legacy hand-constructed shapes still compile ---- +// +// `ServerToolBase.id` and the `toolName` field on the wide (non-correlated) +// event types were made optional so that values built by hand before these +// fields existed keep compiling under a minor release, without loosening the +// strongly-typed literal guarantees on `serverTool()` output or on the +// per-tool "correlated" event helpers below. + +// A legacy server tool literal that predates `id` compiles as `ServerToolBase`. +const legacyServerTool: ServerToolBase = { + _brand: 'server-tool', + config: { + type: 'openrouter:datetime', + }, +}; +expectTypeOf(legacyServerTool).toExtend(); +expectTypeOf(legacyServerTool.id).toEqualTypeOf(); + +// A legacy preliminary-result event literal that predates `toolName` compiles. +const legacyPreliminary: ToolPreliminaryResultEvent = { + type: 'tool.preliminary_result', + toolCallId: 'call_1', + result: { + stage: 'start', + }, + timestamp: Date.now(), +}; +expectTypeOf(legacyPreliminary.toolName).toEqualTypeOf(); + +// A legacy result event literal that predates `toolName` compiles. +const legacyResult: ToolResultEvent = { + type: 'tool.result', + toolCallId: 'call_1', + source: 'client', + result: { + tempC: 20, + }, + timestamp: Date.now(), +}; +expectTypeOf(legacyResult.toolName).toEqualTypeOf(); + +// A legacy `getToolStream` preliminary event literal that predates `toolName`. +const legacyToolStreamEvent: ToolStreamEvent = { + type: 'preliminary_result', + toolCallId: 'call_1', + result: { + stage: 'start', + }, +}; +expectTypeOf(legacyToolStreamEvent.toolName).toEqualTypeOf(); + +// A legacy `getFullChatStream` preliminary event literal that predates `toolName`. +const legacyChatStreamEvent: ChatStreamEvent = { + type: 'tool.preliminary_result', + toolCallId: 'call_1', + result: { + stage: 'start', + }, +}; +expectTypeOf(legacyChatStreamEvent.toolName).toEqualTypeOf(); + +// --- serverTool() factory output stays required + literal ------------------- + +const publicSearch = serverTool( + { + type: 'web_search_2025_08_26', + }, + { + id: 'server:public_search', + }, +); +expectTypeOf(publicSearch.id).toEqualTypeOf<'server:public_search'>(); +expectTypeOf(publicSearch).toExtend>(); +// @ts-expect-error ServerTool still requires a literal `id`, not `string | undefined` +const _missingId: ServerTool<'web_search_2025_08_26'> = { + _brand: 'server-tool', + config: { + type: 'web_search_2025_08_26', + }, +}; +void _missingId; + +// --- Correlated per-tool helpers still require + provide literal names ----- + +expectTypeOf< + CorrelatedToolPreliminaryResultEvent['toolName'] +>().toEqualTypeOf<'progress_tool'>(); +expectTypeOf['result']>().toEqualTypeOf<{ + stage: string; +}>(); + +// @ts-expect-error correlated preliminary events require a literal `toolName`, not optional +const _preliminaryMissingName: CorrelatedToolPreliminaryResultEvent = { + type: 'tool.preliminary_result', + toolCallId: 'call_1', + result: { + stage: 'start', + }, + timestamp: Date.now(), +}; +void _preliminaryMissingName; + +// @ts-expect-error correlated result events require a literal `toolName`, not optional +const _resultMissingName: CorrelatedToolResultEvent = { + type: 'tool.result', + toolCallId: 'call_1', + source: 'client', + result: { + tempC: 20, + }, + timestamp: Date.now(), +}; +void _resultMissingName; + +// Correlated tuple-typed unions still discriminate on a required literal `toolName`. +expectTypeOf['toolName']>().toEqualTypeOf< + 'weather' | 'progress_tool' | 'manual_tool' | 'hitl_tool' +>(); +expectTypeOf().not.toEqualTypeOf(); +expectTypeOf().not.toEqualTypeOf(); From a322f2d649d9371aed298045819cb1d7c9d2792e Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:06:58 -0500 Subject: [PATCH 16/31] fix(agent): strip tool-set metadata from model requests Co-Authored-By: Claude --- packages/agent-tool-set/src/tool-set.ts | 16 +++ packages/agent/src/inner-loop/call-model.ts | 15 +++ packages/agent/src/lib/async-params.ts | 36 +++++- packages/agent/src/lib/model-result.ts | 16 ++- .../unit/call-model-active-tools.test.ts | 117 ++++++++++++++++-- 5 files changed, 184 insertions(+), 16 deletions(-) diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts index 49731d51..fd2d7359 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent-tool-set/src/tool-set.ts @@ -480,6 +480,22 @@ export class ToolSet< /** * Back-compat alias for {@link resolve}. Prefer `resolve` for new code. + * + * Returns the full snapshot, including metadata (`enabled`, `disabled`, + * `statusByTool`) that is not a valid `callModel` input. Only `tools` and + * `activeTools` are meant to reach `callModel` — spread those two fields + * (or use `resolve(...).callModel`, which contains exactly them), + * not the whole return value of this method: + * + * ```ts + * const { tools, activeTools } = toolSet.inferTools(); + * callModel(client, { model, input, tools, activeTools }); + * ``` + * + * `callModel` also defensively strips `enabled` / `disabled` / + * `statusByTool` (and a top-level `callModel` key) from whatever it's + * given, so spreading this method's full result is safe too — but the + * two-field pattern above is the documented, minimal contract. */ inferTools(input?: ActivationInput): { tools: Tool[]; diff --git a/packages/agent/src/inner-loop/call-model.ts b/packages/agent/src/inner-loop/call-model.ts index e00c95be..858ec0f5 100644 --- a/packages/agent/src/inner-loop/call-model.ts +++ b/packages/agent/src/inner-loop/call-model.ts @@ -2,6 +2,7 @@ import type { OpenRouterCore } from '@openrouter/sdk/core'; import type { RequestOptions } from '@openrouter/sdk/lib/sdks'; import type { $ZodObject, $ZodShape, infer as zodInfer } from 'zod/v4/core'; import type { CallModelInput } from '../lib/async-params.js'; +import { TOOL_SET_SNAPSHOT_METADATA_KEYS } from '../lib/async-params.js'; import { resolveHooks } from '../lib/hooks-resolve.js'; import type { GetResponseOptions } from '../lib/model-result.js'; import { ModelResult } from '../lib/model-result.js'; @@ -151,9 +152,23 @@ export function callModel< // Build the request with converted tools // Note: async functions are resolved later in ModelResult.executeToolsIfNeeded() // The request can have async fields (functions) or sync fields, and the tools are converted to API format + // + // Defense-in-depth: `apiRequest` is typed as "whatever wasn't one of the + // known client-only fields above", but TypeScript's excess-property + // checking does not run on spread arguments — so a caller who does + // `callModel(client, { ...toolSet.inferTools(), model, input })` (a + // documented, intended pattern for `tools`/`activeTools`) can silently + // carry `@openrouter/agent-tool-set` snapshot metadata (`enabled`, + // `disabled`, `statusByTool`) straight through to the outbound request + // with no compile-time or destructure-time signal. Strip any such keys + // here, at the single choke point every callModel() call passes through, + // regardless of which ToolSet method produced the spread object. const finalRequest: Record = { ...apiRequest, }; + for (const key of TOOL_SET_SNAPSHOT_METADATA_KEYS) { + delete finalRequest[key]; + } if (apiTools !== undefined) { finalRequest['tools'] = apiTools; diff --git a/packages/agent/src/lib/async-params.ts b/packages/agent/src/lib/async-params.ts index 015a153d..1b3ed0b3 100644 --- a/packages/agent/src/lib/async-params.ts +++ b/packages/agent/src/lib/async-params.ts @@ -17,6 +17,29 @@ import type { // Re-export Tool type for convenience export type { Tool } from './tool-types.js'; +/** + * Keys that appear on `@openrouter/agent-tool-set`'s `ToolSet.inferTools()` / + * `resolve()` / `resolveSituation()` snapshots but are never valid outbound + * API request fields. + * + * The documented pattern is to spread `{ tools, activeTools }` (or + * `snapshot.callModel`) from one of those snapshots into `callModel`. If a + * caller instead spreads the *whole* snapshot — e.g. + * `callModel(client, { ...toolSet.inferTools(), model })` — this set is what + * keeps `enabled` / `disabled` / `statusByTool` (and the nested `callModel` + * wrapper itself, if a whole `ResolvedToolSnapshot` is spread) from silently + * riding along into the request body. Checked by both `callModel()` and + * {@link resolveAsyncFunctions}, independent of the exact shape any given + * tool-set snapshot method returns, so it stays robust even if the tool-set + * package adds more metadata under these names later. + */ +export const TOOL_SET_SNAPSHOT_METADATA_KEYS: ReadonlySet = new Set([ + 'enabled', + 'disabled', + 'statusByTool', + 'callModel', +]); + /** * Type guard to check if a value is a parameter function * Parameter functions take TurnContext and return a value or promise @@ -66,7 +89,11 @@ type BaseCallModelInput< * 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. + * `@openrouter/agent-tool-set`'s `.inferTools()` output — spreading its + * `{ tools, activeTools }` (or a whole snapshot from `.inferTools()` / + * `.resolve()` / `.resolveSituation()`) into this object is safe: + * `callModel` strips any tool-set snapshot metadata (`enabled`, `disabled`, + * `statusByTool`) before it ever reaches the outbound API request. */ activeTools?: readonly string[]; stopWhen?: StopWhen; @@ -323,7 +350,12 @@ export async function resolveAsyncFunctions = { + ...rest, + }; + for (const key of TOOL_SET_SNAPSHOT_METADATA_KEYS) { + delete resolved[key]; + } + return this.applyResolvedForcedToolChoicePolicy(resolved as ResolvedCallModelInput); } /** diff --git a/packages/agent/tests/unit/call-model-active-tools.test.ts b/packages/agent/tests/unit/call-model-active-tools.test.ts index d14312f2..90c3f6b3 100644 --- a/packages/agent/tests/unit/call-model-active-tools.test.ts +++ b/packages/agent/tests/unit/call-model-active-tools.test.ts @@ -64,6 +64,31 @@ async function captureOutboundTools(options: { tools: ReadonlyArray>; activeTools?: readonly string[]; }): Promise { + const { names } = await captureOutboundRequest({ + model: 'openai/gpt-4o-mini', + input: 'hi', + tools: options.tools, + ...(options.activeTools !== undefined && { + activeTools: options.activeTools, + }), + }); + + if (names === null) { + throw new Error('request body was not captured'); + } + return names; +} + +/** + * Run `callModel` with an arbitrary request object (deliberately typed as + * `unknown` so tests can pass shapes that don't type-check, such as a whole + * `@openrouter/agent-tool-set` snapshot spread in) and capture the raw JSON + * body sent to the HTTP client, short-circuiting the actual network call. + */ +async function captureOutboundRequest(request: unknown): Promise<{ + names: string[] | null; + raw: unknown; +}> { const captured: { names: string[] | null; raw: unknown; @@ -77,19 +102,15 @@ async function captureOutboundTools(options: { httpClient, }); - const result = callModel(client, { - model: 'openai/gpt-4o-mini', - input: 'hi', - tools: options.tools, - ...(options.activeTools !== undefined && { - activeTools: options.activeTools, - }), - }); + // Deliberately bypasses CallModelInput's type checking to exercise runtime + // stripping of stray keys (a plain `unknown` cast is enough here; the repo's + // biome config doesn't flag this `as` chain as `noExplicitAny`). + const result = callModel(client, request as unknown as Parameters[1]); try { await result.getText(); } catch (err) { - if (captured.names === null) { + if (captured.raw === null) { throw err; } if (!(err instanceof Error) || err.message !== STOP_ERROR) { @@ -97,10 +118,10 @@ async function captureOutboundTools(options: { } } - if (captured.names === null) { - throw new Error(`request body was not captured; raw=${JSON.stringify(captured.raw)}`); + if (captured.raw === null) { + throw new Error('request body was not captured'); } - return captured.names; + return captured; } describe('callModel activeTools filter', () => { @@ -207,3 +228,75 @@ describe('callModel activeTools filter', () => { expect(captured.raw).not.toHaveProperty('tools'); }); }); + +describe('callModel strips @openrouter/agent-tool-set snapshot metadata', () => { + const toolA = tool({ + name: 'a', + inputSchema: z.object({}), + execute: async () => ({ + ok: true, + }), + }); + + it('never sends enabled/disabled/statusByTool/callModel keys when a whole tool-set snapshot is spread in', async () => { + // Mirrors the documented-but-dangerous pattern of spreading the full + // return value of `ToolSet.inferTools()` / `.resolve()` / + // `.resolveSituation()` straight into callModel, instead of picking out + // just `{ tools, activeTools }` (or `.callModel`). + const snapshotLikeRequest = { + model: 'openai/gpt-4o-mini', + input: 'hi', + tools: [ + toolA, + ], + activeTools: [ + 'a', + ], + enabled: [ + 'a', + ], + disabled: [] as string[], + statusByTool: { + a: 'enabled', + }, + // A whole `ResolvedToolSnapshot` also carries a nested, spread-safe + // `callModel` field; a bare top-level `callModel` key must never reach + // the outbound request body either. + callModel: { + tools: [ + toolA, + ], + activeTools: [ + 'a', + ], + }, + }; + + const { raw } = await captureOutboundRequest(snapshotLikeRequest); + + expect(raw).not.toHaveProperty('enabled'); + expect(raw).not.toHaveProperty('disabled'); + expect(raw).not.toHaveProperty('statusByTool'); + expect(raw).not.toHaveProperty('callModel'); + // The legitimate fields must still make it through unaffected. + expect(extractToolNames(raw as CapturedPayload)).toEqual([ + 'a', + ]); + }); + + it('still sends the documented { tools, activeTools } spread-safe pattern unaffected', async () => { + // Guards against over-eager stripping: `tools`/`activeTools` themselves + // (the two fields the docs say to spread) must keep working. + const names = await captureOutboundTools({ + tools: [ + toolA, + ], + activeTools: [ + 'a', + ], + }); + expect(names).toEqual([ + 'a', + ]); + }); +}); From 9b7dde557bd649b84b70156226e16f3138f3617a Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:12:32 -0500 Subject: [PATCH 17/31] fix(agent-tool-set): include conditional ids in disabled type Co-Authored-By: Claude --- packages/agent-tool-set/src/types.ts | 16 +++++++++++-- .../tests/unit/tool-set.test.ts | 24 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index 3a8455ab..8fd4f275 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -270,6 +270,14 @@ export type StatusByToolMap = { * `P['enabled']` and the snapshot is fully known at compile time. * When conditional ≠ never, TActive is the sound upper bound * `P['enabled'] | P['conditional']`; runtime arrays/status are exact. + * + * `disabled` is declared as the complement of the *definitely-enabled* set + * (`P['enabled']`), not of `TActive`. A conditional id's predicate can + * resolve to either outcome at runtime — `#resolveWithActivation` pushes it + * into `disabled` whenever the predicate says off — so conditional ids must + * remain in both the `enabled` and `disabled` upper bounds for the declared + * types to stay sound. (Subtracting `TActive`, which already includes + * `P['conditional']`, would wrongly exclude conditional ids from `disabled`.) */ export type ResolvedToolSnapshot< TTools extends readonly Tool[], @@ -287,8 +295,12 @@ export type ResolvedToolSnapshot< }; /** IDs that resolved active (client + server). */ readonly enabled: readonly (TActive & ToolIdsOfTuple)[]; - /** IDs that resolved inactive. */ - readonly disabled: readonly Exclude, TActive & ToolIdsOfTuple>[]; + /** + * IDs that resolved inactive (client + server). Sound upper bound: the + * complement of the definitely-enabled set, so conditional ids whose + * predicate resolves `false` are included here too. + */ + readonly disabled: readonly Exclude, P['enabled']>[]; /** Exhaustive id → status entry. Every ToolIdsOfTuple key present. */ readonly statusByTool: StatusByToolMap>; }; 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 f93a0449..a3c790fb 100644 --- a/packages/agent-tool-set/tests/unit/tool-set.test.ts +++ b/packages/agent-tool-set/tests/unit/tool-set.test.ts @@ -507,7 +507,27 @@ describe('compile-time partition inference', () => { // Static-only resolve is exact; with conditional IDs, tools is the upper bound. const snapshot = ts.resolve(); expectTypeOf(snapshot.enabled).toEqualTypeOf(); - expectTypeOf(snapshot.disabled).toEqualTypeOf(); + // `disabled`'s sound upper bound includes conditional ids too ('a' | 'c'), + // since a predicate can resolve to inactive for a different input. + expectTypeOf(snapshot.disabled).toEqualTypeOf(); + }); + + it('includes a conditional id in the runtime disabled array when its predicate resolves false', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }) + .deactivate('b') + .activateWhen('a', () => false); + + const snapshot = ts.resolve(); + expectTypeOf(snapshot.disabled).toEqualTypeOf(); + expect(snapshot.disabled).toEqual([ + 'a', + 'b', + ]); }); it('returns an exactly-typed active tool tuple for static partitions', () => { @@ -798,6 +818,7 @@ describe('defineSituations / resolveSituation', () => { 'b', 'c', ]); + expectTypeOf(denied.disabled).toEqualTypeOf(); expect(denied.statusByTool.c).toMatchObject({ enabled: false, reason: 'situation', @@ -847,6 +868,7 @@ describe('defineSituations / resolveSituation', () => { expect(blocked.disabled).toEqual([ 'a', ]); + expectTypeOf(blocked.disabled).toEqualTypeOf(); expect(blocked.statusByTool.a).toEqual({ enabled: false, reason: 'situation', From f4be2a5f6d2155c33db1b6d10acd304c99cb0e53 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:32:43 -0500 Subject: [PATCH 18/31] fix(agent-tool-set): make statusByTool exhaustive for __proto__ IDs Build statusByTool with Object.create(null) instead of `{}`. Tool IDs are caller-supplied strings (serverTool only rejects the empty string), so __proto__ is a valid ID; assigning statusByTool['__proto__'] on a plain object invokes the inherited setter and reassigns the object's prototype instead of creating an own property, silently dropping that ID from the documented-exhaustive map. Mirrors the existing pattern in extractServerToolIdentity (model-result.ts) and doom-loop.ts. Adds regression tests covering __proto__, constructor, and prototype as tool IDs, verifying they remain real own properties (Object.hasOwn, Object.keys) with correct status values, alongside ordinary IDs, and that enabled/disabled/tools/activeTools stay sound. Co-Authored-By: Claude --- packages/agent-tool-set/src/tool-set.ts | 13 +- .../tests/unit/tool-set.test.ts | 132 ++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts index fd2d7359..87209bdf 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent-tool-set/src/tool-set.ts @@ -602,7 +602,18 @@ export class ToolSet< const activeTools: string[] = []; const enabled: string[] = []; const disabled: string[] = []; - const statusByTool: Record = {}; + // Object.create(null), not `{}`: tool IDs are caller-supplied strings + // (serverTool only rejects the empty string), so `__proto__` is a valid + // ID. Assigning `statusByTool['__proto__'] = ...` on a `{}` object would + // invoke the inherited setter and reassign the object's prototype + // instead of creating an own property, silently dropping that ID from + // the exhaustive map. Same reasoning as extractServerToolIdentity in + // packages/agent/src/lib/model-result.ts and the subset builder in + // packages/agent/src/lib/doom-loop.ts. + const statusByTool: Record = Object.create(null) as Record< + string, + ToolStatusEntry + >; for (const id of this.#index.orderedIds) { const tool = this.#index.toolById.get(id); 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 a3c790fb..3b21fc55 100644 --- a/packages/agent-tool-set/tests/unit/tool-set.test.ts +++ b/packages/agent-tool-set/tests/unit/tool-set.test.ts @@ -462,6 +462,138 @@ describe('exhaustive statusByTool snapshot', () => { 'b', ]); }); + + it('keeps prototype-sensitive IDs as real own properties of statusByTool', () => { + // __proto__, constructor, and prototype are all valid tool IDs (serverTool + // only rejects the empty string) — statusByTool must hold each as an own + // property rather than silently dropping it via the inherited setter. + const dunderProto = makeTool('__proto__'); + const ctor = makeTool('constructor'); + const proto = makeTool('prototype'); + + const ts = createToolSet({ + tools: [ + a, + dunderProto, + ctor, + proto, + b, + ] as const, + }).deactivate('constructor'); + + const { tools, activeTools, enabled, disabled, statusByTool } = ts.resolve(); + + // Every exotic ID is a real own key, discoverable via normal enumeration. + expect(Object.keys(statusByTool).sort()).toEqual([ + '__proto__', + 'a', + 'b', + 'constructor', + 'prototype', + ]); + expect(Object.hasOwn(statusByTool, '__proto__')).toBe(true); + expect(Object.hasOwn(statusByTool, 'constructor')).toBe(true); + expect(Object.hasOwn(statusByTool, 'prototype')).toBe(true); + + // The object's own prototype must be untouched (still Object.prototype-less, + // i.e. not reassigned by the `__proto__` write) and `constructor` must be + // the tool's status entry, not Object's constructor function. + expect(Object.getPrototypeOf(statusByTool)).toBe(null); + expect(statusByTool.constructor).toEqual({ + enabled: false, + reason: 'deactivate', + directive: 'deactivate', + }); + expect(statusByTool.prototype).toEqual({ + enabled: true, + reason: 'default', + }); + expect(statusByTool.__proto__).toEqual({ + enabled: true, + reason: 'default', + }); + + // Ordinary IDs resolve correctly alongside the exotic ones. + expect(statusByTool.a).toEqual({ + enabled: true, + reason: 'default', + }); + expect(statusByTool.b).toEqual({ + enabled: true, + reason: 'default', + }); + + // The rest of the exhaustive snapshot is sound too, not just statusByTool. + expect(enabled).toEqual([ + 'a', + '__proto__', + 'prototype', + 'b', + ]); + expect(disabled).toEqual([ + 'constructor', + ]); + expect(tools).toEqual([ + a, + dunderProto, + proto, + b, + ]); + expect(activeTools).toEqual([ + 'a', + '__proto__', + 'prototype', + 'b', + ]); + }); + + it('resolves __proto__/constructor/prototype IDs individually via activate/deactivate/activateWhen', () => { + const dunderProto = makeTool('__proto__'); + const ctor = makeTool('constructor'); + const proto = makeTool('prototype'); + + const ts = createToolSet({ + tools: [ + dunderProto, + ctor, + proto, + ] as const, + }) + .activate('__proto__') + .deactivate('prototype') + .activateWhen('constructor', () => false); + + const { statusByTool, enabled, disabled } = ts.resolve(); + + expect(Object.hasOwn(statusByTool, '__proto__')).toBe(true); + expect(Object.hasOwn(statusByTool, 'constructor')).toBe(true); + expect(Object.hasOwn(statusByTool, 'prototype')).toBe(true); + + expect(statusByTool.__proto__).toEqual({ + enabled: true, + reason: 'activate', + directive: 'activate', + }); + expect(statusByTool.constructor).toEqual({ + enabled: false, + reason: 'activateWhen', + directive: 'activateWhen', + predicate: true, + }); + expect(statusByTool.prototype).toEqual({ + enabled: false, + reason: 'deactivate', + directive: 'deactivate', + }); + + expect(enabled.sort()).toEqual([ + '__proto__', + ]); + expect(disabled.sort()).toEqual([ + 'constructor', + 'prototype', + ]); + }); }); describe('compile-time partition inference', () => { From 8e9555cb8dee2b392c34925166dec6ecc7f902c3 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:34:06 -0500 Subject: [PATCH 19/31] fix(agent-tool-set): sound FilterToolsByIds fallback for dynamic tool arrays FilterToolsByIds only had a tuple-recursive branch, so a non-tuple `readonly Tool[]` (e.g. a dynamically assembled or MCP tool array) always fell through to `readonly []` at the type level, even though runtime resolve()/indexTools still returned the correct active elements. This made ResolvedToolSnapshot.tools and .callModel.tools unusable for those inputs. Add a `number extends T['length']` guard (true for general arrays, false for literal tuples) that routes dynamic arrays through a new distributive per-element filter (KeepIfActive) instead of the tuple recursion. Literal tuples keep the exact head/tail recursion unchanged, preserving order and concrete per-element narrowing. Adds packages/agent-tool-set/tests/unit/filter-tools-by-ids.test-d.ts covering both the tuple case (exact filtering/order/types preserved) and the wide readonly Tool[] case (no longer collapses to readonly []). Co-Authored-By: Claude --- packages/agent-tool-set/src/types.ts | 47 +++++++--- .../tests/unit/filter-tools-by-ids.test-d.ts | 90 +++++++++++++++++++ 2 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 packages/agent-tool-set/tests/unit/filter-tools-by-ids.test-d.ts diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index 8fd4f275..cca732b4 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -65,21 +65,46 @@ export type ToolById = Extract< Tool >; -/** Keep tuple order; drop members whose id is not in Active. */ +/** + * Distributive per-element filter used for the wide (non-tuple) case below. + * A "naked" type parameter (`El`) is required for the conditional to + * distribute over the `T[number]` union; wrapping it in an indexed access + * (e.g. checking `T[number]` directly inside the conditional) would collapse + * to a single non-distributive check instead. + */ +type KeepIfActive = El extends Tool + ? ToolIdOf extends Active + ? El + : never + : never; + +/** + * Keep tuple order; drop members whose id is not in Active. + * + * A genuine fixed-length tuple (`T['length']` is a literal number) is + * filtered by exact head/tail recursion, preserving order and concrete + * per-element types. A dynamic `readonly Tool[]` (e.g. an `@openrouter/mcp` + * tool array not typed as a literal tuple) has `number extends T['length']`, + * so it falls back to a distributive per-element filter instead of + * recursing — the tuple pattern never matches a general array, and without + * this branch the recursion always bottoms out at `readonly []`. + */ export type FilterToolsByIds< T extends readonly Tool[], Active extends string, -> = T extends readonly [ - infer H extends Tool, - ...infer R extends readonly Tool[], -] - ? ToolIdOf extends Active - ? readonly [ - H, - ...FilterToolsByIds, +> = number extends T['length'] + ? readonly KeepIfActive[] + : T extends readonly [ + infer H extends Tool, + ...infer R extends readonly Tool[], ] - : FilterToolsByIds - : readonly []; + ? ToolIdOf extends Active + ? readonly [ + H, + ...FilterToolsByIds, + ] + : FilterToolsByIds + : readonly []; // ─── three-way compile-time partition ─────────────────────────────────────── diff --git a/packages/agent-tool-set/tests/unit/filter-tools-by-ids.test-d.ts b/packages/agent-tool-set/tests/unit/filter-tools-by-ids.test-d.ts new file mode 100644 index 00000000..b925461f --- /dev/null +++ b/packages/agent-tool-set/tests/unit/filter-tools-by-ids.test-d.ts @@ -0,0 +1,90 @@ +/** + * Type-level tests: `FilterToolsByIds` keeps exact tuple filtering for + * concrete tuples, and must not collapse to `readonly []` for a dynamic + * (non-tuple) `readonly Tool[]`. + */ + +import type { Tool } from '@openrouter/agent'; +import { tool } from '@openrouter/agent'; +import { expectTypeOf } from 'vitest'; +import { z } from 'zod/v4'; +import type { FilterToolsByIds } from '../../src/index.js'; + +const a = tool({ + name: 'a', + inputSchema: z.object({}), + execute: async () => ({ + a: true, + }), +}); + +const b = tool({ + name: 'b', + inputSchema: z.object({}), + execute: async () => ({ + b: true, + }), +}); + +const c = tool({ + name: 'c', + inputSchema: z.object({}), + execute: async () => ({ + c: true, + }), +}); + +type Tools = readonly [ + typeof a, + typeof b, + typeof c, +]; + +// --- Concrete tuples: exact filtering, order preserved, types kept --------- + +type NarrowedAC = FilterToolsByIds; +expectTypeOf().toEqualTypeOf< + readonly [ + typeof a, + typeof c, + ] +>(); + +type NarrowedNone = FilterToolsByIds; +expectTypeOf().toEqualTypeOf(); + +type NarrowedAll = FilterToolsByIds; +expectTypeOf().toEqualTypeOf(); + +// Middle element dropped, order of survivors preserved (not sorted/reordered). +type NarrowedBOnly = FilterToolsByIds; +expectTypeOf().toEqualTypeOf< + readonly [ + typeof b, + ] +>(); + +// --- Dynamic `readonly Tool[]` must not collapse to `readonly []` ---------- +// +// A tool handle whose concrete tuple isn't known at the type level (e.g. an +// `@openrouter/mcp` tool array typed as `readonly Tool[]`) must still filter +// to a usable, non-empty array shape instead of always bottoming out at the +// tuple recursion's `readonly []` base case. `number extends T['length']` +// detects this dynamic-array case (true for general arrays, false for +// literal tuples) so filtering falls back to a distributive per-element +// check instead of head/tail recursion. +type WideFiltered = FilterToolsByIds; + +expectTypeOf().not.toEqualTypeOf(); +expectTypeOf().toExtend(); + +declare const wideEl: WideFiltered[number]; +expectTypeOf(wideEl).toExtend(); + +// A concrete tool assignable to the active-id-filtered wide array still +// type-checks (proving the wide branch doesn't degrade to `never[]`). +const wideArray: WideFiltered = [ + a, + c, +]; +void wideArray; From 2b54a3760eac4ba22d210e693343a38375243bcd Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:43:52 -0500 Subject: [PATCH 20/31] fix(agent): include runtime error payload in correlated tool.result types CorrelatedToolResultEvent now unions `{ error: string }` into the concrete-tool success branch of `result`, matching the shape ModelResult actually broadcasts under `tool.result` for parse failures, thrown/rejected executions, and tool-reported execution errors. Previously, narrowing by `toolName` let consumers safely access success-only output fields on what could be an error payload at runtime. The `_mcp` and wide `readonly Tool[]` fallback branches are left as `unknown`, which already permits the error shape. Adds a throwing typed tool fixture and type/runtime assertions proving the correlated type includes the error payload while preserving success narrowing. Addresses PR #31 review thread PRRT_kwDORynLp86XHkpG. Co-Authored-By: Claude --- packages/agent/src/lib/tool-types.ts | 27 +++- .../unit/tool-name-correlation.test-d.ts | 140 +++++++++++++++--- 2 files changed, 143 insertions(+), 24 deletions(-) diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 9071ae43..91a78051 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -1391,6 +1391,17 @@ export type CorrelatedToolPreliminaryResultEvent = Omit< * legacy source compatibility), so it's overridden back to a required * literal here via `Omit<...> & {...}` — parameterizing the base type alone * does not re-require an optional field. + * + * `result` unions in `{ error: string }` for concrete tools: at runtime, + * `ModelResult` broadcasts this exact shape under the same `tool.result` + * type and `toolName` for parse failures, thrown/rejected executions, and + * tool-reported execution errors (see `broadcastToolResult` call sites in + * `model-result.ts`). Without this, narrowing by `toolName` would let a + * consumer safely (but incorrectly) access success-only output fields on an + * error payload. The `_mcp: true` branch and the generic `readonly Tool[]` + * fallback branch are left as `unknown`, which already structurally permits + * `{ error: string }` — only the concrete-tool success branch needs the + * explicit union. */ export type CorrelatedToolResultEvent = Omit< ToolResultEvent< @@ -1404,12 +1415,16 @@ export type CorrelatedToolResultEvent = Omit< T, ] ? unknown - : T extends - | ToolWithExecute<$ZodObject<$ZodShape>, infer O> - | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, infer O> - | HITLTool<$ZodObject<$ZodShape>, infer O> - ? zodInfer - : InferToolOutput, + : + | (T extends + | ToolWithExecute<$ZodObject<$ZodShape>, infer O> + | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, infer O> + | HITLTool<$ZodObject<$ZodShape>, infer O> + ? zodInfer + : InferToolOutput) + | { + error: string; + }, T extends ToolWithGenerator<$ZodObject<$ZodShape>, infer E> ? zodInfer : never, InferToolName >, diff --git a/packages/agent/tests/unit/tool-name-correlation.test-d.ts b/packages/agent/tests/unit/tool-name-correlation.test-d.ts index 12ef88f2..1e21296e 100644 --- a/packages/agent/tests/unit/tool-name-correlation.test-d.ts +++ b/packages/agent/tests/unit/tool-name-correlation.test-d.ts @@ -85,6 +85,20 @@ const hitl = tool({ onToolCalled: async () => null, }); +// A tool whose `execute` throws, used below to prove that the correlated +// `tool.result` type accurately includes the runtime `{ error: string }` +// payload broadcast by `ModelResult` for rejected/errored executions. +const boom = tool({ + name: 'boom_tool', + inputSchema: z.object({}), + outputSchema: z.object({ + ok: z.boolean(), + }), + execute: async () => { + throw new Error('explode'); + }, +}); + // --- Literal names survive the factory -------------------------------------- expectTypeOf(weather.function.name).toEqualTypeOf<'weather'>(); expectTypeOf(progress.function.name).toEqualTypeOf<'progress_tool'>(); @@ -118,24 +132,57 @@ type Stream = CorrelatedResponseStreamEvent; type ToolStream = CorrelatedToolStreamEvent; // --- Narrowing tool.result by toolName -------------------------------------- +// +// `result` on a correlated `tool.result` event is a union of the tool's +// success output and `{ error: string }`, since `ModelResult` broadcasts the +// latter under the same `type`/`toolName` for parse failures, thrown/rejected +// executions, and tool-reported execution errors. Consumers narrow further +// with an `'error' in result` (or similar) check. declare const correlated: Events; if (correlated.type === 'tool.result' && correlated.toolName === 'weather') { - expectTypeOf(correlated.result).toEqualTypeOf<{ - tempC: number; - }>(); + expectTypeOf(correlated.result).toEqualTypeOf< + | { + tempC: number; + } + | { + error: string; + } + >(); expectTypeOf(correlated.toolName).toEqualTypeOf<'weather'>(); + if ('error' in correlated.result) { + expectTypeOf(correlated.result).toEqualTypeOf<{ + error: string; + }>(); + } else { + expectTypeOf(correlated.result).toEqualTypeOf<{ + tempC: number; + }>(); + } } if (correlated.type === 'tool.result' && correlated.toolName === 'progress_tool') { - expectTypeOf(correlated.result).toEqualTypeOf<{ - done: boolean; - }>(); + expectTypeOf(correlated.result).toEqualTypeOf< + | { + done: boolean; + } + | { + error: string; + } + >(); } if (correlated.type === 'tool.result' && correlated.toolName === 'hitl_tool') { - expectTypeOf(correlated.result).toEqualTypeOf<{ - answer: string; - }>(); + expectTypeOf(correlated.result).toEqualTypeOf< + | { + answer: string; + } + | { + error: string; + } + >(); } if (correlated.type === 'tool.preliminary_result' && correlated.toolName === 'progress_tool') { + // Preliminary (in-progress) results are never used to broadcast parse, + // execution, or rejection errors — only the final `tool.result` is — so + // this stays the plain success-event shape. expectTypeOf(correlated.result).toEqualTypeOf<{ stage: string; }>(); @@ -144,9 +191,14 @@ if (correlated.type === 'tool.preliminary_result' && correlated.toolName === 'pr // Stream method view uses the same correlated union for tool events declare const streamEvent: Stream; if (streamEvent.type === 'tool.result' && streamEvent.toolName === 'weather') { - expectTypeOf(streamEvent.result).toEqualTypeOf<{ - tempC: number; - }>(); + expectTypeOf(streamEvent.result).toEqualTypeOf< + | { + tempC: number; + } + | { + error: string; + } + >(); } // Legacy getToolStream preliminary events carry toolName + correlated result @@ -159,9 +211,56 @@ if (toolStreamEvent.type === 'preliminary_result' && toolStreamEvent.toolName == // Per-tool correlated result helper expectTypeOf['toolName']>().toEqualTypeOf<'weather'>(); -expectTypeOf['result']>().toEqualTypeOf<{ - tempC: number; -}>(); +expectTypeOf['result']>().toEqualTypeOf< + | { + tempC: number; + } + | { + error: string; + } +>(); + +// --- Error payloads are included for a throwing typed tool ------------------- +// +// `boom`'s `execute` always throws. At runtime `ModelResult` broadcasts +// `{ error: string }` under `tool.result` / `toolName: 'boom_tool'` for this +// case (see the `tool-name-events.test.ts` runtime coverage). The correlated +// type must accept that shape without widening away the success narrowing. +expectTypeOf['result']>().toEqualTypeOf< + | { + ok: boolean; + } + | { + error: string; + } +>(); + +declare const boomResult: CorrelatedToolResultEvent; +if ('error' in boomResult.result) { + expectTypeOf(boomResult.result).toEqualTypeOf<{ + error: string; + }>(); +} else { + expectTypeOf(boomResult.result).toEqualTypeOf<{ + ok: boolean; + }>(); +} + +// A literal error payload assigns to the correlated result event for a +// concrete tool — this is exactly the runtime shape `broadcastToolResult` +// produces for parse failures, thrown/rejected executions, and +// tool-reported execution errors. +const boomErrorEvent: CorrelatedToolResultEvent = { + type: 'tool.result', + toolCallId: 'call_1', + toolName: 'boom_tool', + source: 'client', + result: { + error: 'explode', + }, + timestamp: Date.now(), +}; +void boomErrorEvent; // --- Generic `readonly Tool[]` must not collapse to `never` ----------------- // @@ -256,9 +355,14 @@ declare const narrowResult: Extract< } >; if (narrowResult.toolName === 'weather') { - expectTypeOf(narrowResult.result).toEqualTypeOf<{ - tempC: number; - }>(); + expectTypeOf(narrowResult.result).toEqualTypeOf< + | { + tempC: number; + } + | { + error: string; + } + >(); } // --- Source compatibility: legacy hand-constructed shapes still compile ---- // From 0f0efba263fe6e9fb3b7a3bc98de3fd02448c4d2 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:04:51 -0500 Subject: [PATCH 21/31] fix(agent-tool-set): widen ServerToolIdOf to string for erased custom-ID server tools When a custom-ID ServerTool value is widened/erased to the exported ServerToolBase interface, its id is only known as plain string at the type level. ServerToolIdOf previously synthesized `server:${config.type}` as the sole valid id in that case, which is unsound: it rejects the real runtime id and falsely accepts a default id that was never actually assigned. Widen to string instead, so the real runtime id type-checks. Concrete ServerTool values still keep their literal TId; tools with no structural id at all still fall back to the synthesized default. Adds type-level (expectTypeOf) and runtime tests reproducing the reviewed scenario in PR #31 (thread PRRT_kwDORynLp86XHkpB). Co-Authored-By: Claude --- packages/agent-tool-set/src/types.ts | 18 +++-- .../tests/unit/tool-set.test.ts | 65 ++++++++++++++++++- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index cca732b4..2b46023c 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -21,18 +21,22 @@ export type ClientToolName = T extends { * Server-tool stable ID. * Prefixed so it can never collide with a client function name. * Prefers an explicit `id` on the tool; falls back to `server:${config.type}`. + * + * When `T`'s `id` has been widened to plain `string` (e.g. a custom-ID + * `ServerTool` value erased to the exported `ServerToolBase` + * interface), the concrete literal is no longer visible at the type level. + * Synthesizing `` `server:${config.type}` `` in that case would be unsound: + * the runtime `id` could be anything, and the synthesized literal would + * both reject the real id and falsely claim a default that may not hold. + * Widening to `string` here is the sound choice — it accepts any runtime id. + * Only tools with no structural `id` at all fall back to the synthesized + * default, and concrete `ServerTool` values keep their literal `TId`. */ export type ServerToolIdOf = T extends { readonly id: infer Id extends string; } ? string extends Id - ? T extends { - readonly config: { - type: infer K extends string; - }; - } - ? `server:${K}` - : never + ? string : Id : T extends { readonly config: { 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 3b21fc55..e60dd771 100644 --- a/packages/agent-tool-set/tests/unit/tool-set.test.ts +++ b/packages/agent-tool-set/tests/unit/tool-set.test.ts @@ -1,4 +1,8 @@ -import type { ConversationState, CorrelatedToolEventUnion } from '@openrouter/agent'; +import type { + ConversationState, + CorrelatedToolEventUnion, + ServerToolBase, +} from '@openrouter/agent'; import { serverTool, tool } from '@openrouter/agent'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import { z } from 'zod/v4'; @@ -815,6 +819,65 @@ describe('server tools', () => { }); expect(() => ts.activate('web_search_2025_08_26' as 'a')).toThrow(/Unknown tool/); }); + + describe('custom-ID server tool erased to ServerToolBase', () => { + // Reproduces the reviewed scenario: a custom-ID server tool value whose + // static type has been widened to the exported `ServerToolBase` + // interface (e.g. crossing a module boundary, or via a variable + // annotation). The literal id is no longer visible at the type level, so + // `ServerToolIdOf` must widen to `string` rather than falsely claiming + // the synthesized default `server:${config.type}` is the only valid id. + const erased: ServerToolBase = serverTool( + { + type: 'web_search_2025_08_26', + }, + { + id: 'server:public_search', + }, + ); + + it('accepts the real runtime ID for .activate/.deactivate (type-level)', () => { + const ts = createToolSet({ + tools: [ + a, + erased, + ] as const, + }); + // Sound: widens to `string` instead of falsely narrowing to just the + // synthesized default (`'a' | 'server:web_search_2025_08_26'`). + expectTypeOf>().toEqualTypeOf<'a' | string>(); + // And the real runtime id type-checks as an argument to .deactivate(...). + ts.deactivate('server:public_search'); + }); + + it('accepts the real runtime ID for .activate/.deactivate (runtime)', () => { + const ts = createToolSet({ + tools: [ + a, + erased, + ] as const, + }).deactivate('server:public_search'); + const { enabled, disabled } = ts.resolve(); + expect(enabled).toEqual([ + 'a', + ]); + expect(disabled).toEqual([ + 'server:public_search', + ]); + }); + + it('throws Unknown tool for the synthesized default id, which was never the real id', () => { + const ts = createToolSet({ + tools: [ + a, + erased, + ] as const, + }); + expect(() => ts.deactivate('server:web_search_2025_08_26' as 'server:public_search')).toThrow( + /Unknown tool/, + ); + }); + }); }); describe('defineSituations / resolveSituation', () => { From 9d17e04237926ee24c6e841efa904b9f197c09d4 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:52:26 -0500 Subject: [PATCH 22/31] fix(agent-tool-set): sound partition/situation types for mutable ToolSet aliasing Mutable ToolSet instances now carry a single, deliberately widened partition/situation type (WidenedPartition/WidenedSituationMap) from construction onward, and every mutator on a mutable instance returns that same unrefined type instead of a freshly refined one. This closes the gap where two aliases of one mutable object could statically claim contradictory exact partitions after only one of them mutated. - Add TMutable type param and Mutated<...> helper on ToolSet; used by activate/deactivate/activateWhen/deactivateWhen/defineSituations and the internal #withPartitionMutation. - ToolSet.create/createToolSet({ mutable: true }) now produce WidenedPartition/WidenedSituationMap instead of the exact InitialPartition/EmptySituations used by the immutable path. - clone({ mutable: true }) widens on flip-to-mutable; clone()/ clone({ mutable: false }) keep preserving the exact source type. - Fix three pre-existing TS2394/TS2375 overload-compatibility errors under exactOptionalPropertyTypes in clone, activateWhen, and deactivateWhen. - Add compile-time and runtime aliasing-soundness tests, plus a regression test confirming the immutable path's exact narrowing is unchanged. Co-Authored-By: Claude --- packages/agent-tool-set/src/index.ts | 2 + packages/agent-tool-set/src/tool-set.ts | 220 +++++++++++++++--- packages/agent-tool-set/src/types.ts | 36 +++ .../tests/unit/tool-set.test.ts | 180 ++++++++++++++ 4 files changed, 403 insertions(+), 35 deletions(-) diff --git a/packages/agent-tool-set/src/index.ts b/packages/agent-tool-set/src/index.ts index 28601207..48c64e65 100644 --- a/packages/agent-tool-set/src/index.ts +++ b/packages/agent-tool-set/src/index.ts @@ -34,4 +34,6 @@ export type { ToolIdsOfTuple, ToolSetLike, ToolStatusEntry, + WidenedPartition, + WidenedSituationMap, } from './types.js'; diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts index 87209bdf..ca5bb9a0 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent-tool-set/src/tool-set.ts @@ -24,6 +24,8 @@ import type { ToolIdOf, ToolIdsOfTuple, ToolStatusEntry, + WidenedPartition, + WidenedSituationMap, } from './types.js'; type ActivationEntry> = @@ -147,6 +149,37 @@ function normalizeConditionalRule>( }; } +/** + * Selects the return type of a mutator call. + * + * A mutable `ToolSet` mutates one shared runtime object in place, and any + * number of aliases can reference that object. If a mutator refined `P`/`Sit` + * on a mutable instance the way the immutable path does, two aliases of the + * *same* live object could statically claim different, contradictory exact + * types the instant either one mutated — unsound, since both aliases still + * point at the one object whose actual state matches only the latest call. + * + * When `TMutable extends true`, mutators therefore return the receiver's own + * unchanged type (`ToolSet`) instead of a + * refined `NextP`/`NextSit` — every alias of a mutable instance keeps the + * exact same (already maximally conservative) static type for its whole + * lifetime, so no alias can ever contradict another. Immutable instances + * (`TMutable extends false`) are unaffected: each mutation still returns a + * brand-new object with the precisely refined `NextP`/`NextSit`, exactly as + * before. + */ +type Mutated< + TTools extends readonly Tool[], + TShared extends Record, + P extends Partition, + Sit extends SituationMap, + TMutable extends boolean, + NextP extends Partition, + NextSit extends SituationMap = Sit, +> = TMutable extends true + ? ToolSet + : ToolSet; + /** * Immutable-by-default stateful set of tools with a three-way static * partition (enabled / disabled / conditional) and optional named situations. @@ -155,12 +188,17 @@ function normalizeConditionalRule>( * @typeParam TShared - Shared context shape for predicates * @typeParam P - Compile-time partition of tool-set IDs * @typeParam Sit - Named situation registry + * @typeParam TMutable - Whether this instance mutates in place. Mutable + * instances deliberately carry a single widened `P`/`Sit` for their entire + * lifetime (see {@link Mutated}) so that every alias stays sound; immutable + * instances keep the exact, precisely-refined `P`/`Sit` per instance. */ export class ToolSet< TTools extends readonly Tool[] = readonly Tool[], TShared extends Record = Record, P extends Partition = InitialPartition, Sit extends SituationMap = EmptySituations, + TMutable extends boolean = false, > { readonly #index: IndexedTools; readonly #activation: Map>; @@ -191,12 +229,40 @@ export class ToolSet< static create< T extends readonly Tool[], S extends Record = Record, - >(opts: { tools: T; mutable?: boolean }): ToolSet, EmptySituations> { - return new ToolSet, EmptySituations>( + >(opts: { + tools: T; + mutable: true; + }): ToolSet, WidenedSituationMap, true>; + static create< + T extends readonly Tool[], + S extends Record = Record, + >(opts: { + tools: T; + mutable?: false; + }): ToolSet, EmptySituations, false>; + static create< + T extends readonly Tool[], + S extends Record = Record, + >(opts: { + tools: T; + mutable?: boolean; + }): + | ToolSet, WidenedSituationMap, true> + | ToolSet, EmptySituations, false> { + const mutable = opts.mutable ?? false; + if (mutable) { + return new ToolSet, WidenedSituationMap, true>( + indexTools(opts.tools), + new Map(), + new Map(), + true, + ); + } + return new ToolSet, EmptySituations, false>( indexTools(opts.tools), new Map(), new Map(), - opts.mutable ?? false, + false, ); } @@ -213,26 +279,29 @@ export class ToolSet< #withPartitionMutation( mutate: (activation: Map>) => void, - ): ToolSet { + ): Mutated { if (this.#mutable) { - // Mutable mode deliberately does not refine partition type params — - // successive mutations would leave stale compile-time brands. Runtime state still updates. + // Mutable mode mutates the shared runtime object in place and returns + // `this` unchanged: every alias of a mutable instance already carries + // the same widened `P`/`Sit`, so returning that same (unrefined) type + // here — instead of a freshly refined `NextP` — keeps all aliases + // statically consistent with the one object they actually reference. mutate(this.#activation); - return this as unknown as ToolSet; + return this as unknown as Mutated; } const nextActivation = cloneActivationMap(this.#activation); mutate(nextActivation); - return new ToolSet( + return new ToolSet( this.#index, nextActivation, this.#situations, false, - ); + ) as unknown as Mutated; } activate>( names: N | readonly N[], - ): ToolSet, Sit> { + ): Mutated> { const list = toIdArray(names as string | readonly string[]); for (const n of list) { this.#assertKnown(n); @@ -250,7 +319,7 @@ export class ToolSet< deactivate>( names: N | readonly N[], - ): ToolSet, Sit> { + ): Mutated> { const list = toIdArray(names as string | readonly string[]); for (const n of list) { this.#assertKnown(n); @@ -269,21 +338,25 @@ export class ToolSet< activateWhen>( name: N, predicate: ActivationPredicate, - ): ToolSet, Sit>; + ): Mutated>; activateWhen>( map: { readonly [K in N]?: ActivationPredicate; }, - ): ToolSet, Sit>; - activateWhen( - nameOrMap: unknown, + ): Mutated>; + activateWhen>( + nameOrMap: + | N + | { + readonly [K in N]?: ActivationPredicate; + }, predicate?: ActivationPredicate, - ): ToolSet { + ): Mutated> { const entries = this.#normalizePredicateArg( nameOrMap as string | Partial>>, predicate, ); - return this.#withPartitionMutation((activation) => { + return this.#withPartitionMutation>((activation) => { for (const [n, p] of entries) { activation.set(n, { kind: 'activateWhen', @@ -297,21 +370,25 @@ export class ToolSet< deactivateWhen>( name: N, predicate: ActivationPredicate, - ): ToolSet, Sit>; + ): Mutated>; deactivateWhen>( map: { readonly [K in N]?: ActivationPredicate; }, - ): ToolSet, Sit>; - deactivateWhen( - nameOrMap: unknown, + ): Mutated>; + deactivateWhen>( + nameOrMap: + | N + | { + readonly [K in N]?: ActivationPredicate; + }, predicate?: ActivationPredicate, - ): ToolSet { + ): Mutated> { const entries = this.#normalizePredicateArg( nameOrMap as string | Partial>>, predicate, ); - return this.#withPartitionMutation((activation) => { + return this.#withPartitionMutation>((activation) => { for (const [n, p] of entries) { activation.set(n, { kind: 'deactivateWhen', @@ -375,7 +452,7 @@ export class ToolSet< const M extends { readonly [K in string]: SituationConfig, TShared>; }, - >(situations: M): ToolSet> { + >(situations: M): Mutated> { const next = new Map>(); for (const [name, config] of Object.entries(situations) as Array< @@ -434,19 +511,22 @@ export class ToolSet< } if (this.#mutable) { + // Same rationale as #withPartitionMutation: `this` keeps its existing + // (already widened) static type instead of claiming a freshly refined + // `InferSituationMap`, so every alias stays statically consistent. this.#situations.clear(); for (const [k, v] of next) { this.#situations.set(k, v); } - return this as unknown as ToolSet>; + return this as unknown as Mutated>; } - return new ToolSet>( + return new ToolSet, false>( this.#index, cloneActivationMap(this.#activation), next, false, - ); + ) as unknown as Mutated>; } /** @@ -715,28 +795,98 @@ export class ToolSet< }; } - clone(opts?: { mutable?: boolean }): ToolSet { - return new ToolSet( + /** + * Copy state into a fresh, independent instance. + * + * Flipping to `mutable: true` starts a *new* mutation lifetime, so — like + * `ToolSet.create({ mutable: true })` — the clone's partition/situations + * widen to {@link WidenedPartition}/{@link WidenedSituationMap} rather than + * inheriting the source's exact `P`/`Sit`: an exact type could otherwise be + * invalidated the moment the clone is mutated, while other clones or the + * original remain unaffected. Cloning without flipping mode (mode + * inherited, including an already-mutable source) or flipping to `false` + * keeps the source's `P`/`Sit` unchanged. + */ + clone(opts?: { mutable?: undefined }): ToolSet; + clone(opts: { + mutable: true; + }): ToolSet, WidenedSituationMap, true>; + clone(opts: { mutable: false }): ToolSet; + clone( + opts?: + | { + mutable?: undefined; + } + | { + mutable: true; + } + | { + mutable: false; + }, + ): + | ToolSet + | ToolSet, WidenedSituationMap, true> + | ToolSet { + const mutable = opts?.mutable ?? this.#mutable; + if (opts?.mutable === true && !this.#mutable) { + return new ToolSet, WidenedSituationMap, true>( + this.#index, + cloneActivationMap(this.#activation), + cloneSituationsMap(this.#situations), + true, + ); + } + return new ToolSet( this.#index, cloneActivationMap(this.#activation), cloneSituationsMap(this.#situations), - opts?.mutable ?? this.#mutable, - ); + mutable, + ) as ToolSet; } } +/** + * Construct a {@link ToolSet}. + * + * `mutable: true` deliberately returns a widened partition/situations type + * (`WidenedPartition`/`WidenedSituationMap`) rather than the exact + * `InitialPartition`/`EmptySituations` used by the default immutable mode — + * see {@link Mutated} for why mutable instances need this from construction + * onward. Omitting `mutable` (or passing `false`) keeps today's exact, + * precisely-refined immutable partition tracking unchanged. + */ +export function createToolSet< + const T extends readonly Tool[], + TShared extends Record = Record, +>(opts: { + tools: T; + mutable: true; +}): ToolSet, WidenedSituationMap, true>; +export function createToolSet< + const T extends readonly Tool[], + TShared extends Record = Record, +>(opts: { + tools: T; + mutable?: false; +}): ToolSet, EmptySituations, false>; export function createToolSet< const T extends readonly Tool[], TShared extends Record = Record, >(opts: { tools: T; mutable?: boolean; -}): ToolSet, EmptySituations> { +}): + | ToolSet, WidenedSituationMap, true> + | ToolSet, EmptySituations, false> { + if (opts.mutable) { + return ToolSet.create({ + tools: opts.tools, + mutable: true, + }); + } return ToolSet.create({ tools: opts.tools, - ...(opts.mutable !== undefined && { - mutable: opts.mutable, - }), + mutable: false, }); } diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index 2b46023c..3cef4c5f 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -142,6 +142,31 @@ export type InitialPartition = { conditional: never; }; +/** + * Deliberately imprecise partition for mutable `ToolSet` instances. + * + * A mutable `ToolSet` mutates one shared runtime object in place, and any + * number of aliases can reference that same object. If mutators refined the + * partition type the way the immutable path does, two aliases of the same + * live object could statically claim different, contradictory exact + * partitions the instant one of them mutated — an unsound state (e.g. one + * alias's type promising `disabled: never` while the object it points to + * has, in fact, just been deactivated through another alias). + * + * Mutable instances therefore use this single widened partition, + * unconditionally and unchanging, for their entire lifetime: nothing is + * ever statically guaranteed enabled or disabled, and every id is treated + * as `conditional` (only knowable by calling `resolve()`). Every alias of a + * mutable instance carries the exact same (already maximally conservative) + * type, so no alias can ever make a compile-time claim the runtime object + * could contradict. + */ +export type WidenedPartition = { + enabled: never; + disabled: never; + conditional: ToolIdsOfTuple; +}; + export type ActivatePartition

= { enabled: P['enabled'] | Name; disabled: Exclude; @@ -215,6 +240,17 @@ export type SituationMap = Record< export type EmptySituations = Record; +/** + * Deliberately imprecise situation registry for mutable `ToolSet` instances. + * + * Mirrors {@link WidenedPartition}: since `defineSituations` on a mutable + * instance mutates the shared runtime situation registry in place, its type + * cannot statically promise a specific set of situation names without + * risking the same cross-alias contradiction. `string` keeps every + * situation name assignable while still requiring a real string key. + */ +export type WidenedSituationMap = Record; + export type SituationNames = keyof S & string; /** 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 e60dd771..b7d69ea5 100644 --- a/packages/agent-tool-set/tests/unit/tool-set.test.ts +++ b/packages/agent-tool-set/tests/unit/tool-set.test.ts @@ -12,6 +12,9 @@ import type { InferDisabledIds, InferEnabledIds, InferToolSet, + ToolSet, + WidenedPartition, + WidenedSituationMap, } from '../../src/index.js'; import { createToolSet } from '../../src/index.js'; @@ -362,6 +365,183 @@ describe('clone', () => { const after = clone.deactivate('a'); expect(after).toBe(clone); }); + + it('widens the partition/situation types when cloning to mutable', () => { + const immutable = createToolSet({ + tools: [ + a, + b, + ] as const, + }).deactivate('a'); + expectTypeOf>().toEqualTypeOf<'b'>(); + expectTypeOf>().toEqualTypeOf<'a'>(); + + const mutableCopy = immutable.clone({ + mutable: true, + }); + expectTypeOf(mutableCopy).toEqualTypeOf< + ToolSet< + readonly [ + typeof a, + typeof b, + ], + Record, + WidenedPartition< + readonly [ + typeof a, + typeof b, + ] + >, + WidenedSituationMap, + true + > + >(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'a' | 'b'>(); + }); + + it('preserves the exact source partition type on clone() and clone({mutable: false})', () => { + const immutable = createToolSet({ + tools: [ + a, + b, + ] as const, + }).deactivate('a'); + + const defaultClone = immutable.clone(); + expectTypeOf(defaultClone).toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'b'>(); + expectTypeOf>().toEqualTypeOf<'a'>(); + + const explicitImmutableClone = immutable.clone({ + mutable: false, + }); + expectTypeOf(explicitImmutableClone).toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'b'>(); + expectTypeOf>().toEqualTypeOf<'a'>(); + }); +}); + +describe('mutable aliasing soundness', () => { + it('gives createToolSet({mutable: true}) the widened partition/situation types', () => { + const mutable = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + mutable: true, + }); + expectTypeOf(mutable).toEqualTypeOf< + ToolSet< + readonly [ + typeof a, + typeof b, + typeof c, + ], + Record, + WidenedPartition< + readonly [ + typeof a, + typeof b, + typeof c, + ] + >, + WidenedSituationMap, + true + > + >(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'a' | 'b' | 'c'>(); + }); + + it('keeps every alias of a mutable instance at the same static type after divergent mutations', () => { + const base = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + mutable: true, + }); + // Two aliases of the very same underlying object. + const aliasOne = base; + const aliasTwo = base; + + // Mutating through one alias must not statically diverge either alias's + // type from the other — both must remain the identical widened type, + // since they point at the same live object. + const afterActivate = aliasOne.activate('a'); + const afterDeactivate = aliasTwo.deactivate('b'); + + expectTypeOf(afterActivate).toEqualTypeOf(); + expectTypeOf(afterDeactivate).toEqualTypeOf(); + expectTypeOf(afterActivate).toEqualTypeOf(); + + // activateWhen/deactivateWhen must also leave the widened type unchanged. + const afterActivateWhen = afterActivate.activateWhen('c', () => true); + const afterDeactivateWhen = afterDeactivate.deactivateWhen('c', () => false); + expectTypeOf(afterActivateWhen).toEqualTypeOf(); + expectTypeOf(afterDeactivateWhen).toEqualTypeOf(); + + // Runtime: since it's the same mutable object, both aliases observe + // every mutation — including ones made "through" the other alias. + expect(afterActivate).toBe(base); + expect(afterDeactivate).toBe(base); + expect(afterActivateWhen).toBe(base); + expect(afterDeactivateWhen).toBe(base); + }); + + it('does not let a mutable alias make a contradictory exact static claim', () => { + const mutable = createToolSet({ + tools: [ + a, + b, + ] as const, + mutable: true, + }); + const alias = mutable; + + // If mutators refined the partition type the way the immutable path + // does, `alias` could statically claim 'a' | 'b' enabled while the + // shared object it points to had, in fact, just been deactivated + // through `mutable`. Confirm both stay conditional-only instead. + mutable.deactivate('a'); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf<'a' | 'b'>(); + + // Runtime state is, as ever, precisely observable via resolve(). + expect(alias.resolve().activeTools).toEqual([ + 'b', + ]); + }); + + it('keeps the immutable chain exactly narrowed (no regression from the mutable-aliasing fix)', () => { + const base = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }); + expectTypeOf>().toEqualTypeOf<'a' | 'b' | 'c'>(); + + const afterDeactivate = base.deactivate('b'); + expectTypeOf>().toEqualTypeOf<'a' | 'c'>(); + expectTypeOf>().toEqualTypeOf<'b'>(); + + const afterActivateWhen = afterDeactivate.activateWhen('a', () => true); + expectTypeOf>().toEqualTypeOf<'c'>(); + expectTypeOf>().toEqualTypeOf<'b'>(); + expectTypeOf>().toEqualTypeOf<'a'>(); + + // Each immutable step is a genuinely distinct, more-refined instance. + expect(afterDeactivate).not.toBe(base); + expect(afterActivateWhen).not.toBe(afterDeactivate); + }); }); describe('resolve / inferTools input shapes', () => { From c275cc5e6972486f149592fe0d861923859391fb Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:30:19 -0500 Subject: [PATCH 23/31] fix(agent): preserve async events in correlated streams Resolve the main-branch merge by retaining async started/settled events in both wide and correlated response-stream unions, including the concrete correlated result type. Co-Authored-By: Claude --- packages/agent/src/lib/tool-types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 91a78051..0c52c2fd 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -1575,6 +1575,8 @@ export type ResponseStreamEvent< | ToolPreliminaryResultEvent | ToolResultEvent | ToolCallOutputEvent + | ToolAsyncStartedEvent + | ToolAsyncSettledEvent | TurnStartEvent | TurnEndEvent; @@ -1589,7 +1591,7 @@ export type CorrelatedResponseStreamEvent = | CorrelatedToolEventUnion | ToolCallOutputEvent | ToolAsyncStartedEvent - | ToolAsyncSettledEvent + | ToolAsyncSettledEvent> | TurnStartEvent | TurnEndEvent; From b45e982b402f7ef29aba1ed66e524e0fef24afa6 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:05:14 -0500 Subject: [PATCH 24/31] fix(agent): align active tool request handling --- packages/agent/src/inner-loop/call-model.ts | 2 +- packages/agent/src/lib/model-result.ts | 31 +++++++++++++------ .../unit/call-model-active-tools.test.ts | 25 +++++++++++++++ .../unit/hooks-session-lifecycle.test.ts | 6 +++- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/packages/agent/src/inner-loop/call-model.ts b/packages/agent/src/inner-loop/call-model.ts index 858ec0f5..2dda8bcd 100644 --- a/packages/agent/src/inner-loop/call-model.ts +++ b/packages/agent/src/inner-loop/call-model.ts @@ -145,7 +145,7 @@ export function callModel< // context cost stays constant regardless of the tool count. Appended // here (not per-request in ModelResult) so `resolvedRequest.tools` stays // stable across turns. Calls to it are engine-intercepted. - if (apiTools && tools && asyncTools?.checkins !== false && needsTaskTool(tools)) { + if (apiTools && filteredTools && asyncTools?.checkins !== false && needsTaskTool(filteredTools)) { apiTools.push(buildTaskToolApiDefinition(convertZodToJsonSchema)); } diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 3253afc8..7981a2a3 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -3093,8 +3093,8 @@ export class ModelResult< if (task.status === 'completed') { this.broadcastToolResult( task.callId, - task.name, - this.toolSourceByName(task.name), + String(task.name), + this.toolSourceByName(String(task.name)), task.result as InferToolOutputsUnion, ); } @@ -3368,9 +3368,14 @@ export class ModelResult< } if (executed.type === 'parse_error') { - this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', { - error: executed.errorMessage, - } as InferToolOutputsUnion); + this.broadcastToolResult( + toolCall.id, + String(toolCall.name), + isMcpTool(tool) ? 'mcp' : 'client', + { + error: executed.errorMessage, + } as InferToolOutputsUnion, + ); return executed; } if (executed.type === 'hook_blocked') { @@ -3439,9 +3444,14 @@ export class ModelResult< preliminaryResultsForCall: InferToolEventsUnion[]; } { const message = `Tool "${toolCall.name}" timed out after ${timeoutMs}ms`; - this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', { - error: message, - } as InferToolOutputsUnion); + this.broadcastToolResult( + toolCall.id, + String(toolCall.name), + isMcpTool(tool) ? 'mcp' : 'client', + { + error: message, + } as InferToolOutputsUnion, + ); return { type: 'execution' as const, toolCall, @@ -3573,8 +3583,8 @@ export class ModelResult< // `runToolWithHooks` is the single point of emission for PostToolUseFailure. this.broadcastToolResult( originalToolCall.id, - originalToolCall.name, - this.toolSourceByName(originalToolCall.name), + String(originalToolCall.name), + this.toolSourceByName(String(originalToolCall.name)), { error: errorMessage, } as InferToolOutputsUnion, @@ -4923,6 +4933,7 @@ export class ModelResult< toolTimeoutMs: _ttm, toolConcurrency: _tc, asyncTools: _at, + activeTools: _activeTools, ...rest } = this.options.request; // Defense-in-depth: also drop `@openrouter/agent-tool-set` snapshot diff --git a/packages/agent/tests/unit/call-model-active-tools.test.ts b/packages/agent/tests/unit/call-model-active-tools.test.ts index 90c3f6b3..91fb196d 100644 --- a/packages/agent/tests/unit/call-model-active-tools.test.ts +++ b/packages/agent/tests/unit/call-model-active-tools.test.ts @@ -184,6 +184,31 @@ describe('callModel activeTools filter', () => { ]); }); + it('does not advertise the task helper when its background tool is filtered out', async () => { + const backgroundTool = tool({ + name: 'background', + lifecycle: 'background', + inputSchema: z.object({}), + execute: async () => ({ + ok: true, + }), + }); + + const names = await captureOutboundTools({ + tools: [ + backgroundTool, + toolA, + ], + activeTools: [ + 'a', + ], + }); + + expect(names).toEqual([ + 'a', + ]); + }); + it('omits the tools key entirely (not an empty array) when activeTools filters out every tool', async () => { const captured: { names: string[] | null; diff --git a/packages/agent/tests/unit/hooks-session-lifecycle.test.ts b/packages/agent/tests/unit/hooks-session-lifecycle.test.ts index a9c7ea75..333aa528 100644 --- a/packages/agent/tests/unit/hooks-session-lifecycle.test.ts +++ b/packages/agent/tests/unit/hooks-session-lifecycle.test.ts @@ -587,7 +587,7 @@ describe('session lifecycle end-to-end', () => { ); }); - it('strips hooks from the outgoing API request on both request-resolution paths', async () => { + it('strips hooks and activeTools from the outgoing API request on both request-resolution paths', async () => { // `hooks` is a client-only field. callModel destructures it before the // request reaches ModelResult, but ModelResult is publicly exported and // its constructor accepts a request that may still carry `hooks` (e.g. @@ -609,6 +609,9 @@ describe('session lifecycle end-to-end', () => { model: 'test-model', input: 'hi', hooks, + activeTools: [ + 'echo', + ], }, hooks, } as unknown as ConstructorParameters[0]); @@ -635,6 +638,7 @@ describe('session lifecycle end-to-end', () => { } ).responsesRequest; expect(sentRequest).not.toHaveProperty('hooks'); + expect(sentRequest).not.toHaveProperty('activeTools'); } }); }); From 6a50aa13a7462c76711e234f549ea0550bcdb0a3 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:37:15 -0500 Subject: [PATCH 25/31] fix(agent): preserve unified tool names --- packages/agent-tool-set/src/types.ts | 10 ++++--- .../tests/unit/server-tool-id.test-d.ts | 24 ++++++++++++++++ packages/agent/src/lib/agent-tool.ts | 5 ++-- packages/agent/src/lib/tool-types.ts | 5 ++-- packages/agent/src/lib/tool.ts | 9 +++--- .../unit/tool-name-correlation.test-d.ts | 28 ++++++++++++++++++- 6 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 packages/agent-tool-set/tests/unit/server-tool-id.test-d.ts diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index 3cef4c5f..afb671f8 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -33,11 +33,13 @@ export type ClientToolName = T extends { * default, and concrete `ServerTool` values keep their literal `TId`. */ export type ServerToolIdOf = T extends { - readonly id: infer Id extends string; + readonly id?: infer Id; } - ? string extends Id - ? string - : Id + ? Extract extends infer StringId extends string + ? string extends StringId + ? string + : StringId + : never : T extends { readonly config: { type: infer K extends string; diff --git a/packages/agent-tool-set/tests/unit/server-tool-id.test-d.ts b/packages/agent-tool-set/tests/unit/server-tool-id.test-d.ts new file mode 100644 index 00000000..0ec1b928 --- /dev/null +++ b/packages/agent-tool-set/tests/unit/server-tool-id.test-d.ts @@ -0,0 +1,24 @@ +import type { ServerToolBase } from '@openrouter/agent'; +import { serverTool } from '@openrouter/agent'; +import { expectTypeOf } from 'vitest'; +import { createToolSet } from '../../src/tool-set.js'; +import type { InferAllIds, ServerToolIdOf } from '../../src/types.js'; + +const precise = serverTool( + { + type: 'web_search_2025_08_26', + }, + { + id: 'server:public_search', + }, +); +expectTypeOf>().toEqualTypeOf<'server:public_search'>(); + +const generalized: ServerToolBase = precise; +const set = createToolSet({ + tools: [ + generalized, + ] as const, +}); +set.deactivate('any-runtime-server-tool-id'); +expectTypeOf>().toEqualTypeOf(); diff --git a/packages/agent/src/lib/agent-tool.ts b/packages/agent/src/lib/agent-tool.ts index bf3a551a..558c76d7 100644 --- a/packages/agent/src/lib/agent-tool.ts +++ b/packages/agent/src/lib/agent-tool.ts @@ -203,7 +203,7 @@ export function agentToolBuilder< TName extends string = string, >( config: AgentToolConfig, -): UnifiedTool, Record, TCtx> { +): UnifiedTool, Record, TCtx, TName> { // Same reserved-name guards as tool() — a subagent named 'shared' would // collide with the shared-context store key, one named 'task' would // disable the built-in task-interaction tool. @@ -358,7 +358,8 @@ export function agentToolBuilder< TOutput, $ZodType, Record, - TCtx + TCtx, + TName >['function'], }; } diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 0c52c2fd..abd0c86d 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -683,7 +683,7 @@ export interface UnifiedToolFunction< TContext extends Record = Record, TName extends string = string, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, -> extends BaseToolFunction { +> extends BaseToolFunction { /** Discriminator against every legacy kind. */ readonly lifecycle: ToolLifecycle; /** @@ -805,9 +805,10 @@ export type UnifiedTool< TEvent extends $ZodType = $ZodType, TContext extends Record = Record, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TName extends string = string, > = { type: ToolType.Function; - function: UnifiedToolFunction; + function: UnifiedToolFunction; }; /** diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index 6099415b..c5220c51 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -410,7 +410,7 @@ export function tool< config: RunToolConfigWithOutput & { lifecycle: 'deferred'; }, -): BuiltDeferredTool; +): BuiltDeferredTool; // Overload for unified run tools with outputSchema (any lifecycle). export function tool< @@ -421,7 +421,7 @@ export function tool< TName extends string = string, >( config: RunToolConfigWithOutput, -): UnifiedTool, TCtx>; +): UnifiedTool, TCtx, TName>; // Overload for SYNC unified run tools without outputSchema (output inferred // from run's return — including a generator's TReturn). @@ -433,7 +433,7 @@ export function tool< TName extends string = string, >( config: SyncRunToolConfigWithoutOutput, -): UnifiedTool, TEvent, Record, TCtx>; +): UnifiedTool, TEvent, Record, TCtx, TName>; // Overload for generator tools (when eventSchema is provided). // TContext on the *returned* tool stays the wide default so specific tools remain @@ -847,7 +847,8 @@ export type BuiltDeferredTool< TOutput extends $ZodType, TEvent extends $ZodType = $ZodType, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, -> = UnifiedTool, TCtx> & + TName extends string = string, +> = UnifiedTool, TCtx, TName> & DeferredToolMethods>; /** Copy shared config fields onto a function object when present. */ diff --git a/packages/agent/tests/unit/tool-name-correlation.test-d.ts b/packages/agent/tests/unit/tool-name-correlation.test-d.ts index 1e21296e..c8559e1d 100644 --- a/packages/agent/tests/unit/tool-name-correlation.test-d.ts +++ b/packages/agent/tests/unit/tool-name-correlation.test-d.ts @@ -58,6 +58,18 @@ const progress = tool({ }, }); +const unified = tool({ + name: 'unified_tool', + lifecycle: 'background', + inputSchema: z.object({}), + outputSchema: z.object({ + taskId: z.string(), + }), + run: async () => ({ + taskId: 'task_1', + }), +}); + const manual = tool({ name: 'manual_tool', inputSchema: z.object({ @@ -102,12 +114,14 @@ const boom = tool({ // --- Literal names survive the factory -------------------------------------- expectTypeOf(weather.function.name).toEqualTypeOf<'weather'>(); expectTypeOf(progress.function.name).toEqualTypeOf<'progress_tool'>(); +expectTypeOf(unified.function.name).toEqualTypeOf<'unified_tool'>(); expectTypeOf(manual.function.name).toEqualTypeOf<'manual_tool'>(); expectTypeOf(shared.function.name).toEqualTypeOf<'shared_tool'>(); expectTypeOf(hitl.function.name).toEqualTypeOf<'hitl_tool'>(); expectTypeOf>().toEqualTypeOf<'weather'>(); expectTypeOf>().toEqualTypeOf<'progress_tool'>(); +expectTypeOf>().toEqualTypeOf<'unified_tool'>(); expectTypeOf>().toEqualTypeOf<'manual_tool'>(); expectTypeOf>().toEqualTypeOf<'shared_tool'>(); expectTypeOf>().toEqualTypeOf<'hitl_tool'>(); @@ -115,6 +129,7 @@ expectTypeOf>().toEqualTypeOf<'hitl_tool'>(); // Wide defaults still assign to Tool expectTypeOf(weather).toExtend(); expectTypeOf(progress).toExtend(); +expectTypeOf(unified).toExtend(); expectTypeOf(manual).toExtend(); expectTypeOf(shared).toExtend(); expectTypeOf(hitl).toExtend(); @@ -123,6 +138,7 @@ expectTypeOf().toExtend(); type Tools = readonly [ typeof weather, typeof progress, + typeof unified, typeof manual, typeof hitl, ]; @@ -169,6 +185,16 @@ if (correlated.type === 'tool.result' && correlated.toolName === 'progress_tool' } >(); } +if (correlated.type === 'tool.result' && correlated.toolName === 'unified_tool') { + expectTypeOf(correlated.result).toEqualTypeOf< + | { + taskId: string; + } + | { + error: string; + } + >(); +} if (correlated.type === 'tool.result' && correlated.toolName === 'hitl_tool') { expectTypeOf(correlated.result).toEqualTypeOf< | { @@ -480,7 +506,7 @@ void _resultMissingName; // Correlated tuple-typed unions still discriminate on a required literal `toolName`. expectTypeOf['toolName']>().toEqualTypeOf< - 'weather' | 'progress_tool' | 'manual_tool' | 'hitl_tool' + 'weather' | 'progress_tool' | 'unified_tool' | 'manual_tool' | 'hitl_tool' >(); expectTypeOf().not.toEqualTypeOf(); expectTypeOf().not.toEqualTypeOf(); From b310bf6ea3c639175d5b0b0bc80d507d5cebc720 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:00:48 -0500 Subject: [PATCH 26/31] fix(agent): cover built-in task events and situation rules --- packages/agent-tool-set/src/tool-set.ts | 37 ++++++++--- .../tests/unit/tool-set.test.ts | 62 +++++++++++++++++++ packages/agent/src/index.ts | 1 + packages/agent/src/lib/tool-types.ts | 26 +++++--- .../tests/unit/task-tool-integration.test.ts | 24 ++++--- .../unit/tool-name-correlation.test-d.ts | 48 +++++++++++++- 6 files changed, 171 insertions(+), 27 deletions(-) diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts index ca5bb9a0..d368b90d 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent-tool-set/src/tool-set.ts @@ -132,7 +132,9 @@ function cloneSituationsMap>( } function normalizeConditionalRule>( - rule: SituationConditionalRule, + situation: string, + toolId: string, + rule: unknown, ): { mode: 'activateWhen' | 'deactivateWhen'; predicate: ActivationPredicate; @@ -140,12 +142,27 @@ function normalizeConditionalRule>( if (typeof rule === 'function') { return { mode: 'activateWhen', - predicate: rule, + predicate: rule as ActivationPredicate, }; } + if ( + typeof rule !== 'object' || + rule === null || + !('predicate' in rule) || + typeof rule.predicate !== 'function' || + ('mode' in rule && + rule.mode !== undefined && + rule.mode !== 'activateWhen' && + rule.mode !== 'deactivateWhen') + ) { + throw new Error( + `Situation "${situation}": conditional rule for tool "${toolId}" must be a function or { mode, predicate } object`, + ); + } + const mode = 'mode' in rule ? rule.mode : undefined; return { - mode: rule.mode ?? 'activateWhen', - predicate: rule.predicate, + mode: (mode ?? 'activateWhen') as 'activateWhen' | 'deactivateWhen', + predicate: rule.predicate as ActivationPredicate, }; } @@ -463,12 +480,14 @@ export class ToolSet< >) { const enabled = config.enabled ?? []; const disabled = config.disabled ?? []; - const conditionalEntries = Object.entries(config.conditional ?? {}) as Array< - [ + const conditionalEntries = Object.entries(config.conditional ?? {}).filter( + ( + entry, + ): entry is [ string, SituationConditionalRule, - ] - >; + ] => entry[1] !== undefined, + ); const seen = new Set(); const record = (id: string, bucket: string): void => { @@ -500,7 +519,7 @@ export class ToolSet< ...disabled, ], conditional: conditionalEntries.map(([id, rule]) => { - const normalized = normalizeConditionalRule(rule); + const normalized = normalizeConditionalRule(name, id, rule); return { id, mode: normalized.mode, 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 b7d69ea5..826f25a3 100644 --- a/packages/agent-tool-set/tests/unit/tool-set.test.ts +++ b/packages/agent-tool-set/tests/unit/tool-set.test.ts @@ -1298,6 +1298,68 @@ describe('defineSituations / resolveSituation', () => { ).toThrow(/lists tool "a" more than once/); }); + it('ignores undefined conditional entries', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }).defineSituations({ + optional: { + conditional: { + a: undefined, + b: () => false, + }, + }, + }); + + expect(ts.resolveSituation('optional').activeTools).toEqual([ + 'a', + ]); + }); + + it('rejects malformed conditional rules at definition time with the situation and tool id', () => { + const base = createToolSet({ + tools: [ + a, + ] as const, + }); + + expect(() => + base.defineSituations({ + checkout: { + conditional: { + a: 'invalid', + }, + }, + } as never), + ).toThrow( + 'Situation "checkout": conditional rule for tool "a" must be a function or { mode, predicate } object', + ); + }); + + it('rejects a missing conditional predicate at definition time', () => { + const base = createToolSet({ + tools: [ + a, + ] as const, + }); + + expect(() => + base.defineSituations({ + checkout: { + conditional: { + a: { + mode: 'activateWhen', + }, + }, + }, + } as never), + ).toThrow( + 'Situation "checkout": conditional rule for tool "a" must be a function or { mode, predicate } object', + ); + }); + it('throws on unknown situation names at resolve time', () => { const ts = createToolSet({ tools: [ diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 9a6f5681..1845d1fb 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -245,6 +245,7 @@ export type { export { DEFAULT_TASK_LOG_LIMITS, ToolTask } from './lib/tool-task.js'; export type { AsyncToolAck, + BuiltinTaskToolEvent, ChatStreamEvent, ClientTool, ConversationState, diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index abd0c86d..e89d7490 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -1444,6 +1444,16 @@ export type CorrelatedToolResultEvent = Omit< */ type WidestCorrelatedToolEvent = ToolPreliminaryResultEvent | ToolResultEvent; +/** + * Final result emitted by the engine-injected `task` tool used to inspect, + * steer, fetch, or cancel long-running tasks. The payload is `unknown` + * because custom check handlers and completed task results are user-defined. + */ +export type BuiltinTaskToolEvent = Omit, 'toolName'> & { + toolName: 'task'; + source: 'client'; +}; + /** * Discriminated union of name-correlated tool events across a tools tuple. * Checking `event.toolName === 'my_tool'` narrows `result` to that tool's output. @@ -1455,13 +1465,15 @@ type WidestCorrelatedToolEvent = ToolPreliminaryResultEvent | ToolResultEvent; * when `T[K]` resolves to the full `Tool` union (`ClientTool | ServerToolBase`) * the check fails as a monolithic comparison rather than narrowing per-member. */ -export type CorrelatedToolEventUnion = readonly Tool[] extends T - ? WidestCorrelatedToolEvent - : { - [K in keyof T]: T[K] extends ClientTool - ? CorrelatedToolPreliminaryResultEvent | CorrelatedToolResultEvent - : never; - }[number]; +export type CorrelatedToolEventUnion = + | BuiltinTaskToolEvent + | (readonly Tool[] extends T + ? WidestCorrelatedToolEvent + : { + [K in keyof T]: T[K] extends ClientTool + ? CorrelatedToolPreliminaryResultEvent | CorrelatedToolResultEvent + : never; + }[number]); /** * Widest backward-compatible shape for {@link CorrelatedToolStreamPreliminaryUnion} diff --git a/packages/agent/tests/unit/task-tool-integration.test.ts b/packages/agent/tests/unit/task-tool-integration.test.ts index 14af02e3..5422eb68 100644 --- a/packages/agent/tests/unit/task-tool-integration.test.ts +++ b/packages/agent/tests/unit/task-tool-integration.test.ts @@ -2,7 +2,7 @@ import type { OpenRouterCore } from '@openrouter/sdk/core'; import type * as models from '@openrouter/sdk/models'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod/v4'; -import type { ConversationState, StateAccessor } from '../../src/index.js'; +import type { BuiltinTaskToolEvent, ConversationState, StateAccessor } from '../../src/index.js'; import { isToolResultEvent } from '../../src/index.js'; import { callModel } from '../../src/inner-loop/call-model.js'; import { tool } from '../../src/lib/tool.js'; @@ -369,6 +369,7 @@ describe('task tool — events & state persistence', () => { 'task', JSON.stringify({ taskId, + view: 'logs', }), ), ]), @@ -403,19 +404,24 @@ describe('task tool — events & state persistence', () => { }, }); - const toolResults: Array<{ - toolCallId: string; - result: unknown; - }> = []; + const toolResults: BuiltinTaskToolEvent[] = []; for await (const event of result.getFullResponsesStream()) { - if (isToolResultEvent(event)) { - toolResults.push(event as never); + if (isToolResultEvent(event) && event.toolName === 'task') { + toolResults.push(event); } } const checkEvent = toolResults.find((e) => e.toolCallId === 'call_check'); - expect(checkEvent).toBeDefined(); - expect((checkEvent?.result as Record)['status']).toBe('working'); + expect(checkEvent).toMatchObject({ + type: 'tool.result', + toolCallId: 'call_check', + toolName: 'task', + source: 'client', + result: { + status: 'working', + logs: expect.any(Array), + }, + }); }); it('task-tool call/output pairs persist into conversation state history', async () => { diff --git a/packages/agent/tests/unit/tool-name-correlation.test-d.ts b/packages/agent/tests/unit/tool-name-correlation.test-d.ts index c8559e1d..ccc7f954 100644 --- a/packages/agent/tests/unit/tool-name-correlation.test-d.ts +++ b/packages/agent/tests/unit/tool-name-correlation.test-d.ts @@ -7,6 +7,7 @@ import { expectTypeOf } from 'vitest'; import * as z from 'zod'; import { serverTool, tool } from '../../src/lib/tool.js'; import type { + BuiltinTaskToolEvent, ChatStreamEvent, CorrelatedResponseStreamEvent, CorrelatedToolEventUnion, @@ -504,9 +505,52 @@ const _resultMissingName: CorrelatedToolResultEvent = { }; void _resultMissingName; -// Correlated tuple-typed unions still discriminate on a required literal `toolName`. +// Correlated tuple-typed unions still discriminate on a required literal `toolName`, +// including the engine-injected task helper. expectTypeOf['toolName']>().toEqualTypeOf< - 'weather' | 'progress_tool' | 'unified_tool' | 'manual_tool' | 'hitl_tool' + 'task' | 'weather' | 'progress_tool' | 'unified_tool' | 'manual_tool' | 'hitl_tool' >(); +expectTypeOf< + Extract< + Events, + { + toolName: 'task'; + } + > +>().toEqualTypeOf(); + +function assertNever(value: never): never { + throw new Error(`Unexpected value: ${String(value)}`); +} + +function exhaustToolNames(event: Events): void { + switch (event.toolName) { + case 'task': + case 'weather': + case 'progress_tool': + case 'unified_tool': + case 'manual_tool': + case 'hitl_tool': + return; + default: + assertNever(event); + } +} +void exhaustToolNames; + +function missingBuiltinTaskCase(event: Events): void { + switch (event.toolName) { + case 'weather': + case 'progress_tool': + case 'unified_tool': + case 'manual_tool': + case 'hitl_tool': + return; + default: + // @ts-expect-error the built-in task event remains unhandled + assertNever(event); + } +} +void missingBuiltinTaskCase; expectTypeOf().not.toEqualTypeOf(); expectTypeOf().not.toEqualTypeOf(); From f903a088debb1a61e29cd6f1445e219a49a28fbc Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:56:23 -0500 Subject: [PATCH 27/31] fix(agent): scope tool-set request metadata --- packages/agent-tool-set/src/tool-set.ts | 13 +++-- packages/agent-tool-set/src/types.ts | 3 ++ packages/agent/src/index.ts | 6 ++- packages/agent/src/inner-loop/call-model.ts | 25 +++------ packages/agent/src/lib/async-params.ts | 52 +++++++++---------- packages/agent/src/lib/model-result.ts | 10 ++-- packages/agent/src/lib/tool.ts | 38 ++++++++++---- .../unit/call-model-active-tools.test.ts | 24 +++++++++ .../agent/tests/unit/tool-context.test.ts | 2 +- .../unit/tool-name-correlation.test-d.ts | 2 +- .../tests/unit/tool-shared-name.test-d.ts | 13 +++++ packages/agent/tsconfig.typecheck.json | 6 ++- 12 files changed, 122 insertions(+), 72 deletions(-) create mode 100644 packages/agent/tests/unit/tool-shared-name.test-d.ts diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts index d368b90d..86eeae8c 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent-tool-set/src/tool-set.ts @@ -1,5 +1,5 @@ import type { ServerToolBase, Tool } from '@openrouter/agent'; -import { isServerTool } from '@openrouter/agent'; +import { isServerTool, TOOL_SET_SNAPSHOT } from '@openrouter/agent'; import type { ActivatePartition, ActivationInput, @@ -591,10 +591,9 @@ export class ToolSet< * callModel(client, { model, input, tools, activeTools }); * ``` * - * `callModel` also defensively strips `enabled` / `disabled` / - * `statusByTool` (and a top-level `callModel` key) from whatever it's - * given, so spreading this method's full result is safe too — but the - * two-field pattern above is the documented, minimal contract. + * This result carries an internal marker so `callModel` can strip its + * metadata when the whole object is spread. Identically named request fields + * on ordinary, unmarked inputs are preserved. */ inferTools(input?: ActivationInput): { tools: Tool[]; @@ -602,6 +601,7 @@ export class ToolSet< enabled: readonly string[]; disabled: readonly string[]; statusByTool: StatusByToolMap; + [TOOL_SET_SNAPSHOT]: true; } { const snapshot = this.resolve(input); return { @@ -614,6 +614,7 @@ export class ToolSet< enabled: snapshot.enabled, disabled: snapshot.disabled, statusByTool: snapshot.statusByTool, + [TOOL_SET_SNAPSHOT]: true, }; } @@ -695,6 +696,7 @@ export class ToolSet< enabled: string[]; disabled: string[]; statusByTool: Record; + [TOOL_SET_SNAPSHOT]: true; } { const resolvedInput: ActivationInput = input ?? {}; const tools: Tool[] = []; @@ -745,6 +747,7 @@ export class ToolSet< enabled, disabled, statusByTool, + [TOOL_SET_SNAPSHOT]: true, }; } diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index afb671f8..6639814e 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -5,6 +5,7 @@ import type { ServerToolBase, Tool, } from '@openrouter/agent'; +import { TOOL_SET_SNAPSHOT } from '@openrouter/agent'; // ─── identity ─────────────────────────────────────────────────────────────── @@ -370,6 +371,8 @@ export type ResolvedToolSnapshot< readonly disabled: readonly Exclude, P['enabled']>[]; /** Exhaustive id → status entry. Every ToolIdsOfTuple key present. */ readonly statusByTool: StatusByToolMap>; + /** Internal marker allowing `callModel` to recognize a snapshot spread. */ + readonly [TOOL_SET_SNAPSHOT]: true; }; // ─── ToolSet structural eraser + inference utilities ──────────────────────── diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 1845d1fb..1849332f 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -100,7 +100,11 @@ export type { CallModelInputWithState, ResolvedCallModelInput, } from './lib/async-params.js'; -export { hasAsyncFunctions, resolveAsyncFunctions } from './lib/async-params.js'; +export { + hasAsyncFunctions, + resolveAsyncFunctions, + TOOL_SET_SNAPSHOT, +} from './lib/async-params.js'; // Async tool task registry types export type { SettledToolTask } from './lib/async-tool-registry.js'; export { AsyncToolRegistry } from './lib/async-tool-registry.js'; diff --git a/packages/agent/src/inner-loop/call-model.ts b/packages/agent/src/inner-loop/call-model.ts index 2dda8bcd..84a687d3 100644 --- a/packages/agent/src/inner-loop/call-model.ts +++ b/packages/agent/src/inner-loop/call-model.ts @@ -2,7 +2,7 @@ import type { OpenRouterCore } from '@openrouter/sdk/core'; import type { RequestOptions } from '@openrouter/sdk/lib/sdks'; import type { $ZodObject, $ZodShape, infer as zodInfer } from 'zod/v4/core'; import type { CallModelInput } from '../lib/async-params.js'; -import { TOOL_SET_SNAPSHOT_METADATA_KEYS } from '../lib/async-params.js'; +import { stripToolSetSnapshotMetadata } from '../lib/async-params.js'; import { resolveHooks } from '../lib/hooks-resolve.js'; import type { GetResponseOptions } from '../lib/model-result.js'; import { ModelResult } from '../lib/model-result.js'; @@ -149,26 +149,13 @@ export function callModel< apiTools.push(buildTaskToolApiDefinition(convertZodToJsonSchema)); } - // Build the request with converted tools - // Note: async functions are resolved later in ModelResult.executeToolsIfNeeded() - // The request can have async fields (functions) or sync fields, and the tools are converted to API format - // - // Defense-in-depth: `apiRequest` is typed as "whatever wasn't one of the - // known client-only fields above", but TypeScript's excess-property - // checking does not run on spread arguments — so a caller who does - // `callModel(client, { ...toolSet.inferTools(), model, input })` (a - // documented, intended pattern for `tools`/`activeTools`) can silently - // carry `@openrouter/agent-tool-set` snapshot metadata (`enabled`, - // `disabled`, `statusByTool`) straight through to the outbound request - // with no compile-time or destructure-time signal. Strip any such keys - // here, at the single choke point every callModel() call passes through, - // regardless of which ToolSet method produced the spread object. - const finalRequest: Record = { + // Build the request with converted tools. Tool-set snapshots carry a symbol + // marker that survives object spread, allowing their metadata to be removed + // without reserving otherwise legitimate API field names. + const finalRequest: Record = { ...apiRequest, }; - for (const key of TOOL_SET_SNAPSHOT_METADATA_KEYS) { - delete finalRequest[key]; - } + stripToolSetSnapshotMetadata(finalRequest); if (apiTools !== undefined) { finalRequest['tools'] = apiTools; diff --git a/packages/agent/src/lib/async-params.ts b/packages/agent/src/lib/async-params.ts index 1b3ed0b3..bf5bb7ab 100644 --- a/packages/agent/src/lib/async-params.ts +++ b/packages/agent/src/lib/async-params.ts @@ -17,29 +17,27 @@ import type { // Re-export Tool type for convenience export type { Tool } from './tool-types.js'; -/** - * Keys that appear on `@openrouter/agent-tool-set`'s `ToolSet.inferTools()` / - * `resolve()` / `resolveSituation()` snapshots but are never valid outbound - * API request fields. - * - * The documented pattern is to spread `{ tools, activeTools }` (or - * `snapshot.callModel`) from one of those snapshots into `callModel`. If a - * caller instead spreads the *whole* snapshot — e.g. - * `callModel(client, { ...toolSet.inferTools(), model })` — this set is what - * keeps `enabled` / `disabled` / `statusByTool` (and the nested `callModel` - * wrapper itself, if a whole `ResolvedToolSnapshot` is spread) from silently - * riding along into the request body. Checked by both `callModel()` and - * {@link resolveAsyncFunctions}, independent of the exact shape any given - * tool-set snapshot method returns, so it stays robust even if the tool-set - * package adds more metadata under these names later. - */ -export const TOOL_SET_SNAPSHOT_METADATA_KEYS: ReadonlySet = new Set([ +/** Identifies objects produced by `@openrouter/agent-tool-set`. */ +export const TOOL_SET_SNAPSHOT = Symbol.for('@openrouter/agent-tool-set/snapshot'); + +const TOOL_SET_SNAPSHOT_METADATA_KEYS: ReadonlySet = new Set([ 'enabled', 'disabled', 'statusByTool', 'callModel', ]); +/** Remove tool-set metadata only from marked snapshots or their spreads. */ +export function stripToolSetSnapshotMetadata(input: Record): void { + if (input[TOOL_SET_SNAPSHOT] !== true) { + return; + } + for (const key of TOOL_SET_SNAPSHOT_METADATA_KEYS) { + delete input[key]; + } + delete input[TOOL_SET_SNAPSHOT]; +} + /** * Type guard to check if a value is a parameter function * Parameter functions take TurnContext and return a value or promise @@ -90,10 +88,9 @@ type BaseCallModelInput< * 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 — spreading its - * `{ tools, activeTools }` (or a whole snapshot from `.inferTools()` / - * `.resolve()` / `.resolveSituation()`) into this object is safe: - * `callModel` strips any tool-set snapshot metadata (`enabled`, `disabled`, - * `statusByTool`) before it ever reaches the outbound API request. + * `{ tools, activeTools }` (or a whole marked snapshot from `.inferTools()` / + * `.resolve()` / `.resolveSituation()`) into this object is safe: `callModel` + * strips metadata introduced by that snapshot before sending the request. */ activeTools?: readonly string[]; stopWhen?: StopWhen; @@ -346,16 +343,17 @@ export async function resolveAsyncFunctions; + stripToolSetSnapshotMetadata(request); + // Iterate over all keys in the input - for (const [key, value] of Object.entries(input)) { + for (const [key, value] of Object.entries(request)) { // Skip client-only fields - they're handled separately and shouldn't be sent to the API // Note: tools are already in API format at this point (converted in callModel()), so we include them // - // Also defensively drop `@openrouter/agent-tool-set` snapshot metadata - // (see TOOL_SET_SNAPSHOT_METADATA_KEYS) in case it reached this stage - // without being caught by callModel()'s own filtering — e.g. a caller - // spreading a raw ToolSet snapshot into a differently-sourced input. - if (clientOnlyFields.has(key) || TOOL_SET_SNAPSHOT_METADATA_KEYS.has(key)) { + if (clientOnlyFields.has(key)) { continue; } diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index 7981a2a3..17bd4928 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -8,7 +8,7 @@ import type { CallModelInput, ResolvedCallModelInput } from './async-params.js'; import { hasAsyncFunctions, resolveAsyncFunctions, - TOOL_SET_SNAPSHOT_METADATA_KEYS, + stripToolSetSnapshotMetadata, } from './async-params.js'; import type { SettledToolTask, TaskToolInput, ToolSemaphore, ToolTaskMode } from './async-tools.js'; import { @@ -4936,14 +4936,10 @@ export class ModelResult< activeTools: _activeTools, ...rest } = this.options.request; - // Defense-in-depth: also drop `@openrouter/agent-tool-set` snapshot - // metadata in case it reached this stage without callModel filtering it. - const resolved: Record = { + const resolved: Record = { ...rest, }; - for (const key of TOOL_SET_SNAPSHOT_METADATA_KEYS) { - delete resolved[key]; - } + stripToolSetSnapshotMetadata(resolved); return this.applyResolvedForcedToolChoicePolicy(resolved as ResolvedCallModelInput); } diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index c5220c51..f995cd81 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -225,8 +225,9 @@ type HITLToolConfig< type ToolConfigWithSharedContext< TShared extends Record, TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, + TName extends string = string, > = { - name: string; + name: TName; description?: string; inputSchema: $ZodObject<$ZodShape>; outputSchema?: $ZodType; @@ -245,11 +246,11 @@ type ToolConfigWithSharedContext< execute: | (( params: Record, - context?: ToolExecuteContext, TShared>, + context?: ToolExecuteContext, TShared>, ) => unknown) | (( params: Record, - context?: ToolExecuteContext, TShared>, + context?: ToolExecuteContext, TShared>, ) => AsyncGenerator) | false; /** Convert tool execution output to model-facing output */ @@ -382,7 +383,7 @@ type RegularToolConfig< * ```typescript * type SharedCtx = z.infer; * - * const execTool = tool({ + * const execTool = tool()({ * name: "sandbox_exec", * inputSchema: z.object({ command: z.string() }), * execute: async (params, ctx) => { @@ -392,6 +393,20 @@ type RegularToolConfig< * }); * ``` */ +// Curried explicit-TShared overload. TypeScript cannot infer type arguments +// that follow an explicitly supplied one, so the config gets its own generic +// call boundary to preserve literal names. +export function tool>(): < + const TName extends string, + TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, +>( + config: ToolConfigWithSharedContext, +) => Tool & { + function: { + name: TName; + }; +}; + // NEW unified overloads — ordered FIRST so `run` configs never fall through // to a legacy-overload error message. Disjointness with the released // overloads is structural: run configs declare `execute?: undefined` / @@ -489,10 +504,9 @@ export function tool< config: RegularToolConfigWithoutOutput, ): ToolWithExecute, Record, TCtx, TName>; -// Overload for explicit TShared: tool({...}) -// When a non-ZodObject type is provided as the first generic, -// the specific overloads above won't match (constraint mismatch), -// so TypeScript falls through to this catch-all. +// Backward-compatible uncurried explicit-TShared overload. Its name remains +// wide because TypeScript cannot partially infer generics after TShared; +// use tool()({...}) when literal-name inference is needed. export function tool< TShared extends Record, TName extends string = string, @@ -509,7 +523,7 @@ export function tool< // Implementation export function tool( - config: + config?: | GeneratorToolConfig<$ZodObject<$ZodShape>, $ZodType, $ZodType> | RegularToolConfig<$ZodObject<$ZodShape>, $ZodType, unknown> | ManualToolConfig<$ZodObject<$ZodShape>> @@ -517,7 +531,11 @@ export function tool( | RunToolConfigWithOutput<$ZodObject<$ZodShape>, $ZodType> | SyncRunToolConfigWithoutOutput<$ZodObject<$ZodShape>, unknown> | ToolConfigWithSharedContext>, -): Tool { +): Tool | ((sharedConfig: ToolConfigWithSharedContext>) => Tool) { + if (config === undefined) { + return tool; + } + // 'shared' is reserved for shared context — forbid it as a tool name if (config.name === SHARED_CONTEXT_KEY) { throw new Error( diff --git a/packages/agent/tests/unit/call-model-active-tools.test.ts b/packages/agent/tests/unit/call-model-active-tools.test.ts index 91fb196d..6d173a55 100644 --- a/packages/agent/tests/unit/call-model-active-tools.test.ts +++ b/packages/agent/tests/unit/call-model-active-tools.test.ts @@ -3,6 +3,7 @@ import { HTTPClient } from '@openrouter/sdk/lib/http'; import { describe, expect, it } from 'vitest'; import { z } from 'zod/v4'; import { callModel } from '../../src/inner-loop/call-model.js'; +import { stripToolSetSnapshotMetadata, TOOL_SET_SNAPSHOT } from '../../src/lib/async-params.js'; import { tool } from '../../src/lib/tool.js'; type CapturedPayload = { @@ -295,6 +296,7 @@ describe('callModel strips @openrouter/agent-tool-set snapshot metadata', () => 'a', ], }, + [TOOL_SET_SNAPSHOT]: true, }; const { raw } = await captureOutboundRequest(snapshotLikeRequest); @@ -309,6 +311,28 @@ describe('callModel strips @openrouter/agent-tool-set snapshot metadata', () => ]); }); + it('preserves identically named request fields when the request is not a tool-set snapshot', () => { + const request = { + enabled: true, + disabled: false, + statusByTool: { + a: 'request-value', + }, + callModel: 'request-value', + }; + + stripToolSetSnapshotMetadata(request); + + expect(request).toEqual({ + enabled: true, + disabled: false, + statusByTool: { + a: 'request-value', + }, + callModel: 'request-value', + }); + }); + it('still sends the documented { tools, activeTools } spread-safe pattern unaffected', async () => { // Guards against over-eager stripping: `tools`/`activeTools` themselves // (the two fields the docs say to spread) must keep working. diff --git a/packages/agent/tests/unit/tool-context.test.ts b/packages/agent/tests/unit/tool-context.test.ts index 9f7b5a39..495cbe4c 100644 --- a/packages/agent/tests/unit/tool-context.test.ts +++ b/packages/agent/tests/unit/tool-context.test.ts @@ -541,7 +541,7 @@ describe('tool() with contextSchema', () => { type SharedCtx = { _sessionId?: string; }; - const t = tool({ + const t = tool()({ name: 'typed_shared', inputSchema: z4.object({ cmd: z4.string(), diff --git a/packages/agent/tests/unit/tool-name-correlation.test-d.ts b/packages/agent/tests/unit/tool-name-correlation.test-d.ts index ccc7f954..eb6db3ff 100644 --- a/packages/agent/tests/unit/tool-name-correlation.test-d.ts +++ b/packages/agent/tests/unit/tool-name-correlation.test-d.ts @@ -81,7 +81,7 @@ const manual = tool({ const shared = tool<{ userId: string; -}>({ +}>()({ name: 'shared_tool', inputSchema: z.object({}), execute: async (_params, ctx) => ctx?.shared.userId ?? '', diff --git a/packages/agent/tests/unit/tool-shared-name.test-d.ts b/packages/agent/tests/unit/tool-shared-name.test-d.ts new file mode 100644 index 00000000..0cc0058a --- /dev/null +++ b/packages/agent/tests/unit/tool-shared-name.test-d.ts @@ -0,0 +1,13 @@ +import { expectTypeOf } from 'vitest'; +import { z } from 'zod/v4'; +import { tool } from '../../src/lib/tool.js'; + +const shared = tool<{ + userId: string; +}>()({ + name: 'shared_tool', + inputSchema: z.object({}), + execute: async (_params, ctx) => ctx?.shared.userId ?? '', +}); + +expectTypeOf(shared.function.name).toEqualTypeOf<'shared_tool'>(); diff --git a/packages/agent/tsconfig.typecheck.json b/packages/agent/tsconfig.typecheck.json index 543147e6..a40cbeda 100644 --- a/packages/agent/tsconfig.typecheck.json +++ b/packages/agent/tsconfig.typecheck.json @@ -1,6 +1,10 @@ { "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true, "rootDir": "." }, - "include": ["src/**/*.ts", "tests/unit/context-schema-inference.test-d.ts"], + "include": [ + "src/**/*.ts", + "tests/unit/context-schema-inference.test-d.ts", + "tests/unit/tool-shared-name.test-d.ts" + ], "exclude": ["node_modules", "esm"] } From bbda34f72bcf12df02670578e7eec218cd2abbf2 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:38:06 -0500 Subject: [PATCH 28/31] fix(toolkits): address server and shared tool identities --- packages/agent-tool-set/src/types.ts | 16 +++++---- .../tests/unit/server-tool-id.test-d.ts | 35 ++++++++++++++++++- .../tests/unit/tool-set.test.ts | 35 +++++++++++++++++++ packages/agent/src/lib/tool.ts | 18 ++++------ .../tests/unit/tool-shared-name.test-d.ts | 11 ++++++ 5 files changed, 95 insertions(+), 20 deletions(-) diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index 6639814e..9efcafac 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -33,13 +33,15 @@ export type ClientToolName = T extends { * Only tools with no structural `id` at all fall back to the synthesized * default, and concrete `ServerTool` values keep their literal `TId`. */ -export type ServerToolIdOf = T extends { - readonly id?: infer Id; -} - ? Extract extends infer StringId extends string - ? string extends StringId - ? string - : StringId +export type ServerToolIdOf = 'id' extends keyof T + ? T extends { + readonly id?: infer Id; + } + ? Extract extends infer StringId extends string + ? string extends StringId + ? string + : StringId + : never : never : T extends { readonly config: { diff --git a/packages/agent-tool-set/tests/unit/server-tool-id.test-d.ts b/packages/agent-tool-set/tests/unit/server-tool-id.test-d.ts index 0ec1b928..bcb520e4 100644 --- a/packages/agent-tool-set/tests/unit/server-tool-id.test-d.ts +++ b/packages/agent-tool-set/tests/unit/server-tool-id.test-d.ts @@ -2,7 +2,7 @@ import type { ServerToolBase } from '@openrouter/agent'; import { serverTool } from '@openrouter/agent'; import { expectTypeOf } from 'vitest'; import { createToolSet } from '../../src/tool-set.js'; -import type { InferAllIds, ServerToolIdOf } from '../../src/types.js'; +import type { FilterToolsByIds, InferAllIds, ServerToolIdOf } from '../../src/types.js'; const precise = serverTool( { @@ -22,3 +22,36 @@ const set = createToolSet({ }); set.deactivate('any-runtime-server-tool-id'); expectTypeOf>().toEqualTypeOf(); + +const handWritten = { + _brand: 'server-tool', + config: { + type: 'web_search_2025_08_26', + }, +} as const; +const handWrittenId = 'server:web_search_2025_08_26'; + +expectTypeOf>().toEqualTypeOf(); +expectTypeOf< + FilterToolsByIds< + readonly [ + typeof handWritten, + ], + typeof handWrittenId + > +>().toEqualTypeOf< + readonly [ + typeof handWritten, + ] +>(); + +const handWrittenSet = createToolSet({ + tools: [ + handWritten, + ] as const, +}); +handWrittenSet.activate(handWrittenId); +handWrittenSet.deactivate(handWrittenId); +expectTypeOf['statusByTool']>().toEqualTypeOf< + typeof handWrittenId +>(); 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 826f25a3..d13e012f 100644 --- a/packages/agent-tool-set/tests/unit/tool-set.test.ts +++ b/packages/agent-tool-set/tests/unit/tool-set.test.ts @@ -1000,6 +1000,41 @@ describe('server tools', () => { expect(() => ts.activate('web_search_2025_08_26' as 'a')).toThrow(/Unknown tool/); }); + describe('hand-written server tool without an id', () => { + const handWritten = { + _brand: 'server-tool', + config: { + type: 'web_search_2025_08_26', + }, + } as const; + const id = 'server:web_search_2025_08_26'; + + it('uses the synthesized ID for activation, status, and filtering', () => { + const ts = createToolSet({ + tools: [ + a, + handWritten, + ] as const, + }).deactivate(id); + + const resolved = ts.resolve(); + expect(resolved.enabled).toEqual([ + 'a', + ]); + expect(resolved.disabled).toEqual([ + id, + ]); + expect(resolved.statusByTool[id]).toEqual({ + enabled: false, + reason: 'deactivate', + directive: 'deactivate', + }); + expect(resolved.tools).toEqual([ + a, + ]); + }); + }); + describe('custom-ID server tool erased to ServerToolBase', () => { // Reproduces the reviewed scenario: a custom-ID server tool value whose // static type has been widened to the exported `ServerToolBase` diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index f995cd81..d4aaa453 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -504,20 +504,14 @@ export function tool< config: RegularToolConfigWithoutOutput, ): ToolWithExecute, Record, TCtx, TName>; -// Backward-compatible uncurried explicit-TShared overload. Its name remains -// wide because TypeScript cannot partially infer generics after TShared; -// use tool()({...}) when literal-name inference is needed. -export function tool< - TShared extends Record, - TName extends string = string, - TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, ->( - config: ToolConfigWithSharedContext & { - name: TName; - }, +// Backward-compatible direct explicit-TShared overload. TypeScript cannot +// infer another type parameter after an explicit TShared, so literal-name +// inference uses the curried overload above. +export function tool>( + config: ToolConfigWithSharedContext, ): Tool & { function: { - name: TName; + name: string; }; }; diff --git a/packages/agent/tests/unit/tool-shared-name.test-d.ts b/packages/agent/tests/unit/tool-shared-name.test-d.ts index 0cc0058a..56cd43f1 100644 --- a/packages/agent/tests/unit/tool-shared-name.test-d.ts +++ b/packages/agent/tests/unit/tool-shared-name.test-d.ts @@ -1,6 +1,17 @@ import { expectTypeOf } from 'vitest'; import { z } from 'zod/v4'; import { tool } from '../../src/lib/tool.js'; +import type { Tool } from '../../src/lib/tool-types.js'; + +const direct = tool<{ + userId: string; +}>({ + name: 'direct_shared_tool', + inputSchema: z.object({}), + execute: async (_params, ctx) => ctx?.shared.userId ?? '', +}); +expectTypeOf(direct).toExtend(); +expectTypeOf(direct.function.name).toEqualTypeOf(); const shared = tool<{ userId: string; From e96ac0fe858141d8565e4ca76205e96b55a93076 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:55:25 -0500 Subject: [PATCH 29/31] fix(agent): clarify shared tool name inference --- .changeset/agent-tool-set.md | 7 ++- packages/agent/README.md | 25 ++++++++++- packages/agent/src/lib/tool.ts | 8 ++-- .../tests/unit/tool-shared-name.test-d.ts | 43 ++++++++++++++++++- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/.changeset/agent-tool-set.md b/.changeset/agent-tool-set.md index 01581a95..e054ecc4 100644 --- a/.changeset/agent-tool-set.md +++ b/.changeset/agent-tool-set.md @@ -10,10 +10,13 @@ import { callModel, OpenRouter, serverTool, tool } from '@openrouter/agent'; import { createToolSet } from '@openrouter/agent-tool-set'; import { z } from 'zod/v4'; -const listOrders = tool({ +type AppContext = { accountId: string }; + +// Curried form preserves the literal name for correlated tool event types. +const listOrders = tool()({ name: 'list_orders', inputSchema: z.object({}), - execute: async () => ({ orders: [] }), + execute: async (_params, ctx) => ({ accountId: ctx?.shared.accountId, orders: [] }), }); // override the default `server:${type}` id const search = serverTool({ type: 'web_search_2025_08_26' }, { id: 'public_search' }); diff --git a/packages/agent/README.md b/packages/agent/README.md index 32f7abfe..ec5865c7 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -939,13 +939,27 @@ const result = callModel(client, { ### Shared Context -Share mutable state across all tools in a conversation: +Share mutable state across all tools in a conversation. When typing +`ctx.shared` explicitly, use the curried `tool()({...})` form: ```typescript +type SharedContext = { + processedIds: string[]; +}; + +const processItem = tool()({ + name: 'process_item', + inputSchema: z.object({ id: z.string() }), + execute: async ({ id }, ctx) => { + ctx?.setSharedContext({ processedIds: [...ctx.shared.processedIds, id] }); + return { processed: id }; + }, +}); + const result = callModel(client, { model: 'openai/gpt-4o', input: 'Process these items', - tools: [toolA, toolB] as const, + tools: [processItem] as const, sharedContextSchema: z.object({ processedIds: z.array(z.string()) }), context: { shared: { processedIds: [] }, @@ -953,6 +967,13 @@ const result = callModel(client, { }); ``` +The direct `tool({...})` syntax remains supported for backward +compatibility, but its returned tool name is typed as `string`. TypeScript +cannot partially infer trailing generics after an explicit `TShared`, so the +curried form is required when event types must correlate on a literal tool name. +Calls without an explicit shared-context type, such as `tool({...})`, continue +to infer literal names normally. + ### Conversation State Management Persist multi-turn conversations with full state tracking. The `state` diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index d4aaa453..a19717ca 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -375,9 +375,11 @@ type RegularToolConfig< * - **Regular tool**: When `execute` is a function (no `eventSchema`) * - **Manual tool**: When `execute: false` is set * - * Shared context typing: Pass a type parameter to type `ctx.shared` - * in the execute callback. Runtime validation happens at callModel - * via `sharedContextSchema`. + * Shared context typing: Use `tool()({...})` to type `ctx.shared` + * and preserve the tool's literal name. The backward-compatible direct form + * `tool({...})` is also supported, but its returned name is `string` + * because TypeScript cannot infer trailing type parameters after an explicit + * `TShared`. Runtime validation happens at callModel via `sharedContextSchema`. * * @example Regular tool with typed shared context: * ```typescript diff --git a/packages/agent/tests/unit/tool-shared-name.test-d.ts b/packages/agent/tests/unit/tool-shared-name.test-d.ts index 56cd43f1..e8041c5a 100644 --- a/packages/agent/tests/unit/tool-shared-name.test-d.ts +++ b/packages/agent/tests/unit/tool-shared-name.test-d.ts @@ -1,7 +1,15 @@ import { expectTypeOf } from 'vitest'; import { z } from 'zod/v4'; import { tool } from '../../src/lib/tool.js'; -import type { Tool } from '../../src/lib/tool-types.js'; +import type { CorrelatedToolResultEvent, InferToolName, Tool } from '../../src/lib/tool-types.js'; + +const inferred = tool({ + name: 'inferred_tool', + inputSchema: z.object({}), + execute: async () => '', +}); +expectTypeOf(inferred.function.name).toEqualTypeOf<'inferred_tool'>(); +expectTypeOf>().toEqualTypeOf<'inferred_tool'>(); const direct = tool<{ userId: string; @@ -12,6 +20,17 @@ const direct = tool<{ }); expectTypeOf(direct).toExtend(); expectTypeOf(direct.function.name).toEqualTypeOf(); +expectTypeOf>().toEqualTypeOf(); + +const directEvent: CorrelatedToolResultEvent = { + type: 'tool.result', + toolCallId: 'call_1', + toolName: 'any_runtime_name', + source: 'client', + result: 'result', + timestamp: Date.now(), +}; +expectTypeOf(directEvent.toolName).toEqualTypeOf(); const shared = tool<{ userId: string; @@ -22,3 +41,25 @@ const shared = tool<{ }); expectTypeOf(shared.function.name).toEqualTypeOf<'shared_tool'>(); +expectTypeOf>().toEqualTypeOf<'shared_tool'>(); + +const sharedEvent: CorrelatedToolResultEvent = { + type: 'tool.result', + toolCallId: 'call_2', + toolName: 'shared_tool', + source: 'client', + result: 'result', + timestamp: Date.now(), +}; +expectTypeOf(sharedEvent.toolName).toEqualTypeOf<'shared_tool'>(); + +const wrongSharedEvent: CorrelatedToolResultEvent = { + type: 'tool.result', + toolCallId: 'call_3', + // @ts-expect-error curried shared-context tools correlate on their literal name + toolName: 'other_tool', + source: 'client', + result: 'result', + timestamp: Date.now(), +}; +void wrongSharedEvent; From 6c6b2ee3a0e6e765607a89164c112e1e97005d2b Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:24:02 -0500 Subject: [PATCH 30/31] fix(tool-set): make conditional snapshots type-safe --- packages/agent-tool-set/README.md | 12 +-- packages/agent-tool-set/package.json | 2 +- packages/agent-tool-set/src/index.ts | 1 + packages/agent-tool-set/src/tool-set.ts | 2 + packages/agent-tool-set/src/types.ts | 24 ++++- .../tests/unit/resolved-tools.test-d.ts | 92 +++++++++++++++++++ .../agent-tool-set/tsconfig.typecheck.json | 6 ++ 7 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts create mode 100644 packages/agent-tool-set/tsconfig.typecheck.json diff --git a/packages/agent-tool-set/README.md b/packages/agent-tool-set/README.md index b2bee2eb..11613f18 100644 --- a/packages/agent-tool-set/README.md +++ b/packages/agent-tool-set/README.md @@ -117,12 +117,12 @@ Duplicate IDs throw at `createToolSet` construction. Activation methods accept o | Resolution style | Developer-time knowledge | Runtime knowledge | | --- | --- | --- | -| Static `activate` / `deactivate` | Exact partition | Exact snapshot | -| Named static situation (`enabled`/`disabled` only) | Exact filtered tool tuple | Exact snapshot | -| `activateWhen` / `deactivateWhen` / situation `conditional` | Upper bound (`enabled ∪ conditional`) | Exact snapshot after predicates | -| Mutable `ToolSet` | Partition types may widen | Exact snapshot | +| Static `activate` / `deactivate` | Exact partition and filtered `tools` tuple | Exact snapshot | +| Named static situation (`enabled`/`disabled` only) | Exact partition and filtered `tools` tuple | Exact snapshot | +| `activateWhen` / `deactivateWhen` / situation `conditional` | `tools` is a readonly array of possible active members; length and positions are not exact | Exact snapshot after predicates | +| Mutable `ToolSet` | Widened partition; `tools` is a readonly array of possible active members | Exact snapshot | -The type system cannot execute predicates. Conditional IDs therefore expand the compile-time upper bound of active tools; after `resolve`, the returned arrays and `statusByTool` are always exhaustive and exact. +The type system cannot execute predicates. If any IDs are conditional, `snapshot.tools` and `snapshot.callModel.tools` are arrays whose member union is limited to the active upper bound, but their length and positions remain unknown. Static-only partitions retain exact filtered tuples. At runtime, all snapshot arrays and `statusByTool` reflect the resolved predicates exactly. ## API @@ -162,7 +162,7 @@ Situation overlays the base partition for every ID it mentions; unmentioned IDs ```ts { - tools: /* active tools, construction order, concrete types */; + tools: /* exact tuple when static; possible-member array when conditional */; activeTools: /* active *client* names for callModel */; callModel: { tools, activeTools }; // safe to spread into callModel() enabled: /* every active ID (client + server) */; diff --git a/packages/agent-tool-set/package.json b/packages/agent-tool-set/package.json index ff311168..0cfabc79 100644 --- a/packages/agent-tool-set/package.json +++ b/packages/agent-tool-set/package.json @@ -43,7 +43,7 @@ "build": "tsc", "test": "vitest --run --project unit", "test:watch": "vitest --watch --project unit", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", "compile": "tsc" }, "dependencies": { diff --git a/packages/agent-tool-set/src/index.ts b/packages/agent-tool-set/src/index.ts index 48c64e65..c8650d41 100644 --- a/packages/agent-tool-set/src/index.ts +++ b/packages/agent-tool-set/src/index.ts @@ -21,6 +21,7 @@ export type { InitialPartition, Partition, ResolvedToolSnapshot, + ResolvedTools, ServerToolIdOf, ServerToolIdsOfTuple, SituationConditionalRule, diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts index 86eeae8c..926a949d 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent-tool-set/src/tool-set.ts @@ -14,6 +14,7 @@ import type { InitialPartition, Partition, ResolvedToolSnapshot, + ResolvedTools, ServerToolIdsOfTuple, SituationConditionalRule, SituationConfig, @@ -916,6 +917,7 @@ export function createToolSet< export type { ClientToolNamesOfTuple, FilterToolsByIds, + ResolvedTools, ServerToolIdsOfTuple, ToolIdOf, ToolIdsOfTuple, diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index 9efcafac..898721e8 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -115,6 +115,22 @@ export type FilterToolsByIds< : FilterToolsByIds : readonly []; +/** + * Exact tuple for static partitions; possible active members for partitions + * whose runtime predicates can change tuple membership. + */ +export type ResolvedTools< + TTools extends readonly Tool[], + P extends Partition, + TActive extends string = P['enabled'] | P['conditional'], +> = [ + P['conditional'], +] extends [ + never, +] + ? FilterToolsByIds> + : readonly FilterToolsByIds>[number][]; + // ─── three-way compile-time partition ─────────────────────────────────────── /** @@ -339,7 +355,9 @@ export type StatusByToolMap = { * For static-only partitions (`conditional = never`), TActive is exactly * `P['enabled']` and the snapshot is fully known at compile time. * When conditional ≠ never, TActive is the sound upper bound - * `P['enabled'] | P['conditional']`; runtime arrays/status are exact. + * `P['enabled'] | P['conditional']`; tools become a readonly array of the + * possible active member union because predicates can change length and + * positions at runtime. Runtime arrays/status are exact. * * `disabled` is declared as the complement of the *definitely-enabled* set * (`P['enabled']`), not of `TActive`. A conditional id's predicate can @@ -355,12 +373,12 @@ export type ResolvedToolSnapshot< TActive extends string = P['enabled'] | P['conditional'], > = { /** Active tools only, construction order preserved, concrete member types kept. */ - readonly tools: FilterToolsByIds>; + readonly tools: ResolvedTools; /** Active client names only (`callModel.activeTools` wire format). Server ids omitted. */ readonly activeTools: readonly Extract>[]; /** Spread-safe input for `callModel`; snapshot metadata is intentionally excluded. */ readonly callModel: { - readonly tools: FilterToolsByIds>; + readonly tools: ResolvedTools; readonly activeTools: readonly Extract>[]; }; /** IDs that resolved active (client + server). */ diff --git a/packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts b/packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts new file mode 100644 index 00000000..28c1dc0e --- /dev/null +++ b/packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts @@ -0,0 +1,92 @@ +import { tool } from '@openrouter/agent'; +import { expectTypeOf } from 'vitest'; +import { z } from 'zod/v4'; +import type { + ConditionalPartition, + InitialPartition, + ResolvedToolSnapshot, +} from '../../src/index.js'; +import { createToolSet } from '../../src/index.js'; + +const a = tool({ + name: 'a', + inputSchema: z.object({}), + execute: async () => 'a', +}); + +const b = tool({ + name: 'b', + inputSchema: z.object({}), + execute: async () => 'b', +}); + +const staticSnapshot = createToolSet({ + tools: [ + a, + b, + ] as const, +}) + .deactivate('b') + .activate('a') + .resolve(); + +expectTypeOf(staticSnapshot.tools).toEqualTypeOf< + readonly [ + typeof a, + ] +>(); +expectTypeOf(staticSnapshot.callModel.tools).toEqualTypeOf< + readonly [ + typeof a, + ] +>(); +expectTypeOf(staticSnapshot.tools.length).toEqualTypeOf<1>(); + +const conditionalSnapshot = createToolSet({ + tools: [ + a, + b, + ] as const, +}) + .activateWhen('a', () => false) + .resolve(); + +type PossibleTool = typeof a | typeof b; +expectTypeOf(conditionalSnapshot.tools).toEqualTypeOf(); +expectTypeOf(conditionalSnapshot.callModel.tools).toEqualTypeOf(); +expectTypeOf(conditionalSnapshot.tools[0]).toEqualTypeOf(); +expectTypeOf(conditionalSnapshot.tools[0]).not.toEqualTypeOf(); +expectTypeOf(conditionalSnapshot.tools.length).toEqualTypeOf(); + +type ConditionalA = ConditionalPartition< + InitialPartition< + readonly [ + typeof a, + typeof b, + ] + >, + 'a' +>; +type GenericActiveA = ResolvedToolSnapshot< + readonly [ + typeof a, + typeof b, + ], + ConditionalA, + 'a' +>; +expectTypeOf().toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); + +const mutableSnapshot = createToolSet({ + tools: [ + a, + b, + ] as const, + mutable: true, +}).resolve(); + +expectTypeOf(mutableSnapshot.tools).toEqualTypeOf(); +expectTypeOf(mutableSnapshot.callModel.tools).toEqualTypeOf(); +expectTypeOf(mutableSnapshot.tools[0]).toEqualTypeOf(); +expectTypeOf(mutableSnapshot.tools.length).toEqualTypeOf(); diff --git a/packages/agent-tool-set/tsconfig.typecheck.json b/packages/agent-tool-set/tsconfig.typecheck.json new file mode 100644 index 00000000..e4829760 --- /dev/null +++ b/packages/agent-tool-set/tsconfig.typecheck.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true, "rootDir": "." }, + "include": ["src/**/*.ts", "tests/unit/resolved-tools.test-d.ts"], + "exclude": ["node_modules", "esm"] +} From 578dd9eeb7ff24816ce2bdd7ea6c1308139aa0d1 Mon Sep 17 00:00:00 2001 From: Luke Parke <5702154+LukasParke@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:31:22 -0500 Subject: [PATCH 31/31] fix(tool-set): preserve statically enabled resolved tools --- packages/agent-tool-set/src/tool-set.ts | 40 ++----------------- packages/agent-tool-set/src/types.ts | 39 ++++++++++++------ .../tests/unit/resolved-tools.test-d.ts | 8 +++- 3 files changed, 36 insertions(+), 51 deletions(-) diff --git a/packages/agent-tool-set/src/tool-set.ts b/packages/agent-tool-set/src/tool-set.ts index 926a949d..083d2da5 100644 --- a/packages/agent-tool-set/src/tool-set.ts +++ b/packages/agent-tool-set/src/tool-set.ts @@ -554,27 +554,11 @@ export class ToolSet< * When the partition is purely static, the active tool tuple is exact at * compile time. Conditional ids expand the compile-time upper bound. */ - resolve(input?: ActivationInput): ResolvedToolSnapshot< - TTools, - P, - [ - P['conditional'], - ] extends [ - never, - ] - ? P['enabled'] - : P['enabled'] | P['conditional'] - > { + resolve(input?: ActivationInput): ResolvedToolSnapshot { return this.#resolveWithActivation(this.#activation, input) as unknown as ResolvedToolSnapshot< TTools, P, - [ - P['conditional'], - ] extends [ - never, - ] - ? P['enabled'] - : P['enabled'] | P['conditional'] + P['conditional'] >; } @@ -631,15 +615,7 @@ export class ToolSet< ): ResolvedToolSnapshot< TTools, ApplySituationPartition, - [ - ApplySituationPartition['conditional'], - ] extends [ - never, - ] - ? ApplySituationPartition['enabled'] - : - | ApplySituationPartition['enabled'] - | ApplySituationPartition['conditional'] + ApplySituationPartition['conditional'] > { const situation = this.#situations.get(name); if (!situation) { @@ -672,15 +648,7 @@ export class ToolSet< return this.#resolveWithActivation(activation, input) as unknown as ResolvedToolSnapshot< TTools, ApplySituationPartition, - [ - ApplySituationPartition['conditional'], - ] extends [ - never, - ] - ? ApplySituationPartition['enabled'] - : - | ApplySituationPartition['enabled'] - | ApplySituationPartition['conditional'] + ApplySituationPartition['conditional'] >; } diff --git a/packages/agent-tool-set/src/types.ts b/packages/agent-tool-set/src/types.ts index 898721e8..59593f6c 100644 --- a/packages/agent-tool-set/src/types.ts +++ b/packages/agent-tool-set/src/types.ts @@ -115,6 +115,11 @@ export type FilterToolsByIds< : FilterToolsByIds : readonly []; +/** IDs that are statically enabled or selected from the conditional partition. */ +type ResolvedActiveIds

= + | P['enabled'] + | Extract; + /** * Exact tuple for static partitions; possible active members for partitions * whose runtime predicates can change tuple membership. @@ -122,14 +127,17 @@ export type FilterToolsByIds< export type ResolvedTools< TTools extends readonly Tool[], P extends Partition, - TActive extends string = P['enabled'] | P['conditional'], + TActive extends P['conditional'] = P['conditional'], > = [ P['conditional'], ] extends [ never, ] - ? FilterToolsByIds> - : readonly FilterToolsByIds>[number][]; + ? FilterToolsByIds> + : readonly FilterToolsByIds< + TTools, + ResolvedActiveIds & ToolIdsOfTuple + >[number][]; // ─── three-way compile-time partition ─────────────────────────────────────── @@ -352,12 +360,11 @@ export type StatusByToolMap = { /** * What resolve() / resolveSituation() returns. * - * For static-only partitions (`conditional = never`), TActive is exactly - * `P['enabled']` and the snapshot is fully known at compile time. - * When conditional ≠ never, TActive is the sound upper bound - * `P['enabled'] | P['conditional']`; tools become a readonly array of the - * possible active member union because predicates can change length and - * positions at runtime. Runtime arrays/status are exact. + * For static-only partitions (`conditional = never`), the snapshot is fully + * known at compile time. When conditional ≠ never, TActive selects possible + * members from `P['conditional']`; `P['enabled']` is always included. Tools + * become a readonly array of that possible member union because predicates + * can change length and positions at runtime. Runtime arrays/status are exact. * * `disabled` is declared as the complement of the *definitely-enabled* set * (`P['enabled']`), not of `TActive`. A conditional id's predicate can @@ -370,19 +377,25 @@ export type StatusByToolMap = { export type ResolvedToolSnapshot< TTools extends readonly Tool[], P extends Partition, - TActive extends string = P['enabled'] | P['conditional'], + TActive extends P['conditional'] = P['conditional'], > = { /** Active tools only, construction order preserved, concrete member types kept. */ readonly tools: ResolvedTools; /** Active client names only (`callModel.activeTools` wire format). Server ids omitted. */ - readonly activeTools: readonly Extract>[]; + readonly activeTools: readonly Extract< + ResolvedActiveIds, + ClientToolNamesOfTuple + >[]; /** Spread-safe input for `callModel`; snapshot metadata is intentionally excluded. */ readonly callModel: { readonly tools: ResolvedTools; - readonly activeTools: readonly Extract>[]; + readonly activeTools: readonly Extract< + ResolvedActiveIds, + ClientToolNamesOfTuple + >[]; }; /** IDs that resolved active (client + server). */ - readonly enabled: readonly (TActive & ToolIdsOfTuple)[]; + readonly enabled: readonly (ResolvedActiveIds & ToolIdsOfTuple)[]; /** * IDs that resolved inactive (client + server). Sound upper bound: the * complement of the definitely-enabled set, so conditional ids whose diff --git a/packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts b/packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts index 28c1dc0e..b5e7ed18 100644 --- a/packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts +++ b/packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts @@ -75,8 +75,12 @@ type GenericActiveA = ResolvedToolSnapshot< ConditionalA, 'a' >; -expectTypeOf().toEqualTypeOf(); -expectTypeOf().toEqualTypeOf(); +declare const genericActiveA: GenericActiveA; +expectTypeOf(genericActiveA.tools).toEqualTypeOf(); +expectTypeOf(genericActiveA.callModel.tools).toEqualTypeOf(); +expectTypeOf().toEqualTypeOf(); +expectTypeOf(genericActiveA.tools[0]).toEqualTypeOf(); +expectTypeOf().toEqualTypeOf<'a' | 'b'>(); const mutableSnapshot = createToolSet({ tools: [