Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/agent-tool-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@openrouter/agent-tool-set": minor
"@openrouter/agent": minor
---

Add `@openrouter/agent-tool-set` (port of ai-tool-set v1.0.0, MIT © zirkelc): declarative activate / deactivate / activateWhen / deactivateWhen for tools with state- and context-aware predicates. Integrates with a new `activeTools?: readonly string[]` option on `callModel` that filters which tools are sent to the model for a given call.
64 changes: 64 additions & 0 deletions packages/agent-tool-set/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# @openrouter/agent-tool-set

Declarative, state-aware activation and deactivation for tools used with `@openrouter/agent`.

Port of [`ai-tool-set`](https://github.com/zirkelc/ai-tool-set) v1.0.0 (MIT © zirkelc), adapted for this SDK:

- Input is an ordered array of `Tool` (as used by `callModel`), not a name-keyed record.
- Predicates receive `{ state, context }` where `state` is the SDK's `ConversationState` and `context` is the typed shared context.
- Integrates with a new `activeTools` option on `callModel` — you can spread `inferTools()` directly into the request.

## Install

```bash
pnpm add @openrouter/agent-tool-set
```

## Usage

```ts
import { OpenRouter, tool, callModel } from '@openrouter/agent';
import { createToolSet } from '@openrouter/agent-tool-set';
import { z } from 'zod/v4';

const listOrders = tool({
name: 'list_orders',
inputSchema: z.object({}),
execute: async () => ({ orders: [] }),
});

const cancelOrder = tool({
name: 'cancel_order',
inputSchema: z.object({ id: z.string() }),
execute: async () => ({ ok: true }),
});

const toolSet = createToolSet({ tools: [listOrders, cancelOrder] as const })
.activateWhen('list_orders', ({ context }) => context?.isAuthenticated === true)
.deactivateWhen('cancel_order', ({ state }) => (state?.messages?.length ?? 0) === 0);

const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY });

const { tools, activeTools } = toolSet.inferTools({ context: { isAuthenticated: true } });

const result = callModel(client, {
model: 'openai/gpt-4o-mini',
input: 'List my orders.',
tools,
activeTools,
});
```

## API

- `createToolSet({ tools, mutable? })` — build a set from an ordered tool array.
- `.tools` — all tools in construction order, regardless of activation.
- `.activate(name | names[])` / `.deactivate(name | names[])` — static flip.
- `.activateWhen(name, predicate)` / `.activateWhen({ [name]: predicate, ... })` — conditional activation (defaults inactive).
- `.deactivateWhen(name, predicate)` / `.deactivateWhen({ [name]: predicate, ... })` — conditional deactivation (defaults active).
- `.inferTools(input?)` → `{ tools: Tool[]; activeTools: string[] }` — resolve against an input.
- `.clone({ mutable? })` — copy state, optionally flipping mode.

Last-call-wins: each directive on a given tool replaces any prior one for that tool.

Immutable by default (every mutator returns a new `ToolSet`). Pass `mutable: true` to mutate in place.
55 changes: 55 additions & 0 deletions packages/agent-tool-set/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"name": "@openrouter/agent-tool-set",
"version": "0.1.0",
"author": "OpenRouter",
"description": "Declarative activation/deactivation for @openrouter/agent tools. Port of ai-tool-set (MIT © zirkelc) adapted for callModel + tool().",
"keywords": [
"openrouter",
"agent",
"tools",
"toolset",
"typescript",
"ai"
],
"license": "Apache-2.0",
"type": "module",
"main": "./esm/index.js",
"exports": {
".": {
"types": "./esm/index.d.ts",
"default": "./esm/index.js"
},
"./package.json": "./package.json"
},
"sideEffects": false,
"repository": {
"type": "git",
"url": "https://github.com/OpenRouterTeam/typescript-agent.git",
"directory": "packages/agent-tool-set"
},
"publishConfig": {
"access": "public",
"provenance": true
},
"files": [
"esm",
"package.json",
"README.md"
],
"scripts": {
"lint": "biome check src tests",
"lint:fix": "biome check --write src tests",
"build": "tsc",
"test": "vitest --run --project unit",
"test:e2e": "vitest --run --project e2e",
"test:watch": "vitest --watch --project unit",
"typecheck": "tsc --noEmit",
"compile": "tsc"
},
"dependencies": {
"@openrouter/agent": "workspace:*"
},
"peerDependencies": {
"zod": "^4.0.0"
}
}
2 changes: 2 additions & 0 deletions packages/agent-tool-set/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createToolSet, ToolSet } from './tool-set.js';
export type { ActivationInput, ActivationPredicate, InferToolSet } from './types.js';
253 changes: 253 additions & 0 deletions packages/agent-tool-set/src/tool-set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import type { Tool } from '@openrouter/agent';
import { isServerTool } from '@openrouter/agent';
import type { ActivationInput, ActivationPredicate } from './types.js';

type Entry<TShared extends Record<string, unknown>> =
| {
kind: 'static';
active: boolean;
}
| {
kind: 'activateWhen';
predicate: ActivationPredicate<TShared>;
}
| {
kind: 'deactivateWhen';
predicate: ActivationPredicate<TShared>;
};

function toNameArray(names: string | readonly string[]): readonly string[] {
return typeof names === 'string'
? [
names,
]
: names;
}

function isPredicateMap<TShared extends Record<string, unknown>>(
value: unknown,
): value is Record<string, ActivationPredicate<TShared>> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function buildToolsMap(tools: readonly Tool[]): Map<string, Tool> {
const map = new Map<string, Tool>();
for (const t of tools) {
if (isServerTool(t)) {
continue;
Comment thread
mattapperson marked this conversation as resolved.
}
const name = t.function.name;
if (map.has(name)) {
throw new Error(`Duplicate tool name: "${name}"`);
}
map.set(name, t);
}
return map;
}

export class ToolSet<
TTools extends readonly Tool[] = readonly Tool[],
TShared extends Record<string, unknown> = Record<string, unknown>,
> {
readonly #tools: Map<string, Tool>;
readonly #activation: Map<string, Entry<TShared>>;
readonly #mutable: boolean;

private constructor(
tools: Map<string, Tool>,
activation: Map<string, Entry<TShared>>,
mutable: boolean,
) {
this.#tools = tools;
this.#activation = activation;
this.#mutable = mutable;
}

/** Internal factory. Prefer `createToolSet` for the public API. */
static create<
T extends readonly Tool[],
S extends Record<string, unknown> = Record<string, unknown>,
>(opts: { tools: T; mutable?: boolean }): ToolSet<T, S> {
return new ToolSet<T, S>(buildToolsMap(opts.tools), new Map(), opts.mutable ?? false);
}

/** All tools in construction order, regardless of activation state. */
get tools(): readonly Tool[] {
return Array.from(this.#tools.values());
}

#assertKnown(name: string): void {
if (!this.#tools.has(name)) {
throw new Error(`Unknown tool: "${name}"`);
}
}

#withMutation(
mutate: (activation: Map<string, Entry<TShared>>) => void,
): ToolSet<TTools, TShared> {
if (this.#mutable) {
mutate(this.#activation);
return this;
}
const nextActivation = new Map(this.#activation);
mutate(nextActivation);
return new ToolSet<TTools, TShared>(this.#tools, nextActivation, false);
}

activate(names: string | readonly string[]): ToolSet<TTools, TShared> {
const list = toNameArray(names);
for (const n of list) {
this.#assertKnown(n);
}
return this.#withMutation((activation) => {
for (const n of list) {
activation.set(n, {
kind: 'static',
active: true,
});
}
});
}

deactivate(names: string | readonly string[]): ToolSet<TTools, TShared> {
const list = toNameArray(names);
for (const n of list) {
this.#assertKnown(n);
}
return this.#withMutation((activation) => {
for (const n of list) {
activation.set(n, {
kind: 'static',
active: false,
});
}
});
}

activateWhen(name: string, predicate: ActivationPredicate<TShared>): ToolSet<TTools, TShared>;
activateWhen(map: Record<string, ActivationPredicate<TShared>>): ToolSet<TTools, TShared>;
activateWhen(
nameOrMap: string | Record<string, ActivationPredicate<TShared>>,
predicate?: ActivationPredicate<TShared>,
): ToolSet<TTools, TShared> {
const entries = this.#normalizePredicateArg(nameOrMap, predicate);
return this.#withMutation((activation) => {
for (const [n, p] of entries) {
activation.set(n, {
kind: 'activateWhen',
predicate: p,
});
}
});
}

deactivateWhen(name: string, predicate: ActivationPredicate<TShared>): ToolSet<TTools, TShared>;
deactivateWhen(map: Record<string, ActivationPredicate<TShared>>): ToolSet<TTools, TShared>;
deactivateWhen(
nameOrMap: string | Record<string, ActivationPredicate<TShared>>,
predicate?: ActivationPredicate<TShared>,
): ToolSet<TTools, TShared> {
const entries = this.#normalizePredicateArg(nameOrMap, predicate);
return this.#withMutation((activation) => {
for (const [n, p] of entries) {
activation.set(n, {
kind: 'deactivateWhen',
predicate: p,
});
}
});
}

#normalizePredicateArg(
nameOrMap: string | Record<string, ActivationPredicate<TShared>>,
predicate?: ActivationPredicate<TShared>,
): Array<
[
string,
ActivationPredicate<TShared>,
]
> {
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<TShared>(nameOrMap)) {
throw new Error('activateWhen/deactivateWhen requires a name+predicate or predicate map');
}
const entries: Array<
[
string,
ActivationPredicate<TShared>,
]
> = Object.entries(nameOrMap);
for (const [n] of entries) {
this.#assertKnown(n);
}
return entries;
}

/**
* Resolve activation against an input and return the filtered active tools
* plus the parallel list of active names, both in construction order.
*/
inferTools(input?: ActivationInput<TShared>): {
tools: Tool[];
activeTools: string[];
} {
const resolved: ActivationInput<TShared> = input ?? {};
const tools: Tool[] = [];
const activeTools: string[] = [];
for (const [name, t] of this.#tools) {
if (this.#resolveActive(name, resolved)) {
tools.push(t);
activeTools.push(name);
}
}
return {
tools,
activeTools,
};
}

#resolveActive(name: string, input: ActivationInput<TShared>): boolean {
const entry = this.#activation.get(name);
if (!entry) {
return true;
}
if (entry.kind === 'static') {
return entry.active;
}
if (entry.kind === 'activateWhen') {
return entry.predicate(input) === true;
}
return entry.predicate(input) !== true;
}

clone(opts?: { mutable?: boolean }): ToolSet<TTools, TShared> {
return new ToolSet<TTools, TShared>(
this.#tools,
new Map(this.#activation),
opts?.mutable ?? this.#mutable,
);
}
}

export function createToolSet<T extends readonly Tool[]>(opts: {
Comment thread
mattapperson marked this conversation as resolved.
Outdated
tools: T;
mutable?: boolean;
}): ToolSet<T> {
return ToolSet.create<T>({
tools: opts.tools,
...(opts.mutable !== undefined && {
mutable: opts.mutable,
}),
});
}
Loading
Loading