@openrouter/agent 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +732 -7
  2. package/THIRD_PARTY_NOTICES.md +25 -0
  3. package/esm/index.d.ts +20 -5
  4. package/esm/index.d.ts.map +1 -1
  5. package/esm/index.js +10 -3
  6. package/esm/index.js.map +1 -1
  7. package/esm/inner-loop/call-model.d.ts.map +1 -1
  8. package/esm/inner-loop/call-model.js +63 -46
  9. package/esm/inner-loop/call-model.js.map +1 -1
  10. package/esm/inner-loop/resume-tool-results.d.ts +128 -0
  11. package/esm/inner-loop/resume-tool-results.d.ts.map +1 -0
  12. package/esm/inner-loop/resume-tool-results.js +244 -0
  13. package/esm/inner-loop/resume-tool-results.js.map +1 -0
  14. package/esm/lib/agent-tool.d.ts +92 -0
  15. package/esm/lib/agent-tool.d.ts.map +1 -0
  16. package/esm/lib/agent-tool.js +211 -0
  17. package/esm/lib/agent-tool.js.map +1 -0
  18. package/esm/lib/async-params.d.ts +96 -0
  19. package/esm/lib/async-params.d.ts.map +1 -1
  20. package/esm/lib/async-params.js +30 -1
  21. package/esm/lib/async-params.js.map +1 -1
  22. package/esm/lib/async-tool-registry.d.ts +134 -0
  23. package/esm/lib/async-tool-registry.d.ts.map +1 -0
  24. package/esm/lib/async-tool-registry.js +352 -0
  25. package/esm/lib/async-tool-registry.js.map +1 -0
  26. package/esm/lib/async-tools.d.ts +23 -0
  27. package/esm/lib/async-tools.d.ts.map +1 -0
  28. package/esm/lib/async-tools.js +19 -0
  29. package/esm/lib/async-tools.js.map +1 -0
  30. package/esm/lib/conversation-state.d.ts +1 -0
  31. package/esm/lib/conversation-state.d.ts.map +1 -1
  32. package/esm/lib/conversation-state.js +8 -0
  33. package/esm/lib/conversation-state.js.map +1 -1
  34. package/esm/lib/doom-loop.d.ts +533 -0
  35. package/esm/lib/doom-loop.d.ts.map +1 -0
  36. package/esm/lib/doom-loop.js +1221 -0
  37. package/esm/lib/doom-loop.js.map +1 -0
  38. package/esm/lib/hooks-schemas.d.ts +33 -0
  39. package/esm/lib/hooks-schemas.d.ts.map +1 -1
  40. package/esm/lib/hooks-schemas.js +54 -0
  41. package/esm/lib/hooks-schemas.js.map +1 -1
  42. package/esm/lib/hooks-types.d.ts +6 -2
  43. package/esm/lib/hooks-types.d.ts.map +1 -1
  44. package/esm/lib/hooks-types.js.map +1 -1
  45. package/esm/lib/model-result.d.ts +648 -12
  46. package/esm/lib/model-result.d.ts.map +1 -1
  47. package/esm/lib/model-result.js +2784 -164
  48. package/esm/lib/model-result.js.map +1 -1
  49. package/esm/lib/next-turn-params.d.ts.map +1 -1
  50. package/esm/lib/next-turn-params.js +3 -1
  51. package/esm/lib/next-turn-params.js.map +1 -1
  52. package/esm/lib/next-turn-params.test.js +72 -0
  53. package/esm/lib/next-turn-params.test.js.map +1 -1
  54. package/esm/lib/reusable-stream.d.ts +9 -0
  55. package/esm/lib/reusable-stream.d.ts.map +1 -1
  56. package/esm/lib/reusable-stream.js +17 -0
  57. package/esm/lib/reusable-stream.js.map +1 -1
  58. package/esm/lib/stream-transformers.d.ts +21 -1
  59. package/esm/lib/stream-transformers.d.ts.map +1 -1
  60. package/esm/lib/stream-transformers.js +45 -0
  61. package/esm/lib/stream-transformers.js.map +1 -1
  62. package/esm/lib/tool-check.d.ts +76 -0
  63. package/esm/lib/tool-check.d.ts.map +1 -0
  64. package/esm/lib/tool-check.js +241 -0
  65. package/esm/lib/tool-check.js.map +1 -0
  66. package/esm/lib/tool-concurrency.d.ts +42 -0
  67. package/esm/lib/tool-concurrency.d.ts.map +1 -0
  68. package/esm/lib/tool-concurrency.js +80 -0
  69. package/esm/lib/tool-concurrency.js.map +1 -0
  70. package/esm/lib/tool-context.d.ts +45 -1
  71. package/esm/lib/tool-context.d.ts.map +1 -1
  72. package/esm/lib/tool-context.js +60 -1
  73. package/esm/lib/tool-context.js.map +1 -1
  74. package/esm/lib/tool-executor.d.ts +52 -9
  75. package/esm/lib/tool-executor.d.ts.map +1 -1
  76. package/esm/lib/tool-executor.js +253 -21
  77. package/esm/lib/tool-executor.js.map +1 -1
  78. package/esm/lib/tool-orchestrator.d.ts.map +1 -1
  79. package/esm/lib/tool-orchestrator.js +87 -50
  80. package/esm/lib/tool-orchestrator.js.map +1 -1
  81. package/esm/lib/tool-set-types.d.ts +298 -0
  82. package/esm/lib/tool-set-types.d.ts.map +1 -0
  83. package/esm/lib/tool-set-types.js +2 -0
  84. package/esm/lib/tool-set-types.js.map +1 -0
  85. package/esm/lib/tool-set.d.ts +157 -0
  86. package/esm/lib/tool-set.d.ts.map +1 -0
  87. package/esm/lib/tool-set.js +473 -0
  88. package/esm/lib/tool-set.js.map +1 -0
  89. package/esm/lib/tool-task.d.ts +157 -0
  90. package/esm/lib/tool-task.d.ts.map +1 -0
  91. package/esm/lib/tool-task.js +206 -0
  92. package/esm/lib/tool-task.js.map +1 -0
  93. package/esm/lib/tool-types.d.ts +667 -38
  94. package/esm/lib/tool-types.d.ts.map +1 -1
  95. package/esm/lib/tool-types.js +59 -10
  96. package/esm/lib/tool-types.js.map +1 -1
  97. package/esm/lib/tool.d.ts +227 -19
  98. package/esm/lib/tool.d.ts.map +1 -1
  99. package/esm/lib/tool.js +206 -6
  100. package/esm/lib/tool.js.map +1 -1
  101. package/esm/mcp/auth/auth-resolver.d.ts +14 -0
  102. package/esm/mcp/auth/auth-resolver.d.ts.map +1 -0
  103. package/esm/mcp/auth/auth-resolver.js +27 -0
  104. package/esm/mcp/auth/auth-resolver.js.map +1 -0
  105. package/esm/mcp/auth/auth-types.d.ts +33 -0
  106. package/esm/mcp/auth/auth-types.d.ts.map +1 -0
  107. package/esm/mcp/auth/auth-types.js +4 -0
  108. package/esm/mcp/auth/auth-types.js.map +1 -0
  109. package/esm/mcp/build-tools.d.ts +29 -0
  110. package/esm/mcp/build-tools.d.ts.map +1 -0
  111. package/esm/mcp/build-tools.js +80 -0
  112. package/esm/mcp/build-tools.js.map +1 -0
  113. package/esm/mcp/cache/cache-store.d.ts +24 -0
  114. package/esm/mcp/cache/cache-store.d.ts.map +1 -0
  115. package/esm/mcp/cache/cache-store.js +20 -0
  116. package/esm/mcp/cache/cache-store.js.map +1 -0
  117. package/esm/mcp/cache/cache-types.d.ts +71 -0
  118. package/esm/mcp/cache/cache-types.d.ts.map +1 -0
  119. package/esm/mcp/cache/cache-types.js +49 -0
  120. package/esm/mcp/cache/cache-types.js.map +1 -0
  121. package/esm/mcp/cache/serialize.d.ts +25 -0
  122. package/esm/mcp/cache/serialize.d.ts.map +1 -0
  123. package/esm/mcp/cache/serialize.js +96 -0
  124. package/esm/mcp/cache/serialize.js.map +1 -0
  125. package/esm/mcp/close-quietly.d.ts +24 -0
  126. package/esm/mcp/close-quietly.d.ts.map +1 -0
  127. package/esm/mcp/close-quietly.js +30 -0
  128. package/esm/mcp/close-quietly.js.map +1 -0
  129. package/esm/mcp/create-mcp-tools.d.ts +13 -0
  130. package/esm/mcp/create-mcp-tools.d.ts.map +1 -0
  131. package/esm/mcp/create-mcp-tools.js +87 -0
  132. package/esm/mcp/create-mcp-tools.js.map +1 -0
  133. package/esm/mcp/elicitation.d.ts +11 -0
  134. package/esm/mcp/elicitation.d.ts.map +1 -0
  135. package/esm/mcp/elicitation.js +51 -0
  136. package/esm/mcp/elicitation.js.map +1 -0
  137. package/esm/mcp/errors.d.ts +91 -0
  138. package/esm/mcp/errors.d.ts.map +1 -0
  139. package/esm/mcp/errors.js +91 -0
  140. package/esm/mcp/errors.js.map +1 -0
  141. package/esm/mcp/handle.d.ts +66 -0
  142. package/esm/mcp/handle.d.ts.map +1 -0
  143. package/esm/mcp/handle.js +323 -0
  144. package/esm/mcp/handle.js.map +1 -0
  145. package/esm/mcp/index.d.ts +13 -0
  146. package/esm/mcp/index.d.ts.map +1 -0
  147. package/esm/mcp/index.js +11 -0
  148. package/esm/mcp/index.js.map +1 -0
  149. package/esm/mcp/mcp-connection.d.ts +150 -0
  150. package/esm/mcp/mcp-connection.d.ts.map +1 -0
  151. package/esm/mcp/mcp-connection.js +478 -0
  152. package/esm/mcp/mcp-connection.js.map +1 -0
  153. package/esm/mcp/mcp-sdk.d.ts +10 -0
  154. package/esm/mcp/mcp-sdk.d.ts.map +1 -0
  155. package/esm/mcp/mcp-sdk.js +37 -0
  156. package/esm/mcp/mcp-sdk.js.map +1 -0
  157. package/esm/mcp/rehydrate.d.ts +76 -0
  158. package/esm/mcp/rehydrate.d.ts.map +1 -0
  159. package/esm/mcp/rehydrate.js +505 -0
  160. package/esm/mcp/rehydrate.js.map +1 -0
  161. package/esm/mcp/resource-tools.d.ts +17 -0
  162. package/esm/mcp/resource-tools.d.ts.map +1 -0
  163. package/esm/mcp/resource-tools.js +155 -0
  164. package/esm/mcp/resource-tools.js.map +1 -0
  165. package/esm/mcp/result-mapper.d.ts +19 -0
  166. package/esm/mcp/result-mapper.d.ts.map +1 -0
  167. package/esm/mcp/result-mapper.js +40 -0
  168. package/esm/mcp/result-mapper.js.map +1 -0
  169. package/esm/mcp/schema/json-schema-guards.d.ts +14 -0
  170. package/esm/mcp/schema/json-schema-guards.d.ts.map +1 -0
  171. package/esm/mcp/schema/json-schema-guards.js +19 -0
  172. package/esm/mcp/schema/json-schema-guards.js.map +1 -0
  173. package/esm/mcp/schema/json-schema-to-zod.d.ts +20 -0
  174. package/esm/mcp/schema/json-schema-to-zod.d.ts.map +1 -0
  175. package/esm/mcp/schema/json-schema-to-zod.js +72 -0
  176. package/esm/mcp/schema/json-schema-to-zod.js.map +1 -0
  177. package/esm/mcp/tool-wrapper.d.ts +41 -0
  178. package/esm/mcp/tool-wrapper.d.ts.map +1 -0
  179. package/esm/mcp/tool-wrapper.js +131 -0
  180. package/esm/mcp/tool-wrapper.js.map +1 -0
  181. package/esm/mcp/transport-types.d.ts +45 -0
  182. package/esm/mcp/transport-types.d.ts.map +1 -0
  183. package/esm/mcp/transport-types.js +2 -0
  184. package/esm/mcp/transport-types.js.map +1 -0
  185. package/esm/mcp/types.d.ts +119 -0
  186. package/esm/mcp/types.d.ts.map +1 -0
  187. package/esm/mcp/types.js +2 -0
  188. package/esm/mcp/types.js.map +1 -0
  189. package/esm/mcp/version.d.ts +3 -0
  190. package/esm/mcp/version.d.ts.map +1 -0
  191. package/esm/mcp/version.js +5 -0
  192. package/esm/mcp/version.js.map +1 -0
  193. package/package.json +70 -5
package/README.md CHANGED
@@ -56,6 +56,17 @@ const text = await result.getText();
56
56
  console.log(text);
57
57
  ```
58
58
 
59
+ ## Optional subpaths
60
+
61
+ - `@openrouter/agent/tool-set` provides declarative tool activation.
62
+ - `@openrouter/agent/mcp` provides MCP discovery, caching, and tool wrapping. Install its optional client when using MCP:
63
+
64
+ ```bash
65
+ pnpm add @openrouter/agent @modelcontextprotocol/client
66
+ ```
67
+
68
+ Existing `@openrouter/mcp` imports remain supported as compatibility facades.
69
+
59
70
  ## Features
60
71
 
61
72
  ### Multiple Response Consumption Patterns
@@ -68,10 +79,14 @@ const result = callModel(client, { model, input, tools });
68
79
  // Await the final text
69
80
  const text = await result.getText();
70
81
 
71
- // Await the full response with usage data
82
+ // Await the full response with usage data (the FINAL round only)
72
83
  const response = await result.getResponse();
73
84
  console.log(response.usage); // { inputTokens, outputTokens, cost, ... }
74
85
 
86
+ // Await aggregate usage across EVERY round of the tool loop
87
+ const usage = await result.getUsage();
88
+ console.log(usage); // { modelCalls, inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens, cost? }
89
+
75
90
  // Stream text deltas
76
91
  for await (const delta of result.getTextStream()) {
77
92
  process.stdout.write(delta);
@@ -109,12 +124,47 @@ What each stream emits:
109
124
  | `getReasoningStream()` | reasoning deltas |
110
125
  | `getToolStream()` | tool-call **argument deltas**; `preliminary_result` events for generator tools — *not* execution results |
111
126
  | `getToolCallsStream()` | parsed tool calls as they complete |
112
- | `getItemsStream()` | all output items (messages, function calls, …) |
113
- | `getFullResponsesStream()` | every response event, including `tool.result` / `tool.call_output` execution events |
127
+ | `getItemsStream()` | all output items (messages, function calls, …) — output items **only**, no usage/response metadata |
128
+ | `getFullResponsesStream()` | every response event, including `tool.result` / `tool.call_output` execution events, and each round's `response.completed` (with that round's usage block) |
129
+
130
+ #### Usage across a multi-round tool loop
131
+
132
+ `getResponse()` resolves to the **final** round's response, so in a
133
+ multi-round tool loop the tokens spent on the intermediate `tool_calls`
134
+ generations are not in `response.usage`. `getItemsStream()` carries output
135
+ items only and never surfaces `response.completed`, so usage is not reachable
136
+ from that stream either.
137
+
138
+ `getUsage()` closes the gap with aggregate totals across every model call the
139
+ run made — the initial request, each tool-round follow-up, the empty-final
140
+ retry, the `allowFinalResponse` final turn, and approval-resume requests:
141
+
142
+ ```typescript
143
+ const result = callModel(client, { model, input, tools });
144
+
145
+ for await (const item of result.getItemsStream()) {
146
+ render(item);
147
+ }
148
+
149
+ const usage = await result.getUsage();
150
+ console.log(usage.modelCalls, usage.totalTokens, usage.cost);
151
+ ```
152
+
153
+ It gates on run completion like `getResponse()` does, so the totals are final
154
+ whether you await it directly, after `getResponse()`, or after draining any of
155
+ the streaming getters. Unlike `getResponse()` it never rejects — a failed run
156
+ still consumed tokens — and returns the totals accrued so far, with
157
+ `modelCalls: 0` and zeroed tokens when no model call completed. `cost` is
158
+ present only when the server reported cost accounting.
159
+
160
+ Same `SessionUsageTotals` shape and numbers as the `SessionEnd` hook's
161
+ `totalUsage`. For **per-call** granularity use the `PostModelCall` hook (one
162
+ emit per model call, with `turnType`/`turnNumber`) or read each round's
163
+ `response.completed` off `getFullResponsesStream()`.
114
164
 
115
165
  ### Tool Types
116
166
 
117
- The `tool()` factory creates type-safe tools with full Zod schema inference. Three tool types are supported:
167
+ The `tool()` factory creates type-safe tools with full Zod schema inference. In addition to the legacy kinds below, the unified `run` interface with `lifecycle: 'sync' | 'background' | 'deferred'` covers [async tools](#async-tools) whose results arrive after the tool round, and `tool.agent()` creates [subagent tools](#agent-tools-subagents).
118
168
 
119
169
  **Regular tools** — automatically executed by the agent loop:
120
170
 
@@ -157,6 +207,26 @@ const confirmTool = tool({
157
207
  });
158
208
  ```
159
209
 
210
+ **Forced tool choices** are one-shot for each resolved semantic value. After a
211
+ forced choice produces a tool call, unchanged follow-up choices are relaxed to
212
+ `auto` so the model can either call another tool or answer in text. A dynamic
213
+ choice re-arms when it resolves to a different value (or after an unforced
214
+ turn):
215
+
216
+ ```typescript
217
+ callModel(client, {
218
+ model: 'openai/gpt-4o',
219
+ input: 'Plan, research, then submit.',
220
+ tools: [planTool, searchTool, submitTool] as const,
221
+ toolChoice: ({ numberOfTurns }) =>
222
+ numberOfTurns === 0
223
+ ? { type: 'function', name: 'plan' }
224
+ : numberOfTurns === 3
225
+ ? { type: 'function', name: 'submit' }
226
+ : 'auto',
227
+ });
228
+ ```
229
+
160
230
  ### Stop Conditions
161
231
 
162
232
  Control when the agent loop stops executing tools:
@@ -219,6 +289,414 @@ original `instructions` are sent with `toolChoice: 'none'`. Any
219
289
  non-executable (manual) tool calls in the halted turn are paired with
220
290
  synthesized stub `function_call_output` items so the input is well-formed.
221
291
 
292
+ ### Cancellation & Request Timeouts
293
+
294
+ A `callModel` run is a *sequence* of API requests (initial, one per tool
295
+ round, plus final/retry turns). Two independent bounds compose:
296
+
297
+ ```typescript
298
+ const controller = new AbortController();
299
+
300
+ const result = callModel(
301
+ client,
302
+ {
303
+ model: 'openai/gpt-4o',
304
+ input: 'Research this topic',
305
+ tools: [searchTool] as const,
306
+ // Cancel the WHOLE run: stops the loop at the next turn boundary and
307
+ // aborts the in-flight request/stream. Promises reject with the
308
+ // signal's abort reason.
309
+ signal: controller.signal,
310
+ },
311
+ {
312
+ // Bound EACH request: a provider that stalls fails that request after
313
+ // 90s instead of hanging until an outer timeout kills the process.
314
+ timeoutMs: 90000,
315
+ },
316
+ );
317
+
318
+ // later, e.g. on user navigation:
319
+ controller.abort(new Error('user cancelled'));
320
+ ```
321
+
322
+ `timeoutMs` is per-request (each dispatch gets a fresh budget), not
323
+ per-run — bound the run with `stopWhen` (`maxCost`, `maxTokensUsed`,
324
+ `stepCountIs`) and/or `signal`. The two compose: when both are set, each
325
+ request is bounded by whichever fires first. Prefer the `signal` option
326
+ over passing a raw `signal` through `RequestOptions` — the underlying SDK
327
+ skips its `timeoutMs` wiring whenever a request already carries a signal,
328
+ so the engine re-composes them for you only on the `signal` option path.
329
+
330
+ ### Doom-Loop Detection
331
+
332
+ Catch runs that stop making progress while continuing to spend: the model
333
+ re-issuing the same tool call with identical arguments (including repeated
334
+ *empty* calls and repeated invalid-JSON calls), repeating identical
335
+ server-tool requests, or emitting the same text tokens over and over. Off by
336
+ default; opt in with `doomLoop: true`:
337
+
338
+ ```typescript
339
+ const result = callModel(client, {
340
+ model: 'openai/gpt-4o',
341
+ input: 'Research this topic',
342
+ tools: [searchTool, bashTool] as const,
343
+ doomLoop: true, // recommended defaults: observe@2, block@3, stop@6
344
+ // or tune it:
345
+ // doomLoop: {
346
+ // ladder: { observe: 2, steer: false, block: 3, stop: 6 },
347
+ // text: { minRepeats: 4 }, // or `false` to disable text detection
348
+ // },
349
+ });
350
+
351
+ // Was the run stopped by detection?
352
+ const verdict = await result.getDoomLoopVerdict();
353
+ if (verdict) console.warn(verdict.message);
354
+ ```
355
+
356
+ Detection is **deterministic** — a verdict is a pure function of the
357
+ transcript, so the same sequence of calls/text always fires at the same
358
+ point. Repeated **rounds** build a per-tool streak: interleaved calls to
359
+ *other* tools don't reset it, and N identical calls fanned out in parallel
360
+ within ONE round count once (a streak measures the model re-issuing a call
361
+ *after seeing its result*, which requires a round trip).
362
+
363
+ Two kinds of evidence accumulate side by side, and the stronger one decides:
364
+
365
+ - **Round-set streaks.** A round's identity for one tool is the **set** of
366
+ calls it made, so a fan-out of *distinct* arguments reissued verbatim
367
+ counts: `read(a), read(b), read(c)` every round accumulates. Ordering
368
+ within the round is irrelevant, and a round whose membership changes — in
369
+ either direction — resets this streak, since adding or dropping work is
370
+ progress for the round as a unit.
371
+ - **Per-call streaks.** Each `(tool, arguments)` identity also counts its own
372
+ consecutive rounds, whatever its round-mates did. A call repeating inside
373
+ varying company (`[a,b]`, `[a,c]`, `[a,d]` — `a` is a 3-peat) is flagged
374
+ even though every round's set differs, and a repeat spanning an approval
375
+ pause keeps counting when the paused member drops from the resumed round.
376
+ For an exactly-repeating round both counts are equal, so nothing
377
+ double-fires.
378
+
379
+ When a repeating fan-out crosses a rung, every call in the round gets the
380
+ verdict (so `block` stops the whole fan-out, not just one member), and calls
381
+ carrying the SAME evidence share byte-identical text — the `steer` rung
382
+ dedupes on exact text, so one piece of evidence injects one correction. A
383
+ round can carry two pieces of evidence at once (`[a]`, `[a,b]`, `[a,b]`: by
384
+ round 3, `a` is a 3-peat call while `{a,b}` is a 2-peat set), in which case
385
+ each renders its own message — at most two per tool per round, each stating
386
+ a distinct fact. When the per-call count alone crosses a rung, only that
387
+ call is refused and genuinely new round-mates run free. The streak crosses
388
+ a graduated ladder — strongest crossed rung wins:
389
+
390
+ | Action | Effect |
391
+ |---|---|
392
+ | `observe` | Emit the `DoomLoopDetected` hook only |
393
+ | `steer` | Inject a corrective user message before the next turn (off by default). Guidance queued right before a pause persists in `ConversationState.doomLoop.pendingSteer` and is delivered on resume |
394
+ | `escalate` | **Recover by throwing more intelligence at the next turn** (off by default; requires an `escalation` config). One-turn overrides — a stronger model and/or a forced `openrouter:advisor` consult — then automatic revert. Bounded by `escalation.maxEscalations` (default 2, persisted across resumes); exhausted or unconfigured escalations fall through to weaker rungs |
395
+ | `block` | Refuse the call and return an explanatory error as the tool output — the model sees why and can change course. Not applicable to text or server-tool verdicts (already emitted/executed); those downgrade to `observe` |
396
+ | `stop` | Halt the loop before any further model request (`SessionEnd.reason: 'doom_loop'`). Unresolved tool calls in the final turn get synthesized halt-error outputs so persisted history stays well-formed and resumable |
397
+
398
+ **Escalation recovery** — instead of (or before) blocking, unblock the loop
399
+ by escalating the next turn, then return to the cheap model:
400
+
401
+ ```typescript
402
+ callModel(client, {
403
+ model: 'z-ai/glm-5.2', // the everyday executor
404
+ input: 'Research this topic',
405
+ tools: [searchTool] as const,
406
+ doomLoop: {
407
+ ladder: { observe: 2, escalate: 3, block: 5, stop: 8 },
408
+ escalation: {
409
+ // Either or both:
410
+ model: 'anthropic/claude-opus-4.6', // run the NEXT turn on a stronger model
411
+ advisor: true, // and/or force an openrouter:advisor consult
412
+ maxEscalations: 2, // spend cap for the whole conversation
413
+ },
414
+ },
415
+ });
416
+ ```
417
+
418
+ On an `escalate` verdict the engine (1) injects a user notice naming the
419
+ detected loop, and (2) applies one-turn request overrides: `model` is
420
+ swapped for that dispatch only, and/or the `openrouter:advisor` server tool
421
+ is appended with `forwardTranscript: true`, loop-diagnosing instructions,
422
+ and `toolChoice` pinned to it (`allowed_tools`/`required`) so the stuck
423
+ model must consult the advisor before doing anything else. `advisor` may
424
+ also be an object passed through as the advisor tool's parameters
425
+ (`model`, `instructions`, `maxToolCalls`, ...). The following turn reverts
426
+ automatically. Budget is consumed when a recovery is *applied* (not at
427
+ verdict time) and `escalationsUsed` persists in `ConversationState.doomLoop`
428
+ so a resumed run cannot reset it; concurrent detector verdicts in one window
429
+ escalate once.
430
+
431
+ Ladder configs are sanity-checked at resolve time: enabling `block` with
432
+ `stop: false` warns (a model that keeps re-issuing a blocked call loops
433
+ until `stopWhen` fires — and `stopWhen` defaults to unbounded), dead
434
+ rungs (a weaker threshold at or past an enabled stronger one) warn, and so
435
+ does an `escalate` rung without an `escalation` config (or vice versa).
436
+
437
+ **Tools declare what identifies a call** via `loopKey` on the tool
438
+ definition — a computed function over the call's validated arguments, like
439
+ every other tool hook, or `false` to exempt:
440
+
441
+ ```typescript
442
+ // Compute the identity — a web-search tool normalizes its query.
443
+ tool({
444
+ name: 'web_search',
445
+ inputSchema: z.object({ query: z.string() }),
446
+ loopKey: ({ query }) => query.trim().toLowerCase(),
447
+ execute: async ({ query }) => search(query),
448
+ });
449
+
450
+ // Return the subset of fields that matter. A bash call is identified by
451
+ // the command AND where it runs; other fields (e.g. verbose) don't count.
452
+ tool({
453
+ name: 'bash',
454
+ inputSchema: z.object({ command: z.string(), cwd: z.string(), verbose: z.boolean() }),
455
+ loopKey: ({ command, cwd }) => ({ command, cwd }),
456
+ execute: async ({ command, cwd }) => run(command, cwd),
457
+ });
458
+
459
+ // false: statically exempt — repetition is this tool's job.
460
+ tool({
461
+ name: 'check_status',
462
+ inputSchema: z.object({ jobId: z.string() }),
463
+ loopKey: false,
464
+ execute: async ({ jobId }) => poll(jobId),
465
+ });
466
+ ```
467
+
468
+ A function-form `loopKey` may return `null` to exempt an individual call.
469
+ Returning `undefined`, throwing, or returning unhashable material (bigint,
470
+ circular, >64 levels deep) falls back to the full-arguments identity with a
471
+ warning — detection never fails a run. Without any `loopKey`, the full
472
+ validated arguments object is the identity. A field-name array
473
+ (`loopKey: ['command', 'cwd']`) is also accepted — data rather than code,
474
+ so it can be serialized into MCP tool caches and advertised over the wire
475
+ via `_meta['openrouter/loopKey']`. MCP-wrapped tools accept a `loopKey`
476
+ via `markMcp(tool, { loopKey })` or the `loopKeys` map on
477
+ `createMCPTools`.
478
+
479
+ > **Exempt tools that repeat by design — including repeating *fan-outs*.**
480
+ > The detector compares arguments, not results, so a call whose arguments are
481
+ > stable while its results change is indistinguishable from a loop. Since a
482
+ > round's identity is now the whole *set* of a tool's calls, this covers
483
+ > parallel shapes too: an agent that re-reads the same context files at the
484
+ > start of every turn, or fans out a fixed set of pollers, accumulates a streak
485
+ > and is refused at the default `block` rung from round 3 — and because every
486
+ > call in the round gets the verdict, that is N synthesized error outputs per
487
+ > round, not one. These shapes were invisible before this behavior existed, so
488
+ > `loopKey: false` (or a `loopKey` returning `null`) is the opt-out for any
489
+ > tool whose repetition is legitimate.
490
+
491
+ **Fingerprints are a cross-port contract**: key material is canonicalized
492
+ per RFC 8785 (JCS) and hashed with SHA-256 over the UTF-8 bytes, so the
493
+ Python/Go ports produce identical fingerprints — they MUST use an RFC 8785
494
+ implementation (pip `jcs`, `cyberphone/json-canonicalization`), not their
495
+ stdlib JSON serializer. The conformance vectors live in
496
+ `tests/vectors/doom-loop-fingerprints.json`. Key order never defeats
497
+ detection; malformed calls count too (a model stuck emitting the same
498
+ invalid JSON trips the detector instead of bouncing off the parse error
499
+ forever). Detector state is plain JSON inside `ConversationState.doomLoop`,
500
+ so streaks survive serialize → resume **when the resuming call also passes
501
+ `doomLoop`**. A `stop` verdict persists across decision-only resumes
502
+ (`approveToolCalls`/`rejectToolCalls`); a fresh conversational turn clears
503
+ it (streaks are kept, so renewed repetition re-condemns quickly).
504
+
505
+ The `DoomLoopDetected` hook observes every verdict and can override the
506
+ action per event (`overrideAction`, last handler wins) — de-escalate a
507
+ block to observe for a known-chatty tool, or escalate straight to stop.
508
+
509
+ **What this does NOT catch** (documented limits, locked by negative tests):
510
+
511
+ - **Varying-input loops.** The fingerprint is identity-based: a model that
512
+ invents a fresh nonce/timestamp field each call evades the default
513
+ whole-arguments identity entirely. A `loopKey` that returns the meaningful
514
+ fields closes this per tool; the structural fix (outcome hashing — the
515
+ progress-ledger detector from the design doc) is planned, not shipped.
516
+ - **Paraphrased repetition.** Text detectors require exact repeated token
517
+ blocks (within a response) or byte-identical whitespace-normalized text
518
+ (across steps). Semantically-identical rephrasings do not trip.
519
+ - **Pre-mutation identity.** Fingerprints are computed on the arguments the
520
+ MODEL issued, before any `PreToolUse` mutation — a hook that rewrites
521
+ varying inputs into identical ones does not make them count as repeats
522
+ (and a hook that injects a nonce cannot mask real repetition).
523
+ - **Manual/client-executed calls** pause the loop for the caller and are
524
+ not recorded (only executed, blocked, and parse-error calls are
525
+ evidence).
526
+ - **Cross-tool round patterns.** Streaks are per tool: a loop alternating
527
+ BETWEEN tools with no per-tool repetition (`read(a)` one round, `grep(a)`
528
+ the next, forever) shows each tool a sparse pattern its own evidence
529
+ cannot condemn. Interleaved calls to other tools never *reset* a tool's
530
+ streak, so an every-other-round repeat still accumulates — slowly.
531
+
532
+ ### Async Tools
533
+
534
+ One `tool()` shape covers every execution lifecycle. Write an ordinary `run` — an async function or an async generator — and say what kind it is with `lifecycle`:
535
+
536
+ - **`'sync'`** (default): awaited in the round, exactly like `execute`.
537
+ - **`'background'`**: the loop keeps going. Work settling within the grace window (`graceMs`, default 250ms) behaves like a plain sync call; otherwise the model immediately receives a pending placeholder and the return value is injected as a `tool_task_result` message when it settles.
538
+ - **`'deferred'`**: `run` returns `ctx.defer(taskId)` to park the call on durable external work — the run pauses (`status: 'awaiting_async_tools'`) until the task is completed from any process. Returning a plain value resolves immediately.
539
+
540
+ Generator `run` yields become the task's **log** (feeding check-ins, `tool.preliminary_result` events, and transcripts); the generator's **return** is the result, validated against `outputSchema`. Non-generator bodies log with `ctx.log()`.
541
+
542
+ ```typescript
543
+ const renderVideo = tool({
544
+ name: 'render_video',
545
+ lifecycle: 'background',
546
+ inputSchema: z.object({ script: z.string() }),
547
+ outputSchema: z.object({ url: z.string() }),
548
+ ack: 'Rendering started.',
549
+ timeoutMs: 300_000,
550
+ run: async function* ({ script }, ctx) {
551
+ const job = await renderer.start(script, { signal: ctx?.signal });
552
+ ctx?.onMessage((msg) => job.reprioritize(msg)); // steering opt-in
553
+ for await (const p of job.progress()) yield { pct: p };
554
+ return job.result();
555
+ },
556
+ });
557
+
558
+ const legalReview = tool({
559
+ name: 'request_legal_review',
560
+ lifecycle: 'deferred',
561
+ inputSchema: z.object({ contractId: z.string() }),
562
+ outputSchema: z.object({ approved: z.boolean() }),
563
+ run: async ({ contractId }, ctx) => {
564
+ const ticket = await legal.open(contractId, { conversationId: ctx?.conversationId });
565
+ return ctx!.defer(ticket.id);
566
+ },
567
+ });
568
+ ```
569
+
570
+ Deferred completion is typed and lives on the tool — callable from any process holding the `StateAccessor`:
571
+
572
+ ```typescript
573
+ // webhook handler — hours later, different process
574
+ await legalReview.resolve(client, {
575
+ state: makeAccessor(conversationId),
576
+ taskId: ticketId,
577
+ output: { approved: true }, // ← typechecked against outputSchema
578
+ run: { model: 'openai/gpt-4o' }, // continue immediately (omit to record-only)
579
+ });
580
+ ```
581
+
582
+ `legalReview.fail(...)` / `legalReview.cancel(...)` complete the surface; double resolution throws `ToolTaskAlreadySettledError`. The low-level `resumeToolResults()` handles batches. When a run would end with background work in flight, `asyncTools.onRunEnd` decides: `'drain'` (default), `'detach'`, or `'cancel'`.
583
+
584
+ > **Security:** `.resolve()` injects a value the model treats as a tool result. Authenticate the webhook before calling it — the SDK cannot do that for you. Outputs are validated against `outputSchema` at runtime as well as compile time.
585
+
586
+ ### Checking On Long-Running Tasks
587
+
588
+ When any long-running tool is registered, the SDK appends **one universal `task` tool** to the request — a single static wire definition regardless of how many async tools exist (per-tool schemas are never augmented, so context cost stays constant). The pending placeholder tells the model to use it:
589
+
590
+ ```typescript
591
+ task({ taskId: "task_7f3" }) // status: state, elapsed, last log
592
+ task({ taskId, view: "logs", tail: 5 }) // recent progress entries
593
+ task({ taskId, view: "transcript" }) // full detail (agents: child conversation)
594
+ task({ taskId, action: "steer", message: "..." }) // send guidance to the running task
595
+ task({ taskId, action: "result" }) // final result if settled, else status
596
+ task({ taskId, action: "cancel", reason: "..." }) // stop the task
597
+ ```
598
+
599
+ Calls are engine-intercepted and dispatched to the **owning tool's** `check` config — the wire surface is universal, the handling stays tool-specific:
600
+
601
+ ```typescript
602
+ const renderVideo = tool({
603
+ name: 'render_video',
604
+ lifecycle: 'background',
605
+ // ... as above ...
606
+ check: { // optional — SDK default when absent
607
+ schema: z.object({ focus: z.string().optional() }), // validates task({ params })
608
+ execute: async (params, turnContext) => {
609
+ // turnContext.toolCallStatus → 'working' | 'completed' | ...
610
+ // turnContext.accumulatedYieldedEvents → every run yield so far
611
+ // turnContext.task → { statusView, tailLogs, transcript, send, cancel }
612
+ if (params.focus) turnContext.task?.send(params.focus);
613
+ return turnContext.task?.statusView();
614
+ },
615
+ },
616
+ });
617
+ ```
618
+
619
+ Without a custom `check`, the SDK default answers the three views (`status` / `logs` / `transcript`, truncated to `asyncTools.maxTranscriptChars`, default 20k). Task-tool calls are doom-loop-exempt, bypass per-tool concurrency/timeout gates, and never fire Pre/PostToolUse hooks — but a `PermissionRequest` hook denial recorded for the call IS honored, so a policy layer can veto `cancel`/`steer`. Disable entirely with `asyncTools: { checkins: false }` (placeholders then revert to "do not call this tool again"). The name `task` is reserved: `tool()` and `tool.agent()` reject it at definition time; a dynamically-built tool list that bypasses `tool()` and claims the name suppresses the built-in with a warning (and the engine routes `task` calls to that user tool instead of intercepting them).
620
+
621
+ After a process restart, deferred tasks answer `status` from persisted state (including a bounded `lastLog`); full logs and transcripts are in-memory only and report an explanatory note instead.
622
+
623
+ ### Steering Running Tasks
624
+
625
+ - **From code:** `result.sendToTask(taskId, message)` delivers into the run body's `ctx.onMessage` handler (queued until one registers). Deferred tasks throw — their work runs in an external system.
626
+ - **From the model:** `task({ taskId, action: 'steer', message })` delivers directly, or expose custom `params` handled by `check.execute` with `turnContext.task.send(...)`.
627
+ - **Agent tools** forward steering messages into the child conversation automatically (as user messages at the child's next turn boundary).
628
+
629
+ ### Agent Tools (Subagents)
630
+
631
+ `tool.agent()` creates a tool whose work IS a child `callModel` conversation, running as a background task:
632
+
633
+ ```typescript
634
+ const researcher = tool.agent({
635
+ name: 'research_topic',
636
+ description: 'Deep-research a topic in the background.',
637
+ inputSchema: z.object({ topic: z.string() }),
638
+ outputSchema: z.object({ text: z.string() }),
639
+ agent: ({ topic }) => ({
640
+ model: 'openai/gpt-4o',
641
+ input: `Research: ${topic}`,
642
+ tools: [searchTool, fetchTool] as const,
643
+ stopWhen: stepCountIs(15),
644
+ }),
645
+ // default result mapper — Dennis-style last_message outcome:
646
+ result: async (child) => ({ text: await child.getText() }),
647
+ });
648
+ ```
649
+
650
+ The parent keeps working while children run (several can run concurrently under the background pool). The child's conversation is the check-in **transcript**; each child turn is a **log** entry; `status` reports `turnsCompleted` and `currentActivity`. `cancelTask(taskId)` (or parent abort / `timeoutMs`) cancels the child; `sendToTask` steers it mid-run. Children run **in-memory** (no `StateAccessor`) and do not inherit the parent's hooks — pass child hooks explicitly in the `agent` spec if needed. A child that pauses (HITL/manual/approval/deferred tools inside it) fails the task with a clear error.
651
+
652
+ ### Strict Tool Schemas
653
+
654
+ Every client tool kind, including `tool.agent()`, accepts `strict: true` to
655
+ request provider-enforced schema adherence for generated tool-call arguments.
656
+ The SDK faithfully converts the caller's `inputSchema`; it does not rewrite
657
+ the runtime Zod contract.
658
+
659
+ OpenAI-style strict function calling requires every declared object property
660
+ to appear in JSON Schema's `required` list. Use `.nullable()` for a value that
661
+ may be absent conceptually, because `.optional()` omits the property from
662
+ `required`:
663
+
664
+ ```typescript
665
+ const weatherTool = tool({
666
+ name: 'get_weather',
667
+ inputSchema: z.object({
668
+ location: z.string(),
669
+ // The key is required, but the model may return null.
670
+ units: z.enum(['celsius', 'fahrenheit']).nullable(),
671
+ }),
672
+ strict: true,
673
+ execute: async ({ location, units }) => getWeather(location, units),
674
+ });
675
+ ```
676
+
677
+ The SDK sends the generated schema unchanged. Providers validate it according
678
+ to their own strict-mode dialect and the SDK propagates any API error. Use
679
+ `.nullable()`, or set `strict: false` when omission is part of the tool's
680
+ contract. Provider support and strict-schema restrictions can vary.
681
+
682
+ ### Per-Tool Timeouts & Concurrency
683
+
684
+ Every tool kind accepts `timeoutMs` (per-execution deadline; the run-level `toolTimeoutMs` sets a default) and `maxConcurrency` (max simultaneous executions of that tool). On timeout the round stops waiting — the model receives `{ error, code: 'tool_timeout' }` and the tool's `ctx.signal` aborts; the timeout bounds the round's *wait*, not the tool body, so signal-ignoring bodies can't hang the run. `ctx.signal` also fires on run abort (`signal` option) and `ModelResult.cancel()`.
685
+
686
+ Round-level parallelism (unbounded by default, matching previous behavior) is capped with `toolConcurrency`:
687
+
688
+ ```typescript
689
+ const result = callModel(client, {
690
+ model: 'openai/gpt-4o',
691
+ input: 'fan out',
692
+ tools: [searchTool] as const,
693
+ toolTimeoutMs: 30_000,
694
+ toolConcurrency: { round: 4, background: 8 }, // or a bare number for { round: n }
695
+ });
696
+ ```
697
+
698
+ Execution order may change under a cap; output order never does (results stay in call order for prompt-cache stability).
699
+
222
700
  ### Tool Approval
223
701
 
224
702
  Gate tool execution with approval checks for sensitive operations:
@@ -324,8 +802,9 @@ const result = callModel(client, { model, input, tools, hooks });
324
802
  | `PermissionRequest` | When a tool requires approval, before pausing for the human gate | `decision: 'allow'` skips the gate (the tool runs once via the normal round), `'deny'` synthesizes a rejected result without executing, `'ask_user'` (default) falls through to the approval flow. Last handler wins. Payload includes a `riskLevel` derived from the approval gate's shape (`'high'` for tool- or call-level functions, `'medium'` for blanket `true`) |
325
803
  | `Stop` | When a `stopWhen` condition halts the loop (`reason: 'max_turns'`) | `forceResume: true` continues the loop (capped at 3 consecutive overrides without tool progress — a bare `forceResume` that changes no state will typically re-trigger the stop condition immediately and burn through the cap, so pair it with `appendPrompt` or external state the stop condition observes); `appendPrompt` injects a user message for the next turn (honored independently of `forceResume`). Blocked/rejected tool outputs count as progress for the cap: the model receives that feedback, and each round costs a full request, so the loop cannot spin hot |
326
804
  | `SessionStart` | Once per run, before the initial request. `config` summarizes the session (`hasTools`, `hasApproval`, `hasState`) | none (void) |
327
- | `SessionEnd` | Once per run, on every exit path — completion, approval pause, interruption, error, and the no-tools streaming paths. `reason` is `'complete' \| 'error' \| 'max_turns' \| 'user'`. When at least one model call completed, `totalUsage` aggregates tokens/cost across all of them (`modelCalls`, `inputTokens`, `outputTokens`, `totalTokens`, `cachedTokens`, `reasoningTokens`, and `cost` when the server reported it) | none (void) |
805
+ | `SessionEnd` | Once per run, on every exit path — completion, approval pause, interruption, error, and the no-tools streaming paths. `reason` is `'complete' \| 'error' \| 'max_turns' \| 'user' \| 'doom_loop'`. When at least one model call completed, `totalUsage` aggregates tokens/cost across all of them (`modelCalls`, `inputTokens`, `outputTokens`, `totalTokens`, `cachedTokens`, `reasoningTokens`, and `cost` when the server reported it) | none (void) |
328
806
  | `PostModelCall` | Once per completed model response, on **every** request the loop makes — initial, each tool-round follow-up, the empty-final retry, the `allowFinalResponse` final turn, and approval-resume requests. Payload: `responseId` (the OpenRouter generation id), `model`, `durationMs` (dispatch → fully materialized response, including stream consumption), `turnType` (`'initial' \| 'resume' \| 'tool_round' \| 'final' \| 'retry'`), `turnNumber`, and `usage` (`inputTokens`, `outputTokens`, `totalTokens`, `cachedTokens`, `reasoningTokens`, `cost?`) when the server reported usage accounting. Purely observational — the telemetry primitive for tracing/benchmark consumers: one span per model call | none (void) |
807
+ | `DoomLoopDetected` | Every time doom-loop detection crosses a ladder rung, once per `(tool, fingerprint)` per round — *identical* parallel duplicates in one round share the event, but a repeating fan-out of DISTINCT arguments emits one event per member, since each is its own `(tool, fingerprint)` (requires the `doomLoop` option). Payload: `detector` (`'tool-fingerprint' \| 'server-tool-fingerprint' \| 'text-repetition' \| 'text-streak'`), the resolved `action` (`'observe' \| 'steer' \| 'escalate' \| 'block' \| 'stop'`), the `streak`, the `fingerprint`, `toolName`/`toolInput` for tool verdicts, and the explanatory `message` | `overrideAction` replaces the engine's resolved action for this event (last handler wins); `block` on a text or server-tool verdict downgrades to `observe`; `escalate` without an `escalation` config or remaining budget downgrades to `observe` |
329
808
 
330
809
  Notes on lifecycle pairing: `SessionEnd` only fires when a matching
331
810
  `SessionStart` succeeded, and at most once per run. Pending async hook work is
@@ -340,6 +819,9 @@ a materialized response emits no `PostModelCall`; a `response.incomplete`
340
819
  response (e.g. truncated at `max_output_tokens`) **does** emit — it carries a
341
820
  real generation id and consumed tokens. Note `usage.cost` is only present when
342
821
  the request had usage accounting enabled server-side.
822
+
823
+ `SessionEnd.totalUsage` is push-based; for the same totals without registering
824
+ a hook, await [`getUsage()`](#usage-across-a-multi-round-tool-loop).
343
825
  Every handler receives `(payload, context)` — `context` carries the
344
826
  `sessionId` (the single source of session identity; payloads do not repeat
345
827
  it), the `hookName`, and an `AbortSignal` for cooperative cancellation. The
@@ -468,13 +950,27 @@ const result = callModel(client, {
468
950
 
469
951
  ### Shared Context
470
952
 
471
- Share mutable state across all tools in a conversation:
953
+ Share mutable state across all tools in a conversation. When typing
954
+ `ctx.shared` explicitly, use the curried `tool<SharedContext>()({...})` form:
472
955
 
473
956
  ```typescript
957
+ type SharedContext = {
958
+ processedIds: string[];
959
+ };
960
+
961
+ const processItem = tool<SharedContext>()({
962
+ name: 'process_item',
963
+ inputSchema: z.object({ id: z.string() }),
964
+ execute: async ({ id }, ctx) => {
965
+ ctx?.setSharedContext({ processedIds: [...ctx.shared.processedIds, id] });
966
+ return { processed: id };
967
+ },
968
+ });
969
+
474
970
  const result = callModel(client, {
475
971
  model: 'openai/gpt-4o',
476
972
  input: 'Process these items',
477
- tools: [toolA, toolB] as const,
973
+ tools: [processItem] as const,
478
974
  sharedContextSchema: z.object({ processedIds: z.array(z.string()) }),
479
975
  context: {
480
976
  shared: { processedIds: [] },
@@ -482,6 +978,13 @@ const result = callModel(client, {
482
978
  });
483
979
  ```
484
980
 
981
+ The direct `tool<SharedContext>({...})` syntax remains supported for backward
982
+ compatibility, but its returned tool name is typed as `string`. TypeScript
983
+ cannot partially infer trailing generics after an explicit `TShared`, so the
984
+ curried form is required when event types must correlate on a literal tool name.
985
+ Calls without an explicit shared-context type, such as `tool({...})`, continue
986
+ to infer literal names normally.
987
+
485
988
  ### Conversation State Management
486
989
 
487
990
  Persist multi-turn conversations with full state tracking. The `state`
@@ -558,6 +1061,221 @@ const chatMsg = toChatMessage(openRouterMessage);
558
1061
  const orMessages2 = fromChatMessages(chatMessages);
559
1062
  ```
560
1063
 
1064
+ # Tool Sets
1065
+
1066
+ Declarative, state-aware activation and deactivation for tools used with `@openrouter/agent`.
1067
+
1068
+ Port of [`ai-tool-set`](https://github.com/zirkelc/ai-tool-set) (MIT © Chris Cook), adapted for this SDK's ordered `Tool[]` / `callModel` model. See [`THIRD_PARTY_NOTICES.md`](./THIRD_PARTY_NOTICES.md).
1069
+
1070
+ ## What it adds
1071
+
1072
+ - **Stable tool-set IDs** for every addressable tool:
1073
+ - client tools → `function.name`
1074
+ - server tools → `server:${config.type}` by default (overridable via `serverTool(config, { id })`)
1075
+ - A **typed three-way partition** of those IDs: definitely enabled, definitely disabled, conditional.
1076
+ - **Exhaustive runtime snapshots** from `resolve()` / `resolveSituation()` — every ID appears in `statusByTool`.
1077
+ - **Named declarative situations** with compile-time exact tool tuples when the situation is fully static.
1078
+ - Integration with `callModel`'s `activeTools` option via the snapshot's spread-safe `.callModel` input.
1079
+
1080
+ ## Install
1081
+
1082
+ ```bash
1083
+ pnpm add @openrouter/agent
1084
+ ```
1085
+
1086
+ ## Usage
1087
+
1088
+ ```ts
1089
+ import { OpenRouter, tool, serverTool, callModel } from '@openrouter/agent';
1090
+ import {
1091
+ createToolSet,
1092
+ type InferEnabledIds,
1093
+ type InferDisabledIds,
1094
+ type InferConditionalIds,
1095
+ type InferAllIds,
1096
+ } from '@openrouter/agent/tool-set';
1097
+ import { z } from 'zod/v4';
1098
+
1099
+ type AppContext = {
1100
+ isAuthenticated: boolean;
1101
+ isAdmin: boolean;
1102
+ };
1103
+
1104
+ const listOrders = tool({
1105
+ name: 'list_orders',
1106
+ inputSchema: z.object({}),
1107
+ execute: async () => ({ orders: [] }),
1108
+ });
1109
+
1110
+ const cancelOrder = tool({
1111
+ name: 'cancel_order',
1112
+ inputSchema: z.object({ id: z.string() }),
1113
+ execute: async () => ({ ok: true }),
1114
+ });
1115
+
1116
+ const login = tool({
1117
+ name: 'login',
1118
+ inputSchema: z.object({}),
1119
+ execute: async () => ({ token: '…' }),
1120
+ });
1121
+
1122
+ const webSearch = serverTool({ type: 'web_search_2025_08_26' });
1123
+ // id defaults to 'server:web_search_2025_08_26'
1124
+
1125
+ const allTools = [listOrders, cancelOrder, login, webSearch] as const;
1126
+
1127
+ const toolSet = createToolSet<typeof allTools, AppContext>({ tools: allTools })
1128
+ .deactivate('cancel_order')
1129
+ .activateWhen('list_orders', ({ context }) => context?.isAuthenticated === true)
1130
+ .defineSituations({
1131
+ guest: {
1132
+ enabled: ['login', 'server:web_search_2025_08_26'],
1133
+ disabled: ['list_orders', 'cancel_order'],
1134
+ },
1135
+ authenticated: {
1136
+ enabled: ['list_orders', 'server:web_search_2025_08_26'],
1137
+ disabled: ['login'],
1138
+ conditional: {
1139
+ cancel_order: ({ context }) => context?.isAdmin === true,
1140
+ },
1141
+ },
1142
+ });
1143
+
1144
+ // Compile-time partition of the *base* set (before a situation overlay):
1145
+ type All = InferAllIds<typeof toolSet>;
1146
+ // 'list_orders' | 'cancel_order' | 'login' | 'server:web_search_2025_08_26'
1147
+ type Enabled = InferEnabledIds<typeof toolSet>; // excludes cancel_order + list_orders (conditional)
1148
+ type Disabled = InferDisabledIds<typeof toolSet>; // 'cancel_order'
1149
+ type Conditional = InferConditionalIds<typeof toolSet>; // 'list_orders'
1150
+
1151
+ const client = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY });
1152
+
1153
+ // Named static situation → exact tool tuple at compile time
1154
+ const guest = toolSet.resolveSituation('guest');
1155
+ // guest.tools is exactly [login, webSearch]
1156
+ // guest.enabled / guest.disabled / guest.statusByTool are exhaustive
1157
+
1158
+ const authenticated = toolSet.resolveSituation('authenticated', {
1159
+ context: { isAuthenticated: true, isAdmin: false },
1160
+ });
1161
+
1162
+ const result = callModel(client, {
1163
+ model: 'openai/gpt-4o-mini',
1164
+ input: 'List my orders.',
1165
+ ...authenticated.callModel,
1166
+ });
1167
+ ```
1168
+
1169
+ ## Identity
1170
+
1171
+ | Kind | Tool-set ID |
1172
+ | --- | --- |
1173
+ | Client `tool({ name: 'x' })` | `'x'` |
1174
+ | `serverTool({ type: 'web_search_2025_08_26' })` | `'server:web_search_2025_08_26'` |
1175
+ | `serverTool(config, { id: 'server:public_search' })` | `'server:public_search'` |
1176
+
1177
+ Duplicate IDs throw at `createToolSet` construction. Activation methods accept only known IDs.
1178
+
1179
+ ## Compile-time vs runtime exactness
1180
+
1181
+ | Resolution style | Developer-time knowledge | Runtime knowledge |
1182
+ | --- | --- | --- |
1183
+ | Static `activate` / `deactivate` | Exact partition | Exact snapshot |
1184
+ | Named static situation (`enabled`/`disabled` only) | Exact filtered tool tuple | Exact snapshot |
1185
+ | `activateWhen` / `deactivateWhen` / situation `conditional` | Upper bound (`enabled ∪ conditional`) | Exact snapshot after predicates |
1186
+ | Mutable `ToolSet` | Partition types may widen | Exact snapshot |
1187
+
1188
+ The type system cannot execute predicates. Conditional IDs therefore expand the compile-time upper bound of active tools; after `resolve`, the returned arrays and `statusByTool` are always exhaustive and exact.
1189
+
1190
+ ## API
1191
+
1192
+ ### `createToolSet<T, TShared?>({ tools, mutable? })`
1193
+
1194
+ Build a set from an ordered tool array. Optional `TShared` types the `context` argument on predicates. Defaults to immutable.
1195
+
1196
+ ### `.tools`
1197
+
1198
+ Concrete tools tuple in construction order (client + server), regardless of activation.
1199
+
1200
+ ### `.activate(id | id[])` / `.deactivate(id | id[])`
1201
+
1202
+ Static flip (last-call-wins). Accepts client names **and** server IDs. Updates the compile-time partition.
1203
+
1204
+ ### `.activateWhen(id, predicate)` / `.activateWhen({ [id]: predicate })`
1205
+
1206
+ Conditional activation — defaults inactive, becomes active when predicate returns `true`. Moves the ID into the conditional partition.
1207
+
1208
+ ### `.deactivateWhen(id, predicate)` / `.deactivateWhen({ [id]: predicate })`
1209
+
1210
+ Conditional deactivation — defaults active, becomes inactive when predicate returns `true`. Also moves the ID into the conditional partition.
1211
+
1212
+ Predicate input: `{ state?: ConversationState; context?: TShared }`.
1213
+
1214
+ ### `.defineSituations({ [name]: config })`
1215
+
1216
+ Declarative named situations. Each config may include:
1217
+
1218
+ - `enabled?: readonly Id[]` — statically on
1219
+ - `disabled?: readonly Id[]` — statically off
1220
+ - `conditional?: { [id]: predicate | { mode?, predicate } }` — runtime rules
1221
+
1222
+ Situation overlays the base partition for every ID it mentions; unmentioned IDs keep the base state. Unknown, duplicate, or conflicting IDs within one situation throw.
1223
+
1224
+ ### `.resolve(input?)` → snapshot
1225
+
1226
+ ```ts
1227
+ {
1228
+ tools: /* active tools, construction order, concrete types */;
1229
+ activeTools: /* active *client* names for callModel */;
1230
+ callModel: { tools, activeTools }; // safe to spread into callModel()
1231
+ enabled: /* every active ID (client + server) */;
1232
+ disabled: /* every inactive ID */;
1233
+ statusByTool: {
1234
+ [id]: {
1235
+ enabled: boolean;
1236
+ reason: 'default' | 'activate' | 'deactivate' | 'activateWhen' | 'deactivateWhen' | 'situation';
1237
+ directive?: 'activate' | 'deactivate' | 'activateWhen' | 'deactivateWhen';
1238
+ predicate?: boolean; // true when a runtime predicate decided the result
1239
+ };
1240
+ };
1241
+ }
1242
+ ```
1243
+
1244
+ ### `.resolveSituation(name, input?)` → snapshot
1245
+
1246
+ Same shape as `resolve`, with the named situation overlay applied first.
1247
+
1248
+ ### `.inferTools(input?)`
1249
+
1250
+ Back-compat alias for `resolve`. Prefer `resolve` in new code.
1251
+
1252
+ ### `.clone({ mutable? })`
1253
+
1254
+ Copy state, optionally flipping mode.
1255
+
1256
+ ### Inference utilities
1257
+
1258
+ ```ts
1259
+ type All = InferAllIds<typeof toolSet>;
1260
+ type Enabled = InferEnabledIds<typeof toolSet>;
1261
+ type Disabled = InferDisabledIds<typeof toolSet>;
1262
+ type Conditional = InferConditionalIds<typeof toolSet>;
1263
+ ```
1264
+
1265
+ ### `InferToolSet<TTools>`
1266
+
1267
+ Alias of the agent's `CorrelatedToolEventUnion<TTools>` — name-correlated preliminary/result stream events based on a tools tuple.
1268
+
1269
+ ## Notes
1270
+
1271
+ - Immutable by default (every mutator returns a new `ToolSet` with refined partition types).
1272
+ - `mutable: true` mutates in place. Partition type parameters may widen for soundness; runtime state is still exact.
1273
+ - Last-call-wins: each directive on a given ID replaces any prior one for that ID.
1274
+ - Server tools participate fully in activation once they have an ID. When active they appear in `tools` (and `enabled` / `statusByTool`) but **not** in `activeTools`, which remains the client-name list expected by `callModel`.
1275
+ - Keep a snapshot's `tools` and `activeTools` together by spreading `.callModel`; `callModel` cannot verify `activeTools` against an unrelated tools array.
1276
+ - `callModel` ignores names in `activeTools` that are not present in `tools`. Tool-set snapshots avoid stale names by deriving both arrays from the same set.
1277
+
1278
+
561
1279
  ## Subpath Exports
562
1280
 
563
1281
  For tree-shaking or targeted imports, the package provides granular subpath exports:
@@ -568,11 +1286,18 @@ import { tool } from '@openrouter/agent/tool';
568
1286
  import { ModelResult } from '@openrouter/agent/model-result';
569
1287
  import { HooksManager } from '@openrouter/agent/hooks-manager';
570
1288
  import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions';
1289
+ import { DoomLoopMonitor, fingerprintToolCall } from '@openrouter/agent/doom-loop';
571
1290
  import { toClaudeMessage } from '@openrouter/agent/anthropic-compat';
572
1291
  import { toChatMessage } from '@openrouter/agent/chat-compat';
573
1292
  import { ToolContextStore } from '@openrouter/agent/tool-context';
574
1293
  import { ToolEventBroadcaster } from '@openrouter/agent/tool-event-broadcaster';
575
1294
  import { createInitialState } from '@openrouter/agent/conversation-state';
1295
+ import { resumeToolResults } from '@openrouter/agent/resume-tool-results';
1296
+ import { Semaphore } from '@openrouter/agent/tool-concurrency';
1297
+ import { AsyncToolRegistry } from '@openrouter/agent/async-tool-registry';
1298
+ import { ToolTask } from '@openrouter/agent/tool-task';
1299
+ import { TaskToolInputSchema } from '@openrouter/agent/tool-check';
1300
+ import { AgentTranscriptSource } from '@openrouter/agent/agent-tool';
576
1301
  ```
577
1302
 
578
1303
  ## Development