@nifrajs/coding-agent 3.3.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.
Files changed (49) hide show
  1. package/dist/context.js +29 -1
  2. package/dist/context.js.map +1 -1
  3. package/dist/diff.js +38 -2
  4. package/dist/diff.js.map +1 -1
  5. package/dist/host.d.ts.map +1 -1
  6. package/dist/host.js +146 -23
  7. package/dist/host.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/isolated.js +37 -2
  13. package/dist/isolated.js.map +1 -1
  14. package/dist/native.d.ts +5 -0
  15. package/dist/native.d.ts.map +1 -1
  16. package/dist/native.js +138 -34
  17. package/dist/native.js.map +1 -1
  18. package/dist/replay.d.ts +4 -0
  19. package/dist/replay.d.ts.map +1 -1
  20. package/dist/replay.js +189 -24
  21. package/dist/replay.js.map +1 -1
  22. package/dist/rpc.d.ts +10 -0
  23. package/dist/rpc.d.ts.map +1 -1
  24. package/dist/rpc.js +208 -29
  25. package/dist/rpc.js.map +1 -1
  26. package/dist/sessions.d.ts +14 -2
  27. package/dist/sessions.d.ts.map +1 -1
  28. package/dist/sessions.js +253 -59
  29. package/dist/sessions.js.map +1 -1
  30. package/dist/verification.d.ts +23 -0
  31. package/dist/verification.d.ts.map +1 -1
  32. package/dist/verification.js +113 -1
  33. package/dist/verification.js.map +1 -1
  34. package/dist/workflows.d.ts +1 -0
  35. package/dist/workflows.d.ts.map +1 -1
  36. package/dist/workflows.js +43 -21
  37. package/dist/workflows.js.map +1 -1
  38. package/package.json +6 -5
  39. package/src/context.ts +32 -1
  40. package/src/diff.ts +40 -2
  41. package/src/host.ts +146 -23
  42. package/src/index.ts +5 -0
  43. package/src/isolated.ts +39 -2
  44. package/src/native.ts +173 -32
  45. package/src/replay.ts +206 -25
  46. package/src/rpc.ts +261 -33
  47. package/src/sessions.ts +271 -56
  48. package/src/verification.ts +140 -1
  49. package/src/workflows.ts +37 -25
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"
@@ -148,6 +149,7 @@ interface NativePendingApproval {
148
149
  readonly resolve: (resolution: NativeApprovalResolution) => void
149
150
  readonly timer: ReturnType<typeof setTimeout>
150
151
  readonly onAbort: () => void
152
+ readonly signal: AbortSignal
151
153
  }
152
154
 
153
155
  interface NativeSession {
@@ -157,6 +159,8 @@ interface NativeSession {
157
159
  controller: AbortController
158
160
  snapshot: AgentSessionSnapshot
159
161
  active: AgentEventStream | undefined
162
+ turnId: string | undefined
163
+ turnAbortCleanup: (() => void) | undefined
160
164
  seq: number
161
165
  extensionRevision: number
162
166
  closed: boolean
@@ -205,6 +209,20 @@ export class NifraBackend implements AgentBackend {
205
209
  }
206
210
 
207
211
  async createSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
212
+ if (
213
+ typeof input.cwd !== "string" ||
214
+ input.cwd.length === 0 ||
215
+ input.cwd.length > 4_096 ||
216
+ input.cwd.includes("\0")
217
+ )
218
+ throw new TypeError("nifra backend: cwd must be a bounded non-empty path")
219
+ if (
220
+ input.capabilities !== undefined &&
221
+ (!Array.isArray(input.capabilities) ||
222
+ input.capabilities.length > 256 ||
223
+ !input.capabilities.every((value) => boundedApprovalToken(value)))
224
+ )
225
+ throw new TypeError("nifra backend: capabilities must be bounded tokens")
208
226
  const id = input.sessionId ?? crypto.randomUUID()
209
227
  if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(id))
210
228
  throw new TypeError("nifra backend: sessionId must be a bounded token")
@@ -228,6 +246,8 @@ export class NifraBackend implements AgentBackend {
228
246
  controller: new AbortController(),
229
247
  snapshot,
230
248
  active: undefined,
249
+ turnId: undefined,
250
+ turnAbortCleanup: undefined,
231
251
  seq: 0,
232
252
  extensionRevision: 0,
233
253
  closed: false,
@@ -251,30 +271,38 @@ export class NifraBackend implements AgentBackend {
251
271
  session.cancelled = false
252
272
  session.controller = new AbortController()
253
273
  const signal = session.controller.signal
274
+ const turnId = crypto.randomUUID()
275
+ session.turnId = turnId
254
276
  if (input.signal !== undefined) {
255
277
  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 },
278
+ const onAbort = (): void => {
279
+ // Capture the turn identity. A request signal must never abort a later turn that reused
280
+ // this session after the original stream settled.
281
+ if (
282
+ session.active !== stream ||
283
+ session.turnId !== turnId ||
284
+ session.controller.signal !== signal
262
285
  )
286
+ return
287
+ void this.cancelTurn(session, stream, turnId, "cancelled")
288
+ }
289
+ if (externalSignal.aborted) onAbort()
290
+ else {
291
+ externalSignal.addEventListener("abort", onAbort, { once: true })
292
+ session.turnAbortCleanup = () => externalSignal.removeEventListener("abort", onAbort)
293
+ // Abort can race the registration between the check above and addEventListener.
294
+ if (externalSignal.aborted) onAbort()
295
+ }
263
296
  }
264
- void this.run(session, input.message, stream, signal)
297
+ void this.run(session, input.message, stream, signal, turnId)
265
298
  return stream
266
299
  }
267
300
 
268
301
  async cancel(sessionId: string, reason = "cancelled"): Promise<void> {
269
302
  const session = this.requireSession(sessionId)
270
303
  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
304
+ if (session.active === undefined) return
305
+ await this.cancelTurn(session, session.active, session.turnId, reason)
278
306
  }
279
307
 
280
308
  async snapshot(sessionId: string): Promise<AgentSessionSnapshot> {
@@ -326,9 +354,12 @@ export class NifraBackend implements AgentBackend {
326
354
  if (session.closed) return
327
355
  session.closed = true
328
356
  session.cancelled = true
357
+ session.turnAbortCleanup?.()
358
+ session.turnAbortCleanup = undefined
329
359
  session.controller.abort("closed")
330
360
  session.active?.complete()
331
361
  session.active = undefined
362
+ session.turnId = undefined
332
363
  this.sessions.delete(sessionId)
333
364
  }
334
365
 
@@ -337,14 +368,18 @@ export class NifraBackend implements AgentBackend {
337
368
  message: string,
338
369
  stream: AgentEventStream,
339
370
  signal: AbortSignal,
371
+ turnId: string,
340
372
  ): Promise<void> {
341
- const turnId = crypto.randomUUID()
373
+ if (!this.isCurrentTurn(session, stream, turnId, signal)) {
374
+ stream.complete()
375
+ return
376
+ }
342
377
  this.update(session, "running", turnId)
343
378
  this.emit(session, { type: "turn.started", turnId, prompt: message })
344
379
  session.messages.push({ role: "user", text: message })
345
380
  try {
346
381
  for (let step = 0; step < this.options.maxSteps; step++) {
347
- if (signal.aborted) throw new Error("native turn cancelled")
382
+ this.assertCurrentTurn(session, stream, turnId, signal)
348
383
  const raw = this.options.model.complete({
349
384
  sessionId: session.id,
350
385
  cwd: session.cwd,
@@ -358,10 +393,12 @@ export class NifraBackend implements AgentBackend {
358
393
  ),
359
394
  signal,
360
395
  })
361
- const response = await this.consumeModel(raw, session, turnId, signal)
396
+ const response = await this.consumeModel(raw, session, stream, turnId, signal)
397
+ this.assertCurrentTurn(session, stream, turnId, signal)
362
398
  if (response.type === "text") {
363
- session.messages.push({ role: "assistant", text: response.text })
364
- this.emit(session, { type: "assistant.message", turnId, text: response.text })
399
+ const text = boundedText(response.text, this.options.maxMessageChars)
400
+ session.messages.push({ role: "assistant", text })
401
+ this.emit(session, { type: "assistant.message", turnId, text })
365
402
  this.finish(session, stream)
366
403
  return
367
404
  }
@@ -391,6 +428,7 @@ export class NifraBackend implements AgentBackend {
391
428
  typeof tool.requiresApproval === "function"
392
429
  ? await tool.requiresApproval(response.input)
393
430
  : tool.requiresApproval === true
431
+ this.assertCurrentTurn(session, stream, turnId, signal)
394
432
  if (needsApproval) {
395
433
  const resolution = await this.requestApproval(
396
434
  session,
@@ -400,6 +438,7 @@ export class NifraBackend implements AgentBackend {
400
438
  response.input,
401
439
  signal,
402
440
  )
441
+ this.assertCurrentTurn(session, stream, turnId, signal)
403
442
  if (!resolution.approved) {
404
443
  const code = resolution.errorCode ?? "APPROVAL_DENIED"
405
444
  this.emit(session, {
@@ -416,6 +455,7 @@ export class NifraBackend implements AgentBackend {
416
455
  }
417
456
  try {
418
457
  const output = await execute(response.input, { cwd: session.cwd, signal })
458
+ this.assertCurrentTurn(session, stream, turnId, signal)
419
459
  const text = boundedText(output)
420
460
  session.messages.push({ role: "tool", name: tool.name, text })
421
461
  this.emit(session, {
@@ -427,7 +467,11 @@ export class NifraBackend implements AgentBackend {
427
467
  output: text,
428
468
  })
429
469
  } catch (error) {
430
- const message = error instanceof Error ? error.message : String(error)
470
+ if (signal.aborted || !this.isCurrentTurn(session, stream, turnId, signal)) throw error
471
+ const message = boundedText(
472
+ error instanceof Error ? error.message : error,
473
+ this.options.maxMessageChars,
474
+ )
431
475
  session.messages.push({ role: "tool", name: tool.name, text: message })
432
476
  this.emit(session, {
433
477
  type: "tool.completed",
@@ -441,50 +485,57 @@ export class NifraBackend implements AgentBackend {
441
485
  }
442
486
  throw new Error("native turn exceeded maxSteps")
443
487
  } catch (error) {
444
- if (session.closed || session.cancelled) {
488
+ if (!this.isCurrentTurn(session, stream, turnId, signal)) {
445
489
  stream.complete()
446
- if (session.active === stream) session.active = undefined
447
490
  return
448
491
  }
449
- this.update(session, signal.aborted ? "stopped" : "failed")
492
+ if (signal.aborted || session.cancelled) {
493
+ await this.cancelTurn(session, stream, turnId, "cancelled")
494
+ return
495
+ }
496
+ this.update(session, "failed")
450
497
  this.emit(session, {
451
498
  type: "session.failed",
452
499
  error: agentError(
453
- signal.aborted ? "TURN_CANCELLED" : "NATIVE_TURN_FAILED",
454
- error instanceof Error ? error.message : String(error),
500
+ "NATIVE_TURN_FAILED",
501
+ boundedText(error instanceof Error ? error.message : error, 4_096),
455
502
  ),
456
503
  recoverable: true,
457
504
  })
458
505
  stream.complete()
459
- session.active = undefined
506
+ this.clearTurn(session, stream, turnId)
460
507
  }
461
508
  }
462
509
 
463
510
  private async consumeModel(
464
511
  raw: ReturnType<NativeModelPort["complete"]>,
465
512
  session: NativeSession,
513
+ stream: AgentEventStream,
466
514
  turnId: string,
467
515
  signal: AbortSignal,
468
516
  ): Promise<NativeModelResponse> {
469
517
  if (isAsyncIterable<NativeModelChunk>(raw)) {
470
518
  let response: NativeModelResponse | undefined
471
519
  for await (const chunk of raw) {
472
- if (signal.aborted) throw new Error("native turn cancelled")
520
+ this.assertCurrentTurn(session, stream, turnId, signal)
473
521
  if (chunk.type === "text_delta")
474
522
  this.emit(session, { type: "assistant.delta", turnId, text: chunk.text })
475
523
  else response = chunk.response
476
524
  }
477
525
  if (response === undefined) throw new Error("native model stream ended without a response")
526
+ this.assertCurrentTurn(session, stream, turnId, signal)
478
527
  return response
479
528
  }
480
- return await raw
529
+ const response = await raw
530
+ this.assertCurrentTurn(session, stream, turnId, signal)
531
+ return response
481
532
  }
482
533
 
483
534
  private finish(session: NativeSession, stream: AgentEventStream): void {
484
535
  this.update(session, "idle")
485
536
  this.emit(session, { type: "session.completed", snapshot: session.snapshot })
486
537
  stream.complete()
487
- session.active = undefined
538
+ this.clearTurn(session, stream, session.turnId)
488
539
  }
489
540
 
490
541
  private requestApproval(
@@ -526,6 +577,7 @@ export class NifraBackend implements AgentBackend {
526
577
  resolve,
527
578
  timer,
528
579
  onAbort,
580
+ signal,
529
581
  }
530
582
  this.pendingApprovals.set(approvalId, pending)
531
583
  signal.addEventListener("abort", onAbort, { once: true })
@@ -595,7 +647,7 @@ export class NifraBackend implements AgentBackend {
595
647
  const pending = this.pendingApprovals.get(approvalId)
596
648
  if (pending === undefined || pending.sessionId !== session.id) return undefined
597
649
  clearTimeout(pending.timer)
598
- session.controller.signal.removeEventListener("abort", pending.onAbort)
650
+ pending.signal.removeEventListener("abort", pending.onAbort)
599
651
  this.pendingApprovals.delete(approvalId)
600
652
  const boundedReason = reason === undefined ? undefined : boundedApprovalText(reason, 512)
601
653
  const resolution: NativeApprovalResolution = Object.freeze({
@@ -614,6 +666,76 @@ export class NifraBackend implements AgentBackend {
614
666
  return resolution
615
667
  }
616
668
 
669
+ private async cancelTurn(
670
+ session: NativeSession,
671
+ expectedStream: AgentEventStream | undefined,
672
+ expectedTurnId: string | undefined,
673
+ reason: string,
674
+ ): Promise<void> {
675
+ if (
676
+ expectedStream !== undefined &&
677
+ (session.active !== expectedStream || session.turnId !== expectedTurnId)
678
+ )
679
+ return
680
+ const stream = session.active
681
+ const turnId = session.turnId
682
+ const stopReason = boundedText(reason, 512) || "cancelled"
683
+ session.cancelled = true
684
+ this.clearTurnSignal(session)
685
+ try {
686
+ session.controller.abort(stopReason)
687
+ } catch {
688
+ // AbortController.abort is normally infallible; lifecycle cleanup must still complete if a
689
+ // host supplies an unusual controller implementation.
690
+ }
691
+ this.update(session, "stopped")
692
+ this.emit(session, { type: "session.stopped", reason: stopReason })
693
+ stream?.complete()
694
+ this.clearTurn(session, stream, turnId)
695
+ }
696
+
697
+ private clearTurnSignal(session: NativeSession): void {
698
+ session.turnAbortCleanup?.()
699
+ session.turnAbortCleanup = undefined
700
+ }
701
+
702
+ private clearTurn(
703
+ session: NativeSession,
704
+ stream: AgentEventStream | undefined,
705
+ turnId: string | undefined,
706
+ ): void {
707
+ if (stream !== undefined && session.active !== stream) return
708
+ if (turnId !== undefined && session.turnId !== turnId) return
709
+ this.clearTurnSignal(session)
710
+ if (stream === undefined || session.active === stream) session.active = undefined
711
+ if (turnId === undefined || session.turnId === turnId) session.turnId = undefined
712
+ }
713
+
714
+ private isCurrentTurn(
715
+ session: NativeSession,
716
+ stream: AgentEventStream,
717
+ turnId: string,
718
+ signal: AbortSignal,
719
+ ): boolean {
720
+ return (
721
+ !session.closed &&
722
+ !session.cancelled &&
723
+ session.active === stream &&
724
+ session.turnId === turnId &&
725
+ session.controller.signal === signal
726
+ )
727
+ }
728
+
729
+ private assertCurrentTurn(
730
+ session: NativeSession,
731
+ stream: AgentEventStream,
732
+ turnId: string,
733
+ signal: AbortSignal,
734
+ ): void {
735
+ if (signal.aborted || !this.isCurrentTurn(session, stream, turnId, signal))
736
+ throw new Error("native turn cancelled")
737
+ }
738
+
617
739
  private emit(
618
740
  session: NativeSession,
619
741
  payload: import("@nifrajs/agent-protocol").AgentEventPayload,
@@ -625,6 +747,8 @@ export class NifraBackend implements AgentBackend {
625
747
  at: this.now(),
626
748
  ...payload,
627
749
  }) as AgentEvent
750
+ if (!isAgentEvent(event))
751
+ throw new Error("nifra backend emitted an event outside protocol bounds")
628
752
  session.snapshot = Object.freeze({
629
753
  ...session.snapshot,
630
754
  lastSeq: event.seq,
@@ -640,7 +764,9 @@ export class NifraBackend implements AgentBackend {
640
764
  ): void {
641
765
  const next = { ...session.snapshot, status, updatedAt: this.now(), lastSeq: session.seq }
642
766
  session.snapshot = Object.freeze(
643
- status === "running" && turnId !== undefined ? { ...next, activeTurnId: turnId } : next,
767
+ status === "running" && turnId !== undefined
768
+ ? { ...next, activeTurnId: turnId }
769
+ : removeActiveTurn(next),
644
770
  )
645
771
  if (session.active !== undefined)
646
772
  this.emit(session, { type: "session.updated", snapshot: session.snapshot })
@@ -664,12 +790,20 @@ function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
664
790
  function boundedText(value: unknown, maxChars = 64 * 1024): string {
665
791
  try {
666
792
  const text = typeof value === "string" ? value : JSON.stringify(value)
667
- return text.length > maxChars ? `${text.slice(0, maxChars)}…[truncated]` : text
793
+ if (typeof text !== "string") return "[unserializable tool output]"
794
+ const suffix = "…[truncated]"
795
+ return text.length > maxChars
796
+ ? `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`
797
+ : text
668
798
  } catch {
669
799
  return "[unserializable tool output]"
670
800
  }
671
801
  }
672
802
 
803
+ function boundedApprovalToken(value: unknown): value is string {
804
+ return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
805
+ }
806
+
673
807
  function boundedApprovalText(value: string, maxChars: number): string {
674
808
  const text = value.trim()
675
809
  if (text.length === 0) return "native tool"
@@ -701,3 +835,10 @@ function failedStream(error: unknown): AgentEventStream {
701
835
  stream.fail(error)
702
836
  return stream
703
837
  }
838
+
839
+ function removeActiveTurn(
840
+ snapshot: Omit<AgentSessionSnapshot, "activeTurnId"> & { readonly activeTurnId?: string },
841
+ ): Omit<AgentSessionSnapshot, "activeTurnId"> {
842
+ const { activeTurnId: _activeTurnId, ...withoutActiveTurn } = snapshot
843
+ return withoutActiveTurn
844
+ }