@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.
Files changed (88) hide show
  1. package/README.md +11 -4
  2. package/SECURITY.md +2 -1
  3. package/dist/approvals.d.ts +14 -2
  4. package/dist/approvals.d.ts.map +1 -1
  5. package/dist/approvals.js +51 -43
  6. package/dist/approvals.js.map +1 -1
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +11 -2
  10. package/dist/cli.js.map +1 -1
  11. package/dist/context.js +29 -1
  12. package/dist/context.js.map +1 -1
  13. package/dist/diff.js +38 -2
  14. package/dist/diff.js.map +1 -1
  15. package/dist/handoffs.d.ts.map +1 -1
  16. package/dist/handoffs.js +12 -4
  17. package/dist/handoffs.js.map +1 -1
  18. package/dist/host.d.ts.map +1 -1
  19. package/dist/host.js +146 -23
  20. package/dist/host.js.map +1 -1
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/isolated.js +37 -2
  26. package/dist/isolated.js.map +1 -1
  27. package/dist/native.d.ts +12 -0
  28. package/dist/native.d.ts.map +1 -1
  29. package/dist/native.js +165 -40
  30. package/dist/native.js.map +1 -1
  31. package/dist/orchestration/compile.d.ts +7 -0
  32. package/dist/orchestration/compile.d.ts.map +1 -1
  33. package/dist/orchestration/compile.js +16 -2
  34. package/dist/orchestration/compile.js.map +1 -1
  35. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  36. package/dist/orchestration/evidence-store.js +9 -4
  37. package/dist/orchestration/evidence-store.js.map +1 -1
  38. package/dist/orchestration/host.d.ts +10 -0
  39. package/dist/orchestration/host.d.ts.map +1 -1
  40. package/dist/orchestration/host.js +9 -0
  41. package/dist/orchestration/host.js.map +1 -1
  42. package/dist/replay.d.ts +4 -0
  43. package/dist/replay.d.ts.map +1 -1
  44. package/dist/replay.js +189 -24
  45. package/dist/replay.js.map +1 -1
  46. package/dist/rpc.d.ts +10 -0
  47. package/dist/rpc.d.ts.map +1 -1
  48. package/dist/rpc.js +209 -29
  49. package/dist/rpc.js.map +1 -1
  50. package/dist/session-migration.d.ts.map +1 -1
  51. package/dist/session-migration.js +3 -2
  52. package/dist/session-migration.js.map +1 -1
  53. package/dist/sessions.d.ts +20 -2
  54. package/dist/sessions.d.ts.map +1 -1
  55. package/dist/sessions.js +269 -60
  56. package/dist/sessions.js.map +1 -1
  57. package/dist/subagents.d.ts +30 -0
  58. package/dist/subagents.d.ts.map +1 -1
  59. package/dist/subagents.js +118 -15
  60. package/dist/subagents.js.map +1 -1
  61. package/dist/verification.d.ts +28 -1
  62. package/dist/verification.d.ts.map +1 -1
  63. package/dist/verification.js +119 -3
  64. package/dist/verification.js.map +1 -1
  65. package/dist/workflows.d.ts +1 -0
  66. package/dist/workflows.d.ts.map +1 -1
  67. package/dist/workflows.js +55 -23
  68. package/dist/workflows.js.map +1 -1
  69. package/package.json +9 -5
  70. package/src/approvals.ts +62 -45
  71. package/src/cli.ts +11 -2
  72. package/src/context.ts +32 -1
  73. package/src/diff.ts +40 -2
  74. package/src/handoffs.ts +20 -8
  75. package/src/host.ts +146 -23
  76. package/src/index.ts +7 -0
  77. package/src/isolated.ts +39 -2
  78. package/src/native.ts +213 -39
  79. package/src/orchestration/compile.ts +28 -2
  80. package/src/orchestration/evidence-store.ts +9 -3
  81. package/src/orchestration/host.ts +17 -0
  82. package/src/replay.ts +206 -25
  83. package/src/rpc.ts +262 -33
  84. package/src/session-migration.ts +3 -2
  85. package/src/sessions.ts +288 -57
  86. package/src/subagents.ts +147 -17
  87. package/src/verification.ts +146 -3
  88. 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<Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars">> &
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
- if (externalSignal.aborted) session.controller.abort(externalSignal.reason)
257
- else
258
- externalSignal.addEventListener(
259
- "abort",
260
- () => session.controller.abort(externalSignal.reason),
261
- { once: true },
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
- const stream = session.active
272
- session.cancelled = true
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
- const turnId = crypto.randomUUID()
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
- session.messages.push({ role: "user", text: message })
391
+ this.remember(session, { role: "user", text: message })
345
392
  try {
346
393
  for (let step = 0; step < this.options.maxSteps; step++) {
347
- if (signal.aborted) throw new Error("native turn cancelled")
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.map((item) => Object.freeze({ ...item }))),
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
- session.messages.push({ role: "assistant", text: response.text })
364
- this.emit(session, { type: "assistant.message", turnId, text: response.text })
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
- session.messages.push({ role: "tool", name: response.name, text: "unknown tool" })
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
- session.messages.push({ role: "tool", name: tool.name, text: code })
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
- session.messages.push({ role: "tool", name: tool.name, text })
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
- const message = error instanceof Error ? error.message : String(error)
431
- session.messages.push({ role: "tool", name: tool.name, text: message })
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.closed || session.cancelled) {
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
- this.update(session, signal.aborted ? "stopped" : "failed")
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
- signal.aborted ? "TURN_CANCELLED" : "NATIVE_TURN_FAILED",
454
- error instanceof Error ? error.message : String(error),
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.active = undefined
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
- if (signal.aborted) throw new Error("native turn cancelled")
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
- return await raw
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.active = undefined
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
- session.controller.signal.removeEventListener("abort", pending.onAbort)
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 ? { ...next, activeTurnId: turnId } : next,
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
- return text.length > maxChars ? `${text.slice(0, maxChars)}…[truncated]` : text
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 { BoundedSubagentRunner } from "../subagents.ts"
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
- return layers.map((layer) => compileLayer(layer, options, 1))
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. Each node emits at most one
55
- * record per status, so XOR accumulation never cancels a distinct outcome.
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
- for (let i = 0; i < 32; i++) this.acc[i] = (this.acc[i] as number) ^ (hash[i] as number)
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)