@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.
@@ -387,8 +387,8 @@ function extractId(response) {
387
387
  }
388
388
 
389
389
  // src/version.ts
390
- var VERSION = "1.0.0-rc.5";
391
- var ENGINE_VERSION = "1.0.0-rc.5";
390
+ var VERSION = "1.0.1";
391
+ var ENGINE_VERSION = "1.0.1";
392
392
 
393
393
  // src/engine-manager.ts
394
394
  var import_node_child_process = require("child_process");
@@ -396,6 +396,565 @@ var import_node_readline = require("readline");
396
396
  var import_node_fs = require("fs");
397
397
  var import_node_path = require("path");
398
398
 
399
+ // src/config.ts
400
+ var _simulationMode = false;
401
+ var _sampleRate = 0;
402
+ function config(options2) {
403
+ if (options2.simulation !== void 0) {
404
+ _simulationMode = options2.simulation;
405
+ }
406
+ if (options2.sampleRate !== void 0) {
407
+ if (options2.sampleRate < 0 || options2.sampleRate > 1) {
408
+ throw new RangeError(
409
+ `sampleRate must be between 0.0 and 1.0, got ${options2.sampleRate}`
410
+ );
411
+ }
412
+ _sampleRate = options2.sampleRate;
413
+ }
414
+ }
415
+ function isSimulationMode() {
416
+ return _simulationMode || simulationEnvEnabled();
417
+ }
418
+ function simulationEnvEnabled() {
419
+ const env = (process.env["ATTEST_SIMULATION"] ?? "").trim().toLowerCase();
420
+ return env === "1" || env === "true" || env === "yes";
421
+ }
422
+ function getSampleRate() {
423
+ if (_sampleRate > 0) return _sampleRate;
424
+ const env = process.env["ATTEST_SAMPLE_RATE"];
425
+ if (env !== void 0 && env !== "") {
426
+ const parsed = Number(env);
427
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
428
+ throw new RangeError(
429
+ `ATTEST_SAMPLE_RATE must be between 0.0 and 1.0, got '${env}'`
430
+ );
431
+ }
432
+ return parsed;
433
+ }
434
+ return 0;
435
+ }
436
+ function resetConfig() {
437
+ _simulationMode = false;
438
+ _sampleRate = 0;
439
+ }
440
+
441
+ // src/usage.ts
442
+ var INT_FIELDS = [
443
+ "model_calls",
444
+ "judge_calls",
445
+ "input_tokens",
446
+ "output_tokens",
447
+ "cached_tokens",
448
+ "unpriced_calls",
449
+ "budget_refused"
450
+ ];
451
+ var USD_FIELDS = ["cost_usd", "retry_cost_usd"];
452
+ function zeroUsage() {
453
+ return {
454
+ model_calls: 0,
455
+ judge_calls: 0,
456
+ input_tokens: 0,
457
+ output_tokens: 0,
458
+ cached_tokens: 0,
459
+ cost_usd: 0,
460
+ retry_cost_usd: 0,
461
+ unpriced_calls: 0,
462
+ budget_refused: 0
463
+ };
464
+ }
465
+ function addUsage(a, b) {
466
+ return {
467
+ model_calls: a.model_calls + b.model_calls,
468
+ judge_calls: a.judge_calls + b.judge_calls,
469
+ input_tokens: a.input_tokens + b.input_tokens,
470
+ output_tokens: a.output_tokens + b.output_tokens,
471
+ cached_tokens: a.cached_tokens + b.cached_tokens,
472
+ cost_usd: a.cost_usd + b.cost_usd,
473
+ retry_cost_usd: a.retry_cost_usd + b.retry_cost_usd,
474
+ unpriced_calls: a.unpriced_calls + b.unpriced_calls,
475
+ budget_refused: a.budget_refused + b.budget_refused
476
+ };
477
+ }
478
+ function sumUsage(usages) {
479
+ let total = zeroUsage();
480
+ for (const u of usages) total = addUsage(total, u);
481
+ return total;
482
+ }
483
+ function parseUsage(raw) {
484
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
485
+ throw new TypeError(`evaluate_batch result: usage must be an object, got ${JSON.stringify(raw)}`);
486
+ }
487
+ const obj = raw;
488
+ const field = (name, integer) => {
489
+ const v = obj[name];
490
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || integer && !Number.isInteger(v)) {
491
+ const kind = integer ? "a non-negative integer" : "a non-negative number";
492
+ throw new TypeError(`evaluate_batch result: usage.${name} must be ${kind}, got ${JSON.stringify(v)}`);
493
+ }
494
+ return v;
495
+ };
496
+ const ints = Object.fromEntries(INT_FIELDS.map((f) => [f, field(f, true)]));
497
+ const usd = Object.fromEntries(USD_FIELDS.map((f) => [f, field(f, false)]));
498
+ return { ...ints, ...usd };
499
+ }
500
+ function formatUsage(u) {
501
+ 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)}`;
502
+ if (u.budget_refused > 0) line += ` \xB7 ${u.budget_refused} calls refused by budget`;
503
+ if (u.unpriced_calls > 0) line += ` \xB7 ${u.unpriced_calls} unpriced calls not in $`;
504
+ return line;
505
+ }
506
+ function usageSummaryLine(u) {
507
+ if (u.model_calls === 0 && u.budget_refused === 0) return void 0;
508
+ return `attest usage: ${formatUsage(u)}`;
509
+ }
510
+
511
+ // src/client.ts
512
+ var DEFAULT_TIMEOUT_MS = 3e4;
513
+ var DEFAULT_DIAGNOSTIC_BUFFER = 32;
514
+ var DEFAULT_DESYNC_THRESHOLD = 3;
515
+ var DEFAULT_DESYNC_WINDOW_MS = 1e3;
516
+ function resolveTimeoutMs(override) {
517
+ if (override !== void 0) return override;
518
+ const env = process.env["ATTEST_ENGINE_TIMEOUT"];
519
+ if (env !== void 0 && env !== "") {
520
+ const parsed = Number(env);
521
+ if (!Number.isFinite(parsed) || parsed <= 0) {
522
+ throw new Error(
523
+ `ATTEST_ENGINE_TIMEOUT must be a positive number of milliseconds, got: '${env}'`
524
+ );
525
+ }
526
+ return parsed;
527
+ }
528
+ return DEFAULT_TIMEOUT_MS;
529
+ }
530
+ function debugProtocolEnabled() {
531
+ const flag = process.env["ATTEST_DEBUG_PROTOCOL"];
532
+ return flag === "1" || flag === "true" || flag === "yes";
533
+ }
534
+ function envLoggerDefault() {
535
+ if (debugProtocolEnabled()) {
536
+ return {
537
+ warn: (msg, ...args) => console.warn(msg, ...args),
538
+ error: (msg, ...args) => console.error(msg, ...args)
539
+ };
540
+ }
541
+ return {
542
+ warn: () => {
543
+ },
544
+ error: () => {
545
+ }
546
+ };
547
+ }
548
+ var AttestClient = class {
549
+ engine;
550
+ requestId = 0;
551
+ pending = /* @__PURE__ */ new Map();
552
+ writeChain = Promise.resolve();
553
+ readerActive = false;
554
+ lineHandler = null;
555
+ attachedReader = null;
556
+ logger;
557
+ diagnosticBuffer;
558
+ diagnosticListeners = /* @__PURE__ */ new Set();
559
+ desyncThreshold;
560
+ desyncWindowMs;
561
+ desynced = false;
562
+ constructor(engine, options2) {
563
+ this.engine = engine;
564
+ this.logger = options2?.logger ?? envLoggerDefault();
565
+ this.diagnosticBuffer = new ProtocolDiagnosticBuffer(
566
+ options2?.diagnosticBufferSize ?? DEFAULT_DIAGNOSTIC_BUFFER
567
+ );
568
+ this.desyncThreshold = options2?.desyncThreshold ?? DEFAULT_DESYNC_THRESHOLD;
569
+ this.desyncWindowMs = options2?.desyncWindowMs ?? DEFAULT_DESYNC_WINDOW_MS;
570
+ if (options2?.onDiagnostic !== void 0) {
571
+ this.diagnosticListeners.add(options2.onDiagnostic);
572
+ }
573
+ }
574
+ /** Subscribe to protocol diagnostics. Returns an unsubscribe function. */
575
+ onProtocolDiagnostic(listener) {
576
+ this.diagnosticListeners.add(listener);
577
+ return () => {
578
+ this.diagnosticListeners.delete(listener);
579
+ };
580
+ }
581
+ /** Snapshot of the diagnostic ring buffer (most recent last). */
582
+ protocolDiagnostics() {
583
+ return this.diagnosticBuffer.snapshot();
584
+ }
585
+ /**
586
+ * Attach the id-routed reader to the engine's stdout. Idempotent; `sendRequest` starts it
587
+ * on first use, so calling it is optional.
588
+ */
589
+ startReader() {
590
+ if (this.readerActive) return;
591
+ const rl = this.engine.readlineInterface;
592
+ if (rl === null) {
593
+ throw new Error("Engine readline interface not available. Call engine.start() first.");
594
+ }
595
+ this.readerActive = true;
596
+ this.attachedReader = rl;
597
+ this.lineHandler = (line) => this.handleLine(line);
598
+ rl.on("line", this.lineHandler);
599
+ rl.on("close", () => {
600
+ if (this.attachedReader !== rl) return;
601
+ const tail = this.engine.stderrTail ?? "";
602
+ this.failAll(
603
+ new Error(tail === "" ? "Engine closed stdout." : `Engine closed stdout. stderr:
604
+ ${tail}`)
605
+ );
606
+ this.readerActive = false;
607
+ this.attachedReader = null;
608
+ });
609
+ }
610
+ stopReader() {
611
+ if (!this.readerActive) return;
612
+ const rl = this.attachedReader;
613
+ if (rl !== null && this.lineHandler !== null) {
614
+ rl.removeListener("line", this.lineHandler);
615
+ }
616
+ this.lineHandler = null;
617
+ this.readerActive = false;
618
+ this.attachedReader = null;
619
+ }
620
+ recordDiagnostic(kind, message, rawLine, countsTowardDesync = true) {
621
+ const diagnostic = {
622
+ kind,
623
+ message,
624
+ rawLine: previewLine(rawLine),
625
+ timestampMs: Date.now()
626
+ };
627
+ this.diagnosticBuffer.push(diagnostic);
628
+ this.logger.warn(`[attest.protocol] ${kind}: ${message}`);
629
+ for (const listener of this.diagnosticListeners) {
630
+ try {
631
+ listener(diagnostic);
632
+ } catch (err) {
633
+ this.logger.error(
634
+ `[attest.protocol] diagnostic listener threw: ${err instanceof Error ? err.message : String(err)}`
635
+ );
636
+ }
637
+ }
638
+ if (countsTowardDesync) this.maybeTriggerDesync(diagnostic.timestampMs);
639
+ }
640
+ maybeTriggerDesync(nowMs) {
641
+ if (this.desynced) return;
642
+ const recent = this.diagnosticBuffer.countWithin(this.desyncWindowMs, nowMs);
643
+ if (recent >= this.desyncThreshold) {
644
+ this.desynced = true;
645
+ const snapshot = this.diagnosticBuffer.snapshot();
646
+ const err = new ProtocolDesyncError(
647
+ `Engine protocol desync: ${recent} unroutable response lines within ${this.desyncWindowMs}ms.`,
648
+ snapshot
649
+ );
650
+ this.logger.error(`[attest.protocol] ${err.message}`);
651
+ this.failAll(err);
652
+ }
653
+ }
654
+ handleLine(line) {
655
+ let response;
656
+ try {
657
+ response = decodeResponse(line);
658
+ } catch (err) {
659
+ this.handleDecodeFailure(line, err);
660
+ return;
661
+ }
662
+ let reqId;
663
+ try {
664
+ reqId = extractId(response);
665
+ } catch (err) {
666
+ const message = err instanceof Error ? err.message : String(err);
667
+ this.recordDiagnostic("missing_id", message, line, false);
668
+ return;
669
+ }
670
+ const pending = this.pending.get(reqId);
671
+ if (pending === void 0) {
672
+ this.recordDiagnostic(
673
+ "unknown_id",
674
+ `no pending request for id=${reqId}`,
675
+ line
676
+ );
677
+ return;
678
+ }
679
+ this.pending.delete(reqId);
680
+ try {
681
+ const result = extractResult(response);
682
+ pending.resolve(result);
683
+ } catch (err) {
684
+ pending.reject(err);
685
+ }
686
+ }
687
+ handleDecodeFailure(line, err) {
688
+ if (err instanceof ProtocolError) {
689
+ let reqId = -1;
690
+ try {
691
+ const raw = JSON.parse(line.trim());
692
+ const idCandidate = raw["id"];
693
+ if (typeof idCandidate === "number" && Number.isFinite(idCandidate)) {
694
+ reqId = idCandidate;
695
+ }
696
+ } catch {
697
+ reqId = -1;
698
+ }
699
+ const pending = this.pending.get(reqId);
700
+ if (pending !== void 0) {
701
+ this.pending.delete(reqId);
702
+ pending.reject(err);
703
+ return;
704
+ }
705
+ this.recordDiagnostic(
706
+ "non_routable_error",
707
+ `engine error code=${err.code} (${err.errorMessage}) had no matching pending id`,
708
+ line
709
+ );
710
+ return;
711
+ }
712
+ const message = err instanceof Error ? err.message : String(err);
713
+ const kind = classifyDecodeError(message);
714
+ this.recordDiagnostic(kind, message, line);
715
+ }
716
+ /** Reject every request still waiting for a response. */
717
+ rejectPending(err) {
718
+ this.failAll(err);
719
+ }
720
+ failAll(err) {
721
+ for (const pending of this.pending.values()) {
722
+ pending.reject(err);
723
+ }
724
+ this.pending.clear();
725
+ }
726
+ /**
727
+ * Send one request and resolve with the result of the response that carries its id.
728
+ *
729
+ * After a timeout the engine is restarted (its late response can no longer be matched),
730
+ * every other pending request is rejected, and the new engine gets the remaining session
731
+ * budget.
732
+ */
733
+ async sendRequest(method, params, timeoutMs) {
734
+ return this.send(method, params, timeoutMs, true);
735
+ }
736
+ /** `sendRequest` without the restart-on-timeout step, for the engine's own handshake. */
737
+ async sendWithoutRestart(method, params, timeoutMs) {
738
+ return this.send(method, params, timeoutMs, false);
739
+ }
740
+ async send(method, params, timeoutMs, restartOnTimeout) {
741
+ if (restartOnTimeout) await this.engine.whenReady?.();
742
+ const shared = this.engine.client;
743
+ if (shared !== void 0 && shared !== this) {
744
+ return restartOnTimeout ? shared.sendRequest(method, params, timeoutMs) : shared.sendWithoutRestart(method, params, resolveTimeoutMs(timeoutMs));
745
+ }
746
+ if (this.desynced) {
747
+ throw new ProtocolDesyncError(
748
+ "Engine protocol is desynced; AttestClient will not send further requests.",
749
+ this.diagnosticBuffer.snapshot()
750
+ );
751
+ }
752
+ const resolvedTimeout = resolveTimeoutMs(timeoutMs);
753
+ if (this.engine.isRunning === false) {
754
+ const tail = this.engine.stderrTail ?? "";
755
+ throw new Error(`Engine process is not running.${tail === "" ? "" : ` stderr:
756
+ ${tail}`}`);
757
+ }
758
+ const cp = this.engine.childProcess;
759
+ if (cp === null || cp.stdin === null) {
760
+ throw new Error("Engine process not started.");
761
+ }
762
+ this.startReader();
763
+ const reservation = worstCaseReservation(method, params);
764
+ let capturedReqId;
765
+ let timer;
766
+ const requestPromise = new Promise((resolve5, reject) => {
767
+ this.writeChain = this.writeChain.then(() => {
768
+ this.requestId += 1;
769
+ const reqId = this.requestId;
770
+ capturedReqId = reqId;
771
+ this.pending.set(reqId, { resolve: resolve5, reject, reservation });
772
+ const requestStr = encodeRequest(reqId, method, params);
773
+ cp.stdin.write(requestStr, (err) => {
774
+ if (err) {
775
+ this.pending.delete(reqId);
776
+ reject(this.describeWriteError(err));
777
+ }
778
+ });
779
+ });
780
+ });
781
+ const timeoutPromise = new Promise((_, reject) => {
782
+ timer = setTimeout(() => reject(new EngineTimeoutError(method, resolvedTimeout)), resolvedTimeout);
783
+ });
784
+ try {
785
+ return await Promise.race([requestPromise, timeoutPromise]);
786
+ } catch (err) {
787
+ if (err instanceof EngineTimeoutError) {
788
+ if (capturedReqId !== void 0) this.pending.delete(capturedReqId);
789
+ if (restartOnTimeout) await this.recoverFromTimeout(method, params, err);
790
+ }
791
+ throw err;
792
+ } finally {
793
+ clearTimeout(timer);
794
+ }
795
+ }
796
+ /** A failed pipe write names the engine's stderr tail; the original error stays as `cause` and `code`. */
797
+ describeWriteError(err) {
798
+ const tail = this.engine.stderrTail ?? "";
799
+ const wrapped = new Error(
800
+ `Failed to write to the engine: ${err.message}.${tail === "" ? "" : ` stderr:
801
+ ${tail}`}`,
802
+ { cause: err }
803
+ );
804
+ return Object.assign(wrapped, { code: err.code });
805
+ }
806
+ async recoverFromTimeout(method, params, timeout) {
807
+ if (this.engine.restartAfterTimeout === void 0 || this.engine.client !== this) return;
808
+ const abandoned = [
809
+ worstCaseReservation(method, params),
810
+ ...[...this.pending.values()].map((p) => p.reservation)
811
+ ];
812
+ this.failAll(new Error(`Engine restarted after '${method}' timed out; request abandoned.`));
813
+ try {
814
+ await this.engine.restartAfterTimeout(abandoned);
815
+ } catch (err) {
816
+ throw new Error(
817
+ `Engine request '${method}' timed out and the engine could not be restarted: ` + (err instanceof Error ? err.message : String(err)),
818
+ { cause: timeout }
819
+ );
820
+ }
821
+ }
822
+ async evaluateBatch(trace, assertions, options2) {
823
+ if (isSimulationMode()) {
824
+ requireSimulationCapability(this.engine["initResult"]);
825
+ }
826
+ const params = {
827
+ trace,
828
+ assertions: [...assertions],
829
+ ...options2?.budget !== void 0 ? { budget: options2.budget } : {},
830
+ ...options2?.context !== void 0 ? { context: options2.context } : {}
831
+ };
832
+ let raw;
833
+ try {
834
+ raw = await this.sendRequest(
835
+ "evaluate_batch",
836
+ params,
837
+ options2?.timeout
838
+ );
839
+ } catch (err) {
840
+ if (err instanceof ProtocolError) this.engine.markUnknownSpend?.();
841
+ throw err;
842
+ }
843
+ const usage = parseUsage(raw["usage"]);
844
+ this.engine.recordUsage?.(usage);
845
+ const result = raw;
846
+ this.requireSentAssertionIds(assertions, result);
847
+ return { ...result, usage };
848
+ }
849
+ /** Reject a response whose assertion ids differ from the ids that were sent. */
850
+ requireSentAssertionIds(assertions, result) {
851
+ const sent = new Set(assertions.map((a) => a.assertion_id));
852
+ const returned = new Set((result.results ?? []).map((r) => r.assertion_id));
853
+ const missing = [...sent].filter((id) => !returned.has(id));
854
+ const unexpected = [...returned].filter((id) => !sent.has(id));
855
+ if (missing.length === 0 && unexpected.length === 0) return;
856
+ throw new ProtocolDesyncError(
857
+ `evaluate_batch response does not match the request: missing assertion ids [${missing.join(", ")}], unexpected [${unexpected.join(", ")}].`,
858
+ this.diagnosticBuffer.snapshot()
859
+ );
860
+ }
861
+ async submitPluginResult(traceId2, pluginName, assertionId2, status, score, explanation, options2) {
862
+ const result = {
863
+ assertion_id: assertionId2,
864
+ status,
865
+ score,
866
+ explanation,
867
+ cost: 0,
868
+ duration_ms: 0
869
+ };
870
+ const params = {
871
+ trace_id: traceId2,
872
+ plugin_name: pluginName,
873
+ assertion_id: assertionId2,
874
+ result
875
+ };
876
+ const raw = await this.sendRequest(
877
+ "submit_plugin_result",
878
+ params,
879
+ options2?.timeout
880
+ );
881
+ return Boolean(raw?.accepted ?? false);
882
+ }
883
+ async queryDrift(assertionId2, windowSize = 50, options2) {
884
+ if (isSimulationMode()) {
885
+ return {
886
+ assertion_id: assertionId2,
887
+ mean: 0,
888
+ stddev: 0,
889
+ count: 0,
890
+ latest_score: 0,
891
+ deviation: 0,
892
+ status: "no_data"
893
+ };
894
+ }
895
+ const raw = await this.sendRequest(
896
+ "query_drift",
897
+ { assertion_id: assertionId2, window_size: windowSize },
898
+ options2?.timeout
899
+ );
900
+ return raw.report;
901
+ }
902
+ async generateUserMessage(persona, conversationHistory, faultConfig, options2) {
903
+ if (isSimulationMode()) {
904
+ return `[simulation] Hello from ${persona.name}`;
905
+ }
906
+ const params = {
907
+ persona,
908
+ conversation_history: conversationHistory
909
+ };
910
+ if (faultConfig !== void 0) {
911
+ params.fault_config = faultConfig;
912
+ }
913
+ let raw;
914
+ try {
915
+ raw = await this.sendRequest(
916
+ "generate_user_message",
917
+ params,
918
+ options2?.timeout
919
+ );
920
+ } catch (err) {
921
+ if (err instanceof ProtocolError) this.engine.markUnknownSpend?.();
922
+ throw err;
923
+ }
924
+ this.engine.markUnknownSpend?.();
925
+ return String(raw.message);
926
+ }
927
+ };
928
+ function worstCaseReservation(method, params) {
929
+ if (method === "generate_user_message") return { judgeCalls: null, usd: null };
930
+ if (method !== "evaluate_batch") return { judgeCalls: 0, usd: 0 };
931
+ const assertions = params["assertions"] ?? [];
932
+ const judges = assertions.some((a) => a.type === "llm_judge");
933
+ const paid = judges || assertions.some((a) => a.type === "embedding");
934
+ const budget = params["budget"];
935
+ return {
936
+ judgeCalls: judges ? budget?.max_judge_calls ?? null : 0,
937
+ usd: paid ? budget?.max_cost_usd ?? null : 0
938
+ };
939
+ }
940
+ function classifyDecodeError(message) {
941
+ if (message.startsWith("malformed JSON")) return "malformed_json";
942
+ if (message.startsWith("expected JSON object")) return "non_object_response";
943
+ if (message.startsWith("invalid jsonrpc version")) return "invalid_jsonrpc_version";
944
+ if (message.startsWith("empty response line")) return "malformed_json";
945
+ return "malformed_json";
946
+ }
947
+ function requireSimulationCapability(init) {
948
+ if (init === null || init === void 0) {
949
+ throw new Error("simulation mode: the engine has not been initialized");
950
+ }
951
+ if (!(init.capabilities ?? []).includes("simulation_mode")) {
952
+ throw new Error(
953
+ `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.`
954
+ );
955
+ }
956
+ }
957
+
399
958
  // src/engine-downloader.ts
400
959
  var https = __toESM(require("https"), 1);
401
960
  var http = __toESM(require("http"), 1);
@@ -512,686 +1071,349 @@ async function downloadEngine() {
512
1071
  const dir = versionedBinDir();
513
1072
  fs.mkdirSync(dir, { recursive: true });
514
1073
  const destPath = path.join(dir, binaryFilename());
515
- const suffix = `${String(process.pid)}.${crypto.randomBytes(8).toString("hex")}`;
516
- const tmpPath = path.join(dir, `.${binaryFilename()}.tmp.${suffix}`);
517
- try {
518
- fs.writeFileSync(tmpPath, data, { flag: "wx", mode: 493 });
519
- fs.chmodSync(tmpPath, 493);
520
- fs.renameSync(tmpPath, destPath);
521
- } catch (err) {
522
- if (err.code !== "EEXIST") fs.rmSync(tmpPath, { force: true });
523
- throw err;
524
- }
525
- process.stderr.write(`[attest] Engine installed at ${destPath}
526
- `);
527
- return destPath;
528
- }
529
-
530
- // src/engine-manager.ts
531
- var ENGINE_BINARY_NAME = "attest-engine";
532
- var VERSION_PROBE_TIMEOUT_MS = 1e4;
533
- var EngineVersionMismatchError = class extends Error {
534
- enginePath;
535
- source;
536
- found;
537
- required;
538
- constructor(enginePath, source, found, required) {
539
- const reported = found === null ? "reports no version" : `reports version ${found}`;
540
- super(
541
- `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}/.`
542
- );
543
- this.name = "EngineVersionMismatchError";
544
- this.enginePath = enginePath;
545
- this.source = source;
546
- this.found = found;
547
- this.required = required;
548
- }
549
- };
550
- function engineVersion(enginePath) {
551
- const proc = (0, import_node_child_process.spawnSync)(enginePath, ["version"], {
552
- stdio: ["ignore", "pipe", "pipe"],
553
- encoding: "utf-8",
554
- timeout: VERSION_PROBE_TIMEOUT_MS
555
- });
556
- if (proc.error) throw proc.error;
557
- const out = proc.stdout.trim();
558
- const prefix = `${ENGINE_BINARY_NAME} `;
559
- if (proc.status !== 0 || !out.startsWith(prefix)) return null;
560
- return out.slice(prefix.length).trim();
561
- }
562
- function onPath() {
563
- for (const dir of (process.env["PATH"] ?? "").split(import_node_path.delimiter)) {
564
- if (!dir) continue;
565
- const candidate = (0, import_node_path.join)(dir, binaryFilename());
566
- try {
567
- (0, import_node_fs.accessSync)(candidate, import_node_fs.constants.X_OK);
568
- } catch {
569
- continue;
570
- }
571
- if ((0, import_node_fs.statSync)(candidate).isFile()) return candidate;
572
- }
573
- return null;
574
- }
575
- function requireFile(enginePath, label) {
576
- if (!(0, import_node_fs.existsSync)(enginePath) || !(0, import_node_fs.statSync)(enginePath).isFile()) {
577
- throw new Error(`${label}=${enginePath} does not exist or is not a file.`);
578
- }
579
- }
580
- async function locateEngine(explicitPath, allowDownload) {
581
- if (explicitPath) {
582
- requireFile(explicitPath, "Engine path");
583
- return [explicitPath, "explicit engine path"];
584
- }
585
- const envPath = process.env["ATTEST_ENGINE_PATH"];
586
- if (envPath) {
587
- requireFile(envPath, "ATTEST_ENGINE_PATH");
588
- return [envPath, "ATTEST_ENGINE_PATH"];
589
- }
590
- const found = onPath();
591
- if (found) return [found, "PATH"];
592
- const cached = cachedEnginePath();
593
- if (cached) return [cached, "engine cache"];
594
- if (!allowDownload) {
595
- throw new Error(
596
- `No '${ENGINE_BINARY_NAME}' ${ENGINE_VERSION} in ATTEST_ENGINE_PATH, PATH or ~/.attest/bin/${ENGINE_VERSION}/.`
597
- );
598
- }
599
- const noDownload = process.env["ATTEST_ENGINE_NO_DOWNLOAD"]?.toLowerCase();
600
- if (noDownload === "1" || noDownload === "true" || noDownload === "yes") {
601
- throw new Error(
602
- `Cannot find '${ENGINE_BINARY_NAME}' ${ENGINE_VERSION} and ATTEST_ENGINE_NO_DOWNLOAD is set.
603
- Install options:
604
- 1. Build from source: make engine, then set ATTEST_ENGINE_PATH
605
- 2. Download release v${ENGINE_VERSION}: https://github.com/attest-framework/attest/releases
606
- 3. Allow auto-download by unsetting ATTEST_ENGINE_NO_DOWNLOAD`
607
- );
608
- }
609
- return [await downloadEngine(), "download"];
610
- }
611
- async function resolveEngine(explicitPath, options2 = {}) {
612
- const [enginePath, source] = await locateEngine(explicitPath, options2.allowDownload ?? true);
613
- const found = engineVersion(enginePath);
614
- if (found !== ENGINE_VERSION) {
615
- throw new EngineVersionMismatchError(enginePath, source, found, ENGINE_VERSION);
616
- }
617
- return enginePath;
618
- }
619
- var EngineManager = class {
620
- enginePath;
621
- logLevel;
622
- process = null;
623
- reader = null;
624
- initialized = false;
625
- requestId = 0;
626
- initResult = null;
627
- constructor(options2) {
628
- this.enginePath = options2?.enginePath ?? "";
629
- this.logLevel = options2?.logLevel ?? "warn";
630
- }
631
- async start() {
632
- this.enginePath = await resolveEngine(this.enginePath || void 0);
633
- this.process = (0, import_node_child_process.spawn)(this.enginePath, [`--log-level=${this.logLevel}`], {
634
- stdio: ["pipe", "pipe", "pipe"]
635
- });
636
- this.reader = (0, import_node_readline.createInterface)({ input: this.process.stdout });
637
- try {
638
- this.initResult = await this.initialize();
639
- } catch (err) {
640
- await this.stop();
641
- throw err;
642
- }
643
- this.initialized = true;
644
- return this.initResult;
645
- }
646
- /** Send initialize and check the engine's version and capabilities. */
647
- async initialize() {
648
- const init = await this.sendRequestInternal("initialize", {
649
- sdk_name: "attest-typescript",
650
- sdk_version: VERSION,
651
- protocol_version: 1,
652
- required_capabilities: ["layers_1_4"],
653
- preferred_encoding: "json"
654
- });
655
- if (init.engine_version !== ENGINE_VERSION) {
656
- throw new EngineVersionMismatchError(
657
- this.enginePath,
658
- "initialize",
659
- init.engine_version,
660
- ENGINE_VERSION
661
- );
662
- }
663
- if (!init.compatible) {
664
- throw new Error(`Engine incompatible. Missing capabilities: ${JSON.stringify(init.missing)}`);
665
- }
666
- return init;
667
- }
668
- async stop() {
669
- if (this.process === null) return;
670
- if (this.initialized) {
671
- try {
672
- await this.sendRequestInternal("shutdown", {});
673
- } catch {
674
- }
675
- }
676
- if (this.reader) {
677
- this.reader.close();
678
- this.reader = null;
679
- }
680
- if (this.process.exitCode === null) {
681
- this.process.kill("SIGTERM");
682
- await new Promise((res) => {
683
- const timer = setTimeout(() => {
684
- this.process?.kill("SIGKILL");
685
- }, 5e3);
686
- this.process.on("exit", () => {
687
- clearTimeout(timer);
688
- res();
689
- });
690
- });
691
- }
692
- this.initialized = false;
693
- this.process = null;
694
- }
695
- async sendRequest(method, params) {
696
- if (!this.initialized && method !== "initialize") {
697
- throw new Error("Engine not initialized. Call start() first.");
698
- }
699
- return this.sendRequestInternal(method, params);
700
- }
701
- async sendRequestInternal(method, params) {
702
- if (this.process === null || this.process.stdin === null || this.reader === null) {
703
- throw new Error("Engine process not started.");
704
- }
705
- this.requestId += 1;
706
- const requestStr = encodeRequest(this.requestId, method, params);
707
- return new Promise((resolve5, reject) => {
708
- const onLine = (line) => {
709
- try {
710
- const response = decodeResponse(line);
711
- resolve5(extractResult(response));
712
- } catch (err) {
713
- reject(err);
714
- }
715
- };
716
- this.reader.once("line", onLine);
717
- const ok = this.process.stdin.write(requestStr, (err) => {
718
- if (err) {
719
- this.reader.removeListener("line", onLine);
720
- reject(err);
721
- }
722
- });
723
- if (!ok) {
724
- this.process.stdin.once("drain", () => {
725
- });
726
- }
727
- });
728
- }
729
- get isRunning() {
730
- return this.process !== null && this.process.exitCode === null;
731
- }
732
- /** Expose process for AttestClient reader loop access */
733
- get childProcess() {
734
- return this.process;
735
- }
736
- /** Expose readline interface for AttestClient reader loop */
737
- get readlineInterface() {
738
- return this.reader;
739
- }
740
- };
741
-
742
- // src/config.ts
743
- var _simulationMode = false;
744
- var _sampleRate = 0;
745
- function config(options2) {
746
- if (options2.simulation !== void 0) {
747
- _simulationMode = options2.simulation;
748
- }
749
- if (options2.sampleRate !== void 0) {
750
- if (options2.sampleRate < 0 || options2.sampleRate > 1) {
751
- throw new RangeError(
752
- `sampleRate must be between 0.0 and 1.0, got ${options2.sampleRate}`
753
- );
754
- }
755
- _sampleRate = options2.sampleRate;
756
- }
757
- }
758
- function isSimulationMode() {
759
- return _simulationMode || simulationEnvEnabled();
760
- }
761
- function simulationEnvEnabled() {
762
- const env = (process.env["ATTEST_SIMULATION"] ?? "").trim().toLowerCase();
763
- return env === "1" || env === "true" || env === "yes";
764
- }
765
- function getSampleRate() {
766
- if (_sampleRate > 0) return _sampleRate;
767
- const env = process.env["ATTEST_SAMPLE_RATE"];
768
- if (env !== void 0 && env !== "") {
769
- const parsed = Number(env);
770
- if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
771
- throw new RangeError(
772
- `ATTEST_SAMPLE_RATE must be between 0.0 and 1.0, got '${env}'`
773
- );
774
- }
775
- return parsed;
1074
+ const suffix = `${String(process.pid)}.${crypto.randomBytes(8).toString("hex")}`;
1075
+ const tmpPath = path.join(dir, `.${binaryFilename()}.tmp.${suffix}`);
1076
+ try {
1077
+ fs.writeFileSync(tmpPath, data, { flag: "wx", mode: 493 });
1078
+ fs.chmodSync(tmpPath, 493);
1079
+ fs.renameSync(tmpPath, destPath);
1080
+ } catch (err) {
1081
+ if (err.code !== "EEXIST") fs.rmSync(tmpPath, { force: true });
1082
+ throw err;
776
1083
  }
777
- return 0;
778
- }
779
- function resetConfig() {
780
- _simulationMode = false;
781
- _sampleRate = 0;
1084
+ process.stderr.write(`[attest] Engine installed at ${destPath}
1085
+ `);
1086
+ return destPath;
782
1087
  }
783
1088
 
784
- // src/usage.ts
785
- var INT_FIELDS = [
786
- "model_calls",
787
- "judge_calls",
788
- "input_tokens",
789
- "output_tokens",
790
- "cached_tokens",
791
- "unpriced_calls",
792
- "budget_refused"
793
- ];
794
- var USD_FIELDS = ["cost_usd", "retry_cost_usd"];
795
- function zeroUsage() {
796
- return {
797
- model_calls: 0,
798
- judge_calls: 0,
799
- input_tokens: 0,
800
- output_tokens: 0,
801
- cached_tokens: 0,
802
- cost_usd: 0,
803
- retry_cost_usd: 0,
804
- unpriced_calls: 0,
805
- budget_refused: 0
806
- };
807
- }
808
- function addUsage(a, b) {
809
- return {
810
- model_calls: a.model_calls + b.model_calls,
811
- judge_calls: a.judge_calls + b.judge_calls,
812
- input_tokens: a.input_tokens + b.input_tokens,
813
- output_tokens: a.output_tokens + b.output_tokens,
814
- cached_tokens: a.cached_tokens + b.cached_tokens,
815
- cost_usd: a.cost_usd + b.cost_usd,
816
- retry_cost_usd: a.retry_cost_usd + b.retry_cost_usd,
817
- unpriced_calls: a.unpriced_calls + b.unpriced_calls,
818
- budget_refused: a.budget_refused + b.budget_refused
819
- };
820
- }
821
- function sumUsage(usages) {
822
- let total = zeroUsage();
823
- for (const u of usages) total = addUsage(total, u);
824
- return total;
1089
+ // src/engine-manager.ts
1090
+ var ENGINE_BINARY_NAME = "attest-engine";
1091
+ var VERSION_PROBE_TIMEOUT_MS = 1e4;
1092
+ var DEFAULT_INITIALIZE_TIMEOUT_MS = 3e4;
1093
+ var SHUTDOWN_TIMEOUT_MS = 5e3;
1094
+ var STDERR_TAIL_BYTES = 4096;
1095
+ var EXIT_REPORT_GRACE_MS = 1e3;
1096
+ var PIPE_WRITE_ERROR_CODES = /* @__PURE__ */ new Set(["EPIPE", "ECONNRESET", "ERR_STREAM_DESTROYED"]);
1097
+ function isPipeWriteError(err) {
1098
+ for (let e = err; e instanceof Error; e = e.cause) {
1099
+ const code = e.code;
1100
+ if (code !== void 0 && PIPE_WRITE_ERROR_CODES.has(code)) return true;
1101
+ }
1102
+ return false;
825
1103
  }
826
- function parseUsage(raw) {
827
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
828
- throw new TypeError(`evaluate_batch result: usage must be an object, got ${JSON.stringify(raw)}`);
1104
+ var EngineVersionMismatchError = class extends Error {
1105
+ enginePath;
1106
+ source;
1107
+ found;
1108
+ required;
1109
+ constructor(enginePath, source, found, required) {
1110
+ const reported = found === null ? "reports no version" : `reports version ${found}`;
1111
+ super(
1112
+ `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}/.`
1113
+ );
1114
+ this.name = "EngineVersionMismatchError";
1115
+ this.enginePath = enginePath;
1116
+ this.source = source;
1117
+ this.found = found;
1118
+ this.required = required;
829
1119
  }
830
- const obj = raw;
831
- const field = (name, integer) => {
832
- const v = obj[name];
833
- if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || integer && !Number.isInteger(v)) {
834
- const kind = integer ? "a non-negative integer" : "a non-negative number";
835
- throw new TypeError(`evaluate_batch result: usage.${name} must be ${kind}, got ${JSON.stringify(v)}`);
836
- }
837
- return v;
838
- };
839
- const ints = Object.fromEntries(INT_FIELDS.map((f) => [f, field(f, true)]));
840
- const usd = Object.fromEntries(USD_FIELDS.map((f) => [f, field(f, false)]));
841
- return { ...ints, ...usd };
842
- }
843
- function formatUsage(u) {
844
- 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)}`;
845
- if (u.budget_refused > 0) line += ` \xB7 ${u.budget_refused} calls refused by budget`;
846
- if (u.unpriced_calls > 0) line += ` \xB7 ${u.unpriced_calls} unpriced calls not in $`;
847
- return line;
848
- }
849
- function usageSummaryLine(u) {
850
- if (u.model_calls === 0 && u.budget_refused === 0) return void 0;
851
- return `attest usage: ${formatUsage(u)}`;
1120
+ };
1121
+ function engineVersion(enginePath) {
1122
+ const proc = (0, import_node_child_process.spawnSync)(enginePath, ["version"], {
1123
+ stdio: ["ignore", "pipe", "pipe"],
1124
+ encoding: "utf-8",
1125
+ timeout: VERSION_PROBE_TIMEOUT_MS
1126
+ });
1127
+ if (proc.error) throw proc.error;
1128
+ const out = proc.stdout.trim();
1129
+ const prefix = `${ENGINE_BINARY_NAME} `;
1130
+ if (proc.status !== 0 || !out.startsWith(prefix)) return null;
1131
+ return out.slice(prefix.length).trim();
852
1132
  }
853
-
854
- // src/client.ts
855
- var DEFAULT_TIMEOUT_MS = 3e4;
856
- var DEFAULT_DIAGNOSTIC_BUFFER = 32;
857
- var DEFAULT_DESYNC_THRESHOLD = 3;
858
- var DEFAULT_DESYNC_WINDOW_MS = 1e3;
859
- function resolveTimeoutMs(override) {
860
- if (override !== void 0) return override;
861
- const env = process.env["ATTEST_ENGINE_TIMEOUT"];
862
- if (env !== void 0 && env !== "") {
863
- const parsed = Number(env);
864
- if (!Number.isFinite(parsed) || parsed <= 0) {
865
- throw new Error(
866
- `ATTEST_ENGINE_TIMEOUT must be a positive number of milliseconds, got: '${env}'`
867
- );
1133
+ function onPath() {
1134
+ for (const dir of (process.env["PATH"] ?? "").split(import_node_path.delimiter)) {
1135
+ if (!dir) continue;
1136
+ const candidate = (0, import_node_path.join)(dir, binaryFilename());
1137
+ try {
1138
+ (0, import_node_fs.accessSync)(candidate, import_node_fs.constants.X_OK);
1139
+ } catch {
1140
+ continue;
868
1141
  }
869
- return parsed;
1142
+ if ((0, import_node_fs.statSync)(candidate).isFile()) return candidate;
870
1143
  }
871
- return DEFAULT_TIMEOUT_MS;
872
- }
873
- function makeTimeoutPromise(method, timeoutMs) {
874
- return new Promise((_, reject) => {
875
- setTimeout(() => {
876
- reject(new EngineTimeoutError(method, timeoutMs));
877
- }, timeoutMs);
878
- });
879
- }
880
- function debugProtocolEnabled() {
881
- const flag = process.env["ATTEST_DEBUG_PROTOCOL"];
882
- return flag === "1" || flag === "true" || flag === "yes";
1144
+ return null;
883
1145
  }
884
- function envLoggerDefault() {
885
- if (debugProtocolEnabled()) {
886
- return {
887
- warn: (msg, ...args) => console.warn(msg, ...args),
888
- error: (msg, ...args) => console.error(msg, ...args)
889
- };
1146
+ function requireFile(enginePath, label) {
1147
+ if (!(0, import_node_fs.existsSync)(enginePath) || !(0, import_node_fs.statSync)(enginePath).isFile()) {
1148
+ throw new Error(`${label}=${enginePath} does not exist or is not a file.`);
890
1149
  }
891
- return {
892
- warn: () => {
893
- },
894
- error: () => {
895
- }
896
- };
897
1150
  }
898
- var AttestClient = class {
899
- engine;
900
- requestId = 0;
901
- pending = /* @__PURE__ */ new Map();
902
- writeChain = Promise.resolve();
903
- readerActive = false;
904
- lineHandler = null;
905
- logger;
906
- diagnosticBuffer;
907
- diagnosticListeners = /* @__PURE__ */ new Set();
908
- desyncThreshold;
909
- desyncWindowMs;
910
- desynced = false;
911
- constructor(engine, options2) {
912
- this.engine = engine;
913
- this.logger = options2?.logger ?? envLoggerDefault();
914
- this.diagnosticBuffer = new ProtocolDiagnosticBuffer(
915
- options2?.diagnosticBufferSize ?? DEFAULT_DIAGNOSTIC_BUFFER
1151
+ async function locateEngine(explicitPath, allowDownload) {
1152
+ if (explicitPath) {
1153
+ requireFile(explicitPath, "Engine path");
1154
+ return [explicitPath, "explicit engine path"];
1155
+ }
1156
+ const envPath = process.env["ATTEST_ENGINE_PATH"];
1157
+ if (envPath) {
1158
+ requireFile(envPath, "ATTEST_ENGINE_PATH");
1159
+ return [envPath, "ATTEST_ENGINE_PATH"];
1160
+ }
1161
+ const found = onPath();
1162
+ if (found) return [found, "PATH"];
1163
+ const cached = cachedEnginePath();
1164
+ if (cached) return [cached, "engine cache"];
1165
+ if (!allowDownload) {
1166
+ throw new Error(
1167
+ `No '${ENGINE_BINARY_NAME}' ${ENGINE_VERSION} in ATTEST_ENGINE_PATH, PATH or ~/.attest/bin/${ENGINE_VERSION}/.`
916
1168
  );
917
- this.desyncThreshold = options2?.desyncThreshold ?? DEFAULT_DESYNC_THRESHOLD;
918
- this.desyncWindowMs = options2?.desyncWindowMs ?? DEFAULT_DESYNC_WINDOW_MS;
919
- if (options2?.onDiagnostic !== void 0) {
920
- this.diagnosticListeners.add(options2.onDiagnostic);
921
- }
922
1169
  }
923
- /** Subscribe to protocol diagnostics. Returns an unsubscribe function. */
924
- onProtocolDiagnostic(listener) {
925
- this.diagnosticListeners.add(listener);
926
- return () => {
927
- this.diagnosticListeners.delete(listener);
928
- };
1170
+ const noDownload = process.env["ATTEST_ENGINE_NO_DOWNLOAD"]?.toLowerCase();
1171
+ if (noDownload === "1" || noDownload === "true" || noDownload === "yes") {
1172
+ throw new Error(
1173
+ `Cannot find '${ENGINE_BINARY_NAME}' ${ENGINE_VERSION} and ATTEST_ENGINE_NO_DOWNLOAD is set.
1174
+ Install options:
1175
+ 1. Build from source: make engine, then set ATTEST_ENGINE_PATH
1176
+ 2. Download release v${ENGINE_VERSION}: https://github.com/attest-framework/attest/releases
1177
+ 3. Allow auto-download by unsetting ATTEST_ENGINE_NO_DOWNLOAD`
1178
+ );
929
1179
  }
930
- /** Snapshot of the diagnostic ring buffer (most recent last). */
931
- protocolDiagnostics() {
932
- return this.diagnosticBuffer.snapshot();
1180
+ return [await downloadEngine(), "download"];
1181
+ }
1182
+ async function resolveEngine(explicitPath, options2 = {}) {
1183
+ const [enginePath, source] = await locateEngine(explicitPath, options2.allowDownload ?? true);
1184
+ const found = engineVersion(enginePath);
1185
+ if (found !== ENGINE_VERSION) {
1186
+ throw new EngineVersionMismatchError(enginePath, source, found, ENGINE_VERSION);
933
1187
  }
934
- startReader() {
935
- if (this.readerActive) return;
936
- const rl = this.engine.readlineInterface;
937
- if (rl === null) {
938
- throw new Error("Engine readline interface not available. Call engine.start() first.");
939
- }
940
- this.readerActive = true;
941
- this.lineHandler = (line) => this.handleLine(line);
942
- rl.on("line", this.lineHandler);
943
- rl.on("close", () => {
944
- this.failAll(new Error("Engine closed stdout."));
945
- this.readerActive = false;
946
- });
1188
+ return enginePath;
1189
+ }
1190
+ function envNumber(name) {
1191
+ const raw = process.env[name];
1192
+ if (raw === void 0 || raw === "") return null;
1193
+ const parsed = Number(raw);
1194
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
1195
+ }
1196
+ async function reportExit(exited, graceMs) {
1197
+ let timer;
1198
+ const grace = new Promise((done) => {
1199
+ timer = setTimeout(done, graceMs);
1200
+ });
1201
+ try {
1202
+ await Promise.race([exited, grace]);
1203
+ } finally {
1204
+ clearTimeout(timer);
947
1205
  }
948
- stopReader() {
949
- if (!this.readerActive) return;
950
- const rl = this.engine.readlineInterface;
951
- if (rl !== null && this.lineHandler !== null) {
952
- rl.removeListener("line", this.lineHandler);
953
- }
954
- this.lineHandler = null;
955
- this.readerActive = false;
1206
+ }
1207
+ var EngineManager = class {
1208
+ enginePath;
1209
+ logLevel;
1210
+ initializeTimeoutMs;
1211
+ process = null;
1212
+ reader = null;
1213
+ initialized = false;
1214
+ initResult = null;
1215
+ routedClient;
1216
+ /** The running engine's stderr tail; each launch owns a fresh one so a killed engine cannot write into it. */
1217
+ stderr = { text: "" };
1218
+ restarting = null;
1219
+ configuredBudget = { judgeCalls: null, usd: null };
1220
+ usedJudgeCalls = 0;
1221
+ usedUsd = 0;
1222
+ heldJudgeCalls = 0;
1223
+ heldUsd = 0;
1224
+ /** Spend the SDK cannot observe may have happened; a restart then leaves no budget. */
1225
+ unknownSpend = false;
1226
+ constructor(options2) {
1227
+ this.enginePath = options2?.enginePath ?? "";
1228
+ this.logLevel = options2?.logLevel ?? "warn";
1229
+ this.initializeTimeoutMs = options2?.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
956
1230
  }
957
- recordDiagnostic(kind, message, rawLine) {
958
- const diagnostic = {
959
- kind,
960
- message,
961
- rawLine: previewLine(rawLine),
962
- timestampMs: Date.now()
1231
+ /**
1232
+ * Start the engine and complete the `initialize` handshake. Rejects when the engine exits,
1233
+ * closes its pipes or fails to answer within the initialize timeout; the error carries the
1234
+ * tail of the engine's stderr.
1235
+ */
1236
+ async start() {
1237
+ this.enginePath = await resolveEngine(this.enginePath || void 0);
1238
+ this.configuredBudget = {
1239
+ judgeCalls: envNumber("ATTEST_MAX_JUDGE_CALLS"),
1240
+ usd: envNumber("ATTEST_BUDGET_USD")
963
1241
  };
964
- this.diagnosticBuffer.push(diagnostic);
965
- this.logger.warn(`[attest.protocol] ${kind}: ${message}`);
966
- for (const listener of this.diagnosticListeners) {
967
- try {
968
- listener(diagnostic);
969
- } catch (err) {
970
- this.logger.error(
971
- `[attest.protocol] diagnostic listener threw: ${err instanceof Error ? err.message : String(err)}`
972
- );
973
- }
974
- }
975
- this.maybeTriggerDesync(diagnostic.timestampMs);
976
- }
977
- maybeTriggerDesync(nowMs) {
978
- if (this.desynced) return;
979
- const recent = this.diagnosticBuffer.countWithin(this.desyncWindowMs, nowMs);
980
- if (recent >= this.desyncThreshold) {
981
- this.desynced = true;
982
- const snapshot = this.diagnosticBuffer.snapshot();
983
- const err = new ProtocolDesyncError(
984
- `Engine protocol desync: ${recent} unroutable response lines within ${this.desyncWindowMs}ms.`,
985
- snapshot
986
- );
987
- this.logger.error(`[attest.protocol] ${err.message}`);
988
- this.failAll(err);
989
- }
1242
+ this.usedJudgeCalls = this.usedUsd = this.heldJudgeCalls = this.heldUsd = 0;
1243
+ this.unknownSpend = false;
1244
+ this.initResult = await this.launch(void 0);
1245
+ this.initialized = true;
1246
+ return this.initResult;
990
1247
  }
991
- handleLine(line) {
992
- let response;
993
- try {
994
- response = decodeResponse(line);
995
- } catch (err) {
996
- this.handleDecodeFailure(line, err);
997
- return;
998
- }
999
- let reqId;
1248
+ /** Spawn the engine and run `initialize` on it; the engine is stopped when that fails. */
1249
+ async launch(envOverrides) {
1250
+ const stderr = { text: "" };
1251
+ this.stderr = stderr;
1252
+ const child = (0, import_node_child_process.spawn)(this.enginePath, [`--log-level=${this.logLevel}`], {
1253
+ stdio: ["pipe", "pipe", "pipe"],
1254
+ env: { ...process.env, ...envOverrides }
1255
+ });
1256
+ this.process = child;
1257
+ child.stderr.setEncoding("utf8");
1258
+ child.stderr.on("data", (chunk) => {
1259
+ stderr.text = (stderr.text + chunk).slice(-STDERR_TAIL_BYTES);
1260
+ });
1261
+ child.stdin.on("error", () => void 0);
1262
+ this.reader = (0, import_node_readline.createInterface)({ input: child.stdout });
1263
+ const client = new AttestClient(this);
1264
+ this.routedClient = client;
1265
+ client.startReader();
1266
+ const exited = new Promise((_, reject) => {
1267
+ child.once("error", (err) => reject(new Error(`attest-engine failed to start: ${err.message}`)));
1268
+ child.once("close", (code, signal) => {
1269
+ const how = signal !== null ? `signal ${signal}` : `exit code ${String(code)}`;
1270
+ reject(
1271
+ new Error(
1272
+ `attest-engine exited before it was ready (${how}).` + (stderr.text === "" ? "" : ` stderr:
1273
+ ${stderr.text.trimEnd()}`)
1274
+ )
1275
+ );
1276
+ });
1277
+ });
1278
+ exited.catch(() => void 0);
1000
1279
  try {
1001
- reqId = extractId(response);
1280
+ return await Promise.race([this.initialize(client), exited]);
1002
1281
  } catch (err) {
1003
- const message = err instanceof Error ? err.message : String(err);
1004
- this.recordDiagnostic("missing_id", message, line);
1005
- return;
1282
+ await this.terminate();
1283
+ if (isPipeWriteError(err)) await reportExit(exited, EXIT_REPORT_GRACE_MS);
1284
+ throw err;
1006
1285
  }
1007
- const pending = this.pending.get(reqId);
1008
- if (pending === void 0) {
1009
- this.recordDiagnostic(
1010
- "unknown_id",
1011
- `no pending request for id=${reqId}`,
1012
- line
1286
+ }
1287
+ /** Send initialize and check the engine's version and capabilities. */
1288
+ async initialize(client) {
1289
+ const init = await client.sendWithoutRestart(
1290
+ "initialize",
1291
+ {
1292
+ sdk_name: "attest-typescript",
1293
+ sdk_version: VERSION,
1294
+ protocol_version: 1,
1295
+ required_capabilities: ["layers_1_4"],
1296
+ preferred_encoding: "json"
1297
+ },
1298
+ this.initializeTimeoutMs
1299
+ );
1300
+ if (init.engine_version !== ENGINE_VERSION) {
1301
+ throw new EngineVersionMismatchError(
1302
+ this.enginePath,
1303
+ "initialize",
1304
+ init.engine_version,
1305
+ ENGINE_VERSION
1013
1306
  );
1014
- return;
1015
1307
  }
1016
- this.pending.delete(reqId);
1017
- try {
1018
- const result = extractResult(response);
1019
- pending.resolve(result);
1020
- } catch (err) {
1021
- pending.reject(err);
1308
+ if (!init.compatible) {
1309
+ throw new Error(`Engine incompatible. Missing capabilities: ${JSON.stringify(init.missing)}`);
1022
1310
  }
1311
+ return init;
1023
1312
  }
1024
- handleDecodeFailure(line, err) {
1025
- if (err instanceof ProtocolError) {
1026
- let reqId = -1;
1313
+ async stop() {
1314
+ if (this.restarting !== null) await this.restarting.catch(() => void 0);
1315
+ if (this.process === null) return;
1316
+ if (this.initialized && this.routedClient !== void 0 && this.isRunning) {
1027
1317
  try {
1028
- const raw = JSON.parse(line.trim());
1029
- const idCandidate = raw["id"];
1030
- if (typeof idCandidate === "number" && Number.isFinite(idCandidate)) {
1031
- reqId = idCandidate;
1032
- }
1318
+ await this.routedClient.sendWithoutRestart("shutdown", {}, SHUTDOWN_TIMEOUT_MS);
1033
1319
  } catch {
1034
- reqId = -1;
1035
- }
1036
- const pending = this.pending.get(reqId);
1037
- if (pending !== void 0) {
1038
- this.pending.delete(reqId);
1039
- pending.reject(err);
1040
- return;
1041
1320
  }
1042
- this.recordDiagnostic(
1043
- "non_routable_error",
1044
- `engine error code=${err.code} (${err.errorMessage}) had no matching pending id`,
1045
- line
1046
- );
1047
- return;
1048
1321
  }
1049
- const message = err instanceof Error ? err.message : String(err);
1050
- const kind = classifyDecodeError(message);
1051
- this.recordDiagnostic(kind, message, line);
1322
+ await this.terminate();
1323
+ this.initialized = false;
1052
1324
  }
1053
- failAll(err) {
1054
- for (const pending of this.pending.values()) {
1055
- pending.reject(err);
1325
+ /** Close the pipes, reject pending requests and stop the engine process (SIGKILL when `force`). */
1326
+ async terminate(force = false) {
1327
+ const child = this.process;
1328
+ this.routedClient?.stopReader();
1329
+ this.routedClient?.rejectPending(new Error("Engine stopped."));
1330
+ this.reader?.close();
1331
+ this.reader = null;
1332
+ this.process = null;
1333
+ if (child === null) return;
1334
+ if (child.exitCode === null && child.signalCode === null) {
1335
+ await new Promise((res) => {
1336
+ const timer = force ? void 0 : setTimeout(() => child.kill("SIGKILL"), 5e3);
1337
+ child.once("exit", () => {
1338
+ clearTimeout(timer);
1339
+ res();
1340
+ });
1341
+ child.kill(force ? "SIGKILL" : "SIGTERM");
1342
+ });
1056
1343
  }
1057
- this.pending.clear();
1058
1344
  }
1059
- async sendRequest(method, params, timeoutMs) {
1060
- if (this.desynced) {
1061
- throw new ProtocolDesyncError(
1062
- "Engine protocol is desynced; AttestClient will not send further requests.",
1063
- this.diagnosticBuffer.snapshot()
1064
- );
1345
+ /**
1346
+ * Replace the engine after a request timed out: its late response can no longer be told
1347
+ * apart from a new one. The new engine gets the remaining session budget: the configured
1348
+ * limit minus the usage already returned, minus what the abandoned requests may still have
1349
+ * reserved. A restart that leaves no budget makes later model calls
1350
+ * `not_evaluated(budget_exhausted)`.
1351
+ */
1352
+ restartAfterTimeout(abandoned) {
1353
+ for (const r of abandoned) {
1354
+ this.heldJudgeCalls += r.judgeCalls ?? Number.POSITIVE_INFINITY;
1355
+ this.heldUsd += r.usd ?? Number.POSITIVE_INFINITY;
1065
1356
  }
1066
- const resolvedTimeout = resolveTimeoutMs(timeoutMs);
1067
- if (!this.readerActive) {
1068
- return Promise.race([
1069
- this.engine.sendRequest(method, params),
1070
- makeTimeoutPromise(method, resolvedTimeout)
1071
- ]);
1357
+ this.restarting ??= this.restart().finally(() => {
1358
+ this.restarting = null;
1359
+ });
1360
+ return this.restarting;
1361
+ }
1362
+ /** An engine reply the SDK cannot account for (no usage, an RPC error, a dropped reply) may have spent budget. */
1363
+ markUnknownSpend() {
1364
+ this.unknownSpend = true;
1365
+ }
1366
+ async restart() {
1367
+ await this.terminate(true);
1368
+ const overrides = {};
1369
+ const { judgeCalls, usd } = this.configuredBudget;
1370
+ const unknown = this.unknownSpend ? Number.POSITIVE_INFINITY : 0;
1371
+ if (judgeCalls !== null) {
1372
+ overrides["ATTEST_MAX_JUDGE_CALLS"] = String(
1373
+ Math.max(0, Math.floor(judgeCalls - this.usedJudgeCalls - this.heldJudgeCalls - unknown))
1374
+ );
1072
1375
  }
1073
- const cp = this.engine.childProcess;
1074
- if (cp === null || cp.stdin === null) {
1075
- throw new Error("Engine process not started.");
1376
+ if (usd !== null) {
1377
+ overrides["ATTEST_BUDGET_USD"] = String(Math.max(0, usd - this.usedUsd - this.heldUsd - unknown));
1076
1378
  }
1077
- let capturedReqId;
1078
- const requestPromise = new Promise((resolve5, reject) => {
1079
- this.writeChain = this.writeChain.then(() => {
1080
- this.requestId += 1;
1081
- const reqId = this.requestId;
1082
- capturedReqId = reqId;
1083
- this.pending.set(reqId, { resolve: resolve5, reject });
1084
- const requestStr = encodeRequest(reqId, method, params);
1085
- cp.stdin.write(requestStr, (err) => {
1086
- if (err) {
1087
- this.pending.delete(reqId);
1088
- reject(err);
1089
- }
1090
- });
1091
- });
1092
- });
1093
- const timeoutPromise = makeTimeoutPromise(method, resolvedTimeout);
1094
- return Promise.race([requestPromise, timeoutPromise]).catch((err) => {
1095
- if (err instanceof EngineTimeoutError && capturedReqId !== void 0) {
1096
- this.pending.delete(capturedReqId);
1097
- }
1098
- throw err;
1099
- });
1379
+ this.initResult = await this.launch(overrides);
1100
1380
  }
1101
- async evaluateBatch(trace, assertions, options2) {
1102
- if (isSimulationMode()) {
1103
- requireSimulationCapability(this.engine["initResult"]);
1104
- }
1105
- const params = {
1106
- trace,
1107
- assertions: [...assertions],
1108
- ...options2?.budget !== void 0 ? { budget: options2.budget } : {},
1109
- ...options2?.context !== void 0 ? { context: options2.context } : {}
1110
- };
1111
- const raw = await this.sendRequest(
1112
- "evaluate_batch",
1113
- params,
1114
- options2?.timeout
1115
- );
1116
- return { ...raw, usage: parseUsage(raw["usage"]) };
1381
+ /** Count the usage an evaluate_batch result reported against the session budget. */
1382
+ recordUsage(usage) {
1383
+ this.usedJudgeCalls += usage.judge_calls;
1384
+ this.usedUsd += usage.cost_usd;
1117
1385
  }
1118
- async submitPluginResult(traceId2, pluginName, assertionId2, status, score, explanation, options2) {
1119
- const result = {
1120
- assertion_id: assertionId2,
1121
- status,
1122
- score,
1123
- explanation,
1124
- cost: 0,
1125
- duration_ms: 0
1126
- };
1127
- const params = {
1128
- trace_id: traceId2,
1129
- plugin_name: pluginName,
1130
- assertion_id: assertionId2,
1131
- result
1132
- };
1133
- const raw = await this.sendRequest(
1134
- "submit_plugin_result",
1135
- params,
1136
- options2?.timeout
1137
- );
1138
- return Boolean(raw?.accepted ?? false);
1386
+ /** Resolves once an engine restart in progress has finished. */
1387
+ async whenReady() {
1388
+ if (this.restarting !== null) await this.restarting;
1139
1389
  }
1140
- async queryDrift(assertionId2, windowSize = 50, options2) {
1141
- if (isSimulationMode()) {
1142
- return {
1143
- assertion_id: assertionId2,
1144
- mean: 0,
1145
- stddev: 0,
1146
- count: 0,
1147
- latest_score: 0,
1148
- deviation: 0,
1149
- status: "no_data"
1150
- };
1151
- }
1152
- const raw = await this.sendRequest(
1153
- "query_drift",
1154
- { assertion_id: assertionId2, window_size: windowSize },
1155
- options2?.timeout
1156
- );
1157
- return raw.report;
1390
+ /** Tail of the engine's stderr: the reason an engine died. */
1391
+ get stderrTail() {
1392
+ return this.stderr.text.trimEnd();
1158
1393
  }
1159
- async generateUserMessage(persona, conversationHistory, faultConfig, options2) {
1160
- if (isSimulationMode()) {
1161
- return `[simulation] Hello from ${persona.name}`;
1162
- }
1163
- const params = {
1164
- persona,
1165
- conversation_history: conversationHistory
1166
- };
1167
- if (faultConfig !== void 0) {
1168
- params.fault_config = faultConfig;
1394
+ /** The client that routes this engine's responses by id; replaced when the engine restarts. */
1395
+ get client() {
1396
+ return this.routedClient;
1397
+ }
1398
+ async sendRequest(method, params) {
1399
+ if (!this.initialized && method !== "initialize") {
1400
+ throw new Error("Engine not initialized. Call start() first.");
1169
1401
  }
1170
- const raw = await this.sendRequest(
1171
- "generate_user_message",
1172
- params,
1173
- options2?.timeout
1174
- );
1175
- return String(raw.message);
1402
+ if (this.routedClient === void 0) throw new Error("Engine process not started.");
1403
+ return this.routedClient.sendRequest(method, params);
1176
1404
  }
1177
- };
1178
- function classifyDecodeError(message) {
1179
- if (message.startsWith("malformed JSON")) return "malformed_json";
1180
- if (message.startsWith("expected JSON object")) return "non_object_response";
1181
- if (message.startsWith("invalid jsonrpc version")) return "invalid_jsonrpc_version";
1182
- if (message.startsWith("empty response line")) return "malformed_json";
1183
- return "malformed_json";
1184
- }
1185
- function requireSimulationCapability(init) {
1186
- if (init === null || init === void 0) {
1187
- throw new Error("simulation mode: the engine has not been initialized");
1405
+ get isRunning() {
1406
+ return this.process !== null && this.process.exitCode === null && this.process.signalCode === null;
1188
1407
  }
1189
- if (!(init.capabilities ?? []).includes("simulation_mode")) {
1190
- throw new Error(
1191
- `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.`
1192
- );
1408
+ /** Expose process for AttestClient reader loop access */
1409
+ get childProcess() {
1410
+ return this.process;
1193
1411
  }
1194
- }
1412
+ /** Expose readline interface for AttestClient reader loop */
1413
+ get readlineInterface() {
1414
+ return this.reader;
1415
+ }
1416
+ };
1195
1417
 
1196
1418
  // src/trace.ts
1197
1419
  var import_node_crypto = require("crypto");
@@ -3019,11 +3241,14 @@ async function recordExclusive(name, fn, opts) {
3019
3241
  throw new CaptureError(`capture ${name} wrote no fixture (${exitDetail(exit)})`);
3020
3242
  }
3021
3243
  const outcome = JSON.parse(lines[lines.length - 1] ?? "");
3022
- if (exit.code !== 0 || !outcome.verified) {
3244
+ if (exit.code !== 0 || outcome.outcome === "not_verified") {
3023
3245
  throw new CaptureError(`capture ${name}: fixture ${outcome.fixture} is not verified; no test drafted`, outcome.fixture);
3024
3246
  }
3247
+ if (outcome.outcome === "nothing_to_verify") {
3248
+ return { value, fixture: outcome.fixture, verified: false, outcome: "nothing_to_verify", testFile: null };
3249
+ }
3025
3250
  const testFile = opts.emitTest === false ? null : emitVitest(outcome.fixture);
3026
- return { value, fixture: outcome.fixture, verified: true, testFile };
3251
+ return { value, fixture: outcome.fixture, verified: true, outcome: "verified", testFile };
3027
3252
  }
3028
3253
 
3029
3254
  // src/replay.ts