aria-code 0.52.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (478) hide show
  1. aria_code/__init__.py +42 -0
  2. aria_code/_version.py +20 -0
  3. aria_code/adk_apps/__init__.py +1 -0
  4. aria_code/adk_apps/aria_code_review/__init__.py +1 -0
  5. aria_code/adk_apps/aria_code_review/agent.py +32 -0
  6. aria_code/adk_apps/aria_research/__init__.py +1 -0
  7. aria_code/adk_apps/aria_research/agent.py +31 -0
  8. aria_code/agents/__init__.py +34 -0
  9. aria_code/agents/base.py +318 -0
  10. aria_code/agents/code_review.py +200 -0
  11. aria_code/agents/deep/__init__.py +37 -0
  12. aria_code/agents/deep/calibration_loop.py +144 -0
  13. aria_code/agents/deep/critic.py +125 -0
  14. aria_code/agents/deep/deepen.py +193 -0
  15. aria_code/agents/deep/models.py +149 -0
  16. aria_code/agents/deep/pipeline.py +164 -0
  17. aria_code/agents/deep/quant_fusion.py +192 -0
  18. aria_code/agents/deep/themes.py +95 -0
  19. aria_code/agents/deep/tiers.py +106 -0
  20. aria_code/agents/engineering/__init__.py +14 -0
  21. aria_code/agents/engineering/coder.py +222 -0
  22. aria_code/agents/engineering/tester.py +145 -0
  23. aria_code/agents/financial/__init__.py +21 -0
  24. aria_code/agents/financial/cashflow_burn.py +119 -0
  25. aria_code/agents/financial/catalyst.py +279 -0
  26. aria_code/agents/financial/corporate_finance.py +175 -0
  27. aria_code/agents/financial/debate.py +224 -0
  28. aria_code/agents/financial/earnings.py +303 -0
  29. aria_code/agents/financial/fundamental.py +191 -0
  30. aria_code/agents/financial/macro.py +99 -0
  31. aria_code/agents/financial/news.py +207 -0
  32. aria_code/agents/financial/risk.py +165 -0
  33. aria_code/agents/financial/sector.py +279 -0
  34. aria_code/agents/financial/strategist.py +121 -0
  35. aria_code/agents/financial/stripe_revenue.py +127 -0
  36. aria_code/agents/financial/synthesis.py +274 -0
  37. aria_code/agents/financial/technical.py +258 -0
  38. aria_code/agents/orchestrator.py +44 -0
  39. aria_code/agents/portfolio_agent.py +363 -0
  40. aria_code/agents/realty/__init__.py +62 -0
  41. aria_code/agents/realty/asset_diagnosis.py +152 -0
  42. aria_code/agents/realty/business_match.py +166 -0
  43. aria_code/agents/realty/cashflow_verify.py +209 -0
  44. aria_code/agents/realty/contract_rules.py +210 -0
  45. aria_code/agents/realty/energy_anomaly.py +189 -0
  46. aria_code/agents/realty/exit_settlement.py +208 -0
  47. aria_code/agents/realty/fulfillment_risk.py +206 -0
  48. aria_code/agents/realty/ops_optimize.py +160 -0
  49. aria_code/agents/realty/revenue_share.py +215 -0
  50. aria_code/agents/registry.py +158 -0
  51. aria_code/agents/signal_scheme.py +111 -0
  52. aria_code/agents/sports/__init__.py +0 -0
  53. aria_code/agents/sports/football_agent.py +197 -0
  54. aria_code/agents/supervisor.py +113 -0
  55. aria_code/agents/team.py +492 -0
  56. aria_code/agents/warehouse/__init__.py +25 -0
  57. aria_code/agents/warehouse/contracts.py +26 -0
  58. aria_code/agents/warehouse/dashboard.py +155 -0
  59. aria_code/agents/warehouse/fulfillment_leadtime.py +95 -0
  60. aria_code/agents/warehouse/inbound_exceptions.py +25 -0
  61. aria_code/agents/warehouse/inventory_health.py +24 -0
  62. aria_code/agents/warehouse/local_run.py +71 -0
  63. aria_code/agents/warehouse/local_snapshot.py +132 -0
  64. aria_code/agents/warehouse/logistics_cost.py +84 -0
  65. aria_code/agents/warehouse/logistics_sync.py +21 -0
  66. aria_code/agents/warehouse/workflow.py +31 -0
  67. aria_code/aliyun_data_client.py +4 -0
  68. aria_code/apps/README.md +12 -0
  69. aria_code/apps/__init__.py +2 -0
  70. aria_code/apps/channels/README.md +15 -0
  71. aria_code/apps/channels/__init__.py +17 -0
  72. aria_code/apps/channels/intake.py +92 -0
  73. aria_code/apps/channels/registry.py +95 -0
  74. aria_code/apps/channels/tradingview.py +94 -0
  75. aria_code/apps/cli/README.md +13 -0
  76. aria_code/apps/cli/__init__.py +2 -0
  77. aria_code/apps/cli/_optional.py +120 -0
  78. aria_code/apps/cli/bootstrap.py +145 -0
  79. aria_code/apps/cli/broker_render.py +206 -0
  80. aria_code/apps/cli/codegen_paths.py +29 -0
  81. aria_code/apps/cli/commands/__init__.py +16 -0
  82. aria_code/apps/cli/commands/_business_render.py +78 -0
  83. aria_code/apps/cli/commands/_ui.py +71 -0
  84. aria_code/apps/cli/commands/analysis_cmds.py +369 -0
  85. aria_code/apps/cli/commands/ashare_prediction_cmds.py +327 -0
  86. aria_code/apps/cli/commands/auth_cmds.py +239 -0
  87. aria_code/apps/cli/commands/backtest_cmds.py +2543 -0
  88. aria_code/apps/cli/commands/broker_cmds.py +1234 -0
  89. aria_code/apps/cli/commands/business_workflow_cmds.py +319 -0
  90. aria_code/apps/cli/commands/canvas_cmds.py +77 -0
  91. aria_code/apps/cli/commands/catalog.py +87 -0
  92. aria_code/apps/cli/commands/core_cmds.py +2520 -0
  93. aria_code/apps/cli/commands/data_cmds.py +457 -0
  94. aria_code/apps/cli/commands/diagnostic_cmds.py +230 -0
  95. aria_code/apps/cli/commands/diagnostic_ops_cmds.py +813 -0
  96. aria_code/apps/cli/commands/file_cmds.py +312 -0
  97. aria_code/apps/cli/commands/finance_service_cmds.py +60 -0
  98. aria_code/apps/cli/commands/fx_commodity_cmds.py +123 -0
  99. aria_code/apps/cli/commands/market.py +434 -0
  100. aria_code/apps/cli/commands/market_cmds.py +1381 -0
  101. aria_code/apps/cli/commands/market_context.py +425 -0
  102. aria_code/apps/cli/commands/market_render.py +7 -0
  103. aria_code/apps/cli/commands/model_cmds.py +2153 -0
  104. aria_code/apps/cli/commands/ops_cmds.py +721 -0
  105. aria_code/apps/cli/commands/orchestrator_cmds.py +179 -0
  106. aria_code/apps/cli/commands/pdf_export_cmds.py +125 -0
  107. aria_code/apps/cli/commands/portfolio_cmds.py +1278 -0
  108. aria_code/apps/cli/commands/report.py +437 -0
  109. aria_code/apps/cli/commands/scaffold_templates.py +617 -0
  110. aria_code/apps/cli/commands/session_cmds.py +236 -0
  111. aria_code/apps/cli/commands/session_ux_cmds.py +359 -0
  112. aria_code/apps/cli/commands/team.py +669 -0
  113. aria_code/apps/cli/commands/team_render.py +8 -0
  114. aria_code/apps/cli/commands/ui_cmds.py +401 -0
  115. aria_code/apps/cli/commands/warehouse_cmds.py +79 -0
  116. aria_code/apps/cli/commands/workflow_cmds.py +480 -0
  117. aria_code/apps/cli/commands/workspace_cmds.py +1494 -0
  118. aria_code/apps/cli/config_paths.py +73 -0
  119. aria_code/apps/cli/config_store.py +78 -0
  120. aria_code/apps/cli/constants.py +88 -0
  121. aria_code/apps/cli/context.py +34 -0
  122. aria_code/apps/cli/deterministic.py +191 -0
  123. aria_code/apps/cli/direct.py +48 -0
  124. aria_code/apps/cli/finance_service_catalog.py +111 -0
  125. aria_code/apps/cli/football_reports.py +231 -0
  126. aria_code/apps/cli/github_app_auth.py +135 -0
  127. aria_code/apps/cli/google_login.py +196 -0
  128. aria_code/apps/cli/handlers/__init__.py +11 -0
  129. aria_code/apps/cli/handlers/broker_handlers.py +122 -0
  130. aria_code/apps/cli/handlers/chart_handlers.py +1309 -0
  131. aria_code/apps/cli/handlers/market_handlers.py +2513 -0
  132. aria_code/apps/cli/handlers/realty_handlers.py +114 -0
  133. aria_code/apps/cli/handlers/sports_handlers.py +22 -0
  134. aria_code/apps/cli/handlers/strategy_advice.py +82 -0
  135. aria_code/apps/cli/helpers.py +302 -0
  136. aria_code/apps/cli/hooks.py +180 -0
  137. aria_code/apps/cli/i18n.py +297 -0
  138. aria_code/apps/cli/intent.py +136 -0
  139. aria_code/apps/cli/intent_router.py +276 -0
  140. aria_code/apps/cli/intent_signals.py +344 -0
  141. aria_code/apps/cli/lifecycle_hooks.py +48 -0
  142. aria_code/apps/cli/local_tool_schemas.py +536 -0
  143. aria_code/apps/cli/main.py +29 -0
  144. aria_code/apps/cli/market_metadata.py +135 -0
  145. aria_code/apps/cli/market_universe.py +487 -0
  146. aria_code/apps/cli/message_processing.py +310 -0
  147. aria_code/apps/cli/model_catalog.py +620 -0
  148. aria_code/apps/cli/pdf_report.py +977 -0
  149. aria_code/apps/cli/plan_mode.py +139 -0
  150. aria_code/apps/cli/plotly_html.py +15 -0
  151. aria_code/apps/cli/prediction_feedback.py +202 -0
  152. aria_code/apps/cli/preflight.py +502 -0
  153. aria_code/apps/cli/project_aria.py +60 -0
  154. aria_code/apps/cli/prompt_assembly.py +59 -0
  155. aria_code/apps/cli/prompts/__init__.py +0 -0
  156. aria_code/apps/cli/prompts/coding.py +53 -0
  157. aria_code/apps/cli/prompts/select.py +142 -0
  158. aria_code/apps/cli/prompts/system_prompts.py +552 -0
  159. aria_code/apps/cli/prompts/ui.py +436 -0
  160. aria_code/apps/cli/provider_endpoints.py +237 -0
  161. aria_code/apps/cli/provider_keys.py +73 -0
  162. aria_code/apps/cli/providers/__init__.py +1 -0
  163. aria_code/apps/cli/providers/base.py +521 -0
  164. aria_code/apps/cli/providers/chat_routing.py +128 -0
  165. aria_code/apps/cli/providers/collaboration.py +98 -0
  166. aria_code/apps/cli/providers/llm/__init__.py +1 -0
  167. aria_code/apps/cli/providers/llm/offline_responses.py +68 -0
  168. aria_code/apps/cli/providers/llm/ollama_stream.py +1594 -0
  169. aria_code/apps/cli/providers/llm/response_cache.py +60 -0
  170. aria_code/apps/cli/providers/llm/sse_stream.py +257 -0
  171. aria_code/apps/cli/providers/runtime_bridge.py +353 -0
  172. aria_code/apps/cli/providers/vertexai_stream.py +316 -0
  173. aria_code/apps/cli/runtime_consumer.py +657 -0
  174. aria_code/apps/cli/sandbox.py +32 -0
  175. aria_code/apps/cli/session_export.py +120 -0
  176. aria_code/apps/cli/session_jsonl.py +208 -0
  177. aria_code/apps/cli/session_store.py +112 -0
  178. aria_code/apps/cli/skills_catalog.py +273 -0
  179. aria_code/apps/cli/todo_tracker.py +190 -0
  180. aria_code/apps/cli/tool_executor.py +823 -0
  181. aria_code/apps/cli/tool_registry.py +34 -0
  182. aria_code/apps/cli/tool_scope.py +106 -0
  183. aria_code/apps/cli/tools/__init__.py +40 -0
  184. aria_code/apps/cli/tools/context.py +46 -0
  185. aria_code/apps/cli/tools/file_tools.py +180 -0
  186. aria_code/apps/cli/tools/market_tools.py +620 -0
  187. aria_code/apps/cli/tools/notebook_tools.py +111 -0
  188. aria_code/apps/cli/tools/system_tools.py +726 -0
  189. aria_code/apps/cli/tools/write_tools.py +845 -0
  190. aria_code/apps/cli/tradingview_bridge.py +529 -0
  191. aria_code/apps/cli/turn_planning.py +137 -0
  192. aria_code/apps/cli/update_check.py +153 -0
  193. aria_code/apps/cli/utils/__init__.py +0 -0
  194. aria_code/apps/cli/utils/market_detect.py +1597 -0
  195. aria_code/apps/daemon/README.md +14 -0
  196. aria_code/apps/vscode/README.md +115 -0
  197. aria_code/apps/vscode/package.json +70 -0
  198. aria_code/aria_cli.py +6743 -0
  199. aria_code/aria_daemon.py +1398 -0
  200. aria_code/aria_feishu_bot.py +1423 -0
  201. aria_code/aria_mcp_server.py +7 -0
  202. aria_code/aria_relay_client.py +4 -0
  203. aria_code/aria_relay_server.py +490 -0
  204. aria_code/aria_telegram_bot.py +202 -0
  205. aria_code/ariarc.py +385 -0
  206. aria_code/artifacts.py +581 -0
  207. aria_code/backtest_engine.py +4 -0
  208. aria_code/backtest_report.py +4 -0
  209. aria_code/brokers/__init__.py +78 -0
  210. aria_code/brokers/automation.py +193 -0
  211. aria_code/brokers/base.py +231 -0
  212. aria_code/brokers/capabilities.py +264 -0
  213. aria_code/brokers/cn/__init__.py +10 -0
  214. aria_code/brokers/cn/easytrader_broker.py +193 -0
  215. aria_code/brokers/cn/futu_broker.py +194 -0
  216. aria_code/brokers/cn/longbridge_broker.py +190 -0
  217. aria_code/brokers/cn/tiger_broker.py +196 -0
  218. aria_code/brokers/cn/xtquant_broker.py +225 -0
  219. aria_code/brokers/config.py +414 -0
  220. aria_code/brokers/intl/__init__.py +5 -0
  221. aria_code/brokers/intl/alpaca_broker.py +183 -0
  222. aria_code/brokers/intl/ibkr_broker.py +215 -0
  223. aria_code/brokers/intl/trading212_broker.py +183 -0
  224. aria_code/brokers/intl/webull_broker.py +156 -0
  225. aria_code/brokers/paper_broker.py +266 -0
  226. aria_code/brokers/planning.py +296 -0
  227. aria_code/brokers/registry.py +213 -0
  228. aria_code/brokers/trading.py +267 -0
  229. aria_code/canva_client.py +4 -0
  230. aria_code/change_store.py +127 -0
  231. aria_code/clients/__init__.py +0 -0
  232. aria_code/clients/aliyun_data_client.py +660 -0
  233. aria_code/clients/aria_relay_client.py +182 -0
  234. aria_code/clients/canva_client.py +284 -0
  235. aria_code/clients/figma_client.py +100 -0
  236. aria_code/clients/football_data_client.py +1671 -0
  237. aria_code/clients/kling_video_client.py +182 -0
  238. aria_code/clients/market_data_client.py +1976 -0
  239. aria_code/clients/openai_image_client.py +242 -0
  240. aria_code/clients/runway_video_client.py +157 -0
  241. aria_code/clients/warehouse_erp_client.py +105 -0
  242. aria_code/cloud_memory.py +181 -0
  243. aria_code/command_safety.py +19 -0
  244. aria_code/computer_use_tools.py +4 -0
  245. aria_code/dashboard_generator.py +579 -0
  246. aria_code/data_analysis_tools.py +4 -0
  247. aria_code/data_cleaner.py +483 -0
  248. aria_code/data_service.py +513 -0
  249. aria_code/datasources/__init__.py +23 -0
  250. aria_code/datasources/base.py +166 -0
  251. aria_code/datasources/router.py +253 -0
  252. aria_code/datasources/sources/__init__.py +15 -0
  253. aria_code/datasources/sources/akshare_source.py +269 -0
  254. aria_code/datasources/sources/alpha_vantage_source.py +203 -0
  255. aria_code/datasources/sources/edgar_source.py +219 -0
  256. aria_code/datasources/sources/finnhub_source.py +198 -0
  257. aria_code/datasources/sources/fred_source.py +223 -0
  258. aria_code/datasources/sources/tushare_source.py +143 -0
  259. aria_code/datasources/sources/web_scraper_source.py +278 -0
  260. aria_code/datasources/sources/world_bank_source.py +206 -0
  261. aria_code/datasources/sources/yfinance_source.py +152 -0
  262. aria_code/demo_player.py +207 -0
  263. aria_code/doctor.py +813 -0
  264. aria_code/domain/__init__.py +0 -0
  265. aria_code/domain/backtest_engine.py +354 -0
  266. aria_code/domain/backtest_report.py +472 -0
  267. aria_code/domain/finance_formulas.py +389 -0
  268. aria_code/domain/portfolio_ledger.py +307 -0
  269. aria_code/domain/strategy_vault.py +421 -0
  270. aria_code/evals/__init__.py +37 -0
  271. aria_code/evals/harness.py +598 -0
  272. aria_code/evals/runner.py +238 -0
  273. aria_code/external_agent_runner.py +115 -0
  274. aria_code/figma_client.py +4 -0
  275. aria_code/file_analysis_tools.py +4 -0
  276. aria_code/finance_formulas.py +4 -0
  277. aria_code/football_data_client.py +4 -0
  278. aria_code/image_gen_tools.py +4 -0
  279. aria_code/intent_classifier.py +386 -0
  280. aria_code/kling_video_client.py +4 -0
  281. aria_code/licensing.py +151 -0
  282. aria_code/local_finance_tools.py +4 -0
  283. aria_code/local_image_provider.py +4 -0
  284. aria_code/local_llm_provider.py +4 -0
  285. aria_code/macro_tools.py +4 -0
  286. aria_code/markdown_pdf.py +289 -0
  287. aria_code/market_data_client.py +4 -0
  288. aria_code/mcp_client.py +639 -0
  289. aria_code/memory_manager.py +246 -0
  290. aria_code/model_capability.py +486 -0
  291. aria_code/notification_tools.py +4 -0
  292. aria_code/openai_image_client.py +4 -0
  293. aria_code/packages/__init__.py +23 -0
  294. aria_code/packages/adk_bridge/__init__.py +11 -0
  295. aria_code/packages/adk_bridge/code_review_tools.py +49 -0
  296. aria_code/packages/adk_bridge/market_tools.py +125 -0
  297. aria_code/packages/aria_agents/__init__.py +5 -0
  298. aria_code/packages/aria_agents/manifest.py +78 -0
  299. aria_code/packages/aria_core/__init__.py +34 -0
  300. aria_code/packages/aria_core/architecture.py +269 -0
  301. aria_code/packages/aria_core/export.py +147 -0
  302. aria_code/packages/aria_core/manifest.py +65 -0
  303. aria_code/packages/aria_core/paths.py +57 -0
  304. aria_code/packages/aria_core/secure_file.py +68 -0
  305. aria_code/packages/aria_infra/__init__.py +15 -0
  306. aria_code/packages/aria_infra/arthera.py +52 -0
  307. aria_code/packages/aria_infra/doctor.py +246 -0
  308. aria_code/packages/aria_infra/product.py +40 -0
  309. aria_code/packages/aria_mcp/__init__.py +25 -0
  310. aria_code/packages/aria_mcp/bridge.py +201 -0
  311. aria_code/packages/aria_mcp/circuit.py +82 -0
  312. aria_code/packages/aria_mcp/config.py +111 -0
  313. aria_code/packages/aria_mcp/installer.py +95 -0
  314. aria_code/packages/aria_mcp/server.py +1550 -0
  315. aria_code/packages/aria_mcp/tools.py +81 -0
  316. aria_code/packages/aria_sdk/__init__.py +19 -0
  317. aria_code/packages/aria_sdk/client.py +396 -0
  318. aria_code/packages/aria_sdk/providers.py +70 -0
  319. aria_code/packages/aria_sdk/streaming.py +79 -0
  320. aria_code/packages/aria_sdk/types.py +86 -0
  321. aria_code/packages/aria_services/__init__.py +92 -0
  322. aria_code/packages/aria_services/cache.py +60 -0
  323. aria_code/packages/aria_services/context.py +356 -0
  324. aria_code/packages/aria_services/context_checkpoints.py +157 -0
  325. aria_code/packages/aria_services/data.py +11 -0
  326. aria_code/packages/aria_services/provider_health.py +268 -0
  327. aria_code/packages/aria_services/references.py +247 -0
  328. aria_code/packages/aria_services/registry.py +249 -0
  329. aria_code/packages/aria_services/research_protocol.py +57 -0
  330. aria_code/packages/aria_services/research_quality.py +186 -0
  331. aria_code/packages/aria_services/settings.py +157 -0
  332. aria_code/packages/aria_services/usage.py +151 -0
  333. aria_code/packages/aria_skills/__init__.py +46 -0
  334. aria_code/packages/aria_skills/catalog.py +149 -0
  335. aria_code/packages/aria_skills/loader.py +455 -0
  336. aria_code/packages/aria_skills/registry.py +84 -0
  337. aria_code/packages/aria_tools/__init__.py +5 -0
  338. aria_code/packages/aria_tools/financial/__init__.py +8 -0
  339. aria_code/packages/aria_tools/financial/_paths.py +130 -0
  340. aria_code/packages/aria_tools/financial/compliance.py +31 -0
  341. aria_code/packages/aria_tools/financial/factors.py +33 -0
  342. aria_code/packages/aria_tools/financial/risk_tools.py +33 -0
  343. aria_code/packages/aria_tools/financial/strategy.py +31 -0
  344. aria_code/packages/aria_tools/registry.py +128 -0
  345. aria_code/packages/quant_engine/__init__.py +34 -0
  346. aria_code/packages/quant_engine/_namespace.py +62 -0
  347. aria_code/packages/quant_engine/backtest/__init__.py +14 -0
  348. aria_code/packages/quant_engine/backtest/core.py +53 -0
  349. aria_code/packages/quant_engine/backtest/engine.py +96 -0
  350. aria_code/packages/quant_engine/portfolio/__init__.py +13 -0
  351. aria_code/packages/quant_engine/portfolio/optimizer.py +85 -0
  352. aria_code/packages/quant_engine/sports/__init__.py +80 -0
  353. aria_code/packages/quant_engine/sports/calibrator.py +354 -0
  354. aria_code/packages/quant_engine/sports/dixon_coles.py +234 -0
  355. aria_code/packages/quant_engine/sports/elo.py +300 -0
  356. aria_code/packages/quant_engine/sports/form.py +188 -0
  357. aria_code/packages/quant_engine/sports/h2h.py +195 -0
  358. aria_code/packages/quant_engine/sports/ml_model.py +377 -0
  359. aria_code/packages/quant_engine/sports/predictor.py +312 -0
  360. aria_code/packages/quant_engine/sports/tracker.py +665 -0
  361. aria_code/packages/quant_engine/stochastic/__init__.py +35 -0
  362. aria_code/packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  363. aria_code/packages/quant_engine/stochastic/ito_calculus.py +477 -0
  364. aria_code/packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  365. aria_code/packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  366. aria_code/packages/quant_engine/stochastic/options_pricing.py +576 -0
  367. aria_code/packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  368. aria_code/packs/__init__.py +85 -0
  369. aria_code/packs/base.py +168 -0
  370. aria_code/packs/finance/__init__.py +250 -0
  371. aria_code/packs/logistics/__init__.py +169 -0
  372. aria_code/packs/payments/__init__.py +139 -0
  373. aria_code/packs/realty/__init__.py +190 -0
  374. aria_code/packs/registry.py +202 -0
  375. aria_code/packs/rules.py +90 -0
  376. aria_code/plan_utils.py +194 -0
  377. aria_code/plugin_loader.py +328 -0
  378. aria_code/portfolio_ledger.py +4 -0
  379. aria_code/preview_server.py +449 -0
  380. aria_code/privacy/__init__.py +5 -0
  381. aria_code/privacy/feedback.py +123 -0
  382. aria_code/project_review.py +278 -0
  383. aria_code/project_review_api.py +235 -0
  384. aria_code/project_review_server.py +18 -0
  385. aria_code/project_tools.py +4 -0
  386. aria_code/providers/__init__.py +30 -0
  387. aria_code/providers/llm/__init__.py +19 -0
  388. aria_code/providers/llm/anthropic.py +184 -0
  389. aria_code/providers/llm/autoconfig.py +213 -0
  390. aria_code/providers/llm/base.py +139 -0
  391. aria_code/providers/llm/custom.py +158 -0
  392. aria_code/providers/llm/ollama.py +129 -0
  393. aria_code/providers/llm/openai_compat.py +313 -0
  394. aria_code/providers/llm/registry.py +444 -0
  395. aria_code/providers/local_image_provider.py +202 -0
  396. aria_code/providers/local_llm_provider.py +589 -0
  397. aria_code/realty_data_tools.py +4 -0
  398. aria_code/report_exporters.py +150 -0
  399. aria_code/report_generator.py +1764 -0
  400. aria_code/runtime/__init__.py +169 -0
  401. aria_code/runtime/acceptance.py +481 -0
  402. aria_code/runtime/agent_loop.py +1520 -0
  403. aria_code/runtime/approval.py +57 -0
  404. aria_code/runtime/budget.py +232 -0
  405. aria_code/runtime/checkpoints.py +432 -0
  406. aria_code/runtime/crash_report.py +174 -0
  407. aria_code/runtime/events.py +113 -0
  408. aria_code/runtime/gateway.py +150 -0
  409. aria_code/runtime/lsp.py +484 -0
  410. aria_code/runtime/orchestrator.py +55 -0
  411. aria_code/runtime/repo_map.py +700 -0
  412. aria_code/runtime/run_state.py +127 -0
  413. aria_code/runtime/run_store.py +450 -0
  414. aria_code/runtime/self_healing.py +422 -0
  415. aria_code/runtime/subagent.py +547 -0
  416. aria_code/runtime/task_ledger.py +86 -0
  417. aria_code/runtime/task_orchestrator.py +90 -0
  418. aria_code/runtime/tool_executor.py +217 -0
  419. aria_code/runtime/tool_policy.py +107 -0
  420. aria_code/runtime/worktrees.py +215 -0
  421. aria_code/runway_video_client.py +4 -0
  422. aria_code/safety/__init__.py +23 -0
  423. aria_code/safety/permissions.py +306 -0
  424. aria_code/safety/service.py +102 -0
  425. aria_code/setup_wizard.py +673 -0
  426. aria_code/spreadsheet_tools.py +4 -0
  427. aria_code/strategy_vault.py +4 -0
  428. aria_code/tools/__init__.py +0 -0
  429. aria_code/tools/artifact_tools.py +252 -0
  430. aria_code/tools/broker_tools.py +92 -0
  431. aria_code/tools/build_quant_engine.py +97 -0
  432. aria_code/tools/code_audit_tools.py +192 -0
  433. aria_code/tools/computer_use_tools.py +504 -0
  434. aria_code/tools/data_analysis_tools.py +809 -0
  435. aria_code/tools/enterprise_finance_tools.py +156 -0
  436. aria_code/tools/extended_tools.py +240 -0
  437. aria_code/tools/file_analysis_tools.py +885 -0
  438. aria_code/tools/image_gen_tools.py +114 -0
  439. aria_code/tools/local_finance_tools.py +3417 -0
  440. aria_code/tools/logistics_tools.py +135 -0
  441. aria_code/tools/macro_tools.py +369 -0
  442. aria_code/tools/notification_tools.py +249 -0
  443. aria_code/tools/project_tools.py +525 -0
  444. aria_code/tools/realty_data_tools.py +659 -0
  445. aria_code/tools/spreadsheet_tools.py +388 -0
  446. aria_code/tools/stripe_tools.py +115 -0
  447. aria_code/ui/__init__.py +105 -0
  448. aria_code/ui/banner.py +508 -0
  449. aria_code/ui/completer.py +501 -0
  450. aria_code/ui/console.py +281 -0
  451. aria_code/ui/image_render.py +243 -0
  452. aria_code/ui/input_box.py +488 -0
  453. aria_code/ui/picker.py +201 -0
  454. aria_code/ui/render/__init__.py +11 -0
  455. aria_code/ui/render/finance.py +1705 -0
  456. aria_code/ui/render/market.py +225 -0
  457. aria_code/ui/render/output.py +775 -0
  458. aria_code/ui/render/responsive.py +75 -0
  459. aria_code/ui/render/team.py +354 -0
  460. aria_code/ui/robot.py +235 -0
  461. aria_code/ui/startup_dashboard.py +109 -0
  462. aria_code/utils/vscode_uri.py +37 -0
  463. aria_code/video_analysis.py +147 -0
  464. aria_code/video_editor.py +271 -0
  465. aria_code/workspace/__init__.py +6 -0
  466. aria_code/workspace/files.py +193 -0
  467. aria_code/workspace/verify.py +113 -0
  468. aria_code/workspace_changes.py +199 -0
  469. aria_code/workspace_context.py +233 -0
  470. aria_code/workspace_service.py +196 -0
  471. aria_code/workspace_tools.py +150 -0
  472. aria_code-0.52.0.dist-info/METADATA +237 -0
  473. aria_code-0.52.0.dist-info/RECORD +478 -0
  474. aria_code-0.52.0.dist-info/WHEEL +5 -0
  475. aria_code-0.52.0.dist-info/entry_points.txt +3 -0
  476. aria_code-0.52.0.dist-info/licenses/LICENSE +201 -0
  477. aria_code-0.52.0.dist-info/licenses/NOTICE +49 -0
  478. aria_code-0.52.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1594 @@
1
+ """stream_ollama — Ollama streaming function extracted from aria_cli.py.
2
+
3
+ Module globals are rebound to aria_cli's namespace by the function-rebind
4
+ shim in aria_cli.py after import, so all bare name references resolve correctly.
5
+ """
6
+ from __future__ import annotations
7
+ import asyncio
8
+ import re
9
+ from datetime import datetime
10
+
11
+ try:
12
+ from packages.aria_skills.loader import CORE_FILE_TOOLS as _CORE_FILE_TOOLS
13
+ except Exception:
14
+ _CORE_FILE_TOOLS: frozenset[str] = frozenset()
15
+
16
+ # Imported rather than borrowed from aria_cli's globals: this function is also
17
+ # reached without aria_cli loaded (SDK, daemon), where the rebind never runs.
18
+ from .response_cache import (
19
+ cache_get as _cache_get,
20
+ cache_key as _cache_key,
21
+ cache_set as _cache_set,
22
+ )
23
+
24
+ # ── names reclaimed from the borrow set ──────────────────────────────────────
25
+ # stream_ollama borrows 45 bare names from aria_cli's module globals. The
26
+ # rebind in aria_cli._rebind_stream_ollama MERGES rather than replaces —
27
+ # `dict(source.__globals__)` then `.update(globals())` — so anything defined
28
+ # here is the base of that merge and aria_cli still wins on conflicts. Adding a
29
+ # name here therefore cannot change the CLI path; it only gives the fallback
30
+ # path (SDK, daemon, any consumer that never imports aria_cli) a name it
31
+ # previously died on.
32
+ #
33
+ # Every name below was checked to be *the same object* aria_cli holds. That
34
+ # check is the whole point, not ceremony:
35
+ #
36
+ # · _CONFIRM_TOOLS looks importable from apps.cli.tool_executor, but that
37
+ # module's copy is a placeholder stub — an empty set() — while aria_cli's
38
+ # is the real {'edit_file', 'multi_edit', ...}. Importing the obvious
39
+ # source would have silently made every tool skip confirmation. Fail open.
40
+ # · _apply_tool_approval, _confirm_tool_execution_decision and
41
+ # _format_tool_summary resolve to different function objects in
42
+ # tool_executor than in aria_cli, and they sit on the approval path.
43
+ # · get_ariarc and build_tool_system_prompt differ only by import root
44
+ # (`ariarc` vs `aria_code.ariarc`) — importing either here would add a
45
+ # second module object rather than remove a borrow.
46
+ # · _try_prefetch_market_data exists in handlers.market_handlers, but
47
+ # aria_cli defines its own.
48
+ #
49
+ # tests/test_ollama_stream_imports.py enforces the identity rule, so this list
50
+ # cannot grow a diverging entry later.
51
+ import json
52
+ import logging
53
+ import os
54
+ import time
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ # market_detect owns it; deterministic only re-exports. Same object either
59
+ # way, but going direct keeps this module from pulling in deterministic
60
+ # (and, now, the domain packs behind it).
61
+ from aria_code.apps.cli.utils.market_detect import _is_broker_intent
62
+ from aria_code.apps.cli.helpers import detect_ollama_models_rich
63
+ from aria_code.apps.cli.model_catalog import (
64
+ _HAS_MODEL_CAP,
65
+ get_model_cfg,
66
+ resolve_model_key,
67
+ )
68
+ from aria_code.apps.cli.prompts.coding import CODING_SYSTEM_PROMPT
69
+ from aria_code.apps.cli.tool_registry import LOCAL_TOOL_SCHEMAS
70
+ from aria_code.apps.cli.tools.market_tools import _HAS_MDC
71
+ from aria_code.ui.console import HAS_RICH, Panel, console
72
+
73
+ # Second batch. Same rule as above — each verified to be the object aria_cli
74
+ # holds, which is why _format_tool_summary is still absent: apps.cli.
75
+ # tool_executor's copy is a different function object from aria_cli's and sits
76
+ # on the tool-output path.
77
+ #
78
+ # The four intent predicates and the three message helpers were already
79
+ # extracted; aria_cli only aliases them, so importing the real modules here
80
+ # removes a borrow without moving any code. The three offline responders were
81
+ # genuinely still in aria_cli and moved to offline_responses.py — a provider's
82
+ # "backend unavailable" message belongs with the provider, and the fallback
83
+ # path could not previously build its own.
84
+ from aria_code.apps.cli.intent import (
85
+ is_analysis_request as _is_analysis_request,
86
+ is_coding_request as _is_coding_request,
87
+ is_general_knowledge as _is_general_knowledge,
88
+ is_sports_query as _is_sports_query,
89
+ )
90
+ from aria_code.apps.cli.message_processing import (
91
+ compact_messages as _compact_messages,
92
+ parse_text_tool_calls as _parse_text_tool_calls,
93
+ strip_tool_call_tags as _strip_tool_call_tags,
94
+ )
95
+ from .offline_responses import (
96
+ is_simple_greeting as _is_simple_greeting,
97
+ offline_greeting_response as _offline_greeting_response,
98
+ ollama_unavailable_result as _ollama_unavailable_result,
99
+ )
100
+
101
+ # Third batch. aria_cli held wrappers around all of these; converting those to
102
+ # aliases is what makes the import safe, because a wrapper is a different
103
+ # function object from the thing it wraps.
104
+ from aria_code.apps.cli.handlers.market_handlers import _try_prefetch_market_data
105
+ from aria_code.apps.cli.handlers.sports_handlers import (
106
+ try_prefetch_sports_data as _try_prefetch_sports_data,
107
+ )
108
+ from aria_code.apps.cli.message_processing import (
109
+ build_broker_context_block as _build_broker_context_block,
110
+ )
111
+ from aria_code.apps.cli.prompts.system_prompts import (
112
+ build_analysis_prompt_lite as _build_analysis_prompt_lite,
113
+ build_analysis_system_prompt as _build_analysis_system_prompt,
114
+ build_coding_prompt_lite as _build_coding_prompt_lite,
115
+ build_finance_prompt as _build_finance_prompt,
116
+ build_prefetched_analysis_prompt as _build_prefetched_analysis_prompt,
117
+ )
118
+
119
+ # Not an import: build_analysis_system_prompt() stamps today's date at call
120
+ # time, so this is a snapshot, and aria_cli holds its own taken when *it* was
121
+ # imported. The two are equal strings on the same day but never the same
122
+ # object, which is why the identity guard lists this name as a documented
123
+ # value-equality exception rather than skipping it. On the CLI path aria_cli's
124
+ # snapshot wins the rebind merge anyway; this one exists so the fallback path
125
+ # has one at all.
126
+ ANALYSIS_SYSTEM_PROMPT = _build_analysis_system_prompt()
127
+
128
+
129
+ def _try_inject_file_paths(_message: str) -> str:
130
+ """Legacy hook retained for the extracted stream implementation.
131
+
132
+ Context references are resolved by the typed ``@`` reference service before
133
+ provider dispatch. Silently reading path-like text here would bypass that
134
+ service's validation and duplicate context, so the legacy hook is now a
135
+ deliberate no-op.
136
+ """
137
+ return ""
138
+
139
+
140
+ def _recent_sports_quant_context(history: list, max_chars: int = 5000) -> str:
141
+ """Return the latest sports quant block from chat history for follow-ups."""
142
+ markers = (
143
+ "【泊松模型量化预测",
144
+ "【量化预测",
145
+ "最可能比分",
146
+ "可能比分",
147
+ )
148
+ for msg in reversed(history or []):
149
+ content = str(msg.get("content", "")) if isinstance(msg, dict) else ""
150
+ if not content:
151
+ continue
152
+ if "预期进球" in content and any(marker in content for marker in markers):
153
+ return content[-max_chars:]
154
+ return ""
155
+
156
+
157
+ _TOOL_NAME_ALIASES = {
158
+ "get_stock_data": "get_market_data",
159
+ "get_stock_price": "get_market_data",
160
+ "fetch_stock_data": "get_market_data",
161
+ "query_stock_data": "get_market_data",
162
+ "get_stock_history": "get_market_history",
163
+ "get_historical_data": "get_market_history",
164
+ "fetch_market_history": "get_market_history",
165
+ }
166
+
167
+
168
+ def _response_cache_eligible(message: str, history: list, enable_tools: bool) -> bool:
169
+ """Return whether a turn can safely reuse a prose-only model response."""
170
+ return not enable_tools and not history and len(message) < 300
171
+
172
+
173
+ def _routing_message_for_turn(message: str, history: list) -> str:
174
+ """Keep provider routing anchored to the user's original request.
175
+
176
+ Runtime tool rounds pass a generated ``## Tool Results`` prompt as the
177
+ current message. Classifying that synthetic prompt can switch a finance
178
+ turn into coding and expose unrelated file/shell tools.
179
+ """
180
+ current = str(message or "")
181
+ if not current.lstrip().startswith("## Tool Results"):
182
+ return current
183
+ for item in reversed(history or []):
184
+ if not isinstance(item, dict) or item.get("role") != "user":
185
+ continue
186
+ content = item.get("content", "")
187
+ if not isinstance(content, str):
188
+ continue
189
+ if content.strip() and not content.lstrip().startswith("## Tool Results"):
190
+ return content
191
+ return current
192
+
193
+
194
+ def _normalize_requested_tool_call(
195
+ tool_name: str,
196
+ params: dict,
197
+ available_names: set[str],
198
+ ) -> tuple[str, dict, str]:
199
+ """Map common model aliases and parameter names to registered tools."""
200
+ requested_name = str(tool_name or "").strip()
201
+ aliased_name = _TOOL_NAME_ALIASES.get(requested_name, requested_name)
202
+ resolved_name = aliased_name if aliased_name in available_names else requested_name
203
+ normalized_params = dict(params or {})
204
+
205
+ # Normalize symbol parameter across all market tools
206
+ if resolved_name in {"get_market_data", "get_market_history", "calculate_factors", "get_risk_metrics", "get_options_chain"}:
207
+ sym = normalized_params.get("symbol")
208
+ if not sym:
209
+ for key in ("symbols", "ticker", "tickers", "code", "stock", "asset"):
210
+ if normalized_params.get(key):
211
+ sym = normalized_params.pop(key)
212
+ break
213
+ if isinstance(sym, (list, tuple)):
214
+ sym = sym[0] if sym else ""
215
+ elif isinstance(sym, dict):
216
+ sym = sym.get("symbol") or sym.get("ticker") or sym.get("code") or ""
217
+ sym_str = str(sym or "").strip()
218
+ if (sym_str.startswith("[") and sym_str.endswith("]")) or (sym_str.startswith("(") and sym_str.endswith(")")):
219
+ sym_str = sym_str[1:-1].strip()
220
+ normalized_params["symbol"] = sym_str.strip("'\"` ").upper()
221
+
222
+ # Normalize query parameter for web_search
223
+ if resolved_name in {"web_search", "web_fetch"}:
224
+ q = (
225
+ normalized_params.get("query")
226
+ or normalized_params.get("q")
227
+ or normalized_params.get("search")
228
+ or normalized_params.get("keyword")
229
+ or normalized_params.get("keywords")
230
+ or normalized_params.get("text")
231
+ or normalized_params.get("symbol")
232
+ or ""
233
+ )
234
+ if isinstance(q, (list, tuple)):
235
+ q = " ".join(str(x) for x in q if x)
236
+ normalized_params["query"] = str(q).strip().strip("'\"`")
237
+
238
+ return resolved_name, normalized_params, requested_name
239
+
240
+
241
+ def _finance_tool_schema_allowed(
242
+ schema: dict,
243
+ allowed_names: set[str],
244
+ explicit_mcp: bool = False,
245
+ ) -> bool:
246
+ """Allow local finance tools and approved finance tools exposed over MCP."""
247
+ name = str(schema.get("function", {}).get("name", ""))
248
+ if name in allowed_names:
249
+ return True
250
+ if not name.startswith("mcp__"):
251
+ return False
252
+ short_name = name.rsplit("__", 1)[-1]
253
+ return explicit_mcp or short_name in allowed_names
254
+
255
+
256
+ def _skill_tool_schema_allowed(schema: dict, allowed_names: set[str]) -> bool:
257
+ """Match a Skill policy against local and namespaced MCP tool schemas.
258
+
259
+ Core file I/O tools (read_file, write_file, ...) are always allowed
260
+ regardless of the skill's policy — see CORE_FILE_TOOLS for why.
261
+ """
262
+ name = str(schema.get("function", {}).get("name", ""))
263
+ if name in _CORE_FILE_TOOLS or name in allowed_names:
264
+ return True
265
+ if name.startswith("mcp__"):
266
+ short_name = name.rsplit("__", 1)[-1]
267
+ return short_name in _CORE_FILE_TOOLS or short_name in allowed_names
268
+ return False
269
+
270
+
271
+ async def stream_ollama(ollama_url: str, message: str, history: list,
272
+ model: str = "qwen2.5:7b",
273
+ on_token=None, on_thinking=None,
274
+ on_tool_call=None, on_tool_result=None,
275
+ cancel_event: asyncio.Event = None,
276
+ enable_tools: bool = True,
277
+ tool_schemas: list = None,
278
+ defer_tool_execution: bool = False,
279
+ system_override: str = None,
280
+ show_market_prefetch_status: bool = True) -> dict:
281
+ """Stream chat via local Ollama with tool calling support (native + text-based)."""
282
+ import aiohttp
283
+
284
+ _intent_message = _routing_message_for_turn(message, history)
285
+
286
+ # ── Response cache: skip Ollama for repeated stateless queries ───────────
287
+ # Only cache when there is no conversation history (stateless), the query
288
+ # is short (likely a simple quote/concept), and no tools are being called.
289
+ # A cached prose answer cannot stand in for a tool-capable turn: returning
290
+ # it here bypasses schema injection and the runtime tool loop entirely.
291
+ _should_cache = _response_cache_eligible(message, history, enable_tools)
292
+ if _should_cache:
293
+ _ck = _cache_key(model, message)
294
+ _cached = _cache_get(_ck)
295
+ if _cached:
296
+ if on_token:
297
+ on_token(_cached)
298
+ return {"success": True, "response": _cached,
299
+ "provider": "ollama_cache", "usage": {}}
300
+
301
+ _models_probe, _ollama_err = detect_ollama_models_rich(ollama_url)
302
+ if _ollama_err:
303
+ if _is_simple_greeting(_intent_message):
304
+ return _offline_greeting_response()
305
+ return _ollama_unavailable_result(ollama_url, _ollama_err)
306
+
307
+ # ── 模型自动解析:确保请求的模型在 Ollama 中存在 ─────────────────────────
308
+ try:
309
+ from local_llm_provider import resolve_model_async
310
+ _resolved = await resolve_model_async(ollama_url, model)
311
+ if _resolved != model:
312
+ model = _resolved # silently remap to available model
313
+ except Exception:
314
+ pass # resolution failed — proceed with original model name
315
+
316
+ # ── 模型分级守卫:小模型不能处理 coding/analysis/complex-finance 任务 ──────
317
+ # 如果分配到的模型是 small/nano 级别,但任务需要代码生成、复杂分析或长文本,
318
+ # 自动升级到 Ollama 中最优可用模型,防止低质量/模板化输出。
319
+ try:
320
+ from model_capability import get_model_capability, is_router_only, can_handle_coding
321
+ _cap_check = get_model_capability(model)
322
+ _task_needs_upgrade = (
323
+ is_router_only(_cap_check)
324
+ or (not can_handle_coding(_cap_check) and _is_coding_request(_intent_message))
325
+ # Small (1-4B) models also struggle with complex finance questions:
326
+ # they ignore detailed system prompts and output template garbage.
327
+ # Upgrade when the question is non-trivial and the model is "small".
328
+ # Use 8 as the minimum length threshold (works for both Chinese and English):
329
+ # Chinese "比特币值得投资吗" = 9 chars, English "buy or sell?" = 12 chars.
330
+ or (_cap_check.size_class == "small" and len(_intent_message) > 8
331
+ and not _is_simple_greeting(_intent_message))
332
+ )
333
+ if _task_needs_upgrade and _models_probe:
334
+ # 按优先级寻找可用的升级模型
335
+ # NOTE: gpt-oss 排在 deepseek-v3.1 前面,因为 deepseek-v3.1:671b-cloud
336
+ # 在 Ollama 实例中有时超时,而 gpt-oss:120b-cloud 响应稳定。
337
+ _upgrade_prefixes = [
338
+ "aria-sonata-3b", "qwen2.5-coder:7b", "qwen2.5-coder:3b",
339
+ "qwen2.5:7b", "qwen2.5:3b", "llama3.2:3b", "mistral",
340
+ # Cloud models registered in this Ollama instance (remote but available)
341
+ "gpt-oss", "deepseek-v3.1",
342
+ ]
343
+ # _models_probe is a list of dicts: {"name": str, "size_label": str, ...}
344
+ # Must extract "name" field — do NOT call .startswith() on the dict.
345
+ _probe_names = [
346
+ m["name"] if isinstance(m, dict) else m
347
+ for m in _models_probe
348
+ ]
349
+ for _pref in _upgrade_prefixes:
350
+ _candidate = next(
351
+ (m for m in _probe_names if m.startswith(_pref)), None
352
+ )
353
+ if _candidate and _candidate != model:
354
+ model = _candidate
355
+ break
356
+ except Exception:
357
+ pass
358
+
359
+ # ── 五档路由:通过 Prelude 意图分类器(或关键词 fallback)决定 prompt ────
360
+ # Always rebuild finance prompt to get today's date
361
+ _finance_prompt = _build_finance_prompt(_intent_message)
362
+
363
+ try:
364
+ from intent_classifier import (
365
+ classify_intent_async,
366
+ INTENT_CODING, INTENT_ANALYSIS, INTENT_REALTIME,
367
+ INTENT_GENERAL, INTENT_FINANCE,
368
+ )
369
+ _intent = await classify_intent_async(_intent_message, ollama_url)
370
+ except Exception:
371
+ # Fallback to legacy keyword detection if intent_classifier unavailable
372
+ if _is_coding_request(_intent_message):
373
+ _intent = "coding"
374
+ elif _is_analysis_request(_intent_message):
375
+ _intent = "analysis"
376
+ elif _is_general_knowledge(_intent_message):
377
+ _intent = "general"
378
+ else:
379
+ _intent = "finance"
380
+
381
+ # The lightweight classifier can label stock-analysis prompts as generic
382
+ # finance. Preserve the stronger deterministic signal so tool-capable
383
+ # models receive the prompt that requires market data before analysis.
384
+ if _is_analysis_request(_intent_message) and _intent in {"general", "finance"}:
385
+ _intent = "analysis"
386
+
387
+ _is_general = (_intent == "general")
388
+ try:
389
+ from apps.cli.intent_router import build_intent_route
390
+ _route = build_intent_route(_intent_message)
391
+ except Exception:
392
+ _route = None
393
+
394
+ # Resolve portable workflow metadata before tool schemas are selected. This
395
+ # lets a verified Skill narrow the tools exposed to the model, rather than
396
+ # treating its declared policy as prompt-only guidance.
397
+ _is_tool_followup = message.lstrip().startswith("## Tool Results")
398
+ _skill_activation = None
399
+ try:
400
+ from packages.aria_skills import activate_external_skills as _activate_external_skills
401
+
402
+ _skill_activation = _activate_external_skills(
403
+ _intent_message,
404
+ record=not _is_tool_followup,
405
+ )
406
+ except Exception as _skill_exc:
407
+ logger.debug("skill activation failed: %s", _skill_exc)
408
+
409
+ # ── Context-aware tool schema filtering ───────────────────────────────────
410
+ # Intent drives tool exposure, but cross-intent requests (e.g. "分析AAPL然后
411
+ # 写一个回测策略") need BOTH market data tools AND coding tools.
412
+ # Detect overlap by checking if the message contains signals from both domains.
413
+ _CU_TOOL_NAMES = {"browser_navigate", "browser_screenshot",
414
+ "computer_screenshot", "computer_action"}
415
+ _CODE_TOOL_NAMES = {"read_file", "write_file", "edit_file", "list_files",
416
+ "search_code", "run_command", "github",
417
+ "glob", "notebook_read", "notebook_edit"}
418
+ _FINANCE_TOOL_NAMES = {
419
+ "get_market_data", "get_market_history", "calculate_factors",
420
+ "get_risk_metrics", "analyze_news",
421
+ "get_market_insights", "get_predictions", "peer_comparison",
422
+ "web_search", "web_fetch",
423
+ "research_report_assess",
424
+ "research_run_create", "research_run_get", "research_run_transition",
425
+ "research_run_attach_artifact", "research_run_record_quality",
426
+ "research_run_events",
427
+ }
428
+
429
+ _available_tool_schemas = list(
430
+ LOCAL_TOOL_SCHEMAS if tool_schemas is None else tool_schemas
431
+ )
432
+ _msg_low = _intent_message.lower()
433
+ _explicit_tool_signal = any(k in _msg_low for k in (
434
+ "调用工具", "使用工具", "用工具", "调用 agent", "使用 agent",
435
+ "调用智能体", "使用智能体", "mcp", "tool call", "use tool",
436
+ "use agent", "spawn agent",
437
+ ))
438
+ _explicit_agent_signal = any(k in _msg_low for k in (
439
+ "agent", "智能体", "多代理", "多智能体", "研究团队", "subagent",
440
+ ))
441
+ _explicit_mcp_signal = "mcp" in _msg_low
442
+ _finance_tool_names = set(_FINANCE_TOOL_NAMES)
443
+ if any(k in _msg_low for k in ("加密", "比特币", "以太坊", "crypto", "btc", "eth")):
444
+ _finance_tool_names.add("get_crypto_data")
445
+ if any(k in _msg_low for k in ("外汇", "汇率", "forex", "fx", "usd/", "eur/")):
446
+ _finance_tool_names.add("get_forex_data")
447
+ if any(k in _msg_low for k in ("持仓", "账户", "下单", "broker", "portfolio", "position")):
448
+ _finance_tool_names.update({"broker_query", "broker_order", "get_risk_metrics"})
449
+ if any(k in _msg_low for k in ("期权", "option", "隐含波动率")):
450
+ _finance_tool_names.add("get_options_chain")
451
+ if _explicit_agent_signal:
452
+ _finance_tool_names.update({
453
+ "spawn_task", "task_status", "task_result", "task_list",
454
+ "task_cancel", "update_todos",
455
+ })
456
+ _explicit_code_signal = bool(getattr(_route, "explicit_code", False)) if _route else any(k in _msg_low for k in (
457
+ "代码", "脚本", "python", "程序", "实现", "开发", "修改文件",
458
+ "写代码", "编写代码", "策略代码", "保存为.py", ".py",
459
+ "script", "code", "program", "implement", "edit file", "write file",
460
+ ))
461
+ _is_visual_artifact_request = bool(getattr(_route, "visual_artifact", False)) if _route else any(k in _msg_low for k in (
462
+ "图表", "走势图", "k线图", "k线", "chart", "plot", "dashboard", "看板", "report", "报告",
463
+ ))
464
+ _has_coding_signal = _explicit_code_signal or (
465
+ not _is_visual_artifact_request
466
+ and any(k in _msg_low for k in ("写", "回测", "backtest", "save", "file"))
467
+ )
468
+ _has_finance_signal = any(k in _msg_low for k in (
469
+ "分析", "股票", "行情", "股价", "市场", "quantitative", "stock",
470
+ "price", "market", "analyze", "analysis", "ticker",
471
+ ))
472
+ _is_cross_intent = _has_coding_signal and _has_finance_signal
473
+
474
+ if _is_visual_artifact_request and not _explicit_code_signal:
475
+ _excluded = _CU_TOOL_NAMES | _CODE_TOOL_NAMES
476
+ _schemas_for_context = [
477
+ s for s in _available_tool_schemas
478
+ if s.get("function", {}).get("name") not in _excluded
479
+ ]
480
+ elif _intent in ("finance", "analysis", "realtime") and not _is_cross_intent:
481
+ # Keep finance turns focused. MCP tools remain available because they
482
+ # may provide institution-specific data, while file/code tools are
483
+ # excluded so the model does not search the repository for a quote API.
484
+ _schemas_for_context = [
485
+ s for s in _available_tool_schemas
486
+ if _finance_tool_schema_allowed(
487
+ s,
488
+ _finance_tool_names,
489
+ explicit_mcp=_explicit_mcp_signal,
490
+ )
491
+ ]
492
+ elif _is_cross_intent:
493
+ # Cross-intent: expose both finance AND coding tools (minus CU/browser).
494
+ # Intent hint injected into system prompt guides priority without hard exclusion.
495
+ _schemas_for_context = [
496
+ s for s in _available_tool_schemas
497
+ if s.get("function", {}).get("name") not in _CU_TOOL_NAMES
498
+ ]
499
+ else:
500
+ # Coding/general: all local tools except CU (browser/computer control)
501
+ _schemas_for_context = [
502
+ s for s in _available_tool_schemas
503
+ if s.get("function", {}).get("name") not in _CU_TOOL_NAMES
504
+ ]
505
+
506
+ _skill_allowed_tools = {
507
+ tool_name
508
+ for skill in (_skill_activation.skills if _skill_activation else ())
509
+ for tool_name in skill.policy.allowed_tools
510
+ }
511
+ if _skill_allowed_tools:
512
+ _schemas_for_context = [
513
+ schema for schema in _schemas_for_context
514
+ if _skill_tool_schema_allowed(schema, _skill_allowed_tools)
515
+ ]
516
+
517
+ # ── Select prompt size based on model capability ─────────────────────────
518
+ # Small / nano models (≤3B) cannot effectively use the full CODING_SYSTEM_PROMPT
519
+ # (6000+ tokens of examples they mostly ignore). Send a condensed version that
520
+ # keeps the essential rules and the single complete working template.
521
+ #
522
+ # Analysis uses the full tool-aware prompt for capable models and the lite
523
+ # prefetched-data prompt for small/text-only models.
524
+ try:
525
+ from model_capability import get_model_capability as _gmc
526
+ _prompt_cap = _gmc(model)
527
+ _model_size = _prompt_cap.size_class
528
+ _model_supports_native_tools = bool(
529
+ _prompt_cap.tool_calls and _prompt_cap.format == "ollama_native"
530
+ )
531
+ except Exception:
532
+ _model_size = "medium"
533
+ _model_supports_native_tools = False
534
+ _use_lite_prompt = _model_size in ("nano", "small")
535
+
536
+ if _intent == "coding":
537
+ _base_prompt = _build_coding_prompt_lite(_intent_message) if _use_lite_prompt else CODING_SYSTEM_PROMPT
538
+ elif _intent == "analysis":
539
+ # Native-tool models must receive the prompt that explicitly requires
540
+ # get_market_data. The lite prompt is the fallback for text-only/small
541
+ # models and tells them to rely on deterministic prefetched data.
542
+ _base_prompt = (
543
+ ANALYSIS_SYSTEM_PROMPT
544
+ if _model_supports_native_tools and not _use_lite_prompt
545
+ else _build_analysis_prompt_lite(_intent_message)
546
+ )
547
+ elif _intent == "general":
548
+ # 纯知识/概念问题:注入日期,但不注入工具 schema
549
+ from datetime import datetime as _dt2
550
+ _today_str = _dt2.now().strftime("%Y年%m月%d日")
551
+ _base_prompt = (
552
+ f"你是 Aria,Arthera 的 AI 助手。今天是 {_today_str}。\n"
553
+ "你的能力覆盖:金融量化分析、足球/体育赛事分析与预测(含泊松算法)、编程、通用知识问答。\n\n"
554
+ "## 体育量化分析规则(重要)\n"
555
+ "用户消息中可能包含两种特殊数据块:\n\n"
556
+ "### 【比赛信息】块\n"
557
+ "= football-data.org API 获取的真实赛程(比赛时间、状态、比分)。这是**事实**,不要质疑。\n\n"
558
+ "### 【泊松模型量化预测】块\n"
559
+ "= Aria 用 Dixon-Coles 泊松算法计算的量化结果,包含:\n"
560
+ " - 两队 FIFA 排名强度参数(进攻/防守)\n"
561
+ " - 预期进球数(λ值)\n"
562
+ " - 主胜/平局/客胜概率(%)\n"
563
+ " - 最可能比分及其概率\n"
564
+ " - 隐含赔率\n"
565
+ "当收到此块时,你应当:\n"
566
+ "1. **直接引用数字**(「算法显示加拿大胜率 42.4%」),不要重新计算或质疑\n"
567
+ "2. **解释概率背后的逻辑**:FIFA 排名差距、进攻强度对比说明了什么\n"
568
+ "3. **分析高频比分区间**:比如 1-0/1-1/2-1 集中说明比赛预计紧张胶着\n"
569
+ "4. **补充战术/球员层面的定性分析**(这是你的训练知识,算法没有的部分)\n"
570
+ "5. **绝对不要**说「我没有实时数据」「世界杯尚未开始」「不包含实时数据」「以上预测基于历史」——这些与上方数据矛盾\n"
571
+ "6. **绝对不要**在末尾添加「若需最新数据请使用/football命令」之类的建议——用户已经有数据了\n\n"
572
+ "## 通用规则\n"
573
+ "- 使用 Markdown(**粗体**、## 标题、- 列表、表格)\n"
574
+ "- 不要编造股价/汇率等金融数字\n"
575
+ "- 简洁精准,用数据说话\n"
576
+ )
577
+ else:
578
+ # realtime / finance: use full finance prompt with tool access
579
+ _base_prompt = _finance_prompt
580
+
581
+ # Project context injection: skip or condense for small/nano models.
582
+ # A 1.5B model with a 4000-token README injected into its context will
583
+ # either copy the README into its response or hallucinate beyond recovery.
584
+ _small_model = _model_size in ("nano", "small")
585
+ if not _is_general:
586
+ if not _small_model and _PROJECT_CONTEXT:
587
+ system_prompt = _base_prompt + _PROJECT_CONTEXT
588
+ else:
589
+ # For small models: skip the full README, only keep a 2-line summary
590
+ _ctx_brief = ""
591
+ if _PROJECT_CONTEXT:
592
+ _first_lines = [l for l in _PROJECT_CONTEXT.split("\n") if l.strip()][:3]
593
+ _ctx_brief = "\n# Context: " + " | ".join(_first_lines[:2]) + "\n"
594
+ system_prompt = _base_prompt + _ctx_brief
595
+ else:
596
+ system_prompt = _base_prompt
597
+
598
+ # Inject cross-intent hint so the model knows to use both tool sets in order
599
+ if _is_cross_intent:
600
+ _cross_hint = (
601
+ "\n\n## Task Hint\n"
602
+ "This request spans both **market analysis** and **code generation**. "
603
+ "Suggested order: (1) fetch market data first, (2) use the data to write/run code. "
604
+ "Use get_market_data for live prices, then write_file + run_command for scripts.\n"
605
+ )
606
+ system_prompt = system_prompt + _cross_hint
607
+
608
+ # Prepend global user memory (user profile, project history, preferences)
609
+ try:
610
+ from memory_manager import MemoryManager as _MM
611
+ _mem_block = _MM().load_context(max_chars=500)
612
+ if _mem_block:
613
+ system_prompt = _mem_block + "\n" + system_prompt
614
+ except Exception:
615
+ pass
616
+
617
+ # Append ariarc project context if available — small models skip this
618
+ if _HAS_ARIARC and not _is_general and not _small_model:
619
+ try:
620
+ _arc = get_ariarc()
621
+ _arc_block = _arc.build_system_prompt_block()
622
+ if _arc_block:
623
+ # Hard-cap ariarc block at 800 chars to prevent context overflow
624
+ _arc_short = _arc_block[:800] + ("…" if len(_arc_block) > 800 else "")
625
+ system_prompt = system_prompt + "\n\n" + _arc_short
626
+ except Exception:
627
+ pass
628
+
629
+ # Inject live broker context when user is asking about their own portfolio,
630
+ # or when a broker is connected and the message is finance-related.
631
+ if _is_broker_intent(_intent_message):
632
+ _broker_ctx = _build_broker_context_block()
633
+ if _broker_ctx:
634
+ system_prompt = system_prompt + "\n\n" + _broker_ctx
635
+
636
+ # Allow /file analyze and other commands to inject a specialist role override
637
+ if system_override:
638
+ system_prompt = system_override + "\n\n" + system_prompt
639
+
640
+ url = f"{ollama_url}/api/chat"
641
+ _mcfg = get_model_cfg(model)
642
+
643
+ if _HAS_MODEL_CAP:
644
+ _cap = get_model_capability(model)
645
+ _num_ctx = _cap.context_window
646
+ _temperature = _cap.temperature
647
+ else:
648
+ _num_ctx = _mcfg.get("num_ctx", 16384)
649
+ _temperature = _mcfg.get("temperature", 0.3)
650
+ _max_tokens = _mcfg.get("max_tokens", min(_mcfg.get("num_ctx", 8192) // 4, 8192))
651
+ _mkey = resolve_model_key(model)
652
+
653
+ # ── 上下文硬截断:保留 80% 上下文给历史,防止溢出 ────────────────────
654
+ # 用 1.5 chars/token(CJK 混合文本实际比率)而非英文假设的 4 chars/token
655
+ _chars_per_tok = 1.5
656
+ _ctx_chars_for_hist = int(_num_ctx * 0.80 * _chars_per_tok) - len(system_prompt) - len(message) - 512
657
+ _ctx_chars_limit = max(_ctx_chars_for_hist, 1000)
658
+ # 从最新历史往前选,确保总字符数不超限
659
+ _trimmed_history: list = []
660
+ _hist_chars = 0
661
+ for _hm in reversed(history):
662
+ _hm_len = len(_hm.get("content",""))
663
+ if _hist_chars + _hm_len > _ctx_chars_limit:
664
+ break
665
+ _trimmed_history.insert(0, _hm)
666
+ _hist_chars += _hm_len
667
+
668
+ messages = [{"role": "system", "content": system_prompt}]
669
+ for msg in _trimmed_history:
670
+ prepared_msg = {
671
+ "role": msg["role"],
672
+ "content": msg.get("content", ""),
673
+ }
674
+ for key in ("tool_calls", "tool_call_id", "name"):
675
+ if msg.get(key) is not None:
676
+ prepared_msg[key] = msg[key]
677
+ messages.append(prepared_msg)
678
+ # Runtime adapters pass history and the current prompt separately. Legacy
679
+ # callers may already have appended that exact prompt, so deduplicate by
680
+ # content rather than by role; a tool-result message is also role=user and
681
+ # must not cause the next agent instruction to be dropped.
682
+ _last_is_current = bool(
683
+ _trimmed_history
684
+ and _trimmed_history[-1].get("role") == "user"
685
+ and _trimmed_history[-1].get("content") == message
686
+ )
687
+ if not _last_is_current:
688
+ messages.append({"role": "user", "content": message})
689
+
690
+ # ── 工具注入:通识问答跳过,同时跳过无法可靠调用工具的小模型 ──────────
691
+ # 判断模型是否具备工具调用能力(text_only / format不支持的都跳过)
692
+ _model_can_use_tools = False
693
+ if (
694
+ _HAS_MODEL_CAP
695
+ and enable_tools
696
+ and _available_tool_schemas
697
+ and (not _is_general or _explicit_tool_signal)
698
+ ):
699
+ _tool_cap = get_model_capability(model)
700
+ # 只有明确支持工具且 context_window >= 8192 的模型才注入 tool schema
701
+ _model_can_use_tools = (
702
+ _tool_cap.format != "text_only"
703
+ and _tool_cap.context_window >= 8192
704
+ )
705
+ if _model_can_use_tools:
706
+ _tool_sys = build_tool_system_prompt(_schemas_for_context, model)
707
+ if _tool_sys and messages:
708
+ if messages[0].get("role") == "system":
709
+ messages[0]["content"] += _tool_sys
710
+ else:
711
+ messages.insert(0, {"role": "system", "content": _tool_sys.strip()})
712
+
713
+ # ── 实时数据预取:始终为分析/报价查询预取真实市场数据注入 prompt ──────────
714
+ # 无论模型是否支持工具调用,都注入真实数据,防止模型生成占位符($X.XX)
715
+ # 策略:
716
+ # 1. system prompt 替换为"数据已预取"专用 prompt
717
+ # 2. 数据同时注入到用户消息开头(本地模型对最近的 user message 最敏感)
718
+ _skip_market_prefetch = (
719
+ _is_general
720
+ or _is_visual_artifact_request
721
+ or _model_can_use_tools
722
+ )
723
+ if _HAS_MDC and not _skip_market_prefetch:
724
+ import time as _t_inj
725
+ _t_inj_start = _t_inj.time()
726
+ _market_inject = _try_prefetch_market_data(_intent_message, history)
727
+ _t_inj_ms = int((_t_inj.time() - _t_inj_start) * 1000)
728
+ if _market_inject:
729
+ # 过程可见化:⏺/✓ 格式,与工具调用步骤保持一致
730
+ import re as _re_inj
731
+ _inj_m = _re_inj.search(r'## 📊 (\S+) 实时行情(来源:(\S+))', _market_inject)
732
+ if HAS_RICH and show_market_prefetch_status:
733
+ _sym_label = _inj_m.group(1) if _inj_m else "market_data"
734
+ _src_label = _inj_m.group(2) if _inj_m else "local"
735
+ console.print(
736
+ f"\n [#C08050]⏺[/#C08050] [bold]market_data[/bold]"
737
+ f" [dim]{_sym_label} · {_src_label}[/dim]"
738
+ )
739
+ console.print(
740
+ f" [green]✓[/green] [dim]实时行情已注入[/dim]"
741
+ f" [dim]({_t_inj_ms}ms)[/dim]"
742
+ )
743
+ # Replace system prompt with data-first prompt.
744
+ # Use nano variant for 1-3B models (no template placeholders).
745
+ _is_nano_model = _use_lite_prompt or _model_size in ("nano", "small")
746
+ _prefetched_sys = _build_prefetched_analysis_prompt(nano=_is_nano_model, user_message=_intent_message)
747
+ if messages and messages[0].get("role") == "system":
748
+ messages[0]["content"] = _prefetched_sys
749
+ else:
750
+ messages.insert(0, {"role": "system", "content": _prefetched_sys})
751
+ # Prepend real data to the user message so the model sees it last
752
+ # (most recent = highest attention weight for local models).
753
+ _augmented_user = (
754
+ _market_inject
755
+ + "\n---\n"
756
+ "上面是真实实时数据。请只使用这些具体数字作答,不要引用训练记忆中的历史价格。\n\n"
757
+ + message
758
+ )
759
+ for _mi in reversed(messages):
760
+ if _mi.get("role") == "user":
761
+ _mi["content"] = _augmented_user
762
+ break
763
+
764
+ # Portable SKILL.md workflows are loaded after prompt/prefetch selection so
765
+ # they survive the data-first prompt replacement used by local models.
766
+ try:
767
+ _skill_block = _skill_activation.prompt_block
768
+ if _skill_block and messages:
769
+ if messages[0].get("role") == "system":
770
+ messages[0]["content"] += "\n\n" + _skill_block
771
+ else:
772
+ messages.insert(0, {"role": "system", "content": _skill_block})
773
+ if not _is_tool_followup:
774
+ for _active_skill in _skill_activation.skills:
775
+ if HAS_RICH:
776
+ console.print(
777
+ f" [#C08050]⏺[/#C08050] [bold]skill[/bold] "
778
+ f"[dim]{_active_skill.qualified_name} · "
779
+ f"{_active_skill.integrity}[/dim]"
780
+ )
781
+ else:
782
+ print(
783
+ f" skill {_active_skill.qualified_name} · "
784
+ f"{_active_skill.integrity}"
785
+ )
786
+ except (AttributeError, TypeError) as _skill_exc:
787
+ logger.debug("skill prompt injection failed: %s", _skill_exc)
788
+
789
+ # ── 体育赛事数据预取:sports query → inject live scores / WC data + Poisson ─
790
+ if _is_general and _is_sports_query(_intent_message):
791
+ _sports_ctx = _try_prefetch_sports_data(_intent_message)
792
+ if not _sports_ctx:
793
+ _sports_ctx = _recent_sports_quant_context(history)
794
+ if _sports_ctx:
795
+ _has_quant = (
796
+ "泊松模型量化预测" in _sports_ctx
797
+ or ("预期进球" in _sports_ctx and "可能比分" in _sports_ctx)
798
+ )
799
+ if HAS_RICH:
800
+ _label = "[bold #50A0C0]sports_data+quant[/bold #50A0C0]" if _has_quant else "[bold #50A0C0]sports_data[/bold #50A0C0]"
801
+ console.print(f" {_label} [dim]赛事数据{'+ 泊松预测 ' if _has_quant else ''}已注入[/dim]")
802
+ # Print Poisson block directly so user sees it even if LLM ignores context
803
+ if _has_quant and not _ARIA_BOT_MODE:
804
+ console.print(Panel(
805
+ _sports_ctx,
806
+ title="[bold]⚽ 量化预测数据[/bold]",
807
+ border_style="cyan",
808
+ padding=(0, 1),
809
+ ))
810
+ for _mi in reversed(messages):
811
+ if _mi.get("role") == "user":
812
+ if _has_quant:
813
+ _injection_note = (
814
+ "\n---\n"
815
+ "## 数据说明\n"
816
+ "以上数据来自 football-data.org 实时 API + 泊松量化模型(Aria 本地计算)。\n"
817
+ f"【比赛信息】块 = API 真实赛程数据,获取日期为 {datetime.now().strftime('%Y-%m-%d')}。\n"
818
+ "【泊松模型量化预测】块 = Aria 使用 Dixon-Coles 泊松分布对此场比赛运行的算法结果。\n\n"
819
+ "## 你的任务\n"
820
+ "1. **直接引用预测数据中的概率数字**(如「加拿大胜率 42.4%」),不要说你没有数据\n"
821
+ "2. 解释为什么会有这样的概率分布(结合两队 FIFA 排名、进攻/防守强度)\n"
822
+ "3. 如果用户要“一个以上/多个/最准比分”,必须按 `top_scorelines` 概率降序列出候选比分和概率\n"
823
+ "4. 分析最可能的比分区间(高频比分说明比赛预计紧张/单方碾压)\n"
824
+ "5. 可以给出走势判断,但不要编造射正率、最近5场客场数据、历史交锋次数等输入数据之外的具体事实\n"
825
+ "6. 注意区分胜平负概率和准确比分概率:热门球队胜率最高,不代表最可能的单一比分一定是该队获胜\n"
826
+ "7. 不要否认上方已提供的赛程或模型数据;如需说明限制,具体指出缺失字段\n"
827
+ "8. **严禁**在回复末尾添加任何类似以下内容的免责声明:\n"
828
+ " - 「以上预测基于历史...并不包含实时数据」\n"
829
+ " - 「不包含实时数据或赛前最新信息」\n"
830
+ " - 「若需赛前最新数据,请使用 /football 命令」\n"
831
+ " 上方已有实时 API 数据 + 量化模型,这些免责声明与数据矛盾,请勿添加。\n\n"
832
+ )
833
+ else:
834
+ _injection_note = (
835
+ "\n---\n"
836
+ "以上是从 football-data.org 获取的赛事数据。\n"
837
+ "请基于这些数据给出分析,若数据不完整可结合训练知识合理推断。\n\n"
838
+ )
839
+ _mi["content"] = _sports_ctx + _injection_note + message
840
+ break
841
+
842
+ # ── 文件路径自动注入:若用户消息引用了本地文件,预读并注入内容 ────────────
843
+ # 无论意图是什么,只要消息里有可读的文件路径就注入(coding / analysis 均有效)
844
+ _file_inject = _try_inject_file_paths(_intent_message)
845
+ if _file_inject:
846
+ for _mi in reversed(messages):
847
+ if _mi.get("role") == "user":
848
+ _mi["content"] = _file_inject + _mi["content"]
849
+ break
850
+
851
+ # ── Token budget 分级策略 ────────────────────────────────────────────────
852
+ # 小模型(<8K ctx)防止无限延伸;通识问答分两档:
853
+ # · 纯问候/一句话问题 → 200 tokens(快速)
854
+ # · 知识解释问题("什么是X", "如何…") → 1500 tokens(保证完整性)
855
+ # · 正常问题 → 模型 max_tokens 配置值
856
+ _is_small_model = _HAS_MODEL_CAP and get_model_capability(model).context_window < 8192
857
+ _is_greeting = _is_simple_greeting(_intent_message)
858
+ _wants_complete_output = any(k in _intent_message.lower() for k in (
859
+ "完整", "完整输出", "完整给出", "全面", "详细", "不要中断", "不要截断",
860
+ "complete", "full output", "comprehensive", "do not stop", "don't stop",
861
+ "do not truncate", "end-to-end",
862
+ ))
863
+
864
+ if _is_greeting:
865
+ # Reasoning-capable Ollama Cloud models can spend more than 200 tokens
866
+ # in ``message.thinking`` before emitting the short visible greeting.
867
+ _effective_max_tokens = 512 if (
868
+ _HAS_MODEL_CAP and get_model_capability(model).thinking
869
+ ) else 200
870
+ elif _wants_complete_output:
871
+ _complete_cap = max(2048, min(8192, int(_num_ctx * 0.45)))
872
+ _effective_max_tokens = max(_max_tokens, _complete_cap)
873
+ elif _is_general:
874
+ _effective_max_tokens = max(1500, min(_max_tokens, 4096)) # 足够完整回答概念解释,不截断
875
+ elif _use_lite_prompt:
876
+ # Small/nano model: coding tasks need more room for complete scripts;
877
+ # analysis/finance keep a tighter cap to prevent runaway echo generation.
878
+ if _intent == "coding":
879
+ _effective_max_tokens = 2000
880
+ else:
881
+ _effective_max_tokens = 512
882
+ elif _is_small_model:
883
+ _effective_max_tokens = min(_max_tokens, 2048)
884
+ else:
885
+ _effective_max_tokens = _max_tokens
886
+
887
+ # 停止词:覆盖常见 hallucination 模式
888
+ # 包含:英文求助模板、工具执行幻觉、"任务就绪"尾部幻觉(中文小模型常见)
889
+ _stop_seqs = [
890
+ # ── 英文求助/拒绝模板 ─────────────────────────────────────────────
891
+ "I'm sorry, as an AI",
892
+ "I'm sorry for any confusion",
893
+ "I cannot perform",
894
+ "I can't perform",
895
+ "Do You Need Help",
896
+ "Are There Specific Areas",
897
+ "Let us brainstorm together",
898
+ "AWAITING FEEDBACK",
899
+ "Would love more context",
900
+ "Please provide more details",
901
+ "Could you please provide",
902
+ "Without knowing those specifics",
903
+ "os.system('pip install",
904
+ "git clone https://github.com",
905
+ "Let's download these libraries",
906
+ # ── 中文"任务就绪"尾部幻觉(小模型在回答结束后常产生) ────────────
907
+ "好的,我将开始执行任务",
908
+ "好的,我已经准备好了要做的工作",
909
+ "请告诉我您希望我在接下来做什么",
910
+ "请问有什么我可以帮助您的吗",
911
+ "请告诉我你需要什么帮助",
912
+ "我会尽快为您完成这项任务",
913
+ "如果您有任何其他问题,请随时告诉我",
914
+ "如果你有其他问题,请随时提问",
915
+ # ── 英文任务就绪幻觉 ─────────────────────────────────────────────
916
+ "I'm ready to help with your next",
917
+ "Let me know if you need anything else",
918
+ "Is there anything else you'd like me to",
919
+ "Feel free to ask if you have more questions",
920
+ # ── 工具调用幻觉(声称已调用但实际没有 tool_call 事件)────────────
921
+ "I have already called `get_market_data`",
922
+ "I have already called `get_stock_price`",
923
+ "I have already called get_market_data",
924
+ "I have already fetched",
925
+ "I have already retrieved",
926
+ "我已经调用了",
927
+ "我已调用工具",
928
+ "我已经获取了最新数据",
929
+ # ── 模板占位符输出(模型把 system prompt 模板当内容输出)────────────
930
+ "${real_price_from_data",
931
+ "${data['day_range']}",
932
+ "{actual date today}",
933
+ "{real price from data}",
934
+ "List real recent headlines",
935
+ ]
936
+
937
+ payload = {
938
+ "model": model, "messages": messages, "stream": True,
939
+ "options": {
940
+ "num_ctx": _num_ctx,
941
+ "temperature": _temperature,
942
+ "top_p": 0.9,
943
+ "repeat_penalty": 1.4,
944
+ "repeat_last_n": 256,
945
+ "num_predict": _effective_max_tokens,
946
+ },
947
+ "stop": _stop_seqs,
948
+ }
949
+ # Only inject native Ollama tools field for capable models.
950
+ # Small (≤4K ctx) or text_only models must never receive tool schemas —
951
+ # they produce malformed partial JSON that leaks into the output stream.
952
+ if _model_can_use_tools:
953
+ _cap2 = get_model_capability(model) if _HAS_MODEL_CAP else None
954
+ if _cap2 and _cap2.tool_calls and _cap2.format == "ollama_native":
955
+ payload["tools"] = _schemas_for_context
956
+
957
+ full_response = ""
958
+ response_segments = []
959
+ tools_used = []
960
+ tool_calls_pending = []
961
+ _tool_call_counts = {}
962
+ _tool_name_counts = {}
963
+ _available_tool_names = {
964
+ s.get("function", {}).get("name", "")
965
+ for s in _schemas_for_context
966
+ if s.get("function", {}).get("name")
967
+ }
968
+ max_tool_rounds = 25 if _wants_complete_output or _intent == "coding" else 12
969
+ _server_retry_budget = 2
970
+ _continuation_count = 0
971
+ usage = {"prompt_tokens": 0, "completion_tokens": 0, "thinking_tokens": 0}
972
+ _last_tool_had_error = False # Track if previous tool failed
973
+ _in_error_recovery = False # Stays True until run_command succeeds (not reset by read_file)
974
+ _nudge_count = 0 # Limit error recovery nudges
975
+ _consecutive_reads = 0 # Track repeated read_file without fixing
976
+ _last_failed_cmd = "" # Track last failed run_command to detect repeats
977
+ _consecutive_cmd_failures = 0 # Count consecutive failures of same command
978
+ # Repetition loop detection — check every 80 chars (was 200, too slow for 200-token responses)
979
+ _rep_check_interval = 80
980
+ _rep_token_count = [0] # mutable for closure
981
+ _rep_cancelled = [False] # signals loop to stop
982
+
983
+ def _tool_signature(tool_name: str, params: dict) -> tuple[str, str]:
984
+ try:
985
+ payload = json.dumps(params or {}, sort_keys=True, ensure_ascii=False, default=str)
986
+ except Exception:
987
+ payload = str(params or {})
988
+ return tool_name, payload
989
+
990
+ def _register_tool_call(tool_name: str, params: dict) -> dict:
991
+ tool_name, normalized_params, requested_name = _normalize_requested_tool_call(
992
+ tool_name,
993
+ params,
994
+ _available_tool_names,
995
+ )
996
+ call = {"tool": tool_name, "params": normalized_params}
997
+ if requested_name != tool_name:
998
+ call["_aria_requested_tool"] = requested_name
999
+ sig = _tool_signature(tool_name, params)
1000
+ seen = _tool_call_counts.get(sig, 0)
1001
+ _tool_call_counts[sig] = seen + 1
1002
+ _tool_name_counts[tool_name] = _tool_name_counts.get(tool_name, 0) + 1
1003
+ # Market data calls are expensive and usually deterministic for a turn.
1004
+ # One successful/attempted call is enough; repeated identical calls are
1005
+ # almost always model looping.
1006
+ limit = 1 if tool_name in {
1007
+ "get_market_data", "get_crypto_data", "get_forex_data",
1008
+ "get_technical_indicators",
1009
+ } else 2
1010
+ total_limit = {
1011
+ "web_search": 3,
1012
+ "web_fetch": 4,
1013
+ "search_news": 3,
1014
+ }.get(tool_name)
1015
+ if seen >= limit:
1016
+ call["_aria_duplicate"] = True
1017
+ call["_aria_limit_reason"] = "duplicate parameters"
1018
+ elif total_limit is not None and _tool_name_counts[tool_name] > total_limit:
1019
+ call["_aria_duplicate"] = True
1020
+ call["_aria_limit_reason"] = f"turn budget exceeded ({total_limit})"
1021
+ return call
1022
+
1023
+ def _check_repetition(text: str) -> bool:
1024
+ """Return True if the response is looping.
1025
+
1026
+ Covers three patterns:
1027
+ A. Paragraph-level loop: same long block (50-400 chars) reappears
1028
+ B. Sentence-level tail loop: short sentence (15-50 chars) appears
1029
+ 2+ times at the END — catches "好的,我已经准备好了" × 2 style tails
1030
+ C. Beginning-restart loop: model generates the full response, then
1031
+ starts again from the very beginning. Detects when the opening
1032
+ 80 chars of the accumulated response reappear after the midpoint.
1033
+ This is the most common 1.5B model failure mode.
1034
+ """
1035
+ if len(text) < 100:
1036
+ return False
1037
+
1038
+ # Pattern C: restart-from-beginning (fast path, checked first)
1039
+ if len(text) > 300:
1040
+ _opening = text[:80].strip()
1041
+ if _opening and len(_opening) >= 20:
1042
+ _after_half = text[len(text) // 2:]
1043
+ if _opening in _after_half:
1044
+ return True
1045
+
1046
+ tail = text[-4000:]
1047
+
1048
+ # Pattern A: medium-to-large probe in trailing window
1049
+ for sub_len in (400, 250, 150, 80, 50):
1050
+ if len(tail) < sub_len * 2:
1051
+ continue
1052
+ probe = tail[-sub_len:].strip()
1053
+ if len(probe) < 20:
1054
+ continue
1055
+ if tail[:-sub_len].count(probe) >= 1:
1056
+ return True
1057
+
1058
+ # Pattern B: short sentence repetition at tail (boilerplate hallucination)
1059
+ # Split by Chinese sentence-ending punctuation + newlines
1060
+ import re as _re2
1061
+ # Common non-looping phrases that legitimately repeat (disclaimers, transitions)
1062
+ _B_IGNORE = {
1063
+ "本内容不构成投资建议", "不构成投资建议", "请注意风险",
1064
+ "以上仅供参考", "仅供参考", "请以官方数据为准",
1065
+ "如有问题请咨询专业人士", "投资有风险,入市需谨慎",
1066
+ "好的", "当然", "当然可以", "明白了", "好的,我来",
1067
+ }
1068
+ sentences = [s.strip() for s in _re2.split(r'[。!?\n]+', tail) if s.strip()]
1069
+ if len(sentences) >= 4:
1070
+ # Check if any sentence in the last 3 also appears before it in the tail
1071
+ for sent in sentences[-3:]:
1072
+ if len(sent) < 15: # raised from 10 → less hair-trigger
1073
+ continue
1074
+ if sent in _B_IGNORE:
1075
+ continue
1076
+ # Require 3+ occurrences (raised from 2) to reduce false positives
1077
+ if tail.count(sent) >= 3:
1078
+ return True
1079
+
1080
+ return False
1081
+
1082
+ for tool_round in range(max_tool_rounds):
1083
+ # Context compaction: compress older messages if context too large
1084
+ if tool_round > 0:
1085
+ payload["messages"] = _compact_messages(payload["messages"], model_key=_mkey)
1086
+
1087
+ full_response = ""
1088
+ tool_calls_this_round = []
1089
+ _done_reason = ""
1090
+ # Do not show provisional finance prose before this round is known to
1091
+ # be prose-only. Some local models append malformed function JSON after
1092
+ # a sentence, and streaming it first makes the protocol visible.
1093
+ _buffer_finance_prose = enable_tools and _intent in {"finance", "analysis", "realtime"}
1094
+ _buffered_tokens: list[str] = []
1095
+
1096
+ try:
1097
+ async with aiohttp.ClientSession() as session:
1098
+ async with session.post(url, json=payload,
1099
+ timeout=aiohttp.ClientTimeout(total=300)) as resp:
1100
+ if resp.status != 200:
1101
+ try:
1102
+ _body = await resp.text()
1103
+ _json = json.loads(_body) if _body.strip().startswith("{") else {}
1104
+ _ollama_err = _json.get("error") or _body[:200]
1105
+ except Exception:
1106
+ _ollama_err = f"HTTP {resp.status}"
1107
+ # Invalidate model cache so next call re-probes
1108
+ try:
1109
+ from local_llm_provider import _model_cache
1110
+ _model_cache.clear()
1111
+ except Exception:
1112
+ pass
1113
+ if resp.status >= 500 and _server_retry_budget > 0:
1114
+ _server_retry_budget -= 1
1115
+ if HAS_RICH:
1116
+ console.print(" [yellow]模型服务暂时错误,已压缩上下文并重试…[/yellow]")
1117
+ payload["messages"] = _compact_messages(payload["messages"], model_key=_mkey)
1118
+ payload["messages"].append({
1119
+ "role": "user",
1120
+ "content": (
1121
+ "SYSTEM: The model server returned a transient 5xx error. "
1122
+ "Continue the current task from the available tool results. "
1123
+ "Do not restart or repeat completed work."
1124
+ ),
1125
+ })
1126
+ await asyncio.sleep(1.0)
1127
+ continue
1128
+ return {"success": False, "error": f"Ollama {resp.status}: {_ollama_err}"}
1129
+ async for line in resp.content:
1130
+ if cancel_event and cancel_event.is_set():
1131
+ return {"success": True, "response": full_response,
1132
+ "cancelled": True, "provider": "ollama", "usage": usage}
1133
+ text = line.decode("utf-8", errors="ignore").strip()
1134
+ if not text:
1135
+ continue
1136
+ try:
1137
+ data = json.loads(text)
1138
+ except json.JSONDecodeError:
1139
+ continue
1140
+
1141
+ # Check for native tool calls from Ollama
1142
+ msg = data.get("message", {})
1143
+ reasoning = msg.get("thinking", "")
1144
+ if reasoning:
1145
+ if on_thinking:
1146
+ on_thinking(reasoning)
1147
+ if msg.get("tool_calls"):
1148
+ for tc in msg["tool_calls"]:
1149
+ fn = tc.get("function", {})
1150
+ tool_name = fn.get("name", "")
1151
+ tool_args = fn.get("arguments", {})
1152
+ if isinstance(tool_args, str):
1153
+ try:
1154
+ tool_args = json.loads(tool_args)
1155
+ except json.JSONDecodeError:
1156
+ tool_args = {}
1157
+ call = _register_tool_call(tool_name, tool_args)
1158
+ tool_calls_this_round.append(call)
1159
+ if not call.get("_aria_duplicate"):
1160
+ tools_used.append(tool_name)
1161
+ if on_tool_call and not call.get("_aria_duplicate"):
1162
+ on_tool_call(tool_name, tool_args)
1163
+
1164
+ if data.get("done"):
1165
+ # Capture Ollama usage stats from final message
1166
+ usage["prompt_tokens"] += data.get("prompt_eval_count", 0)
1167
+ usage["completion_tokens"] += data.get("eval_count", 0)
1168
+ _done_reason = str(data.get("done_reason") or data.get("stop_reason") or "")
1169
+ break
1170
+
1171
+ token = msg.get("content", "")
1172
+ if token:
1173
+ full_response += token
1174
+ # 重复检测:先检测再流出,避免重复内容流到用户终端
1175
+ _rep_token_count[0] += len(token)
1176
+ if _rep_token_count[0] >= _rep_check_interval:
1177
+ _rep_token_count[0] = 0
1178
+ if _check_repetition(full_response):
1179
+ # 定位重复起始点:找到最长不重复前缀
1180
+ _fr = full_response
1181
+ _cut = len(_fr) // 2
1182
+ # 尝试精确裁切:找重复开始的位置
1183
+ for _probe_len in (300, 200, 150, 100):
1184
+ if len(_fr) < _probe_len * 2:
1185
+ continue
1186
+ _probe = _fr[-_probe_len:]
1187
+ _pos = _fr[:-_probe_len].find(_probe)
1188
+ if _pos > 0:
1189
+ _cut = _pos
1190
+ break
1191
+ full_response = _fr[:_cut].rstrip()
1192
+ _rep_cancelled[0] = True
1193
+ if cancel_event:
1194
+ cancel_event.set()
1195
+ break
1196
+ # 流出 token — 过滤条件:
1197
+ # 1. 以 <tool_call 开头的 XML 工具调用(内部处理,不显示)
1198
+ # 2. 以 { 开头的裸 JSON 工具调用(小模型幻觉,直接屏蔽)
1199
+ _fr_lstrip = full_response.lstrip()
1200
+ _looks_like_tool_json = (
1201
+ _fr_lstrip.startswith("{")
1202
+ and ('"name"' in full_response or '"function"' in full_response)
1203
+ and '"arguments"' in full_response
1204
+ )
1205
+ # 3. 孤立的 ``` 围栏(未配对的代码块标记,过滤掉)
1206
+ _stripped_tok = token.strip()
1207
+ _is_orphan_fence = (
1208
+ _stripped_tok.startswith("```")
1209
+ and len(_stripped_tok) <= 6 # just ``` or ```py etc
1210
+ and full_response.count("```") % 2 == 1 # unpaired
1211
+ )
1212
+ _has_internal_protocol = bool(
1213
+ re.search(r"(?<![A-Za-z0-9_])\??brtc\b", full_response, re.IGNORECASE)
1214
+ )
1215
+ if not _fr_lstrip.startswith("<tool_call") \
1216
+ and not _looks_like_tool_json \
1217
+ and not _is_orphan_fence \
1218
+ and not _has_internal_protocol:
1219
+ if _buffer_finance_prose:
1220
+ _buffered_tokens.append(token)
1221
+ elif on_token:
1222
+ on_token(token)
1223
+ except Exception as e:
1224
+ err_msg = str(e) or type(e).__name__
1225
+ if any(x in err_msg.lower() for x in ("cannot connect", "connect call failed", "connection refused", "errno 61")):
1226
+ return _ollama_unavailable_result(ollama_url, err_msg)
1227
+ return {"success": False, "error": f"Ollama: {err_msg}"}
1228
+
1229
+ # Fallback: parse text-based tool calls if no native ones found
1230
+ if not tool_calls_this_round and full_response.strip():
1231
+ text_calls = _parse_text_tool_calls(full_response)
1232
+ if text_calls:
1233
+ tool_calls_this_round = [
1234
+ _register_tool_call(tc["tool"], tc["params"])
1235
+ for tc in text_calls
1236
+ ]
1237
+ for tc in tool_calls_this_round:
1238
+ if not tc.get("_aria_duplicate"):
1239
+ tools_used.append(tc["tool"])
1240
+ if on_tool_call and not tc.get("_aria_duplicate"):
1241
+ on_tool_call(tc["tool"], tc["params"])
1242
+
1243
+ # If repetition was detected, truncate and return cleanly
1244
+ if _rep_cancelled[0]:
1245
+ # Remove the repeated tail — keep only the first clean portion
1246
+ lines = full_response.strip().splitlines()
1247
+ # Find where repetition started: keep up to the point where unique content ends
1248
+ seen_paragraphs = set()
1249
+ clean_lines = []
1250
+ for line in lines:
1251
+ key = line.strip()
1252
+ if key and len(key) > 20:
1253
+ if key in seen_paragraphs:
1254
+ break # Hit a repeated paragraph — stop here
1255
+ seen_paragraphs.add(key)
1256
+ clean_lines.append(line)
1257
+ full_response = "\n".join(clean_lines).rstrip()
1258
+ if on_token:
1259
+ # The repetition note is appended as a final token
1260
+ on_token("\n\n*[model stopped — repetition detected]*")
1261
+ full_response += "\n\n*[model stopped — repetition detected]*"
1262
+ return {
1263
+ "success": True, "response": full_response,
1264
+ "tools_used": tools_used, "sources": [],
1265
+ "tool_calls_pending": [], "usage": usage, "provider": "ollama",
1266
+ }
1267
+
1268
+ # If no tool calls this round
1269
+ if not tool_calls_this_round:
1270
+ full_response = _strip_tool_call_tags(full_response)
1271
+ if _buffer_finance_prose and on_token and full_response:
1272
+ on_token(full_response)
1273
+ clean_text = full_response.strip().lower()
1274
+
1275
+ if _done_reason in {"length", "num_predict"} and _continuation_count < 3:
1276
+ _continuation_count += 1
1277
+ response_segments.append(full_response.rstrip())
1278
+ payload["messages"].append({"role": "assistant", "content": full_response})
1279
+ payload["messages"].append({
1280
+ "role": "user",
1281
+ "content": (
1282
+ "继续完成上一条回答,从刚才中断处接着写。"
1283
+ "不要重写已经输出的内容,不要总结,直到任务完整结束。"
1284
+ ),
1285
+ })
1286
+ if HAS_RICH:
1287
+ console.print("\n [dim]继续输出未完成内容…[/dim]\n")
1288
+ continue
1289
+
1290
+ # Detect "intent without action" — model says it will do something
1291
+ # but didn't output a tool call
1292
+ _intent_words = [
1293
+ "let me", "i will", "i'll", "let's", "让我", "我会", "我将",
1294
+ "让我们", "我来", "接下来", "下面", "我们来", "我需要",
1295
+ "再次", "重新", "检查", "修复", "fix", "retry", "check",
1296
+ ]
1297
+ has_intent = any(w in clean_text for w in _intent_words)
1298
+ should_nudge = (_in_error_recovery or _last_tool_had_error or has_intent) and _nudge_count < 5
1299
+
1300
+ if should_nudge and tool_round < max_tool_rounds - 1:
1301
+ _nudge_count += 1
1302
+ if _in_error_recovery:
1303
+ nudge = (
1304
+ "SYSTEM: You are in error recovery mode. The script FAILED and is NOT yet fixed. "
1305
+ "You MUST call a tool NOW to fix it:\n"
1306
+ "- If you already read the file: call edit_file to fix the specific error, or write_file to rewrite.\n"
1307
+ "- If you haven't read it: call read_file first.\n"
1308
+ "- After fixing: call run_command to retry.\n"
1309
+ "Do NOT output text. Output ONLY a <tool_call>."
1310
+ )
1311
+ elif _last_tool_had_error:
1312
+ nudge = (
1313
+ "SYSTEM: The previous step FAILED. Fix it NOW by calling a tool:\n"
1314
+ "1. read_file to see the code.\n"
1315
+ "2. edit_file or write_file to fix.\n"
1316
+ "3. run_command to retry.\n"
1317
+ "Output a <tool_call> NOW."
1318
+ )
1319
+ else:
1320
+ nudge = (
1321
+ "SYSTEM: You said you would do something but did not call a tool. "
1322
+ "Do NOT describe what you will do — just DO it. "
1323
+ "Output a <tool_call> NOW to take the next action."
1324
+ )
1325
+ payload["messages"].append({"role": "assistant", "content": full_response})
1326
+ payload["messages"].append({"role": "user", "content": nudge})
1327
+ continue
1328
+
1329
+ # Truly done. Tokens were already streamed above; do not print the
1330
+ # accumulated response again or the terminal shows duplicate blocks.
1331
+ break
1332
+
1333
+ # Tool calls present — suppress model text (tool UI provides feedback)
1334
+ # Large models may emit multiple write_file calls in one round (project scaffolding).
1335
+ # Destructive / interactive tools (run_command, edit_file) remain sequential.
1336
+ _MUST_SERIALIZE = {"run_command", "edit_file"}
1337
+ if len(tool_calls_this_round) > 1:
1338
+ _all_safe = all(tc["tool"] not in _MUST_SERIALIZE for tc in tool_calls_this_round)
1339
+ _is_large_mdl = _model_size in ("large",)
1340
+ if _is_large_mdl and _all_safe:
1341
+ tool_calls_this_round = tool_calls_this_round[:5] # max 5 parallel writes
1342
+ else:
1343
+ tool_calls_this_round = tool_calls_this_round[:1]
1344
+
1345
+ if defer_tool_execution:
1346
+ clean_text = _strip_tool_call_tags(full_response)
1347
+ return {
1348
+ "success": True,
1349
+ "response": clean_text,
1350
+ "tools_used": [],
1351
+ "sources": [],
1352
+ "tool_calls_pending": [
1353
+ {"tool": tc["tool"], "params": tc["params"]}
1354
+ for tc in tool_calls_this_round
1355
+ if not tc.get("_aria_duplicate")
1356
+ ],
1357
+ "usage": usage,
1358
+ "provider": "ollama",
1359
+ }
1360
+
1361
+ # Execute tool calls locally and feed results back
1362
+ clean_text = _strip_tool_call_tags(full_response)
1363
+ payload["messages"].append({"role": "assistant", "content": clean_text,
1364
+ "tool_calls": [{"function": {"name": tc["tool"], "arguments": tc["params"]}}
1365
+ for tc in tool_calls_this_round]})
1366
+
1367
+ ollama_cancelled = False
1368
+ for tc in tool_calls_this_round:
1369
+ # Check cancel between tools
1370
+ if cancel_event and cancel_event.is_set():
1371
+ ollama_cancelled = True
1372
+ break
1373
+
1374
+ tool_name = tc["tool"]
1375
+ # Note: _print_tool_call already called by on_tool_call during streaming
1376
+
1377
+ if tc.get("_aria_duplicate"):
1378
+ reason = tc.get("_aria_limit_reason") or "duplicate tool call"
1379
+ summary = (
1380
+ f"SYSTEM: Tool call skipped ({reason}): {tool_name}. "
1381
+ "Use the existing tool result already available in this turn. "
1382
+ "Do not call this tool again in this turn; finish the answer from available evidence."
1383
+ )
1384
+ payload["messages"].append({
1385
+ "role": "tool",
1386
+ "content": summary,
1387
+ })
1388
+ continue
1389
+
1390
+ # Ask user confirmation for destructive tools
1391
+ if tool_name in _CONFIRM_TOOLS:
1392
+ try:
1393
+ approval = _confirm_tool_execution_decision(
1394
+ tool_name,
1395
+ tc["params"],
1396
+ config_policy=_ACTIVE_COMMAND_POLICY[0],
1397
+ )
1398
+ _apply_tool_approval(tc["params"], approval)
1399
+ if not approval.approved:
1400
+ ollama_cancelled = True
1401
+ if HAS_RICH:
1402
+ console.print("\n [dim]Cancelled[/dim]")
1403
+ break
1404
+ # Persist "Allow & set balanced" choice
1405
+ if approval.upgrade_policy:
1406
+ tc["params"].pop("_upgrade_policy", None)
1407
+ _ACTIVE_COMMAND_POLICY[0] = "balanced"
1408
+ if HAS_RICH:
1409
+ console.print(" [dim]策略已升级为 balanced(本会话)[/dim]")
1410
+ except KeyboardInterrupt:
1411
+ ollama_cancelled = True
1412
+ break
1413
+
1414
+ try:
1415
+ tool_t0 = time.time()
1416
+ # Inject current policy for run_command so post-approval execution
1417
+ # isn't re-blocked by the default "safe" policy
1418
+ if tool_name == "run_command" and "policy" not in tc["params"]:
1419
+ tc["params"]["policy"] = _ACTIVE_COMMAND_POLICY[0]
1420
+ result = execute_local_tool(tool_name, tc["params"])
1421
+ tool_dt = time.time() - tool_t0
1422
+ except KeyboardInterrupt:
1423
+ ollama_cancelled = True
1424
+ break
1425
+ _print_tool_result(tool_name, result, tool_dt)
1426
+
1427
+ summary = _format_tool_summary(tool_name, result)
1428
+
1429
+ # Track if this tool had an error (for nudge logic)
1430
+ _last_tool_had_error = not result.get("success", False)
1431
+ if result.get("success") and tool_name == "run_command":
1432
+ exit_code = result.get("data", {}).get("exit_code", 0)
1433
+ _last_tool_had_error = (exit_code != 0)
1434
+
1435
+ # Error recovery state machine
1436
+ if _last_tool_had_error:
1437
+ _in_error_recovery = True
1438
+ _consecutive_reads = 0
1439
+ # Detect repeated failed run_command (same command failing 2+ times)
1440
+ if tool_name == "run_command":
1441
+ cmd_str = tc["params"].get("command", "")
1442
+ if cmd_str == _last_failed_cmd:
1443
+ _consecutive_cmd_failures += 1
1444
+ else:
1445
+ _last_failed_cmd = cmd_str
1446
+ _consecutive_cmd_failures = 1
1447
+ if _consecutive_cmd_failures >= 2:
1448
+ summary += ("\n\nSYSTEM: You have run the SAME command and it FAILED again with the same error. "
1449
+ "STOP re-running it. You MUST fix the code first:\n"
1450
+ "1. read_file to see the script content\n"
1451
+ "2. edit_file to fix the specific error (or write_file to rewrite entirely)\n"
1452
+ "3. THEN run_command to retry.\n"
1453
+ "Do NOT run the same command again until you have fixed the code.")
1454
+ elif tool_name in ("read_file", "list_files", "search_code"):
1455
+ # Diagnostic tools do NOT exit error recovery
1456
+ _consecutive_reads += 1
1457
+ # If model read the file 2+ times without fixing, inject directive
1458
+ if _in_error_recovery and _consecutive_reads >= 2:
1459
+ summary += ("\n\nSYSTEM: You have read this file multiple times without fixing it. "
1460
+ "STOP reading. Use edit_file to fix the specific error, "
1461
+ "or use write_file to rewrite the entire script. Then run_command to retry.")
1462
+ elif tool_name in ("edit_file", "write_file"):
1463
+ # Fix was applied — stay in recovery until run_command succeeds
1464
+ _consecutive_reads = 0
1465
+ _consecutive_cmd_failures = 0 # Reset — code was changed
1466
+ _last_failed_cmd = ""
1467
+ elif tool_name == "run_command" and not _last_tool_had_error:
1468
+ # run_command succeeded — exit error recovery
1469
+ _in_error_recovery = False
1470
+ _consecutive_reads = 0
1471
+ _consecutive_cmd_failures = 0
1472
+ _last_failed_cmd = ""
1473
+ _nudge_count = 0
1474
+
1475
+ if on_tool_result:
1476
+ on_tool_result(tool_name, summary)
1477
+
1478
+ # Feed tool result back to Ollama for next round
1479
+ payload["messages"].append({
1480
+ "role": "tool",
1481
+ "content": summary,
1482
+ })
1483
+
1484
+ if ollama_cancelled:
1485
+ return {"success": True, "response": full_response,
1486
+ "cancelled": True, "tools_used": tools_used,
1487
+ "sources": [], "thinking": "", "provider": "ollama", "usage": usage}
1488
+
1489
+ # Continue streaming with tool results in context
1490
+ if HAS_RICH:
1491
+ console.print() # newline before next AI response
1492
+
1493
+ # Write successful stateless response to cache for future reuse
1494
+ if response_segments:
1495
+ full_response = "\n".join(part for part in response_segments + [full_response] if part)
1496
+
1497
+ if _should_cache and full_response and not tools_used:
1498
+ _cache_set(_ck, full_response)
1499
+
1500
+ # ── Code-block executor fallback ─────────────────────────────────────────
1501
+ # Small models often ignore the <tool_call> instruction and write plain code
1502
+ # blocks instead. When the intent is "coding" and the model produced a
1503
+ # Python block but zero tool calls, auto-extract the code and queue
1504
+ # write_file + run_command so the outer agentic loop executes it.
1505
+ _auto_tool_calls: list = []
1506
+ _allow_code_block_autorun = (
1507
+ _intent == "coding"
1508
+ and _has_coding_signal
1509
+ and (_explicit_code_signal or not _is_visual_artifact_request)
1510
+ )
1511
+ if _allow_code_block_autorun and not tools_used and full_response:
1512
+ import re as _re
1513
+ # Accept both complete (``` closed) and truncated (unclosed) code blocks
1514
+ _py_blocks = _re.findall(r"```python\n(.*?)```", full_response, _re.DOTALL)
1515
+ if not _py_blocks:
1516
+ # Fallback: grab everything after the opening fence (handles truncation)
1517
+ _m = _re.search(r"```python\n(.*)", full_response, _re.DOTALL)
1518
+ if _m:
1519
+ _py_blocks = [_m.group(1)]
1520
+ if _py_blocks:
1521
+ _code = _py_blocks[-1].strip()
1522
+ # Basic sanitisation: strip leading spaces from ticker assignments
1523
+ _code = _re.sub(
1524
+ r"""(ticker\s*=\s*['"])(\s+)([A-Z]{1,10})(['"])""",
1525
+ r"\1\3\4", _code
1526
+ )
1527
+ # Auto-add missing `import mplfinance as mpf` when mpf is used
1528
+ if "mpf." in _code and "import mplfinance" not in _code:
1529
+ _code = "import mplfinance as mpf\n" + _code
1530
+ # Auto-add `import matplotlib.pyplot as plt` when plt is used
1531
+ if "plt." in _code and "import matplotlib.pyplot as plt" not in _code:
1532
+ _code = (
1533
+ "import matplotlib; matplotlib.use('Agg')\n"
1534
+ "import matplotlib.pyplot as plt\n" + _code
1535
+ )
1536
+ # Try to extract user-specified filename from the original message
1537
+ _fname_match = _re.search(
1538
+ r'保存(?:到|为|成)?\s*([^\s,,。]+\.py)'
1539
+ r'|save\s+(?:to\s+|as\s+)?([^\s,]+\.py)'
1540
+ r'|(?:named?|called?|filename?)\s+([^\s,]+\.py)',
1541
+ _intent_message, _re.IGNORECASE
1542
+ )
1543
+ if _fname_match:
1544
+ _fname = next(g for g in _fname_match.groups() if g)
1545
+ # Strip any path prefix from the extracted name
1546
+ _fname = os.path.basename(_fname)
1547
+ else:
1548
+ _fname = f"aria_generated_{int(time.time())}.py"
1549
+ _fpath = f"~/Documents/Aria Code/generated/{_fname}"
1550
+
1551
+ # Validate Python syntax before writing — prepend warning comment if broken
1552
+ import py_compile as _pyc
1553
+ import tempfile as _tf2
1554
+ try:
1555
+ with _tf2.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as _stmp:
1556
+ _stmp.write(_code)
1557
+ _stmp_path = _stmp.name
1558
+ _pyc.compile(_stmp_path, doraise=True)
1559
+ os.unlink(_stmp_path)
1560
+ except _pyc.PyCompileError as _pce:
1561
+ # Surface the error prominently; file is still saved so user can fix it
1562
+ _err_line = str(_pce).replace(str(_stmp_path), _fname)
1563
+ _code = f"# ⚠️ SYNTAX ERROR (fix before running):\n# {_err_line}\n\n" + _code
1564
+ try:
1565
+ os.unlink(_stmp_path)
1566
+ except Exception:
1567
+ pass
1568
+ except Exception:
1569
+ pass
1570
+
1571
+ _auto_tool_calls = [
1572
+ {"tool": "write_file", "params": {"path": _fpath, "content": _code}},
1573
+ ]
1574
+ import shlex as _shlex_auto
1575
+ _auto_tool_calls.append({
1576
+ "tool": "run_command",
1577
+ "params": {
1578
+ "command": f"python3 {_shlex_auto.quote(os.path.expanduser(_fpath))}",
1579
+ "timeout": 120,
1580
+ },
1581
+ })
1582
+ if on_tool_call:
1583
+ on_tool_call("write_file", _auto_tool_calls[0]["params"])
1584
+ on_tool_call("run_command", _auto_tool_calls[1]["params"])
1585
+
1586
+ if _auto_tool_calls:
1587
+ return {"success": True, "response": full_response,
1588
+ "tool_calls_pending": _auto_tool_calls,
1589
+ "tools_used": tools_used, "sources": [], "thinking": "",
1590
+ "provider": "ollama", "usage": usage}
1591
+
1592
+ return {"success": True, "response": full_response,
1593
+ "tools_used": tools_used, "sources": [], "thinking": "", "provider": "ollama",
1594
+ "usage": usage}