aria-code 0.167.0__py3-none-win_amd64.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_bots/__init__.py +7 -0
- aria_bots/channels/README.md +97 -0
- aria_bots/channels/__init__.py +1 -0
- aria_bots/channels/approvals.py +263 -0
- aria_bots/channels/conversation.py +280 -0
- aria_bots/channels/digest.py +186 -0
- aria_bots/channels/registry.py +95 -0
- aria_bots/engine.py +144 -0
- aria_bots/feishu_bot.py +1385 -0
- aria_bots/logistics/__init__.py +1 -0
- aria_bots/logistics/carriers.py +253 -0
- aria_bots/logistics/freight.py +154 -0
- aria_bots/logistics/inventory.py +242 -0
- aria_bots/logistics/mcp_server.py +156 -0
- aria_bots/logistics/records.py +119 -0
- aria_bots/logistics/tenancy.py +152 -0
- aria_bots/market.py +91 -0
- aria_bots/relay_client.py +326 -0
- aria_bots/telegram_bot.py +299 -0
- aria_code/__init__.py +111 -0
- aria_code/_bin/aria-native.exe +0 -0
- aria_code/_bin/aria-runtime.exe +0 -0
- aria_code/_version.py +20 -0
- aria_code/account_memory.py +296 -0
- aria_code/account_session.py +166 -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 +49 -0
- aria_code/agents/base.py +329 -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 +315 -0
- aria_code/agents/financial/fundamental.py +178 -0
- aria_code/agents/financial/macro.py +111 -0
- aria_code/agents/financial/news.py +231 -0
- aria_code/agents/financial/risk.py +146 -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 +277 -0
- aria_code/agents/orchestrator.py +44 -0
- aria_code/agents/portfolio_agent.py +392 -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 +168 -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 +500 -0
- aria_code/agents/video/__init__.py +16 -0
- aria_code/agents/video/director.py +376 -0
- aria_code/agents/video/qa_agent.py +143 -0
- aria_code/agents/video/script_doctor.py +112 -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/navigation.schema.json +110 -0
- aria_code/agents/warehouse/task_agent.py +172 -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 +9 -0
- aria_code/apps/channels/__init__.py +17 -0
- aria_code/apps/channels/approvals.py +11 -0
- aria_code/apps/channels/conversation.py +11 -0
- aria_code/apps/channels/digest.py +11 -0
- aria_code/apps/channels/intake.py +92 -0
- aria_code/apps/channels/registry.py +11 -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/app_server.py +656 -0
- aria_code/apps/cli/auto_update.py +125 -0
- aria_code/apps/cli/bootstrap.py +206 -0
- aria_code/apps/cli/broker_render.py +206 -0
- aria_code/apps/cli/chat_turn.py +1327 -0
- aria_code/apps/cli/cloud_models.py +231 -0
- aria_code/apps/cli/codegen_paths.py +29 -0
- aria_code/apps/cli/commands/__init__.py +34 -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 +378 -0
- aria_code/apps/cli/commands/ashare_prediction_cmds.py +327 -0
- aria_code/apps/cli/commands/auth_cmds.py +215 -0
- aria_code/apps/cli/commands/backtest_cmds.py +2518 -0
- aria_code/apps/cli/commands/broker_cmds.py +1236 -0
- aria_code/apps/cli/commands/business_workflow_cmds.py +379 -0
- aria_code/apps/cli/commands/canvas_cmds.py +77 -0
- aria_code/apps/cli/commands/catalog.py +189 -0
- aria_code/apps/cli/commands/core_cmds.py +2748 -0
- aria_code/apps/cli/commands/data_cmds.py +521 -0
- aria_code/apps/cli/commands/diagnostic_cmds.py +285 -0
- aria_code/apps/cli/commands/diagnostic_ops_cmds.py +813 -0
- aria_code/apps/cli/commands/engineering_cmds.py +399 -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 +544 -0
- aria_code/apps/cli/commands/market_cmds.py +1419 -0
- aria_code/apps/cli/commands/market_context.py +432 -0
- aria_code/apps/cli/commands/market_render.py +7 -0
- aria_code/apps/cli/commands/model_cmds.py +2374 -0
- aria_code/apps/cli/commands/ops_cmds.py +721 -0
- aria_code/apps/cli/commands/orchestrator_cmds.py +181 -0
- aria_code/apps/cli/commands/pdf_export_cmds.py +125 -0
- aria_code/apps/cli/commands/portfolio_cmds.py +1316 -0
- aria_code/apps/cli/commands/report.py +476 -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 +723 -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 +297 -0
- aria_code/apps/cli/commands/workflow_cmds.py +629 -0
- aria_code/apps/cli/commands/workspace_cmds.py +1543 -0
- aria_code/apps/cli/config_paths.py +73 -0
- aria_code/apps/cli/config_store.py +158 -0
- aria_code/apps/cli/constants.py +88 -0
- aria_code/apps/cli/context.py +34 -0
- aria_code/apps/cli/conversation_policy.py +53 -0
- aria_code/apps/cli/deterministic.py +200 -0
- aria_code/apps/cli/direct.py +48 -0
- aria_code/apps/cli/earnings_view.py +172 -0
- aria_code/apps/cli/endpoint_migration.py +64 -0
- aria_code/apps/cli/exec_events.py +255 -0
- aria_code/apps/cli/file_references.py +155 -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 +239 -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 +1314 -0
- aria_code/apps/cli/handlers/market_handlers.py +2570 -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/headless.py +352 -0
- aria_code/apps/cli/helpers.py +326 -0
- aria_code/apps/cli/hooks.py +180 -0
- aria_code/apps/cli/i18n.py +310 -0
- aria_code/apps/cli/intent.py +136 -0
- aria_code/apps/cli/intent_router.py +305 -0
- aria_code/apps/cli/intent_shadow.py +72 -0
- aria_code/apps/cli/intent_signals.py +347 -0
- aria_code/apps/cli/lifecycle_hooks.py +48 -0
- aria_code/apps/cli/local_tool_schemas.py +550 -0
- aria_code/apps/cli/main.py +39 -0
- aria_code/apps/cli/market_hours.py +100 -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/mixin_binding.py +32 -0
- aria_code/apps/cli/model_catalog.py +652 -0
- aria_code/apps/cli/model_probe.py +169 -0
- aria_code/apps/cli/model_retirement.py +65 -0
- aria_code/apps/cli/native_branding.py +12 -0
- aria_code/apps/cli/native_bridge.py +118 -0
- aria_code/apps/cli/native_entry.py +58 -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 +78 -0
- aria_code/apps/cli/prompts/__init__.py +0 -0
- aria_code/apps/cli/prompts/coding.py +71 -0
- aria_code/apps/cli/prompts/select.py +193 -0
- aria_code/apps/cli/prompts/system_prompts.py +562 -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 +775 -0
- aria_code/apps/cli/providers/chat_routing.py +139 -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 +1574 -0
- aria_code/apps/cli/providers/llm/response_cache.py +60 -0
- aria_code/apps/cli/providers/llm/sse_stream.py +298 -0
- aria_code/apps/cli/providers/runtime_bridge.py +586 -0
- aria_code/apps/cli/providers/vertexai_stream.py +592 -0
- aria_code/apps/cli/release_download.py +159 -0
- aria_code/apps/cli/review_runner.py +111 -0
- aria_code/apps/cli/runtime_consumer.py +769 -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_refresh.py +123 -0
- aria_code/apps/cli/session_store.py +125 -0
- aria_code/apps/cli/skills_catalog.py +273 -0
- aria_code/apps/cli/task_isolation.py +211 -0
- aria_code/apps/cli/terminal_chrome.py +412 -0
- aria_code/apps/cli/todo_tracker.py +190 -0
- aria_code/apps/cli/tool_executor.py +932 -0
- aria_code/apps/cli/tool_registry.py +34 -0
- aria_code/apps/cli/tool_scope.py +113 -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 +181 -0
- aria_code/apps/cli/tools/market_tools.py +626 -0
- aria_code/apps/cli/tools/notebook_tools.py +111 -0
- aria_code/apps/cli/tools/system_tools.py +656 -0
- aria_code/apps/cli/tools/write_tools.py +961 -0
- aria_code/apps/cli/tradingview_bridge.py +529 -0
- aria_code/apps/cli/transactions.py +269 -0
- aria_code/apps/cli/turn_planning.py +137 -0
- aria_code/apps/cli/update_check.py +257 -0
- aria_code/apps/cli/updater.py +373 -0
- aria_code/apps/cli/utils/__init__.py +0 -0
- aria_code/apps/cli/utils/market_detect.py +1663 -0
- aria_code/apps/cli/web_login.py +136 -0
- aria_code/apps/cli/workflow_runner.py +127 -0
- aria_code/apps/cli/workspace_route.py +121 -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 +5244 -0
- aria_code/aria_daemon.py +1372 -0
- aria_code/aria_feishu_bot.py +16 -0
- aria_code/aria_mcp_server.py +61 -0
- aria_code/aria_relay_client.py +4 -0
- aria_code/aria_telegram_bot.py +11 -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 +16 -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 +1985 -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 +190 -0
- aria_code/command_safety.py +19 -0
- aria_code/computer_use_server.py +276 -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/sec_statements.py +217 -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/doctor.py +860 -0
- aria_code/domain/__init__.py +0 -0
- aria_code/domain/_finance_core/__init__.py +5 -0
- aria_code/domain/_finance_core/backtest_engine.py +325 -0
- aria_code/domain/_finance_core/performance_stats.py +101 -0
- aria_code/domain/backtest_engine.py +75 -0
- aria_code/domain/backtest_report.py +488 -0
- aria_code/domain/finance_formulas.py +389 -0
- aria_code/domain/performance_stats.py +6 -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/behavior.py +113 -0
- aria_code/evals/gap.py +93 -0
- aria_code/evals/harness.py +949 -0
- aria_code/evals/review_probe.py +132 -0
- aria_code/evals/runner.py +425 -0
- aria_code/evals/scorecard.py +322 -0
- aria_code/evals/summary.py +125 -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 +459 -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 +744 -0
- aria_code/memory_manager.py +273 -0
- aria_code/model_capability.py +512 -0
- aria_code/notification_tools.py +4 -0
- aria_code/openai_image_client.py +4 -0
- aria_code/packages/__init__.py +26 -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 +58 -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 +232 -0
- aria_code/packages/aria_mcp/circuit.py +82 -0
- aria_code/packages/aria_mcp/config.py +162 -0
- aria_code/packages/aria_mcp/installer.py +95 -0
- aria_code/packages/aria_mcp/server.py +1705 -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 +393 -0
- aria_code/packages/aria_sdk/providers.py +70 -0
- aria_code/packages/aria_sdk/streaming.py +75 -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 +304 -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 +137 -0
- aria_code/packages/aria_services/research_quality.py +186 -0
- aria_code/packages/aria_services/settings.py +160 -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 +5 -0
- aria_code/packages/aria_tools/financial/_paths.py +92 -0
- aria_code/packages/aria_tools/registry.py +129 -0
- aria_code/packages/quant_engine/__init__.py +33 -0
- aria_code/packages/quant_engine/_namespace.py +24 -0
- aria_code/packages/quant_engine/backtest/__init__.py +10 -0
- aria_code/packages/quant_engine/backtest/core.py +53 -0
- aria_code/packages/quant_engine/backtest/engine.py +102 -0
- aria_code/packages/quant_engine/portfolio/__init__.py +9 -0
- aria_code/packages/quant_engine/portfolio/optimizer.py +85 -0
- aria_code/packages/quant_engine/sports/__init__.py +76 -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/predictor.py +312 -0
- aria_code/packages/quant_engine/sports/tracker.py +665 -0
- aria_code/packages/quant_engine/stochastic/__init__.py +31 -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/healthcare/__init__.py +40 -0
- aria_code/packs/logistics/__init__.py +171 -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/google_video_provider.py +256 -0
- aria_code/providers/llm/__init__.py +19 -0
- aria_code/providers/llm/anthropic.py +184 -0
- aria_code/providers/llm/arthera_gateway.py +90 -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 +137 -0
- aria_code/providers/llm/openai_compat.py +350 -0
- aria_code/providers/llm/registry.py +457 -0
- aria_code/providers/local_image_provider.py +202 -0
- aria_code/providers/local_llm_provider.py +549 -0
- aria_code/providers/ollama_models.py +79 -0
- aria_code/providers/openai_params.py +32 -0
- aria_code/providers/tool_messages.py +100 -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/review_service.py +497 -0
- aria_code/runtime/__init__.py +176 -0
- aria_code/runtime/acceptance.py +632 -0
- aria_code/runtime/agent_loop.py +1851 -0
- aria_code/runtime/agent_profiles.py +75 -0
- aria_code/runtime/approval.py +71 -0
- aria_code/runtime/baseline.py +219 -0
- aria_code/runtime/budget.py +232 -0
- aria_code/runtime/checkpoints.py +481 -0
- aria_code/runtime/consult.py +65 -0
- aria_code/runtime/contract.py +282 -0
- aria_code/runtime/delivery.py +432 -0
- aria_code/runtime/events.py +113 -0
- aria_code/runtime/gateway.py +167 -0
- aria_code/runtime/lsp.py +484 -0
- aria_code/runtime/native_index.py +153 -0
- aria_code/runtime/orchestrator.py +55 -0
- aria_code/runtime/processes.py +343 -0
- aria_code/runtime/project_graph.py +614 -0
- aria_code/runtime/repo_map.py +712 -0
- aria_code/runtime/research_bundle.py +105 -0
- aria_code/runtime/review.py +269 -0
- aria_code/runtime/run_state.py +127 -0
- aria_code/runtime/run_store.py +522 -0
- aria_code/runtime/scoped_runs.py +113 -0
- aria_code/runtime/self_healing.py +422 -0
- aria_code/runtime/semantic_diff.py +171 -0
- aria_code/runtime/subagent.py +721 -0
- aria_code/runtime/symbol_edit.py +117 -0
- aria_code/runtime/task_brief.py +46 -0
- aria_code/runtime/task_context.py +145 -0
- aria_code/runtime/task_ledger.py +108 -0
- aria_code/runtime/task_orchestrator.py +90 -0
- aria_code/runtime/task_outcome.py +33 -0
- aria_code/runtime/task_verification.py +266 -0
- aria_code/runtime/task_worktree.py +448 -0
- aria_code/runtime/tool_executor.py +329 -0
- aria_code/runtime/tool_policy.py +107 -0
- aria_code/runtime/transactions.py +216 -0
- aria_code/runtime/windows_job.py +115 -0
- aria_code/runtime/workflows.py +222 -0
- aria_code/runtime/worktrees.py +215 -0
- aria_code/runway_video_client.py +4 -0
- aria_code/safety/__init__.py +27 -0
- aria_code/safety/client_isolation.py +178 -0
- aria_code/safety/permissions.py +339 -0
- aria_code/safety/risk.py +699 -0
- aria_code/safety/sandbox.py +148 -0
- aria_code/safety/service.py +102 -0
- aria_code/service_endpoints.py +14 -0
- aria_code/setup_wizard.py +684 -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 +509 -0
- aria_code/tools/data_analysis_tools.py +831 -0
- aria_code/tools/enterprise_finance_tools.py +202 -0
- aria_code/tools/extended_tools.py +240 -0
- aria_code/tools/file_analysis_tools.py +880 -0
- aria_code/tools/image_gen_tools.py +114 -0
- aria_code/tools/local_finance_tools.py +3488 -0
- aria_code/tools/logistics_carriers.py +11 -0
- aria_code/tools/logistics_inventory.py +11 -0
- aria_code/tools/logistics_io.py +11 -0
- aria_code/tools/logistics_tenancy.py +11 -0
- aria_code/tools/logistics_tools.py +11 -0
- aria_code/tools/macro_tools.py +412 -0
- aria_code/tools/notification_tools.py +249 -0
- aria_code/tools/project_tools.py +525 -0
- aria_code/tools/realty_data_tools.py +662 -0
- aria_code/tools/spreadsheet_tools.py +399 -0
- aria_code/tools/stripe_tools.py +115 -0
- aria_code/tools/video_tools.py +500 -0
- aria_code/training/__init__.py +1 -0
- aria_code/training/compare.py +117 -0
- aria_code/training/prelude_commands.py +111 -0
- aria_code/training/prelude_eval.py +390 -0
- aria_code/training/prelude_real.py +183 -0
- aria_code/training/prelude_train.py +248 -0
- aria_code/training/sft_dataset.py +230 -0
- aria_code/training/trajectory_dataset.py +263 -0
- aria_code/ui/__init__.py +105 -0
- aria_code/ui/banner.py +404 -0
- aria_code/ui/completer.py +571 -0
- aria_code/ui/console.py +285 -0
- aria_code/ui/input_box.py +505 -0
- aria_code/ui/mentions.py +206 -0
- aria_code/ui/picker.py +248 -0
- aria_code/ui/render/__init__.py +11 -0
- aria_code/ui/render/actions.py +297 -0
- aria_code/ui/render/finance.py +1732 -0
- aria_code/ui/render/market.py +253 -0
- aria_code/ui/render/output.py +954 -0
- aria_code/ui/render/responsive.py +75 -0
- aria_code/ui/render/team.py +367 -0
- aria_code/ui/robot.py +239 -0
- aria_code/ui/startup_dashboard.py +110 -0
- aria_code/video_analysis.py +147 -0
- aria_code/video_editor.py +518 -0
- aria_code/video_workflow/__init__.py +8 -0
- aria_code/video_workflow/__main__.py +61 -0
- aria_code/video_workflow/engines/__init__.py +18 -0
- aria_code/video_workflow/engines/base.py +69 -0
- aria_code/video_workflow/engines/custom_model.py +204 -0
- aria_code/video_workflow/engines/google_veo.py +94 -0
- aria_code/video_workflow/engines/local_engine.py +71 -0
- aria_code/video_workflow/engines/registry.py +98 -0
- aria_code/video_workflow/google_models.py +278 -0
- aria_code/video_workflow/music/__init__.py +5 -0
- aria_code/video_workflow/music/foley_service.py +132 -0
- aria_code/video_workflow/music/service.py +212 -0
- aria_code/video_workflow/pipeline.py +620 -0
- aria_code/video_workflow/plan.py +202 -0
- aria_code/video_workflow/session.py +297 -0
- aria_code/workspace/__init__.py +6 -0
- aria_code/workspace/files.py +219 -0
- aria_code/workspace/verify.py +167 -0
- aria_code/workspace_changes.py +199 -0
- aria_code/workspace_service.py +196 -0
- aria_code/workspace_tools.py +150 -0
- aria_code-0.167.0.dist-info/METADATA +330 -0
- aria_code-0.167.0.dist-info/RECORD +601 -0
- aria_code-0.167.0.dist-info/WHEEL +5 -0
- aria_code-0.167.0.dist-info/entry_points.txt +5 -0
- aria_code-0.167.0.dist-info/licenses/LICENSE +201 -0
- aria_code-0.167.0.dist-info/licenses/NOTICE +49 -0
- aria_code-0.167.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,1851 @@
|
|
|
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 re
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import TYPE_CHECKING, AsyncGenerator, Awaitable, Callable, Dict, FrozenSet, Iterable, List, Optional, Sequence, Tuple, Union
|
|
16
|
+
|
|
17
|
+
from .acceptance import AcceptanceGate
|
|
18
|
+
from .approval import ApprovalDecision, apply_approval_decision
|
|
19
|
+
from .tool_executor import ToolExecutor
|
|
20
|
+
from .budget import BudgetTracker
|
|
21
|
+
from .delivery import DeliveryLedger
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .contract import ChangeContract
|
|
25
|
+
from .review import ReviewGate
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DEFAULT_SERIAL_TOOLS = {"write_file", "edit_file", "multi_edit", "run_command", "process"}
|
|
29
|
+
|
|
30
|
+
# Phrases the model uses to signal task completion
|
|
31
|
+
_DONE_PHRASES = frozenset([
|
|
32
|
+
"task complete", "task is complete", "all done", "completed successfully",
|
|
33
|
+
"here is the final", "here's the final", "analysis complete",
|
|
34
|
+
"任务完成", "已完成", "分析完成", "操作完成", "已经完成", "以下是最终",
|
|
35
|
+
"i have completed", "i've completed", "the task has been completed",
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_task_complete(response_text: str) -> bool:
|
|
40
|
+
"""Heuristic: did the AI signal task completion without requesting more tools?"""
|
|
41
|
+
if not response_text:
|
|
42
|
+
return False
|
|
43
|
+
lower = response_text.lower()
|
|
44
|
+
return any(phrase in lower for phrase in _DONE_PHRASES)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LoopGuard:
|
|
48
|
+
"""Detect repeated identical *failing* tool calls and break the agent loop.
|
|
49
|
+
|
|
50
|
+
Weaker local models (and sometimes cloud models) get stuck calling the
|
|
51
|
+
exact same tool with the exact same arguments after it has already failed
|
|
52
|
+
— e.g. reading a file that does not exist, or calling an unknown tool. This
|
|
53
|
+
guard tracks failure signatures across rounds:
|
|
54
|
+
|
|
55
|
+
* After ``soft_threshold`` identical failures it injects a directive
|
|
56
|
+
telling the model to STOP repeating that call and try another approach.
|
|
57
|
+
* After ``hard_threshold`` it signals the caller to break the loop so we
|
|
58
|
+
don't burn the remaining tool rounds.
|
|
59
|
+
|
|
60
|
+
A successful call clears that signature's counter.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
soft_threshold: int = 2,
|
|
67
|
+
hard_threshold: int = 4,
|
|
68
|
+
tool_soft_threshold: int = 3,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.soft_threshold = soft_threshold
|
|
71
|
+
self.hard_threshold = hard_threshold
|
|
72
|
+
# Tool-level counting (ANY params): catches the observed blind spot
|
|
73
|
+
# where a tool kept failing with slightly different arguments — the
|
|
74
|
+
# exact-signature counters never accumulated, so peer_comparison
|
|
75
|
+
# failed 4x in one turn without a single guard directive. Advisory
|
|
76
|
+
# only: exploration patterns (read_file over several candidate paths)
|
|
77
|
+
# legitimately fail with varying params, so the hard break stays
|
|
78
|
+
# signature-exact.
|
|
79
|
+
self.tool_soft_threshold = tool_soft_threshold
|
|
80
|
+
self._fail_counts: Dict[str, int] = {}
|
|
81
|
+
self._tool_fail_counts: Dict[str, int] = {}
|
|
82
|
+
self._tool_fail_sigs: Dict[str, set] = {}
|
|
83
|
+
self._warned: set = set()
|
|
84
|
+
self._tool_warned: set = set()
|
|
85
|
+
self.should_break: bool = False
|
|
86
|
+
self._last_successful_write: str | None = None
|
|
87
|
+
self._successful_write_repeats = 0
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _signature(tool_name: str, params: dict) -> str:
|
|
91
|
+
import hashlib
|
|
92
|
+
import json
|
|
93
|
+
try:
|
|
94
|
+
key = json.dumps(params or {}, sort_keys=True, ensure_ascii=False, default=str)
|
|
95
|
+
except Exception:
|
|
96
|
+
key = str(params)
|
|
97
|
+
digest = hashlib.md5(key.encode("utf-8", "ignore")).hexdigest()[:12]
|
|
98
|
+
return f"{tool_name}::{digest}"
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def is_failure(result) -> bool:
|
|
102
|
+
"""Best-effort detection of a failed tool result (dict or summary string)."""
|
|
103
|
+
if isinstance(result, dict):
|
|
104
|
+
if result.get("success") is True:
|
|
105
|
+
return False
|
|
106
|
+
if result.get("success") is False:
|
|
107
|
+
return True
|
|
108
|
+
text = f"{result.get('error', '')} {result.get('output', '')}"
|
|
109
|
+
else:
|
|
110
|
+
text = str(result)
|
|
111
|
+
low = text[:200].lower()
|
|
112
|
+
return any(s in low for s in (
|
|
113
|
+
"error", "unknown local tool", "unknown tool", "not found",
|
|
114
|
+
"no such file", "failed", "traceback", "exception",
|
|
115
|
+
"missing", "未找到", "失败", "错误",
|
|
116
|
+
))
|
|
117
|
+
|
|
118
|
+
def record(self, tool_name: str, params: dict, result) -> "str | None":
|
|
119
|
+
"""Record one tool result. Return a directive string if a loop is detected."""
|
|
120
|
+
sig = self._signature(tool_name, params)
|
|
121
|
+
if tool_name in {"write_file", "edit_file", "multi_edit"} and not self.is_failure(result):
|
|
122
|
+
self._successful_write_repeats = self._successful_write_repeats + 1 if sig == self._last_successful_write else 1
|
|
123
|
+
self._last_successful_write = sig
|
|
124
|
+
if self._successful_write_repeats >= self.hard_threshold:
|
|
125
|
+
self.should_break = True
|
|
126
|
+
return "Repeated identical successful writes made no progress. Stop writing completion reports; report the actual verified result and the remaining limitation."
|
|
127
|
+
if self._successful_write_repeats == self.soft_threshold:
|
|
128
|
+
return "This exact write already succeeded. Do not repeat it. Read back if needed, then stop calling tools when the task is complete."
|
|
129
|
+
else:
|
|
130
|
+
self._last_successful_write = None
|
|
131
|
+
self._successful_write_repeats = 0
|
|
132
|
+
if not self.is_failure(result):
|
|
133
|
+
self._fail_counts.pop(sig, None)
|
|
134
|
+
self._warned.discard(sig)
|
|
135
|
+
self._tool_fail_counts.pop(tool_name, None)
|
|
136
|
+
self._tool_fail_sigs.pop(tool_name, None)
|
|
137
|
+
self._tool_warned.discard(tool_name)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
self._fail_counts[sig] = self._fail_counts.get(sig, 0) + 1
|
|
141
|
+
self._tool_fail_counts[tool_name] = self._tool_fail_counts.get(tool_name, 0) + 1
|
|
142
|
+
self._tool_fail_sigs.setdefault(tool_name, set()).add(sig)
|
|
143
|
+
count = self._fail_counts[sig]
|
|
144
|
+
|
|
145
|
+
if count >= self.hard_threshold:
|
|
146
|
+
self.should_break = True
|
|
147
|
+
return (
|
|
148
|
+
f"⛔ 已连续 {count} 次用相同参数调用 `{tool_name}` 且全部失败。"
|
|
149
|
+
f"立即停止调用工具,基于现有信息直接回答用户,或说明卡在哪里、需要什么。"
|
|
150
|
+
)
|
|
151
|
+
if count >= self.soft_threshold and sig not in self._warned:
|
|
152
|
+
self._warned.add(sig)
|
|
153
|
+
return (
|
|
154
|
+
f"⚠ 你已经用完全相同的参数调用了 `{tool_name}` {count} 次,每次都失败。"
|
|
155
|
+
f"不要再用相同参数重试。请改变策略:换参数、换工具(如先用 list_files/search_code 定位),"
|
|
156
|
+
f"或基于已有结果继续。"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
tool_count = self._tool_fail_counts.get(tool_name, 0)
|
|
160
|
+
distinct_sigs = len(self._tool_fail_sigs.get(tool_name, ()))
|
|
161
|
+
# Only when the arguments actually varied — identical-params repeats
|
|
162
|
+
# belong exclusively to the signature counters above (which already
|
|
163
|
+
# warned at soft and will hard-break), and this message says
|
|
164
|
+
# "参数各不相同", which must be true when shown.
|
|
165
|
+
if (
|
|
166
|
+
tool_count >= self.tool_soft_threshold
|
|
167
|
+
and distinct_sigs >= 2
|
|
168
|
+
and tool_name not in self._tool_warned
|
|
169
|
+
):
|
|
170
|
+
self._tool_warned.add(tool_name)
|
|
171
|
+
return (
|
|
172
|
+
f"⚠ 工具 `{tool_name}` 本回合已失败 {tool_count} 次(参数各不相同)。"
|
|
173
|
+
f"它当前很可能不可用(网络/依赖问题)。不要继续重试该工具;"
|
|
174
|
+
f"改用其他数据来源,或在回答中说明该工具不可用——"
|
|
175
|
+
f"也不要在“下一步建议”里推荐它而不注明刚刚失败。"
|
|
176
|
+
)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def split_tool_calls(
|
|
181
|
+
pending: Sequence[dict],
|
|
182
|
+
serial_tools: Iterable[str] = DEFAULT_SERIAL_TOOLS,
|
|
183
|
+
) -> Tuple[List[dict], List[dict]]:
|
|
184
|
+
"""Split tool calls into parallel-safe and serial batches.
|
|
185
|
+
|
|
186
|
+
Beyond the static whitelist, detects data dependencies at runtime:
|
|
187
|
+
if a later run_command references a file written/edited earlier in
|
|
188
|
+
the same batch, it is moved to the serial queue so it runs after
|
|
189
|
+
the write completes.
|
|
190
|
+
"""
|
|
191
|
+
serial = set(serial_tools)
|
|
192
|
+
parallel_batch: List[dict] = []
|
|
193
|
+
serial_batch: List[dict] = []
|
|
194
|
+
written_paths: set = set()
|
|
195
|
+
|
|
196
|
+
for tc in pending:
|
|
197
|
+
tool = tc.get("tool", "")
|
|
198
|
+
params = tc.get("params", {})
|
|
199
|
+
|
|
200
|
+
if tool in serial:
|
|
201
|
+
# Track paths being written so dependents can detect the dependency
|
|
202
|
+
for key in ("path", "file_path", "filename", "target"):
|
|
203
|
+
p = params.get(key)
|
|
204
|
+
if p:
|
|
205
|
+
written_paths.add(str(p))
|
|
206
|
+
serial_batch.append(tc)
|
|
207
|
+
elif tool == "run_command":
|
|
208
|
+
cmd = str(params.get("command", ""))
|
|
209
|
+
# If the command references a path currently being written → serial
|
|
210
|
+
if written_paths and any(p in cmd for p in written_paths):
|
|
211
|
+
serial_batch.append(tc)
|
|
212
|
+
else:
|
|
213
|
+
parallel_batch.append(tc)
|
|
214
|
+
else:
|
|
215
|
+
parallel_batch.append(tc)
|
|
216
|
+
|
|
217
|
+
return parallel_batch, serial_batch
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def collect_parallel_done(
|
|
221
|
+
pending: Sequence[dict],
|
|
222
|
+
parallel_results: Sequence[tuple],
|
|
223
|
+
serial_tools: Iterable[str] = DEFAULT_SERIAL_TOOLS,
|
|
224
|
+
) -> Dict[int, dict]:
|
|
225
|
+
"""Map original pending indices to already-executed parallel results."""
|
|
226
|
+
serial = set(serial_tools)
|
|
227
|
+
done: Dict[int, dict] = {}
|
|
228
|
+
for original_index, tool_call in enumerate(pending):
|
|
229
|
+
if tool_call.get("tool") in serial:
|
|
230
|
+
continue
|
|
231
|
+
for result_tool_call, result in parallel_results:
|
|
232
|
+
if result_tool_call is tool_call:
|
|
233
|
+
done[original_index] = result
|
|
234
|
+
break
|
|
235
|
+
return done
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
RemoteToolRunner = Callable[[str, dict], Awaitable[dict]]
|
|
239
|
+
Hook = Callable[[str, str, dict, dict | None], None]
|
|
240
|
+
SummaryFormatter = Callable[[str, dict], str]
|
|
241
|
+
ApprovalCallback = Callable[[str, dict], Union[ApprovalDecision, Awaitable[ApprovalDecision]]]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass
|
|
245
|
+
class AgentTurnState:
|
|
246
|
+
"""Mutable state accumulated across one agent response turn."""
|
|
247
|
+
|
|
248
|
+
provider: str = "aws"
|
|
249
|
+
total_response: str = ""
|
|
250
|
+
tools_used: List[str] = field(default_factory=list)
|
|
251
|
+
sources: List[dict] = field(default_factory=list)
|
|
252
|
+
usage: Dict[str, int] = field(default_factory=lambda: {
|
|
253
|
+
"prompt_tokens": 0,
|
|
254
|
+
"completion_tokens": 0,
|
|
255
|
+
"thinking_tokens": 0,
|
|
256
|
+
})
|
|
257
|
+
tool_time_total: float = 0.0
|
|
258
|
+
# 可选的预算追踪器。默认 None —— 不传就完全保持既有行为。
|
|
259
|
+
_budget: Optional["BudgetTracker"] = None
|
|
260
|
+
|
|
261
|
+
def append_response(self, text: str | None) -> None:
|
|
262
|
+
if text:
|
|
263
|
+
self.total_response += text
|
|
264
|
+
|
|
265
|
+
def apply_model_result(self, result: dict, fallback_response: str = "") -> None:
|
|
266
|
+
self.append_response(result.get("response") or fallback_response)
|
|
267
|
+
self.tools_used.extend(result.get("tools_used", []))
|
|
268
|
+
self.sources.extend(result.get("sources", []))
|
|
269
|
+
self.provider = result.get("provider", self.provider)
|
|
270
|
+
self.add_usage(result.get("usage", {}))
|
|
271
|
+
|
|
272
|
+
def add_usage(self, usage: dict | None) -> None:
|
|
273
|
+
if not usage:
|
|
274
|
+
return
|
|
275
|
+
prompt = int(usage.get("prompt_tokens", 0) or 0)
|
|
276
|
+
completion = int(usage.get("completion_tokens", 0) or 0)
|
|
277
|
+
thinking = int(usage.get("thinking_tokens", 0) or 0)
|
|
278
|
+
self.usage["prompt_tokens"] += prompt
|
|
279
|
+
self.usage["completion_tokens"] += completion
|
|
280
|
+
self.usage["thinking_tokens"] += thinking
|
|
281
|
+
|
|
282
|
+
# 预算记账挂在这里而不是循环体里:所有路径的 usage 都会经过
|
|
283
|
+
# add_usage(),挂在唯一入口上就不会漏记某条分支。thinking token 计入
|
|
284
|
+
# 输出侧——各家对它的计费都按输出档,漏掉会显著低估推理模型的花费。
|
|
285
|
+
if self._budget is not None:
|
|
286
|
+
self._budget.record(self.provider, prompt, completion + thinking)
|
|
287
|
+
|
|
288
|
+
def add_tool_time(self, elapsed: float) -> None:
|
|
289
|
+
self.tool_time_total += elapsed
|
|
290
|
+
|
|
291
|
+
def reset_response(self) -> None:
|
|
292
|
+
self.total_response = ""
|
|
293
|
+
|
|
294
|
+
def final_text(self, fallback_response: str = "") -> str:
|
|
295
|
+
return self.total_response or fallback_response
|
|
296
|
+
|
|
297
|
+
def token_counts(self, *, token_count: int = 0, thinking_tokens: int = 0) -> Tuple[int, int, int, int]:
|
|
298
|
+
prompt_t = self.usage.get("prompt_tokens", 0)
|
|
299
|
+
completion_t = self.usage.get("completion_tokens", 0) or token_count
|
|
300
|
+
think_t = self.usage.get("thinking_tokens", 0) or thinking_tokens
|
|
301
|
+
return prompt_t, completion_t, think_t, prompt_t + completion_t + think_t
|
|
302
|
+
|
|
303
|
+
def generation_time(self, elapsed: float) -> float:
|
|
304
|
+
return elapsed - self.tool_time_total
|
|
305
|
+
|
|
306
|
+
def unique_tools(self) -> List[str]:
|
|
307
|
+
return list(dict.fromkeys(self.tools_used))
|
|
308
|
+
|
|
309
|
+
def build_metadata(
|
|
310
|
+
self,
|
|
311
|
+
*,
|
|
312
|
+
elapsed: float,
|
|
313
|
+
token_count: int = 0,
|
|
314
|
+
thinking_tokens: int = 0,
|
|
315
|
+
) -> "AgentTurnMetadata":
|
|
316
|
+
prompt_t, completion_t, think_t, total_t = self.token_counts(
|
|
317
|
+
token_count=token_count,
|
|
318
|
+
thinking_tokens=thinking_tokens,
|
|
319
|
+
)
|
|
320
|
+
parts = [f"{elapsed:.1f}s"]
|
|
321
|
+
gen_time = self.generation_time(elapsed)
|
|
322
|
+
|
|
323
|
+
if total_t > 0:
|
|
324
|
+
token_parts = []
|
|
325
|
+
if prompt_t > 0:
|
|
326
|
+
token_parts.append(f"in: {prompt_t:,}")
|
|
327
|
+
if completion_t > 0:
|
|
328
|
+
token_parts.append(f"out: {completion_t:,}")
|
|
329
|
+
if think_t > 0:
|
|
330
|
+
token_parts.append(f"think: {think_t:,}")
|
|
331
|
+
parts.append(f"{total_t:,} tokens ({', '.join(token_parts)})")
|
|
332
|
+
if completion_t > 0 and gen_time > 0.5:
|
|
333
|
+
parts.append(f"{completion_t / gen_time:.0f} t/s")
|
|
334
|
+
elif token_count > 0:
|
|
335
|
+
parts.append(f"{token_count:,} tokens")
|
|
336
|
+
if gen_time > 0.5:
|
|
337
|
+
parts.append(f"{token_count / gen_time:.0f} t/s")
|
|
338
|
+
|
|
339
|
+
if self.tool_time_total > 0:
|
|
340
|
+
parts.append(f"tools: {self.tool_time_total:.1f}s")
|
|
341
|
+
if self.provider != "aws":
|
|
342
|
+
parts.append(self.provider)
|
|
343
|
+
unique_tools = self.unique_tools()
|
|
344
|
+
if unique_tools:
|
|
345
|
+
parts.append(" ".join(unique_tools))
|
|
346
|
+
|
|
347
|
+
# Turn-level cost — only for cloud providers with token data
|
|
348
|
+
_is_cloud = self.provider not in ("ollama", "ollama_cache", "local", "")
|
|
349
|
+
if _is_cloud and total_t > 0:
|
|
350
|
+
_cost = (prompt_t * 0.14 + completion_t * 0.28 + think_t * 1.10) / 1_000_000
|
|
351
|
+
if _cost >= 0.0001:
|
|
352
|
+
parts.append(f"${_cost:.4f}")
|
|
353
|
+
|
|
354
|
+
return AgentTurnMetadata(
|
|
355
|
+
parts=parts,
|
|
356
|
+
prompt_tokens=prompt_t,
|
|
357
|
+
completion_tokens=completion_t,
|
|
358
|
+
thinking_tokens=think_t,
|
|
359
|
+
total_tokens=total_t,
|
|
360
|
+
generation_time=gen_time,
|
|
361
|
+
provider=self.provider,
|
|
362
|
+
tools=unique_tools,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def build_result(
|
|
366
|
+
self,
|
|
367
|
+
*,
|
|
368
|
+
elapsed: float,
|
|
369
|
+
fallback_response: str = "",
|
|
370
|
+
token_count: int = 0,
|
|
371
|
+
thinking_tokens: int = 0,
|
|
372
|
+
success: bool = True,
|
|
373
|
+
cancelled: bool = False,
|
|
374
|
+
error: str = "",
|
|
375
|
+
acceptance: Optional[dict] = None,
|
|
376
|
+
stop_reason: str = "completed",
|
|
377
|
+
contract: Optional[dict] = None,
|
|
378
|
+
delivery: Optional[dict] = None,
|
|
379
|
+
) -> "AgentTurnResult":
|
|
380
|
+
metadata = self.build_metadata(
|
|
381
|
+
elapsed=elapsed,
|
|
382
|
+
token_count=token_count,
|
|
383
|
+
thinking_tokens=thinking_tokens,
|
|
384
|
+
)
|
|
385
|
+
return AgentTurnResult(
|
|
386
|
+
success=success,
|
|
387
|
+
cancelled=cancelled,
|
|
388
|
+
error=error,
|
|
389
|
+
final_text=self.final_text(fallback_response),
|
|
390
|
+
metadata=metadata,
|
|
391
|
+
provider=metadata.provider,
|
|
392
|
+
tools=metadata.tools,
|
|
393
|
+
sources=list(self.sources),
|
|
394
|
+
acceptance=acceptance,
|
|
395
|
+
stop_reason=stop_reason,
|
|
396
|
+
contract=contract,
|
|
397
|
+
delivery=delivery,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def build_cancelled_result(
|
|
401
|
+
self,
|
|
402
|
+
*,
|
|
403
|
+
elapsed: float,
|
|
404
|
+
fallback_response: str = "",
|
|
405
|
+
token_count: int = 0,
|
|
406
|
+
thinking_tokens: int = 0,
|
|
407
|
+
) -> "AgentTurnResult":
|
|
408
|
+
return self.build_result(
|
|
409
|
+
elapsed=elapsed,
|
|
410
|
+
fallback_response=fallback_response,
|
|
411
|
+
token_count=token_count,
|
|
412
|
+
thinking_tokens=thinking_tokens,
|
|
413
|
+
success=True,
|
|
414
|
+
cancelled=True,
|
|
415
|
+
stop_reason="cancelled",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def build_error_result(
|
|
419
|
+
self,
|
|
420
|
+
error: str | None,
|
|
421
|
+
*,
|
|
422
|
+
elapsed: float,
|
|
423
|
+
fallback_response: str = "",
|
|
424
|
+
token_count: int = 0,
|
|
425
|
+
thinking_tokens: int = 0,
|
|
426
|
+
) -> "AgentTurnResult":
|
|
427
|
+
return self.build_result(
|
|
428
|
+
elapsed=elapsed,
|
|
429
|
+
fallback_response=fallback_response,
|
|
430
|
+
token_count=token_count,
|
|
431
|
+
thinking_tokens=thinking_tokens,
|
|
432
|
+
success=False,
|
|
433
|
+
cancelled=False,
|
|
434
|
+
error=error or "Unknown error",
|
|
435
|
+
stop_reason="failed",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@dataclass(frozen=True)
|
|
440
|
+
class AgentTurnMetadata:
|
|
441
|
+
"""Display and accounting metadata for one completed agent turn."""
|
|
442
|
+
|
|
443
|
+
parts: List[str]
|
|
444
|
+
prompt_tokens: int = 0
|
|
445
|
+
completion_tokens: int = 0
|
|
446
|
+
thinking_tokens: int = 0
|
|
447
|
+
total_tokens: int = 0
|
|
448
|
+
generation_time: float = 0.0
|
|
449
|
+
provider: str = "aws"
|
|
450
|
+
tools: List[str] = field(default_factory=list)
|
|
451
|
+
|
|
452
|
+
def system_prompt_estimate(self, message: str) -> int:
|
|
453
|
+
return max(0, self.prompt_tokens - len(message) // 3)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@dataclass(frozen=True)
|
|
457
|
+
class AgentTurnResult:
|
|
458
|
+
"""Structured result for a completed agent turn."""
|
|
459
|
+
|
|
460
|
+
success: bool
|
|
461
|
+
cancelled: bool
|
|
462
|
+
error: str
|
|
463
|
+
final_text: str
|
|
464
|
+
metadata: AgentTurnMetadata
|
|
465
|
+
provider: str = "aws"
|
|
466
|
+
tools: List[str] = field(default_factory=list)
|
|
467
|
+
sources: List[dict] = field(default_factory=list)
|
|
468
|
+
# 验收证据。None = 本轮没有验收(只读回合,或没有可推断的检查命令);
|
|
469
|
+
# 有值时 ``acceptance["verified"]`` 才是「做完了」这句话的凭据。
|
|
470
|
+
acceptance: Optional[dict] = None
|
|
471
|
+
stop_reason: str = "completed"
|
|
472
|
+
# The change contract this turn ran under and what it refused, or None
|
|
473
|
+
# when no contract applied.
|
|
474
|
+
contract: Optional[dict] = None
|
|
475
|
+
# The runtime's account of the turn (runtime/delivery.py): what changed,
|
|
476
|
+
# what was verified, the risk, checkpoints. None when nothing happened
|
|
477
|
+
# worth reporting.
|
|
478
|
+
delivery: Optional[dict] = None
|
|
479
|
+
|
|
480
|
+
@classmethod
|
|
481
|
+
def cancelled_result(
|
|
482
|
+
cls,
|
|
483
|
+
*,
|
|
484
|
+
metadata: AgentTurnMetadata | None = None,
|
|
485
|
+
final_text: str = "",
|
|
486
|
+
) -> "AgentTurnResult":
|
|
487
|
+
return cls(
|
|
488
|
+
success=True,
|
|
489
|
+
cancelled=True,
|
|
490
|
+
error="",
|
|
491
|
+
final_text=final_text,
|
|
492
|
+
metadata=metadata or AgentTurnMetadata(parts=[]),
|
|
493
|
+
stop_reason="cancelled",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
@classmethod
|
|
497
|
+
def error_result(
|
|
498
|
+
cls,
|
|
499
|
+
error: str,
|
|
500
|
+
*,
|
|
501
|
+
metadata: AgentTurnMetadata | None = None,
|
|
502
|
+
final_text: str = "",
|
|
503
|
+
) -> "AgentTurnResult":
|
|
504
|
+
return cls(
|
|
505
|
+
success=False,
|
|
506
|
+
cancelled=False,
|
|
507
|
+
error=error,
|
|
508
|
+
final_text=final_text,
|
|
509
|
+
metadata=metadata or AgentTurnMetadata(parts=[]),
|
|
510
|
+
stop_reason="failed",
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def to_dict(self) -> dict:
|
|
514
|
+
return {
|
|
515
|
+
"success": self.success,
|
|
516
|
+
"stop_reason": self.stop_reason,
|
|
517
|
+
"cancelled": self.cancelled,
|
|
518
|
+
"error": self.error,
|
|
519
|
+
"final_text": self.final_text,
|
|
520
|
+
"provider": self.provider,
|
|
521
|
+
"tools": list(self.tools),
|
|
522
|
+
"sources": list(self.sources),
|
|
523
|
+
"metadata": {
|
|
524
|
+
"parts": list(self.metadata.parts),
|
|
525
|
+
"prompt_tokens": self.metadata.prompt_tokens,
|
|
526
|
+
"completion_tokens": self.metadata.completion_tokens,
|
|
527
|
+
"thinking_tokens": self.metadata.thinking_tokens,
|
|
528
|
+
"total_tokens": self.metadata.total_tokens,
|
|
529
|
+
"generation_time": self.metadata.generation_time,
|
|
530
|
+
"provider": self.metadata.provider,
|
|
531
|
+
"tools": list(self.metadata.tools),
|
|
532
|
+
# 只在真的验收过时才出现,免得每个只读回合都带一个空字段,
|
|
533
|
+
# 让消费者误以为「没验收」和「验收失败」是同一件事。
|
|
534
|
+
**({"acceptance": dict(self.acceptance)} if self.acceptance else {}),
|
|
535
|
+
},
|
|
536
|
+
"acceptance": dict(self.acceptance) if self.acceptance else None,
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
def to_envelope(self) -> "AgentTurnEnvelope":
|
|
540
|
+
return AgentTurnEnvelope.from_result(self)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@dataclass(frozen=True)
|
|
544
|
+
class AgentTurnEnvelope:
|
|
545
|
+
"""Stable runtime envelope for CLI/API consumers."""
|
|
546
|
+
|
|
547
|
+
status: str
|
|
548
|
+
success: bool
|
|
549
|
+
cancelled: bool
|
|
550
|
+
error: str
|
|
551
|
+
final_text: str
|
|
552
|
+
provider: str
|
|
553
|
+
tools: List[str]
|
|
554
|
+
summary: str
|
|
555
|
+
metadata: dict
|
|
556
|
+
|
|
557
|
+
@classmethod
|
|
558
|
+
def from_result(cls, result: AgentTurnResult) -> "AgentTurnEnvelope":
|
|
559
|
+
return cls(
|
|
560
|
+
status="ok" if result.success else "error",
|
|
561
|
+
success=result.success,
|
|
562
|
+
cancelled=result.cancelled,
|
|
563
|
+
error=result.error,
|
|
564
|
+
final_text=result.final_text,
|
|
565
|
+
provider=result.provider,
|
|
566
|
+
tools=list(result.tools),
|
|
567
|
+
summary=" · ".join(result.metadata.parts),
|
|
568
|
+
metadata=result.to_dict()["metadata"],
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
def to_dict(self) -> dict:
|
|
572
|
+
return {
|
|
573
|
+
"status": self.status,
|
|
574
|
+
"success": self.success,
|
|
575
|
+
"cancelled": self.cancelled,
|
|
576
|
+
"error": self.error,
|
|
577
|
+
"final_text": self.final_text,
|
|
578
|
+
"provider": self.provider,
|
|
579
|
+
"tools": list(self.tools),
|
|
580
|
+
"summary": self.summary,
|
|
581
|
+
"metadata": dict(self.metadata),
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@dataclass(frozen=True)
|
|
586
|
+
class AgentErrorPresentation:
|
|
587
|
+
"""User-facing error presentation for model/agent failures."""
|
|
588
|
+
|
|
589
|
+
error: str
|
|
590
|
+
lines: List[str]
|
|
591
|
+
level: str = "error"
|
|
592
|
+
use_generic_error_prefix: bool = False
|
|
593
|
+
|
|
594
|
+
@classmethod
|
|
595
|
+
def from_error(cls, error: str | None, *, lang: str = "zh") -> "AgentErrorPresentation":
|
|
596
|
+
normalized = error or "Unknown error"
|
|
597
|
+
lowered = normalized.lower()
|
|
598
|
+
is_zh = str(lang or "zh").lower().startswith("zh")
|
|
599
|
+
if "aria-4223" in lowered:
|
|
600
|
+
return cls(
|
|
601
|
+
error=normalized,
|
|
602
|
+
level="warning",
|
|
603
|
+
lines=[
|
|
604
|
+
(
|
|
605
|
+
"当前请求需要实时、可验证的金融数据,但本轮没有取得有效工具结果。"
|
|
606
|
+
if is_zh else
|
|
607
|
+
"This request requires current verified financial data, but no usable tool result was obtained."
|
|
608
|
+
),
|
|
609
|
+
(
|
|
610
|
+
"未生成市场结论。请检查数据源或运行 /doctor 后重试。"
|
|
611
|
+
if is_zh else
|
|
612
|
+
"No market conclusion was generated. Check data sources or run /doctor, then retry."
|
|
613
|
+
),
|
|
614
|
+
],
|
|
615
|
+
)
|
|
616
|
+
if lowered.startswith("missing_api_key:"):
|
|
617
|
+
provider = normalized.split(":", 1)[1] or "provider"
|
|
618
|
+
return cls(
|
|
619
|
+
error=normalized,
|
|
620
|
+
level="warning",
|
|
621
|
+
lines=[
|
|
622
|
+
f"{provider} API Key 未配置。" if is_zh else f"{provider} API key is not configured.",
|
|
623
|
+
f"运行: /apikey set {provider} <key>" if is_zh else f"Run: /apikey set {provider} <key>",
|
|
624
|
+
],
|
|
625
|
+
)
|
|
626
|
+
# Gemini on Vertex AI, the default model, borrowing the gcloud login.
|
|
627
|
+
# These reached the user raw: "Error: vertex_needs_project: set …".
|
|
628
|
+
if lowered.startswith("vertex_needs_project"):
|
|
629
|
+
return cls(
|
|
630
|
+
error=normalized,
|
|
631
|
+
level="warning",
|
|
632
|
+
lines=[
|
|
633
|
+
"Gemini 通过 Google Cloud 调用,但尚未设置项目。" if is_zh else
|
|
634
|
+
"Gemini runs on Google Cloud, and no project is set.",
|
|
635
|
+
"设置项目:/config set gcp_project=<项目 ID>(或 gcloud config set project <ID>),"
|
|
636
|
+
"或用 /model 换一个模型。" if is_zh else
|
|
637
|
+
"Set one with /config set gcp_project=<id> (or gcloud config set project <id>), "
|
|
638
|
+
"or pick another model with /model.",
|
|
639
|
+
],
|
|
640
|
+
)
|
|
641
|
+
if lowered.startswith("vertex_not_logged_in"):
|
|
642
|
+
return cls(
|
|
643
|
+
error=normalized,
|
|
644
|
+
level="warning",
|
|
645
|
+
lines=[
|
|
646
|
+
"Gemini 通过 Google Cloud 调用,但 gcloud 尚未登录。" if is_zh else
|
|
647
|
+
"Gemini runs on Google Cloud, and gcloud is not signed in.",
|
|
648
|
+
"运行 gcloud auth login,或用 /model 换一个模型。" if is_zh else
|
|
649
|
+
"Run gcloud auth login, or pick another model with /model.",
|
|
650
|
+
],
|
|
651
|
+
)
|
|
652
|
+
if lowered.startswith("vertex_gcloud_failed"):
|
|
653
|
+
detail = normalized.split(":", 1)[1].strip() if ":" in normalized else ""
|
|
654
|
+
return cls(
|
|
655
|
+
error=normalized,
|
|
656
|
+
level="warning",
|
|
657
|
+
lines=[
|
|
658
|
+
(f"gcloud 未能签发访问令牌({detail})。" if is_zh else
|
|
659
|
+
f"gcloud could not issue an access token ({detail}).") if detail else
|
|
660
|
+
("gcloud 未能签发访问令牌。" if is_zh else "gcloud could not issue an access token."),
|
|
661
|
+
"运行 gcloud auth login 后重试,或运行 /doctor。" if is_zh else
|
|
662
|
+
"Run gcloud auth login and retry, or run /doctor.",
|
|
663
|
+
],
|
|
664
|
+
)
|
|
665
|
+
if "http 402" in lowered or "insufficient balance" in lowered:
|
|
666
|
+
return cls(
|
|
667
|
+
error=normalized,
|
|
668
|
+
level="warning",
|
|
669
|
+
lines=[
|
|
670
|
+
"Provider 余额不足,当前模型请求未执行。"
|
|
671
|
+
if is_zh else
|
|
672
|
+
"The provider balance is insufficient; the model request was not run."
|
|
673
|
+
],
|
|
674
|
+
)
|
|
675
|
+
if "http 401" in lowered or "http 403" in lowered:
|
|
676
|
+
return cls(
|
|
677
|
+
error=normalized,
|
|
678
|
+
level="warning",
|
|
679
|
+
lines=[
|
|
680
|
+
"Provider 身份验证失败,请检查 API Key 或登录状态。"
|
|
681
|
+
if is_zh else
|
|
682
|
+
"Provider authentication failed. Check the API key or sign-in state."
|
|
683
|
+
],
|
|
684
|
+
)
|
|
685
|
+
if "http 429" in lowered or "rate limit" in lowered:
|
|
686
|
+
return cls(
|
|
687
|
+
error=normalized,
|
|
688
|
+
level="warning",
|
|
689
|
+
lines=[
|
|
690
|
+
"Provider 请求频率受限,请稍后重试。"
|
|
691
|
+
if is_zh else
|
|
692
|
+
"The provider rate limit was reached. Try again shortly."
|
|
693
|
+
],
|
|
694
|
+
)
|
|
695
|
+
if normalized in ("no_cloud_provider", "no_provider"):
|
|
696
|
+
return cls(
|
|
697
|
+
error=normalized,
|
|
698
|
+
level="warning",
|
|
699
|
+
lines=(
|
|
700
|
+
[
|
|
701
|
+
"没有可用的 AI 模型",
|
|
702
|
+
" 尚未登录,Ollama 也没有运行。",
|
|
703
|
+
" 解决方案(任选其一):",
|
|
704
|
+
" • 登录即用: /login —— 在浏览器里登录或注册,模型由服务端提供,不需要自备 API Key",
|
|
705
|
+
" • 本地离线: ollama serve",
|
|
706
|
+
" • 自备 Key: /apikey set deepseek <your-key>",
|
|
707
|
+
" • 导出环境变量: export DEEPSEEK_API_KEY=sk-...",
|
|
708
|
+
]
|
|
709
|
+
if is_zh else
|
|
710
|
+
[
|
|
711
|
+
"No AI model is available.",
|
|
712
|
+
" You are not signed in, and Ollama is offline.",
|
|
713
|
+
" Choose one:",
|
|
714
|
+
" • Sign in: /login — in the browser; the model is served for you, no API key needed",
|
|
715
|
+
" • Offline: ollama serve",
|
|
716
|
+
" • Your own key: /apikey set deepseek <your-key>",
|
|
717
|
+
]
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
if normalized == "all_providers_failed":
|
|
721
|
+
return cls(
|
|
722
|
+
error=normalized,
|
|
723
|
+
level="warning",
|
|
724
|
+
lines=[
|
|
725
|
+
"所有云端 Provider 均请求失败,请检查网络或 API Key 是否有效。"
|
|
726
|
+
if is_zh else
|
|
727
|
+
"All cloud providers failed. Check the network connection and API keys."
|
|
728
|
+
],
|
|
729
|
+
)
|
|
730
|
+
if normalized == "empty_response":
|
|
731
|
+
return cls(
|
|
732
|
+
error=normalized,
|
|
733
|
+
# 597899e 把这里从 error 降为 warning("minimalist" 的空响应 UI)。
|
|
734
|
+
# 原注释主张相反——本轮已终止,视觉上应与普通提示区分——已随代码
|
|
735
|
+
# 更正,免得文件继续自相矛盾。
|
|
736
|
+
level="warning",
|
|
737
|
+
lines=(
|
|
738
|
+
[
|
|
739
|
+
"模型连续返回空响应(已自动重试 1 次),本轮停止。",
|
|
740
|
+
"可换个说法重试;若持续出现,请使用 /health 检查当前模型与 Provider。",
|
|
741
|
+
]
|
|
742
|
+
if is_zh else
|
|
743
|
+
[
|
|
744
|
+
"The model returned no visible response twice (auto-retried once), so this turn was stopped.",
|
|
745
|
+
"Try rephrasing. If it persists, run /health to check the model and provider.",
|
|
746
|
+
]
|
|
747
|
+
),
|
|
748
|
+
)
|
|
749
|
+
return cls(
|
|
750
|
+
error=normalized,
|
|
751
|
+
# Also lowered by 597899e. Note this is the catch-all branch: an
|
|
752
|
+
# unexpected failure now renders with the same weight as a rate-limit
|
|
753
|
+
# notice. Deliberate per that commit, but worth revisiting — the
|
|
754
|
+
# dataclass default below it is still "error".
|
|
755
|
+
level="warning",
|
|
756
|
+
lines=[f"Error: {normalized}"],
|
|
757
|
+
use_generic_error_prefix=True,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
@dataclass
|
|
762
|
+
class ToolBatchState:
|
|
763
|
+
"""Mutable state for one model-requested batch of tool calls."""
|
|
764
|
+
|
|
765
|
+
tool_results: List[dict] = field(default_factory=list)
|
|
766
|
+
elapsed_total: float = 0.0
|
|
767
|
+
cancelled: bool = False
|
|
768
|
+
|
|
769
|
+
def add_result(
|
|
770
|
+
self,
|
|
771
|
+
tool_name: str,
|
|
772
|
+
result: dict,
|
|
773
|
+
formatter: SummaryFormatter,
|
|
774
|
+
*,
|
|
775
|
+
elapsed: float = 0.0,
|
|
776
|
+
) -> dict:
|
|
777
|
+
self.elapsed_total += elapsed
|
|
778
|
+
return record_tool_result(self.tool_results, tool_name, result, formatter)
|
|
779
|
+
|
|
780
|
+
def cancel(self) -> None:
|
|
781
|
+
self.cancelled = True
|
|
782
|
+
|
|
783
|
+
def build_next_turn(self, total_response: str) -> Tuple[dict, dict, str]:
|
|
784
|
+
return build_next_turn_messages(total_response, self.tool_results)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
@dataclass(frozen=True)
|
|
788
|
+
class ToolCallTask:
|
|
789
|
+
"""One ordered tool call in a model-requested turn."""
|
|
790
|
+
|
|
791
|
+
index: int
|
|
792
|
+
tool_call: dict
|
|
793
|
+
parallel_result: dict | None = None
|
|
794
|
+
|
|
795
|
+
@property
|
|
796
|
+
def tool_name(self) -> str:
|
|
797
|
+
return self.tool_call.get("tool", "")
|
|
798
|
+
|
|
799
|
+
@property
|
|
800
|
+
def params(self) -> dict:
|
|
801
|
+
return self.tool_call.get("params", {})
|
|
802
|
+
|
|
803
|
+
@property
|
|
804
|
+
def has_parallel_result(self) -> bool:
|
|
805
|
+
return self.parallel_result is not None
|
|
806
|
+
|
|
807
|
+
def progress_label(self, total: int) -> str:
|
|
808
|
+
if total > 1:
|
|
809
|
+
return f" [{self.index + 1}/{total}] Running {self.tool_name}..."
|
|
810
|
+
return f" Running {self.tool_name}..."
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@dataclass
|
|
814
|
+
class ToolTurnPlan:
|
|
815
|
+
"""Runtime plan for executing one pending tool-call turn."""
|
|
816
|
+
|
|
817
|
+
pending: Sequence[dict]
|
|
818
|
+
parallel_done: Dict[int, dict] = field(default_factory=dict)
|
|
819
|
+
batch: ToolBatchState = field(default_factory=ToolBatchState)
|
|
820
|
+
|
|
821
|
+
def tasks(self) -> List[ToolCallTask]:
|
|
822
|
+
return [
|
|
823
|
+
ToolCallTask(
|
|
824
|
+
index=index,
|
|
825
|
+
tool_call=tool_call,
|
|
826
|
+
parallel_result=self.parallel_done.get(index),
|
|
827
|
+
)
|
|
828
|
+
for index, tool_call in enumerate(self.pending)
|
|
829
|
+
]
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@dataclass(frozen=True)
|
|
833
|
+
class ToolExecutionActivity:
|
|
834
|
+
"""One executed tool activity in a model-requested batch."""
|
|
835
|
+
|
|
836
|
+
tool: str
|
|
837
|
+
result: dict
|
|
838
|
+
elapsed: float
|
|
839
|
+
params: dict
|
|
840
|
+
from_parallel: bool = False
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
@dataclass(frozen=True)
|
|
844
|
+
class ToolExecutionTurnResult:
|
|
845
|
+
"""Structured result from one pending tool-call turn."""
|
|
846
|
+
|
|
847
|
+
batch: ToolBatchState
|
|
848
|
+
activities: List[ToolExecutionActivity]
|
|
849
|
+
assistant_message: dict
|
|
850
|
+
tool_messages: List[dict]
|
|
851
|
+
user_message: dict
|
|
852
|
+
followup: str
|
|
853
|
+
guard_directives: List[str] = field(default_factory=list)
|
|
854
|
+
|
|
855
|
+
@property
|
|
856
|
+
def cancelled(self) -> bool:
|
|
857
|
+
return self.batch.cancelled
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
async def run_parallel_tools(
|
|
861
|
+
pending: Sequence[dict],
|
|
862
|
+
tool_executor: ToolExecutor,
|
|
863
|
+
*,
|
|
864
|
+
remote_runner: RemoteToolRunner | None = None,
|
|
865
|
+
hook: Hook | None = None,
|
|
866
|
+
serial_tools: Iterable[str] = DEFAULT_SERIAL_TOOLS,
|
|
867
|
+
) -> Dict[int, dict]:
|
|
868
|
+
"""Execute parallel-safe pending tools and return results by original index."""
|
|
869
|
+
parallel_batch, _ = split_tool_calls(pending, serial_tools)
|
|
870
|
+
# Reuse only identical local reads in this batch. There are no intervening
|
|
871
|
+
# writes here, and nothing is cached across rounds (external edits matter).
|
|
872
|
+
import copy
|
|
873
|
+
import json
|
|
874
|
+
shared_reads: dict[str, asyncio.Task] = {}
|
|
875
|
+
|
|
876
|
+
async def _exec_one(tool_call: dict) -> tuple:
|
|
877
|
+
tool_name = tool_call.get("tool", "")
|
|
878
|
+
tool_params = tool_call.get("params", {})
|
|
879
|
+
if tool_name in tool_executor.local_tools:
|
|
880
|
+
if tool_name in {"read_file", "list_files", "glob", "search_code"}:
|
|
881
|
+
key = tool_name + json.dumps(tool_params, sort_keys=True, ensure_ascii=False)
|
|
882
|
+
reused = key in shared_reads
|
|
883
|
+
if not reused:
|
|
884
|
+
shared_reads[key] = asyncio.create_task(tool_executor.execute(tool_name, tool_params))
|
|
885
|
+
result = copy.deepcopy(await shared_reads[key])
|
|
886
|
+
if reused:
|
|
887
|
+
result["reused"] = True
|
|
888
|
+
else:
|
|
889
|
+
result = await tool_executor.execute(tool_name, tool_params)
|
|
890
|
+
elif remote_runner is not None:
|
|
891
|
+
if hook is not None:
|
|
892
|
+
hook("pre_tool", tool_name, tool_params, None)
|
|
893
|
+
try:
|
|
894
|
+
result = await remote_runner(tool_name, tool_params)
|
|
895
|
+
except Exception as exc:
|
|
896
|
+
result = {"success": False, "error": str(exc)}
|
|
897
|
+
if hook is not None:
|
|
898
|
+
hook("post_tool", tool_name, tool_params,
|
|
899
|
+
{key: value for key, value in result.items() if key != "_vision_image"})
|
|
900
|
+
else:
|
|
901
|
+
result = {"success": False, "error": f"Unknown tool: {tool_name}"}
|
|
902
|
+
return tool_call, result
|
|
903
|
+
|
|
904
|
+
parallel_results: List[tuple] = []
|
|
905
|
+
if parallel_batch:
|
|
906
|
+
gathered = await asyncio.gather(
|
|
907
|
+
*[_exec_one(tool_call) for tool_call in parallel_batch],
|
|
908
|
+
return_exceptions=True,
|
|
909
|
+
)
|
|
910
|
+
for item in gathered:
|
|
911
|
+
if isinstance(item, Exception):
|
|
912
|
+
parallel_results.append((None, {"success": False, "error": str(item)}))
|
|
913
|
+
else:
|
|
914
|
+
parallel_results.append(item)
|
|
915
|
+
return collect_parallel_done(pending, parallel_results, serial_tools)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
async def run_serial_tool(
|
|
919
|
+
tool_name: str,
|
|
920
|
+
tool_params: dict,
|
|
921
|
+
tool_executor: ToolExecutor,
|
|
922
|
+
*,
|
|
923
|
+
remote_runner: RemoteToolRunner | None = None,
|
|
924
|
+
hook: Hook | None = None,
|
|
925
|
+
approval: ApprovalDecision | None = None,
|
|
926
|
+
) -> Tuple[dict, float]:
|
|
927
|
+
"""Execute one tool call and return (result, elapsed_seconds)."""
|
|
928
|
+
started = time.time()
|
|
929
|
+
if tool_name in tool_executor.local_tools:
|
|
930
|
+
result = tool_executor.execute_local(tool_name, tool_params, approval=approval)
|
|
931
|
+
elif remote_runner is not None:
|
|
932
|
+
if hook is not None:
|
|
933
|
+
hook("pre_tool", tool_name, tool_params, None)
|
|
934
|
+
try:
|
|
935
|
+
result = await remote_runner(tool_name, tool_params)
|
|
936
|
+
except Exception as exc:
|
|
937
|
+
result = {"success": False, "error": str(exc)}
|
|
938
|
+
if hook is not None:
|
|
939
|
+
hook("post_tool", tool_name, tool_params,
|
|
940
|
+
{key: value for key, value in result.items() if key != "_vision_image"})
|
|
941
|
+
else:
|
|
942
|
+
result = {"success": False, "error": f"Unknown tool: {tool_name}"}
|
|
943
|
+
return result, time.time() - started
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
async def _maybe_await(value):
|
|
947
|
+
if inspect.isawaitable(value):
|
|
948
|
+
return await value
|
|
949
|
+
return value
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
async def execute_tool_turn(
|
|
953
|
+
pending: Sequence[dict],
|
|
954
|
+
*,
|
|
955
|
+
total_response: str,
|
|
956
|
+
tool_executor: ToolExecutor,
|
|
957
|
+
formatter: SummaryFormatter,
|
|
958
|
+
remote_runner: RemoteToolRunner | None = None,
|
|
959
|
+
hook: Hook | None = None,
|
|
960
|
+
cancel_event: asyncio.Event | None = None,
|
|
961
|
+
confirm_tools: Iterable[str] = (),
|
|
962
|
+
approval_callback: ApprovalCallback | None = None,
|
|
963
|
+
approval_applier: Callable[[dict, ApprovalDecision], dict] = apply_approval_decision,
|
|
964
|
+
loop_guard: LoopGuard | None = None,
|
|
965
|
+
serial_tools: Iterable[str] = DEFAULT_SERIAL_TOOLS,
|
|
966
|
+
contract: "ChangeContract | None" = None,
|
|
967
|
+
) -> ToolExecutionTurnResult:
|
|
968
|
+
"""Execute one model-requested tool batch and prepare the next turn.
|
|
969
|
+
|
|
970
|
+
This is the reusable runtime boundary for the agent tool loop. Callers
|
|
971
|
+
provide UI-specific approval and rendering callbacks; this function owns
|
|
972
|
+
batching, permission application, execution, loop-guard retry directives,
|
|
973
|
+
and construction of the assistant/user follow-up messages.
|
|
974
|
+
"""
|
|
975
|
+
|
|
976
|
+
confirm = set(confirm_tools)
|
|
977
|
+
# Keep the model-facing call transcript free of runtime-only approval
|
|
978
|
+
# fields that may be injected into the executable parameter dictionary.
|
|
979
|
+
pending_transcript = [
|
|
980
|
+
{
|
|
981
|
+
"tool": str(tool_call.get("tool", "")),
|
|
982
|
+
"params": dict(tool_call.get("params", {}) or {}),
|
|
983
|
+
# Opaque provider tokens (Gemini call ids / thought signatures)
|
|
984
|
+
# that must be replayed with the call in the next request.
|
|
985
|
+
**{
|
|
986
|
+
key: tool_call[key]
|
|
987
|
+
for key in ("call_id", "thought_signature")
|
|
988
|
+
if tool_call.get(key)
|
|
989
|
+
},
|
|
990
|
+
}
|
|
991
|
+
for tool_call in pending
|
|
992
|
+
]
|
|
993
|
+
# The contract is checked before anything runs — parallel-safe calls run
|
|
994
|
+
# first, all at once — and a refused call never reaches the executor: its
|
|
995
|
+
# refusal stands in for its result, so the model learns which rule held.
|
|
996
|
+
refused: Dict[int, dict] = {}
|
|
997
|
+
if contract is not None:
|
|
998
|
+
for index, tool_call in enumerate(pending):
|
|
999
|
+
verdict = contract.check(str(tool_call.get("tool", "")), tool_call.get("params", {}) or {})
|
|
1000
|
+
if not verdict.allowed:
|
|
1001
|
+
refused[index] = verdict.as_tool_result(str(tool_call.get("tool", "")))
|
|
1002
|
+
runnable_indices = [index for index in range(len(pending)) if index not in refused]
|
|
1003
|
+
ran_in_parallel = await run_parallel_tools(
|
|
1004
|
+
[pending[index] for index in runnable_indices],
|
|
1005
|
+
tool_executor,
|
|
1006
|
+
remote_runner=remote_runner,
|
|
1007
|
+
hook=hook,
|
|
1008
|
+
serial_tools=serial_tools,
|
|
1009
|
+
)
|
|
1010
|
+
parallel_done = {runnable_indices[i]: result for i, result in ran_in_parallel.items()}
|
|
1011
|
+
parallel_done.update(refused)
|
|
1012
|
+
tool_turn = ToolTurnPlan(pending=pending, parallel_done=parallel_done)
|
|
1013
|
+
tool_batch = tool_turn.batch
|
|
1014
|
+
activities: List[ToolExecutionActivity] = []
|
|
1015
|
+
|
|
1016
|
+
for task in tool_turn.tasks():
|
|
1017
|
+
if cancel_event and cancel_event.is_set():
|
|
1018
|
+
tool_batch.cancel()
|
|
1019
|
+
break
|
|
1020
|
+
|
|
1021
|
+
tool_name = task.tool_name
|
|
1022
|
+
tool_params = task.params
|
|
1023
|
+
|
|
1024
|
+
if task.has_parallel_result:
|
|
1025
|
+
tr = task.parallel_result or {}
|
|
1026
|
+
tool_batch.add_result(tool_name, tr, formatter)
|
|
1027
|
+
activities.append(ToolExecutionActivity(
|
|
1028
|
+
tool=tool_name,
|
|
1029
|
+
result=tr,
|
|
1030
|
+
elapsed=0.0,
|
|
1031
|
+
params=tool_params,
|
|
1032
|
+
from_parallel=True,
|
|
1033
|
+
))
|
|
1034
|
+
continue
|
|
1035
|
+
|
|
1036
|
+
approval = None
|
|
1037
|
+
if tool_name in confirm:
|
|
1038
|
+
if approval_callback is None:
|
|
1039
|
+
tool_batch.cancel()
|
|
1040
|
+
break
|
|
1041
|
+
decision = await _maybe_await(approval_callback(tool_name, tool_params))
|
|
1042
|
+
if decision is None:
|
|
1043
|
+
decision = ApprovalDecision.deny("approval unavailable")
|
|
1044
|
+
if not decision.approved and decision.feedback:
|
|
1045
|
+
# "No, do this instead": the call is skipped and the turn goes
|
|
1046
|
+
# on with the user's words as its result, as Codex does.
|
|
1047
|
+
declined = decision.as_declined_result()
|
|
1048
|
+
tool_batch.add_result(tool_name, declined, formatter)
|
|
1049
|
+
activities.append(ToolExecutionActivity(
|
|
1050
|
+
tool=tool_name, result=declined, elapsed=0.0, params=tool_params))
|
|
1051
|
+
continue
|
|
1052
|
+
if not decision.approved:
|
|
1053
|
+
tool_batch.cancel()
|
|
1054
|
+
break
|
|
1055
|
+
approval_applier(tool_params, decision)
|
|
1056
|
+
approval = decision
|
|
1057
|
+
|
|
1058
|
+
tr, tool_elapsed = await run_serial_tool(
|
|
1059
|
+
tool_name,
|
|
1060
|
+
tool_params,
|
|
1061
|
+
tool_executor,
|
|
1062
|
+
remote_runner=remote_runner,
|
|
1063
|
+
hook=hook,
|
|
1064
|
+
approval=approval,
|
|
1065
|
+
)
|
|
1066
|
+
tool_batch.add_result(tool_name, tr, formatter, elapsed=tool_elapsed)
|
|
1067
|
+
activities.append(ToolExecutionActivity(
|
|
1068
|
+
tool=tool_name,
|
|
1069
|
+
result=tr,
|
|
1070
|
+
elapsed=tool_elapsed,
|
|
1071
|
+
params=tool_params,
|
|
1072
|
+
))
|
|
1073
|
+
|
|
1074
|
+
guard_directives: List[str] = []
|
|
1075
|
+
if loop_guard is not None:
|
|
1076
|
+
for activity in activities:
|
|
1077
|
+
directive = loop_guard.record(activity.tool, activity.params, activity.result)
|
|
1078
|
+
if directive:
|
|
1079
|
+
guard_directives.append(directive)
|
|
1080
|
+
|
|
1081
|
+
assistant_message, user_message, followup = tool_batch.build_next_turn(total_response)
|
|
1082
|
+
assistant_message["tool_calls"] = [
|
|
1083
|
+
{
|
|
1084
|
+
"function": {
|
|
1085
|
+
"name": str(tool_call.get("tool", "")),
|
|
1086
|
+
"arguments": dict(tool_call.get("params", {}) or {}),
|
|
1087
|
+
},
|
|
1088
|
+
**({"id": tool_call["call_id"]} if tool_call.get("call_id") else {}),
|
|
1089
|
+
**(
|
|
1090
|
+
{"thought_signature": tool_call["thought_signature"]}
|
|
1091
|
+
if tool_call.get("thought_signature")
|
|
1092
|
+
else {}
|
|
1093
|
+
),
|
|
1094
|
+
}
|
|
1095
|
+
for tool_call in pending_transcript
|
|
1096
|
+
if tool_call.get("tool")
|
|
1097
|
+
]
|
|
1098
|
+
tool_messages = [
|
|
1099
|
+
{
|
|
1100
|
+
"role": "tool",
|
|
1101
|
+
"content": str(item.get("result", "")),
|
|
1102
|
+
"name": str(item.get("tool", "")),
|
|
1103
|
+
}
|
|
1104
|
+
for item in tool_batch.tool_results
|
|
1105
|
+
]
|
|
1106
|
+
if guard_directives:
|
|
1107
|
+
guard_text = "\n\n".join(guard_directives)
|
|
1108
|
+
if isinstance(user_message.get("content"), str):
|
|
1109
|
+
user_message["content"] += f"\n\n{guard_text}"
|
|
1110
|
+
elif isinstance(user_message.get("content"), list):
|
|
1111
|
+
user_message["content"].append({"type": "text", "text": guard_text})
|
|
1112
|
+
followup += f"\n\n{guard_text}"
|
|
1113
|
+
|
|
1114
|
+
return ToolExecutionTurnResult(
|
|
1115
|
+
batch=tool_batch,
|
|
1116
|
+
activities=activities,
|
|
1117
|
+
assistant_message=assistant_message,
|
|
1118
|
+
tool_messages=tool_messages,
|
|
1119
|
+
user_message=user_message,
|
|
1120
|
+
followup=followup,
|
|
1121
|
+
guard_directives=guard_directives,
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
# Cap each tool result so a single large output (a long pip-install log, a big
|
|
1126
|
+
# data dump, a verbose traceback) cannot blow past the model's context window.
|
|
1127
|
+
# Without this, one oversized result is appended verbatim, the next provider
|
|
1128
|
+
# call exceeds num_ctx, the prompt is truncated, and the model loses the task
|
|
1129
|
+
# mid-run. Head+tail keeps the actionable parts (what ran / the final error)
|
|
1130
|
+
# and drops the noisy middle.
|
|
1131
|
+
_MAX_TOOL_RESULT_CHARS = 6000
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _truncate_tool_result(text: str, limit: int = _MAX_TOOL_RESULT_CHARS) -> str:
|
|
1135
|
+
if len(text) <= limit:
|
|
1136
|
+
return text
|
|
1137
|
+
head = limit * 2 // 3
|
|
1138
|
+
tail = limit - head
|
|
1139
|
+
omitted = len(text) - head - tail
|
|
1140
|
+
return (
|
|
1141
|
+
text[:head]
|
|
1142
|
+
+ f"\n\n… [已截断 {omitted:,} 字符 — 输出过长,仅保留首尾以保护上下文] …\n\n"
|
|
1143
|
+
+ text[-tail:]
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
_MAX_TEXT_TOOL_CALL_RETRIES = 2
|
|
1148
|
+
|
|
1149
|
+
# What a reply looks like when the model wrote tool calls instead of making
|
|
1150
|
+
# them: a raw call tag, Gemini's "default_api:" call syntax, or an imitation of
|
|
1151
|
+
# the "## Tool Results" block build_tool_followup produces.
|
|
1152
|
+
_TEXT_TOOL_CALL = re.compile(
|
|
1153
|
+
r"<tool_call>|\bdefault_api[:.]\w|^#{2,3} ?Tool Results\b|^### \[\w+\] (?:✓ Success|❌ Error)",
|
|
1154
|
+
re.MULTILINE,
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
TEXT_TOOL_CALL_DIRECTIVE = (
|
|
1158
|
+
"Your last reply wrote tool calls (or tool results) as text. None of them "
|
|
1159
|
+
"were executed and no files were changed. Do not write calls or results "
|
|
1160
|
+
"out as text: call the tools through function calling, and only report "
|
|
1161
|
+
"results you actually received."
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
_CODE_SPAN = re.compile(r"```.*?(?:```|\Z)|`[^`\n]*`", re.DOTALL)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
_MAX_PROMPT_ECHO_RETRIES = 1
|
|
1169
|
+
|
|
1170
|
+
# A reply after a tool round that is an instruction to the assistant rather
|
|
1171
|
+
# than an answer to the user: the model continued our follow-up message (the
|
|
1172
|
+
# reply even starts with the space that would separate the two) instead of
|
|
1173
|
+
# acting on it. Seen on Gemini: " Do not stop until all necessary actions have
|
|
1174
|
+
# been performed." ended a turn as its final answer with nothing changed.
|
|
1175
|
+
_PROMPT_ECHO_MAX_CHARS = 240
|
|
1176
|
+
_PROMPT_ECHO = re.compile(
|
|
1177
|
+
r"^(?:do not stop|don't stop|if you are (?:finished|done)|if the task is"
|
|
1178
|
+
r"|continue (?:using|with|the task)|use (?:the )?tools|you (?:must|should) (?:continue|now)"
|
|
1179
|
+
r"|make sure (?:to|you)|remember to)\b"
|
|
1180
|
+
r"|\b(?:necessary actions|your final (?:response|answer)|describe your findings"
|
|
1181
|
+
r"|continue using tools|\d+-\d+ sentences)\b",
|
|
1182
|
+
re.IGNORECASE,
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
PROMPT_ECHO_DIRECTIVE = (
|
|
1186
|
+
"Your last reply was an instruction, not an answer: it reads like a "
|
|
1187
|
+
"continuation of the prompt, and the task is not finished. Carry on with "
|
|
1188
|
+
"the user's request — call the tools you need. When the work is actually "
|
|
1189
|
+
"done, reply with what you changed and how you checked it."
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def looks_like_prompt_echo(text: str) -> bool:
|
|
1194
|
+
"""True when a short final reply is an instruction aimed at the assistant.
|
|
1195
|
+
|
|
1196
|
+
Only short, single-paragraph replies qualify: a real answer that happens
|
|
1197
|
+
to say "make sure to run the tests" is longer, or follows a summary.
|
|
1198
|
+
"""
|
|
1199
|
+
stripped = (text or "").strip()
|
|
1200
|
+
if not stripped or len(stripped) > _PROMPT_ECHO_MAX_CHARS or "\n\n" in stripped:
|
|
1201
|
+
return False
|
|
1202
|
+
return bool(_PROMPT_ECHO.search(_CODE_SPAN.sub("", stripped)))
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def looks_like_text_tool_calls(text: str) -> bool:
|
|
1206
|
+
"""True when a reply with no real tool calls contains written-out ones.
|
|
1207
|
+
|
|
1208
|
+
Code spans are ignored, so an answer that quotes these formats — this
|
|
1209
|
+
being a coding agent, a repo can well contain them — is not mistaken
|
|
1210
|
+
for one.
|
|
1211
|
+
"""
|
|
1212
|
+
return bool(text) and bool(_TEXT_TOOL_CALL.search(_CODE_SPAN.sub("", text)))
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def build_tool_followup(tool_results: Sequence[dict]) -> str:
|
|
1216
|
+
"""Build a structured follow-up message from tool results.
|
|
1217
|
+
|
|
1218
|
+
Each result block is labelled with its tool name and a success/error
|
|
1219
|
+
status so the model can clearly distinguish outcomes and respond
|
|
1220
|
+
appropriately to failures rather than silently ignoring them. Each result
|
|
1221
|
+
is size-capped (see ``_truncate_tool_result``) so a single huge output
|
|
1222
|
+
cannot overflow the context window and cut the task short.
|
|
1223
|
+
"""
|
|
1224
|
+
if not tool_results:
|
|
1225
|
+
return "No tool results. Continue with what you know or ask the user for clarification."
|
|
1226
|
+
|
|
1227
|
+
blocks: List[str] = []
|
|
1228
|
+
error_tools: List[str] = []
|
|
1229
|
+
|
|
1230
|
+
for item in tool_results:
|
|
1231
|
+
tool = item.get("tool", "unknown")
|
|
1232
|
+
result = item.get("result", "")
|
|
1233
|
+
result_str = _truncate_tool_result(str(result))
|
|
1234
|
+
|
|
1235
|
+
is_error = (
|
|
1236
|
+
result_str.startswith("Error") or
|
|
1237
|
+
result_str.startswith("❌") or
|
|
1238
|
+
"error" in result_str[:80].lower() or
|
|
1239
|
+
"traceback" in result_str[:200].lower() or
|
|
1240
|
+
"exception" in result_str[:200].lower()
|
|
1241
|
+
)
|
|
1242
|
+
status = "❌ Error" if is_error else "✓ Success"
|
|
1243
|
+
if is_error:
|
|
1244
|
+
error_tools.append(tool)
|
|
1245
|
+
blocks.append(f"### [{tool}] {status}\n{result_str}")
|
|
1246
|
+
|
|
1247
|
+
followup = "## Tool Results\n\n" + "\n\n---\n\n".join(blocks)
|
|
1248
|
+
|
|
1249
|
+
if error_tools:
|
|
1250
|
+
healing_directives = []
|
|
1251
|
+
import re as _re_heal
|
|
1252
|
+
for item in tool_results:
|
|
1253
|
+
if item.get("tool") == "run_command":
|
|
1254
|
+
res_text = str(item.get("result", ""))
|
|
1255
|
+
if "Traceback" in res_text or "Error:" in res_text:
|
|
1256
|
+
m_line = _re_heal.search(r'File ["\']([^"\']+)["\'], line (\d+)', res_text)
|
|
1257
|
+
if m_line:
|
|
1258
|
+
fpath, lineno = m_line.group(1), m_line.group(2)
|
|
1259
|
+
healing_directives.append(
|
|
1260
|
+
f"📌 [Self-Healing Directive] Error located at `{fpath}` line {lineno}. "
|
|
1261
|
+
f"Use `edit_file` to patch this specific line/block rather than rewriting the entire file. "
|
|
1262
|
+
f"Then re-run `run_command` to verify the fix."
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
followup += (
|
|
1266
|
+
f"\n\n⚠ Tool(s) returned errors: {', '.join(error_tools)}. "
|
|
1267
|
+
"Read the error carefully. Options: (1) use read_file / search_code to diagnose, "
|
|
1268
|
+
"(2) use edit_file to fix the issue and retry run_command, "
|
|
1269
|
+
"(3) try a different approach. "
|
|
1270
|
+
"Do NOT give up silently — explain what failed and what you tried."
|
|
1271
|
+
)
|
|
1272
|
+
if healing_directives:
|
|
1273
|
+
followup += "\n\n" + "\n".join(healing_directives)
|
|
1274
|
+
else:
|
|
1275
|
+
followup += (
|
|
1276
|
+
"\n\nAll tools completed successfully. "
|
|
1277
|
+
"If the task is now complete, provide your final response. "
|
|
1278
|
+
"If additional steps are needed, continue using tools.\n\n"
|
|
1279
|
+
"Please continue your analysis using these results."
|
|
1280
|
+
)
|
|
1281
|
+
|
|
1282
|
+
return followup
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def record_tool_result(
|
|
1286
|
+
tool_results: List[dict],
|
|
1287
|
+
tool_name: str,
|
|
1288
|
+
result: dict,
|
|
1289
|
+
formatter: SummaryFormatter,
|
|
1290
|
+
) -> dict:
|
|
1291
|
+
"""Append one tool result summary and return the appended record."""
|
|
1292
|
+
# Keep image bytes out of text summaries and attach only to this batch.
|
|
1293
|
+
clean_result = {key: value for key, value in result.items() if key != "_vision_image"}
|
|
1294
|
+
summary = formatter(tool_name, clean_result)
|
|
1295
|
+
record = {"tool": tool_name, "result": summary}
|
|
1296
|
+
if result.get("success") and result.get("_vision_image"):
|
|
1297
|
+
record["_vision_image"] = result["_vision_image"]
|
|
1298
|
+
tool_results.append(record)
|
|
1299
|
+
return record
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def build_next_turn_messages(total_response: str, tool_results: Sequence[dict]) -> Tuple[dict, dict, str]:
|
|
1303
|
+
"""Build messages with images belonging only to this tool batch."""
|
|
1304
|
+
followup = build_tool_followup(tool_results)
|
|
1305
|
+
assistant_message = {"role": "assistant", "content": total_response}
|
|
1306
|
+
images = [item["_vision_image"] for item in tool_results if item.get("_vision_image")]
|
|
1307
|
+
user_content = followup
|
|
1308
|
+
if images:
|
|
1309
|
+
user_content = [
|
|
1310
|
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{value}"}}
|
|
1311
|
+
for value in images
|
|
1312
|
+
] + [{"type": "text", "text": followup}]
|
|
1313
|
+
|
|
1314
|
+
user_message = {"role": "user", "content": user_content}
|
|
1315
|
+
return assistant_message, user_message, followup
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
# ── AgentEvent typed union ────────────────────────────────────────────────────
|
|
1319
|
+
|
|
1320
|
+
@dataclass(frozen=True)
|
|
1321
|
+
class AgentEventToken:
|
|
1322
|
+
"""A text token streamed from the model."""
|
|
1323
|
+
text: str
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
@dataclass(frozen=True)
|
|
1327
|
+
class AgentEventThinking:
|
|
1328
|
+
"""A thinking/reasoning token from extended-thinking models."""
|
|
1329
|
+
content: str
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
@dataclass(frozen=True)
|
|
1333
|
+
class AgentEventToolCall:
|
|
1334
|
+
"""Model requested a tool call (before execution)."""
|
|
1335
|
+
tool: str
|
|
1336
|
+
params: dict
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
@dataclass(frozen=True)
|
|
1340
|
+
class AgentEventToolResult:
|
|
1341
|
+
"""One tool has finished executing."""
|
|
1342
|
+
tool: str
|
|
1343
|
+
result: dict
|
|
1344
|
+
elapsed: float
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
@dataclass(frozen=True)
|
|
1348
|
+
class AgentEventStatus:
|
|
1349
|
+
"""Informational status change (e.g. provider fallback)."""
|
|
1350
|
+
state: str
|
|
1351
|
+
message: str
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
@dataclass(frozen=True)
|
|
1355
|
+
class AgentEventComplete:
|
|
1356
|
+
"""Agent loop finished normally. Carries the full turn result."""
|
|
1357
|
+
result: "AgentTurnResult"
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
@dataclass(frozen=True)
|
|
1361
|
+
class AgentEventCancelled:
|
|
1362
|
+
"""Agent loop was cancelled by the user."""
|
|
1363
|
+
partial_text: str
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
@dataclass(frozen=True)
|
|
1367
|
+
class AgentEventError:
|
|
1368
|
+
"""Agent loop encountered an unrecoverable error."""
|
|
1369
|
+
error: str
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
AgentEvent = Union[
|
|
1373
|
+
AgentEventToken,
|
|
1374
|
+
AgentEventThinking,
|
|
1375
|
+
AgentEventToolCall,
|
|
1376
|
+
AgentEventToolResult,
|
|
1377
|
+
AgentEventStatus,
|
|
1378
|
+
AgentEventComplete,
|
|
1379
|
+
AgentEventCancelled,
|
|
1380
|
+
AgentEventError,
|
|
1381
|
+
]
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
# ── AgentOptions ──────────────────────────────────────────────────────────────
|
|
1385
|
+
|
|
1386
|
+
@dataclass
|
|
1387
|
+
class AgentOptions:
|
|
1388
|
+
"""Tunable parameters for one run_agent() invocation."""
|
|
1389
|
+
|
|
1390
|
+
max_rounds: int = 30
|
|
1391
|
+
serial_tools: FrozenSet[str] = field(
|
|
1392
|
+
default_factory=lambda: frozenset(DEFAULT_SERIAL_TOOLS)
|
|
1393
|
+
)
|
|
1394
|
+
tool_schemas: List[dict] = field(default_factory=list)
|
|
1395
|
+
# Interactive tool approval (UI adapters inject these; None = execute
|
|
1396
|
+
# without prompting, matching the historical embedded-loop default only
|
|
1397
|
+
# for callers that never wire an approval UI).
|
|
1398
|
+
confirm_tools: FrozenSet[str] = field(default_factory=frozenset)
|
|
1399
|
+
approval_callback: Optional[ApprovalCallback] = None
|
|
1400
|
+
approval_applier: Optional[Callable[[dict, "ApprovalDecision"], dict]] = None
|
|
1401
|
+
requires_evidence: bool = False
|
|
1402
|
+
grounding_tools: FrozenSet[str] = field(default_factory=frozenset)
|
|
1403
|
+
evidence_already_grounded: bool = False
|
|
1404
|
+
# 花费上限。None = 不追踪(保持既有行为,调用方不传就跟以前一模一样)。
|
|
1405
|
+
# 传入一个 BudgetTracker 后,循环会在**每轮开始前**检查,超限则停止并把
|
|
1406
|
+
# 原因写进 result["budget_paused"],而不是抛异常——抛异常会让调用方拿不到
|
|
1407
|
+
# 已经产出的中间结果。
|
|
1408
|
+
budget: Optional["BudgetTracker"] = None
|
|
1409
|
+
# 验收闸门。None = 不验收(保持既有行为)。传入一个 AcceptanceGate 后,
|
|
1410
|
+
# 只要本轮真的改写了磁盘上的文件,模型宣称完成时循环会先跑一遍推断出的
|
|
1411
|
+
# 检查命令;红了就把失败输出回灌给模型继续修,绿了才让这一轮结束。
|
|
1412
|
+
acceptance: Optional["AcceptanceGate"] = None
|
|
1413
|
+
# Change contract. None = no task bounds beyond the permission mode. With
|
|
1414
|
+
# one, the model is shown it before the first round and the runtime
|
|
1415
|
+
# refuses every tool call that breaks it (see runtime/contract.py).
|
|
1416
|
+
contract: Optional["ChangeContract"] = None
|
|
1417
|
+
# Independent review. None = none. With a ReviewGate, a turn that changed
|
|
1418
|
+
# files is read by a fresh-context reviewer (goal, contract, checks, diff —
|
|
1419
|
+
# not this transcript) once its checks are green; blocking findings go
|
|
1420
|
+
# back to the model like red checks do (see runtime/review.py).
|
|
1421
|
+
review: Optional["ReviewGate"] = None
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
# ── run_agent() ───────────────────────────────────────────────────────────────
|
|
1425
|
+
|
|
1426
|
+
async def run_agent(
|
|
1427
|
+
prompt: str,
|
|
1428
|
+
history: list,
|
|
1429
|
+
*,
|
|
1430
|
+
provider_fn: Callable,
|
|
1431
|
+
tool_executor: ToolExecutor,
|
|
1432
|
+
options: Optional["AgentOptions"] = None,
|
|
1433
|
+
remote_runner: Optional[RemoteToolRunner] = None,
|
|
1434
|
+
on_token: Optional[Callable[[str], None]] = None,
|
|
1435
|
+
on_thinking: Optional[Callable[[str], None]] = None,
|
|
1436
|
+
on_tool_call: Optional[Callable[[str, dict], None]] = None,
|
|
1437
|
+
on_tool_result: Optional[Callable[[str, dict], None]] = None,
|
|
1438
|
+
on_status: Optional[Callable[[str, str], None]] = None,
|
|
1439
|
+
hook: Optional[Hook] = None,
|
|
1440
|
+
cancel_event: Optional[asyncio.Event] = None,
|
|
1441
|
+
tool_result_formatter: Optional[SummaryFormatter] = None,
|
|
1442
|
+
) -> AsyncGenerator["AgentEvent", None]:
|
|
1443
|
+
"""Provider-agnostic multi-round agent loop.
|
|
1444
|
+
|
|
1445
|
+
Yields ``AgentEvent`` objects so every caller (REPL, bot, API) can
|
|
1446
|
+
handle UI in its own way without duplicating round-management logic.
|
|
1447
|
+
|
|
1448
|
+
Parameters
|
|
1449
|
+
----------
|
|
1450
|
+
prompt:
|
|
1451
|
+
The user's message for this turn.
|
|
1452
|
+
history:
|
|
1453
|
+
Conversation history **before** the current prompt.
|
|
1454
|
+
provider_fn:
|
|
1455
|
+
Async callable ``(message, history, on_token, on_thinking,
|
|
1456
|
+
on_tool_call, cancel_event) -> dict``. Must return the same
|
|
1457
|
+
result dict that ``stream_ollama`` / ``stream_chat`` return.
|
|
1458
|
+
tool_executor:
|
|
1459
|
+
Local tool registry.
|
|
1460
|
+
options:
|
|
1461
|
+
Tunable loop parameters (max_rounds, serial_tools, …).
|
|
1462
|
+
remote_runner:
|
|
1463
|
+
Optional async callable for tools not in ``tool_executor``.
|
|
1464
|
+
on_token / on_thinking / on_tool_call / on_tool_result / on_status:
|
|
1465
|
+
Pass-through callbacks forwarded to ``provider_fn`` so callers
|
|
1466
|
+
that already set up streaming callbacks don't need to change.
|
|
1467
|
+
hook:
|
|
1468
|
+
Pre/post-tool hook fired around each tool execution.
|
|
1469
|
+
cancel_event:
|
|
1470
|
+
asyncio.Event; when set the loop exits at the next safe point.
|
|
1471
|
+
tool_result_formatter:
|
|
1472
|
+
Formats a tool result dict into a summary string. Defaults to
|
|
1473
|
+
``str(result.get('output', result))``.
|
|
1474
|
+
"""
|
|
1475
|
+
opts = options or AgentOptions()
|
|
1476
|
+
_formatter: SummaryFormatter = tool_result_formatter or (
|
|
1477
|
+
lambda _tool, res: str(res.get("output", res))
|
|
1478
|
+
)
|
|
1479
|
+
_serial = set(opts.serial_tools)
|
|
1480
|
+
|
|
1481
|
+
turn_state = AgentTurnState(provider="unknown", _budget=opts.budget)
|
|
1482
|
+
start_time = time.time()
|
|
1483
|
+
current_message = prompt
|
|
1484
|
+
if opts.requires_evidence:
|
|
1485
|
+
available_grounding_tools = ", ".join(sorted(opts.grounding_tools)[:20]) or "(none)"
|
|
1486
|
+
current_message = (
|
|
1487
|
+
"[Grounding requirement]\n"
|
|
1488
|
+
"This request requires current, auditable financial evidence. Call at least "
|
|
1489
|
+
"one applicable grounding tool before producing a market conclusion. If the "
|
|
1490
|
+
"tool fails or data is unavailable, report that limitation and do not infer "
|
|
1491
|
+
"prices, metrics, recommendations, or forecasts.\n"
|
|
1492
|
+
f"Grounding tools: {available_grounding_tools}\n\n"
|
|
1493
|
+
f"[User request]\n{prompt}"
|
|
1494
|
+
)
|
|
1495
|
+
if opts.contract is not None:
|
|
1496
|
+
request = current_message if opts.requires_evidence else f"[User request]\n{prompt}"
|
|
1497
|
+
current_message = f"{opts.contract.prompt_block()}\n\n{request}"
|
|
1498
|
+
contract_refusals: List[dict] = []
|
|
1499
|
+
reviewed_diff = ""
|
|
1500
|
+
ledger = DeliveryLedger(root=str(
|
|
1501
|
+
getattr(opts.acceptance, "root", None) or getattr(opts.contract, "root", None) or "") or None)
|
|
1502
|
+
token_count = 0
|
|
1503
|
+
thinking_tokens = 0
|
|
1504
|
+
result: dict = {}
|
|
1505
|
+
loop_guard = LoopGuard()
|
|
1506
|
+
grounded_results = 1 if opts.evidence_already_grounded else 0
|
|
1507
|
+
stop_reason = "max_rounds"
|
|
1508
|
+
text_tool_call_retries = 0
|
|
1509
|
+
prompt_echo_retries = 0
|
|
1510
|
+
|
|
1511
|
+
for round_num in range(opts.max_rounds):
|
|
1512
|
+
# ── 预算闸门 ─────────────────────────────────────────────────────────
|
|
1513
|
+
# 放在发起调用**之前**:花完才发现超支,每次都会多花一轮,而这一轮
|
|
1514
|
+
# 恰恰可能是塞了满上下文的那一轮。
|
|
1515
|
+
if opts.budget is not None and not opts.budget.should_continue(
|
|
1516
|
+
projected_usd=opts.budget.projected_next_round_usd()
|
|
1517
|
+
):
|
|
1518
|
+
# 本模块不持有 logger(全靠 yield 事件对外汇报),所以暂停原因
|
|
1519
|
+
# 只写进 result,由调用方决定怎么呈现——CLI 会打印,后台任务会
|
|
1520
|
+
# 记进 task ledger。
|
|
1521
|
+
reason = opts.budget.paused_reason or "budget exhausted"
|
|
1522
|
+
result["budget_paused"] = reason
|
|
1523
|
+
result["budget_paused_round"] = round_num + 1
|
|
1524
|
+
# 走既有的 hook 通道对外汇报,不新造一条:CLI 已经把 hook 接到
|
|
1525
|
+
# RuntimeTrace,事件会自动进入 /trace 和跨会话的 run_store,
|
|
1526
|
+
# 花费停在哪一轮、当时用量多少事后可查。
|
|
1527
|
+
if hook is not None:
|
|
1528
|
+
hook("budget_paused", "budget", {
|
|
1529
|
+
"round": round_num + 1,
|
|
1530
|
+
"reason": reason,
|
|
1531
|
+
"spent_usd": round(opts.budget.state.spent_usd, 6),
|
|
1532
|
+
"total_tokens": opts.budget.state.total_tokens,
|
|
1533
|
+
"per_provider": dict(opts.budget.state.per_provider),
|
|
1534
|
+
}, None)
|
|
1535
|
+
result["budget_summary"] = opts.budget.summary()
|
|
1536
|
+
stop_reason = "budget_exhausted"
|
|
1537
|
+
yield AgentEventStatus(state=stop_reason, message=reason)
|
|
1538
|
+
break
|
|
1539
|
+
|
|
1540
|
+
if opts.budget is not None:
|
|
1541
|
+
opts.budget.record_round()
|
|
1542
|
+
|
|
1543
|
+
# ── Provider call ────────────────────────────────────────────────────
|
|
1544
|
+
response_text = ""
|
|
1545
|
+
_round_tokens = 0
|
|
1546
|
+
|
|
1547
|
+
def _wrap_on_token(tok: str) -> None:
|
|
1548
|
+
nonlocal response_text, token_count, _round_tokens
|
|
1549
|
+
response_text += tok
|
|
1550
|
+
_round_tokens += 1
|
|
1551
|
+
token_count += 1
|
|
1552
|
+
if on_token is not None and (not opts.requires_evidence or grounded_results > 0):
|
|
1553
|
+
on_token(tok)
|
|
1554
|
+
|
|
1555
|
+
def _wrap_on_thinking(content: str) -> None:
|
|
1556
|
+
nonlocal thinking_tokens
|
|
1557
|
+
thinking_tokens += 1
|
|
1558
|
+
if on_thinking is not None:
|
|
1559
|
+
on_thinking(content)
|
|
1560
|
+
|
|
1561
|
+
def _wrap_on_tool_call(tool: str, params: dict) -> None:
|
|
1562
|
+
if on_tool_call is not None:
|
|
1563
|
+
on_tool_call(tool, params)
|
|
1564
|
+
|
|
1565
|
+
try:
|
|
1566
|
+
result = await provider_fn(
|
|
1567
|
+
current_message,
|
|
1568
|
+
history,
|
|
1569
|
+
on_token=_wrap_on_token,
|
|
1570
|
+
on_thinking=_wrap_on_thinking,
|
|
1571
|
+
on_tool_call=_wrap_on_tool_call,
|
|
1572
|
+
on_tool_result=on_tool_result,
|
|
1573
|
+
on_status=on_status,
|
|
1574
|
+
cancel_event=cancel_event,
|
|
1575
|
+
)
|
|
1576
|
+
except Exception as exc:
|
|
1577
|
+
yield AgentEventError(error=str(exc))
|
|
1578
|
+
return
|
|
1579
|
+
|
|
1580
|
+
if result.get("cancelled"):
|
|
1581
|
+
turn_state.append_response(response_text)
|
|
1582
|
+
yield AgentEventCancelled(partial_text=turn_state.total_response)
|
|
1583
|
+
return
|
|
1584
|
+
|
|
1585
|
+
if not result.get("success"):
|
|
1586
|
+
yield AgentEventError(error=result.get("error", "Unknown error"))
|
|
1587
|
+
return
|
|
1588
|
+
|
|
1589
|
+
turn_state.apply_model_result(result, response_text)
|
|
1590
|
+
|
|
1591
|
+
pending = result.get("tool_calls_pending", [])
|
|
1592
|
+
if not pending and looks_like_text_tool_calls(result.get("response") or response_text):
|
|
1593
|
+
# The model wrote its calls (and often their "results") as prose:
|
|
1594
|
+
# nothing ran. Ending here reported a task as done — sometimes with
|
|
1595
|
+
# tests "passing" — while no file had been touched. Say so and
|
|
1596
|
+
# give it the round back; if it keeps doing it, end incomplete.
|
|
1597
|
+
text_tool_call_retries += 1
|
|
1598
|
+
if text_tool_call_retries > _MAX_TEXT_TOOL_CALL_RETRIES:
|
|
1599
|
+
stop_reason = "text_tool_calls"
|
|
1600
|
+
yield AgentEventStatus(
|
|
1601
|
+
state=stop_reason,
|
|
1602
|
+
message="Model kept writing tool calls as text; task remains incomplete",
|
|
1603
|
+
)
|
|
1604
|
+
turn_state.append_response(
|
|
1605
|
+
"\n\nThe model kept writing tool calls as text instead of "
|
|
1606
|
+
"calling the tools; none of them were executed."
|
|
1607
|
+
)
|
|
1608
|
+
break
|
|
1609
|
+
yield AgentEventStatus(
|
|
1610
|
+
state="text_tool_calls",
|
|
1611
|
+
message="Model wrote tool calls as text; asking it to call them",
|
|
1612
|
+
)
|
|
1613
|
+
history = list(history) + [
|
|
1614
|
+
{"role": "user", "content": current_message},
|
|
1615
|
+
{"role": "assistant", "content": turn_state.total_response},
|
|
1616
|
+
]
|
|
1617
|
+
current_message = TEXT_TOOL_CALL_DIRECTIVE
|
|
1618
|
+
turn_state.reset_response()
|
|
1619
|
+
continue
|
|
1620
|
+
if (not pending and round_num > 0
|
|
1621
|
+
and looks_like_prompt_echo(result.get("response") or response_text)):
|
|
1622
|
+
# After a tool round the model "answered" by continuing our own
|
|
1623
|
+
# follow-up instead of acting on it. Accepting that ended tasks
|
|
1624
|
+
# as completed with nothing changed. One more round, then incomplete.
|
|
1625
|
+
prompt_echo_retries += 1
|
|
1626
|
+
if prompt_echo_retries > _MAX_PROMPT_ECHO_RETRIES:
|
|
1627
|
+
stop_reason = "prompt_echo"
|
|
1628
|
+
yield AgentEventStatus(
|
|
1629
|
+
state=stop_reason,
|
|
1630
|
+
message="Model replied with an instruction instead of an answer; task remains incomplete",
|
|
1631
|
+
)
|
|
1632
|
+
turn_state.append_response(
|
|
1633
|
+
"\n\nThe model replied with an instruction instead of an answer "
|
|
1634
|
+
"and did not continue the task."
|
|
1635
|
+
)
|
|
1636
|
+
break
|
|
1637
|
+
yield AgentEventStatus(
|
|
1638
|
+
state="prompt_echo",
|
|
1639
|
+
message="Model replied with an instruction instead of an answer; asking it to continue",
|
|
1640
|
+
)
|
|
1641
|
+
history = list(history) + [
|
|
1642
|
+
{"role": "user", "content": current_message},
|
|
1643
|
+
{"role": "assistant", "content": turn_state.total_response},
|
|
1644
|
+
]
|
|
1645
|
+
current_message = PROMPT_ECHO_DIRECTIVE
|
|
1646
|
+
turn_state.reset_response()
|
|
1647
|
+
continue
|
|
1648
|
+
if not pending:
|
|
1649
|
+
# ── 验收闸门 ─────────────────────────────────────────────────────
|
|
1650
|
+
# 模型不再要工具 = 它认为做完了。这是唯一一个「宣称完成」的出口,
|
|
1651
|
+
# 所以检查必须挂在这里:挂在写文件之后太早(改到一半必然是红的),
|
|
1652
|
+
# 挂在循环结束之后太晚(那时已经没有轮次可以拿来修了)。
|
|
1653
|
+
if opts.acceptance is not None and opts.acceptance.should_run():
|
|
1654
|
+
report = await opts.acceptance.run()
|
|
1655
|
+
if report is not None:
|
|
1656
|
+
yield AgentEventStatus(
|
|
1657
|
+
state="acceptance_passed" if report.passed else "acceptance_failed",
|
|
1658
|
+
message=report.headline(),
|
|
1659
|
+
)
|
|
1660
|
+
if hook is not None:
|
|
1661
|
+
hook("acceptance", "verify", report.summary(), None)
|
|
1662
|
+
# A check red only where it was red before the change
|
|
1663
|
+
# (runtime/baseline.py) is not this task's to repair.
|
|
1664
|
+
if report.ran and not report.passed and report.regressed:
|
|
1665
|
+
# 把失败输出当成下一轮的用户消息回灌。走和工具结果
|
|
1666
|
+
# 完全相同的通道,模型不需要认识一种新的消息类型。
|
|
1667
|
+
history = list(history) + [
|
|
1668
|
+
{"role": "user", "content": current_message},
|
|
1669
|
+
{"role": "assistant", "content": turn_state.total_response},
|
|
1670
|
+
]
|
|
1671
|
+
current_message = report.repair_directive()
|
|
1672
|
+
turn_state.reset_response()
|
|
1673
|
+
continue
|
|
1674
|
+
# ── Independent review ───────────────────────────────────────────
|
|
1675
|
+
# After the checks, never instead of them: a red run is repaired
|
|
1676
|
+
# first, and reviewing code that does not pass its own tests spends
|
|
1677
|
+
# a model call to learn what the tests already said.
|
|
1678
|
+
if opts.review is not None:
|
|
1679
|
+
diff = ledger.diff_text()
|
|
1680
|
+
acceptance_state = opts.acceptance.summary() if opts.acceptance is not None else {}
|
|
1681
|
+
if acceptance_state.get("verified") is not False and opts.review.should_run(
|
|
1682
|
+
changed=ledger.changed, reviewed_diff=reviewed_diff, diff=diff):
|
|
1683
|
+
last_checks = (acceptance_state.get("reports") or [{}])[-1].get("checks") or []
|
|
1684
|
+
review = await opts.review.run(
|
|
1685
|
+
goal=prompt,
|
|
1686
|
+
diff=diff,
|
|
1687
|
+
checks=last_checks,
|
|
1688
|
+
contract=opts.contract.render() if opts.contract is not None else "",
|
|
1689
|
+
)
|
|
1690
|
+
reviewed_diff = diff
|
|
1691
|
+
yield AgentEventStatus(
|
|
1692
|
+
state={"pass": "review_passed", "blocking": "review_blocking"}.get(
|
|
1693
|
+
review.verdict, "review_error"),
|
|
1694
|
+
message=review.headline(),
|
|
1695
|
+
)
|
|
1696
|
+
if hook is not None:
|
|
1697
|
+
hook("review", "review", review.as_dict(), None)
|
|
1698
|
+
if review.blocking and opts.review.can_repair():
|
|
1699
|
+
history = list(history) + [
|
|
1700
|
+
{"role": "user", "content": current_message},
|
|
1701
|
+
{"role": "assistant", "content": turn_state.total_response},
|
|
1702
|
+
]
|
|
1703
|
+
current_message = review.repair_directive()
|
|
1704
|
+
turn_state.reset_response()
|
|
1705
|
+
continue
|
|
1706
|
+
if opts.requires_evidence and grounded_results == 0:
|
|
1707
|
+
yield AgentEventStatus(
|
|
1708
|
+
state="evidence_required",
|
|
1709
|
+
message="No usable financial evidence tool result was obtained",
|
|
1710
|
+
)
|
|
1711
|
+
yield AgentEventError(
|
|
1712
|
+
error=(
|
|
1713
|
+
"[ARIA-4223] Current verified financial data is required, "
|
|
1714
|
+
"but no usable evidence tool result was obtained."
|
|
1715
|
+
)
|
|
1716
|
+
)
|
|
1717
|
+
return
|
|
1718
|
+
stop_reason = "completed"
|
|
1719
|
+
break
|
|
1720
|
+
for tool_call in pending:
|
|
1721
|
+
yield AgentEventToolCall(
|
|
1722
|
+
tool=tool_call.get("tool", ""),
|
|
1723
|
+
params=tool_call.get("params", {}),
|
|
1724
|
+
)
|
|
1725
|
+
|
|
1726
|
+
# Warn caller on final round
|
|
1727
|
+
if round_num == opts.max_rounds - 1:
|
|
1728
|
+
yield AgentEventStatus(
|
|
1729
|
+
state="max_rounds",
|
|
1730
|
+
message=f"Max rounds ({opts.max_rounds}) reached",
|
|
1731
|
+
)
|
|
1732
|
+
break
|
|
1733
|
+
|
|
1734
|
+
# ── Tool execution ───────────────────────────────────────────────────
|
|
1735
|
+
tool_turn_result = await execute_tool_turn(
|
|
1736
|
+
pending,
|
|
1737
|
+
total_response=turn_state.total_response,
|
|
1738
|
+
tool_executor=tool_executor,
|
|
1739
|
+
formatter=_formatter,
|
|
1740
|
+
remote_runner=remote_runner,
|
|
1741
|
+
hook=hook,
|
|
1742
|
+
cancel_event=cancel_event,
|
|
1743
|
+
loop_guard=loop_guard,
|
|
1744
|
+
serial_tools=_serial,
|
|
1745
|
+
confirm_tools=opts.confirm_tools,
|
|
1746
|
+
approval_callback=opts.approval_callback,
|
|
1747
|
+
approval_applier=opts.approval_applier or apply_approval_decision,
|
|
1748
|
+
contract=opts.contract,
|
|
1749
|
+
)
|
|
1750
|
+
|
|
1751
|
+
for activity in tool_turn_result.activities:
|
|
1752
|
+
turn_state.tools_used.append(activity.tool)
|
|
1753
|
+
ledger.record(activity.tool, activity.params, activity.result)
|
|
1754
|
+
refusal = (activity.result or {}).get("contract_violation")
|
|
1755
|
+
if refusal:
|
|
1756
|
+
contract_refusals.append(dict(refusal))
|
|
1757
|
+
yield AgentEventStatus(
|
|
1758
|
+
state="contract_blocked",
|
|
1759
|
+
message=f"{activity.tool} refused by the change contract: {refusal.get('reason', '')}",
|
|
1760
|
+
)
|
|
1761
|
+
if opts.acceptance is not None:
|
|
1762
|
+
opts.acceptance.record_tool(activity.tool, activity.result)
|
|
1763
|
+
canonical_tool = str(activity.tool).rsplit("__", 1)[-1]
|
|
1764
|
+
allowed_tools = {
|
|
1765
|
+
str(name).rsplit("__", 1)[-1]
|
|
1766
|
+
for name in opts.grounding_tools
|
|
1767
|
+
}
|
|
1768
|
+
result_payload = dict(activity.result or {})
|
|
1769
|
+
usable_payload = {
|
|
1770
|
+
key: value
|
|
1771
|
+
for key, value in result_payload.items()
|
|
1772
|
+
if key not in {"success", "cached", "elapsed_ms"}
|
|
1773
|
+
}
|
|
1774
|
+
if (
|
|
1775
|
+
canonical_tool in allowed_tools
|
|
1776
|
+
and result_payload.get("success") is not False
|
|
1777
|
+
and not result_payload.get("error")
|
|
1778
|
+
and any(value not in (None, "", [], {}) for value in usable_payload.values())
|
|
1779
|
+
):
|
|
1780
|
+
grounded_results += 1
|
|
1781
|
+
yield AgentEventToolResult(
|
|
1782
|
+
tool=activity.tool,
|
|
1783
|
+
result=activity.result,
|
|
1784
|
+
elapsed=activity.elapsed,
|
|
1785
|
+
)
|
|
1786
|
+
|
|
1787
|
+
turn_state.add_tool_time(tool_turn_result.batch.elapsed_total)
|
|
1788
|
+
if tool_turn_result.cancelled:
|
|
1789
|
+
yield AgentEventCancelled(partial_text=turn_state.total_response)
|
|
1790
|
+
return
|
|
1791
|
+
if tool_turn_result.guard_directives:
|
|
1792
|
+
yield AgentEventStatus(
|
|
1793
|
+
state="loop_guard",
|
|
1794
|
+
message="Repeated failing tool call detected",
|
|
1795
|
+
)
|
|
1796
|
+
|
|
1797
|
+
history = list(history) + [
|
|
1798
|
+
{"role": "user", "content": current_message},
|
|
1799
|
+
tool_turn_result.assistant_message,
|
|
1800
|
+
*tool_turn_result.tool_messages,
|
|
1801
|
+
]
|
|
1802
|
+
current_message = tool_turn_result.followup
|
|
1803
|
+
turn_state.reset_response()
|
|
1804
|
+
|
|
1805
|
+
if loop_guard.should_break:
|
|
1806
|
+
stop_reason = "loop_guard"
|
|
1807
|
+
yield AgentEventStatus(
|
|
1808
|
+
state=stop_reason, message="Repeated failing tool calls; task remains incomplete"
|
|
1809
|
+
)
|
|
1810
|
+
turn_state.append_response(
|
|
1811
|
+
"\n\nRepeated failing tool calls were detected and the agent stopped retrying."
|
|
1812
|
+
)
|
|
1813
|
+
break
|
|
1814
|
+
|
|
1815
|
+
# ── Build final result ───────────────────────────────────────────────────
|
|
1816
|
+
elapsed = time.time() - start_time
|
|
1817
|
+
acceptance_summary = (
|
|
1818
|
+
opts.acceptance.summary()
|
|
1819
|
+
if opts.acceptance is not None and opts.acceptance.reports else None
|
|
1820
|
+
)
|
|
1821
|
+
if stop_reason == "completed" and acceptance_summary and acceptance_summary.get("verified") is False:
|
|
1822
|
+
stop_reason = "checks_failed"
|
|
1823
|
+
contract_summary = (
|
|
1824
|
+
{
|
|
1825
|
+
"goal": opts.contract.goal,
|
|
1826
|
+
"source": opts.contract.source,
|
|
1827
|
+
"text": opts.contract.render(),
|
|
1828
|
+
"refused": contract_refusals,
|
|
1829
|
+
}
|
|
1830
|
+
if opts.contract is not None else None
|
|
1831
|
+
)
|
|
1832
|
+
delivery = ledger.report(
|
|
1833
|
+
acceptance=acceptance_summary,
|
|
1834
|
+
contract=contract_summary,
|
|
1835
|
+
review=opts.review.summary() if opts.review is not None else None,
|
|
1836
|
+
stop_reason=stop_reason,
|
|
1837
|
+
)
|
|
1838
|
+
turn_result = turn_state.build_result(
|
|
1839
|
+
elapsed=elapsed,
|
|
1840
|
+
success=stop_reason == "completed",
|
|
1841
|
+
error="" if stop_reason == "completed" else stop_reason,
|
|
1842
|
+
stop_reason=stop_reason,
|
|
1843
|
+
fallback_response=result.get("response", ""),
|
|
1844
|
+
token_count=token_count,
|
|
1845
|
+
thinking_tokens=thinking_tokens,
|
|
1846
|
+
acceptance=acceptance_summary,
|
|
1847
|
+
contract=contract_summary,
|
|
1848
|
+
delivery=delivery.as_dict() if delivery.worth_showing else None,
|
|
1849
|
+
)
|
|
1850
|
+
yield AgentEventComplete(result=turn_result)
|
|
1851
|
+
|