@nifrajs/coding-agent 3.5.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 (68) 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/handoffs.d.ts.map +1 -1
  12. package/dist/handoffs.js +12 -4
  13. package/dist/handoffs.js.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/native.d.ts +7 -0
  18. package/dist/native.d.ts.map +1 -1
  19. package/dist/native.js +28 -7
  20. package/dist/native.js.map +1 -1
  21. package/dist/orchestration/compile.d.ts +7 -0
  22. package/dist/orchestration/compile.d.ts.map +1 -1
  23. package/dist/orchestration/compile.js +16 -2
  24. package/dist/orchestration/compile.js.map +1 -1
  25. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  26. package/dist/orchestration/evidence-store.js +9 -4
  27. package/dist/orchestration/evidence-store.js.map +1 -1
  28. package/dist/orchestration/host.d.ts +10 -0
  29. package/dist/orchestration/host.d.ts.map +1 -1
  30. package/dist/orchestration/host.js +9 -0
  31. package/dist/orchestration/host.js.map +1 -1
  32. package/dist/rpc.d.ts +1 -1
  33. package/dist/rpc.d.ts.map +1 -1
  34. package/dist/rpc.js +10 -9
  35. package/dist/rpc.js.map +1 -1
  36. package/dist/session-migration.d.ts.map +1 -1
  37. package/dist/session-migration.js +3 -2
  38. package/dist/session-migration.js.map +1 -1
  39. package/dist/sessions.d.ts +6 -0
  40. package/dist/sessions.d.ts.map +1 -1
  41. package/dist/sessions.js +17 -2
  42. package/dist/sessions.js.map +1 -1
  43. package/dist/subagents.d.ts +30 -0
  44. package/dist/subagents.d.ts.map +1 -1
  45. package/dist/subagents.js +118 -15
  46. package/dist/subagents.js.map +1 -1
  47. package/dist/verification.d.ts +5 -1
  48. package/dist/verification.d.ts.map +1 -1
  49. package/dist/verification.js +6 -2
  50. package/dist/verification.js.map +1 -1
  51. package/dist/workflows.d.ts.map +1 -1
  52. package/dist/workflows.js +12 -2
  53. package/dist/workflows.js.map +1 -1
  54. package/package.json +9 -6
  55. package/src/approvals.ts +62 -45
  56. package/src/cli.ts +11 -2
  57. package/src/handoffs.ts +20 -8
  58. package/src/index.ts +2 -0
  59. package/src/native.ts +41 -8
  60. package/src/orchestration/compile.ts +28 -2
  61. package/src/orchestration/evidence-store.ts +9 -3
  62. package/src/orchestration/host.ts +17 -0
  63. package/src/rpc.ts +14 -13
  64. package/src/session-migration.ts +3 -2
  65. package/src/sessions.ts +18 -2
  66. package/src/subagents.ts +147 -17
  67. package/src/verification.ts +6 -2
  68. package/src/workflows.ts +10 -2
package/src/approvals.ts CHANGED
@@ -52,10 +52,16 @@ export interface ApprovalManagerOptions {
52
52
 
53
53
  interface PendingApproval {
54
54
  readonly request: ApprovalRequest
55
- readonly resolve: (approved: boolean) => void
55
+ /** Settles with the decision; it exists before `onRequired` runs, so no decision can miss it. */
56
+ readonly decided: Promise<boolean>
57
+ readonly settle: (approved: boolean) => void
56
58
  readonly timer: ReturnType<typeof setTimeout>
57
59
  }
58
60
 
61
+ /** The agent protocol's token alphabet, so every protocol-valid id is accepted here too. */
62
+ const ID_TOKEN = /^[A-Za-z0-9._:/-]{1,128}$/
63
+ const MAX_ACTION_LENGTH = 512
64
+
59
65
  /**
60
66
  * Small approval broker shared by RPC, Workbench, and workflow extensions.
61
67
  *
@@ -89,38 +95,37 @@ export class ApprovalManager {
89
95
 
90
96
  /** Convert a streamed backend approval event into a resolvable pending request. */
91
97
  async observe(event: AgentApprovalRequiredEvent): Promise<ApprovalRequest | undefined> {
92
- if (this.pendingApprovals.has(event.approvalId))
93
- return this.pendingApprovals.get(event.approvalId)?.request
94
- return this.create({
98
+ const existing = this.pendingApprovals.get(event.approvalId)
99
+ if (existing !== undefined) return existing.request
100
+ const pending = await this.create({
95
101
  id: event.approvalId,
96
102
  sessionId: event.sessionId,
97
103
  turnId: event.turnId,
98
- action: event.action,
104
+ // The protocol bounds text more loosely; a long label must not fail the backend's turn.
105
+ action: event.action.slice(0, MAX_ACTION_LENGTH),
99
106
  capability: event.capability,
100
107
  ...(event.reason === undefined ? {} : { reason: event.reason }),
101
108
  })
109
+ return pending?.request
102
110
  }
103
111
 
104
- /** Publish a pending approval without waiting for its decision (useful for transports). */
112
+ /**
113
+ * Publish a pending approval without waiting for its decision (useful for transports). Returns
114
+ * `undefined` when the broker is full or an approval with the same id is still pending.
115
+ */
105
116
  async offer(
106
117
  input: Omit<ApprovalRequest, "createdAt" | "expiresAt">,
107
118
  ): Promise<ApprovalRequest | undefined> {
108
- return this.create(input)
119
+ return (await this.create(input))?.request
109
120
  }
110
121
 
111
- /** Create a host-owned approval that can be awaited by a workflow or extension. */
122
+ /**
123
+ * Create a host-owned approval that can be awaited by a workflow or extension. Resolves `false`
124
+ * when the broker is full or an approval with the same id is still pending.
125
+ */
112
126
  async request(input: Omit<ApprovalRequest, "createdAt" | "expiresAt">): Promise<boolean> {
113
- const request = await this.offer(input)
114
- if (request === undefined) return false
115
- const pending = this.pendingApprovals.get(request.id)
116
- if (pending === undefined) return false
117
- return new Promise<boolean>((resolve) => {
118
- // Replace the resolver only for this host-owned request; observed backend requests are
119
- // intentionally notification-only and are resolved through `resolve`.
120
- const current = this.pendingApprovals.get(request.id)
121
- if (current === undefined) return resolve(false)
122
- this.pendingApprovals.set(request.id, { ...current, resolve })
123
- })
127
+ const pending = await this.create(input)
128
+ return pending === undefined ? false : pending.decided
124
129
  }
125
130
 
126
131
  /**
@@ -145,49 +150,68 @@ export class ApprovalManager {
145
150
  code: bound.vector !== coordinate.vector ? "stale_vector" : "identity_mismatch",
146
151
  }
147
152
  if (!coordinateIsFresh(bound, now)) {
148
- this.resolve(coordinate.requestId, false, "approval expired")
153
+ this.settle(coordinate.requestId, false, "approval expired")
149
154
  return { ok: false, code: "expired" }
150
155
  }
151
156
  if (nextApprovalState("pending", approved ? "approve" : "deny") === undefined)
152
157
  return { ok: false, code: "illegal_transition" }
153
- const decision = this.resolve(coordinate.requestId, approved)
158
+ const decision = this.settle(coordinate.requestId, approved)
154
159
  if (decision === undefined) return { ok: false, code: "unknown_boundary" }
155
160
  return { ok: true, decision }
156
161
  }
157
162
 
163
+ /**
164
+ * Settle a pending approval by id. An approval opened with a coordinate is approved only through
165
+ * {@link resolveMatched}; this untyped path can still deny it, and returns `undefined` for an
166
+ * approve.
167
+ */
158
168
  resolve(approvalId: string, approved: boolean, reason?: string): ApprovalDecision | undefined {
169
+ if (
170
+ approved === true &&
171
+ this.pendingApprovals.get(approvalId)?.request.coordinate !== undefined
172
+ )
173
+ return undefined
174
+ return this.settle(approvalId, approved, reason)
175
+ }
176
+
177
+ close(): void {
178
+ for (const approvalId of this.pendingApprovals.keys())
179
+ this.settle(approvalId, false, "approval manager closed")
180
+ }
181
+
182
+ private settle(
183
+ approvalId: string,
184
+ approved: boolean,
185
+ reason?: string,
186
+ ): ApprovalDecision | undefined {
159
187
  const pending = this.pendingApprovals.get(approvalId)
160
188
  if (pending === undefined) return undefined
161
189
  clearTimeout(pending.timer)
162
190
  this.pendingApprovals.delete(approvalId)
163
- pending.resolve(approved === true)
191
+ pending.settle(approved === true)
164
192
  const decision: ApprovalDecision = {
165
193
  approvalId,
166
194
  approved: approved === true,
167
195
  ...(reason === undefined ? {} : { reason: reason.slice(0, 512) }),
168
196
  at: Date.now(),
169
197
  }
170
- void this.options.onResolved?.(decision)
198
+ // A failing broadcast must not become an unhandled rejection in the host.
199
+ Promise.resolve(this.options.onResolved?.(decision)).catch(() => {})
171
200
  return decision
172
201
  }
173
202
 
174
- close(): void {
175
- for (const approvalId of this.pendingApprovals.keys())
176
- this.resolve(approvalId, false, "approval manager closed")
177
- }
178
-
179
203
  private async create(
180
204
  input: Omit<ApprovalRequest, "createdAt" | "expiresAt">,
181
- ): Promise<ApprovalRequest | undefined> {
205
+ ): Promise<PendingApproval | undefined> {
182
206
  if (this.pendingApprovals.size >= this.options.maxPending) return undefined
183
- if (!/^[A-Za-z0-9._:-]{1,128}$/.test(input.id))
184
- throw new TypeError("approvals: approval id is invalid")
185
- if (!/^[A-Za-z0-9._:-]{1,128}$/.test(input.sessionId))
186
- throw new TypeError("approvals: session id is invalid")
187
- if (!input.action || input.action.length > 512)
207
+ if (!ID_TOKEN.test(input.id)) throw new TypeError("approvals: approval id is invalid")
208
+ if (!ID_TOKEN.test(input.sessionId)) throw new TypeError("approvals: session id is invalid")
209
+ if (!input.action || input.action.length > MAX_ACTION_LENGTH)
188
210
  throw new TypeError("approvals: action is empty or too long")
189
211
  if (!input.capability || input.capability.length > 128)
190
212
  throw new TypeError("approvals: capability is empty or too long")
213
+ // Replacing a pending id would orphan the waiter of the request already holding it.
214
+ if (this.pendingApprovals.has(input.id)) return undefined
191
215
  const createdAt = Date.now()
192
216
  const request: ApprovalRequest = Object.freeze({
193
217
  ...input,
@@ -197,22 +221,15 @@ export class ApprovalManager {
197
221
  createdAt,
198
222
  expiresAt: createdAt + this.options.timeoutMs,
199
223
  })
200
- let resolveApproval = (_approved: boolean): void => {}
224
+ const { promise: decided, resolve: settle } = Promise.withResolvers<boolean>()
201
225
  const timer = setTimeout(() => {
202
- this.resolve(request.id, false, "approval timed out")
226
+ this.settle(request.id, false, "approval timed out")
203
227
  }, this.options.timeoutMs)
204
228
  ;(timer as unknown as { unref?: () => void }).unref?.()
205
- const pending: PendingApproval = {
206
- request,
207
- resolve: (approved) => resolveApproval(approved),
208
- timer,
209
- }
229
+ const pending: PendingApproval = { request, decided, settle, timer }
210
230
  this.pendingApprovals.set(request.id, pending)
211
231
  await this.options.onRequired?.(request)
212
- // A backend-observed request is resolved through `resolve`; a host request swaps this closure
213
- // from the promise below before returning to the workflow.
214
- resolveApproval = (_approved: boolean): void => {}
215
- return request
232
+ return pending
216
233
  }
217
234
  }
218
235
 
package/src/cli.ts CHANGED
@@ -25,10 +25,13 @@ Usage:
25
25
  nifra-agent [--backend pi|replay] [--cwd <dir>] [--once <prompt>]
26
26
  [--json] [--no-session] [--session-dir <dir>] [--session-id <id>]
27
27
  [--verify-after-turn check,assure] [--max-repair-attempts <n>]
28
- [--pi <command>] [--replay <file>]
28
+ [--pi <command>] [--replay <file>] [--extensions]
29
29
  nifra-agent --migrate-session <id> --migrate-from <dir> --migrate-to <dir> [--json]
30
30
  nifra-agent --rpc [--cwd <dir>] [--host 127.0.0.1] [--port 0] [--expose-error-stacks]
31
31
 
32
+ --extensions loads and runs .nifra/extensions/** from --cwd. That is the project's own code: pass it
33
+ only for a repository you trust. Without it no extension is loaded.
34
+
32
35
  Commands in interactive mode:
33
36
  /reload reload Pi or Nifra extensions
34
37
  /compact compact the bounded prompt window
@@ -64,6 +67,7 @@ export interface CliOptions {
64
67
  readonly migrationTarget?: string
65
68
  readonly verifyAfterTurn: readonly ("check" | "assure" | "test")[]
66
69
  readonly maxRepairAttempts: number
70
+ readonly extensions: boolean
67
71
  }
68
72
 
69
73
  export function parseArgs(args: readonly string[]): CliOptions {
@@ -72,6 +76,7 @@ export function parseArgs(args: readonly string[]): CliOptions {
72
76
  let once: string | undefined
73
77
  let json = false
74
78
  let noSession = false
79
+ let extensions = false
75
80
  let piCommand = "pi"
76
81
  let replayFile: string | undefined
77
82
  let sessionDir: string | undefined
@@ -93,6 +98,7 @@ export function parseArgs(args: readonly string[]): CliOptions {
93
98
  else if (arg === "--once" || arg === "--message") once = args[++index]
94
99
  else if (arg === "--json") json = true
95
100
  else if (arg === "--no-session") noSession = true
101
+ else if (arg === "--extensions") extensions = true
96
102
  else if (arg === "--session-dir") sessionDir = resolve(args[++index] ?? "")
97
103
  else if (arg === "--session-id") sessionId = args[++index]
98
104
  else if (arg === "--migrate-session") migrateSession = args[++index]
@@ -142,6 +148,7 @@ export function parseArgs(args: readonly string[]): CliOptions {
142
148
  ...(once === undefined ? {} : { once }),
143
149
  json,
144
150
  noSession,
151
+ extensions,
145
152
  piCommand,
146
153
  ...(replayFile === undefined ? {} : { replayFile }),
147
154
  ...(sessionDir === undefined ? {} : { sessionDir }),
@@ -215,7 +222,9 @@ async function main(): Promise<void> {
215
222
  enableNifraTools: true,
216
223
  ...(options.sessionDir === undefined ? {} : { sessionDir: options.sessionDir }),
217
224
  })
218
- const extensionRoots = await discoverExtensions(options.cwd)
225
+ // A module's top-level code runs as soon as it is imported, before any capability check can refuse
226
+ // it, so a cloned repository's extensions load only when the user asks.
227
+ const extensionRoots = options.extensions ? await discoverExtensions(options.cwd) : []
219
228
  const extensions = new ExtensionHost({
220
229
  cwd: options.cwd,
221
230
  roots: extensionRoots,
package/src/handoffs.ts CHANGED
@@ -27,6 +27,8 @@ import type { ApprovalManager } from "./approvals.ts"
27
27
  import { ChildVectorTracker } from "./orchestration/policy.ts"
28
28
 
29
29
  const ID_TOKEN = /^[A-Za-z0-9._:-]{1,128}$/
30
+ /** The agent protocol's token alphabet, which the approval broker accepts for session ids. */
31
+ const SESSION_TOKEN = /^[A-Za-z0-9._:/-]{1,128}$/
30
32
 
31
33
  /** Stable, content-free reasons the coordinator refuses a handoff request or decision. */
32
34
  export type HandoffRejection =
@@ -136,7 +138,8 @@ export class HandoffCoordinator {
136
138
  !input.capability ||
137
139
  input.capability.length > 128 ||
138
140
  !input.from ||
139
- input.from.length > 128
141
+ input.from.length > 128 ||
142
+ (input.sessionId !== undefined && !SESSION_TOKEN.test(input.sessionId))
140
143
  )
141
144
  throw new HandoffError("invalid_handoff")
142
145
  if (this.records.has(input.requestId)) throw new HandoffError("duplicate")
@@ -162,13 +165,18 @@ export class HandoffCoordinator {
162
165
  }
163
166
  this.records.set(input.requestId, record)
164
167
 
168
+ // The mirror carries the boundary's coordinate, so only a matched decision approves it. It is a
169
+ // notification: a broker that is full or fails to broadcast leaves the handoff itself intact.
165
170
  if (record.requireApproval && this.approvals !== undefined)
166
- void this.approvals.offer({
167
- id: input.requestId,
168
- sessionId: input.sessionId ?? input.runId,
169
- action: "handoff",
170
- capability: input.capability,
171
- })
171
+ this.approvals
172
+ .offer({
173
+ id: input.requestId,
174
+ sessionId: input.sessionId ?? input.runId,
175
+ action: "handoff",
176
+ capability: input.capability,
177
+ coordinate,
178
+ })
179
+ .catch(() => {})
172
180
  return this.view(record)
173
181
  }
174
182
 
@@ -264,7 +272,11 @@ export class HandoffCoordinator {
264
272
 
265
273
  private settleApproval(record: HandoffRecord, op: string): void {
266
274
  if (!record.requireApproval || this.approvals === undefined) return
267
- this.approvals.resolve(record.coordinate.requestId, op === "accept" || op === "resolve")
275
+ this.approvals.resolveMatched(
276
+ record.coordinate,
277
+ op === "accept" || op === "resolve",
278
+ this.now(),
279
+ )
268
280
  }
269
281
 
270
282
  private isTerminal(state: HandoffLifecycleState): boolean {
package/src/index.ts CHANGED
@@ -133,6 +133,8 @@ export {
133
133
  } from "./sessions.ts"
134
134
  export {
135
135
  BoundedSubagentRunner,
136
+ type SubagentAbandonment,
137
+ type SubagentAbandonmentLedger,
136
138
  type SubagentExecutor,
137
139
  type SubagentResult,
138
140
  type SubagentRunnerOptions,
package/src/native.ts CHANGED
@@ -123,6 +123,11 @@ export interface NifraBackendOptions {
123
123
  readonly approvalTimeoutMs?: number
124
124
  readonly maxSteps?: number
125
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
126
131
  readonly now?: () => number
127
132
  }
128
133
 
@@ -156,6 +161,7 @@ interface NativeSession {
156
161
  readonly id: string
157
162
  readonly cwd: string
158
163
  readonly messages: NativeMessage[]
164
+ historyChars: number
159
165
  controller: AbortController
160
166
  snapshot: AgentSessionSnapshot
161
167
  active: AgentEventStream | undefined
@@ -179,7 +185,9 @@ export class NifraBackend implements AgentBackend {
179
185
  name: "nifra",
180
186
  capabilities: Object.freeze(["sessions", "extensions", "reload", "approvals", "streaming"]),
181
187
  })
182
- private readonly options: Required<Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars">> &
188
+ private readonly options: Required<
189
+ Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars" | "maxHistoryChars">
190
+ > &
183
191
  NifraBackendOptions
184
192
  private readonly sessions = new Map<string, NativeSession>()
185
193
  private readonly pendingApprovals = new Map<string, NativePendingApproval>()
@@ -189,6 +197,7 @@ export class NifraBackend implements AgentBackend {
189
197
  ...options,
190
198
  maxSteps: options.maxSteps ?? 32,
191
199
  maxMessageChars: options.maxMessageChars ?? 64 * 1024,
200
+ maxHistoryChars: options.maxHistoryChars ?? 1024 * 1024,
192
201
  tools: Object.freeze([...(options.tools ?? [])]),
193
202
  })
194
203
  if (
@@ -199,6 +208,8 @@ export class NifraBackend implements AgentBackend {
199
208
  throw new RangeError("nifra backend: maxSteps must be between 1 and 512")
200
209
  if (!Number.isSafeInteger(this.options.maxMessageChars) || this.options.maxMessageChars < 256)
201
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")
202
213
  if (
203
214
  this.options.approvalTimeoutMs !== undefined &&
204
215
  (!Number.isSafeInteger(this.options.approvalTimeoutMs) ||
@@ -243,6 +254,7 @@ export class NifraBackend implements AgentBackend {
243
254
  id,
244
255
  cwd: input.cwd,
245
256
  messages: [],
257
+ historyChars: 0,
246
258
  controller: new AbortController(),
247
259
  snapshot,
248
260
  active: undefined,
@@ -376,14 +388,15 @@ export class NifraBackend implements AgentBackend {
376
388
  }
377
389
  this.update(session, "running", turnId)
378
390
  this.emit(session, { type: "turn.started", turnId, prompt: message })
379
- session.messages.push({ role: "user", text: message })
391
+ this.remember(session, { role: "user", text: message })
380
392
  try {
381
393
  for (let step = 0; step < this.options.maxSteps; step++) {
382
394
  this.assertCurrentTurn(session, stream, turnId, signal)
395
+ this.trimHistory(session)
383
396
  const raw = this.options.model.complete({
384
397
  sessionId: session.id,
385
398
  cwd: session.cwd,
386
- messages: Object.freeze(session.messages.map((item) => Object.freeze({ ...item }))),
399
+ messages: Object.freeze(session.messages.slice()),
387
400
  tools: Object.freeze(
388
401
  this.options.tools!.map((tool) => ({
389
402
  name: tool.name,
@@ -397,7 +410,7 @@ export class NifraBackend implements AgentBackend {
397
410
  this.assertCurrentTurn(session, stream, turnId, signal)
398
411
  if (response.type === "text") {
399
412
  const text = boundedText(response.text, this.options.maxMessageChars)
400
- session.messages.push({ role: "assistant", text })
413
+ this.remember(session, { role: "assistant", text })
401
414
  this.emit(session, { type: "assistant.message", turnId, text })
402
415
  this.finish(session, stream)
403
416
  return
@@ -412,7 +425,7 @@ export class NifraBackend implements AgentBackend {
412
425
  ok: false,
413
426
  error: agentError("UNKNOWN_TOOL", `unknown native tool: ${response.name}`),
414
427
  })
415
- session.messages.push({ role: "tool", name: response.name, text: "unknown tool" })
428
+ this.remember(session, { role: "tool", name: response.name, text: "unknown tool" })
416
429
  continue
417
430
  }
418
431
  const callId = crypto.randomUUID()
@@ -449,7 +462,7 @@ export class NifraBackend implements AgentBackend {
449
462
  ok: false,
450
463
  error: agentError(code, boundedApprovalMessage(code, tool.name, resolution.reason)),
451
464
  })
452
- session.messages.push({ role: "tool", name: tool.name, text: code })
465
+ this.remember(session, { role: "tool", name: tool.name, text: code })
453
466
  continue
454
467
  }
455
468
  }
@@ -457,7 +470,7 @@ export class NifraBackend implements AgentBackend {
457
470
  const output = await execute(response.input, { cwd: session.cwd, signal })
458
471
  this.assertCurrentTurn(session, stream, turnId, signal)
459
472
  const text = boundedText(output)
460
- session.messages.push({ role: "tool", name: tool.name, text })
473
+ this.remember(session, { role: "tool", name: tool.name, text })
461
474
  this.emit(session, {
462
475
  type: "tool.completed",
463
476
  turnId,
@@ -472,7 +485,7 @@ export class NifraBackend implements AgentBackend {
472
485
  error instanceof Error ? error.message : error,
473
486
  this.options.maxMessageChars,
474
487
  )
475
- session.messages.push({ role: "tool", name: tool.name, text: message })
488
+ this.remember(session, { role: "tool", name: tool.name, text: message })
476
489
  this.emit(session, {
477
490
  type: "tool.completed",
478
491
  turnId,
@@ -781,6 +794,26 @@ export class NifraBackend implements AgentBackend {
781
794
  private now(): number {
782
795
  return this.options.now?.() ?? Date.now()
783
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)
784
817
  }
785
818
 
786
819
  function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
@@ -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)
package/src/rpc.ts CHANGED
@@ -162,17 +162,19 @@ export class CodingAgentRpcServer {
162
162
  const url = new URL(request.url)
163
163
  if (url.pathname === "/health" && request.method === "GET")
164
164
  return this.response({ ok: true, protocol: 1 }, 200, cors)
165
- const backoffMs = this.authBackoffMs()
166
- if (backoffMs > 0) {
167
- const throttledCors = new Headers(cors)
168
- throttledCors.set("retry-after", String(Math.ceil(backoffMs / 1000)))
169
- return this.response(
170
- { error: { code: "auth_throttled", message: "too many failed authorization attempts" } },
171
- 429,
172
- throttledCors,
173
- )
174
- }
175
165
  if (!authorized(request, this.token)) {
166
+ // Only failed attempts are throttled. A caller holding the token is served during a backoff,
167
+ // or anyone who can reach the port could keep the real client locked out with wrong guesses.
168
+ const backoffMs = this.authBackoffMs()
169
+ if (backoffMs > 0) {
170
+ const throttledCors = new Headers(cors)
171
+ throttledCors.set("retry-after", String(Math.ceil(backoffMs / 1000)))
172
+ return this.response(
173
+ { error: { code: "auth_throttled", message: "too many failed authorization attempts" } },
174
+ 429,
175
+ throttledCors,
176
+ )
177
+ }
176
178
  this.noteAuthFailure()
177
179
  return this.response(
178
180
  { error: { code: "unauthorized", message: "agent RPC authorization required" } },
@@ -180,7 +182,6 @@ export class CodingAgentRpcServer {
180
182
  cors,
181
183
  )
182
184
  }
183
- this.noteAuthSuccess()
184
185
  if (url.pathname !== "/rpc" || request.method !== "POST")
185
186
  return this.response(
186
187
  { error: { code: "not_found", message: "unknown agent RPC endpoint" } },
@@ -635,7 +636,7 @@ export class CodingAgentRpcServer {
635
636
  const now = Date.now()
636
637
  if (this.authFailures === 0) return 0
637
638
  if (now - this.authLastFailureAt > AUTH_FAILURE_WINDOW_MS) {
638
- this.noteAuthSuccess()
639
+ this.resetAuthFailures()
639
640
  return 0
640
641
  }
641
642
  return Math.max(0, this.authBackoffUntil - now)
@@ -653,7 +654,7 @@ export class CodingAgentRpcServer {
653
654
  }
654
655
  }
655
656
 
656
- private noteAuthSuccess(): void {
657
+ private resetAuthFailures(): void {
657
658
  this.authFailures = 0
658
659
  this.authLastFailureAt = 0
659
660
  this.authBackoffUntil = 0