@nifrajs/coding-agent 3.2.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context.js +29 -1
- package/dist/context.js.map +1 -1
- package/dist/diff.js +38 -2
- package/dist/diff.js.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +217 -24
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/isolated.js +37 -2
- package/dist/isolated.js.map +1 -1
- package/dist/native.d.ts +17 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +265 -41
- package/dist/native.js.map +1 -1
- package/dist/replay.d.ts +4 -0
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +189 -24
- package/dist/replay.js.map +1 -1
- package/dist/rpc.d.ts +10 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +208 -29
- package/dist/rpc.js.map +1 -1
- package/dist/sessions.d.ts +14 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +253 -59
- package/dist/sessions.js.map +1 -1
- package/dist/verification.d.ts +23 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +113 -1
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts +1 -0
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +43 -21
- package/dist/workflows.js.map +1 -1
- package/package.json +6 -5
- package/src/context.ts +32 -1
- package/src/diff.ts +40 -2
- package/src/host.ts +219 -24
- package/src/index.ts +5 -0
- package/src/isolated.ts +39 -2
- package/src/native.ts +408 -42
- package/src/replay.ts +206 -25
- package/src/rpc.ts +261 -33
- package/src/sessions.ts +271 -56
- package/src/verification.ts +140 -1
- package/src/workflows.ts +37 -25
package/src/native.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
agentError,
|
|
14
14
|
type CreateSessionInput,
|
|
15
15
|
createAgentEventStream,
|
|
16
|
+
isAgentEvent,
|
|
16
17
|
type ReloadResult,
|
|
17
18
|
type SendMessageInput,
|
|
18
19
|
} from "@nifrajs/agent-protocol"
|
|
@@ -102,6 +103,12 @@ function parseNativeModelResponse(value: unknown): NativeModelResponse {
|
|
|
102
103
|
export interface NativeApprovalPort {
|
|
103
104
|
request(input: {
|
|
104
105
|
readonly sessionId: string
|
|
106
|
+
/** Stable, bounded identifier for this approval boundary. */
|
|
107
|
+
readonly approvalId: string
|
|
108
|
+
readonly turnId: string
|
|
109
|
+
readonly callId: string
|
|
110
|
+
readonly action: string
|
|
111
|
+
readonly capability: string
|
|
105
112
|
readonly tool: NativeTool
|
|
106
113
|
readonly input: unknown
|
|
107
114
|
readonly signal: AbortSignal
|
|
@@ -112,11 +119,39 @@ export interface NifraBackendOptions {
|
|
|
112
119
|
readonly model: NativeModelPort
|
|
113
120
|
readonly tools?: readonly NativeTool[]
|
|
114
121
|
readonly approval?: NativeApprovalPort
|
|
122
|
+
/** Maximum time a protocol-visible native approval may remain unresolved. */
|
|
123
|
+
readonly approvalTimeoutMs?: number
|
|
115
124
|
readonly maxSteps?: number
|
|
116
125
|
readonly maxMessageChars?: number
|
|
117
126
|
readonly now?: () => number
|
|
118
127
|
}
|
|
119
128
|
|
|
129
|
+
type NativeApprovalFailureCode =
|
|
130
|
+
| "APPROVAL_DENIED"
|
|
131
|
+
| "APPROVAL_CANCELLED"
|
|
132
|
+
| "APPROVAL_TIMEOUT"
|
|
133
|
+
| "APPROVAL_CLOSED"
|
|
134
|
+
| "APPROVAL_FAILED"
|
|
135
|
+
|
|
136
|
+
interface NativeApprovalResolution {
|
|
137
|
+
readonly approved: boolean
|
|
138
|
+
readonly reason?: string
|
|
139
|
+
readonly errorCode?: NativeApprovalFailureCode
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface NativePendingApproval {
|
|
143
|
+
readonly id: string
|
|
144
|
+
readonly sessionId: string
|
|
145
|
+
readonly turnId: string
|
|
146
|
+
readonly callId: string
|
|
147
|
+
readonly action: string
|
|
148
|
+
readonly capability: string
|
|
149
|
+
readonly resolve: (resolution: NativeApprovalResolution) => void
|
|
150
|
+
readonly timer: ReturnType<typeof setTimeout>
|
|
151
|
+
readonly onAbort: () => void
|
|
152
|
+
readonly signal: AbortSignal
|
|
153
|
+
}
|
|
154
|
+
|
|
120
155
|
interface NativeSession {
|
|
121
156
|
readonly id: string
|
|
122
157
|
readonly cwd: string
|
|
@@ -124,9 +159,12 @@ interface NativeSession {
|
|
|
124
159
|
controller: AbortController
|
|
125
160
|
snapshot: AgentSessionSnapshot
|
|
126
161
|
active: AgentEventStream | undefined
|
|
162
|
+
turnId: string | undefined
|
|
163
|
+
turnAbortCleanup: (() => void) | undefined
|
|
127
164
|
seq: number
|
|
128
165
|
extensionRevision: number
|
|
129
166
|
closed: boolean
|
|
167
|
+
cancelled: boolean
|
|
130
168
|
}
|
|
131
169
|
|
|
132
170
|
/**
|
|
@@ -144,6 +182,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
144
182
|
private readonly options: Required<Pick<NifraBackendOptions, "maxSteps" | "maxMessageChars">> &
|
|
145
183
|
NifraBackendOptions
|
|
146
184
|
private readonly sessions = new Map<string, NativeSession>()
|
|
185
|
+
private readonly pendingApprovals = new Map<string, NativePendingApproval>()
|
|
147
186
|
|
|
148
187
|
constructor(options: NifraBackendOptions) {
|
|
149
188
|
this.options = Object.freeze({
|
|
@@ -160,9 +199,30 @@ export class NifraBackend implements AgentBackend {
|
|
|
160
199
|
throw new RangeError("nifra backend: maxSteps must be between 1 and 512")
|
|
161
200
|
if (!Number.isSafeInteger(this.options.maxMessageChars) || this.options.maxMessageChars < 256)
|
|
162
201
|
throw new RangeError("nifra backend: maxMessageChars must be at least 256")
|
|
202
|
+
if (
|
|
203
|
+
this.options.approvalTimeoutMs !== undefined &&
|
|
204
|
+
(!Number.isSafeInteger(this.options.approvalTimeoutMs) ||
|
|
205
|
+
this.options.approvalTimeoutMs < 1 ||
|
|
206
|
+
this.options.approvalTimeoutMs > 24 * 60 * 60_000)
|
|
207
|
+
)
|
|
208
|
+
throw new RangeError("nifra backend: approvalTimeoutMs must be between 1ms and 24h")
|
|
163
209
|
}
|
|
164
210
|
|
|
165
211
|
async createSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
|
|
212
|
+
if (
|
|
213
|
+
typeof input.cwd !== "string" ||
|
|
214
|
+
input.cwd.length === 0 ||
|
|
215
|
+
input.cwd.length > 4_096 ||
|
|
216
|
+
input.cwd.includes("\0")
|
|
217
|
+
)
|
|
218
|
+
throw new TypeError("nifra backend: cwd must be a bounded non-empty path")
|
|
219
|
+
if (
|
|
220
|
+
input.capabilities !== undefined &&
|
|
221
|
+
(!Array.isArray(input.capabilities) ||
|
|
222
|
+
input.capabilities.length > 256 ||
|
|
223
|
+
!input.capabilities.every((value) => boundedApprovalToken(value)))
|
|
224
|
+
)
|
|
225
|
+
throw new TypeError("nifra backend: capabilities must be bounded tokens")
|
|
166
226
|
const id = input.sessionId ?? crypto.randomUUID()
|
|
167
227
|
if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(id))
|
|
168
228
|
throw new TypeError("nifra backend: sessionId must be a bounded token")
|
|
@@ -186,9 +246,12 @@ export class NifraBackend implements AgentBackend {
|
|
|
186
246
|
controller: new AbortController(),
|
|
187
247
|
snapshot,
|
|
188
248
|
active: undefined,
|
|
249
|
+
turnId: undefined,
|
|
250
|
+
turnAbortCleanup: undefined,
|
|
189
251
|
seq: 0,
|
|
190
252
|
extensionRevision: 0,
|
|
191
253
|
closed: false,
|
|
254
|
+
cancelled: false,
|
|
192
255
|
}
|
|
193
256
|
this.sessions.set(id, session)
|
|
194
257
|
return snapshot
|
|
@@ -205,31 +268,41 @@ export class NifraBackend implements AgentBackend {
|
|
|
205
268
|
)
|
|
206
269
|
const stream = createAgentEventStream()
|
|
207
270
|
session.active = stream
|
|
271
|
+
session.cancelled = false
|
|
208
272
|
session.controller = new AbortController()
|
|
209
273
|
const signal = session.controller.signal
|
|
274
|
+
const turnId = crypto.randomUUID()
|
|
275
|
+
session.turnId = turnId
|
|
210
276
|
if (input.signal !== undefined) {
|
|
211
277
|
const externalSignal = input.signal
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
278
|
+
const onAbort = (): void => {
|
|
279
|
+
// Capture the turn identity. A request signal must never abort a later turn that reused
|
|
280
|
+
// this session after the original stream settled.
|
|
281
|
+
if (
|
|
282
|
+
session.active !== stream ||
|
|
283
|
+
session.turnId !== turnId ||
|
|
284
|
+
session.controller.signal !== signal
|
|
218
285
|
)
|
|
286
|
+
return
|
|
287
|
+
void this.cancelTurn(session, stream, turnId, "cancelled")
|
|
288
|
+
}
|
|
289
|
+
if (externalSignal.aborted) onAbort()
|
|
290
|
+
else {
|
|
291
|
+
externalSignal.addEventListener("abort", onAbort, { once: true })
|
|
292
|
+
session.turnAbortCleanup = () => externalSignal.removeEventListener("abort", onAbort)
|
|
293
|
+
// Abort can race the registration between the check above and addEventListener.
|
|
294
|
+
if (externalSignal.aborted) onAbort()
|
|
295
|
+
}
|
|
219
296
|
}
|
|
220
|
-
void this.run(session, input.message, stream, signal)
|
|
297
|
+
void this.run(session, input.message, stream, signal, turnId)
|
|
221
298
|
return stream
|
|
222
299
|
}
|
|
223
300
|
|
|
224
301
|
async cancel(sessionId: string, reason = "cancelled"): Promise<void> {
|
|
225
302
|
const session = this.requireSession(sessionId)
|
|
226
303
|
if (session.closed) return
|
|
227
|
-
|
|
228
|
-
session.
|
|
229
|
-
this.update(session, "stopped")
|
|
230
|
-
this.emit(session, { type: "session.stopped", reason })
|
|
231
|
-
stream?.complete()
|
|
232
|
-
session.active = undefined
|
|
304
|
+
if (session.active === undefined) return
|
|
305
|
+
await this.cancelTurn(session, session.active, session.turnId, reason)
|
|
233
306
|
}
|
|
234
307
|
|
|
235
308
|
async snapshot(sessionId: string): Promise<AgentSessionSnapshot> {
|
|
@@ -256,13 +329,37 @@ export class NifraBackend implements AgentBackend {
|
|
|
256
329
|
return { revision, loaded: [], disabled: [], rolledBack: false }
|
|
257
330
|
}
|
|
258
331
|
|
|
332
|
+
async resolveApproval(
|
|
333
|
+
sessionId: string,
|
|
334
|
+
approvalId: string,
|
|
335
|
+
approved: boolean,
|
|
336
|
+
reason?: string,
|
|
337
|
+
): Promise<boolean | undefined> {
|
|
338
|
+
const session = this.requireSession(sessionId)
|
|
339
|
+
if (session.closed || session.cancelled) return undefined
|
|
340
|
+
const pending = this.pendingApprovals.get(approvalId)
|
|
341
|
+
if (pending === undefined || pending.sessionId !== sessionId) return undefined
|
|
342
|
+
const resolution = this.settleApproval(
|
|
343
|
+
session,
|
|
344
|
+
approvalId,
|
|
345
|
+
approved === true,
|
|
346
|
+
approved === true ? reason : (reason ?? "approval denied"),
|
|
347
|
+
approved === true ? undefined : "APPROVAL_DENIED",
|
|
348
|
+
)
|
|
349
|
+
return resolution?.approved
|
|
350
|
+
}
|
|
351
|
+
|
|
259
352
|
async close(sessionId: string): Promise<void> {
|
|
260
353
|
const session = this.requireSession(sessionId)
|
|
261
354
|
if (session.closed) return
|
|
262
355
|
session.closed = true
|
|
356
|
+
session.cancelled = true
|
|
357
|
+
session.turnAbortCleanup?.()
|
|
358
|
+
session.turnAbortCleanup = undefined
|
|
263
359
|
session.controller.abort("closed")
|
|
264
360
|
session.active?.complete()
|
|
265
361
|
session.active = undefined
|
|
362
|
+
session.turnId = undefined
|
|
266
363
|
this.sessions.delete(sessionId)
|
|
267
364
|
}
|
|
268
365
|
|
|
@@ -271,14 +368,18 @@ export class NifraBackend implements AgentBackend {
|
|
|
271
368
|
message: string,
|
|
272
369
|
stream: AgentEventStream,
|
|
273
370
|
signal: AbortSignal,
|
|
371
|
+
turnId: string,
|
|
274
372
|
): Promise<void> {
|
|
275
|
-
|
|
373
|
+
if (!this.isCurrentTurn(session, stream, turnId, signal)) {
|
|
374
|
+
stream.complete()
|
|
375
|
+
return
|
|
376
|
+
}
|
|
276
377
|
this.update(session, "running", turnId)
|
|
277
378
|
this.emit(session, { type: "turn.started", turnId, prompt: message })
|
|
278
379
|
session.messages.push({ role: "user", text: message })
|
|
279
380
|
try {
|
|
280
381
|
for (let step = 0; step < this.options.maxSteps; step++) {
|
|
281
|
-
|
|
382
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
282
383
|
const raw = this.options.model.complete({
|
|
283
384
|
sessionId: session.id,
|
|
284
385
|
cwd: session.cwd,
|
|
@@ -292,10 +393,12 @@ export class NifraBackend implements AgentBackend {
|
|
|
292
393
|
),
|
|
293
394
|
signal,
|
|
294
395
|
})
|
|
295
|
-
const response = await this.consumeModel(raw, session, turnId, signal)
|
|
396
|
+
const response = await this.consumeModel(raw, session, stream, turnId, signal)
|
|
397
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
296
398
|
if (response.type === "text") {
|
|
297
|
-
|
|
298
|
-
|
|
399
|
+
const text = boundedText(response.text, this.options.maxMessageChars)
|
|
400
|
+
session.messages.push({ role: "assistant", text })
|
|
401
|
+
this.emit(session, { type: "assistant.message", turnId, text })
|
|
299
402
|
this.finish(session, stream)
|
|
300
403
|
return
|
|
301
404
|
}
|
|
@@ -313,6 +416,7 @@ export class NifraBackend implements AgentBackend {
|
|
|
313
416
|
continue
|
|
314
417
|
}
|
|
315
418
|
const callId = crypto.randomUUID()
|
|
419
|
+
const execute = tool.execute
|
|
316
420
|
this.emit(session, {
|
|
317
421
|
type: "tool.started",
|
|
318
422
|
turnId,
|
|
@@ -324,30 +428,34 @@ export class NifraBackend implements AgentBackend {
|
|
|
324
428
|
typeof tool.requiresApproval === "function"
|
|
325
429
|
? await tool.requiresApproval(response.input)
|
|
326
430
|
: tool.requiresApproval === true
|
|
431
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
327
432
|
if (needsApproval) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
)
|
|
433
|
+
const resolution = await this.requestApproval(
|
|
434
|
+
session,
|
|
435
|
+
turnId,
|
|
436
|
+
callId,
|
|
437
|
+
tool,
|
|
438
|
+
response.input,
|
|
439
|
+
signal,
|
|
440
|
+
)
|
|
441
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
442
|
+
if (!resolution.approved) {
|
|
443
|
+
const code = resolution.errorCode ?? "APPROVAL_DENIED"
|
|
337
444
|
this.emit(session, {
|
|
338
445
|
type: "tool.completed",
|
|
339
446
|
turnId,
|
|
340
447
|
callId,
|
|
341
448
|
name: tool.name,
|
|
342
449
|
ok: false,
|
|
343
|
-
error: agentError(
|
|
450
|
+
error: agentError(code, boundedApprovalMessage(code, tool.name, resolution.reason)),
|
|
344
451
|
})
|
|
345
|
-
session.messages.push({ role: "tool", name: tool.name, text:
|
|
452
|
+
session.messages.push({ role: "tool", name: tool.name, text: code })
|
|
346
453
|
continue
|
|
347
454
|
}
|
|
348
455
|
}
|
|
349
456
|
try {
|
|
350
|
-
const output = await
|
|
457
|
+
const output = await execute(response.input, { cwd: session.cwd, signal })
|
|
458
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
351
459
|
const text = boundedText(output)
|
|
352
460
|
session.messages.push({ role: "tool", name: tool.name, text })
|
|
353
461
|
this.emit(session, {
|
|
@@ -359,7 +467,11 @@ export class NifraBackend implements AgentBackend {
|
|
|
359
467
|
output: text,
|
|
360
468
|
})
|
|
361
469
|
} catch (error) {
|
|
362
|
-
|
|
470
|
+
if (signal.aborted || !this.isCurrentTurn(session, stream, turnId, signal)) throw error
|
|
471
|
+
const message = boundedText(
|
|
472
|
+
error instanceof Error ? error.message : error,
|
|
473
|
+
this.options.maxMessageChars,
|
|
474
|
+
)
|
|
363
475
|
session.messages.push({ role: "tool", name: tool.name, text: message })
|
|
364
476
|
this.emit(session, {
|
|
365
477
|
type: "tool.completed",
|
|
@@ -373,46 +485,255 @@ export class NifraBackend implements AgentBackend {
|
|
|
373
485
|
}
|
|
374
486
|
throw new Error("native turn exceeded maxSteps")
|
|
375
487
|
} catch (error) {
|
|
376
|
-
if (session
|
|
377
|
-
|
|
488
|
+
if (!this.isCurrentTurn(session, stream, turnId, signal)) {
|
|
489
|
+
stream.complete()
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
if (signal.aborted || session.cancelled) {
|
|
493
|
+
await this.cancelTurn(session, stream, turnId, "cancelled")
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
this.update(session, "failed")
|
|
378
497
|
this.emit(session, {
|
|
379
498
|
type: "session.failed",
|
|
380
499
|
error: agentError(
|
|
381
|
-
|
|
382
|
-
error instanceof Error ? error.message :
|
|
500
|
+
"NATIVE_TURN_FAILED",
|
|
501
|
+
boundedText(error instanceof Error ? error.message : error, 4_096),
|
|
383
502
|
),
|
|
384
503
|
recoverable: true,
|
|
385
504
|
})
|
|
386
505
|
stream.complete()
|
|
387
|
-
session
|
|
506
|
+
this.clearTurn(session, stream, turnId)
|
|
388
507
|
}
|
|
389
508
|
}
|
|
390
509
|
|
|
391
510
|
private async consumeModel(
|
|
392
511
|
raw: ReturnType<NativeModelPort["complete"]>,
|
|
393
512
|
session: NativeSession,
|
|
513
|
+
stream: AgentEventStream,
|
|
394
514
|
turnId: string,
|
|
395
515
|
signal: AbortSignal,
|
|
396
516
|
): Promise<NativeModelResponse> {
|
|
397
517
|
if (isAsyncIterable<NativeModelChunk>(raw)) {
|
|
398
518
|
let response: NativeModelResponse | undefined
|
|
399
519
|
for await (const chunk of raw) {
|
|
400
|
-
|
|
520
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
401
521
|
if (chunk.type === "text_delta")
|
|
402
522
|
this.emit(session, { type: "assistant.delta", turnId, text: chunk.text })
|
|
403
523
|
else response = chunk.response
|
|
404
524
|
}
|
|
405
525
|
if (response === undefined) throw new Error("native model stream ended without a response")
|
|
526
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
406
527
|
return response
|
|
407
528
|
}
|
|
408
|
-
|
|
529
|
+
const response = await raw
|
|
530
|
+
this.assertCurrentTurn(session, stream, turnId, signal)
|
|
531
|
+
return response
|
|
409
532
|
}
|
|
410
533
|
|
|
411
534
|
private finish(session: NativeSession, stream: AgentEventStream): void {
|
|
412
535
|
this.update(session, "idle")
|
|
413
536
|
this.emit(session, { type: "session.completed", snapshot: session.snapshot })
|
|
414
537
|
stream.complete()
|
|
415
|
-
session
|
|
538
|
+
this.clearTurn(session, stream, session.turnId)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private requestApproval(
|
|
542
|
+
session: NativeSession,
|
|
543
|
+
turnId: string,
|
|
544
|
+
callId: string,
|
|
545
|
+
tool: NativeTool,
|
|
546
|
+
input: unknown,
|
|
547
|
+
signal: AbortSignal,
|
|
548
|
+
): Promise<NativeApprovalResolution> {
|
|
549
|
+
const approvalId = `nifra-approval-${crypto.randomUUID()}`
|
|
550
|
+
const action = boundedApprovalText(tool.name, 512)
|
|
551
|
+
const capability = boundedApprovalText(tool.capabilities?.[0] ?? `tool:${tool.name}`, 128)
|
|
552
|
+
let pending!: NativePendingApproval
|
|
553
|
+
const promise = new Promise<NativeApprovalResolution>((resolve) => {
|
|
554
|
+
const onAbort = (): void => {
|
|
555
|
+
this.settleApproval(
|
|
556
|
+
session,
|
|
557
|
+
approvalId,
|
|
558
|
+
false,
|
|
559
|
+
session.closed ? "approval closed" : "approval cancelled",
|
|
560
|
+
session.closed ? "APPROVAL_CLOSED" : "APPROVAL_CANCELLED",
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
const timer = setTimeout(
|
|
564
|
+
() => {
|
|
565
|
+
this.settleApproval(session, approvalId, false, "approval timed out", "APPROVAL_TIMEOUT")
|
|
566
|
+
},
|
|
567
|
+
this.options.approvalTimeoutMs ?? 5 * 60_000,
|
|
568
|
+
)
|
|
569
|
+
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
570
|
+
pending = {
|
|
571
|
+
id: approvalId,
|
|
572
|
+
sessionId: session.id,
|
|
573
|
+
turnId,
|
|
574
|
+
callId,
|
|
575
|
+
action,
|
|
576
|
+
capability,
|
|
577
|
+
resolve,
|
|
578
|
+
timer,
|
|
579
|
+
onAbort,
|
|
580
|
+
signal,
|
|
581
|
+
}
|
|
582
|
+
this.pendingApprovals.set(approvalId, pending)
|
|
583
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
this.emit(session, {
|
|
587
|
+
type: "approval.required",
|
|
588
|
+
turnId,
|
|
589
|
+
approvalId,
|
|
590
|
+
action,
|
|
591
|
+
capability,
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
if (signal.aborted) pending.onAbort()
|
|
595
|
+
else if (this.options.approval !== undefined) {
|
|
596
|
+
try {
|
|
597
|
+
void Promise.resolve(
|
|
598
|
+
this.options.approval.request({
|
|
599
|
+
sessionId: session.id,
|
|
600
|
+
approvalId,
|
|
601
|
+
turnId,
|
|
602
|
+
callId,
|
|
603
|
+
action,
|
|
604
|
+
capability,
|
|
605
|
+
tool,
|
|
606
|
+
input,
|
|
607
|
+
signal,
|
|
608
|
+
}),
|
|
609
|
+
).then(
|
|
610
|
+
(approved) =>
|
|
611
|
+
this.settleApproval(
|
|
612
|
+
session,
|
|
613
|
+
approvalId,
|
|
614
|
+
approved === true,
|
|
615
|
+
approved === true ? undefined : "approval denied",
|
|
616
|
+
approved === true ? undefined : "APPROVAL_DENIED",
|
|
617
|
+
),
|
|
618
|
+
() =>
|
|
619
|
+
this.settleApproval(
|
|
620
|
+
session,
|
|
621
|
+
approvalId,
|
|
622
|
+
false,
|
|
623
|
+
"approval callback failed",
|
|
624
|
+
"APPROVAL_FAILED",
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
} catch {
|
|
628
|
+
this.settleApproval(
|
|
629
|
+
session,
|
|
630
|
+
approvalId,
|
|
631
|
+
false,
|
|
632
|
+
"approval callback failed",
|
|
633
|
+
"APPROVAL_FAILED",
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return promise
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private settleApproval(
|
|
641
|
+
session: NativeSession,
|
|
642
|
+
approvalId: string,
|
|
643
|
+
approved: boolean,
|
|
644
|
+
reason: string | undefined,
|
|
645
|
+
errorCode: NativeApprovalFailureCode | undefined,
|
|
646
|
+
): NativeApprovalResolution | undefined {
|
|
647
|
+
const pending = this.pendingApprovals.get(approvalId)
|
|
648
|
+
if (pending === undefined || pending.sessionId !== session.id) return undefined
|
|
649
|
+
clearTimeout(pending.timer)
|
|
650
|
+
pending.signal.removeEventListener("abort", pending.onAbort)
|
|
651
|
+
this.pendingApprovals.delete(approvalId)
|
|
652
|
+
const boundedReason = reason === undefined ? undefined : boundedApprovalText(reason, 512)
|
|
653
|
+
const resolution: NativeApprovalResolution = Object.freeze({
|
|
654
|
+
approved,
|
|
655
|
+
...(boundedReason === undefined ? {} : { reason: boundedReason }),
|
|
656
|
+
...(errorCode === undefined ? {} : { errorCode }),
|
|
657
|
+
})
|
|
658
|
+
this.emit(session, {
|
|
659
|
+
type: "approval.resolved",
|
|
660
|
+
turnId: pending.turnId,
|
|
661
|
+
approvalId,
|
|
662
|
+
approved,
|
|
663
|
+
...(boundedReason === undefined ? {} : { reason: boundedReason }),
|
|
664
|
+
})
|
|
665
|
+
pending.resolve(resolution)
|
|
666
|
+
return resolution
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private async cancelTurn(
|
|
670
|
+
session: NativeSession,
|
|
671
|
+
expectedStream: AgentEventStream | undefined,
|
|
672
|
+
expectedTurnId: string | undefined,
|
|
673
|
+
reason: string,
|
|
674
|
+
): Promise<void> {
|
|
675
|
+
if (
|
|
676
|
+
expectedStream !== undefined &&
|
|
677
|
+
(session.active !== expectedStream || session.turnId !== expectedTurnId)
|
|
678
|
+
)
|
|
679
|
+
return
|
|
680
|
+
const stream = session.active
|
|
681
|
+
const turnId = session.turnId
|
|
682
|
+
const stopReason = boundedText(reason, 512) || "cancelled"
|
|
683
|
+
session.cancelled = true
|
|
684
|
+
this.clearTurnSignal(session)
|
|
685
|
+
try {
|
|
686
|
+
session.controller.abort(stopReason)
|
|
687
|
+
} catch {
|
|
688
|
+
// AbortController.abort is normally infallible; lifecycle cleanup must still complete if a
|
|
689
|
+
// host supplies an unusual controller implementation.
|
|
690
|
+
}
|
|
691
|
+
this.update(session, "stopped")
|
|
692
|
+
this.emit(session, { type: "session.stopped", reason: stopReason })
|
|
693
|
+
stream?.complete()
|
|
694
|
+
this.clearTurn(session, stream, turnId)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private clearTurnSignal(session: NativeSession): void {
|
|
698
|
+
session.turnAbortCleanup?.()
|
|
699
|
+
session.turnAbortCleanup = undefined
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private clearTurn(
|
|
703
|
+
session: NativeSession,
|
|
704
|
+
stream: AgentEventStream | undefined,
|
|
705
|
+
turnId: string | undefined,
|
|
706
|
+
): void {
|
|
707
|
+
if (stream !== undefined && session.active !== stream) return
|
|
708
|
+
if (turnId !== undefined && session.turnId !== turnId) return
|
|
709
|
+
this.clearTurnSignal(session)
|
|
710
|
+
if (stream === undefined || session.active === stream) session.active = undefined
|
|
711
|
+
if (turnId === undefined || session.turnId === turnId) session.turnId = undefined
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private isCurrentTurn(
|
|
715
|
+
session: NativeSession,
|
|
716
|
+
stream: AgentEventStream,
|
|
717
|
+
turnId: string,
|
|
718
|
+
signal: AbortSignal,
|
|
719
|
+
): boolean {
|
|
720
|
+
return (
|
|
721
|
+
!session.closed &&
|
|
722
|
+
!session.cancelled &&
|
|
723
|
+
session.active === stream &&
|
|
724
|
+
session.turnId === turnId &&
|
|
725
|
+
session.controller.signal === signal
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private assertCurrentTurn(
|
|
730
|
+
session: NativeSession,
|
|
731
|
+
stream: AgentEventStream,
|
|
732
|
+
turnId: string,
|
|
733
|
+
signal: AbortSignal,
|
|
734
|
+
): void {
|
|
735
|
+
if (signal.aborted || !this.isCurrentTurn(session, stream, turnId, signal))
|
|
736
|
+
throw new Error("native turn cancelled")
|
|
416
737
|
}
|
|
417
738
|
|
|
418
739
|
private emit(
|
|
@@ -426,6 +747,8 @@ export class NifraBackend implements AgentBackend {
|
|
|
426
747
|
at: this.now(),
|
|
427
748
|
...payload,
|
|
428
749
|
}) as AgentEvent
|
|
750
|
+
if (!isAgentEvent(event))
|
|
751
|
+
throw new Error("nifra backend emitted an event outside protocol bounds")
|
|
429
752
|
session.snapshot = Object.freeze({
|
|
430
753
|
...session.snapshot,
|
|
431
754
|
lastSeq: event.seq,
|
|
@@ -441,7 +764,9 @@ export class NifraBackend implements AgentBackend {
|
|
|
441
764
|
): void {
|
|
442
765
|
const next = { ...session.snapshot, status, updatedAt: this.now(), lastSeq: session.seq }
|
|
443
766
|
session.snapshot = Object.freeze(
|
|
444
|
-
status === "running" && turnId !== undefined
|
|
767
|
+
status === "running" && turnId !== undefined
|
|
768
|
+
? { ...next, activeTurnId: turnId }
|
|
769
|
+
: removeActiveTurn(next),
|
|
445
770
|
)
|
|
446
771
|
if (session.active !== undefined)
|
|
447
772
|
this.emit(session, { type: "session.updated", snapshot: session.snapshot })
|
|
@@ -462,17 +787,58 @@ function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
|
|
|
462
787
|
return value !== null && typeof value === "object" && Symbol.asyncIterator in value
|
|
463
788
|
}
|
|
464
789
|
|
|
465
|
-
function boundedText(value: unknown): string {
|
|
790
|
+
function boundedText(value: unknown, maxChars = 64 * 1024): string {
|
|
466
791
|
try {
|
|
467
792
|
const text = typeof value === "string" ? value : JSON.stringify(value)
|
|
468
|
-
|
|
793
|
+
if (typeof text !== "string") return "[unserializable tool output]"
|
|
794
|
+
const suffix = "…[truncated]"
|
|
795
|
+
return text.length > maxChars
|
|
796
|
+
? `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`
|
|
797
|
+
: text
|
|
469
798
|
} catch {
|
|
470
799
|
return "[unserializable tool output]"
|
|
471
800
|
}
|
|
472
801
|
}
|
|
473
802
|
|
|
803
|
+
function boundedApprovalToken(value: unknown): value is string {
|
|
804
|
+
return typeof value === "string" && /^[A-Za-z0-9._:/-]{1,128}$/.test(value)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function boundedApprovalText(value: string, maxChars: number): string {
|
|
808
|
+
const text = value.trim()
|
|
809
|
+
if (text.length === 0) return "native tool"
|
|
810
|
+
return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function boundedApprovalMessage(
|
|
814
|
+
code: NativeApprovalFailureCode,
|
|
815
|
+
toolName: string,
|
|
816
|
+
reason: string | undefined,
|
|
817
|
+
): string {
|
|
818
|
+
const base =
|
|
819
|
+
code === "APPROVAL_DENIED"
|
|
820
|
+
? "approval denied"
|
|
821
|
+
: code === "APPROVAL_TIMEOUT"
|
|
822
|
+
? "approval timed out"
|
|
823
|
+
: code === "APPROVAL_CANCELLED"
|
|
824
|
+
? "approval cancelled"
|
|
825
|
+
: code === "APPROVAL_CLOSED"
|
|
826
|
+
? "approval closed"
|
|
827
|
+
: "approval failed"
|
|
828
|
+
const name = boundedApprovalText(toolName, 128)
|
|
829
|
+
const suffix = reason === undefined || reason === "approval denied" ? "" : `: ${reason}`
|
|
830
|
+
return boundedApprovalText(`${base} for ${name}${suffix}`, 512)
|
|
831
|
+
}
|
|
832
|
+
|
|
474
833
|
function failedStream(error: unknown): AgentEventStream {
|
|
475
834
|
const stream = createAgentEventStream()
|
|
476
835
|
stream.fail(error)
|
|
477
836
|
return stream
|
|
478
837
|
}
|
|
838
|
+
|
|
839
|
+
function removeActiveTurn(
|
|
840
|
+
snapshot: Omit<AgentSessionSnapshot, "activeTurnId"> & { readonly activeTurnId?: string },
|
|
841
|
+
): Omit<AgentSessionSnapshot, "activeTurnId"> {
|
|
842
|
+
const { activeTurnId: _activeTurnId, ...withoutActiveTurn } = snapshot
|
|
843
|
+
return withoutActiveTurn
|
|
844
|
+
}
|