agentloop-profiler 0.4.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.

Potentially problematic release.


This version of agentloop-profiler might be problematic. Click here for more details.

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