@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.
- package/README.md +732 -7
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/esm/index.d.ts +20 -5
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +10 -3
- package/esm/index.js.map +1 -1
- package/esm/inner-loop/call-model.d.ts.map +1 -1
- package/esm/inner-loop/call-model.js +63 -46
- package/esm/inner-loop/call-model.js.map +1 -1
- package/esm/inner-loop/resume-tool-results.d.ts +128 -0
- package/esm/inner-loop/resume-tool-results.d.ts.map +1 -0
- package/esm/inner-loop/resume-tool-results.js +244 -0
- package/esm/inner-loop/resume-tool-results.js.map +1 -0
- package/esm/lib/agent-tool.d.ts +92 -0
- package/esm/lib/agent-tool.d.ts.map +1 -0
- package/esm/lib/agent-tool.js +211 -0
- package/esm/lib/agent-tool.js.map +1 -0
- package/esm/lib/async-params.d.ts +96 -0
- package/esm/lib/async-params.d.ts.map +1 -1
- package/esm/lib/async-params.js +30 -1
- package/esm/lib/async-params.js.map +1 -1
- package/esm/lib/async-tool-registry.d.ts +134 -0
- package/esm/lib/async-tool-registry.d.ts.map +1 -0
- package/esm/lib/async-tool-registry.js +352 -0
- package/esm/lib/async-tool-registry.js.map +1 -0
- package/esm/lib/async-tools.d.ts +23 -0
- package/esm/lib/async-tools.d.ts.map +1 -0
- package/esm/lib/async-tools.js +19 -0
- package/esm/lib/async-tools.js.map +1 -0
- package/esm/lib/conversation-state.d.ts +1 -0
- package/esm/lib/conversation-state.d.ts.map +1 -1
- package/esm/lib/conversation-state.js +8 -0
- package/esm/lib/conversation-state.js.map +1 -1
- package/esm/lib/doom-loop.d.ts +533 -0
- package/esm/lib/doom-loop.d.ts.map +1 -0
- package/esm/lib/doom-loop.js +1221 -0
- package/esm/lib/doom-loop.js.map +1 -0
- package/esm/lib/hooks-schemas.d.ts +33 -0
- package/esm/lib/hooks-schemas.d.ts.map +1 -1
- package/esm/lib/hooks-schemas.js +54 -0
- package/esm/lib/hooks-schemas.js.map +1 -1
- package/esm/lib/hooks-types.d.ts +6 -2
- package/esm/lib/hooks-types.d.ts.map +1 -1
- package/esm/lib/hooks-types.js.map +1 -1
- package/esm/lib/model-result.d.ts +648 -12
- package/esm/lib/model-result.d.ts.map +1 -1
- package/esm/lib/model-result.js +2784 -164
- package/esm/lib/model-result.js.map +1 -1
- package/esm/lib/next-turn-params.d.ts.map +1 -1
- package/esm/lib/next-turn-params.js +3 -1
- package/esm/lib/next-turn-params.js.map +1 -1
- package/esm/lib/next-turn-params.test.js +72 -0
- package/esm/lib/next-turn-params.test.js.map +1 -1
- package/esm/lib/reusable-stream.d.ts +9 -0
- package/esm/lib/reusable-stream.d.ts.map +1 -1
- package/esm/lib/reusable-stream.js +17 -0
- package/esm/lib/reusable-stream.js.map +1 -1
- package/esm/lib/stream-transformers.d.ts +21 -1
- package/esm/lib/stream-transformers.d.ts.map +1 -1
- package/esm/lib/stream-transformers.js +45 -0
- package/esm/lib/stream-transformers.js.map +1 -1
- package/esm/lib/tool-check.d.ts +76 -0
- package/esm/lib/tool-check.d.ts.map +1 -0
- package/esm/lib/tool-check.js +241 -0
- package/esm/lib/tool-check.js.map +1 -0
- package/esm/lib/tool-concurrency.d.ts +42 -0
- package/esm/lib/tool-concurrency.d.ts.map +1 -0
- package/esm/lib/tool-concurrency.js +80 -0
- package/esm/lib/tool-concurrency.js.map +1 -0
- package/esm/lib/tool-context.d.ts +45 -1
- package/esm/lib/tool-context.d.ts.map +1 -1
- package/esm/lib/tool-context.js +60 -1
- package/esm/lib/tool-context.js.map +1 -1
- package/esm/lib/tool-executor.d.ts +52 -9
- package/esm/lib/tool-executor.d.ts.map +1 -1
- package/esm/lib/tool-executor.js +253 -21
- package/esm/lib/tool-executor.js.map +1 -1
- package/esm/lib/tool-orchestrator.d.ts.map +1 -1
- package/esm/lib/tool-orchestrator.js +87 -50
- package/esm/lib/tool-orchestrator.js.map +1 -1
- package/esm/lib/tool-set-types.d.ts +298 -0
- package/esm/lib/tool-set-types.d.ts.map +1 -0
- package/esm/lib/tool-set-types.js +2 -0
- package/esm/lib/tool-set-types.js.map +1 -0
- package/esm/lib/tool-set.d.ts +157 -0
- package/esm/lib/tool-set.d.ts.map +1 -0
- package/esm/lib/tool-set.js +473 -0
- package/esm/lib/tool-set.js.map +1 -0
- package/esm/lib/tool-task.d.ts +157 -0
- package/esm/lib/tool-task.d.ts.map +1 -0
- package/esm/lib/tool-task.js +206 -0
- package/esm/lib/tool-task.js.map +1 -0
- package/esm/lib/tool-types.d.ts +667 -38
- package/esm/lib/tool-types.d.ts.map +1 -1
- package/esm/lib/tool-types.js +59 -10
- package/esm/lib/tool-types.js.map +1 -1
- package/esm/lib/tool.d.ts +227 -19
- package/esm/lib/tool.d.ts.map +1 -1
- package/esm/lib/tool.js +206 -6
- package/esm/lib/tool.js.map +1 -1
- package/esm/mcp/auth/auth-resolver.d.ts +14 -0
- package/esm/mcp/auth/auth-resolver.d.ts.map +1 -0
- package/esm/mcp/auth/auth-resolver.js +27 -0
- package/esm/mcp/auth/auth-resolver.js.map +1 -0
- package/esm/mcp/auth/auth-types.d.ts +33 -0
- package/esm/mcp/auth/auth-types.d.ts.map +1 -0
- package/esm/mcp/auth/auth-types.js +4 -0
- package/esm/mcp/auth/auth-types.js.map +1 -0
- package/esm/mcp/build-tools.d.ts +29 -0
- package/esm/mcp/build-tools.d.ts.map +1 -0
- package/esm/mcp/build-tools.js +80 -0
- package/esm/mcp/build-tools.js.map +1 -0
- package/esm/mcp/cache/cache-store.d.ts +24 -0
- package/esm/mcp/cache/cache-store.d.ts.map +1 -0
- package/esm/mcp/cache/cache-store.js +20 -0
- package/esm/mcp/cache/cache-store.js.map +1 -0
- package/esm/mcp/cache/cache-types.d.ts +71 -0
- package/esm/mcp/cache/cache-types.d.ts.map +1 -0
- package/esm/mcp/cache/cache-types.js +49 -0
- package/esm/mcp/cache/cache-types.js.map +1 -0
- package/esm/mcp/cache/serialize.d.ts +25 -0
- package/esm/mcp/cache/serialize.d.ts.map +1 -0
- package/esm/mcp/cache/serialize.js +96 -0
- package/esm/mcp/cache/serialize.js.map +1 -0
- package/esm/mcp/close-quietly.d.ts +24 -0
- package/esm/mcp/close-quietly.d.ts.map +1 -0
- package/esm/mcp/close-quietly.js +30 -0
- package/esm/mcp/close-quietly.js.map +1 -0
- package/esm/mcp/create-mcp-tools.d.ts +13 -0
- package/esm/mcp/create-mcp-tools.d.ts.map +1 -0
- package/esm/mcp/create-mcp-tools.js +87 -0
- package/esm/mcp/create-mcp-tools.js.map +1 -0
- package/esm/mcp/elicitation.d.ts +11 -0
- package/esm/mcp/elicitation.d.ts.map +1 -0
- package/esm/mcp/elicitation.js +51 -0
- package/esm/mcp/elicitation.js.map +1 -0
- package/esm/mcp/errors.d.ts +91 -0
- package/esm/mcp/errors.d.ts.map +1 -0
- package/esm/mcp/errors.js +91 -0
- package/esm/mcp/errors.js.map +1 -0
- package/esm/mcp/handle.d.ts +66 -0
- package/esm/mcp/handle.d.ts.map +1 -0
- package/esm/mcp/handle.js +323 -0
- package/esm/mcp/handle.js.map +1 -0
- package/esm/mcp/index.d.ts +13 -0
- package/esm/mcp/index.d.ts.map +1 -0
- package/esm/mcp/index.js +11 -0
- package/esm/mcp/index.js.map +1 -0
- package/esm/mcp/mcp-connection.d.ts +150 -0
- package/esm/mcp/mcp-connection.d.ts.map +1 -0
- package/esm/mcp/mcp-connection.js +478 -0
- package/esm/mcp/mcp-connection.js.map +1 -0
- package/esm/mcp/mcp-sdk.d.ts +10 -0
- package/esm/mcp/mcp-sdk.d.ts.map +1 -0
- package/esm/mcp/mcp-sdk.js +37 -0
- package/esm/mcp/mcp-sdk.js.map +1 -0
- package/esm/mcp/rehydrate.d.ts +76 -0
- package/esm/mcp/rehydrate.d.ts.map +1 -0
- package/esm/mcp/rehydrate.js +505 -0
- package/esm/mcp/rehydrate.js.map +1 -0
- package/esm/mcp/resource-tools.d.ts +17 -0
- package/esm/mcp/resource-tools.d.ts.map +1 -0
- package/esm/mcp/resource-tools.js +155 -0
- package/esm/mcp/resource-tools.js.map +1 -0
- package/esm/mcp/result-mapper.d.ts +19 -0
- package/esm/mcp/result-mapper.d.ts.map +1 -0
- package/esm/mcp/result-mapper.js +40 -0
- package/esm/mcp/result-mapper.js.map +1 -0
- package/esm/mcp/schema/json-schema-guards.d.ts +14 -0
- package/esm/mcp/schema/json-schema-guards.d.ts.map +1 -0
- package/esm/mcp/schema/json-schema-guards.js +19 -0
- package/esm/mcp/schema/json-schema-guards.js.map +1 -0
- package/esm/mcp/schema/json-schema-to-zod.d.ts +20 -0
- package/esm/mcp/schema/json-schema-to-zod.d.ts.map +1 -0
- package/esm/mcp/schema/json-schema-to-zod.js +72 -0
- package/esm/mcp/schema/json-schema-to-zod.js.map +1 -0
- package/esm/mcp/tool-wrapper.d.ts +41 -0
- package/esm/mcp/tool-wrapper.d.ts.map +1 -0
- package/esm/mcp/tool-wrapper.js +131 -0
- package/esm/mcp/tool-wrapper.js.map +1 -0
- package/esm/mcp/transport-types.d.ts +45 -0
- package/esm/mcp/transport-types.d.ts.map +1 -0
- package/esm/mcp/transport-types.js +2 -0
- package/esm/mcp/transport-types.js.map +1 -0
- package/esm/mcp/types.d.ts +119 -0
- package/esm/mcp/types.d.ts.map +1 -0
- package/esm/mcp/types.js +2 -0
- package/esm/mcp/types.js.map +1 -0
- package/esm/mcp/version.d.ts +3 -0
- package/esm/mcp/version.d.ts.map +1 -0
- package/esm/mcp/version.js +5 -0
- package/esm/mcp/version.js.map +1 -0
- 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.
|
|
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: [
|
|
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
|