aria-code 0.52.0__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.
- aria_code/__init__.py +42 -0
- aria_code/_version.py +20 -0
- aria_code/adk_apps/__init__.py +1 -0
- aria_code/adk_apps/aria_code_review/__init__.py +1 -0
- aria_code/adk_apps/aria_code_review/agent.py +32 -0
- aria_code/adk_apps/aria_research/__init__.py +1 -0
- aria_code/adk_apps/aria_research/agent.py +31 -0
- aria_code/agents/__init__.py +34 -0
- aria_code/agents/base.py +318 -0
- aria_code/agents/code_review.py +200 -0
- aria_code/agents/deep/__init__.py +37 -0
- aria_code/agents/deep/calibration_loop.py +144 -0
- aria_code/agents/deep/critic.py +125 -0
- aria_code/agents/deep/deepen.py +193 -0
- aria_code/agents/deep/models.py +149 -0
- aria_code/agents/deep/pipeline.py +164 -0
- aria_code/agents/deep/quant_fusion.py +192 -0
- aria_code/agents/deep/themes.py +95 -0
- aria_code/agents/deep/tiers.py +106 -0
- aria_code/agents/engineering/__init__.py +14 -0
- aria_code/agents/engineering/coder.py +222 -0
- aria_code/agents/engineering/tester.py +145 -0
- aria_code/agents/financial/__init__.py +21 -0
- aria_code/agents/financial/cashflow_burn.py +119 -0
- aria_code/agents/financial/catalyst.py +279 -0
- aria_code/agents/financial/corporate_finance.py +175 -0
- aria_code/agents/financial/debate.py +224 -0
- aria_code/agents/financial/earnings.py +303 -0
- aria_code/agents/financial/fundamental.py +191 -0
- aria_code/agents/financial/macro.py +99 -0
- aria_code/agents/financial/news.py +207 -0
- aria_code/agents/financial/risk.py +165 -0
- aria_code/agents/financial/sector.py +279 -0
- aria_code/agents/financial/strategist.py +121 -0
- aria_code/agents/financial/stripe_revenue.py +127 -0
- aria_code/agents/financial/synthesis.py +274 -0
- aria_code/agents/financial/technical.py +258 -0
- aria_code/agents/orchestrator.py +44 -0
- aria_code/agents/portfolio_agent.py +363 -0
- aria_code/agents/realty/__init__.py +62 -0
- aria_code/agents/realty/asset_diagnosis.py +152 -0
- aria_code/agents/realty/business_match.py +166 -0
- aria_code/agents/realty/cashflow_verify.py +209 -0
- aria_code/agents/realty/contract_rules.py +210 -0
- aria_code/agents/realty/energy_anomaly.py +189 -0
- aria_code/agents/realty/exit_settlement.py +208 -0
- aria_code/agents/realty/fulfillment_risk.py +206 -0
- aria_code/agents/realty/ops_optimize.py +160 -0
- aria_code/agents/realty/revenue_share.py +215 -0
- aria_code/agents/registry.py +158 -0
- aria_code/agents/signal_scheme.py +111 -0
- aria_code/agents/sports/__init__.py +0 -0
- aria_code/agents/sports/football_agent.py +197 -0
- aria_code/agents/supervisor.py +113 -0
- aria_code/agents/team.py +492 -0
- aria_code/agents/warehouse/__init__.py +25 -0
- aria_code/agents/warehouse/contracts.py +26 -0
- aria_code/agents/warehouse/dashboard.py +155 -0
- aria_code/agents/warehouse/fulfillment_leadtime.py +95 -0
- aria_code/agents/warehouse/inbound_exceptions.py +25 -0
- aria_code/agents/warehouse/inventory_health.py +24 -0
- aria_code/agents/warehouse/local_run.py +71 -0
- aria_code/agents/warehouse/local_snapshot.py +132 -0
- aria_code/agents/warehouse/logistics_cost.py +84 -0
- aria_code/agents/warehouse/logistics_sync.py +21 -0
- aria_code/agents/warehouse/workflow.py +31 -0
- aria_code/aliyun_data_client.py +4 -0
- aria_code/apps/README.md +12 -0
- aria_code/apps/__init__.py +2 -0
- aria_code/apps/channels/README.md +15 -0
- aria_code/apps/channels/__init__.py +17 -0
- aria_code/apps/channels/intake.py +92 -0
- aria_code/apps/channels/registry.py +95 -0
- aria_code/apps/channels/tradingview.py +94 -0
- aria_code/apps/cli/README.md +13 -0
- aria_code/apps/cli/__init__.py +2 -0
- aria_code/apps/cli/_optional.py +120 -0
- aria_code/apps/cli/bootstrap.py +145 -0
- aria_code/apps/cli/broker_render.py +206 -0
- aria_code/apps/cli/codegen_paths.py +29 -0
- aria_code/apps/cli/commands/__init__.py +16 -0
- aria_code/apps/cli/commands/_business_render.py +78 -0
- aria_code/apps/cli/commands/_ui.py +71 -0
- aria_code/apps/cli/commands/analysis_cmds.py +369 -0
- aria_code/apps/cli/commands/ashare_prediction_cmds.py +327 -0
- aria_code/apps/cli/commands/auth_cmds.py +239 -0
- aria_code/apps/cli/commands/backtest_cmds.py +2543 -0
- aria_code/apps/cli/commands/broker_cmds.py +1234 -0
- aria_code/apps/cli/commands/business_workflow_cmds.py +319 -0
- aria_code/apps/cli/commands/canvas_cmds.py +77 -0
- aria_code/apps/cli/commands/catalog.py +87 -0
- aria_code/apps/cli/commands/core_cmds.py +2520 -0
- aria_code/apps/cli/commands/data_cmds.py +457 -0
- aria_code/apps/cli/commands/diagnostic_cmds.py +230 -0
- aria_code/apps/cli/commands/diagnostic_ops_cmds.py +813 -0
- aria_code/apps/cli/commands/file_cmds.py +312 -0
- aria_code/apps/cli/commands/finance_service_cmds.py +60 -0
- aria_code/apps/cli/commands/fx_commodity_cmds.py +123 -0
- aria_code/apps/cli/commands/market.py +434 -0
- aria_code/apps/cli/commands/market_cmds.py +1381 -0
- aria_code/apps/cli/commands/market_context.py +425 -0
- aria_code/apps/cli/commands/market_render.py +7 -0
- aria_code/apps/cli/commands/model_cmds.py +2153 -0
- aria_code/apps/cli/commands/ops_cmds.py +721 -0
- aria_code/apps/cli/commands/orchestrator_cmds.py +179 -0
- aria_code/apps/cli/commands/pdf_export_cmds.py +125 -0
- aria_code/apps/cli/commands/portfolio_cmds.py +1278 -0
- aria_code/apps/cli/commands/report.py +437 -0
- aria_code/apps/cli/commands/scaffold_templates.py +617 -0
- aria_code/apps/cli/commands/session_cmds.py +236 -0
- aria_code/apps/cli/commands/session_ux_cmds.py +359 -0
- aria_code/apps/cli/commands/team.py +669 -0
- aria_code/apps/cli/commands/team_render.py +8 -0
- aria_code/apps/cli/commands/ui_cmds.py +401 -0
- aria_code/apps/cli/commands/warehouse_cmds.py +79 -0
- aria_code/apps/cli/commands/workflow_cmds.py +480 -0
- aria_code/apps/cli/commands/workspace_cmds.py +1494 -0
- aria_code/apps/cli/config_paths.py +73 -0
- aria_code/apps/cli/config_store.py +78 -0
- aria_code/apps/cli/constants.py +88 -0
- aria_code/apps/cli/context.py +34 -0
- aria_code/apps/cli/deterministic.py +191 -0
- aria_code/apps/cli/direct.py +48 -0
- aria_code/apps/cli/finance_service_catalog.py +111 -0
- aria_code/apps/cli/football_reports.py +231 -0
- aria_code/apps/cli/github_app_auth.py +135 -0
- aria_code/apps/cli/google_login.py +196 -0
- aria_code/apps/cli/handlers/__init__.py +11 -0
- aria_code/apps/cli/handlers/broker_handlers.py +122 -0
- aria_code/apps/cli/handlers/chart_handlers.py +1309 -0
- aria_code/apps/cli/handlers/market_handlers.py +2513 -0
- aria_code/apps/cli/handlers/realty_handlers.py +114 -0
- aria_code/apps/cli/handlers/sports_handlers.py +22 -0
- aria_code/apps/cli/handlers/strategy_advice.py +82 -0
- aria_code/apps/cli/helpers.py +302 -0
- aria_code/apps/cli/hooks.py +180 -0
- aria_code/apps/cli/i18n.py +297 -0
- aria_code/apps/cli/intent.py +136 -0
- aria_code/apps/cli/intent_router.py +276 -0
- aria_code/apps/cli/intent_signals.py +344 -0
- aria_code/apps/cli/lifecycle_hooks.py +48 -0
- aria_code/apps/cli/local_tool_schemas.py +536 -0
- aria_code/apps/cli/main.py +29 -0
- aria_code/apps/cli/market_metadata.py +135 -0
- aria_code/apps/cli/market_universe.py +487 -0
- aria_code/apps/cli/message_processing.py +310 -0
- aria_code/apps/cli/model_catalog.py +620 -0
- aria_code/apps/cli/pdf_report.py +977 -0
- aria_code/apps/cli/plan_mode.py +139 -0
- aria_code/apps/cli/plotly_html.py +15 -0
- aria_code/apps/cli/prediction_feedback.py +202 -0
- aria_code/apps/cli/preflight.py +502 -0
- aria_code/apps/cli/project_aria.py +60 -0
- aria_code/apps/cli/prompt_assembly.py +59 -0
- aria_code/apps/cli/prompts/__init__.py +0 -0
- aria_code/apps/cli/prompts/coding.py +53 -0
- aria_code/apps/cli/prompts/select.py +142 -0
- aria_code/apps/cli/prompts/system_prompts.py +552 -0
- aria_code/apps/cli/prompts/ui.py +436 -0
- aria_code/apps/cli/provider_endpoints.py +237 -0
- aria_code/apps/cli/provider_keys.py +73 -0
- aria_code/apps/cli/providers/__init__.py +1 -0
- aria_code/apps/cli/providers/base.py +521 -0
- aria_code/apps/cli/providers/chat_routing.py +128 -0
- aria_code/apps/cli/providers/collaboration.py +98 -0
- aria_code/apps/cli/providers/llm/__init__.py +1 -0
- aria_code/apps/cli/providers/llm/offline_responses.py +68 -0
- aria_code/apps/cli/providers/llm/ollama_stream.py +1594 -0
- aria_code/apps/cli/providers/llm/response_cache.py +60 -0
- aria_code/apps/cli/providers/llm/sse_stream.py +257 -0
- aria_code/apps/cli/providers/runtime_bridge.py +353 -0
- aria_code/apps/cli/providers/vertexai_stream.py +316 -0
- aria_code/apps/cli/runtime_consumer.py +657 -0
- aria_code/apps/cli/sandbox.py +32 -0
- aria_code/apps/cli/session_export.py +120 -0
- aria_code/apps/cli/session_jsonl.py +208 -0
- aria_code/apps/cli/session_store.py +112 -0
- aria_code/apps/cli/skills_catalog.py +273 -0
- aria_code/apps/cli/todo_tracker.py +190 -0
- aria_code/apps/cli/tool_executor.py +823 -0
- aria_code/apps/cli/tool_registry.py +34 -0
- aria_code/apps/cli/tool_scope.py +106 -0
- aria_code/apps/cli/tools/__init__.py +40 -0
- aria_code/apps/cli/tools/context.py +46 -0
- aria_code/apps/cli/tools/file_tools.py +180 -0
- aria_code/apps/cli/tools/market_tools.py +620 -0
- aria_code/apps/cli/tools/notebook_tools.py +111 -0
- aria_code/apps/cli/tools/system_tools.py +726 -0
- aria_code/apps/cli/tools/write_tools.py +845 -0
- aria_code/apps/cli/tradingview_bridge.py +529 -0
- aria_code/apps/cli/turn_planning.py +137 -0
- aria_code/apps/cli/update_check.py +153 -0
- aria_code/apps/cli/utils/__init__.py +0 -0
- aria_code/apps/cli/utils/market_detect.py +1597 -0
- aria_code/apps/daemon/README.md +14 -0
- aria_code/apps/vscode/README.md +115 -0
- aria_code/apps/vscode/package.json +70 -0
- aria_code/aria_cli.py +6743 -0
- aria_code/aria_daemon.py +1398 -0
- aria_code/aria_feishu_bot.py +1423 -0
- aria_code/aria_mcp_server.py +7 -0
- aria_code/aria_relay_client.py +4 -0
- aria_code/aria_relay_server.py +490 -0
- aria_code/aria_telegram_bot.py +202 -0
- aria_code/ariarc.py +385 -0
- aria_code/artifacts.py +581 -0
- aria_code/backtest_engine.py +4 -0
- aria_code/backtest_report.py +4 -0
- aria_code/brokers/__init__.py +78 -0
- aria_code/brokers/automation.py +193 -0
- aria_code/brokers/base.py +231 -0
- aria_code/brokers/capabilities.py +264 -0
- aria_code/brokers/cn/__init__.py +10 -0
- aria_code/brokers/cn/easytrader_broker.py +193 -0
- aria_code/brokers/cn/futu_broker.py +194 -0
- aria_code/brokers/cn/longbridge_broker.py +190 -0
- aria_code/brokers/cn/tiger_broker.py +196 -0
- aria_code/brokers/cn/xtquant_broker.py +225 -0
- aria_code/brokers/config.py +414 -0
- aria_code/brokers/intl/__init__.py +5 -0
- aria_code/brokers/intl/alpaca_broker.py +183 -0
- aria_code/brokers/intl/ibkr_broker.py +215 -0
- aria_code/brokers/intl/trading212_broker.py +183 -0
- aria_code/brokers/intl/webull_broker.py +156 -0
- aria_code/brokers/paper_broker.py +266 -0
- aria_code/brokers/planning.py +296 -0
- aria_code/brokers/registry.py +213 -0
- aria_code/brokers/trading.py +267 -0
- aria_code/canva_client.py +4 -0
- aria_code/change_store.py +127 -0
- aria_code/clients/__init__.py +0 -0
- aria_code/clients/aliyun_data_client.py +660 -0
- aria_code/clients/aria_relay_client.py +182 -0
- aria_code/clients/canva_client.py +284 -0
- aria_code/clients/figma_client.py +100 -0
- aria_code/clients/football_data_client.py +1671 -0
- aria_code/clients/kling_video_client.py +182 -0
- aria_code/clients/market_data_client.py +1976 -0
- aria_code/clients/openai_image_client.py +242 -0
- aria_code/clients/runway_video_client.py +157 -0
- aria_code/clients/warehouse_erp_client.py +105 -0
- aria_code/cloud_memory.py +181 -0
- aria_code/command_safety.py +19 -0
- aria_code/computer_use_tools.py +4 -0
- aria_code/dashboard_generator.py +579 -0
- aria_code/data_analysis_tools.py +4 -0
- aria_code/data_cleaner.py +483 -0
- aria_code/data_service.py +513 -0
- aria_code/datasources/__init__.py +23 -0
- aria_code/datasources/base.py +166 -0
- aria_code/datasources/router.py +253 -0
- aria_code/datasources/sources/__init__.py +15 -0
- aria_code/datasources/sources/akshare_source.py +269 -0
- aria_code/datasources/sources/alpha_vantage_source.py +203 -0
- aria_code/datasources/sources/edgar_source.py +219 -0
- aria_code/datasources/sources/finnhub_source.py +198 -0
- aria_code/datasources/sources/fred_source.py +223 -0
- aria_code/datasources/sources/tushare_source.py +143 -0
- aria_code/datasources/sources/web_scraper_source.py +278 -0
- aria_code/datasources/sources/world_bank_source.py +206 -0
- aria_code/datasources/sources/yfinance_source.py +152 -0
- aria_code/demo_player.py +207 -0
- aria_code/doctor.py +813 -0
- aria_code/domain/__init__.py +0 -0
- aria_code/domain/backtest_engine.py +354 -0
- aria_code/domain/backtest_report.py +472 -0
- aria_code/domain/finance_formulas.py +389 -0
- aria_code/domain/portfolio_ledger.py +307 -0
- aria_code/domain/strategy_vault.py +421 -0
- aria_code/evals/__init__.py +37 -0
- aria_code/evals/harness.py +598 -0
- aria_code/evals/runner.py +238 -0
- aria_code/external_agent_runner.py +115 -0
- aria_code/figma_client.py +4 -0
- aria_code/file_analysis_tools.py +4 -0
- aria_code/finance_formulas.py +4 -0
- aria_code/football_data_client.py +4 -0
- aria_code/image_gen_tools.py +4 -0
- aria_code/intent_classifier.py +386 -0
- aria_code/kling_video_client.py +4 -0
- aria_code/licensing.py +151 -0
- aria_code/local_finance_tools.py +4 -0
- aria_code/local_image_provider.py +4 -0
- aria_code/local_llm_provider.py +4 -0
- aria_code/macro_tools.py +4 -0
- aria_code/markdown_pdf.py +289 -0
- aria_code/market_data_client.py +4 -0
- aria_code/mcp_client.py +639 -0
- aria_code/memory_manager.py +246 -0
- aria_code/model_capability.py +486 -0
- aria_code/notification_tools.py +4 -0
- aria_code/openai_image_client.py +4 -0
- aria_code/packages/__init__.py +23 -0
- aria_code/packages/adk_bridge/__init__.py +11 -0
- aria_code/packages/adk_bridge/code_review_tools.py +49 -0
- aria_code/packages/adk_bridge/market_tools.py +125 -0
- aria_code/packages/aria_agents/__init__.py +5 -0
- aria_code/packages/aria_agents/manifest.py +78 -0
- aria_code/packages/aria_core/__init__.py +34 -0
- aria_code/packages/aria_core/architecture.py +269 -0
- aria_code/packages/aria_core/export.py +147 -0
- aria_code/packages/aria_core/manifest.py +65 -0
- aria_code/packages/aria_core/paths.py +57 -0
- aria_code/packages/aria_core/secure_file.py +68 -0
- aria_code/packages/aria_infra/__init__.py +15 -0
- aria_code/packages/aria_infra/arthera.py +52 -0
- aria_code/packages/aria_infra/doctor.py +246 -0
- aria_code/packages/aria_infra/product.py +40 -0
- aria_code/packages/aria_mcp/__init__.py +25 -0
- aria_code/packages/aria_mcp/bridge.py +201 -0
- aria_code/packages/aria_mcp/circuit.py +82 -0
- aria_code/packages/aria_mcp/config.py +111 -0
- aria_code/packages/aria_mcp/installer.py +95 -0
- aria_code/packages/aria_mcp/server.py +1550 -0
- aria_code/packages/aria_mcp/tools.py +81 -0
- aria_code/packages/aria_sdk/__init__.py +19 -0
- aria_code/packages/aria_sdk/client.py +396 -0
- aria_code/packages/aria_sdk/providers.py +70 -0
- aria_code/packages/aria_sdk/streaming.py +79 -0
- aria_code/packages/aria_sdk/types.py +86 -0
- aria_code/packages/aria_services/__init__.py +92 -0
- aria_code/packages/aria_services/cache.py +60 -0
- aria_code/packages/aria_services/context.py +356 -0
- aria_code/packages/aria_services/context_checkpoints.py +157 -0
- aria_code/packages/aria_services/data.py +11 -0
- aria_code/packages/aria_services/provider_health.py +268 -0
- aria_code/packages/aria_services/references.py +247 -0
- aria_code/packages/aria_services/registry.py +249 -0
- aria_code/packages/aria_services/research_protocol.py +57 -0
- aria_code/packages/aria_services/research_quality.py +186 -0
- aria_code/packages/aria_services/settings.py +157 -0
- aria_code/packages/aria_services/usage.py +151 -0
- aria_code/packages/aria_skills/__init__.py +46 -0
- aria_code/packages/aria_skills/catalog.py +149 -0
- aria_code/packages/aria_skills/loader.py +455 -0
- aria_code/packages/aria_skills/registry.py +84 -0
- aria_code/packages/aria_tools/__init__.py +5 -0
- aria_code/packages/aria_tools/financial/__init__.py +8 -0
- aria_code/packages/aria_tools/financial/_paths.py +130 -0
- aria_code/packages/aria_tools/financial/compliance.py +31 -0
- aria_code/packages/aria_tools/financial/factors.py +33 -0
- aria_code/packages/aria_tools/financial/risk_tools.py +33 -0
- aria_code/packages/aria_tools/financial/strategy.py +31 -0
- aria_code/packages/aria_tools/registry.py +128 -0
- aria_code/packages/quant_engine/__init__.py +34 -0
- aria_code/packages/quant_engine/_namespace.py +62 -0
- aria_code/packages/quant_engine/backtest/__init__.py +14 -0
- aria_code/packages/quant_engine/backtest/core.py +53 -0
- aria_code/packages/quant_engine/backtest/engine.py +96 -0
- aria_code/packages/quant_engine/portfolio/__init__.py +13 -0
- aria_code/packages/quant_engine/portfolio/optimizer.py +85 -0
- aria_code/packages/quant_engine/sports/__init__.py +80 -0
- aria_code/packages/quant_engine/sports/calibrator.py +354 -0
- aria_code/packages/quant_engine/sports/dixon_coles.py +234 -0
- aria_code/packages/quant_engine/sports/elo.py +300 -0
- aria_code/packages/quant_engine/sports/form.py +188 -0
- aria_code/packages/quant_engine/sports/h2h.py +195 -0
- aria_code/packages/quant_engine/sports/ml_model.py +377 -0
- aria_code/packages/quant_engine/sports/predictor.py +312 -0
- aria_code/packages/quant_engine/sports/tracker.py +665 -0
- aria_code/packages/quant_engine/stochastic/__init__.py +35 -0
- aria_code/packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- aria_code/packages/quant_engine/stochastic/ito_calculus.py +477 -0
- aria_code/packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- aria_code/packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- aria_code/packages/quant_engine/stochastic/options_pricing.py +576 -0
- aria_code/packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- aria_code/packs/__init__.py +85 -0
- aria_code/packs/base.py +168 -0
- aria_code/packs/finance/__init__.py +250 -0
- aria_code/packs/logistics/__init__.py +169 -0
- aria_code/packs/payments/__init__.py +139 -0
- aria_code/packs/realty/__init__.py +190 -0
- aria_code/packs/registry.py +202 -0
- aria_code/packs/rules.py +90 -0
- aria_code/plan_utils.py +194 -0
- aria_code/plugin_loader.py +328 -0
- aria_code/portfolio_ledger.py +4 -0
- aria_code/preview_server.py +449 -0
- aria_code/privacy/__init__.py +5 -0
- aria_code/privacy/feedback.py +123 -0
- aria_code/project_review.py +278 -0
- aria_code/project_review_api.py +235 -0
- aria_code/project_review_server.py +18 -0
- aria_code/project_tools.py +4 -0
- aria_code/providers/__init__.py +30 -0
- aria_code/providers/llm/__init__.py +19 -0
- aria_code/providers/llm/anthropic.py +184 -0
- aria_code/providers/llm/autoconfig.py +213 -0
- aria_code/providers/llm/base.py +139 -0
- aria_code/providers/llm/custom.py +158 -0
- aria_code/providers/llm/ollama.py +129 -0
- aria_code/providers/llm/openai_compat.py +313 -0
- aria_code/providers/llm/registry.py +444 -0
- aria_code/providers/local_image_provider.py +202 -0
- aria_code/providers/local_llm_provider.py +589 -0
- aria_code/realty_data_tools.py +4 -0
- aria_code/report_exporters.py +150 -0
- aria_code/report_generator.py +1764 -0
- aria_code/runtime/__init__.py +169 -0
- aria_code/runtime/acceptance.py +481 -0
- aria_code/runtime/agent_loop.py +1520 -0
- aria_code/runtime/approval.py +57 -0
- aria_code/runtime/budget.py +232 -0
- aria_code/runtime/checkpoints.py +432 -0
- aria_code/runtime/crash_report.py +174 -0
- aria_code/runtime/events.py +113 -0
- aria_code/runtime/gateway.py +150 -0
- aria_code/runtime/lsp.py +484 -0
- aria_code/runtime/orchestrator.py +55 -0
- aria_code/runtime/repo_map.py +700 -0
- aria_code/runtime/run_state.py +127 -0
- aria_code/runtime/run_store.py +450 -0
- aria_code/runtime/self_healing.py +422 -0
- aria_code/runtime/subagent.py +547 -0
- aria_code/runtime/task_ledger.py +86 -0
- aria_code/runtime/task_orchestrator.py +90 -0
- aria_code/runtime/tool_executor.py +217 -0
- aria_code/runtime/tool_policy.py +107 -0
- aria_code/runtime/worktrees.py +215 -0
- aria_code/runway_video_client.py +4 -0
- aria_code/safety/__init__.py +23 -0
- aria_code/safety/permissions.py +306 -0
- aria_code/safety/service.py +102 -0
- aria_code/setup_wizard.py +673 -0
- aria_code/spreadsheet_tools.py +4 -0
- aria_code/strategy_vault.py +4 -0
- aria_code/tools/__init__.py +0 -0
- aria_code/tools/artifact_tools.py +252 -0
- aria_code/tools/broker_tools.py +92 -0
- aria_code/tools/build_quant_engine.py +97 -0
- aria_code/tools/code_audit_tools.py +192 -0
- aria_code/tools/computer_use_tools.py +504 -0
- aria_code/tools/data_analysis_tools.py +809 -0
- aria_code/tools/enterprise_finance_tools.py +156 -0
- aria_code/tools/extended_tools.py +240 -0
- aria_code/tools/file_analysis_tools.py +885 -0
- aria_code/tools/image_gen_tools.py +114 -0
- aria_code/tools/local_finance_tools.py +3417 -0
- aria_code/tools/logistics_tools.py +135 -0
- aria_code/tools/macro_tools.py +369 -0
- aria_code/tools/notification_tools.py +249 -0
- aria_code/tools/project_tools.py +525 -0
- aria_code/tools/realty_data_tools.py +659 -0
- aria_code/tools/spreadsheet_tools.py +388 -0
- aria_code/tools/stripe_tools.py +115 -0
- aria_code/ui/__init__.py +105 -0
- aria_code/ui/banner.py +508 -0
- aria_code/ui/completer.py +501 -0
- aria_code/ui/console.py +281 -0
- aria_code/ui/image_render.py +243 -0
- aria_code/ui/input_box.py +488 -0
- aria_code/ui/picker.py +201 -0
- aria_code/ui/render/__init__.py +11 -0
- aria_code/ui/render/finance.py +1705 -0
- aria_code/ui/render/market.py +225 -0
- aria_code/ui/render/output.py +775 -0
- aria_code/ui/render/responsive.py +75 -0
- aria_code/ui/render/team.py +354 -0
- aria_code/ui/robot.py +235 -0
- aria_code/ui/startup_dashboard.py +109 -0
- aria_code/utils/vscode_uri.py +37 -0
- aria_code/video_analysis.py +147 -0
- aria_code/video_editor.py +271 -0
- aria_code/workspace/__init__.py +6 -0
- aria_code/workspace/files.py +193 -0
- aria_code/workspace/verify.py +113 -0
- aria_code/workspace_changes.py +199 -0
- aria_code/workspace_context.py +233 -0
- aria_code/workspace_service.py +196 -0
- aria_code/workspace_tools.py +150 -0
- aria_code-0.52.0.dist-info/METADATA +237 -0
- aria_code-0.52.0.dist-info/RECORD +478 -0
- aria_code-0.52.0.dist-info/WHEEL +5 -0
- aria_code-0.52.0.dist-info/entry_points.txt +3 -0
- aria_code-0.52.0.dist-info/licenses/LICENSE +201 -0
- aria_code-0.52.0.dist-info/licenses/NOTICE +49 -0
- aria_code-0.52.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1520 @@
|
|
|
1
|
+
"""Agent-loop orchestration helpers for Aria Code.
|
|
2
|
+
|
|
3
|
+
This module intentionally starts with pure, easily-tested primitives. The CLI
|
|
4
|
+
still owns UI prompts and provider calls, while the runtime owns the mechanical
|
|
5
|
+
shape of tool batching and follow-up construction.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import inspect
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import AsyncGenerator, Awaitable, Callable, Dict, FrozenSet, Iterable, List, Optional, Sequence, Tuple, Union
|
|
15
|
+
|
|
16
|
+
from .acceptance import AcceptanceGate
|
|
17
|
+
from .approval import ApprovalDecision, apply_approval_decision
|
|
18
|
+
from .tool_executor import ToolExecutor
|
|
19
|
+
from .budget import BudgetTracker
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_SERIAL_TOOLS = {"write_file", "edit_file", "multi_edit", "run_command"}
|
|
23
|
+
|
|
24
|
+
# Phrases the model uses to signal task completion
|
|
25
|
+
_DONE_PHRASES = frozenset([
|
|
26
|
+
"task complete", "task is complete", "all done", "completed successfully",
|
|
27
|
+
"here is the final", "here's the final", "analysis complete",
|
|
28
|
+
"任务完成", "已完成", "分析完成", "操作完成", "已经完成", "以下是最终",
|
|
29
|
+
"i have completed", "i've completed", "the task has been completed",
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def detect_task_complete(response_text: str) -> bool:
|
|
34
|
+
"""Heuristic: did the AI signal task completion without requesting more tools?"""
|
|
35
|
+
if not response_text:
|
|
36
|
+
return False
|
|
37
|
+
lower = response_text.lower()
|
|
38
|
+
return any(phrase in lower for phrase in _DONE_PHRASES)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LoopGuard:
|
|
42
|
+
"""Detect repeated identical *failing* tool calls and break the agent loop.
|
|
43
|
+
|
|
44
|
+
Weaker local models (and sometimes cloud models) get stuck calling the
|
|
45
|
+
exact same tool with the exact same arguments after it has already failed
|
|
46
|
+
— e.g. reading a file that does not exist, or calling an unknown tool. This
|
|
47
|
+
guard tracks failure signatures across rounds:
|
|
48
|
+
|
|
49
|
+
* After ``soft_threshold`` identical failures it injects a directive
|
|
50
|
+
telling the model to STOP repeating that call and try another approach.
|
|
51
|
+
* After ``hard_threshold`` it signals the caller to break the loop so we
|
|
52
|
+
don't burn the remaining tool rounds.
|
|
53
|
+
|
|
54
|
+
A successful call clears that signature's counter.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
soft_threshold: int = 2,
|
|
61
|
+
hard_threshold: int = 4,
|
|
62
|
+
tool_soft_threshold: int = 3,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.soft_threshold = soft_threshold
|
|
65
|
+
self.hard_threshold = hard_threshold
|
|
66
|
+
# Tool-level counting (ANY params): catches the observed blind spot
|
|
67
|
+
# where a tool kept failing with slightly different arguments — the
|
|
68
|
+
# exact-signature counters never accumulated, so peer_comparison
|
|
69
|
+
# failed 4x in one turn without a single guard directive. Advisory
|
|
70
|
+
# only: exploration patterns (read_file over several candidate paths)
|
|
71
|
+
# legitimately fail with varying params, so the hard break stays
|
|
72
|
+
# signature-exact.
|
|
73
|
+
self.tool_soft_threshold = tool_soft_threshold
|
|
74
|
+
self._fail_counts: Dict[str, int] = {}
|
|
75
|
+
self._tool_fail_counts: Dict[str, int] = {}
|
|
76
|
+
self._tool_fail_sigs: Dict[str, set] = {}
|
|
77
|
+
self._warned: set = set()
|
|
78
|
+
self._tool_warned: set = set()
|
|
79
|
+
self.should_break: bool = False
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _signature(tool_name: str, params: dict) -> str:
|
|
83
|
+
import hashlib
|
|
84
|
+
import json
|
|
85
|
+
try:
|
|
86
|
+
key = json.dumps(params or {}, sort_keys=True, ensure_ascii=False, default=str)
|
|
87
|
+
except Exception:
|
|
88
|
+
key = str(params)
|
|
89
|
+
digest = hashlib.md5(key.encode("utf-8", "ignore")).hexdigest()[:12]
|
|
90
|
+
return f"{tool_name}::{digest}"
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def is_failure(result) -> bool:
|
|
94
|
+
"""Best-effort detection of a failed tool result (dict or summary string)."""
|
|
95
|
+
if isinstance(result, dict):
|
|
96
|
+
if result.get("success") is True:
|
|
97
|
+
return False
|
|
98
|
+
if result.get("success") is False:
|
|
99
|
+
return True
|
|
100
|
+
text = f"{result.get('error', '')} {result.get('output', '')}"
|
|
101
|
+
else:
|
|
102
|
+
text = str(result)
|
|
103
|
+
low = text[:200].lower()
|
|
104
|
+
return any(s in low for s in (
|
|
105
|
+
"error", "unknown local tool", "unknown tool", "not found",
|
|
106
|
+
"no such file", "failed", "traceback", "exception",
|
|
107
|
+
"missing", "未找到", "失败", "错误",
|
|
108
|
+
))
|
|
109
|
+
|
|
110
|
+
def record(self, tool_name: str, params: dict, result) -> "str | None":
|
|
111
|
+
"""Record one tool result. Return a directive string if a loop is detected."""
|
|
112
|
+
sig = self._signature(tool_name, params)
|
|
113
|
+
if not self.is_failure(result):
|
|
114
|
+
self._fail_counts.pop(sig, None)
|
|
115
|
+
self._warned.discard(sig)
|
|
116
|
+
self._tool_fail_counts.pop(tool_name, None)
|
|
117
|
+
self._tool_fail_sigs.pop(tool_name, None)
|
|
118
|
+
self._tool_warned.discard(tool_name)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
self._fail_counts[sig] = self._fail_counts.get(sig, 0) + 1
|
|
122
|
+
self._tool_fail_counts[tool_name] = self._tool_fail_counts.get(tool_name, 0) + 1
|
|
123
|
+
self._tool_fail_sigs.setdefault(tool_name, set()).add(sig)
|
|
124
|
+
count = self._fail_counts[sig]
|
|
125
|
+
|
|
126
|
+
if count >= self.hard_threshold:
|
|
127
|
+
self.should_break = True
|
|
128
|
+
return (
|
|
129
|
+
f"⛔ 已连续 {count} 次用相同参数调用 `{tool_name}` 且全部失败。"
|
|
130
|
+
f"立即停止调用工具,基于现有信息直接回答用户,或说明卡在哪里、需要什么。"
|
|
131
|
+
)
|
|
132
|
+
if count >= self.soft_threshold and sig not in self._warned:
|
|
133
|
+
self._warned.add(sig)
|
|
134
|
+
return (
|
|
135
|
+
f"⚠ 你已经用完全相同的参数调用了 `{tool_name}` {count} 次,每次都失败。"
|
|
136
|
+
f"不要再用相同参数重试。请改变策略:换参数、换工具(如先用 list_files/search_code 定位),"
|
|
137
|
+
f"或基于已有结果继续。"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
tool_count = self._tool_fail_counts.get(tool_name, 0)
|
|
141
|
+
distinct_sigs = len(self._tool_fail_sigs.get(tool_name, ()))
|
|
142
|
+
# Only when the arguments actually varied — identical-params repeats
|
|
143
|
+
# belong exclusively to the signature counters above (which already
|
|
144
|
+
# warned at soft and will hard-break), and this message says
|
|
145
|
+
# "参数各不相同", which must be true when shown.
|
|
146
|
+
if (
|
|
147
|
+
tool_count >= self.tool_soft_threshold
|
|
148
|
+
and distinct_sigs >= 2
|
|
149
|
+
and tool_name not in self._tool_warned
|
|
150
|
+
):
|
|
151
|
+
self._tool_warned.add(tool_name)
|
|
152
|
+
return (
|
|
153
|
+
f"⚠ 工具 `{tool_name}` 本回合已失败 {tool_count} 次(参数各不相同)。"
|
|
154
|
+
f"它当前很可能不可用(网络/依赖问题)。不要继续重试该工具;"
|
|
155
|
+
f"改用其他数据来源,或在回答中说明该工具不可用——"
|
|
156
|
+
f"也不要在“下一步建议”里推荐它而不注明刚刚失败。"
|
|
157
|
+
)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def split_tool_calls(
|
|
162
|
+
pending: Sequence[dict],
|
|
163
|
+
serial_tools: Iterable[str] = DEFAULT_SERIAL_TOOLS,
|
|
164
|
+
) -> Tuple[List[dict], List[dict]]:
|
|
165
|
+
"""Split tool calls into parallel-safe and serial batches.
|
|
166
|
+
|
|
167
|
+
Beyond the static whitelist, detects data dependencies at runtime:
|
|
168
|
+
if a later run_command references a file written/edited earlier in
|
|
169
|
+
the same batch, it is moved to the serial queue so it runs after
|
|
170
|
+
the write completes.
|
|
171
|
+
"""
|
|
172
|
+
serial = set(serial_tools)
|
|
173
|
+
parallel_batch: List[dict] = []
|
|
174
|
+
serial_batch: List[dict] = []
|
|
175
|
+
written_paths: set = set()
|
|
176
|
+
|
|
177
|
+
for tc in pending:
|
|
178
|
+
tool = tc.get("tool", "")
|
|
179
|
+
params = tc.get("params", {})
|
|
180
|
+
|
|
181
|
+
if tool in serial:
|
|
182
|
+
# Track paths being written so dependents can detect the dependency
|
|
183
|
+
for key in ("path", "file_path", "filename", "target"):
|
|
184
|
+
p = params.get(key)
|
|
185
|
+
if p:
|
|
186
|
+
written_paths.add(str(p))
|
|
187
|
+
serial_batch.append(tc)
|
|
188
|
+
elif tool == "run_command":
|
|
189
|
+
cmd = str(params.get("command", ""))
|
|
190
|
+
# If the command references a path currently being written → serial
|
|
191
|
+
if written_paths and any(p in cmd for p in written_paths):
|
|
192
|
+
serial_batch.append(tc)
|
|
193
|
+
else:
|
|
194
|
+
parallel_batch.append(tc)
|
|
195
|
+
else:
|
|
196
|
+
parallel_batch.append(tc)
|
|
197
|
+
|
|
198
|
+
return parallel_batch, serial_batch
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def collect_parallel_done(
|
|
202
|
+
pending: Sequence[dict],
|
|
203
|
+
parallel_results: Sequence[tuple],
|
|
204
|
+
serial_tools: Iterable[str] = DEFAULT_SERIAL_TOOLS,
|
|
205
|
+
) -> Dict[int, dict]:
|
|
206
|
+
"""Map original pending indices to already-executed parallel results."""
|
|
207
|
+
serial = set(serial_tools)
|
|
208
|
+
done: Dict[int, dict] = {}
|
|
209
|
+
for original_index, tool_call in enumerate(pending):
|
|
210
|
+
if tool_call.get("tool") in serial:
|
|
211
|
+
continue
|
|
212
|
+
for result_tool_call, result in parallel_results:
|
|
213
|
+
if result_tool_call is tool_call:
|
|
214
|
+
done[original_index] = result
|
|
215
|
+
break
|
|
216
|
+
return done
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
RemoteToolRunner = Callable[[str, dict], Awaitable[dict]]
|
|
220
|
+
Hook = Callable[[str, str, dict, dict | None], None]
|
|
221
|
+
SummaryFormatter = Callable[[str, dict], str]
|
|
222
|
+
ApprovalCallback = Callable[[str, dict], Union[ApprovalDecision, Awaitable[ApprovalDecision]]]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class AgentTurnState:
|
|
227
|
+
"""Mutable state accumulated across one agent response turn."""
|
|
228
|
+
|
|
229
|
+
provider: str = "aws"
|
|
230
|
+
total_response: str = ""
|
|
231
|
+
tools_used: List[str] = field(default_factory=list)
|
|
232
|
+
sources: List[dict] = field(default_factory=list)
|
|
233
|
+
usage: Dict[str, int] = field(default_factory=lambda: {
|
|
234
|
+
"prompt_tokens": 0,
|
|
235
|
+
"completion_tokens": 0,
|
|
236
|
+
"thinking_tokens": 0,
|
|
237
|
+
})
|
|
238
|
+
tool_time_total: float = 0.0
|
|
239
|
+
# 可选的预算追踪器。默认 None —— 不传就完全保持既有行为。
|
|
240
|
+
_budget: Optional["BudgetTracker"] = None
|
|
241
|
+
|
|
242
|
+
def append_response(self, text: str | None) -> None:
|
|
243
|
+
if text:
|
|
244
|
+
self.total_response += text
|
|
245
|
+
|
|
246
|
+
def apply_model_result(self, result: dict, fallback_response: str = "") -> None:
|
|
247
|
+
self.append_response(result.get("response") or fallback_response)
|
|
248
|
+
self.tools_used.extend(result.get("tools_used", []))
|
|
249
|
+
self.sources.extend(result.get("sources", []))
|
|
250
|
+
self.provider = result.get("provider", self.provider)
|
|
251
|
+
self.add_usage(result.get("usage", {}))
|
|
252
|
+
|
|
253
|
+
def add_usage(self, usage: dict | None) -> None:
|
|
254
|
+
if not usage:
|
|
255
|
+
return
|
|
256
|
+
prompt = int(usage.get("prompt_tokens", 0) or 0)
|
|
257
|
+
completion = int(usage.get("completion_tokens", 0) or 0)
|
|
258
|
+
thinking = int(usage.get("thinking_tokens", 0) or 0)
|
|
259
|
+
self.usage["prompt_tokens"] += prompt
|
|
260
|
+
self.usage["completion_tokens"] += completion
|
|
261
|
+
self.usage["thinking_tokens"] += thinking
|
|
262
|
+
|
|
263
|
+
# 预算记账挂在这里而不是循环体里:所有路径的 usage 都会经过
|
|
264
|
+
# add_usage(),挂在唯一入口上就不会漏记某条分支。thinking token 计入
|
|
265
|
+
# 输出侧——各家对它的计费都按输出档,漏掉会显著低估推理模型的花费。
|
|
266
|
+
if self._budget is not None:
|
|
267
|
+
self._budget.record(self.provider, prompt, completion + thinking)
|
|
268
|
+
|
|
269
|
+
def add_tool_time(self, elapsed: float) -> None:
|
|
270
|
+
self.tool_time_total += elapsed
|
|
271
|
+
|
|
272
|
+
def reset_response(self) -> None:
|
|
273
|
+
self.total_response = ""
|
|
274
|
+
|
|
275
|
+
def final_text(self, fallback_response: str = "") -> str:
|
|
276
|
+
return self.total_response or fallback_response
|
|
277
|
+
|
|
278
|
+
def token_counts(self, *, token_count: int = 0, thinking_tokens: int = 0) -> Tuple[int, int, int, int]:
|
|
279
|
+
prompt_t = self.usage.get("prompt_tokens", 0)
|
|
280
|
+
completion_t = self.usage.get("completion_tokens", 0) or token_count
|
|
281
|
+
think_t = self.usage.get("thinking_tokens", 0) or thinking_tokens
|
|
282
|
+
return prompt_t, completion_t, think_t, prompt_t + completion_t + think_t
|
|
283
|
+
|
|
284
|
+
def generation_time(self, elapsed: float) -> float:
|
|
285
|
+
return elapsed - self.tool_time_total
|
|
286
|
+
|
|
287
|
+
def unique_tools(self) -> List[str]:
|
|
288
|
+
return list(dict.fromkeys(self.tools_used))
|
|
289
|
+
|
|
290
|
+
def build_metadata(
|
|
291
|
+
self,
|
|
292
|
+
*,
|
|
293
|
+
elapsed: float,
|
|
294
|
+
token_count: int = 0,
|
|
295
|
+
thinking_tokens: int = 0,
|
|
296
|
+
) -> "AgentTurnMetadata":
|
|
297
|
+
prompt_t, completion_t, think_t, total_t = self.token_counts(
|
|
298
|
+
token_count=token_count,
|
|
299
|
+
thinking_tokens=thinking_tokens,
|
|
300
|
+
)
|
|
301
|
+
parts = [f"{elapsed:.1f}s"]
|
|
302
|
+
gen_time = self.generation_time(elapsed)
|
|
303
|
+
|
|
304
|
+
if total_t > 0:
|
|
305
|
+
token_parts = []
|
|
306
|
+
if prompt_t > 0:
|
|
307
|
+
token_parts.append(f"in: {prompt_t:,}")
|
|
308
|
+
if completion_t > 0:
|
|
309
|
+
token_parts.append(f"out: {completion_t:,}")
|
|
310
|
+
if think_t > 0:
|
|
311
|
+
token_parts.append(f"think: {think_t:,}")
|
|
312
|
+
parts.append(f"{total_t:,} tokens ({', '.join(token_parts)})")
|
|
313
|
+
if completion_t > 0 and gen_time > 0.5:
|
|
314
|
+
parts.append(f"{completion_t / gen_time:.0f} t/s")
|
|
315
|
+
elif token_count > 0:
|
|
316
|
+
parts.append(f"{token_count:,} tokens")
|
|
317
|
+
if gen_time > 0.5:
|
|
318
|
+
parts.append(f"{token_count / gen_time:.0f} t/s")
|
|
319
|
+
|
|
320
|
+
if self.tool_time_total > 0:
|
|
321
|
+
parts.append(f"tools: {self.tool_time_total:.1f}s")
|
|
322
|
+
if self.provider != "aws":
|
|
323
|
+
parts.append(self.provider)
|
|
324
|
+
unique_tools = self.unique_tools()
|
|
325
|
+
if unique_tools:
|
|
326
|
+
parts.append(" ".join(unique_tools))
|
|
327
|
+
|
|
328
|
+
# Turn-level cost — only for cloud providers with token data
|
|
329
|
+
_is_cloud = self.provider not in ("ollama", "ollama_cache", "local", "")
|
|
330
|
+
if _is_cloud and total_t > 0:
|
|
331
|
+
_cost = (prompt_t * 0.14 + completion_t * 0.28 + think_t * 1.10) / 1_000_000
|
|
332
|
+
if _cost >= 0.0001:
|
|
333
|
+
parts.append(f"${_cost:.4f}")
|
|
334
|
+
|
|
335
|
+
return AgentTurnMetadata(
|
|
336
|
+
parts=parts,
|
|
337
|
+
prompt_tokens=prompt_t,
|
|
338
|
+
completion_tokens=completion_t,
|
|
339
|
+
thinking_tokens=think_t,
|
|
340
|
+
total_tokens=total_t,
|
|
341
|
+
generation_time=gen_time,
|
|
342
|
+
provider=self.provider,
|
|
343
|
+
tools=unique_tools,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def build_result(
|
|
347
|
+
self,
|
|
348
|
+
*,
|
|
349
|
+
elapsed: float,
|
|
350
|
+
fallback_response: str = "",
|
|
351
|
+
token_count: int = 0,
|
|
352
|
+
thinking_tokens: int = 0,
|
|
353
|
+
success: bool = True,
|
|
354
|
+
cancelled: bool = False,
|
|
355
|
+
error: str = "",
|
|
356
|
+
acceptance: Optional[dict] = None,
|
|
357
|
+
) -> "AgentTurnResult":
|
|
358
|
+
metadata = self.build_metadata(
|
|
359
|
+
elapsed=elapsed,
|
|
360
|
+
token_count=token_count,
|
|
361
|
+
thinking_tokens=thinking_tokens,
|
|
362
|
+
)
|
|
363
|
+
return AgentTurnResult(
|
|
364
|
+
success=success,
|
|
365
|
+
cancelled=cancelled,
|
|
366
|
+
error=error,
|
|
367
|
+
final_text=self.final_text(fallback_response),
|
|
368
|
+
metadata=metadata,
|
|
369
|
+
provider=metadata.provider,
|
|
370
|
+
tools=metadata.tools,
|
|
371
|
+
sources=list(self.sources),
|
|
372
|
+
acceptance=acceptance,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def build_cancelled_result(
|
|
376
|
+
self,
|
|
377
|
+
*,
|
|
378
|
+
elapsed: float,
|
|
379
|
+
fallback_response: str = "",
|
|
380
|
+
token_count: int = 0,
|
|
381
|
+
thinking_tokens: int = 0,
|
|
382
|
+
) -> "AgentTurnResult":
|
|
383
|
+
return self.build_result(
|
|
384
|
+
elapsed=elapsed,
|
|
385
|
+
fallback_response=fallback_response,
|
|
386
|
+
token_count=token_count,
|
|
387
|
+
thinking_tokens=thinking_tokens,
|
|
388
|
+
success=True,
|
|
389
|
+
cancelled=True,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def build_error_result(
|
|
393
|
+
self,
|
|
394
|
+
error: str | None,
|
|
395
|
+
*,
|
|
396
|
+
elapsed: float,
|
|
397
|
+
fallback_response: str = "",
|
|
398
|
+
token_count: int = 0,
|
|
399
|
+
thinking_tokens: int = 0,
|
|
400
|
+
) -> "AgentTurnResult":
|
|
401
|
+
return self.build_result(
|
|
402
|
+
elapsed=elapsed,
|
|
403
|
+
fallback_response=fallback_response,
|
|
404
|
+
token_count=token_count,
|
|
405
|
+
thinking_tokens=thinking_tokens,
|
|
406
|
+
success=False,
|
|
407
|
+
cancelled=False,
|
|
408
|
+
error=error or "Unknown error",
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@dataclass(frozen=True)
|
|
413
|
+
class AgentTurnMetadata:
|
|
414
|
+
"""Display and accounting metadata for one completed agent turn."""
|
|
415
|
+
|
|
416
|
+
parts: List[str]
|
|
417
|
+
prompt_tokens: int = 0
|
|
418
|
+
completion_tokens: int = 0
|
|
419
|
+
thinking_tokens: int = 0
|
|
420
|
+
total_tokens: int = 0
|
|
421
|
+
generation_time: float = 0.0
|
|
422
|
+
provider: str = "aws"
|
|
423
|
+
tools: List[str] = field(default_factory=list)
|
|
424
|
+
|
|
425
|
+
def system_prompt_estimate(self, message: str) -> int:
|
|
426
|
+
return max(0, self.prompt_tokens - len(message) // 3)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@dataclass(frozen=True)
|
|
430
|
+
class AgentTurnResult:
|
|
431
|
+
"""Structured result for a completed agent turn."""
|
|
432
|
+
|
|
433
|
+
success: bool
|
|
434
|
+
cancelled: bool
|
|
435
|
+
error: str
|
|
436
|
+
final_text: str
|
|
437
|
+
metadata: AgentTurnMetadata
|
|
438
|
+
provider: str = "aws"
|
|
439
|
+
tools: List[str] = field(default_factory=list)
|
|
440
|
+
sources: List[dict] = field(default_factory=list)
|
|
441
|
+
# 验收证据。None = 本轮没有验收(只读回合,或没有可推断的检查命令);
|
|
442
|
+
# 有值时 ``acceptance["verified"]`` 才是「做完了」这句话的凭据。
|
|
443
|
+
acceptance: Optional[dict] = None
|
|
444
|
+
|
|
445
|
+
@classmethod
|
|
446
|
+
def cancelled_result(
|
|
447
|
+
cls,
|
|
448
|
+
*,
|
|
449
|
+
metadata: AgentTurnMetadata | None = None,
|
|
450
|
+
final_text: str = "",
|
|
451
|
+
) -> "AgentTurnResult":
|
|
452
|
+
return cls(
|
|
453
|
+
success=True,
|
|
454
|
+
cancelled=True,
|
|
455
|
+
error="",
|
|
456
|
+
final_text=final_text,
|
|
457
|
+
metadata=metadata or AgentTurnMetadata(parts=[]),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
@classmethod
|
|
461
|
+
def error_result(
|
|
462
|
+
cls,
|
|
463
|
+
error: str,
|
|
464
|
+
*,
|
|
465
|
+
metadata: AgentTurnMetadata | None = None,
|
|
466
|
+
final_text: str = "",
|
|
467
|
+
) -> "AgentTurnResult":
|
|
468
|
+
return cls(
|
|
469
|
+
success=False,
|
|
470
|
+
cancelled=False,
|
|
471
|
+
error=error,
|
|
472
|
+
final_text=final_text,
|
|
473
|
+
metadata=metadata or AgentTurnMetadata(parts=[]),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def to_dict(self) -> dict:
|
|
477
|
+
return {
|
|
478
|
+
"success": self.success,
|
|
479
|
+
"cancelled": self.cancelled,
|
|
480
|
+
"error": self.error,
|
|
481
|
+
"final_text": self.final_text,
|
|
482
|
+
"provider": self.provider,
|
|
483
|
+
"tools": list(self.tools),
|
|
484
|
+
"sources": list(self.sources),
|
|
485
|
+
"metadata": {
|
|
486
|
+
"parts": list(self.metadata.parts),
|
|
487
|
+
"prompt_tokens": self.metadata.prompt_tokens,
|
|
488
|
+
"completion_tokens": self.metadata.completion_tokens,
|
|
489
|
+
"thinking_tokens": self.metadata.thinking_tokens,
|
|
490
|
+
"total_tokens": self.metadata.total_tokens,
|
|
491
|
+
"generation_time": self.metadata.generation_time,
|
|
492
|
+
"provider": self.metadata.provider,
|
|
493
|
+
"tools": list(self.metadata.tools),
|
|
494
|
+
# 只在真的验收过时才出现,免得每个只读回合都带一个空字段,
|
|
495
|
+
# 让消费者误以为「没验收」和「验收失败」是同一件事。
|
|
496
|
+
**({"acceptance": dict(self.acceptance)} if self.acceptance else {}),
|
|
497
|
+
},
|
|
498
|
+
"acceptance": dict(self.acceptance) if self.acceptance else None,
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
def to_envelope(self) -> "AgentTurnEnvelope":
|
|
502
|
+
return AgentTurnEnvelope.from_result(self)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@dataclass(frozen=True)
|
|
506
|
+
class AgentTurnEnvelope:
|
|
507
|
+
"""Stable runtime envelope for CLI/API consumers."""
|
|
508
|
+
|
|
509
|
+
status: str
|
|
510
|
+
success: bool
|
|
511
|
+
cancelled: bool
|
|
512
|
+
error: str
|
|
513
|
+
final_text: str
|
|
514
|
+
provider: str
|
|
515
|
+
tools: List[str]
|
|
516
|
+
summary: str
|
|
517
|
+
metadata: dict
|
|
518
|
+
|
|
519
|
+
@classmethod
|
|
520
|
+
def from_result(cls, result: AgentTurnResult) -> "AgentTurnEnvelope":
|
|
521
|
+
return cls(
|
|
522
|
+
status="ok" if result.success else "error",
|
|
523
|
+
success=result.success,
|
|
524
|
+
cancelled=result.cancelled,
|
|
525
|
+
error=result.error,
|
|
526
|
+
final_text=result.final_text,
|
|
527
|
+
provider=result.provider,
|
|
528
|
+
tools=list(result.tools),
|
|
529
|
+
summary=" · ".join(result.metadata.parts),
|
|
530
|
+
metadata=result.to_dict()["metadata"],
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
def to_dict(self) -> dict:
|
|
534
|
+
return {
|
|
535
|
+
"status": self.status,
|
|
536
|
+
"success": self.success,
|
|
537
|
+
"cancelled": self.cancelled,
|
|
538
|
+
"error": self.error,
|
|
539
|
+
"final_text": self.final_text,
|
|
540
|
+
"provider": self.provider,
|
|
541
|
+
"tools": list(self.tools),
|
|
542
|
+
"summary": self.summary,
|
|
543
|
+
"metadata": dict(self.metadata),
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@dataclass(frozen=True)
|
|
548
|
+
class AgentErrorPresentation:
|
|
549
|
+
"""User-facing error presentation for model/agent failures."""
|
|
550
|
+
|
|
551
|
+
error: str
|
|
552
|
+
lines: List[str]
|
|
553
|
+
level: str = "error"
|
|
554
|
+
use_generic_error_prefix: bool = False
|
|
555
|
+
|
|
556
|
+
@classmethod
|
|
557
|
+
def from_error(cls, error: str | None, *, lang: str = "zh") -> "AgentErrorPresentation":
|
|
558
|
+
normalized = error or "Unknown error"
|
|
559
|
+
lowered = normalized.lower()
|
|
560
|
+
is_zh = str(lang or "zh").lower().startswith("zh")
|
|
561
|
+
if "aria-4223" in lowered:
|
|
562
|
+
return cls(
|
|
563
|
+
error=normalized,
|
|
564
|
+
level="warning",
|
|
565
|
+
lines=[
|
|
566
|
+
(
|
|
567
|
+
"当前请求需要实时、可验证的金融数据,但本轮没有取得有效工具结果。"
|
|
568
|
+
if is_zh else
|
|
569
|
+
"This request requires current verified financial data, but no usable tool result was obtained."
|
|
570
|
+
),
|
|
571
|
+
(
|
|
572
|
+
"未生成市场结论。请检查数据源或运行 /doctor 后重试。"
|
|
573
|
+
if is_zh else
|
|
574
|
+
"No market conclusion was generated. Check data sources or run /doctor, then retry."
|
|
575
|
+
),
|
|
576
|
+
],
|
|
577
|
+
)
|
|
578
|
+
if lowered.startswith("missing_api_key:"):
|
|
579
|
+
provider = normalized.split(":", 1)[1] or "provider"
|
|
580
|
+
return cls(
|
|
581
|
+
error=normalized,
|
|
582
|
+
level="warning",
|
|
583
|
+
lines=[
|
|
584
|
+
f"{provider} API Key 未配置。" if is_zh else f"{provider} API key is not configured.",
|
|
585
|
+
f"运行: /apikey set {provider} <key>" if is_zh else f"Run: /apikey set {provider} <key>",
|
|
586
|
+
],
|
|
587
|
+
)
|
|
588
|
+
if "http 402" in lowered or "insufficient balance" in lowered:
|
|
589
|
+
return cls(
|
|
590
|
+
error=normalized,
|
|
591
|
+
level="warning",
|
|
592
|
+
lines=[
|
|
593
|
+
"Provider 余额不足,当前模型请求未执行。"
|
|
594
|
+
if is_zh else
|
|
595
|
+
"The provider balance is insufficient; the model request was not run."
|
|
596
|
+
],
|
|
597
|
+
)
|
|
598
|
+
if "http 401" in lowered or "http 403" in lowered:
|
|
599
|
+
return cls(
|
|
600
|
+
error=normalized,
|
|
601
|
+
level="warning",
|
|
602
|
+
lines=[
|
|
603
|
+
"Provider 身份验证失败,请检查 API Key 或登录状态。"
|
|
604
|
+
if is_zh else
|
|
605
|
+
"Provider authentication failed. Check the API key or sign-in state."
|
|
606
|
+
],
|
|
607
|
+
)
|
|
608
|
+
if "http 429" in lowered or "rate limit" in lowered:
|
|
609
|
+
return cls(
|
|
610
|
+
error=normalized,
|
|
611
|
+
level="warning",
|
|
612
|
+
lines=[
|
|
613
|
+
"Provider 请求频率受限,请稍后重试。"
|
|
614
|
+
if is_zh else
|
|
615
|
+
"The provider rate limit was reached. Try again shortly."
|
|
616
|
+
],
|
|
617
|
+
)
|
|
618
|
+
if normalized in ("no_cloud_provider", "no_provider"):
|
|
619
|
+
return cls(
|
|
620
|
+
error=normalized,
|
|
621
|
+
level="warning",
|
|
622
|
+
lines=(
|
|
623
|
+
[
|
|
624
|
+
"没有可用的 AI 模型",
|
|
625
|
+
" 尚未登录,Ollama 也没有运行。",
|
|
626
|
+
" 解决方案(任选其一):",
|
|
627
|
+
" • 登录即用: /login —— 在浏览器里登录或注册,模型由服务端提供,不需要自备 API Key",
|
|
628
|
+
" • 本地离线: ollama serve",
|
|
629
|
+
" • 自备 Key: /apikey set deepseek <your-key>",
|
|
630
|
+
" • 导出环境变量: export DEEPSEEK_API_KEY=sk-...",
|
|
631
|
+
]
|
|
632
|
+
if is_zh else
|
|
633
|
+
[
|
|
634
|
+
"No AI model is available.",
|
|
635
|
+
" You are not signed in, and Ollama is offline.",
|
|
636
|
+
" Choose one:",
|
|
637
|
+
" • Sign in: /login — in the browser; the model is served for you, no API key needed",
|
|
638
|
+
" • Offline: ollama serve",
|
|
639
|
+
" • Your own key: /apikey set deepseek <your-key>",
|
|
640
|
+
]
|
|
641
|
+
),
|
|
642
|
+
)
|
|
643
|
+
if normalized == "all_providers_failed":
|
|
644
|
+
return cls(
|
|
645
|
+
error=normalized,
|
|
646
|
+
level="warning",
|
|
647
|
+
lines=[
|
|
648
|
+
"所有云端 Provider 均请求失败,请检查网络或 API Key 是否有效。"
|
|
649
|
+
if is_zh else
|
|
650
|
+
"All cloud providers failed. Check the network connection and API keys."
|
|
651
|
+
],
|
|
652
|
+
)
|
|
653
|
+
if normalized == "empty_response":
|
|
654
|
+
return cls(
|
|
655
|
+
error=normalized,
|
|
656
|
+
# 597899e 把这里从 error 降为 warning("minimalist" 的空响应 UI)。
|
|
657
|
+
# 原注释主张相反——本轮已终止,视觉上应与普通提示区分——已随代码
|
|
658
|
+
# 更正,免得文件继续自相矛盾。
|
|
659
|
+
level="warning",
|
|
660
|
+
lines=(
|
|
661
|
+
[
|
|
662
|
+
"模型连续返回空响应(已自动重试 1 次),本轮停止。",
|
|
663
|
+
"可换个说法重试;若持续出现,请使用 /health 检查当前模型与 Provider。",
|
|
664
|
+
]
|
|
665
|
+
if is_zh else
|
|
666
|
+
[
|
|
667
|
+
"The model returned no visible response twice (auto-retried once), so this turn was stopped.",
|
|
668
|
+
"Try rephrasing. If it persists, run /health to check the model and provider.",
|
|
669
|
+
]
|
|
670
|
+
),
|
|
671
|
+
)
|
|
672
|
+
return cls(
|
|
673
|
+
error=normalized,
|
|
674
|
+
# Also lowered by 597899e. Note this is the catch-all branch: an
|
|
675
|
+
# unexpected failure now renders with the same weight as a rate-limit
|
|
676
|
+
# notice. Deliberate per that commit, but worth revisiting — the
|
|
677
|
+
# dataclass default below it is still "error".
|
|
678
|
+
level="warning",
|
|
679
|
+
lines=[f"Error: {normalized}"],
|
|
680
|
+
use_generic_error_prefix=True,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@dataclass
|
|
685
|
+
class ToolBatchState:
|
|
686
|
+
"""Mutable state for one model-requested batch of tool calls."""
|
|
687
|
+
|
|
688
|
+
tool_results: List[dict] = field(default_factory=list)
|
|
689
|
+
elapsed_total: float = 0.0
|
|
690
|
+
cancelled: bool = False
|
|
691
|
+
|
|
692
|
+
def add_result(
|
|
693
|
+
self,
|
|
694
|
+
tool_name: str,
|
|
695
|
+
result: dict,
|
|
696
|
+
formatter: SummaryFormatter,
|
|
697
|
+
*,
|
|
698
|
+
elapsed: float = 0.0,
|
|
699
|
+
) -> dict:
|
|
700
|
+
self.elapsed_total += elapsed
|
|
701
|
+
return record_tool_result(self.tool_results, tool_name, result, formatter)
|
|
702
|
+
|
|
703
|
+
def cancel(self) -> None:
|
|
704
|
+
self.cancelled = True
|
|
705
|
+
|
|
706
|
+
def build_next_turn(self, total_response: str) -> Tuple[dict, dict, str]:
|
|
707
|
+
return build_next_turn_messages(total_response, self.tool_results)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@dataclass(frozen=True)
|
|
711
|
+
class ToolCallTask:
|
|
712
|
+
"""One ordered tool call in a model-requested turn."""
|
|
713
|
+
|
|
714
|
+
index: int
|
|
715
|
+
tool_call: dict
|
|
716
|
+
parallel_result: dict | None = None
|
|
717
|
+
|
|
718
|
+
@property
|
|
719
|
+
def tool_name(self) -> str:
|
|
720
|
+
return self.tool_call.get("tool", "")
|
|
721
|
+
|
|
722
|
+
@property
|
|
723
|
+
def params(self) -> dict:
|
|
724
|
+
return self.tool_call.get("params", {})
|
|
725
|
+
|
|
726
|
+
@property
|
|
727
|
+
def has_parallel_result(self) -> bool:
|
|
728
|
+
return self.parallel_result is not None
|
|
729
|
+
|
|
730
|
+
def progress_label(self, total: int) -> str:
|
|
731
|
+
if total > 1:
|
|
732
|
+
return f" [{self.index + 1}/{total}] Running {self.tool_name}..."
|
|
733
|
+
return f" Running {self.tool_name}..."
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
@dataclass
|
|
737
|
+
class ToolTurnPlan:
|
|
738
|
+
"""Runtime plan for executing one pending tool-call turn."""
|
|
739
|
+
|
|
740
|
+
pending: Sequence[dict]
|
|
741
|
+
parallel_done: Dict[int, dict] = field(default_factory=dict)
|
|
742
|
+
batch: ToolBatchState = field(default_factory=ToolBatchState)
|
|
743
|
+
|
|
744
|
+
def tasks(self) -> List[ToolCallTask]:
|
|
745
|
+
return [
|
|
746
|
+
ToolCallTask(
|
|
747
|
+
index=index,
|
|
748
|
+
tool_call=tool_call,
|
|
749
|
+
parallel_result=self.parallel_done.get(index),
|
|
750
|
+
)
|
|
751
|
+
for index, tool_call in enumerate(self.pending)
|
|
752
|
+
]
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
@dataclass(frozen=True)
|
|
756
|
+
class ToolExecutionActivity:
|
|
757
|
+
"""One executed tool activity in a model-requested batch."""
|
|
758
|
+
|
|
759
|
+
tool: str
|
|
760
|
+
result: dict
|
|
761
|
+
elapsed: float
|
|
762
|
+
params: dict
|
|
763
|
+
from_parallel: bool = False
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@dataclass(frozen=True)
|
|
767
|
+
class ToolExecutionTurnResult:
|
|
768
|
+
"""Structured result from one pending tool-call turn."""
|
|
769
|
+
|
|
770
|
+
batch: ToolBatchState
|
|
771
|
+
activities: List[ToolExecutionActivity]
|
|
772
|
+
assistant_message: dict
|
|
773
|
+
tool_messages: List[dict]
|
|
774
|
+
user_message: dict
|
|
775
|
+
followup: str
|
|
776
|
+
guard_directives: List[str] = field(default_factory=list)
|
|
777
|
+
|
|
778
|
+
@property
|
|
779
|
+
def cancelled(self) -> bool:
|
|
780
|
+
return self.batch.cancelled
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
async def run_parallel_tools(
|
|
784
|
+
pending: Sequence[dict],
|
|
785
|
+
tool_executor: ToolExecutor,
|
|
786
|
+
*,
|
|
787
|
+
remote_runner: RemoteToolRunner | None = None,
|
|
788
|
+
hook: Hook | None = None,
|
|
789
|
+
serial_tools: Iterable[str] = DEFAULT_SERIAL_TOOLS,
|
|
790
|
+
) -> Dict[int, dict]:
|
|
791
|
+
"""Execute parallel-safe pending tools and return results by original index."""
|
|
792
|
+
parallel_batch, _ = split_tool_calls(pending, serial_tools)
|
|
793
|
+
|
|
794
|
+
async def _exec_one(tool_call: dict) -> tuple:
|
|
795
|
+
tool_name = tool_call.get("tool", "")
|
|
796
|
+
tool_params = tool_call.get("params", {})
|
|
797
|
+
if tool_name in tool_executor.local_tools:
|
|
798
|
+
result = await tool_executor.execute(tool_name, tool_params)
|
|
799
|
+
elif remote_runner is not None:
|
|
800
|
+
if hook is not None:
|
|
801
|
+
hook("pre_tool", tool_name, tool_params, None)
|
|
802
|
+
try:
|
|
803
|
+
result = await remote_runner(tool_name, tool_params)
|
|
804
|
+
except Exception as exc:
|
|
805
|
+
result = {"success": False, "error": str(exc)}
|
|
806
|
+
if hook is not None:
|
|
807
|
+
hook("post_tool", tool_name, tool_params, result)
|
|
808
|
+
else:
|
|
809
|
+
result = {"success": False, "error": f"Unknown tool: {tool_name}"}
|
|
810
|
+
return tool_call, result
|
|
811
|
+
|
|
812
|
+
parallel_results: List[tuple] = []
|
|
813
|
+
if parallel_batch:
|
|
814
|
+
gathered = await asyncio.gather(
|
|
815
|
+
*[_exec_one(tool_call) for tool_call in parallel_batch],
|
|
816
|
+
return_exceptions=True,
|
|
817
|
+
)
|
|
818
|
+
for item in gathered:
|
|
819
|
+
if isinstance(item, Exception):
|
|
820
|
+
parallel_results.append((None, {"success": False, "error": str(item)}))
|
|
821
|
+
else:
|
|
822
|
+
parallel_results.append(item)
|
|
823
|
+
return collect_parallel_done(pending, parallel_results, serial_tools)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
async def run_serial_tool(
|
|
827
|
+
tool_name: str,
|
|
828
|
+
tool_params: dict,
|
|
829
|
+
tool_executor: ToolExecutor,
|
|
830
|
+
*,
|
|
831
|
+
remote_runner: RemoteToolRunner | None = None,
|
|
832
|
+
hook: Hook | None = None,
|
|
833
|
+
approval: ApprovalDecision | None = None,
|
|
834
|
+
) -> Tuple[dict, float]:
|
|
835
|
+
"""Execute one tool call and return (result, elapsed_seconds)."""
|
|
836
|
+
started = time.time()
|
|
837
|
+
if tool_name in tool_executor.local_tools:
|
|
838
|
+
result = tool_executor.execute_local(tool_name, tool_params, approval=approval)
|
|
839
|
+
elif remote_runner is not None:
|
|
840
|
+
if hook is not None:
|
|
841
|
+
hook("pre_tool", tool_name, tool_params, None)
|
|
842
|
+
try:
|
|
843
|
+
result = await remote_runner(tool_name, tool_params)
|
|
844
|
+
except Exception as exc:
|
|
845
|
+
result = {"success": False, "error": str(exc)}
|
|
846
|
+
if hook is not None:
|
|
847
|
+
hook("post_tool", tool_name, tool_params, result)
|
|
848
|
+
else:
|
|
849
|
+
result = {"success": False, "error": f"Unknown tool: {tool_name}"}
|
|
850
|
+
return result, time.time() - started
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
async def _maybe_await(value):
|
|
854
|
+
if inspect.isawaitable(value):
|
|
855
|
+
return await value
|
|
856
|
+
return value
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
async def execute_tool_turn(
|
|
860
|
+
pending: Sequence[dict],
|
|
861
|
+
*,
|
|
862
|
+
total_response: str,
|
|
863
|
+
tool_executor: ToolExecutor,
|
|
864
|
+
formatter: SummaryFormatter,
|
|
865
|
+
remote_runner: RemoteToolRunner | None = None,
|
|
866
|
+
hook: Hook | None = None,
|
|
867
|
+
cancel_event: asyncio.Event | None = None,
|
|
868
|
+
confirm_tools: Iterable[str] = (),
|
|
869
|
+
approval_callback: ApprovalCallback | None = None,
|
|
870
|
+
approval_applier: Callable[[dict, ApprovalDecision], dict] = apply_approval_decision,
|
|
871
|
+
loop_guard: LoopGuard | None = None,
|
|
872
|
+
serial_tools: Iterable[str] = DEFAULT_SERIAL_TOOLS,
|
|
873
|
+
) -> ToolExecutionTurnResult:
|
|
874
|
+
"""Execute one model-requested tool batch and prepare the next turn.
|
|
875
|
+
|
|
876
|
+
This is the reusable runtime boundary for the agent tool loop. Callers
|
|
877
|
+
provide UI-specific approval and rendering callbacks; this function owns
|
|
878
|
+
batching, permission application, execution, loop-guard retry directives,
|
|
879
|
+
and construction of the assistant/user follow-up messages.
|
|
880
|
+
"""
|
|
881
|
+
|
|
882
|
+
confirm = set(confirm_tools)
|
|
883
|
+
# Keep the model-facing call transcript free of runtime-only approval
|
|
884
|
+
# fields that may be injected into the executable parameter dictionary.
|
|
885
|
+
pending_transcript = [
|
|
886
|
+
{
|
|
887
|
+
"tool": str(tool_call.get("tool", "")),
|
|
888
|
+
"params": dict(tool_call.get("params", {}) or {}),
|
|
889
|
+
}
|
|
890
|
+
for tool_call in pending
|
|
891
|
+
]
|
|
892
|
+
parallel_done = await run_parallel_tools(
|
|
893
|
+
pending,
|
|
894
|
+
tool_executor,
|
|
895
|
+
remote_runner=remote_runner,
|
|
896
|
+
hook=hook,
|
|
897
|
+
serial_tools=serial_tools,
|
|
898
|
+
)
|
|
899
|
+
tool_turn = ToolTurnPlan(pending=pending, parallel_done=parallel_done)
|
|
900
|
+
tool_batch = tool_turn.batch
|
|
901
|
+
activities: List[ToolExecutionActivity] = []
|
|
902
|
+
|
|
903
|
+
for task in tool_turn.tasks():
|
|
904
|
+
if cancel_event and cancel_event.is_set():
|
|
905
|
+
tool_batch.cancel()
|
|
906
|
+
break
|
|
907
|
+
|
|
908
|
+
tool_name = task.tool_name
|
|
909
|
+
tool_params = task.params
|
|
910
|
+
|
|
911
|
+
if task.has_parallel_result:
|
|
912
|
+
tr = task.parallel_result or {}
|
|
913
|
+
tool_batch.add_result(tool_name, tr, formatter)
|
|
914
|
+
activities.append(ToolExecutionActivity(
|
|
915
|
+
tool=tool_name,
|
|
916
|
+
result=tr,
|
|
917
|
+
elapsed=0.0,
|
|
918
|
+
params=tool_params,
|
|
919
|
+
from_parallel=True,
|
|
920
|
+
))
|
|
921
|
+
continue
|
|
922
|
+
|
|
923
|
+
approval = None
|
|
924
|
+
if tool_name in confirm:
|
|
925
|
+
if approval_callback is None:
|
|
926
|
+
tool_batch.cancel()
|
|
927
|
+
break
|
|
928
|
+
decision = await _maybe_await(approval_callback(tool_name, tool_params))
|
|
929
|
+
if decision is None:
|
|
930
|
+
decision = ApprovalDecision.deny("approval unavailable")
|
|
931
|
+
if not decision.approved:
|
|
932
|
+
tool_batch.cancel()
|
|
933
|
+
break
|
|
934
|
+
approval_applier(tool_params, decision)
|
|
935
|
+
approval = decision
|
|
936
|
+
|
|
937
|
+
tr, tool_elapsed = await run_serial_tool(
|
|
938
|
+
tool_name,
|
|
939
|
+
tool_params,
|
|
940
|
+
tool_executor,
|
|
941
|
+
remote_runner=remote_runner,
|
|
942
|
+
hook=hook,
|
|
943
|
+
approval=approval,
|
|
944
|
+
)
|
|
945
|
+
tool_batch.add_result(tool_name, tr, formatter, elapsed=tool_elapsed)
|
|
946
|
+
activities.append(ToolExecutionActivity(
|
|
947
|
+
tool=tool_name,
|
|
948
|
+
result=tr,
|
|
949
|
+
elapsed=tool_elapsed,
|
|
950
|
+
params=tool_params,
|
|
951
|
+
))
|
|
952
|
+
|
|
953
|
+
guard_directives: List[str] = []
|
|
954
|
+
if loop_guard is not None:
|
|
955
|
+
for activity in activities:
|
|
956
|
+
directive = loop_guard.record(activity.tool, activity.params, activity.result)
|
|
957
|
+
if directive:
|
|
958
|
+
guard_directives.append(directive)
|
|
959
|
+
|
|
960
|
+
assistant_message, user_message, followup = tool_batch.build_next_turn(total_response)
|
|
961
|
+
assistant_message["tool_calls"] = [
|
|
962
|
+
{
|
|
963
|
+
"function": {
|
|
964
|
+
"name": str(tool_call.get("tool", "")),
|
|
965
|
+
"arguments": dict(tool_call.get("params", {}) or {}),
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
for tool_call in pending_transcript
|
|
969
|
+
if tool_call.get("tool")
|
|
970
|
+
]
|
|
971
|
+
tool_messages = [
|
|
972
|
+
{
|
|
973
|
+
"role": "tool",
|
|
974
|
+
"content": str(item.get("result", "")),
|
|
975
|
+
"name": str(item.get("tool", "")),
|
|
976
|
+
}
|
|
977
|
+
for item in tool_batch.tool_results
|
|
978
|
+
]
|
|
979
|
+
if guard_directives:
|
|
980
|
+
guard_text = "\n\n".join(guard_directives)
|
|
981
|
+
if isinstance(user_message.get("content"), str):
|
|
982
|
+
user_message["content"] += f"\n\n{guard_text}"
|
|
983
|
+
elif isinstance(user_message.get("content"), list):
|
|
984
|
+
user_message["content"].append({"type": "text", "text": guard_text})
|
|
985
|
+
followup += f"\n\n{guard_text}"
|
|
986
|
+
|
|
987
|
+
return ToolExecutionTurnResult(
|
|
988
|
+
batch=tool_batch,
|
|
989
|
+
activities=activities,
|
|
990
|
+
assistant_message=assistant_message,
|
|
991
|
+
tool_messages=tool_messages,
|
|
992
|
+
user_message=user_message,
|
|
993
|
+
followup=followup,
|
|
994
|
+
guard_directives=guard_directives,
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
# Cap each tool result so a single large output (a long pip-install log, a big
|
|
999
|
+
# data dump, a verbose traceback) cannot blow past the model's context window.
|
|
1000
|
+
# Without this, one oversized result is appended verbatim, the next provider
|
|
1001
|
+
# call exceeds num_ctx, the prompt is truncated, and the model loses the task
|
|
1002
|
+
# mid-run. Head+tail keeps the actionable parts (what ran / the final error)
|
|
1003
|
+
# and drops the noisy middle.
|
|
1004
|
+
_MAX_TOOL_RESULT_CHARS = 6000
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _truncate_tool_result(text: str, limit: int = _MAX_TOOL_RESULT_CHARS) -> str:
|
|
1008
|
+
if len(text) <= limit:
|
|
1009
|
+
return text
|
|
1010
|
+
head = limit * 2 // 3
|
|
1011
|
+
tail = limit - head
|
|
1012
|
+
omitted = len(text) - head - tail
|
|
1013
|
+
return (
|
|
1014
|
+
text[:head]
|
|
1015
|
+
+ f"\n\n… [已截断 {omitted:,} 字符 — 输出过长,仅保留首尾以保护上下文] …\n\n"
|
|
1016
|
+
+ text[-tail:]
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def build_tool_followup(tool_results: Sequence[dict]) -> str:
|
|
1021
|
+
"""Build a structured follow-up message from tool results.
|
|
1022
|
+
|
|
1023
|
+
Each result block is labelled with its tool name and a success/error
|
|
1024
|
+
status so the model can clearly distinguish outcomes and respond
|
|
1025
|
+
appropriately to failures rather than silently ignoring them. Each result
|
|
1026
|
+
is size-capped (see ``_truncate_tool_result``) so a single huge output
|
|
1027
|
+
cannot overflow the context window and cut the task short.
|
|
1028
|
+
"""
|
|
1029
|
+
if not tool_results:
|
|
1030
|
+
return "No tool results. Continue with what you know or ask the user for clarification."
|
|
1031
|
+
|
|
1032
|
+
blocks: List[str] = []
|
|
1033
|
+
error_tools: List[str] = []
|
|
1034
|
+
|
|
1035
|
+
for item in tool_results:
|
|
1036
|
+
tool = item.get("tool", "unknown")
|
|
1037
|
+
result = item.get("result", "")
|
|
1038
|
+
result_str = _truncate_tool_result(str(result))
|
|
1039
|
+
|
|
1040
|
+
is_error = (
|
|
1041
|
+
result_str.startswith("Error") or
|
|
1042
|
+
result_str.startswith("❌") or
|
|
1043
|
+
"error" in result_str[:80].lower() or
|
|
1044
|
+
"traceback" in result_str[:200].lower() or
|
|
1045
|
+
"exception" in result_str[:200].lower()
|
|
1046
|
+
)
|
|
1047
|
+
status = "❌ Error" if is_error else "✓ Success"
|
|
1048
|
+
if is_error:
|
|
1049
|
+
error_tools.append(tool)
|
|
1050
|
+
blocks.append(f"### [{tool}] {status}\n{result_str}")
|
|
1051
|
+
|
|
1052
|
+
followup = "## Tool Results\n\n" + "\n\n---\n\n".join(blocks)
|
|
1053
|
+
|
|
1054
|
+
if error_tools:
|
|
1055
|
+
healing_directives = []
|
|
1056
|
+
import re as _re_heal
|
|
1057
|
+
for item in tool_results:
|
|
1058
|
+
if item.get("tool") == "run_command":
|
|
1059
|
+
res_text = str(item.get("result", ""))
|
|
1060
|
+
if "Traceback" in res_text or "Error:" in res_text:
|
|
1061
|
+
m_line = _re_heal.search(r'File ["\']([^"\']+)["\'], line (\d+)', res_text)
|
|
1062
|
+
if m_line:
|
|
1063
|
+
fpath, lineno = m_line.group(1), m_line.group(2)
|
|
1064
|
+
healing_directives.append(
|
|
1065
|
+
f"📌 [Self-Healing Directive] Error located at `{fpath}` line {lineno}. "
|
|
1066
|
+
f"Use `edit_file` to patch this specific line/block rather than rewriting the entire file. "
|
|
1067
|
+
f"Then re-run `run_command` to verify the fix."
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
followup += (
|
|
1071
|
+
f"\n\n⚠ Tool(s) returned errors: {', '.join(error_tools)}. "
|
|
1072
|
+
"Read the error carefully. Options: (1) use read_file / search_code to diagnose, "
|
|
1073
|
+
"(2) use edit_file to fix the issue and retry run_command, "
|
|
1074
|
+
"(3) try a different approach. "
|
|
1075
|
+
"Do NOT give up silently — explain what failed and what you tried."
|
|
1076
|
+
)
|
|
1077
|
+
if healing_directives:
|
|
1078
|
+
followup += "\n\n" + "\n".join(healing_directives)
|
|
1079
|
+
else:
|
|
1080
|
+
followup += (
|
|
1081
|
+
"\n\nAll tools completed successfully. "
|
|
1082
|
+
"If the task is now complete, provide your final response. "
|
|
1083
|
+
"If additional steps are needed, continue using tools.\n\n"
|
|
1084
|
+
"Please continue your analysis using these results."
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
return followup
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def record_tool_result(
|
|
1091
|
+
tool_results: List[dict],
|
|
1092
|
+
tool_name: str,
|
|
1093
|
+
result: dict,
|
|
1094
|
+
formatter: SummaryFormatter,
|
|
1095
|
+
) -> dict:
|
|
1096
|
+
"""Append one tool result summary and return the appended record."""
|
|
1097
|
+
summary = formatter(tool_name, result)
|
|
1098
|
+
record = {"tool": tool_name, "result": summary}
|
|
1099
|
+
tool_results.append(record)
|
|
1100
|
+
return record
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def build_next_turn_messages(total_response: str, tool_results: Sequence[dict]) -> Tuple[dict, dict, str]:
|
|
1104
|
+
"""Build assistant/user messages and follow-up text for the next agent turn.
|
|
1105
|
+
|
|
1106
|
+
When a screenshot tool stored an image in computer_use_tools._PENDING_VISION_IMAGE,
|
|
1107
|
+
the user message content becomes a multipart list so vision models can see the image.
|
|
1108
|
+
"""
|
|
1109
|
+
followup = build_tool_followup(tool_results)
|
|
1110
|
+
assistant_message = {"role": "assistant", "content": total_response}
|
|
1111
|
+
|
|
1112
|
+
# Check for a pending screenshot from computer_screenshot / browser_screenshot
|
|
1113
|
+
vision_b64: "str | None" = None
|
|
1114
|
+
try:
|
|
1115
|
+
from aria_code.computer_use_tools import pop_pending_vision_image
|
|
1116
|
+
vision_b64 = pop_pending_vision_image()
|
|
1117
|
+
except ImportError:
|
|
1118
|
+
pass
|
|
1119
|
+
|
|
1120
|
+
if vision_b64:
|
|
1121
|
+
user_content: "str | list" = [
|
|
1122
|
+
{
|
|
1123
|
+
"type": "image_url",
|
|
1124
|
+
"image_url": {"url": f"data:image/png;base64,{vision_b64}"},
|
|
1125
|
+
},
|
|
1126
|
+
{"type": "text", "text": followup},
|
|
1127
|
+
]
|
|
1128
|
+
else:
|
|
1129
|
+
user_content = followup
|
|
1130
|
+
|
|
1131
|
+
user_message = {"role": "user", "content": user_content}
|
|
1132
|
+
return assistant_message, user_message, followup
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
# ── AgentEvent typed union ────────────────────────────────────────────────────
|
|
1136
|
+
|
|
1137
|
+
@dataclass(frozen=True)
|
|
1138
|
+
class AgentEventToken:
|
|
1139
|
+
"""A text token streamed from the model."""
|
|
1140
|
+
text: str
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
@dataclass(frozen=True)
|
|
1144
|
+
class AgentEventThinking:
|
|
1145
|
+
"""A thinking/reasoning token from extended-thinking models."""
|
|
1146
|
+
content: str
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
@dataclass(frozen=True)
|
|
1150
|
+
class AgentEventToolCall:
|
|
1151
|
+
"""Model requested a tool call (before execution)."""
|
|
1152
|
+
tool: str
|
|
1153
|
+
params: dict
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
@dataclass(frozen=True)
|
|
1157
|
+
class AgentEventToolResult:
|
|
1158
|
+
"""One tool has finished executing."""
|
|
1159
|
+
tool: str
|
|
1160
|
+
result: dict
|
|
1161
|
+
elapsed: float
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
@dataclass(frozen=True)
|
|
1165
|
+
class AgentEventStatus:
|
|
1166
|
+
"""Informational status change (e.g. provider fallback)."""
|
|
1167
|
+
state: str
|
|
1168
|
+
message: str
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
@dataclass(frozen=True)
|
|
1172
|
+
class AgentEventComplete:
|
|
1173
|
+
"""Agent loop finished normally. Carries the full turn result."""
|
|
1174
|
+
result: "AgentTurnResult"
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
@dataclass(frozen=True)
|
|
1178
|
+
class AgentEventCancelled:
|
|
1179
|
+
"""Agent loop was cancelled by the user."""
|
|
1180
|
+
partial_text: str
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
@dataclass(frozen=True)
|
|
1184
|
+
class AgentEventError:
|
|
1185
|
+
"""Agent loop encountered an unrecoverable error."""
|
|
1186
|
+
error: str
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
AgentEvent = Union[
|
|
1190
|
+
AgentEventToken,
|
|
1191
|
+
AgentEventThinking,
|
|
1192
|
+
AgentEventToolCall,
|
|
1193
|
+
AgentEventToolResult,
|
|
1194
|
+
AgentEventStatus,
|
|
1195
|
+
AgentEventComplete,
|
|
1196
|
+
AgentEventCancelled,
|
|
1197
|
+
AgentEventError,
|
|
1198
|
+
]
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
# ── AgentOptions ──────────────────────────────────────────────────────────────
|
|
1202
|
+
|
|
1203
|
+
@dataclass
|
|
1204
|
+
class AgentOptions:
|
|
1205
|
+
"""Tunable parameters for one run_agent() invocation."""
|
|
1206
|
+
|
|
1207
|
+
max_rounds: int = 30
|
|
1208
|
+
serial_tools: FrozenSet[str] = field(
|
|
1209
|
+
default_factory=lambda: frozenset(DEFAULT_SERIAL_TOOLS)
|
|
1210
|
+
)
|
|
1211
|
+
tool_schemas: List[dict] = field(default_factory=list)
|
|
1212
|
+
# Interactive tool approval (UI adapters inject these; None = execute
|
|
1213
|
+
# without prompting, matching the historical embedded-loop default only
|
|
1214
|
+
# for callers that never wire an approval UI).
|
|
1215
|
+
confirm_tools: FrozenSet[str] = field(default_factory=frozenset)
|
|
1216
|
+
approval_callback: Optional[ApprovalCallback] = None
|
|
1217
|
+
approval_applier: Optional[Callable[[dict, "ApprovalDecision"], dict]] = None
|
|
1218
|
+
requires_evidence: bool = False
|
|
1219
|
+
grounding_tools: FrozenSet[str] = field(default_factory=frozenset)
|
|
1220
|
+
evidence_already_grounded: bool = False
|
|
1221
|
+
# 花费上限。None = 不追踪(保持既有行为,调用方不传就跟以前一模一样)。
|
|
1222
|
+
# 传入一个 BudgetTracker 后,循环会在**每轮开始前**检查,超限则停止并把
|
|
1223
|
+
# 原因写进 result["budget_paused"],而不是抛异常——抛异常会让调用方拿不到
|
|
1224
|
+
# 已经产出的中间结果。
|
|
1225
|
+
budget: Optional["BudgetTracker"] = None
|
|
1226
|
+
# 验收闸门。None = 不验收(保持既有行为)。传入一个 AcceptanceGate 后,
|
|
1227
|
+
# 只要本轮真的改写了磁盘上的文件,模型宣称完成时循环会先跑一遍推断出的
|
|
1228
|
+
# 检查命令;红了就把失败输出回灌给模型继续修,绿了才让这一轮结束。
|
|
1229
|
+
acceptance: Optional["AcceptanceGate"] = None
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
# ── run_agent() ───────────────────────────────────────────────────────────────
|
|
1233
|
+
|
|
1234
|
+
async def run_agent(
|
|
1235
|
+
prompt: str,
|
|
1236
|
+
history: list,
|
|
1237
|
+
*,
|
|
1238
|
+
provider_fn: Callable,
|
|
1239
|
+
tool_executor: ToolExecutor,
|
|
1240
|
+
options: Optional["AgentOptions"] = None,
|
|
1241
|
+
remote_runner: Optional[RemoteToolRunner] = None,
|
|
1242
|
+
on_token: Optional[Callable[[str], None]] = None,
|
|
1243
|
+
on_thinking: Optional[Callable[[str], None]] = None,
|
|
1244
|
+
on_tool_call: Optional[Callable[[str, dict], None]] = None,
|
|
1245
|
+
on_tool_result: Optional[Callable[[str, dict], None]] = None,
|
|
1246
|
+
on_status: Optional[Callable[[str, str], None]] = None,
|
|
1247
|
+
hook: Optional[Hook] = None,
|
|
1248
|
+
cancel_event: Optional[asyncio.Event] = None,
|
|
1249
|
+
tool_result_formatter: Optional[SummaryFormatter] = None,
|
|
1250
|
+
) -> AsyncGenerator["AgentEvent", None]:
|
|
1251
|
+
"""Provider-agnostic multi-round agent loop.
|
|
1252
|
+
|
|
1253
|
+
Yields ``AgentEvent`` objects so every caller (REPL, bot, API) can
|
|
1254
|
+
handle UI in its own way without duplicating round-management logic.
|
|
1255
|
+
|
|
1256
|
+
Parameters
|
|
1257
|
+
----------
|
|
1258
|
+
prompt:
|
|
1259
|
+
The user's message for this turn.
|
|
1260
|
+
history:
|
|
1261
|
+
Conversation history **before** the current prompt.
|
|
1262
|
+
provider_fn:
|
|
1263
|
+
Async callable ``(message, history, on_token, on_thinking,
|
|
1264
|
+
on_tool_call, cancel_event) -> dict``. Must return the same
|
|
1265
|
+
result dict that ``stream_ollama`` / ``stream_chat`` return.
|
|
1266
|
+
tool_executor:
|
|
1267
|
+
Local tool registry.
|
|
1268
|
+
options:
|
|
1269
|
+
Tunable loop parameters (max_rounds, serial_tools, …).
|
|
1270
|
+
remote_runner:
|
|
1271
|
+
Optional async callable for tools not in ``tool_executor``.
|
|
1272
|
+
on_token / on_thinking / on_tool_call / on_tool_result / on_status:
|
|
1273
|
+
Pass-through callbacks forwarded to ``provider_fn`` so callers
|
|
1274
|
+
that already set up streaming callbacks don't need to change.
|
|
1275
|
+
hook:
|
|
1276
|
+
Pre/post-tool hook fired around each tool execution.
|
|
1277
|
+
cancel_event:
|
|
1278
|
+
asyncio.Event; when set the loop exits at the next safe point.
|
|
1279
|
+
tool_result_formatter:
|
|
1280
|
+
Formats a tool result dict into a summary string. Defaults to
|
|
1281
|
+
``str(result.get('output', result))``.
|
|
1282
|
+
"""
|
|
1283
|
+
opts = options or AgentOptions()
|
|
1284
|
+
_formatter: SummaryFormatter = tool_result_formatter or (
|
|
1285
|
+
lambda _tool, res: str(res.get("output", res))
|
|
1286
|
+
)
|
|
1287
|
+
_serial = set(opts.serial_tools)
|
|
1288
|
+
|
|
1289
|
+
turn_state = AgentTurnState(provider="unknown", _budget=opts.budget)
|
|
1290
|
+
start_time = time.time()
|
|
1291
|
+
current_message = prompt
|
|
1292
|
+
if opts.requires_evidence:
|
|
1293
|
+
available_grounding_tools = ", ".join(sorted(opts.grounding_tools)[:20]) or "(none)"
|
|
1294
|
+
current_message = (
|
|
1295
|
+
"[Grounding requirement]\n"
|
|
1296
|
+
"This request requires current, auditable financial evidence. Call at least "
|
|
1297
|
+
"one applicable grounding tool before producing a market conclusion. If the "
|
|
1298
|
+
"tool fails or data is unavailable, report that limitation and do not infer "
|
|
1299
|
+
"prices, metrics, recommendations, or forecasts.\n"
|
|
1300
|
+
f"Grounding tools: {available_grounding_tools}\n\n"
|
|
1301
|
+
f"[User request]\n{prompt}"
|
|
1302
|
+
)
|
|
1303
|
+
token_count = 0
|
|
1304
|
+
thinking_tokens = 0
|
|
1305
|
+
result: dict = {}
|
|
1306
|
+
loop_guard = LoopGuard()
|
|
1307
|
+
grounded_results = 1 if opts.evidence_already_grounded else 0
|
|
1308
|
+
|
|
1309
|
+
for round_num in range(opts.max_rounds):
|
|
1310
|
+
# ── 预算闸门 ─────────────────────────────────────────────────────────
|
|
1311
|
+
# 放在发起调用**之前**:花完才发现超支,每次都会多花一轮,而这一轮
|
|
1312
|
+
# 恰恰可能是塞了满上下文的那一轮。
|
|
1313
|
+
if opts.budget is not None and not opts.budget.should_continue(
|
|
1314
|
+
projected_usd=opts.budget.projected_next_round_usd()
|
|
1315
|
+
):
|
|
1316
|
+
# 本模块不持有 logger(全靠 yield 事件对外汇报),所以暂停原因
|
|
1317
|
+
# 只写进 result,由调用方决定怎么呈现——CLI 会打印,后台任务会
|
|
1318
|
+
# 记进 task ledger。
|
|
1319
|
+
reason = opts.budget.paused_reason or "budget exhausted"
|
|
1320
|
+
result["budget_paused"] = reason
|
|
1321
|
+
result["budget_paused_round"] = round_num + 1
|
|
1322
|
+
# 走既有的 hook 通道对外汇报,不新造一条:CLI 已经把 hook 接到
|
|
1323
|
+
# RuntimeTrace,事件会自动进入 /trace 和跨会话的 run_store,
|
|
1324
|
+
# 花费停在哪一轮、当时用量多少事后可查。
|
|
1325
|
+
if hook is not None:
|
|
1326
|
+
hook("budget_paused", "budget", {
|
|
1327
|
+
"round": round_num + 1,
|
|
1328
|
+
"reason": reason,
|
|
1329
|
+
"spent_usd": round(opts.budget.state.spent_usd, 6),
|
|
1330
|
+
"total_tokens": opts.budget.state.total_tokens,
|
|
1331
|
+
"per_provider": dict(opts.budget.state.per_provider),
|
|
1332
|
+
}, None)
|
|
1333
|
+
result["budget_summary"] = opts.budget.summary()
|
|
1334
|
+
break
|
|
1335
|
+
|
|
1336
|
+
if opts.budget is not None:
|
|
1337
|
+
opts.budget.record_round()
|
|
1338
|
+
|
|
1339
|
+
# ── Provider call ────────────────────────────────────────────────────
|
|
1340
|
+
response_text = ""
|
|
1341
|
+
_round_tokens = 0
|
|
1342
|
+
|
|
1343
|
+
def _wrap_on_token(tok: str) -> None:
|
|
1344
|
+
nonlocal response_text, token_count, _round_tokens
|
|
1345
|
+
response_text += tok
|
|
1346
|
+
_round_tokens += 1
|
|
1347
|
+
token_count += 1
|
|
1348
|
+
if on_token is not None and (not opts.requires_evidence or grounded_results > 0):
|
|
1349
|
+
on_token(tok)
|
|
1350
|
+
|
|
1351
|
+
def _wrap_on_thinking(content: str) -> None:
|
|
1352
|
+
nonlocal thinking_tokens
|
|
1353
|
+
thinking_tokens += 1
|
|
1354
|
+
if on_thinking is not None:
|
|
1355
|
+
on_thinking(content)
|
|
1356
|
+
|
|
1357
|
+
def _wrap_on_tool_call(tool: str, params: dict) -> None:
|
|
1358
|
+
if on_tool_call is not None:
|
|
1359
|
+
on_tool_call(tool, params)
|
|
1360
|
+
|
|
1361
|
+
try:
|
|
1362
|
+
result = await provider_fn(
|
|
1363
|
+
current_message,
|
|
1364
|
+
history,
|
|
1365
|
+
on_token=_wrap_on_token,
|
|
1366
|
+
on_thinking=_wrap_on_thinking,
|
|
1367
|
+
on_tool_call=_wrap_on_tool_call,
|
|
1368
|
+
on_tool_result=on_tool_result,
|
|
1369
|
+
on_status=on_status,
|
|
1370
|
+
cancel_event=cancel_event,
|
|
1371
|
+
)
|
|
1372
|
+
except Exception as exc:
|
|
1373
|
+
yield AgentEventError(error=str(exc))
|
|
1374
|
+
return
|
|
1375
|
+
|
|
1376
|
+
if result.get("cancelled"):
|
|
1377
|
+
turn_state.append_response(response_text)
|
|
1378
|
+
yield AgentEventCancelled(partial_text=turn_state.total_response)
|
|
1379
|
+
return
|
|
1380
|
+
|
|
1381
|
+
if not result.get("success"):
|
|
1382
|
+
yield AgentEventError(error=result.get("error", "Unknown error"))
|
|
1383
|
+
return
|
|
1384
|
+
|
|
1385
|
+
turn_state.apply_model_result(result, response_text)
|
|
1386
|
+
|
|
1387
|
+
pending = result.get("tool_calls_pending", [])
|
|
1388
|
+
if not pending:
|
|
1389
|
+
# ── 验收闸门 ─────────────────────────────────────────────────────
|
|
1390
|
+
# 模型不再要工具 = 它认为做完了。这是唯一一个「宣称完成」的出口,
|
|
1391
|
+
# 所以检查必须挂在这里:挂在写文件之后太早(改到一半必然是红的),
|
|
1392
|
+
# 挂在循环结束之后太晚(那时已经没有轮次可以拿来修了)。
|
|
1393
|
+
if opts.acceptance is not None and opts.acceptance.should_run():
|
|
1394
|
+
report = await opts.acceptance.run()
|
|
1395
|
+
if report is not None:
|
|
1396
|
+
yield AgentEventStatus(
|
|
1397
|
+
state="acceptance_passed" if report.passed else "acceptance_failed",
|
|
1398
|
+
message=report.headline(),
|
|
1399
|
+
)
|
|
1400
|
+
if hook is not None:
|
|
1401
|
+
hook("acceptance", "verify", report.summary(), None)
|
|
1402
|
+
if report.ran and not report.passed:
|
|
1403
|
+
# 把失败输出当成下一轮的用户消息回灌。走和工具结果
|
|
1404
|
+
# 完全相同的通道,模型不需要认识一种新的消息类型。
|
|
1405
|
+
history = list(history) + [
|
|
1406
|
+
{"role": "user", "content": current_message},
|
|
1407
|
+
{"role": "assistant", "content": turn_state.total_response},
|
|
1408
|
+
]
|
|
1409
|
+
current_message = report.repair_directive()
|
|
1410
|
+
turn_state.reset_response()
|
|
1411
|
+
continue
|
|
1412
|
+
if opts.requires_evidence and grounded_results == 0:
|
|
1413
|
+
yield AgentEventStatus(
|
|
1414
|
+
state="evidence_required",
|
|
1415
|
+
message="No usable financial evidence tool result was obtained",
|
|
1416
|
+
)
|
|
1417
|
+
yield AgentEventError(
|
|
1418
|
+
error=(
|
|
1419
|
+
"[ARIA-4223] Current verified financial data is required, "
|
|
1420
|
+
"but no usable evidence tool result was obtained."
|
|
1421
|
+
)
|
|
1422
|
+
)
|
|
1423
|
+
return
|
|
1424
|
+
break
|
|
1425
|
+
for tool_call in pending:
|
|
1426
|
+
yield AgentEventToolCall(
|
|
1427
|
+
tool=tool_call.get("tool", ""),
|
|
1428
|
+
params=tool_call.get("params", {}),
|
|
1429
|
+
)
|
|
1430
|
+
|
|
1431
|
+
# Warn caller on final round
|
|
1432
|
+
if round_num == opts.max_rounds - 1:
|
|
1433
|
+
yield AgentEventStatus(
|
|
1434
|
+
state="max_rounds",
|
|
1435
|
+
message=f"Max rounds ({opts.max_rounds}) reached",
|
|
1436
|
+
)
|
|
1437
|
+
break
|
|
1438
|
+
|
|
1439
|
+
# ── Tool execution ───────────────────────────────────────────────────
|
|
1440
|
+
tool_turn_result = await execute_tool_turn(
|
|
1441
|
+
pending,
|
|
1442
|
+
total_response=turn_state.total_response,
|
|
1443
|
+
tool_executor=tool_executor,
|
|
1444
|
+
formatter=_formatter,
|
|
1445
|
+
remote_runner=remote_runner,
|
|
1446
|
+
hook=hook,
|
|
1447
|
+
cancel_event=cancel_event,
|
|
1448
|
+
loop_guard=loop_guard,
|
|
1449
|
+
serial_tools=_serial,
|
|
1450
|
+
confirm_tools=opts.confirm_tools,
|
|
1451
|
+
approval_callback=opts.approval_callback,
|
|
1452
|
+
approval_applier=opts.approval_applier or apply_approval_decision,
|
|
1453
|
+
)
|
|
1454
|
+
|
|
1455
|
+
for activity in tool_turn_result.activities:
|
|
1456
|
+
turn_state.tools_used.append(activity.tool)
|
|
1457
|
+
if opts.acceptance is not None:
|
|
1458
|
+
opts.acceptance.record_tool(activity.tool, activity.result)
|
|
1459
|
+
canonical_tool = str(activity.tool).rsplit("__", 1)[-1]
|
|
1460
|
+
allowed_tools = {
|
|
1461
|
+
str(name).rsplit("__", 1)[-1]
|
|
1462
|
+
for name in opts.grounding_tools
|
|
1463
|
+
}
|
|
1464
|
+
result_payload = dict(activity.result or {})
|
|
1465
|
+
usable_payload = {
|
|
1466
|
+
key: value
|
|
1467
|
+
for key, value in result_payload.items()
|
|
1468
|
+
if key not in {"success", "cached", "elapsed_ms"}
|
|
1469
|
+
}
|
|
1470
|
+
if (
|
|
1471
|
+
canonical_tool in allowed_tools
|
|
1472
|
+
and result_payload.get("success") is not False
|
|
1473
|
+
and not result_payload.get("error")
|
|
1474
|
+
and any(value not in (None, "", [], {}) for value in usable_payload.values())
|
|
1475
|
+
):
|
|
1476
|
+
grounded_results += 1
|
|
1477
|
+
yield AgentEventToolResult(
|
|
1478
|
+
tool=activity.tool,
|
|
1479
|
+
result=activity.result,
|
|
1480
|
+
elapsed=activity.elapsed,
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
turn_state.add_tool_time(tool_turn_result.batch.elapsed_total)
|
|
1484
|
+
if tool_turn_result.cancelled:
|
|
1485
|
+
yield AgentEventCancelled(partial_text=turn_state.total_response)
|
|
1486
|
+
return
|
|
1487
|
+
if tool_turn_result.guard_directives:
|
|
1488
|
+
yield AgentEventStatus(
|
|
1489
|
+
state="loop_guard",
|
|
1490
|
+
message="Repeated failing tool call detected",
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
history = list(history) + [
|
|
1494
|
+
{"role": "user", "content": current_message},
|
|
1495
|
+
tool_turn_result.assistant_message,
|
|
1496
|
+
*tool_turn_result.tool_messages,
|
|
1497
|
+
]
|
|
1498
|
+
current_message = tool_turn_result.followup
|
|
1499
|
+
turn_state.reset_response()
|
|
1500
|
+
|
|
1501
|
+
if loop_guard.should_break:
|
|
1502
|
+
turn_state.append_response(
|
|
1503
|
+
"\n\nRepeated failing tool calls were detected and the agent stopped retrying."
|
|
1504
|
+
)
|
|
1505
|
+
break
|
|
1506
|
+
|
|
1507
|
+
# ── Build final result ───────────────────────────────────────────────────
|
|
1508
|
+
elapsed = time.time() - start_time
|
|
1509
|
+
turn_result = turn_state.build_result(
|
|
1510
|
+
elapsed=elapsed,
|
|
1511
|
+
fallback_response=result.get("response", ""),
|
|
1512
|
+
token_count=token_count,
|
|
1513
|
+
thinking_tokens=thinking_tokens,
|
|
1514
|
+
acceptance=(
|
|
1515
|
+
opts.acceptance.summary()
|
|
1516
|
+
if opts.acceptance is not None and opts.acceptance.reports
|
|
1517
|
+
else None
|
|
1518
|
+
),
|
|
1519
|
+
)
|
|
1520
|
+
yield AgentEventComplete(result=turn_result)
|