argus-code-review 0.3.3__tar.gz → 0.4.0__tar.gz

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 (162) hide show
  1. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/CHANGELOG.md +18 -1
  2. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/PKG-INFO +1 -1
  3. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/gemini_runner.py +105 -66
  4. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/helpers.py +45 -8
  5. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/pipeline_models.py +8 -0
  6. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-cross-cutting.md +5 -6
  7. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-lite.md +1 -1
  8. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/runners.py +16 -36
  9. argus_code_review-0.4.0/tests/test_failure_diagnostics.py +129 -0
  10. argus_code_review-0.4.0/tests/test_gemini_http_retry.py +136 -0
  11. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_gemini_runner.py +48 -11
  12. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/.gitignore +0 -0
  13. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/LICENSE +0 -0
  14. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/README.md +0 -0
  15. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/__init__.py +0 -0
  16. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/bench.py +0 -0
  17. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/bench_default.toml +0 -0
  18. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/cli.py +0 -0
  19. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/config.py +0 -0
  20. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/coverage.py +0 -0
  21. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/dotenv_utils.py +0 -0
  22. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/gemini_cache.py +0 -0
  23. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/github_client.py +0 -0
  24. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/graph.py +0 -0
  25. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/llm/models.py +0 -0
  26. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/llm/output_models.py +0 -0
  27. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/llm/pricing.py +0 -0
  28. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/llm/usage.py +0 -0
  29. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/models.py +0 -0
  30. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/openai_client.py +0 -0
  31. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/openai_runner.py +0 -0
  32. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/__init__.py +0 -0
  33. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/actions_scanner.py +0 -0
  34. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/engine.py +0 -0
  35. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/eslint_bundle/.gitignore +0 -0
  36. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/eslint_bundle/README.md +0 -0
  37. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/eslint_bundle/eslint.config.js +0 -0
  38. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/eslint_bundle/package-lock.json +0 -0
  39. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/eslint_bundle/package.json +0 -0
  40. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/js_scanner.py +0 -0
  41. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/migration_scanner.py +0 -0
  42. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/rules/README.md +0 -0
  43. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/sarif.py +0 -0
  44. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/scanner_utils.py +0 -0
  45. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/secrets_scanner.py +0 -0
  46. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/shadow.py +0 -0
  47. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/terraform_scanner.py +0 -0
  48. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/precheck/workflow_lint_scanner.py +0 -0
  49. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/__init__.py +0 -0
  50. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-blocking-validator.md +0 -0
  51. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-coverage-check.md +0 -0
  52. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-feedback-verifier.md +0 -0
  53. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-planner.md +0 -0
  54. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-preflight-router.md +0 -0
  55. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-prior-art.md +0 -0
  56. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-scope.md +0 -0
  57. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-specialist-deployment.md +0 -0
  58. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-specialist-frontend.md +0 -0
  59. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-specialist-infra.md +0 -0
  60. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-specialist-llm-patterns.md +0 -0
  61. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-specialist-observability.md +0 -0
  62. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-specialist-orchestration.md +0 -0
  63. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-specialist-security.md +0 -0
  64. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-specialist-slackbot.md +0 -0
  65. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-specialist-sql.md +0 -0
  66. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-subagent.md +0 -0
  67. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-tests-and-docs.md +0 -0
  68. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts/pr-review-writer.md +0 -0
  69. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/prompts_runtime.py +0 -0
  70. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/repo_provision.py +0 -0
  71. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/review_tools.py +0 -0
  72. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/scope.py +0 -0
  73. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/storage/__init__.py +0 -0
  74. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/storage/http.py +0 -0
  75. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/storage/models.py +0 -0
  76. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/storage/precheck.py +0 -0
  77. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/storage/resolver.py +0 -0
  78. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/storage/session.py +0 -0
  79. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/storage/sql.py +0 -0
  80. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/argus/storage/sqlite.py +0 -0
  81. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/pyproject.toml +0 -0
  82. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/schema/008_add_code_reviews.sql +0 -0
  83. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/schema/009_add_reviewer_version.sql +0 -0
  84. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/schema/010_add_review_patterns.sql +0 -0
  85. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/schema/011_add_review_progress_columns.sql +0 -0
  86. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/schema/015_create_agent_runs.sql +0 -0
  87. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/schema/016_add_agent_runs_failure_reason.sql +0 -0
  88. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/schema/017_add_precheck_rules.sql +0 -0
  89. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/schema/018_widen_agent_runs_failure_reason.sql +0 -0
  90. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/__init__.py +0 -0
  91. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/conftest.py +0 -0
  92. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/golden/review_response.schema.json +0 -0
  93. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/storage/__init__.py +0 -0
  94. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/storage/test_backend_contract.py +0 -0
  95. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/storage/test_http.py +0 -0
  96. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/storage/test_models.py +0 -0
  97. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/storage/test_resolver.py +0 -0
  98. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/storage/test_session.py +0 -0
  99. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/storage/test_sql.py +0 -0
  100. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/storage/test_sqlite_backend.py +0 -0
  101. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_actions_scanner.py +0 -0
  102. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_argus_review_local.py +0 -0
  103. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_bench.py +0 -0
  104. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_bench_config_guard.py +0 -0
  105. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_catchup_gate.py +0 -0
  106. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_cli_args.py +0 -0
  107. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_cli_output_contract.py +0 -0
  108. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_cli_post_review.py +0 -0
  109. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_cli_preflight.py +0 -0
  110. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_cli_prompts.py +0 -0
  111. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_config.py +0 -0
  112. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_conftest.py +0 -0
  113. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_dotenv_utils.py +0 -0
  114. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_gemini_cache.py +0 -0
  115. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_github_client_checks_signal.py +0 -0
  116. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_github_client_write.py +0 -0
  117. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_graph_fetch_diff.py +0 -0
  118. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_graph_get_llm_temperature.py +0 -0
  119. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_graph_http_guards.py +0 -0
  120. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_graph_precheck.py +0 -0
  121. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_graph_preflight_image_bump.py +0 -0
  122. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_graph_progress.py +0 -0
  123. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_graph_storage_resolution.py +0 -0
  124. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_graph_timeout_surfacing.py +0 -0
  125. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_js_scanner.py +0 -0
  126. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_llm_models.py +0 -0
  127. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_llm_models_override.py +0 -0
  128. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_llm_pricing.py +0 -0
  129. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_migration_scanner.py +0 -0
  130. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_models.py +0 -0
  131. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_multi_round.py +0 -0
  132. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_openai_client.py +0 -0
  133. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_openai_runner.py +0 -0
  134. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_output_models.py +0 -0
  135. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_packaging.py +0 -0
  136. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_plan_review.py +0 -0
  137. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_precheck_engine.py +0 -0
  138. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_precheck_engine_integration.py +0 -0
  139. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_precheck_sarif.py +0 -0
  140. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_precheck_shadow.py +0 -0
  141. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_precheck_shadow_integration.py +0 -0
  142. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_prompts.py +0 -0
  143. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_repo_provision.py +0 -0
  144. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_review_patterns_integration.py +0 -0
  145. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_review_tools.py +0 -0
  146. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_runners_context7.py +0 -0
  147. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_runners_context_usage.py +0 -0
  148. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_runners_helpers.py +0 -0
  149. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_runners_new.py +0 -0
  150. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_runners_turn_budget.py +0 -0
  151. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_scanner_utils.py +0 -0
  152. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_scope.py +0 -0
  153. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_scope_gate.py +0 -0
  154. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_sdk_bundled_cli_version.py +0 -0
  155. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_secrets_scanner.py +0 -0
  156. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_specialist_validation.py +0 -0
  157. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_stage_costs.py +0 -0
  158. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_storage_precheck.py +0 -0
  159. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_structured_output_methods.py +0 -0
  160. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_terraform_scanner.py +0 -0
  161. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_terraform_scanner_integration.py +0 -0
  162. {argus_code_review-0.3.3 → argus_code_review-0.4.0}/tests/test_workflow_lint_scanner.py +0 -0
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-28
11
+
12
+ ### Changed
13
+
14
+ - Gemini `caching = "auto"` (the packaged default for the bulk reviewers) no longer creates an explicit context cache; only `"on"` does. Explicit caching held just the system prompt and tools (about 1-3k tokens) and Gemini then reported only that slice as cached on every turn, while the growing conversation (the bulk of each request) was billed uncached. With no explicit cache, Gemini's automatic prefix caching applies to the whole repeated prefix. In paired live runs of the same PR the reported cached share of prompt tokens went from about 7% to about 90% (total prompt tokens about equal, verdicts and finding counts equivalent), and Argus's own cost estimate for the review fell from $12.66 to $3.20. That estimate is computed from the reported `cached_content_token_count`, not from a billing export, and Google does not document how automatic cache hits interact with `cached_content`, so treat the savings as unconfirmed. Because the default behavior of `auto` changes, this ships as a minor release (0.4.0). `caching = "on"` keeps the previous explicit-cache behavior.
15
+
16
+ ## [0.3.4] - 2026-10-23
17
+
18
+ ### Fixed
19
+
20
+ - Gemini requests now retry HTTP 500/502/503/504 in place at every turn (up to three attempts with exponential jitter), retaining the conversation and without replaying tools. The outer session timeout still bounds the total work. The quota-aware whole-session turn-0 retry remains for 429s and transport errors, but a 5xx the SDK has already retried (three attempts) is not restarted, so a persistent 5xx costs at most three requests per turn; 429s are excluded from the SDK's status-only retry policy so daily-quota errors still fail fast.
21
+ - Failed Gemini sessions preserve cost and tool accounting from completed turns, and include the failed turn index in their short failure detail.
22
+ - Reviewer failure details now reach degraded-coverage reports instead of disappearing after the runner. Only canonical provider codes/statuses, standard HTTP reason phrases (such as `502 Bad Gateway`) and httpx transport error names are rendered; raw provider messages are never included. The cause precedes the reviewer-area name so it remains visible to consumers that truncate descriptions to 200 characters. Existing failure-reason values and database constraints are unchanged.
23
+ - The model-registry check in the cross-cutting and lite prompts now applies to the repository under review instead of naming one specific package's constants. A package that has no registry of its own is no longer told it must use someone else's, and a legitimately overridden model value is not flagged. The em-dash character rule, which was one project's prose convention rather than a general review criterion, is removed from the shared prompt.
24
+
10
25
  ## [0.3.3] - 2026-10-22
11
26
 
12
27
  ### Changed
@@ -448,7 +463,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
448
463
  packaged set.
449
464
  - `argus --version`, `argus prompts list`, and `argus prompts export`.
450
465
 
451
- [Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.3.3...HEAD
466
+ [Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.4.0...HEAD
467
+ [0.4.0]: https://github.com/redesignhealth/argus-review/compare/v0.3.4...v0.4.0
468
+ [0.3.4]: https://github.com/redesignhealth/argus-review/compare/v0.3.3...v0.3.4
452
469
  [0.3.3]: https://github.com/redesignhealth/argus-review/compare/v0.3.2...v0.3.3
453
470
  [0.3.2]: https://github.com/redesignhealth/argus-review/compare/v0.3.1...v0.3.2
454
471
  [0.3.1]: https://github.com/redesignhealth/argus-review/compare/v0.3.0...v0.3.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argus-code-review
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: Self-orchestrated PR review agent using LangGraph + Claude Agent SDK
5
5
  Project-URL: Repository, https://github.com/redesignhealth/argus-review
6
6
  Project-URL: Issues, https://github.com/redesignhealth/argus-review/issues
@@ -54,7 +54,7 @@ key so that function's own ``group_name`` fallback still applies
54
54
  (this runner has no access to the caller's ``group.name`` -- only to
55
55
  ``entry.role``, which is a different, bench-level identifier).
56
56
 
57
- Explicit context caching (``entry.caching in {"auto", "on"}``) is
57
+ Explicit context caching (``entry.caching == "on"``) is
58
58
  strictly best-effort: Gemini's explicit-cache API has a minimum
59
59
  content-size requirement a short prompt plus this small a toolset might
60
60
  not clear, so any exception raised while creating/reusing a cache is
@@ -147,7 +147,7 @@ import logging
147
147
  import os
148
148
  import random
149
149
  import re
150
- from dataclasses import dataclass
150
+ from dataclasses import dataclass, field
151
151
  from datetime import datetime, timezone
152
152
  from typing import Any
153
153
 
@@ -177,14 +177,22 @@ logger = logging.getLogger(__name__)
177
177
 
178
178
  _MAX_TURNS_GEMINI = 100 # raised from 45 -- see TECH-6558
179
179
 
180
- # Both "auto" and "on" attempt explicit caching today -- there is no
181
- # separate heuristic distinguishing them yet (Track 1 defined the
182
- # `caching` values without one; see argus.bench's `_VALID_CACHING`). The
183
- # graceful-degradation try/except in `_maybe_activate_cache` is what
184
- # actually protects a caller who set "auto" against a too-small
185
- # prompt+toolset that can't clear Gemini's minimum cache-content-size
186
- # requirement. "off" skips caching entirely.
187
- _CACHE_ENABLED_VALUES = frozenset({"auto", "on"})
180
+ # Only "on" creates an explicit cache. "auto" (the packaged default) and "off"
181
+ # both send the whole request inline and leave caching to Gemini's own
182
+ # automatic prefix caching, which is enabled by default on current models.
183
+ #
184
+ # Why "auto" is not explicit: an explicit cache holds only what is placed in
185
+ # it at creation time (here the system prompt, tools and tool config, about
186
+ # 1-3k tokens), and a request that references it ("cached_content=") must
187
+ # not also carry those three fields. Every turn's growing conversation
188
+ # (the diff plus all prior tool results) is then sent as ordinary
189
+ # `contents`. In live A/B runs of the same PR,
190
+ # explicit caching reported ~7% cached prompt tokens (exactly the cache size
191
+ # on every turn) while caching="off" reported ~90%, with the same total
192
+ # prompt volume. The graceful-degradation try/except in
193
+ # `_maybe_activate_cache` still protects a caller who sets "on" against a
194
+ # prompt+toolset that cannot clear Gemini's minimum cache size.
195
+ _CACHE_ENABLED_VALUES = frozenset({"on"})
188
196
 
189
197
  _DEFAULT_READ_LIMIT = 2000 # mirrors argus.review_tools._DEFAULT_READ_LIMIT
190
198
 
@@ -219,7 +227,9 @@ _HTTP_TIMEOUT_FRACTION = 0.8
219
227
  # DEADLINE_EXCEEDED/INTERNAL, or an httpx timeout/connect/read/remote-protocol
220
228
  # error) before the model returned ANY response (i.e. on turn 0), so the retry
221
229
  # restarts the session cleanly from the beginning. Later failures are not
222
- # retried. The cooldown depends on the error kind (see `_retry_decision`):
230
+ # restarted; the SDK retries only individual 5xx requests (see
231
+ # `_HTTP_RETRY_ATTEMPTS`), and a 5xx it already retried is not restarted again,
232
+ # so the restart covers 429 and transport errors only. The cooldown depends on the error kind (see `_retry_decision`):
223
233
  # 45-60 s jitter for 429/RESOURCE_EXHAUSTED (per-minute quota windows), 5-10 s
224
234
  # for 5xx/network errors; a server Retry-After/retryDelay >= 5 s is honored;
225
235
  # never more than 60 s. Daily-quota 429s are not retried. Overridable via
@@ -249,6 +259,14 @@ _TRANSIENT_HTTPX = (
249
259
  httpx.ReadError,
250
260
  httpx.RemoteProtocolError,
251
261
  )
262
+
263
+ # SDK-level per-request retry of 5xx at any turn; the request is re-sent
264
+ # unchanged, so no tools are replayed. 429s stay in _retry_decision because the
265
+ # SDK cannot tell daily quota from per-minute limits or honor Retry-After.
266
+ _HTTP_RETRY_ATTEMPTS = 3 # including the initial call
267
+ _HTTP_RETRY_STATUS_CODES = (500, 502, 503, 504)
268
+ _HTTP_RETRY_INITIAL_DELAY_S = 2.0
269
+ _HTTP_RETRY_MAX_DELAY_S = 20.0
252
270
  _DETAIL_MAX_CHARS = 300
253
271
  _SECRET_RE = re.compile(
254
272
  r"(AIza[0-9A-Za-z_\-]{20,}|(?i:(?:api[_-]?key|key|token)=)[^&\s\"']+|"
@@ -271,8 +289,38 @@ class _Progress:
271
289
  """Mutable per-session state shared between attempts and the failure handler."""
272
290
 
273
291
  responses: int = 0 # successful generate_content responses received
292
+ last_turn: int | None = None # 0-based index of the turn that last ran
274
293
  retried: bool = False
275
294
  retry_wait_s: float = 0.0
295
+ # Usage accrued so far, so a session that dies mid-flight still reports it.
296
+ prompt_tokens: int = 0
297
+ cached_tokens: int = 0
298
+ candidates_tokens: int = 0
299
+ thoughts_tokens: int = 0
300
+ tool_use_prompt_tokens: int = 0
301
+ tool_calls: list[str] = field(default_factory=list)
302
+
303
+ @property
304
+ def input_tokens(self) -> int:
305
+ # Gemini's prompt_token_count already includes the cached portion, so
306
+ # subtract it to avoid billing it at both input and cache-read rates.
307
+ # Tool-result tokens are billed as input.
308
+ return max(self.prompt_tokens - self.cached_tokens, 0) + self.tool_use_prompt_tokens
309
+
310
+ @property
311
+ def output_tokens(self) -> int:
312
+ # Thinking tokens are a separate additive bucket billed as output.
313
+ return self.candidates_tokens + self.thoughts_tokens
314
+
315
+ def cost_usd(self, model: str) -> float:
316
+ if self.responses == 0:
317
+ return 0.0
318
+ return estimate_cost_usd(
319
+ model=model,
320
+ input_tokens=self.input_tokens,
321
+ output_tokens=self.output_tokens,
322
+ cached_input_tokens=self.cached_tokens,
323
+ )
276
324
 
277
325
 
278
326
  def _scrub(text: str) -> str:
@@ -346,6 +394,8 @@ def _retry_decision(exc: BaseException) -> tuple[str, float] | None:
346
394
  if not _is_transient_error(exc):
347
395
  return None
348
396
  code, status = _error_code_status(exc)
397
+ if isinstance(exc, genai_errors.ServerError) and code in _HTTP_RETRY_STATUS_CODES:
398
+ return None # the SDK already retried this request; a restart would repeat it
349
399
  server_delay = _retry_after_seconds(exc)
350
400
  if code == 429 or (status or "").upper() == "RESOURCE_EXHAUSTED":
351
401
  if _is_daily_quota(exc, server_delay):
@@ -767,7 +817,7 @@ async def _run_turns(
767
817
  repo_root: str,
768
818
  timeout_s: float,
769
819
  started_at: datetime,
770
- progress: _Progress | None = None,
820
+ progress: _Progress,
771
821
  ) -> SessionResult:
772
822
  """The actual tool-calling loop. Returns ``failure_reason="turn_budget_exhausted"``
773
823
  when the loop ran out of turns without a ``finish_review`` call, else
@@ -834,6 +884,16 @@ async def _run_turns(
834
884
  label=label,
835
885
  )
836
886
 
887
+ # Retry only model calls, not detached synchronous cache creation.
888
+ request_http_options = types.HttpOptions(
889
+ retry_options=types.HttpRetryOptions(
890
+ attempts=_HTTP_RETRY_ATTEMPTS,
891
+ initial_delay=_HTTP_RETRY_INITIAL_DELAY_S,
892
+ max_delay=_HTTP_RETRY_MAX_DELAY_S,
893
+ http_status_codes=list(_HTTP_RETRY_STATUS_CODES),
894
+ )
895
+ )
896
+
837
897
  def _build_config(cache_name: str | None) -> types.GenerateContentConfig:
838
898
  # Gemini constraint (confirmed against the live API, not just
839
899
  # documentation): a request using `cached_content=` must NOT
@@ -847,11 +907,14 @@ async def _run_turns(
847
907
  # `CreateCachedContentConfig.tool_config`), so the cached
848
908
  # branch here sends `cached_content` alone.
849
909
  if cache_name:
850
- return types.GenerateContentConfig(cached_content=cache_name)
910
+ return types.GenerateContentConfig(
911
+ cached_content=cache_name, http_options=request_http_options
912
+ )
851
913
  return types.GenerateContentConfig(
852
914
  system_instruction=system_prompt,
853
915
  tools=gemini_tools,
854
916
  tool_config=_FORCE_TOOL_CONFIG,
917
+ http_options=request_http_options,
855
918
  )
856
919
 
857
920
  config = _build_config(cached_content_name)
@@ -860,19 +923,14 @@ async def _run_turns(
860
923
  types.Content(role="user", parts=[types.Part.from_text(text=user_message)])
861
924
  ]
862
925
 
863
- tool_calls: list[str] = []
864
926
  files_explored: list[str] = []
865
927
  exhausted = False
866
- usage_prompt_total = 0
867
- usage_candidates_total = 0
868
- usage_cached_total = 0
869
- usage_thoughts_total = 0
870
- usage_tool_use_prompt_total = 0
871
928
 
872
929
  _mid_budget_nudge_turn = _compute_mid_budget_nudge_turn(_MAX_TURNS_GEMINI)
873
930
 
874
931
  with review_tools.review_session(repo_root) as findings_sink:
875
932
  for _turn in range(_MAX_TURNS_GEMINI):
933
+ progress.last_turn = _turn
876
934
  if (
877
935
  _turn == _MAX_TURNS_GEMINI - _NUDGE_TURNS_BEFORE_BUDGET
878
936
  and contents[-1].parts is not None
@@ -928,23 +986,16 @@ async def _run_turns(
928
986
  model=model, contents=contents, config=config
929
987
  )
930
988
 
931
- if progress is not None:
932
- progress.responses += 1
989
+ progress.responses += 1
933
990
  usage = response.usage_metadata
934
991
  if usage is not None:
935
- usage_prompt_total += usage.prompt_token_count or 0
936
- usage_candidates_total += usage.candidates_token_count or 0
937
- usage_cached_total += usage.cached_content_token_count or 0
938
- # thoughts_token_count (thinking-mode output) and
939
- # tool_use_prompt_token_count (tool-result tokens fed
940
- # back to the model) are both billable and both
941
- # separate additive buckets, per
942
- # GenerateContentResponseUsageMetadata's own docstring
943
- # (total_token_count = prompt + candidates +
944
- # tool_use_prompt + thoughts) -- NOT already folded
945
- # into prompt_token_count/candidates_token_count.
946
- usage_thoughts_total += usage.thoughts_token_count or 0
947
- usage_tool_use_prompt_total += usage.tool_use_prompt_token_count or 0
992
+ # thoughts and tool-use prompt tokens are billable, separate
993
+ # additive buckets (not folded into prompt/candidates counts).
994
+ progress.prompt_tokens += usage.prompt_token_count or 0
995
+ progress.candidates_tokens += usage.candidates_token_count or 0
996
+ progress.cached_tokens += usage.cached_content_token_count or 0
997
+ progress.thoughts_tokens += usage.thoughts_token_count or 0
998
+ progress.tool_use_prompt_tokens += usage.tool_use_prompt_token_count or 0
948
999
 
949
1000
  function_calls = response.function_calls or []
950
1001
  if not function_calls:
@@ -998,7 +1049,7 @@ async def _run_turns(
998
1049
  for call in function_calls:
999
1050
  name = call.name or ""
1000
1051
  args = dict(call.args or {})
1001
- tool_calls.append(name)
1052
+ progress.tool_calls.append(name)
1002
1053
  output, is_error = await asyncio.to_thread(_execute_tool_call, name, args)
1003
1054
  if is_error and name != "finish_review":
1004
1055
  logger.warning("Tool call failed [%s]: %s", name, output[:200])
@@ -1030,35 +1081,19 @@ async def _run_turns(
1030
1081
  result_text = _build_result_text(findings_sink, files_explored)
1031
1082
 
1032
1083
  finished_at = datetime.now(timezone.utc)
1033
- unique_tool_names = sorted(set(tool_calls))
1034
-
1035
- # Gemini's prompt_token_count already INCLUDES the cached portion
1036
- # (unlike Anthropic's fully-separate input/cache-read counters,
1037
- # which is the convention estimate_cost_usd's cached_input_tokens
1038
- # documents) -- so the cached amount must be subtracted out of the
1039
- # uncached input count here, or it would be billed at both the full
1040
- # input rate AND the cache-read rate. tool_use_prompt tokens are
1041
- # billed at the input rate (added in); thoughts tokens are billed
1042
- # at the output rate (added in) -- see the accumulation loop above.
1043
- input_tokens = max(usage_prompt_total - usage_cached_total, 0) + usage_tool_use_prompt_total
1044
- output_tokens = usage_candidates_total + usage_thoughts_total
1045
- cost_usd = estimate_cost_usd(
1046
- model=model,
1047
- input_tokens=input_tokens,
1048
- output_tokens=output_tokens,
1049
- cached_input_tokens=usage_cached_total,
1050
- )
1084
+ unique_tool_names = sorted(set(progress.tool_calls))
1085
+ cost_usd = progress.cost_usd(model)
1051
1086
 
1052
- tools_str = f" (tools: {', '.join(unique_tool_names)})" if tool_calls else ""
1087
+ tools_str = f" (tools: {', '.join(unique_tool_names)})" if progress.tool_calls else ""
1053
1088
  logger.info(
1054
1089
  "Gemini agent done [%s]: %d tool calls%s, cost=$%.4f input_tokens=%d cached_tokens=%d output_tokens=%d",
1055
1090
  label or "unlabeled",
1056
- len(tool_calls),
1091
+ len(progress.tool_calls),
1057
1092
  tools_str,
1058
1093
  cost_usd,
1059
- input_tokens,
1060
- usage_cached_total,
1061
- output_tokens,
1094
+ progress.input_tokens,
1095
+ progress.cached_tokens,
1096
+ progress.output_tokens,
1062
1097
  )
1063
1098
 
1064
1099
  return SessionResult(
@@ -1067,7 +1102,7 @@ async def _run_turns(
1067
1102
  duration_seconds=(finished_at - started_at).total_seconds(),
1068
1103
  started_at=started_at,
1069
1104
  finished_at=finished_at,
1070
- tool_call_count=len(tool_calls),
1105
+ tool_call_count=len(progress.tool_calls),
1071
1106
  tool_names=unique_tool_names,
1072
1107
  # No Context7 MCP integration in scope for this runner (see module
1073
1108
  # docstring) -- always 0, never derived from tool_calls.
@@ -1207,19 +1242,21 @@ async def run_session_gemini(
1207
1242
  except (TimeoutError, httpx.TimeoutException) as exc:
1208
1243
  finished_at = datetime.now(timezone.utc)
1209
1244
  logger.warning(
1210
- "Gemini session [%s] timed out after %ss (%s)",
1245
+ "Gemini session [%s] timed out after %ss (%s), %d turn(s) and %d tool call(s) done",
1211
1246
  label or "unlabeled",
1212
1247
  effective_timeout_s,
1213
1248
  type(exc).__name__,
1249
+ progress.responses,
1250
+ len(progress.tool_calls),
1214
1251
  )
1215
1252
  return SessionResult(
1216
1253
  result_text="",
1217
- cost_usd=0.0,
1254
+ cost_usd=progress.cost_usd(model),
1218
1255
  duration_seconds=(finished_at - started_at).total_seconds(),
1219
1256
  started_at=started_at,
1220
1257
  finished_at=finished_at,
1221
- tool_call_count=0,
1222
- tool_names=[],
1258
+ tool_call_count=len(progress.tool_calls),
1259
+ tool_names=sorted(set(progress.tool_calls)),
1223
1260
  context7_call_count=0,
1224
1261
  model=model,
1225
1262
  failure_reason="timeout",
@@ -1259,6 +1296,8 @@ async def run_session_gemini(
1259
1296
  # subprocess "worker" the way the Claude Agent SDK path does.
1260
1297
  finished_at = datetime.now(timezone.utc)
1261
1298
  detail = _failure_detail(exc)
1299
+ if progress.last_turn is not None:
1300
+ detail = f"{detail}, turn {progress.last_turn}"
1262
1301
  logger.warning(
1263
1302
  "Gemini session [%s] failed after %.1fs (%s, %s%s): %s",
1264
1303
  label or "unlabeled",
@@ -1270,12 +1309,12 @@ async def run_session_gemini(
1270
1309
  )
1271
1310
  return SessionResult(
1272
1311
  result_text="",
1273
- cost_usd=0.0,
1312
+ cost_usd=progress.cost_usd(model),
1274
1313
  duration_seconds=(finished_at - started_at).total_seconds(),
1275
1314
  started_at=started_at,
1276
1315
  finished_at=finished_at,
1277
- tool_call_count=0,
1278
- tool_names=[],
1316
+ tool_call_count=len(progress.tool_calls),
1317
+ tool_names=sorted(set(progress.tool_calls)),
1279
1318
  context7_call_count=0,
1280
1319
  model=model,
1281
1320
  failure_reason="worker_crashed",
@@ -11,6 +11,8 @@ import re
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
+ import httpx
15
+
14
16
  from argus.models import Finding, ReviewResponse, RiskLevel, Severity, Verdict
15
17
  from argus.pipeline_models import RawFinding, SystemReviewResult
16
18
 
@@ -145,6 +147,31 @@ def collect_reviewed_files(results: list[SystemReviewResult]) -> set[str]:
145
147
  return reviewed
146
148
 
147
149
 
150
+ def _concrete_subclass_names(base: type[BaseException]) -> set[str]:
151
+ names = {base.__name__}
152
+ for sub in base.__subclasses__():
153
+ names |= _concrete_subclass_names(sub)
154
+ return names
155
+
156
+
157
+ # Only canonical provider codes/statuses, HTTP reason phrases and transport
158
+ # exception names may be rendered into reports, never arbitrary exception text
159
+ # or payloads. Transport names come from httpx itself so they cannot drift.
160
+ _TRANSPORT_ERROR_NAMES = sorted(
161
+ _concrete_subclass_names(httpx.TransportError) | {"HTTPStatusError"}
162
+ )
163
+ _CANONICAL_FAILURE_DETAIL_RE = re.compile(
164
+ r"(?:[1-5][0-9]{2}(?: (?:INTERNAL|UNAVAILABLE|RESOURCE_EXHAUSTED|"
165
+ r"DEADLINE_EXCEEDED|UNAUTHENTICATED|PERMISSION_DENIED|INVALID_ARGUMENT|"
166
+ r"NOT_FOUND|FAILED_PRECONDITION|UNKNOWN|CANCELLED|ABORTED|OUT_OF_RANGE|"
167
+ r"UNIMPLEMENTED|DATA_LOSS|"
168
+ # HTTP reason phrase (non-JSON error body), e.g. "Bad Gateway"; <= 40 chars.
169
+ r"(?=[A-Za-z ]{1,40}(?![A-Za-z ]))[A-Z][a-z]+(?: [A-Z][a-z]+)*))?|"
170
+ r"(?:" + "|".join(_TRANSPORT_ERROR_NAMES) + r"|ServerError|ClientError|APIError))"
171
+ r"(?: \(daily quota\))?(?:, turn [0-9]+)?"
172
+ )
173
+
174
+
148
175
  def failed_reviewer_labels(results: list[SystemReviewResult]) -> list[tuple[str, str]]:
149
176
  """Return ``(system_group, failure_reason)`` pairs for reviewers that did
150
177
  not complete normally, in the order they appear in ``results``.
@@ -159,10 +186,13 @@ def failed_reviewer_labels(results: list[SystemReviewResult]) -> list[tuple[str,
159
186
  """
160
187
  labels: list[tuple[str, str]] = []
161
188
  for result in results:
162
- reason = result.failure_reason
189
+ reason: str | None = result.failure_reason
163
190
  if reason is None and result.timed_out:
164
191
  reason = "timeout"
165
192
  if reason is not None:
193
+ detail = result.failure_detail
194
+ if detail and _CANONICAL_FAILURE_DETAIL_RE.fullmatch(detail):
195
+ reason = f"{reason}: {detail}"
166
196
  labels.append((result.system_group, reason))
167
197
  return labels
168
198
 
@@ -272,15 +302,16 @@ def build_degraded_coverage_findings(
272
302
  """
273
303
  findings: list[Finding] = []
274
304
  for label, reason in failed_labels:
305
+ reason_code, _, detail = reason.partition(": ")
275
306
  is_precheck = label.startswith("precheck:")
276
307
  subject = "Precheck scanner" if is_precheck else "Reviewer session"
277
- is_missing = reason == _SCANNER_NOT_INSTALLED_REASON
308
+ is_missing = reason_code == _SCANNER_NOT_INSTALLED_REASON
278
309
  # Distinct wording from a crash/timeout: the reviewer was alive and
279
310
  # exploring, it just never reached finish_review.
280
- is_exhausted = reason == "turn_budget_exhausted"
311
+ is_exhausted = reason_code == "turn_budget_exhausted"
281
312
  if is_missing:
282
313
  reason_desc = "is not installed"
283
- elif reason == "timeout":
314
+ elif reason_code == "timeout":
284
315
  reason_desc = "timed out"
285
316
  elif is_exhausted:
286
317
  reason_desc = "ran out of turns before finishing"
@@ -302,16 +333,22 @@ def build_degraded_coverage_findings(
302
333
  f"Re-run the review or manually inspect the changes in '{label}' "
303
334
  f"to ensure potential issues were not missed due to {reason}."
304
335
  )
336
+ if detail:
337
+ # Cause first: API/MCP summaries may clip at 200 chars, and the
338
+ # area name is unbounded model output.
339
+ description = f"{subject} {reason_desc}. Area: '{label}'. Coverage is degraded."
340
+ else:
341
+ description = (
342
+ f"{subject} '{label}' {reason_desc} and produced no findings. "
343
+ "Coverage for this area is degraded."
344
+ )
305
345
  findings.append(
306
346
  Finding(
307
347
  severity=Severity.SUGGESTION,
308
348
  category="coverage-gap",
309
349
  file=None,
310
350
  line=None,
311
- description=(
312
- f"{subject} '{label}' {reason_desc} and produced no findings. "
313
- "Coverage for this area is degraded."
314
- ),
351
+ description=description,
315
352
  suggestion=suggestion,
316
353
  )
317
354
  )
@@ -168,6 +168,14 @@ class SystemReviewResult(BaseModel):
168
168
  default=False,
169
169
  description="True when failure_reason == 'timeout'; kept for back-compat.",
170
170
  )
171
+ failure_detail: str | None = Field(
172
+ default=None,
173
+ description=(
174
+ "Short, key-free cause for failure_reason (e.g. '503 UNAVAILABLE, turn 45'), "
175
+ "copied from the runner's SessionResult. Report/degraded-coverage text only -- "
176
+ "not persisted to agent_runs and never parsed for control flow."
177
+ ),
178
+ )
171
179
 
172
180
  @model_validator(mode="before")
173
181
  @classmethod
@@ -87,12 +87,11 @@ Principle: if an LLM/AI pipeline builds execution, retry, state-tracking, or par
87
87
  Architecture-mandate violations (an adopted design doc names a framework for a concern and the code uses a different approach for that same concern) are standing conformance debt, not a one-time "was this broken before the PR" question. **Causal-scope-filter override**: this overrides the causal scope-check test used elsewhere in this prompt -- if this diff modifies or extends a pipeline that is already non-compliant, the finding is still in scope. Only skip when the diff does not touch the non-compliant pipeline's code at all.
88
88
 
89
89
  ### Model Registry Compliance (BLOCKING)
90
- - All LLM model identifiers MUST come from `argus.llm.models`'s exported constants (`GPT_MINI`, `CLAUDE_FRONTIER`, `CLAUDE_OPUS`, `CLAUDE_DEFAULT`, `CLAUDE_MINI`), which resolve `ALIAS_MAP` entries — only register an alias that has an actual call site; this is a standalone, isolated package, not a monorepo, so there's no value in carrying unused registry entries.
91
- - Flag as **BLOCKING** any new hardcoded model strings the diff introduces — e.g., literal `"gpt-5.4-mini"`, `"claude-sonnet-5"`, `"claude-haiku-4-5"`, or deprecated names like `"gpt-4o"`, `"o3"`, `"claude-3-*"`. This targets model-SELECTION code (a call site choosing which model to use); it does not apply to a legacy pin like `"claude-sonnet-5"` appearing as a `--specialist-model`/`ARGUS_SPECIALIST_MODEL` CLI/env override value, or in docs/tests demonstrating that override -- such a value remains a legitimate override even after it stops being the hardcoded default.
92
- - Short-lived eval / preview pins are allowed only via `EXPERIMENTAL_MODELS` in `argus/llm/models.py` (with a comment naming a tracking issue/PR and expected removal date) or via inline `# model-registry: allow -- <reason>` comments on user-input alias keys (not provider model identifiers).
93
-
94
- ### Character Hygiene (cross-file)
95
- - Confirm the lines ADDED by this diff contain no em-dash characters (U+2014). Flag every added line that contains one. Scope this to diff-added lines only (prose in pre-existing code or docs is out of scope).
90
+ - Judge this against the repository under review, never against a fixed package name. If the repository has a central model registry (a module that exports model aliases or constants for call sites to import -- commonly something like `ALIAS_MAP`, `MODELS`, or an `llm/models.py`), all LLM model identifiers MUST come from it.
91
+ - Flag as **BLOCKING** any new hardcoded model string the diff introduces in model-SELECTION code -- e.g. a literal `"gpt-5.4-mini"`, `"claude-sonnet-5"`, `"claude-haiku-4-5"`, or deprecated names like `"gpt-4o"`, `"o3"`, `"claude-3-*"`. This targets the call site choosing which model to use; it does not apply to a value passed through a CLI/env override (e.g. a `--*-model` flag) or shown in docs or tests demonstrating that override.
92
+ - Only register an alias that has an actual call site; an unused registry entry is dead weight, not a defect worth blocking on.
93
+ - Short-lived eval or preview pins belong in the registry's own experimental section (with a comment naming a tracking issue and an expected removal date) or behind an inline `# model-registry: allow -- <reason>` comment.
94
+ - If the repository has no such registry, do not demand one. Report hardcoded provider model identifiers as a SUGGESTION only when the repository's own conventions clearly place configuration values outside code.
96
95
 
97
96
  ## Output
98
97
 
@@ -21,7 +21,7 @@ Review only the changed code for these issues:
21
21
  - Blocking I/O calls in async functions (e.g. `requests` instead of `httpx`)
22
22
 
23
23
  ### Model Registry
24
- - Hardcoded LLM model strings (e.g. `"claude-sonnet-5"`, `"gpt-5.4-mini"`) — must use constants from `argus.llm.models` (`CLAUDE_DEFAULT`, `CLAUDE_FRONTIER`, `CLAUDE_OPUS`, `CLAUDE_MINI`, `GPT_MINI`). This targets model-SELECTION code (a call site choosing which model to use); it does not apply to `claude-sonnet-5` appearing as a `--specialist-model`/`ARGUS_SPECIALIST_MODEL` CLI/env override value, or in docs/tests demonstrating that override -- `claude-sonnet-5` (the previous `CLAUDE_DEFAULT` pin) remains a valid override value even though it's no longer the hardcoded default.
24
+ - Hardcoded LLM model strings in model-selection code (e.g. `"claude-sonnet-5"`, `"gpt-5.4-mini"`) — when the repository has a central registry of model aliases or constants, call sites must use it. A value passed through a CLI/env override (e.g. a `--*-model` flag), or shown in docs or tests demonstrating that override, is legitimate.
25
25
 
26
26
  ## Out of Scope
27
27
  Do NOT flag: security deep-dives, performance analysis, test coverage gaps, architectural concerns, code style preferences, or cross-file integration issues. Those require a full Argus review.
@@ -734,6 +734,18 @@ _SPECIALIST_PROMPT_MAP: dict[SpecialistName, str] = {
734
734
  # ---------------------------------------------------------------------------
735
735
 
736
736
 
737
+ def _apply_session_failure(result: SystemReviewResult, session: SessionResult) -> None:
738
+ """Copy a session's failure state onto its parsed reviewer result.
739
+
740
+ Goes through the sync helper because a bare attribute assignment would
741
+ bypass ``SystemReviewResult``'s construction-time ``mode="before"`` validator.
742
+ """
743
+ result.timed_out, result.failure_reason = _sync_timed_out_and_failure_reason(
744
+ session.timed_out, session.failure_reason
745
+ )
746
+ result.failure_detail = session.failure_detail
747
+
748
+
737
749
  def _build_agent_run(
738
750
  *,
739
751
  session: SessionResult,
@@ -856,15 +868,7 @@ async def run_system_reviewer(
856
868
  )
857
869
  result = _parse_review_result(session.result_text, group.name)
858
870
  result.cost_usd = session.cost_usd
859
- # Set both fields via the shared sync helper (not a bare attribute
860
- # assignment) so `result.timed_out` stays correctly synced even before
861
- # this object is next reconstructed via model_validate() -- a plain
862
- # `result.failure_reason = session.failure_reason` bypasses
863
- # SystemReviewResult's `mode="before"` validator entirely, since that
864
- # validator only fires at construction time.
865
- result.timed_out, result.failure_reason = _sync_timed_out_and_failure_reason(
866
- session.timed_out, session.failure_reason
867
- )
871
+ _apply_session_failure(result, session)
868
872
  agent_run = _build_agent_run(
869
873
  session=session,
870
874
  agent_name=f"system:{group.name}",
@@ -954,15 +958,7 @@ async def run_specialist_reviewer(
954
958
  )
955
959
  result = _parse_review_result(session.result_text, f"{group.name}::{specialist}")
956
960
  result.cost_usd = session.cost_usd
957
- # Set both fields via the shared sync helper (not a bare attribute
958
- # assignment) so `result.timed_out` stays correctly synced even before
959
- # this object is next reconstructed via model_validate() -- a plain
960
- # `result.failure_reason = session.failure_reason` bypasses
961
- # SystemReviewResult's `mode="before"` validator entirely, since that
962
- # validator only fires at construction time.
963
- result.timed_out, result.failure_reason = _sync_timed_out_and_failure_reason(
964
- session.timed_out, session.failure_reason
965
- )
961
+ _apply_session_failure(result, session)
966
962
  agent_run = _build_agent_run(
967
963
  session=session,
968
964
  agent_name=f"specialist:{group.name}::{specialist}",
@@ -1043,15 +1039,7 @@ async def run_cross_cutting_reviewer(
1043
1039
  )
1044
1040
  result = _parse_review_result(session.result_text, "cross-cutting")
1045
1041
  result.cost_usd = session.cost_usd
1046
- # Set both fields via the shared sync helper (not a bare attribute
1047
- # assignment) so `result.timed_out` stays correctly synced even before
1048
- # this object is next reconstructed via model_validate() -- a plain
1049
- # `result.failure_reason = session.failure_reason` bypasses
1050
- # SystemReviewResult's `mode="before"` validator entirely, since that
1051
- # validator only fires at construction time.
1052
- result.timed_out, result.failure_reason = _sync_timed_out_and_failure_reason(
1053
- session.timed_out, session.failure_reason
1054
- )
1042
+ _apply_session_failure(result, session)
1055
1043
  agent_run = _build_agent_run(
1056
1044
  session=session,
1057
1045
  agent_name="cross-cutting",
@@ -1113,15 +1101,7 @@ async def run_tests_and_docs_reviewer(
1113
1101
  )
1114
1102
  result = _parse_review_result(session.result_text, "tests-and-docs")
1115
1103
  result.cost_usd = session.cost_usd
1116
- # Set both fields via the shared sync helper (not a bare attribute
1117
- # assignment) so `result.timed_out` stays correctly synced even before
1118
- # this object is next reconstructed via model_validate() -- a plain
1119
- # `result.failure_reason = session.failure_reason` bypasses
1120
- # SystemReviewResult's `mode="before"` validator entirely, since that
1121
- # validator only fires at construction time.
1122
- result.timed_out, result.failure_reason = _sync_timed_out_and_failure_reason(
1123
- session.timed_out, session.failure_reason
1124
- )
1104
+ _apply_session_failure(result, session)
1125
1105
  agent_run = _build_agent_run(
1126
1106
  session=session,
1127
1107
  agent_name="tests-and-docs",