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
agentloop/patches.py ADDED
@@ -0,0 +1,603 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from agentloop.findings import build_diagnosis
10
+ from agentloop.markdown import (
11
+ markdown_code_span,
12
+ markdown_fenced_code,
13
+ markdown_heading,
14
+ markdown_text,
15
+ )
16
+
17
+ SUPPORTED_PATCH_TYPES = {
18
+ "parallelize_tools",
19
+ "cache_context",
20
+ "add_schema_validation",
21
+ "batch_model_calls",
22
+ "route_to_smaller_model",
23
+ "split_large_step",
24
+ "runaway_loop",
25
+ "tool_oscillation",
26
+ }
27
+ SOURCE_SUFFIXES = {".py", ".js", ".jsx", ".ts", ".tsx"}
28
+ SKIP_DIRS = {".git", ".pytest_cache", ".venv", "__pycache__", "agentloop.egg-info", "runs"}
29
+
30
+
31
+ class RepositoryPathError(ValueError):
32
+ """The ``repo_path`` given to :func:`build_patch_plan` is not usable.
33
+
34
+ Raised when the path escapes the allowed root or does not identify an existing
35
+ directory -- both operator-fixable, unlike anything else the call can raise. It
36
+ subclasses :class:`ValueError` so existing ``except ValueError`` handlers keep
37
+ working, and it exists so a caller can catch the path problem alone rather than
38
+ every ``ValueError`` that planning might raise from deeper down.
39
+ """
40
+
41
+
42
+ @dataclass
43
+ class FileCandidate:
44
+ path: str
45
+ symbols: list[str]
46
+ confidence: str
47
+ reason: str
48
+ locations: list[dict[str, Any]] = field(default_factory=list)
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ return {
52
+ "path": self.path,
53
+ "symbols": self.symbols,
54
+ "confidence": self.confidence,
55
+ "reason": self.reason,
56
+ "locations": self.locations,
57
+ }
58
+
59
+
60
+ @dataclass
61
+ class PatchPlan:
62
+ patch_id: str
63
+ finding_id: str
64
+ type: str
65
+ title: str
66
+ risk: str
67
+ framework: str
68
+ files: list[FileCandidate]
69
+ evidence_spans: list[str]
70
+ before_pattern: str
71
+ proposed_rewrite: str
72
+ suggested_diff: str
73
+ validation_command: str
74
+ acceptance_criteria: str
75
+ notes: list[str] = field(default_factory=list)
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ return {
79
+ "patch_id": self.patch_id,
80
+ "finding_id": self.finding_id,
81
+ "type": self.type,
82
+ "title": self.title,
83
+ "risk": self.risk,
84
+ "framework": self.framework,
85
+ "files": [candidate.to_dict() for candidate in self.files],
86
+ "evidence_spans": self.evidence_spans,
87
+ "before_pattern": self.before_pattern,
88
+ "proposed_rewrite": self.proposed_rewrite,
89
+ "suggested_diff": self.suggested_diff,
90
+ "validation_command": self.validation_command,
91
+ "acceptance_criteria": self.acceptance_criteria,
92
+ "notes": self.notes,
93
+ }
94
+
95
+
96
+ def build_patch_plan(
97
+ trace: Any,
98
+ repo_path: str | Path = ".",
99
+ *,
100
+ allowed_root: str | Path | None = None,
101
+ ) -> dict[str, Any]:
102
+ repo = _resolve_repository_path(repo_path, allowed_root=allowed_root)
103
+ diagnosis = build_diagnosis(trace)
104
+ source_index = _index_source_files(repo)
105
+ frameworks = _detect_frameworks(source_index)
106
+ plans: list[PatchPlan] = []
107
+ unsupported: list[dict[str, Any]] = []
108
+
109
+ for finding in diagnosis.get("findings", []):
110
+ finding_type = str(finding.get("type", ""))
111
+ if finding_type not in SUPPORTED_PATCH_TYPES or not finding.get("rewrite", {}).get(
112
+ "patchable"
113
+ ):
114
+ unsupported.append(
115
+ _unsupported_finding(
116
+ finding, "finding type is not supported by dry-run patch planning yet"
117
+ )
118
+ )
119
+ continue
120
+ plans.append(_plan_for_finding(finding, source_index, frameworks, len(plans) + 1))
121
+
122
+ return {
123
+ "run_id": diagnosis["run_id"],
124
+ "name": diagnosis["name"],
125
+ "dry_run": True,
126
+ "repo_path": str(repo),
127
+ "summary": {
128
+ "patch_count": len(plans),
129
+ "unsupported_finding_count": len(unsupported),
130
+ "frameworks_detected": frameworks,
131
+ "supported_patch_types": sorted(SUPPORTED_PATCH_TYPES),
132
+ },
133
+ "patch_plans": [plan.to_dict() for plan in plans],
134
+ "unsupported_findings": unsupported,
135
+ "rule_errors": diagnosis.get("rule_errors", []),
136
+ "analysis_complete": diagnosis.get("analysis_complete", True),
137
+ }
138
+
139
+
140
+ def patch_plan_to_markdown(plan: dict[str, Any]) -> str:
141
+ summary = plan["summary"]
142
+ lines = [
143
+ f"# AgentLoop Patch Plan: {markdown_heading(plan['name'])}",
144
+ "",
145
+ f"- Run ID: {markdown_code_span(plan['run_id'])}",
146
+ f"- Dry run: {plan['dry_run']}",
147
+ f"- Repo: {markdown_code_span(plan['repo_path'])}",
148
+ f"- Patch plans: {summary['patch_count']}",
149
+ f"- Unsupported findings: {summary['unsupported_finding_count']}",
150
+ "- Frameworks detected: "
151
+ f"{markdown_text(', '.join(map(str, summary['frameworks_detected'])) or 'none')}",
152
+ "",
153
+ "## Patch Plans",
154
+ "",
155
+ ]
156
+ patch_plans = plan.get("patch_plans", [])
157
+ if plan.get("rule_errors"):
158
+ lines.extend(["Analysis incomplete; some finding rules failed:", ""])
159
+ for error in plan["rule_errors"]:
160
+ lines.append(
161
+ f"- {markdown_code_span(error['rule_id'])}: {markdown_text(error['message'])}"
162
+ )
163
+ lines.append("")
164
+ if not patch_plans:
165
+ lines.append("No supported patch plans were generated.")
166
+ for item in patch_plans:
167
+ lines.extend(
168
+ [
169
+ f"### {markdown_heading(item['patch_id'])}: {markdown_heading(item['title'])}",
170
+ "",
171
+ f"- Finding: {markdown_code_span(item['finding_id'])}",
172
+ f"- Type: {markdown_code_span(item['type'])}",
173
+ f"- Risk: {markdown_text(item['risk'])}",
174
+ f"- Framework: {markdown_text(item['framework'])}",
175
+ "- Evidence spans: "
176
+ f"{markdown_text(', '.join(map(str, item['evidence_spans'])) or 'none')}",
177
+ f"- Before pattern: {markdown_text(item['before_pattern'])}",
178
+ f"- Proposed rewrite: {markdown_text(item['proposed_rewrite'])}",
179
+ f"- Validation command: {markdown_code_span(item['validation_command'])}",
180
+ f"- Acceptance criteria: {markdown_text(item['acceptance_criteria'])}",
181
+ "",
182
+ "Suggested diff shape:",
183
+ "",
184
+ markdown_fenced_code(item["suggested_diff"], language="text"),
185
+ "",
186
+ "Likely files:",
187
+ "",
188
+ ]
189
+ )
190
+ if not item["files"]:
191
+ lines.extend(["- No likely source file found from trace span names.", ""])
192
+ for candidate in item["files"]:
193
+ symbols = markdown_text(", ".join(map(str, candidate["symbols"])) or "unknown")
194
+ location = _format_locations(candidate.get("locations", []))
195
+ lines.append(
196
+ f"- {markdown_code_span(candidate['path'])}{location} "
197
+ f"({markdown_text(candidate['confidence'])}): {symbols}. "
198
+ f"{markdown_text(candidate['reason'])}"
199
+ )
200
+ if item["notes"]:
201
+ lines.extend(["", "Notes:", ""])
202
+ for note in item["notes"]:
203
+ lines.append(f"- {markdown_text(note)}")
204
+ lines.append("")
205
+
206
+ unsupported = plan.get("unsupported_findings", [])
207
+ if unsupported:
208
+ lines.extend(["## Unsupported Findings", ""])
209
+ for item in unsupported:
210
+ lines.append(
211
+ f"- {markdown_code_span(item['finding_id'])} "
212
+ f"{markdown_code_span(item['type'])}: {markdown_text(item['reason'])}"
213
+ )
214
+ return "\n".join(lines).rstrip() + "\n"
215
+
216
+
217
+ def _plan_for_finding(
218
+ finding: dict[str, Any],
219
+ source_index: list[dict[str, Any]],
220
+ frameworks: list[str],
221
+ index: int,
222
+ ) -> PatchPlan:
223
+ finding_type = str(finding["type"])
224
+ evidence_names = _evidence_names(finding)
225
+ files = _candidate_files(source_index, evidence_names)
226
+ framework = _select_framework(frameworks, files)
227
+ templates = _rewrite_templates(finding_type, framework)
228
+ return PatchPlan(
229
+ patch_id=f"patch_{index:03d}",
230
+ finding_id=str(finding["finding_id"]),
231
+ type=finding_type,
232
+ title=str(finding["title"]),
233
+ risk=templates["risk"],
234
+ framework=framework,
235
+ files=files,
236
+ evidence_spans=list(finding.get("affected_spans", [])),
237
+ before_pattern=templates["before_pattern"],
238
+ proposed_rewrite=templates["proposed_rewrite"],
239
+ suggested_diff=templates["suggested_diff"],
240
+ validation_command=str(finding.get("validation", {}).get("command") or "agentloop replay"),
241
+ acceptance_criteria=str(
242
+ finding.get("validation", {}).get("acceptance_criteria")
243
+ or "before/after replay passes configured quality and budget gates"
244
+ ),
245
+ notes=_notes(finding_type, files),
246
+ )
247
+
248
+
249
+ def _resolve_repository_path(
250
+ repo_path: str | Path,
251
+ *,
252
+ allowed_root: str | Path | None,
253
+ ) -> Path:
254
+ root_input = Path.cwd() if allowed_root is None else allowed_root
255
+ root = os.path.normcase(os.path.realpath(os.fspath(root_input)))
256
+ root_prefix = root.rstrip(os.sep) + os.sep
257
+ candidate = os.path.normcase(os.path.realpath(os.path.join(root, os.fspath(repo_path))))
258
+ candidate_with_separator = candidate.rstrip(os.sep) + os.sep
259
+ if not candidate_with_separator.startswith(root_prefix):
260
+ raise RepositoryPathError("repository path must remain within the allowed root")
261
+ if not os.path.isdir(candidate_with_separator):
262
+ raise RepositoryPathError("repository path must identify an existing directory")
263
+ return Path(candidate_with_separator)
264
+
265
+
266
+ def _index_source_files(repo: Path) -> list[dict[str, Any]]:
267
+ out: list[dict[str, Any]] = []
268
+ root = os.path.normcase(os.path.realpath(os.fspath(repo)))
269
+ root_prefix = root.rstrip(os.sep) + os.sep
270
+ for directory, directory_names, filenames in os.walk(root, followlinks=False):
271
+ directory_names[:] = [
272
+ name
273
+ for name in directory_names
274
+ if name not in SKIP_DIRS and not os.path.islink(os.path.join(directory, name))
275
+ ]
276
+ for filename in filenames:
277
+ suffix = Path(filename).suffix
278
+ if suffix not in SOURCE_SUFFIXES:
279
+ continue
280
+ joined_path = os.path.join(directory, filename)
281
+ if os.path.islink(joined_path):
282
+ continue
283
+ source_path = os.path.normcase(os.path.realpath(joined_path))
284
+ if not source_path.startswith(root_prefix):
285
+ continue
286
+ if not os.path.isfile(source_path):
287
+ continue
288
+ try:
289
+ with open(source_path, encoding="utf-8") as source_file:
290
+ text = source_file.read()
291
+ except (OSError, UnicodeDecodeError):
292
+ continue
293
+ relative_path = Path(os.path.relpath(source_path, root)).as_posix()
294
+ out.append(
295
+ {
296
+ "path": relative_path,
297
+ "suffix": suffix,
298
+ "text": text,
299
+ "symbols": _python_symbols(text) if suffix == ".py" else [],
300
+ "frameworks": _framework_markers(text),
301
+ }
302
+ )
303
+ return out
304
+
305
+
306
+ def _python_symbols(text: str) -> list[dict[str, Any]]:
307
+ try:
308
+ tree = ast.parse(text)
309
+ except SyntaxError:
310
+ return []
311
+ symbols: list[dict[str, Any]] = []
312
+ for node in ast.walk(tree):
313
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
314
+ symbols.append(
315
+ {"name": node.name, "line": node.lineno, "kind": node.__class__.__name__}
316
+ )
317
+ return symbols
318
+
319
+
320
+ def _framework_markers(text: str) -> list[str]:
321
+ markers: list[str] = []
322
+ lowered = text.lower()
323
+ if "stategraph" in text or "langgraph" in lowered:
324
+ markers.append("langgraph")
325
+ if "openai.agents" in lowered or "from agents import" in lowered or "runner.run" in lowered:
326
+ markers.append("openai_agents")
327
+ if "agentloop.trace" in lowered or "@agentloop.trace" in lowered:
328
+ markers.append("plain_python")
329
+ return markers
330
+
331
+
332
+ def _detect_frameworks(source_index: list[dict[str, Any]]) -> list[str]:
333
+ frameworks = set()
334
+ for item in source_index:
335
+ frameworks.update(item["frameworks"])
336
+ if source_index:
337
+ frameworks.add("plain_python")
338
+ return sorted(frameworks)
339
+
340
+
341
+ def _candidate_files(
342
+ source_index: list[dict[str, Any]], evidence_names: list[str]
343
+ ) -> list[FileCandidate]:
344
+ candidates: list[FileCandidate] = []
345
+ for item in source_index:
346
+ symbol_hits = [
347
+ symbol
348
+ for symbol in item["symbols"]
349
+ if symbol["name"] in evidence_names
350
+ or any(symbol["name"].endswith(f"_{name}") for name in evidence_names)
351
+ ]
352
+ text_hits = [name for name in evidence_names if name and name in item["text"]]
353
+ if not symbol_hits and not text_hits:
354
+ continue
355
+ confidence = "high" if symbol_hits else "medium"
356
+ symbols = sorted({symbol["name"] for symbol in symbol_hits} or set(text_hits))
357
+ reason = (
358
+ "matched Python function/class names from trace evidence"
359
+ if symbol_hits
360
+ else "matched trace span names in source text"
361
+ )
362
+ locations = [
363
+ {"symbol": symbol["name"], "line": symbol["line"], "kind": symbol["kind"]}
364
+ for symbol in symbol_hits
365
+ ]
366
+ candidates.append(
367
+ FileCandidate(
368
+ path=item["path"],
369
+ symbols=symbols,
370
+ confidence=confidence,
371
+ reason=reason,
372
+ locations=locations,
373
+ )
374
+ )
375
+ return candidates[:8]
376
+
377
+
378
+ def _select_framework(frameworks: list[str], files: list[FileCandidate]) -> str:
379
+ if "langgraph" in frameworks and any(
380
+ "langgraph" in file.path.lower() or "graph" in file.path.lower() for file in files
381
+ ):
382
+ return "langgraph"
383
+ if "openai_agents" in frameworks:
384
+ return "openai_agents"
385
+ if "langgraph" in frameworks:
386
+ return "langgraph"
387
+ return "plain_python"
388
+
389
+
390
+ def _evidence_names(finding: dict[str, Any]) -> list[str]:
391
+ names = []
392
+ for row in finding.get("evidence", []):
393
+ name = str(row.get("name") or "").split(".")[-1]
394
+ if name:
395
+ names.append(name)
396
+ return sorted(set(names))
397
+
398
+
399
+ def _rewrite_templates(finding_type: str, framework: str) -> dict[str, str]:
400
+ if finding_type == "parallelize_tools":
401
+ return _parallelize_template(framework)
402
+ if finding_type == "cache_context":
403
+ return _cache_template(framework)
404
+ if finding_type == "add_schema_validation":
405
+ return _schema_template(framework)
406
+ if finding_type == "batch_model_calls":
407
+ return _batch_template(framework)
408
+ if finding_type == "route_to_smaller_model":
409
+ return _routing_template(framework)
410
+ if finding_type == "split_large_step":
411
+ return _split_template(framework)
412
+ if finding_type == "runaway_loop":
413
+ return _runaway_template(framework)
414
+ if finding_type == "tool_oscillation":
415
+ return _oscillation_template(framework)
416
+ return {
417
+ "risk": "medium",
418
+ "before_pattern": "Trace shows a patchable optimization finding.",
419
+ "proposed_rewrite": "Apply the rewrite hinted by the finding, then validate with replay.",
420
+ "suggested_diff": "Apply the finding rewrite, then compare baseline and candidate traces with agentloop replay.",
421
+ }
422
+
423
+
424
+ def _parallelize_template(framework: str) -> dict[str, str]:
425
+ if framework == "langgraph":
426
+ rewrite = "Fan out independent work with parallel LangGraph branches or Send/map nodes, then join before synthesis."
427
+ suggested = "Replace a serial node loop with parallel Send/map fan-out and join results before the synthesis node."
428
+ elif framework == "openai_agents":
429
+ rewrite = "Run independent tool calls with asyncio.gather inside the agent tool orchestration step before returning combined results."
430
+ suggested = "Wrap independent tool invocations in asyncio.gather and return the combined tool results to the agent."
431
+ else:
432
+ rewrite = "Replace serial independent tool calls with asyncio.gather for async tools or ThreadPoolExecutor for sync tools."
433
+ suggested = "Before: for item in items: results.append(tool(item))\nAfter: results = await asyncio.gather(*(tool(item) for item in items))"
434
+ return {
435
+ "risk": "medium",
436
+ "before_pattern": "Repeated tool calls are candidates for concurrency; output independence and shared-state safety require validation.",
437
+ "proposed_rewrite": "After validating concurrency safety: " + rewrite,
438
+ "suggested_diff": suggested,
439
+ }
440
+
441
+
442
+ def _cache_template(framework: str) -> dict[str, str]:
443
+ if framework == "openai_agents":
444
+ rewrite = "Move stable instructions into agent instructions or a cached prompt prefix; pass only run-specific inputs per step."
445
+ suggested = "Move stable prompt text into Agent instructions or a cached prefix; keep only task-specific data in each model call."
446
+ else:
447
+ rewrite = "Hoist repeated stable prompt/context into a constant, cached prefix, summary artifact, or provider prompt-cache boundary."
448
+ suggested = "Before: prompt = stable_context + dynamic_input on every call\nAfter: cached_prefix = stable_context; prompt = cached_prefix + dynamic_input"
449
+ return {
450
+ "risk": "low",
451
+ "before_pattern": "Multiple model calls resend the same stable context or instruction prefix.",
452
+ "proposed_rewrite": rewrite,
453
+ "suggested_diff": suggested,
454
+ }
455
+
456
+
457
+ def _schema_template(framework: str) -> dict[str, str]:
458
+ if framework == "openai_agents":
459
+ rewrite = "Use structured outputs or output_type validation, then repair invalid output with a cheap correction step before a full retry."
460
+ suggested = "Add output_type/schema validation and route validation failures to a small repair call before retrying the full step."
461
+ else:
462
+ rewrite = "Add JSON schema or Pydantic validation plus a small repair prompt before rerunning the full model step."
463
+ suggested = "Validate model output against a schema; on failure, run one bounded repair prompt before a full retry."
464
+ return {
465
+ "risk": "medium",
466
+ "before_pattern": "Retry spans indicate invalid or unusable model/tool output caused expensive reruns.",
467
+ "proposed_rewrite": rewrite,
468
+ "suggested_diff": suggested,
469
+ }
470
+
471
+
472
+ def _batch_template(framework: str) -> dict[str, str]:
473
+ if framework == "langgraph":
474
+ rewrite = "Replace repeated same-role model nodes with a map/batch node and one join step when independent outputs can share a prompt."
475
+ suggested = "Group repeated items into one batch prompt or one LangGraph map node, then unpack item-level results before synthesis."
476
+ else:
477
+ rewrite = "Batch repeated summarization, extraction, or classification calls into one prompt or bounded chunks."
478
+ suggested = "Before: for item in items: summarize(item)\nAfter: summarize_batch(items) with structured per-item output"
479
+ return {
480
+ "risk": "medium",
481
+ "before_pattern": "Three or more same-name model calls repeat the same operation shape.",
482
+ "proposed_rewrite": rewrite,
483
+ "suggested_diff": suggested,
484
+ }
485
+
486
+
487
+ def _routing_template(framework: str) -> dict[str, str]:
488
+ if framework == "openai_agents":
489
+ rewrite = "Route small planning, extraction, verification, or summarization steps to a cheaper model while preserving the final model for synthesis."
490
+ suggested = "Set the inexpensive step model on the Agent/tool call and keep replay gates plus a quality scorer for acceptance."
491
+ else:
492
+ rewrite = "Add a model router for low-token, low-risk steps and validate with quality gates before making it the default."
493
+ suggested = "Before: model='large-model' for every step\nAfter: model=router.pick(step_kind, token_count, risk_level)"
494
+ return {
495
+ "risk": "medium",
496
+ "before_pattern": "A small model step uses an expensive model despite low token volume.",
497
+ "proposed_rewrite": rewrite,
498
+ "suggested_diff": suggested,
499
+ }
500
+
501
+
502
+ def _split_template(framework: str) -> dict[str, str]:
503
+ rewrite = "Split oversized reasoning into retrieve/filter/compress/finalize stages so the final call receives smaller, higher-signal context."
504
+ suggested = (
505
+ "Before: final_answer(full_context)\n"
506
+ "After: candidates = retrieve(full_context); summary = compress(candidates); final_answer(summary)"
507
+ )
508
+ if framework == "langgraph":
509
+ suggested = "Insert filter and compression nodes before the final synthesis node; replay must preserve answer quality."
510
+ return {
511
+ "risk": "medium",
512
+ "before_pattern": "A model span carries a very large context window or fragile all-in-one reasoning step.",
513
+ "proposed_rewrite": rewrite,
514
+ "suggested_diff": suggested,
515
+ }
516
+
517
+
518
+ def _runaway_template(framework: str) -> dict[str, str]:
519
+ if framework == "langgraph":
520
+ rewrite = "Add explicit max-iteration, max-cost, and unchanged-state stop conditions to the cyclic graph edge."
521
+ suggested = "Gate the loop edge on iteration_count, budget_remaining, and state_changed before routing back to the same node."
522
+ else:
523
+ rewrite = "Add bounded-loop guards around the agent step: max iterations, max cost, max retries, and unchanged-state detection."
524
+ suggested = "Before: while not done: step()\nAfter: while not done and guard.allow(state): step(); guard.record(state)"
525
+ return {
526
+ "risk": "medium",
527
+ "before_pattern": "The same step repeats many times in one run without an obvious convergence boundary.",
528
+ "proposed_rewrite": rewrite,
529
+ "suggested_diff": suggested,
530
+ }
531
+
532
+
533
+ def _oscillation_template(framework: str) -> dict[str, str]:
534
+ rewrite = "Add a decision memo or state-change check so the agent does not alternate between equivalent tool calls."
535
+ suggested = (
536
+ "Before: tool_a(); tool_b(); tool_a(); tool_b()\n"
537
+ "After: if transition_key not in seen and state_changed: run_next_tool(); seen.add(transition_key)"
538
+ )
539
+ if framework == "langgraph":
540
+ suggested = "Store a transition key in graph state and block repeated A/B/A/B routes unless state materially changed."
541
+ return {
542
+ "risk": "medium",
543
+ "before_pattern": "Tool calls alternate between the same two tools, suggesting the workflow is revisiting equivalent state.",
544
+ "proposed_rewrite": rewrite,
545
+ "suggested_diff": suggested,
546
+ }
547
+
548
+
549
+ def _format_locations(locations: list[dict[str, Any]]) -> str:
550
+ if not locations:
551
+ return ""
552
+ formatted = ", ".join(
553
+ f"{markdown_text(location.get('symbol', 'symbol'))}:"
554
+ f"{markdown_text(location.get('line', '?'))}"
555
+ for location in locations[:3]
556
+ )
557
+ return f" lines {formatted}"
558
+
559
+
560
+ def _notes(finding_type: str, files: list[FileCandidate]) -> list[str]:
561
+ notes = ["Dry-run only: no files were modified."]
562
+ if not files:
563
+ notes.append(
564
+ "Add explicit span names that match source function names to improve file targeting."
565
+ )
566
+ if finding_type == "parallelize_tools":
567
+ notes.append(
568
+ "Only parallelize calls that do not mutate shared state and do not depend on each other's outputs."
569
+ )
570
+ if finding_type == "cache_context":
571
+ notes.append(
572
+ "Confirm cached context has the same invalidation boundary as the original prompt."
573
+ )
574
+ if finding_type == "add_schema_validation":
575
+ notes.append("Keep the repair path cheaper than a full retry and cap repair attempts.")
576
+ if finding_type == "batch_model_calls":
577
+ notes.append(
578
+ "Batch only items whose outputs can be validated independently after unpacking."
579
+ )
580
+ if finding_type == "route_to_smaller_model":
581
+ notes.append(
582
+ "Require a quality scorer or golden fixture before routing production traffic to the cheaper model."
583
+ )
584
+ if finding_type == "split_large_step":
585
+ notes.append("Preserve citations or source references across the compression boundary.")
586
+ if finding_type == "runaway_loop":
587
+ notes.append(
588
+ "Record the stop reason in trace metadata so future runs can distinguish healthy exits from guardrail exits."
589
+ )
590
+ if finding_type == "tool_oscillation":
591
+ notes.append(
592
+ "Make the state-change predicate domain-specific; generic duplicate suppression can hide real progress."
593
+ )
594
+ return notes
595
+
596
+
597
+ def _unsupported_finding(finding: dict[str, Any], reason: str) -> dict[str, Any]:
598
+ return {
599
+ "finding_id": finding.get("finding_id"),
600
+ "type": finding.get("type"),
601
+ "title": finding.get("title"),
602
+ "reason": reason,
603
+ }