@nifrajs/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.
- package/README.md +12 -2
- package/dist/deployment.d.ts +14 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +56 -9
- package/dist/deployment.js.map +1 -1
- package/dist/events.d.ts +10 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +43 -11
- package/dist/events.js.map +1 -1
- package/dist/execution-policy.d.ts +10 -0
- package/dist/execution-policy.d.ts.map +1 -1
- package/dist/execution-policy.js +89 -10
- package/dist/execution-policy.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +41 -1
- package/dist/index.js.map +1 -1
- package/dist/mount.d.ts +9 -1
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +38 -8
- package/dist/mount.js.map +1 -1
- package/package.json +5 -2
- package/src/deployment.ts +58 -9
- package/src/events.ts +44 -10
- package/src/execution-policy.ts +90 -10
- package/src/index.ts +47 -1
- package/src/mount.ts +50 -7
package/src/deployment.ts
CHANGED
|
@@ -452,6 +452,8 @@ export class AgentDeployment {
|
|
|
452
452
|
private handleRef: string | undefined
|
|
453
453
|
private report: DeploymentCapabilityReport | undefined
|
|
454
454
|
private readonly evidence: DeploymentEvidence[] = []
|
|
455
|
+
private queue: Promise<unknown> = Promise.resolve()
|
|
456
|
+
private starting: AbortController | undefined
|
|
455
457
|
|
|
456
458
|
constructor(
|
|
457
459
|
private readonly adapter: AgentDeploymentAdapter,
|
|
@@ -486,13 +488,25 @@ export class AgentDeployment {
|
|
|
486
488
|
}
|
|
487
489
|
}
|
|
488
490
|
|
|
489
|
-
|
|
491
|
+
prepare(planInput: AgentDeploymentPlan | unknown): Promise<DeploymentPrepareResult> {
|
|
492
|
+
return this.serial(() => this.prepareNow(planInput))
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private async prepareNow(planInput: unknown): Promise<DeploymentPrepareResult> {
|
|
490
496
|
if (this.state !== "new") return this.fail("invalid_transition")
|
|
491
|
-
|
|
497
|
+
let plan: AgentDeploymentPlan
|
|
498
|
+
try {
|
|
499
|
+
plan = parseDeploymentPlan(planInput)
|
|
500
|
+
} catch (error) {
|
|
501
|
+
return this.fail(asDeploymentError(error, "invalid_plan").code)
|
|
502
|
+
}
|
|
492
503
|
this.deploymentId = plan.deploymentId
|
|
493
504
|
if (this.signal.aborted) return this.fail("cancelled")
|
|
494
|
-
|
|
495
|
-
|
|
505
|
+
try {
|
|
506
|
+
assertCapabilityPlan(await this.capabilityReport(), plan, this.authority, this.approved)
|
|
507
|
+
} catch (error) {
|
|
508
|
+
return this.fail(asDeploymentError(error, "invalid_capabilities").code)
|
|
509
|
+
}
|
|
496
510
|
try {
|
|
497
511
|
const result = parsePrepare(
|
|
498
512
|
await this.adapter.prepare({ plan, authority: this.authority, signal: this.signal }),
|
|
@@ -507,7 +521,11 @@ export class AgentDeployment {
|
|
|
507
521
|
}
|
|
508
522
|
}
|
|
509
523
|
|
|
510
|
-
|
|
524
|
+
start(): Promise<DeploymentStartResult> {
|
|
525
|
+
return this.serial(() => this.startNow())
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private async startNow(): Promise<DeploymentStartResult> {
|
|
511
529
|
if (
|
|
512
530
|
this.state !== "prepared" ||
|
|
513
531
|
this.preparedRef === undefined ||
|
|
@@ -515,13 +533,15 @@ export class AgentDeployment {
|
|
|
515
533
|
)
|
|
516
534
|
return this.fail("invalid_transition")
|
|
517
535
|
if (this.signal.aborted) return this.fail("cancelled")
|
|
536
|
+
const starting = new AbortController()
|
|
537
|
+
this.starting = starting
|
|
518
538
|
try {
|
|
519
539
|
const result = parseStart(
|
|
520
540
|
await this.adapter.start({
|
|
521
541
|
deploymentId: this.deploymentId,
|
|
522
542
|
preparedRef: this.preparedRef,
|
|
523
543
|
authority: this.authority,
|
|
524
|
-
signal: this.signal,
|
|
544
|
+
signal: AbortSignal.any([this.signal, starting.signal]),
|
|
525
545
|
}),
|
|
526
546
|
this.deploymentId,
|
|
527
547
|
)
|
|
@@ -530,11 +550,18 @@ export class AgentDeployment {
|
|
|
530
550
|
this.evidence.push({ kind: "started", deploymentId: this.deploymentId, state: "running" })
|
|
531
551
|
return result
|
|
532
552
|
} catch (error) {
|
|
553
|
+
if (starting.signal.aborted) return this.fail("cancelled")
|
|
533
554
|
return this.fail(asDeploymentError(error, "adapter_error").code)
|
|
555
|
+
} finally {
|
|
556
|
+
this.starting = undefined
|
|
534
557
|
}
|
|
535
558
|
}
|
|
536
559
|
|
|
537
|
-
|
|
560
|
+
inspect(): Promise<DeploymentInspection> {
|
|
561
|
+
return this.serial(() => this.inspectNow())
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private async inspectNow(): Promise<DeploymentInspection> {
|
|
538
565
|
if (this.state === "new" || this.state === "disposed" || this.deploymentId === undefined)
|
|
539
566
|
return this.fail("invalid_transition")
|
|
540
567
|
try {
|
|
@@ -557,7 +584,16 @@ export class AgentDeployment {
|
|
|
557
584
|
}
|
|
558
585
|
}
|
|
559
586
|
|
|
560
|
-
|
|
587
|
+
/**
|
|
588
|
+
* Cancel the deployment. A start still in flight is asked to stop through its signal, and the
|
|
589
|
+
* cancel runs once it settles, against the workload it left running, if any.
|
|
590
|
+
*/
|
|
591
|
+
cancel(): Promise<DeploymentCancelResult> {
|
|
592
|
+
this.starting?.abort()
|
|
593
|
+
return this.serial(() => this.cancelNow())
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private async cancelNow(): Promise<DeploymentCancelResult> {
|
|
561
597
|
if ((this.state !== "prepared" && this.state !== "running") || this.deploymentId === undefined)
|
|
562
598
|
return this.fail("invalid_transition")
|
|
563
599
|
try {
|
|
@@ -579,7 +615,13 @@ export class AgentDeployment {
|
|
|
579
615
|
}
|
|
580
616
|
}
|
|
581
617
|
|
|
582
|
-
|
|
618
|
+
/** Dispose of the deployment; like {@link cancel}, it first asks a start in flight to stop. */
|
|
619
|
+
dispose(): Promise<DeploymentDisposeResult> {
|
|
620
|
+
this.starting?.abort()
|
|
621
|
+
return this.serial(() => this.disposeNow())
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private async disposeNow(): Promise<DeploymentDisposeResult> {
|
|
583
625
|
if (this.state === "disposed")
|
|
584
626
|
return { deploymentId: this.deploymentId ?? this.id, state: "disposed" }
|
|
585
627
|
try {
|
|
@@ -605,6 +647,13 @@ export class AgentDeployment {
|
|
|
605
647
|
}
|
|
606
648
|
}
|
|
607
649
|
|
|
650
|
+
/** Lifecycle calls run one at a time, so each one sees the state the previous one left. */
|
|
651
|
+
private serial<T>(operation: () => Promise<T>): Promise<T> {
|
|
652
|
+
const run = this.queue.then(operation)
|
|
653
|
+
this.queue = run.catch(() => {})
|
|
654
|
+
return run
|
|
655
|
+
}
|
|
656
|
+
|
|
608
657
|
private fail(code: DeploymentErrorCode): never {
|
|
609
658
|
this.evidence.push({
|
|
610
659
|
kind: "failed",
|
package/src/events.ts
CHANGED
|
@@ -123,8 +123,28 @@ export interface AgentEvidenceLog {
|
|
|
123
123
|
): AgentEvidenceReplay | undefined | Promise<AgentEvidenceReplay | undefined>
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* A view of `log` holding one owner's turns: every turn id is recorded and replayed under `owner`
|
|
128
|
+
* (the caller's user or tenant id), so another owner's reconnect or reused turn id finds none of
|
|
129
|
+
* them. The HTTP seams apply it per request through their `evidenceOwner` option.
|
|
130
|
+
*/
|
|
131
|
+
export function scopeAgentEvidenceLog(log: AgentEvidenceLog, owner: string): AgentEvidenceLog {
|
|
132
|
+
if (typeof owner !== "string" || owner === "")
|
|
133
|
+
throw new TypeError("agent evidence log: owner must be a non-empty string")
|
|
134
|
+
// Turn ids never contain "/", and the encoded owner never does either, so keys cannot collide.
|
|
135
|
+
const prefix = `${encodeURIComponent(owner)}/`
|
|
136
|
+
return {
|
|
137
|
+
open: (turnId) => log.open(prefix + turnId),
|
|
138
|
+
finish: (turnId, result) => log.finish(prefix + turnId, result),
|
|
139
|
+
replay: (turnId, afterSeq) => log.replay(prefix + turnId, afterSeq),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
126
143
|
export interface MemoryAgentEvidenceLogOptions {
|
|
127
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* Maximum retained turns. Opening one more evicts the oldest finished turn, or the oldest turn when
|
|
146
|
+
* none has finished; an evicted running turn ends its rejoined replays with no result. Default 256.
|
|
147
|
+
*/
|
|
128
148
|
readonly maxTurns?: number
|
|
129
149
|
}
|
|
130
150
|
|
|
@@ -155,16 +175,33 @@ export function createMemoryAgentEvidenceLog(
|
|
|
155
175
|
record.resolveResult = resolve
|
|
156
176
|
})
|
|
157
177
|
}
|
|
178
|
+
const settle = (turn: MemoryTurnRecord, result: unknown): void => {
|
|
179
|
+
turn.finished = true
|
|
180
|
+
turn.resolveResult(result)
|
|
181
|
+
for (const subscriber of turn.subscribers) subscriber.complete()
|
|
182
|
+
turn.subscribers.clear()
|
|
183
|
+
}
|
|
184
|
+
const evictOne = (): void => {
|
|
185
|
+
let victim: string | undefined
|
|
186
|
+
for (const [id, turn] of turns) {
|
|
187
|
+
if (turn.finished) {
|
|
188
|
+
victim = id
|
|
189
|
+
break
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
victim ??= turns.keys().next().value
|
|
193
|
+
if (victim === undefined) return
|
|
194
|
+
const turn = turns.get(victim)
|
|
195
|
+
turns.delete(victim)
|
|
196
|
+
// Its finish() can no longer find it by id, so a rejoined replay would wait forever.
|
|
197
|
+
if (turn !== undefined && !turn.finished) settle(turn, undefined)
|
|
198
|
+
}
|
|
158
199
|
|
|
159
200
|
return {
|
|
160
201
|
open(turnId) {
|
|
161
202
|
let turn = turns.get(turnId)
|
|
162
203
|
if (turn === undefined) {
|
|
163
|
-
while (turns.size >= maxTurns)
|
|
164
|
-
const oldest = turns.keys().next().value
|
|
165
|
-
if (oldest === undefined) break
|
|
166
|
-
turns.delete(oldest)
|
|
167
|
-
}
|
|
204
|
+
while (turns.size >= maxTurns) evictOne()
|
|
168
205
|
turn = {
|
|
169
206
|
evidence: [],
|
|
170
207
|
finished: false,
|
|
@@ -190,10 +227,7 @@ export function createMemoryAgentEvidenceLog(
|
|
|
190
227
|
finish(turnId, result) {
|
|
191
228
|
const turn = turns.get(turnId)
|
|
192
229
|
if (turn === undefined || turn.finished) return
|
|
193
|
-
turn
|
|
194
|
-
turn.resolveResult(result)
|
|
195
|
-
for (const subscriber of turn.subscribers) subscriber.complete()
|
|
196
|
-
turn.subscribers.clear()
|
|
230
|
+
settle(turn, result)
|
|
197
231
|
},
|
|
198
232
|
replay(turnId, afterSeq) {
|
|
199
233
|
const turn = turns.get(turnId)
|
package/src/execution-policy.ts
CHANGED
|
@@ -10,6 +10,16 @@ import {
|
|
|
10
10
|
export const LOCAL_PROCESS_LIMITATION = "The local adapter is NOT a security boundary."
|
|
11
11
|
|
|
12
12
|
const SIGKILL_GRACE_MS = 2000
|
|
13
|
+
/** How long the pipes may stay open after the escalation kill before the run stops reading them. */
|
|
14
|
+
const PIPE_RELEASE_MS = 200
|
|
15
|
+
/** The longest delay setTimeout honors; past it the callback fires at once. */
|
|
16
|
+
const MAX_TIMER_MS = 2_147_483_647
|
|
17
|
+
// On POSIX each run leads its own process group, so a timeout, a cancel, the run's own end, or the
|
|
18
|
+
// host's exit ends every process the command started, not only the direct child.
|
|
19
|
+
const WINDOWS = process.platform === "win32"
|
|
20
|
+
const PROCESS_GROUPS = !WINDOWS
|
|
21
|
+
const liveGroups = new Set<number>()
|
|
22
|
+
let exitHookInstalled = false
|
|
13
23
|
|
|
14
24
|
export interface LocalProcessAdapterOptions {
|
|
15
25
|
readonly cwd?: string
|
|
@@ -159,11 +169,19 @@ function spawnProcess(input: SpawnInput): Promise<LocalProcessResult> {
|
|
|
159
169
|
env: input.env,
|
|
160
170
|
shell: false,
|
|
161
171
|
stdio: ["ignore", "pipe", "pipe"],
|
|
172
|
+
detached: PROCESS_GROUPS,
|
|
162
173
|
})
|
|
163
174
|
} catch {
|
|
164
175
|
reject(new LocalProcessPolicyError("invalid_request"))
|
|
165
176
|
return
|
|
166
177
|
}
|
|
178
|
+
const group = PROCESS_GROUPS ? child.pid : undefined
|
|
179
|
+
if (group !== undefined) trackGroup(group)
|
|
180
|
+
const signalAll = (signal: NodeJS.Signals): void => {
|
|
181
|
+
if (group !== undefined && signalGroup(group, signal)) return
|
|
182
|
+
if (WINDOWS && child.pid !== undefined && child.exitCode === null) windowsTreeKill(child.pid)
|
|
183
|
+
else child.kill(signal)
|
|
184
|
+
}
|
|
167
185
|
const stdout: Uint8Array[] = []
|
|
168
186
|
const stderr: Uint8Array[] = []
|
|
169
187
|
let timedOut = false
|
|
@@ -178,23 +196,38 @@ function spawnProcess(input: SpawnInput): Promise<LocalProcessResult> {
|
|
|
178
196
|
outputUsed.value += selected.byteLength
|
|
179
197
|
}
|
|
180
198
|
if (child.stdout === null || child.stderr === null) {
|
|
181
|
-
|
|
199
|
+
signalAll("SIGKILL")
|
|
200
|
+
if (group !== undefined) liveGroups.delete(group)
|
|
182
201
|
reject(new LocalProcessPolicyError("invalid_request"))
|
|
183
202
|
return
|
|
184
203
|
}
|
|
185
204
|
child.stdout.on("data", (chunk: Uint8Array) => append(stdout, chunk))
|
|
186
205
|
child.stderr.on("data", (chunk: Uint8Array) => append(stderr, chunk))
|
|
187
206
|
let killTimer: ReturnType<typeof setTimeout> | undefined
|
|
207
|
+
let releaseTimer: ReturnType<typeof setTimeout> | undefined
|
|
188
208
|
const terminate = (): void => {
|
|
189
|
-
|
|
190
|
-
// A
|
|
209
|
+
signalAll("SIGTERM")
|
|
210
|
+
// A process that ignores SIGTERM would otherwise leave this promise pending forever,
|
|
191
211
|
// making policy.timeMs advisory instead of a bound.
|
|
192
|
-
killTimer ??= setTimeout(() =>
|
|
212
|
+
killTimer ??= setTimeout(() => {
|
|
213
|
+
signalAll("SIGKILL")
|
|
214
|
+
// A process that left the group (setsid) can still hold the pipes open; stop waiting.
|
|
215
|
+
releaseTimer = setTimeout(() => settle(child.exitCode, child.signalCode), PIPE_RELEASE_MS)
|
|
216
|
+
}, SIGKILL_GRACE_MS)
|
|
193
217
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
218
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
219
|
+
const deadline = performance.now() + input.policy.timeMs
|
|
220
|
+
const arm = (): void => {
|
|
221
|
+
const remaining = deadline - performance.now()
|
|
222
|
+
timer =
|
|
223
|
+
remaining > MAX_TIMER_MS
|
|
224
|
+
? setTimeout(arm, MAX_TIMER_MS)
|
|
225
|
+
: setTimeout(() => {
|
|
226
|
+
timedOut = true
|
|
227
|
+
terminate()
|
|
228
|
+
}, remaining)
|
|
229
|
+
}
|
|
230
|
+
arm()
|
|
198
231
|
const cancel = (): void => {
|
|
199
232
|
cancelled = true
|
|
200
233
|
terminate()
|
|
@@ -202,6 +235,8 @@ function spawnProcess(input: SpawnInput): Promise<LocalProcessResult> {
|
|
|
202
235
|
const cleanup = (): void => {
|
|
203
236
|
clearTimeout(timer)
|
|
204
237
|
if (killTimer !== undefined) clearTimeout(killTimer)
|
|
238
|
+
if (releaseTimer !== undefined) clearTimeout(releaseTimer)
|
|
239
|
+
if (group !== undefined) liveGroups.delete(group)
|
|
205
240
|
input.signal?.removeEventListener("abort", cancel)
|
|
206
241
|
}
|
|
207
242
|
child.once("error", () => {
|
|
@@ -210,10 +245,14 @@ function spawnProcess(input: SpawnInput): Promise<LocalProcessResult> {
|
|
|
210
245
|
cleanup()
|
|
211
246
|
reject(new LocalProcessPolicyError("invalid_request"))
|
|
212
247
|
})
|
|
213
|
-
|
|
248
|
+
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
|
214
249
|
if (settled) return
|
|
215
250
|
settled = true
|
|
251
|
+
// A background process the command left behind ends with the run.
|
|
252
|
+
if (group !== undefined) signalGroup(group, "SIGKILL")
|
|
216
253
|
cleanup()
|
|
254
|
+
child.stdout?.destroy()
|
|
255
|
+
child.stderr?.destroy()
|
|
217
256
|
resolve({
|
|
218
257
|
ok: !timedOut && !cancelled && exitCode === 0,
|
|
219
258
|
exitCode,
|
|
@@ -224,12 +263,53 @@ function spawnProcess(input: SpawnInput): Promise<LocalProcessResult> {
|
|
|
224
263
|
cancelled,
|
|
225
264
|
limitations: input.limitations,
|
|
226
265
|
})
|
|
227
|
-
}
|
|
266
|
+
}
|
|
267
|
+
child.once("close", settle)
|
|
228
268
|
input.signal?.addEventListener("abort", cancel, { once: true })
|
|
229
269
|
if (input.signal?.aborted === true) cancel() // close the precheck/listener-registration race
|
|
230
270
|
})
|
|
231
271
|
}
|
|
232
272
|
|
|
273
|
+
/**
|
|
274
|
+
* End a Windows process and its descendants. Windows has no process groups, and a console child
|
|
275
|
+
* cannot be asked to stop, so the tree is terminated outright.
|
|
276
|
+
*/
|
|
277
|
+
export function windowsTreeKill(
|
|
278
|
+
pid: number,
|
|
279
|
+
run: (
|
|
280
|
+
command: string,
|
|
281
|
+
args: readonly string[],
|
|
282
|
+
options: { stdio: "ignore"; windowsHide: boolean },
|
|
283
|
+
) => { on(event: "error", listener: () => void): unknown } = spawn,
|
|
284
|
+
): void {
|
|
285
|
+
try {
|
|
286
|
+
run("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on(
|
|
287
|
+
"error",
|
|
288
|
+
() => {},
|
|
289
|
+
)
|
|
290
|
+
} catch {}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Signal a run's whole process group; false when the group is already gone. */
|
|
294
|
+
function signalGroup(group: number, signal: NodeJS.Signals): boolean {
|
|
295
|
+
try {
|
|
296
|
+
process.kill(-group, signal)
|
|
297
|
+
return true
|
|
298
|
+
} catch {
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function trackGroup(group: number): void {
|
|
304
|
+
liveGroups.add(group)
|
|
305
|
+
if (exitHookInstalled) return
|
|
306
|
+
exitHookInstalled = true
|
|
307
|
+
// A detached group outlives the host unless something ends it; runs still in flight end with it.
|
|
308
|
+
process.on("exit", () => {
|
|
309
|
+
for (const live of liveGroups) signalGroup(live, "SIGKILL")
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
233
313
|
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
|
234
314
|
const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0)
|
|
235
315
|
const result = new Uint8Array(size)
|
package/src/index.ts
CHANGED
|
@@ -148,6 +148,8 @@ export interface AgentTurnState {
|
|
|
148
148
|
readonly kind: AgentPendingKind
|
|
149
149
|
readonly tool?: string
|
|
150
150
|
readonly effectId: string
|
|
151
|
+
/** SHA-256 of the suspended tool input, so a resume must replay that input and no other. */
|
|
152
|
+
readonly inputDigest?: string
|
|
151
153
|
}
|
|
152
154
|
}
|
|
153
155
|
|
|
@@ -587,6 +589,9 @@ export async function turn<
|
|
|
587
589
|
kind: pending.kind,
|
|
588
590
|
...(pending.tool === undefined ? {} : { tool: pending.tool }),
|
|
589
591
|
effectId: pending.effectId,
|
|
592
|
+
...(pending.tool !== undefined && Object.hasOwn(pending, "input")
|
|
593
|
+
? { inputDigest: await inputDigestOf(pending.input) }
|
|
594
|
+
: {}),
|
|
590
595
|
},
|
|
591
596
|
evidence,
|
|
592
597
|
})
|
|
@@ -960,6 +965,20 @@ async function resumeTool<
|
|
|
960
965
|
reason: "approval" | "budget" | "model" | "cancelled",
|
|
961
966
|
) => Promise<AgentTurnResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>>,
|
|
962
967
|
): Promise<AgentTurnResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> {
|
|
968
|
+
// A resume continues the step this turn suspended on - that tool, that effect, that input. The
|
|
969
|
+
// continuation travels through the caller, so anything else in it is refused, not run.
|
|
970
|
+
const pending = state.pending
|
|
971
|
+
const continuation = resume.continuation
|
|
972
|
+
if (
|
|
973
|
+
pending?.tool === undefined ||
|
|
974
|
+
continuation.tool !== pending.tool ||
|
|
975
|
+
continuation.effectId !== pending.effectId ||
|
|
976
|
+
continuation.kind !== pending.kind ||
|
|
977
|
+
(pending.inputDigest !== undefined &&
|
|
978
|
+
pending.inputDigest !== (await inputDigestOf(continuation.input)))
|
|
979
|
+
) {
|
|
980
|
+
throw new AgentResumeMismatchError()
|
|
981
|
+
}
|
|
963
982
|
const tool =
|
|
964
983
|
resume.continuation.tool === undefined
|
|
965
984
|
? undefined
|
|
@@ -976,8 +995,10 @@ async function resumeTool<
|
|
|
976
995
|
},
|
|
977
996
|
save,
|
|
978
997
|
)
|
|
998
|
+
// The caller's decision answers only a step that suspended to await one; a step suspended for budget
|
|
999
|
+
// or cancellation asks the approval port as it would have.
|
|
979
1000
|
const approvalPorts: AgentPorts =
|
|
980
|
-
resume.approval === undefined
|
|
1001
|
+
resume.approval === undefined || pending.kind !== "approval"
|
|
981
1002
|
? ports
|
|
982
1003
|
: {
|
|
983
1004
|
...ports,
|
|
@@ -1084,6 +1105,31 @@ function withTranscript<Output>(
|
|
|
1084
1105
|
}
|
|
1085
1106
|
}
|
|
1086
1107
|
|
|
1108
|
+
/** A resume whose continuation is not the step the turn suspended on. */
|
|
1109
|
+
export class AgentResumeMismatchError extends Error {
|
|
1110
|
+
override readonly name = "AgentResumeMismatchError"
|
|
1111
|
+
constructor() {
|
|
1112
|
+
super("agent: the resume does not continue the step this turn suspended on")
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async function inputDigestOf(input: unknown): Promise<string> {
|
|
1117
|
+
const bytes = new TextEncoder().encode(canonicalJson(input))
|
|
1118
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))
|
|
1119
|
+
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function canonicalJson(value: unknown): string {
|
|
1123
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`
|
|
1124
|
+
if (value !== null && typeof value === "object") {
|
|
1125
|
+
const entries = Object.entries(value)
|
|
1126
|
+
.filter(([, field]) => field !== undefined)
|
|
1127
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
1128
|
+
return `{${entries.map(([key, field]) => `${JSON.stringify(key)}:${canonicalJson(field)}`).join(",")}}`
|
|
1129
|
+
}
|
|
1130
|
+
return JSON.stringify(value) ?? "null"
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1087
1133
|
function freezeState(state: AgentTurnState): AgentTurnState {
|
|
1088
1134
|
return Object.freeze({
|
|
1089
1135
|
...state,
|
package/src/mount.ts
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* evidence and rejoin the still-running turn (or receive the stored terminal frame) - the run is
|
|
20
20
|
* never re-executed. Without the log the header is ignored and every POST starts a run.
|
|
21
21
|
* - The seam performs no authentication or authorization. Wrap it with the app's own route guards, and
|
|
22
|
-
* scope the store/model returned by `ports` to the caller (RLS born in at the data layer).
|
|
22
|
+
* scope the store/model returned by `ports` to the caller (RLS born in at the data layer). With an
|
|
23
|
+
* `evidenceLog`, set `evidenceOwner` too: without it a turn id is a bearer capability, and any
|
|
24
|
+
* caller who reaches the route with it replays that turn's evidence.
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
import {
|
|
@@ -35,6 +37,7 @@ import {
|
|
|
35
37
|
type AgentEvidenceLog,
|
|
36
38
|
type AgentEvidenceReplay,
|
|
37
39
|
createAgentEvidenceStream,
|
|
40
|
+
scopeAgentEvidenceLog,
|
|
38
41
|
} from "./events.ts"
|
|
39
42
|
import {
|
|
40
43
|
type AgentDefinition,
|
|
@@ -52,6 +55,7 @@ const DEFAULT_PATH = "/agent"
|
|
|
52
55
|
const DEFAULT_MAX_BODY_BYTES = 1_000_000
|
|
53
56
|
const PENDING_KINDS: readonly AgentPendingKind[] = ["approval", "budget", "model", "cancelled"]
|
|
54
57
|
const LAST_EVENT_ID_PATTERN = /^\d{1,15}$/
|
|
58
|
+
const TURN_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/
|
|
55
59
|
|
|
56
60
|
/** The structural slice of a route context the seam needs. Kept loose so core stays a peer dependency. */
|
|
57
61
|
export interface AgentRouteContext {
|
|
@@ -86,6 +90,12 @@ export interface MountAgentOptions<
|
|
|
86
90
|
* (`createMemoryAgentEvidenceLog`) is single-process; a durable log is an adapter concern.
|
|
87
91
|
*/
|
|
88
92
|
readonly evidenceLog?: AgentEvidenceLog
|
|
93
|
+
/**
|
|
94
|
+
* Who a turn's recorded evidence belongs to - the caller's user or tenant id. Turns are recorded and
|
|
95
|
+
* replayed under it, so a reconnect or a reused `turnId` from another caller finds nothing of this
|
|
96
|
+
* caller's turns. It runs before a replay is served; throw to refuse the request.
|
|
97
|
+
*/
|
|
98
|
+
readonly evidenceOwner?: (c: AgentRouteContext) => string | Promise<string>
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
/** Mount a single agent as `POST {path}`, negotiating an SSE evidence stream on `Accept`. */
|
|
@@ -120,10 +130,15 @@ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extend
|
|
|
120
130
|
if (body === undefined) return jsonResponse(400, { error: "body_must_be_object" })
|
|
121
131
|
|
|
122
132
|
const turnIdRaw = typeof body.turnId === "string" ? body.turnId : undefined
|
|
133
|
+
if (turnIdRaw !== undefined && !TURN_ID_PATTERN.test(turnIdRaw))
|
|
134
|
+
return jsonResponse(400, { error: "invalid_turn_id" })
|
|
123
135
|
const turnId = turnIdRaw ?? crypto.randomUUID()
|
|
124
136
|
|
|
125
137
|
const resume = parseResume(body.resume)
|
|
126
|
-
const log =
|
|
138
|
+
const log =
|
|
139
|
+
options.evidenceLog === undefined || options.evidenceOwner === undefined
|
|
140
|
+
? options.evidenceLog
|
|
141
|
+
: scopeAgentEvidenceLog(options.evidenceLog, await options.evidenceOwner(c))
|
|
127
142
|
const wantsSse = (c.req.headers.get("accept") ?? "").includes("text/event-stream")
|
|
128
143
|
|
|
129
144
|
// A reconnect replays recorded evidence and rejoins the turn; it never starts a second run.
|
|
@@ -140,11 +155,17 @@ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extend
|
|
|
140
155
|
|
|
141
156
|
const start = (
|
|
142
157
|
telemetry?: AgentPorts["telemetry"],
|
|
158
|
+
disconnect?: AbortSignal,
|
|
143
159
|
): Promise<AgentRunResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> => {
|
|
144
160
|
// Compose rather than replace: an SSE evidence stream must not displace a telemetry port the
|
|
145
161
|
// caller injected through `ports` (an exporter, a durable evidence log).
|
|
146
162
|
const combined = combineAgentTelemetry(basePorts.telemetry, log?.open(turnId), telemetry)
|
|
147
|
-
const
|
|
163
|
+
const signal = withDisconnect(basePorts.signal, disconnect)
|
|
164
|
+
const ports = {
|
|
165
|
+
...basePorts,
|
|
166
|
+
...(combined === undefined ? {} : { telemetry: combined }),
|
|
167
|
+
...(signal === undefined ? {} : { signal }),
|
|
168
|
+
}
|
|
148
169
|
if (resume !== undefined) {
|
|
149
170
|
return resumeAgent(options.agent, turnId, { value: body.input, resume }, ports, runOptions)
|
|
150
171
|
}
|
|
@@ -174,16 +195,26 @@ async function execute<InputSchema extends StandardSchemaV1, OutputSchema extend
|
|
|
174
195
|
|
|
175
196
|
function sseResponse<Output>(
|
|
176
197
|
turnId: string,
|
|
177
|
-
start: (
|
|
198
|
+
start: (
|
|
199
|
+
telemetry: AgentPorts["telemetry"],
|
|
200
|
+
disconnect: AbortSignal | undefined,
|
|
201
|
+
) => Promise<AgentRunResult<Output>>,
|
|
178
202
|
log: AgentEvidenceLog | undefined,
|
|
179
203
|
): Response {
|
|
180
204
|
const stream = createAgentEvidenceStream()
|
|
181
205
|
// `id:` frames are only meaningful when a log can serve the reconnect they invite.
|
|
182
206
|
const withIds = log !== undefined
|
|
207
|
+
// A disconnect detaches the client. With a log the run keeps recording for a reconnect to replay;
|
|
208
|
+
// without one nothing can rejoin it, so the run is cancelled.
|
|
209
|
+
const disconnect = log === undefined ? new AbortController() : undefined
|
|
210
|
+
let detached = false
|
|
183
211
|
const body = new ReadableStream<Uint8Array>({
|
|
184
212
|
async start(controller) {
|
|
185
|
-
const
|
|
186
|
-
const
|
|
213
|
+
const deliver = sseSender(controller)
|
|
214
|
+
const send: typeof deliver = (event, data, id) => {
|
|
215
|
+
if (!detached) deliver(event, data, id)
|
|
216
|
+
}
|
|
217
|
+
const run = start(stream, disconnect?.signal)
|
|
187
218
|
// The evidence stream must terminate whether the run resolves or throws, or the `for await`
|
|
188
219
|
// below would hang; the run result (or the error) is still reported in-band after it drains.
|
|
189
220
|
run
|
|
@@ -204,13 +235,25 @@ function sseResponse<Output>(
|
|
|
204
235
|
await finishQuietly(log, turnId, frame)
|
|
205
236
|
send(frame.event, frame.data)
|
|
206
237
|
} finally {
|
|
207
|
-
controller.close()
|
|
238
|
+
if (!detached) controller.close()
|
|
208
239
|
}
|
|
209
240
|
},
|
|
241
|
+
cancel() {
|
|
242
|
+
detached = true
|
|
243
|
+
disconnect?.abort()
|
|
244
|
+
},
|
|
210
245
|
})
|
|
211
246
|
return sseHeaders(body)
|
|
212
247
|
}
|
|
213
248
|
|
|
249
|
+
function withDisconnect(
|
|
250
|
+
signal: AbortSignal | undefined,
|
|
251
|
+
disconnect: AbortSignal | undefined,
|
|
252
|
+
): AbortSignal | undefined {
|
|
253
|
+
if (signal === undefined || disconnect === undefined) return signal ?? disconnect
|
|
254
|
+
return AbortSignal.any([signal, disconnect])
|
|
255
|
+
}
|
|
256
|
+
|
|
214
257
|
function sseReplayResponse(turnId: string, replay: AgentEvidenceReplay): Response {
|
|
215
258
|
const body = new ReadableStream<Uint8Array>({
|
|
216
259
|
async start(controller) {
|