@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.
- package/CHANGELOG.md +18 -0
- package/dist/agents/QueryAgent.d.ts.map +1 -1
- package/dist/agents/QueryAgent.js +1 -0
- package/dist/agents/QueryAgent.js.map +1 -1
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +16 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/acp/update.d.ts.map +1 -1
- package/dist/bridge/acp/update.js +1 -0
- package/dist/bridge/acp/update.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +12 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/constants/continuation.d.ts +9 -0
- package/dist/constants/continuation.d.ts.map +1 -1
- package/dist/constants/continuation.js +9 -0
- package/dist/constants/continuation.js.map +1 -1
- package/dist/contracts/session/api.d.ts +7 -1
- package/dist/contracts/session/api.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.js +1 -0
- package/dist/manager/session/turn-recorder.js.map +1 -1
- package/dist/provider/collect-chat-completion.d.ts.map +1 -1
- package/dist/provider/collect-chat-completion.js +4 -0
- package/dist/provider/collect-chat-completion.js.map +1 -1
- package/dist/provider/fallback.d.ts.map +1 -1
- package/dist/provider/fallback.js +11 -0
- package/dist/provider/fallback.js.map +1 -1
- package/dist/provider/idle-timeout.d.ts.map +1 -1
- package/dist/provider/idle-timeout.js +5 -0
- package/dist/provider/idle-timeout.js.map +1 -1
- package/dist/provider/mock.d.ts +2 -0
- package/dist/provider/mock.d.ts.map +1 -1
- package/dist/provider/mock.js +16 -8
- package/dist/provider/mock.js.map +1 -1
- package/dist/provider/model-window-suffix.d.ts.map +1 -1
- package/dist/provider/model-window-suffix.js +1 -0
- package/dist/provider/model-window-suffix.js.map +1 -1
- package/dist/provider/retry.d.ts.map +1 -1
- package/dist/provider/retry.js +5 -0
- package/dist/provider/retry.js.map +1 -1
- package/dist/runtime/query/index.d.ts +14 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +1 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/effort-clamp.d.ts +13 -0
- package/dist/runtime/query/iteration/effort-clamp.d.ts.map +1 -0
- package/dist/runtime/query/iteration/effort-clamp.js +42 -0
- package/dist/runtime/query/iteration/effort-clamp.js.map +1 -0
- package/dist/runtime/query/iteration/index.d.ts +34 -0
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +199 -6
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.js +6 -0
- package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
- package/dist/store/memory/disk.d.ts.map +1 -1
- package/dist/store/memory/disk.js +1 -1
- package/dist/store/memory/disk.js.map +1 -1
- package/dist/store/memory/markdown.d.ts.map +1 -1
- package/dist/store/memory/markdown.js +1 -1
- package/dist/store/memory/markdown.js.map +1 -1
- package/dist/store/memory/operation-lock.d.ts +20 -4
- package/dist/store/memory/operation-lock.d.ts.map +1 -1
- package/dist/store/memory/operation-lock.js +235 -58
- package/dist/store/memory/operation-lock.js.map +1 -1
- package/dist/turn/reporter.d.ts.map +1 -1
- package/dist/turn/reporter.js +11 -0
- package/dist/turn/reporter.js.map +1 -1
- package/dist/types/agent/query.d.ts +2 -0
- package/dist/types/agent/query.d.ts.map +1 -1
- package/dist/types/message/index.d.ts +1 -1
- package/dist/types/message/index.d.ts.map +1 -1
- package/dist/types/message/index.js +1 -0
- package/dist/types/message/index.js.map +1 -1
- package/dist/types/provider/chat.d.ts +19 -0
- package/dist/types/provider/chat.d.ts.map +1 -1
- package/dist/types/provider/config.d.ts +7 -0
- package/dist/types/provider/config.d.ts.map +1 -1
- package/dist/types/provider/index.d.ts +1 -1
- package/dist/types/provider/index.d.ts.map +1 -1
- package/dist/types/provider/interface.d.ts +15 -0
- package/dist/types/provider/interface.d.ts.map +1 -1
- package/dist/types/provider/stream.d.ts +6 -0
- package/dist/types/provider/stream.d.ts.map +1 -1
- package/dist/types/session/events.d.ts +34 -0
- package/dist/types/session/events.d.ts.map +1 -1
- package/dist/types/session/events.js.map +1 -1
- package/dist/types/session/records.d.ts +34 -34
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +3 -0
- package/dist/types/session/records.js.map +1 -1
- package/dist/types/session/stop-reason.d.ts +11 -0
- package/dist/types/session/stop-reason.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/agents/QueryAgent.ts +1 -0
- package/src/bridge/a2a/mapper.ts +19 -0
- package/src/bridge/acp/update.ts +6 -1
- package/src/bridge/sse/mapper.ts +13 -0
- package/src/constants/continuation.ts +13 -0
- package/src/contracts/session/api.ts +6 -0
- package/src/manager/session/turn-recorder.ts +1 -0
- package/src/provider/collect-chat-completion.ts +3 -0
- package/src/provider/fallback.ts +17 -0
- package/src/provider/idle-timeout.ts +9 -0
- package/src/provider/mock.ts +19 -9
- package/src/provider/model-window-suffix.ts +1 -0
- package/src/provider/retry.ts +9 -0
- package/src/runtime/query/index.ts +15 -0
- package/src/runtime/query/iteration/effort-clamp.ts +46 -0
- package/src/runtime/query/iteration/index.ts +260 -6
- package/src/runtime/query/iteration/phases/context.ts +2 -0
- package/src/runtime/query/iteration/stream-turn.ts +5 -0
- package/src/store/memory/disk.ts +1 -0
- package/src/store/memory/markdown.ts +1 -0
- package/src/store/memory/operation-lock.ts +288 -57
- package/src/turn/reporter.ts +12 -0
- package/src/types/agent/query.ts +2 -0
- package/src/types/message/index.ts +5 -3
- package/src/types/provider/chat.ts +20 -0
- package/src/types/provider/config.ts +7 -0
- package/src/types/provider/index.ts +1 -0
- package/src/types/provider/interface.ts +20 -0
- package/src/types/provider/stream.ts +6 -0
- package/src/types/session/events.ts +65 -5
- package/src/types/session/records.ts +3 -0
- package/src/types/session/stop-reason.ts +11 -0
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
|
|
304
|
+
if (attempted && !justBroke && performance.now() >= deadline) {
|
|
305
|
+
throw busyError(path, lastPid, timeoutMs)
|
|
44
306
|
}
|
|
307
|
+
justBroke = false
|
|
45
308
|
attempted = true
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
}
|
package/src/turn/reporter.ts
CHANGED
|
@@ -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.
|
package/src/types/agent/query.ts
CHANGED
|
@@ -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: {
|
|
699
|
-
|
|
700
|
-
|
|
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
|
}
|
|
@@ -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
|
-
| {
|
|
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
|
-
| {
|
|
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
|
-
| {
|
|
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
|
-
| {
|
|
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
|
-
| {
|
|
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',
|