pdd-cli 0.0.42__py3-none-any.whl → 0.0.90__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 (119) hide show
  1. pdd/__init__.py +4 -4
  2. pdd/agentic_common.py +863 -0
  3. pdd/agentic_crash.py +534 -0
  4. pdd/agentic_fix.py +1179 -0
  5. pdd/agentic_langtest.py +162 -0
  6. pdd/agentic_update.py +370 -0
  7. pdd/agentic_verify.py +183 -0
  8. pdd/auto_deps_main.py +15 -5
  9. pdd/auto_include.py +63 -5
  10. pdd/bug_main.py +3 -2
  11. pdd/bug_to_unit_test.py +2 -0
  12. pdd/change_main.py +11 -4
  13. pdd/cli.py +22 -1181
  14. pdd/cmd_test_main.py +80 -19
  15. pdd/code_generator.py +58 -18
  16. pdd/code_generator_main.py +672 -25
  17. pdd/commands/__init__.py +42 -0
  18. pdd/commands/analysis.py +248 -0
  19. pdd/commands/fix.py +140 -0
  20. pdd/commands/generate.py +257 -0
  21. pdd/commands/maintenance.py +174 -0
  22. pdd/commands/misc.py +79 -0
  23. pdd/commands/modify.py +230 -0
  24. pdd/commands/report.py +144 -0
  25. pdd/commands/templates.py +215 -0
  26. pdd/commands/utility.py +110 -0
  27. pdd/config_resolution.py +58 -0
  28. pdd/conflicts_main.py +8 -3
  29. pdd/construct_paths.py +281 -81
  30. pdd/context_generator.py +10 -2
  31. pdd/context_generator_main.py +113 -11
  32. pdd/continue_generation.py +47 -7
  33. pdd/core/__init__.py +0 -0
  34. pdd/core/cli.py +503 -0
  35. pdd/core/dump.py +554 -0
  36. pdd/core/errors.py +63 -0
  37. pdd/core/utils.py +90 -0
  38. pdd/crash_main.py +44 -11
  39. pdd/data/language_format.csv +71 -62
  40. pdd/data/llm_model.csv +20 -18
  41. pdd/detect_change_main.py +5 -4
  42. pdd/fix_code_loop.py +331 -77
  43. pdd/fix_error_loop.py +209 -60
  44. pdd/fix_errors_from_unit_tests.py +4 -3
  45. pdd/fix_main.py +75 -18
  46. pdd/fix_verification_errors.py +12 -100
  47. pdd/fix_verification_errors_loop.py +319 -272
  48. pdd/fix_verification_main.py +57 -17
  49. pdd/generate_output_paths.py +93 -10
  50. pdd/generate_test.py +16 -5
  51. pdd/get_jwt_token.py +48 -9
  52. pdd/get_run_command.py +73 -0
  53. pdd/get_test_command.py +68 -0
  54. pdd/git_update.py +70 -19
  55. pdd/increase_tests.py +7 -0
  56. pdd/incremental_code_generator.py +2 -2
  57. pdd/insert_includes.py +11 -3
  58. pdd/llm_invoke.py +1278 -110
  59. pdd/load_prompt_template.py +36 -10
  60. pdd/pdd_completion.fish +25 -2
  61. pdd/pdd_completion.sh +30 -4
  62. pdd/pdd_completion.zsh +79 -4
  63. pdd/postprocess.py +10 -3
  64. pdd/preprocess.py +228 -15
  65. pdd/preprocess_main.py +8 -5
  66. pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
  67. pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
  68. pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
  69. pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
  70. pdd/prompts/agentic_update_LLM.prompt +1071 -0
  71. pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
  72. pdd/prompts/auto_include_LLM.prompt +98 -101
  73. pdd/prompts/change_LLM.prompt +1 -3
  74. pdd/prompts/detect_change_LLM.prompt +562 -3
  75. pdd/prompts/example_generator_LLM.prompt +22 -1
  76. pdd/prompts/extract_code_LLM.prompt +5 -1
  77. pdd/prompts/extract_program_code_fix_LLM.prompt +14 -2
  78. pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
  79. pdd/prompts/extract_promptline_LLM.prompt +17 -11
  80. pdd/prompts/find_verification_errors_LLM.prompt +6 -0
  81. pdd/prompts/fix_code_module_errors_LLM.prompt +16 -4
  82. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +6 -41
  83. pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
  84. pdd/prompts/generate_test_LLM.prompt +21 -6
  85. pdd/prompts/increase_tests_LLM.prompt +1 -2
  86. pdd/prompts/insert_includes_LLM.prompt +1181 -6
  87. pdd/prompts/split_LLM.prompt +1 -62
  88. pdd/prompts/trace_LLM.prompt +25 -22
  89. pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
  90. pdd/prompts/update_prompt_LLM.prompt +22 -1
  91. pdd/prompts/xml_convertor_LLM.prompt +3246 -7
  92. pdd/pytest_output.py +188 -21
  93. pdd/python_env_detector.py +151 -0
  94. pdd/render_mermaid.py +236 -0
  95. pdd/setup_tool.py +648 -0
  96. pdd/simple_math.py +2 -0
  97. pdd/split_main.py +3 -2
  98. pdd/summarize_directory.py +56 -7
  99. pdd/sync_determine_operation.py +918 -186
  100. pdd/sync_main.py +82 -32
  101. pdd/sync_orchestration.py +1456 -453
  102. pdd/sync_tui.py +848 -0
  103. pdd/template_registry.py +264 -0
  104. pdd/templates/architecture/architecture_json.prompt +242 -0
  105. pdd/templates/generic/generate_prompt.prompt +174 -0
  106. pdd/trace.py +168 -12
  107. pdd/trace_main.py +4 -3
  108. pdd/track_cost.py +151 -61
  109. pdd/unfinished_prompt.py +49 -3
  110. pdd/update_main.py +549 -67
  111. pdd/update_model_costs.py +2 -2
  112. pdd/update_prompt.py +19 -4
  113. {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/METADATA +20 -7
  114. pdd_cli-0.0.90.dist-info/RECORD +153 -0
  115. {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/licenses/LICENSE +1 -1
  116. pdd_cli-0.0.42.dist-info/RECORD +0 -115
  117. {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/WHEEL +0 -0
  118. {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/entry_points.txt +0 -0
  119. {pdd_cli-0.0.42.dist-info → pdd_cli-0.0.90.dist-info}/top_level.txt +0 -0
pdd/agentic_fix.py ADDED
@@ -0,0 +1,1179 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import difflib
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Tuple, List, Optional, Dict
11
+ from rich.console import Console
12
+
13
+ from .get_language import get_language # Detects language from file extension (e.g., ".py" -> "python")
14
+ from .get_run_command import get_run_command_for_file # Gets run command for a file based on extension
15
+ from .llm_invoke import _load_model_data # Loads provider/model metadata from llm_model.csv
16
+ from .load_prompt_template import load_prompt_template # Loads prompt templates by name
17
+ from .agentic_langtest import default_verify_cmd_for # Provides a default verify command (per language)
18
+
19
+ console = Console()
20
+
21
+ # Provider selection order. The code will try agents in this sequence if keys/CLIs are present.
22
+ AGENT_PROVIDER_PREFERENCE = ["anthropic", "google", "openai"]
23
+
24
+ # Logging level selection; defaults to "quiet" under pytest, else "normal"
25
+ _env_level = os.getenv("PDD_AGENTIC_LOGLEVEL")
26
+ if _env_level is None and os.getenv("PYTEST_CURRENT_TEST"):
27
+ _env_level = "quiet"
28
+ _LOGLEVEL = (_env_level or "normal").strip().lower()
29
+ _IS_QUIET = _LOGLEVEL == "quiet"
30
+ _IS_VERBOSE = _LOGLEVEL == "verbose"
31
+
32
+ # Tunable knobs via env
33
+ _AGENT_COST_PER_CALL = float(os.getenv("PDD_AGENTIC_COST_PER_CALL", "0.02")) # estimated cost accounting
34
+ _AGENT_CALL_TIMEOUT = int(os.getenv("PDD_AGENTIC_TIMEOUT", "240")) # timeout (s) for each agent call
35
+ _VERIFY_TIMEOUT = int(os.getenv("PDD_AGENTIC_VERIFY_TIMEOUT", "120")) # timeout (s) for local verification step
36
+ _MAX_LOG_LINES = int(os.getenv("PDD_AGENTIC_MAX_LOG_LINES", "200")) # preview head truncation for logs
37
+
38
+ # When verification mode is "auto", we may run agent-supplied TESTCMD blocks (if emitted)
39
+ _AGENT_TESTCMD_ALLOWED = os.getenv("PDD_AGENTIC_AGENT_TESTCMD", "1") != "0"
40
+
41
+ def _print(msg: str, *, force: bool = False) -> None:
42
+ """Centralized print helper using Rich; suppressed in quiet mode unless force=True."""
43
+ if not _IS_QUIET or force:
44
+ console.print(msg)
45
+
46
+ def _info(msg: str) -> None:
47
+ """Informational log (respects quiet mode)."""
48
+ _print(msg)
49
+
50
+ def _always(msg: str) -> None:
51
+ """Always print (respects quiet mode toggle via _print)."""
52
+ _print(msg)
53
+
54
+ def _verbose(msg: str) -> None:
55
+ """Verbose-only print (print only when _IS_VERBOSE is True)."""
56
+ if _IS_VERBOSE:
57
+ console.print(msg)
58
+
59
+ def _begin_marker(path: Path) -> str:
60
+ """Marker that must wrap the BEGIN of a corrected file block emitted by the agent."""
61
+ return f"<<<BEGIN_FILE:{path}>>>"
62
+
63
+ def _end_marker(path: Path) -> str:
64
+ """Marker that must wrap the END of a corrected file block emitted by the agent."""
65
+ return f"<<<END_FILE:{path}>>>"
66
+
67
+ def get_agent_command(provider: str, instruction_file: Path) -> List[str]:
68
+ """
69
+ Return a base CLI command for a provider when using the generic runner.
70
+ Note: Anthropic/Google are handled by specialized variant runners, so this often returns [].
71
+ """
72
+ p = provider.lower()
73
+ if p == "anthropic":
74
+ return []
75
+ if p == "google":
76
+ return []
77
+ if p == "openai":
78
+ return ["codex", "exec", "--skip-git-repo-check"]
79
+ return []
80
+
81
+ def find_llm_csv_path() -> Optional[Path]:
82
+ """Look for .pdd/llm_model.csv in $HOME first, then in project cwd."""
83
+ home_path = Path.home() / ".pdd" / "llm_model.csv"
84
+ project_path = Path.cwd() / ".pdd" / "llm_model.csv"
85
+ if home_path.is_file():
86
+ return home_path
87
+ if project_path.is_file():
88
+ return project_path
89
+ return None
90
+
91
+ def _print_head(label: str, text: str, max_lines: int = _MAX_LOG_LINES) -> None:
92
+ """
93
+ Print only the first N lines of a long blob with a label.
94
+ Active in verbose mode; keeps console noise manageable.
95
+ """
96
+ if not _IS_VERBOSE:
97
+ return
98
+ lines = (text or "").splitlines()
99
+ head = "\n".join(lines[:max_lines])
100
+ tail = "" if len(lines) <= max_lines else f"\n... (truncated, total {len(lines)} lines)"
101
+ console.print(f"[bold cyan]{label}[/bold cyan]\n{head}{tail}")
102
+
103
+ def _print_diff(old: str, new: str, path: Path) -> None:
104
+ """Show unified diff for a changed file (verbose mode only)."""
105
+ if not _IS_VERBOSE:
106
+ return
107
+ old_lines = old.splitlines(keepends=True)
108
+ new_lines = new.splitlines(keepends=True)
109
+ diff = list(difflib.unified_diff(old_lines, new_lines, fromfile=f"{path} (before)", tofile=f"{path} (after)"))
110
+ if not diff:
111
+ console.print("[yellow]No diff in code file after this agent attempt.[/yellow]")
112
+ return
113
+ text = "".join(diff)
114
+ _print_head("Unified diff (first lines)", text)
115
+
116
+ def _normalize_code_text(body: str) -> str:
117
+ """
118
+ Normalize agent-emitted file content:
119
+ - remove a single leading newline if present
120
+ - ensure exactly one trailing newline
121
+ """
122
+ if body.startswith("\n"):
123
+ body = body[1:]
124
+ body = body.rstrip("\n") + "\n"
125
+ return body
126
+
127
+ # Regex for many <<<BEGIN_FILE:path>>> ... <<<END_FILE:path>>> blocks in a single output
128
+ _MULTI_FILE_BLOCK_RE = re.compile(
129
+ r"<<<BEGIN_FILE:(.*?)>>>(.*?)<<<END_FILE:\1>>>",
130
+ re.DOTALL,
131
+ )
132
+
133
+ def _extract_files_from_output(*blobs: str) -> Dict[str, str]:
134
+ """
135
+ Parse stdout/stderr blobs and collect all emitted file blocks into {path: content}.
136
+ Returns an empty dict if none found.
137
+ """
138
+ out: Dict[str, str] = {}
139
+ for blob in blobs:
140
+ if not blob:
141
+ continue
142
+ for m in _MULTI_FILE_BLOCK_RE.finditer(blob):
143
+ path = (m.group(1) or "").strip()
144
+ body = m.group(2) or ""
145
+ if path and body != "":
146
+ out[path] = body
147
+ return out
148
+
149
+ # Regex for an optional agent-supplied test command block
150
+ _TESTCMD_RE = re.compile(
151
+ r"<<<BEGIN_TESTCMD>>>\s*(.*?)\s*<<<END_TESTCMD>>>",
152
+ re.DOTALL,
153
+ )
154
+
155
+ def _extract_testcmd(*blobs: str) -> Optional[str]:
156
+ """Return the single agent-supplied TESTCMD (if present), else None."""
157
+ for blob in blobs:
158
+ if not blob:
159
+ continue
160
+ m = _TESTCMD_RE.search(blob)
161
+ if m:
162
+ cmd = (m.group(1) or "").strip()
163
+ if cmd:
164
+ return cmd
165
+ return None
166
+
167
+ def _extract_corrected_from_output(stdout: str, stderr: str, code_path: Path) -> Optional[str]:
168
+ """
169
+ Single-file fallback extraction: search for the corrected content block that
170
+ specifically targets the primary code file, using various path forms
171
+ (absolute path, real path, relative path, basename).
172
+ Returns the last match, or None if not found.
173
+ """
174
+ resolved = code_path.resolve()
175
+ abs_path = str(resolved)
176
+ real_path = str(Path(abs_path).resolve())
177
+ rel_path = str(code_path)
178
+ just_name = code_path.name
179
+
180
+ def _pattern_for(path_str: str) -> re.Pattern:
181
+ begin = re.escape(f"<<<BEGIN_FILE:{path_str}>>>")
182
+ end = re.escape(f"<<<END_FILE:{path_str}>>>")
183
+ return re.compile(begin + r"(.*?)" + end, re.DOTALL)
184
+
185
+ candidates = [
186
+ _pattern_for(abs_path),
187
+ _pattern_for(real_path),
188
+ _pattern_for(rel_path),
189
+ _pattern_for(just_name),
190
+ ]
191
+
192
+ matches: List[str] = []
193
+ for blob in [stdout or "", stderr or ""]:
194
+ for pat in candidates:
195
+ for m in pat.finditer(blob):
196
+ body = m.group(1) or ""
197
+ if body != "":
198
+ matches.append(body)
199
+
200
+ if not matches:
201
+ return None
202
+
203
+ # Filter out obvious placeholder template mistakes
204
+ placeholder_token = "FULL CORRECTED FILE CONTENT HERE"
205
+ filtered = [b for b in matches if placeholder_token.lower() not in b.lower()]
206
+ return filtered[-1] if filtered else matches[-1]
207
+
208
+ # Code fence (```python ... ```) fallback for providers that sometimes omit markers (e.g., Gemini)
209
+ _CODE_FENCE_RE = re.compile(r"```(?:python)?\s*(.*?)```", re.DOTALL | re.IGNORECASE)
210
+
211
+ def _extract_python_code_block(*blobs: str) -> Optional[str]:
212
+ """Return the last fenced Python code block found in given blobs, or None."""
213
+ candidates: List[str] = []
214
+ for blob in blobs:
215
+ if not blob:
216
+ continue
217
+ for match in _CODE_FENCE_RE.findall(blob):
218
+ block = match or ""
219
+ if block != "":
220
+ candidates.append(block)
221
+ if not candidates:
222
+ return None
223
+ block = candidates[-1]
224
+ return block if block.endswith("\n") else (block + "\n")
225
+
226
+ def _sanitized_env_common() -> dict:
227
+ """
228
+ Build a deterministic, non-interactive env for subprocess calls:
229
+ - disable colors/TTY features
230
+ - provide small default terminal size
231
+ - mark as CI
232
+ """
233
+ env = os.environ.copy()
234
+ env["TERM"] = "dumb"
235
+ env["CI"] = "1"
236
+ env["NO_COLOR"] = "1"
237
+ env["CLICOLOR"] = "0"
238
+ env["CLICOLOR_FORCE"] = "0"
239
+ env["FORCE_COLOR"] = "0"
240
+ env["SHELL"] = "/bin/sh"
241
+ env["COLUMNS"] = env.get("COLUMNS", "80")
242
+ env["LINES"] = env.get("LINES", "40")
243
+ return env
244
+
245
+ def _sanitized_env_for_anthropic(use_cli_auth: bool = False) -> dict:
246
+ """
247
+ Like _sanitized_env_common, plus:
248
+ - optionally remove ANTHROPIC_API_KEY to force subscription auth via Claude CLI
249
+ """
250
+ env = _sanitized_env_common()
251
+ if use_cli_auth:
252
+ # Remove API key so Claude CLI uses subscription auth instead
253
+ env.pop("ANTHROPIC_API_KEY", None)
254
+ return env
255
+
256
+ def _sanitized_env_for_openai() -> dict:
257
+ """
258
+ Like _sanitized_env_common, plus:
259
+ - strip completion-related env vars that can affect behavior
260
+ - set OpenAI CLI no-tty/no-color flags
261
+ """
262
+ env = _sanitized_env_common()
263
+ for k in list(env.keys()):
264
+ if k.startswith("COMP_") or k in ("BASH_COMPLETION", "BASH_COMPLETION_COMPAT_DIR", "BASH_VERSION", "BASH", "ZDOTDIR", "ZSH_NAME", "ZSH_VERSION"):
265
+ env.pop(k, None)
266
+ env["DISABLE_AUTO_COMPLETE"] = "1"
267
+ env["OPENAI_CLI_NO_TTY"] = "1"
268
+ env["OPENAI_CLI_NO_COLOR"] = "1"
269
+ return env
270
+
271
+ def _run_cli(cmd: List[str], cwd: Path, timeout: int) -> subprocess.CompletedProcess:
272
+ """
273
+ Generic subprocess runner for arbitrary CLI commands.
274
+ Captures stdout/stderr, returns CompletedProcess without raising on non-zero exit.
275
+ """
276
+ return subprocess.run(
277
+ cmd,
278
+ capture_output=True,
279
+ text=True,
280
+ check=False,
281
+ timeout=timeout,
282
+ cwd=str(cwd),
283
+ )
284
+
285
+ def _run_cli_args_openai(args: List[str], cwd: Path, timeout: int) -> subprocess.CompletedProcess:
286
+ """Subprocess runner for OpenAI commands with OpenAI-specific sanitized env."""
287
+ return subprocess.run(
288
+ args,
289
+ capture_output=True,
290
+ text=True,
291
+ check=False,
292
+ timeout=timeout,
293
+ cwd=str(cwd),
294
+ env=_sanitized_env_for_openai(),
295
+ )
296
+
297
+ def _run_openai_variants(prompt_text: str, cwd: Path, total_timeout: int, label: str) -> subprocess.CompletedProcess:
298
+ """
299
+ Try several OpenAI CLI variants to improve robustness.
300
+ Returns the first attempt that yields output or succeeds.
301
+
302
+ NOTE: Agents need write access to modify files in agentic mode,
303
+ so we do not restrict the sandbox.
304
+ """
305
+ # Write prompt to a unique temp file to avoid race conditions in concurrent execution
306
+ with tempfile.NamedTemporaryFile(
307
+ mode='w',
308
+ suffix='.txt',
309
+ prefix='.agentic_prompt_',
310
+ dir=cwd,
311
+ delete=False,
312
+ encoding='utf-8'
313
+ ) as f:
314
+ f.write(prompt_text)
315
+ prompt_file = Path(f.name)
316
+
317
+ try:
318
+ # Agentic instruction that tells Codex to read the prompt file and fix
319
+ agentic_instruction = (
320
+ f"Read the file {prompt_file} for instructions on what to fix. "
321
+ "You have full file access to explore and modify files as needed. "
322
+ "After reading the instructions, fix the failing tests."
323
+ )
324
+
325
+ variants = [
326
+ ["codex", "exec", agentic_instruction],
327
+ ["codex", "exec", "--skip-git-repo-check", agentic_instruction],
328
+ ]
329
+ per_attempt = 300
330
+ last = None
331
+ for args in variants:
332
+ try:
333
+ _verbose(f"[cyan]OpenAI variant ({label}): {' '.join(args[:-1])} ...[/cyan]")
334
+ last = _run_cli_args_openai(args, cwd, per_attempt)
335
+ if (last.stdout or last.stderr) or last.returncode == 0:
336
+ return last
337
+ except subprocess.TimeoutExpired:
338
+ _info(f"[yellow]OpenAI variant timed out: {' '.join(args[:-1])} ...[/yellow]")
339
+ continue
340
+ if last is None:
341
+ return subprocess.CompletedProcess(variants[-1], 124, stdout="", stderr="timeout")
342
+ return last
343
+ finally:
344
+ prompt_file.unlink(missing_ok=True)
345
+
346
+ def _run_cli_args_anthropic(args: List[str], cwd: Path, timeout: int) -> subprocess.CompletedProcess:
347
+ """Subprocess runner for Anthropic commands with subscription auth (removes API key)."""
348
+ return subprocess.run(
349
+ args,
350
+ capture_output=True,
351
+ text=True,
352
+ check=False,
353
+ timeout=timeout,
354
+ cwd=str(cwd),
355
+ env=_sanitized_env_for_anthropic(use_cli_auth=True),
356
+ )
357
+
358
+ def _run_anthropic_variants(prompt_text: str, cwd: Path, total_timeout: int, label: str) -> subprocess.CompletedProcess:
359
+ """
360
+ Anthropic CLI runner in agentic mode (without -p flag).
361
+
362
+ NOTE: We do NOT use -p (print mode) because it prevents file tool access.
363
+ Instead, we write the prompt to a file and let Claude read it in agentic mode.
364
+ """
365
+ # Write prompt to a unique temp file to avoid race conditions in concurrent execution
366
+ with tempfile.NamedTemporaryFile(
367
+ mode='w',
368
+ suffix='.txt',
369
+ prefix='.agentic_prompt_',
370
+ dir=cwd,
371
+ delete=False,
372
+ encoding='utf-8'
373
+ ) as f:
374
+ f.write(prompt_text)
375
+ prompt_file = Path(f.name)
376
+
377
+ try:
378
+ # Agentic instruction that tells Claude to read the prompt file and fix
379
+ agentic_instruction = (
380
+ f"Read the file {prompt_file} for instructions on what to fix. "
381
+ "You have full file access to explore and modify files as needed. "
382
+ "After reading the instructions, fix the failing tests."
383
+ )
384
+
385
+ variants = [
386
+ ["claude", "--dangerously-skip-permissions", agentic_instruction],
387
+ ]
388
+ per_attempt = 300
389
+ last: Optional[subprocess.CompletedProcess] = None
390
+ for args in variants:
391
+ try:
392
+ _verbose(f"[cyan]Anthropic variant ({label}): {' '.join(args[:-1])} ...[/cyan]")
393
+ last = _run_cli_args_anthropic(args, cwd, per_attempt)
394
+ if last.stdout or last.stderr or last.returncode == 0:
395
+ return last
396
+ except subprocess.TimeoutExpired:
397
+ _info(f"[yellow]Anthropic variant timed out: {' '.join(args[:-1])} ...[/yellow]")
398
+ continue
399
+ if last is None:
400
+ return subprocess.CompletedProcess(variants[-1], 124, stdout="", stderr="timeout")
401
+ return last
402
+ finally:
403
+ prompt_file.unlink(missing_ok=True)
404
+
405
+ def _run_cli_args_google(args: List[str], cwd: Path, timeout: int) -> subprocess.CompletedProcess:
406
+ """Subprocess runner for Google commands with common sanitized env."""
407
+ return subprocess.run(
408
+ args,
409
+ capture_output=True,
410
+ text=True,
411
+ check=False,
412
+ timeout=timeout,
413
+ cwd=str(cwd),
414
+ env=_sanitized_env_common(),
415
+ )
416
+
417
+ def _run_google_variants(prompt_text: str, cwd: Path, total_timeout: int, label: str) -> subprocess.CompletedProcess:
418
+ """
419
+ Google CLI runner in agentic mode (without -p flag).
420
+
421
+ NOTE: We do NOT use -p (pipe mode) because it may prevent tool access.
422
+ Instead, we write the prompt to a file and let Gemini read it in agentic mode.
423
+ """
424
+ # Write prompt to a unique temp file to avoid race conditions in concurrent execution
425
+ with tempfile.NamedTemporaryFile(
426
+ mode='w',
427
+ suffix='.txt',
428
+ prefix='.agentic_prompt_',
429
+ dir=cwd,
430
+ delete=False,
431
+ encoding='utf-8'
432
+ ) as f:
433
+ f.write(prompt_text)
434
+ prompt_file = Path(f.name)
435
+
436
+ try:
437
+ # Agentic instruction that tells Gemini to read the prompt file and fix
438
+ agentic_instruction = (
439
+ f"Read the file {prompt_file} for instructions on what to fix. "
440
+ "You have full file access to explore and modify files as needed. "
441
+ "After reading the instructions, fix the failing tests."
442
+ )
443
+
444
+ variants = [
445
+ ["gemini", agentic_instruction],
446
+ ]
447
+ per_attempt = 300
448
+ last = None
449
+ for args in variants:
450
+ try:
451
+ _verbose(f"[cyan]Google variant ({label}): {' '.join(args)} ...[/cyan]")
452
+ last = _run_cli_args_google(args, cwd, per_attempt)
453
+ if (last.stdout or last.stderr) or last.returncode == 0:
454
+ return last
455
+ except subprocess.TimeoutExpired:
456
+ _info(f"[yellow]Google variant timed out: {' '.join(args)} ...[/yellow]")
457
+ continue
458
+ if last is None:
459
+ return subprocess.CompletedProcess(variants[-1], 124, stdout="", stderr="timeout")
460
+ return last
461
+ finally:
462
+ prompt_file.unlink(missing_ok=True)
463
+
464
+ def _run_testcmd(cmd: str, cwd: Path) -> bool:
465
+ """
466
+ Execute an agent-supplied TESTCMD locally via bash -lc "<cmd>".
467
+ Return True on exit code 0, else False. Captures and previews output (verbose).
468
+ """
469
+ _info(f"[cyan]Executing agent-supplied test command:[/cyan] {cmd}")
470
+ proc = subprocess.run(
471
+ ["bash", "-lc", cmd],
472
+ capture_output=True,
473
+ text=True,
474
+ check=False,
475
+ timeout=_VERIFY_TIMEOUT,
476
+ cwd=str(cwd),
477
+ )
478
+ _print_head("testcmd stdout", proc.stdout or "")
479
+ _print_head("testcmd stderr", proc.stderr or "")
480
+ return proc.returncode == 0
481
+
482
+ def _verify_and_log(unit_test_file: str, cwd: Path, *, verify_cmd: Optional[str], enabled: bool) -> bool:
483
+ """
484
+ Standard local verification gate:
485
+ - If disabled, return True immediately (skip verification).
486
+ - If verify_cmd exists: format placeholders and run it via _run_testcmd.
487
+ - Else: run the file directly using the appropriate interpreter for its language.
488
+ Returns True iff the executed command exits 0.
489
+ """
490
+ if not enabled:
491
+ return True
492
+ if verify_cmd:
493
+ cmd = verify_cmd.replace("{test}", str(Path(unit_test_file).resolve())).replace("{cwd}", str(cwd))
494
+ return _run_testcmd(cmd, cwd)
495
+ # Get language-appropriate run command from language_format.csv
496
+ run_cmd = get_run_command_for_file(str(Path(unit_test_file).resolve()))
497
+ if run_cmd:
498
+ return _run_testcmd(run_cmd, cwd)
499
+ # Fallback: try running with Python if no run command found
500
+ verify = subprocess.run(
501
+ [os.sys.executable, str(Path(unit_test_file).resolve())],
502
+ capture_output=True,
503
+ text=True,
504
+ check=False,
505
+ timeout=_VERIFY_TIMEOUT,
506
+ cwd=str(cwd),
507
+ )
508
+ _print_head("verify stdout", verify.stdout or "")
509
+ _print_head("verify stderr", verify.stderr or "")
510
+ return verify.returncode == 0
511
+
512
+ def _safe_is_subpath(child: Path, parent: Path) -> bool:
513
+ """
514
+ True if 'child' resolves under 'parent' (prevents writes outside project root).
515
+ """
516
+ try:
517
+ child.resolve().relative_to(parent.resolve())
518
+ return True
519
+ except Exception:
520
+ return False
521
+
522
+ # Suffixes we strip when mapping "foo_fixed.py" -> "foo.py"
523
+ _COMMON_FIXED_SUFFIXES = ("_fixed", ".fixed", "-fixed")
524
+
525
+ def _strip_common_suffixes(name: str) -> str:
526
+ """Remove a known fixed-suffix from a basename (before extension), if present."""
527
+ base, ext = os.path.splitext(name)
528
+ for suf in _COMMON_FIXED_SUFFIXES:
529
+ if base.endswith(suf):
530
+ base = base[: -len(suf)]
531
+ break
532
+ return base + ext
533
+
534
+ def _find_existing_by_basename(project_root: Path, basename: str) -> Optional[Path]:
535
+ """Search the project tree for the first file whose name matches 'basename'."""
536
+ try:
537
+ for p in project_root.rglob(basename):
538
+ if p.is_file():
539
+ return p.resolve()
540
+ except Exception:
541
+ return None
542
+ return None
543
+
544
+ def _normalize_target_path(
545
+ emitted_path: str,
546
+ project_root: Path,
547
+ primary_code_path: Path,
548
+ allow_new: bool,
549
+ ) -> Optional[Path]:
550
+ """
551
+ Resolve an emitted path to a safe file path we should write:
552
+ - make path absolute under project root
553
+ - allow direct match, primary-file match (with/without _fixed), or basename search
554
+ - create new files only if allow_new is True
555
+ """
556
+ p = Path(emitted_path)
557
+ if not p.is_absolute():
558
+ p = (project_root / emitted_path).resolve()
559
+ if not _safe_is_subpath(p, project_root):
560
+ _info(f"[yellow]Skipping write outside project root: {p}[/yellow]")
561
+ return None
562
+ if p.exists():
563
+ return p
564
+ emitted_base = Path(emitted_path).name
565
+ primary_base = primary_code_path.name
566
+ if emitted_base == primary_base:
567
+ return primary_code_path
568
+ if _strip_common_suffixes(emitted_base) == primary_base:
569
+ return primary_code_path
570
+ existing = _find_existing_by_basename(project_root, emitted_base)
571
+ if existing:
572
+ return existing
573
+ if not allow_new:
574
+ _info(f"[yellow]Skipping creation of new file (in-place only): {p}[/yellow]")
575
+ return None
576
+ return p
577
+
578
+ def _apply_file_map(
579
+ file_map: Dict[str, str],
580
+ project_root: Path,
581
+ primary_code_path: Path,
582
+ allow_new: bool,
583
+ ) -> List[Path]:
584
+ """
585
+ Apply a {emitted_path -> content} mapping to disk:
586
+ - resolve a safe target path
587
+ - normalize content
588
+ - write file and print unified diff (verbose)
589
+ Returns a list of the written Paths.
590
+ """
591
+ applied: List[Path] = []
592
+ for emitted, body in file_map.items():
593
+ target = _normalize_target_path(emitted, project_root, primary_code_path, allow_new)
594
+ if target is None:
595
+ continue
596
+ body_to_write = _normalize_code_text(body)
597
+ old = ""
598
+ if target.exists():
599
+ try:
600
+ old = target.read_text(encoding="utf-8")
601
+ except Exception:
602
+ old = ""
603
+ target.parent.mkdir(parents=True, exist_ok=True)
604
+ target.write_text(body_to_write, encoding="utf-8")
605
+ _print_diff(old, body_to_write, target)
606
+ applied.append(target)
607
+ return applied
608
+
609
+ def _post_apply_verify_or_testcmd(
610
+ provider: str,
611
+ unit_test_file: str,
612
+ cwd: Path,
613
+ *,
614
+ verify_cmd: Optional[str],
615
+ verify_enabled: bool,
616
+ stdout: str,
617
+ stderr: str,
618
+ ) -> bool:
619
+ """
620
+ After applying changes, run standard verification.
621
+ If it fails and TESTCMDs are allowed, try running the agent-supplied TESTCMD.
622
+ Return True iff any verification path succeeds.
623
+ """
624
+ # 1) If standard verification is enabled, use it
625
+ if _verify_and_log(unit_test_file, cwd, verify_cmd=verify_cmd, enabled=verify_enabled):
626
+ return True
627
+ # 2) Otherwise (or if disabled/failed) try agent-supplied TESTCMD if allowed
628
+ if _AGENT_TESTCMD_ALLOWED:
629
+ testcmd = _extract_testcmd(stdout or "", stderr or "")
630
+ if testcmd:
631
+ return _run_testcmd(testcmd, cwd)
632
+ return False
633
+
634
+ def _snapshot_mtimes(root: Path) -> Dict[Path, float]:
635
+ """Record mtimes of all files in root."""
636
+ snapshot = {}
637
+ try:
638
+ for p in root.rglob("*"):
639
+ if ".git" in p.parts or "__pycache__" in p.parts:
640
+ continue
641
+ if p.is_file():
642
+ snapshot[p] = p.stat().st_mtime
643
+ except Exception:
644
+ pass
645
+ return snapshot
646
+
647
+ def _detect_mtime_changes(root: Path, snapshot: Dict[Path, float]) -> List[str]:
648
+ """Return list of changed/new file paths."""
649
+ changes = []
650
+ try:
651
+ for p in root.rglob("*"):
652
+ if ".git" in p.parts or "__pycache__" in p.parts:
653
+ continue
654
+ if p.is_file():
655
+ if p not in snapshot:
656
+ changes.append(str(p))
657
+ elif p.stat().st_mtime != snapshot[p]:
658
+ changes.append(str(p))
659
+ except Exception:
660
+ pass
661
+ return changes
662
+
663
+ def _try_harvest_then_verify(
664
+ provider: str,
665
+ code_path: Path,
666
+ unit_test_file: str,
667
+ code_snapshot: str,
668
+ prompt_content: str,
669
+ test_content: str,
670
+ error_content: str,
671
+ cwd: Path,
672
+ *,
673
+ verify_cmd: Optional[str],
674
+ verify_enabled: bool,
675
+ changed_files: List[str],
676
+ ) -> bool:
677
+ """
678
+ Strict, fast path:
679
+ - Ask agent to ONLY emit corrected file blocks (and optionally TESTCMD).
680
+ - Apply emitted results deterministically.
681
+ - Verify locally.
682
+ """
683
+ harvest_prompt_template = load_prompt_template("agentic_fix_harvest_only_LLM")
684
+ if not harvest_prompt_template:
685
+ _info("[yellow]Failed to load harvest-only agent prompt template.[/yellow]")
686
+ return False
687
+
688
+ harvest_instr = harvest_prompt_template.format(
689
+ code_abs=str(code_path),
690
+ test_abs=str(Path(unit_test_file).resolve()),
691
+ begin=_begin_marker(code_path),
692
+ end=_end_marker(code_path),
693
+ code_content=code_snapshot,
694
+ prompt_content=prompt_content,
695
+ test_content=test_content,
696
+ error_content=error_content,
697
+ verify_cmd=verify_cmd or "No verification command provided.",
698
+ )
699
+ harvest_file = Path("agentic_fix_harvest.txt")
700
+ harvest_file.write_text(harvest_instr, encoding="utf-8")
701
+ _info(f"[cyan]Executing {provider.capitalize()} with harvest-only instructions: {harvest_file.resolve()}[/cyan]")
702
+ _print_head("Harvest-only instruction preview", harvest_instr)
703
+
704
+ # Snapshot mtimes before agent run
705
+ mtime_snapshot = _snapshot_mtimes(cwd)
706
+
707
+ try:
708
+ # Provider-specific variant runners with shorter time budgets
709
+ if provider == "openai":
710
+ res = _run_openai_variants(harvest_instr, cwd, max(60, _AGENT_CALL_TIMEOUT // 3), "harvest")
711
+ elif provider == "anthropic":
712
+ res = _run_anthropic_variants(harvest_instr, cwd, max(60, _AGENT_CALL_TIMEOUT // 3), "harvest")
713
+ elif provider == "google":
714
+ res = _run_google_variants(harvest_instr, cwd, max(60, _AGENT_CALL_TIMEOUT // 3), "harvest")
715
+ else:
716
+ res = _run_cli(get_agent_command(provider, harvest_file), cwd, max(60, _AGENT_CALL_TIMEOUT // 2))
717
+ except subprocess.TimeoutExpired:
718
+ _info(f"[yellow]{provider.capitalize()} harvest-only attempt timed out.[/yellow]")
719
+ try:
720
+ harvest_file.unlink()
721
+ except Exception:
722
+ pass
723
+ return False
724
+
725
+ _print_head(f"{provider.capitalize()} harvest stdout", res.stdout or "")
726
+ _print_head(f"{provider.capitalize()} harvest stderr", res.stderr or "")
727
+
728
+ # Detect direct changes by agent
729
+ direct_changes = _detect_mtime_changes(cwd, mtime_snapshot)
730
+ changed_files.extend(direct_changes)
731
+
732
+ allow_new = True
733
+
734
+ # Prefer multi-file blocks; else try single-file; else Gemini code-fence fallback
735
+ multi = _extract_files_from_output(res.stdout or "", res.stderr or "")
736
+ if multi:
737
+ _info("[cyan]Applying multi-file harvest from agent output...[/cyan]")
738
+ applied = _apply_file_map(multi, cwd, code_path, allow_new)
739
+ changed_files.extend([str(p) for p in applied])
740
+ ok = _post_apply_verify_or_testcmd(
741
+ provider, unit_test_file, cwd,
742
+ verify_cmd=verify_cmd, verify_enabled=verify_enabled,
743
+ stdout=res.stdout or "", stderr=res.stderr or ""
744
+ )
745
+ try:
746
+ harvest_file.unlink()
747
+ except Exception:
748
+ pass
749
+ return ok
750
+
751
+ harvested_single = _extract_corrected_from_output(res.stdout or "", res.stderr or "", code_path.resolve())
752
+ if harvested_single is None:
753
+ if provider == "google":
754
+ code_block = _extract_python_code_block(res.stdout or "", res.stderr or "")
755
+ if code_block:
756
+ _info("[cyan]No markers found, but detected a Python code block from Google. Applying it...[/cyan]")
757
+ body_to_write = _normalize_code_text(code_block)
758
+ code_path.write_text(body_to_write, encoding="utf-8")
759
+ changed_files.append(str(code_path))
760
+ newest = code_path.read_text(encoding="utf-8")
761
+ _print_diff(code_snapshot, newest, code_path)
762
+ ok = _post_apply_verify_or_testcmd(
763
+ provider, unit_test_file, working_dir,
764
+ verify_cmd=verify_cmd, verify_enabled=verify_enabled,
765
+ stdout=res.stdout or "", stderr=res.stderr or ""
766
+ )
767
+ try:
768
+ harvest_file.unlink()
769
+ except Exception:
770
+ pass
771
+ return ok
772
+
773
+ # If no output blocks, but direct changes occurred, we should verify
774
+ if direct_changes:
775
+ _info("[cyan]No output markers found, but detected file changes. Verifying...[/cyan]")
776
+ ok = _post_apply_verify_or_testcmd(
777
+ provider, unit_test_file, cwd,
778
+ verify_cmd=verify_cmd, verify_enabled=verify_enabled,
779
+ stdout=res.stdout or "", stderr=res.stderr or ""
780
+ )
781
+ try:
782
+ harvest_file.unlink()
783
+ except Exception:
784
+ pass
785
+ return ok
786
+
787
+ _info("[yellow]Harvest-only attempt did not include the required markers.[/yellow]")
788
+ try:
789
+ harvest_file.unlink()
790
+ except Exception:
791
+ pass
792
+ return False
793
+
794
+ _info("[cyan]Applying harvested corrected file (single)...[/cyan]")
795
+ body_to_write = _normalize_code_text(harvested_single)
796
+ code_path.write_text(body_to_write, encoding="utf-8")
797
+ changed_files.append(str(code_path))
798
+ newest = code_path.read_text(encoding="utf-8")
799
+ _print_diff(code_snapshot, newest, code_path)
800
+
801
+ ok = _post_apply_verify_or_testcmd(
802
+ provider, unit_test_file, cwd,
803
+ verify_cmd=verify_cmd, verify_enabled=verify_enabled,
804
+ stdout=res.stdout or "", stderr=res.stderr or ""
805
+ )
806
+ try:
807
+ harvest_file.unlink()
808
+ except Exception:
809
+ pass
810
+ return ok
811
+
812
+ def run_agentic_fix(
813
+ prompt_file: str,
814
+ code_file: str,
815
+ unit_test_file: str,
816
+ error_log_file: str,
817
+ verify_cmd: Optional[str] = None,
818
+ cwd: Optional[Path] = None,
819
+ *,
820
+ verbose: bool = False,
821
+ quiet: bool = False,
822
+ ) -> Tuple[bool, str, float, str, List[str]]:
823
+ """
824
+ Main entrypoint for agentic fallback:
825
+ - Prepares inputs and prompt (with code/tests/error log)
826
+ - Optionally preflight-populates error log if empty (so agent sees failures)
827
+ - Tries providers in preference order: harvest-first, then primary attempt
828
+ - Applies changes locally and verifies locally
829
+ - Returns (success, message, est_cost, used_model, changed_files)
830
+ """
831
+ global _IS_VERBOSE, _IS_QUIET
832
+ if verbose:
833
+ _IS_VERBOSE = True
834
+ _IS_QUIET = False
835
+ elif quiet:
836
+ _IS_QUIET = True
837
+ _IS_VERBOSE = False
838
+
839
+ _always("[bold yellow]Standard fix failed. Initiating agentic fallback (AGENT-ONLY)...[/bold yellow]")
840
+
841
+ instruction_file: Optional[Path] = None
842
+ est_cost: float = 0.0
843
+ used_model: str = "agentic-cli"
844
+ changed_files: List[str] = [] # Track all files changed by agents
845
+
846
+ try:
847
+ # Use explicit cwd if provided, otherwise fall back to current directory
848
+ working_dir = Path(cwd) if cwd else Path.cwd()
849
+ _info(f"[cyan]Project root (cwd): {working_dir}[/cyan]")
850
+
851
+ # Load provider table and filter to those with API keys present in the environment
852
+ csv_path = find_llm_csv_path()
853
+ model_df = _load_model_data(csv_path)
854
+
855
+ available_agents: List[str] = []
856
+ present_keys: List[str] = []
857
+ seen = set()
858
+
859
+ for provider in AGENT_PROVIDER_PREFERENCE:
860
+ provider_df = model_df[model_df["provider"].str.lower() == provider]
861
+ if provider_df.empty:
862
+ continue
863
+ api_key_name = provider_df.iloc[0]["api_key"]
864
+ if not api_key_name:
865
+ continue
866
+ # Check CLI availability first (subscription auth), then API key
867
+ has_cli_auth = provider == "anthropic" and shutil.which("claude")
868
+ has_api_key = os.getenv(api_key_name) or (provider == "google" and os.getenv("GEMINI_API_KEY"))
869
+ if has_cli_auth or has_api_key:
870
+ if has_cli_auth:
871
+ present_keys.append("claude-cli-auth")
872
+ else:
873
+ present_keys.append(api_key_name or ("GEMINI_API_KEY" if provider == "google" else ""))
874
+ if provider not in seen:
875
+ available_agents.append(provider)
876
+ seen.add(provider)
877
+
878
+ _info(f"[cyan]Env API keys present (names only): {', '.join([k for k in present_keys if k]) or 'none'}[/cyan]")
879
+ if not available_agents:
880
+ return False, "No configured agent API keys found in environment.", est_cost, used_model, changed_files
881
+
882
+ _info(f"[cyan]Available agents found: {', '.join(available_agents)}[/cyan]")
883
+
884
+ # Read input artifacts that feed into the prompt
885
+ prompt_content = Path(prompt_file).read_text(encoding="utf-8")
886
+
887
+ # Resolve relative paths against working_dir, not Path.cwd()
888
+ code_path_input = Path(code_file)
889
+ if not code_path_input.is_absolute():
890
+ code_path = (working_dir / code_path_input).resolve()
891
+ else:
892
+ code_path = code_path_input.resolve()
893
+
894
+ test_path_input = Path(unit_test_file)
895
+ if not test_path_input.is_absolute():
896
+ test_path = (working_dir / test_path_input).resolve()
897
+ else:
898
+ test_path = test_path_input.resolve()
899
+
900
+ orig_code = code_path.read_text(encoding="utf-8")
901
+ orig_test = test_path.read_text(encoding="utf-8")
902
+ test_content = orig_test # Alias for prompt template compatibility
903
+
904
+ # Read error log if it exists, otherwise we'll populate it via preflight
905
+ error_log_path = Path(error_log_file)
906
+ error_content = error_log_path.read_text(encoding="utf-8") if error_log_path.exists() else ""
907
+
908
+ # --- Preflight: populate error_content if empty so the agent sees fresh failures ---
909
+ # This makes run_agentic_fix self-sufficient even if the caller forgot to write the error log.
910
+ # Also detect useless content patterns like empty XML tags (e.g., "<history></history>")
911
+ def _is_useless_error_content(content: str) -> bool:
912
+ """Check if error content is empty or useless (e.g., empty XML tags)."""
913
+ stripped = (content or "").strip()
914
+ if not stripped:
915
+ return True
916
+ # Detect empty XML-like tags with no actual error content
917
+ import re
918
+ # Remove all XML-like empty tags and whitespace
919
+ cleaned = re.sub(r"<[^>]+>\s*</[^>]+>", "", stripped).strip()
920
+ if not cleaned:
921
+ return True
922
+ # Check if content lacks any traceback or error keywords
923
+ error_indicators = ["Error", "Exception", "Traceback", "failed", "FAILED", "error:"]
924
+ return not any(ind in content for ind in error_indicators)
925
+
926
+ if _is_useless_error_content(error_content):
927
+ try:
928
+ lang = get_language(os.path.splitext(code_path)[1])
929
+ pre_cmd = os.getenv("PDD_AGENTIC_VERIFY_CMD") or default_verify_cmd_for(lang, unit_test_file)
930
+ if pre_cmd:
931
+ pre_cmd = pre_cmd.replace("{test}", str(Path(unit_test_file).resolve())).replace("{cwd}", str(working_dir))
932
+ pre = subprocess.run(
933
+ ["bash", "-lc", pre_cmd],
934
+ capture_output=True,
935
+ text=True,
936
+ check=False,
937
+ timeout=_VERIFY_TIMEOUT,
938
+ cwd=str(working_dir),
939
+ )
940
+ else:
941
+ # Use language-appropriate run command from language_format.csv
942
+ run_cmd = get_run_command_for_file(str(Path(unit_test_file).resolve()))
943
+ if run_cmd:
944
+ pre = subprocess.run(
945
+ ["bash", "-lc", run_cmd],
946
+ capture_output=True,
947
+ text=True,
948
+ check=False,
949
+ timeout=_VERIFY_TIMEOUT,
950
+ cwd=str(working_dir),
951
+ )
952
+ else:
953
+ # Fallback: run directly with Python interpreter
954
+ pre = subprocess.run(
955
+ [os.sys.executable, str(Path(unit_test_file).resolve())],
956
+ capture_output=True,
957
+ text=True,
958
+ check=False,
959
+ timeout=_VERIFY_TIMEOUT,
960
+ cwd=str(working_dir),
961
+ )
962
+ error_content = (pre.stdout or "") + "\n" + (pre.stderr or "")
963
+ try:
964
+ Path(error_log_file).write_text(error_content, encoding="utf-8")
965
+ except Exception:
966
+ pass
967
+ _print_head("preflight verify stdout", pre.stdout or "")
968
+ _print_head("preflight verify stderr", pre.stderr or "")
969
+ except Exception as e:
970
+ _info(f"[yellow]Preflight verification failed: {e}. Proceeding with empty error log.[/yellow]")
971
+ # --- End preflight ---
972
+
973
+ # Compute verification policy and command
974
+ ext = code_path.suffix.lower()
975
+ is_python = ext == ".py"
976
+
977
+ env_verify = os.getenv("PDD_AGENTIC_VERIFY", None) # "auto"/"0"/"1"/None
978
+ verify_force = os.getenv("PDD_AGENTIC_VERIFY_FORCE", "0") == "1"
979
+
980
+ # If verify_cmd arg is provided, it overrides env var and default
981
+ if verify_cmd is None:
982
+ verify_cmd = os.getenv("PDD_AGENTIC_VERIFY_CMD", None)
983
+
984
+ if verify_cmd is None:
985
+ verify_cmd = default_verify_cmd_for(get_language(os.path.splitext(code_path)[1]), unit_test_file)
986
+
987
+ # Load primary prompt template
988
+ primary_prompt_template = load_prompt_template("agentic_fix_primary_LLM")
989
+ if not primary_prompt_template:
990
+ return False, "Failed to load primary agent prompt template.", est_cost, used_model, changed_files
991
+
992
+ # Fill primary instruction (includes code/tests/error/markers/verify_cmd hint)
993
+ primary_instr = primary_prompt_template.format(
994
+ code_abs=str(code_path),
995
+ test_abs=str(Path(unit_test_file).resolve()),
996
+ begin=_begin_marker(code_path),
997
+ end=_end_marker(code_path),
998
+ prompt_content=prompt_content,
999
+ code_content=orig_code,
1000
+ test_content=test_content,
1001
+ error_content=error_content,
1002
+ verify_cmd=verify_cmd or "No verification command provided.",
1003
+ )
1004
+ instruction_file = working_dir / "agentic_fix_instructions.txt"
1005
+ instruction_file.write_text(primary_instr, encoding="utf-8")
1006
+ _info(f"[cyan]Instruction file: {instruction_file.resolve()} ({instruction_file.stat().st_size} bytes)[/cyan]")
1007
+ _print_head("Instruction preview", primary_instr)
1008
+
1009
+ # Decide verification enablement
1010
+ if verify_force:
1011
+ verify_enabled = True
1012
+ # If a verification command is present (from user or defaults), ALWAYS enable verification.
1013
+ elif verify_cmd:
1014
+ verify_enabled = True
1015
+ else:
1016
+ if env_verify is None:
1017
+ # AUTO mode: if not explicitly disabled, allow agent-supplied TESTCMD
1018
+ verify_enabled = True
1019
+ elif env_verify.lower() == "auto":
1020
+ verify_enabled = False
1021
+ else:
1022
+ verify_enabled = (env_verify != "0")
1023
+
1024
+ allow_new = True # allow creating new support files when the agent emits them
1025
+
1026
+ # Try each available agent in order
1027
+ for provider in available_agents:
1028
+ used_model = f"agentic-{provider}"
1029
+ cmd = get_agent_command(provider, instruction_file)
1030
+ binary = (cmd[0] if cmd else {"anthropic": "claude", "google": "gemini", "openai": "codex"}.get(provider, ""))
1031
+ cli_path = shutil.which(binary) or "NOT-IN-PATH"
1032
+ _info(f"[cyan]Attempting fix with {provider.capitalize()} agent...[/cyan]")
1033
+ if _IS_VERBOSE:
1034
+ _verbose(f"[cyan]CLI binary: {binary} -> {cli_path}[/cyan]")
1035
+ if cmd:
1036
+ _verbose(f"Executing (cwd={working_dir}): {' '.join(cmd)}")
1037
+
1038
+ # Skip if the provider CLI is not available on PATH
1039
+ if cli_path == "NOT-IN-PATH":
1040
+ _info(f"[yellow]Skipping {provider.capitalize()} (CLI '{binary}' not found in PATH).[/yellow]")
1041
+ continue
1042
+
1043
+ # PRIMARY-FIRST: Try the full agent approach first (allows exploration, debugging)
1044
+ _info(f"[cyan]Trying primary approach with {provider.capitalize()}...[/cyan]")
1045
+ est_cost += _AGENT_COST_PER_CALL
1046
+
1047
+ # Snapshot mtimes before agent run
1048
+ mtime_snapshot = _snapshot_mtimes(working_dir)
1049
+
1050
+ try:
1051
+ if provider == "openai":
1052
+ res = _run_openai_variants(primary_instr, working_dir, max(30, _AGENT_CALL_TIMEOUT // 2), "primary")
1053
+ elif provider == "anthropic":
1054
+ res = _run_anthropic_variants(primary_instr, working_dir, max(30, _AGENT_CALL_TIMEOUT // 2), "primary")
1055
+ elif provider == "google":
1056
+ res = _run_google_variants(primary_instr, working_dir, max(30, _AGENT_CALL_TIMEOUT // 2), "primary")
1057
+ else:
1058
+ res = _run_cli(cmd, working_dir, _AGENT_CALL_TIMEOUT)
1059
+ except subprocess.TimeoutExpired:
1060
+ _info(f"[yellow]{provider.capitalize()} agent timed out after {_AGENT_CALL_TIMEOUT}s. Trying next...[/yellow]")
1061
+ continue
1062
+
1063
+ _print_head(f"{provider.capitalize()} stdout", res.stdout or "")
1064
+ _print_head(f"{provider.capitalize()} stderr", res.stderr or "")
1065
+
1066
+ # Detect direct changes by agent
1067
+ direct_changes = _detect_mtime_changes(working_dir, mtime_snapshot)
1068
+ changed_files.extend(direct_changes)
1069
+
1070
+ # Parse emitted changes (multi-file preferred)
1071
+ multi = _extract_files_from_output(res.stdout or "", res.stderr or "")
1072
+ if multi:
1073
+ _info("[cyan]Detected multi-file corrected content (primary attempt). Applying...[/cyan]")
1074
+ applied = _apply_file_map(multi, working_dir, code_path, allow_new)
1075
+ changed_files.extend([str(p) for p in applied])
1076
+ else:
1077
+ # Single-file fallback or Gemini code fence
1078
+ harvested = _extract_corrected_from_output(res.stdout or "", res.stderr or "", code_path.resolve())
1079
+ if harvested is not None:
1080
+ _info("[cyan]Detected corrected file content in agent output (primary attempt). Applying patch...[/cyan]")
1081
+ body_to_write = _normalize_code_text(harvested)
1082
+ code_path.write_text(body_to_write, encoding="utf-8")
1083
+ changed_files.append(str(code_path))
1084
+ elif provider == "google":
1085
+ code_block = _extract_python_code_block(res.stdout or "", res.stderr or "")
1086
+ if code_block:
1087
+ _info("[cyan]Detected a Python code block from Google (no markers). Applying patch...[/cyan]")
1088
+ body_to_write = _normalize_code_text(code_block)
1089
+ code_path.write_text(body_to_write, encoding="utf-8")
1090
+ changed_files.append(str(code_path))
1091
+
1092
+ # Show diff (verbose) and decide whether to verify
1093
+ new_code = code_path.read_text(encoding="utf-8")
1094
+ new_test = test_path.read_text(encoding="utf-8")
1095
+ _print_diff(orig_code, new_code, code_path)
1096
+ if new_test != orig_test:
1097
+ _print_diff(orig_test, new_test, test_path)
1098
+ if str(test_path) not in changed_files:
1099
+ changed_files.append(str(test_path))
1100
+
1101
+ # Proceed to verify if: agent returned 0, OR either file changed, OR markers found, OR direct changes
1102
+ code_changed = new_code != orig_code
1103
+ test_changed = new_test != orig_test
1104
+ proceed_to_verify = (res.returncode == 0) or code_changed or test_changed or bool(multi) or bool(direct_changes)
1105
+ if proceed_to_verify:
1106
+ ok = _post_apply_verify_or_testcmd(
1107
+ provider, unit_test_file, working_dir,
1108
+ verify_cmd=verify_cmd, verify_enabled=verify_enabled,
1109
+ stdout=res.stdout or "", stderr=res.stderr or ""
1110
+ )
1111
+ if ok:
1112
+ _always(f"[bold green]{provider.capitalize()} agent completed successfully and tests passed.[/bold green]")
1113
+ try:
1114
+ instruction_file.unlink()
1115
+ except Exception:
1116
+ pass
1117
+ return True, f"Agentic fix successful with {provider.capitalize()}.", est_cost, used_model, changed_files
1118
+
1119
+ # PRIMARY FAILED - Try harvest as a quick fallback before moving to next provider
1120
+ if provider in ("google", "openai", "anthropic"):
1121
+ _info("[yellow]Primary attempt did not pass; trying harvest fallback...[/yellow]")
1122
+ est_cost += _AGENT_COST_PER_CALL
1123
+ try:
1124
+ if _try_harvest_then_verify(
1125
+ provider,
1126
+ code_path,
1127
+ unit_test_file,
1128
+ orig_code,
1129
+ prompt_content,
1130
+ test_content,
1131
+ error_content,
1132
+ working_dir,
1133
+ verify_cmd=verify_cmd,
1134
+ verify_enabled=verify_enabled,
1135
+ changed_files=changed_files,
1136
+ ):
1137
+ try:
1138
+ instruction_file.unlink()
1139
+ except Exception:
1140
+ pass
1141
+ return True, f"Agentic fix successful with {provider.capitalize()} (harvest fallback).", est_cost, used_model, changed_files
1142
+ except subprocess.TimeoutExpired:
1143
+ _info(f"[yellow]{provider.capitalize()} harvest fallback timed out.[/yellow]")
1144
+
1145
+ # Prepare for next iteration/provider: update baseline code snapshot
1146
+ orig_code = new_code
1147
+ _info(f"[yellow]{provider.capitalize()} attempt did not yield a passing test. Trying next...[/yellow]")
1148
+
1149
+ # No providers managed to pass verification
1150
+ try:
1151
+ if instruction_file and instruction_file.exists():
1152
+ instruction_file.unlink()
1153
+ except Exception:
1154
+ pass
1155
+ return False, "All agents failed to produce a passing fix (no local fallback).", est_cost, used_model, changed_files
1156
+
1157
+ except FileNotFoundError as e:
1158
+ # Common failure: provider CLI not installed/in PATH, or missing input files
1159
+ msg = f"A required file or command was not found: {e}. Is the agent CLI installed and in your PATH?"
1160
+ _always(f"[bold red]Error:[/bold red] {msg}")
1161
+ try:
1162
+ if instruction_file and instruction_file.exists():
1163
+ instruction_file.unlink()
1164
+ except Exception:
1165
+ pass
1166
+ return False, msg, 0.0, "agentic-cli", changed_files
1167
+ except Exception as e:
1168
+ # Safety net for any unexpected runtime error
1169
+ _always(f"[bold red]An unexpected error occurred during agentic fix:[/bold red] {e}")
1170
+ try:
1171
+ if instruction_file and instruction_file.exists():
1172
+ instruction_file.unlink()
1173
+ except Exception:
1174
+ pass
1175
+ return False, str(e), 0.0, "agentic-cli", changed_files
1176
+
1177
+ # Back-compat public alias for tests/consumers
1178
+ # Expose the harvest function under a stable name used by earlier code/tests.
1179
+ try_harvest_then_verify = _try_harvest_then_verify