@nifrajs/coding-agent 4.0.0 → 5.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 (99) hide show
  1. package/README.md +14 -2
  2. package/dist/cli.d.ts +13 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +120 -100
  5. package/dist/cli.js.map +1 -1
  6. package/dist/context.d.ts.map +1 -1
  7. package/dist/context.js +9 -21
  8. package/dist/context.js.map +1 -1
  9. package/dist/diff.d.ts.map +1 -1
  10. package/dist/diff.js +8 -21
  11. package/dist/diff.js.map +1 -1
  12. package/dist/handoffs.d.ts +5 -0
  13. package/dist/handoffs.d.ts.map +1 -1
  14. package/dist/handoffs.js +27 -0
  15. package/dist/handoffs.js.map +1 -1
  16. package/dist/host.d.ts +15 -1
  17. package/dist/host.d.ts.map +1 -1
  18. package/dist/host.js +117 -13
  19. package/dist/host.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/native.d.ts +22 -0
  25. package/dist/native.d.ts.map +1 -1
  26. package/dist/native.js +75 -18
  27. package/dist/native.js.map +1 -1
  28. package/dist/orchestration/dispatch.d.ts +5 -1
  29. package/dist/orchestration/dispatch.d.ts.map +1 -1
  30. package/dist/orchestration/dispatch.js +1 -1
  31. package/dist/orchestration/dispatch.js.map +1 -1
  32. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  33. package/dist/orchestration/evidence-store.js +5 -6
  34. package/dist/orchestration/evidence-store.js.map +1 -1
  35. package/dist/orchestration/host.d.ts +14 -0
  36. package/dist/orchestration/host.d.ts.map +1 -1
  37. package/dist/orchestration/host.js +26 -0
  38. package/dist/orchestration/host.js.map +1 -1
  39. package/dist/orchestration/index.d.ts +1 -1
  40. package/dist/orchestration/index.d.ts.map +1 -1
  41. package/dist/orchestration/index.js.map +1 -1
  42. package/dist/orchestration/recovery.d.ts +5 -1
  43. package/dist/orchestration/recovery.d.ts.map +1 -1
  44. package/dist/orchestration/recovery.js +5 -3
  45. package/dist/orchestration/recovery.js.map +1 -1
  46. package/dist/process.d.ts +22 -1
  47. package/dist/process.d.ts.map +1 -1
  48. package/dist/process.js +63 -1
  49. package/dist/process.js.map +1 -1
  50. package/dist/rpc.d.ts +23 -0
  51. package/dist/rpc.d.ts.map +1 -1
  52. package/dist/rpc.js +204 -46
  53. package/dist/rpc.js.map +1 -1
  54. package/dist/session-migration.d.ts.map +1 -1
  55. package/dist/session-migration.js +30 -6
  56. package/dist/session-migration.js.map +1 -1
  57. package/dist/sessions.d.ts +13 -2
  58. package/dist/sessions.d.ts.map +1 -1
  59. package/dist/sessions.js +104 -24
  60. package/dist/sessions.js.map +1 -1
  61. package/dist/subagents.d.ts.map +1 -1
  62. package/dist/subagents.js +13 -2
  63. package/dist/subagents.js.map +1 -1
  64. package/dist/turn-replay.d.ts +40 -0
  65. package/dist/turn-replay.d.ts.map +1 -0
  66. package/dist/turn-replay.js +136 -0
  67. package/dist/turn-replay.js.map +1 -0
  68. package/dist/ui.js +84 -30
  69. package/dist/ui.js.map +1 -1
  70. package/dist/verification.d.ts +7 -0
  71. package/dist/verification.d.ts.map +1 -1
  72. package/dist/verification.js +66 -63
  73. package/dist/verification.js.map +1 -1
  74. package/dist/workflows.d.ts +5 -0
  75. package/dist/workflows.d.ts.map +1 -1
  76. package/dist/workflows.js +2 -1
  77. package/dist/workflows.js.map +1 -1
  78. package/package.json +6 -6
  79. package/src/cli.ts +125 -93
  80. package/src/context.ts +12 -20
  81. package/src/diff.ts +11 -23
  82. package/src/handoffs.ts +27 -0
  83. package/src/host.ts +134 -12
  84. package/src/index.ts +1 -0
  85. package/src/native.ts +118 -21
  86. package/src/orchestration/dispatch.ts +6 -2
  87. package/src/orchestration/evidence-store.ts +5 -6
  88. package/src/orchestration/host.ts +44 -0
  89. package/src/orchestration/index.ts +1 -0
  90. package/src/orchestration/recovery.ts +14 -8
  91. package/src/process.ts +77 -0
  92. package/src/rpc.ts +261 -49
  93. package/src/session-migration.ts +29 -5
  94. package/src/sessions.ts +115 -29
  95. package/src/subagents.ts +11 -2
  96. package/src/turn-replay.ts +151 -0
  97. package/src/ui.ts +90 -32
  98. package/src/verification.ts +74 -60
  99. package/src/workflows.ts +7 -1
package/src/host.ts CHANGED
@@ -4,11 +4,14 @@ import {
4
4
  type AgentBackend,
5
5
  type AgentEvent,
6
6
  type AgentEventPayload,
7
+ type AgentExtensionEvent,
7
8
  type AgentSessionSnapshot,
8
9
  agentError,
10
+ agentEventProblem,
9
11
  type CreateSessionInput,
10
12
  type ForkSessionResult,
11
13
  isAgentEvent,
14
+ isAgentExtensionEventType,
12
15
  type ReloadResult,
13
16
  } from "@nifrajs/agent-protocol"
14
17
  import {
@@ -50,19 +53,31 @@ export interface CodingAgentHostOptions {
50
53
  >
51
54
  /** Retain bounded exception stacks for trusted local diagnostics. */
52
55
  readonly exposeErrorStacks?: boolean
56
+ /**
57
+ * Checks for a backend's extension events (`x-<vendor>.<name>`), by type. An event whose check
58
+ * returns false or throws fails the turn like a malformed protocol event; a type with no check
59
+ * passes through as long as it stays inside the protocol's bounds.
60
+ */
61
+ readonly extensionEvents?: Readonly<Record<string, (event: AgentExtensionEvent) => boolean>>
53
62
  }
54
63
 
55
64
  /** Small lifecycle host shared by the CLI, RPC server, and future Workbench. */
56
65
  export class CodingAgentHost {
57
66
  readonly backend: AgentBackend
58
67
  private session: AgentSessionSnapshot | undefined
68
+ private starting: Promise<AgentSessionSnapshot> | undefined
69
+ // Bumped by every start and stop: a start that finds it changed was superseded by a stop.
70
+ private lifecycle = 0
59
71
  private readonly sessionStore: SessionStore | undefined
60
72
  private readonly context: ContextWindow
61
73
  private readonly extensions: ExtensionHost | undefined
62
74
  readonly approvals: ApprovalManager
75
+ /** Approvals {@link resolveApproval} is settling; it forwards those itself, once the broker accepts. */
76
+ private readonly resolving = new Set<string>()
63
77
  private readonly verifyAfterTurn: readonly ("check" | "assure" | "test")[]
64
78
  private readonly maxRepairAttempts: number
65
79
  private readonly exposeErrorStacks: boolean
80
+ private readonly extensionChecks: ReadonlyMap<string, (event: AgentExtensionEvent) => boolean>
66
81
  private readonly verification:
67
82
  | Pick<VerificationOptions, "command" | "commandArgs" | "timeoutMs" | "maxOutputBytes" | "env">
68
83
  | undefined
@@ -72,7 +87,28 @@ export class CodingAgentHost {
72
87
  this.sessionStore = options.sessionStore
73
88
  this.context = new ContextWindow(options.contextWindow)
74
89
  this.extensions = options.extensions
75
- this.approvals = new ApprovalManager(options.approvals)
90
+ const onResolved = options.approvals?.onResolved
91
+ this.approvals = new ApprovalManager({
92
+ ...options.approvals,
93
+ // A denial the broker reaches on its own (timeout, expiry, close) goes to the backend too, or a
94
+ // backend holding the approval open longer than the host could still act on it.
95
+ onResolved: async (decision) => {
96
+ const sessionId = this.session?.id
97
+ if (!decision.approved && !this.resolving.has(decision.approvalId) && sessionId) {
98
+ try {
99
+ await this.backend.resolveApproval?.(
100
+ sessionId,
101
+ decision.approvalId,
102
+ false,
103
+ decision.reason,
104
+ )
105
+ } catch {
106
+ // The broker's denial stands whether or not the backend still knew the approval.
107
+ }
108
+ }
109
+ await onResolved?.(decision)
110
+ },
111
+ })
76
112
  this.verifyAfterTurn = Object.freeze([...(options.verifyAfterTurn ?? [])])
77
113
  this.maxRepairAttempts = options.maxRepairAttempts ?? 2
78
114
  if (
@@ -83,11 +119,39 @@ export class CodingAgentHost {
83
119
  throw new RangeError("coding agent: maxRepairAttempts must be between 0 and 8")
84
120
  this.exposeErrorStacks = options.exposeErrorStacks === true
85
121
  this.verification = options.verification
122
+ const checks = new Map<string, (event: AgentExtensionEvent) => boolean>()
123
+ for (const [type, check] of Object.entries(options.extensionEvents ?? {})) {
124
+ if (!isAgentExtensionEventType(type))
125
+ throw new TypeError(`coding agent: extension event type must be x-<vendor>.<name>: ${type}`)
126
+ if (typeof check !== "function")
127
+ throw new TypeError(`coding agent: the check for ${type} must be a function`)
128
+ checks.set(type, check)
129
+ }
130
+ this.extensionChecks = checks
86
131
  }
87
132
 
88
133
  async start(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
89
- if (this.session !== undefined) throw new Error("coding agent: a session is already active")
134
+ if (this.session !== undefined || this.starting !== undefined)
135
+ throw new Error("coding agent: a session is already active")
136
+ const starting = this.startSession(input, ++this.lifecycle)
137
+ this.starting = starting
138
+ try {
139
+ return await starting
140
+ } finally {
141
+ if (this.starting === starting) this.starting = undefined
142
+ }
143
+ }
144
+
145
+ private async startSession(
146
+ input: CreateSessionInput,
147
+ generation: number,
148
+ ): Promise<AgentSessionSnapshot> {
90
149
  const created = await this.backend.createSession(input)
150
+ if (generation !== this.lifecycle) {
151
+ await this.backend.cancel(created.id, "host stopped").catch(() => {})
152
+ await this.backend.close(created.id).catch(() => {})
153
+ throw new Error("coding agent: the host stopped while the session was starting")
154
+ }
91
155
  this.session = created
92
156
  try {
93
157
  let history: readonly import("./sessions.ts").SessionLogEntry[] = []
@@ -100,6 +164,8 @@ export class CodingAgentHost {
100
164
  this.nextSeq = nextSequence(created, history)
101
165
  await this.extensions?.reload()
102
166
  await this.sessionStore?.append(created.id, "session.started", created)
167
+ if (generation !== this.lifecycle)
168
+ throw new Error("coding agent: the host stopped while the session was starting")
103
169
  return created
104
170
  } catch (error) {
105
171
  // Session creation may allocate a process or other external resource. If history, extension
@@ -157,13 +223,11 @@ export class CodingAgentHost {
157
223
 
158
224
  private acceptBackendEvent(rawEvent: unknown): AgentEvent {
159
225
  const session = this.requireSession()
160
- if (!isAgentEvent(rawEvent) || rawEvent.sessionId !== session.id) {
161
- return this.createEvent({
162
- type: "session.failed",
163
- error: agentError("BACKEND_PROTOCOL", "backend emitted an invalid agent event"),
164
- recoverable: false,
165
- })
166
- }
226
+ // agentEventProblem re-walks the event, so it runs only once the event is known to be bad.
227
+ if (!isAgentEvent(rawEvent))
228
+ return this.protocolFailure(agentEventProblem(rawEvent) ?? "event is invalid")
229
+ const problem = this.acceptedEventProblem(rawEvent, session.id)
230
+ if (problem !== undefined) return this.protocolFailure(problem)
167
231
  const event = Object.freeze({
168
232
  ...rawEvent,
169
233
  sessionId: session.id,
@@ -210,6 +274,28 @@ export class CodingAgentHost {
210
274
  return event
211
275
  }
212
276
 
277
+ private protocolFailure(problem: string): AgentEvent {
278
+ return this.createEvent({
279
+ type: "session.failed",
280
+ error: agentError("BACKEND_PROTOCOL", `backend emitted an invalid agent event: ${problem}`),
281
+ recoverable: false,
282
+ })
283
+ }
284
+
285
+ private acceptedEventProblem(event: AgentEvent, sessionId: string): string | undefined {
286
+ if (event.sessionId !== sessionId) return "sessionId is not this session's"
287
+ if (!isExtensionEvent(event)) return undefined
288
+ const check = this.extensionChecks.get(event.type)
289
+ if (check === undefined) return undefined
290
+ let accepted = false
291
+ try {
292
+ accepted = check(event) === true
293
+ } catch {
294
+ accepted = false
295
+ }
296
+ return accepted ? undefined : `${event.type} failed its registered check`
297
+ }
298
+
213
299
  private async verifyTurn(): Promise<{
214
300
  readonly events: readonly AgentEvent[]
215
301
  readonly tasks: readonly VerificationRepairTask[]
@@ -376,6 +462,9 @@ export class CodingAgentHost {
376
462
  }
377
463
 
378
464
  async stop(reason = "stopped"): Promise<void> {
465
+ // A start still in flight is superseded: wait for it to roll its session back.
466
+ this.lifecycle++
467
+ await this.starting?.catch(() => {})
379
468
  if (this.session === undefined) return
380
469
  const id = this.session.id
381
470
  this.session = undefined
@@ -420,10 +509,19 @@ export class CodingAgentHost {
420
509
  approved: boolean,
421
510
  reason?: string,
422
511
  ): Promise<ApprovalDecision | undefined> {
512
+ // The broker decides first: an approval it no longer holds (unknown, expired, already settled,
513
+ // or bound to a coordinate) never reaches the backend.
514
+ this.resolving.add(approvalId)
515
+ let decision: ApprovalDecision | undefined
516
+ try {
517
+ decision = this.approvals.resolve(approvalId, approved, reason)
518
+ } finally {
519
+ this.resolving.delete(approvalId)
520
+ }
521
+ if (decision === undefined) return undefined
423
522
  if (this.session !== undefined && this.backend.resolveApproval !== undefined)
424
- await this.backend.resolveApproval(this.session.id, approvalId, approved, reason)
425
- const decision = this.approvals.resolve(approvalId, approved, reason)
426
- if (decision !== undefined && this.session !== undefined)
523
+ await this.backend.resolveApproval(this.session.id, approvalId, decision.approved, reason)
524
+ if (this.session !== undefined)
427
525
  await this.sessionStore?.append(this.session.id, "approval.resolved", decision, {
428
526
  pinned: true,
429
527
  })
@@ -517,8 +615,14 @@ function boundedHostToken(value: unknown): value is string {
517
615
  return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
518
616
  }
519
617
 
618
+ function isExtensionEvent(event: AgentEvent): event is AgentExtensionEvent {
619
+ return isAgentExtensionEventType(event.type)
620
+ }
621
+
520
622
  /** Default session evidence is content-free; live protocol consumers still receive the full event. */
521
623
  function eventEvidence(event: AgentEvent): unknown {
624
+ // An extension event's data is the backend's own content; evidence keeps only where it belongs.
625
+ if (isExtensionEvent(event)) return event.turnId === undefined ? {} : { turnId: event.turnId }
522
626
  switch (event.type) {
523
627
  case "session.started":
524
628
  case "session.updated":
@@ -533,7 +637,25 @@ function eventEvidence(event: AgentEvent): unknown {
533
637
  return { turnId: event.turnId }
534
638
  case "assistant.delta":
535
639
  case "assistant.message":
640
+ case "reasoning.delta":
536
641
  return { turnId: event.turnId }
642
+ case "plan.updated":
643
+ return {
644
+ turnId: event.turnId,
645
+ entries: event.entries.length,
646
+ completed: event.entries.filter((entry) => entry.status === "completed").length,
647
+ }
648
+ case "context.usage": {
649
+ const {
650
+ version: _version,
651
+ sessionId: _sessionId,
652
+ seq: _seq,
653
+ at: _at,
654
+ type: _type,
655
+ ...counts
656
+ } = event
657
+ return counts
658
+ }
537
659
  case "tool.started":
538
660
  return { turnId: event.turnId, callId: event.callId, name: event.name }
539
661
  case "tool.delta":
package/src/index.ts CHANGED
@@ -153,6 +153,7 @@ export {
153
153
  export {
154
154
  createVerificationRepairTask,
155
155
  isSafeReviewDiff,
156
+ isVerificationAvailable,
156
157
  type ReviewExecutionErrorCode,
157
158
  type ReviewExecutionResult,
158
159
  type ReviewOptions,
package/src/native.ts CHANGED
@@ -5,17 +5,21 @@ import {
5
5
  structuredOutputParser,
6
6
  } from "@nifrajs/agent"
7
7
  import {
8
+ AGENT_MAX_PAYLOAD_STRING,
8
9
  type AgentBackend,
9
10
  type AgentBackendInfo,
10
11
  type AgentEvent,
11
12
  type AgentEventStream,
12
13
  type AgentSessionSnapshot,
13
14
  agentError,
15
+ boundAgentText,
16
+ boundAgentValue,
14
17
  type CreateSessionInput,
15
18
  createAgentEventStream,
16
19
  isAgentEvent,
17
20
  type ReloadResult,
18
21
  type SendMessageInput,
22
+ splitAgentText,
19
23
  } from "@nifrajs/agent-protocol"
20
24
 
21
25
  export interface NativeMessage {
@@ -49,6 +53,18 @@ export type NativeModelResponse =
49
53
 
50
54
  export type NativeModelChunk =
51
55
  | { readonly type: "text_delta"; readonly text: string }
56
+ | { readonly type: "reasoning_delta"; readonly text: string; readonly itemId?: string }
57
+ /** Token counts for the request; becomes a `context.usage` event. */
58
+ | {
59
+ readonly type: "usage"
60
+ readonly usedTokens: number
61
+ readonly windowTokens?: number
62
+ readonly inputTokens?: number
63
+ readonly outputTokens?: number
64
+ readonly cachedInputTokens?: number
65
+ readonly reasoningTokens?: number
66
+ readonly estimated?: boolean
67
+ }
52
68
  | { readonly type: "response"; readonly response: NativeModelResponse }
53
69
 
54
70
  export interface NativeModelPort {
@@ -122,7 +138,14 @@ export interface NifraBackendOptions {
122
138
  /** Maximum time a protocol-visible native approval may remain unresolved. */
123
139
  readonly approvalTimeoutMs?: number
124
140
  readonly maxSteps?: number
141
+ /** Longest message `send` accepts (default 64 KiB). */
125
142
  readonly maxMessageChars?: number
143
+ /**
144
+ * Longest reply the model may give in one turn (default 1 MiB); past it the reply is cut with
145
+ * a marker. A reply longer than one event's text travels as consecutive `assistant.message`
146
+ * events, and the model's history keeps it whole.
147
+ */
148
+ readonly maxReplyChars?: number
126
149
  /**
127
150
  * Character budget for the conversation a session sends to the model (default 1 MiB). Past it,
128
151
  * whole earlier turns are dropped, oldest first; the current turn is always sent in full.
@@ -186,7 +209,7 @@ export class NifraBackend implements AgentBackend {
186
209
  capabilities: Object.freeze(["sessions", "extensions", "reload", "approvals", "streaming"]),
187
210
  })
188
211
  private readonly options: Required<
189
- Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars" | "maxHistoryChars">
212
+ Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars" | "maxReplyChars" | "maxHistoryChars">
190
213
  > &
191
214
  NifraBackendOptions
192
215
  private readonly sessions = new Map<string, NativeSession>()
@@ -197,6 +220,7 @@ export class NifraBackend implements AgentBackend {
197
220
  ...options,
198
221
  maxSteps: options.maxSteps ?? 32,
199
222
  maxMessageChars: options.maxMessageChars ?? 64 * 1024,
223
+ maxReplyChars: options.maxReplyChars ?? 1024 * 1024,
200
224
  maxHistoryChars: options.maxHistoryChars ?? 1024 * 1024,
201
225
  tools: Object.freeze([...(options.tools ?? [])]),
202
226
  })
@@ -208,6 +232,8 @@ export class NifraBackend implements AgentBackend {
208
232
  throw new RangeError("nifra backend: maxSteps must be between 1 and 512")
209
233
  if (!Number.isSafeInteger(this.options.maxMessageChars) || this.options.maxMessageChars < 256)
210
234
  throw new RangeError("nifra backend: maxMessageChars must be at least 256")
235
+ if (!Number.isSafeInteger(this.options.maxReplyChars) || this.options.maxReplyChars < 256)
236
+ throw new RangeError("nifra backend: maxReplyChars must be at least 256")
211
237
  if (!Number.isSafeInteger(this.options.maxHistoryChars) || this.options.maxHistoryChars < 256)
212
238
  throw new RangeError("nifra backend: maxHistoryChars must be at least 256")
213
239
  if (
@@ -387,9 +413,10 @@ export class NifraBackend implements AgentBackend {
387
413
  return
388
414
  }
389
415
  this.update(session, "running", turnId)
390
- this.emit(session, { type: "turn.started", turnId, prompt: message })
391
- this.remember(session, { role: "user", text: message })
392
416
  try {
417
+ // The event carries a display copy; the model gets the whole message.
418
+ this.emit(session, { type: "turn.started", turnId, prompt: boundAgentText(message) })
419
+ this.remember(session, { role: "user", text: message })
393
420
  for (let step = 0; step < this.options.maxSteps; step++) {
394
421
  this.assertCurrentTurn(session, stream, turnId, signal)
395
422
  this.trimHistory(session)
@@ -409,9 +436,10 @@ export class NifraBackend implements AgentBackend {
409
436
  const response = await this.consumeModel(raw, session, stream, turnId, signal)
410
437
  this.assertCurrentTurn(session, stream, turnId, signal)
411
438
  if (response.type === "text") {
412
- const text = boundedText(response.text, this.options.maxMessageChars)
439
+ const text = boundedText(response.text, this.options.maxReplyChars)
413
440
  this.remember(session, { role: "assistant", text })
414
- this.emit(session, { type: "assistant.message", turnId, text })
441
+ for (const piece of splitAgentText(text))
442
+ this.emit(session, { type: "assistant.message", turnId, text: piece })
415
443
  this.finish(session, stream)
416
444
  return
417
445
  }
@@ -421,22 +449,28 @@ export class NifraBackend implements AgentBackend {
421
449
  type: "tool.completed",
422
450
  turnId,
423
451
  callId: crypto.randomUUID(),
424
- name: response.name,
452
+ name: boundedApprovalToken(response.name) ? response.name : "unknown",
425
453
  ok: false,
426
- error: agentError("UNKNOWN_TOOL", `unknown native tool: ${response.name}`),
454
+ error: agentError(
455
+ "UNKNOWN_TOOL",
456
+ boundAgentText(`unknown native tool: ${response.name}`, 512),
457
+ ),
427
458
  })
428
459
  this.remember(session, { role: "tool", name: response.name, text: "unknown tool" })
429
460
  continue
430
461
  }
431
462
  const callId = crypto.randomUUID()
432
463
  const execute = tool.execute
433
- this.emit(session, {
464
+ // The event carries a display copy; the approval and the tool get the whole input.
465
+ const started: ToolStartedPayload = {
434
466
  type: "tool.started",
435
467
  turnId,
436
468
  callId,
437
469
  name: tool.name,
438
- input: response.input,
439
- })
470
+ }
471
+ const shown = boundAgentValue(response.input)
472
+ if (shown !== undefined) started.input = shown
473
+ this.emit(session, started)
440
474
  const needsApproval =
441
475
  typeof tool.requiresApproval === "function"
442
476
  ? await tool.requiresApproval(response.input)
@@ -477,14 +511,12 @@ export class NifraBackend implements AgentBackend {
477
511
  callId,
478
512
  name: tool.name,
479
513
  ok: true,
480
- output: text,
514
+ output: boundAgentText(text, AGENT_MAX_PAYLOAD_STRING),
481
515
  })
482
516
  } catch (error) {
483
517
  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
- )
518
+ const message =
519
+ boundedText(error instanceof Error ? error.message : error) || "native tool failed"
488
520
  this.remember(session, { role: "tool", name: tool.name, text: message })
489
521
  this.emit(session, {
490
522
  type: "tool.completed",
@@ -492,7 +524,7 @@ export class NifraBackend implements AgentBackend {
492
524
  callId,
493
525
  name: tool.name,
494
526
  ok: false,
495
- error: agentError("TOOL_FAILED", message),
527
+ error: agentError("TOOL_FAILED", boundAgentText(message)),
496
528
  })
497
529
  }
498
530
  }
@@ -511,7 +543,10 @@ export class NifraBackend implements AgentBackend {
511
543
  type: "session.failed",
512
544
  error: agentError(
513
545
  "NATIVE_TURN_FAILED",
514
- boundedText(error instanceof Error ? error.message : error, 4_096),
546
+ boundAgentText(
547
+ boundedText(error instanceof Error ? error.message : error, 4_096) ||
548
+ "native turn failed",
549
+ ),
515
550
  ),
516
551
  recoverable: true,
517
552
  })
@@ -529,11 +564,30 @@ export class NifraBackend implements AgentBackend {
529
564
  ): Promise<NativeModelResponse> {
530
565
  if (isAsyncIterable<NativeModelChunk>(raw)) {
531
566
  let response: NativeModelResponse | undefined
567
+ // Streamed text stops where the final reply is cut, so the deltas never outgrow it.
568
+ let room = this.options.maxReplyChars
532
569
  for await (const chunk of raw) {
533
570
  this.assertCurrentTurn(session, stream, turnId, signal)
534
- if (chunk.type === "text_delta")
535
- this.emit(session, { type: "assistant.delta", turnId, text: chunk.text })
536
- else response = chunk.response
571
+ if (chunk.type === "response") {
572
+ response = chunk.response
573
+ continue
574
+ }
575
+ if (chunk.type === "reasoning_delta") {
576
+ const itemId = isEventToken(chunk.itemId) ? { itemId: chunk.itemId } : {}
577
+ for (const piece of splitAgentText(chunk.text))
578
+ this.emit(session, { type: "reasoning.delta", turnId, ...itemId, text: piece })
579
+ continue
580
+ }
581
+ if (chunk.type === "usage") {
582
+ const usage = nativeUsage(chunk)
583
+ if (usage !== undefined) this.emit(session, { type: "context.usage", turnId, ...usage })
584
+ continue
585
+ }
586
+ if (room <= 0) continue
587
+ const text = chunk.text.slice(0, room)
588
+ room -= text.length
589
+ for (const piece of splitAgentText(text))
590
+ this.emit(session, { type: "assistant.delta", turnId, text: piece })
537
591
  }
538
592
  if (response === undefined) throw new Error("native model stream ended without a response")
539
593
  this.assertCurrentTurn(session, stream, turnId, signal)
@@ -692,7 +746,7 @@ export class NifraBackend implements AgentBackend {
692
746
  return
693
747
  const stream = session.active
694
748
  const turnId = session.turnId
695
- const stopReason = boundedText(reason, 512) || "cancelled"
749
+ const stopReason = boundAgentText(boundedText(reason, 512) || "cancelled")
696
750
  session.cancelled = true
697
751
  this.clearTurnSignal(session)
698
752
  try {
@@ -812,6 +866,14 @@ export class NifraBackend implements AgentBackend {
812
866
  }
813
867
  }
814
868
 
869
+ interface ToolStartedPayload {
870
+ type: "tool.started"
871
+ turnId: string
872
+ callId: string
873
+ name: string
874
+ input?: unknown
875
+ }
876
+
815
877
  function messageChars(message: NativeMessage): number {
816
878
  return message.text.length + (message.name?.length ?? 0)
817
879
  }
@@ -875,3 +937,38 @@ function removeActiveTurn(
875
937
  const { activeTurnId: _activeTurnId, ...withoutActiveTurn } = snapshot
876
938
  return withoutActiveTurn
877
939
  }
940
+
941
+ function isEventToken(value: unknown): value is string {
942
+ return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
943
+ }
944
+
945
+ const USAGE_COUNTS = [
946
+ "windowTokens",
947
+ "inputTokens",
948
+ "outputTokens",
949
+ "cachedInputTokens",
950
+ "reasoningTokens",
951
+ ] as const
952
+
953
+ type NativeUsage = {
954
+ -readonly [Key in keyof Omit<Extract<NativeModelChunk, { type: "usage" }>, "type">]: Extract<
955
+ NativeModelChunk,
956
+ { type: "usage" }
957
+ >[Key]
958
+ }
959
+
960
+ // A model port is application code; a count it gets wrong is dropped, never a failed turn.
961
+ function nativeUsage(chunk: Extract<NativeModelChunk, { type: "usage" }>): NativeUsage | undefined {
962
+ if (!isTokenCount(chunk.usedTokens)) return undefined
963
+ const usage: NativeUsage = { usedTokens: chunk.usedTokens }
964
+ for (const key of USAGE_COUNTS) {
965
+ const count = chunk[key]
966
+ if (isTokenCount(count)) usage[key] = count
967
+ }
968
+ if (chunk.estimated === true) usage.estimated = true
969
+ return usage
970
+ }
971
+
972
+ function isTokenCount(value: unknown): value is number {
973
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
974
+ }
@@ -15,7 +15,11 @@ export const DISPATCH_JOB_NAME = "nifra.agent.run-node"
15
15
  export const MAX_DISPATCH_STRING = 256
16
16
  export const MAX_DISPATCH_ATTEMPTS = 16
17
17
 
18
- export type DispatchBoundary = "before-effect" | "after-effect"
18
+ /**
19
+ * `before-effect`: no side effect has happened, so a redelivery may run the effect. `effect-started`:
20
+ * the effect was invoked and may have acted, so a redelivery needs a proof. `after-effect`: it acted.
21
+ */
22
+ export type DispatchBoundary = "before-effect" | "effect-started" | "after-effect"
19
23
  export type DispatchState =
20
24
  | "queued"
21
25
  | "leased"
@@ -300,7 +304,7 @@ export function parseRunCheckpoint(value: unknown): RunCheckpoint {
300
304
  if (input.version !== RUN_DISPATCH_VERSION)
301
305
  throw new TypeError("run checkpoint.version is unsupported")
302
306
  const boundary = input.boundary
303
- if (boundary !== "before-effect" && boundary !== "after-effect")
307
+ if (boundary !== "before-effect" && boundary !== "effect-started" && boundary !== "after-effect")
304
308
  throw new TypeError("run checkpoint.boundary is invalid")
305
309
  return Object.freeze({
306
310
  version: RUN_DISPATCH_VERSION,
@@ -10,6 +10,7 @@
10
10
  * evidence only, and the file store persists exactly the content-free record it parsed.
11
11
  */
12
12
 
13
+ import { createHash } from "node:crypto"
13
14
  import { appendFile } from "node:fs/promises"
14
15
  import type { ArtifactRef, RunEvidence } from "@nifrajs/agent-protocol"
15
16
  import { assertEvidenceSize, parseRunEvidence } from "@nifrajs/agent-protocol"
@@ -41,12 +42,10 @@ export interface EvidenceStore {
41
42
  }
42
43
 
43
44
  const DEFAULT_MAX_LIVE = 1024
44
- const encoder = new TextEncoder()
45
45
 
46
- async function sha256Bytes(text: string): Promise<Uint8Array> {
47
- const digest = await crypto.subtle.digest("SHA-256", encoder.encode(text))
48
- return new Uint8Array(digest)
49
- }
46
+ // Synchronous: `crypto.subtle` hands every record to a worker thread and back, which on a loaded
47
+ // machine made a 10k-record run take seconds.
48
+ const sha256Bytes = (text: string): Uint8Array => createHash("sha256").update(text).digest()
50
49
 
51
50
  /**
52
51
  * The identity of one record for the terminal digest: node outcome only. `seq`, `durationMs`, and
@@ -97,7 +96,7 @@ class EvidenceAccumulator {
97
96
  } else this.failedCount++
98
97
  if (record.artifacts !== undefined)
99
98
  for (const ref of record.artifacts) this.artifactRefs.push(ref)
100
- const hash = await sha256Bytes(digestKey(record))
99
+ const hash = sha256Bytes(digestKey(record))
101
100
  let carry = 0
102
101
  for (let i = 31; i >= 0; i--) {
103
102
  const sum = (this.acc[i] as number) + (hash[i] as number) + carry