pdd-cli 0.0.45__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 (114) 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 +73 -21
  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 +258 -82
  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 -63
  40. pdd/data/llm_model.csv +20 -18
  41. pdd/detect_change_main.py +5 -4
  42. pdd/fix_code_loop.py +330 -76
  43. pdd/fix_error_loop.py +207 -61
  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 +306 -272
  48. pdd/fix_verification_main.py +28 -9
  49. pdd/generate_output_paths.py +93 -10
  50. pdd/generate_test.py +16 -5
  51. pdd/get_jwt_token.py +9 -2
  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/incremental_code_generator.py +2 -2
  56. pdd/insert_includes.py +11 -3
  57. pdd/llm_invoke.py +1269 -103
  58. pdd/load_prompt_template.py +36 -10
  59. pdd/pdd_completion.fish +25 -2
  60. pdd/pdd_completion.sh +30 -4
  61. pdd/pdd_completion.zsh +79 -4
  62. pdd/postprocess.py +10 -3
  63. pdd/preprocess.py +228 -15
  64. pdd/preprocess_main.py +8 -5
  65. pdd/prompts/agentic_crash_explore_LLM.prompt +49 -0
  66. pdd/prompts/agentic_fix_explore_LLM.prompt +45 -0
  67. pdd/prompts/agentic_fix_harvest_only_LLM.prompt +48 -0
  68. pdd/prompts/agentic_fix_primary_LLM.prompt +85 -0
  69. pdd/prompts/agentic_update_LLM.prompt +1071 -0
  70. pdd/prompts/agentic_verify_explore_LLM.prompt +45 -0
  71. pdd/prompts/auto_include_LLM.prompt +100 -905
  72. pdd/prompts/detect_change_LLM.prompt +122 -20
  73. pdd/prompts/example_generator_LLM.prompt +22 -1
  74. pdd/prompts/extract_code_LLM.prompt +5 -1
  75. pdd/prompts/extract_program_code_fix_LLM.prompt +7 -1
  76. pdd/prompts/extract_prompt_update_LLM.prompt +7 -8
  77. pdd/prompts/extract_promptline_LLM.prompt +17 -11
  78. pdd/prompts/find_verification_errors_LLM.prompt +6 -0
  79. pdd/prompts/fix_code_module_errors_LLM.prompt +4 -2
  80. pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +8 -0
  81. pdd/prompts/fix_verification_errors_LLM.prompt +22 -0
  82. pdd/prompts/generate_test_LLM.prompt +21 -6
  83. pdd/prompts/increase_tests_LLM.prompt +1 -5
  84. pdd/prompts/insert_includes_LLM.prompt +228 -108
  85. pdd/prompts/trace_LLM.prompt +25 -22
  86. pdd/prompts/unfinished_prompt_LLM.prompt +85 -1
  87. pdd/prompts/update_prompt_LLM.prompt +22 -1
  88. pdd/pytest_output.py +127 -12
  89. pdd/render_mermaid.py +236 -0
  90. pdd/setup_tool.py +648 -0
  91. pdd/simple_math.py +2 -0
  92. pdd/split_main.py +3 -2
  93. pdd/summarize_directory.py +49 -6
  94. pdd/sync_determine_operation.py +543 -98
  95. pdd/sync_main.py +81 -31
  96. pdd/sync_orchestration.py +1334 -751
  97. pdd/sync_tui.py +848 -0
  98. pdd/template_registry.py +264 -0
  99. pdd/templates/architecture/architecture_json.prompt +242 -0
  100. pdd/templates/generic/generate_prompt.prompt +174 -0
  101. pdd/trace.py +168 -12
  102. pdd/trace_main.py +4 -3
  103. pdd/track_cost.py +151 -61
  104. pdd/unfinished_prompt.py +49 -3
  105. pdd/update_main.py +549 -67
  106. pdd/update_model_costs.py +2 -2
  107. pdd/update_prompt.py +19 -4
  108. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.90.dist-info}/METADATA +19 -6
  109. pdd_cli-0.0.90.dist-info/RECORD +153 -0
  110. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.90.dist-info}/licenses/LICENSE +1 -1
  111. pdd_cli-0.0.45.dist-info/RECORD +0 -116
  112. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.90.dist-info}/WHEEL +0 -0
  113. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.90.dist-info}/entry_points.txt +0 -0
  114. {pdd_cli-0.0.45.dist-info → pdd_cli-0.0.90.dist-info}/top_level.txt +0 -0
pdd/agentic_common.py ADDED
@@ -0,0 +1,863 @@
1
+ # pdd/agentic_common.py
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import secrets
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Tuple
12
+
13
+ from rich.console import Console
14
+
15
+ from .llm_invoke import LLM_MODEL_CSV_PATH, _load_model_data
16
+
17
+ console = Console()
18
+
19
+ AGENT_PROVIDER_PREFERENCE: List[str] = ["anthropic", "google", "openai"]
20
+
21
+ # CLI command mapping for each provider
22
+ CLI_COMMANDS: Dict[str, str] = {
23
+ "anthropic": "claude",
24
+ "google": "gemini",
25
+ "openai": "codex",
26
+ }
27
+
28
+ # Timeouts
29
+ DEFAULT_TIMEOUT_SECONDS: float = 240.0
30
+ TIMEOUT_ENV_VAR: str = "PDD_AGENTIC_TIMEOUT"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TokenPricing:
35
+ """
36
+ Simple per-token pricing descriptor.
37
+
38
+ Prices are expressed in USD per 1,000,000 tokens.
39
+ cached_input_multiplier is the fraction of full input price charged
40
+ for cached tokens (e.g. 0.25 == 75% discount).
41
+ """
42
+
43
+ input_per_million: float
44
+ output_per_million: float
45
+ cached_input_multiplier: float = 1.0
46
+
47
+
48
+ # Approximate Gemini pricing by model family.
49
+ # These values can be refined if needed; they are only used when the
50
+ # provider returns token counts instead of a direct USD cost.
51
+ GEMINI_PRICING_BY_FAMILY: Dict[str, TokenPricing] = {
52
+ "flash": TokenPricing(input_per_million=0.35, output_per_million=1.05, cached_input_multiplier=0.5),
53
+ "pro": TokenPricing(input_per_million=3.50, output_per_million=10.50, cached_input_multiplier=0.5),
54
+ "default": TokenPricing(input_per_million=0.35, output_per_million=1.05, cached_input_multiplier=0.5),
55
+ }
56
+
57
+ # Codex/OpenAI pricing (explicitly provided in prompt)
58
+ CODEX_PRICING: TokenPricing = TokenPricing(
59
+ input_per_million=1.50,
60
+ output_per_million=6.00,
61
+ cached_input_multiplier=0.25, # 75% discount for cached tokens
62
+ )
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Logging utilities (Rich-based, respect verbose/quiet flags)
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def _format_label(label: str) -> str:
71
+ return f"[{label}] " if label else ""
72
+
73
+
74
+ def log_info(message: str, *, verbose: bool, quiet: bool, label: str = "") -> None:
75
+ """
76
+ Log an informational message.
77
+
78
+ Skips output when quiet=True.
79
+ """
80
+ if quiet:
81
+ return
82
+ prefix = _format_label(label)
83
+ console.print(f"{prefix}{message}")
84
+
85
+
86
+ def log_debug(message: str, *, verbose: bool, quiet: bool, label: str = "") -> None:
87
+ """
88
+ Log a debug message.
89
+
90
+ Only emits output when verbose=True and quiet=False.
91
+ """
92
+ if quiet or not verbose:
93
+ return
94
+ prefix = _format_label(label)
95
+ console.log(f"{prefix}{message}")
96
+
97
+
98
+ def log_error(message: str, *, verbose: bool, quiet: bool, label: str = "") -> None:
99
+ """
100
+ Log an error message.
101
+
102
+ Errors are always printed, even in quiet mode.
103
+ """
104
+ prefix = _format_label(label)
105
+ console.print(f"[red]{prefix}{message}[/red]")
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Internal helpers
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ def _safe_load_model_data() -> Any | None:
114
+ """
115
+ Best-effort wrapper around _load_model_data.
116
+
117
+ This is used as part of provider availability checks so that we
118
+ respect whatever configuration llm_invoke is using (including
119
+ any API-key related metadata encoded in the model CSV).
120
+ """
121
+ try:
122
+ return _load_model_data(LLM_MODEL_CSV_PATH)
123
+ except Exception:
124
+ return None
125
+
126
+
127
+ def _provider_has_api_key(provider: str, model_data: Any | None) -> bool:
128
+ """
129
+ Determine whether the given provider has an API key or CLI auth configured.
130
+
131
+ This function:
132
+ - For Anthropic: Also checks if Claude CLI is available (subscription auth)
133
+ which doesn't require an API key.
134
+ - Attempts to infer API-key environment variable names from the
135
+ llm_invoke model data (if it is a DataFrame-like object).
136
+ - Falls back to well-known default environment variable names.
137
+
138
+ The actual presence of API keys is checked via os.environ.
139
+ """
140
+ env = os.environ
141
+
142
+ # For Anthropic: Check if Claude CLI is available for subscription auth
143
+ # This is more robust as it uses the user's Claude subscription instead of API credits
144
+ if provider == "anthropic":
145
+ if shutil.which("claude"):
146
+ # Claude CLI is available - we can use subscription auth
147
+ # even without an API key
148
+ return True
149
+
150
+ # Try to extract env var hints from model_data, if it looks like a DataFrame.
151
+ inferred_env_vars: List[str] = []
152
+ if model_data is not None:
153
+ try:
154
+ columns = list(getattr(model_data, "columns", []))
155
+ if "provider" in columns:
156
+ # DataFrame-like path
157
+ try:
158
+ df = model_data # type: ignore[assignment]
159
+ # Filter rows matching provider name (case-insensitive)
160
+ provider_mask = df["provider"].str.lower() == provider.lower() # type: ignore[index]
161
+ provider_rows = df[provider_mask]
162
+ # Look for any column that might specify an API-key env var
163
+ candidate_cols = [
164
+ c
165
+ for c in columns
166
+ if "api" in c.lower() and "key" in c.lower() or "env" in c.lower()
167
+ ]
168
+ for _, row in provider_rows.iterrows(): # type: ignore[attr-defined]
169
+ for col in candidate_cols:
170
+ value = str(row.get(col, "")).strip()
171
+ # Heuristic: looks like an env var name (upper & contains underscore)
172
+ if value and value.upper() == value and "_" in value:
173
+ inferred_env_vars.append(value)
174
+ except Exception:
175
+ # If anything above fails, we silently fall back to defaults.
176
+ pass
177
+ except Exception:
178
+ pass
179
+
180
+ default_env_map: Dict[str, List[str]] = {
181
+ "anthropic": ["ANTHROPIC_API_KEY"],
182
+ "google": ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
183
+ "openai": ["OPENAI_API_KEY"],
184
+ }
185
+
186
+ env_candidates = inferred_env_vars or default_env_map.get(provider, [])
187
+ return any(env.get(name) for name in env_candidates)
188
+
189
+
190
+ def _get_agent_timeout() -> float:
191
+ """
192
+ Resolve the agentic subprocess timeout from environment, with a sane default.
193
+ """
194
+ raw = os.getenv(TIMEOUT_ENV_VAR)
195
+ if not raw:
196
+ return DEFAULT_TIMEOUT_SECONDS
197
+ try:
198
+ value = float(raw)
199
+ if value <= 0:
200
+ raise ValueError
201
+ return value
202
+ except ValueError:
203
+ return DEFAULT_TIMEOUT_SECONDS
204
+
205
+
206
+ def _build_subprocess_env(
207
+ base: Optional[Mapping[str, str]] = None,
208
+ *,
209
+ use_cli_auth: bool = False,
210
+ ) -> Dict[str, str]:
211
+ """
212
+ Build a sanitized environment for non-interactive subprocess execution.
213
+
214
+ Ensures:
215
+ - TERM=dumb
216
+ - NO_COLOR=1
217
+ - CI=1
218
+ while preserving existing variables (including API keys).
219
+
220
+ Args:
221
+ base: Optional base environment mapping (defaults to os.environ).
222
+ use_cli_auth: If True, remove ANTHROPIC_API_KEY to force Claude CLI
223
+ subscription auth instead of API key auth. This is more
224
+ robust as it uses the user's Claude subscription.
225
+ """
226
+ env: Dict[str, str] = dict(base or os.environ)
227
+ env.setdefault("TERM", "dumb")
228
+ env.setdefault("NO_COLOR", "1")
229
+ env.setdefault("CI", "1")
230
+
231
+ if use_cli_auth:
232
+ # Remove API key to force Claude CLI subscription auth
233
+ env.pop("ANTHROPIC_API_KEY", None)
234
+
235
+ return env
236
+
237
+
238
+ def _build_provider_command(
239
+ provider: str,
240
+ instruction: str,
241
+ *,
242
+ use_interactive_mode: bool = False,
243
+ ) -> List[str]:
244
+ """
245
+ Build the CLI command line for the given provider.
246
+
247
+ Provider commands:
248
+
249
+ - Anthropic (Claude Code):
250
+ Normal: ["claude", "-p", <instruction>, "--dangerously-skip-permissions", "--output-format", "json"]
251
+ Interactive (more robust, uses subscription auth):
252
+ ["claude", "--dangerously-skip-permissions", "--output-format", "json", <instruction>]
253
+
254
+ - Google (Gemini CLI):
255
+ Normal: ["gemini", "-p", <instruction>, "--yolo", "--output-format", "json"]
256
+ Interactive: ["gemini", "--yolo", "--output-format", "json", <instruction>]
257
+
258
+ - OpenAI (Codex CLI):
259
+ ["codex", "exec", "--full-auto", "--json", <instruction>]
260
+
261
+ Args:
262
+ provider: The provider name ("anthropic", "google", "openai").
263
+ instruction: The instruction to pass to the CLI.
264
+ use_interactive_mode: If True, use interactive mode instead of -p flag.
265
+ This is more robust for Anthropic as it uses
266
+ subscription auth and allows full file access.
267
+ """
268
+ if provider == "anthropic":
269
+ if use_interactive_mode:
270
+ # Interactive mode: no -p flag, uses subscription auth
271
+ # This allows full file access and is more robust
272
+ return [
273
+ "claude",
274
+ "--dangerously-skip-permissions",
275
+ "--output-format",
276
+ "json",
277
+ instruction,
278
+ ]
279
+ else:
280
+ return [
281
+ "claude",
282
+ "-p",
283
+ instruction,
284
+ "--dangerously-skip-permissions",
285
+ "--output-format",
286
+ "json",
287
+ ]
288
+ if provider == "google":
289
+ if use_interactive_mode:
290
+ # Interactive mode for Gemini
291
+ return [
292
+ "gemini",
293
+ "--yolo",
294
+ "--output-format",
295
+ "json",
296
+ instruction,
297
+ ]
298
+ else:
299
+ return [
300
+ "gemini",
301
+ "-p",
302
+ instruction,
303
+ "--yolo",
304
+ "--output-format",
305
+ "json",
306
+ ]
307
+ if provider == "openai":
308
+ return [
309
+ "codex",
310
+ "exec",
311
+ "--full-auto",
312
+ "--json",
313
+ instruction,
314
+ ]
315
+ raise ValueError(f"Unknown provider: {provider}")
316
+
317
+
318
+ def _classify_gemini_model(model_name: str) -> str:
319
+ """
320
+ Classify a Gemini model name into a pricing family: 'flash', 'pro', or 'default'.
321
+ """
322
+ lower = model_name.lower()
323
+ if "flash" in lower:
324
+ return "flash"
325
+ if "pro" in lower:
326
+ return "pro"
327
+ return "default"
328
+
329
+
330
+ def _safe_int(value: Any) -> int:
331
+ try:
332
+ return int(value)
333
+ except (TypeError, ValueError):
334
+ return 0
335
+
336
+
337
+ def _calculate_gemini_cost(stats: Mapping[str, Any]) -> float:
338
+ """
339
+ Compute total Gemini cost from stats.models[model]["tokens"] entries.
340
+
341
+ Each model entry should have:
342
+ tokens = { "prompt": int, "candidates": int, "cached": int, ... }
343
+
344
+ Pricing is determined by the model family (flash/pro/default).
345
+ Cached tokens are charged at a discounted rate.
346
+ """
347
+ models = stats.get("models") or {}
348
+ if not isinstance(models, Mapping):
349
+ return 0.0
350
+
351
+ total_cost = 0.0
352
+ for model_name, model_data in models.items():
353
+ if not isinstance(model_data, Mapping):
354
+ continue
355
+ tokens = model_data.get("tokens") or {}
356
+ if not isinstance(tokens, Mapping):
357
+ continue
358
+
359
+ prompt_tokens = _safe_int(tokens.get("prompt"))
360
+ output_tokens = _safe_int(tokens.get("candidates"))
361
+ cached_tokens = _safe_int(tokens.get("cached"))
362
+
363
+ family = _classify_gemini_model(str(model_name))
364
+ pricing = GEMINI_PRICING_BY_FAMILY.get(family, GEMINI_PRICING_BY_FAMILY["default"])
365
+
366
+ # Assume prompt_tokens includes cached_tokens; charge non-cached at full price,
367
+ # cached at a discounted rate.
368
+ new_prompt_tokens = max(prompt_tokens - cached_tokens, 0)
369
+ effective_cached_tokens = min(cached_tokens, prompt_tokens)
370
+
371
+ cost_input_new = new_prompt_tokens * pricing.input_per_million / 1_000_000
372
+ cost_input_cached = (
373
+ effective_cached_tokens
374
+ * pricing.input_per_million
375
+ * pricing.cached_input_multiplier
376
+ / 1_000_000
377
+ )
378
+ cost_output = output_tokens * pricing.output_per_million / 1_000_000
379
+
380
+ total_cost += cost_input_new + cost_input_cached + cost_output
381
+
382
+ return total_cost
383
+
384
+
385
+ def _calculate_codex_cost(usage: Mapping[str, Any]) -> float:
386
+ """
387
+ Compute Codex/OpenAI cost from a `usage` dict with:
388
+
389
+ - input_tokens
390
+ - output_tokens
391
+ - cached_input_tokens
392
+
393
+ Cached tokens are charged at a 75% discount (i.e. 25% of full price).
394
+ """
395
+ input_tokens = _safe_int(usage.get("input_tokens"))
396
+ output_tokens = _safe_int(usage.get("output_tokens"))
397
+ cached_input_tokens = _safe_int(usage.get("cached_input_tokens"))
398
+
399
+ new_input_tokens = max(input_tokens - cached_input_tokens, 0)
400
+ effective_cached_tokens = min(cached_input_tokens, input_tokens)
401
+
402
+ pricing = CODEX_PRICING
403
+
404
+ cost_input_new = new_input_tokens * pricing.input_per_million / 1_000_000
405
+ cost_input_cached = (
406
+ effective_cached_tokens
407
+ * pricing.input_per_million
408
+ * pricing.cached_input_multiplier
409
+ / 1_000_000
410
+ )
411
+ cost_output = output_tokens * pricing.output_per_million / 1_000_000
412
+
413
+ return cost_input_new + cost_input_cached + cost_output
414
+
415
+
416
+ def _parse_anthropic_result(data: Mapping[str, Any]) -> Tuple[bool, str, float]:
417
+ """
418
+ Parse Claude Code (Anthropic) JSON result.
419
+
420
+ Expected:
421
+ - data["response"]: main content
422
+ - data["error"]: optional error block
423
+ - data["total_cost_usd"]: total cost in USD (if available)
424
+ """
425
+ error_info = data.get("error")
426
+ has_error = bool(error_info)
427
+
428
+ if isinstance(error_info, Mapping):
429
+ error_msg = str(error_info.get("message") or error_info)
430
+ elif error_info is not None:
431
+ error_msg = str(error_info)
432
+ else:
433
+ error_msg = ""
434
+
435
+ response_text = str(data.get("response") or "")
436
+ if not response_text and error_msg:
437
+ response_text = error_msg
438
+
439
+ cost_raw = data.get("total_cost_usd")
440
+ try:
441
+ cost = float(cost_raw)
442
+ except (TypeError, ValueError):
443
+ cost = 0.0
444
+
445
+ return (not has_error, response_text, cost)
446
+
447
+
448
+ def _parse_gemini_result(data: Mapping[str, Any]) -> Tuple[bool, str, float]:
449
+ """
450
+ Parse Gemini CLI JSON result.
451
+
452
+ Expected high-level structure:
453
+ {
454
+ "response": "string",
455
+ "stats": { ... per-model token usage ... },
456
+ "error": { ... } # optional
457
+ }
458
+ """
459
+ error_info = data.get("error")
460
+ has_error = bool(error_info)
461
+
462
+ if isinstance(error_info, Mapping):
463
+ error_msg = str(error_info.get("message") or error_info)
464
+ elif error_info is not None:
465
+ error_msg = str(error_info)
466
+ else:
467
+ error_msg = ""
468
+
469
+ response_text = str(data.get("response") or "")
470
+ if not response_text and error_msg:
471
+ response_text = error_msg
472
+
473
+ stats = data.get("stats") or {}
474
+ cost = 0.0
475
+ if isinstance(stats, Mapping):
476
+ try:
477
+ cost = _calculate_gemini_cost(stats)
478
+ except Exception:
479
+ cost = 0.0
480
+
481
+ return (not has_error, response_text, cost)
482
+
483
+
484
+ def _extract_codex_usage(stdout: str) -> Optional[Mapping[str, Any]]:
485
+ """
486
+ Extract the latest `usage` object from Codex JSONL output.
487
+
488
+ The `codex exec --json` command emits newline-delimited JSON events.
489
+ We scan all lines and keep the most recent event containing a `usage` key.
490
+ """
491
+ last_usage: Optional[Mapping[str, Any]] = None
492
+ for line in stdout.splitlines():
493
+ line = line.strip()
494
+ if not line:
495
+ continue
496
+ try:
497
+ event = json.loads(line)
498
+ except json.JSONDecodeError:
499
+ continue
500
+ usage = event.get("usage")
501
+ if isinstance(usage, Mapping):
502
+ last_usage = usage
503
+ return last_usage
504
+
505
+
506
+ def _extract_codex_output(stdout: str) -> str:
507
+ """
508
+ Extract assistant-visible output text from Codex JSONL output.
509
+
510
+ Heuristic:
511
+ - Collect content from events with type == "message" and role == "assistant"
512
+ - Fallback to raw stdout if nothing is found
513
+ """
514
+ assistant_messages: List[str] = []
515
+ for line in stdout.splitlines():
516
+ line = line.strip()
517
+ if not line:
518
+ continue
519
+ try:
520
+ event = json.loads(line)
521
+ except json.JSONDecodeError:
522
+ continue
523
+
524
+ if event.get("type") == "message" and event.get("role") == "assistant":
525
+ content = event.get("content")
526
+ if isinstance(content, str):
527
+ assistant_messages.append(content)
528
+ elif isinstance(content, list):
529
+ # Sometimes content may be a list of segments; concatenate any text fields.
530
+ parts: List[str] = []
531
+ for part in content:
532
+ if isinstance(part, Mapping) and "text" in part:
533
+ parts.append(str(part["text"]))
534
+ else:
535
+ parts.append(str(part))
536
+ assistant_messages.append("".join(parts))
537
+
538
+ if assistant_messages:
539
+ return "\n".join(assistant_messages)
540
+
541
+ return stdout.strip()
542
+
543
+
544
+ def _run_with_provider(
545
+ provider: str,
546
+ agentic_instruction: str,
547
+ cwd: Path,
548
+ *,
549
+ verbose: bool,
550
+ quiet: bool,
551
+ label: str = "",
552
+ ) -> Tuple[bool, str, float]:
553
+ """
554
+ Invoke the given provider's CLI in headless JSON mode.
555
+
556
+ For Anthropic (Claude), uses subscription auth (removes API key from env)
557
+ and interactive mode (no -p flag) for more robust authentication that
558
+ doesn't require API credits.
559
+
560
+ Returns:
561
+ (success, message, cost)
562
+
563
+ - success: True if the CLI completed successfully without reported errors
564
+ - message: natural-language output on success, or error description on failure
565
+ - cost: estimated USD cost for this attempt
566
+ """
567
+ # Use interactive mode and CLI auth for Anthropic (more robust, uses subscription)
568
+ use_interactive = provider == "anthropic"
569
+ use_cli_auth = provider == "anthropic"
570
+
571
+ cmd = _build_provider_command(
572
+ provider,
573
+ agentic_instruction,
574
+ use_interactive_mode=use_interactive,
575
+ )
576
+ timeout = _get_agent_timeout()
577
+ env = _build_subprocess_env(use_cli_auth=use_cli_auth)
578
+
579
+ log_debug(
580
+ f"Invoking provider '{provider}' with timeout {timeout:.1f}s",
581
+ verbose=verbose,
582
+ quiet=quiet,
583
+ label=label,
584
+ )
585
+ log_debug(
586
+ f"Command: {' '.join(cmd)}",
587
+ verbose=verbose,
588
+ quiet=quiet,
589
+ label=label,
590
+ )
591
+
592
+ try:
593
+ completed = subprocess.run(
594
+ cmd,
595
+ cwd=str(cwd),
596
+ env=env,
597
+ capture_output=True,
598
+ text=True,
599
+ timeout=timeout,
600
+ check=False,
601
+ )
602
+ except FileNotFoundError:
603
+ message = f"CLI command for provider '{provider}' was not found."
604
+ log_error(message, verbose=verbose, quiet=quiet, label=label)
605
+ return False, message, 0.0
606
+ except subprocess.TimeoutExpired:
607
+ message = f"Provider '{provider}' CLI timed out after {timeout:.1f} seconds."
608
+ log_error(message, verbose=verbose, quiet=quiet, label=label)
609
+ return False, message, 0.0
610
+ except Exception as exc:
611
+ message = f"Error invoking provider '{provider}': {exc}"
612
+ log_error(message, verbose=verbose, quiet=quiet, label=label)
613
+ return False, message, 0.0
614
+
615
+ stdout = completed.stdout or ""
616
+ stderr = completed.stderr or ""
617
+ if verbose and stdout:
618
+ log_debug(f"{provider} stdout:\n{stdout}", verbose=verbose, quiet=quiet, label=label)
619
+ if verbose and stderr:
620
+ log_debug(f"{provider} stderr:\n{stderr}", verbose=verbose, quiet=quiet, label=label)
621
+
622
+ # Default assumptions
623
+ success = completed.returncode == 0
624
+ cost = 0.0
625
+ message: str
626
+
627
+ # Provider-specific JSON parsing and cost extraction
628
+ if provider in ("anthropic", "google"):
629
+ raw_json = stdout.strip() or stderr.strip()
630
+ if not raw_json:
631
+ message = f"Provider '{provider}' produced no JSON output."
632
+ log_error(message, verbose=verbose, quiet=quiet, label=label)
633
+ return False, message, 0.0
634
+
635
+ try:
636
+ data = json.loads(raw_json)
637
+ except json.JSONDecodeError as exc:
638
+ # Include raw output in the error message to aid debugging
639
+ # (e.g. if the provider printed a plain text error instead of JSON)
640
+ message = f"Failed to parse JSON from provider '{provider}': {exc}\nOutput: {raw_json}"
641
+ log_error(message, verbose=verbose, quiet=quiet, label=label)
642
+ return False, message, 0.0
643
+
644
+ if not isinstance(data, Mapping):
645
+ message = f"Unexpected JSON structure from provider '{provider}'."
646
+ log_error(message, verbose=verbose, quiet=quiet, label=label)
647
+ return False, message, 0.0
648
+
649
+ if provider == "anthropic":
650
+ parsed_success, response_text, cost = _parse_anthropic_result(data)
651
+ else: # google / Gemini
652
+ parsed_success, response_text, cost = _parse_gemini_result(data)
653
+
654
+ # Combine CLI exit code with JSON-level success flag
655
+ if not success or not parsed_success:
656
+ success = False
657
+ message = response_text or stderr.strip() or stdout.strip() or "No response from provider."
658
+
659
+ if not success and completed.returncode != 0 and stderr:
660
+ message = f"{message}\n\nCLI stderr:\n{stderr.strip()}"
661
+ return success, message, cost
662
+
663
+ # OpenAI / Codex: JSONL stream on stdout
664
+ if provider == "openai":
665
+ usage = _extract_codex_usage(stdout)
666
+ if usage is not None:
667
+ try:
668
+ cost = _calculate_codex_cost(usage)
669
+ except Exception:
670
+ cost = 0.0
671
+
672
+ message = _extract_codex_output(stdout)
673
+ if not success:
674
+ if stderr.strip():
675
+ message = (
676
+ f"{message}\n\nCLI stderr:\n{stderr.strip()}"
677
+ if message
678
+ else f"Codex CLI failed with exit code {completed.returncode}.\n\nstderr:\n{stderr.strip()}"
679
+ )
680
+ elif not message:
681
+ message = f"Codex CLI failed with exit code {completed.returncode}."
682
+
683
+ return success, message or "No response from provider.", cost
684
+
685
+ # Should not reach here because _build_provider_command validates provider
686
+ message = f"Unsupported provider '{provider}'."
687
+ log_error(message, verbose=verbose, quiet=quiet, label=label)
688
+ return False, message, 0.0
689
+
690
+
691
+ # ---------------------------------------------------------------------------
692
+ # Public API
693
+ # ---------------------------------------------------------------------------
694
+
695
+
696
+ def get_available_agents() -> List[str]:
697
+ """
698
+ Return a list of available agent providers, e.g. ["anthropic", "google"].
699
+
700
+ A provider is considered available if:
701
+ - Its CLI binary exists on PATH (checked via shutil.which)
702
+ - Its API key appears configured (using llm_invoke's model data plus
703
+ well-known environment variables)
704
+ """
705
+ model_data = _safe_load_model_data()
706
+ available: List[str] = []
707
+
708
+ for provider in AGENT_PROVIDER_PREFERENCE:
709
+ cli = CLI_COMMANDS.get(provider)
710
+ if not cli:
711
+ continue
712
+ if shutil.which(cli) is None:
713
+ continue
714
+ if not _provider_has_api_key(provider, model_data):
715
+ continue
716
+ available.append(provider)
717
+
718
+ return available
719
+
720
+
721
+ def run_agentic_task(
722
+ instruction: str,
723
+ cwd: Path,
724
+ *,
725
+ verbose: bool = False,
726
+ quiet: bool = False,
727
+ label: str = "",
728
+ ) -> Tuple[bool, str, float, str]:
729
+ """
730
+ Run an agentic task using the first available provider in preference order.
731
+
732
+ The task is executed in headless mode with JSON output for structured
733
+ parsing and real cost tracking.
734
+
735
+ Process:
736
+ 1. Write `instruction` into a unique temp file named
737
+ `.agentic_prompt_<random>.txt` under `cwd`.
738
+ 2. Build agentic meta-instruction:
739
+
740
+ "Read the file {prompt_file} for instructions. You have full file
741
+ access to explore and modify files as needed."
742
+
743
+ 3. Try providers in `AGENT_PROVIDER_PREFERENCE` order, but only those
744
+ returned by `get_available_agents()`.
745
+ 4. For each provider:
746
+ - Invoke its CLI in headless JSON mode with file-write permissions.
747
+ - Parse JSON to extract response text and cost.
748
+ - On success, stop and return.
749
+ - On failure, proceed to next provider.
750
+ 5. Clean up the temp prompt file.
751
+
752
+ Args:
753
+ instruction: Natural-language instruction describing the task.
754
+ cwd: Project root where the agent should operate.
755
+ verbose: Enable verbose logging (debug output).
756
+ quiet: Suppress non-error logging.
757
+ label: Optional label prefix for log messages (e.g. "agentic-fix").
758
+
759
+ Returns:
760
+ Tuple[bool, str, float, str]:
761
+ - success: Whether the task completed successfully.
762
+ - output: On success, the agent's main response text.
763
+ On failure, a human-readable error message.
764
+ - cost: Total estimated USD cost across all provider attempts.
765
+ - provider_used: Name of the successful provider
766
+ ("anthropic", "google", or "openai"),
767
+ or "" if no provider succeeded.
768
+ """
769
+ if not instruction or not instruction.strip():
770
+ message = "Agentic instruction must be a non-empty string."
771
+ log_error(message, verbose=verbose, quiet=quiet, label=label)
772
+ return False, message, 0.0, ""
773
+
774
+ if not cwd.exists() or not cwd.is_dir():
775
+ message = f"Working directory does not exist or is not a directory: {cwd}"
776
+ log_error(message, verbose=verbose, quiet=quiet, label=label)
777
+ return False, message, 0.0, ""
778
+
779
+ available = get_available_agents()
780
+ if not available:
781
+ message = "No agent providers are available. Ensure CLI tools and API keys are configured."
782
+ log_error(message, verbose=verbose, quiet=quiet, label=label)
783
+ return False, message, 0.0, ""
784
+
785
+ log_info(
786
+ f"Available providers (in preference order): {', '.join(available)}",
787
+ verbose=verbose,
788
+ quiet=quiet,
789
+ label=label,
790
+ )
791
+
792
+ # 1. Write user instruction into a unique prompt file under cwd
793
+ prompt_token = secrets.token_hex(8)
794
+ prompt_file = cwd / f".agentic_prompt_{prompt_token}.txt"
795
+
796
+ try:
797
+ prompt_file.write_text(instruction, encoding="utf-8")
798
+ except OSError as exc:
799
+ message = f"Failed to write prompt file '{prompt_file}': {exc}"
800
+ log_error(message, verbose=verbose, quiet=quiet, label=label)
801
+ return False, message, 0.0, ""
802
+
803
+ agentic_instruction = (
804
+ f"Read the file {prompt_file} for instructions. "
805
+ "You have full file access to explore and modify files as needed."
806
+ )
807
+
808
+ total_cost = 0.0
809
+ provider_errors: List[str] = []
810
+
811
+ try:
812
+ for provider in AGENT_PROVIDER_PREFERENCE:
813
+ if provider not in available:
814
+ continue
815
+
816
+ log_info(
817
+ f"Trying provider '{provider}'...",
818
+ verbose=verbose,
819
+ quiet=quiet,
820
+ label=label,
821
+ )
822
+
823
+ success, message, cost = _run_with_provider(
824
+ provider,
825
+ agentic_instruction,
826
+ cwd,
827
+ verbose=verbose,
828
+ quiet=quiet,
829
+ label=label,
830
+ )
831
+ total_cost += cost
832
+
833
+ if success:
834
+ log_info(
835
+ f"Provider '{provider}' completed successfully. "
836
+ f"Estimated cost: ${cost:.6f}",
837
+ verbose=verbose,
838
+ quiet=quiet,
839
+ label=label,
840
+ )
841
+ return True, message, total_cost, provider
842
+
843
+ provider_errors.append(f"{provider}: {message}")
844
+ log_error(
845
+ f"Provider '{provider}' failed: {message}",
846
+ verbose=verbose,
847
+ quiet=quiet,
848
+ label=label,
849
+ )
850
+
851
+ # If we reach here, all providers failed
852
+ combined_error = "All agent providers failed. " + " | ".join(provider_errors)
853
+ log_error(combined_error, verbose=verbose, quiet=quiet, label=label)
854
+ return False, combined_error, total_cost, ""
855
+
856
+ finally:
857
+ # 5. Clean up prompt file
858
+ try:
859
+ if prompt_file.exists():
860
+ prompt_file.unlink()
861
+ except OSError:
862
+ # Best-effort cleanup; ignore errors.
863
+ pass