Skip to content

feat(agent-tool-set) - #31

Open
mattapperson wants to merge 23 commits into
mainfrom
toolkits
Open

feat(agent-tool-set)#31
mattapperson wants to merge 23 commits into
mainfrom
toolkits

Conversation

@mattapperson

@mattapperson mattapperson commented Apr 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds @openrouter/agent-tool-set — a declarative, immutable-by-default ToolSet for activating/deactivating tools with static rules, state/context-aware predicates, and named "situations" (fixed overlay configs), backed by a compile-time three-way partition (enabled / disabled / conditional) of stable tool-set IDs.
  • Adds a new activeTools?: readonly string[] option to @openrouter/agent's callModel, so a ToolSet snapshot's callModel sub-object can be spread directly into a request. Filtering is applied once, before both API conversion and executor/ModelResult registration, so excluded tools are neither advertised to the model nor callable. Server tools always bypass this filter.
  • Adds stable ids to server tools (ServerToolBase.id, default server:${config.type}, overridable via serverTool(config, { id })) so they can participate in tool-set activation and identity alongside client tools (function.name).
  • Adds "correlated" tool event types (CorrelatedToolResultEvent, CorrelatedToolPreliminaryResultEvent, CorrelatedToolEventUnion, CorrelatedResponseStreamEvent, CorrelatedToolStreamEvent) that let consumers narrow event.toolName to recover the exact per-tool result/event payload type for a given tools tuple. The existing wide event shapes (ResponseStreamEvent, ToolResultEvent, ToolPreliminaryResultEvent, ToolStreamEvent) are unchanged in shape but now carry an optional toolName field and a TName generic for back-compat.

Public API (@openrouter/agent-tool-set)

  • createToolSet<T, TShared?>({ tools, mutable? }) — build a set from an ordered tool array. Duplicate tool-set IDs throw at construction. Immutable by default; mutable: true mutates in place (partition type parameters may widen, but runtime state stays exact).
  • .tools — full tools tuple in construction order, regardless of activation.
  • .activate(id | id[]) / .deactivate(id | id[]) — static flip, last-call-wins, accepts client names and server IDs.
  • .activateWhen(id, predicate) / .activateWhen({ [id]: predicate }) and .deactivateWhen(...) (same two call shapes) — conditional activation driven by { state?, context? }; moves the ID into the compile-time conditional partition.
  • .defineSituations({ [name]: { enabled?, disabled?, conditional? } }) — named declarative overlays; unmentioned IDs keep the base partition state. Unknown/duplicate/conflicting IDs within one situation throw.
  • .resolve(input?)ResolvedToolSnapshot — resolve against the base partition (see below).
  • .resolveSituation(name, input?)ResolvedToolSnapshot — resolve with a named situation's overlay applied first. Fully static situations produce an exact tool tuple at compile time.
  • .inferTools(input?) — back-compat alias for .resolve(); returns { tools, activeTools, enabled, disabled, statusByTool } (not just { tools, activeTools }). Prefer .resolve() in new code.
  • .clone({ mutable? }) — copy state, optionally flipping mutability.
  • Inference utilities: InferAllIds, InferEnabledIds, InferDisabledIds, InferConditionalIds (compile-time ID sets recovered from a ToolSet instance), and InferToolSet<T> (alias of the agent's CorrelatedToolEventUnion<T>).
  • Supporting types: ActivationInput, ActivationPredicate, Partition/EmptyPartition/InitialPartition/ActivatePartition/DeactivatePartition/ConditionalPartition/ApplySituationPartition, SituationConfig/SituationMap/SituationNames/SituationConditionalRule/InferSituationEntry/InferSituationMap/EmptySituations, StatusReason/ToolStatusEntry/StatusByToolMap, ResolvedToolSnapshot, ToolSetLike, identity/filter helpers (ClientToolName, ClientToolNamesOfTuple, ServerToolIdOf, ServerToolIdsOfTuple, ToolById, ToolIdOf, ToolIdsOfTuple, FilterToolsByIds).

Server tool IDs

Every tool gets a stable tool-set ID: client tools use function.name; server tools default to server:${config.type} and can be overridden with serverTool(config, { id: 'server:public_search' }). serverTool() throws if options.id === ''. IDs are namespace-prefixed so a server tool can never collide with a client function name, and duplicate IDs across the tuple throw at createToolSet construction.

resolve / resolveSituation / partitions

Partition tracks every tool-set ID in exactly one of three compile-time buckets — enabled, disabled, conditional — refined as .activate/.deactivate (static) and .activateWhen/.deactivateWhen (conditional) are called; .defineSituations + .resolveSituation overlay a named, fixed configuration onto that base partition (ApplySituationPartition). When a partition is purely static (no conditional IDs), .resolve()/.resolveSituation() return an exact active-tool tuple at compile time; conditional IDs widen the compile-time upper bound to enabled | conditional, while the runtime snapshot (tools, activeTools, enabled, disabled, statusByTool) is always exhaustive and exact — every known ID appears in statusByTool with its resolved { enabled, reason, directive?, predicate? }.

Spread-safe .callModel

ResolvedToolSnapshot includes a nested callModel: { tools, activeTools } sub-object in addition to the top-level tools/activeTools/enabled/disabled/statusByTool fields. Spreading ...snapshot.callModel into callModel(client, { ...snapshot.callModel, model, input }) passes only tools/activeTools; spreading the top-level snapshot instead would leak enabled/disabled/statusByTool as unrecognized callModel request fields. This is the fix for the metadata-leak footgun.

Agent changes (@openrouter/agent)

  • BaseCallModelInput gains activeTools?: readonly string[], listed in clientOnlyFields. callModel computes the filtered tool list once and uses it for both convertToolsToAPIFormat and the tools passed toward ModelResult/the executor, so excluded tools are neither advertised to the model nor executable. The filter predicate short-circuits true for server tools (isServerTool(t) || activeSet.has(t.function.name)), so server tools always remain active regardless of activeTools.
  • ServerToolBase gains an id: string field; serverTool<T, TId>(config, options?: { id?: TId }) accepts an optional id override, defaulting to server:${config.type} and throwing on an empty-string override.
  • New correlated event types in tool-types.ts: CorrelatedToolPreliminaryResultEvent, CorrelatedToolResultEvent, CorrelatedToolEventUnion, CorrelatedResponseStreamEvent, CorrelatedToolStreamEvent. ToolPreliminaryResultEvent, ToolResultEvent, ResponseStreamEvent, ToolStreamEvent, and ChatStreamEvent each gain a TName extends string = string generic and (where applicable) a toolName: TName field, preserving the existing wide/untyped shapes as the default.

API example

import { OpenRouter, tool, serverTool, callModel } from '@openrouter/agent';
import { createToolSet } 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 toolSet = createToolSet<[typeof listOrders, typeof cancelOrder, typeof login, typeof webSearch], AppContext>({
  tools: [listOrders, cancelOrder, login, webSearch],
})
  .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 },
    },
  });

const authenticated = toolSet.resolveSituation('authenticated', {
  context: { isAuthenticated: true, isAdmin: false },
});
// authenticated.tools / .activeTools / .enabled / .disabled / .statusByTool are exhaustive.

const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY });
const result = await callModel(client, {
  model: 'openai/gpt-4o-mini',
  input: 'List my orders.',
  ...authenticated.callModel, // spread-safe: only { tools, activeTools }, no metadata leaks in
});

Test plan

  • pnpm build — both packages compile
  • pnpm typecheck — strict mode clean
  • pnpm test — 775 unit tests pass (660 across 54 files in @openrouter/agent + 41 across 1 file in @openrouter/agent-tool-set)
    • packages/agent-tool-set/src/tool-set.test.ts — 41 cases across createToolSet, activate/deactivate, activateWhen/deactivateWhen, last-call-wins semantics, immutability vs mutability, clone, resolve/inferTools input shapes, exhaustive statusByTool snapshots, compile-time partition inference, server tools, defineSituations/resolveSituation, the TShared generic, InferToolSet/event narrowing, and the callModel-oriented spread shape.
    • packages/agent/tests/unit/call-model-active-tools.test.ts — cases verifying the outbound request body via a capturing HTTPClient, including that server tools bypass the activeTools filter.
  • pnpm lint — Biome clean
  • pnpm changeset status — both packages bumped minor

Release

.changeset/agent-tool-set.md — minor bump for both @openrouter/agent-tool-set (0.1.0 initial release) and @openrouter/agent (new activeTools option, server tool id, correlated event types).


Open in Devin Review

Adds a new workspace package with declarative activate/deactivate/
activateWhen/deactivateWhen for tools, with predicates that receive the
SDK's ConversationState and typed shared context. Also adds an
`activeTools?: readonly string[]` option to callModel so inferTools()
output can be spread directly into a request.

Port of ai-tool-set v1.0.0 (MIT (C) zirkelc).
@mattapperson mattapperson changed the title feat(agent-tool-set): port ai-tool-set to @openrouter/agent-tool-set feat(agent-tool-set) Apr 20, 2026
Drops the record-mapping InferToolSet and its InferActiveTools /
InferInactiveTools aliases (faithful-port artifacts without true
partition narrowing). The streaming-events discriminated union takes
the InferToolSet name.
@mattapperson
mattapperson changed the base branch from turborepo-migration to main April 20, 2026 19:00

@robert-j-y robert-j-y left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs a rebase onto the current turborepo-migration — PR #30 landed and widened Tool to ClientTool | ServerToolBase. After rebase, two sites break:

1. packages/agent/src/inner-loop/call-model.ts:102 — produces TS2339: Property 'function' does not exist on type 'Tool'. Property 'function' does not exist on type 'ServerToolBase'. at compile time, and TypeError: Cannot read properties of undefined (reading 'name') at runtime if any serverTool(...) is in the array. Change:

const filteredTools = activeSet ? tools?.filter((t) => activeSet.has(t.function.name)) : tools;

to:

const filteredTools = activeSet
  ? tools?.filter((t) => isServerTool(t) || activeSet.has(t.function.name))
  : tools;

(import isServerTool from ../lib/tool-types.js)

2. packages/agent-tool-set/src/tool-set.ts buildToolsMap (lines 32–42) — same root cause. Compile error doesn't surface yet only because agent-tool-set resolves @openrouter/agent through the stale compiled .d.ts; runtime t.function.name still throws on any server tool passed to createToolSet. Change:

for (const t of tools) {
  const name = t.function.name;
  if (map.has(name)) throw new Error(`Duplicate tool name: "${name}"`);
  map.set(name, t);
}

to:

for (const t of tools) {
  if (isServerTool(t)) continue;
  const name = t.function.name;
  if (map.has(name)) throw new Error(`Duplicate tool name: "${name}"`);
  map.set(name, t);
}

(import isServerTool from @openrouter/agent)

Server tools have no name to activate by, so skipping them keeps ToolSet client-tool-only while still allowing users to pass a mixed array through.

3. PR description: InferActiveTools and InferInactiveTools are listed under "Types:" but are not exported from packages/agent-tool-set/src/index.ts. Remove or add.

…er and buildToolsMap

Addresses review feedback on PR #31 after rebasing onto current main
(PR #30 widened `Tool` to `ClientTool | ServerToolBase`).

- call-model.ts: filter keeps server tools unconditionally; name matching
  only applies to client tools, preventing `t.function.name` access on
  `ServerToolBase`.
- tool-set.ts: `buildToolsMap` skips server tools since they have no
  name to activate by; `createToolSet` remains client-tool-only while
  accepting mixed arrays.
mattapperson added a commit that referenced this pull request Apr 21, 2026
…er and buildToolsMap

Addresses review feedback on PR #31 after rebasing onto current main
(PR #30 widened `Tool` to `ClientTool | ServerToolBase`).

- call-model.ts: filter keeps server tools unconditionally; name matching
  only applies to client tools, preventing `t.function.name` access on
  `ServerToolBase`.
- tool-set.ts: `buildToolsMap` skips server tools since they have no
  name to activate by; `createToolSet` remains client-tool-only while
  accepting mixed arrays.

@mattapperson mattapperson left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two concerns on the new ToolSet surface worth a look before merge.

Comment thread packages/agent-tool-set/src/tool-set.ts Outdated
Comment thread packages/agent-tool-set/src/tool-set.ts Outdated
Addresses review feedback on PR #31:

- Server tools are no longer silently dropped. ToolSet now tracks the
  full ordered list separately from the client-tool name index, so
  `.tools` and `.inferTools()` return both client and server tools.
  Server tools are always active (no name to filter by) and never appear
  in the `activeTools` list returned by `inferTools()`.
- `createToolSet` now exposes the `TShared` generic
  (`createToolSet<T, TShared>`), so predicates type `context` as the
  user's context shape instead of `Record<string, unknown>`.
mattapperson added a commit that referenced this pull request Apr 21, 2026
Addresses review feedback on PR #31:

- Server tools are no longer silently dropped. ToolSet now tracks the
  full ordered list separately from the client-tool name index, so
  `.tools` and `.inferTools()` return both client and server tools.
  Server tools are always active (no name to filter by) and never appear
  in the `activeTools` list returned by `inferTools()`.
- `createToolSet` now exposes the `TShared` generic
  (`createToolSet<T, TShared>`), so predicates type `context` as the
  user's context shape instead of `Record<string, unknown>`.

@mattapperson mattapperson left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reviewed, no issues found

LukasParke pushed a commit that referenced this pull request Jul 22, 2026
…er and buildToolsMap

Addresses review feedback on PR #31 after rebasing onto current main
(PR #30 widened `Tool` to `ClientTool | ServerToolBase`).

- call-model.ts: filter keeps server tools unconditionally; name matching
  only applies to client tools, preventing `t.function.name` access on
  `ServerToolBase`.
- tool-set.ts: `buildToolsMap` skips server tools since they have no
  name to activate by; `createToolSet` remains client-tool-only while
  accepting mixed arrays.
LukasParke pushed a commit that referenced this pull request Jul 22, 2026
Addresses review feedback on PR #31:

- Server tools are no longer silently dropped. ToolSet now tracks the
  full ordered list separately from the client-tool name index, so
  `.tools` and `.inferTools()` return both client and server tools.
  Server tools are always active (no name to filter by) and never appear
  in the `activeTools` list returned by `inferTools()`.
- `createToolSet` now exposes the `TShared` generic
  (`createToolSet<T, TShared>`), so predicates type `context` as the
  user's context shape instead of `Record<string, unknown>`.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-reviewed the head commit (769c98c) for the toolkits branch. The diff is functionally the same shape as previously assessed: @openrouter/agent-tool-set (new package) plus the activeTools filter wired into callModel before convertToolsToAPIFormat and before the executor's tools list (packages/agent/src/inner-loop/call-model.ts:99-119). I traced the filter path, isServerTool short-circuiting, and the last-call-wins activation resolver in packages/agent-tool-set/src/tool-set.ts, and found no correctness or security regressions. No functional changes since the prior pass were evident in this diff, so I'm treating any earlier design-level concerns as still open (see findings) rather than newly introduced or resolved, since neither the filtering logic nor the ToolSet activation semantics changed.

Findings (3)

🟡 minor · packages/agent/src/lib/async-params.ts:64-70
activeTools is typed as plain readonly string[] with no constraint against the tool names actually present in TTools. A typo (or a stale name after a tool is renamed/removed) silently drops that tool from the request instead of erroring — confirmed intentional by the 'silently ignores unknown activeTools names' test in call-model-active-tools.test.ts. Worth at least a dev-mode warning when a name in activeTools matches nothing in tools, since this is the kind of bug that fails silently rather than loudly.

🟡 minor · packages/agent-tool-set/src/tool-set.ts:214-226
inferTools()/activate()/deactivate() only validate tool names against #clientToolsByName, so a caller mixing a ToolSet's activeTools output with an unrelated/mutated tools array in callModel gets no cross-check that the two lists actually correspond — the two must be kept in sync by convention only. Not a bug given current usage (README pattern spreads {tools, activeTools} together), but worth a doc callout for anyone splitting the two fields.

nit · packages/agent-tool-set/package.json:14
License is declared Apache-2.0 for a package whose core logic is a port of MIT-licensed ai-tool-set; THIRD_PARTY_NOTICES.md covers attribution correctly, so this is fine as-is, just flagging for legal sign-off consistency with any other ported code in the repo.

…pshots

Add a compile-time enabled/disabled/conditional ID partition on ToolSet,
exhaustive resolve()/resolveSituation() snapshots with statusByTool, and
declarative defineSituations. Server tools get stable IDs (default
server:${type}, overridable via serverTool options) so they participate
in activation. InferToolSet now aliases CorrelatedToolEventUnion.

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This head commit (cd8c1dd) is functionally identical to the previously-reviewed commit; the only diff is packages/agent-tool-set/package.json dropping the test:e2e script entry (vitest.config.ts still defines the e2e project via --project e2e, just no longer exposed as a top-level pnpm script). No source, type, or test logic changed in tool-set.ts, types.ts, call-model.ts, model-result.ts, tool-types.ts, or tool.ts since my last approval.

Findings (1)

🟡 minor · packages/agent-tool-set/package.json:34
test:e2e script was removed while vitest.config.ts still declares an 'e2e' vitest project (tests/e2e/**/*.test.ts). If any e2e tests are ever added to this package, there's no longer a documented entrypoint to run them via pnpm — CI/docs relying on pnpm --filter agent-tool-set test:e2e would break silently. Carried forward from my prior review: the unenforced correspondence between a ToolSet's activeTools output and an externally-supplied tools array, and the silent no-op on unknown activeTools names in callModel, both remain open as advisory (non-blocking) points.

devin-ai-integration[bot]

This comment was marked as resolved.

@LukasParke

Copy link
Copy Markdown
Contributor

Addressed the latest review feedback in 2f5ee6e:

  • removed the stale e2e Vitest project from @openrouter/agent-tool-set, matching the package’s lack of an e2e script/suite
  • documented that snapshot tools and activeTools should stay coupled via .callModel
  • documented the established behavior that unknown activeTools names are ignored

Fresh forced lint, typecheck, unit tests, build, and root e2e all pass (98 agent e2e tests; MCP conditional tests skipped as designed).

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This head commit (2f5ee6e) makes only two changes since my last-reviewed commit: it removes the now-unused e2e vitest project block from packages/agent-tool-set/vitest.config.ts, and it adds two Notes bullets to the README documenting the tools/activeTools correspondence caveat and callModel's silent-ignore-of-unknown-names behavior. All source, type, and test files are otherwise byte-identical to the previously approved state.

Findings (1)

🟡 minor · packages/agent-tool-set/README.md:207-209
RESOLVED (as documentation): the two Notes bullets now explicitly call out that callModel cannot verify activeTools against an unrelated tools array, and that unknown activeTools names are silently ignored — directly addressing my prior advisory findings. The underlying runtime behavior is unchanged (still no cross-check), but callers are now warned and pointed at the safe .callModel spread pattern, which is the right mitigation for a library-level API like this.

@LukasParke LukasParke added cortex-keep-updated cortex keeps this PR up to date with its base branch and removed cortex-keep-updated cortex keeps this PR up to date with its base branch labels Jul 23, 2026
@cortex-github-agent

Copy link
Copy Markdown
Contributor

⚠️ cortex keep-fresh: could not auto-merge packages/agent/src/index.ts (resolver produced a line not present on any side: --- context after this hunk ---). Manual update needed; label cortex-merge-conflict added.

3 similar comments
@cortex-github-agent

Copy link
Copy Markdown
Contributor

⚠️ cortex keep-fresh: could not auto-merge packages/agent/src/index.ts (resolver produced a line not present on any side: --- context after this hunk ---). Manual update needed; label cortex-merge-conflict added.

@cortex-github-agent

Copy link
Copy Markdown
Contributor

⚠️ cortex keep-fresh: could not auto-merge packages/agent/src/index.ts (resolver produced a line not present on any side: --- context after this hunk ---). Manual update needed; label cortex-merge-conflict added.

@cortex-github-agent

Copy link
Copy Markdown
Contributor

⚠️ cortex keep-fresh: could not auto-merge packages/agent/src/index.ts (resolver produced a line not present on any side: --- context after this hunk ---). Manual update needed; label cortex-merge-conflict added.

@cortex-github-agent

Copy link
Copy Markdown
Contributor

⚠️ cortex keep-fresh: could not auto-merge packages/agent/src/index.ts (resolver produced a line not present on any side: File: packages/agent/src/index.ts). Manual update needed; label cortex-merge-conflict added.

@cortex-github-agent

Copy link
Copy Markdown
Contributor

⚠️ cortex keep-fresh: could not auto-merge packages/agent/src/index.ts (resolver produced a line not present on any side: --- context after this hunk ---). Manual update needed; label cortex-merge-conflict added.

2 similar comments
@cortex-github-agent

Copy link
Copy Markdown
Contributor

⚠️ cortex keep-fresh: could not auto-merge packages/agent/src/index.ts (resolver produced a line not present on any side: --- context after this hunk ---). Manual update needed; label cortex-merge-conflict added.

@cortex-github-agent

Copy link
Copy Markdown
Contributor

⚠️ cortex keep-fresh: could not auto-merge packages/agent/src/index.ts (resolver produced a line not present on any side: --- context after this hunk ---). Manual update needed; label cortex-merge-conflict added.

@LukasParke LukasParke removed cortex-merge-conflict cortex could not auto-merge; manual update needed cortex-keep-updated cortex keeps this PR up to date with its base branch labels Aug 6, 2026
CorrelatedToolEventUnion and CorrelatedToolStreamPreliminaryUnion checked
`T[K] extends ClientTool` inside a mapped type over T's indexed access,
which doesn't distribute for the wide `T = readonly Tool[]` case (only a
naked type parameter distributes over a union). This collapsed the whole
union to `never`, silently dropping `tool.result`/`tool.preliminary_result`
from CorrelatedResponseStreamEvent<readonly Tool[]> and
`preliminary_result` from CorrelatedToolStreamEvent<readonly Tool[]> --
regressing getFullResponsesStream/getToolStream for any caller whose tools
value isn't a fixed tuple (e.g. @openrouter/mcp's `readonly Tool[]` handle).

Apply the `readonly Tool[] extends T ? <widest> : <narrow correlated>`
idiom already used by StreamableOutputItem in stream-transformers.ts, so
the wide case falls back to the pre-existing backward-compatible shapes
while concrete tuples keep full toolName narrowing.

Adds type tests in tool-name-correlation.test-d.ts covering both the wide
fallback (tool.result/tool.preliminary_result/preliminary_result present,
not never) and the narrow tuple case (toolName narrowing unaffected).

Addresses PR #31 review thread PRRT_kwDORynLp86VKkcY.

Co-Authored-By: Claude <noreply@anthropic.com>

@LukasParke LukasParke left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review found five additional correctness/type-soundness issues beyond the existing threads. Each was reproduced against the PR head.

Comment thread packages/agent-tool-set/src/types.ts Outdated
Comment thread packages/agent-tool-set/src/tool-set.ts Outdated
Comment thread packages/agent-tool-set/src/types.ts Outdated
Comment thread packages/agent/src/lib/tool-types.ts Outdated
Comment thread packages/agent-tool-set/src/tool-set.ts
LukasParke and others added 5 commits August 6, 2026 16:53
Co-Authored-By: Claude <noreply@anthropic.com>
…Tools filters out all tools

When activeTools filters out every tool (or a fully-deactivated tool
set from inferTools()/.resolve()), callModel now collapses the
filtered list to undefined so the outbound request omits the tools
key entirely instead of sending tools: []. Several providers reject
an explicit empty tools array outright. ModelResult already treats
undefined tools as its no-tools state, so this keeps behavior
consistent end to end.

Adds a regression test using the existing capturing-client harness
that asserts the outbound request body has no tools property at all
in this case.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Devin flagged that ServerToolBase.id and the toolName field on the wide
event types (ToolPreliminaryResultEvent, ToolResultEvent,
ToolStreamEvent's preliminary_result branch, ChatStreamEvent's
tool.preliminary_result branch) became required, breaking compilation
for hand-constructed legacy values even though this PR ships as a
minor bump.

Make those base fields optional for source compatibility, while
keeping serverTool() output and the per-tool "correlated" helpers
(CorrelatedToolPreliminaryResultEvent, CorrelatedToolResultEvent)
strongly typed with required literal id/toolName via an explicit
Omit<Base, 'field'> & { field: Literal } override. Add type tests
proving both halves: legacy literals still compile, and the
factory/correlated types still reject a missing or loosely-typed
id/toolName.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
perry-the-pr-reviewer[bot]

This comment was marked as resolved.

LukasParke and others added 6 commits August 6, 2026 17:12
Co-Authored-By: Claude <noreply@anthropic.com>
Build statusByTool with Object.create(null) instead of `{}`. Tool IDs are
caller-supplied strings (serverTool only rejects the empty string), so
__proto__ is a valid ID; assigning statusByTool['__proto__'] on a plain
object invokes the inherited setter and reassigns the object's prototype
instead of creating an own property, silently dropping that ID from the
documented-exhaustive map. Mirrors the existing pattern in
extractServerToolIdentity (model-result.ts) and doom-loop.ts.

Adds regression tests covering __proto__, constructor, and prototype as
tool IDs, verifying they remain real own properties (Object.hasOwn,
Object.keys) with correct status values, alongside ordinary IDs, and that
enabled/disabled/tools/activeTools stay sound.

Co-Authored-By: Claude <noreply@anthropic.com>
… arrays

FilterToolsByIds only had a tuple-recursive branch, so a non-tuple
`readonly Tool[]` (e.g. a dynamically assembled or MCP tool array) always
fell through to `readonly []` at the type level, even though runtime
resolve()/indexTools still returned the correct active elements. This made
ResolvedToolSnapshot.tools and .callModel.tools unusable for those inputs.

Add a `number extends T['length']` guard (true for general arrays, false
for literal tuples) that routes dynamic arrays through a new distributive
per-element filter (KeepIfActive) instead of the tuple recursion. Literal
tuples keep the exact head/tail recursion unchanged, preserving order and
concrete per-element narrowing.

Adds packages/agent-tool-set/tests/unit/filter-tools-by-ids.test-d.ts
covering both the tuple case (exact filtering/order/types preserved) and
the wide readonly Tool[] case (no longer collapses to readonly []).

Co-Authored-By: Claude <noreply@anthropic.com>
…ypes

CorrelatedToolResultEvent<T> now unions `{ error: string }` into the
concrete-tool success branch of `result`, matching the shape ModelResult
actually broadcasts under `tool.result` for parse failures, thrown/rejected
executions, and tool-reported execution errors. Previously, narrowing by
`toolName` let consumers safely access success-only output fields on what
could be an error payload at runtime. The `_mcp` and wide `readonly Tool[]`
fallback branches are left as `unknown`, which already permits the error
shape.

Adds a throwing typed tool fixture and type/runtime assertions proving the
correlated type includes the error payload while preserving success
narrowing.

Addresses PR #31 review thread PRRT_kwDORynLp86XHkpG.

Co-Authored-By: Claude <noreply@anthropic.com>
…-ID server tools

When a custom-ID ServerTool<T, TId> value is widened/erased to the exported
ServerToolBase interface, its id is only known as plain string at the type
level. ServerToolIdOf previously synthesized `server:${config.type}` as the
sole valid id in that case, which is unsound: it rejects the real runtime id
and falsely accepts a default id that was never actually assigned. Widen to
string instead, so the real runtime id type-checks. Concrete ServerTool<T,
TId> values still keep their literal TId; tools with no structural id at all
still fall back to the synthesized default.

Adds type-level (expectTypeOf) and runtime tests reproducing the reviewed
scenario in PR #31 (thread PRRT_kwDORynLp86XHkpB).

Co-Authored-By: Claude <noreply@anthropic.com>
…Set aliasing

Mutable ToolSet instances now carry a single, deliberately widened
partition/situation type (WidenedPartition/WidenedSituationMap) from
construction onward, and every mutator on a mutable instance returns
that same unrefined type instead of a freshly refined one. This closes
the gap where two aliases of one mutable object could statically claim
contradictory exact partitions after only one of them mutated.

- Add TMutable type param and Mutated<...> helper on ToolSet; used by
  activate/deactivate/activateWhen/deactivateWhen/defineSituations and
  the internal #withPartitionMutation.
- ToolSet.create/createToolSet({ mutable: true }) now produce
  WidenedPartition<T>/WidenedSituationMap instead of the exact
  InitialPartition<T>/EmptySituations used by the immutable path.
- clone({ mutable: true }) widens on flip-to-mutable; clone()/
  clone({ mutable: false }) keep preserving the exact source type.
- Fix three pre-existing TS2394/TS2375 overload-compatibility errors
  under exactOptionalPropertyTypes in clone, activateWhen, and
  deactivateWhen.
- Add compile-time and runtime aliasing-soundness tests, plus a
  regression test confirming the immutable path's exact narrowing is
  unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread packages/agent/src/lib/model-result.ts Outdated
Comment on lines +3036 to +3046
// Defense-in-depth: also drop `@openrouter/agent-tool-set` snapshot
// metadata (see TOOL_SET_SNAPSHOT_METADATA_KEYS) in case it reached this
// stage without being caught by callModel()'s own filtering — this is the
// sync-path counterpart to the same check in resolveAsyncFunctions().
const resolved: Record<string, unknown> = {
...rest,
};
for (const key of TOOL_SET_SNAPSHOT_METADATA_KEYS) {
delete resolved[key];
}
return resolved as ResolvedCallModelInput;

@devin-ai-integration devin-ai-integration Bot Aug 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tool-filter list can be forwarded to the API as an unrecognized request field

The new tool-filter list is not removed (missing activeTools in the strip list at packages/agent/src/lib/model-result.ts:3036-3046) on the non-async request path, so it can travel into the outbound API request body as an unrecognized field.
Impact: A request assembled without the top-level helper can be rejected or mishandled by the provider because of an extra unknown field.

Sync request-assembly path omits `activeTools` while the async path strips it

resolveAsyncFunctions adds 'activeTools' to clientOnlyFields (packages/agent/src/lib/async-params.ts:286), but the synchronous counterpart resolveRequestForContext — whose comment explicitly says "keep this list in sync with clientOnlyFields in async-params.ts" (packages/agent/src/lib/model-result.ts:3016-3017) — still destructures only the pre-existing client-only keys (packages/agent/src/lib/model-result.ts:3020-3035) and then deletes only TOOL_SET_SNAPSHOT_METADATA_KEYS. activeTools therefore survives in rest.

In practice callModel() destructures activeTools out before constructing ModelResult, so the common path is safe. But ModelResult and GetResponseOptions are public exports and GetResponseOptions.request is typed as CallModelInput, which now permits activeTools; any consumer constructing ModelResult directly (or any future internal caller) gets the field forwarded verbatim on the sync path while the async path silently drops it — divergent behavior between the two paths for the same input.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +36 to +41
export const TOOL_SET_SNAPSHOT_METADATA_KEYS: ReadonlySet<string> = new Set([
'enabled',
'disabled',
'statusByTool',
'callModel',
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Blanket stripping of enabled/disabled/callModel keys from every outbound request

TOOL_SET_SNAPSHOT_METADATA_KEYS is applied unconditionally to every callModel() request (packages/agent/src/inner-loop/call-model.ts:155-157), to resolveAsyncFunctions (packages/agent/src/lib/async-params.ts:300), and to the sync path in model-result.ts. resolveAsyncFunctions is also a public export (./async-params). If models.ResponsesRequest ever gains — or already has — a top-level field named enabled, disabled, statusByTool, or callModel, it will be silently dropped with no warning and no compile-time signal (the field would still type-check on CallModelInput). I could not verify the SDK's full ResponsesRequest key set offline. Worth confirming against @openrouter/sdk's ResponsesRequest, and consider narrowing the guard (e.g. only strip when the value shape matches a tool-set snapshot, or only in the documented spread scenario) rather than deleting by name globally.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Resolve the main-branch merge by retaining async started/settled events in both wide and correlated response-stream unions, including the concrete correlated result type.

Co-Authored-By: Claude <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Turning off a long-running tool can make the model offer a helper action the system then ignores, breaking the conversation

The built-in task helper is offered to the model based on the unfiltered tool list (needsTaskTool(tools) at packages/agent/src/inner-loop/call-model.ts:148) even though the engine later decides whether to handle it from the filtered list, so a turned-off long-running tool leaves the model able to request an action nobody answers.
Impact: If the model uses that helper action, the request that follows is malformed and the provider rejects it, aborting the run.

Mismatch between task-tool advertisement (unfiltered) and task-tool interception (filtered)

callModel now computes filteredTools (packages/agent/src/inner-loop/call-model.ts:125-137) and passes it to the engine as engineOptions.tools, but the task-tool advertisement check at line 148 still uses the original tools. If a lifecycle: 'background'|'deferred' tool is excluded via activeTools, needsTaskTool(tools) is still true, so buildTaskToolApiDefinition(...) is appended to the wire tools.

In ModelResult, taskToolActive() (packages/agent/src/lib/model-result.ts:4087-4096) inspects this.options.tools — the filtered list — and returns false because no long-running tool remains. executeSingleToolCall therefore skips the interception branch, fails to find a tool named task in options.tools, and returns null. In executeToolRound a null outcome pushes no function_call_output, leaving the model's function_call unpaired in the follow-up request (providers 400 on unpaired calls).

The symmetric case also misbehaves: if a user tool literally named task is filtered out while a long-running tool stays active, needsTaskTool(tools) returns false (collision detected on the unfiltered list) so the task tool is never advertised, while taskToolActive() on the filtered list returns true — check-ins silently become unreachable.

Prompt for agents
In packages/agent/src/inner-loop/call-model.ts, the universal `task` tool is appended to the outbound tool definitions based on `needsTaskTool(tools)` using the ORIGINAL, unfiltered tools array, while everything else downstream (API conversion and `engineOptions.tools`) now uses the new `filteredTools` computed from `activeTools`. `ModelResult.taskToolActive()` decides whether to intercept `task` calls from `this.options.tools`, i.e. the filtered list.

Consequences of the mismatch: (a) filtering out a background/deferred tool still advertises `task` to the model, but the engine no longer intercepts it, so a `task` call produces no `function_call_output` and the next request contains an unpaired `function_call`; (b) filtering out a user tool named `task` suppresses the built-in advertisement even though interception is now enabled.

Fix by deriving the task-tool decision from the same filtered list used for `apiTools`/`engineOptions.tools` (i.e. call `needsTaskTool(filteredTools)` and gate on `filteredTools`), so advertisement and interception always agree.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +496 to 509
export function tool<
TShared extends Record<string, unknown>,
TName extends string = string,
TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>,
>(
config: ToolConfigWithSharedContext<TShared, TCtx> & {
name: TName;
},
): Tool & {
function: {
name: TName;
};
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Explicit-TShared tool() calls do not pick up the literal name

The catch-all overload now returns Tool & { function: { name: TName } }, but TName sits after the explicitly supplied TShared. TypeScript does not perform partial type-argument inference: writing tool<AppContext>({ name: 'shared_tool', ... }) fills TName from its default (string), not from the literal. Consequently name-correlated event narrowing does not work for shared-context tools, and the assertion expectTypeOf(shared.function.name).toEqualTypeOf<'shared_tool'>() in packages/agent/tests/unit/tool-name-correlation.test-d.ts:106 may not hold. Worth confirming vitest typecheck actually covers that file.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@perry-the-pr-reviewer perry-the-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perry's Review

Verdict: 🔁 Needs changes

This is a well-structured, thoroughly tested port of ai-tool-set with deep type-level design. The compile-time partition system, mutable-aliasing soundness, and prototype-pollution hardening are all excellent. However, the lint CI is failing — the same class of indentation blocker I flagged on the previous review (which was fixed for call-model.ts and async-params.ts) was re-introduced in model-result.ts when the toolName parameter was added to broadcastToolResult.

Details

Blockers

CI lint failure — two broadcastToolResult call sites in model-result.ts (lines 3371, 3442)

When String(toolCall.name) was inserted as a new parameter, the line grew longer and Biome expects the error: property on the next line to be indented 2 more spaces. The current indentation (10 spaces at line 3372, 6 spaces at line 3443) doesn't match what biome check produces. Run biome check --write packages/agent/src/lib/model-result.ts to auto-fix.

This is the third review cycle with the same root cause: editing a file with biome-formatted code without re-running the formatter on the result. Consider adding a biome check --write step to your pre-commit or CI fix-up flow.

Suggestions

UnifiedToolFunction has a dead TName generic — name-correlation is incomplete for unified/agent tools

UnifiedToolFunction declares TName extends string = string in its generic parameters but extends BaseToolFunction<TInput, TCtx> without passing TName. Every other tool interface (ToolFunctionWithExecute, ToolFunctionWithGenerator, ManualToolFunction, HITLToolFunction) was updated to thread TName through to BaseToolFunction<TInput, TCtx, TName>UnifiedToolFunction was missed. As a result, name is always string for unified/agent tools, and the CorrelatedToolEventUnion / CorrelatedResponseStreamEvent narrowing the PR introduces won't produce literal toolName types for unified tools. The TName parameter on UnifiedToolFunction is currently dead code.

Fix: extends BaseToolFunction<TInput, TCtx, TName> (and UnifiedTool / the tool() factory's run overloads would need to thread TName through too). This is not a regression — unified tools had name: string before this PR — but it's a gap in the feature's coverage that the test suite doesn't exercise (the tool-name-correlation.test-d.ts file has no unified-tool case).

Nits

  • Inconsistent String(toolCall.name) usage: Some broadcastToolResult call sites use String(toolCall.name) while others use toolCall.name or originalToolCall.name directly (e.g., line 3574). Since ParsedToolCall.name is already string, String() is a no-op. Pick one style for consistency.

  • as CorrelatedResponseStreamEvent<TTools> casts (lines 997, 1021, 6805): The runtime construction of these events includes toolName as a plain string, but the correlated type expects a literal from the tools tuple. The cast is sound in practice (toolName is always a real tool name) but bypasses the type system — a mismatched toolName would compile and produce a wrong consumer-side type. Acceptable for internal broadcast paths, but worth a comment noting the cast is intentional.

What's good

  • The three-way compile-time partition (enabled / disabled / conditional) with Exclude-based invariants is a clean design.
  • Mutable-aliasing soundness via WidenedPartition / WidenedSituationMap is well-reasoned and well-tested.
  • Object.create(null) for statusByTool with explicit __proto__ / constructor / prototype test coverage is excellent defensive coding.
  • The triple-layered TOOL_SET_SNAPSHOT_METADATA_KEYS defense (call-model.ts + async-params.ts + model-result.ts) prevents metadata leaks from all spread patterns.
  • Server tool ID handling (ServerToolIdOf widening to string when the literal is erased) is sound.
  • 775 tests pass, typecheck passes, e2e and unit tests pass. The test suite is thorough.

Risk: 🟡 Medium

Risk assessment:

Dimension Severity Risk Reasoning
Implementation risk 🟨🟨 Medium Lint CI failure blocks merge; type casts on broadcast paths bypass the type system; TName gap for unified tools.
Premise risk 🟩 Low Port of a well-understood library (ai-tool-set), adapted thoughtfully for this SDK's ordered-tuple model.
Estimated impact 🟩 Low New package (initial release) with no existing consumers; agent changes are backward-compatible (TName defaults to string).
Risk Factor Severity Risk Reasoning
Reversibility 🟩 Low All changes are additive — no deletions of existing APIs, just new exports and a type widening that defaults to the old shape.
Detectability 🟩 Low Lint failure is immediately caught by CI; the TName gap is detectable via type-level tests (though none exist yet for unified tools).
Blast radius 🟩 Low New package has no consumers yet; agent changes affect callModel's tool filtering and event types, both backward-compatible.
Data integrity 🟩 Low No persisted state is touched — the tool-set is a runtime-only abstraction.
Financial exposure None None No billing, payment, or accounting paths are touched.
Security and privacy exposure 🟩 Low Object.create(null) prevents prototype pollution from caller-supplied tool IDs; no credentials or PII are reachable.
Propagation 🟩 Low The new event types are opt-in (Correlated*); the existing wide shapes (ResponseStreamEvent, ToolStreamEvent) are unchanged.
Availability None None No serving path is affected — this is a client-side type/tooling change.
Recovery cost 🟩 Low A revert is a clean rollback; no data migration needed.
Time to correct 🟩 Low The lint fix is a single biome check --write; the TName threading is a small follow-up.


if (executed.type === 'parse_error') {
this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', {
this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: CI lint failure — after inserting String(toolCall.name), the error: property on the next line needs 2 more spaces of indentation (10 → 12) to satisfy biome check. Run biome check --write packages/agent/src/lib/model-result.ts to auto-fix. Same class of issue as the previous review's call-model.ts / async-params.ts indentation blockers.

▶ Prompt for agents: run biome check --write on packages/agent/src/lib/model-result.ts and commit the formatting fix.

} {
const message = `Tool "${toolCall.name}" timed out after ${timeoutMs}ms`;
this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', {
this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: CI lint failure — same indentation issue as line 3371. The error: property on the next line needs 2 more spaces (6 → 8) after String(toolCall.name) was added. Run biome check --write to auto-fix.

▶ Prompt for agents: run biome check --write on packages/agent/src/lib/model-result.ts and commit the formatting fix.

TName extends string = string,
TCtx extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>,
> extends BaseToolFunction<TInput, TCtx> {
> extends BaseToolFunction<TInput, TCtx, TName> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: UnifiedToolFunction doesn't thread TName to BaseToolFunction.

This line correctly updates HITLToolFunction to extends BaseToolFunction<TInput, TCtx, TName>. The same change is needed ~100 lines down on UnifiedToolFunction, which declares TName extends string = string in its generics but still extends BaseToolFunction<TInput, TCtx> (missing TName). As a result, unified/agent tools always get name: string and won't produce literal toolName types in CorrelatedToolEventUnion — the TName parameter on UnifiedToolFunction is dead code. The tool-name-correlation.test-d.ts suite has no unified-tool case to catch this.

▶ Prompt for agents: update UnifiedToolFunction to extends BaseToolFunction<TInput, TCtx, TName>, then thread TName through UnifiedTool and the tool() factory's run overloads so unified tools preserve literal names like the legacy tool kinds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants