Skip to content

feat(agent-tool-set) - #31

Merged
LukasParke merged 31 commits into
mainfrom
toolkits
Aug 12, 2026
Merged

feat(agent-tool-set)#31
LukasParke merged 31 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

@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
@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.

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
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.

@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

@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 0 new potential issues.

Open in Devin Review

@cortex-github-agent

Copy link
Copy Markdown
Contributor

⚠️ cortex keep-fresh: could not auto-merge packages/agent/src/lib/async-params.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 cortex-github-agent Bot added the cortex-merge-conflict cortex could not auto-merge; manual update needed label Jul 29, 2026
@cortex-github-agent

Copy link
Copy Markdown
Contributor

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

1 similar comment
@cortex-github-agent

Copy link
Copy Markdown
Contributor

⚠️ cortex keep-fresh: could not auto-merge packages/agent/src/lib/async-params.ts (resolver produced a line not present on any side: File: packages/agent/src/lib/async-params.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/lib/async-params.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/lib/async-params.ts (resolver produced a line not present on any side: [... rest of file omitted ...]). 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/lib/async-params.ts (resolver produced a line not present on any side: File: packages/agent/src/lib/async-params.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/lib/async-params.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/lib/async-params.ts (resolver produced a line not present on any side: File: packages/agent/src/lib/async-params.ts). Manual update needed; label cortex-merge-conflict added.

LukasParke and others added 13 commits August 11, 2026 13:02
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>
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>
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[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@LukasParke
LukasParke dismissed stale reviews from cortex-github-agent[bot], perry-the-pr-reviewer[bot], and perry-the-pr-reviewer[bot] August 11, 2026 22:38

Agent: Dismissing stale automated review. The cited Biome failures are fixed and current lint/typecheck/tests pass; the UnifiedToolFunction TName suggestion is implemented with semantically checked unified-tool narrowing coverage.

@LukasParke
LukasParke merged commit 8d2ed61 into main Aug 12, 2026
7 checks passed
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