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/demo.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from agentloop import trace_agent, trace_model_call, trace_retry, trace_tool_call
|
|
8
|
+
|
|
9
|
+
# Every model call below declares both token counts explicitly. These traces are
|
|
10
|
+
# synthetic fixtures, so their counts are declared fixture values the way a real
|
|
11
|
+
# integration declares provider-reported usage — not measurements of the prompt
|
|
12
|
+
# text. Leaving a count off would make AgentLoop fall back to a whitespace word
|
|
13
|
+
# estimate, which records `estimated_words` provenance and correctly turns the
|
|
14
|
+
# demo's cost gates indeterminate (issue #133).
|
|
15
|
+
SYSTEM_LONG = (
|
|
16
|
+
"You are a careful research agent. Use evidence, verify claims, and write concise reports. "
|
|
17
|
+
* 20
|
|
18
|
+
)
|
|
19
|
+
SYSTEM_SHORT = (
|
|
20
|
+
"You are a careful research agent. Use evidence, verify claims, and write concise reports. " * 4
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _demo_metadata(**values: object) -> dict[str, object]:
|
|
25
|
+
metadata: dict[str, object] = {"synthetic": True, "source": "agentloop_demo"}
|
|
26
|
+
metadata.update(values)
|
|
27
|
+
return metadata
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sleep_ms(ms: int) -> None:
|
|
31
|
+
time.sleep(ms / 1000)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fake_search(query: str) -> list[str]:
|
|
35
|
+
sleep_ms(300)
|
|
36
|
+
return [f"source-{i}-{query}" for i in range(1, 6)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fake_read(source: str) -> str:
|
|
40
|
+
sleep_ms(250)
|
|
41
|
+
return f"Content from {source}. " * 30
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def inspect_result(result: str) -> str:
|
|
45
|
+
sleep_ms(120)
|
|
46
|
+
return f"inspected {result}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_baseline(out_dir: str | Path = "runs") -> Path:
|
|
50
|
+
out_path = Path(out_dir) / "research_agent_baseline.json"
|
|
51
|
+
with trace_agent("research_agent_baseline", metadata=_demo_metadata()) as trace:
|
|
52
|
+
with trace_model_call(
|
|
53
|
+
"plan",
|
|
54
|
+
model="gpt-4.1",
|
|
55
|
+
input_text=SYSTEM_LONG + "Plan research on agent inference runtimes.",
|
|
56
|
+
input_tokens=286,
|
|
57
|
+
output_tokens=220,
|
|
58
|
+
):
|
|
59
|
+
sleep_ms(400)
|
|
60
|
+
|
|
61
|
+
with trace_tool_call("search_web", metadata={"query": "agent inference runtime"}):
|
|
62
|
+
sources = fake_search("agent inference runtime")
|
|
63
|
+
|
|
64
|
+
summaries = []
|
|
65
|
+
for source in sources:
|
|
66
|
+
with trace_tool_call("read_source", metadata={"source": source}):
|
|
67
|
+
content = fake_read(source)
|
|
68
|
+
with trace_model_call(
|
|
69
|
+
"summarize_source",
|
|
70
|
+
model="gpt-4.1",
|
|
71
|
+
input_text=SYSTEM_LONG + content,
|
|
72
|
+
input_tokens=430,
|
|
73
|
+
output_tokens=350,
|
|
74
|
+
):
|
|
75
|
+
sleep_ms(350)
|
|
76
|
+
summaries.append("summary for " + source)
|
|
77
|
+
|
|
78
|
+
with trace_retry("parse_failed_then_retry", metadata={"reason": "invalid json"}):
|
|
79
|
+
sleep_ms(250)
|
|
80
|
+
|
|
81
|
+
with trace_model_call(
|
|
82
|
+
"verify_claims",
|
|
83
|
+
model="gpt-4.1",
|
|
84
|
+
input_text=SYSTEM_LONG + "\n".join(summaries),
|
|
85
|
+
input_tokens=305,
|
|
86
|
+
output_tokens=450,
|
|
87
|
+
):
|
|
88
|
+
sleep_ms(450)
|
|
89
|
+
|
|
90
|
+
with trace_model_call(
|
|
91
|
+
"write_report",
|
|
92
|
+
model="gpt-4.1",
|
|
93
|
+
input_text=SYSTEM_LONG + "\n".join(summaries),
|
|
94
|
+
input_tokens=305,
|
|
95
|
+
output_tokens=800,
|
|
96
|
+
):
|
|
97
|
+
sleep_ms(550)
|
|
98
|
+
|
|
99
|
+
trace.export_json(out_path)
|
|
100
|
+
trace.print_report()
|
|
101
|
+
return out_path
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_proof_pair(out_dir: str | Path = "runs") -> tuple[Path, Path]:
|
|
105
|
+
"""Generate a before/after trace pair for the closed-loop product demo."""
|
|
106
|
+
|
|
107
|
+
return run_proof_baseline(out_dir), run_proof_candidate(out_dir)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def run_proof_baseline(out_dir: str | Path = "runs") -> Path:
|
|
111
|
+
out_path = Path(out_dir) / "agentloop_proof_baseline.json"
|
|
112
|
+
repeated_context = SYSTEM_LONG + "Classify and summarize every source independently. " * 20
|
|
113
|
+
with trace_agent(
|
|
114
|
+
"agentloop_proof_baseline",
|
|
115
|
+
metadata=_demo_metadata(schema_valid=False, quality_score=0.82),
|
|
116
|
+
) as trace:
|
|
117
|
+
with trace_model_call(
|
|
118
|
+
"classify_intent",
|
|
119
|
+
model="gpt-4.1",
|
|
120
|
+
input_text="Classify this simple support request.",
|
|
121
|
+
input_tokens=5,
|
|
122
|
+
output_tokens=40,
|
|
123
|
+
):
|
|
124
|
+
sleep_ms(220)
|
|
125
|
+
|
|
126
|
+
with trace_tool_call("search_web", metadata={"query": "agent workflow profiler"}):
|
|
127
|
+
sources = fake_search("agent workflow profiler")[:3]
|
|
128
|
+
|
|
129
|
+
summaries = []
|
|
130
|
+
for source in sources:
|
|
131
|
+
with trace_tool_call("fake_read", metadata={"source": source}):
|
|
132
|
+
fake_read(source)
|
|
133
|
+
|
|
134
|
+
for index in range(15):
|
|
135
|
+
with trace_model_call(
|
|
136
|
+
"summarize_source",
|
|
137
|
+
model="gpt-4.1-mini",
|
|
138
|
+
input_text=repeated_context + f" item {index}",
|
|
139
|
+
input_tokens=80,
|
|
140
|
+
output_tokens=60,
|
|
141
|
+
):
|
|
142
|
+
sleep_ms(12)
|
|
143
|
+
summaries.append(f"summary {index}")
|
|
144
|
+
|
|
145
|
+
for index in range(8):
|
|
146
|
+
with trace_tool_call("plan_next", metadata={"iteration": index}):
|
|
147
|
+
sleep_ms(60)
|
|
148
|
+
|
|
149
|
+
for name in ["search_web", "inspect_result", "search_web", "inspect_result"]:
|
|
150
|
+
with trace_tool_call(name):
|
|
151
|
+
if name == "search_web":
|
|
152
|
+
fake_search("same query")
|
|
153
|
+
else:
|
|
154
|
+
inspect_result("same result")
|
|
155
|
+
|
|
156
|
+
with trace_retry("invalid_json_retry", metadata={"reason": "schema_validation_failed"}):
|
|
157
|
+
sleep_ms(180)
|
|
158
|
+
|
|
159
|
+
large_context = repeated_context + "\n".join(summaries) + (" Evidence chunk. " * 1600)
|
|
160
|
+
with trace_model_call(
|
|
161
|
+
"final_answer",
|
|
162
|
+
model="gpt-4.1",
|
|
163
|
+
input_text=large_context,
|
|
164
|
+
input_tokens=4000,
|
|
165
|
+
output_tokens=700,
|
|
166
|
+
):
|
|
167
|
+
sleep_ms(500)
|
|
168
|
+
|
|
169
|
+
trace.export_json(out_path)
|
|
170
|
+
trace.print_report()
|
|
171
|
+
return out_path
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def run_proof_candidate(out_dir: str | Path = "runs") -> Path:
|
|
175
|
+
out_path = Path(out_dir) / "agentloop_proof_candidate.json"
|
|
176
|
+
compact_context = (
|
|
177
|
+
SYSTEM_SHORT + "Use cached instructions, batch summaries, and stop after convergence."
|
|
178
|
+
)
|
|
179
|
+
with trace_agent(
|
|
180
|
+
"agentloop_proof_candidate",
|
|
181
|
+
metadata=_demo_metadata(schema_valid=True, quality_score=0.94),
|
|
182
|
+
) as trace:
|
|
183
|
+
with trace_model_call(
|
|
184
|
+
"classify_intent",
|
|
185
|
+
model="gpt-4.1-mini",
|
|
186
|
+
input_text="Classify this simple support request.",
|
|
187
|
+
input_tokens=5,
|
|
188
|
+
output_tokens=30,
|
|
189
|
+
):
|
|
190
|
+
sleep_ms(90)
|
|
191
|
+
|
|
192
|
+
with trace_tool_call("search_web", metadata={"query": "agent workflow profiler"}):
|
|
193
|
+
sources = fake_search("agent workflow profiler")[:3]
|
|
194
|
+
|
|
195
|
+
with trace_tool_call("fake_read_parallel", metadata={"count": len(sources)}):
|
|
196
|
+
with ThreadPoolExecutor(max_workers=3) as pool:
|
|
197
|
+
contents = list(pool.map(fake_read, sources))
|
|
198
|
+
|
|
199
|
+
with trace_model_call(
|
|
200
|
+
"summarize_batch",
|
|
201
|
+
model="gpt-4.1-mini",
|
|
202
|
+
input_text=compact_context + "\n".join(content[:220] for content in contents),
|
|
203
|
+
input_tokens=136,
|
|
204
|
+
output_tokens=280,
|
|
205
|
+
):
|
|
206
|
+
sleep_ms(170)
|
|
207
|
+
|
|
208
|
+
with trace_tool_call(
|
|
209
|
+
"plan_next", metadata={"iteration": 1, "stop_reason": "state_converged"}
|
|
210
|
+
):
|
|
211
|
+
sleep_ms(60)
|
|
212
|
+
|
|
213
|
+
with trace_model_call(
|
|
214
|
+
"final_answer",
|
|
215
|
+
model="gpt-4.1-mini",
|
|
216
|
+
input_text=compact_context + " Compressed evidence with source ids.",
|
|
217
|
+
input_tokens=70,
|
|
218
|
+
output_tokens=420,
|
|
219
|
+
):
|
|
220
|
+
sleep_ms(220)
|
|
221
|
+
|
|
222
|
+
trace.export_json(out_path)
|
|
223
|
+
trace.print_report()
|
|
224
|
+
return out_path
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def run_optimized(out_dir: str | Path = "runs") -> Path:
|
|
228
|
+
out_path = Path(out_dir) / "research_agent_optimized.json"
|
|
229
|
+
with trace_agent("research_agent_optimized", metadata=_demo_metadata()) as trace:
|
|
230
|
+
with trace_model_call(
|
|
231
|
+
"plan",
|
|
232
|
+
model="gpt-4.1-mini",
|
|
233
|
+
input_text=SYSTEM_SHORT + "Plan research on agent inference runtimes.",
|
|
234
|
+
input_tokens=62,
|
|
235
|
+
output_tokens=120,
|
|
236
|
+
):
|
|
237
|
+
sleep_ms(200)
|
|
238
|
+
|
|
239
|
+
with trace_tool_call("search_web", metadata={"query": "agent inference runtime"}):
|
|
240
|
+
sources = fake_search("agent inference runtime")
|
|
241
|
+
|
|
242
|
+
with trace_tool_call("read_sources_parallel", metadata={"count": len(sources)}):
|
|
243
|
+
with ThreadPoolExecutor(max_workers=5) as pool:
|
|
244
|
+
contents = list(pool.map(fake_read, sources))
|
|
245
|
+
|
|
246
|
+
compact_context = "\n".join(content[:300] for content in contents)
|
|
247
|
+
with trace_model_call(
|
|
248
|
+
"summarize_batch",
|
|
249
|
+
model="gpt-4.1-mini",
|
|
250
|
+
input_text=SYSTEM_SHORT + compact_context,
|
|
251
|
+
input_tokens=221,
|
|
252
|
+
output_tokens=500,
|
|
253
|
+
):
|
|
254
|
+
sleep_ms(300)
|
|
255
|
+
|
|
256
|
+
with trace_model_call(
|
|
257
|
+
"verify_and_write",
|
|
258
|
+
model="gpt-4.1",
|
|
259
|
+
input_text=SYSTEM_SHORT + compact_context,
|
|
260
|
+
input_tokens=221,
|
|
261
|
+
output_tokens=650,
|
|
262
|
+
):
|
|
263
|
+
sleep_ms(450)
|
|
264
|
+
|
|
265
|
+
trace.export_json(out_path)
|
|
266
|
+
trace.print_report()
|
|
267
|
+
return out_path
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def run_langgraph_style(out_dir: str | Path = "runs") -> Path:
|
|
271
|
+
from agentloop.integrations.langgraph import trace_node
|
|
272
|
+
|
|
273
|
+
@trace_node("retrieve_sources")
|
|
274
|
+
def retrieve_sources(state: dict) -> dict:
|
|
275
|
+
sleep_ms(250)
|
|
276
|
+
state["sources"] = ["paper-a", "paper-b", "paper-c"]
|
|
277
|
+
return state
|
|
278
|
+
|
|
279
|
+
@trace_node("rank_sources")
|
|
280
|
+
def rank_sources(state: dict) -> dict:
|
|
281
|
+
sleep_ms(180)
|
|
282
|
+
state["sources"] = state["sources"][:2]
|
|
283
|
+
return state
|
|
284
|
+
|
|
285
|
+
out_path = Path(out_dir) / "langgraph_style_demo.json"
|
|
286
|
+
with trace_agent("langgraph_style_demo", metadata=_demo_metadata()) as trace:
|
|
287
|
+
state = {"question": "How should agent runtimes be profiled?"}
|
|
288
|
+
with trace_model_call(
|
|
289
|
+
"planner", model="gpt-4.1-mini", input_text=state["question"], output_tokens=120
|
|
290
|
+
):
|
|
291
|
+
sleep_ms(160)
|
|
292
|
+
state = retrieve_sources(state)
|
|
293
|
+
state = rank_sources(state)
|
|
294
|
+
with trace_model_call(
|
|
295
|
+
"final_answer", model="gpt-4.1-mini", input_text=str(state), output_tokens=300
|
|
296
|
+
):
|
|
297
|
+
sleep_ms(240)
|
|
298
|
+
trace.export_json(out_path)
|
|
299
|
+
trace.print_report()
|
|
300
|
+
return out_path
|
agentloop/doctor.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import socket
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.error import URLError
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
from urllib.request import Request, urlopen
|
|
14
|
+
|
|
15
|
+
from agentloop.config import postgres_connection_source
|
|
16
|
+
from agentloop.runtime import get_runtime_config
|
|
17
|
+
from agentloop.store import get_store
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class DoctorCheck:
|
|
22
|
+
name: str
|
|
23
|
+
status: str
|
|
24
|
+
detail: str
|
|
25
|
+
fix: str | None = None
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict[str, Any]:
|
|
28
|
+
payload = {"name": self.name, "status": self.status, "detail": self.detail}
|
|
29
|
+
if self.fix:
|
|
30
|
+
payload["fix"] = self.fix
|
|
31
|
+
return payload
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ok(name: str, detail: str) -> DoctorCheck:
|
|
35
|
+
return DoctorCheck(name=name, status="ok", detail=detail)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _warn(name: str, detail: str, fix: str | None = None) -> DoctorCheck:
|
|
39
|
+
return DoctorCheck(name=name, status="warn", detail=detail, fix=fix)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fail(name: str, detail: str, fix: str | None = None) -> DoctorCheck:
|
|
43
|
+
return DoctorCheck(name=name, status="fail", detail=detail, fix=fix)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _can_connect(host: str, port: int, timeout: float = 1.0) -> bool:
|
|
47
|
+
try:
|
|
48
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
49
|
+
return True
|
|
50
|
+
except OSError:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _health_check(api_url: str, timeout: float = 2.0) -> DoctorCheck:
|
|
55
|
+
parsed = urlsplit(api_url)
|
|
56
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
57
|
+
return _warn(
|
|
58
|
+
"api_health",
|
|
59
|
+
"AGENTLOOP_API_URL must use http:// or https://",
|
|
60
|
+
"Set AGENTLOOP_API_URL to the AgentLoop HTTP API origin.",
|
|
61
|
+
)
|
|
62
|
+
url = api_url.rstrip("/") + "/health"
|
|
63
|
+
try:
|
|
64
|
+
request = Request(url, headers={"User-Agent": "agentloop-doctor"})
|
|
65
|
+
with urlopen( # nosec B310 - API URL scheme is validated above.
|
|
66
|
+
request, timeout=timeout
|
|
67
|
+
) as response:
|
|
68
|
+
if response.status == 200:
|
|
69
|
+
return _ok("api_health", f"{url} returned HTTP 200")
|
|
70
|
+
return _warn("api_health", f"{url} returned HTTP {response.status}")
|
|
71
|
+
except URLError as exc:
|
|
72
|
+
return _warn(
|
|
73
|
+
"api_health",
|
|
74
|
+
f"Could not reach {url}: {exc.reason}",
|
|
75
|
+
"Start the API with: agentloop server --host 127.0.0.1 --port 8000",
|
|
76
|
+
)
|
|
77
|
+
except Exception as exc: # pragma: no cover - defensive for platform SSL/socket edge cases
|
|
78
|
+
return _warn("api_health", f"Could not reach {url}: {exc}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _ready_check(api_url: str, timeout: float = 2.0) -> DoctorCheck:
|
|
82
|
+
parsed = urlsplit(api_url)
|
|
83
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
84
|
+
return _fail(
|
|
85
|
+
"api_ready",
|
|
86
|
+
"AGENTLOOP_API_URL must use http:// or https://",
|
|
87
|
+
"Set AGENTLOOP_API_URL to the deployed AgentLoop HTTP API origin.",
|
|
88
|
+
)
|
|
89
|
+
url = api_url.rstrip("/") + "/readyz"
|
|
90
|
+
try:
|
|
91
|
+
request = Request(url, headers={"User-Agent": "agentloop-production-check"})
|
|
92
|
+
with urlopen( # nosec B310 - API URL scheme is validated above.
|
|
93
|
+
request, timeout=timeout
|
|
94
|
+
) as response:
|
|
95
|
+
if response.status == 200:
|
|
96
|
+
return _ok("api_ready", f"{url} returned HTTP 200")
|
|
97
|
+
return _fail("api_ready", f"{url} returned HTTP {response.status}")
|
|
98
|
+
except URLError as exc:
|
|
99
|
+
return _fail(
|
|
100
|
+
"api_ready",
|
|
101
|
+
f"Could not reach {url}: {exc.reason}",
|
|
102
|
+
"Deploy the API and confirm /readyz is reachable.",
|
|
103
|
+
)
|
|
104
|
+
except Exception as exc: # pragma: no cover - defensive for platform SSL/socket edge cases
|
|
105
|
+
return _fail("api_ready", f"Could not reach {url}: {exc}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _env_enabled(name: str) -> bool:
|
|
109
|
+
return os.getenv(name, "").lower() in {"1", "true", "yes", "on"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run_doctor(*, check_api: bool = True, runs_dir: Path | str = "runs") -> dict[str, Any]:
|
|
113
|
+
"""Run local setup checks, probing hosted wiring only when it is configured."""
|
|
114
|
+
|
|
115
|
+
checks: list[DoctorCheck] = []
|
|
116
|
+
|
|
117
|
+
py_version = sys.version_info
|
|
118
|
+
if py_version >= (3, 10):
|
|
119
|
+
checks.append(_ok("python", f"Python {platform.python_version()}"))
|
|
120
|
+
else:
|
|
121
|
+
checks.append(
|
|
122
|
+
_fail(
|
|
123
|
+
"python",
|
|
124
|
+
f"Python {platform.python_version()} is too old",
|
|
125
|
+
"Use Python 3.10 or newer.",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# These are the actual core runtime dependencies in pyproject.toml. Server,
|
|
130
|
+
# dashboard, Postgres, and instrumentation dependencies are checked by their
|
|
131
|
+
# corresponding modes instead of making a minimal local install look broken.
|
|
132
|
+
for module_name in ["typer", "rich"]:
|
|
133
|
+
try:
|
|
134
|
+
importlib.import_module(module_name)
|
|
135
|
+
checks.append(_ok(f"dependency:{module_name}", "importable"))
|
|
136
|
+
except ImportError:
|
|
137
|
+
checks.append(
|
|
138
|
+
_fail(
|
|
139
|
+
f"dependency:{module_name}",
|
|
140
|
+
"not installed",
|
|
141
|
+
"Install the package with: python -m pip install agentloop-profiler",
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
out_dir = Path(runs_dir)
|
|
146
|
+
try:
|
|
147
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
probe = out_dir / ".agentloop_write_probe"
|
|
149
|
+
probe.write_text("ok", encoding="utf-8")
|
|
150
|
+
probe.unlink(missing_ok=True)
|
|
151
|
+
checks.append(_ok("runs_dir", f"{out_dir} is writable"))
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
checks.append(
|
|
154
|
+
_fail(
|
|
155
|
+
"runs_dir",
|
|
156
|
+
f"{out_dir} is not writable: {exc}",
|
|
157
|
+
"Choose a writable path or set AGENTLOOP_EXPORT_DIR.",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
db = get_store()
|
|
163
|
+
db.init()
|
|
164
|
+
checks.append(_ok("store", f"{db.__class__.__name__} initialized"))
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
checks.append(
|
|
167
|
+
_fail(
|
|
168
|
+
"store",
|
|
169
|
+
f"store initialization failed: {exc}",
|
|
170
|
+
"Check AGENTLOOP_STORE_BACKEND and database credentials.",
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
cfg = get_runtime_config()
|
|
175
|
+
auth_required = _env_enabled("AGENTLOOP_REQUIRE_API_KEY")
|
|
176
|
+
explicit_api_url = os.getenv("AGENTLOOP_API_URL")
|
|
177
|
+
|
|
178
|
+
if cfg.auto_upload and auth_required and not cfg.api_key:
|
|
179
|
+
checks.append(
|
|
180
|
+
_warn(
|
|
181
|
+
"api_key",
|
|
182
|
+
"auto upload is enabled but AGENTLOOP_API_KEY is empty",
|
|
183
|
+
"Create a key with: agentloop create-api-key --project-id demo",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
elif cfg.api_key:
|
|
187
|
+
checks.append(_ok("api_key", "AGENTLOOP_API_KEY is configured"))
|
|
188
|
+
else:
|
|
189
|
+
checks.append(_ok("api_key", "not configured; not required for local-only use"))
|
|
190
|
+
|
|
191
|
+
if cfg.auto_upload:
|
|
192
|
+
checks.append(_ok("auto_upload", f"enabled -> {cfg.api_url}"))
|
|
193
|
+
else:
|
|
194
|
+
checks.append(_ok("auto_upload", "disabled; traces stay local"))
|
|
195
|
+
|
|
196
|
+
if check_api:
|
|
197
|
+
if explicit_api_url or cfg.auto_upload:
|
|
198
|
+
checks.append(_health_check(cfg.api_url))
|
|
199
|
+
else:
|
|
200
|
+
checks.append(_ok("api_health", "skipped; no hosted API is configured"))
|
|
201
|
+
|
|
202
|
+
failed = sum(1 for check in checks if check.status == "fail")
|
|
203
|
+
warned = sum(1 for check in checks if check.status == "warn")
|
|
204
|
+
return {
|
|
205
|
+
"ok": failed == 0,
|
|
206
|
+
"failed": failed,
|
|
207
|
+
"warnings": warned,
|
|
208
|
+
"checks": [check.to_dict() for check in checks],
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def run_production_check(
|
|
213
|
+
*,
|
|
214
|
+
check_api: bool = True,
|
|
215
|
+
check_store: bool = True,
|
|
216
|
+
allow_http: bool = False,
|
|
217
|
+
) -> dict[str, Any]:
|
|
218
|
+
"""Run strict checks for an internet-facing AgentLoop API/dashboard deployment."""
|
|
219
|
+
|
|
220
|
+
checks: list[DoctorCheck] = []
|
|
221
|
+
|
|
222
|
+
py_version = sys.version_info
|
|
223
|
+
if py_version >= (3, 10):
|
|
224
|
+
checks.append(_ok("python", f"Python {platform.python_version()}"))
|
|
225
|
+
else:
|
|
226
|
+
checks.append(
|
|
227
|
+
_fail(
|
|
228
|
+
"python",
|
|
229
|
+
f"Python {platform.python_version()} is too old",
|
|
230
|
+
"Use Python 3.10 or newer.",
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
backend = os.getenv("AGENTLOOP_STORE_BACKEND", "sqlite").lower()
|
|
235
|
+
if backend == "postgres":
|
|
236
|
+
checks.append(_ok("store_backend", "postgres"))
|
|
237
|
+
else:
|
|
238
|
+
checks.append(
|
|
239
|
+
_fail(
|
|
240
|
+
"store_backend",
|
|
241
|
+
f"{backend} is not production-ready",
|
|
242
|
+
"Set AGENTLOOP_STORE_BACKEND=postgres.",
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
database_source = postgres_connection_source()
|
|
247
|
+
if database_source:
|
|
248
|
+
checks.append(_ok("database_url", f"configured via {database_source}"))
|
|
249
|
+
else:
|
|
250
|
+
checks.append(
|
|
251
|
+
_fail(
|
|
252
|
+
"database_url",
|
|
253
|
+
"missing",
|
|
254
|
+
"Set AGENTLOOP_DATABASE_URL/DATABASE_URL or libpq PGHOST/PGSERVICE variables.",
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if check_store:
|
|
259
|
+
try:
|
|
260
|
+
db = get_store()
|
|
261
|
+
db.init()
|
|
262
|
+
checks.append(_ok("store", f"{db.__class__.__name__} initialized"))
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
checks.append(
|
|
265
|
+
_fail(
|
|
266
|
+
"store",
|
|
267
|
+
f"store initialization failed: {exc}",
|
|
268
|
+
"Check database credentials and network access.",
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if _env_enabled("AGENTLOOP_REQUIRE_API_KEY"):
|
|
273
|
+
checks.append(_ok("api_auth", "AGENTLOOP_REQUIRE_API_KEY=true"))
|
|
274
|
+
else:
|
|
275
|
+
checks.append(
|
|
276
|
+
_fail("api_auth", "API key auth is disabled", "Set AGENTLOOP_REQUIRE_API_KEY=true.")
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
admin_key = os.getenv("AGENTLOOP_ADMIN_API_KEY", "")
|
|
280
|
+
weak_admin_values = {
|
|
281
|
+
"",
|
|
282
|
+
"change-me-admin-key",
|
|
283
|
+
"replace-with-a-long-random-admin-secret",
|
|
284
|
+
"dev-secret",
|
|
285
|
+
"admin-secret",
|
|
286
|
+
}
|
|
287
|
+
if admin_key in weak_admin_values or len(admin_key) < 32:
|
|
288
|
+
checks.append(
|
|
289
|
+
_fail(
|
|
290
|
+
"admin_api_key",
|
|
291
|
+
"missing or too weak",
|
|
292
|
+
"Set AGENTLOOP_ADMIN_API_KEY to a generated secret with at least 32 characters.",
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
checks.append(_ok("admin_api_key", "configured"))
|
|
297
|
+
|
|
298
|
+
cors_origins = [
|
|
299
|
+
origin.strip()
|
|
300
|
+
for origin in os.getenv("AGENTLOOP_CORS_ORIGINS", "").split(",")
|
|
301
|
+
if origin.strip()
|
|
302
|
+
]
|
|
303
|
+
if not cors_origins:
|
|
304
|
+
checks.append(
|
|
305
|
+
_fail("cors_origins", "missing", "Set AGENTLOOP_CORS_ORIGINS to the dashboard origin.")
|
|
306
|
+
)
|
|
307
|
+
elif "*" in cors_origins:
|
|
308
|
+
checks.append(
|
|
309
|
+
_fail(
|
|
310
|
+
"cors_origins",
|
|
311
|
+
"wildcard origin is not allowed for production",
|
|
312
|
+
"Use exact HTTPS origins.",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
checks.append(_ok("cors_origins", ", ".join(cors_origins)))
|
|
317
|
+
|
|
318
|
+
api_url = os.getenv("AGENTLOOP_API_URL", "")
|
|
319
|
+
if not api_url:
|
|
320
|
+
checks.append(_fail("api_url", "missing", "Set AGENTLOOP_API_URL to the public API URL."))
|
|
321
|
+
elif api_url.startswith("https://"):
|
|
322
|
+
checks.append(_ok("api_url", api_url))
|
|
323
|
+
elif allow_http:
|
|
324
|
+
checks.append(
|
|
325
|
+
_warn("api_url", f"{api_url} is not HTTPS", "Use HTTPS for production traffic.")
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
checks.append(_fail("api_url", f"{api_url} is not HTTPS", "Use an HTTPS public API URL."))
|
|
329
|
+
|
|
330
|
+
if check_api and api_url:
|
|
331
|
+
checks.append(_health_check(api_url))
|
|
332
|
+
checks.append(_ready_check(api_url))
|
|
333
|
+
|
|
334
|
+
failed = sum(1 for check in checks if check.status == "fail")
|
|
335
|
+
warned = sum(1 for check in checks if check.status == "warn")
|
|
336
|
+
return {
|
|
337
|
+
"ok": failed == 0,
|
|
338
|
+
"failed": failed,
|
|
339
|
+
"warnings": warned,
|
|
340
|
+
"checks": [check.to_dict() for check in checks],
|
|
341
|
+
}
|