diff --git a/.changeset/agent-tool-set.md b/.changeset/agent-tool-set.md new file mode 100644 index 00000000..e054ecc4 --- /dev/null +++ b/.changeset/agent-tool-set.md @@ -0,0 +1,37 @@ +--- +"@openrouter/agent-tool-set": minor +"@openrouter/agent": minor +--- + +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'; + +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 (_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' }); + +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, +}); +``` diff --git a/packages/agent-tool-set/README.md b/packages/agent-tool-set/README.md new file mode 100644 index 00000000..11613f18 --- /dev/null +++ b/packages/agent-tool-set/README.md @@ -0,0 +1,213 @@ +# @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) (MIT © Chris Cook), adapted for this SDK's ordered `Tool[]` / `callModel` model. See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md). + +## 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 via the snapshot's spread-safe `.callModel` input. + +## Install + +```bash +pnpm add @openrouter/agent-tool-set +``` + +## Usage + +```ts +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({ + 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 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) + .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 }); + +// 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 authenticated = toolSet.resolveSituation('authenticated', { + context: { isAuthenticated: true, isAdmin: false }, +}); + +const result = callModel(client, { + model: 'openai/gpt-4o-mini', + input: 'List my orders.', + ...authenticated.callModel, +}); +``` + +## 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 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. 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 + +### `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: /* 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) */; + 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. + +## Notes + +- 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`. +- 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/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 new file mode 100644 index 00000000..0cfabc79 --- /dev/null +++ b/packages/agent-tool-set/package.json @@ -0,0 +1,55 @@ +{ + "name": "@openrouter/agent-tool-set", + "version": "0.0.0", + "author": "OpenRouter", + "description": "Declarative activation/deactivation for @openrouter/agent tools. Port of ai-tool-set (MIT © Chris Cook) 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", + "THIRD_PARTY_NOTICES.md" + ], + "scripts": { + "lint": "biome check src tests", + "lint:fix": "biome check --write src tests", + "build": "tsc", + "test": "vitest --run --project unit", + "test:watch": "vitest --watch --project unit", + "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", + "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..c8650d41 --- /dev/null +++ b/packages/agent-tool-set/src/index.ts @@ -0,0 +1,40 @@ +export { createToolSet, ToolSet } from './tool-set.js'; +export type { + ActivatePartition, + ActivationInput, + ActivationPredicate, + ApplySituationPartition, + ClientToolName, + ClientToolNamesOfTuple, + ConditionalPartition, + DeactivatePartition, + EmptyPartition, + EmptySituations, + FilterToolsByIds, + InferAllIds, + InferConditionalIds, + InferDisabledIds, + InferEnabledIds, + InferSituationEntry, + InferSituationMap, + InferToolSet, + InitialPartition, + Partition, + ResolvedToolSnapshot, + ResolvedTools, + ServerToolIdOf, + ServerToolIdsOfTuple, + SituationConditionalRule, + SituationConfig, + SituationMap, + SituationNames, + StatusByToolMap, + StatusReason, + ToolById, + ToolIdOf, + 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 new file mode 100644 index 00000000..083d2da5 --- /dev/null +++ b/packages/agent-tool-set/src/tool-set.ts @@ -0,0 +1,892 @@ +import type { ServerToolBase, Tool } from '@openrouter/agent'; +import { isServerTool, TOOL_SET_SNAPSHOT } from '@openrouter/agent'; +import type { + ActivatePartition, + ActivationInput, + ActivationPredicate, + ApplySituationPartition, + ClientToolNamesOfTuple, + ConditionalPartition, + DeactivatePartition, + EmptySituations, + FilterToolsByIds, + InferSituationMap, + InitialPartition, + Partition, + ResolvedToolSnapshot, + ResolvedTools, + ServerToolIdsOfTuple, + SituationConditionalRule, + SituationConfig, + SituationMap, + SituationNames, + StatusByToolMap, + StatusReason, + ToolIdOf, + ToolIdsOfTuple, + ToolStatusEntry, + WidenedPartition, + WidenedSituationMap, +} from './types.js'; + +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'; + }; + +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, + ] + : names; +} + +function isPredicateMap>( + value: unknown, +): value is Record> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +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) { + const id = toolId(t); + if (toolById.has(id)) { + throw new Error(`Duplicate tool ID: "${id}"`); + } + toolById.set(id, t); + orderedIds.push(id); + if (isServerTool(t)) { + serverIds.add(id); + } else { + clientNames.add(id); + } + } + + 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>( + situation: string, + toolId: string, + rule: unknown, +): { + mode: 'activateWhen' | 'deactivateWhen'; + predicate: ActivationPredicate; +} { + if (typeof rule === 'function') { + return { + mode: 'activateWhen', + 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: (mode ?? 'activateWhen') as 'activateWhen' | 'deactivateWhen', + predicate: rule.predicate as ActivationPredicate, + }; +} + +/** + * 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. + * + * @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 + * @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>; + 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( + index: IndexedTools, + activation: Map>, + situations: Map>, + mutable: boolean, + ) { + this.#index = index; + this.#activation = activation; + this.#situations = situations; + 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: 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(), + false, + ); + } + + /** All tools in construction order, regardless of activation state. */ + get tools(): TTools { + return this.#index.orderedTools; + } + + #assertKnown(id: string): void { + if (!this.#index.toolById.has(id)) { + throw new Error(`Unknown tool: "${id}"`); + } + } + + #withPartitionMutation( + mutate: (activation: Map>) => void, + ): Mutated { + if (this.#mutable) { + // 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 Mutated; + } + const nextActivation = cloneActivationMap(this.#activation); + mutate(nextActivation); + return new ToolSet( + this.#index, + nextActivation, + this.#situations, + false, + ) as unknown as Mutated; + } + + activate>( + names: N | readonly N[], + ): Mutated> { + const list = toIdArray(names as string | readonly string[]); + for (const n of list) { + this.#assertKnown(n); + } + return this.#withPartitionMutation>((activation) => { + for (const n of list) { + activation.set(n, { + kind: 'static', + active: true, + source: 'activate', + }); + } + }); + } + + deactivate>( + names: N | readonly N[], + ): Mutated> { + const list = toIdArray(names as string | readonly string[]); + for (const n of list) { + this.#assertKnown(n); + } + return this.#withPartitionMutation>((activation) => { + for (const n of list) { + activation.set(n, { + kind: 'static', + active: false, + source: 'deactivate', + }); + } + }); + } + + activateWhen>( + name: N, + predicate: ActivationPredicate, + ): Mutated>; + activateWhen>( + map: { + readonly [K in N]?: ActivationPredicate; + }, + ): Mutated>; + activateWhen>( + nameOrMap: + | N + | { + readonly [K in N]?: ActivationPredicate; + }, + predicate?: ActivationPredicate, + ): Mutated> { + 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: N, + predicate: ActivationPredicate, + ): Mutated>; + deactivateWhen>( + map: { + readonly [K in N]?: ActivationPredicate; + }, + ): Mutated>; + deactivateWhen>( + nameOrMap: + | N + | { + readonly [K in N]?: ActivationPredicate; + }, + predicate?: ActivationPredicate, + ): Mutated> { + 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 | Partial>>, + 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).filter( + ( + entry, + ): entry is [ + string, + ActivationPredicate, + ] => typeof entry[1] === 'function', + ); + for (const [n] of entries) { + this.#assertKnown(n); + } + return entries; + } + + /** + * 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): Mutated> { + 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 ?? {}).filter( + ( + entry, + ): entry is [ + string, + SituationConditionalRule, + ] => entry[1] !== undefined, + ); + + 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(name, id, rule); + return { + id, + mode: normalized.mode, + predicate: normalized.predicate, + }; + }), + }); + } + + 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 Mutated>; + } + + return new ToolSet, false>( + this.#index, + cloneActivationMap(this.#activation), + next, + false, + ) as unknown as Mutated>; + } + + /** + * 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 { + return this.#resolveWithActivation(this.#activation, input) as unknown as ResolvedToolSnapshot< + TTools, + P, + P['conditional'] + >; + } + + /** + * 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 }); + * ``` + * + * 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[]; + activeTools: string[]; + enabled: readonly string[]; + disabled: readonly string[]; + statusByTool: StatusByToolMap; + [TOOL_SET_SNAPSHOT]: true; + } { + const snapshot = this.resolve(input); + return { + tools: [ + ...snapshot.tools, + ], + activeTools: [ + ...snapshot.activeTools, + ], + enabled: snapshot.enabled, + disabled: snapshot.disabled, + statusByTool: snapshot.statusByTool, + [TOOL_SET_SNAPSHOT]: true, + }; + } + + /** + * 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'] + > { + 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'] + >; + } + + #resolveWithActivation( + activation: Map>, + input?: ActivationInput, + ): { + tools: Tool[]; + activeTools: string[]; + callModel: { + tools: Tool[]; + activeTools: string[]; + }; + enabled: string[]; + disabled: string[]; + statusByTool: Record; + [TOOL_SET_SNAPSHOT]: true; + } { + const resolvedInput: ActivationInput = input ?? {}; + const tools: Tool[] = []; + const activeTools: string[] = []; + const enabled: string[] = []; + const disabled: string[] = []; + // 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); + if (!tool) { + continue; + } + + 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, + callModel: { + tools, + activeTools, + }, + enabled, + disabled, + statusByTool, + [TOOL_SET_SNAPSHOT]: true, + }; + } + + #evaluate( + id: string, + activation: Map>, + input: ActivationInput, + ): { + active: boolean; + entry: ActivationEntry | undefined; + } { + const entry = activation.get(id); + if (!entry) { + return { + active: true, + entry: undefined, + }; + } + if (entry.kind === 'static') { + return { + active: entry.active, + entry, + }; + } + if (entry.kind === 'activateWhen') { + return { + active: entry.predicate(input) === true, + entry, + }; + } + 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, + }; + } + + /** + * 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), + 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, WidenedSituationMap, true> + | ToolSet, EmptySituations, false> { + if (opts.mutable) { + return ToolSet.create({ + tools: opts.tools, + mutable: true, + }); + } + return ToolSet.create({ + tools: opts.tools, + mutable: false, + }); +} + +// Re-export commonly needed type helpers used at call sites without a separate import. +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 new file mode 100644 index 00000000..59593f6c --- /dev/null +++ b/packages/agent-tool-set/src/types.ts @@ -0,0 +1,449 @@ +import type { + ClientTool, + ConversationState, + CorrelatedToolEventUnion, + ServerToolBase, + Tool, +} from '@openrouter/agent'; +import { TOOL_SET_SNAPSHOT } 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}`. + * + * 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 = '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: { + 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 +>; + +/** + * 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, +> = number extends T['length'] + ? readonly KeepIfActive[] + : T extends readonly [ + infer H extends Tool, + ...infer R extends readonly Tool[], + ] + ? ToolIdOf extends Active + ? readonly [ + H, + ...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. + */ +export type ResolvedTools< + TTools extends readonly Tool[], + P extends Partition, + TActive extends P['conditional'] = P['conditional'], +> = [ + P['conditional'], +] extends [ + never, +] + ? FilterToolsByIds> + : readonly FilterToolsByIds< + TTools, + ResolvedActiveIds & ToolIdsOfTuple + >[number][]; + +// ─── 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; +}; + +/** + * 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; + 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; +}; + +export type ActivationPredicate = Record> = + (input: ActivationInput) => boolean; + +// ─── 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; + }; +}; + +/** Situations registry accumulated on the ToolSet type. */ +export type SituationMap = Record< + string, + { + enabled: string; + disabled: string; + conditional: string; + } +>; + +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; + +/** + * 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`), 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 + * 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[], + P extends Partition, + 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< + ResolvedActiveIds, + ClientToolNamesOfTuple + >[]; + /** Spread-safe input for `callModel`; snapshot metadata is intentionally excluded. */ + readonly callModel: { + readonly tools: ResolvedTools; + readonly activeTools: readonly Extract< + ResolvedActiveIds, + ClientToolNamesOfTuple + >[]; + }; + /** IDs that resolved active (client + server). */ + 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 + * predicate resolves `false` are included here too. + */ + 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 ──────────────────────── + +/** + * 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; + +/** + * Name-correlated streaming events for a tools tuple. + * Delegates to the agent's core {@link CorrelatedToolEventUnion}. + */ +export type InferToolSet = CorrelatedToolEventUnion; 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; 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..b5e7ed18 --- /dev/null +++ b/packages/agent-tool-set/tests/unit/resolved-tools.test-d.ts @@ -0,0 +1,96 @@ +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' +>; +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: [ + 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/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..bcb520e4 --- /dev/null +++ b/packages/agent-tool-set/tests/unit/server-tool-id.test-d.ts @@ -0,0 +1,57 @@ +import type { ServerToolBase } from '@openrouter/agent'; +import { serverTool } from '@openrouter/agent'; +import { expectTypeOf } from 'vitest'; +import { createToolSet } from '../../src/tool-set.js'; +import type { FilterToolsByIds, 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(); + +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 new file mode 100644 index 00000000..d13e012f --- /dev/null +++ b/packages/agent-tool-set/tests/unit/tool-set.test.ts @@ -0,0 +1,1585 @@ +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'; +import type { + InferAllIds, + InferConditionalIds, + InferDisabledIds, + InferEnabledIds, + InferToolSet, + ToolSet, + WidenedPartition, + WidenedSituationMap, +} from '../../src/index.js'; +import { createToolSet } from '../../src/index.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) => ('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', () => { + const dup = makeTool('a'); + expect(() => + createToolSet({ + tools: [ + a, + dup, + ] as const, + }), + ).toThrow(/Duplicate tool ID: "a"/); + }); + + it('constructs an empty set without tools', () => { + const ts = createToolSet({ + tools: [] as const, + }); + expect(ts.tools).toEqual([]); + expect(ts.resolve()).toMatchObject({ + tools: [], + activeTools: [], + enabled: [], + disabled: [], + statusByTool: {}, + }); + }); + + it('defaults all tools to active when no directives are set', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }); + const { tools, activeTools, enabled, disabled, statusByTool } = ts.resolve(); + expect(tools).toEqual([ + a, + b, + ]); + expect(activeTools).toEqual([ + 'a', + 'b', + ]); + expect(enabled).toEqual([ + 'a', + 'b', + ]); + expect(disabled).toEqual([]); + expect(statusByTool).toEqual({ + a: { + enabled: true, + reason: 'default', + }, + b: { + enabled: true, + reason: 'default', + }, + }); + }); +}); + +describe('activate / deactivate', () => { + it('deactivates a single tool by name', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }).deactivate('b'); + expect(ts.resolve().activeTools).toEqual([ + 'a', + 'c', + ]); + expect(ts.resolve().disabled).toEqual([ + 'b', + ]); + }); + + it('activates/deactivates arrays of names', () => { + const ts = createToolSet({ + tools: [ + a, + b, + c, + ] as const, + }) + .deactivate([ + 'a', + 'b', + ]) + .activate([ + 'b', + ]); + expect(ts.resolve().activeTools).toEqual([ + 'b', + 'c', + ]); + }); + + it('throws on unknown names', () => { + const ts = createToolSet({ + tools: [ + a, + ] as const, + }); + expect(() => ts.activate('missing' as 'a')).toThrow(/Unknown tool: "missing"/); + expect(() => + ts.deactivate([ + 'a', + 'missing' as 'a', + ]), + ).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.resolve().activeTools).toEqual([ + 'b', + ]); + expect( + ts.resolve({ + 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.resolve().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, + // @ts-expect-error unknown id + nope: () => true, + }), + ).toThrow(/Unknown tool: "nope"/); + // original untouched + expect(ts.resolve().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.resolve().activeTools).toEqual([ + 'b', + ]); + }); + + it('accepts a predicate map', () => { + const ts = createToolSet({ + tools: [ + a, + b, + ] as const, + }).deactivateWhen({ + a: () => true, + b: () => false, + }); + expect(ts.resolve().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.resolve().activeTools).toEqual([ + 'b', + ]); + + const ts2 = createToolSet({ + tools: [ + a, + b, + ] as const, + }) + .deactivateWhen('a', () => true) + .activate('a'); + expect(ts2.resolve().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.resolve().activeTools).toEqual([ + 'a', + 'b', + ]); + expect(next.resolve().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.resolve().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.resolve().activeTools).toEqual([ + 'a', + 'b', + ]); + // original untouched + expect(immutable.resolve().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); + }); + + 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', () => { + it('handles undefined and empty input', () => { + const ts = createToolSet({ + tools: [ + a, + ] as const, + }).activateWhen('a', ({ state, context }) => state === undefined && context === undefined); + expect(ts.resolve().activeTools).toEqual([ + 'a', + ]); + expect(ts.resolve({}).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.resolve({ + state, + context: { + foo: 'bar', + }, + }); + expect(spy).toHaveBeenCalledWith({ + state, + context: { + foo: 'bar', + }, + }); + }); + + 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', + ]); + }); + + 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', () => { + 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(); + // `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', () => { + 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', () => { + const webSearch = serverTool({ + type: 'web_search_2025_08_26', + }); + 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({ + tools: [ + a, + webSearch, + b, + datetime, + ] as const, + }); + expect(ts.tools).toEqual([ + a, + webSearch, + b, + datetime, + ]); + expectTypeOf>().toEqualTypeOf< + 'a' | 'b' | 'server:web_search_2025_08_26' | 'server:openrouter:datetime' + >(); + }); + + it('includes active server tools in tools/enabled/statusByTool but not activeTools', () => { + const ts = createToolSet({ + tools: [ + a, + webSearch, + b, + ] as const, + }).deactivate('a'); + const { tools, activeTools, enabled, statusByTool } = ts.resolve(); + expect(tools).toEqual([ + webSearch, + b, + ]); + 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('can deactivate server tools by stable ID', () => { + const ts = createToolSet({ + tools: [ + a, + webSearch, + b, + ] as const, + }).deactivate('server:web_search_2025_08_26'); + const { tools, enabled, disabled, statusByTool } = ts.resolve(); + expect(tools).toEqual([ + a, + b, + ]); + 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('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' 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` + // 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', () => { + 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('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: [ + 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', + ]); + expectTypeOf(denied.disabled).toEqualTypeOf(); + 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('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', + ]); + expectTypeOf(blocked.disabled).toEqualTypeOf(); + 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: [ + 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('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: [ + 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', + ]); + }); +}); + +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 }) => { + if (!context) { + return false; + } + expectTypeOf(context).toEqualTypeOf(); + return context.isAuthenticated; + }); + + expect( + ts.resolve({ + context: { + isAuthenticated: true, + userId: 'u1', + }, + }).activeTools, + ).toEqual([ + 'a', + ]); + expect( + ts.resolve({ + 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 }) => { + if (!context) { + return false; + } + expectTypeOf(context).toEqualTypeOf>(); + return context['enabled'] === true; + }); + expect( + ts.resolve({ + context: { + enabled: true, + }, + }).activeTools, + ).toEqual([ + 'a', + ]); + }); +}); + +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 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, + }); + 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(); + + // Snapshot metadata stays available without leaking into the API request. + const forCallModel: { + tools: readonly [ + typeof a, + typeof c, + ]; + activeTools: readonly ('a' | 'c')[]; + } = snapshot.callModel; + expect(forCallModel.tools).toEqual([ + a, + c, + ]); + expect(forCallModel.activeTools).toEqual([ + 'a', + 'c', + ]); + }); +}); 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/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"] +} diff --git a/packages/agent-tool-set/vitest.config.ts b/packages/agent-tool-set/vitest.config.ts new file mode 100644 index 00000000..c64e27f0 --- /dev/null +++ b/packages/agent-tool-set/vitest.config.ts @@ -0,0 +1,33 @@ +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, + }, + }, + ], + }, +}); 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/index.ts b/packages/agent/src/index.ts index 3b612660..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'; @@ -216,7 +220,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 @@ -241,10 +249,17 @@ export type { export { DEFAULT_TASK_LOG_LIMITS, ToolTask } from './lib/tool-task.js'; export type { AsyncToolAck, + BuiltinTaskToolEvent, ChatStreamEvent, ClientTool, ConversationState, ConversationStatus, + CorrelatedResponseStreamEvent, + CorrelatedToolEventUnion, + CorrelatedToolPreliminaryResultEvent, + CorrelatedToolResultEvent, + CorrelatedToolStreamEvent, + CorrelatedToolStreamPreliminaryUnion, DeferOptions, DeferredHandle, HasApprovalTools, @@ -253,6 +268,7 @@ export type { InferToolEvent, InferToolEventsUnion, InferToolInput, + InferToolName, InferToolOutput, InferToolOutputsUnion, ManualTool, @@ -265,6 +281,7 @@ export type { ResponseStreamEvent, ResponseStreamEvent as EnhancedResponseStreamEvent, ServerTool, + ServerToolBase, ServerToolConfig, ServerToolResultItem, ServerToolType, diff --git a/packages/agent/src/inner-loop/call-model.ts b/packages/agent/src/inner-loop/call-model.ts index 6c1ff25a..84a687d3 100644 --- a/packages/agent/src/inner-loop/call-model.ts +++ b/packages/agent/src/inner-loop/call-model.ts @@ -2,12 +2,14 @@ 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 { 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'; 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'; @@ -96,6 +98,7 @@ export function callModel< // Destructure state management options along with tools and stopWhen const { tools, + activeTools, stopWhen, state, requireApproval, @@ -116,8 +119,25 @@ 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 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 = 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 @@ -125,16 +145,17 @@ 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)); } - // 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 - 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, }; + stripToolSetSnapshotMetadata(finalRequest); if (apiTools !== undefined) { finalRequest['tools'] = apiTools; @@ -158,7 +179,7 @@ export function callModel< client, request: finalRequest, options: callModelOptions, - tools, + tools: filteredTools, stopWhen, state, requireApproval, 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/async-params.ts b/packages/agent/src/lib/async-params.ts index 9cabd7c1..bf5bb7ab 100644 --- a/packages/agent/src/lib/async-params.ts +++ b/packages/agent/src/lib/async-params.ts @@ -17,6 +17,27 @@ import type { // Re-export Tool type for convenience export type { Tool } from './tool-types.js'; +/** 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 @@ -62,6 +83,16 @@ 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 — spreading its + * `{ 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; /** Typed context data passed to tools via contextSchema. Includes optional `shared` key. */ context?: ContextInput>; @@ -309,12 +340,19 @@ 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 + // if (clientOnlyFields.has(key)) { continue; } diff --git a/packages/agent/src/lib/model-result.ts b/packages/agent/src/lib/model-result.ts index e8d07757..17bd4928 100644 --- a/packages/agent/src/lib/model-result.ts +++ b/packages/agent/src/lib/model-result.ts @@ -5,7 +5,11 @@ import type { RequestOptions } from '@openrouter/sdk/lib/sdks'; import type * as models from '@openrouter/sdk/models'; import type { $ZodObject, $ZodShape } from 'zod/v4/core'; import type { CallModelInput, ResolvedCallModelInput } from './async-params.js'; -import { hasAsyncFunctions, resolveAsyncFunctions } from './async-params.js'; +import { + hasAsyncFunctions, + resolveAsyncFunctions, + stripToolSetSnapshotMetadata, +} from './async-params.js'; import type { SettledToolTask, TaskToolInput, ToolSemaphore, ToolTaskMode } from './async-tools.js'; import { AsyncToolRegistry, @@ -97,11 +101,12 @@ import { import type { ConversationState, ConversationStatus, + CorrelatedResponseStreamEvent, + CorrelatedToolStreamEvent, InferToolEventsUnion, InferToolOutputsUnion, ParsedToolCall, PendingAsyncTool, - ResponseStreamEvent, ServerToolResultItem, StateAccessor, StopWhen, @@ -111,7 +116,6 @@ import type { ToolCallOutputEvent, ToolContextMapWithShared, ToolResultItem, - ToolStreamEvent, TurnContext, TurnEndEvent, TurnStartEvent, @@ -567,11 +571,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 +627,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 +858,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 +969,7 @@ export class ModelResult< */ private broadcastToolResult( toolCallId: string, + toolName: string, source: 'client' | 'mcp', result: InferToolOutputsUnion, preliminaryResults?: InferToolEventsUnion[], @@ -973,6 +977,7 @@ export class ModelResult< this.toolEventBroadcaster?.push({ type: 'tool_result' as const, toolCallId, + toolName, source, result, ...(preliminaryResults?.length && { @@ -982,13 +987,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 +1003,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 +1026,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 +2774,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,7 +3093,8 @@ export class ModelResult< if (task.status === 'completed') { this.broadcastToolResult( task.callId, - this.toolSourceByName(task.name), + String(task.name), + this.toolSourceByName(String(task.name)), task.result as InferToolOutputsUnion, ); } @@ -3287,7 +3295,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,9 +3368,14 @@ export class ModelResult< } if (executed.type === 'parse_error') { - this.broadcastToolResult(toolCall.id, 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') { @@ -3431,9 +3444,14 @@ export class ModelResult< preliminaryResultsForCall: InferToolEventsUnion[]; } { const message = `Tool "${toolCall.name}" timed out after ${timeoutMs}ms`; - this.broadcastToolResult(toolCall.id, 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, @@ -3565,7 +3583,8 @@ export class ModelResult< // `runToolWithHooks` is the single point of emission for PostToolUseFailure. this.broadcastToolResult( originalToolCall.id, - this.toolSourceByName(originalToolCall.name), + String(originalToolCall.name), + this.toolSourceByName(String(originalToolCall.name)), { error: errorMessage, } as InferToolOutputsUnion, @@ -3639,6 +3658,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 +3827,7 @@ export class ModelResult< if (settled.outcome === 'ok') { this.broadcastToolResult( toolCall.id, + String(toolCall.name), source, settled.result as InferToolOutputsUnion, ); @@ -3828,7 +3849,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 +3907,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 +4141,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, @@ -4907,9 +4933,14 @@ export class ModelResult< toolTimeoutMs: _ttm, toolConcurrency: _tc, asyncTools: _at, + activeTools: _activeTools, ...rest } = this.options.request; - return this.applyResolvedForcedToolChoicePolicy(rest as ResolvedCallModelInput); + const resolved: Record = { + ...rest, + }; + stripToolSetSnapshotMetadata(resolved); + return this.applyResolvedForcedToolChoicePolicy(resolved as ResolvedCallModelInput); } /** @@ -5584,9 +5615,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 +6362,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 +6760,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 +6799,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/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 0ea4140d..e89d7490 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 @@ -679,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; /** @@ -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; }; /** @@ -797,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; }; /** @@ -847,6 +856,16 @@ 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}. + * + * 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; } /** @@ -858,14 +877,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; } /** @@ -997,7 +1021,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; } @@ -1295,10 +1319,18 @@ 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. + * 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; }; @@ -1308,10 +1340,22 @@ 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. + * 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 @@ -1324,6 +1368,145 @@ export type ToolResultEvent = preliminaryResults?: TPreliminaryResults[]; }; +/** + * Name-correlated preliminary result event for one concrete tool. + * Narrowing on `toolName` recovers this tool's event payload type. + * + * `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. + * + * `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. + * + * `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< + 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) + | { + error: string; + }, + T extends ToolWithGenerator<$ZodObject<$ZodShape>, infer E> ? zodInfer : never, + InferToolName + >, + 'toolName' +> & { + toolName: InferToolName; + 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; + +/** + * 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. + * + * 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 = + | 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} + * 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. 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. * Broadcast by executeToolRound so passive consumers (getItemsStream) can yield @@ -1394,17 +1577,37 @@ 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 | ToolAsyncStartedEvent | ToolAsyncSettledEvent | 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> + | TurnStartEvent + | TurnEndEvent; + /** * Type guard to check if an event is a tool async started event */ @@ -1426,18 +1629,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 +1673,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 +1683,33 @@ export type ToolStreamEvent = | { type: 'preliminary_result'; toolCallId: string; + /** + * 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; }; +/** + * 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 +1721,11 @@ export type ChatStreamEvent = | { type: 'tool.preliminary_result'; toolCallId: string; + /** + * Optional for source compatibility with legacy hand-constructed + * events that predate this field. + */ + toolName?: TName; result: TEvent; } | { diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index 3731e676..a19717ca 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} */ @@ -224,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; @@ -244,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 */ @@ -373,15 +375,17 @@ 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 * type SharedCtx = z.infer; * - * const execTool = tool({ + * const execTool = tool()({ * name: "sandbox_exec", * inputSchema: z.object({ command: z.string() }), * execute: async (params, ctx) => { @@ -391,6 +395,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` / @@ -409,7 +427,7 @@ export function tool< config: RunToolConfigWithOutput & { lifecycle: 'deferred'; }, -): BuiltDeferredTool; +): BuiltDeferredTool; // Overload for unified run tools with outputSchema (any lifecycle). export function tool< @@ -420,7 +438,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). @@ -432,7 +450,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 @@ -447,7 +465,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 +475,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 +494,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,19 +504,22 @@ 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, -// the specific overloads above won't match (constraint mismatch), -// so TypeScript falls through to this catch-all. +// 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; +): Tool & { + function: { + name: string; + }; +}; // Implementation export function tool( - config: + config?: | GeneratorToolConfig<$ZodObject<$ZodShape>, $ZodType, $ZodType> | RegularToolConfig<$ZodObject<$ZodShape>, $ZodType, unknown> | ManualToolConfig<$ZodObject<$ZodShape>> @@ -503,7 +527,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( @@ -833,7 +861,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. */ @@ -986,6 +1015,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 @@ -997,26 +1038,36 @@ 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 { + 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', config, + id, }; } diff --git a/packages/agent/tests/unit/call-model-active-tools.test.ts b/packages/agent/tests/unit/call-model-active-tools.test.ts new file mode 100644 index 00000000..6d173a55 --- /dev/null +++ b/packages/agent/tests/unit/call-model-active-tools.test.ts @@ -0,0 +1,351 @@ +import { OpenRouterCore } from '@openrouter/sdk/core'; +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 = { + tools?: unknown; +}; + +function isCapturedPayload(value: unknown): value is CapturedPayload { + return typeof value === 'object' && value !== null; +} + +function isNamedTool(value: unknown): value is { + name: string; +} { + if (typeof value !== 'object' || value === null) { + return false; + } + if (!('name' in value)) { + return false; + } + return ( + typeof ( + value as { + name: unknown; + } + ).name === 'string' + ); +} + +function extractToolNames(payload: CapturedPayload): string[] { + const list = payload.tools; + if (!Array.isArray(list)) { + return []; + } + const names: string[] = []; + for (const t of list) { + if (isNamedTool(t)) { + names.push(t.name); + } + } + return names; +} + +const STOP_ERROR = '__captured__'; + +function makeCapturingClient(captured: { names: string[] | null; raw: unknown }): HTTPClient { + const httpClient = new HTTPClient(); + httpClient.request = async (request: Request): Promise => { + 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 { 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; + } = { + names: null, + raw: null, + }; + const httpClient = makeCapturingClient(captured); + const client = new OpenRouterCore({ + apiKey: 'test-key', + httpClient, + }); + + // 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.raw === null) { + throw err; + } + if (!(err instanceof Error) || err.message !== STOP_ERROR) { + // Some other error wrapped our stop error; capture already succeeded. + } + } + + if (captured.raw === null) { + throw new Error('request body was not captured'); + } + return captured; +} + +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', + ]); + }); + + 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; + 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'); + }); +}); + +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', + ], + }, + [TOOL_SET_SNAPSHOT]: true, + }; + + 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('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. + const names = await captureOutboundTools({ + tools: [ + toolA, + ], + activeTools: [ + 'a', + ], + }); + expect(names).toEqual([ + 'a', + ]); + }); +}); 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'); } }); }); 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/server-tool.test.ts b/packages/agent/tests/unit/server-tool.test.ts index da555529..a37d39ee 100644 --- a/packages/agent/tests/unit/server-tool.test.ts +++ b/packages/agent/tests/unit/server-tool.test.ts @@ -15,10 +15,38 @@ 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('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/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-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 new file mode 100644 index 00000000..eb6db3ff --- /dev/null +++ b/packages/agent/tests/unit/tool-name-correlation.test-d.ts @@ -0,0 +1,556 @@ +/** + * 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 { serverTool, tool } from '../../src/lib/tool.js'; +import type { + BuiltinTaskToolEvent, + ChatStreamEvent, + CorrelatedResponseStreamEvent, + CorrelatedToolEventUnion, + CorrelatedToolPreliminaryResultEvent, + CorrelatedToolResultEvent, + CorrelatedToolStreamEvent, + CorrelatedToolStreamPreliminaryUnion, + InferToolName, + ServerTool, + ServerToolBase, + Tool, + ToolPreliminaryResultEvent, + ToolResultEvent, + ToolStreamEvent, + 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 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({ + id: z.string(), + }), + 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({ + q: z.string(), + }), + outputSchema: z.object({ + answer: z.string(), + }), + 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'>(); +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'>(); + +// Wide defaults still assign to Tool +expectTypeOf(weather).toExtend(); +expectTypeOf(progress).toExtend(); +expectTypeOf(unified).toExtend(); +expectTypeOf(manual).toExtend(); +expectTypeOf(shared).toExtend(); +expectTypeOf(hitl).toExtend(); +expectTypeOf().toExtend(); + +type Tools = readonly [ + typeof weather, + typeof progress, + typeof unified, + typeof manual, + typeof hitl, +]; + +type Events = CorrelatedToolEventUnion; +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; + } + | { + 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; + } + | { + error: string; + } + >(); +} +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< + | { + 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; + }>(); +} + +// 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; + } + | { + error: string; + } + >(); +} + +// 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; + } + | { + 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` ----------------- +// +// 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< + 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< + WideEvents, + { + type: 'tool.result'; + } +>; +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< + CorrelatedToolEventUnion, + { + type: 'tool.result'; + } +>; +if (narrowResult.toolName === 'weather') { + expectTypeOf(narrowResult.result).toEqualTypeOf< + | { + tempC: number; + } + | { + error: string; + } + >(); +} +// --- 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`, +// including the engine-injected task helper. +expectTypeOf['toolName']>().toEqualTypeOf< + '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(); 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'); + }); +}); 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..e8041c5a --- /dev/null +++ b/packages/agent/tests/unit/tool-shared-name.test-d.ts @@ -0,0 +1,65 @@ +import { expectTypeOf } from 'vitest'; +import { z } from 'zod/v4'; +import { tool } from '../../src/lib/tool.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; +}>({ + name: 'direct_shared_tool', + inputSchema: z.object({}), + execute: async (_params, ctx) => ctx?.shared.userId ?? '', +}); +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; +}>()({ + name: 'shared_tool', + inputSchema: z.object({}), + execute: async (_params, ctx) => ctx?.shared.userId ?? '', +}); + +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; 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"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d118b7e..924c9783 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,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/mcp: dependencies: '@modelcontextprotocol/client': @@ -824,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==} @@ -1508,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 @@ -1899,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: {}