@namzu/sdk 60.0.0 → 60.1.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 (130) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/agents/QueryAgent.d.ts.map +1 -1
  3. package/dist/agents/QueryAgent.js +1 -0
  4. package/dist/agents/QueryAgent.js.map +1 -1
  5. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  6. package/dist/bridge/a2a/mapper.js +16 -0
  7. package/dist/bridge/a2a/mapper.js.map +1 -1
  8. package/dist/bridge/acp/update.d.ts.map +1 -1
  9. package/dist/bridge/acp/update.js +1 -0
  10. package/dist/bridge/acp/update.js.map +1 -1
  11. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  12. package/dist/bridge/sse/mapper.js +12 -0
  13. package/dist/bridge/sse/mapper.js.map +1 -1
  14. package/dist/constants/continuation.d.ts +9 -0
  15. package/dist/constants/continuation.d.ts.map +1 -1
  16. package/dist/constants/continuation.js +9 -0
  17. package/dist/constants/continuation.js.map +1 -1
  18. package/dist/contracts/session/api.d.ts +7 -1
  19. package/dist/contracts/session/api.d.ts.map +1 -1
  20. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  21. package/dist/manager/session/turn-recorder.js +1 -0
  22. package/dist/manager/session/turn-recorder.js.map +1 -1
  23. package/dist/provider/collect-chat-completion.d.ts.map +1 -1
  24. package/dist/provider/collect-chat-completion.js +4 -0
  25. package/dist/provider/collect-chat-completion.js.map +1 -1
  26. package/dist/provider/fallback.d.ts.map +1 -1
  27. package/dist/provider/fallback.js +11 -0
  28. package/dist/provider/fallback.js.map +1 -1
  29. package/dist/provider/idle-timeout.d.ts.map +1 -1
  30. package/dist/provider/idle-timeout.js +5 -0
  31. package/dist/provider/idle-timeout.js.map +1 -1
  32. package/dist/provider/mock.d.ts +2 -0
  33. package/dist/provider/mock.d.ts.map +1 -1
  34. package/dist/provider/mock.js +16 -8
  35. package/dist/provider/mock.js.map +1 -1
  36. package/dist/provider/model-window-suffix.d.ts.map +1 -1
  37. package/dist/provider/model-window-suffix.js +1 -0
  38. package/dist/provider/model-window-suffix.js.map +1 -1
  39. package/dist/provider/retry.d.ts.map +1 -1
  40. package/dist/provider/retry.js +5 -0
  41. package/dist/provider/retry.js.map +1 -1
  42. package/dist/runtime/query/index.d.ts +14 -0
  43. package/dist/runtime/query/index.d.ts.map +1 -1
  44. package/dist/runtime/query/index.js +1 -0
  45. package/dist/runtime/query/index.js.map +1 -1
  46. package/dist/runtime/query/iteration/effort-clamp.d.ts +13 -0
  47. package/dist/runtime/query/iteration/effort-clamp.d.ts.map +1 -0
  48. package/dist/runtime/query/iteration/effort-clamp.js +42 -0
  49. package/dist/runtime/query/iteration/effort-clamp.js.map +1 -0
  50. package/dist/runtime/query/iteration/index.d.ts +34 -0
  51. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  52. package/dist/runtime/query/iteration/index.js +199 -6
  53. package/dist/runtime/query/iteration/index.js.map +1 -1
  54. package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
  55. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  56. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  57. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  58. package/dist/runtime/query/iteration/stream-turn.js +6 -0
  59. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  60. package/dist/store/memory/disk.d.ts.map +1 -1
  61. package/dist/store/memory/disk.js +1 -1
  62. package/dist/store/memory/disk.js.map +1 -1
  63. package/dist/store/memory/markdown.d.ts.map +1 -1
  64. package/dist/store/memory/markdown.js +1 -1
  65. package/dist/store/memory/markdown.js.map +1 -1
  66. package/dist/store/memory/operation-lock.d.ts +20 -4
  67. package/dist/store/memory/operation-lock.d.ts.map +1 -1
  68. package/dist/store/memory/operation-lock.js +235 -58
  69. package/dist/store/memory/operation-lock.js.map +1 -1
  70. package/dist/turn/reporter.d.ts.map +1 -1
  71. package/dist/turn/reporter.js +11 -0
  72. package/dist/turn/reporter.js.map +1 -1
  73. package/dist/types/agent/query.d.ts +2 -0
  74. package/dist/types/agent/query.d.ts.map +1 -1
  75. package/dist/types/message/index.d.ts +1 -1
  76. package/dist/types/message/index.d.ts.map +1 -1
  77. package/dist/types/message/index.js +1 -0
  78. package/dist/types/message/index.js.map +1 -1
  79. package/dist/types/provider/chat.d.ts +19 -0
  80. package/dist/types/provider/chat.d.ts.map +1 -1
  81. package/dist/types/provider/config.d.ts +7 -0
  82. package/dist/types/provider/config.d.ts.map +1 -1
  83. package/dist/types/provider/index.d.ts +1 -1
  84. package/dist/types/provider/index.d.ts.map +1 -1
  85. package/dist/types/provider/interface.d.ts +15 -0
  86. package/dist/types/provider/interface.d.ts.map +1 -1
  87. package/dist/types/provider/stream.d.ts +6 -0
  88. package/dist/types/provider/stream.d.ts.map +1 -1
  89. package/dist/types/session/events.d.ts +34 -0
  90. package/dist/types/session/events.d.ts.map +1 -1
  91. package/dist/types/session/events.js.map +1 -1
  92. package/dist/types/session/records.d.ts +34 -34
  93. package/dist/types/session/records.d.ts.map +1 -1
  94. package/dist/types/session/records.js +3 -0
  95. package/dist/types/session/records.js.map +1 -1
  96. package/dist/types/session/stop-reason.d.ts +11 -0
  97. package/dist/types/session/stop-reason.d.ts.map +1 -1
  98. package/package.json +1 -1
  99. package/src/agents/QueryAgent.ts +1 -0
  100. package/src/bridge/a2a/mapper.ts +19 -0
  101. package/src/bridge/acp/update.ts +6 -1
  102. package/src/bridge/sse/mapper.ts +13 -0
  103. package/src/constants/continuation.ts +13 -0
  104. package/src/contracts/session/api.ts +6 -0
  105. package/src/manager/session/turn-recorder.ts +1 -0
  106. package/src/provider/collect-chat-completion.ts +3 -0
  107. package/src/provider/fallback.ts +17 -0
  108. package/src/provider/idle-timeout.ts +9 -0
  109. package/src/provider/mock.ts +19 -9
  110. package/src/provider/model-window-suffix.ts +1 -0
  111. package/src/provider/retry.ts +9 -0
  112. package/src/runtime/query/index.ts +15 -0
  113. package/src/runtime/query/iteration/effort-clamp.ts +46 -0
  114. package/src/runtime/query/iteration/index.ts +260 -6
  115. package/src/runtime/query/iteration/phases/context.ts +2 -0
  116. package/src/runtime/query/iteration/stream-turn.ts +5 -0
  117. package/src/store/memory/disk.ts +1 -0
  118. package/src/store/memory/markdown.ts +1 -0
  119. package/src/store/memory/operation-lock.ts +288 -57
  120. package/src/turn/reporter.ts +12 -0
  121. package/src/types/agent/query.ts +2 -0
  122. package/src/types/message/index.ts +5 -3
  123. package/src/types/provider/chat.ts +20 -0
  124. package/src/types/provider/config.ts +7 -0
  125. package/src/types/provider/index.ts +1 -0
  126. package/src/types/provider/interface.ts +20 -0
  127. package/src/types/provider/stream.ts +6 -0
  128. package/src/types/session/events.ts +65 -5
  129. package/src/types/session/records.ts +3 -0
  130. package/src/types/session/stop-reason.ts +11 -0
@@ -1,10 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { type FileHandle, lstat, open, readFile, unlink } from 'node:fs/promises'
2
+ import type { Stats } from 'node:fs'
3
+ import { lstat, open, readFile, unlink } from 'node:fs/promises'
4
+ import { hostname as osHostname } from 'node:os'
3
5
  import { setTimeout as delay } from 'node:timers/promises'
4
6
 
5
7
  import { NamzuError } from '../../types/errors/index.js'
8
+ import type { Logger } from '../../utils/logger.js'
6
9
 
7
- /** Wait bound, not a lease: a slow or crashed owner is never silently replaced. */
10
+ /** Wait bound, not a lease: a slow owner that is still running is never replaced. */
8
11
  export const DEFAULT_MEMORY_LOCK_TIMEOUT_MS = 10_000
9
12
 
10
13
  export function validateMemoryLockTimeout(timeoutMs: number): void {
@@ -17,83 +20,311 @@ export function validateMemoryLockTimeout(timeoutMs: number): void {
17
20
  }
18
21
  }
19
22
 
20
- function lockError(path: string, reason: string, timeoutMs: number): NamzuError {
23
+ /**
24
+ * An empty or unparsable lock file is only believed abandoned once it is older
25
+ * than this. A live acquirer writes its record immediately after creating the
26
+ * file, so a gap of seconds means the writer died in between (disk full, kill).
27
+ */
28
+ export const MEMORY_LOCK_RECORD_GRACE_MS = 5_000
29
+
30
+ export interface MemoryOperationLockOptions {
31
+ readonly logger?: Logger
32
+ /** Clock compared against file mtimes. */
33
+ readonly now?: () => number
34
+ /** True when a process with this pid exists on this machine. */
35
+ readonly isPidAlive?: (pid: number) => boolean
36
+ readonly hostname?: string
37
+ }
38
+
39
+ interface OwnerRecord {
40
+ readonly pid: number
41
+ readonly token: string
42
+ readonly acquiredAt: number
43
+ readonly hostname?: string
44
+ }
45
+
46
+ interface Inspection {
47
+ readonly stats: Stats
48
+ readonly content: string
49
+ readonly pid: number | undefined
50
+ /** Set only when the owner is provably gone. */
51
+ readonly staleReason: string | undefined
52
+ }
53
+
54
+ interface Resolved {
55
+ readonly logger: Logger | undefined
56
+ readonly now: () => number
57
+ readonly isPidAlive: (pid: number) => boolean
58
+ readonly hostname: string
59
+ }
60
+
61
+ /**
62
+ * Residual risk, deliberately accepted: a pid that is alive is treated as the
63
+ * owner even when it is an unrelated process that reused the number after the
64
+ * real owner died. That cannot be disproved from here, so such a lock stays and
65
+ * the caller gets the plain "another process is using it" error. It is not
66
+ * bounded by `acquiredAt` age: a lock's age is not a lease.
67
+ */
68
+ function defaultIsPidAlive(pid: number): boolean {
69
+ try {
70
+ process.kill(pid, 0)
71
+ return true
72
+ } catch (error) {
73
+ return (error as NodeJS.ErrnoException).code !== 'ESRCH'
74
+ }
75
+ }
76
+
77
+ function lockError(path: string, message: string, timeoutMs: number): NamzuError {
21
78
  return new NamzuError({
22
79
  code: 'storage_error',
23
- message: `Memory operation lock ${path}: ${reason}. Stop all processes using this memory store and inspect the lock owner before removing a stale lock; it is never broken automatically.`,
80
+ message,
24
81
  details: { lockPath: path, timeoutMs },
25
82
  retryable: false,
26
83
  })
27
84
  }
28
85
 
86
+ function busyError(path: string, pid: number | undefined, timeoutMs: number): NamzuError {
87
+ const who =
88
+ pid === undefined ? 'Another Namzu process is' : `Another Namzu process (pid ${pid}) is`
89
+ return lockError(
90
+ path,
91
+ `${who} using the stored memory. Close it, or wait for it to finish.`,
92
+ timeoutMs,
93
+ )
94
+ }
95
+
96
+ function parseOwner(content: string): OwnerRecord | undefined {
97
+ try {
98
+ const value: unknown = JSON.parse(content)
99
+ if (!value || typeof value !== 'object') return undefined
100
+ const pid = (value as { pid?: unknown }).pid
101
+ if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined
102
+ const host = (value as { hostname?: unknown }).hostname
103
+ return {
104
+ pid,
105
+ token: String((value as { token?: unknown }).token ?? ''),
106
+ acquiredAt: Number((value as { acquiredAt?: unknown }).acquiredAt ?? 0),
107
+ ...(typeof host === 'string' && host.length > 0 ? { hostname: host } : {}),
108
+ }
109
+ } catch {
110
+ return undefined
111
+ }
112
+ }
113
+
114
+ /** Reads the file at `path`; undefined when it is gone or was swapped while reading. */
115
+ async function inspect(path: string, env: Resolved): Promise<Inspection | undefined> {
116
+ const before = await lstat(path).catch((error: NodeJS.ErrnoException) => {
117
+ if (error.code === 'ENOENT') return undefined
118
+ throw error
119
+ })
120
+ if (!before) return undefined
121
+ if (!before.isFile() || before.isSymbolicLink())
122
+ return { stats: before, content: '', pid: undefined, staleReason: undefined }
123
+ const content = await readFile(path, 'utf8').catch((error: NodeJS.ErrnoException) => {
124
+ if (error.code === 'ENOENT') return undefined
125
+ throw error
126
+ })
127
+ if (content === undefined) return undefined
128
+ const after = await lstat(path).catch(() => undefined)
129
+ if (!after || after.dev !== before.dev || after.ino !== before.ino) return undefined
130
+
131
+ const owner = parseOwner(content)
132
+ if (owner) {
133
+ // A record from another host on a shared filesystem cannot be checked from
134
+ // here; it is live. A record without a host predates the field: local.
135
+ if (owner.hostname !== undefined && owner.hostname !== env.hostname) {
136
+ return { stats: before, content, pid: owner.pid, staleReason: undefined }
137
+ }
138
+ return {
139
+ stats: before,
140
+ content,
141
+ pid: owner.pid,
142
+ staleReason: env.isPidAlive(owner.pid) ? undefined : `owner process ${owner.pid} is gone`,
143
+ }
144
+ }
145
+ const age = env.now() - before.mtimeMs
146
+ return {
147
+ stats: before,
148
+ content,
149
+ pid: undefined,
150
+ staleReason:
151
+ age > MEMORY_LOCK_RECORD_GRACE_MS
152
+ ? `lock holds no readable owner record and is ${Math.round(age / 1000)}s old`
153
+ : undefined,
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Creates `path` exclusively and writes this process's owner record. Returns
159
+ * undefined when the path is occupied. A failed write (a full disk) leaves no
160
+ * file behind.
161
+ */
162
+ async function createOwned(
163
+ path: string,
164
+ env: Resolved,
165
+ ): Promise<{ identity: Stats; owner: string } | undefined> {
166
+ let handle: Awaited<ReturnType<typeof open>>
167
+ try {
168
+ handle = await open(path, 'wx', 0o600)
169
+ } catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') return undefined
171
+ throw error
172
+ }
173
+ const owner = `${JSON.stringify({
174
+ pid: process.pid,
175
+ token: randomUUID(),
176
+ acquiredAt: Date.now(),
177
+ hostname: env.hostname,
178
+ })}\n`
179
+ let identity: Stats | undefined
180
+ try {
181
+ identity = await handle.stat()
182
+ await handle.writeFile(owner, 'utf8')
183
+ await handle.sync()
184
+ } catch (error) {
185
+ await handle.close().catch(() => undefined)
186
+ // We created this path exclusively a moment ago; drop it so no empty or
187
+ // partial record is left to block the next start.
188
+ const current = await lstat(path).catch(() => undefined)
189
+ if (!identity || (current?.dev === identity.dev && current.ino === identity.ino)) {
190
+ await unlink(path).catch(() => undefined)
191
+ }
192
+ throw error
193
+ }
194
+ await handle.close()
195
+ return { identity, owner }
196
+ }
197
+
198
+ async function releaseOwned(
199
+ path: string,
200
+ identity: Stats,
201
+ owner: string,
202
+ timeoutMs: number,
203
+ ): Promise<void> {
204
+ const current = await lstat(path)
205
+ if (
206
+ !current.isFile() ||
207
+ current.isSymbolicLink() ||
208
+ current.dev !== identity.dev ||
209
+ current.ino !== identity.ino ||
210
+ (await readFile(path, 'utf8')) !== owner
211
+ ) {
212
+ throw lockError(
213
+ path,
214
+ `Memory operation lock ${path}: the lock owner changed before release.`,
215
+ timeoutMs,
216
+ )
217
+ }
218
+ await unlink(path)
219
+ }
220
+
221
+ function sameFile(a: Stats, b: Stats): boolean {
222
+ return a.dev === b.dev && a.ino === b.ino
223
+ }
224
+
225
+ /** Unlinks `path` only if it is still exactly the stale file that was inspected. */
226
+ async function unlinkIfStillStale(path: string, seen: Inspection, env: Resolved): Promise<boolean> {
227
+ const again = await inspect(path, env)
228
+ if (!again || !sameFile(again.stats, seen.stats) || again.content !== seen.content) return false
229
+ if (again.staleReason === undefined) return false
230
+ // Narrow the window further: the inode must still be the inspected one now.
231
+ const last = await lstat(path).catch(() => undefined)
232
+ if (!last || !sameFile(last, seen.stats)) return false
233
+ await unlink(path).catch((error: NodeJS.ErrnoException) => {
234
+ if (error.code !== 'ENOENT') throw error
235
+ })
236
+ return true
237
+ }
238
+
239
+ /**
240
+ * Removes a lock whose owner is provably gone. Two processes that see the same
241
+ * stale lock are serialised through `<lock>.break`; the lock is re-inspected
242
+ * under it and only the inspected inode is unlinked. Returns whether the lock
243
+ * was removed.
244
+ */
245
+ async function breakStaleLock(
246
+ path: string,
247
+ seen: Inspection,
248
+ timeoutMs: number,
249
+ env: Resolved,
250
+ ): Promise<boolean> {
251
+ const breakPath = `${path}.break`
252
+ let held: { identity: Stats; owner: string } | undefined
253
+ for (let attempt = 0; attempt < 2 && !held; attempt++) {
254
+ held = await createOwned(breakPath, env)
255
+ if (held) break
256
+ const breaker = await inspect(breakPath, env)
257
+ if (!breaker) continue
258
+ // A dead breaker's file follows the same rule, once.
259
+ if (breaker.staleReason === undefined || !(await unlinkIfStillStale(breakPath, breaker, env))) {
260
+ return false
261
+ }
262
+ }
263
+ if (!held) return false
264
+ try {
265
+ const removed = await unlinkIfStillStale(path, seen, env)
266
+ if (removed) {
267
+ env.logger?.info('Broke an abandoned memory operation lock', {
268
+ 'namzu.store.lock_path': path,
269
+ 'namzu.store.lock_reason': seen.staleReason,
270
+ })
271
+ }
272
+ return removed
273
+ } finally {
274
+ await releaseOwned(breakPath, held.identity, held.owner, timeoutMs).catch(() => undefined)
275
+ }
276
+ }
277
+
29
278
  /**
30
279
  * Local-filesystem exclusion for cooperating processes. Atomic rename of the
31
280
  * index alone cannot serialize the index/content read-modify-write transaction.
32
- * An abandoned lock fails closed; guessing a lease expired would permit two
33
- * live writers to overwrite each other again.
281
+ * A lock whose owner is provably gone (dead pid on this host, or an owner-less
282
+ * file older than the grace period) is broken; a live or unverifiable owner is
283
+ * waited for until the timeout, never replaced on a guessed lease.
34
284
  */
35
285
  export async function acquireMemoryOperationLock(
36
286
  path: string,
37
287
  timeoutMs: number,
288
+ options: MemoryOperationLockOptions = {},
38
289
  ): Promise<() => Promise<void>> {
290
+ const env: Resolved = {
291
+ logger: options.logger,
292
+ now: options.now ?? Date.now,
293
+ isPidAlive: options.isPidAlive ?? defaultIsPidAlive,
294
+ hostname: options.hostname ?? osHostname(),
295
+ }
39
296
  const deadline = performance.now() + timeoutMs
40
297
  let attempted = false
298
+ let lastPid: number | undefined
299
+ // The wait bound limits waiting for a live owner, not the attempt that
300
+ // follows a successful break: the lock is free then, and breaking it can
301
+ // itself outlast a short bound on a slow disk.
302
+ let justBroke = false
41
303
  for (;;) {
42
- if (attempted && performance.now() >= deadline) {
43
- throw lockError(path, `acquisition timed out after ${timeoutMs} ms`, timeoutMs)
304
+ if (attempted && !justBroke && performance.now() >= deadline) {
305
+ throw busyError(path, lastPid, timeoutMs)
44
306
  }
307
+ justBroke = false
45
308
  attempted = true
46
- let handle: FileHandle
47
- try {
48
- handle = await open(path, 'wx', 0o600)
49
- } catch (error) {
50
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
51
- const existing = await lstat(path).catch((statError: NodeJS.ErrnoException) => {
52
- if (statError.code === 'ENOENT') return undefined
53
- throw statError
54
- })
55
- if (!existing) continue
56
- if (!existing.isFile() || existing.isSymbolicLink()) {
57
- throw lockError(path, 'the occupied lock is not a regular file', timeoutMs)
58
- }
59
- if (performance.now() >= deadline) {
60
- throw lockError(path, `acquisition timed out after ${timeoutMs} ms`, timeoutMs)
61
- }
62
- await delay(Math.min(25, Math.max(1, deadline - performance.now())))
63
- continue
309
+ const created = await createOwned(path, env)
310
+ if (created) {
311
+ return () => releaseOwned(path, created.identity, created.owner, timeoutMs)
64
312
  }
65
-
66
- const owner = `${JSON.stringify({ pid: process.pid, token: randomUUID(), acquiredAt: Date.now() })}\n`
67
- const identity = await handle.stat().catch(async (error: unknown) => {
68
- await handle.close()
69
- // Exclusive create happened, but ownership could not be established.
70
- // Leave the path for explicit recovery rather than unlink an unknown inode.
71
- throw error
72
- })
73
- try {
74
- await handle.writeFile(owner, 'utf8')
75
- await handle.sync()
76
- } catch (error) {
77
- await handle.close()
78
- const current = await lstat(path).catch(() => undefined)
79
- if (current?.dev === identity.dev && current.ino === identity.ino) {
80
- await unlink(path).catch(() => undefined)
81
- }
82
- throw error
313
+ const seen = await inspect(path, env)
314
+ if (!seen) continue
315
+ if (!seen.stats.isFile() || seen.stats.isSymbolicLink()) {
316
+ throw lockError(
317
+ path,
318
+ `The stored memory lock ${path} is not a regular file, so Namzu will not use it. Move it aside to continue.`,
319
+ timeoutMs,
320
+ )
83
321
  }
84
- await handle.close()
85
- return async () => {
86
- const current = await lstat(path)
87
- if (
88
- !current.isFile() ||
89
- current.isSymbolicLink() ||
90
- current.dev !== identity.dev ||
91
- current.ino !== identity.ino ||
92
- (await readFile(path, 'utf8')) !== owner
93
- ) {
94
- throw lockError(path, 'the lock owner changed before release', timeoutMs)
95
- }
96
- await unlink(path)
322
+ lastPid = seen.pid
323
+ if (seen.staleReason !== undefined && (await breakStaleLock(path, seen, timeoutMs, env))) {
324
+ justBroke = true
325
+ continue
97
326
  }
327
+ if (performance.now() >= deadline) throw busyError(path, lastPid, timeoutMs)
328
+ await delay(Math.min(25, Math.max(1, deadline - performance.now())))
98
329
  }
99
330
  }
@@ -422,6 +422,18 @@ export function createTurnReporter(parentLogger?: Logger): TurnReporter {
422
422
  })
423
423
  break
424
424
 
425
+ case 'model_refusal':
426
+ log.warn('Model declined to answer', {
427
+ [NAMZU.TURN_ID]: event.turnId,
428
+ [NAMZU.ITERATION]: event.iteration,
429
+ 'namzu.turn.from_model': event.model,
430
+ 'namzu.turn.from_provider_id': event.providerId,
431
+ 'namzu.turn.outcome': event.outcome,
432
+ 'namzu.turn.to_model': event.fallbackModel,
433
+ 'namzu.turn.category': event.category,
434
+ })
435
+ break
436
+
425
437
  case 'provider_fallback':
426
438
  // `warn` for the same reason as a retry, and one stronger: the rest
427
439
  // of this turn is being served by a provider the caller did not pick.
@@ -98,6 +98,8 @@ export interface QueryAgentConfig extends BaseAgentConfig {
98
98
  maxToolConcurrency?: number
99
99
  /** See {@link QueryParams.maxOutputContinuations}. */
100
100
  maxOutputContinuations?: QueryParams['maxOutputContinuations']
101
+ /** See {@link QueryParams.refusalFallback}. */
102
+ refusalFallback?: QueryParams['refusalFallback']
101
103
  maxToolOutputChars?: number
102
104
  maxToolOutputCharsPerMessage?: number
103
105
  retainedToolPreviewChars?: QueryParams['retainedToolPreviewChars']
@@ -367,6 +367,7 @@ export const RUNTIME_CONTEXT_MESSAGE_KINDS = [
367
367
  'peer-notice',
368
368
  'reminder',
369
369
  'repeat-call',
370
+ 'refusal-retry',
370
371
  'steering',
371
372
  'step-context',
372
373
  'structured-output',
@@ -695,9 +696,10 @@ export function createTurnContextEntry(
695
696
  }
696
697
 
697
698
  /** The key a recorded context entry is filed under, or undefined for any other message. */
698
- export function turnContextKey(message: { readonly role: string; readonly source?: unknown }):
699
- | string
700
- | undefined {
699
+ export function turnContextKey(message: {
700
+ readonly role: string
701
+ readonly source?: unknown
702
+ }): string | undefined {
701
703
  if (message.role !== 'user') return undefined
702
704
  const source = message.source as RuntimeContextMessageSource | undefined
703
705
  return source?.type === 'runtime-context' && source.kind === 'turn-context'
@@ -184,6 +184,24 @@ export type ReasoningEffort =
184
184
  | 'max'
185
185
  | 'ultra'
186
186
 
187
+ /**
188
+ * What a provider says about a reply it declined to give.
189
+ *
190
+ * Carried beside `finishReason: 'content_filter'` and set whenever the finish
191
+ * was a refusal or a safety block, so a host can tell a model that declined
192
+ * from a reply that was merely empty. Every field is optional because
193
+ * providers differ in what they report and several report a refusal with no
194
+ * detail at all: an empty object means "refused, nothing more is known".
195
+ */
196
+ export interface ProviderRefusal {
197
+ /** The provider's own classification of why, when it gives one (for example `cyber`). */
198
+ category?: string
199
+ /** The provider's own sentence about the refusal. Show it; do not parse it. */
200
+ explanation?: string
201
+ /** A model the provider says can usually serve the request instead. */
202
+ recommendedModel?: string
203
+ }
204
+
187
205
  export interface ChatCompletionResponse {
188
206
  id: string
189
207
  model: string
@@ -206,5 +224,7 @@ export interface ChatCompletionResponse {
206
224
  * window rather than the output token limit; see `StreamChunk.finishDetail`.
207
225
  */
208
226
  finishDetail?: 'context_window'
227
+ /** Set with `finishReason: 'content_filter'` when the provider declined to answer. */
228
+ refusal?: ProviderRefusal
209
229
  usage: TokenUsage
210
230
  }
@@ -72,6 +72,8 @@ export interface MockTurn {
72
72
  /** Passages this scripted turn cites, emitted before its text. */
73
73
  citations?: import('../message/index.js').Citation[]
74
74
  finishReason?: 'stop' | 'tool_calls' | 'length' | 'content_filter'
75
+ /** What the provider says about a `'content_filter'` finish. Defaults to `{}`. */
76
+ refusal?: import('./chat.js').ProviderRefusal
75
77
  usage?: Partial<import('../common/index.js').TokenUsage>
76
78
  /** Text fragment size. */
77
79
  chunkSize?: number
@@ -106,6 +108,11 @@ export interface MockScript {
106
108
  * registry-level declaration cannot express "a driver with no vision".
107
109
  */
108
110
  capabilities?: ProviderCapabilities
111
+ /**
112
+ * The model this provider names when asked where to re-run a refused
113
+ * request (`LLMProvider.refusalFallbackFor`). Absent means it names none.
114
+ */
115
+ refusalFallbackModel?: string
109
116
  /** Observe every request (assert on tools, toolChoice, cacheControl…). */
110
117
  onRequest?: (params: import('./chat.js').ChatCompletionParams) => void
111
118
  }
@@ -4,6 +4,7 @@ export type {
4
4
  CacheControl,
5
5
  ChatCompletionParams,
6
6
  ChatCompletionResponse,
7
+ ProviderRefusal,
7
8
  ReasoningEffort,
8
9
  ThinkingConfig,
9
10
  } from './chat.js'
@@ -236,4 +236,24 @@ export interface LLMProvider {
236
236
  * run that would otherwise work.
237
237
  */
238
238
  resolveContextWindow?(model: string, signal?: AbortSignal): Promise<number | undefined>
239
+
240
+ /**
241
+ * The model to re-run a refused request on, when this provider has one.
242
+ *
243
+ * Called after `model` declined to answer (`finishReason: 'content_filter'`),
244
+ * with whatever the provider said about it. Answer with a different model
245
+ * this provider and this credential can actually use, or `undefined` when
246
+ * there is none: the turn then does not change model. A driver whose
247
+ * service names a recommended model in `refusal.recommendedModel` should
248
+ * use it only after checking it is one this account can serve, and falls
249
+ * back to its own declared order otherwise.
250
+ *
251
+ * Optional. A provider without it never has its model swapped on a
252
+ * refusal. A request that failed for any other reason does not call this.
253
+ */
254
+ refusalFallbackFor?(
255
+ model: string,
256
+ refusal: import('./chat.js').ProviderRefusal,
257
+ signal?: AbortSignal,
258
+ ): Promise<string | undefined>
239
259
  }
@@ -132,6 +132,12 @@ export interface StreamChunk {
132
132
  * room to continue into. Set only with `finishReason: 'length'`.
133
133
  */
134
134
  finishDetail?: 'context_window'
135
+ /**
136
+ * The provider declined to answer. Set with `finishReason: 'content_filter'`
137
+ * by every driver whose finish was a refusal or a safety block; an empty
138
+ * object when the provider gave no detail.
139
+ */
140
+ refusal?: import('./chat.js').ProviderRefusal
135
141
  usage?: TokenUsage
136
142
  error?: string
137
143
 
@@ -102,7 +102,12 @@ type CoreSessionEvent =
102
102
  /** The token ledger this turn spends from, keyed by the root turn. */
103
103
  budget?: TurnBudgetBinding
104
104
  }
105
- | { type: 'iteration_started'; sessionId: SessionId; turnId: TurnId; iteration: number }
105
+ | {
106
+ type: 'iteration_started'
107
+ sessionId: SessionId
108
+ turnId: TurnId
109
+ iteration: number
110
+ }
106
111
  /**
107
112
  * Who answers when this turn asks a human, changed mid-turn.
108
113
  *
@@ -454,6 +459,40 @@ type CoreSessionEvent =
454
459
  */
455
460
  restart?: boolean
456
461
  }
462
+ /**
463
+ * The model declined to answer, and what the turn did about it.
464
+ *
465
+ * The provider finished a reply as a refusal or a safety block. The refused
466
+ * reply is kept out of the history sent next. `outcome` says what followed:
467
+ *
468
+ * - `'fallback'`: the request is being re-run on `fallbackModel`, which the
469
+ * rest of the turn keeps. A host that owns the session's model is expected
470
+ * to keep it for later turns too and to say so.
471
+ * - `'retry'`: no other model was available and the refused reply had
472
+ * partial output, so the request is being re-run once on the same model
473
+ * with a note that the previous reply was stopped.
474
+ * - `'declined'`: nothing recovered it. The turn settles with stop reason
475
+ * `'refused'`.
476
+ *
477
+ * Like `provider_fallback`, this is the feature's honesty: a swapped model
478
+ * must be shown, not logged. It is emitted before the re-run starts.
479
+ */
480
+ | {
481
+ type: 'model_refusal'
482
+ sessionId: SessionId
483
+ turnId: TurnId
484
+ iteration: number
485
+ /** The model that declined. */
486
+ model: string
487
+ providerId: string
488
+ outcome: 'fallback' | 'retry' | 'declined'
489
+ /** The model the request moves to. Set only with `outcome: 'fallback'`. */
490
+ fallbackModel?: string
491
+ /** The provider's own classification, when it gave one. */
492
+ category?: string
493
+ /** The provider's own sentence, when it gave one. Show it; do not parse it. */
494
+ explanation?: string
495
+ }
457
496
  | {
458
497
  type: 'tool_completed'
459
498
  /** Bounded result view; omitted when output was overridden or truncated. */
@@ -892,7 +931,12 @@ type CoreSessionEvent =
892
931
  steps: PlanStep[]
893
932
  summary?: string
894
933
  }
895
- | { type: 'plan_approved'; sessionId: SessionId; turnId: TurnId; planId: PlanId }
934
+ | {
935
+ type: 'plan_approved'
936
+ sessionId: SessionId
937
+ turnId: TurnId
938
+ planId: PlanId
939
+ }
896
940
  | {
897
941
  type: 'plan_rejected'
898
942
  sessionId: SessionId
@@ -923,7 +967,12 @@ type CoreSessionEvent =
923
967
  * through `onContextCreated`, so they proved the plan settled without ever
924
968
  * asking whether a consumer of the EVENT STREAM could see it.
925
969
  */
926
- | { type: 'plan_completed'; sessionId: SessionId; turnId: TurnId; planId: PlanId }
970
+ | {
971
+ type: 'plan_completed'
972
+ sessionId: SessionId
973
+ turnId: TurnId
974
+ planId: PlanId
975
+ }
927
976
  /**
928
977
  * The plan is over and it did not finish.
929
978
  *
@@ -931,7 +980,13 @@ type CoreSessionEvent =
931
980
  * an event that says "failed" without saying why puts the reader back
932
981
  * where the missing event did.
933
982
  */
934
- | { type: 'plan_failed'; sessionId: SessionId; turnId: TurnId; planId: PlanId; reason?: string }
983
+ | {
984
+ type: 'plan_failed'
985
+ sessionId: SessionId
986
+ turnId: TurnId
987
+ planId: PlanId
988
+ reason?: string
989
+ }
935
990
  | {
936
991
  type: 'agent_pending'
937
992
  sessionId: SessionId
@@ -1103,7 +1158,12 @@ type CoreSessionEvent =
1103
1158
  exitCode: number
1104
1159
  durationMs: number
1105
1160
  }
1106
- | { type: 'sandbox_destroyed'; sessionId: SessionId; turnId?: TurnId; sandboxId: SandboxId }
1161
+ | {
1162
+ type: 'sandbox_destroyed'
1163
+ sessionId: SessionId
1164
+ turnId?: TurnId
1165
+ sandboxId: SandboxId
1166
+ }
1107
1167
  // ─────────────────────────────────────────────────────────────────────
1108
1168
  // v3 message + tool-input lifecycle (additive 2026-05; see
1109
1169
  // ses_001-tool-stream-events). These are not yet emitted by the
@@ -236,6 +236,7 @@ const stopReason = z.enum([
236
236
  'step_refused',
237
237
  'structured_output_failed',
238
238
  'output_limit',
239
+ 'refused',
239
240
  'answer_rejected',
240
241
  'input_guardrail',
241
242
  'output_guardrail',
@@ -655,6 +656,7 @@ const SESSION_EVENT_TYPE_TABLE: Record<SessionEventType, true> = {
655
656
  hosted_tool: true,
656
657
  provider_retry: true,
657
658
  provider_fallback: true,
659
+ model_refusal: true,
658
660
  tool_completed: true,
659
661
  user_question_asked: true,
660
662
  user_question_answered: true,
@@ -749,6 +751,7 @@ const TURN_BOUND_OTHER_TYPE_LIST = [
749
751
  'hosted_tool',
750
752
  'provider_retry',
751
753
  'provider_fallback',
754
+ 'model_refusal',
752
755
  'tool_completed',
753
756
  'user_question_asked',
754
757
  'user_question_answered',