argus-code-review 0.3.2__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.
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/CHANGELOG.md +19 -1
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/PKG-INFO +1 -1
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/gemini_runner.py +293 -57
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/helpers.py +45 -8
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/pipeline_models.py +8 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-cross-cutting.md +5 -6
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-lite.md +1 -1
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/runners.py +19 -36
- argus_code_review-0.3.4/tests/test_failure_diagnostics.py +129 -0
- argus_code_review-0.3.4/tests/test_gemini_http_retry.py +136 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_gemini_runner.py +247 -1
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/.gitignore +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/LICENSE +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/README.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/bench.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/bench_default.toml +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/cli.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/config.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/coverage.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/dotenv_utils.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/gemini_cache.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/github_client.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/graph.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/llm/models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/llm/output_models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/llm/pricing.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/llm/usage.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/openai_client.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/openai_runner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/actions_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/engine.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/.gitignore +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/README.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/eslint.config.js +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/package-lock.json +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/eslint_bundle/package.json +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/js_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/migration_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/rules/README.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/sarif.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/scanner_utils.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/secrets_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/shadow.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/terraform_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/precheck/workflow_lint_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-blocking-validator.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-coverage-check.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-feedback-verifier.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-planner.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-preflight-router.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-prior-art.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-scope.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-deployment.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-frontend.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-infra.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-llm-patterns.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-observability.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-orchestration.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-security.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-slackbot.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-specialist-sql.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-subagent.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-tests-and-docs.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts/pr-review-writer.md +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/prompts_runtime.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/repo_provision.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/review_tools.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/scope.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/http.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/precheck.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/resolver.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/session.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/sql.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/argus/storage/sqlite.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/pyproject.toml +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/008_add_code_reviews.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/009_add_reviewer_version.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/010_add_review_patterns.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/011_add_review_progress_columns.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/015_create_agent_runs.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/016_add_agent_runs_failure_reason.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/017_add_precheck_rules.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/schema/018_widen_agent_runs_failure_reason.sql +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/conftest.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/golden/review_response.schema.json +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/__init__.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_backend_contract.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_http.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_resolver.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_session.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_sql.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/storage/test_sqlite_backend.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_actions_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_argus_review_local.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_bench.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_bench_config_guard.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_catchup_gate.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_cli_args.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_cli_output_contract.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_cli_post_review.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_cli_preflight.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_cli_prompts.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_config.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_conftest.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_dotenv_utils.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_gemini_cache.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_github_client_checks_signal.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_github_client_write.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_fetch_diff.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_get_llm_temperature.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_http_guards.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_precheck.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_preflight_image_bump.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_progress.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_storage_resolution.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_graph_timeout_surfacing.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_js_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_llm_models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_llm_models_override.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_llm_pricing.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_migration_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_multi_round.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_openai_client.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_openai_runner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_output_models.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_packaging.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_plan_review.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_precheck_engine.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_precheck_engine_integration.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_precheck_sarif.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_precheck_shadow.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_precheck_shadow_integration.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_prompts.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_repo_provision.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_review_patterns_integration.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_review_tools.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_runners_context7.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_runners_context_usage.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_runners_helpers.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_runners_new.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_runners_turn_budget.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_scanner_utils.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_scope.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_scope_gate.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_sdk_bundled_cli_version.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_secrets_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_specialist_validation.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_stage_costs.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_storage_precheck.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_structured_output_methods.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_terraform_scanner.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_terraform_scanner_integration.py +0 -0
- {argus_code_review-0.3.2 → argus_code_review-0.3.4}/tests/test_workflow_lint_scanner.py +0 -0
|
@@ -7,6 +7,22 @@ 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
|
+
|
|
19
|
+
## [0.3.3] - 2026-10-22
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Gemini reviewer sessions now retry ONCE on a transient failure (HTTP 429/500/502/503/504, `RESOURCE_EXHAUSTED`/`UNAVAILABLE`/`DEADLINE_EXCEEDED`/`INTERNAL`, or an httpx timeout/connect/read/remote-protocol error) that happens before the model returned any response. The cooldown depends on the error: 45-60 s jitter for 429/`RESOURCE_EXHAUSTED` (per-minute quota windows), 5-10 s for 5xx and network errors; a server `Retry-After`/`retryDelay` >= 5 s is honored; never more than 60 s. A daily-quota 429 (`PerDay` quota or a retry delay over 120 s) and auth, permission, bad-request and not-found errors are never retried. Overridable via `ARGUS_GEMINI_RETRY_RATE_LIMIT_{MIN,MAX}_S`, `ARGUS_GEMINI_RETRY_COOLDOWN_{MIN,MAX}_S` and `ARGUS_GEMINI_RETRY_MAX_WAIT_S`.
|
|
24
|
+
- A failed Gemini session now logs a WARNING with the exception type, HTTP code/status and a scrubbed, 300-character-truncated message, and `SessionResult.failure_detail` carries a short label such as `429 RESOURCE_EXHAUSTED`. `failure_reason` stays `worker_crashed`; degraded-coverage note text is unchanged. The existing ERROR log for a failed `generate_content` call is also scrubbed of key-shaped strings.
|
|
25
|
+
|
|
10
26
|
## [0.3.2] - 2026-10-21
|
|
11
27
|
|
|
12
28
|
### Added
|
|
@@ -441,7 +457,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
441
457
|
packaged set.
|
|
442
458
|
- `argus --version`, `argus prompts list`, and `argus prompts export`.
|
|
443
459
|
|
|
444
|
-
[Unreleased]: https://github.com/redesignhealth/argus-review/compare/v0.3.
|
|
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
|
|
462
|
+
[0.3.3]: https://github.com/redesignhealth/argus-review/compare/v0.3.2...v0.3.3
|
|
445
463
|
[0.3.2]: https://github.com/redesignhealth/argus-review/compare/v0.3.1...v0.3.2
|
|
446
464
|
[0.3.1]: https://github.com/redesignhealth/argus-review/compare/v0.3.0...v0.3.1
|
|
447
465
|
[0.3.0]: https://github.com/redesignhealth/argus-review/compare/v0.2.11...v0.3.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: argus-code-review
|
|
3
|
-
Version: 0.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
|
|
@@ -144,7 +144,10 @@ from __future__ import annotations
|
|
|
144
144
|
import asyncio
|
|
145
145
|
import json
|
|
146
146
|
import logging
|
|
147
|
+
import os
|
|
148
|
+
import random
|
|
147
149
|
import re
|
|
150
|
+
from dataclasses import dataclass, field
|
|
148
151
|
from datetime import datetime, timezone
|
|
149
152
|
from typing import Any
|
|
150
153
|
|
|
@@ -208,6 +211,213 @@ _FORCE_TOOL_CONFIG = types.ToolConfig(
|
|
|
208
211
|
# uncaught.
|
|
209
212
|
_HTTP_TIMEOUT_FRACTION = 0.8
|
|
210
213
|
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# One-shot retry of transient Gemini failures
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# A session is retried AT MOST ONCE, and only when it failed with a transient
|
|
218
|
+
# error (HTTP 429/500/502/503/504, RESOURCE_EXHAUSTED/UNAVAILABLE/
|
|
219
|
+
# DEADLINE_EXCEEDED/INTERNAL, or an httpx timeout/connect/read/remote-protocol
|
|
220
|
+
# error) before the model returned ANY response (i.e. on turn 0), so the retry
|
|
221
|
+
# restarts the session cleanly from the beginning. Later failures are not
|
|
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`):
|
|
225
|
+
# 45-60 s jitter for 429/RESOURCE_EXHAUSTED (per-minute quota windows), 5-10 s
|
|
226
|
+
# for 5xx/network errors; a server Retry-After/retryDelay >= 5 s is honored;
|
|
227
|
+
# never more than 60 s. Daily-quota 429s are not retried. Overridable via
|
|
228
|
+
# ARGUS_GEMINI_RETRY_RATE_LIMIT_{MIN,MAX}_S, ARGUS_GEMINI_RETRY_COOLDOWN_{MIN,MAX}_S
|
|
229
|
+
# and ARGUS_GEMINI_RETRY_MAX_WAIT_S (read per call, so tests can set them to 0).
|
|
230
|
+
# The 60 s worst case is far inside ARGUS_SESSION_TIMEOUT (default 1500 s), and
|
|
231
|
+
# the retry is skipped if <25% of that timeout remains.
|
|
232
|
+
_RETRY_RATE_LIMIT_MIN_S = 45.0 # 429 / RESOURCE_EXHAUSTED: per-minute windows
|
|
233
|
+
_RETRY_RATE_LIMIT_MAX_S = 60.0
|
|
234
|
+
_RETRY_SHORT_MIN_S = 5.0 # 5xx / UNAVAILABLE / DEADLINE_EXCEEDED / network
|
|
235
|
+
_RETRY_SHORT_MAX_S = 10.0
|
|
236
|
+
_RETRY_MAX_WAIT_S = 60.0 # hard cap on any single wait, incl. a server Retry-After
|
|
237
|
+
_RETRY_AFTER_MIN_S = 5.0 # a server delay below this is ignored (jitter used)
|
|
238
|
+
# A server-suggested delay above this means the quota window is not a
|
|
239
|
+
# per-minute one (minutes/hours): fail fast rather than retry.
|
|
240
|
+
_RETRY_GIVEUP_DELAY_S = 120.0
|
|
241
|
+
# Do not retry when less than this fraction of the session timeout remains
|
|
242
|
+
# (e.g. the first attempt already burned its HTTP timeout).
|
|
243
|
+
_RETRY_MIN_REMAINING_FRACTION = 0.25
|
|
244
|
+
_TRANSIENT_HTTP_CODES = frozenset({429, 500, 502, 503, 504})
|
|
245
|
+
_TRANSIENT_STATUSES = frozenset(
|
|
246
|
+
{"RESOURCE_EXHAUSTED", "UNAVAILABLE", "DEADLINE_EXCEEDED", "INTERNAL"}
|
|
247
|
+
)
|
|
248
|
+
_TRANSIENT_HTTPX = (
|
|
249
|
+
httpx.TimeoutException,
|
|
250
|
+
httpx.ConnectError,
|
|
251
|
+
httpx.ReadError,
|
|
252
|
+
httpx.RemoteProtocolError,
|
|
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
|
|
262
|
+
_DETAIL_MAX_CHARS = 300
|
|
263
|
+
_SECRET_RE = re.compile(
|
|
264
|
+
r"(AIza[0-9A-Za-z_\-]{20,}|(?i:(?:api[_-]?key|key|token)=)[^&\s\"']+|"
|
|
265
|
+
r"(?i:bearer\s+)[A-Za-z0-9._\-]+|sk-[A-Za-z0-9_\-]{16,})"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _env_float(name: str, default: float) -> float:
|
|
270
|
+
raw = os.environ.get(name)
|
|
271
|
+
if raw is None or raw == "":
|
|
272
|
+
return default
|
|
273
|
+
try:
|
|
274
|
+
return max(0.0, float(raw))
|
|
275
|
+
except ValueError:
|
|
276
|
+
return default
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass
|
|
280
|
+
class _Progress:
|
|
281
|
+
"""Mutable per-session state shared between attempts and the failure handler."""
|
|
282
|
+
|
|
283
|
+
responses: int = 0 # successful generate_content responses received
|
|
284
|
+
last_turn: int | None = None # 0-based index of the turn that last ran
|
|
285
|
+
retried: bool = False
|
|
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
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _scrub(text: str) -> str:
|
|
319
|
+
"""Redact anything key-shaped and truncate for logging."""
|
|
320
|
+
text = _SECRET_RE.sub("***", text).replace("\n", " ")
|
|
321
|
+
return text[:_DETAIL_MAX_CHARS]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _error_code_status(exc: BaseException) -> tuple[int | None, str | None]:
|
|
325
|
+
"""Best-effort (HTTP code, status string) for an SDK/transport exception."""
|
|
326
|
+
if isinstance(exc, genai_errors.APIError):
|
|
327
|
+
code = getattr(exc, "code", None)
|
|
328
|
+
status = getattr(exc, "status", None)
|
|
329
|
+
return (
|
|
330
|
+
code if isinstance(code, int) else None,
|
|
331
|
+
status if isinstance(status, str) else None,
|
|
332
|
+
)
|
|
333
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
334
|
+
return exc.response.status_code, None
|
|
335
|
+
return None, None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _is_transient_error(exc: BaseException) -> bool:
|
|
339
|
+
code, status = _error_code_status(exc)
|
|
340
|
+
if code in _TRANSIENT_HTTP_CODES or (status or "").upper() in _TRANSIENT_STATUSES:
|
|
341
|
+
return True
|
|
342
|
+
if isinstance(exc, (genai_errors.APIError, httpx.HTTPStatusError)):
|
|
343
|
+
return False # a definite API answer that is not transient (400/401/403/404...)
|
|
344
|
+
return isinstance(exc, _TRANSIENT_HTTPX)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _retry_after_seconds(exc: BaseException) -> float | None:
|
|
348
|
+
"""Server-suggested delay from a Retry-After header or RetryInfo.retryDelay."""
|
|
349
|
+
response = getattr(exc, "response", None)
|
|
350
|
+
headers = getattr(response, "headers", None)
|
|
351
|
+
try:
|
|
352
|
+
raw = headers.get("retry-after") if headers is not None else None
|
|
353
|
+
if raw is not None:
|
|
354
|
+
return max(0.0, float(raw))
|
|
355
|
+
except (TypeError, ValueError, AttributeError):
|
|
356
|
+
pass
|
|
357
|
+
details = getattr(exc, "details", None)
|
|
358
|
+
err = details.get("error", details) if isinstance(details, dict) else None
|
|
359
|
+
items = err.get("details") if isinstance(err, dict) else None
|
|
360
|
+
for item in items if isinstance(items, list) else []:
|
|
361
|
+
delay = item.get("retryDelay") if isinstance(item, dict) else None
|
|
362
|
+
if isinstance(delay, str) and delay.endswith("s"):
|
|
363
|
+
try:
|
|
364
|
+
return max(0.0, float(delay[:-1]))
|
|
365
|
+
except ValueError:
|
|
366
|
+
pass
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _is_daily_quota(exc: BaseException, server_delay: float | None) -> bool:
|
|
371
|
+
"""True when a 429 reports a per-day quota or a many-minutes retry delay."""
|
|
372
|
+
if server_delay is not None and server_delay > _RETRY_GIVEUP_DELAY_S:
|
|
373
|
+
return True
|
|
374
|
+
return bool(re.search(r"per\s*day|perday", str(exc), re.IGNORECASE))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _retry_decision(exc: BaseException) -> tuple[str, float] | None:
|
|
378
|
+
"""``(kind, wait_seconds)`` if ``exc`` should be retried once, else ``None``.
|
|
379
|
+
|
|
380
|
+
Kinds: ``rate_limit`` (429/RESOURCE_EXHAUSTED, 45-60 s jitter),
|
|
381
|
+
``server`` (5xx/UNAVAILABLE/DEADLINE_EXCEEDED/INTERNAL) and ``network``
|
|
382
|
+
(5-10 s jitter). A server Retry-After/retryDelay >= 5 s replaces the
|
|
383
|
+
jittered value; the wait never exceeds 60 s. A daily-quota 429 is never
|
|
384
|
+
retried.
|
|
385
|
+
"""
|
|
386
|
+
if not _is_transient_error(exc):
|
|
387
|
+
return None
|
|
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
|
|
391
|
+
server_delay = _retry_after_seconds(exc)
|
|
392
|
+
if code == 429 or (status or "").upper() == "RESOURCE_EXHAUSTED":
|
|
393
|
+
if _is_daily_quota(exc, server_delay):
|
|
394
|
+
return None
|
|
395
|
+
kind = "rate_limit"
|
|
396
|
+
lo = _env_float("ARGUS_GEMINI_RETRY_RATE_LIMIT_MIN_S", _RETRY_RATE_LIMIT_MIN_S)
|
|
397
|
+
hi = _env_float("ARGUS_GEMINI_RETRY_RATE_LIMIT_MAX_S", _RETRY_RATE_LIMIT_MAX_S)
|
|
398
|
+
else:
|
|
399
|
+
kind = "server" if code is not None or status else "network"
|
|
400
|
+
lo = _env_float("ARGUS_GEMINI_RETRY_COOLDOWN_MIN_S", _RETRY_SHORT_MIN_S)
|
|
401
|
+
hi = _env_float("ARGUS_GEMINI_RETRY_COOLDOWN_MAX_S", _RETRY_SHORT_MAX_S)
|
|
402
|
+
cap = _env_float("ARGUS_GEMINI_RETRY_MAX_WAIT_S", _RETRY_MAX_WAIT_S)
|
|
403
|
+
wait = random.uniform(lo, max(lo, hi)) # noqa: S311 - jitter, not security
|
|
404
|
+
if server_delay is not None and server_delay >= _RETRY_AFTER_MIN_S:
|
|
405
|
+
wait = server_delay
|
|
406
|
+
return kind, min(wait, cap)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _failure_detail(exc: BaseException) -> str:
|
|
410
|
+
"""Short, key-free label such as ``429 RESOURCE_EXHAUSTED`` or ``ConnectError``."""
|
|
411
|
+
code, status = _error_code_status(exc)
|
|
412
|
+
detail = " ".join(p for p in (str(code) if code is not None else "", status or "") if p)
|
|
413
|
+
detail = detail or type(exc).__name__
|
|
414
|
+
if (code == 429 or (status or "").upper() == "RESOURCE_EXHAUSTED") and _is_daily_quota(
|
|
415
|
+
exc, _retry_after_seconds(exc)
|
|
416
|
+
):
|
|
417
|
+
detail += " (daily quota)"
|
|
418
|
+
return detail
|
|
419
|
+
|
|
420
|
+
|
|
211
421
|
# Matches the fenced-json extraction argus.helpers.parse_review_result
|
|
212
422
|
# already applies for the Claude path.
|
|
213
423
|
_FENCED_JSON_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
|
|
@@ -599,6 +809,7 @@ async def _run_turns(
|
|
|
599
809
|
repo_root: str,
|
|
600
810
|
timeout_s: float,
|
|
601
811
|
started_at: datetime,
|
|
812
|
+
progress: _Progress,
|
|
602
813
|
) -> SessionResult:
|
|
603
814
|
"""The actual tool-calling loop. Returns ``failure_reason="turn_budget_exhausted"``
|
|
604
815
|
when the loop ran out of turns without a ``finish_review`` call, else
|
|
@@ -665,6 +876,16 @@ async def _run_turns(
|
|
|
665
876
|
label=label,
|
|
666
877
|
)
|
|
667
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
|
+
|
|
668
889
|
def _build_config(cache_name: str | None) -> types.GenerateContentConfig:
|
|
669
890
|
# Gemini constraint (confirmed against the live API, not just
|
|
670
891
|
# documentation): a request using `cached_content=` must NOT
|
|
@@ -678,11 +899,14 @@ async def _run_turns(
|
|
|
678
899
|
# `CreateCachedContentConfig.tool_config`), so the cached
|
|
679
900
|
# branch here sends `cached_content` alone.
|
|
680
901
|
if cache_name:
|
|
681
|
-
return types.GenerateContentConfig(
|
|
902
|
+
return types.GenerateContentConfig(
|
|
903
|
+
cached_content=cache_name, http_options=request_http_options
|
|
904
|
+
)
|
|
682
905
|
return types.GenerateContentConfig(
|
|
683
906
|
system_instruction=system_prompt,
|
|
684
907
|
tools=gemini_tools,
|
|
685
908
|
tool_config=_FORCE_TOOL_CONFIG,
|
|
909
|
+
http_options=request_http_options,
|
|
686
910
|
)
|
|
687
911
|
|
|
688
912
|
config = _build_config(cached_content_name)
|
|
@@ -691,19 +915,14 @@ async def _run_turns(
|
|
|
691
915
|
types.Content(role="user", parts=[types.Part.from_text(text=user_message)])
|
|
692
916
|
]
|
|
693
917
|
|
|
694
|
-
tool_calls: list[str] = []
|
|
695
918
|
files_explored: list[str] = []
|
|
696
919
|
exhausted = False
|
|
697
|
-
usage_prompt_total = 0
|
|
698
|
-
usage_candidates_total = 0
|
|
699
|
-
usage_cached_total = 0
|
|
700
|
-
usage_thoughts_total = 0
|
|
701
|
-
usage_tool_use_prompt_total = 0
|
|
702
920
|
|
|
703
921
|
_mid_budget_nudge_turn = _compute_mid_budget_nudge_turn(_MAX_TURNS_GEMINI)
|
|
704
922
|
|
|
705
923
|
with review_tools.review_session(repo_root) as findings_sink:
|
|
706
924
|
for _turn in range(_MAX_TURNS_GEMINI):
|
|
925
|
+
progress.last_turn = _turn
|
|
707
926
|
if (
|
|
708
927
|
_turn == _MAX_TURNS_GEMINI - _NUDGE_TURNS_BEFORE_BUDGET
|
|
709
928
|
and contents[-1].parts is not None
|
|
@@ -736,8 +955,7 @@ async def _run_turns(
|
|
|
736
955
|
entry.role if entry else "unknown",
|
|
737
956
|
model,
|
|
738
957
|
_turn,
|
|
739
|
-
exc,
|
|
740
|
-
exc_info=True,
|
|
958
|
+
_scrub(str(exc)),
|
|
741
959
|
)
|
|
742
960
|
raise
|
|
743
961
|
logger.warning(
|
|
@@ -760,21 +978,16 @@ async def _run_turns(
|
|
|
760
978
|
model=model, contents=contents, config=config
|
|
761
979
|
)
|
|
762
980
|
|
|
981
|
+
progress.responses += 1
|
|
763
982
|
usage = response.usage_metadata
|
|
764
983
|
if usage is not None:
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
# GenerateContentResponseUsageMetadata's own docstring
|
|
773
|
-
# (total_token_count = prompt + candidates +
|
|
774
|
-
# tool_use_prompt + thoughts) -- NOT already folded
|
|
775
|
-
# into prompt_token_count/candidates_token_count.
|
|
776
|
-
usage_thoughts_total += usage.thoughts_token_count or 0
|
|
777
|
-
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
|
|
778
991
|
|
|
779
992
|
function_calls = response.function_calls or []
|
|
780
993
|
if not function_calls:
|
|
@@ -828,7 +1041,7 @@ async def _run_turns(
|
|
|
828
1041
|
for call in function_calls:
|
|
829
1042
|
name = call.name or ""
|
|
830
1043
|
args = dict(call.args or {})
|
|
831
|
-
tool_calls.append(name)
|
|
1044
|
+
progress.tool_calls.append(name)
|
|
832
1045
|
output, is_error = await asyncio.to_thread(_execute_tool_call, name, args)
|
|
833
1046
|
if is_error and name != "finish_review":
|
|
834
1047
|
logger.warning("Tool call failed [%s]: %s", name, output[:200])
|
|
@@ -860,35 +1073,19 @@ async def _run_turns(
|
|
|
860
1073
|
result_text = _build_result_text(findings_sink, files_explored)
|
|
861
1074
|
|
|
862
1075
|
finished_at = datetime.now(timezone.utc)
|
|
863
|
-
unique_tool_names = sorted(set(tool_calls))
|
|
864
|
-
|
|
865
|
-
# Gemini's prompt_token_count already INCLUDES the cached portion
|
|
866
|
-
# (unlike Anthropic's fully-separate input/cache-read counters,
|
|
867
|
-
# which is the convention estimate_cost_usd's cached_input_tokens
|
|
868
|
-
# documents) -- so the cached amount must be subtracted out of the
|
|
869
|
-
# uncached input count here, or it would be billed at both the full
|
|
870
|
-
# input rate AND the cache-read rate. tool_use_prompt tokens are
|
|
871
|
-
# billed at the input rate (added in); thoughts tokens are billed
|
|
872
|
-
# at the output rate (added in) -- see the accumulation loop above.
|
|
873
|
-
input_tokens = max(usage_prompt_total - usage_cached_total, 0) + usage_tool_use_prompt_total
|
|
874
|
-
output_tokens = usage_candidates_total + usage_thoughts_total
|
|
875
|
-
cost_usd = estimate_cost_usd(
|
|
876
|
-
model=model,
|
|
877
|
-
input_tokens=input_tokens,
|
|
878
|
-
output_tokens=output_tokens,
|
|
879
|
-
cached_input_tokens=usage_cached_total,
|
|
880
|
-
)
|
|
1076
|
+
unique_tool_names = sorted(set(progress.tool_calls))
|
|
1077
|
+
cost_usd = progress.cost_usd(model)
|
|
881
1078
|
|
|
882
|
-
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 ""
|
|
883
1080
|
logger.info(
|
|
884
1081
|
"Gemini agent done [%s]: %d tool calls%s, cost=$%.4f input_tokens=%d cached_tokens=%d output_tokens=%d",
|
|
885
1082
|
label or "unlabeled",
|
|
886
|
-
len(tool_calls),
|
|
1083
|
+
len(progress.tool_calls),
|
|
887
1084
|
tools_str,
|
|
888
1085
|
cost_usd,
|
|
889
|
-
input_tokens,
|
|
890
|
-
|
|
891
|
-
output_tokens,
|
|
1086
|
+
progress.input_tokens,
|
|
1087
|
+
progress.cached_tokens,
|
|
1088
|
+
progress.output_tokens,
|
|
892
1089
|
)
|
|
893
1090
|
|
|
894
1091
|
return SessionResult(
|
|
@@ -897,7 +1094,7 @@ async def _run_turns(
|
|
|
897
1094
|
duration_seconds=(finished_at - started_at).total_seconds(),
|
|
898
1095
|
started_at=started_at,
|
|
899
1096
|
finished_at=finished_at,
|
|
900
|
-
tool_call_count=len(tool_calls),
|
|
1097
|
+
tool_call_count=len(progress.tool_calls),
|
|
901
1098
|
tool_names=unique_tool_names,
|
|
902
1099
|
# No Context7 MCP integration in scope for this runner (see module
|
|
903
1100
|
# docstring) -- always 0, never derived from tool_calls.
|
|
@@ -922,6 +1119,35 @@ async def _run_turns(
|
|
|
922
1119
|
)
|
|
923
1120
|
|
|
924
1121
|
|
|
1122
|
+
async def _run_turns_with_retry(
|
|
1123
|
+
*, progress: _Progress, timeout_s: float, **kwargs: Any
|
|
1124
|
+
) -> SessionResult:
|
|
1125
|
+
"""``_run_turns`` with at most one retry of a transient turn-0 failure."""
|
|
1126
|
+
label = kwargs.get("label") or "unlabeled"
|
|
1127
|
+
t0 = asyncio.get_running_loop().time()
|
|
1128
|
+
try:
|
|
1129
|
+
return await _run_turns(timeout_s=timeout_s, progress=progress, **kwargs)
|
|
1130
|
+
except (genai_errors.APIError, httpx.HTTPError) as exc:
|
|
1131
|
+
remaining = timeout_s - (asyncio.get_running_loop().time() - t0)
|
|
1132
|
+
decision = _retry_decision(exc) if progress.responses == 0 else None
|
|
1133
|
+
if decision is None or remaining < timeout_s * _RETRY_MIN_REMAINING_FRACTION:
|
|
1134
|
+
raise
|
|
1135
|
+
kind, wait = decision
|
|
1136
|
+
if wait >= remaining:
|
|
1137
|
+
raise
|
|
1138
|
+
progress.retried = True
|
|
1139
|
+
progress.retry_wait_s = wait
|
|
1140
|
+
logger.info(
|
|
1141
|
+
"gemini session [%s] retrying after %s (kind=%s) in %.1fs",
|
|
1142
|
+
label,
|
|
1143
|
+
_failure_detail(exc),
|
|
1144
|
+
kind,
|
|
1145
|
+
wait,
|
|
1146
|
+
)
|
|
1147
|
+
await asyncio.sleep(wait)
|
|
1148
|
+
return await _run_turns(timeout_s=timeout_s, progress=progress, **kwargs)
|
|
1149
|
+
|
|
1150
|
+
|
|
925
1151
|
def _redact_gemini_inputs(inputs: dict[str, Any], **_: Any) -> dict[str, Any]:
|
|
926
1152
|
"""LangSmith ``process_inputs`` hook: redact credentials from Settings."""
|
|
927
1153
|
if not isinstance(inputs, dict):
|
|
@@ -988,9 +1214,11 @@ async def run_session_gemini(
|
|
|
988
1214
|
)
|
|
989
1215
|
|
|
990
1216
|
started_at = datetime.now(timezone.utc)
|
|
1217
|
+
progress = _Progress()
|
|
991
1218
|
try:
|
|
992
1219
|
return await asyncio.wait_for(
|
|
993
|
-
|
|
1220
|
+
_run_turns_with_retry(
|
|
1221
|
+
progress=progress,
|
|
994
1222
|
entry=entry,
|
|
995
1223
|
model=model,
|
|
996
1224
|
system_prompt=system_prompt,
|
|
@@ -1006,19 +1234,21 @@ async def run_session_gemini(
|
|
|
1006
1234
|
except (TimeoutError, httpx.TimeoutException) as exc:
|
|
1007
1235
|
finished_at = datetime.now(timezone.utc)
|
|
1008
1236
|
logger.warning(
|
|
1009
|
-
"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",
|
|
1010
1238
|
label or "unlabeled",
|
|
1011
1239
|
effective_timeout_s,
|
|
1012
1240
|
type(exc).__name__,
|
|
1241
|
+
progress.responses,
|
|
1242
|
+
len(progress.tool_calls),
|
|
1013
1243
|
)
|
|
1014
1244
|
return SessionResult(
|
|
1015
1245
|
result_text="",
|
|
1016
|
-
cost_usd=
|
|
1246
|
+
cost_usd=progress.cost_usd(model),
|
|
1017
1247
|
duration_seconds=(finished_at - started_at).total_seconds(),
|
|
1018
1248
|
started_at=started_at,
|
|
1019
1249
|
finished_at=finished_at,
|
|
1020
|
-
tool_call_count=
|
|
1021
|
-
tool_names=
|
|
1250
|
+
tool_call_count=len(progress.tool_calls),
|
|
1251
|
+
tool_names=sorted(set(progress.tool_calls)),
|
|
1022
1252
|
context7_call_count=0,
|
|
1023
1253
|
model=model,
|
|
1024
1254
|
failure_reason="timeout",
|
|
@@ -1057,22 +1287,28 @@ async def run_session_gemini(
|
|
|
1057
1287
|
# identically here, even though this runner has no literal
|
|
1058
1288
|
# subprocess "worker" the way the Claude Agent SDK path does.
|
|
1059
1289
|
finished_at = datetime.now(timezone.utc)
|
|
1290
|
+
detail = _failure_detail(exc)
|
|
1291
|
+
if progress.last_turn is not None:
|
|
1292
|
+
detail = f"{detail}, turn {progress.last_turn}"
|
|
1060
1293
|
logger.warning(
|
|
1061
|
-
"Gemini session [%s] failed
|
|
1294
|
+
"Gemini session [%s] failed after %.1fs (%s, %s%s): %s",
|
|
1062
1295
|
label or "unlabeled",
|
|
1063
1296
|
(finished_at - started_at).total_seconds(),
|
|
1064
1297
|
type(exc).__name__,
|
|
1065
|
-
|
|
1298
|
+
detail,
|
|
1299
|
+
", after one retry" if progress.retried else "",
|
|
1300
|
+
_scrub(str(exc)),
|
|
1066
1301
|
)
|
|
1067
1302
|
return SessionResult(
|
|
1068
1303
|
result_text="",
|
|
1069
|
-
cost_usd=
|
|
1304
|
+
cost_usd=progress.cost_usd(model),
|
|
1070
1305
|
duration_seconds=(finished_at - started_at).total_seconds(),
|
|
1071
1306
|
started_at=started_at,
|
|
1072
1307
|
finished_at=finished_at,
|
|
1073
|
-
tool_call_count=
|
|
1074
|
-
tool_names=
|
|
1308
|
+
tool_call_count=len(progress.tool_calls),
|
|
1309
|
+
tool_names=sorted(set(progress.tool_calls)),
|
|
1075
1310
|
context7_call_count=0,
|
|
1076
1311
|
model=model,
|
|
1077
1312
|
failure_reason="worker_crashed",
|
|
1313
|
+
failure_detail=detail,
|
|
1078
1314
|
)
|
|
@@ -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 =
|
|
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 =
|
|
311
|
+
is_exhausted = reason_code == "turn_budget_exhausted"
|
|
281
312
|
if is_missing:
|
|
282
313
|
reason_desc = "is not installed"
|
|
283
|
-
elif
|
|
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
|