argus-code-review 0.3.3__tar.gz → 0.3.4__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.3.4}/CHANGELOG.md +11 -1
  2. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/PKG-INFO +1 -1
  3. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/gemini_runner.py +88 -57
  4. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/helpers.py +45 -8
  5. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/pipeline_models.py +8 -0
  6. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-cross-cutting.md +5 -6
  7. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-lite.md +1 -1
  8. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/runners.py +16 -36
  9. argus_code_review-0.3.4/tests/test_failure_diagnostics.py +129 -0
  10. argus_code_review-0.3.4/tests/test_gemini_http_retry.py +136 -0
  11. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_gemini_runner.py +18 -11
  12. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/.gitignore +0 -0
  13. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/LICENSE +0 -0
  14. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/README.md +0 -0
  15. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/__init__.py +0 -0
  16. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/bench.py +0 -0
  17. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/bench_default.toml +0 -0
  18. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/cli.py +0 -0
  19. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/config.py +0 -0
  20. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/coverage.py +0 -0
  21. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/dotenv_utils.py +0 -0
  22. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/gemini_cache.py +0 -0
  23. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/github_client.py +0 -0
  24. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/graph.py +0 -0
  25. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/llm/models.py +0 -0
  26. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/llm/output_models.py +0 -0
  27. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/llm/pricing.py +0 -0
  28. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/llm/usage.py +0 -0
  29. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/models.py +0 -0
  30. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/openai_client.py +0 -0
  31. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/openai_runner.py +0 -0
  32. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/__init__.py +0 -0
  33. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/actions_scanner.py +0 -0
  34. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/engine.py +0 -0
  35. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/.gitignore +0 -0
  36. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/README.md +0 -0
  37. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/eslint.config.js +0 -0
  38. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/package-lock.json +0 -0
  39. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/package.json +0 -0
  40. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/js_scanner.py +0 -0
  41. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/migration_scanner.py +0 -0
  42. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/rules/README.md +0 -0
  43. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/sarif.py +0 -0
  44. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/scanner_utils.py +0 -0
  45. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/secrets_scanner.py +0 -0
  46. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/shadow.py +0 -0
  47. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/terraform_scanner.py +0 -0
  48. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/precheck/workflow_lint_scanner.py +0 -0
  49. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/__init__.py +0 -0
  50. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-blocking-validator.md +0 -0
  51. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-coverage-check.md +0 -0
  52. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-feedback-verifier.md +0 -0
  53. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-planner.md +0 -0
  54. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-preflight-router.md +0 -0
  55. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-prior-art.md +0 -0
  56. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-scope.md +0 -0
  57. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-deployment.md +0 -0
  58. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-frontend.md +0 -0
  59. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-infra.md +0 -0
  60. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-llm-patterns.md +0 -0
  61. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-observability.md +0 -0
  62. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-orchestration.md +0 -0
  63. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-security.md +0 -0
  64. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-slackbot.md +0 -0
  65. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-sql.md +0 -0
  66. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-subagent.md +0 -0
  67. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-tests-and-docs.md +0 -0
  68. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts/pr-review-writer.md +0 -0
  69. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/prompts_runtime.py +0 -0
  70. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/repo_provision.py +0 -0
  71. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/review_tools.py +0 -0
  72. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/scope.py +0 -0
  73. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/storage/__init__.py +0 -0
  74. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/storage/http.py +0 -0
  75. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/storage/models.py +0 -0
  76. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/storage/precheck.py +0 -0
  77. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/storage/resolver.py +0 -0
  78. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/storage/session.py +0 -0
  79. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/storage/sql.py +0 -0
  80. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/argus/storage/sqlite.py +0 -0
  81. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/pyproject.toml +0 -0
  82. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/schema/008_add_code_reviews.sql +0 -0
  83. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/schema/009_add_reviewer_version.sql +0 -0
  84. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/schema/010_add_review_patterns.sql +0 -0
  85. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/schema/011_add_review_progress_columns.sql +0 -0
  86. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/schema/015_create_agent_runs.sql +0 -0
  87. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/schema/016_add_agent_runs_failure_reason.sql +0 -0
  88. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/schema/017_add_precheck_rules.sql +0 -0
  89. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/schema/018_widen_agent_runs_failure_reason.sql +0 -0
  90. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/__init__.py +0 -0
  91. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/conftest.py +0 -0
  92. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/golden/review_response.schema.json +0 -0
  93. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/storage/__init__.py +0 -0
  94. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/storage/test_backend_contract.py +0 -0
  95. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/storage/test_http.py +0 -0
  96. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/storage/test_models.py +0 -0
  97. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/storage/test_resolver.py +0 -0
  98. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/storage/test_session.py +0 -0
  99. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/storage/test_sql.py +0 -0
  100. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/storage/test_sqlite_backend.py +0 -0
  101. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_actions_scanner.py +0 -0
  102. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_argus_review_local.py +0 -0
  103. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_bench.py +0 -0
  104. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_bench_config_guard.py +0 -0
  105. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_catchup_gate.py +0 -0
  106. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_cli_args.py +0 -0
  107. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_cli_output_contract.py +0 -0
  108. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_cli_post_review.py +0 -0
  109. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_cli_preflight.py +0 -0
  110. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_cli_prompts.py +0 -0
  111. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_config.py +0 -0
  112. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_conftest.py +0 -0
  113. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_dotenv_utils.py +0 -0
  114. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_gemini_cache.py +0 -0
  115. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_github_client_checks_signal.py +0 -0
  116. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_github_client_write.py +0 -0
  117. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_graph_fetch_diff.py +0 -0
  118. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_graph_get_llm_temperature.py +0 -0
  119. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_graph_http_guards.py +0 -0
  120. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_graph_precheck.py +0 -0
  121. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_graph_preflight_image_bump.py +0 -0
  122. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_graph_progress.py +0 -0
  123. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_graph_storage_resolution.py +0 -0
  124. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_graph_timeout_surfacing.py +0 -0
  125. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_js_scanner.py +0 -0
  126. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_llm_models.py +0 -0
  127. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_llm_models_override.py +0 -0
  128. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_llm_pricing.py +0 -0
  129. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_migration_scanner.py +0 -0
  130. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_models.py +0 -0
  131. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_multi_round.py +0 -0
  132. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_openai_client.py +0 -0
  133. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_openai_runner.py +0 -0
  134. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_output_models.py +0 -0
  135. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_packaging.py +0 -0
  136. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_plan_review.py +0 -0
  137. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_precheck_engine.py +0 -0
  138. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_precheck_engine_integration.py +0 -0
  139. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_precheck_sarif.py +0 -0
  140. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_precheck_shadow.py +0 -0
  141. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_precheck_shadow_integration.py +0 -0
  142. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_prompts.py +0 -0
  143. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_repo_provision.py +0 -0
  144. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_review_patterns_integration.py +0 -0
  145. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_review_tools.py +0 -0
  146. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_runners_context7.py +0 -0
  147. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_runners_context_usage.py +0 -0
  148. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_runners_helpers.py +0 -0
  149. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_runners_new.py +0 -0
  150. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_runners_turn_budget.py +0 -0
  151. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_scanner_utils.py +0 -0
  152. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_scope.py +0 -0
  153. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_scope_gate.py +0 -0
  154. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_sdk_bundled_cli_version.py +0 -0
  155. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_secrets_scanner.py +0 -0
  156. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_specialist_validation.py +0 -0
  157. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_stage_costs.py +0 -0
  158. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_storage_precheck.py +0 -0
  159. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_structured_output_methods.py +0 -0
  160. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_terraform_scanner.py +0 -0
  161. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_terraform_scanner_integration.py +0 -0
  162. {argus_code_review-0.3.3 → argus_code_review-0.3.4}/tests/test_workflow_lint_scanner.py +0 -0
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.4] - 2026-10-23
11
+
12
+ ### Fixed
13
+
14
+ - 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.
15
+ - Failed Gemini sessions preserve cost and tool accounting from completed turns, and include the failed turn index in their short failure detail.
16
+ - 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.
17
+ - 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.
18
+
10
19
  ## [0.3.3] - 2026-10-22
11
20
 
12
21
  ### Changed
@@ -448,7 +457,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
448
457
  packaged set.
449
458
  - `argus --version`, `argus prompts list`, and `argus prompts export`.
450
459
 
451
- [Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.3.3...HEAD
460
+ [Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.3.4...HEAD
461
+ [0.3.4]: https://github.com/redesignhealth/argus-review/compare/v0.3.3...v0.3.4
452
462
  [0.3.3]: https://github.com/redesignhealth/argus-review/compare/v0.3.2...v0.3.3
453
463
  [0.3.2]: https://github.com/redesignhealth/argus-review/compare/v0.3.1...v0.3.2
454
464
  [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.3.4
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
@@ -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
 
@@ -219,7 +219,9 @@ _HTTP_TIMEOUT_FRACTION = 0.8
219
219
  # DEADLINE_EXCEEDED/INTERNAL, or an httpx timeout/connect/read/remote-protocol
220
220
  # error) before the model returned ANY response (i.e. on turn 0), so the retry
221
221
  # restarts the session cleanly from the beginning. Later failures are not
222
- # retried. The cooldown depends on the error kind (see `_retry_decision`):
222
+ # restarted; the SDK retries only individual 5xx requests (see
223
+ # `_HTTP_RETRY_ATTEMPTS`), and a 5xx it already retried is not restarted again,
224
+ # so the restart covers 429 and transport errors only. The cooldown depends on the error kind (see `_retry_decision`):
223
225
  # 45-60 s jitter for 429/RESOURCE_EXHAUSTED (per-minute quota windows), 5-10 s
224
226
  # for 5xx/network errors; a server Retry-After/retryDelay >= 5 s is honored;
225
227
  # never more than 60 s. Daily-quota 429s are not retried. Overridable via
@@ -249,6 +251,14 @@ _TRANSIENT_HTTPX = (
249
251
  httpx.ReadError,
250
252
  httpx.RemoteProtocolError,
251
253
  )
254
+
255
+ # SDK-level per-request retry of 5xx at any turn; the request is re-sent
256
+ # unchanged, so no tools are replayed. 429s stay in _retry_decision because the
257
+ # SDK cannot tell daily quota from per-minute limits or honor Retry-After.
258
+ _HTTP_RETRY_ATTEMPTS = 3 # including the initial call
259
+ _HTTP_RETRY_STATUS_CODES = (500, 502, 503, 504)
260
+ _HTTP_RETRY_INITIAL_DELAY_S = 2.0
261
+ _HTTP_RETRY_MAX_DELAY_S = 20.0
252
262
  _DETAIL_MAX_CHARS = 300
253
263
  _SECRET_RE = re.compile(
254
264
  r"(AIza[0-9A-Za-z_\-]{20,}|(?i:(?:api[_-]?key|key|token)=)[^&\s\"']+|"
@@ -271,8 +281,38 @@ class _Progress:
271
281
  """Mutable per-session state shared between attempts and the failure handler."""
272
282
 
273
283
  responses: int = 0 # successful generate_content responses received
284
+ last_turn: int | None = None # 0-based index of the turn that last ran
274
285
  retried: bool = False
275
286
  retry_wait_s: float = 0.0
287
+ # Usage accrued so far, so a session that dies mid-flight still reports it.
288
+ prompt_tokens: int = 0
289
+ cached_tokens: int = 0
290
+ candidates_tokens: int = 0
291
+ thoughts_tokens: int = 0
292
+ tool_use_prompt_tokens: int = 0
293
+ tool_calls: list[str] = field(default_factory=list)
294
+
295
+ @property
296
+ def input_tokens(self) -> int:
297
+ # Gemini's prompt_token_count already includes the cached portion, so
298
+ # subtract it to avoid billing it at both input and cache-read rates.
299
+ # Tool-result tokens are billed as input.
300
+ return max(self.prompt_tokens - self.cached_tokens, 0) + self.tool_use_prompt_tokens
301
+
302
+ @property
303
+ def output_tokens(self) -> int:
304
+ # Thinking tokens are a separate additive bucket billed as output.
305
+ return self.candidates_tokens + self.thoughts_tokens
306
+
307
+ def cost_usd(self, model: str) -> float:
308
+ if self.responses == 0:
309
+ return 0.0
310
+ return estimate_cost_usd(
311
+ model=model,
312
+ input_tokens=self.input_tokens,
313
+ output_tokens=self.output_tokens,
314
+ cached_input_tokens=self.cached_tokens,
315
+ )
276
316
 
277
317
 
278
318
  def _scrub(text: str) -> str:
@@ -346,6 +386,8 @@ def _retry_decision(exc: BaseException) -> tuple[str, float] | None:
346
386
  if not _is_transient_error(exc):
347
387
  return None
348
388
  code, status = _error_code_status(exc)
389
+ if isinstance(exc, genai_errors.ServerError) and code in _HTTP_RETRY_STATUS_CODES:
390
+ return None # the SDK already retried this request; a restart would repeat it
349
391
  server_delay = _retry_after_seconds(exc)
350
392
  if code == 429 or (status or "").upper() == "RESOURCE_EXHAUSTED":
351
393
  if _is_daily_quota(exc, server_delay):
@@ -767,7 +809,7 @@ async def _run_turns(
767
809
  repo_root: str,
768
810
  timeout_s: float,
769
811
  started_at: datetime,
770
- progress: _Progress | None = None,
812
+ progress: _Progress,
771
813
  ) -> SessionResult:
772
814
  """The actual tool-calling loop. Returns ``failure_reason="turn_budget_exhausted"``
773
815
  when the loop ran out of turns without a ``finish_review`` call, else
@@ -834,6 +876,16 @@ async def _run_turns(
834
876
  label=label,
835
877
  )
836
878
 
879
+ # Retry only model calls, not detached synchronous cache creation.
880
+ request_http_options = types.HttpOptions(
881
+ retry_options=types.HttpRetryOptions(
882
+ attempts=_HTTP_RETRY_ATTEMPTS,
883
+ initial_delay=_HTTP_RETRY_INITIAL_DELAY_S,
884
+ max_delay=_HTTP_RETRY_MAX_DELAY_S,
885
+ http_status_codes=list(_HTTP_RETRY_STATUS_CODES),
886
+ )
887
+ )
888
+
837
889
  def _build_config(cache_name: str | None) -> types.GenerateContentConfig:
838
890
  # Gemini constraint (confirmed against the live API, not just
839
891
  # documentation): a request using `cached_content=` must NOT
@@ -847,11 +899,14 @@ async def _run_turns(
847
899
  # `CreateCachedContentConfig.tool_config`), so the cached
848
900
  # branch here sends `cached_content` alone.
849
901
  if cache_name:
850
- return types.GenerateContentConfig(cached_content=cache_name)
902
+ return types.GenerateContentConfig(
903
+ cached_content=cache_name, http_options=request_http_options
904
+ )
851
905
  return types.GenerateContentConfig(
852
906
  system_instruction=system_prompt,
853
907
  tools=gemini_tools,
854
908
  tool_config=_FORCE_TOOL_CONFIG,
909
+ http_options=request_http_options,
855
910
  )
856
911
 
857
912
  config = _build_config(cached_content_name)
@@ -860,19 +915,14 @@ async def _run_turns(
860
915
  types.Content(role="user", parts=[types.Part.from_text(text=user_message)])
861
916
  ]
862
917
 
863
- tool_calls: list[str] = []
864
918
  files_explored: list[str] = []
865
919
  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
920
 
872
921
  _mid_budget_nudge_turn = _compute_mid_budget_nudge_turn(_MAX_TURNS_GEMINI)
873
922
 
874
923
  with review_tools.review_session(repo_root) as findings_sink:
875
924
  for _turn in range(_MAX_TURNS_GEMINI):
925
+ progress.last_turn = _turn
876
926
  if (
877
927
  _turn == _MAX_TURNS_GEMINI - _NUDGE_TURNS_BEFORE_BUDGET
878
928
  and contents[-1].parts is not None
@@ -928,23 +978,16 @@ async def _run_turns(
928
978
  model=model, contents=contents, config=config
929
979
  )
930
980
 
931
- if progress is not None:
932
- progress.responses += 1
981
+ progress.responses += 1
933
982
  usage = response.usage_metadata
934
983
  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
984
+ # thoughts and tool-use prompt tokens are billable, separate
985
+ # additive buckets (not folded into prompt/candidates counts).
986
+ progress.prompt_tokens += usage.prompt_token_count or 0
987
+ progress.candidates_tokens += usage.candidates_token_count or 0
988
+ progress.cached_tokens += usage.cached_content_token_count or 0
989
+ progress.thoughts_tokens += usage.thoughts_token_count or 0
990
+ progress.tool_use_prompt_tokens += usage.tool_use_prompt_token_count or 0
948
991
 
949
992
  function_calls = response.function_calls or []
950
993
  if not function_calls:
@@ -998,7 +1041,7 @@ async def _run_turns(
998
1041
  for call in function_calls:
999
1042
  name = call.name or ""
1000
1043
  args = dict(call.args or {})
1001
- tool_calls.append(name)
1044
+ progress.tool_calls.append(name)
1002
1045
  output, is_error = await asyncio.to_thread(_execute_tool_call, name, args)
1003
1046
  if is_error and name != "finish_review":
1004
1047
  logger.warning("Tool call failed [%s]: %s", name, output[:200])
@@ -1030,35 +1073,19 @@ async def _run_turns(
1030
1073
  result_text = _build_result_text(findings_sink, files_explored)
1031
1074
 
1032
1075
  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
- )
1076
+ unique_tool_names = sorted(set(progress.tool_calls))
1077
+ cost_usd = progress.cost_usd(model)
1051
1078
 
1052
- tools_str = f" (tools: {', '.join(unique_tool_names)})" if tool_calls else ""
1079
+ tools_str = f" (tools: {', '.join(unique_tool_names)})" if progress.tool_calls else ""
1053
1080
  logger.info(
1054
1081
  "Gemini agent done [%s]: %d tool calls%s, cost=$%.4f input_tokens=%d cached_tokens=%d output_tokens=%d",
1055
1082
  label or "unlabeled",
1056
- len(tool_calls),
1083
+ len(progress.tool_calls),
1057
1084
  tools_str,
1058
1085
  cost_usd,
1059
- input_tokens,
1060
- usage_cached_total,
1061
- output_tokens,
1086
+ progress.input_tokens,
1087
+ progress.cached_tokens,
1088
+ progress.output_tokens,
1062
1089
  )
1063
1090
 
1064
1091
  return SessionResult(
@@ -1067,7 +1094,7 @@ async def _run_turns(
1067
1094
  duration_seconds=(finished_at - started_at).total_seconds(),
1068
1095
  started_at=started_at,
1069
1096
  finished_at=finished_at,
1070
- tool_call_count=len(tool_calls),
1097
+ tool_call_count=len(progress.tool_calls),
1071
1098
  tool_names=unique_tool_names,
1072
1099
  # No Context7 MCP integration in scope for this runner (see module
1073
1100
  # docstring) -- always 0, never derived from tool_calls.
@@ -1207,19 +1234,21 @@ async def run_session_gemini(
1207
1234
  except (TimeoutError, httpx.TimeoutException) as exc:
1208
1235
  finished_at = datetime.now(timezone.utc)
1209
1236
  logger.warning(
1210
- "Gemini session [%s] timed out after %ss (%s)",
1237
+ "Gemini session [%s] timed out after %ss (%s), %d turn(s) and %d tool call(s) done",
1211
1238
  label or "unlabeled",
1212
1239
  effective_timeout_s,
1213
1240
  type(exc).__name__,
1241
+ progress.responses,
1242
+ len(progress.tool_calls),
1214
1243
  )
1215
1244
  return SessionResult(
1216
1245
  result_text="",
1217
- cost_usd=0.0,
1246
+ cost_usd=progress.cost_usd(model),
1218
1247
  duration_seconds=(finished_at - started_at).total_seconds(),
1219
1248
  started_at=started_at,
1220
1249
  finished_at=finished_at,
1221
- tool_call_count=0,
1222
- tool_names=[],
1250
+ tool_call_count=len(progress.tool_calls),
1251
+ tool_names=sorted(set(progress.tool_calls)),
1223
1252
  context7_call_count=0,
1224
1253
  model=model,
1225
1254
  failure_reason="timeout",
@@ -1259,6 +1288,8 @@ async def run_session_gemini(
1259
1288
  # subprocess "worker" the way the Claude Agent SDK path does.
1260
1289
  finished_at = datetime.now(timezone.utc)
1261
1290
  detail = _failure_detail(exc)
1291
+ if progress.last_turn is not None:
1292
+ detail = f"{detail}, turn {progress.last_turn}"
1262
1293
  logger.warning(
1263
1294
  "Gemini session [%s] failed after %.1fs (%s, %s%s): %s",
1264
1295
  label or "unlabeled",
@@ -1270,12 +1301,12 @@ async def run_session_gemini(
1270
1301
  )
1271
1302
  return SessionResult(
1272
1303
  result_text="",
1273
- cost_usd=0.0,
1304
+ cost_usd=progress.cost_usd(model),
1274
1305
  duration_seconds=(finished_at - started_at).total_seconds(),
1275
1306
  started_at=started_at,
1276
1307
  finished_at=finished_at,
1277
- tool_call_count=0,
1278
- tool_names=[],
1308
+ tool_call_count=len(progress.tool_calls),
1309
+ tool_names=sorted(set(progress.tool_calls)),
1279
1310
  context7_call_count=0,
1280
1311
  model=model,
1281
1312
  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",