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.
Files changed (601) hide show
  1. aria_bots/__init__.py +7 -0
  2. aria_bots/channels/README.md +97 -0
  3. aria_bots/channels/__init__.py +1 -0
  4. aria_bots/channels/approvals.py +263 -0
  5. aria_bots/channels/conversation.py +280 -0
  6. aria_bots/channels/digest.py +186 -0
  7. aria_bots/channels/registry.py +95 -0
  8. aria_bots/engine.py +144 -0
  9. aria_bots/feishu_bot.py +1385 -0
  10. aria_bots/logistics/__init__.py +1 -0
  11. aria_bots/logistics/carriers.py +253 -0
  12. aria_bots/logistics/freight.py +154 -0
  13. aria_bots/logistics/inventory.py +242 -0
  14. aria_bots/logistics/mcp_server.py +156 -0
  15. aria_bots/logistics/records.py +119 -0
  16. aria_bots/logistics/tenancy.py +152 -0
  17. aria_bots/market.py +91 -0
  18. aria_bots/relay_client.py +326 -0
  19. aria_bots/telegram_bot.py +299 -0
  20. aria_code/__init__.py +111 -0
  21. aria_code/_bin/aria-native.exe +0 -0
  22. aria_code/_bin/aria-runtime.exe +0 -0
  23. aria_code/_version.py +20 -0
  24. aria_code/account_memory.py +296 -0
  25. aria_code/account_session.py +166 -0
  26. aria_code/adk_apps/__init__.py +1 -0
  27. aria_code/adk_apps/aria_code_review/__init__.py +1 -0
  28. aria_code/adk_apps/aria_code_review/agent.py +32 -0
  29. aria_code/adk_apps/aria_research/__init__.py +1 -0
  30. aria_code/adk_apps/aria_research/agent.py +31 -0
  31. aria_code/agents/__init__.py +49 -0
  32. aria_code/agents/base.py +329 -0
  33. aria_code/agents/code_review.py +200 -0
  34. aria_code/agents/deep/__init__.py +37 -0
  35. aria_code/agents/deep/calibration_loop.py +144 -0
  36. aria_code/agents/deep/critic.py +125 -0
  37. aria_code/agents/deep/deepen.py +193 -0
  38. aria_code/agents/deep/models.py +149 -0
  39. aria_code/agents/deep/pipeline.py +164 -0
  40. aria_code/agents/deep/quant_fusion.py +192 -0
  41. aria_code/agents/deep/themes.py +95 -0
  42. aria_code/agents/deep/tiers.py +106 -0
  43. aria_code/agents/engineering/__init__.py +14 -0
  44. aria_code/agents/engineering/coder.py +222 -0
  45. aria_code/agents/engineering/tester.py +145 -0
  46. aria_code/agents/financial/__init__.py +21 -0
  47. aria_code/agents/financial/cashflow_burn.py +119 -0
  48. aria_code/agents/financial/catalyst.py +279 -0
  49. aria_code/agents/financial/corporate_finance.py +175 -0
  50. aria_code/agents/financial/debate.py +224 -0
  51. aria_code/agents/financial/earnings.py +315 -0
  52. aria_code/agents/financial/fundamental.py +178 -0
  53. aria_code/agents/financial/macro.py +111 -0
  54. aria_code/agents/financial/news.py +231 -0
  55. aria_code/agents/financial/risk.py +146 -0
  56. aria_code/agents/financial/sector.py +279 -0
  57. aria_code/agents/financial/strategist.py +121 -0
  58. aria_code/agents/financial/stripe_revenue.py +127 -0
  59. aria_code/agents/financial/synthesis.py +274 -0
  60. aria_code/agents/financial/technical.py +277 -0
  61. aria_code/agents/orchestrator.py +44 -0
  62. aria_code/agents/portfolio_agent.py +392 -0
  63. aria_code/agents/realty/__init__.py +62 -0
  64. aria_code/agents/realty/asset_diagnosis.py +152 -0
  65. aria_code/agents/realty/business_match.py +166 -0
  66. aria_code/agents/realty/cashflow_verify.py +209 -0
  67. aria_code/agents/realty/contract_rules.py +210 -0
  68. aria_code/agents/realty/energy_anomaly.py +189 -0
  69. aria_code/agents/realty/exit_settlement.py +208 -0
  70. aria_code/agents/realty/fulfillment_risk.py +206 -0
  71. aria_code/agents/realty/ops_optimize.py +160 -0
  72. aria_code/agents/realty/revenue_share.py +215 -0
  73. aria_code/agents/registry.py +168 -0
  74. aria_code/agents/signal_scheme.py +111 -0
  75. aria_code/agents/sports/__init__.py +0 -0
  76. aria_code/agents/sports/football_agent.py +197 -0
  77. aria_code/agents/supervisor.py +113 -0
  78. aria_code/agents/team.py +500 -0
  79. aria_code/agents/video/__init__.py +16 -0
  80. aria_code/agents/video/director.py +376 -0
  81. aria_code/agents/video/qa_agent.py +143 -0
  82. aria_code/agents/video/script_doctor.py +112 -0
  83. aria_code/agents/warehouse/__init__.py +25 -0
  84. aria_code/agents/warehouse/contracts.py +26 -0
  85. aria_code/agents/warehouse/dashboard.py +155 -0
  86. aria_code/agents/warehouse/fulfillment_leadtime.py +95 -0
  87. aria_code/agents/warehouse/inbound_exceptions.py +25 -0
  88. aria_code/agents/warehouse/inventory_health.py +24 -0
  89. aria_code/agents/warehouse/local_run.py +71 -0
  90. aria_code/agents/warehouse/local_snapshot.py +132 -0
  91. aria_code/agents/warehouse/logistics_cost.py +84 -0
  92. aria_code/agents/warehouse/logistics_sync.py +21 -0
  93. aria_code/agents/warehouse/navigation.schema.json +110 -0
  94. aria_code/agents/warehouse/task_agent.py +172 -0
  95. aria_code/agents/warehouse/workflow.py +31 -0
  96. aria_code/aliyun_data_client.py +4 -0
  97. aria_code/apps/README.md +12 -0
  98. aria_code/apps/__init__.py +2 -0
  99. aria_code/apps/channels/README.md +9 -0
  100. aria_code/apps/channels/__init__.py +17 -0
  101. aria_code/apps/channels/approvals.py +11 -0
  102. aria_code/apps/channels/conversation.py +11 -0
  103. aria_code/apps/channels/digest.py +11 -0
  104. aria_code/apps/channels/intake.py +92 -0
  105. aria_code/apps/channels/registry.py +11 -0
  106. aria_code/apps/channels/tradingview.py +94 -0
  107. aria_code/apps/cli/README.md +13 -0
  108. aria_code/apps/cli/__init__.py +2 -0
  109. aria_code/apps/cli/_optional.py +120 -0
  110. aria_code/apps/cli/app_server.py +656 -0
  111. aria_code/apps/cli/auto_update.py +125 -0
  112. aria_code/apps/cli/bootstrap.py +206 -0
  113. aria_code/apps/cli/broker_render.py +206 -0
  114. aria_code/apps/cli/chat_turn.py +1327 -0
  115. aria_code/apps/cli/cloud_models.py +231 -0
  116. aria_code/apps/cli/codegen_paths.py +29 -0
  117. aria_code/apps/cli/commands/__init__.py +34 -0
  118. aria_code/apps/cli/commands/_business_render.py +78 -0
  119. aria_code/apps/cli/commands/_ui.py +71 -0
  120. aria_code/apps/cli/commands/analysis_cmds.py +378 -0
  121. aria_code/apps/cli/commands/ashare_prediction_cmds.py +327 -0
  122. aria_code/apps/cli/commands/auth_cmds.py +215 -0
  123. aria_code/apps/cli/commands/backtest_cmds.py +2518 -0
  124. aria_code/apps/cli/commands/broker_cmds.py +1236 -0
  125. aria_code/apps/cli/commands/business_workflow_cmds.py +379 -0
  126. aria_code/apps/cli/commands/canvas_cmds.py +77 -0
  127. aria_code/apps/cli/commands/catalog.py +189 -0
  128. aria_code/apps/cli/commands/core_cmds.py +2748 -0
  129. aria_code/apps/cli/commands/data_cmds.py +521 -0
  130. aria_code/apps/cli/commands/diagnostic_cmds.py +285 -0
  131. aria_code/apps/cli/commands/diagnostic_ops_cmds.py +813 -0
  132. aria_code/apps/cli/commands/engineering_cmds.py +399 -0
  133. aria_code/apps/cli/commands/file_cmds.py +312 -0
  134. aria_code/apps/cli/commands/finance_service_cmds.py +60 -0
  135. aria_code/apps/cli/commands/fx_commodity_cmds.py +123 -0
  136. aria_code/apps/cli/commands/market.py +544 -0
  137. aria_code/apps/cli/commands/market_cmds.py +1419 -0
  138. aria_code/apps/cli/commands/market_context.py +432 -0
  139. aria_code/apps/cli/commands/market_render.py +7 -0
  140. aria_code/apps/cli/commands/model_cmds.py +2374 -0
  141. aria_code/apps/cli/commands/ops_cmds.py +721 -0
  142. aria_code/apps/cli/commands/orchestrator_cmds.py +181 -0
  143. aria_code/apps/cli/commands/pdf_export_cmds.py +125 -0
  144. aria_code/apps/cli/commands/portfolio_cmds.py +1316 -0
  145. aria_code/apps/cli/commands/report.py +476 -0
  146. aria_code/apps/cli/commands/scaffold_templates.py +617 -0
  147. aria_code/apps/cli/commands/session_cmds.py +236 -0
  148. aria_code/apps/cli/commands/session_ux_cmds.py +359 -0
  149. aria_code/apps/cli/commands/team.py +723 -0
  150. aria_code/apps/cli/commands/team_render.py +8 -0
  151. aria_code/apps/cli/commands/ui_cmds.py +401 -0
  152. aria_code/apps/cli/commands/warehouse_cmds.py +297 -0
  153. aria_code/apps/cli/commands/workflow_cmds.py +629 -0
  154. aria_code/apps/cli/commands/workspace_cmds.py +1543 -0
  155. aria_code/apps/cli/config_paths.py +73 -0
  156. aria_code/apps/cli/config_store.py +158 -0
  157. aria_code/apps/cli/constants.py +88 -0
  158. aria_code/apps/cli/context.py +34 -0
  159. aria_code/apps/cli/conversation_policy.py +53 -0
  160. aria_code/apps/cli/deterministic.py +200 -0
  161. aria_code/apps/cli/direct.py +48 -0
  162. aria_code/apps/cli/earnings_view.py +172 -0
  163. aria_code/apps/cli/endpoint_migration.py +64 -0
  164. aria_code/apps/cli/exec_events.py +255 -0
  165. aria_code/apps/cli/file_references.py +155 -0
  166. aria_code/apps/cli/finance_service_catalog.py +111 -0
  167. aria_code/apps/cli/football_reports.py +231 -0
  168. aria_code/apps/cli/github_app_auth.py +135 -0
  169. aria_code/apps/cli/google_login.py +239 -0
  170. aria_code/apps/cli/handlers/__init__.py +11 -0
  171. aria_code/apps/cli/handlers/broker_handlers.py +122 -0
  172. aria_code/apps/cli/handlers/chart_handlers.py +1314 -0
  173. aria_code/apps/cli/handlers/market_handlers.py +2570 -0
  174. aria_code/apps/cli/handlers/realty_handlers.py +114 -0
  175. aria_code/apps/cli/handlers/sports_handlers.py +22 -0
  176. aria_code/apps/cli/handlers/strategy_advice.py +82 -0
  177. aria_code/apps/cli/headless.py +352 -0
  178. aria_code/apps/cli/helpers.py +326 -0
  179. aria_code/apps/cli/hooks.py +180 -0
  180. aria_code/apps/cli/i18n.py +310 -0
  181. aria_code/apps/cli/intent.py +136 -0
  182. aria_code/apps/cli/intent_router.py +305 -0
  183. aria_code/apps/cli/intent_shadow.py +72 -0
  184. aria_code/apps/cli/intent_signals.py +347 -0
  185. aria_code/apps/cli/lifecycle_hooks.py +48 -0
  186. aria_code/apps/cli/local_tool_schemas.py +550 -0
  187. aria_code/apps/cli/main.py +39 -0
  188. aria_code/apps/cli/market_hours.py +100 -0
  189. aria_code/apps/cli/market_metadata.py +135 -0
  190. aria_code/apps/cli/market_universe.py +487 -0
  191. aria_code/apps/cli/message_processing.py +310 -0
  192. aria_code/apps/cli/mixin_binding.py +32 -0
  193. aria_code/apps/cli/model_catalog.py +652 -0
  194. aria_code/apps/cli/model_probe.py +169 -0
  195. aria_code/apps/cli/model_retirement.py +65 -0
  196. aria_code/apps/cli/native_branding.py +12 -0
  197. aria_code/apps/cli/native_bridge.py +118 -0
  198. aria_code/apps/cli/native_entry.py +58 -0
  199. aria_code/apps/cli/pdf_report.py +977 -0
  200. aria_code/apps/cli/plan_mode.py +139 -0
  201. aria_code/apps/cli/plotly_html.py +15 -0
  202. aria_code/apps/cli/prediction_feedback.py +202 -0
  203. aria_code/apps/cli/preflight.py +502 -0
  204. aria_code/apps/cli/project_aria.py +60 -0
  205. aria_code/apps/cli/prompt_assembly.py +78 -0
  206. aria_code/apps/cli/prompts/__init__.py +0 -0
  207. aria_code/apps/cli/prompts/coding.py +71 -0
  208. aria_code/apps/cli/prompts/select.py +193 -0
  209. aria_code/apps/cli/prompts/system_prompts.py +562 -0
  210. aria_code/apps/cli/prompts/ui.py +436 -0
  211. aria_code/apps/cli/provider_endpoints.py +237 -0
  212. aria_code/apps/cli/provider_keys.py +73 -0
  213. aria_code/apps/cli/providers/__init__.py +1 -0
  214. aria_code/apps/cli/providers/base.py +775 -0
  215. aria_code/apps/cli/providers/chat_routing.py +139 -0
  216. aria_code/apps/cli/providers/collaboration.py +98 -0
  217. aria_code/apps/cli/providers/llm/__init__.py +1 -0
  218. aria_code/apps/cli/providers/llm/offline_responses.py +68 -0
  219. aria_code/apps/cli/providers/llm/ollama_stream.py +1574 -0
  220. aria_code/apps/cli/providers/llm/response_cache.py +60 -0
  221. aria_code/apps/cli/providers/llm/sse_stream.py +298 -0
  222. aria_code/apps/cli/providers/runtime_bridge.py +586 -0
  223. aria_code/apps/cli/providers/vertexai_stream.py +592 -0
  224. aria_code/apps/cli/release_download.py +159 -0
  225. aria_code/apps/cli/review_runner.py +111 -0
  226. aria_code/apps/cli/runtime_consumer.py +769 -0
  227. aria_code/apps/cli/sandbox.py +32 -0
  228. aria_code/apps/cli/session_export.py +120 -0
  229. aria_code/apps/cli/session_jsonl.py +208 -0
  230. aria_code/apps/cli/session_refresh.py +123 -0
  231. aria_code/apps/cli/session_store.py +125 -0
  232. aria_code/apps/cli/skills_catalog.py +273 -0
  233. aria_code/apps/cli/task_isolation.py +211 -0
  234. aria_code/apps/cli/terminal_chrome.py +412 -0
  235. aria_code/apps/cli/todo_tracker.py +190 -0
  236. aria_code/apps/cli/tool_executor.py +932 -0
  237. aria_code/apps/cli/tool_registry.py +34 -0
  238. aria_code/apps/cli/tool_scope.py +113 -0
  239. aria_code/apps/cli/tools/__init__.py +40 -0
  240. aria_code/apps/cli/tools/context.py +46 -0
  241. aria_code/apps/cli/tools/file_tools.py +181 -0
  242. aria_code/apps/cli/tools/market_tools.py +626 -0
  243. aria_code/apps/cli/tools/notebook_tools.py +111 -0
  244. aria_code/apps/cli/tools/system_tools.py +656 -0
  245. aria_code/apps/cli/tools/write_tools.py +961 -0
  246. aria_code/apps/cli/tradingview_bridge.py +529 -0
  247. aria_code/apps/cli/transactions.py +269 -0
  248. aria_code/apps/cli/turn_planning.py +137 -0
  249. aria_code/apps/cli/update_check.py +257 -0
  250. aria_code/apps/cli/updater.py +373 -0
  251. aria_code/apps/cli/utils/__init__.py +0 -0
  252. aria_code/apps/cli/utils/market_detect.py +1663 -0
  253. aria_code/apps/cli/web_login.py +136 -0
  254. aria_code/apps/cli/workflow_runner.py +127 -0
  255. aria_code/apps/cli/workspace_route.py +121 -0
  256. aria_code/apps/daemon/README.md +14 -0
  257. aria_code/apps/vscode/README.md +115 -0
  258. aria_code/apps/vscode/package.json +70 -0
  259. aria_code/aria_cli.py +5244 -0
  260. aria_code/aria_daemon.py +1372 -0
  261. aria_code/aria_feishu_bot.py +16 -0
  262. aria_code/aria_mcp_server.py +61 -0
  263. aria_code/aria_relay_client.py +4 -0
  264. aria_code/aria_telegram_bot.py +11 -0
  265. aria_code/ariarc.py +385 -0
  266. aria_code/artifacts.py +581 -0
  267. aria_code/backtest_engine.py +4 -0
  268. aria_code/backtest_report.py +4 -0
  269. aria_code/brokers/__init__.py +78 -0
  270. aria_code/brokers/automation.py +193 -0
  271. aria_code/brokers/base.py +231 -0
  272. aria_code/brokers/capabilities.py +264 -0
  273. aria_code/brokers/cn/__init__.py +10 -0
  274. aria_code/brokers/cn/easytrader_broker.py +193 -0
  275. aria_code/brokers/cn/futu_broker.py +194 -0
  276. aria_code/brokers/cn/longbridge_broker.py +190 -0
  277. aria_code/brokers/cn/tiger_broker.py +196 -0
  278. aria_code/brokers/cn/xtquant_broker.py +225 -0
  279. aria_code/brokers/config.py +414 -0
  280. aria_code/brokers/intl/__init__.py +5 -0
  281. aria_code/brokers/intl/alpaca_broker.py +183 -0
  282. aria_code/brokers/intl/ibkr_broker.py +215 -0
  283. aria_code/brokers/intl/trading212_broker.py +183 -0
  284. aria_code/brokers/intl/webull_broker.py +156 -0
  285. aria_code/brokers/paper_broker.py +266 -0
  286. aria_code/brokers/planning.py +296 -0
  287. aria_code/brokers/registry.py +213 -0
  288. aria_code/brokers/trading.py +267 -0
  289. aria_code/canva_client.py +4 -0
  290. aria_code/change_store.py +127 -0
  291. aria_code/clients/__init__.py +0 -0
  292. aria_code/clients/aliyun_data_client.py +660 -0
  293. aria_code/clients/aria_relay_client.py +16 -0
  294. aria_code/clients/canva_client.py +284 -0
  295. aria_code/clients/figma_client.py +100 -0
  296. aria_code/clients/football_data_client.py +1671 -0
  297. aria_code/clients/kling_video_client.py +182 -0
  298. aria_code/clients/market_data_client.py +1985 -0
  299. aria_code/clients/openai_image_client.py +242 -0
  300. aria_code/clients/runway_video_client.py +157 -0
  301. aria_code/clients/warehouse_erp_client.py +105 -0
  302. aria_code/cloud_memory.py +190 -0
  303. aria_code/command_safety.py +19 -0
  304. aria_code/computer_use_server.py +276 -0
  305. aria_code/computer_use_tools.py +4 -0
  306. aria_code/dashboard_generator.py +579 -0
  307. aria_code/data_analysis_tools.py +4 -0
  308. aria_code/data_cleaner.py +483 -0
  309. aria_code/data_service.py +513 -0
  310. aria_code/datasources/__init__.py +23 -0
  311. aria_code/datasources/base.py +166 -0
  312. aria_code/datasources/router.py +253 -0
  313. aria_code/datasources/sec_statements.py +217 -0
  314. aria_code/datasources/sources/__init__.py +15 -0
  315. aria_code/datasources/sources/akshare_source.py +269 -0
  316. aria_code/datasources/sources/alpha_vantage_source.py +203 -0
  317. aria_code/datasources/sources/edgar_source.py +219 -0
  318. aria_code/datasources/sources/finnhub_source.py +198 -0
  319. aria_code/datasources/sources/fred_source.py +223 -0
  320. aria_code/datasources/sources/tushare_source.py +143 -0
  321. aria_code/datasources/sources/web_scraper_source.py +278 -0
  322. aria_code/datasources/sources/world_bank_source.py +206 -0
  323. aria_code/datasources/sources/yfinance_source.py +152 -0
  324. aria_code/doctor.py +860 -0
  325. aria_code/domain/__init__.py +0 -0
  326. aria_code/domain/_finance_core/__init__.py +5 -0
  327. aria_code/domain/_finance_core/backtest_engine.py +325 -0
  328. aria_code/domain/_finance_core/performance_stats.py +101 -0
  329. aria_code/domain/backtest_engine.py +75 -0
  330. aria_code/domain/backtest_report.py +488 -0
  331. aria_code/domain/finance_formulas.py +389 -0
  332. aria_code/domain/performance_stats.py +6 -0
  333. aria_code/domain/portfolio_ledger.py +307 -0
  334. aria_code/domain/strategy_vault.py +421 -0
  335. aria_code/evals/__init__.py +37 -0
  336. aria_code/evals/behavior.py +113 -0
  337. aria_code/evals/gap.py +93 -0
  338. aria_code/evals/harness.py +949 -0
  339. aria_code/evals/review_probe.py +132 -0
  340. aria_code/evals/runner.py +425 -0
  341. aria_code/evals/scorecard.py +322 -0
  342. aria_code/evals/summary.py +125 -0
  343. aria_code/external_agent_runner.py +115 -0
  344. aria_code/figma_client.py +4 -0
  345. aria_code/file_analysis_tools.py +4 -0
  346. aria_code/finance_formulas.py +4 -0
  347. aria_code/football_data_client.py +4 -0
  348. aria_code/image_gen_tools.py +4 -0
  349. aria_code/intent_classifier.py +459 -0
  350. aria_code/kling_video_client.py +4 -0
  351. aria_code/licensing.py +151 -0
  352. aria_code/local_finance_tools.py +4 -0
  353. aria_code/local_image_provider.py +4 -0
  354. aria_code/local_llm_provider.py +4 -0
  355. aria_code/macro_tools.py +4 -0
  356. aria_code/markdown_pdf.py +289 -0
  357. aria_code/market_data_client.py +4 -0
  358. aria_code/mcp_client.py +744 -0
  359. aria_code/memory_manager.py +273 -0
  360. aria_code/model_capability.py +512 -0
  361. aria_code/notification_tools.py +4 -0
  362. aria_code/openai_image_client.py +4 -0
  363. aria_code/packages/__init__.py +26 -0
  364. aria_code/packages/adk_bridge/__init__.py +11 -0
  365. aria_code/packages/adk_bridge/code_review_tools.py +49 -0
  366. aria_code/packages/adk_bridge/market_tools.py +125 -0
  367. aria_code/packages/aria_agents/__init__.py +5 -0
  368. aria_code/packages/aria_agents/manifest.py +78 -0
  369. aria_code/packages/aria_core/__init__.py +34 -0
  370. aria_code/packages/aria_core/architecture.py +269 -0
  371. aria_code/packages/aria_core/export.py +147 -0
  372. aria_code/packages/aria_core/manifest.py +65 -0
  373. aria_code/packages/aria_core/paths.py +57 -0
  374. aria_code/packages/aria_core/secure_file.py +68 -0
  375. aria_code/packages/aria_infra/__init__.py +15 -0
  376. aria_code/packages/aria_infra/arthera.py +58 -0
  377. aria_code/packages/aria_infra/doctor.py +246 -0
  378. aria_code/packages/aria_infra/product.py +40 -0
  379. aria_code/packages/aria_mcp/__init__.py +25 -0
  380. aria_code/packages/aria_mcp/bridge.py +232 -0
  381. aria_code/packages/aria_mcp/circuit.py +82 -0
  382. aria_code/packages/aria_mcp/config.py +162 -0
  383. aria_code/packages/aria_mcp/installer.py +95 -0
  384. aria_code/packages/aria_mcp/server.py +1705 -0
  385. aria_code/packages/aria_mcp/tools.py +81 -0
  386. aria_code/packages/aria_sdk/__init__.py +19 -0
  387. aria_code/packages/aria_sdk/client.py +393 -0
  388. aria_code/packages/aria_sdk/providers.py +70 -0
  389. aria_code/packages/aria_sdk/streaming.py +75 -0
  390. aria_code/packages/aria_sdk/types.py +86 -0
  391. aria_code/packages/aria_services/__init__.py +92 -0
  392. aria_code/packages/aria_services/cache.py +60 -0
  393. aria_code/packages/aria_services/context.py +356 -0
  394. aria_code/packages/aria_services/context_checkpoints.py +157 -0
  395. aria_code/packages/aria_services/data.py +11 -0
  396. aria_code/packages/aria_services/provider_health.py +304 -0
  397. aria_code/packages/aria_services/references.py +247 -0
  398. aria_code/packages/aria_services/registry.py +249 -0
  399. aria_code/packages/aria_services/research_protocol.py +137 -0
  400. aria_code/packages/aria_services/research_quality.py +186 -0
  401. aria_code/packages/aria_services/settings.py +160 -0
  402. aria_code/packages/aria_services/usage.py +151 -0
  403. aria_code/packages/aria_skills/__init__.py +46 -0
  404. aria_code/packages/aria_skills/catalog.py +149 -0
  405. aria_code/packages/aria_skills/loader.py +455 -0
  406. aria_code/packages/aria_skills/registry.py +84 -0
  407. aria_code/packages/aria_tools/__init__.py +5 -0
  408. aria_code/packages/aria_tools/financial/__init__.py +5 -0
  409. aria_code/packages/aria_tools/financial/_paths.py +92 -0
  410. aria_code/packages/aria_tools/registry.py +129 -0
  411. aria_code/packages/quant_engine/__init__.py +33 -0
  412. aria_code/packages/quant_engine/_namespace.py +24 -0
  413. aria_code/packages/quant_engine/backtest/__init__.py +10 -0
  414. aria_code/packages/quant_engine/backtest/core.py +53 -0
  415. aria_code/packages/quant_engine/backtest/engine.py +102 -0
  416. aria_code/packages/quant_engine/portfolio/__init__.py +9 -0
  417. aria_code/packages/quant_engine/portfolio/optimizer.py +85 -0
  418. aria_code/packages/quant_engine/sports/__init__.py +76 -0
  419. aria_code/packages/quant_engine/sports/calibrator.py +354 -0
  420. aria_code/packages/quant_engine/sports/dixon_coles.py +234 -0
  421. aria_code/packages/quant_engine/sports/elo.py +300 -0
  422. aria_code/packages/quant_engine/sports/form.py +188 -0
  423. aria_code/packages/quant_engine/sports/h2h.py +195 -0
  424. aria_code/packages/quant_engine/sports/predictor.py +312 -0
  425. aria_code/packages/quant_engine/sports/tracker.py +665 -0
  426. aria_code/packages/quant_engine/stochastic/__init__.py +31 -0
  427. aria_code/packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  428. aria_code/packages/quant_engine/stochastic/ito_calculus.py +477 -0
  429. aria_code/packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  430. aria_code/packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  431. aria_code/packages/quant_engine/stochastic/options_pricing.py +576 -0
  432. aria_code/packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  433. aria_code/packs/__init__.py +85 -0
  434. aria_code/packs/base.py +168 -0
  435. aria_code/packs/finance/__init__.py +250 -0
  436. aria_code/packs/healthcare/__init__.py +40 -0
  437. aria_code/packs/logistics/__init__.py +171 -0
  438. aria_code/packs/payments/__init__.py +139 -0
  439. aria_code/packs/realty/__init__.py +190 -0
  440. aria_code/packs/registry.py +202 -0
  441. aria_code/packs/rules.py +90 -0
  442. aria_code/plan_utils.py +194 -0
  443. aria_code/plugin_loader.py +328 -0
  444. aria_code/portfolio_ledger.py +4 -0
  445. aria_code/preview_server.py +449 -0
  446. aria_code/privacy/__init__.py +5 -0
  447. aria_code/privacy/feedback.py +123 -0
  448. aria_code/project_review.py +278 -0
  449. aria_code/project_review_api.py +235 -0
  450. aria_code/project_review_server.py +18 -0
  451. aria_code/project_tools.py +4 -0
  452. aria_code/providers/__init__.py +30 -0
  453. aria_code/providers/google_video_provider.py +256 -0
  454. aria_code/providers/llm/__init__.py +19 -0
  455. aria_code/providers/llm/anthropic.py +184 -0
  456. aria_code/providers/llm/arthera_gateway.py +90 -0
  457. aria_code/providers/llm/autoconfig.py +213 -0
  458. aria_code/providers/llm/base.py +139 -0
  459. aria_code/providers/llm/custom.py +158 -0
  460. aria_code/providers/llm/ollama.py +137 -0
  461. aria_code/providers/llm/openai_compat.py +350 -0
  462. aria_code/providers/llm/registry.py +457 -0
  463. aria_code/providers/local_image_provider.py +202 -0
  464. aria_code/providers/local_llm_provider.py +549 -0
  465. aria_code/providers/ollama_models.py +79 -0
  466. aria_code/providers/openai_params.py +32 -0
  467. aria_code/providers/tool_messages.py +100 -0
  468. aria_code/realty_data_tools.py +4 -0
  469. aria_code/report_exporters.py +150 -0
  470. aria_code/report_generator.py +1764 -0
  471. aria_code/review_service.py +497 -0
  472. aria_code/runtime/__init__.py +176 -0
  473. aria_code/runtime/acceptance.py +632 -0
  474. aria_code/runtime/agent_loop.py +1851 -0
  475. aria_code/runtime/agent_profiles.py +75 -0
  476. aria_code/runtime/approval.py +71 -0
  477. aria_code/runtime/baseline.py +219 -0
  478. aria_code/runtime/budget.py +232 -0
  479. aria_code/runtime/checkpoints.py +481 -0
  480. aria_code/runtime/consult.py +65 -0
  481. aria_code/runtime/contract.py +282 -0
  482. aria_code/runtime/delivery.py +432 -0
  483. aria_code/runtime/events.py +113 -0
  484. aria_code/runtime/gateway.py +167 -0
  485. aria_code/runtime/lsp.py +484 -0
  486. aria_code/runtime/native_index.py +153 -0
  487. aria_code/runtime/orchestrator.py +55 -0
  488. aria_code/runtime/processes.py +343 -0
  489. aria_code/runtime/project_graph.py +614 -0
  490. aria_code/runtime/repo_map.py +712 -0
  491. aria_code/runtime/research_bundle.py +105 -0
  492. aria_code/runtime/review.py +269 -0
  493. aria_code/runtime/run_state.py +127 -0
  494. aria_code/runtime/run_store.py +522 -0
  495. aria_code/runtime/scoped_runs.py +113 -0
  496. aria_code/runtime/self_healing.py +422 -0
  497. aria_code/runtime/semantic_diff.py +171 -0
  498. aria_code/runtime/subagent.py +721 -0
  499. aria_code/runtime/symbol_edit.py +117 -0
  500. aria_code/runtime/task_brief.py +46 -0
  501. aria_code/runtime/task_context.py +145 -0
  502. aria_code/runtime/task_ledger.py +108 -0
  503. aria_code/runtime/task_orchestrator.py +90 -0
  504. aria_code/runtime/task_outcome.py +33 -0
  505. aria_code/runtime/task_verification.py +266 -0
  506. aria_code/runtime/task_worktree.py +448 -0
  507. aria_code/runtime/tool_executor.py +329 -0
  508. aria_code/runtime/tool_policy.py +107 -0
  509. aria_code/runtime/transactions.py +216 -0
  510. aria_code/runtime/windows_job.py +115 -0
  511. aria_code/runtime/workflows.py +222 -0
  512. aria_code/runtime/worktrees.py +215 -0
  513. aria_code/runway_video_client.py +4 -0
  514. aria_code/safety/__init__.py +27 -0
  515. aria_code/safety/client_isolation.py +178 -0
  516. aria_code/safety/permissions.py +339 -0
  517. aria_code/safety/risk.py +699 -0
  518. aria_code/safety/sandbox.py +148 -0
  519. aria_code/safety/service.py +102 -0
  520. aria_code/service_endpoints.py +14 -0
  521. aria_code/setup_wizard.py +684 -0
  522. aria_code/spreadsheet_tools.py +4 -0
  523. aria_code/strategy_vault.py +4 -0
  524. aria_code/tools/__init__.py +0 -0
  525. aria_code/tools/artifact_tools.py +252 -0
  526. aria_code/tools/broker_tools.py +92 -0
  527. aria_code/tools/build_quant_engine.py +97 -0
  528. aria_code/tools/code_audit_tools.py +192 -0
  529. aria_code/tools/computer_use_tools.py +509 -0
  530. aria_code/tools/data_analysis_tools.py +831 -0
  531. aria_code/tools/enterprise_finance_tools.py +202 -0
  532. aria_code/tools/extended_tools.py +240 -0
  533. aria_code/tools/file_analysis_tools.py +880 -0
  534. aria_code/tools/image_gen_tools.py +114 -0
  535. aria_code/tools/local_finance_tools.py +3488 -0
  536. aria_code/tools/logistics_carriers.py +11 -0
  537. aria_code/tools/logistics_inventory.py +11 -0
  538. aria_code/tools/logistics_io.py +11 -0
  539. aria_code/tools/logistics_tenancy.py +11 -0
  540. aria_code/tools/logistics_tools.py +11 -0
  541. aria_code/tools/macro_tools.py +412 -0
  542. aria_code/tools/notification_tools.py +249 -0
  543. aria_code/tools/project_tools.py +525 -0
  544. aria_code/tools/realty_data_tools.py +662 -0
  545. aria_code/tools/spreadsheet_tools.py +399 -0
  546. aria_code/tools/stripe_tools.py +115 -0
  547. aria_code/tools/video_tools.py +500 -0
  548. aria_code/training/__init__.py +1 -0
  549. aria_code/training/compare.py +117 -0
  550. aria_code/training/prelude_commands.py +111 -0
  551. aria_code/training/prelude_eval.py +390 -0
  552. aria_code/training/prelude_real.py +183 -0
  553. aria_code/training/prelude_train.py +248 -0
  554. aria_code/training/sft_dataset.py +230 -0
  555. aria_code/training/trajectory_dataset.py +263 -0
  556. aria_code/ui/__init__.py +105 -0
  557. aria_code/ui/banner.py +404 -0
  558. aria_code/ui/completer.py +571 -0
  559. aria_code/ui/console.py +285 -0
  560. aria_code/ui/input_box.py +505 -0
  561. aria_code/ui/mentions.py +206 -0
  562. aria_code/ui/picker.py +248 -0
  563. aria_code/ui/render/__init__.py +11 -0
  564. aria_code/ui/render/actions.py +297 -0
  565. aria_code/ui/render/finance.py +1732 -0
  566. aria_code/ui/render/market.py +253 -0
  567. aria_code/ui/render/output.py +954 -0
  568. aria_code/ui/render/responsive.py +75 -0
  569. aria_code/ui/render/team.py +367 -0
  570. aria_code/ui/robot.py +239 -0
  571. aria_code/ui/startup_dashboard.py +110 -0
  572. aria_code/video_analysis.py +147 -0
  573. aria_code/video_editor.py +518 -0
  574. aria_code/video_workflow/__init__.py +8 -0
  575. aria_code/video_workflow/__main__.py +61 -0
  576. aria_code/video_workflow/engines/__init__.py +18 -0
  577. aria_code/video_workflow/engines/base.py +69 -0
  578. aria_code/video_workflow/engines/custom_model.py +204 -0
  579. aria_code/video_workflow/engines/google_veo.py +94 -0
  580. aria_code/video_workflow/engines/local_engine.py +71 -0
  581. aria_code/video_workflow/engines/registry.py +98 -0
  582. aria_code/video_workflow/google_models.py +278 -0
  583. aria_code/video_workflow/music/__init__.py +5 -0
  584. aria_code/video_workflow/music/foley_service.py +132 -0
  585. aria_code/video_workflow/music/service.py +212 -0
  586. aria_code/video_workflow/pipeline.py +620 -0
  587. aria_code/video_workflow/plan.py +202 -0
  588. aria_code/video_workflow/session.py +297 -0
  589. aria_code/workspace/__init__.py +6 -0
  590. aria_code/workspace/files.py +219 -0
  591. aria_code/workspace/verify.py +167 -0
  592. aria_code/workspace_changes.py +199 -0
  593. aria_code/workspace_service.py +196 -0
  594. aria_code/workspace_tools.py +150 -0
  595. aria_code-0.167.0.dist-info/METADATA +330 -0
  596. aria_code-0.167.0.dist-info/RECORD +601 -0
  597. aria_code-0.167.0.dist-info/WHEEL +5 -0
  598. aria_code-0.167.0.dist-info/entry_points.txt +5 -0
  599. aria_code-0.167.0.dist-info/licenses/LICENSE +201 -0
  600. aria_code-0.167.0.dist-info/licenses/NOTICE +49 -0
  601. 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
+