@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.
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/approvals.d.ts +30 -2
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +43 -12
- package/dist/approvals.js.map +1 -1
- package/dist/cli.d.ts +13 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +120 -100
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +9 -21
- package/dist/context.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +8 -21
- package/dist/diff.js.map +1 -1
- package/dist/extensions.js +4 -2
- package/dist/extensions.js.map +1 -1
- package/dist/handoffs.d.ts +5 -0
- package/dist/handoffs.d.ts.map +1 -1
- package/dist/handoffs.js +27 -0
- package/dist/handoffs.js.map +1 -1
- package/dist/host.d.ts +15 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +152 -37
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/isolated.js +3 -2
- package/dist/isolated.js.map +1 -1
- package/dist/native.d.ts +29 -2
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +86 -26
- package/dist/native.js.map +1 -1
- package/dist/orchestration/dispatch.d.ts +5 -1
- package/dist/orchestration/dispatch.d.ts.map +1 -1
- package/dist/orchestration/dispatch.js +1 -1
- package/dist/orchestration/dispatch.js.map +1 -1
- package/dist/orchestration/evidence-store.d.ts.map +1 -1
- package/dist/orchestration/evidence-store.js +5 -6
- package/dist/orchestration/evidence-store.js.map +1 -1
- package/dist/orchestration/host.d.ts +14 -0
- package/dist/orchestration/host.d.ts.map +1 -1
- package/dist/orchestration/host.js +26 -0
- package/dist/orchestration/host.js.map +1 -1
- package/dist/orchestration/index.d.ts +1 -1
- package/dist/orchestration/index.d.ts.map +1 -1
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/recovery.d.ts +5 -1
- package/dist/orchestration/recovery.d.ts.map +1 -1
- package/dist/orchestration/recovery.js +5 -3
- package/dist/orchestration/recovery.js.map +1 -1
- package/dist/process.d.ts +22 -1
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +75 -1
- package/dist/process.js.map +1 -1
- package/dist/rpc.d.ts +29 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +205 -46
- package/dist/rpc.js.map +1 -1
- package/dist/session-migration.d.ts.map +1 -1
- package/dist/session-migration.js +30 -6
- package/dist/session-migration.js.map +1 -1
- package/dist/sessions.d.ts +13 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +104 -24
- package/dist/sessions.js.map +1 -1
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +13 -2
- package/dist/subagents.js.map +1 -1
- package/dist/turn-replay.d.ts +40 -0
- package/dist/turn-replay.d.ts.map +1 -0
- package/dist/turn-replay.js +136 -0
- package/dist/turn-replay.js.map +1 -0
- package/dist/ui.js +84 -30
- package/dist/ui.js.map +1 -1
- package/dist/verification.d.ts +7 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +66 -63
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts +5 -0
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +2 -1
- package/dist/workflows.js.map +1 -1
- package/package.json +6 -6
- package/src/approvals.ts +70 -16
- package/src/cli.ts +125 -93
- package/src/context.ts +12 -20
- package/src/diff.ts +11 -23
- package/src/extensions.ts +6 -2
- package/src/handoffs.ts +27 -0
- package/src/host.ts +165 -34
- package/src/index.ts +3 -0
- package/src/isolated.ts +3 -2
- package/src/native.ts +137 -34
- package/src/orchestration/dispatch.ts +6 -2
- package/src/orchestration/evidence-store.ts +5 -6
- package/src/orchestration/host.ts +44 -0
- package/src/orchestration/index.ts +1 -0
- package/src/orchestration/recovery.ts +14 -8
- package/src/process.ts +89 -0
- package/src/rpc.ts +268 -49
- package/src/session-migration.ts +29 -5
- package/src/sessions.ts +115 -29
- package/src/subagents.ts +11 -2
- package/src/turn-replay.ts +151 -0
- package/src/ui.ts +90 -32
- package/src/verification.ts +74 -60
- package/src/workflows.ts +7 -1
package/src/rpc.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AgentBackend,
|
|
3
3
|
type AgentSessionSnapshot,
|
|
4
|
+
type CreateSessionInput,
|
|
4
5
|
type ReloadResult,
|
|
5
6
|
resumeFromCursor,
|
|
6
7
|
} from "@nifrajs/agent-protocol"
|
|
@@ -10,6 +11,7 @@ import type { ExtensionHost } from "./extensions.ts"
|
|
|
10
11
|
import { CodingAgentHost, type CodingAgentHostOptions } from "./host.ts"
|
|
11
12
|
import { readBoundedText } from "./process.ts"
|
|
12
13
|
import type { ContextWindowOptions, SessionStore } from "./sessions.ts"
|
|
14
|
+
import { ReplayableTurn, type TurnReplayLimits } from "./turn-replay.ts"
|
|
13
15
|
import type { UiExtensionHost, UiExtensionManifest } from "./ui.ts"
|
|
14
16
|
import { isSafeReviewDiff, runNifraReview, runNifraVerification } from "./verification.ts"
|
|
15
17
|
|
|
@@ -34,6 +36,27 @@ export interface CodingAgentRpcServerOptions {
|
|
|
34
36
|
readonly verifyAfterTurn?: readonly ("check" | "assure" | "test")[]
|
|
35
37
|
readonly maxRepairAttempts?: number
|
|
36
38
|
readonly verification?: CodingAgentHostOptions["verification"]
|
|
39
|
+
readonly extensionEvents?: CodingAgentHostOptions["extensionEvents"]
|
|
40
|
+
/**
|
|
41
|
+
* The host's approval broker: how long an approval or a tool's question waits for its answer
|
|
42
|
+
* (`timeoutMs`, default 5 minutes, or `"never"`), how many may wait (`maxPending`, default 32),
|
|
43
|
+
* and its `onRequired`/`onResolved` hooks.
|
|
44
|
+
*/
|
|
45
|
+
readonly approvals?: CodingAgentHostOptions["approvals"]
|
|
46
|
+
/**
|
|
47
|
+
* Keep a `turn.send` running when its client disconnects, and let a client that comes back read
|
|
48
|
+
* what it missed with `turn.attach` (an `afterSeq` cursor, or `Last-Event-ID`). The newest
|
|
49
|
+
* `replayEvents` frames, up to `replayBytes`, are held (defaults 1024 and 8 MiB); a turn no client
|
|
50
|
+
* has followed for `detachedTimeoutMs` (default 2 minutes) is cancelled. A client that stops reading
|
|
51
|
+
* is dropped once it falls `replayBytes` behind what is held, and can attach again from its cursor.
|
|
52
|
+
*/
|
|
53
|
+
readonly resumableTurns?:
|
|
54
|
+
| boolean
|
|
55
|
+
| {
|
|
56
|
+
readonly replayEvents?: number
|
|
57
|
+
readonly replayBytes?: number
|
|
58
|
+
readonly detachedTimeoutMs?: number
|
|
59
|
+
}
|
|
37
60
|
}
|
|
38
61
|
|
|
39
62
|
export interface CodingAgentRpcServerHandle {
|
|
@@ -60,6 +83,13 @@ const MIN_REMOTE_TOKEN_BYTES = 32
|
|
|
60
83
|
const AUTH_FAILURE_WINDOW_MS = 60_000
|
|
61
84
|
const AUTH_FAILURE_THRESHOLD = 3
|
|
62
85
|
const AUTH_MAX_BACKOFF_MS = 2_000
|
|
86
|
+
const MAX_REPLAY_EVENTS = 65_536
|
|
87
|
+
const MAX_REPLAY_BYTES = 256 * 1024 * 1024
|
|
88
|
+
const MAX_DETACHED_TIMEOUT_MS = 24 * 60 * 60 * 1000
|
|
89
|
+
|
|
90
|
+
interface ResumableTurnConfig extends TurnReplayLimits {
|
|
91
|
+
readonly detachedTimeoutMs: number
|
|
92
|
+
}
|
|
63
93
|
|
|
64
94
|
/**
|
|
65
95
|
* Minimal loopback RPC surface for the CLI, Workbench, CI clients, and a future mobile companion.
|
|
@@ -76,6 +106,11 @@ export class CodingAgentRpcServer {
|
|
|
76
106
|
private authFailures = 0
|
|
77
107
|
private authLastFailureAt = 0
|
|
78
108
|
private authBackoffUntil = 0
|
|
109
|
+
private creating: Promise<AgentSessionSnapshot> | undefined
|
|
110
|
+
private readonly resumable: ResumableTurnConfig | undefined
|
|
111
|
+
private turn: ReplayableTurn | undefined
|
|
112
|
+
private turnController: AbortController | undefined
|
|
113
|
+
private detachedTimer: ReturnType<typeof setTimeout> | undefined
|
|
79
114
|
|
|
80
115
|
constructor(options: CodingAgentRpcServerOptions) {
|
|
81
116
|
this.options = Object.freeze({ ...options })
|
|
@@ -92,7 +127,12 @@ export class CodingAgentRpcServer {
|
|
|
92
127
|
: { maxRepairAttempts: options.maxRepairAttempts }),
|
|
93
128
|
exposeErrorStacks: options.exposeErrorStacks === true,
|
|
94
129
|
...(options.verification === undefined ? {} : { verification: options.verification }),
|
|
130
|
+
...(options.extensionEvents === undefined
|
|
131
|
+
? {}
|
|
132
|
+
: { extensionEvents: options.extensionEvents }),
|
|
133
|
+
...(options.approvals === undefined ? {} : { approvals: options.approvals }),
|
|
95
134
|
})
|
|
135
|
+
this.resumable = resumableTurnConfig(options.resumableTurns)
|
|
96
136
|
this.token = options.authToken ?? randomAuthToken()
|
|
97
137
|
if (!/^[A-Za-z0-9._~-]{16,256}$/.test(this.token))
|
|
98
138
|
throw new TypeError("agent rpc: authToken must be a bounded token")
|
|
@@ -147,6 +187,8 @@ export class CodingAgentRpcServer {
|
|
|
147
187
|
async stop(): Promise<void> {
|
|
148
188
|
const listener = this.listener
|
|
149
189
|
this.listener = undefined
|
|
190
|
+
clearTimeout(this.detachedTimer)
|
|
191
|
+
this.turnController?.abort()
|
|
150
192
|
try {
|
|
151
193
|
await this.host.stop("rpc server stopped")
|
|
152
194
|
} finally {
|
|
@@ -247,18 +289,16 @@ export class CodingAgentRpcServer {
|
|
|
247
289
|
422,
|
|
248
290
|
cors,
|
|
249
291
|
)
|
|
250
|
-
const snapshot =
|
|
251
|
-
this.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}))
|
|
257
|
-
return json(snapshot, 200, cors)
|
|
292
|
+
const snapshot = await this.ensureSession({
|
|
293
|
+
cwd: this.options.cwd,
|
|
294
|
+
backend: this.options.backend.info.name,
|
|
295
|
+
...(params.sessionId === undefined ? {} : { sessionId: params.sessionId }),
|
|
296
|
+
})
|
|
297
|
+
return json(this.advertised(snapshot), 200, cors)
|
|
258
298
|
}
|
|
259
299
|
case "session.snapshot": {
|
|
260
300
|
const snapshot = this.requireSnapshot()
|
|
261
|
-
return json(snapshot, 200, cors)
|
|
301
|
+
return json(this.advertised(snapshot), 200, cors)
|
|
262
302
|
}
|
|
263
303
|
case "session.resume": {
|
|
264
304
|
const params = record(request.params)
|
|
@@ -268,14 +308,12 @@ export class CodingAgentRpcServer {
|
|
|
268
308
|
422,
|
|
269
309
|
cors,
|
|
270
310
|
)
|
|
271
|
-
const snapshot =
|
|
272
|
-
this.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}))
|
|
278
|
-
return json(snapshot, 200, cors)
|
|
311
|
+
const snapshot = await this.ensureSession({
|
|
312
|
+
cwd: this.options.cwd,
|
|
313
|
+
backend: this.options.backend.info.name,
|
|
314
|
+
sessionId: params.sessionId,
|
|
315
|
+
})
|
|
316
|
+
return json(this.advertised(snapshot), 200, cors)
|
|
279
317
|
}
|
|
280
318
|
case "session.events": {
|
|
281
319
|
const snapshot = this.requireSnapshot()
|
|
@@ -299,7 +337,11 @@ export class CodingAgentRpcServer {
|
|
|
299
337
|
422,
|
|
300
338
|
cors,
|
|
301
339
|
)
|
|
302
|
-
return json(
|
|
340
|
+
return json(
|
|
341
|
+
{ snapshot: this.advertised(snapshot), resume: resumeFromCursor(window, cursor) },
|
|
342
|
+
200,
|
|
343
|
+
cors,
|
|
344
|
+
)
|
|
303
345
|
}
|
|
304
346
|
case "session.checkpoint": {
|
|
305
347
|
this.requireSnapshot()
|
|
@@ -335,42 +377,38 @@ export class CodingAgentRpcServer {
|
|
|
335
377
|
422,
|
|
336
378
|
cors,
|
|
337
379
|
)
|
|
380
|
+
if (this.resumable !== undefined)
|
|
381
|
+
return this.sendResumableTurn(params.message, this.resumable, cors)
|
|
338
382
|
const stream = this.host.prompt(params.message, httpRequest.signal)
|
|
339
383
|
const sessionId = this.host.snapshot?.id
|
|
340
384
|
const encoder = new TextEncoder()
|
|
341
385
|
const includeErrorStacks = this.options.exposeErrorStacks === true
|
|
342
386
|
const maxResponseBytes = this.maxResponseBytes
|
|
387
|
+
// Pulled, not pushed: the turn advances only as the client reads, so one that stops reading
|
|
388
|
+
// holds the backend still instead of the server queueing the rest of the turn for it.
|
|
389
|
+
const events = stream[Symbol.asyncIterator]()
|
|
343
390
|
let finished = false
|
|
344
|
-
const turnError = (error: unknown) => ({
|
|
345
|
-
code: "turn_failed",
|
|
346
|
-
...publicErrorDetails(error, "turn could not be completed", includeErrorStacks),
|
|
347
|
-
})
|
|
348
391
|
const body = new ReadableStream<Uint8Array>({
|
|
349
|
-
async
|
|
392
|
+
async pull(controller) {
|
|
393
|
+
if (finished) return
|
|
350
394
|
try {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (serializedByteLength(`data: ${serialized}\n\n`) > maxResponseBytes)
|
|
355
|
-
throw new Error("turn event exceeded the configured response limit")
|
|
356
|
-
controller.enqueue(encoder.encode(`data: ${serialized}\n\n`))
|
|
357
|
-
}
|
|
358
|
-
if (!finished) {
|
|
395
|
+
const next = await events.next()
|
|
396
|
+
if (finished) return
|
|
397
|
+
if (next.done === true) {
|
|
359
398
|
finished = true
|
|
360
399
|
controller.close()
|
|
400
|
+
return
|
|
361
401
|
}
|
|
402
|
+
const frame = eventFrame(next.value, includeErrorStacks)
|
|
403
|
+
if (serializedByteLength(frame) > maxResponseBytes)
|
|
404
|
+
throw new Error("turn event exceeded the configured response limit")
|
|
405
|
+
controller.enqueue(encoder.encode(frame))
|
|
362
406
|
} catch (error) {
|
|
363
407
|
if (finished) return
|
|
364
408
|
finished = true
|
|
365
409
|
try {
|
|
366
|
-
const detail = JSON.stringify(turnError(error))
|
|
367
|
-
const frame = `event: error\ndata: ${detail}\n\n`
|
|
368
410
|
controller.enqueue(
|
|
369
|
-
encoder.encode(
|
|
370
|
-
serializedByteLength(frame) <= maxResponseBytes
|
|
371
|
-
? frame
|
|
372
|
-
: 'event: error\ndata: {"code":"turn_failed","message":"turn could not be completed"}\n\n',
|
|
373
|
-
),
|
|
411
|
+
encoder.encode(turnErrorFrame(error, includeErrorStacks, maxResponseBytes)),
|
|
374
412
|
)
|
|
375
413
|
controller.close()
|
|
376
414
|
} catch {
|
|
@@ -382,19 +420,55 @@ export class CodingAgentRpcServer {
|
|
|
382
420
|
cancel: async () => {
|
|
383
421
|
if (finished) return
|
|
384
422
|
finished = true
|
|
423
|
+
void events.return?.()
|
|
385
424
|
if (sessionId !== undefined)
|
|
386
425
|
await this.host.backend.cancel(sessionId, "client disconnected").catch(() => {})
|
|
387
426
|
},
|
|
388
427
|
})
|
|
389
|
-
return
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
428
|
+
return eventStreamResponse(body, cors)
|
|
429
|
+
}
|
|
430
|
+
case "turn.attach": {
|
|
431
|
+
if (this.resumable === undefined)
|
|
432
|
+
return json(
|
|
433
|
+
{
|
|
434
|
+
error: {
|
|
435
|
+
code: "feature_unsupported",
|
|
436
|
+
message: "this server does not keep turns for clients that come back",
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
501,
|
|
440
|
+
cors,
|
|
441
|
+
)
|
|
442
|
+
const params = record(request.params)
|
|
443
|
+
const header = httpRequest.headers.get("last-event-id")
|
|
444
|
+
const raw = params.afterSeq ?? (header === null || header === "" ? undefined : header)
|
|
445
|
+
const cursor = raw === undefined ? undefined : Number(raw)
|
|
446
|
+
if (cursor !== undefined && (!Number.isSafeInteger(cursor) || cursor < -1))
|
|
447
|
+
return json(
|
|
448
|
+
{ error: { code: "invalid_cursor", message: "afterSeq must be a safe integer >= -1" } },
|
|
449
|
+
422,
|
|
450
|
+
cors,
|
|
451
|
+
)
|
|
452
|
+
const turn = this.turn
|
|
453
|
+
if (turn === undefined)
|
|
454
|
+
return json(
|
|
455
|
+
{ error: { code: "no_turn", message: "no turn has run on this server" } },
|
|
456
|
+
404,
|
|
457
|
+
cors,
|
|
458
|
+
)
|
|
459
|
+
const stream = turn.attach(cursor)
|
|
460
|
+
if (stream === undefined)
|
|
461
|
+
return json(
|
|
462
|
+
{
|
|
463
|
+
error: {
|
|
464
|
+
code: "resync_required",
|
|
465
|
+
message: "the events after that seq are no longer held; read session.events",
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
409,
|
|
469
|
+
cors,
|
|
470
|
+
)
|
|
471
|
+
return eventStreamResponse(stream, cors)
|
|
398
472
|
}
|
|
399
473
|
case "session.reload": {
|
|
400
474
|
const result: ReloadResult = await this.host.reload()
|
|
@@ -406,7 +480,12 @@ export class CodingAgentRpcServer {
|
|
|
406
480
|
}
|
|
407
481
|
case "approval.list": {
|
|
408
482
|
this.requireSnapshot()
|
|
409
|
-
|
|
483
|
+
// `approvalId` is the name `approval.resolve` takes; `id` stays for existing readers.
|
|
484
|
+
const pending = this.host.pendingApprovals.map((request) => ({
|
|
485
|
+
...request,
|
|
486
|
+
approvalId: request.id,
|
|
487
|
+
}))
|
|
488
|
+
return json({ pending }, 200, cors)
|
|
410
489
|
}
|
|
411
490
|
case "approval.request": {
|
|
412
491
|
const params = record(request.params)
|
|
@@ -543,7 +622,13 @@ export class CodingAgentRpcServer {
|
|
|
543
622
|
: {}),
|
|
544
623
|
exposeErrorStacks: this.options.exposeErrorStacks === true,
|
|
545
624
|
})
|
|
546
|
-
return json(
|
|
625
|
+
return json(
|
|
626
|
+
// A Map serialises to `{}`; the wire carries the values as a plain object.
|
|
627
|
+
{ ...result, values: Object.fromEntries(result.values) },
|
|
628
|
+
result.ok ? 200 : 422,
|
|
629
|
+
cors,
|
|
630
|
+
this.options.exposeErrorStacks === true,
|
|
631
|
+
)
|
|
547
632
|
}
|
|
548
633
|
case "verification.run": {
|
|
549
634
|
const params = record(request.params)
|
|
@@ -627,6 +712,76 @@ export class CodingAgentRpcServer {
|
|
|
627
712
|
}
|
|
628
713
|
}
|
|
629
714
|
|
|
715
|
+
private sendResumableTurn(message: string, config: ResumableTurnConfig, cors: Headers): Response {
|
|
716
|
+
if (this.turn !== undefined && !this.turn.done)
|
|
717
|
+
return this.response(
|
|
718
|
+
{
|
|
719
|
+
error: {
|
|
720
|
+
code: "turn_running",
|
|
721
|
+
message: "a turn is already running; follow it with turn.attach",
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
409,
|
|
725
|
+
cors,
|
|
726
|
+
)
|
|
727
|
+
clearTimeout(this.detachedTimer)
|
|
728
|
+
const controller = new AbortController()
|
|
729
|
+
const turn = new ReplayableTurn(this.turn?.latestSeq ?? -1, config, (count) => {
|
|
730
|
+
clearTimeout(this.detachedTimer)
|
|
731
|
+
if (count > 0 || this.turn !== turn) return
|
|
732
|
+
this.detachedTimer = setTimeout(() => {
|
|
733
|
+
if (this.turn !== turn || turn.done || turn.following > 0) return
|
|
734
|
+
controller.abort()
|
|
735
|
+
const sessionId = this.host.snapshot?.id
|
|
736
|
+
if (sessionId !== undefined)
|
|
737
|
+
void this.host.backend.cancel(sessionId, "no client followed the turn").catch(() => {})
|
|
738
|
+
}, config.detachedTimeoutMs)
|
|
739
|
+
})
|
|
740
|
+
this.turn = turn
|
|
741
|
+
this.turnController = controller
|
|
742
|
+
const stream = turn.follow()
|
|
743
|
+
const includeErrorStacks = this.options.exposeErrorStacks === true
|
|
744
|
+
const maxResponseBytes = this.maxResponseBytes
|
|
745
|
+
const encoder = new TextEncoder()
|
|
746
|
+
void (async () => {
|
|
747
|
+
try {
|
|
748
|
+
for await (const event of this.host.prompt(message, controller.signal)) {
|
|
749
|
+
const frame = eventFrame(event, includeErrorStacks)
|
|
750
|
+
if (serializedByteLength(frame) > maxResponseBytes)
|
|
751
|
+
throw new Error("turn event exceeded the configured response limit")
|
|
752
|
+
turn.push(event.seq, encoder.encode(frame))
|
|
753
|
+
}
|
|
754
|
+
turn.end()
|
|
755
|
+
} catch (error) {
|
|
756
|
+
turn.end(encoder.encode(turnErrorFrame(error, includeErrorStacks, maxResponseBytes)))
|
|
757
|
+
} finally {
|
|
758
|
+
if (this.turn === turn) clearTimeout(this.detachedTimer)
|
|
759
|
+
}
|
|
760
|
+
})()
|
|
761
|
+
return eventStreamResponse(stream, cors)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** The active session, or one started from `input`; concurrent requests share a single start. */
|
|
765
|
+
private async ensureSession(input: CreateSessionInput): Promise<AgentSessionSnapshot> {
|
|
766
|
+
const active = this.host.snapshot
|
|
767
|
+
if (active !== undefined) return active
|
|
768
|
+
this.creating ??= this.host.start(input).finally(() => {
|
|
769
|
+
this.creating = undefined
|
|
770
|
+
})
|
|
771
|
+
return await this.creating
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** The backend's capabilities plus the Agent App features this server answers itself. */
|
|
775
|
+
private advertised(snapshot: AgentSessionSnapshot): AgentSessionSnapshot {
|
|
776
|
+
const { backend, sessionStore, extensions } = this.options
|
|
777
|
+
const served = ["approvals", "resume"]
|
|
778
|
+
if (backend.checkpoint !== undefined || sessionStore !== undefined) served.push("checkpoint")
|
|
779
|
+
if (backend.fork !== undefined || sessionStore !== undefined) served.push("fork")
|
|
780
|
+
if (extensions !== undefined) served.push("workflows")
|
|
781
|
+
const capabilities = [...new Set([...snapshot.capabilities, ...served])]
|
|
782
|
+
return Object.freeze({ ...snapshot, capabilities: Object.freeze(capabilities) })
|
|
783
|
+
}
|
|
784
|
+
|
|
630
785
|
private requireSnapshot(): AgentSessionSnapshot {
|
|
631
786
|
if (this.host.snapshot === undefined) throw new Error("agent RPC session has not been created")
|
|
632
787
|
return this.host.snapshot
|
|
@@ -811,6 +966,70 @@ function json(
|
|
|
811
966
|
return new Response(body, { status: responseStatus, headers })
|
|
812
967
|
}
|
|
813
968
|
|
|
969
|
+
function resumableTurnConfig(
|
|
970
|
+
option: CodingAgentRpcServerOptions["resumableTurns"],
|
|
971
|
+
): ResumableTurnConfig | undefined {
|
|
972
|
+
if (option === undefined || option === false) return undefined
|
|
973
|
+
const settings = option === true ? {} : option
|
|
974
|
+
const config: ResumableTurnConfig = {
|
|
975
|
+
maxEvents: settings.replayEvents ?? 1024,
|
|
976
|
+
maxBytes: settings.replayBytes ?? 8 * 1024 * 1024,
|
|
977
|
+
detachedTimeoutMs: settings.detachedTimeoutMs ?? 120_000,
|
|
978
|
+
}
|
|
979
|
+
if (
|
|
980
|
+
!Number.isSafeInteger(config.maxEvents) ||
|
|
981
|
+
config.maxEvents < 1 ||
|
|
982
|
+
config.maxEvents > MAX_REPLAY_EVENTS
|
|
983
|
+
)
|
|
984
|
+
throw new RangeError(`agent rpc: replayEvents must be between 1 and ${MAX_REPLAY_EVENTS}`)
|
|
985
|
+
if (
|
|
986
|
+
!Number.isSafeInteger(config.maxBytes) ||
|
|
987
|
+
config.maxBytes < 1024 ||
|
|
988
|
+
config.maxBytes > MAX_REPLAY_BYTES
|
|
989
|
+
)
|
|
990
|
+
throw new RangeError(`agent rpc: replayBytes must be between 1024 and ${MAX_REPLAY_BYTES}`)
|
|
991
|
+
if (
|
|
992
|
+
!Number.isSafeInteger(config.detachedTimeoutMs) ||
|
|
993
|
+
config.detachedTimeoutMs < 1 ||
|
|
994
|
+
config.detachedTimeoutMs > MAX_DETACHED_TIMEOUT_MS
|
|
995
|
+
)
|
|
996
|
+
throw new RangeError(
|
|
997
|
+
`agent rpc: detachedTimeoutMs must be between 1 and ${MAX_DETACHED_TIMEOUT_MS}`,
|
|
998
|
+
)
|
|
999
|
+
return Object.freeze(config)
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function eventFrame(event: { readonly seq: number }, includeErrorStacks: boolean): string {
|
|
1003
|
+
return `id: ${event.seq}\ndata: ${serializeRpcValue(event, includeErrorStacks)}\n\n`
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function turnErrorFrame(
|
|
1007
|
+
error: unknown,
|
|
1008
|
+
includeErrorStacks: boolean,
|
|
1009
|
+
maxResponseBytes: number,
|
|
1010
|
+
): string {
|
|
1011
|
+
const detail = JSON.stringify({
|
|
1012
|
+
code: "turn_failed",
|
|
1013
|
+
...publicErrorDetails(error, "turn could not be completed", includeErrorStacks),
|
|
1014
|
+
})
|
|
1015
|
+
const frame = `event: error\ndata: ${detail}\n\n`
|
|
1016
|
+
return serializedByteLength(frame) <= maxResponseBytes
|
|
1017
|
+
? frame
|
|
1018
|
+
: 'event: error\ndata: {"code":"turn_failed","message":"turn could not be completed"}\n\n'
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function eventStreamResponse(body: ReadableStream<Uint8Array>, cors: Headers): Response {
|
|
1022
|
+
return new Response(body, {
|
|
1023
|
+
status: 200,
|
|
1024
|
+
headers: new Headers({
|
|
1025
|
+
...Object.fromEntries(cors),
|
|
1026
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
1027
|
+
"cache-control": "no-cache, no-transform",
|
|
1028
|
+
connection: "keep-alive",
|
|
1029
|
+
}),
|
|
1030
|
+
})
|
|
1031
|
+
}
|
|
1032
|
+
|
|
814
1033
|
function serializeRpcValue(value: unknown, includeErrorStacks: boolean): string {
|
|
815
1034
|
const serialized = JSON.stringify(
|
|
816
1035
|
value,
|
package/src/session-migration.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createReadStream } from "node:fs"
|
|
2
|
+
import { access, mkdir, rename, unlink, writeFile } from "node:fs/promises"
|
|
2
3
|
import { basename, join, resolve } from "node:path"
|
|
3
4
|
import { sessionFileStem } from "./sessions.ts"
|
|
4
5
|
|
|
@@ -164,7 +165,7 @@ export async function migrateLegacySession(
|
|
|
164
165
|
const targetFile = join(targetRoot, `${sessionFileStem(sessionId)}.jsonl`)
|
|
165
166
|
await commitTarget(targetRoot, targetFile, content, options.signal)
|
|
166
167
|
// Re-read the committed target and validate every line before the caller treats this as active.
|
|
167
|
-
const committed = await readEvidenceTarget(targetFile, sessionId)
|
|
168
|
+
const committed = await readEvidenceTarget(targetFile, sessionId, Buffer.byteLength(content))
|
|
168
169
|
if (committed.records.length !== records.length || (await sha256Hex(committed.raw)) !== digest)
|
|
169
170
|
throw new SessionMigrationError("target_validation", "committed evidence target changed")
|
|
170
171
|
for (let index = 0; index < committed.records.length; index++) {
|
|
@@ -225,8 +226,8 @@ async function readLegacyEntries(
|
|
|
225
226
|
const path = join(sourceRoot, `${sessionFileStem(sessionId)}.jsonl`)
|
|
226
227
|
let text: string
|
|
227
228
|
try {
|
|
228
|
-
const bytes = await
|
|
229
|
-
if (bytes
|
|
229
|
+
const bytes = await readCapped(path, maxSourceBytes)
|
|
230
|
+
if (bytes === undefined)
|
|
230
231
|
throw new SessionMigrationError("source_too_large", "legacy session exceeds maxSourceBytes")
|
|
231
232
|
text = bytes.toString("utf8")
|
|
232
233
|
} catch (error) {
|
|
@@ -306,11 +307,34 @@ async function commitTarget(
|
|
|
306
307
|
}
|
|
307
308
|
}
|
|
308
309
|
|
|
310
|
+
/** The file's bytes, or `undefined` as soon as it passes `maxBytes`, without reading the rest. */
|
|
311
|
+
async function readCapped(path: string, maxBytes: number): Promise<Buffer | undefined> {
|
|
312
|
+
const chunks: Buffer[] = []
|
|
313
|
+
let total = 0
|
|
314
|
+
// Sequential reads, not a start/end range: a positioned read fails on a pipe.
|
|
315
|
+
const stream = createReadStream(path, { highWaterMark: Math.min(maxBytes + 1, 64 * 1024) })
|
|
316
|
+
try {
|
|
317
|
+
for await (const chunk of stream) {
|
|
318
|
+
const bytes: Buffer = chunk
|
|
319
|
+
total += bytes.byteLength
|
|
320
|
+
if (total > maxBytes) return undefined
|
|
321
|
+
chunks.push(bytes)
|
|
322
|
+
}
|
|
323
|
+
} finally {
|
|
324
|
+
stream.destroy()
|
|
325
|
+
}
|
|
326
|
+
return Buffer.concat(chunks, total)
|
|
327
|
+
}
|
|
328
|
+
|
|
309
329
|
async function readEvidenceTarget(
|
|
310
330
|
path: string,
|
|
311
331
|
sessionId: string,
|
|
332
|
+
expectedBytes: number,
|
|
312
333
|
): Promise<{ readonly records: readonly SessionEvidenceRecord[]; readonly raw: string }> {
|
|
313
|
-
const
|
|
334
|
+
const bytes = await readCapped(path, expectedBytes)
|
|
335
|
+
if (bytes === undefined)
|
|
336
|
+
throw new SessionMigrationError("target_validation", "committed evidence target changed")
|
|
337
|
+
const raw = bytes.toString("utf8")
|
|
314
338
|
const records: SessionEvidenceRecord[] = []
|
|
315
339
|
let previousSeq = -1
|
|
316
340
|
for (const line of raw.split("\n")) {
|