feat(agent-tool-set) - #31
Conversation
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).
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.
robert-j-y
left a comment
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
Two concerns on the new ToolSet surface worth a look before merge.
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>`.
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
left a comment
There was a problem hiding this comment.
reviewed, no issues found
…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.
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>`.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Addressed the latest review feedback in
Fresh forced lint, typecheck, unit tests, build, and root e2e all pass (98 agent e2e tests; MCP conditional tests skipped as designed). |
There was a problem hiding this comment.
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.
|
|
3 similar comments
|
|
|
|
|
|
|
|
|
|
2 similar comments
|
|
|
|
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
left a comment
There was a problem hiding this comment.
Adversarial review found five additional correctness/type-soundness issues beyond the existing threads. Each was reproduced against the PR head.
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>
| // 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; |
There was a problem hiding this comment.
🟡 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| export const TOOL_SET_SNAPSHOT_METADATA_KEYS: ReadonlySet<string> = new Set([ | ||
| 'enabled', | ||
| 'disabled', | ||
| 'statusByTool', | ||
| 'callModel', | ||
| ]); |
There was a problem hiding this comment.
🔍 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.
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>
| // 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; |
There was a problem hiding this comment.
🔴 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
| 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; | ||
| }; | ||
| }; | ||
|
|
There was a problem hiding this comment.
🔍 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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: SomebroadcastToolResultcall sites useString(toolCall.name)while others usetoolCall.nameororiginalToolCall.namedirectly (e.g., line 3574). SinceParsedToolCall.nameis alreadystring,String()is a no-op. Pick one style for consistency. -
as CorrelatedResponseStreamEvent<TTools>casts (lines 997, 1021, 6805): The runtime construction of these events includestoolNameas a plainstring, 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 mismatchedtoolNamewould 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) withExclude-based invariants is a clean design. - Mutable-aliasing soundness via
WidenedPartition/WidenedSituationMapis well-reasoned and well-tested. Object.create(null)forstatusByToolwith explicit__proto__/constructor/prototypetest coverage is excellent defensive coding.- The triple-layered
TOOL_SET_SNAPSHOT_METADATA_KEYSdefense (call-model.ts+async-params.ts+model-result.ts) prevents metadata leaks from all spread patterns. - Server tool ID handling (
ServerToolIdOfwidening tostringwhen 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', { |
There was a problem hiding this comment.
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', { |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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.
Summary
@openrouter/agent-tool-set— a declarative, immutable-by-defaultToolSetfor 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.activeTools?: readonly string[]option to@openrouter/agent'scallModel, so aToolSetsnapshot'scallModelsub-object can be spread directly into a request. Filtering is applied once, before both API conversion and executor/ModelResultregistration, so excluded tools are neither advertised to the model nor callable. Server tools always bypass this filter.ids to server tools (ServerToolBase.id, defaultserver:${config.type}, overridable viaserverTool(config, { id })) so they can participate in tool-set activation and identity alongside client tools (function.name).CorrelatedToolResultEvent,CorrelatedToolPreliminaryResultEvent,CorrelatedToolEventUnion,CorrelatedResponseStreamEvent,CorrelatedToolStreamEvent) that let consumers narrowevent.toolNameto 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 optionaltoolNamefield and aTNamegeneric 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: truemutates 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-timeconditionalpartition..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.InferAllIds,InferEnabledIds,InferDisabledIds,InferConditionalIds(compile-time ID sets recovered from aToolSetinstance), andInferToolSet<T>(alias of the agent'sCorrelatedToolEventUnion<T>).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 toserver:${config.type}and can be overridden withserverTool(config, { id: 'server:public_search' }).serverTool()throws ifoptions.id === ''. IDs are namespace-prefixed so a server tool can never collide with a client function name, and duplicate IDs across the tuple throw atcreateToolSetconstruction.resolve / resolveSituation / partitions
Partitiontracks 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+.resolveSituationoverlay 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 toenabled | conditional, while the runtime snapshot (tools,activeTools,enabled,disabled,statusByTool) is always exhaustive and exact — every known ID appears instatusByToolwith its resolved{ enabled, reason, directive?, predicate? }.Spread-safe
.callModelResolvedToolSnapshotincludes a nestedcallModel: { tools, activeTools }sub-object in addition to the top-leveltools/activeTools/enabled/disabled/statusByToolfields. Spreading...snapshot.callModelintocallModel(client, { ...snapshot.callModel, model, input })passes onlytools/activeTools; spreading the top-level snapshot instead would leakenabled/disabled/statusByToolas unrecognizedcallModelrequest fields. This is the fix for the metadata-leak footgun.Agent changes (
@openrouter/agent)BaseCallModelInputgainsactiveTools?: readonly string[], listed inclientOnlyFields.callModelcomputes the filtered tool list once and uses it for bothconvertToolsToAPIFormatand the tools passed towardModelResult/the executor, so excluded tools are neither advertised to the model nor executable. The filter predicate short-circuitstruefor server tools (isServerTool(t) || activeSet.has(t.function.name)), so server tools always remain active regardless ofactiveTools.ServerToolBasegains anid: stringfield;serverTool<T, TId>(config, options?: { id?: TId })accepts an optionalidoverride, defaulting toserver:${config.type}and throwing on an empty-string override.tool-types.ts:CorrelatedToolPreliminaryResultEvent,CorrelatedToolResultEvent,CorrelatedToolEventUnion,CorrelatedResponseStreamEvent,CorrelatedToolStreamEvent.ToolPreliminaryResultEvent,ToolResultEvent,ResponseStreamEvent,ToolStreamEvent, andChatStreamEventeach gain aTName extends string = stringgeneric and (where applicable) atoolName: TNamefield, preserving the existing wide/untyped shapes as the default.API example
Test plan
pnpm build— both packages compilepnpm typecheck— strict mode cleanpnpm 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 acrosscreateToolSet,activate/deactivate,activateWhen/deactivateWhen, last-call-wins semantics, immutability vs mutability,clone,resolve/inferToolsinput shapes, exhaustivestatusByToolsnapshots, compile-time partition inference, server tools,defineSituations/resolveSituation, theTSharedgeneric,InferToolSet/event narrowing, and thecallModel-oriented spread shape.packages/agent/tests/unit/call-model-active-tools.test.ts— cases verifying the outbound request body via a capturingHTTPClient, including that server tools bypass theactiveToolsfilter.pnpm lint— Biome cleanpnpm changeset status— both packages bumped minorRelease
.changeset/agent-tool-set.md— minor bump for both@openrouter/agent-tool-set(0.1.0 initial release) and@openrouter/agent(newactiveToolsoption, server toolid, correlated event types).