@nifrajs/coding-agent 3.2.0 → 3.5.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/dist/context.js +29 -1
- package/dist/context.js.map +1 -1
- package/dist/diff.js +38 -2
- package/dist/diff.js.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +217 -24
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/isolated.js +37 -2
- package/dist/isolated.js.map +1 -1
- package/dist/native.d.ts +17 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +265 -41
- package/dist/native.js.map +1 -1
- package/dist/replay.d.ts +4 -0
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +189 -24
- package/dist/replay.js.map +1 -1
- package/dist/rpc.d.ts +10 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +208 -29
- package/dist/rpc.js.map +1 -1
- package/dist/sessions.d.ts +14 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +253 -59
- package/dist/sessions.js.map +1 -1
- package/dist/verification.d.ts +23 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +113 -1
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts +1 -0
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +43 -21
- package/dist/workflows.js.map +1 -1
- package/package.json +6 -5
- package/src/context.ts +32 -1
- package/src/diff.ts +40 -2
- package/src/host.ts +219 -24
- package/src/index.ts +5 -0
- package/src/isolated.ts +39 -2
- package/src/native.ts +408 -42
- package/src/replay.ts +206 -25
- package/src/rpc.ts +261 -33
- package/src/sessions.ts +271 -56
- package/src/verification.ts +140 -1
- package/src/workflows.ts +37 -25
package/src/replay.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
agentError,
|
|
8
8
|
type CreateSessionInput,
|
|
9
9
|
createAgentEventStream,
|
|
10
|
+
isAgentEvent,
|
|
10
11
|
type ReloadResult,
|
|
11
12
|
type SendMessageInput,
|
|
12
13
|
} from "@nifrajs/agent-protocol"
|
|
@@ -20,6 +21,9 @@ interface ReplaySession {
|
|
|
20
21
|
readonly id: string
|
|
21
22
|
snapshot: AgentSessionSnapshot
|
|
22
23
|
active: AgentEventStream | undefined
|
|
24
|
+
controller: AbortController
|
|
25
|
+
turnId: string | undefined
|
|
26
|
+
turnAbortCleanup: (() => void) | undefined
|
|
23
27
|
closed: boolean
|
|
24
28
|
seq: number
|
|
25
29
|
}
|
|
@@ -30,18 +34,59 @@ export class ReplayBackend implements AgentBackend {
|
|
|
30
34
|
name: "replay",
|
|
31
35
|
capabilities: Object.freeze(["sessions", "streaming", "replay"]),
|
|
32
36
|
})
|
|
33
|
-
|
|
37
|
+
/** Serialized immutable source records; parsing per replay prevents caller/consumer mutation leaks. */
|
|
38
|
+
private readonly events: readonly string[]
|
|
34
39
|
private readonly delayMs: number
|
|
35
40
|
private readonly sessions = new Map<string, ReplaySession>()
|
|
36
41
|
|
|
37
42
|
constructor(options: ReplayBackendOptions) {
|
|
38
|
-
|
|
43
|
+
if (
|
|
44
|
+
!Array.isArray(options.events) ||
|
|
45
|
+
options.events.length > MAX_REPLAY_EVENTS ||
|
|
46
|
+
!options.events.every((event) => isAgentEvent(event))
|
|
47
|
+
)
|
|
48
|
+
throw new TypeError("replay backend: events must be valid agent events")
|
|
49
|
+
const encoded: string[] = []
|
|
50
|
+
let totalBytes = 0
|
|
51
|
+
for (const event of options.events) {
|
|
52
|
+
let line: string
|
|
53
|
+
try {
|
|
54
|
+
// Clone before serializing so a hostile `toJSON`/nested reference cannot mutate the caller's
|
|
55
|
+
// record or make the replay depend on later mutations. The serialized form is the immutable
|
|
56
|
+
// source used for every turn below.
|
|
57
|
+
const clone = structuredClone(event) as AgentEvent
|
|
58
|
+
if (!isAgentEvent(clone)) throw new TypeError("replay event clone is invalid")
|
|
59
|
+
line = JSON.stringify(clone)
|
|
60
|
+
} catch {
|
|
61
|
+
throw new TypeError("replay backend: events must be JSON-serializable")
|
|
62
|
+
}
|
|
63
|
+
const bytes = new TextEncoder().encode(line).byteLength + 1
|
|
64
|
+
if (totalBytes > MAX_REPLAY_BYTES - bytes)
|
|
65
|
+
throw new RangeError("replay backend: events exceed the total size limit")
|
|
66
|
+
totalBytes += bytes
|
|
67
|
+
encoded.push(line)
|
|
68
|
+
}
|
|
69
|
+
this.events = Object.freeze(encoded)
|
|
39
70
|
this.delayMs = options.delayMs ?? 0
|
|
40
71
|
if (!Number.isSafeInteger(this.delayMs) || this.delayMs < 0 || this.delayMs > 60_000)
|
|
41
72
|
throw new RangeError("replay backend: delayMs must be between 0 and 60000")
|
|
42
73
|
}
|
|
43
74
|
|
|
44
75
|
async createSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
|
|
76
|
+
if (
|
|
77
|
+
typeof input.cwd !== "string" ||
|
|
78
|
+
input.cwd.length === 0 ||
|
|
79
|
+
input.cwd.length > 4_096 ||
|
|
80
|
+
input.cwd.includes("\0")
|
|
81
|
+
)
|
|
82
|
+
throw new TypeError("replay backend: cwd must be a bounded non-empty path")
|
|
83
|
+
if (
|
|
84
|
+
input.capabilities !== undefined &&
|
|
85
|
+
(!Array.isArray(input.capabilities) ||
|
|
86
|
+
input.capabilities.length > 256 ||
|
|
87
|
+
!input.capabilities.every((value) => boundedReplayToken(value)))
|
|
88
|
+
)
|
|
89
|
+
throw new TypeError("replay backend: capabilities must be bounded tokens")
|
|
45
90
|
const id = input.sessionId ?? crypto.randomUUID()
|
|
46
91
|
if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(id))
|
|
47
92
|
throw new TypeError("replay backend: invalid sessionId")
|
|
@@ -58,7 +103,16 @@ export class ReplayBackend implements AgentBackend {
|
|
|
58
103
|
lastSeq: 0,
|
|
59
104
|
capabilities: Object.freeze([...(input.capabilities ?? this.info.capabilities)]),
|
|
60
105
|
}
|
|
61
|
-
this.sessions.set(id, {
|
|
106
|
+
this.sessions.set(id, {
|
|
107
|
+
id,
|
|
108
|
+
snapshot,
|
|
109
|
+
active: undefined,
|
|
110
|
+
controller: new AbortController(),
|
|
111
|
+
turnId: undefined,
|
|
112
|
+
turnAbortCleanup: undefined,
|
|
113
|
+
closed: false,
|
|
114
|
+
seq: 0,
|
|
115
|
+
})
|
|
62
116
|
return snapshot
|
|
63
117
|
}
|
|
64
118
|
|
|
@@ -70,20 +124,32 @@ export class ReplayBackend implements AgentBackend {
|
|
|
70
124
|
return failedStream(agentError("SESSION_BUSY", "replay session is busy"))
|
|
71
125
|
const stream = createAgentEventStream()
|
|
72
126
|
session.active = stream
|
|
73
|
-
|
|
127
|
+
session.controller = new AbortController()
|
|
128
|
+
session.turnId = crypto.randomUUID()
|
|
129
|
+
const turnId = session.turnId
|
|
130
|
+
const signal = session.controller.signal
|
|
131
|
+
if (input.signal !== undefined) {
|
|
132
|
+
const externalSignal = input.signal
|
|
133
|
+
const onAbort = (): void => {
|
|
134
|
+
if (session.active !== stream || session.turnId !== turnId) return
|
|
135
|
+
void this.cancelTurn(session, stream, turnId, "cancelled")
|
|
136
|
+
}
|
|
137
|
+
if (externalSignal.aborted) onAbort()
|
|
138
|
+
else {
|
|
139
|
+
externalSignal.addEventListener("abort", onAbort, { once: true })
|
|
140
|
+
session.turnAbortCleanup = () => externalSignal.removeEventListener("abort", onAbort)
|
|
141
|
+
if (externalSignal.aborted) onAbort()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (session.active === stream && session.turnId === turnId)
|
|
145
|
+
void this.replay(session, stream, turnId, signal)
|
|
74
146
|
return stream
|
|
75
147
|
}
|
|
76
148
|
|
|
77
149
|
async cancel(sessionId: string, reason = "cancelled"): Promise<void> {
|
|
78
150
|
const session = this.requireSession(sessionId)
|
|
79
|
-
session.active
|
|
80
|
-
session.active
|
|
81
|
-
session.snapshot = Object.freeze({
|
|
82
|
-
...session.snapshot,
|
|
83
|
-
status: "stopped",
|
|
84
|
-
updatedAt: Date.now(),
|
|
85
|
-
})
|
|
86
|
-
if (session.seq === 0) this.push(session, { type: "session.stopped", reason })
|
|
151
|
+
if (session.active === undefined) return
|
|
152
|
+
await this.cancelTurn(session, session.active, session.turnId, reason)
|
|
87
153
|
}
|
|
88
154
|
|
|
89
155
|
async snapshot(sessionId: string): Promise<AgentSessionSnapshot> {
|
|
@@ -104,22 +170,35 @@ export class ReplayBackend implements AgentBackend {
|
|
|
104
170
|
async close(sessionId: string): Promise<void> {
|
|
105
171
|
const session = this.requireSession(sessionId)
|
|
106
172
|
session.closed = true
|
|
173
|
+
session.turnAbortCleanup?.()
|
|
174
|
+
session.turnAbortCleanup = undefined
|
|
175
|
+
session.controller.abort("closed")
|
|
107
176
|
session.active?.complete()
|
|
108
177
|
session.active = undefined
|
|
178
|
+
session.turnId = undefined
|
|
109
179
|
this.sessions.delete(sessionId)
|
|
110
180
|
}
|
|
111
181
|
|
|
112
|
-
private async replay(
|
|
182
|
+
private async replay(
|
|
183
|
+
session: ReplaySession,
|
|
184
|
+
stream: AgentEventStream,
|
|
185
|
+
turnId: string,
|
|
186
|
+
signal: AbortSignal,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
if (!this.isCurrentTurn(session, stream, turnId, signal)) return
|
|
113
189
|
session.snapshot = Object.freeze({
|
|
114
190
|
...session.snapshot,
|
|
115
191
|
status: "running",
|
|
116
192
|
updatedAt: Date.now(),
|
|
117
193
|
})
|
|
118
194
|
try {
|
|
119
|
-
for (const
|
|
120
|
-
if (this.delayMs > 0) await
|
|
195
|
+
for (const encoded of this.events) {
|
|
196
|
+
if (this.delayMs > 0) await delay(this.delayMs, signal)
|
|
197
|
+
if (!this.isCurrentTurn(session, stream, turnId, signal)) return
|
|
198
|
+
const source = JSON.parse(encoded) as AgentEvent
|
|
121
199
|
this.push(session, remap(source, session.id, session.seq))
|
|
122
200
|
}
|
|
201
|
+
if (!this.isCurrentTurn(session, stream, turnId, signal)) return
|
|
123
202
|
session.snapshot = Object.freeze({
|
|
124
203
|
...session.snapshot,
|
|
125
204
|
status: "idle",
|
|
@@ -127,15 +206,81 @@ export class ReplayBackend implements AgentBackend {
|
|
|
127
206
|
})
|
|
128
207
|
this.push(session, { type: "session.completed", snapshot: session.snapshot })
|
|
129
208
|
} catch (error) {
|
|
209
|
+
if (!this.isCurrentTurn(session, stream, turnId, signal)) return
|
|
130
210
|
this.push(session, {
|
|
131
211
|
type: "session.failed",
|
|
132
212
|
error: agentError("REPLAY_FAILED", error instanceof Error ? error.message : String(error)),
|
|
133
213
|
recoverable: true,
|
|
134
214
|
})
|
|
135
215
|
} finally {
|
|
216
|
+
if (this.isCurrentTurn(session, stream, turnId, signal)) {
|
|
217
|
+
stream.complete()
|
|
218
|
+
this.clearTurn(session, stream, turnId)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private async cancelTurn(
|
|
224
|
+
session: ReplaySession,
|
|
225
|
+
expectedStream: AgentEventStream | undefined,
|
|
226
|
+
expectedTurnId: string | undefined,
|
|
227
|
+
reason: string,
|
|
228
|
+
): Promise<void> {
|
|
229
|
+
if (
|
|
230
|
+
expectedStream !== undefined &&
|
|
231
|
+
(session.active !== expectedStream || session.turnId !== expectedTurnId)
|
|
232
|
+
)
|
|
233
|
+
return
|
|
234
|
+
const stream = session.active
|
|
235
|
+
const turnId = session.turnId
|
|
236
|
+
const stopReason = boundedReplayReason(reason)
|
|
237
|
+
session.turnAbortCleanup?.()
|
|
238
|
+
session.turnAbortCleanup = undefined
|
|
239
|
+
session.controller.abort(stopReason)
|
|
240
|
+
if (stream !== undefined && turnId !== undefined) {
|
|
241
|
+
session.snapshot = Object.freeze({
|
|
242
|
+
...session.snapshot,
|
|
243
|
+
status: "stopped",
|
|
244
|
+
updatedAt: Date.now(),
|
|
245
|
+
})
|
|
246
|
+
this.push(session, { type: "session.stopped", reason: stopReason })
|
|
136
247
|
stream.complete()
|
|
137
|
-
|
|
248
|
+
} else {
|
|
249
|
+
session.snapshot = Object.freeze({
|
|
250
|
+
...session.snapshot,
|
|
251
|
+
status: "stopped",
|
|
252
|
+
updatedAt: Date.now(),
|
|
253
|
+
})
|
|
138
254
|
}
|
|
255
|
+
this.clearTurn(session, stream, turnId)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private isCurrentTurn(
|
|
259
|
+
session: ReplaySession,
|
|
260
|
+
stream: AgentEventStream,
|
|
261
|
+
turnId: string,
|
|
262
|
+
signal: AbortSignal,
|
|
263
|
+
): boolean {
|
|
264
|
+
return (
|
|
265
|
+
!session.closed &&
|
|
266
|
+
!signal.aborted &&
|
|
267
|
+
session.active === stream &&
|
|
268
|
+
session.turnId === turnId &&
|
|
269
|
+
session.controller.signal === signal
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private clearTurn(
|
|
274
|
+
session: ReplaySession,
|
|
275
|
+
stream: AgentEventStream | undefined,
|
|
276
|
+
turnId: string | undefined,
|
|
277
|
+
): void {
|
|
278
|
+
if (stream !== undefined && session.active !== stream) return
|
|
279
|
+
if (turnId !== undefined && session.turnId !== turnId) return
|
|
280
|
+
session.turnAbortCleanup?.()
|
|
281
|
+
session.turnAbortCleanup = undefined
|
|
282
|
+
if (stream === undefined || session.active === stream) session.active = undefined
|
|
283
|
+
if (turnId === undefined || session.turnId === turnId) session.turnId = undefined
|
|
139
284
|
}
|
|
140
285
|
|
|
141
286
|
private push(
|
|
@@ -146,6 +291,7 @@ export class ReplayBackend implements AgentBackend {
|
|
|
146
291
|
"version" in event && "sessionId" in event
|
|
147
292
|
? event
|
|
148
293
|
: { version: 1 as const, sessionId: session.id, seq: session.seq, at: Date.now(), ...event }
|
|
294
|
+
if (!isAgentEvent(next)) throw new Error("replay backend produced an invalid event")
|
|
149
295
|
session.seq = next.seq + 1
|
|
150
296
|
session.snapshot = Object.freeze({ ...session.snapshot, lastSeq: next.seq, updatedAt: next.at })
|
|
151
297
|
session.active?.push(next as AgentEvent)
|
|
@@ -159,10 +305,13 @@ export class ReplayBackend implements AgentBackend {
|
|
|
159
305
|
}
|
|
160
306
|
|
|
161
307
|
export async function readReplayEvents(path: string): Promise<readonly AgentEvent[]> {
|
|
162
|
-
const
|
|
308
|
+
const file = Bun.file(path)
|
|
309
|
+
if (file.size > MAX_REPLAY_BYTES) throw new RangeError("replay: event file is too large")
|
|
310
|
+
const text = await file.text()
|
|
163
311
|
const events: AgentEvent[] = []
|
|
164
312
|
for (const line of text.split("\n")) {
|
|
165
313
|
if (line.trim().length === 0) continue
|
|
314
|
+
if (events.length >= MAX_REPLAY_EVENTS) throw new RangeError("replay: too many events")
|
|
166
315
|
const value: unknown = JSON.parse(line)
|
|
167
316
|
if (!isAgentEvent(value)) throw new Error(`replay: invalid event in ${path}`)
|
|
168
317
|
events.push(value)
|
|
@@ -171,16 +320,48 @@ export async function readReplayEvents(path: string): Promise<readonly AgentEven
|
|
|
171
320
|
}
|
|
172
321
|
|
|
173
322
|
function remap(event: AgentEvent, sessionId: string, seq: number): AgentEvent {
|
|
174
|
-
|
|
323
|
+
const base = { ...event, sessionId, seq, at: Date.now() }
|
|
324
|
+
if (
|
|
325
|
+
event.type === "session.started" ||
|
|
326
|
+
event.type === "session.updated" ||
|
|
327
|
+
event.type === "session.completed"
|
|
328
|
+
)
|
|
329
|
+
return Object.freeze({
|
|
330
|
+
...base,
|
|
331
|
+
snapshot: Object.freeze({ ...event.snapshot, id: sessionId }),
|
|
332
|
+
})
|
|
333
|
+
return Object.freeze(base)
|
|
175
334
|
}
|
|
176
335
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
336
|
+
const MAX_REPLAY_BYTES = 64 * 1024 * 1024
|
|
337
|
+
const MAX_REPLAY_EVENTS = 100_000
|
|
338
|
+
|
|
339
|
+
function boundedReplayReason(reason: string): string {
|
|
340
|
+
if (reason.length <= 512) return reason
|
|
341
|
+
return `${reason.slice(0, 511)}…`
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function boundedReplayToken(value: unknown): value is string {
|
|
345
|
+
return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
349
|
+
await new Promise<void>((resolve, reject) => {
|
|
350
|
+
let settled = false
|
|
351
|
+
const timer = setTimeout(() => settle(), ms)
|
|
352
|
+
const onAbort = (): void => settle(new Error("replay cancelled"))
|
|
353
|
+
const cleanup = (): void => signal.removeEventListener("abort", onAbort)
|
|
354
|
+
const settle = (error?: Error): void => {
|
|
355
|
+
if (settled) return
|
|
356
|
+
settled = true
|
|
357
|
+
clearTimeout(timer)
|
|
358
|
+
cleanup()
|
|
359
|
+
if (error === undefined) resolve()
|
|
360
|
+
else reject(error)
|
|
361
|
+
}
|
|
362
|
+
if (signal.aborted) onAbort()
|
|
363
|
+
else signal.addEventListener("abort", onAbort, { once: true })
|
|
364
|
+
})
|
|
184
365
|
}
|
|
185
366
|
|
|
186
367
|
function failedStream(error: unknown): AgentEventStream {
|