@belticlabs/agent-risk-sdk 0.5.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,62 +1,120 @@
1
1
  import {
2
- Verdict
3
- } from "./chunk-4BUUPU3O.js";
2
+ openCall
3
+ } from "./chunk-JGVVQUXQ.js";
4
4
  import {
5
- ApiClient,
6
- BelticApiError,
7
- BelticConfigError,
8
- ChainRejectedError,
9
- Session,
10
- Sessions,
11
- Transport
12
- } from "./chunk-JSE6JQJC.js";
5
+ summaryOf
6
+ } from "./chunk-M4I3FGZG.js";
13
7
  import {
8
+ Chain,
14
9
  canonicalBytes,
15
- canonicalize,
16
10
  didKeyFromEd25519,
17
11
  fromHex,
18
12
  memorySigner,
19
13
  sha256,
20
14
  toHex
21
- } from "./chunk-X3W2Z5GC.js";
15
+ } from "./chunk-UXXSE643.js";
16
+ import {
17
+ ApiErrorSchema,
18
+ Hex64Schema
19
+ } from "./chunk-FNU4CRJJ.js";
22
20
 
23
- // src/core/identity.ts
24
- function identityFromSeed(seed, credential) {
25
- const signer = memorySigner(seed);
26
- const did = didKeyFromEd25519(signer.publicKey);
27
- return { did, signer: { ...signer, keyId: did }, ...credential ? { credential } : {} };
28
- }
21
+ // src/core/api-client.ts
22
+ var TIMEOUT_MS = 1e4;
23
+ var BelticApiError = class extends Error {
24
+ constructor(status, code, message, details, requestId) {
25
+ super(message);
26
+ this.status = status;
27
+ this.code = code;
28
+ this.details = details;
29
+ this.requestId = requestId;
30
+ this.name = "BelticApiError";
31
+ }
32
+ /** 5xx, 429 and network failures are an outage: retried by the transport, absorbed by the fail-open entries; 4xx are neither (GAP-70). */
33
+ get retryable() {
34
+ return this.status === 0 || this.status >= 500 || this.status === 429;
35
+ }
36
+ };
37
+ var ApiClient = class {
38
+ baseUrl;
39
+ headers;
40
+ constructor(baseUrl, apiKey, userAgent) {
41
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
42
+ this.headers = {
43
+ authorization: `Bearer ${apiKey}`,
44
+ "content-type": "application/json",
45
+ "user-agent": userAgent
46
+ };
47
+ }
48
+ async post(path, body) {
49
+ const controller = new AbortController();
50
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
51
+ let res;
52
+ try {
53
+ res = await globalThis.fetch(`${this.baseUrl}${path}`, {
54
+ method: "POST",
55
+ headers: this.headers,
56
+ body: JSON.stringify(body),
57
+ signal: controller.signal
58
+ });
59
+ } catch (err) {
60
+ throw new BelticApiError(
61
+ 0,
62
+ "NETWORK",
63
+ `request to ${path} failed: ${err.message}`
64
+ );
65
+ } finally {
66
+ clearTimeout(timer);
67
+ }
68
+ const text = await res.text();
69
+ let json = null;
70
+ try {
71
+ json = text ? JSON.parse(text) : null;
72
+ } catch {
73
+ json = null;
74
+ }
75
+ if (!res.ok) {
76
+ const e = ApiErrorSchema.safeParse(json).data?.error;
77
+ throw new BelticApiError(
78
+ res.status,
79
+ e?.code ?? `HTTP_${res.status}`,
80
+ e?.message ?? res.statusText,
81
+ e?.details,
82
+ e?.request_id
83
+ );
84
+ }
85
+ return json;
86
+ }
87
+ };
29
88
 
30
- // src/core/payment-moment.ts
31
- function summaryOf(m) {
32
- return {
33
- protocol: m.protocol,
34
- payee: m.payee,
35
- amount: { value: m.amount.value, currency: m.amount.currency },
36
- ...m.payer ? { payer: m.payer } : {}
37
- };
38
- }
89
+ // src/core/config-error.ts
90
+ var BelticConfigError = class extends Error {
91
+ code = "CONFIG";
92
+ constructor(message) {
93
+ super(`Beltic: ${message}`);
94
+ this.name = "BelticConfigError";
95
+ }
96
+ };
39
97
 
40
98
  // src/core/decision.ts
41
99
  var Decision = class _Decision {
42
- constructor(evaluation) {
43
- this.evaluation = evaluation;
100
+ constructor(output) {
101
+ this.output = output;
44
102
  }
45
103
  static ABSENT = new _Decision(null);
46
- static of(evaluation) {
47
- return new _Decision(evaluation);
104
+ static of(output) {
105
+ return new _Decision(output);
48
106
  }
49
107
  static absent() {
50
108
  return _Decision.ABSENT;
51
109
  }
52
110
  get value() {
53
- return this.evaluation?.decision ?? null;
111
+ return this.output?.decision ?? null;
54
112
  }
55
113
  get reasonCodes() {
56
- return this.evaluation?.reasonCodes ?? [];
114
+ return this.output?.reasonCodes ?? [];
57
115
  }
58
116
  get decisionId() {
59
- return this.evaluation?.decisionId ?? null;
117
+ return this.output?.decisionId ?? null;
60
118
  }
61
119
  get allowed() {
62
120
  return this.value === "ALLOW";
@@ -68,22 +126,29 @@ var Decision = class _Decision {
68
126
  return this.value === "REVIEW";
69
127
  }
70
128
  get absent() {
71
- return this.evaluation === null;
72
- }
73
- /** Whether a gate must stop the payment (GAP-52); an absent verdict never blocks. */
74
- blocks(onReview) {
75
- return this.evaluation ? Verdict.of(this.evaluation.decision).blocks(onReview) : false;
129
+ return this.output === null;
76
130
  }
77
131
  /** One sentence for the agent or the person: what Beltic said and why. */
78
132
  explain() {
79
- if (!this.evaluation) return "Beltic could not be asked about this payment.";
133
+ if (!this.output) return "Beltic could not be asked about this payment.";
80
134
  const verb = this.denied ? "denied" : this.review ? "asked for review of" : "allowed";
81
135
  const why = this.reasonCodes.length > 0 ? ` (${this.reasonCodes.join(", ")})` : "";
82
136
  return `Beltic ${verb} this payment${why}.`;
83
137
  }
84
138
  };
85
139
 
86
- // src/core/run.ts
140
+ // src/core/identity.ts
141
+ function identityFromSeed(seedHex) {
142
+ if (!Hex64Schema.safeParse(seedHex).success)
143
+ throw new BelticConfigError(
144
+ "agentSeed must be 64 lowercase hex characters (a 32-byte Ed25519 seed)"
145
+ );
146
+ const signer = memorySigner(fromHex(seedHex));
147
+ const did = didKeyFromEd25519(signer.publicKey);
148
+ return { did, signer: { ...signer, keyId: did } };
149
+ }
150
+
151
+ // src/core/session.ts
87
152
  var MEMORY = 256;
88
153
  var Memory = class {
89
154
  map = /* @__PURE__ */ new Map();
@@ -96,50 +161,65 @@ var Memory = class {
96
161
  if (this.map.size > MEMORY) this.map.delete(this.map.keys().next().value);
97
162
  }
98
163
  };
99
- var Run = class _Run {
100
- constructor(deps, key, opts = {}) {
164
+ var Session = class _Session {
165
+ constructor(deps, opts = {}, resume = null) {
101
166
  this.deps = deps;
102
- this.key = key;
103
167
  this.opts = opts;
168
+ this.conversationId = resume;
169
+ this.openedWith = opts.intent ? _Session.hash(opts.intent) : null;
104
170
  }
105
171
  opened = null;
106
172
  current = null;
107
- /** JCS hash of the mandate on the chain, and of the one the open input carried. */
173
+ /** The conversation id: what was resumed, then what the platform answered. */
174
+ conversationId;
175
+ /** JCS hash of the mandate on the chain, and of the one the options carried. */
108
176
  declared = null;
109
- openedWith = null;
177
+ openedWith;
110
178
  closed = false;
111
179
  timer = null;
112
180
  calls = new Memory();
113
181
  decisions = new Memory();
114
- byPayment = new Memory();
115
- /** The session this run records into — opened on first use, `null` when there is none. */
116
- session() {
117
- const next = this.deps.sessions.open(this.key, this.opener);
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
+ */
187
+ id() {
188
+ return this.stream().then((stream) => stream?.conversationId ?? this.conversationId);
189
+ }
190
+ /**
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`
193
+ * when there is none. For the integrations; a host never holds it.
194
+ * @internal
195
+ */
196
+ stream() {
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
+ );
118
201
  this.opened = next;
119
- return next.then((session) => {
120
- if (session !== this.current) {
121
- this.current = session;
202
+ next.catch(() => {
203
+ if (this.opened === next) this.opened = null;
204
+ });
205
+ return next.then((stream) => {
206
+ if (stream !== this.current) {
207
+ this.current = stream;
122
208
  this.declared = this.openedWith;
123
209
  }
124
- return session;
210
+ return stream;
125
211
  });
126
212
  }
127
- opener = async () => {
128
- const open = this.opts.open;
129
- const input = typeof open === "function" ? await open() : open ?? {};
130
- this.openedWith = input.intent ? _Run.hash(input.intent) : null;
131
- return input;
132
- };
133
- /** `intent.declared`, unless the mandate is the one already on the chain. */
134
- async declare(intent) {
135
- const session = await this.session();
136
- if (!session) return false;
137
- const hash = _Run.hash(intent);
138
- if (hash === this.declared) return false;
139
- const ok = await session.emit("intent.declared", intent);
140
- if (ok) this.declared = hash;
141
- this.touch();
142
- return ok;
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;
143
223
  }
144
224
  /**
145
225
  * The platform's verdict on a payment about to be presented. Asked once
@@ -150,23 +230,21 @@ var Run = class _Run {
150
230
  async decide(payment, opts = {}) {
151
231
  const known = opts.callId ? this.decisions.get(opts.callId) : void 0;
152
232
  if (known) return known;
153
- const session = await this.session();
154
- if (!session) return Decision.absent();
155
- if (opts.intent) await this.declare(opts.intent);
233
+ const stream = await this.stream();
234
+ if (!stream) return Decision.absent();
235
+ if (opts.intent) await this.declare(stream, opts.intent);
156
236
  const summary = summaryOf(payment);
157
- const evaluation = await this.deps.evaluate(session.id, summary);
158
- if (!evaluation) return Decision.absent();
159
- const decision = Decision.of(evaluation);
237
+ const decision = await this.deps.evaluate(stream.id, summary, opts.callId);
238
+ if (decision.absent) return decision;
160
239
  if (opts.callId) this.decisions.set(opts.callId, decision);
161
- for (const key of _Run.paymentKeys(summary)) this.byPayment.set(key, decision);
162
240
  const call = opts.callId ? this.calls.get(opts.callId)?.call : void 0;
163
- await session.emit("gateway.decision", {
241
+ await stream.emit("gateway.decision", {
164
242
  gateway: "beltic",
165
- call: _Run.callOf(call),
166
- decision: evaluation.decision,
167
- reasonCodes: [...evaluation.reasonCodes],
243
+ call: _Session.callOf(call),
244
+ decision: decision.value,
245
+ reasonCodes: [...decision.reasonCodes],
168
246
  record: {
169
- decisionId: evaluation.decisionId,
247
+ decisionId: decision.decisionId,
170
248
  callId: opts.callId ?? null,
171
249
  payment: summary
172
250
  }
@@ -178,24 +256,12 @@ var Run = class _Run {
178
256
  decision(callId) {
179
257
  return this.decisions.get(callId) ?? Decision.absent();
180
258
  }
181
- /**
182
- * The decision given for a payment with the same comparable core (payee,
183
- * amount, payer — or payee and amount when one side names no payer), or
184
- * absent.
185
- */
186
- decisionFor(payment) {
187
- for (const key of _Run.paymentKeys(summaryOf(payment))) {
188
- const known = this.byPayment.get(key);
189
- if (known) return known;
190
- }
191
- return Decision.absent();
192
- }
193
259
  /** A tool call the host runs itself, reported as two events by its own call id. */
194
260
  tools = {
195
261
  start: async (call) => {
196
- const session = await this.session();
197
- if (!session) return false;
198
- const span = session.toolCall(call);
262
+ const stream = await this.stream();
263
+ if (!stream) return false;
264
+ const span = stream.toolCall(call);
199
265
  this.calls.set(call.callId, { call, span });
200
266
  this.touch();
201
267
  return span.opened;
@@ -215,11 +281,11 @@ var Run = class _Run {
215
281
  };
216
282
  /** A person's answer about a call, as the decision it was (GAP-75). */
217
283
  async humanDecided(callId, input) {
218
- const session = await this.session();
219
- if (!session) return false;
220
- const ok = await session.emit("gateway.decision", {
284
+ const stream = await this.stream();
285
+ if (!stream) return false;
286
+ const ok = await stream.emit("gateway.decision", {
221
287
  gateway: "human",
222
- call: _Run.callOf(this.calls.get(callId)?.call),
288
+ call: _Session.callOf(this.calls.get(callId)?.call),
223
289
  decision: input.allowed ? "ALLOW" : "DENY",
224
290
  reasonCodes: [`USER_${input.outcome.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`],
225
291
  record: {
@@ -237,8 +303,14 @@ var Run = class _Run {
237
303
  this.closed = true;
238
304
  if (this.timer) clearTimeout(this.timer);
239
305
  this.deps.onClosed(this);
240
- const session = this.opened ? await this.opened.catch(() => null) : null;
241
- await session?.close(reason);
306
+ const stream = this.opened ? await this.opened.catch(() => null) : null;
307
+ await stream?.close(reason);
308
+ }
309
+ /** `intent.declared`, unless the mandate is the one already on the chain (GAP-76). */
310
+ async declare(stream, intent) {
311
+ const hash = _Session.hash(intent);
312
+ if (hash === this.declared) return;
313
+ if (await stream.emit("intent.declared", intent)) this.declared = hash;
242
314
  }
243
315
  take(callId) {
244
316
  const known = this.calls.get(callId);
@@ -256,121 +328,577 @@ var Run = class _Run {
256
328
  static hash(intent) {
257
329
  return toHex(sha256(canonicalBytes(intent)));
258
330
  }
259
- /** With the payer first, then without it. */
260
- static paymentKeys(summary) {
261
- const { payer, ...core } = summary;
262
- return payer ? [canonicalize(summary), canonicalize(core)] : [canonicalize(core)];
263
- }
264
331
  static callOf(call) {
265
332
  return call ? { tool: call.toolName, args: call.input } : { tool: "unknown" };
266
333
  }
267
334
  };
268
335
 
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;
361
+ }
362
+ };
363
+
364
+ // src/core/transport.ts
365
+ var FLUSH_MS = 1e3;
366
+ var MAX_BATCH = 50;
367
+ var MAX_BATCH_BYTES = 8 * 1024 * 1024;
368
+ var MAX_EVENT_BYTES = 1024 * 1024;
369
+ var MAX_BUFFERED = 5e3;
370
+ var MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
371
+ var MAX_IN_FLIGHT = 8;
372
+ var ENVELOPE_BYTES = 512;
373
+ var BACKOFF_BASE_MS = 200;
374
+ var BACKOFF_MAX_MS = 3e4;
375
+ var ENDED_CODES = /* @__PURE__ */ new Set(["SESSION_CLOSED", "SESSION_EXPIRED"]);
376
+ var ChainRejectedError = class extends Error {
377
+ constructor(sessionId, source, result, options) {
378
+ super(
379
+ `chain ${sessionId}:${source} halted at seq ${result.seq}: ${result.status}${result.code ? ` ${result.code}` : ""}`,
380
+ options
381
+ );
382
+ this.sessionId = sessionId;
383
+ this.source = source;
384
+ this.result = result;
385
+ this.name = "ChainRejectedError";
386
+ }
387
+ };
388
+ var Transport = class _Transport {
389
+ constructor(api) {
390
+ this.api = api;
391
+ }
392
+ chains = /* @__PURE__ */ new Map();
393
+ waiting = [];
394
+ running = 0;
395
+ buffered = 0;
396
+ bufferedBytes = 0;
397
+ timer = null;
398
+ closed = false;
399
+ /**
400
+ * What the fail-open entries absorb (GAP-70): the platform could not be
401
+ * reached or failed on its side — a network error, a 5xx, a 429.
402
+ * Everything the platform *rejected* (a 4xx: bad key, unknown session,
403
+ * invalid payload) is a fault of the client and throws.
404
+ */
405
+ static outage(err) {
406
+ return err instanceof BelticApiError && err.retryable;
407
+ }
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;
415
+ }
416
+ hasRoom(bytes) {
417
+ return !this.closed && this.buffered < MAX_BUFFERED && this.bufferedBytes + bytes <= MAX_BUFFERED_BYTES;
418
+ }
419
+ haltedError(sessionId, source) {
420
+ return this.chains.get(`${sessionId}:${source}`)?.halted ?? null;
421
+ }
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");
429
+ const key = `${ev.sessionId}:${ev.source}`;
430
+ let chain = this.chains.get(key);
431
+ if (!chain) {
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
+ };
442
+ this.chains.set(key, chain);
443
+ }
444
+ if (chain.halted) throw chain.halted;
445
+ if (chain.ended) return;
446
+ if (!this.hasRoom(bytes)) throw new Error("transport buffer is full");
447
+ chain.pending.push({ ev, bytes });
448
+ this.buffered++;
449
+ this.bufferedBytes += bytes;
450
+ if (chain.pending.length >= MAX_BATCH) void this.deliver(chain, false);
451
+ else this.schedule();
452
+ }
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)));
464
+ }
465
+ async close() {
466
+ await this.flush();
467
+ this.closed = true;
468
+ for (const chain of this.chains.values()) {
469
+ if (chain.retry) clearTimeout(chain.retry);
470
+ chain.retry = null;
471
+ }
472
+ }
473
+ schedule() {
474
+ if (this.timer) return;
475
+ this.timer = setTimeout(() => {
476
+ this.timer = null;
477
+ for (const chain of this.chains.values()) void this.deliver(chain, false);
478
+ }, FLUSH_MS);
479
+ this.timer.unref?.();
480
+ }
481
+ unschedule() {
482
+ if (!this.timer) return;
483
+ clearTimeout(this.timer);
484
+ this.timer = null;
485
+ }
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
+ */
491
+ deliver(chain, force) {
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();
497
+ if (chain.retry) {
498
+ if (!force) return Promise.resolve();
499
+ clearTimeout(chain.retry);
500
+ chain.retry = null;
501
+ }
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
+ };
513
+ });
514
+ if (force) this.waiting.unshift(chain);
515
+ else this.waiting.push(chain);
516
+ this.pump();
517
+ return chain.inFlight;
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
+ }
541
+ /** Batches until the chain drains; a retryable failure schedules the next attempt and returns. */
542
+ async attempt(chain) {
543
+ while (chain.pending.length > 0 && !chain.halted) {
544
+ const batch = _Transport.batchOf(chain);
545
+ const first = batch[0].ev;
546
+ let ack;
547
+ try {
548
+ ack = await this.api.post(
549
+ "/v1/evidence",
550
+ batch.map((item) => item.ev)
551
+ );
552
+ } catch (err) {
553
+ console.error("[beltic]", err);
554
+ if (!_Transport.outage(err)) {
555
+ this.halt(
556
+ chain,
557
+ new ChainRejectedError(
558
+ first.sessionId,
559
+ first.source,
560
+ {
561
+ index: 0,
562
+ sessionId: first.sessionId,
563
+ source: first.source,
564
+ seq: first.seq,
565
+ status: "rejected",
566
+ code: "DELIVERY_FAILED"
567
+ },
568
+ { cause: err }
569
+ )
570
+ );
571
+ return;
572
+ }
573
+ const delay = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** chain.attempts) * (0.5 + Math.random() / 2);
574
+ chain.attempts++;
575
+ chain.retry = setTimeout(() => {
576
+ chain.retry = null;
577
+ void this.deliver(chain, false);
578
+ }, delay);
579
+ chain.retry.unref?.();
580
+ return;
581
+ }
582
+ this.release(chain, batch.length);
583
+ chain.attempts = 0;
584
+ const bad = ack.results.find((r) => r.status === "fork" || r.status === "rejected");
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;
594
+ }
595
+ }
596
+ drop(chain) {
597
+ this.release(chain, chain.pending.length);
598
+ }
599
+ halt(chain, error) {
600
+ chain.halted = error;
601
+ this.drop(chain);
602
+ console.error("[beltic]", error);
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
+ }
718
+ };
719
+
720
+ // src/core/streams.ts
721
+ var OPEN_RETRY_MS = 6e4;
722
+ var Streams = class {
723
+ constructor(deps) {
724
+ this.deps = deps;
725
+ }
726
+ attached = /* @__PURE__ */ new Map();
727
+ retryAt = 0;
728
+ /**
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.
731
+ */
732
+ async open(input = {}, onClosed) {
733
+ const identity = this.deps.identity;
734
+ if (!identity)
735
+ throw new BelticConfigError(
736
+ "beltic.session needs an agent seed (new Beltic({ agentSeed }) or BELTIC_AGENT_SEED)"
737
+ );
738
+ if (Date.now() < this.retryAt) return null;
739
+ try {
740
+ const stream = await this.create(identity, input, onClosed);
741
+ this.retryAt = 0;
742
+ return stream;
743
+ } catch (err) {
744
+ if (!Transport.outage(err)) throw err;
745
+ this.retryAt = Date.now() + OPEN_RETRY_MS;
746
+ console.error("[beltic]", err);
747
+ return null;
748
+ }
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
+ }
762
+ async create(identity, input, onClosed) {
763
+ const body = {
764
+ source: "AGENT_TRACE",
765
+ agent: { did: identity.did, credential: identity.did },
766
+ ...input.intent ? { intent: input.intent } : {},
767
+ ...input.resume ? { resume: input.resume } : {}
768
+ };
769
+ const out = await this.deps.api.post("/v1/sessions", body);
770
+ const stream = this.attach(out.sessionId, out.conversationId, "AGENT_TRACE", "buyer", {
771
+ prompt: out.prompt,
772
+ onClosed
773
+ });
774
+ await stream.emit("session.open", {
775
+ runtime: {
776
+ sdk: "@belticlabs/agent-risk-sdk",
777
+ version: this.deps.sdkVersion,
778
+ ...input.runtime
779
+ },
780
+ ...input.attestations ? { attestations: input.attestations } : {}
781
+ });
782
+ if (input.intent) await stream.emit("intent.declared", input.intent);
783
+ return stream;
784
+ }
785
+ attach(id, conversationId, source, born, opts = {}) {
786
+ const key = `${id}:${source}`;
787
+ const existing = this.attached.get(key);
788
+ if (existing) return existing;
789
+ const stream = new Stream(
790
+ {
791
+ transport: this.deps.transport,
792
+ signer: source === "AGENT_TRACE" ? this.deps.identity?.signer : void 0,
793
+ prompt: opts.prompt,
794
+ onClosed: () => {
795
+ this.attached.delete(key);
796
+ opts.onClosed?.();
797
+ }
798
+ },
799
+ id,
800
+ conversationId,
801
+ source,
802
+ born
803
+ );
804
+ this.attached.set(key, stream);
805
+ return stream;
806
+ }
807
+ };
808
+
269
809
  // src/client.ts
270
- var SDK_VERSION = "0.5.0";
271
- var ENV_REQUIRED = ["BELTIC_API_KEY", "BELTIC_BASE_URL", "BELTIC_AGENT_SEED"];
272
- var ENV_CREDENTIAL = "BELTIC_AGENT_CREDENTIAL";
810
+ var SDK_VERSION = "0.7.0";
273
811
  var Beltic = class _Beltic {
274
- transport;
275
- sessions;
276
- identity;
277
- onReview;
278
- failOpen;
812
+ /** The stream registry, for the protocol adapters. @internal */
813
+ streams;
279
814
  api;
280
- onError;
281
- runs = /* @__PURE__ */ new Map();
815
+ transport;
816
+ /** Open handles by conversation id (GAP-84). */
817
+ sessions = /* @__PURE__ */ new Map();
282
818
  /**
283
- * The client the environment describes: `BELTIC_API_KEY`,
284
- * `BELTIC_BASE_URL`, `BELTIC_AGENT_SEED` (64 hex) and optionally
285
- * `BELTIC_AGENT_CREDENTIAL`. Any of the three missing is a configuration
286
- * error, thrown (GAP-78).
819
+ * The client the environment describes: `BELTIC_API_KEY` and
820
+ * `BELTIC_BASE_URL`, both required, and `BELTIC_AGENT_SEED` (64 hex) for
821
+ * the buyer half. A missing required variable is a configuration error,
822
+ * thrown (GAP-78).
287
823
  */
288
- static fromEnv(env = _Beltic.processEnv(), opts = {}) {
289
- const missing = ENV_REQUIRED.filter((name) => !env[name]);
824
+ static fromEnv(env = _Beltic.processEnv()) {
825
+ const missing = ["BELTIC_API_KEY", "BELTIC_BASE_URL"].filter((name) => !env[name]);
290
826
  if (missing.length > 0)
291
- throw new BelticConfigError(
292
- `${missing.join(", ")} missing \u2014 fromEnv needs ${ENV_REQUIRED.join(", ")}`
293
- );
827
+ throw new BelticConfigError(`${missing.join(", ")} missing from the environment`);
294
828
  return new _Beltic({
295
- ...opts,
296
829
  apiKey: env.BELTIC_API_KEY,
297
830
  baseUrl: env.BELTIC_BASE_URL,
298
- identity: identityFromSeed(fromHex(env.BELTIC_AGENT_SEED), env[ENV_CREDENTIAL])
831
+ agentSeed: env.BELTIC_AGENT_SEED
299
832
  });
300
833
  }
301
834
  constructor(opts) {
302
835
  if (!opts.apiKey) throw new BelticConfigError("apiKey is required");
303
836
  if (!URL.canParse(opts.baseUrl))
304
837
  throw new BelticConfigError(`baseUrl is not a URL: ${JSON.stringify(opts.baseUrl)}`);
305
- this.failOpen = opts.failOpen ?? false;
306
- this.onError = opts.onError ?? ((err) => console.error("[beltic]", err));
307
- this.api = new ApiClient({
308
- baseUrl: opts.baseUrl,
309
- apiKey: opts.apiKey,
310
- fetch: opts.fetch,
311
- userAgent: `@belticlabs/agent-risk-sdk/${SDK_VERSION}`
312
- });
313
- this.transport = new Transport(this.api, {
314
- ...opts.transport,
315
- onError: this.onError,
316
- onChainHalted: this.onError
317
- });
318
- this.sessions = new Sessions({
838
+ this.api = new ApiClient(
839
+ opts.baseUrl,
840
+ opts.apiKey,
841
+ `@belticlabs/agent-risk-sdk/${SDK_VERSION}`
842
+ );
843
+ this.transport = new Transport(this.api);
844
+ this.streams = new Streams({
319
845
  api: this.api,
320
846
  transport: this.transport,
321
- sdkVersion: SDK_VERSION,
322
- identity: opts.identity,
323
- failOpen: this.failOpen,
324
- onError: this.onError,
325
- openRetryMs: opts.openRetryMs
847
+ identity: opts.agentSeed ? identityFromSeed(opts.agentSeed) : null,
848
+ sdkVersion: SDK_VERSION
326
849
  });
327
- this.identity = opts.identity;
328
- this.onReview = opts.onReview ?? "abort";
329
- }
330
- /**
331
- * The platform's verdict on a payment — the seller's before it verifies,
332
- * the buyer's before it presents. Read-your-writes: the buffered evidence
333
- * is flushed first so the platform judges what the caller already saw
334
- * (GAP-16). A recorded moment is accepted as is: only its comparable core
335
- * (payee, amount, payer) is sent. `null` only under `failOpen`, when the
336
- * platform could not be reached.
337
- */
338
- async evaluate(sessionId, payment) {
339
- const input = { sessionId, payment: summaryOf(payment) };
340
- try {
341
- return await this.decide(input);
342
- } catch (err) {
343
- if (!this.failOpen || !Transport.outage(err)) throw err;
344
- this.onError(err);
345
- return null;
346
- }
347
- }
348
- async decide(input) {
349
- await this.transport.flush();
350
- const out = await this.api.post("/v1/evaluate", input);
351
- return { ...out, verdict: Verdict.of(out.decision) };
352
850
  }
353
851
  /**
354
- * The run for a key of the host's own — one object per key until it
355
- * closes (the options count on the first call only). See `Run`.
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`.
356
856
  */
357
- run(key, opts = {}) {
358
- const existing = this.runs.get(key);
857
+ session(id, opts = {}) {
858
+ const existing = id ? this.sessions.get(id) : void 0;
359
859
  if (existing) return existing;
360
- const run = new Run(
860
+ const session = new Session(
361
861
  {
362
- sessions: this.sessions,
363
- evaluate: (sessionId, payment) => this.evaluate(sessionId, payment),
862
+ streams: this.streams,
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
+ },
364
867
  onClosed: (closed) => {
365
- if (this.runs.get(key) === closed) this.runs.delete(key);
868
+ for (const [key, s] of this.sessions) if (s === closed) this.sessions.delete(key);
366
869
  }
367
870
  },
368
- key,
369
- opts
871
+ opts,
872
+ id ?? null
370
873
  );
371
- this.runs.set(key, run);
372
- return run;
874
+ if (id) this.sessions.set(id, session);
875
+ return session;
876
+ }
877
+ /**
878
+ * The platform's verdict on a payment — the seller's before it verifies,
879
+ * the buyer's before it presents (Fraud SDK RFC › Evaluation Client).
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).
885
+ */
886
+ async evaluate(sessionId, payment, opts = {}) {
887
+ const input = {
888
+ sessionId,
889
+ payment,
890
+ ...opts.callId ? { callId: opts.callId } : {}
891
+ };
892
+ try {
893
+ await this.transport.flush({ sessionId });
894
+ return Decision.of(await this.api.post("/v1/evaluate", input));
895
+ } catch (err) {
896
+ if (!Transport.outage(err)) throw err;
897
+ console.error("[beltic]", err);
898
+ return Decision.absent();
899
+ }
373
900
  }
901
+ /** Send everything buffered now — every session's — and wait for that attempt. */
374
902
  flush() {
375
903
  return this.transport.flush();
376
904
  }
@@ -383,15 +911,11 @@ var Beltic = class _Beltic {
383
911
  }
384
912
  };
385
913
  export {
386
- ApiClient,
387
914
  Beltic,
388
915
  BelticApiError,
389
916
  BelticConfigError,
390
917
  ChainRejectedError,
391
918
  Decision,
392
- Run,
393
919
  SDK_VERSION,
394
- Session,
395
- Transport,
396
- identityFromSeed
920
+ Session
397
921
  };