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.
- agentloop/__init__.py +136 -0
- agentloop/ablation_protocol.py +229 -0
- agentloop/ablations.py +564 -0
- agentloop/aggregate_cli.py +88 -0
- agentloop/aggregate_stats.py +175 -0
- agentloop/aggregates.py +532 -0
- agentloop/audit.py +39 -0
- agentloop/autoinstrument.py +145 -0
- agentloop/batch_experiments.py +295 -0
- agentloop/batching.py +95 -0
- agentloop/budget_types.py +335 -0
- agentloop/budgets.py +539 -0
- agentloop/calibration.py +872 -0
- agentloop/checkpoint_state.py +306 -0
- agentloop/checkpoint_store.py +86 -0
- agentloop/ci.py +202 -0
- agentloop/cli.py +1314 -0
- agentloop/client.py +228 -0
- agentloop/completion.py +345 -0
- agentloop/completion_types.py +199 -0
- agentloop/config.py +50 -0
- agentloop/context_controls.py +443 -0
- agentloop/context_experiments.py +337 -0
- agentloop/context_types.py +216 -0
- agentloop/costs.py +667 -0
- agentloop/cursor_validation.py +48 -0
- agentloop/decorators.py +445 -0
- agentloop/demo.py +300 -0
- agentloop/doctor.py +341 -0
- agentloop/drift.py +361 -0
- agentloop/drift_cli.py +147 -0
- agentloop/drift_types.py +110 -0
- agentloop/drift_windows.py +340 -0
- agentloop/entrypoint.py +201 -0
- agentloop/estimates.py +133 -0
- agentloop/events.py +75 -0
- agentloop/experiment_artifacts.py +80 -0
- agentloop/experiment_observations.py +169 -0
- agentloop/experiment_reports.py +545 -0
- agentloop/experiment_types.py +355 -0
- agentloop/experiments.py +392 -0
- agentloop/exporters.py +117 -0
- agentloop/finding_benchmark_types.py +131 -0
- agentloop/finding_benchmarks.py +424 -0
- agentloop/findings.py +419 -0
- agentloop/graph.py +464 -0
- agentloop/harness.py +825 -0
- agentloop/harness_bench/__init__.py +1 -0
- agentloop/harness_bench/cli.py +77 -0
- agentloop/harness_bench/drivers.py +96 -0
- agentloop/harness_bench/offline.py +507 -0
- agentloop/harness_evidence.py +585 -0
- agentloop/harness_usage.py +91 -0
- agentloop/html_report.py +860 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/harbor/__init__.py +1 -0
- agentloop/integrations/harbor/atif.py +982 -0
- agentloop/integrations/harbor/atif_validation.py +329 -0
- agentloop/integrations/harbor/cli.py +207 -0
- agentloop/integrations/harbor/manifest.py +86 -0
- agentloop/integrations/harbor/trial_evidence.py +196 -0
- agentloop/integrations/harbor/trials.py +1109 -0
- agentloop/integrations/harbor/verifier.py +118 -0
- agentloop/integrations/langgraph.py +150 -0
- agentloop/integrations/langgraph_harness.py +466 -0
- agentloop/integrations/langgraph_recovery.py +366 -0
- agentloop/integrations/omnigent/__init__.py +1 -0
- agentloop/integrations/omnigent/cli.py +78 -0
- agentloop/integrations/omnigent/evidence.py +93 -0
- agentloop/integrations/omnigent/telemetry.py +422 -0
- agentloop/integrations/openai.py +415 -0
- agentloop/integrations/openai_agents.py +291 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/interoperability/__init__.py +5 -0
- agentloop/interoperability/artifacts.py +112 -0
- agentloop/interoperability/cli.py +59 -0
- agentloop/interoperability/cohort_evidence.py +175 -0
- agentloop/interoperability/cohorts.py +681 -0
- agentloop/interoperability/contracts.py +427 -0
- agentloop/interoperability/coordination.py +554 -0
- agentloop/interoperability/evidence.py +215 -0
- agentloop/interoperability/otlp_jsonl.py +775 -0
- agentloop/interoperability/otlp_validation.py +496 -0
- agentloop/interoperability/registry.py +561 -0
- agentloop/interoperability/validation.py +333 -0
- agentloop/intervention_service.py +110 -0
- agentloop/intervention_store.py +118 -0
- agentloop/interventions.py +190 -0
- agentloop/issues.py +90 -0
- agentloop/judgment_adapters.py +125 -0
- agentloop/judgment_benchmark_types.py +98 -0
- agentloop/judgment_benchmarks.py +606 -0
- agentloop/judgment_types.py +241 -0
- agentloop/judgment_views.py +38 -0
- agentloop/judgments.py +342 -0
- agentloop/loop_guards.py +274 -0
- agentloop/loop_types.py +66 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +342 -0
- agentloop/migrations.py +303 -0
- agentloop/model_routing.py +470 -0
- agentloop/onboarding.py +315 -0
- agentloop/operations.py +44 -0
- agentloop/optimizer.py +199 -0
- agentloop/otel.py +715 -0
- agentloop/otel_ids.py +81 -0
- agentloop/otel_semantics.py +134 -0
- agentloop/parallelism.py +141 -0
- agentloop/patches.py +603 -0
- agentloop/plan_export.py +126 -0
- agentloop/policy_promotion.py +325 -0
- agentloop/promotion_evidence.py +304 -0
- agentloop/promotion_types.py +209 -0
- agentloop/quality.py +446 -0
- agentloop/ranking.py +529 -0
- agentloop/recovery_evidence.py +96 -0
- agentloop/replay.py +753 -0
- agentloop/retention.py +375 -0
- agentloop/retention_types.py +113 -0
- agentloop/reuse_experiments.py +247 -0
- agentloop/routing_checks.py +116 -0
- agentloop/routing_types.py +241 -0
- agentloop/rules.py +396 -0
- agentloop/runtime.py +286 -0
- agentloop/savings.py +158 -0
- agentloop/scheduling_plan.py +99 -0
- agentloop/scheduling_types.py +169 -0
- agentloop/schema.py +257 -0
- agentloop/semantic_rules.py +200 -0
- agentloop/semantic_waste.py +345 -0
- agentloop/semantic_waste_types.py +88 -0
- agentloop/server.py +494 -0
- agentloop/store.py +1429 -0
- agentloop/structural_experiments.py +431 -0
- agentloop/structural_types.py +113 -0
- agentloop/structured_quality.py +1020 -0
- agentloop/studies.py +629 -0
- agentloop/study_statistics.py +58 -0
- agentloop/substitution_evidence.py +199 -0
- agentloop/substitution_export.py +190 -0
- agentloop/substitution_reports.py +407 -0
- agentloop/substitution_types.py +284 -0
- agentloop/substitutions.py +267 -0
- agentloop/timing.py +142 -0
- agentloop/tokens.py +188 -0
- agentloop/tool_scheduling.py +410 -0
- agentloop/trace_sources.py +31 -0
- agentloop/tracer.py +494 -0
- agentloop/value.py +306 -0
- agentloop/version.py +3 -0
- agentloop/workflow_types.py +264 -0
- agentloop/workflows.py +284 -0
- agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
- agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
- agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
- agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +5 -0
- dashboard/app.py +922 -0
- dashboard/streamlit_compat.py +63 -0
- dashboard/trace_upload.py +57 -0
- dashboard/value_view.py +105 -0
agentloop/plan_export.py
ADDED
|
@@ -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))
|