@openrouter/agent 0.7.2 → 0.9.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 +728 -19
- package/esm/index.d.ts +24 -6
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +20 -5
- package/esm/index.js.map +1 -1
- package/esm/inner-loop/call-model.d.ts +8 -5
- package/esm/inner-loop/call-model.d.ts.map +1 -1
- package/esm/inner-loop/call-model.js +49 -40
- 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 +107 -6
- package/esm/lib/async-params.d.ts.map +1 -1
- package/esm/lib/async-params.js +8 -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 +45 -1
- package/esm/lib/conversation-state.d.ts.map +1 -1
- package/esm/lib/conversation-state.js +119 -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-emit.d.ts +50 -0
- package/esm/lib/hooks-emit.d.ts.map +1 -0
- package/esm/lib/hooks-emit.js +307 -0
- package/esm/lib/hooks-emit.js.map +1 -0
- package/esm/lib/hooks-manager.d.ts +141 -0
- package/esm/lib/hooks-manager.d.ts.map +1 -0
- package/esm/lib/hooks-manager.js +282 -0
- package/esm/lib/hooks-manager.js.map +1 -0
- package/esm/lib/hooks-matchers.d.ts +12 -0
- package/esm/lib/hooks-matchers.d.ts.map +1 -0
- package/esm/lib/hooks-matchers.js +26 -0
- package/esm/lib/hooks-matchers.js.map +1 -0
- package/esm/lib/hooks-resolve.d.ts +17 -0
- package/esm/lib/hooks-resolve.d.ts.map +1 -0
- package/esm/lib/hooks-resolve.js +42 -0
- package/esm/lib/hooks-resolve.js.map +1 -0
- package/esm/lib/hooks-schemas.d.ts +221 -0
- package/esm/lib/hooks-schemas.d.ts.map +1 -0
- package/esm/lib/hooks-schemas.js +238 -0
- package/esm/lib/hooks-schemas.js.map +1 -0
- package/esm/lib/hooks-types.d.ts +178 -0
- package/esm/lib/hooks-types.d.ts.map +1 -0
- package/esm/lib/hooks-types.js +45 -0
- package/esm/lib/hooks-types.js.map +1 -0
- package/esm/lib/model-result.d.ts +848 -12
- package/esm/lib/model-result.d.ts.map +1 -1
- package/esm/lib/model-result.js +4055 -410
- package/esm/lib/model-result.js.map +1 -1
- package/esm/lib/reusable-stream.d.ts +15 -0
- package/esm/lib/reusable-stream.d.ts.map +1 -1
- package/esm/lib/reusable-stream.js +25 -0
- package/esm/lib/reusable-stream.js.map +1 -1
- package/esm/lib/stream-transformers.d.ts +20 -0
- 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 +263 -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 -47
- package/esm/lib/tool-orchestrator.js.map +1 -1
- 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 +609 -58
- package/esm/lib/tool-types.d.ts.map +1 -1
- package/esm/lib/tool-types.js +72 -10
- package/esm/lib/tool-types.js.map +1 -1
- package/esm/lib/tool.d.ts +217 -25
- package/esm/lib/tool.d.ts.map +1 -1
- package/esm/lib/tool.js +210 -1
- package/esm/lib/tool.js.map +1 -1
- package/package.json +35 -3
package/README.md
CHANGED
|
@@ -68,10 +68,14 @@ const result = callModel(client, { model, input, tools });
|
|
|
68
68
|
// Await the final text
|
|
69
69
|
const text = await result.getText();
|
|
70
70
|
|
|
71
|
-
// Await the full response with usage data
|
|
71
|
+
// Await the full response with usage data (the FINAL round only)
|
|
72
72
|
const response = await result.getResponse();
|
|
73
73
|
console.log(response.usage); // { inputTokens, outputTokens, cost, ... }
|
|
74
74
|
|
|
75
|
+
// Await aggregate usage across EVERY round of the tool loop
|
|
76
|
+
const usage = await result.getUsage();
|
|
77
|
+
console.log(usage); // { modelCalls, inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens, cost? }
|
|
78
|
+
|
|
75
79
|
// Stream text deltas
|
|
76
80
|
for await (const delta of result.getTextStream()) {
|
|
77
81
|
process.stdout.write(delta);
|
|
@@ -82,9 +86,14 @@ for await (const delta of result.getReasoningStream()) {
|
|
|
82
86
|
process.stdout.write(delta);
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
// Stream tool
|
|
89
|
+
// Stream tool-call argument deltas (plus preliminary results from generator tools)
|
|
86
90
|
for await (const event of result.getToolStream()) {
|
|
87
|
-
console.log(event);
|
|
91
|
+
console.log(event); // { type: 'delta', content: '...' } | { type: 'preliminary_result', ... }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Stream all response events, including tool execution results
|
|
95
|
+
for await (const event of result.getFullResponsesStream()) {
|
|
96
|
+
// includes tool.result / tool.call_output events
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
// Stream structured tool calls
|
|
@@ -96,9 +105,55 @@ for await (const toolCall of result.getToolCallsStream()) {
|
|
|
96
105
|
const toolCalls = await result.getToolCalls();
|
|
97
106
|
```
|
|
98
107
|
|
|
108
|
+
What each stream emits:
|
|
109
|
+
|
|
110
|
+
| Method | Emits |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `getTextStream()` | assistant text deltas |
|
|
113
|
+
| `getReasoningStream()` | reasoning deltas |
|
|
114
|
+
| `getToolStream()` | tool-call **argument deltas**; `preliminary_result` events for generator tools — *not* execution results |
|
|
115
|
+
| `getToolCallsStream()` | parsed tool calls as they complete |
|
|
116
|
+
| `getItemsStream()` | all output items (messages, function calls, …) — output items **only**, no usage/response metadata |
|
|
117
|
+
| `getFullResponsesStream()` | every response event, including `tool.result` / `tool.call_output` execution events, and each round's `response.completed` (with that round's usage block) |
|
|
118
|
+
|
|
119
|
+
#### Usage across a multi-round tool loop
|
|
120
|
+
|
|
121
|
+
`getResponse()` resolves to the **final** round's response, so in a
|
|
122
|
+
multi-round tool loop the tokens spent on the intermediate `tool_calls`
|
|
123
|
+
generations are not in `response.usage`. `getItemsStream()` carries output
|
|
124
|
+
items only and never surfaces `response.completed`, so usage is not reachable
|
|
125
|
+
from that stream either.
|
|
126
|
+
|
|
127
|
+
`getUsage()` closes the gap with aggregate totals across every model call the
|
|
128
|
+
run made — the initial request, each tool-round follow-up, the empty-final
|
|
129
|
+
retry, the `allowFinalResponse` final turn, and approval-resume requests:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
const result = callModel(client, { model, input, tools });
|
|
133
|
+
|
|
134
|
+
for await (const item of result.getItemsStream()) {
|
|
135
|
+
render(item);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const usage = await result.getUsage();
|
|
139
|
+
console.log(usage.modelCalls, usage.totalTokens, usage.cost);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
It gates on run completion like `getResponse()` does, so the totals are final
|
|
143
|
+
whether you await it directly, after `getResponse()`, or after draining any of
|
|
144
|
+
the streaming getters. Unlike `getResponse()` it never rejects — a failed run
|
|
145
|
+
still consumed tokens — and returns the totals accrued so far, with
|
|
146
|
+
`modelCalls: 0` and zeroed tokens when no model call completed. `cost` is
|
|
147
|
+
present only when the server reported cost accounting.
|
|
148
|
+
|
|
149
|
+
Same `SessionUsageTotals` shape and numbers as the `SessionEnd` hook's
|
|
150
|
+
`totalUsage`. For **per-call** granularity use the `PostModelCall` hook (one
|
|
151
|
+
emit per model call, with `turnType`/`turnNumber`) or read each round's
|
|
152
|
+
`response.completed` off `getFullResponsesStream()`.
|
|
153
|
+
|
|
99
154
|
### Tool Types
|
|
100
155
|
|
|
101
|
-
The `tool()` factory creates type-safe tools with full Zod schema inference.
|
|
156
|
+
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).
|
|
102
157
|
|
|
103
158
|
**Regular tools** — automatically executed by the agent loop:
|
|
104
159
|
|
|
@@ -141,6 +196,26 @@ const confirmTool = tool({
|
|
|
141
196
|
});
|
|
142
197
|
```
|
|
143
198
|
|
|
199
|
+
**Forced tool choices** are one-shot for each resolved semantic value. After a
|
|
200
|
+
forced choice produces a tool call, unchanged follow-up choices are relaxed to
|
|
201
|
+
`auto` so the model can either call another tool or answer in text. A dynamic
|
|
202
|
+
choice re-arms when it resolves to a different value (or after an unforced
|
|
203
|
+
turn):
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
callModel(client, {
|
|
207
|
+
model: 'openai/gpt-4o',
|
|
208
|
+
input: 'Plan, research, then submit.',
|
|
209
|
+
tools: [planTool, searchTool, submitTool] as const,
|
|
210
|
+
toolChoice: ({ numberOfTurns }) =>
|
|
211
|
+
numberOfTurns === 0
|
|
212
|
+
? { type: 'function', name: 'plan' }
|
|
213
|
+
: numberOfTurns === 3
|
|
214
|
+
? { type: 'function', name: 'submit' }
|
|
215
|
+
: 'auto',
|
|
216
|
+
});
|
|
217
|
+
```
|
|
218
|
+
|
|
144
219
|
### Stop Conditions
|
|
145
220
|
|
|
146
221
|
Control when the agent loop stops executing tools:
|
|
@@ -171,8 +246,12 @@ Built-in stop conditions:
|
|
|
171
246
|
|
|
172
247
|
**Final response after stop**
|
|
173
248
|
|
|
174
|
-
When `stopWhen` fires while the model is still emitting tool calls,
|
|
175
|
-
|
|
249
|
+
When `stopWhen` fires while the model is still emitting tool calls, the
|
|
250
|
+
loop makes one more model turn with `toolChoice: 'none'` so the run ends
|
|
251
|
+
with a natural-language answer instead of a half-finished tool call.
|
|
252
|
+
Tools stay in the request — only calling is forbidden — which preserves
|
|
253
|
+
the prompt-cache prefix. **This is on by default**; `allowFinalResponse`
|
|
254
|
+
tunes it:
|
|
176
255
|
|
|
177
256
|
```typescript
|
|
178
257
|
callModel(client, {
|
|
@@ -180,18 +259,433 @@ callModel(client, {
|
|
|
180
259
|
input: 'Research this topic',
|
|
181
260
|
tools: [searchTool] as const,
|
|
182
261
|
stopWhen: stepCountIs(5),
|
|
183
|
-
|
|
184
|
-
//
|
|
262
|
+
// default (omitted or `true`): appends DEFAULT_FINAL_RESPONSE_DIRECTIVE
|
|
263
|
+
// as a final user message so the model writes an answer instead of
|
|
264
|
+
// attempting another tool call
|
|
265
|
+
|
|
266
|
+
// override the directive wording:
|
|
267
|
+
// allowFinalResponse: 'Please summarize what you found.',
|
|
268
|
+
// append no message (turn still happens, calls still forbidden):
|
|
269
|
+
// allowFinalResponse: '',
|
|
270
|
+
// disable the final turn entirely:
|
|
271
|
+
// allowFinalResponse: false,
|
|
185
272
|
});
|
|
186
273
|
```
|
|
187
274
|
|
|
188
275
|
The pending tool calls from the halted turn are executed first so they
|
|
189
276
|
have real outputs in the input, then the full conversation and the
|
|
190
|
-
original `instructions` are sent
|
|
191
|
-
non-empty string value is appended as a final `user` message. Any
|
|
277
|
+
original `instructions` are sent with `toolChoice: 'none'`. Any
|
|
192
278
|
non-executable (manual) tool calls in the halted turn are paired with
|
|
193
279
|
synthesized stub `function_call_output` items so the input is well-formed.
|
|
194
280
|
|
|
281
|
+
### Cancellation & Request Timeouts
|
|
282
|
+
|
|
283
|
+
A `callModel` run is a *sequence* of API requests (initial, one per tool
|
|
284
|
+
round, plus final/retry turns). Two independent bounds compose:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
const controller = new AbortController();
|
|
288
|
+
|
|
289
|
+
const result = callModel(
|
|
290
|
+
client,
|
|
291
|
+
{
|
|
292
|
+
model: 'openai/gpt-4o',
|
|
293
|
+
input: 'Research this topic',
|
|
294
|
+
tools: [searchTool] as const,
|
|
295
|
+
// Cancel the WHOLE run: stops the loop at the next turn boundary and
|
|
296
|
+
// aborts the in-flight request/stream. Promises reject with the
|
|
297
|
+
// signal's abort reason.
|
|
298
|
+
signal: controller.signal,
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
// Bound EACH request: a provider that stalls fails that request after
|
|
302
|
+
// 90s instead of hanging until an outer timeout kills the process.
|
|
303
|
+
timeoutMs: 90000,
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// later, e.g. on user navigation:
|
|
308
|
+
controller.abort(new Error('user cancelled'));
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
`timeoutMs` is per-request (each dispatch gets a fresh budget), not
|
|
312
|
+
per-run — bound the run with `stopWhen` (`maxCost`, `maxTokensUsed`,
|
|
313
|
+
`stepCountIs`) and/or `signal`. The two compose: when both are set, each
|
|
314
|
+
request is bounded by whichever fires first. Prefer the `signal` option
|
|
315
|
+
over passing a raw `signal` through `RequestOptions` — the underlying SDK
|
|
316
|
+
skips its `timeoutMs` wiring whenever a request already carries a signal,
|
|
317
|
+
so the engine re-composes them for you only on the `signal` option path.
|
|
318
|
+
|
|
319
|
+
### Doom-Loop Detection
|
|
320
|
+
|
|
321
|
+
Catch runs that stop making progress while continuing to spend: the model
|
|
322
|
+
re-issuing the same tool call with identical arguments (including repeated
|
|
323
|
+
*empty* calls and repeated invalid-JSON calls), repeating identical
|
|
324
|
+
server-tool requests, or emitting the same text tokens over and over. Off by
|
|
325
|
+
default; opt in with `doomLoop: true`:
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
const result = callModel(client, {
|
|
329
|
+
model: 'openai/gpt-4o',
|
|
330
|
+
input: 'Research this topic',
|
|
331
|
+
tools: [searchTool, bashTool] as const,
|
|
332
|
+
doomLoop: true, // recommended defaults: observe@2, block@3, stop@6
|
|
333
|
+
// or tune it:
|
|
334
|
+
// doomLoop: {
|
|
335
|
+
// ladder: { observe: 2, steer: false, block: 3, stop: 6 },
|
|
336
|
+
// text: { minRepeats: 4 }, // or `false` to disable text detection
|
|
337
|
+
// },
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Was the run stopped by detection?
|
|
341
|
+
const verdict = await result.getDoomLoopVerdict();
|
|
342
|
+
if (verdict) console.warn(verdict.message);
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Detection is **deterministic** — a verdict is a pure function of the
|
|
346
|
+
transcript, so the same sequence of calls/text always fires at the same
|
|
347
|
+
point. Repeated **rounds** build a per-tool streak: interleaved calls to
|
|
348
|
+
*other* tools don't reset it, and N identical calls fanned out in parallel
|
|
349
|
+
within ONE round count once (a streak measures the model re-issuing a call
|
|
350
|
+
*after seeing its result*, which requires a round trip).
|
|
351
|
+
|
|
352
|
+
Two kinds of evidence accumulate side by side, and the stronger one decides:
|
|
353
|
+
|
|
354
|
+
- **Round-set streaks.** A round's identity for one tool is the **set** of
|
|
355
|
+
calls it made, so a fan-out of *distinct* arguments reissued verbatim
|
|
356
|
+
counts: `read(a), read(b), read(c)` every round accumulates. Ordering
|
|
357
|
+
within the round is irrelevant, and a round whose membership changes — in
|
|
358
|
+
either direction — resets this streak, since adding or dropping work is
|
|
359
|
+
progress for the round as a unit.
|
|
360
|
+
- **Per-call streaks.** Each `(tool, arguments)` identity also counts its own
|
|
361
|
+
consecutive rounds, whatever its round-mates did. A call repeating inside
|
|
362
|
+
varying company (`[a,b]`, `[a,c]`, `[a,d]` — `a` is a 3-peat) is flagged
|
|
363
|
+
even though every round's set differs, and a repeat spanning an approval
|
|
364
|
+
pause keeps counting when the paused member drops from the resumed round.
|
|
365
|
+
For an exactly-repeating round both counts are equal, so nothing
|
|
366
|
+
double-fires.
|
|
367
|
+
|
|
368
|
+
When a repeating fan-out crosses a rung, every call in the round gets the
|
|
369
|
+
verdict (so `block` stops the whole fan-out, not just one member), and calls
|
|
370
|
+
carrying the SAME evidence share byte-identical text — the `steer` rung
|
|
371
|
+
dedupes on exact text, so one piece of evidence injects one correction. A
|
|
372
|
+
round can carry two pieces of evidence at once (`[a]`, `[a,b]`, `[a,b]`: by
|
|
373
|
+
round 3, `a` is a 3-peat call while `{a,b}` is a 2-peat set), in which case
|
|
374
|
+
each renders its own message — at most two per tool per round, each stating
|
|
375
|
+
a distinct fact. When the per-call count alone crosses a rung, only that
|
|
376
|
+
call is refused and genuinely new round-mates run free. The streak crosses
|
|
377
|
+
a graduated ladder — strongest crossed rung wins:
|
|
378
|
+
|
|
379
|
+
| Action | Effect |
|
|
380
|
+
|---|---|
|
|
381
|
+
| `observe` | Emit the `DoomLoopDetected` hook only |
|
|
382
|
+
| `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 |
|
|
383
|
+
| `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 |
|
|
384
|
+
| `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` |
|
|
385
|
+
| `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 |
|
|
386
|
+
|
|
387
|
+
**Escalation recovery** — instead of (or before) blocking, unblock the loop
|
|
388
|
+
by escalating the next turn, then return to the cheap model:
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
callModel(client, {
|
|
392
|
+
model: 'z-ai/glm-5.2', // the everyday executor
|
|
393
|
+
input: 'Research this topic',
|
|
394
|
+
tools: [searchTool] as const,
|
|
395
|
+
doomLoop: {
|
|
396
|
+
ladder: { observe: 2, escalate: 3, block: 5, stop: 8 },
|
|
397
|
+
escalation: {
|
|
398
|
+
// Either or both:
|
|
399
|
+
model: 'anthropic/claude-opus-4.6', // run the NEXT turn on a stronger model
|
|
400
|
+
advisor: true, // and/or force an openrouter:advisor consult
|
|
401
|
+
maxEscalations: 2, // spend cap for the whole conversation
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
On an `escalate` verdict the engine (1) injects a user notice naming the
|
|
408
|
+
detected loop, and (2) applies one-turn request overrides: `model` is
|
|
409
|
+
swapped for that dispatch only, and/or the `openrouter:advisor` server tool
|
|
410
|
+
is appended with `forwardTranscript: true`, loop-diagnosing instructions,
|
|
411
|
+
and `toolChoice` pinned to it (`allowed_tools`/`required`) so the stuck
|
|
412
|
+
model must consult the advisor before doing anything else. `advisor` may
|
|
413
|
+
also be an object passed through as the advisor tool's parameters
|
|
414
|
+
(`model`, `instructions`, `maxToolCalls`, ...). The following turn reverts
|
|
415
|
+
automatically. Budget is consumed when a recovery is *applied* (not at
|
|
416
|
+
verdict time) and `escalationsUsed` persists in `ConversationState.doomLoop`
|
|
417
|
+
so a resumed run cannot reset it; concurrent detector verdicts in one window
|
|
418
|
+
escalate once.
|
|
419
|
+
|
|
420
|
+
Ladder configs are sanity-checked at resolve time: enabling `block` with
|
|
421
|
+
`stop: false` warns (a model that keeps re-issuing a blocked call loops
|
|
422
|
+
until `stopWhen` fires — and `stopWhen` defaults to unbounded), dead
|
|
423
|
+
rungs (a weaker threshold at or past an enabled stronger one) warn, and so
|
|
424
|
+
does an `escalate` rung without an `escalation` config (or vice versa).
|
|
425
|
+
|
|
426
|
+
**Tools declare what identifies a call** via `loopKey` on the tool
|
|
427
|
+
definition — a computed function over the call's validated arguments, like
|
|
428
|
+
every other tool hook, or `false` to exempt:
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
// Compute the identity — a web-search tool normalizes its query.
|
|
432
|
+
tool({
|
|
433
|
+
name: 'web_search',
|
|
434
|
+
inputSchema: z.object({ query: z.string() }),
|
|
435
|
+
loopKey: ({ query }) => query.trim().toLowerCase(),
|
|
436
|
+
execute: async ({ query }) => search(query),
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Return the subset of fields that matter. A bash call is identified by
|
|
440
|
+
// the command AND where it runs; other fields (e.g. verbose) don't count.
|
|
441
|
+
tool({
|
|
442
|
+
name: 'bash',
|
|
443
|
+
inputSchema: z.object({ command: z.string(), cwd: z.string(), verbose: z.boolean() }),
|
|
444
|
+
loopKey: ({ command, cwd }) => ({ command, cwd }),
|
|
445
|
+
execute: async ({ command, cwd }) => run(command, cwd),
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// false: statically exempt — repetition is this tool's job.
|
|
449
|
+
tool({
|
|
450
|
+
name: 'check_status',
|
|
451
|
+
inputSchema: z.object({ jobId: z.string() }),
|
|
452
|
+
loopKey: false,
|
|
453
|
+
execute: async ({ jobId }) => poll(jobId),
|
|
454
|
+
});
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
A function-form `loopKey` may return `null` to exempt an individual call.
|
|
458
|
+
Returning `undefined`, throwing, or returning unhashable material (bigint,
|
|
459
|
+
circular, >64 levels deep) falls back to the full-arguments identity with a
|
|
460
|
+
warning — detection never fails a run. Without any `loopKey`, the full
|
|
461
|
+
validated arguments object is the identity. A field-name array
|
|
462
|
+
(`loopKey: ['command', 'cwd']`) is also accepted — data rather than code,
|
|
463
|
+
so it can be serialized into MCP tool caches and advertised over the wire
|
|
464
|
+
via `_meta['openrouter/loopKey']`. MCP-wrapped tools accept a `loopKey`
|
|
465
|
+
via `markMcp(tool, { loopKey })` or the `loopKeys` map on
|
|
466
|
+
`createMCPTools`.
|
|
467
|
+
|
|
468
|
+
> **Exempt tools that repeat by design — including repeating *fan-outs*.**
|
|
469
|
+
> The detector compares arguments, not results, so a call whose arguments are
|
|
470
|
+
> stable while its results change is indistinguishable from a loop. Since a
|
|
471
|
+
> round's identity is now the whole *set* of a tool's calls, this covers
|
|
472
|
+
> parallel shapes too: an agent that re-reads the same context files at the
|
|
473
|
+
> start of every turn, or fans out a fixed set of pollers, accumulates a streak
|
|
474
|
+
> and is refused at the default `block` rung from round 3 — and because every
|
|
475
|
+
> call in the round gets the verdict, that is N synthesized error outputs per
|
|
476
|
+
> round, not one. These shapes were invisible before this behavior existed, so
|
|
477
|
+
> `loopKey: false` (or a `loopKey` returning `null`) is the opt-out for any
|
|
478
|
+
> tool whose repetition is legitimate.
|
|
479
|
+
|
|
480
|
+
**Fingerprints are a cross-port contract**: key material is canonicalized
|
|
481
|
+
per RFC 8785 (JCS) and hashed with SHA-256 over the UTF-8 bytes, so the
|
|
482
|
+
Python/Go ports produce identical fingerprints — they MUST use an RFC 8785
|
|
483
|
+
implementation (pip `jcs`, `cyberphone/json-canonicalization`), not their
|
|
484
|
+
stdlib JSON serializer. The conformance vectors live in
|
|
485
|
+
`tests/vectors/doom-loop-fingerprints.json`. Key order never defeats
|
|
486
|
+
detection; malformed calls count too (a model stuck emitting the same
|
|
487
|
+
invalid JSON trips the detector instead of bouncing off the parse error
|
|
488
|
+
forever). Detector state is plain JSON inside `ConversationState.doomLoop`,
|
|
489
|
+
so streaks survive serialize → resume **when the resuming call also passes
|
|
490
|
+
`doomLoop`**. A `stop` verdict persists across decision-only resumes
|
|
491
|
+
(`approveToolCalls`/`rejectToolCalls`); a fresh conversational turn clears
|
|
492
|
+
it (streaks are kept, so renewed repetition re-condemns quickly).
|
|
493
|
+
|
|
494
|
+
The `DoomLoopDetected` hook observes every verdict and can override the
|
|
495
|
+
action per event (`overrideAction`, last handler wins) — de-escalate a
|
|
496
|
+
block to observe for a known-chatty tool, or escalate straight to stop.
|
|
497
|
+
|
|
498
|
+
**What this does NOT catch** (documented limits, locked by negative tests):
|
|
499
|
+
|
|
500
|
+
- **Varying-input loops.** The fingerprint is identity-based: a model that
|
|
501
|
+
invents a fresh nonce/timestamp field each call evades the default
|
|
502
|
+
whole-arguments identity entirely. A `loopKey` that returns the meaningful
|
|
503
|
+
fields closes this per tool; the structural fix (outcome hashing — the
|
|
504
|
+
progress-ledger detector from the design doc) is planned, not shipped.
|
|
505
|
+
- **Paraphrased repetition.** Text detectors require exact repeated token
|
|
506
|
+
blocks (within a response) or byte-identical whitespace-normalized text
|
|
507
|
+
(across steps). Semantically-identical rephrasings do not trip.
|
|
508
|
+
- **Pre-mutation identity.** Fingerprints are computed on the arguments the
|
|
509
|
+
MODEL issued, before any `PreToolUse` mutation — a hook that rewrites
|
|
510
|
+
varying inputs into identical ones does not make them count as repeats
|
|
511
|
+
(and a hook that injects a nonce cannot mask real repetition).
|
|
512
|
+
- **Manual/client-executed calls** pause the loop for the caller and are
|
|
513
|
+
not recorded (only executed, blocked, and parse-error calls are
|
|
514
|
+
evidence).
|
|
515
|
+
- **Cross-tool round patterns.** Streaks are per tool: a loop alternating
|
|
516
|
+
BETWEEN tools with no per-tool repetition (`read(a)` one round, `grep(a)`
|
|
517
|
+
the next, forever) shows each tool a sparse pattern its own evidence
|
|
518
|
+
cannot condemn. Interleaved calls to other tools never *reset* a tool's
|
|
519
|
+
streak, so an every-other-round repeat still accumulates — slowly.
|
|
520
|
+
|
|
521
|
+
### Async Tools
|
|
522
|
+
|
|
523
|
+
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`:
|
|
524
|
+
|
|
525
|
+
- **`'sync'`** (default): awaited in the round, exactly like `execute`.
|
|
526
|
+
- **`'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.
|
|
527
|
+
- **`'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.
|
|
528
|
+
|
|
529
|
+
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()`.
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
const renderVideo = tool({
|
|
533
|
+
name: 'render_video',
|
|
534
|
+
lifecycle: 'background',
|
|
535
|
+
inputSchema: z.object({ script: z.string() }),
|
|
536
|
+
outputSchema: z.object({ url: z.string() }),
|
|
537
|
+
ack: 'Rendering started.',
|
|
538
|
+
timeoutMs: 300_000,
|
|
539
|
+
run: async function* ({ script }, ctx) {
|
|
540
|
+
const job = await renderer.start(script, { signal: ctx?.signal });
|
|
541
|
+
ctx?.onMessage((msg) => job.reprioritize(msg)); // steering opt-in
|
|
542
|
+
for await (const p of job.progress()) yield { pct: p };
|
|
543
|
+
return job.result();
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const legalReview = tool({
|
|
548
|
+
name: 'request_legal_review',
|
|
549
|
+
lifecycle: 'deferred',
|
|
550
|
+
inputSchema: z.object({ contractId: z.string() }),
|
|
551
|
+
outputSchema: z.object({ approved: z.boolean() }),
|
|
552
|
+
run: async ({ contractId }, ctx) => {
|
|
553
|
+
const ticket = await legal.open(contractId, { conversationId: ctx?.conversationId });
|
|
554
|
+
return ctx!.defer(ticket.id);
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
Deferred completion is typed and lives on the tool — callable from any process holding the `StateAccessor`:
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
// webhook handler — hours later, different process
|
|
563
|
+
await legalReview.resolve(client, {
|
|
564
|
+
state: makeAccessor(conversationId),
|
|
565
|
+
taskId: ticketId,
|
|
566
|
+
output: { approved: true }, // ← typechecked against outputSchema
|
|
567
|
+
run: { model: 'openai/gpt-4o' }, // continue immediately (omit to record-only)
|
|
568
|
+
});
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
`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'`.
|
|
572
|
+
|
|
573
|
+
> **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.
|
|
574
|
+
|
|
575
|
+
### Checking On Long-Running Tasks
|
|
576
|
+
|
|
577
|
+
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:
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
task({ taskId: "task_7f3" }) // status: state, elapsed, last log
|
|
581
|
+
task({ taskId, view: "logs", tail: 5 }) // recent progress entries
|
|
582
|
+
task({ taskId, view: "transcript" }) // full detail (agents: child conversation)
|
|
583
|
+
task({ taskId, action: "steer", message: "..." }) // send guidance to the running task
|
|
584
|
+
task({ taskId, action: "result" }) // final result if settled, else status
|
|
585
|
+
task({ taskId, action: "cancel", reason: "..." }) // stop the task
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Calls are engine-intercepted and dispatched to the **owning tool's** `check` config — the wire surface is universal, the handling stays tool-specific:
|
|
589
|
+
|
|
590
|
+
```typescript
|
|
591
|
+
const renderVideo = tool({
|
|
592
|
+
name: 'render_video',
|
|
593
|
+
lifecycle: 'background',
|
|
594
|
+
// ... as above ...
|
|
595
|
+
check: { // optional — SDK default when absent
|
|
596
|
+
schema: z.object({ focus: z.string().optional() }), // validates task({ params })
|
|
597
|
+
execute: async (params, turnContext) => {
|
|
598
|
+
// turnContext.toolCallStatus → 'working' | 'completed' | ...
|
|
599
|
+
// turnContext.accumulatedYieldedEvents → every run yield so far
|
|
600
|
+
// turnContext.task → { statusView, tailLogs, transcript, send, cancel }
|
|
601
|
+
if (params.focus) turnContext.task?.send(params.focus);
|
|
602
|
+
return turnContext.task?.statusView();
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
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).
|
|
609
|
+
|
|
610
|
+
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.
|
|
611
|
+
|
|
612
|
+
### Steering Running Tasks
|
|
613
|
+
|
|
614
|
+
- **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.
|
|
615
|
+
- **From the model:** `task({ taskId, action: 'steer', message })` delivers directly, or expose custom `params` handled by `check.execute` with `turnContext.task.send(...)`.
|
|
616
|
+
- **Agent tools** forward steering messages into the child conversation automatically (as user messages at the child's next turn boundary).
|
|
617
|
+
|
|
618
|
+
### Agent Tools (Subagents)
|
|
619
|
+
|
|
620
|
+
`tool.agent()` creates a tool whose work IS a child `callModel` conversation, running as a background task:
|
|
621
|
+
|
|
622
|
+
```typescript
|
|
623
|
+
const researcher = tool.agent({
|
|
624
|
+
name: 'research_topic',
|
|
625
|
+
description: 'Deep-research a topic in the background.',
|
|
626
|
+
inputSchema: z.object({ topic: z.string() }),
|
|
627
|
+
outputSchema: z.object({ text: z.string() }),
|
|
628
|
+
agent: ({ topic }) => ({
|
|
629
|
+
model: 'openai/gpt-4o',
|
|
630
|
+
input: `Research: ${topic}`,
|
|
631
|
+
tools: [searchTool, fetchTool] as const,
|
|
632
|
+
stopWhen: stepCountIs(15),
|
|
633
|
+
}),
|
|
634
|
+
// default result mapper — Dennis-style last_message outcome:
|
|
635
|
+
result: async (child) => ({ text: await child.getText() }),
|
|
636
|
+
});
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
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.
|
|
640
|
+
|
|
641
|
+
### Strict Tool Schemas
|
|
642
|
+
|
|
643
|
+
Every client tool kind, including `tool.agent()`, accepts `strict: true` to
|
|
644
|
+
request provider-enforced schema adherence for generated tool-call arguments.
|
|
645
|
+
The SDK faithfully converts the caller's `inputSchema`; it does not rewrite
|
|
646
|
+
the runtime Zod contract.
|
|
647
|
+
|
|
648
|
+
OpenAI-style strict function calling requires every declared object property
|
|
649
|
+
to appear in JSON Schema's `required` list. Use `.nullable()` for a value that
|
|
650
|
+
may be absent conceptually, because `.optional()` omits the property from
|
|
651
|
+
`required`:
|
|
652
|
+
|
|
653
|
+
```typescript
|
|
654
|
+
const weatherTool = tool({
|
|
655
|
+
name: 'get_weather',
|
|
656
|
+
inputSchema: z.object({
|
|
657
|
+
location: z.string(),
|
|
658
|
+
// The key is required, but the model may return null.
|
|
659
|
+
units: z.enum(['celsius', 'fahrenheit']).nullable(),
|
|
660
|
+
}),
|
|
661
|
+
strict: true,
|
|
662
|
+
execute: async ({ location, units }) => getWeather(location, units),
|
|
663
|
+
});
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
The SDK sends the generated schema unchanged. Providers validate it according
|
|
667
|
+
to their own strict-mode dialect and the SDK propagates any API error. Use
|
|
668
|
+
`.nullable()`, or set `strict: false` when omission is part of the tool's
|
|
669
|
+
contract. Provider support and strict-schema restrictions can vary.
|
|
670
|
+
|
|
671
|
+
### Per-Tool Timeouts & Concurrency
|
|
672
|
+
|
|
673
|
+
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()`.
|
|
674
|
+
|
|
675
|
+
Round-level parallelism (unbounded by default, matching previous behavior) is capped with `toolConcurrency`:
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
const result = callModel(client, {
|
|
679
|
+
model: 'openai/gpt-4o',
|
|
680
|
+
input: 'fan out',
|
|
681
|
+
tools: [searchTool] as const,
|
|
682
|
+
toolTimeoutMs: 30_000,
|
|
683
|
+
toolConcurrency: { round: 4, background: 8 }, // or a bare number for { round: n }
|
|
684
|
+
});
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
Execution order may change under a cap; output order never does (results stay in call order for prompt-cache stability).
|
|
688
|
+
|
|
195
689
|
### Tool Approval
|
|
196
690
|
|
|
197
691
|
Gate tool execution with approval checks for sensitive operations:
|
|
@@ -224,6 +718,199 @@ const result = callModel(client, {
|
|
|
224
718
|
});
|
|
225
719
|
```
|
|
226
720
|
|
|
721
|
+
### Lifecycle Hooks
|
|
722
|
+
|
|
723
|
+
Observe and control the agent loop with typed lifecycle hooks — inspect or
|
|
724
|
+
block tool calls, mutate inputs, gate approvals programmatically, intercept
|
|
725
|
+
prompts, and run audit/telemetry work. Inspired by the Claude Agent SDK hooks
|
|
726
|
+
pattern.
|
|
727
|
+
|
|
728
|
+
> [!NOTE]
|
|
729
|
+
> Lifecycle hooks are distinct from the SDK **transport** hooks
|
|
730
|
+
> (`SDKHooks`, `BeforeRequestHook`, `HookContext`, …), which intercept
|
|
731
|
+
> HTTP requests. Lifecycle hooks fire on agent-loop events.
|
|
732
|
+
|
|
733
|
+
**Two usage modes.** Pass a plain object for quick setup, or a `HooksManager`
|
|
734
|
+
instance for custom hooks, dynamic registration, and programmatic emit:
|
|
735
|
+
|
|
736
|
+
```typescript
|
|
737
|
+
// Inline config — built-in hooks only
|
|
738
|
+
const result = callModel(client, {
|
|
739
|
+
model: 'openai/gpt-4o',
|
|
740
|
+
input: 'Clean up the temp directory',
|
|
741
|
+
tools: [shellTool] as const,
|
|
742
|
+
hooks: {
|
|
743
|
+
PreToolUse: [
|
|
744
|
+
{
|
|
745
|
+
matcher: 'run_shell', // string | RegExp | (toolName) => boolean
|
|
746
|
+
handler: ({ toolName, toolInput }) => {
|
|
747
|
+
if (String(toolInput.command).includes('rm -rf /')) {
|
|
748
|
+
return { block: 'Refusing to run a destructive command' };
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
],
|
|
753
|
+
PostToolUse: [
|
|
754
|
+
{ handler: ({ toolName, durationMs }) => console.log(toolName, durationMs) },
|
|
755
|
+
],
|
|
756
|
+
},
|
|
757
|
+
});
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
```typescript
|
|
761
|
+
// HooksManager — full control
|
|
762
|
+
import { HooksManager } from '@openrouter/agent';
|
|
763
|
+
|
|
764
|
+
const hooks = new HooksManager();
|
|
765
|
+
|
|
766
|
+
const unsubscribe = hooks.on('PreToolUse', {
|
|
767
|
+
matcher: /^db_/,
|
|
768
|
+
filter: (payload) => Object.keys(payload.toolInput).length > 0, // optional predicate on the payload
|
|
769
|
+
handler: ({ toolInput }, ctx) => {
|
|
770
|
+
console.log(`[${ctx.sessionId}] intercepting db tool`); // session id lives on the context
|
|
771
|
+
return {
|
|
772
|
+
// Replace the tool's input before execution (mutation piping)
|
|
773
|
+
mutatedInput: { ...toolInput, dryRun: true },
|
|
774
|
+
};
|
|
775
|
+
},
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const result = callModel(client, { model, input, tools, hooks });
|
|
779
|
+
|
|
780
|
+
// Later: unsubscribe(), hooks.off(...), hooks.removeAll(...)
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
**Built-in hooks**
|
|
784
|
+
|
|
785
|
+
| Hook | Fires | Result fields |
|
|
786
|
+
|---|---|---|
|
|
787
|
+
| `PreToolUse` | Before every client-tool execution (auto, approval-resume, and hook-allowed paths) | `mutatedInput` replaces the tool's arguments; `block: true \| string` skips execution and reports the reason as the tool's error output |
|
|
788
|
+
| `PostToolUse` | After a successful tool execution (payload includes `toolOutput`, `durationMs`) | none (void) |
|
|
789
|
+
| `PostToolUseFailure` | After a tool execution throws or returns an error. Not fired when a tool never ran (`PermissionRequest` deny, user rejection, `PreToolUse` block) — observe those via the gating hooks themselves | none (void) |
|
|
790
|
+
| `UserPromptSubmit` | Before the initial API request, with the user prompt string | `mutatedPrompt` replaces the prompt; `reject: true \| string` aborts the call with an error |
|
|
791
|
+
| `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`) |
|
|
792
|
+
| `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 |
|
|
793
|
+
| `SessionStart` | Once per run, before the initial request. `config` summarizes the session (`hasTools`, `hasApproval`, `hasState`) | none (void) |
|
|
794
|
+
| `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) |
|
|
795
|
+
| `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) |
|
|
796
|
+
| `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` |
|
|
797
|
+
|
|
798
|
+
Notes on lifecycle pairing: `SessionEnd` only fires when a matching
|
|
799
|
+
`SessionStart` succeeded, and at most once per run. Pending async hook work is
|
|
800
|
+
always drained on teardown — including on paths that skip `SessionStart`,
|
|
801
|
+
such as resuming from a tool approval. A throwing `SessionEnd` handler never
|
|
802
|
+
masks the run's original error (teardown failures are logged as warnings).
|
|
803
|
+
|
|
804
|
+
On no-tools streaming paths the initial response is only materialized when the
|
|
805
|
+
stream is consumed, so `PostModelCall` for that response fires during session
|
|
806
|
+
teardown (before `SessionEnd`). A stream that fails or errors before producing
|
|
807
|
+
a materialized response emits no `PostModelCall`; a `response.incomplete`
|
|
808
|
+
response (e.g. truncated at `max_output_tokens`) **does** emit — it carries a
|
|
809
|
+
real generation id and consumed tokens. Note `usage.cost` is only present when
|
|
810
|
+
the request had usage accounting enabled server-side.
|
|
811
|
+
|
|
812
|
+
`SessionEnd.totalUsage` is push-based; for the same totals without registering
|
|
813
|
+
a hook, await [`getUsage()`](#usage-across-a-multi-round-tool-loop).
|
|
814
|
+
Every handler receives `(payload, context)` — `context` carries the
|
|
815
|
+
`sessionId` (the single source of session identity; payloads do not repeat
|
|
816
|
+
it), the `hookName`, and an `AbortSignal` for cooperative cancellation. The
|
|
817
|
+
engine threads the session id into each emit's context, so a single
|
|
818
|
+
`HooksManager` instance can be shared safely across concurrent `callModel`
|
|
819
|
+
runs — each run's handlers see that run's id. (If you call `emit()` yourself
|
|
820
|
+
on a shared manager, pass `{ sessionId }` in the emit context; the
|
|
821
|
+
`setSessionId()` default is a single mutable field and is last-writer-wins.)
|
|
822
|
+
|
|
823
|
+
**Handler chain semantics**
|
|
824
|
+
|
|
825
|
+
Handlers for a hook run sequentially in registration order.
|
|
826
|
+
|
|
827
|
+
- **Matchers** (`matcher`) scope a handler to tool names — exact string,
|
|
828
|
+
`RegExp` (stateful `/g`//`/y` flags are handled safely), or a predicate
|
|
829
|
+
function (truthy/falsy returns are coerced to boolean). Matchers fail
|
|
830
|
+
closed: a matcher-scoped handler is skipped when the emit has no tool name.
|
|
831
|
+
- **Filters** (`filter`) are arbitrary predicates on the payload.
|
|
832
|
+
- **Mutation piping**: a handler's `mutatedInput`/`mutatedPrompt` replaces the
|
|
833
|
+
corresponding payload field for all subsequent handlers in the chain, and
|
|
834
|
+
for the tool/request itself. A blocking handler's mutation still lands
|
|
835
|
+
before the short-circuit.
|
|
836
|
+
- **Short-circuit**: `block`/`reject` with `true` or a non-empty string stops
|
|
837
|
+
the chain (empty strings do not block).
|
|
838
|
+
- **Error policy**: by default a throwing handler — or a throwing
|
|
839
|
+
matcher/filter — is logged as a warning and skipped, and the chain
|
|
840
|
+
continues. Construct the manager with
|
|
841
|
+
`new HooksManager(custom, { throwOnHandlerError: true })` to propagate
|
|
842
|
+
errors instead (useful in tests).
|
|
843
|
+
|
|
844
|
+
**Async fire-and-forget handlers**
|
|
845
|
+
|
|
846
|
+
A handler can detach background work (telemetry, audit writes) without
|
|
847
|
+
blocking the loop by returning an `AsyncOutput` signal:
|
|
848
|
+
|
|
849
|
+
```typescript
|
|
850
|
+
hooks.on('PostToolUse', {
|
|
851
|
+
handler: (payload, ctx) => ({
|
|
852
|
+
async: true,
|
|
853
|
+
work: sendTelemetry(payload, { signal: ctx.signal }),
|
|
854
|
+
asyncTimeout: 5_000, // default 30_000
|
|
855
|
+
}),
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// On shutdown: abort in-flight handlers, then wait for detached work
|
|
859
|
+
hooks.abortInflight('shutdown');
|
|
860
|
+
await hooks.drain();
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
`drain()` waits for all detached work, bounded per-handler by `asyncTimeout`.
|
|
864
|
+
On timeout the emit's `ctx.signal` is aborted and a warning is logged — the
|
|
865
|
+
work itself cannot be forcibly cancelled, so handlers should observe
|
|
866
|
+
`ctx.signal` to stop cooperatively. `abortInflight()` reaches detached work
|
|
867
|
+
even after the originating `emit()` has returned. The signal object must have
|
|
868
|
+
**exactly** the `AsyncOutput` shape (`async: true` plus optional `work`/
|
|
869
|
+
`asyncTimeout`); a return value carrying any other field is treated as a
|
|
870
|
+
regular result so mutations/blocks are never silently discarded. The
|
|
871
|
+
`isAsyncOutput` type guard is exported.
|
|
872
|
+
|
|
873
|
+
**Custom hooks**
|
|
874
|
+
|
|
875
|
+
Define your own hooks with Zod schema pairs and full type inference, then emit
|
|
876
|
+
them from your own code:
|
|
877
|
+
|
|
878
|
+
```typescript
|
|
879
|
+
import { HooksManager } from '@openrouter/agent';
|
|
880
|
+
import { z } from 'zod/v4';
|
|
881
|
+
|
|
882
|
+
const hooks = new HooksManager({
|
|
883
|
+
DeploymentGate: {
|
|
884
|
+
payload: z.object({ environment: z.string(), version: z.string() }),
|
|
885
|
+
result: z.object({ approved: z.boolean() }),
|
|
886
|
+
},
|
|
887
|
+
AuditLog: {
|
|
888
|
+
payload: z.object({ event: z.string() }),
|
|
889
|
+
result: z.void(), // side-effect only: results are not validated
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
hooks.on('DeploymentGate', {
|
|
894
|
+
handler: ({ environment }) => ({ approved: environment !== 'production' }),
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
const { results } = await hooks.emit('DeploymentGate', {
|
|
898
|
+
environment: 'staging',
|
|
899
|
+
version: '1.2.3',
|
|
900
|
+
});
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
Payloads and results are validated against the schemas on every `emit`.
|
|
904
|
+
Schemas with `.transform()`, `.default()`, or `.coerce` are honored: handlers
|
|
905
|
+
receive the parsed **output** values, matching the inferred TypeScript types.
|
|
906
|
+
Custom hook names must be non-empty and must not collide with built-in names.
|
|
907
|
+
Custom hooks do not participate in mutation piping or blocking (those are
|
|
908
|
+
built-in-only behaviors); the inline config surface only accepts built-in
|
|
909
|
+
hooks — unknown names are warned about and skipped.
|
|
910
|
+
|
|
911
|
+
A payload validation failure follows the same error policy as handlers:
|
|
912
|
+
logged and skipped by default, thrown in strict mode.
|
|
913
|
+
|
|
227
914
|
### Tool Context
|
|
228
915
|
|
|
229
916
|
Provide typed context data to tools without passing it through the model:
|
|
@@ -234,7 +921,8 @@ const dbTool = tool({
|
|
|
234
921
|
inputSchema: z.object({ sql: z.string() }),
|
|
235
922
|
contextSchema: z.object({ connectionString: z.string() }),
|
|
236
923
|
execute: async ({ sql }, ctx) => {
|
|
237
|
-
|
|
924
|
+
// Tool context is available on ctx.local
|
|
925
|
+
const db = connect(ctx?.local.connectionString);
|
|
238
926
|
return db.query(sql);
|
|
239
927
|
},
|
|
240
928
|
});
|
|
@@ -267,13 +955,22 @@ const result = callModel(client, {
|
|
|
267
955
|
|
|
268
956
|
### Conversation State Management
|
|
269
957
|
|
|
270
|
-
Persist multi-turn conversations with full state tracking
|
|
958
|
+
Persist multi-turn conversations with full state tracking. The `state`
|
|
959
|
+
option takes a `StateAccessor` — a `{ load, save }` pair over any storage
|
|
960
|
+
backend (memory, SQLite, Redis, …). The loop calls `load` before the run and
|
|
961
|
+
`save` as the conversation progresses:
|
|
271
962
|
|
|
272
963
|
```typescript
|
|
273
|
-
import {
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
let
|
|
964
|
+
import { callModel, type ConversationState, type StateAccessor } from '@openrouter/agent';
|
|
965
|
+
|
|
966
|
+
// Any storage backend — here, a simple in-memory holder
|
|
967
|
+
let stored: ConversationState | null = null;
|
|
968
|
+
const state: StateAccessor = {
|
|
969
|
+
load: async () => stored,
|
|
970
|
+
save: async (s) => {
|
|
971
|
+
stored = s;
|
|
972
|
+
},
|
|
973
|
+
};
|
|
277
974
|
|
|
278
975
|
// First turn
|
|
279
976
|
const result1 = callModel(client, {
|
|
@@ -282,11 +979,12 @@ const result1 = callModel(client, {
|
|
|
282
979
|
tools: [searchTool] as const,
|
|
283
980
|
state,
|
|
284
981
|
});
|
|
982
|
+
await result1.getText();
|
|
285
983
|
|
|
286
|
-
//
|
|
287
|
-
|
|
984
|
+
// Read the updated state (messages, tool calls, status, metadata)
|
|
985
|
+
const snapshot = await result1.getState();
|
|
288
986
|
|
|
289
|
-
// Continue the conversation
|
|
987
|
+
// Continue the conversation — the accessor loads the saved history
|
|
290
988
|
const result2 = callModel(client, {
|
|
291
989
|
model: 'openai/gpt-4o',
|
|
292
990
|
input: 'Now summarize what you found',
|
|
@@ -295,6 +993,9 @@ const result2 = callModel(client, {
|
|
|
295
993
|
});
|
|
296
994
|
```
|
|
297
995
|
|
|
996
|
+
`ConversationState` is plain JSON — `JSON.stringify`/`JSON.parse` it into any
|
|
997
|
+
store (this is how serverless/cold-start resume works).
|
|
998
|
+
|
|
298
999
|
### Dynamic Parameters Between Turns
|
|
299
1000
|
|
|
300
1001
|
Adjust model parameters dynamically based on tool execution:
|
|
@@ -336,12 +1037,20 @@ For tree-shaking or targeted imports, the package provides granular subpath expo
|
|
|
336
1037
|
import { callModel } from '@openrouter/agent/call-model';
|
|
337
1038
|
import { tool } from '@openrouter/agent/tool';
|
|
338
1039
|
import { ModelResult } from '@openrouter/agent/model-result';
|
|
1040
|
+
import { HooksManager } from '@openrouter/agent/hooks-manager';
|
|
339
1041
|
import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions';
|
|
1042
|
+
import { DoomLoopMonitor, fingerprintToolCall } from '@openrouter/agent/doom-loop';
|
|
340
1043
|
import { toClaudeMessage } from '@openrouter/agent/anthropic-compat';
|
|
341
1044
|
import { toChatMessage } from '@openrouter/agent/chat-compat';
|
|
342
1045
|
import { ToolContextStore } from '@openrouter/agent/tool-context';
|
|
343
1046
|
import { ToolEventBroadcaster } from '@openrouter/agent/tool-event-broadcaster';
|
|
344
1047
|
import { createInitialState } from '@openrouter/agent/conversation-state';
|
|
1048
|
+
import { resumeToolResults } from '@openrouter/agent/resume-tool-results';
|
|
1049
|
+
import { Semaphore } from '@openrouter/agent/tool-concurrency';
|
|
1050
|
+
import { AsyncToolRegistry } from '@openrouter/agent/async-tool-registry';
|
|
1051
|
+
import { ToolTask } from '@openrouter/agent/tool-task';
|
|
1052
|
+
import { TaskToolInputSchema } from '@openrouter/agent/tool-check';
|
|
1053
|
+
import { AgentTranscriptSource } from '@openrouter/agent/agent-tool';
|
|
345
1054
|
```
|
|
346
1055
|
|
|
347
1056
|
## Development
|