agent-inspect 6.25.0 → 6.26.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.
@@ -1500,6 +1500,99 @@ function eventEvidence(event) {
1500
1500
  status: event.status
1501
1501
  };
1502
1502
  }
1503
+ function eventTime(event) {
1504
+ return event.startedAt ?? event.timestamp ?? "";
1505
+ }
1506
+ function sortByTime(events) {
1507
+ return [...events].sort((a, b) => eventTime(a).localeCompare(eventTime(b)));
1508
+ }
1509
+ function attemptNumberOf(event) {
1510
+ const workflow = workflowFor(event);
1511
+ const value = workflow.attemptNumber ?? workflow.attempt;
1512
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1513
+ }
1514
+ function attemptIdOf(event) {
1515
+ const value = workflowFor(event).attemptId;
1516
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
1517
+ }
1518
+ function countOperationAttempts(members) {
1519
+ const attemptIds = new Set(
1520
+ members.map((event) => attemptIdOf(event)).filter((value) => value !== void 0)
1521
+ );
1522
+ if (attemptIds.size > 0) {
1523
+ return attemptIds.size;
1524
+ }
1525
+ const numbers = members.map((event) => attemptNumberOf(event)).filter((value) => value !== void 0).sort((a, b) => a - b);
1526
+ if (numbers.length > 0) {
1527
+ const unique = [...new Set(numbers)];
1528
+ const min = unique[0];
1529
+ const max = unique[unique.length - 1];
1530
+ const contiguous = unique.length === max - min + 1 && unique.every((value, index) => value === min + index);
1531
+ if (contiguous) {
1532
+ return unique.length;
1533
+ }
1534
+ return unique.length;
1535
+ }
1536
+ return members.filter((event) => event.kind === "TOOL" || event.kind === "LLM").length;
1537
+ }
1538
+ function classifyRetryAttempt(event, priorOrdered, allMembers) {
1539
+ const workflow = workflowFor(event);
1540
+ const attemptNumber = attemptNumberOf(event);
1541
+ const attemptId = attemptIdOf(event);
1542
+ const retryOf = workflow.retryOf;
1543
+ if (typeof retryOf === "string" && retryOf.trim() !== "") {
1544
+ const target = allMembers.find(
1545
+ (candidate) => candidate.eventId === retryOf || attemptIdOf(candidate) === retryOf || workflowFor(candidate).operationId === retryOf
1546
+ );
1547
+ if (!target) {
1548
+ return { kind: "unknown", reason: "retryOf target missing" };
1549
+ }
1550
+ if (eventTime(target) > eventTime(event)) {
1551
+ return { kind: "unknown", reason: "retryOf target does not precede retry" };
1552
+ }
1553
+ return { kind: "retry" };
1554
+ }
1555
+ if (attemptNumber !== void 0) {
1556
+ if (attemptNumber > 1) {
1557
+ return { kind: "retry" };
1558
+ }
1559
+ if (attemptNumber === 1) {
1560
+ return { kind: "first" };
1561
+ }
1562
+ }
1563
+ if (attemptId !== void 0) {
1564
+ const priorDistinct = priorOrdered.some((candidate) => {
1565
+ const priorId = attemptIdOf(candidate);
1566
+ return priorId !== void 0 && priorId !== attemptId;
1567
+ });
1568
+ if (priorDistinct) {
1569
+ return { kind: "retry" };
1570
+ }
1571
+ if (priorOrdered.length === 0) {
1572
+ return { kind: "first" };
1573
+ }
1574
+ }
1575
+ const priorFinished = priorOrdered.filter(
1576
+ (candidate) => candidate.status === "ok" || candidate.status === "error"
1577
+ );
1578
+ if (priorFinished.length > 0) {
1579
+ return { kind: "retry" };
1580
+ }
1581
+ if (priorOrdered.length === 0) {
1582
+ return { kind: "first" };
1583
+ }
1584
+ return { kind: "unknown", reason: "ambiguous attempt identity" };
1585
+ }
1586
+ function hasDuplicateAttemptIds(members) {
1587
+ const seen = /* @__PURE__ */ new Set();
1588
+ for (const event of members) {
1589
+ const id = attemptIdOf(event);
1590
+ if (!id) continue;
1591
+ if (seen.has(id)) return true;
1592
+ seen.add(id);
1593
+ }
1594
+ return false;
1595
+ }
1503
1596
  function evaluateRetrySafetyRules(events, rules, _runEvidence) {
1504
1597
  const findings = [];
1505
1598
  const byOperation = /* @__PURE__ */ new Map();
@@ -1513,11 +1606,18 @@ function evaluateRetrySafetyRules(events, rules, _runEvidence) {
1513
1606
  }
1514
1607
  if (rules.maxAttempts !== void 0) {
1515
1608
  for (const [operationId, members] of byOperation) {
1516
- const attemptIds = new Set(
1517
- members.map((event) => workflowFor(event).attemptId).filter((value) => typeof value === "string" && value.trim() !== "")
1518
- );
1519
- const attemptNumbers = members.map((event) => workflowFor(event).attemptNumber ?? workflowFor(event).attempt).filter((value) => typeof value === "number" && Number.isFinite(value));
1520
- const count = attemptIds.size > 0 ? attemptIds.size : attemptNumbers.length > 0 ? Math.max(...attemptNumbers) : members.filter((event) => event.kind === "TOOL" || event.kind === "LLM").length;
1609
+ if (hasDuplicateAttemptIds(members)) {
1610
+ findings.push(
1611
+ fail2(
1612
+ "contract.retry.duplicate-attempt-id",
1613
+ `Operation ${operationId} has duplicate attemptId values.`,
1614
+ members.slice(0, 4).map(eventEvidence),
1615
+ "unique attemptId",
1616
+ "duplicate"
1617
+ )
1618
+ );
1619
+ }
1620
+ const count = countOperationAttempts(members);
1521
1621
  if (count > rules.maxAttempts) {
1522
1622
  findings.push(
1523
1623
  fail2(
@@ -1552,18 +1652,18 @@ function evaluateRetrySafetyRules(events, rules, _runEvidence) {
1552
1652
  const workflow = workflowFor(event);
1553
1653
  const fallbackOf = workflow.fallbackOf;
1554
1654
  if (!fallbackOf) continue;
1555
- const prior = byOperation.get(fallbackOf) ?? events.filter((candidate) => {
1556
- const meta = workflowFor(candidate);
1557
- return meta.operationId === fallbackOf || candidate.runId === fallbackOf;
1558
- });
1559
- const priorFailure = prior.some((candidate) => candidate.status === "error");
1560
- if (!priorFailure) {
1655
+ const prior = byOperation.get(fallbackOf) ?? [];
1656
+ const fallbackStart = eventTime(event);
1657
+ const earlierFailure = prior.some(
1658
+ (candidate) => candidate.status === "error" && eventTime(candidate) !== "" && fallbackStart !== "" && eventTime(candidate) < fallbackStart
1659
+ );
1660
+ if (!earlierFailure) {
1561
1661
  findings.push(
1562
1662
  fail2(
1563
1663
  "contract.retry.fallback-after-failure",
1564
- `Fallback for ${fallbackOf} appeared without a prior failure.`,
1664
+ `Fallback for ${fallbackOf} appeared without an earlier failure in the related operation.`,
1565
1665
  [eventEvidence(event)],
1566
- "prior error attempt",
1666
+ "earlier error attempt",
1567
1667
  { fallbackOf }
1568
1668
  )
1569
1669
  );
@@ -1585,39 +1685,46 @@ function evaluateRetrySafetyRules(events, rules, _runEvidence) {
1585
1685
  byToolOp.set(key, list);
1586
1686
  }
1587
1687
  for (const [, members] of byToolOp) {
1588
- const ordered = [...members].sort((a, b) => {
1589
- const aTime = a.startedAt ?? a.timestamp ?? "";
1590
- const bTime = b.startedAt ?? b.timestamp ?? "";
1591
- return aTime.localeCompare(bTime);
1592
- });
1688
+ const ordered = sortByTime(members);
1593
1689
  let sawOk = false;
1594
1690
  let sawSideEffectOk = false;
1691
+ const prior = [];
1595
1692
  for (const event of ordered) {
1596
1693
  const name = resolveCanonicalToolName(event);
1597
- const isRetry = sawOk;
1598
- if (isRetry) {
1599
- if (rules.requireIdempotencyEvidenceForRetry && !hasIdempotencyEvidence(event)) {
1600
- findings.push(
1601
- fail2(
1602
- "contract.retry.idempotency-evidence",
1603
- `Retry of tool ${name} lacks idempotencyKey / noSideEffect evidence.`,
1604
- [eventEvidence(event)],
1605
- "idempotencyKey|noSideEffect",
1606
- { code: "AI_CHECK_RETRY_EVIDENCE_UNAVAILABLE" }
1607
- )
1608
- );
1609
- }
1610
- if (nonIdempotent.has(name) && sawSideEffectOk && !hasIdempotencyEvidence(event)) {
1611
- findings.push(
1612
- fail2(
1613
- "contract.retry.non-idempotent-side-effect",
1614
- `Retry of non-idempotent tool ${name} after a confirmed ok side effect.`,
1615
- [eventEvidence(event)],
1616
- "no retry after side effect",
1617
- name
1618
- )
1619
- );
1620
- }
1694
+ const classification = classifyRetryAttempt(event, prior, members);
1695
+ if (classification.kind === "unknown" && rules.requireIdempotencyEvidenceForRetry) {
1696
+ findings.push(
1697
+ fail2(
1698
+ "contract.retry.ambiguous-identity",
1699
+ `Ambiguous attempt identity for tool ${name}; strict retry evidence cannot pass.`,
1700
+ [eventEvidence(event)],
1701
+ "explicit attempt identity",
1702
+ classification.reason
1703
+ )
1704
+ );
1705
+ }
1706
+ const isRetry = classification.kind === "retry";
1707
+ if (isRetry && rules.requireIdempotencyEvidenceForRetry && !hasIdempotencyEvidence(event)) {
1708
+ findings.push(
1709
+ fail2(
1710
+ "contract.retry.idempotency-evidence",
1711
+ `Retry of tool ${name} lacks idempotencyKey / noSideEffect evidence.`,
1712
+ [eventEvidence(event)],
1713
+ "idempotencyKey|noSideEffect",
1714
+ { code: "AI_CHECK_RETRY_EVIDENCE_UNAVAILABLE" }
1715
+ )
1716
+ );
1717
+ }
1718
+ if (sawOk && nonIdempotent.has(name) && sawSideEffectOk && !hasIdempotencyEvidence(event)) {
1719
+ findings.push(
1720
+ fail2(
1721
+ "contract.retry.non-idempotent-side-effect",
1722
+ `Retry of non-idempotent tool ${name} after a confirmed ok side effect.`,
1723
+ [eventEvidence(event)],
1724
+ "no retry after side effect",
1725
+ name
1726
+ )
1727
+ );
1621
1728
  }
1622
1729
  if (event.status === "ok") {
1623
1730
  sawOk = true;
@@ -1625,25 +1732,43 @@ function evaluateRetrySafetyRules(events, rules, _runEvidence) {
1625
1732
  sawSideEffectOk = true;
1626
1733
  }
1627
1734
  }
1735
+ prior.push(event);
1628
1736
  }
1629
1737
  }
1630
1738
  }
1631
1739
  if (rules.requireRecoveredFailureVisible) {
1632
1740
  for (const [operationId, members] of byOperation) {
1633
- const hasOk = members.some((event) => event.status === "ok");
1634
- const hasError = members.some((event) => event.status === "error");
1635
- const attemptish = members.some((event) => {
1741
+ const ordered = sortByTime(members);
1742
+ const toolish = ordered.filter(
1743
+ (event) => (event.kind === "TOOL" || event.kind === "LLM") && (event.status === "ok" || event.status === "error")
1744
+ );
1745
+ if (toolish.length < 2) continue;
1746
+ let sawEarlierError = false;
1747
+ let sawLaterOkAfterError = false;
1748
+ for (const event of toolish) {
1749
+ if (event.status === "error") {
1750
+ sawEarlierError = true;
1751
+ } else if (event.status === "ok" && sawEarlierError) {
1752
+ sawLaterOkAfterError = true;
1753
+ }
1754
+ }
1755
+ const hasOk = toolish.some((event) => event.status === "ok");
1756
+ const attemptish = toolish.some((event) => {
1636
1757
  const meta = workflowFor(event);
1637
- return meta.attemptId !== void 0 || meta.attemptNumber !== void 0 || meta.attempt !== void 0;
1638
- }) || members.length > 1;
1639
- if (hasOk && attemptish && !hasError) {
1758
+ return meta.attemptId !== void 0 || meta.attemptNumber !== void 0 || meta.attempt !== void 0 || meta.retryOf !== void 0;
1759
+ }) || toolish.length > 1;
1760
+ if (!attemptish || !hasOk) continue;
1761
+ if (!sawLaterOkAfterError) {
1640
1762
  findings.push(
1641
1763
  fail2(
1642
1764
  "contract.retry.recovered-failure-visible",
1643
- `Operation ${operationId} recovered without retaining a visible failure attempt.`,
1765
+ `Operation ${operationId} does not show an earlier error attempt before a later success in the same chain.`,
1644
1766
  members.slice(0, 4).map(eventEvidence),
1645
- "error attempt retained",
1646
- { hasOk, hasError }
1767
+ "earlier error then later ok",
1768
+ {
1769
+ sawEarlierError,
1770
+ statuses: toolish.map((event) => event.status)
1771
+ }
1647
1772
  )
1648
1773
  );
1649
1774
  }