agentloop-profiler 0.1.0__py3-none-any.whl

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 (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from agentloop.costs import format_cost_usd
8
+ from agentloop.estimates import estimate_markdown
9
+ from agentloop.markdown import (
10
+ markdown_code_span,
11
+ markdown_heading,
12
+ markdown_table_cell,
13
+ markdown_text,
14
+ )
15
+ from agentloop.timing import format_duration_ms
16
+
17
+
18
+ def export_optimization_json(plan: dict[str, Any], path: str | Path) -> Path:
19
+ out = Path(path)
20
+ out.parent.mkdir(parents=True, exist_ok=True)
21
+ out.write_text(json.dumps(plan, indent=2), encoding="utf-8")
22
+ return out
23
+
24
+
25
+ def export_optimization_markdown(plan: dict[str, Any], path: str | Path) -> Path:
26
+ out = Path(path)
27
+ out.parent.mkdir(parents=True, exist_ok=True)
28
+ current = plan["current"]
29
+ after = plan["estimated_after"]
30
+ lines = [
31
+ f"# AgentLoop Optimization Plan: {markdown_heading(plan['name'])}",
32
+ "",
33
+ f"- Run ID: {markdown_code_span(plan['run_id'])}",
34
+ f"- Current runtime: {format_duration_ms(current['runtime_ms'])}",
35
+ f"- Estimated optimized runtime: {format_duration_ms(after['runtime_ms'])}",
36
+ "- Estimated latency reduction: "
37
+ + (
38
+ "unavailable"
39
+ if after["latency_reduction_pct"] is None
40
+ else f"{after['latency_reduction_pct']:.2f}%"
41
+ ),
42
+ "- Current cost: "
43
+ + format_cost_usd(current.get("estimated_cost_usd"), current.get("cost_status")),
44
+ "- Estimated optimized cost: "
45
+ + format_cost_usd(after.get("estimated_cost_usd"), after.get("cost_status")),
46
+ "- Estimated cost reduction: "
47
+ + (
48
+ "unavailable"
49
+ if after.get("cost_reduction_pct") is None
50
+ else f"{after['cost_reduction_pct']:.2f}%"
51
+ ),
52
+ "- Repeated context ratio: "
53
+ + (
54
+ "unavailable"
55
+ if current["repeated_context_ratio"] is None
56
+ else f"{current['repeated_context_ratio']:.1%}"
57
+ ),
58
+ f"- Retry count: {current['retry_count']}",
59
+ "",
60
+ "## Optimization cards",
61
+ "",
62
+ ]
63
+ cards = plan.get("optimization_cards", [])
64
+ if (
65
+ plan.get("savings_aggregation", {}).get("latency_estimate_complete") is False
66
+ or plan.get("savings_aggregation", {}).get("cost_estimate_complete") is False
67
+ ):
68
+ lines.extend(["Some savings are unavailable; totals cover modeled candidates only.", ""])
69
+ if plan.get("rule_errors"):
70
+ lines.extend(["Analysis incomplete; some finding rules failed:", ""])
71
+ for error in plan["rule_errors"]:
72
+ lines.append(
73
+ f"- {markdown_code_span(error['rule_id'])}: {markdown_text(error['message'])}"
74
+ )
75
+ lines.append("")
76
+ if not cards:
77
+ lines.append(
78
+ "No major optimization opportunities detected yet. Collect more traces for stronger recommendations."
79
+ )
80
+ for index, card in enumerate(cards, start=1):
81
+ lines.extend(
82
+ [
83
+ f"### {index}. {markdown_heading(card['title'])}",
84
+ "",
85
+ f"- Type: {markdown_code_span(card['type'])}",
86
+ f"- Confidence: {markdown_text(card['confidence'])}",
87
+ f"- Why: {markdown_text(card['why'])}",
88
+ f"- Rewrite hint: {markdown_text(card['rewrite_hint'])}",
89
+ f"- Estimated latency savings: {format_duration_ms(card['estimated_latency_savings_ms'])}",
90
+ "- Estimated cost savings: "
91
+ + format_cost_usd(card.get("estimated_cost_savings_usd")),
92
+ "",
93
+ ]
94
+ )
95
+ if card.get("rule_id"):
96
+ lines.append(
97
+ f"- Rule: {markdown_code_span(card['rule_id'])} version {markdown_text(card['rule_version'])}"
98
+ )
99
+ if card.get("evidence_level") and card.get("estimate"):
100
+ lines.append(f"- Evidence level: {markdown_text(card['evidence_level'])}")
101
+ lines.extend(estimate_markdown(card.get("estimate")))
102
+ if card.get("evidence_level") and not card.get("estimate"):
103
+ lines.extend(
104
+ [
105
+ f"- Evidence level: {markdown_text(card['evidence_level'])}",
106
+ "- Assumptions: "
107
+ + "; ".join(markdown_text(value) for value in card.get("assumptions", [])),
108
+ f"- Savings formula: {markdown_text(card.get('estimate_formula', ''))}",
109
+ "",
110
+ ]
111
+ )
112
+ lines.extend(
113
+ ["## Bottlenecks", "", "| Name | Type | Duration | Runtime share |", "|---|---|---:|---:|"]
114
+ )
115
+ for item in plan.get("graph", {}).get("bottlenecks", []):
116
+ lines.append(
117
+ f"| {markdown_table_cell(item['name'])} | "
118
+ f"{markdown_table_cell(item['event_type'])} | "
119
+ f"{item['duration_ms'] / 1000:.2f}s | {item['runtime_share']:.1%} |"
120
+ )
121
+ if "semantic_waste" in plan:
122
+ from agentloop.semantic_waste import semantic_waste_markdown
123
+
124
+ lines.extend(semantic_waste_markdown(plan["semantic_waste"]))
125
+ out.write_text("\n".join(lines) + "\n", encoding="utf-8")
126
+ return out
@@ -0,0 +1,325 @@
1
+ """Explicit operator-reviewed, bounded budget canaries with local rollback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass, field
7
+ from datetime import timedelta
8
+ from threading import Event, RLock
9
+ from uuid import uuid4
10
+
11
+ from agentloop.ablation_protocol import number, timestamp
12
+ from agentloop.budgets import BudgetLimits, budget_policy
13
+ from agentloop.context_types import identifier
14
+ from agentloop.harness import Decision, Harness, HarnessConfig, HarnessControlError, Hook, Policy
15
+ from agentloop.harness_evidence import HarnessDecisionRecord, append_records, empty_evidence
16
+ from agentloop.promotion_evidence import assess_promotion
17
+ from agentloop.promotion_types import BudgetProposal, OperatorApproval, utc_now
18
+ from agentloop.tracer import current_trace
19
+
20
+ PROMOTION_KEY = "agentloop.policy_promotion"
21
+ _TRACE_LOCK = RLock()
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CanaryLease:
26
+ lease_id: str
27
+ configuration: HarnessConfig = field(repr=False)
28
+ candidate: bool
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CanaryOutcome:
33
+ status: str
34
+ quality_score: float | None
35
+ latency_ms: float | None
36
+ cost_usd: float | None
37
+ cost_known: bool
38
+
39
+ def __post_init__(self):
40
+ if self.status not in {"completed", "failed", "cancelled", "stopped", "incomplete"}:
41
+ raise ValueError("unsupported canary outcome")
42
+ for name in ("quality_score", "latency_ms", "cost_usd"):
43
+ number(
44
+ getattr(self, name),
45
+ name,
46
+ nullable=True,
47
+ maximum=1 if name == "quality_score" else None,
48
+ )
49
+ if type(self.cost_known) is not bool:
50
+ raise ValueError("cost provenance must be explicit")
51
+
52
+
53
+ class BudgetPromotion:
54
+ """One reviewed proposal; never learns or silently expands rollout scope.
55
+
56
+ The trusted host authenticates operator attestations, applies each lease's
57
+ configuration to real work, reports outcomes and calls tick during idle time.
58
+ A kill switch/rollback changes future admission, never kills in-flight work.
59
+ """
60
+
61
+ def __init__(self, proposal, *, baseline_review_ref, clock=utc_now):
62
+ if type(proposal) is not BudgetProposal:
63
+ raise ValueError("BudgetProposal required")
64
+ identifier(baseline_review_ref, "baseline_review_ref")
65
+ if not callable(clock):
66
+ raise ValueError("clock must be callable")
67
+ self._proposal, self._data, self._clock = proposal, proposal.to_dict(), clock
68
+ self._baseline = HarnessConfig("disabled")
69
+ self._candidate = HarnessConfig(
70
+ "enforce", (budget_policy(BudgetLimits(**self._data["candidate"])),)
71
+ )
72
+ self._versions = self._data["protocol"]["specification"]["versions"]
73
+ self._workload = self._data["protocol"]["specification"]["workload_id"]
74
+ self._state, self._approval, self._started = "candidate", None, None
75
+ self._last_time = None
76
+ self._leases, self._outcomes, self._records = {}, {}, []
77
+ self._native = empty_evidence()
78
+ self._lock = RLock()
79
+ self._kill = Event()
80
+ self._baseline_review_ref = baseline_review_ref
81
+ self._record("candidate_registered", "continue")
82
+
83
+ @property
84
+ def state(self):
85
+ with self._lock:
86
+ return self._state
87
+
88
+ @property
89
+ def kill_switch(self):
90
+ return self._kill
91
+
92
+ def export_evidence(self):
93
+ with self._lock:
94
+ return {
95
+ "schema_version": "1.0",
96
+ "proposal_hash": self._proposal.proposal_hash,
97
+ "state": self._state,
98
+ "admitted": len(self._leases),
99
+ "reported": len(self._outcomes),
100
+ "missing": len(self._leases) - len(self._outcomes),
101
+ "outcomes": deepcopy(self._outcomes),
102
+ "records": deepcopy(self._records),
103
+ "native_evidence": deepcopy(self._native),
104
+ }
105
+
106
+ def shadow_configuration(self):
107
+ with self._lock:
108
+ if self._state != "candidate":
109
+ raise ValueError("shadow transition requires a new candidate")
110
+ self._state = "shadow"
111
+ self._record("candidate_shadowed", "continue")
112
+ return HarnessConfig("shadow", self._candidate.policies)
113
+
114
+ def review(self, approval):
115
+ if type(approval) is not OperatorApproval:
116
+ raise ValueError("explicit OperatorApproval required")
117
+ with self._lock:
118
+ if self._state != "shadow":
119
+ raise ValueError("operator review follows explicit shadow configuration")
120
+ now = self._now()
121
+ assessment = assess_promotion(self._proposal, now=now.isoformat())
122
+ approval_reasons = []
123
+ if not approval.approved:
124
+ approval_reasons.append("operator_rejected")
125
+ if approval.proposal_hash != self._proposal.proposal_hash:
126
+ approval_reasons.append("approval_proposal_mismatch")
127
+ if not timestamp(approval.approved_at) <= now < timestamp(approval.valid_until):
128
+ approval_reasons.append("approval_expired_or_future")
129
+ if assessment["eligible"]:
130
+ latest_evidence = max(
131
+ timestamp(self._data["capability"]["tested_at"]),
132
+ timestamp(self._data["calibration"]["validity"]["as_of"]),
133
+ *(timestamp(row["started_at"]) for row in self._data["observations"]),
134
+ )
135
+ if timestamp(approval.approved_at) < latest_evidence:
136
+ approval_reasons.append("approval_predates_evidence")
137
+ valid = not approval_reasons
138
+ assessment.update(operator_approved=valid, approval_reasons=approval_reasons)
139
+ if not valid or not assessment["eligible"]:
140
+ self._state = "rejected"
141
+ self._record(
142
+ "operator_rejected" if not approval.approved else "promotion_ineligible",
143
+ "escalate",
144
+ reasons=assessment["reasons"] + approval_reasons,
145
+ )
146
+ assessment["state"] = self._state
147
+ return assessment
148
+ self._approval, self._state = approval, "approved"
149
+ age = timedelta(days=self._data["thresholds"]["max_age_days"])
150
+ self._eligibility_until = min(
151
+ timestamp(approval.valid_until),
152
+ timestamp(self._data["valid_until"]),
153
+ timestamp(self._data["calibration"]["validity"]["valid_until"]),
154
+ timestamp(self._data["protocol"]["specification"]["frozen_at"]) + age,
155
+ timestamp(self._data["capability"]["tested_at"]) + age,
156
+ timestamp(self._data["calibration"]["validity"]["as_of"]) + age,
157
+ )
158
+ self._record("canary_approved", "continue", review_ref=approval.review_ref)
159
+ assessment["state"] = self._state
160
+ return assessment
161
+
162
+ def begin_canary(self):
163
+ with self._lock:
164
+ if self._state != "approved":
165
+ raise ValueError("explicit eligible approval required before canary")
166
+ self._started = self._now()
167
+ self._state = "canary"
168
+ self._check(self._started)
169
+ if self._state == "canary":
170
+ self._record("canary_started", "continue")
171
+
172
+ def admit(self, *, scope, versions, workload_id, opted_in=False):
173
+ identifier(scope, "scope")
174
+ if type(opted_in) is not bool:
175
+ raise ValueError("rollout opt-in must be explicit")
176
+ with self._lock:
177
+ self._check(self._now())
178
+ if self._state != "canary" or not opted_in or scope not in self._approval.scopes:
179
+ return CanaryLease("baseline_" + uuid4().hex, self._baseline, False)
180
+ if versions != self._versions or workload_id != self._workload:
181
+ self._rollback("runtime_versions_changed")
182
+ return CanaryLease("baseline_" + uuid4().hex, self._baseline, False)
183
+ if len(self._leases) >= self._data["canary"]["max_admissions"]:
184
+ return CanaryLease("baseline_" + uuid4().hex, self._baseline, False)
185
+ lease = CanaryLease("canary_" + uuid4().hex, self._candidate, True)
186
+ self._leases[lease.lease_id] = lease
187
+ self._record("canary_admitted", "continue", lease_id=lease.lease_id, scope=scope)
188
+ return lease
189
+
190
+ def report(self, lease, outcome):
191
+ if type(lease) is not CanaryLease or type(outcome) is not CanaryOutcome:
192
+ raise ValueError("typed lease and outcome required")
193
+ with self._lock:
194
+ if self._leases.get(lease.lease_id) is not lease or lease.lease_id in self._outcomes:
195
+ raise ValueError("canary lease is unknown or already reported")
196
+ self._check(self._now())
197
+ self._outcomes[lease.lease_id] = {
198
+ "status": outcome.status,
199
+ "quality_score": outcome.quality_score,
200
+ "latency_ms": outcome.latency_ms,
201
+ "cost_usd": outcome.cost_usd,
202
+ "cost_known": outcome.cost_known,
203
+ }
204
+ limits = self._data["canary"]
205
+ passed = (
206
+ outcome.status == "completed"
207
+ and outcome.quality_score is not None
208
+ and outcome.quality_score >= limits["min_quality_score"]
209
+ and outcome.latency_ms is not None
210
+ and outcome.latency_ms <= limits["max_latency_ms"]
211
+ and outcome.cost_known
212
+ and outcome.cost_usd is not None
213
+ and outcome.cost_usd <= limits["max_cost_usd"]
214
+ )
215
+ self._record(
216
+ "canary_outcome_passed" if passed else "canary_outcome_failed",
217
+ "continue" if passed else "escalate",
218
+ lease_id=lease.lease_id,
219
+ )
220
+ if not passed:
221
+ self._rollback("canary_regression_or_missing_measurement")
222
+ elif self._state == "canary" and len(self._outcomes) == limits["max_admissions"]:
223
+ self._state = "canary_passed"
224
+ self._record("canary_finished_requires_review", "continue")
225
+
226
+ def tick(self):
227
+ with self._lock:
228
+ self._check(self._now())
229
+ return self._state
230
+
231
+ def rollback(self):
232
+ with self._lock:
233
+ self._rollback("operator_rollback")
234
+
235
+ def _now(self):
236
+ try:
237
+ now = timestamp(self._clock())
238
+ except BaseException:
239
+ self._rollback("clock_unavailable")
240
+ raise
241
+ if self._last_time is not None and now < self._last_time:
242
+ self._rollback("clock_moved_backwards")
243
+ raise ValueError("promotion clock moved backwards")
244
+ self._last_time = now
245
+ return now
246
+
247
+ def _check(self, now):
248
+ if self._state not in {"approved", "canary"}:
249
+ return
250
+ if self._kill.is_set():
251
+ self._rollback("kill_switch")
252
+ elif now >= self._eligibility_until:
253
+ self._rollback("approval_or_evidence_expired")
254
+ elif (
255
+ self._started is not None
256
+ and (now - self._started).total_seconds() >= self._data["canary"]["max_duration_s"]
257
+ ):
258
+ self._rollback("canary_deadline_or_missing_outcomes")
259
+
260
+ def _rollback(self, reason):
261
+ if self._state == "rolled_back":
262
+ return
263
+ self._state = "rolled_back"
264
+ self._record(
265
+ reason,
266
+ "escalate",
267
+ rollback_config_hash=self._baseline.config_hash,
268
+ baseline_review_ref=self._baseline_review_ref,
269
+ )
270
+
271
+ def _record(self, reason, action, **details):
272
+ entry = {
273
+ "sequence": len(self._records),
274
+ "reason": reason,
275
+ "action": action,
276
+ "state": self._state,
277
+ "proposal_hash": self._proposal.proposal_hash,
278
+ "policy_config_hash": self._data["policy_config_hash"],
279
+ "details": details,
280
+ }
281
+ if len(self._records) >= 3008:
282
+ self._state = "rolled_back"
283
+ raise ValueError("promotion evidence limit")
284
+ policy = Policy(
285
+ "agentloop.promotion",
286
+ "1.0",
287
+ lambda context: Decision(
288
+ action, reason, ("proposal:sha256:" + self._proposal.proposal_hash,)
289
+ ),
290
+ hooks=frozenset({Hook("completion")}),
291
+ actions=frozenset({"continue", "escalate"}),
292
+ )
293
+ audit = Harness(HarnessConfig("enforce", (policy,))).start_run(
294
+ "promotion_audit_" + uuid4().hex
295
+ )
296
+ try:
297
+ audit.wrap(lambda: None, boundary="completion", branch_id="promotion")()
298
+ except HarnessControlError:
299
+ pass
300
+ except BaseException:
301
+ self._state = "rolled_back"
302
+ raise
303
+ native = audit.export_evidence()
304
+ append_records(
305
+ self._native,
306
+ tuple(HarnessDecisionRecord.from_dict(item) for item in native["decisions"].values()),
307
+ native["policies"],
308
+ )
309
+ entry.update(audit_run_id=audit.run_id, audit_call_id=audit.results[0].call_id)
310
+ self._records.append(entry)
311
+ trace = current_trace()
312
+ if trace is not None:
313
+ with _TRACE_LOCK:
314
+ envelope = trace.metadata.setdefault(
315
+ PROMOTION_KEY, {"schema_version": "1.0", "records": []}
316
+ )
317
+ if (
318
+ not isinstance(envelope, dict)
319
+ or envelope.get("schema_version") != "1.0"
320
+ or not isinstance(envelope.get("records"), list)
321
+ or len(envelope["records"]) >= 10000
322
+ ):
323
+ self._state = "rolled_back"
324
+ raise ValueError("invalid promotion trace evidence")
325
+ envelope["records"].append(deepcopy(entry))