@attest-ai/core 1.0.0-rc.5 → 1.0.1

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/esm/index.js CHANGED
@@ -219,8 +219,8 @@ function extractId(response) {
219
219
  }
220
220
 
221
221
  // src/version.ts
222
- var VERSION = "1.0.0-rc.5";
223
- var ENGINE_VERSION = "1.0.0-rc.5";
222
+ var VERSION = "1.0.1";
223
+ var ENGINE_VERSION = "1.0.1";
224
224
 
225
225
  // src/engine-manager.ts
226
226
  import { spawn, spawnSync } from "child_process";
@@ -228,6 +228,565 @@ import { createInterface } from "readline";
228
228
  import { existsSync, accessSync, statSync as statSync2, constants as fsConstants } from "fs";
229
229
  import { delimiter, join as join2 } from "path";
230
230
 
231
+ // src/config.ts
232
+ var _simulationMode = false;
233
+ var _sampleRate = 0;
234
+ function config(options2) {
235
+ if (options2.simulation !== void 0) {
236
+ _simulationMode = options2.simulation;
237
+ }
238
+ if (options2.sampleRate !== void 0) {
239
+ if (options2.sampleRate < 0 || options2.sampleRate > 1) {
240
+ throw new RangeError(
241
+ `sampleRate must be between 0.0 and 1.0, got ${options2.sampleRate}`
242
+ );
243
+ }
244
+ _sampleRate = options2.sampleRate;
245
+ }
246
+ }
247
+ function isSimulationMode() {
248
+ return _simulationMode || simulationEnvEnabled();
249
+ }
250
+ function simulationEnvEnabled() {
251
+ const env = (process.env["ATTEST_SIMULATION"] ?? "").trim().toLowerCase();
252
+ return env === "1" || env === "true" || env === "yes";
253
+ }
254
+ function getSampleRate() {
255
+ if (_sampleRate > 0) return _sampleRate;
256
+ const env = process.env["ATTEST_SAMPLE_RATE"];
257
+ if (env !== void 0 && env !== "") {
258
+ const parsed = Number(env);
259
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
260
+ throw new RangeError(
261
+ `ATTEST_SAMPLE_RATE must be between 0.0 and 1.0, got '${env}'`
262
+ );
263
+ }
264
+ return parsed;
265
+ }
266
+ return 0;
267
+ }
268
+ function resetConfig() {
269
+ _simulationMode = false;
270
+ _sampleRate = 0;
271
+ }
272
+
273
+ // src/usage.ts
274
+ var INT_FIELDS = [
275
+ "model_calls",
276
+ "judge_calls",
277
+ "input_tokens",
278
+ "output_tokens",
279
+ "cached_tokens",
280
+ "unpriced_calls",
281
+ "budget_refused"
282
+ ];
283
+ var USD_FIELDS = ["cost_usd", "retry_cost_usd"];
284
+ function zeroUsage() {
285
+ return {
286
+ model_calls: 0,
287
+ judge_calls: 0,
288
+ input_tokens: 0,
289
+ output_tokens: 0,
290
+ cached_tokens: 0,
291
+ cost_usd: 0,
292
+ retry_cost_usd: 0,
293
+ unpriced_calls: 0,
294
+ budget_refused: 0
295
+ };
296
+ }
297
+ function addUsage(a, b) {
298
+ return {
299
+ model_calls: a.model_calls + b.model_calls,
300
+ judge_calls: a.judge_calls + b.judge_calls,
301
+ input_tokens: a.input_tokens + b.input_tokens,
302
+ output_tokens: a.output_tokens + b.output_tokens,
303
+ cached_tokens: a.cached_tokens + b.cached_tokens,
304
+ cost_usd: a.cost_usd + b.cost_usd,
305
+ retry_cost_usd: a.retry_cost_usd + b.retry_cost_usd,
306
+ unpriced_calls: a.unpriced_calls + b.unpriced_calls,
307
+ budget_refused: a.budget_refused + b.budget_refused
308
+ };
309
+ }
310
+ function sumUsage(usages) {
311
+ let total = zeroUsage();
312
+ for (const u of usages) total = addUsage(total, u);
313
+ return total;
314
+ }
315
+ function parseUsage(raw) {
316
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
317
+ throw new TypeError(`evaluate_batch result: usage must be an object, got ${JSON.stringify(raw)}`);
318
+ }
319
+ const obj = raw;
320
+ const field = (name, integer) => {
321
+ const v = obj[name];
322
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || integer && !Number.isInteger(v)) {
323
+ const kind = integer ? "a non-negative integer" : "a non-negative number";
324
+ throw new TypeError(`evaluate_batch result: usage.${name} must be ${kind}, got ${JSON.stringify(v)}`);
325
+ }
326
+ return v;
327
+ };
328
+ const ints = Object.fromEntries(INT_FIELDS.map((f) => [f, field(f, true)]));
329
+ const usd = Object.fromEntries(USD_FIELDS.map((f) => [f, field(f, false)]));
330
+ return { ...ints, ...usd };
331
+ }
332
+ function formatUsage(u) {
333
+ let line = `${u.model_calls} model calls \xB7 ${u.input_tokens + u.output_tokens} tokens (${u.cached_tokens} cached) \xB7 $${u.cost_usd.toFixed(4)} \xB7 retry cost $${u.retry_cost_usd.toFixed(4)}`;
334
+ if (u.budget_refused > 0) line += ` \xB7 ${u.budget_refused} calls refused by budget`;
335
+ if (u.unpriced_calls > 0) line += ` \xB7 ${u.unpriced_calls} unpriced calls not in $`;
336
+ return line;
337
+ }
338
+ function usageSummaryLine(u) {
339
+ if (u.model_calls === 0 && u.budget_refused === 0) return void 0;
340
+ return `attest usage: ${formatUsage(u)}`;
341
+ }
342
+
343
+ // src/client.ts
344
+ var DEFAULT_TIMEOUT_MS = 3e4;
345
+ var DEFAULT_DIAGNOSTIC_BUFFER = 32;
346
+ var DEFAULT_DESYNC_THRESHOLD = 3;
347
+ var DEFAULT_DESYNC_WINDOW_MS = 1e3;
348
+ function resolveTimeoutMs(override) {
349
+ if (override !== void 0) return override;
350
+ const env = process.env["ATTEST_ENGINE_TIMEOUT"];
351
+ if (env !== void 0 && env !== "") {
352
+ const parsed = Number(env);
353
+ if (!Number.isFinite(parsed) || parsed <= 0) {
354
+ throw new Error(
355
+ `ATTEST_ENGINE_TIMEOUT must be a positive number of milliseconds, got: '${env}'`
356
+ );
357
+ }
358
+ return parsed;
359
+ }
360
+ return DEFAULT_TIMEOUT_MS;
361
+ }
362
+ function debugProtocolEnabled() {
363
+ const flag = process.env["ATTEST_DEBUG_PROTOCOL"];
364
+ return flag === "1" || flag === "true" || flag === "yes";
365
+ }
366
+ function envLoggerDefault() {
367
+ if (debugProtocolEnabled()) {
368
+ return {
369
+ warn: (msg, ...args) => console.warn(msg, ...args),
370
+ error: (msg, ...args) => console.error(msg, ...args)
371
+ };
372
+ }
373
+ return {
374
+ warn: () => {
375
+ },
376
+ error: () => {
377
+ }
378
+ };
379
+ }
380
+ var AttestClient = class {
381
+ engine;
382
+ requestId = 0;
383
+ pending = /* @__PURE__ */ new Map();
384
+ writeChain = Promise.resolve();
385
+ readerActive = false;
386
+ lineHandler = null;
387
+ attachedReader = null;
388
+ logger;
389
+ diagnosticBuffer;
390
+ diagnosticListeners = /* @__PURE__ */ new Set();
391
+ desyncThreshold;
392
+ desyncWindowMs;
393
+ desynced = false;
394
+ constructor(engine, options2) {
395
+ this.engine = engine;
396
+ this.logger = options2?.logger ?? envLoggerDefault();
397
+ this.diagnosticBuffer = new ProtocolDiagnosticBuffer(
398
+ options2?.diagnosticBufferSize ?? DEFAULT_DIAGNOSTIC_BUFFER
399
+ );
400
+ this.desyncThreshold = options2?.desyncThreshold ?? DEFAULT_DESYNC_THRESHOLD;
401
+ this.desyncWindowMs = options2?.desyncWindowMs ?? DEFAULT_DESYNC_WINDOW_MS;
402
+ if (options2?.onDiagnostic !== void 0) {
403
+ this.diagnosticListeners.add(options2.onDiagnostic);
404
+ }
405
+ }
406
+ /** Subscribe to protocol diagnostics. Returns an unsubscribe function. */
407
+ onProtocolDiagnostic(listener) {
408
+ this.diagnosticListeners.add(listener);
409
+ return () => {
410
+ this.diagnosticListeners.delete(listener);
411
+ };
412
+ }
413
+ /** Snapshot of the diagnostic ring buffer (most recent last). */
414
+ protocolDiagnostics() {
415
+ return this.diagnosticBuffer.snapshot();
416
+ }
417
+ /**
418
+ * Attach the id-routed reader to the engine's stdout. Idempotent; `sendRequest` starts it
419
+ * on first use, so calling it is optional.
420
+ */
421
+ startReader() {
422
+ if (this.readerActive) return;
423
+ const rl = this.engine.readlineInterface;
424
+ if (rl === null) {
425
+ throw new Error("Engine readline interface not available. Call engine.start() first.");
426
+ }
427
+ this.readerActive = true;
428
+ this.attachedReader = rl;
429
+ this.lineHandler = (line) => this.handleLine(line);
430
+ rl.on("line", this.lineHandler);
431
+ rl.on("close", () => {
432
+ if (this.attachedReader !== rl) return;
433
+ const tail = this.engine.stderrTail ?? "";
434
+ this.failAll(
435
+ new Error(tail === "" ? "Engine closed stdout." : `Engine closed stdout. stderr:
436
+ ${tail}`)
437
+ );
438
+ this.readerActive = false;
439
+ this.attachedReader = null;
440
+ });
441
+ }
442
+ stopReader() {
443
+ if (!this.readerActive) return;
444
+ const rl = this.attachedReader;
445
+ if (rl !== null && this.lineHandler !== null) {
446
+ rl.removeListener("line", this.lineHandler);
447
+ }
448
+ this.lineHandler = null;
449
+ this.readerActive = false;
450
+ this.attachedReader = null;
451
+ }
452
+ recordDiagnostic(kind, message, rawLine, countsTowardDesync = true) {
453
+ const diagnostic = {
454
+ kind,
455
+ message,
456
+ rawLine: previewLine(rawLine),
457
+ timestampMs: Date.now()
458
+ };
459
+ this.diagnosticBuffer.push(diagnostic);
460
+ this.logger.warn(`[attest.protocol] ${kind}: ${message}`);
461
+ for (const listener of this.diagnosticListeners) {
462
+ try {
463
+ listener(diagnostic);
464
+ } catch (err) {
465
+ this.logger.error(
466
+ `[attest.protocol] diagnostic listener threw: ${err instanceof Error ? err.message : String(err)}`
467
+ );
468
+ }
469
+ }
470
+ if (countsTowardDesync) this.maybeTriggerDesync(diagnostic.timestampMs);
471
+ }
472
+ maybeTriggerDesync(nowMs) {
473
+ if (this.desynced) return;
474
+ const recent = this.diagnosticBuffer.countWithin(this.desyncWindowMs, nowMs);
475
+ if (recent >= this.desyncThreshold) {
476
+ this.desynced = true;
477
+ const snapshot = this.diagnosticBuffer.snapshot();
478
+ const err = new ProtocolDesyncError(
479
+ `Engine protocol desync: ${recent} unroutable response lines within ${this.desyncWindowMs}ms.`,
480
+ snapshot
481
+ );
482
+ this.logger.error(`[attest.protocol] ${err.message}`);
483
+ this.failAll(err);
484
+ }
485
+ }
486
+ handleLine(line) {
487
+ let response;
488
+ try {
489
+ response = decodeResponse(line);
490
+ } catch (err) {
491
+ this.handleDecodeFailure(line, err);
492
+ return;
493
+ }
494
+ let reqId;
495
+ try {
496
+ reqId = extractId(response);
497
+ } catch (err) {
498
+ const message = err instanceof Error ? err.message : String(err);
499
+ this.recordDiagnostic("missing_id", message, line, false);
500
+ return;
501
+ }
502
+ const pending = this.pending.get(reqId);
503
+ if (pending === void 0) {
504
+ this.recordDiagnostic(
505
+ "unknown_id",
506
+ `no pending request for id=${reqId}`,
507
+ line
508
+ );
509
+ return;
510
+ }
511
+ this.pending.delete(reqId);
512
+ try {
513
+ const result = extractResult(response);
514
+ pending.resolve(result);
515
+ } catch (err) {
516
+ pending.reject(err);
517
+ }
518
+ }
519
+ handleDecodeFailure(line, err) {
520
+ if (err instanceof ProtocolError) {
521
+ let reqId = -1;
522
+ try {
523
+ const raw = JSON.parse(line.trim());
524
+ const idCandidate = raw["id"];
525
+ if (typeof idCandidate === "number" && Number.isFinite(idCandidate)) {
526
+ reqId = idCandidate;
527
+ }
528
+ } catch {
529
+ reqId = -1;
530
+ }
531
+ const pending = this.pending.get(reqId);
532
+ if (pending !== void 0) {
533
+ this.pending.delete(reqId);
534
+ pending.reject(err);
535
+ return;
536
+ }
537
+ this.recordDiagnostic(
538
+ "non_routable_error",
539
+ `engine error code=${err.code} (${err.errorMessage}) had no matching pending id`,
540
+ line
541
+ );
542
+ return;
543
+ }
544
+ const message = err instanceof Error ? err.message : String(err);
545
+ const kind = classifyDecodeError(message);
546
+ this.recordDiagnostic(kind, message, line);
547
+ }
548
+ /** Reject every request still waiting for a response. */
549
+ rejectPending(err) {
550
+ this.failAll(err);
551
+ }
552
+ failAll(err) {
553
+ for (const pending of this.pending.values()) {
554
+ pending.reject(err);
555
+ }
556
+ this.pending.clear();
557
+ }
558
+ /**
559
+ * Send one request and resolve with the result of the response that carries its id.
560
+ *
561
+ * After a timeout the engine is restarted (its late response can no longer be matched),
562
+ * every other pending request is rejected, and the new engine gets the remaining session
563
+ * budget.
564
+ */
565
+ async sendRequest(method, params, timeoutMs) {
566
+ return this.send(method, params, timeoutMs, true);
567
+ }
568
+ /** `sendRequest` without the restart-on-timeout step, for the engine's own handshake. */
569
+ async sendWithoutRestart(method, params, timeoutMs) {
570
+ return this.send(method, params, timeoutMs, false);
571
+ }
572
+ async send(method, params, timeoutMs, restartOnTimeout) {
573
+ if (restartOnTimeout) await this.engine.whenReady?.();
574
+ const shared = this.engine.client;
575
+ if (shared !== void 0 && shared !== this) {
576
+ return restartOnTimeout ? shared.sendRequest(method, params, timeoutMs) : shared.sendWithoutRestart(method, params, resolveTimeoutMs(timeoutMs));
577
+ }
578
+ if (this.desynced) {
579
+ throw new ProtocolDesyncError(
580
+ "Engine protocol is desynced; AttestClient will not send further requests.",
581
+ this.diagnosticBuffer.snapshot()
582
+ );
583
+ }
584
+ const resolvedTimeout = resolveTimeoutMs(timeoutMs);
585
+ if (this.engine.isRunning === false) {
586
+ const tail = this.engine.stderrTail ?? "";
587
+ throw new Error(`Engine process is not running.${tail === "" ? "" : ` stderr:
588
+ ${tail}`}`);
589
+ }
590
+ const cp = this.engine.childProcess;
591
+ if (cp === null || cp.stdin === null) {
592
+ throw new Error("Engine process not started.");
593
+ }
594
+ this.startReader();
595
+ const reservation = worstCaseReservation(method, params);
596
+ let capturedReqId;
597
+ let timer;
598
+ const requestPromise = new Promise((resolve5, reject) => {
599
+ this.writeChain = this.writeChain.then(() => {
600
+ this.requestId += 1;
601
+ const reqId = this.requestId;
602
+ capturedReqId = reqId;
603
+ this.pending.set(reqId, { resolve: resolve5, reject, reservation });
604
+ const requestStr = encodeRequest(reqId, method, params);
605
+ cp.stdin.write(requestStr, (err) => {
606
+ if (err) {
607
+ this.pending.delete(reqId);
608
+ reject(this.describeWriteError(err));
609
+ }
610
+ });
611
+ });
612
+ });
613
+ const timeoutPromise = new Promise((_, reject) => {
614
+ timer = setTimeout(() => reject(new EngineTimeoutError(method, resolvedTimeout)), resolvedTimeout);
615
+ });
616
+ try {
617
+ return await Promise.race([requestPromise, timeoutPromise]);
618
+ } catch (err) {
619
+ if (err instanceof EngineTimeoutError) {
620
+ if (capturedReqId !== void 0) this.pending.delete(capturedReqId);
621
+ if (restartOnTimeout) await this.recoverFromTimeout(method, params, err);
622
+ }
623
+ throw err;
624
+ } finally {
625
+ clearTimeout(timer);
626
+ }
627
+ }
628
+ /** A failed pipe write names the engine's stderr tail; the original error stays as `cause` and `code`. */
629
+ describeWriteError(err) {
630
+ const tail = this.engine.stderrTail ?? "";
631
+ const wrapped = new Error(
632
+ `Failed to write to the engine: ${err.message}.${tail === "" ? "" : ` stderr:
633
+ ${tail}`}`,
634
+ { cause: err }
635
+ );
636
+ return Object.assign(wrapped, { code: err.code });
637
+ }
638
+ async recoverFromTimeout(method, params, timeout) {
639
+ if (this.engine.restartAfterTimeout === void 0 || this.engine.client !== this) return;
640
+ const abandoned = [
641
+ worstCaseReservation(method, params),
642
+ ...[...this.pending.values()].map((p) => p.reservation)
643
+ ];
644
+ this.failAll(new Error(`Engine restarted after '${method}' timed out; request abandoned.`));
645
+ try {
646
+ await this.engine.restartAfterTimeout(abandoned);
647
+ } catch (err) {
648
+ throw new Error(
649
+ `Engine request '${method}' timed out and the engine could not be restarted: ` + (err instanceof Error ? err.message : String(err)),
650
+ { cause: timeout }
651
+ );
652
+ }
653
+ }
654
+ async evaluateBatch(trace, assertions, options2) {
655
+ if (isSimulationMode()) {
656
+ requireSimulationCapability(this.engine["initResult"]);
657
+ }
658
+ const params = {
659
+ trace,
660
+ assertions: [...assertions],
661
+ ...options2?.budget !== void 0 ? { budget: options2.budget } : {},
662
+ ...options2?.context !== void 0 ? { context: options2.context } : {}
663
+ };
664
+ let raw;
665
+ try {
666
+ raw = await this.sendRequest(
667
+ "evaluate_batch",
668
+ params,
669
+ options2?.timeout
670
+ );
671
+ } catch (err) {
672
+ if (err instanceof ProtocolError) this.engine.markUnknownSpend?.();
673
+ throw err;
674
+ }
675
+ const usage = parseUsage(raw["usage"]);
676
+ this.engine.recordUsage?.(usage);
677
+ const result = raw;
678
+ this.requireSentAssertionIds(assertions, result);
679
+ return { ...result, usage };
680
+ }
681
+ /** Reject a response whose assertion ids differ from the ids that were sent. */
682
+ requireSentAssertionIds(assertions, result) {
683
+ const sent = new Set(assertions.map((a) => a.assertion_id));
684
+ const returned = new Set((result.results ?? []).map((r) => r.assertion_id));
685
+ const missing = [...sent].filter((id) => !returned.has(id));
686
+ const unexpected = [...returned].filter((id) => !sent.has(id));
687
+ if (missing.length === 0 && unexpected.length === 0) return;
688
+ throw new ProtocolDesyncError(
689
+ `evaluate_batch response does not match the request: missing assertion ids [${missing.join(", ")}], unexpected [${unexpected.join(", ")}].`,
690
+ this.diagnosticBuffer.snapshot()
691
+ );
692
+ }
693
+ async submitPluginResult(traceId2, pluginName, assertionId2, status, score, explanation, options2) {
694
+ const result = {
695
+ assertion_id: assertionId2,
696
+ status,
697
+ score,
698
+ explanation,
699
+ cost: 0,
700
+ duration_ms: 0
701
+ };
702
+ const params = {
703
+ trace_id: traceId2,
704
+ plugin_name: pluginName,
705
+ assertion_id: assertionId2,
706
+ result
707
+ };
708
+ const raw = await this.sendRequest(
709
+ "submit_plugin_result",
710
+ params,
711
+ options2?.timeout
712
+ );
713
+ return Boolean(raw?.accepted ?? false);
714
+ }
715
+ async queryDrift(assertionId2, windowSize = 50, options2) {
716
+ if (isSimulationMode()) {
717
+ return {
718
+ assertion_id: assertionId2,
719
+ mean: 0,
720
+ stddev: 0,
721
+ count: 0,
722
+ latest_score: 0,
723
+ deviation: 0,
724
+ status: "no_data"
725
+ };
726
+ }
727
+ const raw = await this.sendRequest(
728
+ "query_drift",
729
+ { assertion_id: assertionId2, window_size: windowSize },
730
+ options2?.timeout
731
+ );
732
+ return raw.report;
733
+ }
734
+ async generateUserMessage(persona, conversationHistory, faultConfig, options2) {
735
+ if (isSimulationMode()) {
736
+ return `[simulation] Hello from ${persona.name}`;
737
+ }
738
+ const params = {
739
+ persona,
740
+ conversation_history: conversationHistory
741
+ };
742
+ if (faultConfig !== void 0) {
743
+ params.fault_config = faultConfig;
744
+ }
745
+ let raw;
746
+ try {
747
+ raw = await this.sendRequest(
748
+ "generate_user_message",
749
+ params,
750
+ options2?.timeout
751
+ );
752
+ } catch (err) {
753
+ if (err instanceof ProtocolError) this.engine.markUnknownSpend?.();
754
+ throw err;
755
+ }
756
+ this.engine.markUnknownSpend?.();
757
+ return String(raw.message);
758
+ }
759
+ };
760
+ function worstCaseReservation(method, params) {
761
+ if (method === "generate_user_message") return { judgeCalls: null, usd: null };
762
+ if (method !== "evaluate_batch") return { judgeCalls: 0, usd: 0 };
763
+ const assertions = params["assertions"] ?? [];
764
+ const judges = assertions.some((a) => a.type === "llm_judge");
765
+ const paid = judges || assertions.some((a) => a.type === "embedding");
766
+ const budget = params["budget"];
767
+ return {
768
+ judgeCalls: judges ? budget?.max_judge_calls ?? null : 0,
769
+ usd: paid ? budget?.max_cost_usd ?? null : 0
770
+ };
771
+ }
772
+ function classifyDecodeError(message) {
773
+ if (message.startsWith("malformed JSON")) return "malformed_json";
774
+ if (message.startsWith("expected JSON object")) return "non_object_response";
775
+ if (message.startsWith("invalid jsonrpc version")) return "invalid_jsonrpc_version";
776
+ if (message.startsWith("empty response line")) return "malformed_json";
777
+ return "malformed_json";
778
+ }
779
+ function requireSimulationCapability(init) {
780
+ if (init === null || init === void 0) {
781
+ throw new Error("simulation mode: the engine has not been initialized");
782
+ }
783
+ if (!(init.capabilities ?? []).includes("simulation_mode")) {
784
+ throw new Error(
785
+ `simulation mode is on but attest-engine ${init.engine_version} was not started in simulation mode (capability 'simulation_mode' missing), so it would call real providers. Set ATTEST_SIMULATION=1 before the engine starts (the Vitest fixtures do this for you) and use attest-engine 1.0 or later.`
786
+ );
787
+ }
788
+ }
789
+
231
790
  // src/engine-downloader.ts
232
791
  import * as https from "https";
233
792
  import * as http from "http";
@@ -344,686 +903,349 @@ async function downloadEngine() {
344
903
  const dir = versionedBinDir();
345
904
  fs.mkdirSync(dir, { recursive: true });
346
905
  const destPath = path.join(dir, binaryFilename());
347
- const suffix = `${String(process.pid)}.${crypto.randomBytes(8).toString("hex")}`;
348
- const tmpPath = path.join(dir, `.${binaryFilename()}.tmp.${suffix}`);
349
- try {
350
- fs.writeFileSync(tmpPath, data, { flag: "wx", mode: 493 });
351
- fs.chmodSync(tmpPath, 493);
352
- fs.renameSync(tmpPath, destPath);
353
- } catch (err) {
354
- if (err.code !== "EEXIST") fs.rmSync(tmpPath, { force: true });
355
- throw err;
356
- }
357
- process.stderr.write(`[attest] Engine installed at ${destPath}
358
- `);
359
- return destPath;
360
- }
361
-
362
- // src/engine-manager.ts
363
- var ENGINE_BINARY_NAME = "attest-engine";
364
- var VERSION_PROBE_TIMEOUT_MS = 1e4;
365
- var EngineVersionMismatchError = class extends Error {
366
- enginePath;
367
- source;
368
- found;
369
- required;
370
- constructor(enginePath, source, found, required) {
371
- const reported = found === null ? "reports no version" : `reports version ${found}`;
372
- super(
373
- `attest-engine at ${enginePath} (from ${source}) ${reported}; this SDK requires attest-engine ${required}. Point ATTEST_ENGINE_PATH at a matching engine, or remove the mismatched one from PATH so the SDK can download ${required} into ~/.attest/bin/${required}/.`
374
- );
375
- this.name = "EngineVersionMismatchError";
376
- this.enginePath = enginePath;
377
- this.source = source;
378
- this.found = found;
379
- this.required = required;
380
- }
381
- };
382
- function engineVersion(enginePath) {
383
- const proc = spawnSync(enginePath, ["version"], {
384
- stdio: ["ignore", "pipe", "pipe"],
385
- encoding: "utf-8",
386
- timeout: VERSION_PROBE_TIMEOUT_MS
387
- });
388
- if (proc.error) throw proc.error;
389
- const out = proc.stdout.trim();
390
- const prefix = `${ENGINE_BINARY_NAME} `;
391
- if (proc.status !== 0 || !out.startsWith(prefix)) return null;
392
- return out.slice(prefix.length).trim();
393
- }
394
- function onPath() {
395
- for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
396
- if (!dir) continue;
397
- const candidate = join2(dir, binaryFilename());
398
- try {
399
- accessSync(candidate, fsConstants.X_OK);
400
- } catch {
401
- continue;
402
- }
403
- if (statSync2(candidate).isFile()) return candidate;
404
- }
405
- return null;
406
- }
407
- function requireFile(enginePath, label) {
408
- if (!existsSync(enginePath) || !statSync2(enginePath).isFile()) {
409
- throw new Error(`${label}=${enginePath} does not exist or is not a file.`);
410
- }
411
- }
412
- async function locateEngine(explicitPath, allowDownload) {
413
- if (explicitPath) {
414
- requireFile(explicitPath, "Engine path");
415
- return [explicitPath, "explicit engine path"];
416
- }
417
- const envPath = process.env["ATTEST_ENGINE_PATH"];
418
- if (envPath) {
419
- requireFile(envPath, "ATTEST_ENGINE_PATH");
420
- return [envPath, "ATTEST_ENGINE_PATH"];
421
- }
422
- const found = onPath();
423
- if (found) return [found, "PATH"];
424
- const cached = cachedEnginePath();
425
- if (cached) return [cached, "engine cache"];
426
- if (!allowDownload) {
427
- throw new Error(
428
- `No '${ENGINE_BINARY_NAME}' ${ENGINE_VERSION} in ATTEST_ENGINE_PATH, PATH or ~/.attest/bin/${ENGINE_VERSION}/.`
429
- );
430
- }
431
- const noDownload = process.env["ATTEST_ENGINE_NO_DOWNLOAD"]?.toLowerCase();
432
- if (noDownload === "1" || noDownload === "true" || noDownload === "yes") {
433
- throw new Error(
434
- `Cannot find '${ENGINE_BINARY_NAME}' ${ENGINE_VERSION} and ATTEST_ENGINE_NO_DOWNLOAD is set.
435
- Install options:
436
- 1. Build from source: make engine, then set ATTEST_ENGINE_PATH
437
- 2. Download release v${ENGINE_VERSION}: https://github.com/attest-framework/attest/releases
438
- 3. Allow auto-download by unsetting ATTEST_ENGINE_NO_DOWNLOAD`
439
- );
440
- }
441
- return [await downloadEngine(), "download"];
442
- }
443
- async function resolveEngine(explicitPath, options2 = {}) {
444
- const [enginePath, source] = await locateEngine(explicitPath, options2.allowDownload ?? true);
445
- const found = engineVersion(enginePath);
446
- if (found !== ENGINE_VERSION) {
447
- throw new EngineVersionMismatchError(enginePath, source, found, ENGINE_VERSION);
448
- }
449
- return enginePath;
450
- }
451
- var EngineManager = class {
452
- enginePath;
453
- logLevel;
454
- process = null;
455
- reader = null;
456
- initialized = false;
457
- requestId = 0;
458
- initResult = null;
459
- constructor(options2) {
460
- this.enginePath = options2?.enginePath ?? "";
461
- this.logLevel = options2?.logLevel ?? "warn";
462
- }
463
- async start() {
464
- this.enginePath = await resolveEngine(this.enginePath || void 0);
465
- this.process = spawn(this.enginePath, [`--log-level=${this.logLevel}`], {
466
- stdio: ["pipe", "pipe", "pipe"]
467
- });
468
- this.reader = createInterface({ input: this.process.stdout });
469
- try {
470
- this.initResult = await this.initialize();
471
- } catch (err) {
472
- await this.stop();
473
- throw err;
474
- }
475
- this.initialized = true;
476
- return this.initResult;
477
- }
478
- /** Send initialize and check the engine's version and capabilities. */
479
- async initialize() {
480
- const init = await this.sendRequestInternal("initialize", {
481
- sdk_name: "attest-typescript",
482
- sdk_version: VERSION,
483
- protocol_version: 1,
484
- required_capabilities: ["layers_1_4"],
485
- preferred_encoding: "json"
486
- });
487
- if (init.engine_version !== ENGINE_VERSION) {
488
- throw new EngineVersionMismatchError(
489
- this.enginePath,
490
- "initialize",
491
- init.engine_version,
492
- ENGINE_VERSION
493
- );
494
- }
495
- if (!init.compatible) {
496
- throw new Error(`Engine incompatible. Missing capabilities: ${JSON.stringify(init.missing)}`);
497
- }
498
- return init;
499
- }
500
- async stop() {
501
- if (this.process === null) return;
502
- if (this.initialized) {
503
- try {
504
- await this.sendRequestInternal("shutdown", {});
505
- } catch {
506
- }
507
- }
508
- if (this.reader) {
509
- this.reader.close();
510
- this.reader = null;
511
- }
512
- if (this.process.exitCode === null) {
513
- this.process.kill("SIGTERM");
514
- await new Promise((res) => {
515
- const timer = setTimeout(() => {
516
- this.process?.kill("SIGKILL");
517
- }, 5e3);
518
- this.process.on("exit", () => {
519
- clearTimeout(timer);
520
- res();
521
- });
522
- });
523
- }
524
- this.initialized = false;
525
- this.process = null;
526
- }
527
- async sendRequest(method, params) {
528
- if (!this.initialized && method !== "initialize") {
529
- throw new Error("Engine not initialized. Call start() first.");
530
- }
531
- return this.sendRequestInternal(method, params);
532
- }
533
- async sendRequestInternal(method, params) {
534
- if (this.process === null || this.process.stdin === null || this.reader === null) {
535
- throw new Error("Engine process not started.");
536
- }
537
- this.requestId += 1;
538
- const requestStr = encodeRequest(this.requestId, method, params);
539
- return new Promise((resolve5, reject) => {
540
- const onLine = (line) => {
541
- try {
542
- const response = decodeResponse(line);
543
- resolve5(extractResult(response));
544
- } catch (err) {
545
- reject(err);
546
- }
547
- };
548
- this.reader.once("line", onLine);
549
- const ok = this.process.stdin.write(requestStr, (err) => {
550
- if (err) {
551
- this.reader.removeListener("line", onLine);
552
- reject(err);
553
- }
554
- });
555
- if (!ok) {
556
- this.process.stdin.once("drain", () => {
557
- });
558
- }
559
- });
560
- }
561
- get isRunning() {
562
- return this.process !== null && this.process.exitCode === null;
563
- }
564
- /** Expose process for AttestClient reader loop access */
565
- get childProcess() {
566
- return this.process;
567
- }
568
- /** Expose readline interface for AttestClient reader loop */
569
- get readlineInterface() {
570
- return this.reader;
571
- }
572
- };
573
-
574
- // src/config.ts
575
- var _simulationMode = false;
576
- var _sampleRate = 0;
577
- function config(options2) {
578
- if (options2.simulation !== void 0) {
579
- _simulationMode = options2.simulation;
580
- }
581
- if (options2.sampleRate !== void 0) {
582
- if (options2.sampleRate < 0 || options2.sampleRate > 1) {
583
- throw new RangeError(
584
- `sampleRate must be between 0.0 and 1.0, got ${options2.sampleRate}`
585
- );
586
- }
587
- _sampleRate = options2.sampleRate;
588
- }
589
- }
590
- function isSimulationMode() {
591
- return _simulationMode || simulationEnvEnabled();
592
- }
593
- function simulationEnvEnabled() {
594
- const env = (process.env["ATTEST_SIMULATION"] ?? "").trim().toLowerCase();
595
- return env === "1" || env === "true" || env === "yes";
596
- }
597
- function getSampleRate() {
598
- if (_sampleRate > 0) return _sampleRate;
599
- const env = process.env["ATTEST_SAMPLE_RATE"];
600
- if (env !== void 0 && env !== "") {
601
- const parsed = Number(env);
602
- if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
603
- throw new RangeError(
604
- `ATTEST_SAMPLE_RATE must be between 0.0 and 1.0, got '${env}'`
605
- );
606
- }
607
- return parsed;
906
+ const suffix = `${String(process.pid)}.${crypto.randomBytes(8).toString("hex")}`;
907
+ const tmpPath = path.join(dir, `.${binaryFilename()}.tmp.${suffix}`);
908
+ try {
909
+ fs.writeFileSync(tmpPath, data, { flag: "wx", mode: 493 });
910
+ fs.chmodSync(tmpPath, 493);
911
+ fs.renameSync(tmpPath, destPath);
912
+ } catch (err) {
913
+ if (err.code !== "EEXIST") fs.rmSync(tmpPath, { force: true });
914
+ throw err;
608
915
  }
609
- return 0;
610
- }
611
- function resetConfig() {
612
- _simulationMode = false;
613
- _sampleRate = 0;
916
+ process.stderr.write(`[attest] Engine installed at ${destPath}
917
+ `);
918
+ return destPath;
614
919
  }
615
920
 
616
- // src/usage.ts
617
- var INT_FIELDS = [
618
- "model_calls",
619
- "judge_calls",
620
- "input_tokens",
621
- "output_tokens",
622
- "cached_tokens",
623
- "unpriced_calls",
624
- "budget_refused"
625
- ];
626
- var USD_FIELDS = ["cost_usd", "retry_cost_usd"];
627
- function zeroUsage() {
628
- return {
629
- model_calls: 0,
630
- judge_calls: 0,
631
- input_tokens: 0,
632
- output_tokens: 0,
633
- cached_tokens: 0,
634
- cost_usd: 0,
635
- retry_cost_usd: 0,
636
- unpriced_calls: 0,
637
- budget_refused: 0
638
- };
639
- }
640
- function addUsage(a, b) {
641
- return {
642
- model_calls: a.model_calls + b.model_calls,
643
- judge_calls: a.judge_calls + b.judge_calls,
644
- input_tokens: a.input_tokens + b.input_tokens,
645
- output_tokens: a.output_tokens + b.output_tokens,
646
- cached_tokens: a.cached_tokens + b.cached_tokens,
647
- cost_usd: a.cost_usd + b.cost_usd,
648
- retry_cost_usd: a.retry_cost_usd + b.retry_cost_usd,
649
- unpriced_calls: a.unpriced_calls + b.unpriced_calls,
650
- budget_refused: a.budget_refused + b.budget_refused
651
- };
652
- }
653
- function sumUsage(usages) {
654
- let total = zeroUsage();
655
- for (const u of usages) total = addUsage(total, u);
656
- return total;
921
+ // src/engine-manager.ts
922
+ var ENGINE_BINARY_NAME = "attest-engine";
923
+ var VERSION_PROBE_TIMEOUT_MS = 1e4;
924
+ var DEFAULT_INITIALIZE_TIMEOUT_MS = 3e4;
925
+ var SHUTDOWN_TIMEOUT_MS = 5e3;
926
+ var STDERR_TAIL_BYTES = 4096;
927
+ var EXIT_REPORT_GRACE_MS = 1e3;
928
+ var PIPE_WRITE_ERROR_CODES = /* @__PURE__ */ new Set(["EPIPE", "ECONNRESET", "ERR_STREAM_DESTROYED"]);
929
+ function isPipeWriteError(err) {
930
+ for (let e = err; e instanceof Error; e = e.cause) {
931
+ const code = e.code;
932
+ if (code !== void 0 && PIPE_WRITE_ERROR_CODES.has(code)) return true;
933
+ }
934
+ return false;
657
935
  }
658
- function parseUsage(raw) {
659
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
660
- throw new TypeError(`evaluate_batch result: usage must be an object, got ${JSON.stringify(raw)}`);
936
+ var EngineVersionMismatchError = class extends Error {
937
+ enginePath;
938
+ source;
939
+ found;
940
+ required;
941
+ constructor(enginePath, source, found, required) {
942
+ const reported = found === null ? "reports no version" : `reports version ${found}`;
943
+ super(
944
+ `attest-engine at ${enginePath} (from ${source}) ${reported}; this SDK requires attest-engine ${required}. Point ATTEST_ENGINE_PATH at a matching engine, or remove the mismatched one from PATH so the SDK can download ${required} into ~/.attest/bin/${required}/.`
945
+ );
946
+ this.name = "EngineVersionMismatchError";
947
+ this.enginePath = enginePath;
948
+ this.source = source;
949
+ this.found = found;
950
+ this.required = required;
661
951
  }
662
- const obj = raw;
663
- const field = (name, integer) => {
664
- const v = obj[name];
665
- if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || integer && !Number.isInteger(v)) {
666
- const kind = integer ? "a non-negative integer" : "a non-negative number";
667
- throw new TypeError(`evaluate_batch result: usage.${name} must be ${kind}, got ${JSON.stringify(v)}`);
668
- }
669
- return v;
670
- };
671
- const ints = Object.fromEntries(INT_FIELDS.map((f) => [f, field(f, true)]));
672
- const usd = Object.fromEntries(USD_FIELDS.map((f) => [f, field(f, false)]));
673
- return { ...ints, ...usd };
674
- }
675
- function formatUsage(u) {
676
- let line = `${u.model_calls} model calls \xB7 ${u.input_tokens + u.output_tokens} tokens (${u.cached_tokens} cached) \xB7 $${u.cost_usd.toFixed(4)} \xB7 retry cost $${u.retry_cost_usd.toFixed(4)}`;
677
- if (u.budget_refused > 0) line += ` \xB7 ${u.budget_refused} calls refused by budget`;
678
- if (u.unpriced_calls > 0) line += ` \xB7 ${u.unpriced_calls} unpriced calls not in $`;
679
- return line;
680
- }
681
- function usageSummaryLine(u) {
682
- if (u.model_calls === 0 && u.budget_refused === 0) return void 0;
683
- return `attest usage: ${formatUsage(u)}`;
952
+ };
953
+ function engineVersion(enginePath) {
954
+ const proc = spawnSync(enginePath, ["version"], {
955
+ stdio: ["ignore", "pipe", "pipe"],
956
+ encoding: "utf-8",
957
+ timeout: VERSION_PROBE_TIMEOUT_MS
958
+ });
959
+ if (proc.error) throw proc.error;
960
+ const out = proc.stdout.trim();
961
+ const prefix = `${ENGINE_BINARY_NAME} `;
962
+ if (proc.status !== 0 || !out.startsWith(prefix)) return null;
963
+ return out.slice(prefix.length).trim();
684
964
  }
685
-
686
- // src/client.ts
687
- var DEFAULT_TIMEOUT_MS = 3e4;
688
- var DEFAULT_DIAGNOSTIC_BUFFER = 32;
689
- var DEFAULT_DESYNC_THRESHOLD = 3;
690
- var DEFAULT_DESYNC_WINDOW_MS = 1e3;
691
- function resolveTimeoutMs(override) {
692
- if (override !== void 0) return override;
693
- const env = process.env["ATTEST_ENGINE_TIMEOUT"];
694
- if (env !== void 0 && env !== "") {
695
- const parsed = Number(env);
696
- if (!Number.isFinite(parsed) || parsed <= 0) {
697
- throw new Error(
698
- `ATTEST_ENGINE_TIMEOUT must be a positive number of milliseconds, got: '${env}'`
699
- );
965
+ function onPath() {
966
+ for (const dir of (process.env["PATH"] ?? "").split(delimiter)) {
967
+ if (!dir) continue;
968
+ const candidate = join2(dir, binaryFilename());
969
+ try {
970
+ accessSync(candidate, fsConstants.X_OK);
971
+ } catch {
972
+ continue;
700
973
  }
701
- return parsed;
974
+ if (statSync2(candidate).isFile()) return candidate;
702
975
  }
703
- return DEFAULT_TIMEOUT_MS;
704
- }
705
- function makeTimeoutPromise(method, timeoutMs) {
706
- return new Promise((_, reject) => {
707
- setTimeout(() => {
708
- reject(new EngineTimeoutError(method, timeoutMs));
709
- }, timeoutMs);
710
- });
711
- }
712
- function debugProtocolEnabled() {
713
- const flag = process.env["ATTEST_DEBUG_PROTOCOL"];
714
- return flag === "1" || flag === "true" || flag === "yes";
976
+ return null;
715
977
  }
716
- function envLoggerDefault() {
717
- if (debugProtocolEnabled()) {
718
- return {
719
- warn: (msg, ...args) => console.warn(msg, ...args),
720
- error: (msg, ...args) => console.error(msg, ...args)
721
- };
978
+ function requireFile(enginePath, label) {
979
+ if (!existsSync(enginePath) || !statSync2(enginePath).isFile()) {
980
+ throw new Error(`${label}=${enginePath} does not exist or is not a file.`);
722
981
  }
723
- return {
724
- warn: () => {
725
- },
726
- error: () => {
727
- }
728
- };
729
982
  }
730
- var AttestClient = class {
731
- engine;
732
- requestId = 0;
733
- pending = /* @__PURE__ */ new Map();
734
- writeChain = Promise.resolve();
735
- readerActive = false;
736
- lineHandler = null;
737
- logger;
738
- diagnosticBuffer;
739
- diagnosticListeners = /* @__PURE__ */ new Set();
740
- desyncThreshold;
741
- desyncWindowMs;
742
- desynced = false;
743
- constructor(engine, options2) {
744
- this.engine = engine;
745
- this.logger = options2?.logger ?? envLoggerDefault();
746
- this.diagnosticBuffer = new ProtocolDiagnosticBuffer(
747
- options2?.diagnosticBufferSize ?? DEFAULT_DIAGNOSTIC_BUFFER
983
+ async function locateEngine(explicitPath, allowDownload) {
984
+ if (explicitPath) {
985
+ requireFile(explicitPath, "Engine path");
986
+ return [explicitPath, "explicit engine path"];
987
+ }
988
+ const envPath = process.env["ATTEST_ENGINE_PATH"];
989
+ if (envPath) {
990
+ requireFile(envPath, "ATTEST_ENGINE_PATH");
991
+ return [envPath, "ATTEST_ENGINE_PATH"];
992
+ }
993
+ const found = onPath();
994
+ if (found) return [found, "PATH"];
995
+ const cached = cachedEnginePath();
996
+ if (cached) return [cached, "engine cache"];
997
+ if (!allowDownload) {
998
+ throw new Error(
999
+ `No '${ENGINE_BINARY_NAME}' ${ENGINE_VERSION} in ATTEST_ENGINE_PATH, PATH or ~/.attest/bin/${ENGINE_VERSION}/.`
748
1000
  );
749
- this.desyncThreshold = options2?.desyncThreshold ?? DEFAULT_DESYNC_THRESHOLD;
750
- this.desyncWindowMs = options2?.desyncWindowMs ?? DEFAULT_DESYNC_WINDOW_MS;
751
- if (options2?.onDiagnostic !== void 0) {
752
- this.diagnosticListeners.add(options2.onDiagnostic);
753
- }
754
1001
  }
755
- /** Subscribe to protocol diagnostics. Returns an unsubscribe function. */
756
- onProtocolDiagnostic(listener) {
757
- this.diagnosticListeners.add(listener);
758
- return () => {
759
- this.diagnosticListeners.delete(listener);
760
- };
1002
+ const noDownload = process.env["ATTEST_ENGINE_NO_DOWNLOAD"]?.toLowerCase();
1003
+ if (noDownload === "1" || noDownload === "true" || noDownload === "yes") {
1004
+ throw new Error(
1005
+ `Cannot find '${ENGINE_BINARY_NAME}' ${ENGINE_VERSION} and ATTEST_ENGINE_NO_DOWNLOAD is set.
1006
+ Install options:
1007
+ 1. Build from source: make engine, then set ATTEST_ENGINE_PATH
1008
+ 2. Download release v${ENGINE_VERSION}: https://github.com/attest-framework/attest/releases
1009
+ 3. Allow auto-download by unsetting ATTEST_ENGINE_NO_DOWNLOAD`
1010
+ );
761
1011
  }
762
- /** Snapshot of the diagnostic ring buffer (most recent last). */
763
- protocolDiagnostics() {
764
- return this.diagnosticBuffer.snapshot();
1012
+ return [await downloadEngine(), "download"];
1013
+ }
1014
+ async function resolveEngine(explicitPath, options2 = {}) {
1015
+ const [enginePath, source] = await locateEngine(explicitPath, options2.allowDownload ?? true);
1016
+ const found = engineVersion(enginePath);
1017
+ if (found !== ENGINE_VERSION) {
1018
+ throw new EngineVersionMismatchError(enginePath, source, found, ENGINE_VERSION);
765
1019
  }
766
- startReader() {
767
- if (this.readerActive) return;
768
- const rl = this.engine.readlineInterface;
769
- if (rl === null) {
770
- throw new Error("Engine readline interface not available. Call engine.start() first.");
771
- }
772
- this.readerActive = true;
773
- this.lineHandler = (line) => this.handleLine(line);
774
- rl.on("line", this.lineHandler);
775
- rl.on("close", () => {
776
- this.failAll(new Error("Engine closed stdout."));
777
- this.readerActive = false;
778
- });
1020
+ return enginePath;
1021
+ }
1022
+ function envNumber(name) {
1023
+ const raw = process.env[name];
1024
+ if (raw === void 0 || raw === "") return null;
1025
+ const parsed = Number(raw);
1026
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
1027
+ }
1028
+ async function reportExit(exited, graceMs) {
1029
+ let timer;
1030
+ const grace = new Promise((done) => {
1031
+ timer = setTimeout(done, graceMs);
1032
+ });
1033
+ try {
1034
+ await Promise.race([exited, grace]);
1035
+ } finally {
1036
+ clearTimeout(timer);
779
1037
  }
780
- stopReader() {
781
- if (!this.readerActive) return;
782
- const rl = this.engine.readlineInterface;
783
- if (rl !== null && this.lineHandler !== null) {
784
- rl.removeListener("line", this.lineHandler);
785
- }
786
- this.lineHandler = null;
787
- this.readerActive = false;
1038
+ }
1039
+ var EngineManager = class {
1040
+ enginePath;
1041
+ logLevel;
1042
+ initializeTimeoutMs;
1043
+ process = null;
1044
+ reader = null;
1045
+ initialized = false;
1046
+ initResult = null;
1047
+ routedClient;
1048
+ /** The running engine's stderr tail; each launch owns a fresh one so a killed engine cannot write into it. */
1049
+ stderr = { text: "" };
1050
+ restarting = null;
1051
+ configuredBudget = { judgeCalls: null, usd: null };
1052
+ usedJudgeCalls = 0;
1053
+ usedUsd = 0;
1054
+ heldJudgeCalls = 0;
1055
+ heldUsd = 0;
1056
+ /** Spend the SDK cannot observe may have happened; a restart then leaves no budget. */
1057
+ unknownSpend = false;
1058
+ constructor(options2) {
1059
+ this.enginePath = options2?.enginePath ?? "";
1060
+ this.logLevel = options2?.logLevel ?? "warn";
1061
+ this.initializeTimeoutMs = options2?.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
788
1062
  }
789
- recordDiagnostic(kind, message, rawLine) {
790
- const diagnostic = {
791
- kind,
792
- message,
793
- rawLine: previewLine(rawLine),
794
- timestampMs: Date.now()
1063
+ /**
1064
+ * Start the engine and complete the `initialize` handshake. Rejects when the engine exits,
1065
+ * closes its pipes or fails to answer within the initialize timeout; the error carries the
1066
+ * tail of the engine's stderr.
1067
+ */
1068
+ async start() {
1069
+ this.enginePath = await resolveEngine(this.enginePath || void 0);
1070
+ this.configuredBudget = {
1071
+ judgeCalls: envNumber("ATTEST_MAX_JUDGE_CALLS"),
1072
+ usd: envNumber("ATTEST_BUDGET_USD")
795
1073
  };
796
- this.diagnosticBuffer.push(diagnostic);
797
- this.logger.warn(`[attest.protocol] ${kind}: ${message}`);
798
- for (const listener of this.diagnosticListeners) {
799
- try {
800
- listener(diagnostic);
801
- } catch (err) {
802
- this.logger.error(
803
- `[attest.protocol] diagnostic listener threw: ${err instanceof Error ? err.message : String(err)}`
804
- );
805
- }
806
- }
807
- this.maybeTriggerDesync(diagnostic.timestampMs);
808
- }
809
- maybeTriggerDesync(nowMs) {
810
- if (this.desynced) return;
811
- const recent = this.diagnosticBuffer.countWithin(this.desyncWindowMs, nowMs);
812
- if (recent >= this.desyncThreshold) {
813
- this.desynced = true;
814
- const snapshot = this.diagnosticBuffer.snapshot();
815
- const err = new ProtocolDesyncError(
816
- `Engine protocol desync: ${recent} unroutable response lines within ${this.desyncWindowMs}ms.`,
817
- snapshot
818
- );
819
- this.logger.error(`[attest.protocol] ${err.message}`);
820
- this.failAll(err);
821
- }
1074
+ this.usedJudgeCalls = this.usedUsd = this.heldJudgeCalls = this.heldUsd = 0;
1075
+ this.unknownSpend = false;
1076
+ this.initResult = await this.launch(void 0);
1077
+ this.initialized = true;
1078
+ return this.initResult;
822
1079
  }
823
- handleLine(line) {
824
- let response;
825
- try {
826
- response = decodeResponse(line);
827
- } catch (err) {
828
- this.handleDecodeFailure(line, err);
829
- return;
830
- }
831
- let reqId;
1080
+ /** Spawn the engine and run `initialize` on it; the engine is stopped when that fails. */
1081
+ async launch(envOverrides) {
1082
+ const stderr = { text: "" };
1083
+ this.stderr = stderr;
1084
+ const child = spawn(this.enginePath, [`--log-level=${this.logLevel}`], {
1085
+ stdio: ["pipe", "pipe", "pipe"],
1086
+ env: { ...process.env, ...envOverrides }
1087
+ });
1088
+ this.process = child;
1089
+ child.stderr.setEncoding("utf8");
1090
+ child.stderr.on("data", (chunk) => {
1091
+ stderr.text = (stderr.text + chunk).slice(-STDERR_TAIL_BYTES);
1092
+ });
1093
+ child.stdin.on("error", () => void 0);
1094
+ this.reader = createInterface({ input: child.stdout });
1095
+ const client = new AttestClient(this);
1096
+ this.routedClient = client;
1097
+ client.startReader();
1098
+ const exited = new Promise((_, reject) => {
1099
+ child.once("error", (err) => reject(new Error(`attest-engine failed to start: ${err.message}`)));
1100
+ child.once("close", (code, signal) => {
1101
+ const how = signal !== null ? `signal ${signal}` : `exit code ${String(code)}`;
1102
+ reject(
1103
+ new Error(
1104
+ `attest-engine exited before it was ready (${how}).` + (stderr.text === "" ? "" : ` stderr:
1105
+ ${stderr.text.trimEnd()}`)
1106
+ )
1107
+ );
1108
+ });
1109
+ });
1110
+ exited.catch(() => void 0);
832
1111
  try {
833
- reqId = extractId(response);
1112
+ return await Promise.race([this.initialize(client), exited]);
834
1113
  } catch (err) {
835
- const message = err instanceof Error ? err.message : String(err);
836
- this.recordDiagnostic("missing_id", message, line);
837
- return;
1114
+ await this.terminate();
1115
+ if (isPipeWriteError(err)) await reportExit(exited, EXIT_REPORT_GRACE_MS);
1116
+ throw err;
838
1117
  }
839
- const pending = this.pending.get(reqId);
840
- if (pending === void 0) {
841
- this.recordDiagnostic(
842
- "unknown_id",
843
- `no pending request for id=${reqId}`,
844
- line
1118
+ }
1119
+ /** Send initialize and check the engine's version and capabilities. */
1120
+ async initialize(client) {
1121
+ const init = await client.sendWithoutRestart(
1122
+ "initialize",
1123
+ {
1124
+ sdk_name: "attest-typescript",
1125
+ sdk_version: VERSION,
1126
+ protocol_version: 1,
1127
+ required_capabilities: ["layers_1_4"],
1128
+ preferred_encoding: "json"
1129
+ },
1130
+ this.initializeTimeoutMs
1131
+ );
1132
+ if (init.engine_version !== ENGINE_VERSION) {
1133
+ throw new EngineVersionMismatchError(
1134
+ this.enginePath,
1135
+ "initialize",
1136
+ init.engine_version,
1137
+ ENGINE_VERSION
845
1138
  );
846
- return;
847
1139
  }
848
- this.pending.delete(reqId);
849
- try {
850
- const result = extractResult(response);
851
- pending.resolve(result);
852
- } catch (err) {
853
- pending.reject(err);
1140
+ if (!init.compatible) {
1141
+ throw new Error(`Engine incompatible. Missing capabilities: ${JSON.stringify(init.missing)}`);
854
1142
  }
1143
+ return init;
855
1144
  }
856
- handleDecodeFailure(line, err) {
857
- if (err instanceof ProtocolError) {
858
- let reqId = -1;
1145
+ async stop() {
1146
+ if (this.restarting !== null) await this.restarting.catch(() => void 0);
1147
+ if (this.process === null) return;
1148
+ if (this.initialized && this.routedClient !== void 0 && this.isRunning) {
859
1149
  try {
860
- const raw = JSON.parse(line.trim());
861
- const idCandidate = raw["id"];
862
- if (typeof idCandidate === "number" && Number.isFinite(idCandidate)) {
863
- reqId = idCandidate;
864
- }
1150
+ await this.routedClient.sendWithoutRestart("shutdown", {}, SHUTDOWN_TIMEOUT_MS);
865
1151
  } catch {
866
- reqId = -1;
867
- }
868
- const pending = this.pending.get(reqId);
869
- if (pending !== void 0) {
870
- this.pending.delete(reqId);
871
- pending.reject(err);
872
- return;
873
1152
  }
874
- this.recordDiagnostic(
875
- "non_routable_error",
876
- `engine error code=${err.code} (${err.errorMessage}) had no matching pending id`,
877
- line
878
- );
879
- return;
880
1153
  }
881
- const message = err instanceof Error ? err.message : String(err);
882
- const kind = classifyDecodeError(message);
883
- this.recordDiagnostic(kind, message, line);
1154
+ await this.terminate();
1155
+ this.initialized = false;
884
1156
  }
885
- failAll(err) {
886
- for (const pending of this.pending.values()) {
887
- pending.reject(err);
1157
+ /** Close the pipes, reject pending requests and stop the engine process (SIGKILL when `force`). */
1158
+ async terminate(force = false) {
1159
+ const child = this.process;
1160
+ this.routedClient?.stopReader();
1161
+ this.routedClient?.rejectPending(new Error("Engine stopped."));
1162
+ this.reader?.close();
1163
+ this.reader = null;
1164
+ this.process = null;
1165
+ if (child === null) return;
1166
+ if (child.exitCode === null && child.signalCode === null) {
1167
+ await new Promise((res) => {
1168
+ const timer = force ? void 0 : setTimeout(() => child.kill("SIGKILL"), 5e3);
1169
+ child.once("exit", () => {
1170
+ clearTimeout(timer);
1171
+ res();
1172
+ });
1173
+ child.kill(force ? "SIGKILL" : "SIGTERM");
1174
+ });
888
1175
  }
889
- this.pending.clear();
890
1176
  }
891
- async sendRequest(method, params, timeoutMs) {
892
- if (this.desynced) {
893
- throw new ProtocolDesyncError(
894
- "Engine protocol is desynced; AttestClient will not send further requests.",
895
- this.diagnosticBuffer.snapshot()
896
- );
1177
+ /**
1178
+ * Replace the engine after a request timed out: its late response can no longer be told
1179
+ * apart from a new one. The new engine gets the remaining session budget: the configured
1180
+ * limit minus the usage already returned, minus what the abandoned requests may still have
1181
+ * reserved. A restart that leaves no budget makes later model calls
1182
+ * `not_evaluated(budget_exhausted)`.
1183
+ */
1184
+ restartAfterTimeout(abandoned) {
1185
+ for (const r of abandoned) {
1186
+ this.heldJudgeCalls += r.judgeCalls ?? Number.POSITIVE_INFINITY;
1187
+ this.heldUsd += r.usd ?? Number.POSITIVE_INFINITY;
897
1188
  }
898
- const resolvedTimeout = resolveTimeoutMs(timeoutMs);
899
- if (!this.readerActive) {
900
- return Promise.race([
901
- this.engine.sendRequest(method, params),
902
- makeTimeoutPromise(method, resolvedTimeout)
903
- ]);
1189
+ this.restarting ??= this.restart().finally(() => {
1190
+ this.restarting = null;
1191
+ });
1192
+ return this.restarting;
1193
+ }
1194
+ /** An engine reply the SDK cannot account for (no usage, an RPC error, a dropped reply) may have spent budget. */
1195
+ markUnknownSpend() {
1196
+ this.unknownSpend = true;
1197
+ }
1198
+ async restart() {
1199
+ await this.terminate(true);
1200
+ const overrides = {};
1201
+ const { judgeCalls, usd } = this.configuredBudget;
1202
+ const unknown = this.unknownSpend ? Number.POSITIVE_INFINITY : 0;
1203
+ if (judgeCalls !== null) {
1204
+ overrides["ATTEST_MAX_JUDGE_CALLS"] = String(
1205
+ Math.max(0, Math.floor(judgeCalls - this.usedJudgeCalls - this.heldJudgeCalls - unknown))
1206
+ );
904
1207
  }
905
- const cp = this.engine.childProcess;
906
- if (cp === null || cp.stdin === null) {
907
- throw new Error("Engine process not started.");
1208
+ if (usd !== null) {
1209
+ overrides["ATTEST_BUDGET_USD"] = String(Math.max(0, usd - this.usedUsd - this.heldUsd - unknown));
908
1210
  }
909
- let capturedReqId;
910
- const requestPromise = new Promise((resolve5, reject) => {
911
- this.writeChain = this.writeChain.then(() => {
912
- this.requestId += 1;
913
- const reqId = this.requestId;
914
- capturedReqId = reqId;
915
- this.pending.set(reqId, { resolve: resolve5, reject });
916
- const requestStr = encodeRequest(reqId, method, params);
917
- cp.stdin.write(requestStr, (err) => {
918
- if (err) {
919
- this.pending.delete(reqId);
920
- reject(err);
921
- }
922
- });
923
- });
924
- });
925
- const timeoutPromise = makeTimeoutPromise(method, resolvedTimeout);
926
- return Promise.race([requestPromise, timeoutPromise]).catch((err) => {
927
- if (err instanceof EngineTimeoutError && capturedReqId !== void 0) {
928
- this.pending.delete(capturedReqId);
929
- }
930
- throw err;
931
- });
1211
+ this.initResult = await this.launch(overrides);
932
1212
  }
933
- async evaluateBatch(trace, assertions, options2) {
934
- if (isSimulationMode()) {
935
- requireSimulationCapability(this.engine["initResult"]);
936
- }
937
- const params = {
938
- trace,
939
- assertions: [...assertions],
940
- ...options2?.budget !== void 0 ? { budget: options2.budget } : {},
941
- ...options2?.context !== void 0 ? { context: options2.context } : {}
942
- };
943
- const raw = await this.sendRequest(
944
- "evaluate_batch",
945
- params,
946
- options2?.timeout
947
- );
948
- return { ...raw, usage: parseUsage(raw["usage"]) };
1213
+ /** Count the usage an evaluate_batch result reported against the session budget. */
1214
+ recordUsage(usage) {
1215
+ this.usedJudgeCalls += usage.judge_calls;
1216
+ this.usedUsd += usage.cost_usd;
949
1217
  }
950
- async submitPluginResult(traceId2, pluginName, assertionId2, status, score, explanation, options2) {
951
- const result = {
952
- assertion_id: assertionId2,
953
- status,
954
- score,
955
- explanation,
956
- cost: 0,
957
- duration_ms: 0
958
- };
959
- const params = {
960
- trace_id: traceId2,
961
- plugin_name: pluginName,
962
- assertion_id: assertionId2,
963
- result
964
- };
965
- const raw = await this.sendRequest(
966
- "submit_plugin_result",
967
- params,
968
- options2?.timeout
969
- );
970
- return Boolean(raw?.accepted ?? false);
1218
+ /** Resolves once an engine restart in progress has finished. */
1219
+ async whenReady() {
1220
+ if (this.restarting !== null) await this.restarting;
971
1221
  }
972
- async queryDrift(assertionId2, windowSize = 50, options2) {
973
- if (isSimulationMode()) {
974
- return {
975
- assertion_id: assertionId2,
976
- mean: 0,
977
- stddev: 0,
978
- count: 0,
979
- latest_score: 0,
980
- deviation: 0,
981
- status: "no_data"
982
- };
983
- }
984
- const raw = await this.sendRequest(
985
- "query_drift",
986
- { assertion_id: assertionId2, window_size: windowSize },
987
- options2?.timeout
988
- );
989
- return raw.report;
1222
+ /** Tail of the engine's stderr: the reason an engine died. */
1223
+ get stderrTail() {
1224
+ return this.stderr.text.trimEnd();
990
1225
  }
991
- async generateUserMessage(persona, conversationHistory, faultConfig, options2) {
992
- if (isSimulationMode()) {
993
- return `[simulation] Hello from ${persona.name}`;
994
- }
995
- const params = {
996
- persona,
997
- conversation_history: conversationHistory
998
- };
999
- if (faultConfig !== void 0) {
1000
- params.fault_config = faultConfig;
1226
+ /** The client that routes this engine's responses by id; replaced when the engine restarts. */
1227
+ get client() {
1228
+ return this.routedClient;
1229
+ }
1230
+ async sendRequest(method, params) {
1231
+ if (!this.initialized && method !== "initialize") {
1232
+ throw new Error("Engine not initialized. Call start() first.");
1001
1233
  }
1002
- const raw = await this.sendRequest(
1003
- "generate_user_message",
1004
- params,
1005
- options2?.timeout
1006
- );
1007
- return String(raw.message);
1234
+ if (this.routedClient === void 0) throw new Error("Engine process not started.");
1235
+ return this.routedClient.sendRequest(method, params);
1008
1236
  }
1009
- };
1010
- function classifyDecodeError(message) {
1011
- if (message.startsWith("malformed JSON")) return "malformed_json";
1012
- if (message.startsWith("expected JSON object")) return "non_object_response";
1013
- if (message.startsWith("invalid jsonrpc version")) return "invalid_jsonrpc_version";
1014
- if (message.startsWith("empty response line")) return "malformed_json";
1015
- return "malformed_json";
1016
- }
1017
- function requireSimulationCapability(init) {
1018
- if (init === null || init === void 0) {
1019
- throw new Error("simulation mode: the engine has not been initialized");
1237
+ get isRunning() {
1238
+ return this.process !== null && this.process.exitCode === null && this.process.signalCode === null;
1020
1239
  }
1021
- if (!(init.capabilities ?? []).includes("simulation_mode")) {
1022
- throw new Error(
1023
- `simulation mode is on but attest-engine ${init.engine_version} was not started in simulation mode (capability 'simulation_mode' missing), so it would call real providers. Set ATTEST_SIMULATION=1 before the engine starts (the Vitest fixtures do this for you) and use attest-engine 1.0 or later.`
1024
- );
1240
+ /** Expose process for AttestClient reader loop access */
1241
+ get childProcess() {
1242
+ return this.process;
1025
1243
  }
1026
- }
1244
+ /** Expose readline interface for AttestClient reader loop */
1245
+ get readlineInterface() {
1246
+ return this.reader;
1247
+ }
1248
+ };
1027
1249
 
1028
1250
  // src/trace.ts
1029
1251
  import { randomUUID } from "crypto";
@@ -2851,11 +3073,14 @@ async function recordExclusive(name, fn, opts) {
2851
3073
  throw new CaptureError(`capture ${name} wrote no fixture (${exitDetail(exit)})`);
2852
3074
  }
2853
3075
  const outcome = JSON.parse(lines[lines.length - 1] ?? "");
2854
- if (exit.code !== 0 || !outcome.verified) {
3076
+ if (exit.code !== 0 || outcome.outcome === "not_verified") {
2855
3077
  throw new CaptureError(`capture ${name}: fixture ${outcome.fixture} is not verified; no test drafted`, outcome.fixture);
2856
3078
  }
3079
+ if (outcome.outcome === "nothing_to_verify") {
3080
+ return { value, fixture: outcome.fixture, verified: false, outcome: "nothing_to_verify", testFile: null };
3081
+ }
2857
3082
  const testFile = opts.emitTest === false ? null : emitVitest(outcome.fixture);
2858
- return { value, fixture: outcome.fixture, verified: true, testFile };
3083
+ return { value, fixture: outcome.fixture, verified: true, outcome: "verified", testFile };
2859
3084
  }
2860
3085
 
2861
3086
  // src/replay.ts