argus-code-review 0.2.9__tar.gz → 0.2.11__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 (154) hide show
  1. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/CHANGELOG.md +30 -1
  2. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/PKG-INFO +1 -1
  3. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/graph.py +134 -25
  4. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_bench_config_guard.py +126 -0
  5. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_llm_models.py +21 -3
  6. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_plan_review.py +205 -37
  7. argus_code_review-0.2.11/tests/test_structured_output_methods.py +102 -0
  8. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/.gitignore +0 -0
  9. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/LICENSE +0 -0
  10. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/README.md +0 -0
  11. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/__init__.py +0 -0
  12. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/bench.py +0 -0
  13. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/bench_default.toml +0 -0
  14. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/cli.py +0 -0
  15. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/config.py +0 -0
  16. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/coverage.py +0 -0
  17. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/dotenv_utils.py +0 -0
  18. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/gemini_cache.py +0 -0
  19. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/gemini_runner.py +0 -0
  20. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/github_client.py +0 -0
  21. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/helpers.py +0 -0
  22. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/llm/models.py +0 -0
  23. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/llm/output_models.py +0 -0
  24. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/llm/pricing.py +0 -0
  25. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/llm/usage.py +0 -0
  26. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/models.py +0 -0
  27. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/openai_client.py +0 -0
  28. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/openai_runner.py +0 -0
  29. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/pipeline_models.py +0 -0
  30. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/__init__.py +0 -0
  31. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/actions_scanner.py +0 -0
  32. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/engine.py +0 -0
  33. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/eslint_bundle/.gitignore +0 -0
  34. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/eslint_bundle/README.md +0 -0
  35. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/eslint_bundle/eslint.config.js +0 -0
  36. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/eslint_bundle/package-lock.json +0 -0
  37. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/eslint_bundle/package.json +0 -0
  38. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/js_scanner.py +0 -0
  39. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/migration_scanner.py +0 -0
  40. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/rules/README.md +0 -0
  41. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/sarif.py +0 -0
  42. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/scanner_utils.py +0 -0
  43. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/secrets_scanner.py +0 -0
  44. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/shadow.py +0 -0
  45. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/terraform_scanner.py +0 -0
  46. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/precheck/workflow_lint_scanner.py +0 -0
  47. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/__init__.py +0 -0
  48. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-blocking-validator.md +0 -0
  49. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-coverage-check.md +0 -0
  50. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-cross-cutting.md +0 -0
  51. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-feedback-verifier.md +0 -0
  52. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-lite.md +0 -0
  53. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-planner.md +0 -0
  54. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-preflight-router.md +0 -0
  55. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-prior-art.md +0 -0
  56. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-specialist-deployment.md +0 -0
  57. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-specialist-frontend.md +0 -0
  58. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-specialist-infra.md +0 -0
  59. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-specialist-llm-patterns.md +0 -0
  60. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-specialist-observability.md +0 -0
  61. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-specialist-orchestration.md +0 -0
  62. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-specialist-security.md +0 -0
  63. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-specialist-slackbot.md +0 -0
  64. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-specialist-sql.md +0 -0
  65. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-subagent.md +0 -0
  66. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-tests-and-docs.md +0 -0
  67. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts/pr-review-writer.md +0 -0
  68. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/prompts_runtime.py +0 -0
  69. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/repo_provision.py +0 -0
  70. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/review_tools.py +0 -0
  71. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/runners.py +0 -0
  72. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/storage/__init__.py +0 -0
  73. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/storage/http.py +0 -0
  74. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/storage/models.py +0 -0
  75. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/storage/precheck.py +0 -0
  76. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/storage/resolver.py +0 -0
  77. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/storage/session.py +0 -0
  78. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/storage/sql.py +0 -0
  79. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/argus/storage/sqlite.py +0 -0
  80. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/pyproject.toml +0 -0
  81. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/schema/008_add_code_reviews.sql +0 -0
  82. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/schema/009_add_reviewer_version.sql +0 -0
  83. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/schema/010_add_review_patterns.sql +0 -0
  84. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/schema/011_add_review_progress_columns.sql +0 -0
  85. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/schema/015_create_agent_runs.sql +0 -0
  86. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/schema/016_add_agent_runs_failure_reason.sql +0 -0
  87. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/schema/017_add_precheck_rules.sql +0 -0
  88. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/schema/018_widen_agent_runs_failure_reason.sql +0 -0
  89. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/__init__.py +0 -0
  90. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/conftest.py +0 -0
  91. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/golden/review_response.schema.json +0 -0
  92. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/storage/__init__.py +0 -0
  93. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/storage/test_backend_contract.py +0 -0
  94. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/storage/test_http.py +0 -0
  95. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/storage/test_models.py +0 -0
  96. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/storage/test_resolver.py +0 -0
  97. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/storage/test_session.py +0 -0
  98. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/storage/test_sql.py +0 -0
  99. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/storage/test_sqlite_backend.py +0 -0
  100. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_actions_scanner.py +0 -0
  101. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_argus_review_local.py +0 -0
  102. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_bench.py +0 -0
  103. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_catchup_gate.py +0 -0
  104. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_cli_args.py +0 -0
  105. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_cli_output_contract.py +0 -0
  106. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_cli_post_review.py +0 -0
  107. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_cli_preflight.py +0 -0
  108. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_cli_prompts.py +0 -0
  109. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_config.py +0 -0
  110. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_conftest.py +0 -0
  111. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_gemini_cache.py +0 -0
  112. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_gemini_runner.py +0 -0
  113. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_github_client_checks_signal.py +0 -0
  114. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_github_client_write.py +0 -0
  115. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_graph_fetch_diff.py +0 -0
  116. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_graph_get_llm_temperature.py +0 -0
  117. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_graph_http_guards.py +0 -0
  118. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_graph_precheck.py +0 -0
  119. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_graph_preflight_image_bump.py +0 -0
  120. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_graph_progress.py +0 -0
  121. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_graph_storage_resolution.py +0 -0
  122. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_graph_timeout_surfacing.py +0 -0
  123. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_js_scanner.py +0 -0
  124. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_llm_models_override.py +0 -0
  125. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_llm_pricing.py +0 -0
  126. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_migration_scanner.py +0 -0
  127. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_models.py +0 -0
  128. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_multi_round.py +0 -0
  129. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_openai_client.py +0 -0
  130. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_openai_runner.py +0 -0
  131. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_output_models.py +0 -0
  132. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_packaging.py +0 -0
  133. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_precheck_engine.py +0 -0
  134. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_precheck_engine_integration.py +0 -0
  135. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_precheck_sarif.py +0 -0
  136. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_precheck_shadow.py +0 -0
  137. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_precheck_shadow_integration.py +0 -0
  138. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_prompts.py +0 -0
  139. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_repo_provision.py +0 -0
  140. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_review_patterns_integration.py +0 -0
  141. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_review_tools.py +0 -0
  142. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_runners_context7.py +0 -0
  143. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_runners_context_usage.py +0 -0
  144. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_runners_helpers.py +0 -0
  145. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_runners_new.py +0 -0
  146. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_runners_turn_budget.py +0 -0
  147. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_scanner_utils.py +0 -0
  148. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_secrets_scanner.py +0 -0
  149. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_specialist_validation.py +0 -0
  150. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_stage_costs.py +0 -0
  151. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_storage_precheck.py +0 -0
  152. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_terraform_scanner.py +0 -0
  153. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_terraform_scanner_integration.py +0 -0
  154. {argus_code_review-0.2.9 → argus_code_review-0.2.11}/tests/test_workflow_lint_scanner.py +0 -0
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.11] - 2026-10-12
11
+
12
+ ### Fixed
13
+
14
+ - The bench-config-change guard (TECH-6282) no longer fails closed on a file whose
15
+ patch GitHub's compare API omits (diff too large) when the repo has explicitly
16
+ declared that file generated/non-diffable via `.gitattributes`
17
+ (`linguist-generated=true` / `-diff`) — the same signal other AI review tools
18
+ (e.g. Cubic) already honor to skip generated files. Previously, any oversized
19
+ file anywhere in a PR's diff (e.g. a large Drizzle-generated migration snapshot,
20
+ unrelated to Argus's own bench config) forced every review round to BLOCKING
21
+ with no way out, since the guard treated "missing patch" as suspicious
22
+ regardless of whether the file was remotely bench-related. The real
23
+ bench-config-path check (`_is_bench_config_path`) is unaffected: a genuine
24
+ bench config file can't hide behind a `.gitattributes` declaration. (TECH-7402)
25
+
26
+ ## [0.2.10] - 2026-10-03
27
+
28
+ ### Fixed
29
+
30
+ - Fixed structured output generation on Claude 5.5 (`claude-opus-5-5`, `claude-sonnet-5-5`) across the planner, preflight, and coverage stages (TECH-7156, #33):
31
+ - Migrated `plan_review`, `run_preflight_check`, and `check_coverage` to Anthropic native JSON-schema output (`output_config.format` with `type="json_schema"`) instead of unsupported forced tool choice (`tool_choice`).
32
+ - Preserved planner raw streaming, strict validation, repair fallback via OpenAI, truncation detection with transient retry (`PlannerTransientError`), and stage usage accounting.
33
+ - Added targeted regression coverage for streamed native structured output chunk shapes and non-streaming native JSON-schema stage bindings (`tests/test_plan_review.py`, `tests/test_structured_output_methods.py`).
34
+ - Updated architecture documentation (`docs/ARCHITECTURE.md`) to reflect native structured output for the planner.
35
+ - End-to-end evidence: verified via a full Argus branch review through the Redesign Health Anthropic proxy with model overrides unset (the RH proxy itself was unchanged).
36
+
10
37
  ## [0.2.9] - 2026-10-02
11
38
 
12
39
  ### Changed
@@ -380,7 +407,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
380
407
  packaged set.
381
408
  - `argus --version`, `argus prompts list`, and `argus prompts export`.
382
409
 
383
- [Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.2.9...HEAD
410
+ [Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.2.11...HEAD
411
+ [0.2.11]: https://github.com/redesignhealth/argus-review/compare/v0.2.10...v0.2.11
412
+ [0.2.10]: https://github.com/redesignhealth/argus-review/compare/v0.2.9...v0.2.10
384
413
  [0.2.9]: https://github.com/redesignhealth/argus-review/compare/v0.2.8...v0.2.9
385
414
  [0.2.8]: https://github.com/redesignhealth/argus-review/compare/v0.2.7...v0.2.8
386
415
  [0.2.7]: https://github.com/redesignhealth/argus-review/compare/v0.2.6...v0.2.7
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argus-code-review
3
- Version: 0.2.9
3
+ Version: 0.2.11
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
@@ -27,6 +27,7 @@ parameters, making add_node() calls report spurious arg-type errors.
27
27
 
28
28
  import asyncio
29
29
  import base64
30
+ import contextlib
30
31
  import json
31
32
  import logging
32
33
  import operator
@@ -54,7 +55,7 @@ from argus.llm.usage import (
54
55
  )
55
56
 
56
57
  from langchain.chat_models import init_chat_model
57
- from anthropic import APIConnectionError, APITimeoutError
58
+ from anthropic import APIConnectionError, APITimeoutError, transform_schema
58
59
  from langgraph.graph import END, START, StateGraph
59
60
  from sqlalchemy.exc import ProgrammingError
60
61
  from langgraph.types import RetryPolicy, Send
@@ -1080,9 +1081,9 @@ async def plan_review(diff: str, description: str) -> ReviewPlan:
1080
1081
 
1081
1082
  Two-phase pattern mirroring the writer at ``write_review``:
1082
1083
 
1083
- 1. Phase 1 — Opus emits the plan via streamed ``bind_tools`` tool-use so
1084
- we receive the raw JSON text of the tool-call args directly, rather
1085
- than routing through ``with_structured_output`` (which strict-parses
1084
+ 1. Phase 1 — Opus emits the plan via streamed native structured output
1085
+ (``output_config.format``) so we receive the raw JSON text directly,
1086
+ rather than routing through ``with_structured_output`` (which strict-parses
1086
1087
  the wire body via the Anthropic SDK and dies on invalid escape
1087
1088
  sequences Opus occasionally emits, e.g. ``\\s`` inside a string).
1088
1089
  2. Phase 2 — attempt a strict Pydantic parse; if it fails (invalid
@@ -1093,29 +1094,73 @@ async def plan_review(diff: str, description: str) -> ReviewPlan:
1093
1094
  """
1094
1095
  prompt = await fetch_prompt("pr-review-planner")
1095
1096
  messages = _build_planner_messages(prompt, diff, description)
1097
+ # Anthropic native structured output config for ReviewPlan.
1098
+ # Note: langchain-anthropic's `with_structured_output` chains a non-streaming
1099
+ # PydanticOutputParser, preventing raw JSON stream capture needed for
1100
+ # truncation detection and OpenAI JSON-escape repair fallback. Rather than
1101
+ # importing langchain-anthropic's private `_convert_to_anthropic_output_config_format`,
1102
+ # we use the public `anthropic.transform_schema` to construct native `output_config.format`.
1096
1103
  model = (
1097
1104
  _get_llm(_PLANNER_MODEL, "planner")
1098
- .bind_tools([ReviewPlan], tool_choice="ReviewPlan")
1105
+ .bind(
1106
+ output_config={
1107
+ "format": {
1108
+ "type": "json_schema",
1109
+ "schema": transform_schema(ReviewPlan),
1110
+ }
1111
+ }
1112
+ )
1099
1113
  .with_config(
1100
1114
  run_name="planner-phase1-stream",
1101
- tags=["planner", "bind_tools", "json-escape-repair"],
1115
+ tags=["planner", "native_structured_output", "json-escape-repair"],
1102
1116
  )
1103
1117
  )
1104
1118
 
1105
- # Only collect args from the first tool-call slot (index 0). tool_choice
1106
- # pins the model to a single tool, but guarding by index is cheap
1107
- # defense against the model ever emitting multiple tool calls.
1119
+ # Native output streams JSON as text content. Collect string text and
1120
+ # text content blocks, while preserving legacy tool_call_chunks handling
1121
+ # for backwards/mock compatibility. If a chunk exposes text content,
1122
+ # tool_call_chunks are skipped for that chunk to avoid double-counting.
1108
1123
  raw_json_parts: list[str] = []
1109
1124
  dropped_nonzero_indices: set[int] = set()
1110
1125
  async for chunk in model.astream(messages):
1111
- for tc_chunk in getattr(chunk, "tool_call_chunks", []):
1112
- idx = tc_chunk.get("index", 0)
1113
- if idx != 0:
1114
- dropped_nonzero_indices.add(idx)
1115
- continue
1116
- args = tc_chunk.get("args")
1117
- if args:
1118
- raw_json_parts.append(args)
1126
+ text_piece = ""
1127
+ content = getattr(chunk, "content", None)
1128
+ if isinstance(content, str):
1129
+ text_piece = content
1130
+ elif isinstance(content, list):
1131
+ pieces: list[str] = []
1132
+ for block in content:
1133
+ if isinstance(block, str):
1134
+ pieces.append(block)
1135
+ elif isinstance(block, dict):
1136
+ block_type = block.get("type")
1137
+ if (block_type is None or block_type == "text") and isinstance(
1138
+ block.get("text"), str
1139
+ ):
1140
+ pieces.append(block["text"])
1141
+ elif hasattr(block, "text") and isinstance(getattr(block, "text"), str):
1142
+ block_type = getattr(block, "type", None)
1143
+ if block_type is None or block_type == "text":
1144
+ pieces.append(block.text)
1145
+ text_piece = "".join(pieces)
1146
+ elif hasattr(chunk, "text") and isinstance(getattr(chunk, "text"), str):
1147
+ text_piece = chunk.text
1148
+
1149
+ if text_piece:
1150
+ raw_json_parts.append(text_piece)
1151
+ else:
1152
+ for tc_chunk in getattr(chunk, "tool_call_chunks", []) or []:
1153
+ if isinstance(tc_chunk, dict):
1154
+ idx = tc_chunk.get("index", 0)
1155
+ args = tc_chunk.get("args")
1156
+ else:
1157
+ idx = getattr(tc_chunk, "index", 0)
1158
+ args = getattr(tc_chunk, "args", None)
1159
+ if idx != 0:
1160
+ dropped_nonzero_indices.add(idx)
1161
+ continue
1162
+ if args:
1163
+ raw_json_parts.append(args)
1119
1164
 
1120
1165
  if dropped_nonzero_indices:
1121
1166
  logger.warning(
@@ -1127,29 +1172,29 @@ async def plan_review(diff: str, description: str) -> ReviewPlan:
1127
1172
  raw_json = "".join(raw_json_parts)
1128
1173
  if not raw_json:
1129
1174
  logger.error(
1130
- "Planner produced no tool-use output; model=%s tool=ReviewPlan",
1175
+ "Planner produced no structured output; model=%s schema=ReviewPlan",
1131
1176
  _PLANNER_MODEL,
1132
1177
  )
1133
1178
  raise ValueError(
1134
- "Planner produced no tool-use output — model may have returned a "
1135
- "plain text response instead of calling the ReviewPlan tool."
1179
+ "Planner produced no structured output — model returned no JSON "
1180
+ "content matching the ReviewPlan schema."
1136
1181
  )
1137
1182
 
1138
1183
  # Truncation guard: a mid-stream failure can leave raw_json syntactically
1139
1184
  # parseable at the top level but missing tail content. A complete
1140
- # tool-use JSON object must start with `{` and end with `}` after
1185
+ # structured output JSON object must start with `{` and end with `}` after
1141
1186
  # whitespace trim. If it doesn't, the stream was cut off — fail loud
1142
1187
  # rather than feed partial JSON to the fallback repair step.
1143
1188
  stripped = raw_json.strip()
1144
1189
  if not (stripped.startswith("{") and stripped.endswith("}")):
1145
1190
  logger.error(
1146
- "Planner tool-use output appears truncated; length=%d starts_with=%r ends_with=%r",
1191
+ "Planner structured output appears truncated; length=%d starts_with=%r ends_with=%r",
1147
1192
  len(raw_json),
1148
1193
  stripped[:1],
1149
1194
  stripped[-1:] if stripped else "",
1150
1195
  )
1151
1196
  raise PlannerTransientError(
1152
- "Planner tool-use output appears truncated (does not start with "
1197
+ "Planner structured output appears truncated (does not start with "
1153
1198
  "'{' and end with '}') — stream likely terminated mid-response."
1154
1199
  )
1155
1200
 
@@ -1339,7 +1384,9 @@ async def check_coverage(
1339
1384
  )
1340
1385
 
1341
1386
  prompt = await fetch_prompt("pr-review-coverage-check")
1342
- model = _get_llm(_COVERAGE_MODEL, "coverage").with_structured_output(CoverageResult)
1387
+ model = _get_llm(_COVERAGE_MODEL, "coverage").with_structured_output(
1388
+ CoverageResult, method="json_schema"
1389
+ )
1343
1390
  messages = [
1344
1391
  {"role": "system", "content": prompt},
1345
1392
  *_build_coverage_messages(plan, findings, sorted(uncovered)),
@@ -1445,7 +1492,7 @@ async def run_preflight_check(
1445
1492
  prompt = await fetch_prompt("pr-review-preflight-router")
1446
1493
  llm = _get_llm(
1447
1494
  f"anthropic:{CLAUDE_DEFAULT}", "preflight", max_tokens=256, temperature=0
1448
- ).with_structured_output(PreflightResult)
1495
+ ).with_structured_output(PreflightResult, method="json_schema")
1449
1496
  prior_context = (
1450
1497
  f"Prior round verdict: {prior_verdict}" if prior_verdict else "No prior review (round 1)"
1451
1498
  )
@@ -2047,6 +2094,62 @@ def _detect_bench_config_routing_lines(diff: str) -> list[str]:
2047
2094
  return matches
2048
2095
 
2049
2096
 
2097
+ async def _is_generated_nodiff_path(worktree_path: str | None, filename: str) -> bool:
2098
+ """Return True if the repo itself declares ``filename`` generated/non-diffable.
2099
+
2100
+ A file whose patch GitHub's compare API omits (diff too large) is otherwise
2101
+ treated as bench-config-unconfirmed below regardless of whether the file is
2102
+ remotely bench-related -- this fires for any oversized file anywhere in the
2103
+ diff, e.g. a large auto-generated migration snapshot unrelated to Argus's own
2104
+ config. Repos already have a standard, explicit, auditable way to declare a
2105
+ file generated-and-not-meaningfully-diffable: ``.gitattributes``
2106
+ ``linguist-generated=true`` / ``-diff``. Honor that signal here so a
2107
+ missing-patch file only stays unconfirmed (and forces BLOCKING) when the repo
2108
+ hasn't already declared it non-diffable. This does NOT weaken
2109
+ ``_is_bench_config_path`` above, which matches on filename regardless of patch
2110
+ content or gitattributes -- a real bench config file can't hide behind this.
2111
+ """
2112
+ if not worktree_path:
2113
+ return False
2114
+ try:
2115
+ proc = await asyncio.create_subprocess_exec(
2116
+ "git",
2117
+ "-C",
2118
+ worktree_path,
2119
+ "check-attr",
2120
+ "diff",
2121
+ "linguist-generated",
2122
+ "--",
2123
+ filename,
2124
+ stdout=asyncio.subprocess.PIPE,
2125
+ stderr=asyncio.subprocess.PIPE,
2126
+ )
2127
+ try:
2128
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
2129
+ except (asyncio.TimeoutError, TimeoutError):
2130
+ proc.kill()
2131
+ with contextlib.suppress(Exception):
2132
+ await proc.communicate()
2133
+ return False
2134
+ except Exception:
2135
+ return False
2136
+ if proc.returncode != 0:
2137
+ return False
2138
+ diff_unset = False
2139
+ linguist_generated = False
2140
+ for line in stdout.decode(errors="replace").splitlines():
2141
+ # Format: "<path>: <attr>: <value>"
2142
+ parts = line.split(": ", 2)
2143
+ if len(parts) != 3:
2144
+ continue
2145
+ _, attr, value = parts
2146
+ if attr == "diff" and value == "unset":
2147
+ diff_unset = True
2148
+ elif attr == "linguist-generated" and value == "true":
2149
+ linguist_generated = True
2150
+ return diff_unset or linguist_generated
2151
+
2152
+
2050
2153
  def _is_high_blast_radius(diff: str) -> str | None:
2051
2154
  r"""Return the first changed path that matches a high-blast-radius pattern, or None.
2052
2155
 
@@ -2321,6 +2424,12 @@ async def _node_fetch_diff(state: ReviewState, config: RunnableConfig) -> dict[s
2321
2424
  bench_config_changes.append(
2322
2425
  f"added line modifying bench routing in {filename}: {line}"
2323
2426
  )
2427
+ elif await _is_generated_nodiff_path(worktree_path, filename):
2428
+ logger.info(
2429
+ "Missing patch content for '%s' ignored for bench-config verification: "
2430
+ "repo declares it generated/non-diffable via .gitattributes",
2431
+ filename,
2432
+ )
2324
2433
  elif not unconfirmed_reason and filename not in bench_config_changes:
2325
2434
  unconfirmed_reason = (
2326
2435
  f"Diff patch content missing or empty for '{filename}' in GitHub API "
@@ -13,6 +13,7 @@ Covers:
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import subprocess
16
17
  from typing import Any
17
18
  from unittest.mock import AsyncMock, MagicMock, patch
18
19
 
@@ -21,6 +22,7 @@ import pytest
21
22
  from argus.graph import (
22
23
  _detect_bench_config_routing_lines,
23
24
  _is_bench_config_path,
25
+ _is_generated_nodiff_path,
24
26
  _node_fetch_diff,
25
27
  run_review,
26
28
  )
@@ -607,6 +609,130 @@ class TestMultiRoundBenchConfigGuardRegressions:
607
609
  assert fired is False
608
610
  assert response.verdict == Verdict.APPROVE
609
611
 
612
+ @pytest.mark.asyncio
613
+ async def test_missing_patch_on_gitattributes_generated_file_does_not_fail_closed(
614
+ self, tmp_path: Any
615
+ ) -> None:
616
+ """A file the repo declares generated/non-diffable via .gitattributes must NOT fail
617
+ closed just because GitHub omitted its patch (e.g. a large Drizzle/ORM migration
618
+ snapshot)."""
619
+ subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
620
+ (tmp_path / ".gitattributes").write_text(
621
+ "migrations/meta/*.json linguist-generated=true -diff\n"
622
+ )
623
+ generated_path = tmp_path / "migrations" / "meta"
624
+ generated_path.mkdir(parents=True)
625
+ (generated_path / "0001_snapshot.json").write_text("{}")
626
+
627
+ req = ReviewRequest(repo="org/repo", pr_number=42)
628
+ diff = (
629
+ "diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json\n"
630
+ )
631
+
632
+ mock_gh = MagicMock()
633
+ mock_gh.get_compare_files.return_value = (
634
+ [
635
+ {
636
+ "filename": "migrations/meta/0001_snapshot.json",
637
+ "status": "modified",
638
+ "patch": None,
639
+ }
640
+ ],
641
+ False,
642
+ )
643
+
644
+ state = {"request": req.model_dump()}
645
+ config: dict[str, Any] = {"configurable": {"worktree_path": str(tmp_path)}}
646
+
647
+ with (
648
+ patch("argus.graph._fetch_prior_review", new_callable=AsyncMock, return_value=None),
649
+ patch("argus.graph._fetch_dismissed_findings", new_callable=AsyncMock, return_value=[]),
650
+ patch(
651
+ "argus.graph._fetch_pr_diff_and_description",
652
+ new_callable=AsyncMock,
653
+ return_value=(diff, "desc", "head12345678", "main"),
654
+ ),
655
+ patch(_GH_CLIENT_CLASS, return_value=mock_gh),
656
+ ):
657
+ node_result = await _node_fetch_diff(state, config)
658
+
659
+ assert node_result["bench_config_unconfirmed"] is None
660
+ assert node_result["bench_config_changes"] == []
661
+
662
+ response = _make_response(verdict=Verdict.APPROVE)
663
+ fired = apply_bench_config_change_gate(
664
+ response, node_result["bench_config_changes"], node_result["bench_config_unconfirmed"]
665
+ )
666
+ assert fired is False
667
+ assert response.verdict == Verdict.APPROVE
668
+
669
+ @pytest.mark.asyncio
670
+ async def test_missing_patch_without_worktree_still_fails_closed(self) -> None:
671
+ """No worktree_path (e.g. an older call path) means the gitattributes exemption
672
+ can't be checked, so the existing fail-closed behavior must still apply."""
673
+ req = ReviewRequest(repo="org/repo", pr_number=42)
674
+ diff = (
675
+ "diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json\n"
676
+ )
677
+
678
+ mock_gh = MagicMock()
679
+ mock_gh.get_compare_files.return_value = (
680
+ [
681
+ {
682
+ "filename": "migrations/meta/0001_snapshot.json",
683
+ "status": "modified",
684
+ "patch": None,
685
+ }
686
+ ],
687
+ False,
688
+ )
689
+
690
+ state = {"request": req.model_dump()}
691
+ config: dict[str, Any] = {"configurable": {}}
692
+
693
+ with (
694
+ patch("argus.graph._fetch_prior_review", new_callable=AsyncMock, return_value=None),
695
+ patch("argus.graph._fetch_dismissed_findings", new_callable=AsyncMock, return_value=[]),
696
+ patch(
697
+ "argus.graph._fetch_pr_diff_and_description",
698
+ new_callable=AsyncMock,
699
+ return_value=(diff, "desc", "head12345678", "main"),
700
+ ),
701
+ patch(_GH_CLIENT_CLASS, return_value=mock_gh),
702
+ ):
703
+ node_result = await _node_fetch_diff(state, config)
704
+
705
+ assert node_result["bench_config_unconfirmed"] is not None
706
+ assert "Diff patch content missing or empty" in node_result["bench_config_unconfirmed"]
707
+
708
+
709
+ class TestIsGeneratedNodiffPath:
710
+ """Unit tests for _is_generated_nodiff_path."""
711
+
712
+ @pytest.mark.asyncio
713
+ async def test_no_worktree_path_returns_false(self) -> None:
714
+ assert await _is_generated_nodiff_path(None, "some/file.json") is False
715
+
716
+ @pytest.mark.asyncio
717
+ async def test_linguist_generated_true_returns_true(self, tmp_path: Any) -> None:
718
+ subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
719
+ (tmp_path / ".gitattributes").write_text("big.json linguist-generated=true -diff\n")
720
+ (tmp_path / "big.json").write_text("{}")
721
+
722
+ assert await _is_generated_nodiff_path(str(tmp_path), "big.json") is True
723
+
724
+ @pytest.mark.asyncio
725
+ async def test_unrelated_file_returns_false(self, tmp_path: Any) -> None:
726
+ subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
727
+ (tmp_path / ".gitattributes").write_text("big.json linguist-generated=true -diff\n")
728
+ (tmp_path / "other.py").write_text("x = 1\n")
729
+
730
+ assert await _is_generated_nodiff_path(str(tmp_path), "other.py") is False
731
+
732
+ @pytest.mark.asyncio
733
+ async def test_non_git_directory_returns_false(self, tmp_path: Any) -> None:
734
+ assert await _is_generated_nodiff_path(str(tmp_path), "whatever.json") is False
735
+
610
736
 
611
737
  class TestRunReviewBenchGuardIntegration:
612
738
  """Integration test for run_review executing the bench config change gate."""
@@ -7,6 +7,7 @@ cost-estimation surface.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import litellm
10
11
  import pytest
11
12
 
12
13
  from argus.llm.models import (
@@ -49,12 +50,13 @@ class TestPricingLookup:
49
50
 
50
51
  def test_claude_default_pricing_rates(self) -> None:
51
52
  """Regression guard: claude-sonnet-5-5 rates in litellm must match
52
- expected rates ($2/$10/$0.20 per Mtok)."""
53
+ expected rates ($2/$10/$0.10 per Mtok). Anthropic lowered the cache-read
54
+ rate from $0.20 to $0.10 on 2026-10-07 (litellm#45113)."""
53
55
  cost = get_token_cost(ALIAS_MAP["claude-default"])
54
56
  assert cost is not None
55
57
  assert cost.input_cost_per_token == pytest.approx(2e-6)
56
58
  assert cost.output_cost_per_token == pytest.approx(10e-6)
57
- assert cost.cache_read_cost_per_token == pytest.approx(0.2e-6)
59
+ assert cost.cache_read_cost_per_token == pytest.approx(0.1e-6)
58
60
 
59
61
  def test_claude_opus_5_5_pricing_rates(self) -> None:
60
62
  """Regression guard: claude-opus-5-5 rates in litellm must match
@@ -85,13 +87,28 @@ class TestPricingLookup:
85
87
  assert cost.cache_read_cost_per_token < cost.input_cost_per_token
86
88
 
87
89
 
90
+ @pytest.fixture
91
+ def fixed_claude_default_rates(monkeypatch: pytest.MonkeyPatch) -> None:
92
+ """litellm.model_cost is fetched live at import, so pin the row used by the
93
+ arithmetic tests; only the TestPricingLookup guards read live rates."""
94
+ row = {
95
+ "input_cost_per_token": 2e-6,
96
+ "output_cost_per_token": 10e-6,
97
+ "cache_read_input_token_cost": 0.1e-6,
98
+ "cache_creation_input_token_cost": 2.5e-6,
99
+ }
100
+ monkeypatch.setitem(litellm.model_cost, ALIAS_MAP["claude-default"], row)
101
+
102
+
88
103
  class TestEstimateCostUsd:
104
+ @pytest.mark.usefixtures("fixed_claude_default_rates")
89
105
  def test_computes_expected_cost_for_claude_default(self) -> None:
90
106
  cost = estimate_cost_usd(
91
107
  ALIAS_MAP["claude-default"], input_tokens=1_000_000, output_tokens=1_000_000
92
108
  )
93
109
  assert cost == pytest.approx(2.00 + 10.00)
94
110
 
111
+ @pytest.mark.usefixtures("fixed_claude_default_rates")
95
112
  def test_cached_input_tokens_billed_separately_at_cache_read_rate(self) -> None:
96
113
  """cached_input_tokens is additive (billed at the cache-read rate),
97
114
  not a subset subtracted from input_tokens."""
@@ -101,8 +118,9 @@ class TestEstimateCostUsd:
101
118
  output_tokens=0,
102
119
  cached_input_tokens=1_000_000,
103
120
  )
104
- assert cost == pytest.approx(2.00 + 0.20)
121
+ assert cost == pytest.approx(2.00 + 0.10)
105
122
 
123
+ @pytest.mark.usefixtures("fixed_claude_default_rates")
106
124
  def test_cache_creation_tokens_billed(self) -> None:
107
125
  """cache_creation_tokens is billed at the cache-creation rate."""
108
126
  cost = estimate_cost_usd(