agenthooksprotocol 0.1.1 → 0.2.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.
@@ -23,7 +23,7 @@ import {
23
23
  validateInterceptResponse,
24
24
  } from "../draft/index.js";
25
25
  import { BackendTransport } from "./transport.js";
26
- import { ContentManager, ContentSource } from "./content.js";
26
+ import { Attachment, ContentManager, ContentSource } from "./content.js";
27
27
  import { composeResponseAsync, normalizeEffects } from "./composition.js";
28
28
  import { auth } from "./auth.js";
29
29
  import { validateWire } from "./validation.js";
@@ -127,7 +127,7 @@ export class Hooks {
127
127
  private closed?: Promise<void>;
128
128
  private readonly elicitations = new Map<
129
129
  string,
130
- { event: any; bytes: Uint8Array }
130
+ { event: any; owner: Attachment; size: number; release: () => Promise<void> }
131
131
  >();
132
132
  private elicitationBytes = 0;
133
133
  // Tokens exist only for active calls, including preparation before any bytes
@@ -440,8 +440,12 @@ export class Hooks {
440
440
  : 64 * 1024 * 1024,
441
441
  allowLoopback: true,
442
442
  });
443
- manager.own(input);
444
- manager.own(options.contentSources);
443
+ try {
444
+ manager.own([input, options.contentSources]);
445
+ } catch (error) {
446
+ await manager.close().catch(() => {});
447
+ throw error;
448
+ }
445
449
  this.managers.add(manager);
446
450
  const signal = combineSignals(this.lifetime.signal, options.signal);
447
451
  const exchange: ElicitationLifetime | undefined =
@@ -454,6 +458,8 @@ export class Hooks {
454
458
  : undefined;
455
459
  if (exchange) this.activeElicitations.add(exchange);
456
460
  let completed = false;
461
+ let returnedContent: BoundaryResult<K>["content"];
462
+
457
463
  const work = (async () => {
458
464
  // Calls after close are misuse. Active calls return interruption evidence.
459
465
  this.lifetime.signal.throwIfAborted();
@@ -464,6 +470,10 @@ export class Hooks {
464
470
  try {
465
471
  const result = await work;
466
472
  completed = true;
473
+ if (result.content && !result.interrupted) {
474
+ manager.transfer(result.content);
475
+ returnedContent = result.content;
476
+ }
467
477
  return result;
468
478
  } finally {
469
479
  // Terminal exchanges retire even when validation or cancellation bypasses run.
@@ -478,9 +488,16 @@ export class Hooks {
478
488
  if (type === "session.end")
479
489
  this.retireSessionElicitations((input as any).session?.id);
480
490
  if (exchange) this.activeElicitations.delete(exchange);
481
- await manager.close();
482
- this.managers.delete(manager);
483
- this.pending.delete(work);
491
+ try {
492
+ if (completed) await manager.close();
493
+ else await manager.close().catch(() => {});
494
+ } catch (error) {
495
+ await returnedContent?.close().catch(() => {});
496
+ throw error;
497
+ } finally {
498
+ this.managers.delete(manager);
499
+ this.pending.delete(work);
500
+ }
484
501
  }
485
502
  }
486
503
 
@@ -547,8 +564,9 @@ export class Hooks {
547
564
  interrupted: true,
548
565
  } as BoundaryResult<K>;
549
566
  const localSources = new Set<ReadableStream<Uint8Array>>();
550
- const originalRequest = this.elicitationFor(event);
551
- manager.own(originalRequest);
567
+ let correlation: { event: Event; owner: Attachment; release: () => Promise<void> } | undefined;
568
+ let originalRequest: Event | undefined;
569
+
552
570
  let effects: Effect[] = [];
553
571
  let state = structuredClone(options.initialState);
554
572
  // A supplied snapshot is input to the first receiver, not a newly accepted
@@ -595,6 +613,8 @@ export class Hooks {
595
613
  advertised.modes.includes(r.subscription.mode),
596
614
  );
597
615
  try {
616
+ correlation = this.elicitationFor(event, manager);
617
+ originalRequest = correlation?.event;
598
618
  // Validate a metadata-only view before any delivery (this does not consume bytes).
599
619
  const metadata = await manager.prepare(
600
620
  event,
@@ -668,11 +688,8 @@ export class Hooks {
668
688
  projected.elicitation?.request?.selection === "body" &&
669
689
  event.elicitation?.request?.body instanceof ReadableStream
670
690
  ) {
671
- const bytes = await manager.readBody(
672
- event.elicitation.request.body,
673
- signal,
674
- );
675
- this.rememberElicitation(event, bytes, exchange!);
691
+ const retained = await manager.retainBody(event.elicitation.request.body, signal);
692
+ this.rememberElicitation(event, retained, exchange!);
676
693
  }
677
694
  const request = {
678
695
  jsonrpc: "2.0",
@@ -727,7 +744,9 @@ export class Hooks {
727
744
  );
728
745
  const staged = await raceAbort(
729
746
  composeResponseAsync(event, effects, decoded.value, caps, {
730
- readContent: (body) => manager.readBody(body, deadline),
747
+ readContent: (body) => originalRequest && body === (originalRequest as any).elicitation.request.body
748
+ ? correlation!.owner.read(deadline)
749
+ : manager.readBody(body, deadline),
731
750
  ownContent: (body) => {
732
751
  manager.own(body);
733
752
  localSources.add(body);
@@ -838,6 +857,7 @@ export class Hooks {
838
857
  } as InterceptResponse;
839
858
  return {
840
859
  event: await resultEvent(event, manager, localSources),
860
+ content: manager.resultContent(event),
841
861
  response,
842
862
  state: snapshotState(state),
843
863
  get permission() {
@@ -854,9 +874,11 @@ export class Hooks {
854
874
  } catch (error) {
855
875
  if (type === "user.elicitation.request")
856
876
  this.forgetElicitation(requestId);
857
- await manager.close();
877
+ await manager.close().catch(() => {});
858
878
  this.managers.delete(manager);
859
879
  throw error;
880
+ } finally {
881
+ await correlation?.release().catch(() => {});
860
882
  }
861
883
  }
862
884
 
@@ -924,20 +946,26 @@ export class Hooks {
924
946
 
925
947
  private rememberElicitation(
926
948
  event: any,
927
- bytes: Uint8Array,
949
+ retained: { owner: Attachment; size: number; release: () => Promise<void> },
928
950
  exchange: ElicitationLifetime,
929
951
  ): void {
930
- if (exchange.retired || this.lifetime.signal.aborted) return;
931
- if (this.elicitations.has(event.id)) return;
952
+ if (exchange.retired || this.lifetime.signal.aborted || this.elicitations.has(event.id)) {
953
+ void retained.release().catch(() => {});
954
+ return;
955
+ }
932
956
  const limit = this.options.maxContentBytes ?? 64 * 1024 * 1024;
933
- if (bytes.byteLength > limit)
957
+ if (retained.size > limit) {
958
+ void retained.release().catch(() => {});
934
959
  throw new Error("Elicitation request exceeds retention budget");
960
+ }
935
961
  if (
936
- this.elicitationBytes + bytes.byteLength > limit ||
962
+ this.elicitationBytes + retained.size > limit ||
937
963
  this.elicitations.size >= 128
938
- )
964
+ ) {
965
+ void retained.release().catch(() => {});
939
966
  throw new Error("Active elicitation exchanges exceed retention budget");
940
- // Pending exchanges own only correlation facts and selected request bytes.
967
+ }
968
+ // Pending exchanges retain correlation facts and a lease on the same owner.
941
969
  const stored = {
942
970
  type: event.type,
943
971
  id: event.id,
@@ -949,22 +977,18 @@ export class Hooks {
949
977
  request: { mediaType: event.elicitation.request.mediaType },
950
978
  },
951
979
  };
952
- this.elicitations.set(event.id, { event: stored, bytes });
953
- this.elicitationBytes += bytes.byteLength;
980
+ this.elicitations.set(event.id, { event: stored, ...retained });
981
+ this.elicitationBytes += retained.size;
954
982
  }
955
- private elicitationFor(event: any): Event | undefined {
983
+ private elicitationFor(event: any, manager: ContentManager): {
984
+ event: Event; owner: Attachment; release: () => Promise<void>;
985
+ } | undefined {
956
986
  if (event.type !== "user.elicitation.result") return undefined;
957
987
  const entry = this.elicitations.get(event.parentEventId);
958
988
  if (!entry) return undefined;
959
- const copy = cloneInput(entry.event),
960
- bytes = entry.bytes.slice();
961
- copy.elicitation.request.body = new ReadableStream<Uint8Array>({
962
- start(c) {
963
- c.enqueue(bytes);
964
- c.close();
965
- },
966
- });
967
- return copy;
989
+ const copy = cloneInput(entry.event);
990
+ copy.elicitation.request.body = entry.owner.stream;
991
+ return { event: copy, owner: entry.owner, release: manager.lease(entry.owner) };
968
992
  }
969
993
  /** End an abandoned host elicitation exchange without delivering a result. */
970
994
  discardElicitation(requestEventId: string): void {
@@ -982,7 +1006,10 @@ export class Hooks {
982
1006
  for (const exchange of this.activeElicitations)
983
1007
  if (exchange.id === id) exchange.retired = true;
984
1008
  const entry = this.elicitations.get(id);
985
- if (entry) this.elicitationBytes -= entry.bytes.byteLength;
1009
+ if (entry) {
1010
+ this.elicitationBytes -= entry.size;
1011
+ void entry.release().catch(() => {});
1012
+ }
986
1013
  this.elicitations.delete(id);
987
1014
  }
988
1015
 
@@ -1001,6 +1028,7 @@ export class Hooks {
1001
1028
  await Promise.allSettled([...this.managers].map((m) => m.close()));
1002
1029
  await Promise.allSettled([...this.pending]);
1003
1030
  this.authContexts.clear();
1031
+ await Promise.allSettled([...this.elicitations.values()].map(entry => entry.release()));
1004
1032
  this.elicitations.clear();
1005
1033
  this.elicitationBytes = 0;
1006
1034
  })();
@@ -1224,16 +1252,7 @@ async function resultEvent(
1224
1252
  ): Promise<any> {
1225
1253
  if (value instanceof ContentSource) value = value.stream;
1226
1254
  if (value instanceof ReadableStream) {
1227
- const bytes = localSources.has(value)
1228
- ? await manager.readBody(value)
1229
- : await manager.copySnapshot(value);
1230
- if (!bytes) return undefined;
1231
- return new ReadableStream<Uint8Array>({
1232
- start(controller) {
1233
- controller.enqueue(bytes);
1234
- controller.close();
1235
- },
1236
- });
1255
+ return manager.resultBody(value, localSources.has(value));
1237
1256
  }
1238
1257
  if (Array.isArray(value))
1239
1258
  return Promise.all(
@@ -5,7 +5,7 @@ import type {
5
5
  ContentSourceBinding,
6
6
  DeliveryDiagnosticCode,
7
7
  } from "../draft/generated.js";
8
- import type { ContentSource } from "./content.js";
8
+ import type { AttachmentContent, ContentSource } from "./content.js";
9
9
  import type {
10
10
  Capabilities,
11
11
  ContentReference,
@@ -139,6 +139,9 @@ export type BoundaryState = ReadonlyState<
139
139
  NonNullable<InterceptRequest["params"]["state"]>
140
140
  >;
141
141
  export interface BoundaryResult<K extends EventType = EventType> {
142
+ /** Present for owned Attachments. Effective local bodies by content item id,
143
+ * including unread lazy sources. Close explicitly, independently of Hooks. */
144
+ readonly content?: AttachmentContent;
142
145
  /** Accepted effective input, with occurrence identity and static envelope supplied.
143
146
  * Body streams are owned delivery resources, not reusable output streams. */
144
147
  event: HarnessEvent<K>;