patchi 0.7.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patchi/__init__.py +1 -0
- patchi/__main__.py +13 -0
- patchi/cli/__init__.py +0 -0
- patchi/cli/command_families.py +235 -0
- patchi/cli/commands/__init__.py +0 -0
- patchi/cli/commands/access_cmd.py +65 -0
- patchi/cli/commands/agent_stats_cmd.py +273 -0
- patchi/cli/commands/agents_cmd.py +187 -0
- patchi/cli/commands/ai_cmd.py +308 -0
- patchi/cli/commands/assure_cmd.py +538 -0
- patchi/cli/commands/audit_cmd.py +286 -0
- patchi/cli/commands/auto_cmd.py +110 -0
- patchi/cli/commands/blame_cmd.py +53 -0
- patchi/cli/commands/chains_cmd.py +327 -0
- patchi/cli/commands/charter_cmd.py +173 -0
- patchi/cli/commands/chat_cmd.py +597 -0
- patchi/cli/commands/check_cmd.py +232 -0
- patchi/cli/commands/cleanup_cmd.py +328 -0
- patchi/cli/commands/cockpit_cmd.py +208 -0
- patchi/cli/commands/commands_cmd.py +160 -0
- patchi/cli/commands/cross_repo_cmd.py +113 -0
- patchi/cli/commands/deps_cmd.py +110 -0
- patchi/cli/commands/dev_check_cmd.py +342 -0
- patchi/cli/commands/dev_cmd.py +445 -0
- patchi/cli/commands/doctor_cmd.py +457 -0
- patchi/cli/commands/findings_cmd.py +187 -0
- patchi/cli/commands/fix_cmd.py +648 -0
- patchi/cli/commands/fix_review_cmd.py +273 -0
- patchi/cli/commands/heatmap_cmd.py +62 -0
- patchi/cli/commands/help_cmd.py +159 -0
- patchi/cli/commands/hosted_cmd.py +980 -0
- patchi/cli/commands/init.py +456 -0
- patchi/cli/commands/key_cmd.py +571 -0
- patchi/cli/commands/learn_cmd.py +140 -0
- patchi/cli/commands/link_cmd.py +104 -0
- patchi/cli/commands/log_cmd.py +57 -0
- patchi/cli/commands/memory_cmd.py +215 -0
- patchi/cli/commands/model_cmd.py +207 -0
- patchi/cli/commands/notify_cmd.py +213 -0
- patchi/cli/commands/notify_prompts.py +29 -0
- patchi/cli/commands/patch_cmd.py +231 -0
- patchi/cli/commands/plan_cmd.py +128 -0
- patchi/cli/commands/plugins_cmd.py +307 -0
- patchi/cli/commands/queue_cmd.py +152 -0
- patchi/cli/commands/quick_cmd.py +159 -0
- patchi/cli/commands/ready_cmd.py +292 -0
- patchi/cli/commands/report_cmd.py +416 -0
- patchi/cli/commands/restrict_cmd.py +127 -0
- patchi/cli/commands/review_cmd.py +225 -0
- patchi/cli/commands/rules_cmd.py +233 -0
- patchi/cli/commands/scan_cmd.py +1908 -0
- patchi/cli/commands/settings_cmd.py +45 -0
- patchi/cli/commands/status_cmd.py +679 -0
- patchi/cli/commands/test_cmd.py +1052 -0
- patchi/cli/commands/trend_cmd.py +296 -0
- patchi/cli/commands/undo_cmd.py +235 -0
- patchi/cli/commands/update_cmd.py +345 -0
- patchi/cli/commands/verify_cmd.py +56 -0
- patchi/cli/commands/vr_cmd.py +440 -0
- patchi/cli/commands/watch_cmd.py +304 -0
- patchi/cli/commands/web_cmd.py +114 -0
- patchi/cli/console.py +101 -0
- patchi/cli/display/__init__.py +1 -0
- patchi/cli/display/live_audit.py +269 -0
- patchi/cli/display/live_progress.py +169 -0
- patchi/cli/framework.py +241 -0
- patchi/cli/logo.py +128 -0
- patchi/cli/main.py +222 -0
- patchi/cli/registry.py +1171 -0
- patchi/cli/style.py +68 -0
- patchi/cli/themes.py +187 -0
- patchi/cli/ux.py +444 -0
- patchi/core/__init__.py +0 -0
- patchi/core/agents/__init__.py +0 -0
- patchi/core/agents/api_fuzzer_agent.py +141 -0
- patchi/core/agents/attack_agent.py +242 -0
- patchi/core/agents/base.py +1012 -0
- patchi/core/agents/bug_predictor_agent.py +58 -0
- patchi/core/agents/build_tool_validator.py +150 -0
- patchi/core/agents/cache.py +192 -0
- patchi/core/agents/chaos_agent.py +133 -0
- patchi/core/agents/cicd_generator.py +240 -0
- patchi/core/agents/comment_scanner.py +280 -0
- patchi/core/agents/console_logging_agent.py +156 -0
- patchi/core/agents/coordinator.py +858 -0
- patchi/core/agents/core_scanner.py +269 -0
- patchi/core/agents/coverage_prioritizer.py +181 -0
- patchi/core/agents/dead_code_hygiene.py +296 -0
- patchi/core/agents/dead_code_scanner.py +692 -0
- patchi/core/agents/dependency_scanner.py +725 -0
- patchi/core/agents/doc_claim_agent.py +573 -0
- patchi/core/agents/duplicate_scanner.py +568 -0
- patchi/core/agents/env_scanner.py +410 -0
- patchi/core/agents/feature_flag_agent.py +89 -0
- patchi/core/agents/frontend_framework_agent.py +133 -0
- patchi/core/agents/goroutine_agent.py +82 -0
- patchi/core/agents/governor.py +1972 -0
- patchi/core/agents/i18n_agent.py +95 -0
- patchi/core/agents/license_compliance.py +247 -0
- patchi/core/agents/linking_agent.py +251 -0
- patchi/core/agents/memory_profiler_agent.py +102 -0
- patchi/core/agents/modernization_agent.py +84 -0
- patchi/core/agents/mutation_agent.py +174 -0
- patchi/core/agents/nl_query_agent.py +74 -0
- patchi/core/agents/promise_rejection_agent.py +100 -0
- patchi/core/agents/refactoring_agent.py +286 -0
- patchi/core/agents/resource_leak_agent.py +91 -0
- patchi/core/agents/route_graph_scanner.py +951 -0
- patchi/core/agents/run_agent.py +106 -0
- patchi/core/agents/rust_unwrap_agent.py +100 -0
- patchi/core/agents/sbom_generator.py +284 -0
- patchi/core/agents/scanners.py +67 -0
- patchi/core/agents/side/__init__.py +1 -0
- patchi/core/agents/side/build_agent.py +65 -0
- patchi/core/agents/side/format_agent.py +50 -0
- patchi/core/agents/side/install_agent.py +63 -0
- patchi/core/agents/side_file_scanner.py +549 -0
- patchi/core/agents/snapshot_drift_detector.py +146 -0
- patchi/core/agents/spa_route_inventory.py +203 -0
- patchi/core/agents/test_scanner.py +442 -0
- patchi/core/agents/tool_health.py +109 -0
- patchi/core/agents/tool_runner.py +162 -0
- patchi/core/agents/type_scanner.py +168 -0
- patchi/core/agents/ui_scanner.py +558 -0
- patchi/core/ai/__init__.py +20 -0
- patchi/core/ai/agent_audit.py +153 -0
- patchi/core/ai/agent_profiler.py +277 -0
- patchi/core/ai/client.py +499 -0
- patchi/core/ai/cost_tracker.py +107 -0
- patchi/core/ai/model_router.py +318 -0
- patchi/core/ai/orchestrator.py +625 -0
- patchi/core/ai/prompts.py +681 -0
- patchi/core/ai/smart.py +302 -0
- patchi/core/ai/tool_executor.py +24 -0
- patchi/core/ai/tools/__init__.py +10 -0
- patchi/core/ai/tools/executor.py +621 -0
- patchi/core/ai/tools/realize.py +1160 -0
- patchi/core/ai/tools/registry.py +1577 -0
- patchi/core/assurance/__init__.py +33 -0
- patchi/core/assurance/builder.py +211 -0
- patchi/core/assurance/graph.py +342 -0
- patchi/core/assurance/invariants.py +168 -0
- patchi/core/atomic.py +61 -0
- patchi/core/attackers/__init__.py +17 -0
- patchi/core/attackers/api_abuse.py +89 -0
- patchi/core/attackers/auth_bypass.py +73 -0
- patchi/core/attackers/base.py +91 -0
- patchi/core/attackers/business_logic.py +88 -0
- patchi/core/attackers/planner.py +108 -0
- patchi/core/attackers/priv_escalation.py +88 -0
- patchi/core/attackers/recon.py +87 -0
- patchi/core/brain/__init__.py +0 -0
- patchi/core/brain/ast_utils/__init__.py +81 -0
- patchi/core/brain/ast_utils/assignments.py +219 -0
- patchi/core/brain/ast_utils/calls.py +139 -0
- patchi/core/brain/ast_utils/config.py +132 -0
- patchi/core/brain/ast_utils/control_flow.py +74 -0
- patchi/core/brain/ast_utils/dead_symbols.py +205 -0
- patchi/core/brain/ast_utils/decorators.py +108 -0
- patchi/core/brain/ast_utils/helpers.py +144 -0
- patchi/core/brain/ast_utils/imports.py +169 -0
- patchi/core/brain/ast_utils/scan.py +143 -0
- patchi/core/brain/ast_utils/taint.py +310 -0
- patchi/core/brain/audit.py +379 -0
- patchi/core/brain/baseline.py +195 -0
- patchi/core/brain/blast_radius.py +312 -0
- patchi/core/brain/body_tags.py +144 -0
- patchi/core/brain/brain.py +1161 -0
- patchi/core/brain/brain_context.py +393 -0
- patchi/core/brain/brain_watcher.py +340 -0
- patchi/core/brain/charter.py +473 -0
- patchi/core/brain/classifier.py +214 -0
- patchi/core/brain/cockpit.py +350 -0
- patchi/core/brain/code_query.py +710 -0
- patchi/core/brain/contract.py +889 -0
- patchi/core/brain/contract_diff.py +155 -0
- patchi/core/brain/council.py +800 -0
- patchi/core/brain/cross_repo.py +104 -0
- patchi/core/brain/dead_code_tools.py +157 -0
- patchi/core/brain/doc_validator.py +197 -0
- patchi/core/brain/domain_activator.py +2183 -0
- patchi/core/brain/enriched_context.py +220 -0
- patchi/core/brain/file_corpus.py +220 -0
- patchi/core/brain/framework.py +953 -0
- patchi/core/brain/freshness.py +273 -0
- patchi/core/brain/git_aware.py +206 -0
- patchi/core/brain/heatmap.py +104 -0
- patchi/core/brain/ignore_parser.py +153 -0
- patchi/core/brain/import_graph.py +486 -0
- patchi/core/brain/languages.py +397 -0
- patchi/core/brain/layered_brain.py +419 -0
- patchi/core/brain/learning.py +229 -0
- patchi/core/brain/orphaned_endpoints.py +313 -0
- patchi/core/brain/personas/__init__.py +648 -0
- patchi/core/brain/personas/bad_user.py +165 -0
- patchi/core/brain/personas/base.py +349 -0
- patchi/core/brain/proactive.py +807 -0
- patchi/core/brain/project_context.py +121 -0
- patchi/core/brain/project_reader.py +521 -0
- patchi/core/brain/reasoning.py +269 -0
- patchi/core/brain/route_detector/__init__.py +15 -0
- patchi/core/brain/route_detector/base.py +31 -0
- patchi/core/brain/route_detector/csharp.py +209 -0
- patchi/core/brain/route_detector/file_based.py +81 -0
- patchi/core/brain/route_detector/go.py +152 -0
- patchi/core/brain/route_detector/java.py +195 -0
- patchi/core/brain/route_detector/javascript.py +197 -0
- patchi/core/brain/route_detector/php.py +158 -0
- patchi/core/brain/route_detector/python.py +193 -0
- patchi/core/brain/route_detector/registry.py +25 -0
- patchi/core/brain/route_detector/ruby.py +134 -0
- patchi/core/brain/route_detector/rust.py +216 -0
- patchi/core/brain/route_detector/swift.py +118 -0
- patchi/core/brain/route_mapper.py +265 -0
- patchi/core/brain/scanner.py +2136 -0
- patchi/core/brain/secrets.py +280 -0
- patchi/core/brain/symbol_graph.py +1955 -0
- patchi/core/brain/team.py +28 -0
- patchi/core/brain/trace_log.py +132 -0
- patchi/core/brain/type_checker/__init__.py +65 -0
- patchi/core/brain/type_checker/base.py +60 -0
- patchi/core/brain/type_checker/csharp.py +59 -0
- patchi/core/brain/type_checker/dart.py +59 -0
- patchi/core/brain/type_checker/go.py +81 -0
- patchi/core/brain/type_checker/java.py +75 -0
- patchi/core/brain/type_checker/kotlin.py +75 -0
- patchi/core/brain/type_checker/php.py +88 -0
- patchi/core/brain/type_checker/python.py +87 -0
- patchi/core/brain/type_checker/rust.py +81 -0
- patchi/core/brain/type_checker/swift.py +59 -0
- patchi/core/brain/type_checker/typescript.py +298 -0
- patchi/core/brain/understander.py +130 -0
- patchi/core/brain/verify.py +379 -0
- patchi/core/campaigns/__init__.py +14 -0
- patchi/core/campaigns/base.py +62 -0
- patchi/core/campaigns/data_flow_abuse.py +153 -0
- patchi/core/campaigns/orchestrator.py +140 -0
- patchi/core/campaigns/state_transitions.py +178 -0
- patchi/core/ci_bundle.py +104 -0
- patchi/core/config.py +311 -0
- patchi/core/constants.py +245 -0
- patchi/core/debug/__init__.py +25 -0
- patchi/core/debug/adapters/__init__.py +11 -0
- patchi/core/debug/adapters/codelldb.py +379 -0
- patchi/core/debug/adapters/node.py +222 -0
- patchi/core/debug/adapters/powershell.py +210 -0
- patchi/core/debug/adapters/python.py +244 -0
- patchi/core/debug/capture.py +210 -0
- patchi/core/debug/dap_client.py +268 -0
- patchi/core/detector/__init__.py +0 -0
- patchi/core/detector/audit.py +194 -0
- patchi/core/detector/bus.py +226 -0
- patchi/core/detector/dispatcher.py +275 -0
- patchi/core/detector/event.py +254 -0
- patchi/core/detector/sigma_engine.py +378 -0
- patchi/core/detector/triage.py +289 -0
- patchi/core/export/sarif.py +113 -0
- patchi/core/fix/__init__.py +17 -0
- patchi/core/fix/applier.py +574 -0
- patchi/core/fix/base.py +510 -0
- patchi/core/fix/code_fixer.py +169 -0
- patchi/core/fix/dead_code_remover.py +131 -0
- patchi/core/fix/fix_agents.py +775 -0
- patchi/core/fix/patch.py +444 -0
- patchi/core/fix/risk_gate.py +383 -0
- patchi/core/fix/security_fixer.py +93 -0
- patchi/core/fix/verify_loop.py +204 -0
- patchi/core/fuzz/__init__.py +8 -0
- patchi/core/fuzz/corpus.py +137 -0
- patchi/core/fuzz/input_fuzzer.py +256 -0
- patchi/core/fuzz/sequence_fuzzer.py +162 -0
- patchi/core/fuzz/state_fuzzer.py +236 -0
- patchi/core/health.py +407 -0
- patchi/core/hosted/__init__.py +1 -0
- patchi/core/hosted/anomaly.py +298 -0
- patchi/core/hosted/audit_log.py +102 -0
- patchi/core/hosted/compliance_report.py +148 -0
- patchi/core/hosted/ip_reputation.py +169 -0
- patchi/core/hosted/log_parsers.py +251 -0
- patchi/core/hosted/tokens.py +127 -0
- patchi/core/hosted/watchlist.py +164 -0
- patchi/core/hosted/webhooks.py +156 -0
- patchi/core/memory.py +437 -0
- patchi/core/notifications/__init__.py +18 -0
- patchi/core/notifications/channels.py +192 -0
- patchi/core/notifications/digest.py +141 -0
- patchi/core/notifications/escalation.py +158 -0
- patchi/core/notifications/notifier.py +182 -0
- patchi/core/notifications/quiet_hours.py +69 -0
- patchi/core/plugins/__init__.py +48 -0
- patchi/core/plugins/analyzer.py +326 -0
- patchi/core/plugins/analyzers/__init__.py +6 -0
- patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
- patchi/core/plugins/analyzers/secret_scanner.py +158 -0
- patchi/core/plugins/analyzers/todo_scanner.py +115 -0
- patchi/core/plugins/registry.py +335 -0
- patchi/core/queue.py +375 -0
- patchi/core/reliability/__init__.py +13 -0
- patchi/core/reliability/chaos.py +163 -0
- patchi/core/reliability/fault_injector.py +172 -0
- patchi/core/reliability/idempotency.py +128 -0
- patchi/core/reliability/recovery.py +147 -0
- patchi/core/runtime/__init__.py +5 -0
- patchi/core/runtime/tracer.py +230 -0
- patchi/core/scan_bus.py +156 -0
- patchi/core/security/__init__.py +1 -0
- patchi/core/security/ai_validator.py +462 -0
- patchi/core/security/app_mapper.py +216 -0
- patchi/core/security/app_profile.py +464 -0
- patchi/core/security/attack_feedback.py +141 -0
- patchi/core/security/attack_scenarios/__init__.py +1 -0
- patchi/core/security/attack_tree.py +176 -0
- patchi/core/security/auth_audit_agent.py +344 -0
- patchi/core/security/authz_agent.py +602 -0
- patchi/core/security/auto_fix_proactive.py +620 -0
- patchi/core/security/auto_fixer.py +660 -0
- patchi/core/security/auto_ticket.py +53 -0
- patchi/core/security/bandit_agent.py +152 -0
- patchi/core/security/blast_radius.py +226 -0
- patchi/core/security/browser_tester.py +333 -0
- patchi/core/security/business_logic_agent.py +263 -0
- patchi/core/security/catch_block_auditor.py +395 -0
- patchi/core/security/cdn_cache_agent.py +357 -0
- patchi/core/security/chain_analyzer.py +321 -0
- patchi/core/security/chain_to_assurance.py +168 -0
- patchi/core/security/charter.py +510 -0
- patchi/core/security/cloud_waf_detector.py +295 -0
- patchi/core/security/codeql_agent.py +233 -0
- patchi/core/security/compliance_agent.py +348 -0
- patchi/core/security/confidence_gate.py +423 -0
- patchi/core/security/container_scanner.py +282 -0
- patchi/core/security/crypto_agent.py +378 -0
- patchi/core/security/cve_monitor.py +262 -0
- patchi/core/security/dast_agent.py +865 -0
- patchi/core/security/defectdojo.py +238 -0
- patchi/core/security/defenders/__init__.py +75 -0
- patchi/core/security/defenders/auth_middleware.py +115 -0
- patchi/core/security/defenders/base.py +97 -0
- patchi/core/security/defenders/block_ip.py +71 -0
- patchi/core/security/defenders/block_ws_origin.py +36 -0
- patchi/core/security/defenders/crypto_fix.py +118 -0
- patchi/core/security/defenders/enforce_rate_limit.py +108 -0
- patchi/core/security/defenders/fix_code.py +93 -0
- patchi/core/security/defenders/invalidate_session.py +40 -0
- patchi/core/security/defenders/patch_config.py +70 -0
- patchi/core/security/defenders/rotate_secret.py +54 -0
- patchi/core/security/defenders/suspend_account.py +41 -0
- patchi/core/security/defenders/update_dependency.py +160 -0
- patchi/core/security/defense_layer.py +217 -0
- patchi/core/security/dependency_vulnerability_agent.py +616 -0
- patchi/core/security/detection_pipeline.py +289 -0
- patchi/core/security/dns_security_agent.py +497 -0
- patchi/core/security/domain_activator_v2.py +913 -0
- patchi/core/security/domain_loader.py +1004 -0
- patchi/core/security/domains/__init__.py +0 -0
- patchi/core/security/email_authentication_agent.py +417 -0
- patchi/core/security/env_var_validator.py +304 -0
- patchi/core/security/evidence.py +100 -0
- patchi/core/security/falco_runtime_agent.py +387 -0
- patchi/core/security/fix-playbooks/__init__.py +0 -0
- patchi/core/security/gated_finding.py +85 -0
- patchi/core/security/git_diff_activator.py +367 -0
- patchi/core/security/governance.py +208 -0
- patchi/core/security/gradual_gate.py +34 -0
- patchi/core/security/history.py +315 -0
- patchi/core/security/iac_scanner.py +250 -0
- patchi/core/security/ignore_expiry.py +111 -0
- patchi/core/security/ignore_learner.py +416 -0
- patchi/core/security/injection_agent.py +319 -0
- patchi/core/security/insecure_randomness_agent.py +288 -0
- patchi/core/security/intent_analyzer.py +326 -0
- patchi/core/security/jwt_agent.py +453 -0
- patchi/core/security/kubernetes_agent.py +638 -0
- patchi/core/security/layer2_orchestrator.py +341 -0
- patchi/core/security/llm_security_agent.py +252 -0
- patchi/core/security/misconfig_agent.py +600 -0
- patchi/core/security/mobile_security_agent.py +474 -0
- patchi/core/security/network_agent.py +376 -0
- patchi/core/security/noise_filter.py +274 -0
- patchi/core/security/orchestrator.py +313 -0
- patchi/core/security/pattern_context.py +125 -0
- patchi/core/security/pattern_loader.py +101 -0
- patchi/core/security/pentest/__init__.py +4 -0
- patchi/core/security/pentest/base.py +58 -0
- patchi/core/security/pentest/dalfox_adapter.py +40 -0
- patchi/core/security/pentest/ffuf_adapter.py +48 -0
- patchi/core/security/pentest/nuclei_adapter.py +50 -0
- patchi/core/security/pentest/registry.py +80 -0
- patchi/core/security/pentest/shannon_adapter.py +107 -0
- patchi/core/security/pentest/sqlmap_adapter.py +56 -0
- patchi/core/security/pentest/zap_adapter.py +52 -0
- patchi/core/security/plan_auditor.py +276 -0
- patchi/core/security/policy_engine.py +272 -0
- patchi/core/security/prechecks.py +172 -0
- patchi/core/security/precommit_hook.py +247 -0
- patchi/core/security/privacy_agent.py +361 -0
- patchi/core/security/push_notification_agent.py +417 -0
- patchi/core/security/pysa_agent.py +143 -0
- patchi/core/security/reasoning.py +442 -0
- patchi/core/security/red_team_agent.py +184 -0
- patchi/core/security/red_team_engine.py +1142 -0
- patchi/core/security/remediation.py +360 -0
- patchi/core/security/request_interceptor.py +294 -0
- patchi/core/security/runtime_validator.py +245 -0
- patchi/core/security/saml_sso_agent.py +401 -0
- patchi/core/security/sast_agent.py +213 -0
- patchi/core/security/sast_gate.py +95 -0
- patchi/core/security/scheduler.py +287 -0
- patchi/core/security/secrets_runtime_agent.py +476 -0
- patchi/core/security/security_agents.py +175 -0
- patchi/core/security/security_config.py +551 -0
- patchi/core/security/security_probe.py +762 -0
- patchi/core/security/security_taint.py +1005 -0
- patchi/core/security/sensitive_data_agent.py +271 -0
- patchi/core/security/service_mesh_agent.py +412 -0
- patchi/core/security/session_management_agent.py +245 -0
- patchi/core/security/ssrf_agent.py +422 -0
- patchi/core/security/supply_chain.py +449 -0
- patchi/core/security/threat_model_generator.py +378 -0
- patchi/core/security/threat_model_updater.py +196 -0
- patchi/core/security/tool_adapters.py +135 -0
- patchi/core/security/tool_verify.py +173 -0
- patchi/core/security/websocket_security_agent.py +188 -0
- patchi/core/snapshot.py +242 -0
- patchi/core/tenant.py +386 -0
- patchi/core/testing/__init__.py +17 -0
- patchi/core/testing/_browser.py +147 -0
- patchi/core/testing/ads_agent.py +141 -0
- patchi/core/testing/api_contract_agent.py +450 -0
- patchi/core/testing/app_discovery_agent.py +641 -0
- patchi/core/testing/app_launcher.py +184 -0
- patchi/core/testing/browser_test_agent.py +516 -0
- patchi/core/testing/e2e_flow_agent.py +354 -0
- patchi/core/testing/flake_detector_agent.py +292 -0
- patchi/core/testing/gate.py +31 -0
- patchi/core/testing/live_test_runner.py +304 -0
- patchi/core/testing/live_v2/__init__.py +1 -0
- patchi/core/testing/live_v2/browser_pool.py +349 -0
- patchi/core/testing/live_v2/browser_test_runner.py +313 -0
- patchi/core/testing/live_v2/runner.py +626 -0
- patchi/core/testing/live_v2/screenshot_manager.py +474 -0
- patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
- patchi/core/testing/live_v2/video_recorder.py +303 -0
- patchi/core/testing/regression_agent.py +302 -0
- patchi/core/testing/security_test_agent.py +253 -0
- patchi/core/testing/stress_test_agent.py +203 -0
- patchi/core/testing/test_agents.py +50 -0
- patchi/core/testing/test_config.py +194 -0
- patchi/core/testing/ui_accessibility_agent.py +324 -0
- patchi/core/testing/ui_button_agent.py +294 -0
- patchi/core/testing/ui_layout_agent.py +330 -0
- patchi/core/testing/unit_test_agent.py +1149 -0
- patchi/core/testing/visual_regression_agent.py +260 -0
- patchi/web/__init__.py +1 -0
- patchi/web/api/__init__.py +1 -0
- patchi/web/api/brain_map.py +114 -0
- patchi/web/api/charts.py +47 -0
- patchi/web/api/chat.py +224 -0
- patchi/web/api/cicd.py +576 -0
- patchi/web/api/dast_evidence.py +49 -0
- patchi/web/api/dev_check.py +320 -0
- patchi/web/api/fix.py +134 -0
- patchi/web/api/guard.py +65 -0
- patchi/web/api/hosted.py +128 -0
- patchi/web/api/hosted_v2.py +234 -0
- patchi/web/api/live_testing.py +699 -0
- patchi/web/api/scan.py +582 -0
- patchi/web/api/smart.py +111 -0
- patchi/web/api/tenant.py +117 -0
- patchi/web/api/tools.py +85 -0
- patchi/web/api_legacy.py +2401 -0
- patchi/web/app.py +310 -0
- patchi/web/events.py +483 -0
- patchi/web/routes/__init__.py +1 -0
- patchi/web/routes/assurance.py +541 -0
- patchi/web/routes/brain.py +36 -0
- patchi/web/routes/charter.py +227 -0
- patchi/web/routes/chat.py +23 -0
- patchi/web/routes/dashboard.py +558 -0
- patchi/web/routes/dashboard_v2.py +402 -0
- patchi/web/routes/findings.py +330 -0
- patchi/web/routes/guard.py +35 -0
- patchi/web/routes/history.py +126 -0
- patchi/web/routes/landing.py +52 -0
- patchi/web/routes/live_testing.py +24 -0
- patchi/web/routes/review.py +57 -0
- patchi/web/routes/self_improvement.py +110 -0
- patchi/web/routes/settings.py +49 -0
- patchi/web/routes/tokens.py +29 -0
- patchi/web/spawn.py +139 -0
- patchi/web/ws.py +141 -0
- patchi-0.7.5.dist-info/METADATA +315 -0
- patchi-0.7.5.dist-info/RECORD +496 -0
- patchi-0.7.5.dist-info/WHEEL +5 -0
- patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
- patchi-0.7.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Coordinator for Patchi.
|
|
3
|
+
|
|
4
|
+
PRIMARY ENGINE: ThreadPoolExecutor (stdlib, zero extra dependencies).
|
|
5
|
+
- Scanner agents run in parallel (they are all read-only, never conflict).
|
|
6
|
+
- Fix agents always run sequentially (prevents file conflicts).
|
|
7
|
+
- Test agents run on their own independent track.
|
|
8
|
+
|
|
9
|
+
OPTIONAL LLM COORDINATION:
|
|
10
|
+
When an AI key is configured, the coordinator can ask the LLM which agents
|
|
11
|
+
to prioritise for a given project context. This uses direct httpx calls to
|
|
12
|
+
the configured provider — no third-party orchestration library required.
|
|
13
|
+
Falls back to full parallel execution if no LLM is configured.
|
|
14
|
+
|
|
15
|
+
Design:
|
|
16
|
+
- Agents are stateless. All state lives in AgentInput / AgentResult.
|
|
17
|
+
- Coordinator never writes to disk.
|
|
18
|
+
- Results always returned as list[AgentResult] regardless of path taken.
|
|
19
|
+
- Progress callback fires after each agent completes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
# ── Progress event ─────────────────────────────────────────────────────────────
|
|
25
|
+
import logging
|
|
26
|
+
import queue
|
|
27
|
+
import threading
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from loguru import logger
|
|
36
|
+
|
|
37
|
+
from patchi.core import config as cfg
|
|
38
|
+
from patchi.core import memory as mem
|
|
39
|
+
from patchi.core.agents.base import (
|
|
40
|
+
AgentGroup,
|
|
41
|
+
AgentInput,
|
|
42
|
+
AgentResult,
|
|
43
|
+
AgentStatus,
|
|
44
|
+
BaseAgent,
|
|
45
|
+
Finding,
|
|
46
|
+
list_agents,
|
|
47
|
+
)
|
|
48
|
+
from patchi.core.agents.cache import AgentCache
|
|
49
|
+
|
|
50
|
+
_log = logging.getLogger("patchi.agents.coordinator")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_ai_bounded(func, timeout: float):
|
|
54
|
+
"""Call `func on a daemon thread, waiting at most `timeout` seconds for it.
|
|
55
|
+
|
|
56
|
+
A worker abandoned mid-call (e.g. an LLM call we no longer wait for) must
|
|
57
|
+
never keep the interpreter alive at exit: `concurrent.futures` joins its
|
|
58
|
+
threads at `_python_exit`, so a hung non-daemon worker blocks process
|
|
59
|
+
shutdown indefinitely. A daemon thread + bounded queue get avoids that
|
|
60
|
+
entirely and needs no private executor internals.
|
|
61
|
+
"""
|
|
62
|
+
q = queue.Queue(maxsize=1)
|
|
63
|
+
t = threading.Thread(target=lambda f=func: q.put(f()), daemon=True)
|
|
64
|
+
t.start()
|
|
65
|
+
try:
|
|
66
|
+
return q.get(timeout=timeout)
|
|
67
|
+
except Exception:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class CoordinatorProgress:
|
|
73
|
+
agent_name: str
|
|
74
|
+
status: AgentStatus
|
|
75
|
+
current: int
|
|
76
|
+
total: int
|
|
77
|
+
finding_count: int = 0
|
|
78
|
+
message: str = ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Coordinator ────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RunMode:
|
|
85
|
+
PARALLEL = "parallel"
|
|
86
|
+
SEQUENTIAL = "sequential"
|
|
87
|
+
PROCESS = "process"
|
|
88
|
+
AUTO = "auto"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Coordinator:
|
|
92
|
+
"""
|
|
93
|
+
Spawns and collects agent results.
|
|
94
|
+
|
|
95
|
+
Usage:
|
|
96
|
+
coord = Coordinator(project_root)
|
|
97
|
+
results = coord.run_group(AgentGroup.SCANNER)
|
|
98
|
+
results = coord.run_agents(["CoreScanner", "EnvScanner"])
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
# Circuit breaker — tracks consecutive failures per agent (LIMIT-04)
|
|
102
|
+
# Key: agent_name, Value: consecutive_failures
|
|
103
|
+
# NOTE: Moved to instance-level in __init__ to avoid cross-instance corruption.
|
|
104
|
+
_CB_THRESHOLD = 3
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
root: Path,
|
|
109
|
+
on_progress: Callable[[CoordinatorProgress], None] | None = None,
|
|
110
|
+
run_mode: str = RunMode.AUTO,
|
|
111
|
+
):
|
|
112
|
+
self.run_mode = run_mode
|
|
113
|
+
self.root = root
|
|
114
|
+
self.on_progress = on_progress or (lambda _: None)
|
|
115
|
+
self.last_security_report: Any = None
|
|
116
|
+
self._circuit_breaker: dict[str, int] = {}
|
|
117
|
+
self._active_domains: list[str] = [] # git-diff on-demand domains
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
self._config = cfg.load(root)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
_log.warning("Coordinator.__init__ failed: %s", e)
|
|
123
|
+
self._config = {}
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
self._brain = mem.get_brain(root)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
_log.warning("Coordinator.__init__ failed: %s", e)
|
|
129
|
+
self._brain = {}
|
|
130
|
+
|
|
131
|
+
def _build_llm(self) -> dict | None:
|
|
132
|
+
"""Build LLM config for deep scan analysis.
|
|
133
|
+
|
|
134
|
+
Returns ai config dict if an AI provider is available (Ollama, API key,
|
|
135
|
+
or horde fallback), or None if no provider is reachable.
|
|
136
|
+
|
|
137
|
+
Used by scan_cmd.py --deep to get a ready-to-use LLM context.
|
|
138
|
+
"""
|
|
139
|
+
ai_cfg = self._config.get("ai", {})
|
|
140
|
+
|
|
141
|
+
# Local Ollama
|
|
142
|
+
if ai_cfg.get("local_model_name"):
|
|
143
|
+
return {"provider": "ollama", "config": self._config}
|
|
144
|
+
|
|
145
|
+
# API keys
|
|
146
|
+
keys = ai_cfg.get("keys", [])
|
|
147
|
+
if keys:
|
|
148
|
+
for k in keys:
|
|
149
|
+
env_var = k.get("env_var", "")
|
|
150
|
+
import os
|
|
151
|
+
|
|
152
|
+
if os.environ.get(env_var) or os.environ.get("_PATCHI_ENV_LOADED"):
|
|
153
|
+
return {"provider": "api", "config": self._config}
|
|
154
|
+
return {"provider": "api", "config": self._config}
|
|
155
|
+
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def reset_circuit_breaker(self, agent_name: str | None = None) -> list[str]:
|
|
159
|
+
"""Reset circuit breaker for one or all agents. Returns list of reset names."""
|
|
160
|
+
if agent_name:
|
|
161
|
+
self._circuit_breaker.pop(agent_name, None)
|
|
162
|
+
return [agent_name]
|
|
163
|
+
reset = list(self._circuit_breaker.keys())
|
|
164
|
+
self._circuit_breaker.clear()
|
|
165
|
+
return reset
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def circuit_broken_agents(self) -> list[str]:
|
|
169
|
+
"""Return list of agents currently blocked by circuit breaker."""
|
|
170
|
+
return [
|
|
171
|
+
name
|
|
172
|
+
for name, failures in self._circuit_breaker.items()
|
|
173
|
+
if failures >= self._CB_THRESHOLD
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
def _is_circuit_broken(self, agent_name: str) -> bool:
|
|
177
|
+
return self._circuit_breaker.get(agent_name, 0) >= self._CB_THRESHOLD
|
|
178
|
+
|
|
179
|
+
def run_group(
|
|
180
|
+
self,
|
|
181
|
+
group: AgentGroup,
|
|
182
|
+
scope: list[str] | None = None,
|
|
183
|
+
extra: dict | None = None,
|
|
184
|
+
) -> list[AgentResult]:
|
|
185
|
+
try:
|
|
186
|
+
from patchi.core.security.governance import patchi_action_log
|
|
187
|
+
|
|
188
|
+
patchi_action_log(
|
|
189
|
+
self.root,
|
|
190
|
+
"agent_group_start",
|
|
191
|
+
group.value if hasattr(group, "value") else str(group),
|
|
192
|
+
detail=f"scope={len(scope or [])} files",
|
|
193
|
+
)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
_log.warning("Coordinator.run_group failed: %s", e)
|
|
196
|
+
# ── On-demand domain filtering ────────────────────────────────────────
|
|
197
|
+
agent_classes = list_agents(group)
|
|
198
|
+
if self._active_domains:
|
|
199
|
+
try:
|
|
200
|
+
from patchi.core.security.domain_activator_v2 import (
|
|
201
|
+
DomainActivatorV2,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
activator = DomainActivatorV2(self.root)
|
|
205
|
+
relevant = activator.get_relevant_agents(self._active_domains)
|
|
206
|
+
# Always run core agents (PreCheckAgent, etc.)
|
|
207
|
+
core = ["PreCheckAgent", "PlanAuditorAgent"]
|
|
208
|
+
relevant.extend(core)
|
|
209
|
+
agent_classes = [a for a in agent_classes if getattr(a, "name", "") in relevant]
|
|
210
|
+
_log.info(
|
|
211
|
+
"Domain filter: %d → %d agents (domains=%s)",
|
|
212
|
+
len(list_agents(group)),
|
|
213
|
+
len(agent_classes),
|
|
214
|
+
self._active_domains[:5],
|
|
215
|
+
)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
_log.debug("Domain filter failed: %s", e)
|
|
218
|
+
|
|
219
|
+
results = self._run_classes(agent_classes, scope=scope, extra=extra)
|
|
220
|
+
|
|
221
|
+
# Annotate findings with git blame info (all agent groups)
|
|
222
|
+
if self.root and results:
|
|
223
|
+
try:
|
|
224
|
+
from patchi.core.brain.git_aware import annotate_findings_with_blame
|
|
225
|
+
|
|
226
|
+
for r in results:
|
|
227
|
+
if r.findings:
|
|
228
|
+
annotate_findings_with_blame(r.findings, self.root, max_workers=2)
|
|
229
|
+
except Exception as e:
|
|
230
|
+
_log.warning("Coordinator.run_group failed: %s", e)
|
|
231
|
+
|
|
232
|
+
if group == AgentGroup.SECURITY:
|
|
233
|
+
try:
|
|
234
|
+
from patchi.core.security.orchestrator import SecurityOrchestrator
|
|
235
|
+
|
|
236
|
+
self.last_security_report = SecurityOrchestrator().correlate(results)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
_log.warning("Coordinator.run_group failed: %s", e)
|
|
239
|
+
|
|
240
|
+
# NEW: DetectionPipeline + ConfidenceGate + Defense Layer
|
|
241
|
+
if self._config.get("pipeline", {}).get("enabled", False) and self.last_security_report:
|
|
242
|
+
try:
|
|
243
|
+
from patchi.core.security.defense_layer import DefenseLayer
|
|
244
|
+
from patchi.core.security.detection_pipeline import DetectionPipeline
|
|
245
|
+
|
|
246
|
+
# Wire brain context for context-aware classification
|
|
247
|
+
try:
|
|
248
|
+
from patchi.core.brain.brain_context import get_brain_context
|
|
249
|
+
_brain_ctx = get_brain_context(self.root, self._config)
|
|
250
|
+
except Exception:
|
|
251
|
+
_brain_ctx = None
|
|
252
|
+
pipeline = DetectionPipeline(self.root, self._config, brain_context=_brain_ctx)
|
|
253
|
+
gated = pipeline.process(self.last_security_report)
|
|
254
|
+
self.last_gated_report = gated
|
|
255
|
+
if gated.findings:
|
|
256
|
+
defense = DefenseLayer(self.root, self._config)
|
|
257
|
+
defend_results = defense.defend_all(gated.defend)
|
|
258
|
+
self.last_defend_results = defend_results
|
|
259
|
+
except Exception as e:
|
|
260
|
+
_log.warning("Coordinator.run_group failed: %s", e)
|
|
261
|
+
import traceback
|
|
262
|
+
|
|
263
|
+
logger.warning(f"Pipeline error: {traceback.format_exc()}")
|
|
264
|
+
return results
|
|
265
|
+
|
|
266
|
+
def run_agents(
|
|
267
|
+
self,
|
|
268
|
+
agent_names: list[str],
|
|
269
|
+
scope: list[str] | None = None,
|
|
270
|
+
extra: dict | None = None,
|
|
271
|
+
) -> list[AgentResult]:
|
|
272
|
+
from patchi.core.agents.base import get_agent
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
from patchi.core.security.governance import patchi_action_log
|
|
276
|
+
|
|
277
|
+
patchi_action_log(
|
|
278
|
+
self.root,
|
|
279
|
+
"agent_run_start",
|
|
280
|
+
",".join(agent_names),
|
|
281
|
+
detail=f"scope={len(scope or [])} files",
|
|
282
|
+
)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
_log.warning("Coordinator.run_agents failed: %s", e)
|
|
285
|
+
classes = [get_agent(n) for n in agent_names if get_agent(n)]
|
|
286
|
+
return self._run_classes(classes, scope=scope, extra=extra)
|
|
287
|
+
|
|
288
|
+
# Side file scanner names — excluded when side=False
|
|
289
|
+
_SIDE_SCANNERS = frozenset(
|
|
290
|
+
{
|
|
291
|
+
"SideFileScanner",
|
|
292
|
+
"DependencyScanner",
|
|
293
|
+
"EnvScanner",
|
|
294
|
+
"RouteGraphScanner",
|
|
295
|
+
"CommentScanner",
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def set_active_domains(self, domains: list[str]) -> None:
|
|
300
|
+
"""Set domains for on-demand activation (from git diff)."""
|
|
301
|
+
self._active_domains = domains
|
|
302
|
+
|
|
303
|
+
def run_all_scanners(
|
|
304
|
+
self, scope: list[str] | None = None, side: bool = True
|
|
305
|
+
) -> list[AgentResult]:
|
|
306
|
+
if side:
|
|
307
|
+
return self.run_group(AgentGroup.SCANNER, scope=scope)
|
|
308
|
+
# Filter out side scanners — source-only scan
|
|
309
|
+
agents = list_agents(AgentGroup.SCANNER)
|
|
310
|
+
filtered = [a for a in agents if getattr(a, "name", "") not in self._SIDE_SCANNERS]
|
|
311
|
+
return self._run_classes(filtered, scope=scope)
|
|
312
|
+
|
|
313
|
+
def _build_input(self, scope: list[str] | None, extra: dict | None) -> AgentInput:
|
|
314
|
+
brain = self._brain or {}
|
|
315
|
+
project_ctx = brain.get("project_context") or {}
|
|
316
|
+
return AgentInput(
|
|
317
|
+
root=self.root,
|
|
318
|
+
scope=scope or [],
|
|
319
|
+
brain=brain,
|
|
320
|
+
config=self._config,
|
|
321
|
+
extra=extra or {},
|
|
322
|
+
purpose=brain.get("project_purpose", "") or project_ctx.get("purpose", ""),
|
|
323
|
+
domain=brain.get("project_domain", "") or project_ctx.get("domain", ""),
|
|
324
|
+
context=project_ctx,
|
|
325
|
+
active_domains=brain.get("active_security_domains", []),
|
|
326
|
+
on_message=None,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def _maybe_reorder(self, agent_classes: list[type[BaseAgent]]) -> list[type[BaseAgent]]:
|
|
330
|
+
if len(agent_classes) <= 1:
|
|
331
|
+
return agent_classes
|
|
332
|
+
brain = self._brain or {}
|
|
333
|
+
has_security = any(
|
|
334
|
+
getattr(a, "name", "")
|
|
335
|
+
in ("EnvScanner", "SecretScanner", "ConfigAuditAgent", "TaintAnalyzer", "CORSAuditor")
|
|
336
|
+
for a in agent_classes
|
|
337
|
+
)
|
|
338
|
+
if not has_security:
|
|
339
|
+
return agent_classes
|
|
340
|
+
languages = brain.get("languages", {})
|
|
341
|
+
fw_raw = brain.get("frameworks", brain.get("framework", ""))
|
|
342
|
+
if isinstance(fw_raw, list):
|
|
343
|
+
fw_raw = fw_raw[0] if fw_raw else ""
|
|
344
|
+
if isinstance(fw_raw, dict):
|
|
345
|
+
framework = fw_raw.get("name", "") or ""
|
|
346
|
+
elif isinstance(fw_raw, str):
|
|
347
|
+
framework = fw_raw
|
|
348
|
+
else:
|
|
349
|
+
framework = str(fw_raw) if fw_raw else ""
|
|
350
|
+
|
|
351
|
+
# Offline scans must never attempt an LLM call — avoid even spawning
|
|
352
|
+
# the bounded worker thread.
|
|
353
|
+
import os
|
|
354
|
+
|
|
355
|
+
if os.environ.get("PATCHI_OFFLINE"):
|
|
356
|
+
return agent_classes
|
|
357
|
+
|
|
358
|
+
# Cache key: agent set + framework so we only call LLM when something changes
|
|
359
|
+
agent_names_frozen = frozenset(getattr(a, "name", str(a)) for a in agent_classes)
|
|
360
|
+
cache_key = (agent_names_frozen, framework)
|
|
361
|
+
cached = getattr(self, "_reorder_cache", {})
|
|
362
|
+
if cache_key in cached:
|
|
363
|
+
ordered_names = cached[cache_key]
|
|
364
|
+
name_map = {getattr(a, "name", ""): a for a in agent_classes}
|
|
365
|
+
reordered = [name_map[n] for n in ordered_names if n in name_map]
|
|
366
|
+
remaining = [
|
|
367
|
+
a for a in agent_classes if getattr(a, "name", "") not in set(ordered_names)
|
|
368
|
+
]
|
|
369
|
+
return reordered + remaining
|
|
370
|
+
|
|
371
|
+
prompt = (
|
|
372
|
+
f"Project: {languages} files, framework: {framework}\n"
|
|
373
|
+
f"Agents: {', '.join(getattr(a, 'name', str(a)) for a in agent_classes)}\n"
|
|
374
|
+
f"Prioritise critical agents first for this project type. Return ONLY a comma-separated list of agent names in desired order."
|
|
375
|
+
)
|
|
376
|
+
try:
|
|
377
|
+
from patchi.core.ai.client import call_ai
|
|
378
|
+
|
|
379
|
+
# Bounded call: an unreachable fallback provider (e.g. AI Horde
|
|
380
|
+
# DNS hang on Windows) must never freeze the scan. Time out and
|
|
381
|
+
# fall back to the original order. The daemon worker thread plus
|
|
382
|
+
# call_ai's own overall timeout mean even a hung provider can
|
|
383
|
+
# never keep the process alive past the 15s bound — not even at
|
|
384
|
+
# interpreter exit, when concurrent.futures joins every non-daemon
|
|
385
|
+
# worker.
|
|
386
|
+
result = _run_ai_bounded(
|
|
387
|
+
lambda: call_ai(
|
|
388
|
+
self._config,
|
|
389
|
+
"You are a project analysis coordinator.",
|
|
390
|
+
prompt,
|
|
391
|
+
max_tokens=200,
|
|
392
|
+
timeout=15,
|
|
393
|
+
),
|
|
394
|
+
timeout=15,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
if result:
|
|
398
|
+
ordered = [n.strip() for n in result.split(",")]
|
|
399
|
+
name_map = {getattr(a, "name", ""): a for a in agent_classes}
|
|
400
|
+
reordered = [name_map[n] for n in ordered if n in name_map]
|
|
401
|
+
remaining = [a for a in agent_classes if getattr(a, "name", "") not in ordered]
|
|
402
|
+
if reordered:
|
|
403
|
+
# Store in cache
|
|
404
|
+
if not hasattr(self, "_reorder_cache"):
|
|
405
|
+
self._reorder_cache: dict = {}
|
|
406
|
+
self._reorder_cache[cache_key] = ordered
|
|
407
|
+
return reordered + remaining
|
|
408
|
+
except Exception as e:
|
|
409
|
+
_log.debug("Coordinator._maybe_reorder failed: %s", e)
|
|
410
|
+
return agent_classes
|
|
411
|
+
|
|
412
|
+
def _run_classes(
|
|
413
|
+
self,
|
|
414
|
+
agent_classes: list[type[BaseAgent]],
|
|
415
|
+
scope: list[str] | None = None,
|
|
416
|
+
extra: dict | None = None,
|
|
417
|
+
) -> list[AgentResult]:
|
|
418
|
+
if not agent_classes:
|
|
419
|
+
return []
|
|
420
|
+
|
|
421
|
+
agent_classes = self._maybe_reorder(agent_classes)
|
|
422
|
+
# Filter out circuit-broken agents (LIMIT-04)
|
|
423
|
+
filtered = []
|
|
424
|
+
for cls in agent_classes:
|
|
425
|
+
name = getattr(cls, "name", str(cls))
|
|
426
|
+
if self._is_circuit_broken(name):
|
|
427
|
+
continue
|
|
428
|
+
filtered.append(cls)
|
|
429
|
+
agent_classes = filtered
|
|
430
|
+
|
|
431
|
+
# Init agent result cache
|
|
432
|
+
cache = AgentCache(self.root)
|
|
433
|
+
use_cache = not self._config.get("pipeline", {}).get("no_agent_cache", False)
|
|
434
|
+
|
|
435
|
+
inp = self._build_input(scope, extra)
|
|
436
|
+
total = len(agent_classes)
|
|
437
|
+
completed = [0]
|
|
438
|
+
|
|
439
|
+
def on_done(result: AgentResult) -> None:
|
|
440
|
+
completed[0] += 1
|
|
441
|
+
self.on_progress(
|
|
442
|
+
CoordinatorProgress(
|
|
443
|
+
agent_name=result.agent_name,
|
|
444
|
+
status=result.status,
|
|
445
|
+
current=completed[0],
|
|
446
|
+
total=total,
|
|
447
|
+
finding_count=result.finding_count,
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
# Update circuit breaker (LIMIT-04)
|
|
451
|
+
if result.status == AgentStatus.FAILED:
|
|
452
|
+
self._circuit_breaker[result.agent_name] = (
|
|
453
|
+
self._circuit_breaker.get(result.agent_name, 0) + 1
|
|
454
|
+
)
|
|
455
|
+
else:
|
|
456
|
+
self._circuit_breaker[result.agent_name] = 0 # Reset on success
|
|
457
|
+
try:
|
|
458
|
+
mem.save_scan_result(
|
|
459
|
+
result.agent_name,
|
|
460
|
+
{
|
|
461
|
+
"status": result.status.value,
|
|
462
|
+
"duration_ms": result.duration_ms,
|
|
463
|
+
"finding_count": result.finding_count,
|
|
464
|
+
"files_scanned": result.files_scanned,
|
|
465
|
+
"errors": result.errors[:3],
|
|
466
|
+
"findings": [f.to_dict() for f in result.findings],
|
|
467
|
+
"circuit_broken": self._is_circuit_broken(result.agent_name),
|
|
468
|
+
},
|
|
469
|
+
self.root,
|
|
470
|
+
)
|
|
471
|
+
from patchi.core.security.governance import patchi_action_log
|
|
472
|
+
|
|
473
|
+
patchi_action_log(
|
|
474
|
+
self.root,
|
|
475
|
+
"agent_complete",
|
|
476
|
+
result.agent_name,
|
|
477
|
+
agent=result.agent_name,
|
|
478
|
+
detail=f"{result.finding_count} findings, {result.duration_ms}ms",
|
|
479
|
+
status="ok" if result.status == AgentStatus.DONE else "error",
|
|
480
|
+
)
|
|
481
|
+
except Exception as e:
|
|
482
|
+
_log.warning("Coordinator.on_done failed: %s", e)
|
|
483
|
+
|
|
484
|
+
# Check cache for each agent — skip if valid cached result exists
|
|
485
|
+
agent_classes_run: list[type[BaseAgent]] = []
|
|
486
|
+
cached_results: list[AgentResult] = []
|
|
487
|
+
|
|
488
|
+
if use_cache:
|
|
489
|
+
for cls in agent_classes:
|
|
490
|
+
cached = cache.get(cls.name)
|
|
491
|
+
if cached is not None:
|
|
492
|
+
cached_results.append(cached)
|
|
493
|
+
on_done(cached)
|
|
494
|
+
else:
|
|
495
|
+
agent_classes_run.append(cls)
|
|
496
|
+
else:
|
|
497
|
+
agent_classes_run = list(agent_classes)
|
|
498
|
+
|
|
499
|
+
if not agent_classes_run:
|
|
500
|
+
return cached_results
|
|
501
|
+
|
|
502
|
+
has_fix_agents = any(a.group == AgentGroup.FIX for a in agent_classes_run)
|
|
503
|
+
if has_fix_agents:
|
|
504
|
+
# Fix agents always run sequentially (spec: non-negotiable)
|
|
505
|
+
run_results = _run_sequential(agent_classes_run, inp, on_done)
|
|
506
|
+
elif getattr(self, "run_mode", RunMode.AUTO) == RunMode.SEQUENTIAL:
|
|
507
|
+
run_results = _run_sequential(agent_classes_run, inp, on_done)
|
|
508
|
+
elif getattr(self, "run_mode", RunMode.AUTO) == RunMode.PROCESS:
|
|
509
|
+
run_results = _run_process_parallel(
|
|
510
|
+
agent_classes_run, inp, on_done, self._config, self.root
|
|
511
|
+
)
|
|
512
|
+
elif self._config.get("scan_bus", {}).get("enabled", True):
|
|
513
|
+
run_results = _run_scan_bus(
|
|
514
|
+
agent_classes_run, inp, on_done, self._config, self.root
|
|
515
|
+
)
|
|
516
|
+
else:
|
|
517
|
+
run_results = _run_parallel(agent_classes_run, inp, on_done, self._config)
|
|
518
|
+
|
|
519
|
+
# Store results in cache
|
|
520
|
+
if use_cache:
|
|
521
|
+
for r in run_results:
|
|
522
|
+
cache.put(r.agent_name, r)
|
|
523
|
+
|
|
524
|
+
# Invalidate health score cache so the next status call picks up fresh data
|
|
525
|
+
try:
|
|
526
|
+
from patchi.core.health import invalidate_cache
|
|
527
|
+
invalidate_cache()
|
|
528
|
+
except Exception:
|
|
529
|
+
pass
|
|
530
|
+
|
|
531
|
+
return cached_results + run_results
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ── Execution engines ──────────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _run_scan_bus(
|
|
538
|
+
agent_classes: list[type[BaseAgent]],
|
|
539
|
+
inp: AgentInput,
|
|
540
|
+
on_done: Callable[[AgentResult], None],
|
|
541
|
+
config: dict,
|
|
542
|
+
root: Path,
|
|
543
|
+
) -> list[AgentResult]:
|
|
544
|
+
"""ScanBus path: one shared corpus + FindingBus merge across shard workers.
|
|
545
|
+
|
|
546
|
+
Behavior-preserving vs _run_parallel: same agents, same full input, same
|
|
547
|
+
on_done (circuit breaker + save_scan_result). Fix agents never arrive here
|
|
548
|
+
(sequenced earlier); SEQUENTIAL/PROCESS modes bypass this branch.
|
|
549
|
+
"""
|
|
550
|
+
from patchi.core.scan_bus import QueueRunner, ScanBus
|
|
551
|
+
|
|
552
|
+
sb = config.get("scan_bus", {})
|
|
553
|
+
runner = QueueRunner(ScanBus(root, shard_count=int(sb.get("shards", 4) or 4)))
|
|
554
|
+
inp.extra["scan_bus"] = runner.scan_bus
|
|
555
|
+
inp.extra["finding_bus"] = runner.finding_bus
|
|
556
|
+
return runner.run(agent_classes, lambda cls: _run_agent_safe(cls(), inp), on_done)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _run_parallel(
|
|
560
|
+
agent_classes: list[type[BaseAgent]],
|
|
561
|
+
inp: AgentInput,
|
|
562
|
+
on_done: Callable[[AgentResult], None],
|
|
563
|
+
config: dict,
|
|
564
|
+
) -> list[AgentResult]:
|
|
565
|
+
"""ThreadPoolExecutor — I/O-bound agents benefit from threads."""
|
|
566
|
+
device_tier = config.get("device_tier", "mid")
|
|
567
|
+
from patchi.core.constants import DeviceTier
|
|
568
|
+
|
|
569
|
+
try:
|
|
570
|
+
max_workers = DeviceTier(device_tier).max_parallel_agents()
|
|
571
|
+
except ValueError:
|
|
572
|
+
max_workers = 8
|
|
573
|
+
|
|
574
|
+
results: list[AgentResult] = []
|
|
575
|
+
futures: dict[Future, str] = {}
|
|
576
|
+
# Per-agent timeouts from agent class attribute (LIMIT-04)
|
|
577
|
+
timeout_map: dict[Future, int] = {}
|
|
578
|
+
|
|
579
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
580
|
+
for cls in agent_classes:
|
|
581
|
+
agent_timeout = getattr(cls, "timeout", 120)
|
|
582
|
+
future = executor.submit(_run_agent_safe, cls(), inp)
|
|
583
|
+
futures[future] = cls.name
|
|
584
|
+
timeout_map[future] = agent_timeout
|
|
585
|
+
|
|
586
|
+
for future in as_completed(futures):
|
|
587
|
+
agent_timeout = timeout_map.get(future, 120)
|
|
588
|
+
try:
|
|
589
|
+
result = future.result(timeout=agent_timeout)
|
|
590
|
+
except Exception as e:
|
|
591
|
+
result = AgentResult(
|
|
592
|
+
agent_name=futures[future],
|
|
593
|
+
agent_group=AgentGroup.SCANNER,
|
|
594
|
+
status=AgentStatus.FAILED,
|
|
595
|
+
errors=[f"Coordinator caught: {e}"],
|
|
596
|
+
)
|
|
597
|
+
on_done(result)
|
|
598
|
+
results.append(result)
|
|
599
|
+
|
|
600
|
+
return results
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _process_worker_init(cache: Any) -> None:
|
|
604
|
+
"""ProcessPoolExecutor initializer — runs in EACH worker before any task.
|
|
605
|
+
|
|
606
|
+
Installs the parent-built ScanCache as the shared read-only scan cache for
|
|
607
|
+
this worker (one walk in the parent replaces N parallel re-walks), and
|
|
608
|
+
resets the parse-once tree cache so every worker starts cold with a
|
|
609
|
+
bounded, per-process tree cache.
|
|
610
|
+
"""
|
|
611
|
+
try:
|
|
612
|
+
from patchi.core.agents.scan_cache import activate_worker_cache
|
|
613
|
+
|
|
614
|
+
activate_worker_cache(cache)
|
|
615
|
+
except Exception as e:
|
|
616
|
+
_log.warning("Coordinator._process_worker_init failed: %s", e)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _process_worker_run(cls: Any, inp: AgentInput) -> AgentResult:
|
|
620
|
+
"""Module-level worker task: instantiate the agent and run it safely.
|
|
621
|
+
|
|
622
|
+
Module-level so Windows spawn can pickle it by reference. Agent classes
|
|
623
|
+
pickle by qualified name; AgentInput/AgentResult are plain dataclasses.
|
|
624
|
+
"""
|
|
625
|
+
try:
|
|
626
|
+
return _run_agent_safe(cls(), inp)
|
|
627
|
+
except Exception as e:
|
|
628
|
+
return AgentResult(
|
|
629
|
+
agent_name=getattr(cls, "name", str(cls)),
|
|
630
|
+
agent_group=getattr(cls, "group", AgentGroup.SCANNER),
|
|
631
|
+
status=AgentStatus.FAILED,
|
|
632
|
+
errors=[f"Process worker failed: {e}"],
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _run_process_parallel(
|
|
637
|
+
agent_classes: list[type[BaseAgent]],
|
|
638
|
+
inp: AgentInput,
|
|
639
|
+
on_done: Callable[[AgentResult], None],
|
|
640
|
+
config: dict,
|
|
641
|
+
root: Path,
|
|
642
|
+
) -> list[AgentResult]:
|
|
643
|
+
"""ProcessPoolExecutor fanout — CPU-bound agents scale past the GIL.
|
|
644
|
+
|
|
645
|
+
The parent builds the shared read-only ScanCache exactly once (one
|
|
646
|
+
filesystem walk + content hashes) and hands the same immutable listing to
|
|
647
|
+
every worker's initializer; per-worker parse results are deduplicated by
|
|
648
|
+
languages.parse_source's content-hash cache. This is the "single-pass
|
|
649
|
+
scan" extended across processes.
|
|
650
|
+
|
|
651
|
+
Falls back to the thread pool on any pool-setup failure (spawn-pickle
|
|
652
|
+
edge cases, interactive interpreters without a __main__ guard, exotic
|
|
653
|
+
environments) — the process pool is a performance mode, never a
|
|
654
|
+
correctness fork. Fix agents never take this path (sequencing is
|
|
655
|
+
non-negotiable).
|
|
656
|
+
"""
|
|
657
|
+
device_tier = config.get("device_tier", "mid")
|
|
658
|
+
from patchi.core.constants import DeviceTier
|
|
659
|
+
|
|
660
|
+
try:
|
|
661
|
+
max_workers = DeviceTier(device_tier).max_parallel_agents()
|
|
662
|
+
except ValueError:
|
|
663
|
+
max_workers = 8
|
|
664
|
+
max_workers = max(1, min(max_workers, 8))
|
|
665
|
+
|
|
666
|
+
try:
|
|
667
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
668
|
+
|
|
669
|
+
from patchi.core.agents.scan_cache import build_scan_cache
|
|
670
|
+
|
|
671
|
+
cache = build_scan_cache(root)
|
|
672
|
+
except Exception as e:
|
|
673
|
+
_log.warning("Coordinator process pool unavailable (%s) - using threads", e)
|
|
674
|
+
return _run_parallel(agent_classes, inp, on_done, config)
|
|
675
|
+
|
|
676
|
+
results: list[AgentResult] = []
|
|
677
|
+
try:
|
|
678
|
+
with ProcessPoolExecutor(
|
|
679
|
+
max_workers=max_workers,
|
|
680
|
+
initializer=_process_worker_init,
|
|
681
|
+
initargs=(cache,),
|
|
682
|
+
) as executor:
|
|
683
|
+
futures: dict[Future, str] = {}
|
|
684
|
+
timeout_map: dict[Future, int] = {}
|
|
685
|
+
for cls in agent_classes:
|
|
686
|
+
agent_timeout = getattr(cls, "timeout", 120)
|
|
687
|
+
future = executor.submit(_process_worker_run, cls, inp)
|
|
688
|
+
futures[future] = cls.name
|
|
689
|
+
timeout_map[future] = agent_timeout
|
|
690
|
+
|
|
691
|
+
for future in as_completed(futures):
|
|
692
|
+
agent_timeout = timeout_map.get(future, 120)
|
|
693
|
+
try:
|
|
694
|
+
result = future.result(timeout=agent_timeout)
|
|
695
|
+
except Exception as e:
|
|
696
|
+
result = AgentResult(
|
|
697
|
+
agent_name=futures[future],
|
|
698
|
+
agent_group=AgentGroup.SCANNER,
|
|
699
|
+
status=AgentStatus.FAILED,
|
|
700
|
+
errors=[f"Coordinator caught: {e}"],
|
|
701
|
+
)
|
|
702
|
+
on_done(result)
|
|
703
|
+
results.append(result)
|
|
704
|
+
except Exception as e:
|
|
705
|
+
_log.warning("Coordinator process pool crashed (%s) - falling back to threads", e)
|
|
706
|
+
return _run_parallel(agent_classes, inp, on_done, config)
|
|
707
|
+
|
|
708
|
+
return results
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _run_fix_agents_batched(
|
|
712
|
+
agent_classes: list[type[BaseAgent]],
|
|
713
|
+
inp: AgentInput,
|
|
714
|
+
on_done: Callable[[AgentResult], None],
|
|
715
|
+
config: dict,
|
|
716
|
+
) -> list[AgentResult]:
|
|
717
|
+
"""Run fix agents with file-based batching.
|
|
718
|
+
|
|
719
|
+
Agents targeting different files run in parallel.
|
|
720
|
+
Agents targeting the same file run sequentially.
|
|
721
|
+
"""
|
|
722
|
+
device_tier = config.get("device_tier", "mid")
|
|
723
|
+
from patchi.core.constants import DeviceTier
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
max_workers = DeviceTier(device_tier).max_parallel_agents()
|
|
727
|
+
except ValueError:
|
|
728
|
+
max_workers = 8
|
|
729
|
+
|
|
730
|
+
batches = _group_fix_agents_by_file(agent_classes)
|
|
731
|
+
|
|
732
|
+
if len(batches) <= 1:
|
|
733
|
+
return _run_sequential(agent_classes, inp, on_done)
|
|
734
|
+
|
|
735
|
+
results: list[AgentResult] = []
|
|
736
|
+
|
|
737
|
+
with ThreadPoolExecutor(max_workers=min(max_workers, len(batches))) as executor:
|
|
738
|
+
batch_futures: dict[Future, list[str]] = {}
|
|
739
|
+
for batch in batches:
|
|
740
|
+
future = executor.submit(_run_sequential, batch, inp, on_done)
|
|
741
|
+
batch_futures[future] = [c.name for c in batch]
|
|
742
|
+
|
|
743
|
+
for future in as_completed(batch_futures):
|
|
744
|
+
try:
|
|
745
|
+
batch_results = future.result(timeout=300)
|
|
746
|
+
results.extend(batch_results)
|
|
747
|
+
except Exception as e:
|
|
748
|
+
for name in batch_futures[future]:
|
|
749
|
+
results.append(
|
|
750
|
+
AgentResult(
|
|
751
|
+
agent_name=name,
|
|
752
|
+
agent_group=AgentGroup.FIX,
|
|
753
|
+
status=AgentStatus.FAILED,
|
|
754
|
+
errors=[f"Batch failed: {e}"],
|
|
755
|
+
)
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
return results
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _group_fix_agents_by_file(
|
|
762
|
+
agent_classes: list[type[BaseAgent]],
|
|
763
|
+
) -> list[list[type[BaseAgent]]]:
|
|
764
|
+
"""Group fix agents by their declared target files.
|
|
765
|
+
|
|
766
|
+
Agents that declare overlapping target files are placed in the same batch
|
|
767
|
+
(must run sequentially). Agents targeting different files go into separate
|
|
768
|
+
batches (can run in parallel).
|
|
769
|
+
|
|
770
|
+
Each agent class may define a ``target_files()`` classmethod returning
|
|
771
|
+
``list[str]`` of relative file paths it operates on. If no agent declares
|
|
772
|
+
targets, every agent gets its own batch (optimistic parallelism).
|
|
773
|
+
"""
|
|
774
|
+
file_map: dict[str, list[type[BaseAgent]]] = defaultdict(list)
|
|
775
|
+
ungrouped: list[type[BaseAgent]] = []
|
|
776
|
+
|
|
777
|
+
for cls in agent_classes:
|
|
778
|
+
target_files_fn = getattr(cls, "target_files", None)
|
|
779
|
+
if callable(target_files_fn):
|
|
780
|
+
try:
|
|
781
|
+
files = list(target_files_fn())
|
|
782
|
+
except Exception as e:
|
|
783
|
+
_log.warning("_group_fix_agents_by_file failed: %s", e)
|
|
784
|
+
ungrouped.append(cls)
|
|
785
|
+
continue
|
|
786
|
+
else:
|
|
787
|
+
ungrouped.append(cls)
|
|
788
|
+
continue
|
|
789
|
+
|
|
790
|
+
if not files:
|
|
791
|
+
ungrouped.append(cls)
|
|
792
|
+
continue
|
|
793
|
+
|
|
794
|
+
key = tuple(sorted(files))
|
|
795
|
+
file_map[key].append(cls)
|
|
796
|
+
|
|
797
|
+
batches: list[list[type[BaseAgent]]] = list(file_map.values())
|
|
798
|
+
|
|
799
|
+
# Ungrouped agents each get their own batch (can run in parallel with others)
|
|
800
|
+
for cls in ungrouped:
|
|
801
|
+
batches.append([cls])
|
|
802
|
+
|
|
803
|
+
return batches if batches else [list(agent_classes)]
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _run_sequential(
|
|
807
|
+
agent_classes: list[type[BaseAgent]],
|
|
808
|
+
inp: AgentInput,
|
|
809
|
+
on_done: Callable[[AgentResult], None],
|
|
810
|
+
) -> list[AgentResult]:
|
|
811
|
+
results: list[AgentResult] = []
|
|
812
|
+
for cls in agent_classes:
|
|
813
|
+
result = _run_agent_safe(cls(), inp)
|
|
814
|
+
on_done(result)
|
|
815
|
+
results.append(result)
|
|
816
|
+
return results
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _run_agent_safe(agent: BaseAgent, inp: AgentInput) -> AgentResult:
|
|
820
|
+
try:
|
|
821
|
+
return agent.run(inp)
|
|
822
|
+
except Exception as e:
|
|
823
|
+
return AgentResult(
|
|
824
|
+
agent_name=agent.name,
|
|
825
|
+
agent_group=agent.group,
|
|
826
|
+
status=AgentStatus.FAILED,
|
|
827
|
+
errors=[f"Unhandled: {e}"],
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# ── Merge helper ───────────────────────────────────────────────────────────────
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def merge_results(results: list[AgentResult]) -> dict:
|
|
835
|
+
all_findings: list[Finding] = []
|
|
836
|
+
all_data: dict[str, Any] = {}
|
|
837
|
+
errors: list[str] = []
|
|
838
|
+
total_files = 0
|
|
839
|
+
total_ms = 0
|
|
840
|
+
|
|
841
|
+
for r in results:
|
|
842
|
+
all_findings.extend(r.findings)
|
|
843
|
+
all_data[r.agent_name] = r.data
|
|
844
|
+
errors.extend(r.errors)
|
|
845
|
+
total_files += r.files_scanned
|
|
846
|
+
total_ms += r.duration_ms
|
|
847
|
+
|
|
848
|
+
all_findings.sort(key=lambda f: (f.severity.sort_key(), f.file, f.line))
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
"findings": [f.to_dict() for f in all_findings],
|
|
852
|
+
"by_agent": all_data,
|
|
853
|
+
"total_findings": len(all_findings),
|
|
854
|
+
"total_files": total_files,
|
|
855
|
+
"total_ms": total_ms,
|
|
856
|
+
"agent_count": len(results),
|
|
857
|
+
"errors": errors,
|
|
858
|
+
}
|