@nifrajs/coding-agent 4.0.2 → 5.1.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/dist/approvals.d.ts +30 -2
  4. package/dist/approvals.d.ts.map +1 -1
  5. package/dist/approvals.js +43 -12
  6. package/dist/approvals.js.map +1 -1
  7. package/dist/cli.d.ts +13 -0
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +120 -100
  10. package/dist/cli.js.map +1 -1
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +9 -21
  13. package/dist/context.js.map +1 -1
  14. package/dist/diff.d.ts.map +1 -1
  15. package/dist/diff.js +8 -21
  16. package/dist/diff.js.map +1 -1
  17. package/dist/extensions.js +4 -2
  18. package/dist/extensions.js.map +1 -1
  19. package/dist/handoffs.d.ts +5 -0
  20. package/dist/handoffs.d.ts.map +1 -1
  21. package/dist/handoffs.js +27 -0
  22. package/dist/handoffs.js.map +1 -1
  23. package/dist/host.d.ts +15 -1
  24. package/dist/host.d.ts.map +1 -1
  25. package/dist/host.js +152 -37
  26. package/dist/host.js.map +1 -1
  27. package/dist/index.d.ts +2 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/isolated.js +3 -2
  32. package/dist/isolated.js.map +1 -1
  33. package/dist/native.d.ts +29 -2
  34. package/dist/native.d.ts.map +1 -1
  35. package/dist/native.js +86 -26
  36. package/dist/native.js.map +1 -1
  37. package/dist/orchestration/dispatch.d.ts +5 -1
  38. package/dist/orchestration/dispatch.d.ts.map +1 -1
  39. package/dist/orchestration/dispatch.js +1 -1
  40. package/dist/orchestration/dispatch.js.map +1 -1
  41. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  42. package/dist/orchestration/evidence-store.js +5 -6
  43. package/dist/orchestration/evidence-store.js.map +1 -1
  44. package/dist/orchestration/host.d.ts +14 -0
  45. package/dist/orchestration/host.d.ts.map +1 -1
  46. package/dist/orchestration/host.js +26 -0
  47. package/dist/orchestration/host.js.map +1 -1
  48. package/dist/orchestration/index.d.ts +1 -1
  49. package/dist/orchestration/index.d.ts.map +1 -1
  50. package/dist/orchestration/index.js.map +1 -1
  51. package/dist/orchestration/recovery.d.ts +5 -1
  52. package/dist/orchestration/recovery.d.ts.map +1 -1
  53. package/dist/orchestration/recovery.js +5 -3
  54. package/dist/orchestration/recovery.js.map +1 -1
  55. package/dist/process.d.ts +22 -1
  56. package/dist/process.d.ts.map +1 -1
  57. package/dist/process.js +75 -1
  58. package/dist/process.js.map +1 -1
  59. package/dist/rpc.d.ts +29 -0
  60. package/dist/rpc.d.ts.map +1 -1
  61. package/dist/rpc.js +205 -46
  62. package/dist/rpc.js.map +1 -1
  63. package/dist/session-migration.d.ts.map +1 -1
  64. package/dist/session-migration.js +30 -6
  65. package/dist/session-migration.js.map +1 -1
  66. package/dist/sessions.d.ts +13 -2
  67. package/dist/sessions.d.ts.map +1 -1
  68. package/dist/sessions.js +104 -24
  69. package/dist/sessions.js.map +1 -1
  70. package/dist/subagents.d.ts.map +1 -1
  71. package/dist/subagents.js +13 -2
  72. package/dist/subagents.js.map +1 -1
  73. package/dist/turn-replay.d.ts +40 -0
  74. package/dist/turn-replay.d.ts.map +1 -0
  75. package/dist/turn-replay.js +136 -0
  76. package/dist/turn-replay.js.map +1 -0
  77. package/dist/ui.js +84 -30
  78. package/dist/ui.js.map +1 -1
  79. package/dist/verification.d.ts +7 -0
  80. package/dist/verification.d.ts.map +1 -1
  81. package/dist/verification.js +66 -63
  82. package/dist/verification.js.map +1 -1
  83. package/dist/workflows.d.ts +5 -0
  84. package/dist/workflows.d.ts.map +1 -1
  85. package/dist/workflows.js +2 -1
  86. package/dist/workflows.js.map +1 -1
  87. package/package.json +6 -6
  88. package/src/approvals.ts +70 -16
  89. package/src/cli.ts +125 -93
  90. package/src/context.ts +12 -20
  91. package/src/diff.ts +11 -23
  92. package/src/extensions.ts +6 -2
  93. package/src/handoffs.ts +27 -0
  94. package/src/host.ts +165 -34
  95. package/src/index.ts +3 -0
  96. package/src/isolated.ts +3 -2
  97. package/src/native.ts +137 -34
  98. package/src/orchestration/dispatch.ts +6 -2
  99. package/src/orchestration/evidence-store.ts +5 -6
  100. package/src/orchestration/host.ts +44 -0
  101. package/src/orchestration/index.ts +1 -0
  102. package/src/orchestration/recovery.ts +14 -8
  103. package/src/process.ts +89 -0
  104. package/src/rpc.ts +268 -49
  105. package/src/session-migration.ts +29 -5
  106. package/src/sessions.ts +115 -29
  107. package/src/subagents.ts +11 -2
  108. package/src/turn-replay.ts +151 -0
  109. package/src/ui.ts +90 -32
  110. package/src/verification.ts +74 -60
  111. 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
@@ -125,31 +191,40 @@ export class CodingAgentHost {
125
191
  const sessionId = this.session.id
126
192
  let nextMessage = message
127
193
  let repairAttempts = 0
128
- for (;;) {
129
- const stream = this.backend.send({
130
- sessionId,
131
- message: nextMessage,
132
- ...(signal === undefined ? {} : { signal }),
133
- })
134
- for await (const rawEvent of stream) {
135
- const event = this.acceptBackendEvent(rawEvent)
136
- await this.recordEvent(event)
137
- yield event
138
- if (event.type === "session.stopped") return
139
- if (event.type === "session.failed") {
140
- if (event.error.code === "BACKEND_PROTOCOL")
141
- await this.backend.cancel(sessionId, "backend protocol error").catch(() => {})
142
- return
194
+ // A reader that leaves early (an RPC client disconnecting) never records the backend's
195
+ // `approval.resolved`, so the turn's approvals are denied here instead of waiting on a timer.
196
+ const raised = new Set<string>()
197
+ try {
198
+ for (;;) {
199
+ const stream = this.backend.send({
200
+ sessionId,
201
+ message: nextMessage,
202
+ ...(signal === undefined ? {} : { signal }),
203
+ })
204
+ for await (const rawEvent of stream) {
205
+ const event = this.acceptBackendEvent(rawEvent)
206
+ await this.recordEvent(event)
207
+ if (event.type === "approval.required") raised.add(event.approvalId)
208
+ yield event
209
+ if (event.type === "session.stopped") return
210
+ if (event.type === "session.failed") {
211
+ if (event.error.code === "BACKEND_PROTOCOL")
212
+ await this.backend.cancel(sessionId, "backend protocol error").catch(() => {})
213
+ return
214
+ }
143
215
  }
144
- }
145
- if (signal?.aborted === true) return
216
+ if (signal?.aborted === true) return
146
217
 
147
- const repairTasks = await this.verifyTurn()
148
- for (const event of repairTasks.events) yield event
149
- if (repairTasks.tasks.length === 0 || repairAttempts >= this.maxRepairAttempts) return
218
+ const repairTasks = await this.verifyTurn()
219
+ for (const event of repairTasks.events) yield event
220
+ if (repairTasks.tasks.length === 0 || repairAttempts >= this.maxRepairAttempts) return
150
221
 
151
- repairAttempts++
152
- nextMessage = createRepairPrompt(repairTasks.tasks, repairAttempts, this.maxRepairAttempts)
222
+ repairAttempts++
223
+ nextMessage = createRepairPrompt(repairTasks.tasks, repairAttempts, this.maxRepairAttempts)
224
+ }
225
+ } finally {
226
+ for (const approvalId of raised)
227
+ this.approvals.resolve(approvalId, false, "approval turn ended")
153
228
  }
154
229
  }
155
230
 
@@ -157,13 +232,11 @@ export class CodingAgentHost {
157
232
 
158
233
  private acceptBackendEvent(rawEvent: unknown): AgentEvent {
159
234
  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
- }
235
+ // agentEventProblem re-walks the event, so it runs only once the event is known to be bad.
236
+ if (!isAgentEvent(rawEvent))
237
+ return this.protocolFailure(agentEventProblem(rawEvent) ?? "event is invalid")
238
+ const problem = this.acceptedEventProblem(rawEvent, session.id)
239
+ if (problem !== undefined) return this.protocolFailure(problem)
167
240
  const event = Object.freeze({
168
241
  ...rawEvent,
169
242
  sessionId: session.id,
@@ -210,6 +283,28 @@ export class CodingAgentHost {
210
283
  return event
211
284
  }
212
285
 
286
+ private protocolFailure(problem: string): AgentEvent {
287
+ return this.createEvent({
288
+ type: "session.failed",
289
+ error: agentError("BACKEND_PROTOCOL", `backend emitted an invalid agent event: ${problem}`),
290
+ recoverable: false,
291
+ })
292
+ }
293
+
294
+ private acceptedEventProblem(event: AgentEvent, sessionId: string): string | undefined {
295
+ if (event.sessionId !== sessionId) return "sessionId is not this session's"
296
+ if (!isExtensionEvent(event)) return undefined
297
+ const check = this.extensionChecks.get(event.type)
298
+ if (check === undefined) return undefined
299
+ let accepted = false
300
+ try {
301
+ accepted = check(event) === true
302
+ } catch {
303
+ accepted = false
304
+ }
305
+ return accepted ? undefined : `${event.type} failed its registered check`
306
+ }
307
+
213
308
  private async verifyTurn(): Promise<{
214
309
  readonly events: readonly AgentEvent[]
215
310
  readonly tasks: readonly VerificationRepairTask[]
@@ -376,6 +471,9 @@ export class CodingAgentHost {
376
471
  }
377
472
 
378
473
  async stop(reason = "stopped"): Promise<void> {
474
+ // A start still in flight is superseded: wait for it to roll its session back.
475
+ this.lifecycle++
476
+ await this.starting?.catch(() => {})
379
477
  if (this.session === undefined) return
380
478
  const id = this.session.id
381
479
  this.session = undefined
@@ -420,10 +518,19 @@ export class CodingAgentHost {
420
518
  approved: boolean,
421
519
  reason?: string,
422
520
  ): Promise<ApprovalDecision | undefined> {
521
+ // The broker decides first: an approval it no longer holds (unknown, expired, already settled,
522
+ // or bound to a coordinate) never reaches the backend.
523
+ this.resolving.add(approvalId)
524
+ let decision: ApprovalDecision | undefined
525
+ try {
526
+ decision = this.approvals.resolve(approvalId, approved, reason)
527
+ } finally {
528
+ this.resolving.delete(approvalId)
529
+ }
530
+ if (decision === undefined) return undefined
423
531
  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)
532
+ await this.backend.resolveApproval(this.session.id, approvalId, decision.approved, reason)
533
+ if (this.session !== undefined)
427
534
  await this.sessionStore?.append(this.session.id, "approval.resolved", decision, {
428
535
  pinned: true,
429
536
  })
@@ -517,8 +624,14 @@ function boundedHostToken(value: unknown): value is string {
517
624
  return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
518
625
  }
519
626
 
627
+ function isExtensionEvent(event: AgentEvent): event is AgentExtensionEvent {
628
+ return isAgentExtensionEventType(event.type)
629
+ }
630
+
520
631
  /** Default session evidence is content-free; live protocol consumers still receive the full event. */
521
632
  function eventEvidence(event: AgentEvent): unknown {
633
+ // An extension event's data is the backend's own content; evidence keeps only where it belongs.
634
+ if (isExtensionEvent(event)) return event.turnId === undefined ? {} : { turnId: event.turnId }
522
635
  switch (event.type) {
523
636
  case "session.started":
524
637
  case "session.updated":
@@ -533,7 +646,25 @@ function eventEvidence(event: AgentEvent): unknown {
533
646
  return { turnId: event.turnId }
534
647
  case "assistant.delta":
535
648
  case "assistant.message":
649
+ case "reasoning.delta":
536
650
  return { turnId: event.turnId }
651
+ case "plan.updated":
652
+ return {
653
+ turnId: event.turnId,
654
+ entries: event.entries.length,
655
+ completed: event.entries.filter((entry) => entry.status === "completed").length,
656
+ }
657
+ case "context.usage": {
658
+ const {
659
+ version: _version,
660
+ sessionId: _sessionId,
661
+ seq: _seq,
662
+ at: _at,
663
+ type: _type,
664
+ ...counts
665
+ } = event
666
+ return counts
667
+ }
537
668
  case "tool.started":
538
669
  return { turnId: event.turnId, callId: event.callId, name: event.name }
539
670
  case "tool.delta":
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  export { PiBackend, type PiBackendOptions } from "@nifrajs/pi"
2
2
  export {
3
+ APPROVAL_NEVER_EXPIRES_AT,
3
4
  type ApprovalDecision,
4
5
  ApprovalManager,
5
6
  type ApprovalManagerOptions,
6
7
  type ApprovalMatchRejection,
7
8
  type ApprovalMatchResult,
8
9
  type ApprovalRequest,
10
+ type ApprovalTimeout,
9
11
  approvalRequestFromEvent,
10
12
  approvalResolvedEvent,
11
13
  } from "./approvals.ts"
@@ -153,6 +155,7 @@ export {
153
155
  export {
154
156
  createVerificationRepairTask,
155
157
  isSafeReviewDiff,
158
+ isVerificationAvailable,
156
159
  type ReviewExecutionErrorCode,
157
160
  type ReviewExecutionResult,
158
161
  type ReviewOptions,
package/src/isolated.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { realpathSync } from "node:fs"
2
- import { isAbsolute, relative, resolve } from "node:path"
2
+ import { isAbsolute, relative, resolve, sep } from "node:path"
3
3
  import { fileURLToPath } from "node:url"
4
4
  import { readBoundedText } from "./process.ts"
5
5
 
@@ -321,7 +321,8 @@ export class IsolatedExtensionWorker {
321
321
 
322
322
  function isWithin(root: string, candidate: string): boolean {
323
323
  const path = relative(resolve(root), resolve(candidate))
324
- return path === "" || (!path.startsWith("..") && !path.includes("/.."))
324
+ // On Windows a path on another drive has no relative form, so `relative` returns it absolute.
325
+ return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path))
325
326
  }
326
327
 
327
328
  function isValidPort(value: unknown): value is number {
package/src/native.ts CHANGED
@@ -5,18 +5,23 @@ 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"
24
+ import { type ApprovalTimeout, isApprovalTimeout } from "./approvals.ts"
20
25
 
21
26
  export interface NativeMessage {
22
27
  readonly role: "user" | "assistant" | "tool"
@@ -49,6 +54,18 @@ export type NativeModelResponse =
49
54
 
50
55
  export type NativeModelChunk =
51
56
  | { readonly type: "text_delta"; readonly text: string }
57
+ | { readonly type: "reasoning_delta"; readonly text: string; readonly itemId?: string }
58
+ /** Token counts for the request; becomes a `context.usage` event. */
59
+ | {
60
+ readonly type: "usage"
61
+ readonly usedTokens: number
62
+ readonly windowTokens?: number
63
+ readonly inputTokens?: number
64
+ readonly outputTokens?: number
65
+ readonly cachedInputTokens?: number
66
+ readonly reasoningTokens?: number
67
+ readonly estimated?: boolean
68
+ }
52
69
  | { readonly type: "response"; readonly response: NativeModelResponse }
53
70
 
54
71
  export interface NativeModelPort {
@@ -119,10 +136,21 @@ export interface NifraBackendOptions {
119
136
  readonly model: NativeModelPort
120
137
  readonly tools?: readonly NativeTool[]
121
138
  readonly approval?: NativeApprovalPort
122
- /** Maximum time a protocol-visible native approval may remain unresolved. */
123
- readonly approvalTimeoutMs?: number
139
+ /**
140
+ * Maximum time a protocol-visible native approval may remain unresolved (default 5 minutes, at
141
+ * most 24 h). `"never"` removes the timer: the approval waits for an answer, a cancel, the end of
142
+ * its turn, or session close.
143
+ */
144
+ readonly approvalTimeoutMs?: ApprovalTimeout
124
145
  readonly maxSteps?: number
146
+ /** Longest message `send` accepts (default 64 KiB). */
125
147
  readonly maxMessageChars?: number
148
+ /**
149
+ * Longest reply the model may give in one turn (default 1 MiB); past it the reply is cut with
150
+ * a marker. A reply longer than one event's text travels as consecutive `assistant.message`
151
+ * events, and the model's history keeps it whole.
152
+ */
153
+ readonly maxReplyChars?: number
126
154
  /**
127
155
  * Character budget for the conversation a session sends to the model (default 1 MiB). Past it,
128
156
  * whole earlier turns are dropped, oldest first; the current turn is always sent in full.
@@ -152,7 +180,7 @@ interface NativePendingApproval {
152
180
  readonly action: string
153
181
  readonly capability: string
154
182
  readonly resolve: (resolution: NativeApprovalResolution) => void
155
- readonly timer: ReturnType<typeof setTimeout>
183
+ readonly timer: ReturnType<typeof setTimeout> | undefined
156
184
  readonly onAbort: () => void
157
185
  readonly signal: AbortSignal
158
186
  }
@@ -186,7 +214,7 @@ export class NifraBackend implements AgentBackend {
186
214
  capabilities: Object.freeze(["sessions", "extensions", "reload", "approvals", "streaming"]),
187
215
  })
188
216
  private readonly options: Required<
189
- Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars" | "maxHistoryChars">
217
+ Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars" | "maxReplyChars" | "maxHistoryChars">
190
218
  > &
191
219
  NifraBackendOptions
192
220
  private readonly sessions = new Map<string, NativeSession>()
@@ -197,6 +225,7 @@ export class NifraBackend implements AgentBackend {
197
225
  ...options,
198
226
  maxSteps: options.maxSteps ?? 32,
199
227
  maxMessageChars: options.maxMessageChars ?? 64 * 1024,
228
+ maxReplyChars: options.maxReplyChars ?? 1024 * 1024,
200
229
  maxHistoryChars: options.maxHistoryChars ?? 1024 * 1024,
201
230
  tools: Object.freeze([...(options.tools ?? [])]),
202
231
  })
@@ -208,15 +237,17 @@ export class NifraBackend implements AgentBackend {
208
237
  throw new RangeError("nifra backend: maxSteps must be between 1 and 512")
209
238
  if (!Number.isSafeInteger(this.options.maxMessageChars) || this.options.maxMessageChars < 256)
210
239
  throw new RangeError("nifra backend: maxMessageChars must be at least 256")
240
+ if (!Number.isSafeInteger(this.options.maxReplyChars) || this.options.maxReplyChars < 256)
241
+ throw new RangeError("nifra backend: maxReplyChars must be at least 256")
211
242
  if (!Number.isSafeInteger(this.options.maxHistoryChars) || this.options.maxHistoryChars < 256)
212
243
  throw new RangeError("nifra backend: maxHistoryChars must be at least 256")
213
244
  if (
214
245
  this.options.approvalTimeoutMs !== undefined &&
215
- (!Number.isSafeInteger(this.options.approvalTimeoutMs) ||
216
- this.options.approvalTimeoutMs < 1 ||
217
- this.options.approvalTimeoutMs > 24 * 60 * 60_000)
246
+ !isApprovalTimeout(this.options.approvalTimeoutMs)
218
247
  )
219
- throw new RangeError("nifra backend: approvalTimeoutMs must be between 1ms and 24h")
248
+ throw new RangeError(
249
+ 'nifra backend: approvalTimeoutMs must be between 1ms and 24h, or "never"',
250
+ )
220
251
  }
221
252
 
222
253
  async createSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
@@ -387,9 +418,10 @@ export class NifraBackend implements AgentBackend {
387
418
  return
388
419
  }
389
420
  this.update(session, "running", turnId)
390
- this.emit(session, { type: "turn.started", turnId, prompt: message })
391
- this.remember(session, { role: "user", text: message })
392
421
  try {
422
+ // The event carries a display copy; the model gets the whole message.
423
+ this.emit(session, { type: "turn.started", turnId, prompt: boundAgentText(message) })
424
+ this.remember(session, { role: "user", text: message })
393
425
  for (let step = 0; step < this.options.maxSteps; step++) {
394
426
  this.assertCurrentTurn(session, stream, turnId, signal)
395
427
  this.trimHistory(session)
@@ -409,9 +441,10 @@ export class NifraBackend implements AgentBackend {
409
441
  const response = await this.consumeModel(raw, session, stream, turnId, signal)
410
442
  this.assertCurrentTurn(session, stream, turnId, signal)
411
443
  if (response.type === "text") {
412
- const text = boundedText(response.text, this.options.maxMessageChars)
444
+ const text = boundedText(response.text, this.options.maxReplyChars)
413
445
  this.remember(session, { role: "assistant", text })
414
- this.emit(session, { type: "assistant.message", turnId, text })
446
+ for (const piece of splitAgentText(text))
447
+ this.emit(session, { type: "assistant.message", turnId, text: piece })
415
448
  this.finish(session, stream)
416
449
  return
417
450
  }
@@ -421,22 +454,28 @@ export class NifraBackend implements AgentBackend {
421
454
  type: "tool.completed",
422
455
  turnId,
423
456
  callId: crypto.randomUUID(),
424
- name: response.name,
457
+ name: boundedApprovalToken(response.name) ? response.name : "unknown",
425
458
  ok: false,
426
- error: agentError("UNKNOWN_TOOL", `unknown native tool: ${response.name}`),
459
+ error: agentError(
460
+ "UNKNOWN_TOOL",
461
+ boundAgentText(`unknown native tool: ${response.name}`, 512),
462
+ ),
427
463
  })
428
464
  this.remember(session, { role: "tool", name: response.name, text: "unknown tool" })
429
465
  continue
430
466
  }
431
467
  const callId = crypto.randomUUID()
432
468
  const execute = tool.execute
433
- this.emit(session, {
469
+ // The event carries a display copy; the approval and the tool get the whole input.
470
+ const started: ToolStartedPayload = {
434
471
  type: "tool.started",
435
472
  turnId,
436
473
  callId,
437
474
  name: tool.name,
438
- input: response.input,
439
- })
475
+ }
476
+ const shown = boundAgentValue(response.input)
477
+ if (shown !== undefined) started.input = shown
478
+ this.emit(session, started)
440
479
  const needsApproval =
441
480
  typeof tool.requiresApproval === "function"
442
481
  ? await tool.requiresApproval(response.input)
@@ -477,14 +516,12 @@ export class NifraBackend implements AgentBackend {
477
516
  callId,
478
517
  name: tool.name,
479
518
  ok: true,
480
- output: text,
519
+ output: boundAgentText(text, AGENT_MAX_PAYLOAD_STRING),
481
520
  })
482
521
  } catch (error) {
483
522
  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
- )
523
+ const message =
524
+ boundedText(error instanceof Error ? error.message : error) || "native tool failed"
488
525
  this.remember(session, { role: "tool", name: tool.name, text: message })
489
526
  this.emit(session, {
490
527
  type: "tool.completed",
@@ -492,7 +529,7 @@ export class NifraBackend implements AgentBackend {
492
529
  callId,
493
530
  name: tool.name,
494
531
  ok: false,
495
- error: agentError("TOOL_FAILED", message),
532
+ error: agentError("TOOL_FAILED", boundAgentText(message)),
496
533
  })
497
534
  }
498
535
  }
@@ -511,7 +548,10 @@ export class NifraBackend implements AgentBackend {
511
548
  type: "session.failed",
512
549
  error: agentError(
513
550
  "NATIVE_TURN_FAILED",
514
- boundedText(error instanceof Error ? error.message : error, 4_096),
551
+ boundAgentText(
552
+ boundedText(error instanceof Error ? error.message : error, 4_096) ||
553
+ "native turn failed",
554
+ ),
515
555
  ),
516
556
  recoverable: true,
517
557
  })
@@ -529,11 +569,30 @@ export class NifraBackend implements AgentBackend {
529
569
  ): Promise<NativeModelResponse> {
530
570
  if (isAsyncIterable<NativeModelChunk>(raw)) {
531
571
  let response: NativeModelResponse | undefined
572
+ // Streamed text stops where the final reply is cut, so the deltas never outgrow it.
573
+ let room = this.options.maxReplyChars
532
574
  for await (const chunk of raw) {
533
575
  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
576
+ if (chunk.type === "response") {
577
+ response = chunk.response
578
+ continue
579
+ }
580
+ if (chunk.type === "reasoning_delta") {
581
+ const itemId = isEventToken(chunk.itemId) ? { itemId: chunk.itemId } : {}
582
+ for (const piece of splitAgentText(chunk.text))
583
+ this.emit(session, { type: "reasoning.delta", turnId, ...itemId, text: piece })
584
+ continue
585
+ }
586
+ if (chunk.type === "usage") {
587
+ const usage = nativeUsage(chunk)
588
+ if (usage !== undefined) this.emit(session, { type: "context.usage", turnId, ...usage })
589
+ continue
590
+ }
591
+ if (room <= 0) continue
592
+ const text = chunk.text.slice(0, room)
593
+ room -= text.length
594
+ for (const piece of splitAgentText(text))
595
+ this.emit(session, { type: "assistant.delta", turnId, text: piece })
537
596
  }
538
597
  if (response === undefined) throw new Error("native model stream ended without a response")
539
598
  this.assertCurrentTurn(session, stream, turnId, signal)
@@ -573,13 +632,14 @@ export class NifraBackend implements AgentBackend {
573
632
  session.closed ? "APPROVAL_CLOSED" : "APPROVAL_CANCELLED",
574
633
  )
575
634
  }
576
- const timer = setTimeout(
577
- () => {
635
+ const timeoutMs = this.options.approvalTimeoutMs ?? 5 * 60_000
636
+ let timer: ReturnType<typeof setTimeout> | undefined
637
+ if (timeoutMs !== "never") {
638
+ timer = setTimeout(() => {
578
639
  this.settleApproval(session, approvalId, false, "approval timed out", "APPROVAL_TIMEOUT")
579
- },
580
- this.options.approvalTimeoutMs ?? 5 * 60_000,
581
- )
582
- ;(timer as unknown as { unref?: () => void }).unref?.()
640
+ }, timeoutMs)
641
+ ;(timer as unknown as { unref?: () => void }).unref?.()
642
+ }
583
643
  pending = {
584
644
  id: approvalId,
585
645
  sessionId: session.id,
@@ -692,7 +752,7 @@ export class NifraBackend implements AgentBackend {
692
752
  return
693
753
  const stream = session.active
694
754
  const turnId = session.turnId
695
- const stopReason = boundedText(reason, 512) || "cancelled"
755
+ const stopReason = boundAgentText(boundedText(reason, 512) || "cancelled")
696
756
  session.cancelled = true
697
757
  this.clearTurnSignal(session)
698
758
  try {
@@ -812,6 +872,14 @@ export class NifraBackend implements AgentBackend {
812
872
  }
813
873
  }
814
874
 
875
+ interface ToolStartedPayload {
876
+ type: "tool.started"
877
+ turnId: string
878
+ callId: string
879
+ name: string
880
+ input?: unknown
881
+ }
882
+
815
883
  function messageChars(message: NativeMessage): number {
816
884
  return message.text.length + (message.name?.length ?? 0)
817
885
  }
@@ -875,3 +943,38 @@ function removeActiveTurn(
875
943
  const { activeTurnId: _activeTurnId, ...withoutActiveTurn } = snapshot
876
944
  return withoutActiveTurn
877
945
  }
946
+
947
+ function isEventToken(value: unknown): value is string {
948
+ return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
949
+ }
950
+
951
+ const USAGE_COUNTS = [
952
+ "windowTokens",
953
+ "inputTokens",
954
+ "outputTokens",
955
+ "cachedInputTokens",
956
+ "reasoningTokens",
957
+ ] as const
958
+
959
+ type NativeUsage = {
960
+ -readonly [Key in keyof Omit<Extract<NativeModelChunk, { type: "usage" }>, "type">]: Extract<
961
+ NativeModelChunk,
962
+ { type: "usage" }
963
+ >[Key]
964
+ }
965
+
966
+ // A model port is application code; a count it gets wrong is dropped, never a failed turn.
967
+ function nativeUsage(chunk: Extract<NativeModelChunk, { type: "usage" }>): NativeUsage | undefined {
968
+ if (!isTokenCount(chunk.usedTokens)) return undefined
969
+ const usage: NativeUsage = { usedTokens: chunk.usedTokens }
970
+ for (const key of USAGE_COUNTS) {
971
+ const count = chunk[key]
972
+ if (isTokenCount(count)) usage[key] = count
973
+ }
974
+ if (chunk.estimated === true) usage.estimated = true
975
+ return usage
976
+ }
977
+
978
+ function isTokenCount(value: unknown): value is number {
979
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
980
+ }