@nifrajs/agent 3.3.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/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
- async prepare(planInput: AgentDeploymentPlan | unknown): Promise<DeploymentPrepareResult> {
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
- const plan = parseDeploymentPlan(planInput)
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
- const report = await this.capabilityReport()
495
- assertCapabilityPlan(report, plan, this.authority, this.approved)
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
- async start(): Promise<DeploymentStartResult> {
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
- async inspect(): Promise<DeploymentInspection> {
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
- async cancel(): Promise<DeploymentCancelResult> {
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
- async dispose(): Promise<DeploymentDisposeResult> {
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
- /** Maximum retained turns; the oldest turn is evicted when a new one opens. Default 256. */
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.finished = true
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)
@@ -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
@@ -122,7 +132,7 @@ export function createLocalProcessAdapter(
122
132
  ): Record<string, string> {
123
133
  const result: Record<string, string> = {}
124
134
  for (const name of allowlist) {
125
- const value = values[name] ?? process.env[name]
135
+ const value = Object.hasOwn(values, name) ? values[name] : process.env[name]
126
136
  if (value !== undefined) result[name] = value
127
137
  }
128
138
  return result
@@ -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
- child.kill()
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
- child.kill("SIGTERM")
190
- // A child that ignores SIGTERM would otherwise leave this promise pending forever,
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(() => child.kill("SIGKILL"), SIGKILL_GRACE_MS)
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
- const timer = setTimeout(() => {
195
- timedOut = true
196
- terminate()
197
- }, input.policy.timeMs)
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
- child.once("close", (exitCode, signal) => {
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
@@ -24,6 +24,18 @@ import {
24
24
  type ToolIdempotencyStore,
25
25
  } from "@nifrajs/core/tool-contract"
26
26
 
27
+ export {
28
+ approximateContextTokens,
29
+ assembleContext,
30
+ type ContextAssembly,
31
+ type ContextAssemblyOptions,
32
+ type ContextBudget,
33
+ ContextBudgetError,
34
+ type ContextItem,
35
+ type ContextSource,
36
+ type ContextTokenCounter,
37
+ collectContext,
38
+ } from "./context.ts"
27
39
  export {
28
40
  AgentDeployment,
29
41
  type AgentDeploymentAdapter,
@@ -136,6 +148,8 @@ export interface AgentTurnState {
136
148
  readonly kind: AgentPendingKind
137
149
  readonly tool?: string
138
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
139
153
  }
140
154
  }
141
155
 
@@ -575,6 +589,9 @@ export async function turn<
575
589
  kind: pending.kind,
576
590
  ...(pending.tool === undefined ? {} : { tool: pending.tool }),
577
591
  effectId: pending.effectId,
592
+ ...(pending.tool !== undefined && Object.hasOwn(pending, "input")
593
+ ? { inputDigest: await inputDigestOf(pending.input) }
594
+ : {}),
578
595
  },
579
596
  evidence,
580
597
  })
@@ -948,6 +965,20 @@ async function resumeTool<
948
965
  reason: "approval" | "budget" | "model" | "cancelled",
949
966
  ) => Promise<AgentTurnResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>>,
950
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
+ }
951
982
  const tool =
952
983
  resume.continuation.tool === undefined
953
984
  ? undefined
@@ -964,8 +995,10 @@ async function resumeTool<
964
995
  },
965
996
  save,
966
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.
967
1000
  const approvalPorts: AgentPorts =
968
- resume.approval === undefined
1001
+ resume.approval === undefined || pending.kind !== "approval"
969
1002
  ? ports
970
1003
  : {
971
1004
  ...ports,
@@ -1072,6 +1105,31 @@ function withTranscript<Output>(
1072
1105
  }
1073
1106
  }
1074
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
+
1075
1133
  function freezeState(state: AgentTurnState): AgentTurnState {
1076
1134
  return Object.freeze({
1077
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 = options.evidenceLog
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 ports = combined === undefined ? basePorts : { ...basePorts, telemetry: combined }
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: (telemetry: AgentPorts["telemetry"]) => Promise<AgentRunResult<Output>>,
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 send = sseSender(controller)
186
- const run = start(stream)
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) {