@dogpile/sdk 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +1 -0
  3. package/dist/browser/index.js +1595 -54
  4. package/dist/browser/index.js.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/providers/openai-compatible.d.ts +11 -0
  8. package/dist/providers/openai-compatible.d.ts.map +1 -1
  9. package/dist/providers/openai-compatible.js +87 -2
  10. package/dist/providers/openai-compatible.js.map +1 -1
  11. package/dist/runtime/cancellation.d.ts +26 -0
  12. package/dist/runtime/cancellation.d.ts.map +1 -1
  13. package/dist/runtime/cancellation.js +38 -1
  14. package/dist/runtime/cancellation.js.map +1 -1
  15. package/dist/runtime/coordinator.d.ts +74 -1
  16. package/dist/runtime/coordinator.d.ts.map +1 -1
  17. package/dist/runtime/coordinator.js +932 -25
  18. package/dist/runtime/coordinator.js.map +1 -1
  19. package/dist/runtime/decisions.d.ts +25 -3
  20. package/dist/runtime/decisions.d.ts.map +1 -1
  21. package/dist/runtime/decisions.js +241 -3
  22. package/dist/runtime/decisions.js.map +1 -1
  23. package/dist/runtime/defaults.d.ts +37 -1
  24. package/dist/runtime/defaults.d.ts.map +1 -1
  25. package/dist/runtime/defaults.js +347 -0
  26. package/dist/runtime/defaults.js.map +1 -1
  27. package/dist/runtime/engine.d.ts.map +1 -1
  28. package/dist/runtime/engine.js +254 -24
  29. package/dist/runtime/engine.js.map +1 -1
  30. package/dist/runtime/sequential.d.ts.map +1 -1
  31. package/dist/runtime/sequential.js +8 -1
  32. package/dist/runtime/sequential.js.map +1 -1
  33. package/dist/runtime/validation.d.ts +10 -0
  34. package/dist/runtime/validation.d.ts.map +1 -1
  35. package/dist/runtime/validation.js +73 -0
  36. package/dist/runtime/validation.js.map +1 -1
  37. package/dist/types/events.d.ts +329 -8
  38. package/dist/types/events.d.ts.map +1 -1
  39. package/dist/types/replay.d.ts +5 -1
  40. package/dist/types/replay.d.ts.map +1 -1
  41. package/dist/types.d.ts +131 -5
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/index.ts +10 -0
  46. package/src/providers/openai-compatible.ts +82 -3
  47. package/src/runtime/cancellation.ts +59 -1
  48. package/src/runtime/coordinator.ts +1170 -25
  49. package/src/runtime/decisions.ts +307 -4
  50. package/src/runtime/defaults.ts +376 -0
  51. package/src/runtime/engine.ts +363 -24
  52. package/src/runtime/sequential.ts +9 -1
  53. package/src/runtime/validation.ts +81 -0
  54. package/src/types/events.ts +359 -8
  55. package/src/types/replay.ts +12 -1
  56. package/src/types.ts +147 -3
@@ -1,3 +1,4 @@
1
+ import { DogpileError } from "../types.js";
1
2
  import type {
2
3
  AgentSpec,
3
4
  Budget,
@@ -18,7 +19,9 @@ import type {
18
19
  RunEventLog,
19
20
  RunMetadata,
20
21
  RunUsage,
22
+ OnChildFailureMode,
21
23
  Tier,
24
+ Trace,
22
25
  TranscriptEntry,
23
26
  TranscriptLink
24
27
  } from "../types.js";
@@ -129,6 +132,38 @@ export function addCost(left: CostSummary, right: CostSummary): CostSummary {
129
132
  };
130
133
  }
131
134
 
135
+ export function resolveOnChildFailure(
136
+ runOption: OnChildFailureMode | undefined,
137
+ engineOption: OnChildFailureMode | undefined
138
+ ): OnChildFailureMode {
139
+ // onChildFailure precedence: per-run option > engine option > default.
140
+ return runOption ?? engineOption ?? "continue";
141
+ }
142
+
143
+ /**
144
+ * Walk a parent's events and accumulate the cost contributed by every
145
+ * sub-run (BUDGET-03 / D-06). Internal helper — not part of the public surface.
146
+ *
147
+ * - `sub-run-completed` events contribute `event.subResult.cost`.
148
+ * - `sub-run-failed` events contribute `event.partialCost` (real provider
149
+ * spend captured before the throw).
150
+ *
151
+ * Used by the `parent-rollup-drift` parity check in
152
+ * {@link recomputeAccountingFromTrace} to verify the parent's recorded
153
+ * accounting equals `localOnly + Σ children` recursively.
154
+ */
155
+ export function accumulateSubRunCost(events: readonly RunEvent[]): CostSummary {
156
+ let total = emptyCost();
157
+ for (const event of events) {
158
+ if (event.type === "sub-run-completed") {
159
+ total = addCost(total, event.subResult.cost);
160
+ } else if (event.type === "sub-run-failed") {
161
+ total = addCost(total, event.partialCost);
162
+ }
163
+ }
164
+ return total;
165
+ }
166
+
132
167
  export function createTranscriptLink(transcript: readonly TranscriptEntry[]): TranscriptLink {
133
168
  return {
134
169
  kind: "trace-transcript",
@@ -274,6 +309,13 @@ export function createReplayTraceBudgetStateChanges(
274
309
  case "model-output-chunk":
275
310
  case "tool-call":
276
311
  case "tool-result":
312
+ case "sub-run-started":
313
+ case "sub-run-completed":
314
+ case "sub-run-failed":
315
+ case "sub-run-parent-aborted":
316
+ case "sub-run-budget-clamped":
317
+ case "sub-run-queued":
318
+ case "sub-run-concurrency-clamped":
277
319
  return [];
278
320
  }
279
321
  });
@@ -408,6 +450,39 @@ export function createReplayTraceProtocolDecision(
408
450
  output: event.output,
409
451
  cost: event.cost
410
452
  };
453
+ case "sub-run-started":
454
+ return {
455
+ ...base,
456
+ input: event.intent
457
+ };
458
+ case "sub-run-completed":
459
+ return {
460
+ ...base,
461
+ output: event.subResult.output,
462
+ cost: event.subResult.cost
463
+ };
464
+ case "sub-run-failed":
465
+ return {
466
+ ...base
467
+ };
468
+ case "sub-run-parent-aborted":
469
+ return {
470
+ ...base
471
+ };
472
+ case "sub-run-budget-clamped":
473
+ return {
474
+ ...base
475
+ };
476
+ case "sub-run-queued":
477
+ return {
478
+ ...base,
479
+ childRunId: event.childRunId,
480
+ queuePosition: event.queuePosition
481
+ };
482
+ case "sub-run-concurrency-clamped":
483
+ return {
484
+ ...base
485
+ };
411
486
  }
412
487
  }
413
488
 
@@ -433,6 +508,20 @@ function defaultProtocolDecision(event: RunEvent): ReplayTraceProtocolDecisionTy
433
508
  return "stop-for-budget";
434
509
  case "final":
435
510
  return "finalize-output";
511
+ case "sub-run-started":
512
+ return "start-sub-run";
513
+ case "sub-run-completed":
514
+ return "complete-sub-run";
515
+ case "sub-run-failed":
516
+ return "fail-sub-run";
517
+ case "sub-run-parent-aborted":
518
+ return "mark-sub-run-parent-aborted";
519
+ case "sub-run-budget-clamped":
520
+ return "mark-sub-run-budget-clamped";
521
+ case "sub-run-queued":
522
+ return "queue-sub-run";
523
+ case "sub-run-concurrency-clamped":
524
+ return "mark-sub-run-concurrency-clamped";
436
525
  }
437
526
  }
438
527
 
@@ -515,6 +604,293 @@ export function stableJsonStringify(value: unknown): string {
515
604
  return JSON.stringify(canonicalizeSerializable(value));
516
605
  }
517
606
 
607
+ /**
608
+ * The eight numeric fields recursively verified by `recomputeAccountingFromTrace`.
609
+ *
610
+ * These are the only summable scalars on `RunAccounting`. Non-numeric fields
611
+ * (`kind`, `tier`, `budget`, `termination`, `budgetStateChanges`) and derived
612
+ * ratios (`usdCapUtilization`, `totalTokenCapUtilization`) are NOT in this set.
613
+ */
614
+ const RECOMPUTE_FIELD_ORDER: readonly [
615
+ "cost.usd",
616
+ "cost.inputTokens",
617
+ "cost.outputTokens",
618
+ "cost.totalTokens",
619
+ "usage.usd",
620
+ "usage.inputTokens",
621
+ "usage.outputTokens",
622
+ "usage.totalTokens"
623
+ ] = [
624
+ "cost.usd",
625
+ "cost.inputTokens",
626
+ "cost.outputTokens",
627
+ "cost.totalTokens",
628
+ "usage.usd",
629
+ "usage.inputTokens",
630
+ "usage.outputTokens",
631
+ "usage.totalTokens"
632
+ ];
633
+
634
+ const USD_FIELDS: ReadonlySet<string> = new Set(["cost.usd", "usage.usd"]);
635
+ const FLOAT_EPSILON = 1e-9;
636
+
637
+ function readNumericField(accounting: RunAccounting, field: (typeof RECOMPUTE_FIELD_ORDER)[number]): number {
638
+ switch (field) {
639
+ case "cost.usd":
640
+ return accounting.cost.usd;
641
+ case "cost.inputTokens":
642
+ return accounting.cost.inputTokens;
643
+ case "cost.outputTokens":
644
+ return accounting.cost.outputTokens;
645
+ case "cost.totalTokens":
646
+ return accounting.cost.totalTokens;
647
+ case "usage.usd":
648
+ return accounting.usage.usd;
649
+ case "usage.inputTokens":
650
+ return accounting.usage.inputTokens;
651
+ case "usage.outputTokens":
652
+ return accounting.usage.outputTokens;
653
+ case "usage.totalTokens":
654
+ return accounting.usage.totalTokens;
655
+ }
656
+ }
657
+
658
+ function fieldsEqual(field: (typeof RECOMPUTE_FIELD_ORDER)[number], a: number, b: number): boolean {
659
+ if (USD_FIELDS.has(field)) {
660
+ return Math.abs(a - b) < FLOAT_EPSILON;
661
+ }
662
+ return a === b;
663
+ }
664
+
665
+ function firstDifferingField(
666
+ recorded: RunAccounting,
667
+ recomputed: RunAccounting
668
+ ): { readonly field: (typeof RECOMPUTE_FIELD_ORDER)[number]; readonly recorded: number; readonly recomputed: number } | null {
669
+ for (const field of RECOMPUTE_FIELD_ORDER) {
670
+ const a = readNumericField(recorded, field);
671
+ const b = readNumericField(recomputed, field);
672
+ if (!fieldsEqual(field, a, b)) {
673
+ return { field, recorded: a, recomputed: b };
674
+ }
675
+ }
676
+ return null;
677
+ }
678
+
679
+ function buildLocalAccounting(trace: Trace): RunAccounting {
680
+ return createRunAccounting({
681
+ tier: trace.tier,
682
+ ...(trace.budget.caps ? { budget: trace.budget.caps } : {}),
683
+ ...(trace.budget.termination ? { termination: trace.budget.termination } : {}),
684
+ cost: trace.finalOutput.cost,
685
+ events: trace.events
686
+ });
687
+ }
688
+
689
+ export function lastCostBearingEventCost(events: readonly RunEvent[]): CostSummary | null {
690
+ for (let index = events.length - 1; index >= 0; index -= 1) {
691
+ const event = events[index];
692
+ if (event === undefined) continue;
693
+ if (
694
+ event.type === "final" ||
695
+ event.type === "agent-turn" ||
696
+ event.type === "broadcast" ||
697
+ event.type === "budget-stop"
698
+ ) {
699
+ return event.cost;
700
+ }
701
+ }
702
+ return null;
703
+ }
704
+
705
+ /**
706
+ * Recompute a parent's `RunAccounting` from a saved `Trace` for replay-time
707
+ * tamper detection.
708
+ *
709
+ * @remarks
710
+ * Returns the parent's local `RunAccounting` (built the same way `replay()`
711
+ * builds it today, from `trace.finalOutput.cost` and `trace.events`). While
712
+ * walking events, every `sub-run-completed` is recursed into and the
713
+ * recomputed child accounting is compared field-by-field to the recorded
714
+ * `event.subResult.accounting`. A mismatch on any of the eight enumerated
715
+ * numeric fields throws `DogpileError({ code: "invalid-configuration" })`
716
+ * with `detail.reason: "trace-accounting-mismatch"` and a concrete
717
+ * `detail.field` identifying the first differing numeric.
718
+ *
719
+ * Pure: no provider calls, no I/O, no clock reads.
720
+ *
721
+ * Non-summed fields (`kind`, `tier`, `budget`, `termination`,
722
+ * `budgetStateChanges`) and derived ratios (`usdCapUtilization`,
723
+ * `totalTokenCapUtilization`) are not in the comparison set.
724
+ */
725
+ export function recomputeAccountingFromTrace(trace: Trace): RunAccounting {
726
+ const local = buildLocalAccounting(trace);
727
+
728
+ // Parent-level integrity: the recorded `trace.finalOutput.cost` must match
729
+ // the cost on the last cost-bearing event. On a clean trace this holds by
730
+ // construction (every protocol writes `totalCost` into the final event).
731
+ // On a trace where `finalOutput.cost` was mutated without updating the
732
+ // events (or vice versa), this catches the drift.
733
+ const lastEventCost = lastCostBearingEventCost(trace.events);
734
+ if (lastEventCost !== null) {
735
+ const reconstructedFromEvents: RunAccounting = createRunAccounting({
736
+ tier: trace.tier,
737
+ ...(trace.budget.caps ? { budget: trace.budget.caps } : {}),
738
+ ...(trace.budget.termination ? { termination: trace.budget.termination } : {}),
739
+ cost: lastEventCost,
740
+ events: trace.events
741
+ });
742
+ const drift = firstDifferingField(local, reconstructedFromEvents);
743
+ if (drift !== null) {
744
+ throw new DogpileError({
745
+ code: "invalid-configuration",
746
+ message: `Trace accounting mismatch at parent run ${trace.runId}: field "${drift.field}" recorded ${drift.recorded}, recomputed ${drift.recomputed}.`,
747
+ retryable: false,
748
+ detail: {
749
+ kind: "trace-validation",
750
+ reason: "trace-accounting-mismatch",
751
+ eventIndex: -1,
752
+ childRunId: trace.runId,
753
+ field: drift.field,
754
+ recorded: drift.recorded,
755
+ recomputed: drift.recomputed
756
+ }
757
+ });
758
+ }
759
+ }
760
+
761
+ // BUDGET-03 / D-04: parent-rollup-drift parity check. Runs BEFORE the
762
+ // child recurse loop so a tampered child cost surfaces with the dedicated
763
+ // `subReason: "parent-rollup-drift"` rather than the generic
764
+ // `trace-accounting-mismatch` from the recurse check.
765
+ //
766
+ // The discriminator: each sub-run-completed event stores cost in TWO places
767
+ // (`subResult.cost` and `subResult.accounting.cost`). They must agree
768
+ // field-by-field — they are the parent-side roll-up source vs the
769
+ // child-side accounting source. Drift indicates someone mutated one without
770
+ // the other. For sub-run-failed events, `partialCost` must equal the cost
771
+ // implied by the partial trace's last cost-bearing event.
772
+ //
773
+ // Plus: Σ children must not exceed the parent's recorded total — cost is
774
+ // monotonic. A child total > parent total is unambiguous tampering.
775
+ for (let eventIndex = 0; eventIndex < trace.events.length; eventIndex += 1) {
776
+ const event = trace.events[eventIndex];
777
+ if (event === undefined) continue;
778
+ if (event.type === "sub-run-completed") {
779
+ const childRecordedRollup = createRunAccounting({
780
+ tier: trace.tier,
781
+ cost: event.subResult.cost,
782
+ events: []
783
+ });
784
+ const childRecordedAccounting = event.subResult.accounting;
785
+ const drift = firstDifferingField(childRecordedAccounting, childRecordedRollup);
786
+ if (drift !== null) {
787
+ throw new DogpileError({
788
+ code: "invalid-configuration",
789
+ message: `Trace parent-rollup mismatch at sub-run ${event.childRunId}: field "${drift.field}" recorded ${drift.recorded} on accounting, ${drift.recomputed} on subResult.cost.`,
790
+ retryable: false,
791
+ detail: {
792
+ kind: "trace-validation",
793
+ reason: "trace-accounting-mismatch",
794
+ subReason: "parent-rollup-drift",
795
+ eventIndex,
796
+ childRunId: event.childRunId,
797
+ field: drift.field,
798
+ recorded: drift.recorded,
799
+ recomputed: drift.recomputed
800
+ }
801
+ });
802
+ }
803
+ } else if (event.type === "sub-run-failed") {
804
+ const partialFromTrace = lastCostBearingEventCost(event.partialTrace.events) ?? emptyCost();
805
+ const recordedAccounting = createRunAccounting({
806
+ tier: trace.tier,
807
+ cost: event.partialCost,
808
+ events: []
809
+ });
810
+ const recomputedAccounting = createRunAccounting({
811
+ tier: trace.tier,
812
+ cost: partialFromTrace,
813
+ events: []
814
+ });
815
+ const drift = firstDifferingField(recordedAccounting, recomputedAccounting);
816
+ if (drift !== null) {
817
+ throw new DogpileError({
818
+ code: "invalid-configuration",
819
+ message: `Trace parent-rollup mismatch at sub-run ${event.childRunId}: partialCost field "${drift.field}" recorded ${drift.recorded}, recomputed ${drift.recomputed} from partialTrace events.`,
820
+ retryable: false,
821
+ detail: {
822
+ kind: "trace-validation",
823
+ reason: "trace-accounting-mismatch",
824
+ subReason: "parent-rollup-drift",
825
+ eventIndex,
826
+ childRunId: event.childRunId,
827
+ field: drift.field,
828
+ recorded: drift.recorded,
829
+ recomputed: drift.recomputed
830
+ }
831
+ });
832
+ }
833
+ }
834
+ }
835
+
836
+ // Tree-level monotonicity: Σ children must be ≤ parent's recorded total
837
+ // across all 8 fields. Cost is non-negative and monotonic.
838
+ const subRunTotal = accumulateSubRunCost(trace.events);
839
+ const parentTotal = trace.finalOutput.cost;
840
+ for (const field of RECOMPUTE_FIELD_ORDER) {
841
+ if (field.startsWith("usage.")) continue; // usage mirrors cost; one check is enough.
842
+ const [, key] = field.split(".") as [string, keyof CostSummary];
843
+ const parentValue = parentTotal[key];
844
+ const childValue = subRunTotal[key];
845
+ if (childValue - parentValue > FLOAT_EPSILON) {
846
+ throw new DogpileError({
847
+ code: "invalid-configuration",
848
+ message: `Trace parent-rollup mismatch at run ${trace.runId}: field "${field}" Σ children ${childValue} exceeds parent recorded ${parentValue}.`,
849
+ retryable: false,
850
+ detail: {
851
+ kind: "trace-validation",
852
+ reason: "trace-accounting-mismatch",
853
+ subReason: "parent-rollup-drift",
854
+ eventIndex: -1,
855
+ childRunId: trace.runId,
856
+ field,
857
+ recorded: parentValue,
858
+ recomputed: childValue
859
+ }
860
+ });
861
+ }
862
+ }
863
+
864
+ // Child-level integrity: recurse into every sub-run-completed and verify
865
+ // its recorded `subResult.accounting` matches what the child trace recomputes.
866
+ for (let eventIndex = 0; eventIndex < trace.events.length; eventIndex += 1) {
867
+ const event = trace.events[eventIndex];
868
+ if (event === undefined || event.type !== "sub-run-completed") continue;
869
+
870
+ const childRecomputed = recomputeAccountingFromTrace(event.subResult.trace);
871
+ const childRecorded = event.subResult.accounting;
872
+ const drift = firstDifferingField(childRecorded, childRecomputed);
873
+ if (drift !== null) {
874
+ throw new DogpileError({
875
+ code: "invalid-configuration",
876
+ message: `Trace accounting mismatch at sub-run ${event.childRunId}: field "${drift.field}" recorded ${drift.recorded}, recomputed ${drift.recomputed}.`,
877
+ retryable: false,
878
+ detail: {
879
+ kind: "trace-validation",
880
+ reason: "trace-accounting-mismatch",
881
+ eventIndex,
882
+ childRunId: event.childRunId,
883
+ field: drift.field,
884
+ recorded: drift.recorded,
885
+ recomputed: drift.recomputed
886
+ }
887
+ });
888
+ }
889
+ }
890
+
891
+ return local;
892
+ }
893
+
518
894
  export function canonicalizeSerializable<T>(value: T): T {
519
895
  if (Array.isArray(value)) {
520
896
  return value.map((item) => canonicalizeSerializable(item)) as T;