@nifrajs/coding-agent 3.3.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/SECURITY.md +2 -1
- package/dist/approvals.d.ts +14 -2
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +51 -43
- package/dist/approvals.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -2
- package/dist/cli.js.map +1 -1
- 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/handoffs.d.ts.map +1 -1
- package/dist/handoffs.js +12 -4
- package/dist/handoffs.js.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +146 -23
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +2 -2
- 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 +12 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +165 -40
- package/dist/native.js.map +1 -1
- package/dist/orchestration/compile.d.ts +7 -0
- package/dist/orchestration/compile.d.ts.map +1 -1
- package/dist/orchestration/compile.js +16 -2
- package/dist/orchestration/compile.js.map +1 -1
- package/dist/orchestration/evidence-store.d.ts.map +1 -1
- package/dist/orchestration/evidence-store.js +9 -4
- package/dist/orchestration/evidence-store.js.map +1 -1
- package/dist/orchestration/host.d.ts +10 -0
- package/dist/orchestration/host.d.ts.map +1 -1
- package/dist/orchestration/host.js +9 -0
- package/dist/orchestration/host.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 +209 -29
- package/dist/rpc.js.map +1 -1
- package/dist/session-migration.d.ts.map +1 -1
- package/dist/session-migration.js +3 -2
- package/dist/session-migration.js.map +1 -1
- package/dist/sessions.d.ts +20 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +269 -60
- package/dist/sessions.js.map +1 -1
- package/dist/subagents.d.ts +30 -0
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +118 -15
- package/dist/subagents.js.map +1 -1
- package/dist/verification.d.ts +28 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +119 -3
- 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 +55 -23
- package/dist/workflows.js.map +1 -1
- package/package.json +9 -5
- package/src/approvals.ts +62 -45
- package/src/cli.ts +11 -2
- package/src/context.ts +32 -1
- package/src/diff.ts +40 -2
- package/src/handoffs.ts +20 -8
- package/src/host.ts +146 -23
- package/src/index.ts +7 -0
- package/src/isolated.ts +39 -2
- package/src/native.ts +213 -39
- package/src/orchestration/compile.ts +28 -2
- package/src/orchestration/evidence-store.ts +9 -3
- package/src/orchestration/host.ts +17 -0
- package/src/replay.ts +206 -25
- package/src/rpc.ts +262 -33
- package/src/session-migration.ts +3 -2
- package/src/sessions.ts +288 -57
- package/src/subagents.ts +147 -17
- package/src/verification.ts +146 -3
- package/src/workflows.ts +47 -27
package/src/native.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
agentError,
|
|
14
14
|
type CreateSessionInput,
|
|
15
15
|
createAgentEventStream,
|
|
16
|
+
isAgentEvent,
|
|
16
17
|
type ReloadResult,
|
|
17
18
|
type SendMessageInput,
|
|
18
19
|
} from "@nifrajs/agent-protocol"
|
|
@@ -122,6 +123,11 @@ export interface NifraBackendOptions {
|
|
|
122
123
|
readonly approvalTimeoutMs?: number
|
|
123
124
|
readonly maxSteps?: number
|
|
124
125
|
readonly maxMessageChars?: number
|
|
126
|
+
/**
|
|
127
|
+
* Character budget for the conversation a session sends to the model (default 1 MiB). Past it,
|
|
128
|
+
* whole earlier turns are dropped, oldest first; the current turn is always sent in full.
|
|
129
|
+
*/
|
|
130
|
+
readonly maxHistoryChars?: number
|
|
125
131
|
readonly now?: () => number
|
|
126
132
|
}
|
|
127
133
|
|
|
@@ -148,15 +154,19 @@ interface NativePendingApproval {
|
|
|
148
154
|
readonly resolve: (resolution: NativeApprovalResolution) => void
|
|
149
155
|
readonly timer: ReturnType<typeof setTimeout>
|
|
150
156
|
readonly onAbort: () => void
|
|
157
|
+
readonly signal: AbortSignal
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
interface NativeSession {
|
|
154
161
|
readonly id: string
|
|
155
162
|
readonly cwd: string
|
|
156
163
|
readonly messages: NativeMessage[]
|
|
164
|
+
historyChars: number
|
|
157
165
|
controller: AbortController
|
|
158
166
|
snapshot: AgentSessionSnapshot
|
|
159
167
|
active: AgentEventStream | undefined
|
|
168
|
+
turnId: string | undefined
|
|
169
|
+
turnAbortCleanup: (() => void) | undefined
|
|
160
170
|
seq: number
|
|
161
171
|
extensionRevision: number
|
|
162
172
|
closed: boolean
|
|
@@ -175,7 +185,9 @@ export class NifraBackend implements AgentBackend {
|
|
|
175
185
|
name: "nifra",
|
|
176
186
|
capabilities: Object.freeze(["sessions", "extensions", "reload", "approvals", "streaming"]),
|
|
177
187
|
})
|
|
178
|
-
private readonly options: Required<
|
|
188
|
+
private readonly options: Required<
|
|
189
|
+
Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars" | "maxHistoryChars">
|
|
190
|
+
> &
|
|
179
191
|
NifraBackendOptions
|
|
180
192
|
private readonly sessions = new Map<string, NativeSession>()
|
|
181
193
|
private readonly pendingApprovals = new Map<string, NativePendingApproval>()
|
|
@@ -185,6 +197,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
185
197
|
...options,
|
|
186
198
|
maxSteps: options.maxSteps ?? 32,
|
|
187
199
|
maxMessageChars: options.maxMessageChars ?? 64 * 1024,
|
|
200
|
+
maxHistoryChars: options.maxHistoryChars ?? 1024 * 1024,
|
|
188
201
|
tools: Object.freeze([...(options.tools ?? [])]),
|
|
189
202
|
})
|
|
190
203
|
if (
|
|
@@ -195,6 +208,8 @@ export class NifraBackend implements AgentBackend {
|
|
|
195
208
|
throw new RangeError("nifra backend: maxSteps must be between 1 and 512")
|
|
196
209
|
if (!Number.isSafeInteger(this.options.maxMessageChars) || this.options.maxMessageChars < 256)
|
|
197
210
|
throw new RangeError("nifra backend: maxMessageChars must be at least 256")
|
|
211
|
+
if (!Number.isSafeInteger(this.options.maxHistoryChars) || this.options.maxHistoryChars < 256)
|
|
212
|
+
throw new RangeError("nifra backend: maxHistoryChars must be at least 256")
|
|
198
213
|
if (
|
|
199
214
|
this.options.approvalTimeoutMs !== undefined &&
|
|
200
215
|
(!Number.isSafeInteger(this.options.approvalTimeoutMs) ||
|
|
@@ -205,6 +220,20 @@ export class NifraBackend implements AgentBackend {
|
|
|
205
220
|
}
|
|
206
221
|
|
|
207
222
|
async createSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
|
|
223
|
+
if (
|
|
224
|
+
typeof input.cwd !== "string" ||
|
|
225
|
+
input.cwd.length === 0 ||
|
|
226
|
+
input.cwd.length > 4_096 ||
|
|
227
|
+
input.cwd.includes("\0")
|
|
228
|
+
)
|
|
229
|
+
throw new TypeError("nifra backend: cwd must be a bounded non-empty path")
|
|
230
|
+
if (
|
|
231
|
+
input.capabilities !== undefined &&
|
|
232
|
+
(!Array.isArray(input.capabilities) ||
|
|
233
|
+
input.capabilities.length > 256 ||
|
|
234
|
+
!input.capabilities.every((value) => boundedApprovalToken(value)))
|
|
235
|
+
)
|
|
236
|
+
throw new TypeError("nifra backend: capabilities must be bounded tokens")
|
|
208
237
|
const id = input.sessionId ?? crypto.randomUUID()
|
|
209
238
|
if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(id))
|
|
210
239
|
throw new TypeError("nifra backend: sessionId must be a bounded token")
|
|
@@ -225,9 +254,12 @@ export class NifraBackend implements AgentBackend {
|
|
|
225
254
|
id,
|
|
226
255
|
cwd: input.cwd,
|
|
227
256
|
messages: [],
|
|
257
|
+
historyChars: 0,
|
|
228
258
|
controller: new AbortController(),
|
|
229
259
|
snapshot,
|
|
230
260
|
active: undefined,
|
|
261
|
+
turnId: undefined,
|
|
262
|
+
turnAbortCleanup: undefined,
|
|
231
263
|
seq: 0,
|
|
232
264
|
extensionRevision: 0,
|
|
233
265
|
closed: false,
|
|
@@ -251,30 +283,38 @@ export class NifraBackend implements AgentBackend {
|
|
|
251
283
|
session.cancelled = false
|
|
252
284
|
session.controller = new AbortController()
|
|
253
285
|
const signal = session.controller.signal
|
|
286
|
+
const turnId = crypto.randomUUID()
|
|
287
|
+
session.turnId = turnId
|
|
254
288
|
if (input.signal !== undefined) {
|
|
255
289
|
const externalSignal = input.signal
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
290
|
+
const onAbort = (): void => {
|
|
291
|
+
// Capture the turn identity. A request signal must never abort a later turn that reused
|
|
292
|
+
// this session after the original stream settled.
|
|
293
|
+
if (
|
|
294
|
+
session.active !== stream ||
|
|
295
|
+
session.turnId !== turnId ||
|
|
296
|
+
session.controller.signal !== signal
|
|
262
297
|
)
|
|
298
|
+
return
|
|
299
|
+
void this.cancelTurn(session, stream, turnId, "cancelled")
|
|
300
|
+
}
|
|
301
|
+
if (externalSignal.aborted) onAbort()
|
|
302
|
+
else {
|
|
303
|
+
externalSignal.addEventListener("abort", onAbort, { once: true })
|
|
304
|
+
session.turnAbortCleanup = () => externalSignal.removeEventListener("abort", onAbort)
|
|
305
|
+
// Abort can race the registration between the check above and addEventListener.
|
|
306
|
+
if (externalSignal.aborted) onAbort()
|
|
307
|
+
}
|
|
263
308
|
}
|
|
264
|
-
void this.run(session, input.message, stream, signal)
|
|
309
|
+
void this.run(session, input.message, stream, signal, turnId)
|
|
265
310
|
return stream
|
|
266
311
|
}
|
|
267
312
|
|
|
268
313
|
async cancel(sessionId: string, reason = "cancelled"): Promise<void> {
|
|
269
314
|
const session = this.requireSession(sessionId)
|
|
270
315
|
if (session.closed) return
|
|
271
|
-
|
|
272
|
-
session.
|
|
273
|
-
session.controller.abort(reason)
|
|
274
|
-
this.update(session, "stopped")
|
|
275
|
-
this.emit(session, { type: "session.stopped", reason })
|
|
276
|
-
stream?.complete()
|
|
277
|
-
session.active = undefined
|
|
316
|
+
if (session.active === undefined) return
|
|
317
|
+
await this.cancelTurn(session, session.active, session.turnId, reason)
|
|
278
318
|
}
|
|
279
319
|
|
|
280
320
|
async snapshot(sessionId: string): Promise<AgentSessionSnapshot> {
|
|
@@ -326,9 +366,12 @@ export class NifraBackend implements AgentBackend {
|
|
|
326
366
|
if (session.closed) return
|
|
327
367
|
session.closed = true
|
|
328
368
|
session.cancelled = true
|
|
369
|
+
session.turnAbortCleanup?.()
|
|
370
|
+
session.turnAbortCleanup = undefined
|
|
329
371
|
session.controller.abort("closed")
|
|
330
372
|
session.active?.complete()
|
|
331
373
|
session.active = undefined
|
|
374
|
+
session.turnId = undefined
|
|
332
375
|
this.sessions.delete(sessionId)
|
|
333
376
|
}
|
|
334
377
|
|
|
@@ -337,18 +380,23 @@ export class NifraBackend implements AgentBackend {
|
|
|
337
380
|
message: string,
|
|
338
381
|
stream: AgentEventStream,
|
|
339
382
|
signal: AbortSignal,
|
|
383
|
+
turnId: string,
|
|
340
384
|
): Promise<void> {
|
|
341
|
-
|
|
385
|
+
if (!this.isCurrentTurn(session, stream, turnId, signal)) {
|
|
386
|
+
stream.complete()
|
|
387
|
+
return
|
|
388
|
+
}
|
|
342
389
|
this.update(session, "running", turnId)
|
|
343
390
|
this.emit(session, { type: "turn.started", turnId, prompt: message })
|
|
344
|
-
|
|
391
|
+
this.remember(session, { role: "user", text: message })
|
|
345
392
|
try {
|
|
346
393
|
for (let step = 0; step < this.options.maxSteps; step++) {
|
|
347
|
-
|
|
394
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
395
|
+
this.trimHistory(session)
|
|
348
396
|
const raw = this.options.model.complete({
|
|
349
397
|
sessionId: session.id,
|
|
350
398
|
cwd: session.cwd,
|
|
351
|
-
messages: Object.freeze(session.messages.
|
|
399
|
+
messages: Object.freeze(session.messages.slice()),
|
|
352
400
|
tools: Object.freeze(
|
|
353
401
|
this.options.tools!.map((tool) => ({
|
|
354
402
|
name: tool.name,
|
|
@@ -358,10 +406,12 @@ export class NifraBackend implements AgentBackend {
|
|
|
358
406
|
),
|
|
359
407
|
signal,
|
|
360
408
|
})
|
|
361
|
-
const response = await this.consumeModel(raw, session, turnId, signal)
|
|
409
|
+
const response = await this.consumeModel(raw, session, stream, turnId, signal)
|
|
410
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
362
411
|
if (response.type === "text") {
|
|
363
|
-
|
|
364
|
-
this.
|
|
412
|
+
const text = boundedText(response.text, this.options.maxMessageChars)
|
|
413
|
+
this.remember(session, { role: "assistant", text })
|
|
414
|
+
this.emit(session, { type: "assistant.message", turnId, text })
|
|
365
415
|
this.finish(session, stream)
|
|
366
416
|
return
|
|
367
417
|
}
|
|
@@ -375,7 +425,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
375
425
|
ok: false,
|
|
376
426
|
error: agentError("UNKNOWN_TOOL", `unknown native tool: ${response.name}`),
|
|
377
427
|
})
|
|
378
|
-
|
|
428
|
+
this.remember(session, { role: "tool", name: response.name, text: "unknown tool" })
|
|
379
429
|
continue
|
|
380
430
|
}
|
|
381
431
|
const callId = crypto.randomUUID()
|
|
@@ -391,6 +441,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
391
441
|
typeof tool.requiresApproval === "function"
|
|
392
442
|
? await tool.requiresApproval(response.input)
|
|
393
443
|
: tool.requiresApproval === true
|
|
444
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
394
445
|
if (needsApproval) {
|
|
395
446
|
const resolution = await this.requestApproval(
|
|
396
447
|
session,
|
|
@@ -400,6 +451,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
400
451
|
response.input,
|
|
401
452
|
signal,
|
|
402
453
|
)
|
|
454
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
403
455
|
if (!resolution.approved) {
|
|
404
456
|
const code = resolution.errorCode ?? "APPROVAL_DENIED"
|
|
405
457
|
this.emit(session, {
|
|
@@ -410,14 +462,15 @@ export class NifraBackend implements AgentBackend {
|
|
|
410
462
|
ok: false,
|
|
411
463
|
error: agentError(code, boundedApprovalMessage(code, tool.name, resolution.reason)),
|
|
412
464
|
})
|
|
413
|
-
|
|
465
|
+
this.remember(session, { role: "tool", name: tool.name, text: code })
|
|
414
466
|
continue
|
|
415
467
|
}
|
|
416
468
|
}
|
|
417
469
|
try {
|
|
418
470
|
const output = await execute(response.input, { cwd: session.cwd, signal })
|
|
471
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
419
472
|
const text = boundedText(output)
|
|
420
|
-
|
|
473
|
+
this.remember(session, { role: "tool", name: tool.name, text })
|
|
421
474
|
this.emit(session, {
|
|
422
475
|
type: "tool.completed",
|
|
423
476
|
turnId,
|
|
@@ -427,8 +480,12 @@ export class NifraBackend implements AgentBackend {
|
|
|
427
480
|
output: text,
|
|
428
481
|
})
|
|
429
482
|
} catch (error) {
|
|
430
|
-
|
|
431
|
-
|
|
483
|
+
if (signal.aborted || !this.isCurrentTurn(session, stream, turnId, signal)) throw error
|
|
484
|
+
const message = boundedText(
|
|
485
|
+
error instanceof Error ? error.message : error,
|
|
486
|
+
this.options.maxMessageChars,
|
|
487
|
+
)
|
|
488
|
+
this.remember(session, { role: "tool", name: tool.name, text: message })
|
|
432
489
|
this.emit(session, {
|
|
433
490
|
type: "tool.completed",
|
|
434
491
|
turnId,
|
|
@@ -441,50 +498,57 @@ export class NifraBackend implements AgentBackend {
|
|
|
441
498
|
}
|
|
442
499
|
throw new Error("native turn exceeded maxSteps")
|
|
443
500
|
} catch (error) {
|
|
444
|
-
if (session
|
|
501
|
+
if (!this.isCurrentTurn(session, stream, turnId, signal)) {
|
|
445
502
|
stream.complete()
|
|
446
|
-
if (session.active === stream) session.active = undefined
|
|
447
503
|
return
|
|
448
504
|
}
|
|
449
|
-
|
|
505
|
+
if (signal.aborted || session.cancelled) {
|
|
506
|
+
await this.cancelTurn(session, stream, turnId, "cancelled")
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
this.update(session, "failed")
|
|
450
510
|
this.emit(session, {
|
|
451
511
|
type: "session.failed",
|
|
452
512
|
error: agentError(
|
|
453
|
-
|
|
454
|
-
error instanceof Error ? error.message :
|
|
513
|
+
"NATIVE_TURN_FAILED",
|
|
514
|
+
boundedText(error instanceof Error ? error.message : error, 4_096),
|
|
455
515
|
),
|
|
456
516
|
recoverable: true,
|
|
457
517
|
})
|
|
458
518
|
stream.complete()
|
|
459
|
-
session
|
|
519
|
+
this.clearTurn(session, stream, turnId)
|
|
460
520
|
}
|
|
461
521
|
}
|
|
462
522
|
|
|
463
523
|
private async consumeModel(
|
|
464
524
|
raw: ReturnType<NativeModelPort["complete"]>,
|
|
465
525
|
session: NativeSession,
|
|
526
|
+
stream: AgentEventStream,
|
|
466
527
|
turnId: string,
|
|
467
528
|
signal: AbortSignal,
|
|
468
529
|
): Promise<NativeModelResponse> {
|
|
469
530
|
if (isAsyncIterable<NativeModelChunk>(raw)) {
|
|
470
531
|
let response: NativeModelResponse | undefined
|
|
471
532
|
for await (const chunk of raw) {
|
|
472
|
-
|
|
533
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
473
534
|
if (chunk.type === "text_delta")
|
|
474
535
|
this.emit(session, { type: "assistant.delta", turnId, text: chunk.text })
|
|
475
536
|
else response = chunk.response
|
|
476
537
|
}
|
|
477
538
|
if (response === undefined) throw new Error("native model stream ended without a response")
|
|
539
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
478
540
|
return response
|
|
479
541
|
}
|
|
480
|
-
|
|
542
|
+
const response = await raw
|
|
543
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
544
|
+
return response
|
|
481
545
|
}
|
|
482
546
|
|
|
483
547
|
private finish(session: NativeSession, stream: AgentEventStream): void {
|
|
484
548
|
this.update(session, "idle")
|
|
485
549
|
this.emit(session, { type: "session.completed", snapshot: session.snapshot })
|
|
486
550
|
stream.complete()
|
|
487
|
-
session
|
|
551
|
+
this.clearTurn(session, stream, session.turnId)
|
|
488
552
|
}
|
|
489
553
|
|
|
490
554
|
private requestApproval(
|
|
@@ -526,6 +590,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
526
590
|
resolve,
|
|
527
591
|
timer,
|
|
528
592
|
onAbort,
|
|
593
|
+
signal,
|
|
529
594
|
}
|
|
530
595
|
this.pendingApprovals.set(approvalId, pending)
|
|
531
596
|
signal.addEventListener("abort", onAbort, { once: true })
|
|
@@ -595,7 +660,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
595
660
|
const pending = this.pendingApprovals.get(approvalId)
|
|
596
661
|
if (pending === undefined || pending.sessionId !== session.id) return undefined
|
|
597
662
|
clearTimeout(pending.timer)
|
|
598
|
-
|
|
663
|
+
pending.signal.removeEventListener("abort", pending.onAbort)
|
|
599
664
|
this.pendingApprovals.delete(approvalId)
|
|
600
665
|
const boundedReason = reason === undefined ? undefined : boundedApprovalText(reason, 512)
|
|
601
666
|
const resolution: NativeApprovalResolution = Object.freeze({
|
|
@@ -614,6 +679,76 @@ export class NifraBackend implements AgentBackend {
|
|
|
614
679
|
return resolution
|
|
615
680
|
}
|
|
616
681
|
|
|
682
|
+
private async cancelTurn(
|
|
683
|
+
session: NativeSession,
|
|
684
|
+
expectedStream: AgentEventStream | undefined,
|
|
685
|
+
expectedTurnId: string | undefined,
|
|
686
|
+
reason: string,
|
|
687
|
+
): Promise<void> {
|
|
688
|
+
if (
|
|
689
|
+
expectedStream !== undefined &&
|
|
690
|
+
(session.active !== expectedStream || session.turnId !== expectedTurnId)
|
|
691
|
+
)
|
|
692
|
+
return
|
|
693
|
+
const stream = session.active
|
|
694
|
+
const turnId = session.turnId
|
|
695
|
+
const stopReason = boundedText(reason, 512) || "cancelled"
|
|
696
|
+
session.cancelled = true
|
|
697
|
+
this.clearTurnSignal(session)
|
|
698
|
+
try {
|
|
699
|
+
session.controller.abort(stopReason)
|
|
700
|
+
} catch {
|
|
701
|
+
// AbortController.abort is normally infallible; lifecycle cleanup must still complete if a
|
|
702
|
+
// host supplies an unusual controller implementation.
|
|
703
|
+
}
|
|
704
|
+
this.update(session, "stopped")
|
|
705
|
+
this.emit(session, { type: "session.stopped", reason: stopReason })
|
|
706
|
+
stream?.complete()
|
|
707
|
+
this.clearTurn(session, stream, turnId)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private clearTurnSignal(session: NativeSession): void {
|
|
711
|
+
session.turnAbortCleanup?.()
|
|
712
|
+
session.turnAbortCleanup = undefined
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private clearTurn(
|
|
716
|
+
session: NativeSession,
|
|
717
|
+
stream: AgentEventStream | undefined,
|
|
718
|
+
turnId: string | undefined,
|
|
719
|
+
): void {
|
|
720
|
+
if (stream !== undefined && session.active !== stream) return
|
|
721
|
+
if (turnId !== undefined && session.turnId !== turnId) return
|
|
722
|
+
this.clearTurnSignal(session)
|
|
723
|
+
if (stream === undefined || session.active === stream) session.active = undefined
|
|
724
|
+
if (turnId === undefined || session.turnId === turnId) session.turnId = undefined
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private isCurrentTurn(
|
|
728
|
+
session: NativeSession,
|
|
729
|
+
stream: AgentEventStream,
|
|
730
|
+
turnId: string,
|
|
731
|
+
signal: AbortSignal,
|
|
732
|
+
): boolean {
|
|
733
|
+
return (
|
|
734
|
+
!session.closed &&
|
|
735
|
+
!session.cancelled &&
|
|
736
|
+
session.active === stream &&
|
|
737
|
+
session.turnId === turnId &&
|
|
738
|
+
session.controller.signal === signal
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private assertCurrentTurn(
|
|
743
|
+
session: NativeSession,
|
|
744
|
+
stream: AgentEventStream,
|
|
745
|
+
turnId: string,
|
|
746
|
+
signal: AbortSignal,
|
|
747
|
+
): void {
|
|
748
|
+
if (signal.aborted || !this.isCurrentTurn(session, stream, turnId, signal))
|
|
749
|
+
throw new Error("native turn cancelled")
|
|
750
|
+
}
|
|
751
|
+
|
|
617
752
|
private emit(
|
|
618
753
|
session: NativeSession,
|
|
619
754
|
payload: import("@nifrajs/agent-protocol").AgentEventPayload,
|
|
@@ -625,6 +760,8 @@ export class NifraBackend implements AgentBackend {
|
|
|
625
760
|
at: this.now(),
|
|
626
761
|
...payload,
|
|
627
762
|
}) as AgentEvent
|
|
763
|
+
if (!isAgentEvent(event))
|
|
764
|
+
throw new Error("nifra backend emitted an event outside protocol bounds")
|
|
628
765
|
session.snapshot = Object.freeze({
|
|
629
766
|
...session.snapshot,
|
|
630
767
|
lastSeq: event.seq,
|
|
@@ -640,7 +777,9 @@ export class NifraBackend implements AgentBackend {
|
|
|
640
777
|
): void {
|
|
641
778
|
const next = { ...session.snapshot, status, updatedAt: this.now(), lastSeq: session.seq }
|
|
642
779
|
session.snapshot = Object.freeze(
|
|
643
|
-
status === "running" && turnId !== undefined
|
|
780
|
+
status === "running" && turnId !== undefined
|
|
781
|
+
? { ...next, activeTurnId: turnId }
|
|
782
|
+
: removeActiveTurn(next),
|
|
644
783
|
)
|
|
645
784
|
if (session.active !== undefined)
|
|
646
785
|
this.emit(session, { type: "session.updated", snapshot: session.snapshot })
|
|
@@ -655,6 +794,26 @@ export class NifraBackend implements AgentBackend {
|
|
|
655
794
|
private now(): number {
|
|
656
795
|
return this.options.now?.() ?? Date.now()
|
|
657
796
|
}
|
|
797
|
+
|
|
798
|
+
private remember(session: NativeSession, message: NativeMessage): void {
|
|
799
|
+
session.messages.push(Object.freeze(message))
|
|
800
|
+
session.historyChars += messageChars(message)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private trimHistory(session: NativeSession): void {
|
|
804
|
+
while (session.historyChars > this.options.maxHistoryChars) {
|
|
805
|
+
const nextTurn = session.messages.findIndex(
|
|
806
|
+
(item, index) => index > 0 && item.role === "user",
|
|
807
|
+
)
|
|
808
|
+
if (nextTurn === -1) return
|
|
809
|
+
for (const item of session.messages.splice(0, nextTurn))
|
|
810
|
+
session.historyChars -= messageChars(item)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function messageChars(message: NativeMessage): number {
|
|
816
|
+
return message.text.length + (message.name?.length ?? 0)
|
|
658
817
|
}
|
|
659
818
|
|
|
660
819
|
function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
|
|
@@ -664,12 +823,20 @@ function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
|
|
|
664
823
|
function boundedText(value: unknown, maxChars = 64 * 1024): string {
|
|
665
824
|
try {
|
|
666
825
|
const text = typeof value === "string" ? value : JSON.stringify(value)
|
|
667
|
-
|
|
826
|
+
if (typeof text !== "string") return "[unserializable tool output]"
|
|
827
|
+
const suffix = "…[truncated]"
|
|
828
|
+
return text.length > maxChars
|
|
829
|
+
? `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`
|
|
830
|
+
: text
|
|
668
831
|
} catch {
|
|
669
832
|
return "[unserializable tool output]"
|
|
670
833
|
}
|
|
671
834
|
}
|
|
672
835
|
|
|
836
|
+
function boundedApprovalToken(value: unknown): value is string {
|
|
837
|
+
return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
|
|
838
|
+
}
|
|
839
|
+
|
|
673
840
|
function boundedApprovalText(value: string, maxChars: number): string {
|
|
674
841
|
const text = value.trim()
|
|
675
842
|
if (text.length === 0) return "native tool"
|
|
@@ -701,3 +868,10 @@ function failedStream(error: unknown): AgentEventStream {
|
|
|
701
868
|
stream.fail(error)
|
|
702
869
|
return stream
|
|
703
870
|
}
|
|
871
|
+
|
|
872
|
+
function removeActiveTurn(
|
|
873
|
+
snapshot: Omit<AgentSessionSnapshot, "activeTurnId"> & { readonly activeTurnId?: string },
|
|
874
|
+
): Omit<AgentSessionSnapshot, "activeTurnId"> {
|
|
875
|
+
const { activeTurnId: _activeTurnId, ...withoutActiveTurn } = snapshot
|
|
876
|
+
return withoutActiveTurn
|
|
877
|
+
}
|
|
@@ -19,7 +19,11 @@ import type {
|
|
|
19
19
|
RunPlan,
|
|
20
20
|
} from "@nifrajs/agent-protocol"
|
|
21
21
|
import { parseRunPlan } from "@nifrajs/agent-protocol"
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
BoundedSubagentRunner,
|
|
24
|
+
type SubagentAbandonment,
|
|
25
|
+
type SubagentAbandonmentLedger,
|
|
26
|
+
} from "../subagents.ts"
|
|
23
27
|
import type { WorkflowContext, WorkflowStep } from "../workflows.ts"
|
|
24
28
|
import { type CatalogStep, type StepCatalog, type StepRunContext, stepVersion } from "./catalog.ts"
|
|
25
29
|
import { deriveNodeEffectKey } from "./effect-key.ts"
|
|
@@ -48,6 +52,8 @@ export interface OrchestrationLimits {
|
|
|
48
52
|
readonly maxChildren?: number
|
|
49
53
|
/** Capability authority ceiling. `undefined` grants all; otherwise a step's needs must be a subset. */
|
|
50
54
|
readonly allowedCapabilities?: readonly string[]
|
|
55
|
+
/** Refuse a subagent node while this many abandoned subagent executors still run. Default: no bound. */
|
|
56
|
+
readonly maxAbandonedSubagents?: number
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
const DEFAULT_MAX_NODES = 256
|
|
@@ -69,6 +75,10 @@ export interface CompileOptions {
|
|
|
69
75
|
onNodeEffect?(nodeId: string, key: NodeEffectKey): void
|
|
70
76
|
/** Called for each artifact ref a node produces through its port. */
|
|
71
77
|
collectArtifact?(nodeId: string, ref: ArtifactRef): void
|
|
78
|
+
/** Called when a subagent node's run returns while its executor, which ignored the abort, keeps running. */
|
|
79
|
+
onSubagentAbandoned?(nodeId: string, abandonment: SubagentAbandonment): void
|
|
80
|
+
/** Where abandoned subagent executors are counted for `maxAbandonedSubagents`. Default: one per compile. */
|
|
81
|
+
readonly subagentAbandonment?: SubagentAbandonmentLedger
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
/**
|
|
@@ -101,7 +111,11 @@ export function compileRunPlanLayers(
|
|
|
101
111
|
throw new CompileError(`plan has ${total} nodes over the ${maxNodes} ceiling`, "E_MAX_NODES")
|
|
102
112
|
|
|
103
113
|
const layers = layerTopLevel(plan.nodes)
|
|
104
|
-
|
|
114
|
+
const wired: CompileOptions =
|
|
115
|
+
options.subagentAbandonment === undefined
|
|
116
|
+
? { ...options, subagentAbandonment: { abandoned: 0 } }
|
|
117
|
+
: options
|
|
118
|
+
return layers.map((layer) => compileLayer(layer, wired, 1))
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
/** Recursive node count over the whole tree. */
|
|
@@ -365,6 +379,18 @@ async function runSubagent(
|
|
|
365
379
|
...(options.limits?.allowedCapabilities !== undefined
|
|
366
380
|
? { allowedCapabilities: options.limits.allowedCapabilities }
|
|
367
381
|
: {}),
|
|
382
|
+
...(options.limits?.maxAbandonedSubagents !== undefined
|
|
383
|
+
? { maxAbandoned: options.limits.maxAbandonedSubagents }
|
|
384
|
+
: {}),
|
|
385
|
+
...(options.subagentAbandonment !== undefined
|
|
386
|
+
? { abandonment: options.subagentAbandonment }
|
|
387
|
+
: {}),
|
|
388
|
+
...(options.onSubagentAbandoned !== undefined
|
|
389
|
+
? {
|
|
390
|
+
onAbandoned: (abandonment: SubagentAbandonment) =>
|
|
391
|
+
options.onSubagentAbandoned?.(node.id, abandonment),
|
|
392
|
+
}
|
|
393
|
+
: {}),
|
|
368
394
|
})
|
|
369
395
|
const spec = await step.spec!(context)
|
|
370
396
|
const result = await runner.run(spec)
|
|
@@ -51,8 +51,9 @@ async function sha256Bytes(text: string): Promise<Uint8Array> {
|
|
|
51
51
|
/**
|
|
52
52
|
* The identity of one record for the terminal digest: node outcome only. `seq`, `durationMs`, and
|
|
53
53
|
* `runId` are excluded so two runs of the same plan (or the same plan under a different parallel
|
|
54
|
-
* schedule) produce an identical digest - the deterministic-eval anchor.
|
|
55
|
-
* record
|
|
54
|
+
* schedule) produce an identical digest - the deterministic-eval anchor. A retried node emits the same
|
|
55
|
+
* record more than once, so the accumulator adds hashes (mod 2^256) rather than XORing them: repeats
|
|
56
|
+
* count instead of cancelling in pairs.
|
|
56
57
|
*/
|
|
57
58
|
function digestKey(record: RunEvidence): string {
|
|
58
59
|
return canonicalJson({
|
|
@@ -97,7 +98,12 @@ class EvidenceAccumulator {
|
|
|
97
98
|
if (record.artifacts !== undefined)
|
|
98
99
|
for (const ref of record.artifacts) this.artifactRefs.push(ref)
|
|
99
100
|
const hash = await sha256Bytes(digestKey(record))
|
|
100
|
-
|
|
101
|
+
let carry = 0
|
|
102
|
+
for (let i = 31; i >= 0; i--) {
|
|
103
|
+
const sum = (this.acc[i] as number) + (hash[i] as number) + carry
|
|
104
|
+
this.acc[i] = sum & 0xff
|
|
105
|
+
carry = sum >> 8
|
|
106
|
+
}
|
|
101
107
|
this.pushLive(record)
|
|
102
108
|
}
|
|
103
109
|
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
RunPlan,
|
|
23
23
|
} from "@nifrajs/agent-protocol"
|
|
24
24
|
import { parseRunPlan, RUN_PLAN_VERSION } from "@nifrajs/agent-protocol"
|
|
25
|
+
import type { SubagentAbandonment, SubagentAbandonmentLedger } from "../subagents.ts"
|
|
25
26
|
import type { WorkflowStep } from "../workflows.ts"
|
|
26
27
|
import { type WorkflowEvent, WorkflowRunner } from "../workflows.ts"
|
|
27
28
|
import { noopArtifactPort } from "./artifact-port.ts"
|
|
@@ -92,6 +93,11 @@ export interface OrchestrationHostOptions {
|
|
|
92
93
|
* approval requirement. A descriptor, plan, model, or extension cannot override it.
|
|
93
94
|
*/
|
|
94
95
|
readonly policy?: HostPolicy
|
|
96
|
+
/**
|
|
97
|
+
* Called when a subagent node's run returns while its executor, which ignored the abort, keeps
|
|
98
|
+
* running. `limits.maxAbandonedSubagents` bounds how many may run at once across all runs.
|
|
99
|
+
*/
|
|
100
|
+
onSubagentAbandoned?(runId: string, nodeId: string, abandonment: SubagentAbandonment): void
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
const DEFAULT_MAX_STEPS = 100_000
|
|
@@ -128,6 +134,8 @@ export class OrchestrationHost {
|
|
|
128
134
|
private readonly vectors = new ChildVectorTracker()
|
|
129
135
|
private readonly runs = new Map<string, RunRecord>()
|
|
130
136
|
private counter = 0
|
|
137
|
+
private readonly subagentAbandonment: SubagentAbandonmentLedger = { abandoned: 0 }
|
|
138
|
+
private readonly onSubagentAbandoned: OrchestrationHostOptions["onSubagentAbandoned"]
|
|
131
139
|
|
|
132
140
|
constructor(options: OrchestrationHostOptions) {
|
|
133
141
|
this.catalog = options.catalog
|
|
@@ -135,6 +143,12 @@ export class OrchestrationHost {
|
|
|
135
143
|
this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS
|
|
136
144
|
this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH
|
|
137
145
|
this.policy = options.policy
|
|
146
|
+
this.onSubagentAbandoned = options.onSubagentAbandoned
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Subagent executors abandoned by any run of this host that are still running. */
|
|
150
|
+
get abandonedSubagents(): number {
|
|
151
|
+
return this.subagentAbandonment.abandoned
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
/**
|
|
@@ -186,6 +200,9 @@ export class OrchestrationHost {
|
|
|
186
200
|
planDigest,
|
|
187
201
|
artifactPort,
|
|
188
202
|
...(this.limits !== undefined ? { limits: this.limits } : {}),
|
|
203
|
+
subagentAbandonment: this.subagentAbandonment,
|
|
204
|
+
onSubagentAbandoned: (nodeId, abandonment) =>
|
|
205
|
+
this.onSubagentAbandoned?.(runId, nodeId, abandonment),
|
|
189
206
|
onNodeEffect: (nodeId, key) => effectKeys.set(nodeId, key),
|
|
190
207
|
collectArtifact: (nodeId, ref) => {
|
|
191
208
|
const list = artifacts.get(nodeId)
|