@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.
Files changed (105) hide show
  1. package/README.md +728 -19
  2. package/esm/index.d.ts +24 -6
  3. package/esm/index.d.ts.map +1 -1
  4. package/esm/index.js +20 -5
  5. package/esm/index.js.map +1 -1
  6. package/esm/inner-loop/call-model.d.ts +8 -5
  7. package/esm/inner-loop/call-model.d.ts.map +1 -1
  8. package/esm/inner-loop/call-model.js +49 -40
  9. package/esm/inner-loop/call-model.js.map +1 -1
  10. package/esm/inner-loop/resume-tool-results.d.ts +128 -0
  11. package/esm/inner-loop/resume-tool-results.d.ts.map +1 -0
  12. package/esm/inner-loop/resume-tool-results.js +244 -0
  13. package/esm/inner-loop/resume-tool-results.js.map +1 -0
  14. package/esm/lib/agent-tool.d.ts +92 -0
  15. package/esm/lib/agent-tool.d.ts.map +1 -0
  16. package/esm/lib/agent-tool.js +211 -0
  17. package/esm/lib/agent-tool.js.map +1 -0
  18. package/esm/lib/async-params.d.ts +107 -6
  19. package/esm/lib/async-params.d.ts.map +1 -1
  20. package/esm/lib/async-params.js +8 -1
  21. package/esm/lib/async-params.js.map +1 -1
  22. package/esm/lib/async-tool-registry.d.ts +134 -0
  23. package/esm/lib/async-tool-registry.d.ts.map +1 -0
  24. package/esm/lib/async-tool-registry.js +352 -0
  25. package/esm/lib/async-tool-registry.js.map +1 -0
  26. package/esm/lib/async-tools.d.ts +23 -0
  27. package/esm/lib/async-tools.d.ts.map +1 -0
  28. package/esm/lib/async-tools.js +19 -0
  29. package/esm/lib/async-tools.js.map +1 -0
  30. package/esm/lib/conversation-state.d.ts +45 -1
  31. package/esm/lib/conversation-state.d.ts.map +1 -1
  32. package/esm/lib/conversation-state.js +119 -0
  33. package/esm/lib/conversation-state.js.map +1 -1
  34. package/esm/lib/doom-loop.d.ts +533 -0
  35. package/esm/lib/doom-loop.d.ts.map +1 -0
  36. package/esm/lib/doom-loop.js +1221 -0
  37. package/esm/lib/doom-loop.js.map +1 -0
  38. package/esm/lib/hooks-emit.d.ts +50 -0
  39. package/esm/lib/hooks-emit.d.ts.map +1 -0
  40. package/esm/lib/hooks-emit.js +307 -0
  41. package/esm/lib/hooks-emit.js.map +1 -0
  42. package/esm/lib/hooks-manager.d.ts +141 -0
  43. package/esm/lib/hooks-manager.d.ts.map +1 -0
  44. package/esm/lib/hooks-manager.js +282 -0
  45. package/esm/lib/hooks-manager.js.map +1 -0
  46. package/esm/lib/hooks-matchers.d.ts +12 -0
  47. package/esm/lib/hooks-matchers.d.ts.map +1 -0
  48. package/esm/lib/hooks-matchers.js +26 -0
  49. package/esm/lib/hooks-matchers.js.map +1 -0
  50. package/esm/lib/hooks-resolve.d.ts +17 -0
  51. package/esm/lib/hooks-resolve.d.ts.map +1 -0
  52. package/esm/lib/hooks-resolve.js +42 -0
  53. package/esm/lib/hooks-resolve.js.map +1 -0
  54. package/esm/lib/hooks-schemas.d.ts +221 -0
  55. package/esm/lib/hooks-schemas.d.ts.map +1 -0
  56. package/esm/lib/hooks-schemas.js +238 -0
  57. package/esm/lib/hooks-schemas.js.map +1 -0
  58. package/esm/lib/hooks-types.d.ts +178 -0
  59. package/esm/lib/hooks-types.d.ts.map +1 -0
  60. package/esm/lib/hooks-types.js +45 -0
  61. package/esm/lib/hooks-types.js.map +1 -0
  62. package/esm/lib/model-result.d.ts +848 -12
  63. package/esm/lib/model-result.d.ts.map +1 -1
  64. package/esm/lib/model-result.js +4055 -410
  65. package/esm/lib/model-result.js.map +1 -1
  66. package/esm/lib/reusable-stream.d.ts +15 -0
  67. package/esm/lib/reusable-stream.d.ts.map +1 -1
  68. package/esm/lib/reusable-stream.js +25 -0
  69. package/esm/lib/reusable-stream.js.map +1 -1
  70. package/esm/lib/stream-transformers.d.ts +20 -0
  71. package/esm/lib/stream-transformers.d.ts.map +1 -1
  72. package/esm/lib/stream-transformers.js +45 -0
  73. package/esm/lib/stream-transformers.js.map +1 -1
  74. package/esm/lib/tool-check.d.ts +76 -0
  75. package/esm/lib/tool-check.d.ts.map +1 -0
  76. package/esm/lib/tool-check.js +241 -0
  77. package/esm/lib/tool-check.js.map +1 -0
  78. package/esm/lib/tool-concurrency.d.ts +42 -0
  79. package/esm/lib/tool-concurrency.d.ts.map +1 -0
  80. package/esm/lib/tool-concurrency.js +80 -0
  81. package/esm/lib/tool-concurrency.js.map +1 -0
  82. package/esm/lib/tool-context.d.ts +45 -1
  83. package/esm/lib/tool-context.d.ts.map +1 -1
  84. package/esm/lib/tool-context.js +60 -1
  85. package/esm/lib/tool-context.js.map +1 -1
  86. package/esm/lib/tool-executor.d.ts +52 -9
  87. package/esm/lib/tool-executor.d.ts.map +1 -1
  88. package/esm/lib/tool-executor.js +263 -21
  89. package/esm/lib/tool-executor.js.map +1 -1
  90. package/esm/lib/tool-orchestrator.d.ts.map +1 -1
  91. package/esm/lib/tool-orchestrator.js +87 -47
  92. package/esm/lib/tool-orchestrator.js.map +1 -1
  93. package/esm/lib/tool-task.d.ts +157 -0
  94. package/esm/lib/tool-task.d.ts.map +1 -0
  95. package/esm/lib/tool-task.js +206 -0
  96. package/esm/lib/tool-task.js.map +1 -0
  97. package/esm/lib/tool-types.d.ts +609 -58
  98. package/esm/lib/tool-types.d.ts.map +1 -1
  99. package/esm/lib/tool-types.js +72 -10
  100. package/esm/lib/tool-types.js.map +1 -1
  101. package/esm/lib/tool.d.ts +217 -25
  102. package/esm/lib/tool.d.ts.map +1 -1
  103. package/esm/lib/tool.js +210 -1
  104. package/esm/lib/tool.js.map +1 -1
  105. 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 execution events
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. Three tool types are supported:
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, pass
175
- `allowFinalResponse` to force one more model turn with no tools:
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
- allowFinalResponse: 'Please summarize what you found.',
184
- // or just: allowFinalResponse: true
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 to the model with no tools defined. A
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
- const db = connect(ctx?.context.connectionString);
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 { createInitialState, callModel } from '@openrouter/agent';
274
-
275
- // Start a conversation
276
- let state = createInitialState();
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
- // State is updated with messages, tool calls, and metadata
287
- state = (await result1.getResponse()).state;
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