@responsibleai/agent-hooks 0.1.0-alpha.5 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/emitter.js CHANGED
@@ -20,8 +20,15 @@
20
20
  * serial dispatch over isolated snapshots — §7.2: parallel names
21
21
  * isolation semantics, not scheduling.
22
22
  *
23
- * Fail-closed defaults: an `enforce`-mode emission with zero registered
24
- * interceptors yields `deny host_error:no_interceptor` (§7), and
23
+ * An emitter can also be built from a host declaration document (§7.7)
24
+ * through {@link InterceptionEmitter.fromDeclaration} and the path, JSON
25
+ * and value forms. The document is validated and resolved by the Rust
26
+ * core; this module runs the host's kind resolvers (step 11 of §7.7.6),
27
+ * seals the emitter (§7.7.7) and stamps the contract version on every
28
+ * record (§7.7.8).
29
+ *
30
+ * Fail-closed defaults: an `enforce`-mode emission with zero interceptors
31
+ * bound at the point yields `deny host_error:no_interceptor` (§7), and
25
32
  * {@link InterceptionEmitter.emit} **throws** {@link InterceptionBlocked}
26
33
  * on any block — the ignorable-result variant is the explicitly named
27
34
  * {@link InterceptionEmitter.emitUnchecked}.
@@ -31,8 +38,9 @@
31
38
  * a single JS thread. Sharing one emitter across workers is unsupported.
32
39
  */
33
40
  Object.defineProperty(exports, "__esModule", { value: true });
34
- exports.InterceptionEmitter = exports.DEFAULT_TIMEOUT_MS = void 0;
41
+ exports.InterceptionEmitter = exports.EmitterSealed = exports.DEFAULT_TIMEOUT_MS = void 0;
35
42
  const index_1 = require("./index");
43
+ const declaration_1 = require("./declaration");
36
44
  const native_1 = require("./native");
37
45
  /** §7 RECOMMENDED interceptor/resolver timeout (milliseconds). */
38
46
  exports.DEFAULT_TIMEOUT_MS = 5000;
@@ -43,6 +51,24 @@ class InterceptTimeout extends Error {
43
51
  this.name = "InterceptTimeout";
44
52
  }
45
53
  }
54
+ /** Thrown when a setter or `register` is called on an emitter built
55
+ * from a host declaration (§7.7.7): the declaration must stay what ran. */
56
+ /** Recursively freeze a JSON-shaped value in place and return it. */
57
+ function deepFreeze(value) {
58
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
59
+ Object.freeze(value);
60
+ for (const v of Object.values(value))
61
+ deepFreeze(v);
62
+ }
63
+ return value;
64
+ }
65
+ class EmitterSealed extends Error {
66
+ constructor(what) {
67
+ super(`${what}: this emitter was built from a host declaration and is sealed (see spec §7.7.7)`);
68
+ this.name = "EmitterSealed";
69
+ }
70
+ }
71
+ exports.EmitterSealed = EmitterSealed;
46
72
  /** Whether a verdict was synthesized by the host (§11) rather than
47
73
  * returned by an interceptor or resolver. */
48
74
  function isHostSynthesized(v) {
@@ -109,19 +135,32 @@ function synthesized(err, detail) {
109
135
  };
110
136
  }
111
137
  const NOT_CONSULTED = { consulted: false };
138
+ /** Whether `v` looks like an interceptor (§7): an object with an
139
+ * `intercept` method. */
140
+ function isInterceptor(v) {
141
+ return (v !== null &&
142
+ (typeof v === "object" || typeof v === "function") &&
143
+ typeof v.intercept === "function");
144
+ }
112
145
  class InterceptionEmitter {
113
- mode;
114
- resolver;
115
- timeoutMs;
116
146
  interceptors = [];
117
147
  _records = [];
118
148
  composition = index_1.Composition.default();
119
149
  identity = index_1.JCS_SHA256;
120
- names = [];
121
150
  approvalRedactor = null;
122
151
  recordSink = null;
123
152
  maxRecords = null;
124
153
  _recordsDropped = 0;
154
+ mode;
155
+ resolver;
156
+ /** §7 emitter-wide interceptor bound; `null` disables enforcement. */
157
+ timeoutMs;
158
+ /** §9 resolver bound (`approval_resolver_ms`, §7.7.3); `null` disables. */
159
+ resolverTimeoutMs;
160
+ /** §7.7.7: a declaration-built emitter refuses reconfiguration. */
161
+ sealed = false;
162
+ /** The resolved declaration this emitter was built from, if any. */
163
+ _declaration = null;
125
164
  /**
126
165
  * `timeoutMs` bounds each interceptor `intercept()` and resolver
127
166
  * `resolve()` call (§7, RECOMMENDED default 5000 ms); breach fails
@@ -134,17 +173,151 @@ class InterceptionEmitter {
134
173
  this.mode = mode;
135
174
  this.resolver = resolver;
136
175
  this.timeoutMs = timeoutMs;
176
+ this.resolverTimeoutMs = timeoutMs;
177
+ }
178
+ // ---- host declaration (§7.7) ----------------------------------------------
179
+ /** Build an emitter from a validated declaration: steps 8 to 11 of
180
+ * §7.7.6 (resolve against the registry's surface and names in the
181
+ * core, then run every binding's kind resolver). The result is
182
+ * sealed (§7.7.7) and stamps `declaration` on every record (§7.7.8).
183
+ * Throws {@link DeclarationError} on refusal; no emitter exists then. */
184
+ static fromDeclaration(declaration, registry) {
185
+ const resolved = (0, declaration_1.resolveDeclaration)(declaration, registry);
186
+ return InterceptionEmitter.fromResolved(resolved, registry);
187
+ }
188
+ /** {@link HostDeclaration.fromPath} then {@link fromDeclaration}. */
189
+ static async fromDeclarationPath(path, registry) {
190
+ return InterceptionEmitter.fromDeclaration(await declaration_1.HostDeclaration.fromPath(path), registry);
191
+ }
192
+ /** {@link HostDeclaration.fromJson} then {@link fromDeclaration}. */
193
+ static fromDeclarationJson(text, registry) {
194
+ return InterceptionEmitter.fromDeclaration(declaration_1.HostDeclaration.fromJson(text), registry);
195
+ }
196
+ /** {@link HostDeclaration.fromValue} then {@link fromDeclaration}. */
197
+ static fromDeclarationValue(value, registry) {
198
+ return InterceptionEmitter.fromDeclaration(declaration_1.HostDeclaration.fromValue(value), registry);
199
+ }
200
+ /** The resolved declaration this emitter runs under, when it was
201
+ * built from one (§7.7.7). Its canonical JSON is the equivalence
202
+ * oracle for the construction paths. The value is frozen all the
203
+ * way down: writing through it has no effect on dispatch or records.
204
+ * `null` for an emitter configured in code. */
205
+ get declaration() {
206
+ return this._declaration;
207
+ }
208
+ /** Step 11 and construction. Every reference is looked up again in
209
+ * the registry's own maps, so bookkeeping drift between the names the
210
+ * core checked and what the registry holds fails closed. */
211
+ static fromResolved(resolved, registry) {
212
+ const cfg = resolved.configuration;
213
+ let resolver = null;
214
+ if (cfg.approval.resolver !== null) {
215
+ const name = cfg.approval.resolver;
216
+ const r = registry.approvalResolverRef(name);
217
+ if (r === undefined) {
218
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.ReferenceUnresolved, "/configuration/approval/resolver", `approval resolver ${JSON.stringify(name)} vanished from the registry`);
219
+ }
220
+ resolver = r;
221
+ }
222
+ let identity;
223
+ if (cfg.identity_provider === null) {
224
+ identity = null;
225
+ }
226
+ else if (cfg.identity_provider === index_1.JCS_SHA256) {
227
+ identity = index_1.JCS_SHA256;
228
+ }
229
+ else {
230
+ const name = cfg.identity_provider;
231
+ const fn = registry.identityFn(name);
232
+ if (fn === undefined) {
233
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.ReferenceUnresolved, "/configuration/identity_provider", `identity provider ${JSON.stringify(name)} vanished from the registry`);
234
+ }
235
+ identity = { name, fn };
236
+ }
237
+ let approvalRedactor = null;
238
+ if (cfg.approval.redactor !== null) {
239
+ const name = cfg.approval.redactor;
240
+ const fn = registry.redactorFn(name);
241
+ if (fn === undefined) {
242
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.ReferenceUnresolved, "/configuration/approval/redactor", `approval redactor ${JSON.stringify(name)} vanished from the registry`);
243
+ }
244
+ approvalRedactor = fn;
245
+ }
246
+ const bound = [];
247
+ resolved.bindings.forEach((b, i) => {
248
+ const pointer = `/bindings/${i}`;
249
+ const resolverFn = registry.kindResolver(b.kind);
250
+ if (resolverFn === undefined) {
251
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.KindUnknown, `${pointer}/kind`, `kind ${JSON.stringify(b.kind)} vanished from the registry`);
252
+ }
253
+ // The dispatch set is fixed before the resolver runs and the
254
+ // context carries a frozen copy, so a resolver cannot widen its
255
+ // own binding (the Rust core hands out `&BTreeSet` for the same
256
+ // reason).
257
+ const at = new Set(b.at);
258
+ const context = {
259
+ id: b.id,
260
+ kind: b.kind,
261
+ at: Object.freeze([...b.at]),
262
+ timeoutMs: b.timeout_ms,
263
+ host: resolved.host ?? null,
264
+ declarationVersion: resolved.declaration,
265
+ };
266
+ // §7.7.5: a resolver that throws or returns a non-interceptor
267
+ // refuses the document; the message is bounded and the config is
268
+ // never echoed by the loader.
269
+ let interceptor;
270
+ try {
271
+ interceptor = resolverFn(b.config, context);
272
+ }
273
+ catch (e) {
274
+ const msg = e instanceof Error ? e.message : "resolver threw a non-Error value";
275
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.BindingRejected, pointer, `binding ${JSON.stringify(b.id)} (kind ${JSON.stringify(b.kind)}) rejected: ${msg}`);
276
+ }
277
+ if (!isInterceptor(interceptor)) {
278
+ const why = interceptor instanceof Promise
279
+ ? "resolver returned a Promise; kind resolvers are synchronous"
280
+ : "resolver did not return an interceptor";
281
+ throw declaration_1.DeclarationError.single(declaration_1.DeclarationErrorClass.BindingRejected, pointer, `binding ${JSON.stringify(b.id)} (kind ${JSON.stringify(b.kind)}) rejected: ${why}`);
282
+ }
283
+ bound.push({
284
+ interceptor,
285
+ name: b.id,
286
+ at,
287
+ timeoutMs: b.timeout_ms,
288
+ });
289
+ });
290
+ const em = new InterceptionEmitter(cfg.mode, resolver, cfg.timeouts.interceptor_ms);
291
+ em.resolverTimeoutMs = cfg.timeouts.approval_resolver_ms;
292
+ // The emitter owns its own copy of the composition and the
293
+ // declaration it exposes is frozen all the way down, so the seal
294
+ // cannot be bypassed by writing through `emitter.declaration`.
295
+ em.composition = { ...cfg.composition };
296
+ em.identity = identity;
297
+ em.approvalRedactor = approvalRedactor;
298
+ em.maxRecords = cfg.records.max_buffered;
299
+ em.interceptors.push(...bound);
300
+ em._declaration = deepFreeze(resolved);
301
+ em.sealed = true;
302
+ return em;
137
303
  }
138
- /** Race `fn`'s result against the §7 timeout. */
139
- async withTimeout(fn) {
140
- if (this.timeoutMs === null)
304
+ /** §7.7.7: a declaration-built emitter refuses reconfiguration;
305
+ * otherwise the declaration would not be what ran. */
306
+ unsealed(what) {
307
+ if (this.sealed)
308
+ throw new EmitterSealed(what);
309
+ }
310
+ /** Race `fn`'s result against a timeout (§7). `limitMs: null` runs
311
+ * unbounded. */
312
+ async withTimeout(fn, limitMs) {
313
+ if (limitMs === null)
141
314
  return fn();
142
315
  let timer;
143
316
  try {
144
317
  return await Promise.race([
145
318
  (async () => fn())(),
146
319
  new Promise((_, reject) => {
147
- timer = setTimeout(() => reject(new InterceptTimeout()), this.timeoutMs);
320
+ timer = setTimeout(() => reject(new InterceptTimeout()), limitMs);
148
321
  }),
149
322
  ]);
150
323
  }
@@ -157,10 +330,36 @@ class InterceptionEmitter {
157
330
  return this._records;
158
331
  }
159
332
  /** Register an interceptor, optionally with a host-chosen
160
- * payload-free `name` recorded on `verdicts[].name` (§10.3). */
161
- register(interceptor, name) {
162
- this.interceptors.push(interceptor);
163
- this.names.push(name ?? null);
333
+ * payload-free `name` recorded on `verdicts[].name` (§10.3) and the
334
+ * points it runs at (`at`; omitted = every point). At point P the
335
+ * interceptors that run are those bound there, in registration
336
+ * order; `interceptors_registered`, `verdicts[].index` and
337
+ * `decided_by` count and index that list (§7.7.8). Throws
338
+ * {@link EmitterSealed} on a declaration-built emitter. */
339
+ register(interceptor, name, at) {
340
+ this.unsealed("register");
341
+ let points = null;
342
+ if (at !== undefined) {
343
+ // The compile-time type does not protect plain-JS callers: a
344
+ // misspelled point must not bind nowhere without a word.
345
+ points = new Set();
346
+ const known = Object.values(index_1.InterceptionPoint);
347
+ for (const p of at) {
348
+ if (!known.includes(p)) {
349
+ throw new RangeError(`unknown interception point ${JSON.stringify(p)}: the point set is closed (§3)`);
350
+ }
351
+ points.add(p);
352
+ }
353
+ if (points.size === 0) {
354
+ throw new RangeError("at must name at least one interception point; omit it to bind every point (§7.7.5)");
355
+ }
356
+ }
357
+ this.interceptors.push({
358
+ interceptor,
359
+ name: name ?? null,
360
+ at: points,
361
+ timeoutMs: undefined,
362
+ });
164
363
  return this;
165
364
  }
166
365
  /**
@@ -178,6 +377,7 @@ class InterceptionEmitter {
178
377
  * profile. See docs/PRODUCTION.md.
179
378
  */
180
379
  setComposition(composition) {
380
+ this.unsealed("setComposition");
181
381
  const profiles = Object.values(index_1.CompositionProfile);
182
382
  if (!profiles.includes(composition.profile)) {
183
383
  throw new RangeError(`unknown composition profile ${JSON.stringify(composition.profile)}: the profile set is closed (§7.2)`);
@@ -200,6 +400,7 @@ class InterceptionEmitter {
200
400
  * `^[a-z][a-z0-9_-]*$` and must not begin with `jcs` (reserved so a
201
401
  * custom function can never claim golden-vector semantics). */
202
402
  setIdentityProvider(provider) {
403
+ this.unsealed("setIdentityProvider");
203
404
  if (provider !== null && provider !== index_1.JCS_SHA256) {
204
405
  if (!/^[a-z][a-z0-9_-]*$/.test(provider.name) || provider.name.startsWith("jcs")) {
205
406
  throw new RangeError("identity provider name must match ^[a-z][a-z0-9_-]*$ and must not begin with 'jcs' (§10.1)");
@@ -215,13 +416,15 @@ class InterceptionEmitter {
215
416
  * redactor that throws fails the consultation closed as
216
417
  * `host_error:approval_resolver_failed`. */
217
418
  setApprovalRedactor(redactor) {
419
+ this.unsealed("setApprovalRedactor");
218
420
  this.approvalRedactor = redactor;
219
421
  return this;
220
422
  }
221
423
  /** Register a per-emission record callback (§10.3), invoked
222
424
  * synchronously after every emission before buffering; a sink
223
425
  * exception is swallowed (audit delivery is the host's liveness
224
- * concern, not the control plane's). */
426
+ * concern, not the control plane's). Allowed on a sealed emitter: it
427
+ * changes where records go, not what they say (§7.7.7). */
225
428
  setRecordSink(sink) {
226
429
  this.recordSink = sink;
227
430
  return this;
@@ -230,6 +433,7 @@ class InterceptionEmitter {
230
433
  * is dropped and {@link recordsDropped} increments. Unbounded by
231
434
  * default. */
232
435
  setMaxRecords(max) {
436
+ this.unsealed("setMaxRecords");
233
437
  this.maxRecords = max;
234
438
  return this;
235
439
  }
@@ -285,8 +489,9 @@ class InterceptionEmitter {
285
489
  }
286
490
  }
287
491
  const identityFailed = outcome !== null;
492
+ const active = this.active(ctx);
288
493
  if (outcome === null)
289
- outcome = await this.dispatch(ctx);
494
+ outcome = await this.dispatch(ctx, active);
290
495
  const providerName = this.identity === null ? null : this.identity === index_1.JCS_SHA256 ? index_1.JCS_SHA256 : this.identity.name;
291
496
  const meta = {
292
497
  input_identity: inputIdentity,
@@ -300,13 +505,15 @@ class InterceptionEmitter {
300
505
  : null,
301
506
  decided_by: outcome.decidedBy,
302
507
  composition: this.composition,
508
+ declaration: this._declaration?.declaration ?? null,
509
+ // §10.3 summaries with the bound names attached positionally.
303
510
  verdicts: outcome.verdicts.map((v) => ({
304
511
  ...v,
305
- ...(this.names[v.index] != null ? { name: this.names[v.index] } : {}),
512
+ ...(active[v.index]?.name != null ? { name: active[v.index].name } : {}),
306
513
  })),
307
514
  fold_truncated: outcome.foldTruncated,
308
515
  resolved_by: outcome.resolvedBy,
309
- interceptors_registered: this.interceptors.length,
516
+ interceptors_registered: active.length,
310
517
  };
311
518
  let record;
312
519
  if (identityFailed) {
@@ -320,6 +527,58 @@ class InterceptionEmitter {
320
527
  else {
321
528
  record = (0, index_1.finalize)(ctx, outcome.combined, this.mode, meta);
322
529
  }
530
+ return this.deliver(record);
531
+ }
532
+ /** §10.3/§11 host projection failure: synthesize and deliver the
533
+ * fail-closed record for an emission whose `AgentContext` the host
534
+ * could not construct at all — its own projection to the wire failed
535
+ * before anything existed to {@link emit} (e.g. a tool-call argument
536
+ * getter threw during to-wire conversion at the chat seam). Without
537
+ * this the host can only fail the action closed *recordless*; with
538
+ * it the trail stays complete under host-side faults.
539
+ *
540
+ * The record is the §10.3 rejection shape: the payload-free
541
+ * projection of a `deny host_error:context_invalid` carrying
542
+ * `failure.detail` (payload-free: a type name or path, never
543
+ * content) as its message; `null` identities under the declared
544
+ * provider; `decided_by: null`; no per-interceptor summaries (no
545
+ * interceptor ran); envelope members from {@link HostFailure}, with
546
+ * the §10.3 unknown values (`""`/`-1`) where absent. It takes the
547
+ * next slot in the record stream (sink, then buffer) like any
548
+ * emission. In `enforce` mode the host MUST still fail the action
549
+ * closed; in `evaluate_only` the record documents the host fault
550
+ * without implying enforcement (§8). */
551
+ recordHostFailure(point, failure = {}) {
552
+ // Deliberately partial basis: only the envelope facts the host
553
+ // still knows. It never passes §4 validation (`spec` is absent),
554
+ // so the core's finalize always yields the §10.3 rejection shape —
555
+ // null identities under the declared provider — and keeps the
556
+ // synthesized `context_invalid` deny (with the host's detail)
557
+ // instead of substituting its own.
558
+ const basis = { interception_point: point };
559
+ if (failure.session_id !== undefined)
560
+ basis["session"] = { id: failure.session_id };
561
+ if (failure.sequence !== undefined)
562
+ basis["sequence"] = failure.sequence;
563
+ if (failure.timestamp !== undefined)
564
+ basis["timestamp"] = failure.timestamp;
565
+ const providerName = this.identity === null ? null : this.identity === index_1.JCS_SHA256 ? index_1.JCS_SHA256 : this.identity.name;
566
+ const record = (0, index_1.finalize)(basis, (0, index_1.hostErrorVerdict)(index_1.HostError.ContextInvalid, failure.detail), this.mode, {
567
+ input_identity: null,
568
+ identity_provider: providerName,
569
+ enforced_identity: null,
570
+ decided_by: null,
571
+ composition: this.composition,
572
+ declaration: this._declaration?.declaration ?? null,
573
+ verdicts: null,
574
+ fold_truncated: null,
575
+ resolved_by: null,
576
+ interceptors_registered: this.interceptors.filter((b) => runsAt(b, point)).length,
577
+ });
578
+ return this.deliver(record);
579
+ }
580
+ /** Deliver a record to the sink and the bounded buffer (§10.3). */
581
+ deliver(record) {
323
582
  if (this.recordSink) {
324
583
  // Audit delivery must not take down the control plane (§10.3).
325
584
  try {
@@ -339,23 +598,34 @@ class InterceptionEmitter {
339
598
  return record;
340
599
  }
341
600
  // ---------------------------------------------------------------------------
601
+ /** The interceptors bound at the context's point, in dispatch order
602
+ * (§7.7.8). */
603
+ active(ctx) {
604
+ const point = pointOf(ctx);
605
+ return this.interceptors.filter((b) => runsAt(b, point));
606
+ }
607
+ /** The bound a registration runs under: its own, else the emitter's. */
608
+ limit(b) {
609
+ return b.timeoutMs === undefined ? this.timeoutMs : b.timeoutMs;
610
+ }
342
611
  /** Profile dispatch (§7.4–§7.5). Returns the combined verdict and
343
612
  * its record metadata. */
344
- async dispatch(ctx) {
345
- if (this.interceptors.length === 0) {
613
+ async dispatch(ctx, active) {
614
+ if (active.length === 0) {
346
615
  // §7: zero interceptors fails closed, profile-independent.
347
616
  // Register an explicit allow-all interceptor for a deliberate
348
- // passthrough.
617
+ // passthrough. With per-point bindings (§7.7.5) this is per
618
+ // point: a surface point with no binding denies.
349
619
  return synthesized(index_1.HostError.NoInterceptor);
350
620
  }
351
621
  switch (this.composition.profile) {
352
622
  case index_1.CompositionProfile.SequentialFirstDeny:
353
- return this.dispatchFirstDeny(ctx);
623
+ return this.dispatchFirstDeny(ctx, active);
354
624
  case index_1.CompositionProfile.SequentialRunAll:
355
- return this.dispatchRunAll(ctx);
625
+ return this.dispatchRunAll(ctx, active);
356
626
  case index_1.CompositionProfile.ParallelStrictest:
357
627
  case index_1.CompositionProfile.ParallelUnanimous:
358
- return this.dispatchParallel(ctx);
628
+ return this.dispatchParallel(ctx, active);
359
629
  default:
360
630
  // Unreachable through the public API: setComposition validates
361
631
  // against the closed §7.2 set. Fail closed rather than silently
@@ -366,12 +636,12 @@ class InterceptionEmitter {
366
636
  /** Invoke one interceptor on its own copy of `ctx` (§7) and pass the
367
637
  * result through the §5 gate. Never throws: every failure becomes the
368
638
  * §6.3 synthesized deny. */
369
- async invoke(interceptor, ctx) {
639
+ async invoke(bound, ctx) {
370
640
  let v;
371
641
  try {
372
642
  // §7: each interceptor gets its own copy — in-place mutation of
373
643
  // the copy cannot alter enforcement.
374
- v = await this.withTimeout(() => interceptor.intercept(structuredClone(ctx)));
644
+ v = await this.withTimeout(() => bound.interceptor.intercept(structuredClone(ctx)), this.limit(bound));
375
645
  }
376
646
  catch (e) {
377
647
  if (e instanceof InterceptTimeout) {
@@ -400,11 +670,11 @@ class InterceptionEmitter {
400
670
  * short-circuits; a liftable deny consults the seam, then `stop` or
401
671
  * `resume` per the knob.
402
672
  *
403
- * `perInterceptor` stays index-aligned with registration order (one
673
+ * `perInterceptor` stays index-aligned with dispatch order (one
404
674
  * entry per invoked interceptor, §10.3 summaries); `pool`
405
675
  * additionally holds substituted resolutions for the §7.3 unions. */
406
- async dispatchFirstDeny(ctx) {
407
- const n = this.interceptors.length;
676
+ async dispatchFirstDeny(ctx, active) {
677
+ const n = active.length;
408
678
  const onApproval = this.composition.on_approval ?? "stop";
409
679
  const perInterceptor = [];
410
680
  const pool = [];
@@ -412,7 +682,7 @@ class InterceptionEmitter {
412
682
  let resolvedBy = null;
413
683
  const truncated = (i) => i + 1 < n;
414
684
  for (let i = 0; i < n; i++) {
415
- let v = await this.invoke(this.interceptors[i], ctx);
685
+ let v = await this.invoke(active[i], ctx);
416
686
  perInterceptor.push(v);
417
687
  pool.push(v);
418
688
  if (isHostSynthesized(v)) {
@@ -509,12 +779,12 @@ class InterceptionEmitter {
509
779
  * consulted at most once, only when the winner is liftable (which
510
780
  * implies every deny in the emission is liftable — a single plain
511
781
  * deny already won the severity order). */
512
- async dispatchRunAll(ctx) {
782
+ async dispatchRunAll(ctx, active) {
513
783
  const all = [];
514
- for (const interceptor of this.interceptors) {
784
+ for (const bound of active) {
515
785
  // §6.3 per-interceptor: a malformed verdict becomes that
516
786
  // interceptor's synthesized deny; the rest still run.
517
- const v = await this.invoke(interceptor, ctx);
787
+ const v = await this.invoke(bound, ctx);
518
788
  if (v.decision === index_1.Decision.Transform) {
519
789
  const folded = this.foldTransform(ctx, v);
520
790
  if (!(0, index_1.permits)(folded.decision)) {
@@ -539,13 +809,13 @@ class InterceptionEmitter {
539
809
  }
540
810
  /** Parallel profiles (§7.5): isolated snapshots, no fold; serial
541
811
  * dispatch (isolation semantics, not scheduling). */
542
- async dispatchParallel(ctx) {
812
+ async dispatchParallel(ctx, active) {
543
813
  // Every interceptor receives its own copy of the same untransformed
544
814
  // snapshot ({@link invoke} clones per call).
545
815
  const snapshot = structuredClone(ctx);
546
816
  const all = [];
547
- for (const interceptor of this.interceptors) {
548
- all.push(await this.invoke(interceptor, snapshot));
817
+ for (const bound of active) {
818
+ all.push(await this.invoke(bound, snapshot));
549
819
  }
550
820
  return this.aggregateAndConsult(ctx, all);
551
821
  }
@@ -634,9 +904,6 @@ class InterceptionEmitter {
634
904
  }
635
905
  return v;
636
906
  }
637
- /** The declared provider's output for `ctx` (§10.1); `null` iff the
638
- * provider is `null`. Throws {@link AgentHooksCoreError} iff the
639
- * provider rejected the context (§10.2 fail-closed value domain). */
640
907
  /** Custom-provider identity, or null when the provider fails (the
641
908
  * emission has already been decided at this point). */
642
909
  tryCustomIdentity(ctx) {
@@ -647,6 +914,9 @@ class InterceptionEmitter {
647
914
  return null;
648
915
  }
649
916
  }
917
+ /** The declared provider's output for `ctx` (§10.1); `null` iff the
918
+ * provider is `null`. Throws {@link AgentHooksCoreError} iff the
919
+ * provider rejected the context (§10.2 fail-closed value domain). */
650
920
  identityOf(ctx) {
651
921
  if (this.identity === null)
652
922
  return null;
@@ -708,7 +978,7 @@ class InterceptionEmitter {
708
978
  interception_point: ctx.interception_point,
709
979
  verdict,
710
980
  context: presented,
711
- }));
981
+ }), this.resolverTimeoutMs);
712
982
  }
713
983
  catch (e) {
714
984
  if (e instanceof InterceptTimeout) {
@@ -745,6 +1015,24 @@ class InterceptionEmitter {
745
1015
  }
746
1016
  }
747
1017
  exports.InterceptionEmitter = InterceptionEmitter;
1018
+ /** The context's interception point when it names one. */
1019
+ function pointOf(ctx) {
1020
+ const p = ctx["interception_point"];
1021
+ return typeof p === "string" && Object.values(index_1.InterceptionPoint).includes(p)
1022
+ ? p
1023
+ : null;
1024
+ }
1025
+ /** Whether a registration runs at `point` (§7.7.5). A registration
1026
+ * without an `at` filter runs everywhere. An unparseable point never
1027
+ * reaches dispatch (§4 validation denies first); every binding counts
1028
+ * then so the record is conservative. */
1029
+ function runsAt(b, point) {
1030
+ if (b.at === null)
1031
+ return true;
1032
+ if (point === null)
1033
+ return true;
1034
+ return b.at.has(point);
1035
+ }
748
1036
  /** `(code, detail)` of a native failure; anything else maps to
749
1037
  * `fallback` with the stringified error as detail. */
750
1038
  function codeAndDetail(e, fallback = index_1.HostError.ContextInvalid) {
package/dist/index.d.ts CHANGED
@@ -137,7 +137,7 @@ export interface CompositionConfig {
137
137
  on_approval?: OnApproval;
138
138
  /** `parallel/unanimous` only. */
139
139
  on_disagreement?: SynthesisPolicy;
140
- /** Parallel profiles only. */
140
+ /** `parallel/strictest` only. */
141
141
  on_transform_conflict?: SynthesisPolicy;
142
142
  }
143
143
  /** Constructors for the closed profile set (§7.2). */
@@ -231,6 +231,11 @@ export interface InterceptionRecord {
231
231
  decided_by: number | null;
232
232
  /** The composition profile and knobs in effect (§7.1). */
233
233
  composition: CompositionConfig;
234
+ /** The host declaration contract version the emitter was built from
235
+ * (§7.7.8), e.g. `agent-hooks-declaration/1.0`. Present iff the
236
+ * emitter was constructed from a declaration document; absent for
237
+ * an emitter configured in code. */
238
+ declaration?: string;
234
239
  /** Per-interceptor summary; populated in multi-verdict profiles
235
240
  * (`sequential/run_all`, `parallel/*`). */
236
241
  verdicts?: VerdictSummary[];
@@ -242,7 +247,7 @@ export interface InterceptionRecord {
242
247
  * resolution substituted; `"rejection"` iff consulted without a
243
248
  * lift; absent iff never consulted. */
244
249
  resolved_by?: "approval" | "rejection" | null;
245
- /** Interceptors registered at emission time (§10.3). */
250
+ /** Interceptors bound at the emitted point (§10.3, §7.7.8). */
246
251
  interceptors_registered: number;
247
252
  }
248
253
  /** Whether the guarded action executes (§6, §8). */
@@ -315,6 +320,10 @@ export interface FinalizeMeta {
315
320
  decided_by?: number | null;
316
321
  /** REQUIRED: the profile and knobs in effect (§7.1). */
317
322
  composition: CompositionConfig;
323
+ /** The declaration contract version the emitter was built from
324
+ * (§7.7.8); absent or `null` for an emitter configured in code. The
325
+ * core never defaults it. */
326
+ declaration?: string | null;
318
327
  /** Per-interceptor summaries (multi-verdict profiles, §10.3). */
319
328
  verdicts?: VerdictSummary[] | null;
320
329
  /** Sequential profiles (§7.4). */
@@ -340,7 +349,10 @@ export declare function composeAggregate(composition: CompositionConfig, verdict
340
349
  verdicts: VerdictSummary[];
341
350
  };
342
351
  export { AgentContextBuilder } from "./builder";
343
- export { InterceptionEmitter } from "./emitter";
352
+ export { InterceptionEmitter, EmitterSealed } from "./emitter";
353
+ export type { HostFailure } from "./emitter";
354
+ export { CAPABILITIES, DECLARATION_VERSION, DeclarationBuilder, DeclarationError, DeclarationErrorClass, HostDeclaration, HostRegistry, HostRegistryError, HostSurface, MAX_BINDINGS, MAX_DEPTH, MAX_DETAIL_LEN, MAX_DOCUMENT_BYTES, MAX_ID_LEN, MAX_KIND_LEN, MAX_TIMEOUT_MS, SUPPORTED_DECLARATION_VERSIONS, canonicalDeclaration, declarationVersions, fullKnobSupport, validKind, validReference, } from "./declaration";
355
+ export type { BindingContext, Finding, HostInfo, KindResolver, KnobSupport, RegistryNames, ResolvedBinding, ResolvedConfiguration, ResolvedDeclaration, ResolvedSurface, TimeoutSupport, ToolSeamPosture, } from "./declaration";
344
356
  /** Raised by a host when a verdict blocks the guarded action (§6). */
345
357
  /** Returned by `InterceptionEmitter.emit` on a proceeding emission:
346
358
  * the record plus the **effective** (post-composition) target the