@belticlabs/agent-risk-sdk 0.6.0 → 0.7.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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  openCall
3
- } from "./chunk-ZMPKY7AX.js";
3
+ } from "./chunk-JGVVQUXQ.js";
4
4
  import {
5
5
  summaryOf
6
6
  } from "./chunk-M4I3FGZG.js";
@@ -12,7 +12,11 @@ import {
12
12
  memorySigner,
13
13
  sha256,
14
14
  toHex
15
- } from "./chunk-77D74TWX.js";
15
+ } from "./chunk-UXXSE643.js";
16
+ import {
17
+ ApiErrorSchema,
18
+ Hex64Schema
19
+ } from "./chunk-FNU4CRJJ.js";
16
20
 
17
21
  // src/core/api-client.ts
18
22
  var TIMEOUT_MS = 1e4;
@@ -69,7 +73,7 @@ var ApiClient = class {
69
73
  json = null;
70
74
  }
71
75
  if (!res.ok) {
72
- const e = json?.error;
76
+ const e = ApiErrorSchema.safeParse(json).data?.error;
73
77
  throw new BelticApiError(
74
78
  res.status,
75
79
  e?.code ?? `HTTP_${res.status}`,
@@ -134,10 +138,11 @@ var Decision = class _Decision {
134
138
  };
135
139
 
136
140
  // src/core/identity.ts
137
- var SEED_HEX = /^[0-9a-f]{64}$/i;
138
141
  function identityFromSeed(seedHex) {
139
- if (!SEED_HEX.test(seedHex))
140
- throw new BelticConfigError("agentSeed must be 64 hex characters (a 32-byte Ed25519 seed)");
142
+ if (!Hex64Schema.safeParse(seedHex).success)
143
+ throw new BelticConfigError(
144
+ "agentSeed must be 64 lowercase hex characters (a 32-byte Ed25519 seed)"
145
+ );
141
146
  const signer = memorySigner(fromHex(seedHex));
142
147
  const did = didKeyFromEd25519(signer.publicKey);
143
148
  return { did, signer: { ...signer, keyId: did } };
@@ -157,14 +162,16 @@ var Memory = class {
157
162
  }
158
163
  };
159
164
  var Session = class _Session {
160
- constructor(deps, key, opts = {}) {
165
+ constructor(deps, opts = {}, resume = null) {
161
166
  this.deps = deps;
162
- this.key = key;
163
167
  this.opts = opts;
168
+ this.conversationId = resume;
164
169
  this.openedWith = opts.intent ? _Session.hash(opts.intent) : null;
165
170
  }
166
171
  opened = null;
167
172
  current = null;
173
+ /** The conversation id: what was resumed, then what the platform answered. */
174
+ conversationId;
168
175
  /** JCS hash of the mandate on the chain, and of the one the options carried. */
169
176
  declared = null;
170
177
  openedWith;
@@ -172,18 +179,29 @@ var Session = class _Session {
172
179
  timer = null;
173
180
  calls = new Memory();
174
181
  decisions = new Memory();
175
- /** The platform's id for this session — opened on first use, `null` while there is none. */
182
+ /**
183
+ * The conversation id — what the host stores and resumes with (GAP-84).
184
+ * Opened on first use; `null` while the platform has not answered and
185
+ * nothing was resumed.
186
+ */
176
187
  id() {
177
- return this.stream().then((stream) => stream?.id ?? null);
188
+ return this.stream().then((stream) => stream?.conversationId ?? this.conversationId);
178
189
  }
179
190
  /**
180
- * The stream this session records into — opened on first use, `null`
191
+ * The stream this session records into — opened on first use, reopened
192
+ * in the same conversation once the platform ended it (GAP-85), `null`
181
193
  * when there is none. For the integrations; a host never holds it.
182
194
  * @internal
183
195
  */
184
196
  stream() {
185
- const next = this.deps.streams.open(this.key, this.opts);
197
+ const prior = this.opened ?? Promise.resolve(null);
198
+ const next = prior.catch(() => null).then(
199
+ (stream) => stream && (this.deps.streams.halted(stream) || !stream.isClosed) ? stream : this.open()
200
+ );
186
201
  this.opened = next;
202
+ next.catch(() => {
203
+ if (this.opened === next) this.opened = null;
204
+ });
187
205
  return next.then((stream) => {
188
206
  if (stream !== this.current) {
189
207
  this.current = stream;
@@ -192,6 +210,17 @@ var Session = class _Session {
192
210
  return stream;
193
211
  });
194
212
  }
213
+ async open() {
214
+ const stream = await this.deps.streams.open({
215
+ ...this.opts,
216
+ resume: this.conversationId ?? void 0
217
+ });
218
+ if (stream) {
219
+ this.conversationId = stream.conversationId;
220
+ this.deps.onOpened(this, stream.conversationId);
221
+ }
222
+ return stream;
223
+ }
195
224
  /**
196
225
  * The platform's verdict on a payment about to be presented. Asked once
197
226
  * per call id: a host that re-runs its approval step reads the same
@@ -205,7 +234,7 @@ var Session = class _Session {
205
234
  if (!stream) return Decision.absent();
206
235
  if (opts.intent) await this.declare(stream, opts.intent);
207
236
  const summary = summaryOf(payment);
208
- const decision = await this.deps.evaluate(stream.id, summary);
237
+ const decision = await this.deps.evaluate(stream.id, summary, opts.callId);
209
238
  if (decision.absent) return decision;
210
239
  if (opts.callId) this.decisions.set(opts.callId, decision);
211
240
  const call = opts.callId ? this.calls.get(opts.callId)?.call : void 0;
@@ -304,86 +333,46 @@ var Session = class _Session {
304
333
  }
305
334
  };
306
335
 
307
- // src/core/stream.ts
308
- var Stream = class {
309
- constructor(deps, id, source, born) {
310
- this.deps = deps;
311
- this.id = id;
312
- this.source = source;
313
- this.born = born;
314
- this.chain = Chain.genesis(id, source);
315
- }
316
- chain;
317
- building = Promise.resolve();
318
- dropped = 0;
319
- droppedFirstTs = null;
320
- droppedLastTs = null;
321
- closed = false;
322
- get isClosed() {
323
- return this.closed;
324
- }
325
- /**
326
- * Resolves once the event is sequenced and buffered — not once it is
327
- * acknowledged. `false` when the event was dropped for lack of room.
328
- */
329
- async emit(kind, payload) {
330
- const halted = this.deps.transport.haltedError(this.id, this.source);
331
- if (halted) throw halted;
332
- const ts = (/* @__PURE__ */ new Date()).toISOString();
333
- if (!this.deps.transport.hasRoom()) {
334
- this.dropped++;
335
- this.droppedFirstTs ??= ts;
336
- this.droppedLastTs = ts;
337
- return false;
338
- }
339
- if (this.dropped > 0) {
340
- this.deps.transport.enqueue(
341
- await this.next(
342
- "transport.gap",
343
- { dropped: this.dropped, firstTs: this.droppedFirstTs, lastTs: this.droppedLastTs },
344
- ts
345
- )
346
- );
347
- this.dropped = 0;
348
- this.droppedFirstTs = this.droppedLastTs = null;
349
- }
350
- this.deps.transport.enqueue(await this.next(kind, payload, ts));
351
- return true;
352
- }
353
- /** The tool call whose `execute` the host runs itself; see `ToolCallSpan`. */
354
- toolCall(call) {
355
- const { callId, ...start } = call;
356
- return openCall(this, "tool_call", callId, { transport: "local", ...start });
357
- }
358
- async close(reason = "completed", extra = {}) {
359
- if (this.closed) return;
360
- this.closed = true;
361
- await this.emit("session.close", { reason, ...extra });
362
- await this.flush();
363
- this.deps.onClosed?.(this);
364
- }
365
- /** Read-your-writes: the platform must hold the evidence before anyone judges it (GAP-16/66). */
366
- flush() {
367
- return this.deps.transport.flush();
368
- }
369
- /** Serialized: two concurrent emits get consecutive seqs, never the same one. */
370
- next(kind, payload, ts) {
371
- const run = this.building.then(async () => {
372
- const built = await this.chain.append({ ts, kind, payload }, this.deps.signer);
373
- this.chain = built.chain;
374
- return built.event;
375
- });
376
- this.building = run.catch(() => void 0);
377
- return run;
336
+ // src/core/prompt-ledger.ts
337
+ var PromptLedger = class _PromptLedger {
338
+ last;
339
+ constructor(base = null) {
340
+ this.last = base;
341
+ }
342
+ /** How the platform digests one message: over its canonical bytes. */
343
+ static digest(message) {
344
+ return toHex(sha256(canonicalBytes(message)));
345
+ }
346
+ /** The prompt as the wire carries it — whole, or against the last one — and this call becomes the last. */
347
+ encode(callId, messages) {
348
+ const digests = messages.map(_PromptLedger.digest);
349
+ const last = this.last;
350
+ this.last = { prior: callId, digests };
351
+ if (!last) return { prompt: messages };
352
+ let shared = 0;
353
+ while (shared < last.digests.length && shared < digests.length && last.digests[shared] === digests[shared])
354
+ shared++;
355
+ if (shared === 0) return { prompt: messages };
356
+ return { promptDelta: { prior: last.prior, shared, messages: messages.slice(shared) } };
357
+ }
358
+ /** The last call never reached the platform: the next one ships whole. */
359
+ forget() {
360
+ this.last = null;
378
361
  }
379
362
  };
380
363
 
381
364
  // src/core/transport.ts
382
365
  var FLUSH_MS = 1e3;
383
366
  var MAX_BATCH = 50;
367
+ var MAX_BATCH_BYTES = 8 * 1024 * 1024;
368
+ var MAX_EVENT_BYTES = 1024 * 1024;
384
369
  var MAX_BUFFERED = 5e3;
370
+ var MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
371
+ var MAX_IN_FLIGHT = 8;
372
+ var ENVELOPE_BYTES = 512;
385
373
  var BACKOFF_BASE_MS = 200;
386
374
  var BACKOFF_MAX_MS = 3e4;
375
+ var ENDED_CODES = /* @__PURE__ */ new Set(["SESSION_CLOSED", "SESSION_EXPIRED"]);
387
376
  var ChainRejectedError = class extends Error {
388
377
  constructor(sessionId, source, result, options) {
389
378
  super(
@@ -396,18 +385,15 @@ var ChainRejectedError = class extends Error {
396
385
  this.name = "ChainRejectedError";
397
386
  }
398
387
  };
399
- var TransportClosedError = class extends Error {
400
- constructor() {
401
- super("transport is closed");
402
- this.name = "TransportClosedError";
403
- }
404
- };
405
388
  var Transport = class _Transport {
406
389
  constructor(api) {
407
390
  this.api = api;
408
391
  }
409
392
  chains = /* @__PURE__ */ new Map();
393
+ waiting = [];
394
+ running = 0;
410
395
  buffered = 0;
396
+ bufferedBytes = 0;
411
397
  timer = null;
412
398
  closed = false;
413
399
  /**
@@ -419,35 +405,62 @@ var Transport = class _Transport {
419
405
  static outage(err) {
420
406
  return err instanceof BelticApiError && err.retryable;
421
407
  }
422
- get size() {
423
- return this.buffered;
408
+ /** The bytes the platform will measure for an event with this payload (GAP-59), known before a `seq` is spent. */
409
+ static measure(payload) {
410
+ return canonicalBytes(payload).length + ENVELOPE_BYTES;
411
+ }
412
+ /** Whether the platform would take an event of this size at all. */
413
+ static fits(bytes) {
414
+ return bytes <= MAX_EVENT_BYTES;
424
415
  }
425
- hasRoom() {
426
- return !this.closed && this.buffered < MAX_BUFFERED;
416
+ hasRoom(bytes) {
417
+ return !this.closed && this.buffered < MAX_BUFFERED && this.bufferedBytes + bytes <= MAX_BUFFERED_BYTES;
427
418
  }
428
419
  haltedError(sessionId, source) {
429
420
  return this.chains.get(`${sessionId}:${source}`)?.halted ?? null;
430
421
  }
431
- /** Callers check `hasRoom()` first and assign `seq` only then (GAP-38). */
432
- enqueue(ev) {
433
- if (this.closed) throw new TransportClosedError();
422
+ /** Whether the platform ended the session under this chain (GAP-85). */
423
+ ended(sessionId, source) {
424
+ return this.chains.get(`${sessionId}:${source}`)?.ended ?? false;
425
+ }
426
+ /** Callers check `hasRoom(bytes)` first and assign `seq` only then (GAP-38). */
427
+ enqueue(ev, bytes) {
428
+ if (this.closed) throw new Error("transport is closed");
434
429
  const key = `${ev.sessionId}:${ev.source}`;
435
430
  let chain = this.chains.get(key);
436
431
  if (!chain) {
437
- chain = { pending: [], inFlight: null, retry: null, attempts: 0, halted: null };
432
+ chain = {
433
+ sessionId: ev.sessionId,
434
+ pending: [],
435
+ inFlight: null,
436
+ start: null,
437
+ retry: null,
438
+ attempts: 0,
439
+ halted: null,
440
+ ended: false
441
+ };
438
442
  this.chains.set(key, chain);
439
443
  }
440
444
  if (chain.halted) throw chain.halted;
441
- if (!this.hasRoom()) throw new Error("transport buffer is full");
442
- chain.pending.push(ev);
445
+ if (chain.ended) return;
446
+ if (!this.hasRoom(bytes)) throw new Error("transport buffer is full");
447
+ chain.pending.push({ ev, bytes });
443
448
  this.buffered++;
449
+ this.bufferedBytes += bytes;
444
450
  if (chain.pending.length >= MAX_BATCH) void this.deliver(chain, false);
445
451
  else this.schedule();
446
452
  }
447
- /** One attempt per chain, now — a chain waiting out its backoff included; resolves once every attempt settled. */
448
- async flush() {
449
- this.unschedule();
450
- await Promise.all([...this.chains.values()].map((chain) => this.deliver(chain, true)));
453
+ /**
454
+ * One attempt per chain, now — a chain waiting out its backoff included;
455
+ * resolves once every attempt settled. With a scope, only that session's
456
+ * chains (GAP-88).
457
+ */
458
+ async flush(scope) {
459
+ if (!scope) this.unschedule();
460
+ const chains = [...this.chains.values()].filter(
461
+ (chain) => !scope || chain.sessionId === scope.sessionId
462
+ );
463
+ await Promise.all(chains.map((chain) => this.deliver(chain, true)));
451
464
  }
452
465
  async close() {
453
466
  await this.flush();
@@ -470,28 +483,72 @@ var Transport = class _Transport {
470
483
  clearTimeout(this.timer);
471
484
  this.timer = null;
472
485
  }
473
- /** A chain waiting out its backoff is left alone unless forced: only `flush` cuts a backoff short. */
486
+ /**
487
+ * Queues the chain for a delivery slot — at the front when forced. A
488
+ * chain waiting out its backoff is left alone unless forced: only
489
+ * `flush` cuts a backoff short.
490
+ */
474
491
  deliver(chain, force) {
475
- if (chain.inFlight) return chain.inFlight;
476
- if (chain.halted || chain.pending.length === 0) return Promise.resolve();
492
+ if (chain.inFlight) {
493
+ if (force && chain.start) this.promote(chain);
494
+ return chain.inFlight;
495
+ }
496
+ if (chain.halted || chain.ended || chain.pending.length === 0) return Promise.resolve();
477
497
  if (chain.retry) {
478
498
  if (!force) return Promise.resolve();
479
499
  clearTimeout(chain.retry);
480
500
  chain.retry = null;
481
501
  }
482
- chain.inFlight = this.attempt(chain).finally(() => {
483
- chain.inFlight = null;
502
+ chain.inFlight = new Promise((resolve) => {
503
+ chain.start = () => {
504
+ chain.start = null;
505
+ this.running++;
506
+ void this.attempt(chain).finally(() => {
507
+ this.running--;
508
+ chain.inFlight = null;
509
+ resolve();
510
+ this.pump();
511
+ });
512
+ };
484
513
  });
514
+ if (force) this.waiting.unshift(chain);
515
+ else this.waiting.push(chain);
516
+ this.pump();
485
517
  return chain.inFlight;
486
518
  }
519
+ promote(chain) {
520
+ const at = this.waiting.indexOf(chain);
521
+ if (at > 0) {
522
+ this.waiting.splice(at, 1);
523
+ this.waiting.unshift(chain);
524
+ }
525
+ }
526
+ pump() {
527
+ while (this.running < MAX_IN_FLIGHT && this.waiting.length > 0) this.waiting.shift().start?.();
528
+ }
529
+ /** The head of the queue that fits one batch: by count or by bytes, whichever comes first, and never empty. */
530
+ static batchOf(chain) {
531
+ const batch = [];
532
+ let bytes = 0;
533
+ for (const item of chain.pending) {
534
+ if (batch.length >= MAX_BATCH) break;
535
+ if (batch.length > 0 && bytes + item.bytes > MAX_BATCH_BYTES) break;
536
+ batch.push(item);
537
+ bytes += item.bytes;
538
+ }
539
+ return batch;
540
+ }
487
541
  /** Batches until the chain drains; a retryable failure schedules the next attempt and returns. */
488
542
  async attempt(chain) {
489
543
  while (chain.pending.length > 0 && !chain.halted) {
490
- const batch = chain.pending.slice(0, MAX_BATCH);
491
- const first = batch[0];
544
+ const batch = _Transport.batchOf(chain);
545
+ const first = batch[0].ev;
492
546
  let ack;
493
547
  try {
494
- ack = await this.api.post("/v1/evidence", batch);
548
+ ack = await this.api.post(
549
+ "/v1/evidence",
550
+ batch.map((item) => item.ev)
551
+ );
495
552
  } catch (err) {
496
553
  console.error("[beltic]", err);
497
554
  if (!_Transport.outage(err)) {
@@ -522,19 +579,142 @@ var Transport = class _Transport {
522
579
  chain.retry.unref?.();
523
580
  return;
524
581
  }
525
- chain.pending.splice(0, batch.length);
526
- this.buffered -= batch.length;
582
+ this.release(chain, batch.length);
527
583
  chain.attempts = 0;
528
584
  const bad = ack.results.find((r) => r.status === "fork" || r.status === "rejected");
529
- if (bad) this.halt(chain, new ChainRejectedError(first.sessionId, first.source, bad));
585
+ if (!bad) continue;
586
+ if (bad.status === "rejected" && bad.code && ENDED_CODES.has(bad.code)) this.end(chain);
587
+ else this.halt(chain, new ChainRejectedError(first.sessionId, first.source, bad));
588
+ }
589
+ }
590
+ release(chain, count) {
591
+ for (const item of chain.pending.splice(0, count)) {
592
+ this.buffered--;
593
+ this.bufferedBytes -= item.bytes;
530
594
  }
531
595
  }
596
+ drop(chain) {
597
+ this.release(chain, chain.pending.length);
598
+ }
532
599
  halt(chain, error) {
533
600
  chain.halted = error;
534
- this.buffered -= chain.pending.length;
535
- chain.pending = [];
601
+ this.drop(chain);
536
602
  console.error("[beltic]", error);
537
603
  }
604
+ end(chain) {
605
+ chain.ended = true;
606
+ this.drop(chain);
607
+ }
608
+ };
609
+
610
+ // src/core/stream.ts
611
+ var Stream = class {
612
+ constructor(deps, id, conversationId, source, born) {
613
+ this.deps = deps;
614
+ this.id = id;
615
+ this.conversationId = conversationId;
616
+ this.source = source;
617
+ this.born = born;
618
+ this.chain = Chain.genesis(id, source);
619
+ this.ledger = new PromptLedger(deps.prompt ?? null);
620
+ }
621
+ chain;
622
+ ledger;
623
+ building = Promise.resolve();
624
+ dropped = 0;
625
+ oversized = 0;
626
+ droppedFirstTs = null;
627
+ droppedLastTs = null;
628
+ closed = false;
629
+ /** Closed by this side, or ended by the platform (GAP-85). */
630
+ get isClosed() {
631
+ return this.closed || this.deps.transport.ended(this.id, this.source);
632
+ }
633
+ /**
634
+ * Resolves once the event is sequenced and buffered — not once it is
635
+ * acknowledged. `false` when the event was dropped for lack of room.
636
+ */
637
+ async emit(kind, payload) {
638
+ const halted = this.deps.transport.haltedError(this.id, this.source);
639
+ if (halted) throw halted;
640
+ if (this.deps.transport.ended(this.id, this.source)) return false;
641
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
642
+ const bytes = Transport.measure(payload);
643
+ const gap = this.dropped > 0 ? this.gap() : null;
644
+ const gapBytes = gap ? Transport.measure(gap) : 0;
645
+ if (!Transport.fits(bytes) || !this.deps.transport.hasRoom(bytes + gapBytes)) {
646
+ this.dropped++;
647
+ if (!Transport.fits(bytes)) this.oversized++;
648
+ this.droppedFirstTs ??= ts;
649
+ this.droppedLastTs = ts;
650
+ return false;
651
+ }
652
+ if (gap) {
653
+ this.dropped = this.oversized = 0;
654
+ this.droppedFirstTs = this.droppedLastTs = null;
655
+ this.deps.transport.enqueue(
656
+ await this.next("transport.gap", gap, ts),
657
+ gapBytes
658
+ );
659
+ }
660
+ this.deps.transport.enqueue(await this.next(kind, payload, ts), bytes);
661
+ return true;
662
+ }
663
+ /** What the drops since the last accepted event add up to (GAP-38/87). */
664
+ gap() {
665
+ return {
666
+ dropped: this.dropped,
667
+ ...this.oversized > 0 ? { oversized: this.oversized } : {},
668
+ firstTs: this.droppedFirstTs,
669
+ lastTs: this.droppedLastTs
670
+ };
671
+ }
672
+ /**
673
+ * A model call as a span: `llm_call.start` now, with the prompt as a
674
+ * delta against the last one on this chain (GAP-89), `llm_call.end` when
675
+ * the host reports the result. A start that never left (dropped) forgets
676
+ * the ledger, so the next call ships its prompt whole.
677
+ */
678
+ llmCall(call) {
679
+ const { callId, params, ...start } = call;
680
+ const { prompt, ...rest } = params;
681
+ const encoded = Array.isArray(prompt) ? this.ledger.encode(callId, prompt) : prompt === void 0 ? {} : { prompt };
682
+ const span = openCall(this, "llm_call", callId, { ...start, params: { ...rest, ...encoded } });
683
+ if (Array.isArray(prompt))
684
+ span.opened.then(
685
+ (sequenced) => {
686
+ if (!sequenced) this.ledger.forget();
687
+ },
688
+ () => this.ledger.forget()
689
+ );
690
+ return span;
691
+ }
692
+ /** The tool call whose `execute` the host runs itself; see `ToolCallSpan`. */
693
+ toolCall(call) {
694
+ const { callId, ...start } = call;
695
+ return openCall(this, "tool_call", callId, { transport: "local", ...start });
696
+ }
697
+ async close(reason = "completed", extra = {}) {
698
+ if (this.closed) return;
699
+ this.closed = true;
700
+ await this.emit("session.close", { reason, ...extra });
701
+ await this.flush();
702
+ this.deps.onClosed?.(this);
703
+ }
704
+ /** Read-your-writes for this session's chains, and only them (GAP-16/66/88). */
705
+ flush() {
706
+ return this.deps.transport.flush({ sessionId: this.id });
707
+ }
708
+ /** Serialized: two concurrent emits get consecutive seqs, never the same one. */
709
+ next(kind, payload, ts) {
710
+ const run = this.building.then(async () => {
711
+ const built = await this.chain.append({ ts, kind, payload }, this.deps.signer);
712
+ this.chain = built.chain;
713
+ return built.event;
714
+ });
715
+ this.building = run.catch(() => void 0);
716
+ return run;
717
+ }
538
718
  };
539
719
 
540
720
  // src/core/streams.ts
@@ -544,35 +724,12 @@ var Streams = class {
544
724
  this.deps = deps;
545
725
  }
546
726
  attached = /* @__PURE__ */ new Map();
547
- /** Buyer streams by the host's own key (GAP-71). */
548
- opened = /* @__PURE__ */ new Map();
549
727
  retryAt = 0;
550
728
  /**
551
- * Buyer half: the stream for a key of the host's own, opened on first
552
- * use and reused after. A halted chain is reopened as a fresh session
553
- * that continues the same key; a closed key is forgotten.
729
+ * Buyer half: a fresh AGENT_TRACE stream — a new conversation, or a new
730
+ * session of the one `resume` names. `null` while the platform is out.
554
731
  */
555
- open(key, input = {}) {
556
- const prior = this.opened.get(key) ?? Promise.resolve(null);
557
- const next = prior.catch(() => null).then(
558
- (stream) => stream && !stream.isClosed && !this.deps.transport.haltedError(stream.id, stream.source) ? stream : this.openFresh(input, () => this.forget(key, next))
559
- );
560
- this.opened.set(key, next);
561
- next.catch(() => this.forget(key, next));
562
- return next;
563
- }
564
- /** Seller half: emit INTERNAL_NETWORK evidence into a session the buyer bound, or open a seller-born one. */
565
- async ensure(sessionId) {
566
- if (sessionId) return this.attach(sessionId, "INTERNAL_NETWORK", "buyer");
567
- const out = await this.deps.api.post("/v1/sessions", {
568
- source: "INTERNAL_NETWORK"
569
- });
570
- return this.attach(out.sessionId, "INTERNAL_NETWORK", "seller");
571
- }
572
- forget(key, entry) {
573
- if (this.opened.get(key) === entry) this.opened.delete(key);
574
- }
575
- async openFresh(input, onClosed) {
732
+ async open(input = {}, onClosed) {
576
733
  const identity = this.deps.identity;
577
734
  if (!identity)
578
735
  throw new BelticConfigError(
@@ -590,14 +747,30 @@ var Streams = class {
590
747
  return null;
591
748
  }
592
749
  }
750
+ /** Seller half: emit INTERNAL_NETWORK evidence into a session the buyer bound, or open a seller-born one. */
751
+ async ensure(sessionId) {
752
+ if (sessionId) return this.attach(sessionId, sessionId, "INTERNAL_NETWORK", "buyer");
753
+ const out = await this.deps.api.post("/v1/sessions", {
754
+ source: "INTERNAL_NETWORK"
755
+ });
756
+ return this.attach(out.sessionId, out.conversationId, "INTERNAL_NETWORK", "seller");
757
+ }
758
+ /** Whether the platform refused this stream's chain: its next `emit` throws (GAP-85). */
759
+ halted(stream) {
760
+ return this.deps.transport.haltedError(stream.id, stream.source) !== null;
761
+ }
593
762
  async create(identity, input, onClosed) {
594
763
  const body = {
595
764
  source: "AGENT_TRACE",
596
765
  agent: { did: identity.did, credential: identity.did },
597
- ...input.intent ? { intent: input.intent } : {}
766
+ ...input.intent ? { intent: input.intent } : {},
767
+ ...input.resume ? { resume: input.resume } : {}
598
768
  };
599
769
  const out = await this.deps.api.post("/v1/sessions", body);
600
- const stream = this.attach(out.sessionId, "AGENT_TRACE", "buyer", onClosed);
770
+ const stream = this.attach(out.sessionId, out.conversationId, "AGENT_TRACE", "buyer", {
771
+ prompt: out.prompt,
772
+ onClosed
773
+ });
601
774
  await stream.emit("session.open", {
602
775
  runtime: {
603
776
  sdk: "@belticlabs/agent-risk-sdk",
@@ -609,7 +782,7 @@ var Streams = class {
609
782
  if (input.intent) await stream.emit("intent.declared", input.intent);
610
783
  return stream;
611
784
  }
612
- attach(id, source, born, onClosed) {
785
+ attach(id, conversationId, source, born, opts = {}) {
613
786
  const key = `${id}:${source}`;
614
787
  const existing = this.attached.get(key);
615
788
  if (existing) return existing;
@@ -617,12 +790,14 @@ var Streams = class {
617
790
  {
618
791
  transport: this.deps.transport,
619
792
  signer: source === "AGENT_TRACE" ? this.deps.identity?.signer : void 0,
793
+ prompt: opts.prompt,
620
794
  onClosed: () => {
621
795
  this.attached.delete(key);
622
- onClosed?.();
796
+ opts.onClosed?.();
623
797
  }
624
798
  },
625
799
  id,
800
+ conversationId,
626
801
  source,
627
802
  born
628
803
  );
@@ -632,12 +807,13 @@ var Streams = class {
632
807
  };
633
808
 
634
809
  // src/client.ts
635
- var SDK_VERSION = "0.6.0";
810
+ var SDK_VERSION = "0.7.0";
636
811
  var Beltic = class _Beltic {
637
812
  /** The stream registry, for the protocol adapters. @internal */
638
813
  streams;
639
814
  api;
640
815
  transport;
816
+ /** Open handles by conversation id (GAP-84). */
641
817
  sessions = /* @__PURE__ */ new Map();
642
818
  /**
643
819
  * The client the environment describes: `BELTIC_API_KEY` and
@@ -673,38 +849,48 @@ var Beltic = class _Beltic {
673
849
  });
674
850
  }
675
851
  /**
676
- * The session for a key of the host's own (its session, run or
677
- * conversation id) — one object per key until it closes; the options
678
- * count on the first call only. See `Session`.
852
+ * A conversation: new without an id, resumed with one — the id `session.id()`
853
+ * answered in this or any earlier process (GAP-84). One handle per
854
+ * conversation in-process until it closes; the options count when a
855
+ * session is opened. See `Session`.
679
856
  */
680
- session(key, opts = {}) {
681
- const existing = this.sessions.get(key);
857
+ session(id, opts = {}) {
858
+ const existing = id ? this.sessions.get(id) : void 0;
682
859
  if (existing) return existing;
683
860
  const session = new Session(
684
861
  {
685
862
  streams: this.streams,
686
- evaluate: (sessionId, payment) => this.evaluate(sessionId, payment),
863
+ evaluate: (sessionId, payment, callId) => this.evaluate(sessionId, payment, { callId }),
864
+ onOpened: (opened, conversationId) => {
865
+ if (!this.sessions.has(conversationId)) this.sessions.set(conversationId, opened);
866
+ },
687
867
  onClosed: (closed) => {
688
- if (this.sessions.get(key) === closed) this.sessions.delete(key);
868
+ for (const [key, s] of this.sessions) if (s === closed) this.sessions.delete(key);
689
869
  }
690
870
  },
691
- key,
692
- opts
871
+ opts,
872
+ id ?? null
693
873
  );
694
- this.sessions.set(key, session);
874
+ if (id) this.sessions.set(id, session);
695
875
  return session;
696
876
  }
697
877
  /**
698
878
  * The platform's verdict on a payment — the seller's before it verifies,
699
879
  * the buyer's before it presents (Fraud SDK RFC › Evaluation Client).
700
- * Read-your-writes: the buffered evidence is flushed first so the
701
- * platform judges what the caller already saw (GAP-16). Absent when the
702
- * platform could not be reached (GAP-70).
880
+ * Read-your-writes: the session's buffered evidence is flushed first so
881
+ * the platform judges what the caller already saw (GAP-16/88). With a `callId`
882
+ * the platform answers the decision already taken for that call anywhere
883
+ * in the conversation (GAP-84). Absent when the platform could not be
884
+ * reached (GAP-70).
703
885
  */
704
- async evaluate(sessionId, payment) {
705
- const input = { sessionId, payment };
886
+ async evaluate(sessionId, payment, opts = {}) {
887
+ const input = {
888
+ sessionId,
889
+ payment,
890
+ ...opts.callId ? { callId: opts.callId } : {}
891
+ };
706
892
  try {
707
- await this.transport.flush();
893
+ await this.transport.flush({ sessionId });
708
894
  return Decision.of(await this.api.post("/v1/evaluate", input));
709
895
  } catch (err) {
710
896
  if (!Transport.outage(err)) throw err;
@@ -712,7 +898,7 @@ var Beltic = class _Beltic {
712
898
  return Decision.absent();
713
899
  }
714
900
  }
715
- /** Send everything buffered now and wait for that attempt. */
901
+ /** Send everything buffered now — every session's — and wait for that attempt. */
716
902
  flush() {
717
903
  return this.transport.flush();
718
904
  }