aria-code 4.1.3__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.
- agents/__init__.py +32 -0
- agents/base.py +190 -0
- agents/deep/__init__.py +37 -0
- agents/deep/calibration_loop.py +144 -0
- agents/deep/critic.py +125 -0
- agents/deep/deepen.py +193 -0
- agents/deep/models.py +149 -0
- agents/deep/pipeline.py +164 -0
- agents/deep/quant_fusion.py +192 -0
- agents/deep/themes.py +95 -0
- agents/deep/tiers.py +106 -0
- agents/financial/__init__.py +10 -0
- agents/financial/catalyst.py +279 -0
- agents/financial/debate.py +145 -0
- agents/financial/earnings.py +303 -0
- agents/financial/fundamental.py +159 -0
- agents/financial/macro.py +99 -0
- agents/financial/news.py +207 -0
- agents/financial/risk.py +132 -0
- agents/financial/sector.py +279 -0
- agents/financial/synthesis.py +274 -0
- agents/financial/technical.py +258 -0
- agents/portfolio_agent.py +333 -0
- agents/realty/__init__.py +62 -0
- agents/realty/asset_diagnosis.py +150 -0
- agents/realty/business_match.py +165 -0
- agents/realty/cashflow_verify.py +208 -0
- agents/realty/contract_rules.py +209 -0
- agents/realty/energy_anomaly.py +188 -0
- agents/realty/exit_settlement.py +207 -0
- agents/realty/fulfillment_risk.py +205 -0
- agents/realty/ops_optimize.py +159 -0
- agents/realty/revenue_share.py +214 -0
- agents/registry.py +144 -0
- agents/sports/__init__.py +0 -0
- agents/sports/football_agent.py +169 -0
- agents/team.py +289 -0
- aliyun_data_client.py +660 -0
- apps/README.md +12 -0
- apps/__init__.py +2 -0
- apps/channels/README.md +15 -0
- apps/cli/README.md +13 -0
- apps/cli/__init__.py +2 -0
- apps/cli/bootstrap.py +99 -0
- apps/cli/codegen_paths.py +29 -0
- apps/cli/commands/__init__.py +16 -0
- apps/cli/commands/analysis_cmds.py +288 -0
- apps/cli/commands/backtest_cmds.py +1887 -0
- apps/cli/commands/broker_cmds.py +1154 -0
- apps/cli/commands/business_workflow_cmds.py +289 -0
- apps/cli/commands/catalog.py +84 -0
- apps/cli/commands/data_cmds.py +405 -0
- apps/cli/commands/diagnostic_cmds.py +179 -0
- apps/cli/commands/diagnostic_ops_cmds.py +696 -0
- apps/cli/commands/finance_render.py +12 -0
- apps/cli/commands/market.py +399 -0
- apps/cli/commands/market_cmds.py +1276 -0
- apps/cli/commands/market_context.py +425 -0
- apps/cli/commands/market_render.py +7 -0
- apps/cli/commands/model_cmds.py +1579 -0
- apps/cli/commands/ops_cmds.py +668 -0
- apps/cli/commands/portfolio_cmds.py +962 -0
- apps/cli/commands/report.py +377 -0
- apps/cli/commands/scaffold_templates.py +617 -0
- apps/cli/commands/session_cmds.py +179 -0
- apps/cli/commands/session_ux_cmds.py +280 -0
- apps/cli/commands/team.py +588 -0
- apps/cli/commands/team_render.py +8 -0
- apps/cli/commands/ui_cmds.py +358 -0
- apps/cli/commands/workflow_cmds.py +279 -0
- apps/cli/commands/workspace_cmds.py +1414 -0
- apps/cli/config_paths.py +70 -0
- apps/cli/config_store.py +61 -0
- apps/cli/deterministic.py +122 -0
- apps/cli/direct.py +48 -0
- apps/cli/github_app_auth.py +135 -0
- apps/cli/handlers/__init__.py +11 -0
- apps/cli/handlers/broker_handlers.py +122 -0
- apps/cli/handlers/chart_handlers.py +1309 -0
- apps/cli/handlers/market_handlers.py +2509 -0
- apps/cli/handlers/realty_handlers.py +114 -0
- apps/cli/handlers/strategy_advice.py +82 -0
- apps/cli/hooks.py +180 -0
- apps/cli/i18n.py +284 -0
- apps/cli/intent.py +136 -0
- apps/cli/intent_router.py +217 -0
- apps/cli/lifecycle_hooks.py +48 -0
- apps/cli/main.py +29 -0
- apps/cli/market_metadata.py +135 -0
- apps/cli/market_universe.py +265 -0
- apps/cli/message_processing.py +257 -0
- apps/cli/plan_mode.py +139 -0
- apps/cli/plotly_html.py +15 -0
- apps/cli/prediction_feedback.py +202 -0
- apps/cli/preflight.py +497 -0
- apps/cli/project_aria.py +60 -0
- apps/cli/prompts/__init__.py +0 -0
- apps/cli/prompts/coding.py +658 -0
- apps/cli/prompts/system_prompts.py +531 -0
- apps/cli/prompts/ui.py +434 -0
- apps/cli/providers/__init__.py +1 -0
- apps/cli/providers/base.py +271 -0
- apps/cli/providers/chat_routing.py +80 -0
- apps/cli/providers/llm/__init__.py +1 -0
- apps/cli/providers/llm/ollama_stream.py +1170 -0
- apps/cli/providers/llm/sse_stream.py +216 -0
- apps/cli/providers/runtime_bridge.py +185 -0
- apps/cli/runtime_consumer.py +489 -0
- apps/cli/session_export.py +87 -0
- apps/cli/session_jsonl.py +207 -0
- apps/cli/session_store.py +112 -0
- apps/cli/todo_tracker.py +190 -0
- apps/cli/tools/__init__.py +40 -0
- apps/cli/tools/context.py +46 -0
- apps/cli/tools/file_tools.py +112 -0
- apps/cli/tools/market_tools.py +549 -0
- apps/cli/tools/notebook_tools.py +111 -0
- apps/cli/tools/system_tools.py +669 -0
- apps/cli/tools/write_tools.py +715 -0
- apps/cli/tradingview_bridge.py +434 -0
- apps/cli/update_check.py +152 -0
- apps/cli/utils/__init__.py +0 -0
- apps/cli/utils/market_detect.py +1578 -0
- apps/daemon/README.md +14 -0
- apps/vscode/README.md +115 -0
- apps/vscode/package.json +70 -0
- aria_cli.py +11636 -0
- aria_code-4.1.3.dist-info/METADATA +952 -0
- aria_code-4.1.3.dist-info/RECORD +284 -0
- aria_code-4.1.3.dist-info/WHEEL +5 -0
- aria_code-4.1.3.dist-info/entry_points.txt +2 -0
- aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
- aria_code-4.1.3.dist-info/top_level.txt +50 -0
- aria_daemon.py +1295 -0
- aria_feishu_bot.py +1359 -0
- aria_relay_client.py +182 -0
- aria_relay_server.py +405 -0
- aria_telegram_bot.py +202 -0
- ariarc.py +328 -0
- artifacts.py +491 -0
- backtest_report.py +472 -0
- brokers/__init__.py +72 -0
- brokers/base.py +207 -0
- brokers/capabilities.py +264 -0
- brokers/cn/__init__.py +10 -0
- brokers/cn/easytrader_broker.py +193 -0
- brokers/cn/futu_broker.py +194 -0
- brokers/cn/longbridge_broker.py +190 -0
- brokers/cn/tiger_broker.py +196 -0
- brokers/cn/xtquant_broker.py +175 -0
- brokers/config.py +364 -0
- brokers/intl/__init__.py +5 -0
- brokers/intl/alpaca_broker.py +183 -0
- brokers/intl/ibkr_broker.py +215 -0
- brokers/intl/webull_broker.py +156 -0
- brokers/paper_broker.py +259 -0
- brokers/planning.py +296 -0
- brokers/registry.py +181 -0
- brokers/trading.py +237 -0
- change_store.py +127 -0
- command_safety.py +19 -0
- computer_use_tools.py +504 -0
- dashboard_generator.py +578 -0
- data_analysis_tools.py +808 -0
- data_cleaner.py +483 -0
- data_service.py +481 -0
- datasources/__init__.py +23 -0
- datasources/base.py +166 -0
- datasources/router.py +221 -0
- datasources/sources/__init__.py +15 -0
- datasources/sources/akshare_source.py +269 -0
- datasources/sources/alpha_vantage_source.py +202 -0
- datasources/sources/edgar_source.py +218 -0
- datasources/sources/finnhub_source.py +197 -0
- datasources/sources/fred_source.py +219 -0
- datasources/sources/tushare_source.py +141 -0
- datasources/sources/web_scraper_source.py +278 -0
- datasources/sources/world_bank_source.py +205 -0
- datasources/sources/yfinance_source.py +152 -0
- demo_player.py +204 -0
- doctor.py +508 -0
- file_analysis_tools.py +734 -0
- finance_formulas.py +389 -0
- football_data_client.py +1670 -0
- intent_classifier.py +358 -0
- local_finance_tools.py +3221 -0
- local_llm_provider.py +552 -0
- macro_tools.py +368 -0
- market_data_client.py +1899 -0
- mcp_client.py +506 -0
- memory_manager.py +245 -0
- model_capability.py +416 -0
- notification_tools.py +248 -0
- packages/__init__.py +23 -0
- packages/aria_agents/__init__.py +5 -0
- packages/aria_agents/manifest.py +69 -0
- packages/aria_core/__init__.py +34 -0
- packages/aria_core/architecture.py +192 -0
- packages/aria_core/export.py +124 -0
- packages/aria_core/manifest.py +65 -0
- packages/aria_infra/__init__.py +15 -0
- packages/aria_infra/arthera.py +52 -0
- packages/aria_infra/doctor.py +246 -0
- packages/aria_infra/product.py +37 -0
- packages/aria_mcp/__init__.py +25 -0
- packages/aria_mcp/bridge.py +38 -0
- packages/aria_mcp/config.py +97 -0
- packages/aria_mcp/tools.py +61 -0
- packages/aria_sdk/__init__.py +19 -0
- packages/aria_sdk/client.py +396 -0
- packages/aria_sdk/providers.py +70 -0
- packages/aria_sdk/streaming.py +73 -0
- packages/aria_sdk/types.py +86 -0
- packages/aria_services/__init__.py +55 -0
- packages/aria_services/context.py +258 -0
- packages/aria_services/data.py +11 -0
- packages/aria_services/provider_health.py +189 -0
- packages/aria_services/registry.py +213 -0
- packages/aria_services/usage.py +138 -0
- packages/aria_skills/__init__.py +5 -0
- packages/aria_skills/registry.py +59 -0
- packages/aria_tools/__init__.py +5 -0
- packages/aria_tools/registry.py +128 -0
- packages/quant_engine/__init__.py +6 -0
- packages/quant_engine/sports/__init__.py +72 -0
- packages/quant_engine/sports/calibrator.py +353 -0
- packages/quant_engine/sports/dixon_coles.py +234 -0
- packages/quant_engine/sports/elo.py +299 -0
- packages/quant_engine/sports/form.py +188 -0
- packages/quant_engine/sports/h2h.py +195 -0
- packages/quant_engine/sports/ml_model.py +354 -0
- packages/quant_engine/sports/predictor.py +311 -0
- packages/quant_engine/sports/tracker.py +664 -0
- packages/quant_engine/stochastic/__init__.py +27 -0
- packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
- packages/quant_engine/stochastic/ito_calculus.py +477 -0
- packages/quant_engine/stochastic/kelly_criterion.py +181 -0
- packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
- packages/quant_engine/stochastic/options_pricing.py +573 -0
- packages/quant_engine/stochastic/stochastic_processes.py +90 -0
- plan_utils.py +194 -0
- plugin_loader.py +328 -0
- portfolio_ledger.py +262 -0
- privacy/__init__.py +5 -0
- privacy/feedback.py +123 -0
- project_tools.py +525 -0
- providers/__init__.py +30 -0
- providers/llm/__init__.py +19 -0
- providers/llm/anthropic.py +184 -0
- providers/llm/base.py +139 -0
- providers/llm/ollama.py +128 -0
- providers/llm/openai_compat.py +282 -0
- providers/llm/registry.py +358 -0
- realty_data_tools.py +659 -0
- report_generator.py +1314 -0
- runtime/__init__.py +103 -0
- runtime/agent_loop.py +1183 -0
- runtime/approval.py +51 -0
- runtime/events.py +102 -0
- runtime/gateway.py +128 -0
- runtime/lsp.py +346 -0
- runtime/subagent.py +258 -0
- runtime/tool_executor.py +104 -0
- runtime/tool_policy.py +106 -0
- safety/__init__.py +21 -0
- safety/permissions.py +275 -0
- setup_wizard.py +653 -0
- strategy_vault.py +420 -0
- ui/__init__.py +100 -0
- ui/banner.py +310 -0
- ui/completer.py +391 -0
- ui/console.py +271 -0
- ui/image_render.py +243 -0
- ui/input_box.py +376 -0
- ui/picker.py +195 -0
- ui/render/__init__.py +11 -0
- ui/render/finance.py +1480 -0
- ui/render/market.py +225 -0
- ui/render/output.py +681 -0
- ui/render/team.py +346 -0
- ui/robot.py +235 -0
- workspace/__init__.py +6 -0
- workspace/files.py +170 -0
- workspace/verify.py +113 -0
|
@@ -0,0 +1,1170 @@
|
|
|
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
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _recent_sports_quant_context(history: list, max_chars: int = 5000) -> str:
|
|
12
|
+
"""Return the latest sports quant block from chat history for follow-ups."""
|
|
13
|
+
markers = (
|
|
14
|
+
"【泊松模型量化预测",
|
|
15
|
+
"【量化预测",
|
|
16
|
+
"最可能比分",
|
|
17
|
+
"可能比分",
|
|
18
|
+
)
|
|
19
|
+
for msg in reversed(history or []):
|
|
20
|
+
content = str(msg.get("content", "")) if isinstance(msg, dict) else ""
|
|
21
|
+
if not content:
|
|
22
|
+
continue
|
|
23
|
+
if "预期进球" in content and any(marker in content for marker in markers):
|
|
24
|
+
return content[-max_chars:]
|
|
25
|
+
return ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def stream_ollama(ollama_url: str, message: str, history: list,
|
|
29
|
+
model: str = "qwen2.5:7b",
|
|
30
|
+
on_token=None, on_thinking=None,
|
|
31
|
+
on_tool_call=None, on_tool_result=None,
|
|
32
|
+
cancel_event: asyncio.Event = None,
|
|
33
|
+
enable_tools: bool = True,
|
|
34
|
+
system_override: str = None,
|
|
35
|
+
show_market_prefetch_status: bool = True) -> dict:
|
|
36
|
+
"""Stream chat via local Ollama with tool calling support (native + text-based)."""
|
|
37
|
+
import aiohttp
|
|
38
|
+
|
|
39
|
+
# ── Response cache: skip Ollama for repeated stateless queries ───────────
|
|
40
|
+
# Only cache when there is no conversation history (stateless), the query
|
|
41
|
+
# is short (likely a simple quote/concept), and no tools are being called.
|
|
42
|
+
_should_cache = not history and len(message) < 300
|
|
43
|
+
if _should_cache:
|
|
44
|
+
_ck = _cache_key(model, message)
|
|
45
|
+
_cached = _cache_get(_ck)
|
|
46
|
+
if _cached:
|
|
47
|
+
if on_token:
|
|
48
|
+
on_token(_cached)
|
|
49
|
+
return {"success": True, "response": _cached,
|
|
50
|
+
"provider": "ollama_cache", "usage": {}}
|
|
51
|
+
|
|
52
|
+
_models_probe, _ollama_err = detect_ollama_models_rich(ollama_url)
|
|
53
|
+
if _ollama_err:
|
|
54
|
+
if _is_simple_greeting(message):
|
|
55
|
+
return _offline_greeting_response()
|
|
56
|
+
return _ollama_unavailable_result(ollama_url, _ollama_err)
|
|
57
|
+
|
|
58
|
+
# ── 模型自动解析:确保请求的模型在 Ollama 中存在 ─────────────────────────
|
|
59
|
+
try:
|
|
60
|
+
from local_llm_provider import resolve_model_async
|
|
61
|
+
_resolved = await resolve_model_async(ollama_url, model)
|
|
62
|
+
if _resolved != model:
|
|
63
|
+
model = _resolved # silently remap to available model
|
|
64
|
+
except Exception:
|
|
65
|
+
pass # resolution failed — proceed with original model name
|
|
66
|
+
|
|
67
|
+
# ── 模型分级守卫:小模型不能处理 coding/analysis/complex-finance 任务 ──────
|
|
68
|
+
# 如果分配到的模型是 small/nano 级别,但任务需要代码生成、复杂分析或长文本,
|
|
69
|
+
# 自动升级到 Ollama 中最优可用模型,防止低质量/模板化输出。
|
|
70
|
+
try:
|
|
71
|
+
from model_capability import get_model_capability, is_router_only, can_handle_coding
|
|
72
|
+
_cap_check = get_model_capability(model)
|
|
73
|
+
_task_needs_upgrade = (
|
|
74
|
+
is_router_only(_cap_check)
|
|
75
|
+
or (not can_handle_coding(_cap_check) and _is_coding_request(message))
|
|
76
|
+
# Small (1-4B) models also struggle with complex finance questions:
|
|
77
|
+
# they ignore detailed system prompts and output template garbage.
|
|
78
|
+
# Upgrade when the question is non-trivial and the model is "small".
|
|
79
|
+
# Use 8 as the minimum length threshold (works for both Chinese and English):
|
|
80
|
+
# Chinese "比特币值得投资吗" = 9 chars, English "buy or sell?" = 12 chars.
|
|
81
|
+
or (_cap_check.size_class == "small" and len(message) > 8
|
|
82
|
+
and not _is_simple_greeting(message))
|
|
83
|
+
)
|
|
84
|
+
if _task_needs_upgrade and _models_probe:
|
|
85
|
+
# 按优先级寻找可用的升级模型
|
|
86
|
+
# NOTE: gpt-oss 排在 deepseek-v3.1 前面,因为 deepseek-v3.1:671b-cloud
|
|
87
|
+
# 在 Ollama 实例中有时超时,而 gpt-oss:120b-cloud 响应稳定。
|
|
88
|
+
_upgrade_prefixes = [
|
|
89
|
+
"aria-sonata-3b", "qwen2.5-coder:7b", "qwen2.5-coder:3b",
|
|
90
|
+
"qwen2.5:7b", "qwen2.5:3b", "llama3.2:3b", "mistral",
|
|
91
|
+
# Cloud models registered in this Ollama instance (remote but available)
|
|
92
|
+
"gpt-oss", "deepseek-v3.1",
|
|
93
|
+
]
|
|
94
|
+
# _models_probe is a list of dicts: {"name": str, "size_label": str, ...}
|
|
95
|
+
# Must extract "name" field — do NOT call .startswith() on the dict.
|
|
96
|
+
_probe_names = [
|
|
97
|
+
m["name"] if isinstance(m, dict) else m
|
|
98
|
+
for m in _models_probe
|
|
99
|
+
]
|
|
100
|
+
for _pref in _upgrade_prefixes:
|
|
101
|
+
_candidate = next(
|
|
102
|
+
(m for m in _probe_names if m.startswith(_pref)), None
|
|
103
|
+
)
|
|
104
|
+
if _candidate and _candidate != model:
|
|
105
|
+
model = _candidate
|
|
106
|
+
break
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
# ── 五档路由:通过 Prelude 意图分类器(或关键词 fallback)决定 prompt ────
|
|
111
|
+
# Always rebuild finance prompt to get today's date
|
|
112
|
+
_finance_prompt = _build_finance_prompt(message)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
from intent_classifier import (
|
|
116
|
+
classify_intent_async,
|
|
117
|
+
INTENT_CODING, INTENT_ANALYSIS, INTENT_REALTIME,
|
|
118
|
+
INTENT_GENERAL, INTENT_FINANCE,
|
|
119
|
+
)
|
|
120
|
+
_intent = await classify_intent_async(message, ollama_url)
|
|
121
|
+
except Exception:
|
|
122
|
+
# Fallback to legacy keyword detection if intent_classifier unavailable
|
|
123
|
+
if _is_coding_request(message):
|
|
124
|
+
_intent = "coding"
|
|
125
|
+
elif _is_analysis_request(message):
|
|
126
|
+
_intent = "analysis"
|
|
127
|
+
elif _is_general_knowledge(message):
|
|
128
|
+
_intent = "general"
|
|
129
|
+
else:
|
|
130
|
+
_intent = "finance"
|
|
131
|
+
|
|
132
|
+
_is_general = (_intent == "general")
|
|
133
|
+
try:
|
|
134
|
+
from apps.cli.intent_router import build_intent_route
|
|
135
|
+
_route = build_intent_route(message)
|
|
136
|
+
except Exception:
|
|
137
|
+
_route = None
|
|
138
|
+
|
|
139
|
+
# ── Context-aware tool schema filtering ───────────────────────────────────
|
|
140
|
+
# Intent drives tool exposure, but cross-intent requests (e.g. "分析AAPL然后
|
|
141
|
+
# 写一个回测策略") need BOTH market data tools AND coding tools.
|
|
142
|
+
# Detect overlap by checking if the message contains signals from both domains.
|
|
143
|
+
_CU_TOOL_NAMES = {"browser_navigate", "browser_screenshot",
|
|
144
|
+
"computer_screenshot", "computer_action"}
|
|
145
|
+
_CODE_TOOL_NAMES = {"read_file", "write_file", "edit_file", "list_files",
|
|
146
|
+
"search_code", "run_command", "github",
|
|
147
|
+
"glob", "notebook_read", "notebook_edit"}
|
|
148
|
+
|
|
149
|
+
_msg_low = message.lower()
|
|
150
|
+
_explicit_code_signal = bool(getattr(_route, "explicit_code", False)) if _route else any(k in _msg_low for k in (
|
|
151
|
+
"代码", "脚本", "python", "程序", "实现", "开发", "修改文件",
|
|
152
|
+
"写代码", "编写代码", "策略代码", "保存为.py", ".py",
|
|
153
|
+
"script", "code", "program", "implement", "edit file", "write file",
|
|
154
|
+
))
|
|
155
|
+
_is_visual_artifact_request = bool(getattr(_route, "visual_artifact", False)) if _route else any(k in _msg_low for k in (
|
|
156
|
+
"图表", "走势图", "k线图", "k线", "chart", "plot", "dashboard", "看板", "report", "报告",
|
|
157
|
+
))
|
|
158
|
+
_has_coding_signal = _explicit_code_signal or (
|
|
159
|
+
not _is_visual_artifact_request
|
|
160
|
+
and any(k in _msg_low for k in ("写", "回测", "backtest", "save", "file"))
|
|
161
|
+
)
|
|
162
|
+
_has_finance_signal = any(k in _msg_low for k in (
|
|
163
|
+
"分析", "股票", "行情", "股价", "市场", "quantitative", "stock",
|
|
164
|
+
"price", "market", "analyze", "analysis", "ticker",
|
|
165
|
+
))
|
|
166
|
+
_is_cross_intent = _has_coding_signal and _has_finance_signal
|
|
167
|
+
|
|
168
|
+
if _is_visual_artifact_request and not _explicit_code_signal:
|
|
169
|
+
_excluded = _CU_TOOL_NAMES | _CODE_TOOL_NAMES
|
|
170
|
+
_schemas_for_context = [
|
|
171
|
+
s for s in LOCAL_TOOL_SCHEMAS
|
|
172
|
+
if s.get("function", {}).get("name") not in _excluded
|
|
173
|
+
]
|
|
174
|
+
elif _intent in ("finance", "analysis", "realtime") and not _is_cross_intent:
|
|
175
|
+
# Pure finance: market data + broker + web_fetch (for news), no coding/CU tools.
|
|
176
|
+
# Excluding coding tools prevents the LLM from calling run_command instead of
|
|
177
|
+
# get_market_data when answering a stock question.
|
|
178
|
+
_excluded = _CU_TOOL_NAMES | _CODE_TOOL_NAMES
|
|
179
|
+
_schemas_for_context = [
|
|
180
|
+
s for s in LOCAL_TOOL_SCHEMAS
|
|
181
|
+
if s.get("function", {}).get("name") not in _excluded
|
|
182
|
+
]
|
|
183
|
+
elif _is_cross_intent:
|
|
184
|
+
# Cross-intent: expose both finance AND coding tools (minus CU/browser).
|
|
185
|
+
# Intent hint injected into system prompt guides priority without hard exclusion.
|
|
186
|
+
_schemas_for_context = [
|
|
187
|
+
s for s in LOCAL_TOOL_SCHEMAS
|
|
188
|
+
if s.get("function", {}).get("name") not in _CU_TOOL_NAMES
|
|
189
|
+
]
|
|
190
|
+
else:
|
|
191
|
+
# Coding/general: all local tools except CU (browser/computer control)
|
|
192
|
+
_schemas_for_context = [
|
|
193
|
+
s for s in LOCAL_TOOL_SCHEMAS
|
|
194
|
+
if s.get("function", {}).get("name") not in _CU_TOOL_NAMES
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
# ── Select prompt size based on model capability ─────────────────────────
|
|
198
|
+
# Small / nano models (≤3B) cannot effectively use the full CODING_SYSTEM_PROMPT
|
|
199
|
+
# (6000+ tokens of examples they mostly ignore). Send a condensed version that
|
|
200
|
+
# keeps the essential rules and the single complete working template.
|
|
201
|
+
#
|
|
202
|
+
# Analysis: always use the LITE prompt in Ollama mode, even for medium/large
|
|
203
|
+
# cloud models relayed through Ollama. The full ANALYSIS_SYSTEM_PROMPT
|
|
204
|
+
# instructs the model to call `get_market_data`, which is a cloud-only tool
|
|
205
|
+
# not available in the LOCAL_TOOLS registry — leading to "Unknown local tool"
|
|
206
|
+
# errors and an infinite retry loop. The lite prompt explicitly refuses to
|
|
207
|
+
# output N/A templates when no data is injected, which is the correct
|
|
208
|
+
# behaviour in local mode.
|
|
209
|
+
try:
|
|
210
|
+
from model_capability import get_model_capability as _gmc
|
|
211
|
+
_model_size = _gmc(model).size_class
|
|
212
|
+
except Exception:
|
|
213
|
+
_model_size = "medium"
|
|
214
|
+
_use_lite_prompt = _model_size in ("nano", "small")
|
|
215
|
+
|
|
216
|
+
if _intent == "coding":
|
|
217
|
+
_base_prompt = _build_coding_prompt_lite(message) if _use_lite_prompt else CODING_SYSTEM_PROMPT
|
|
218
|
+
elif _intent == "analysis":
|
|
219
|
+
# Always use lite analysis prompt in Ollama — the full prompt triggers
|
|
220
|
+
# get_market_data tool calls that are not available locally.
|
|
221
|
+
_base_prompt = _build_analysis_prompt_lite(message)
|
|
222
|
+
elif _intent == "general":
|
|
223
|
+
# 纯知识/概念问题:注入日期,但不注入工具 schema
|
|
224
|
+
from datetime import datetime as _dt2
|
|
225
|
+
_today_str = _dt2.now().strftime("%Y年%m月%d日")
|
|
226
|
+
_base_prompt = (
|
|
227
|
+
f"你是 Aria,Arthera 的 AI 助手。今天是 {_today_str},**2026 FIFA 世界杯已于 2026-06-11 正式开幕**。\n"
|
|
228
|
+
"你的能力覆盖:金融量化分析、足球/体育赛事分析与预测(含泊松算法)、编程、通用知识问答。\n\n"
|
|
229
|
+
"## 体育量化分析规则(重要)\n"
|
|
230
|
+
"用户消息中可能包含两种特殊数据块:\n\n"
|
|
231
|
+
"### 【比赛信息】块\n"
|
|
232
|
+
"= football-data.org API 获取的真实赛程(比赛时间、状态、比分)。这是**事实**,不要质疑。\n\n"
|
|
233
|
+
"### 【泊松模型量化预测】块\n"
|
|
234
|
+
"= Aria 用 Dixon-Coles 泊松算法计算的量化结果,包含:\n"
|
|
235
|
+
" - 两队 FIFA 排名强度参数(进攻/防守)\n"
|
|
236
|
+
" - 预期进球数(λ值)\n"
|
|
237
|
+
" - 主胜/平局/客胜概率(%)\n"
|
|
238
|
+
" - 最可能比分及其概率\n"
|
|
239
|
+
" - 隐含赔率\n"
|
|
240
|
+
"当收到此块时,你应当:\n"
|
|
241
|
+
"1. **直接引用数字**(「算法显示加拿大胜率 42.4%」),不要重新计算或质疑\n"
|
|
242
|
+
"2. **解释概率背后的逻辑**:FIFA 排名差距、进攻强度对比说明了什么\n"
|
|
243
|
+
"3. **分析高频比分区间**:比如 1-0/1-1/2-1 集中说明比赛预计紧张胶着\n"
|
|
244
|
+
"4. **补充战术/球员层面的定性分析**(这是你的训练知识,算法没有的部分)\n"
|
|
245
|
+
"5. **绝对不要**说「我没有实时数据」「世界杯尚未开始」「不包含实时数据」「以上预测基于历史」——这些与上方数据矛盾\n"
|
|
246
|
+
"6. **绝对不要**在末尾添加「若需最新数据请使用/football命令」之类的建议——用户已经有数据了\n\n"
|
|
247
|
+
"## 通用规则\n"
|
|
248
|
+
"- 使用 Markdown(**粗体**、## 标题、- 列表、表格)\n"
|
|
249
|
+
"- 不要编造股价/汇率等金融数字\n"
|
|
250
|
+
"- 简洁精准,用数据说话\n"
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
# realtime / finance: use full finance prompt with tool access
|
|
254
|
+
_base_prompt = _finance_prompt
|
|
255
|
+
|
|
256
|
+
# Project context injection: skip or condense for small/nano models.
|
|
257
|
+
# A 1.5B model with a 4000-token README injected into its context will
|
|
258
|
+
# either copy the README into its response or hallucinate beyond recovery.
|
|
259
|
+
_small_model = _model_size in ("nano", "small")
|
|
260
|
+
if not _is_general:
|
|
261
|
+
if not _small_model and _PROJECT_CONTEXT:
|
|
262
|
+
system_prompt = _base_prompt + _PROJECT_CONTEXT
|
|
263
|
+
else:
|
|
264
|
+
# For small models: skip the full README, only keep a 2-line summary
|
|
265
|
+
_ctx_brief = ""
|
|
266
|
+
if _PROJECT_CONTEXT:
|
|
267
|
+
_first_lines = [l for l in _PROJECT_CONTEXT.split("\n") if l.strip()][:3]
|
|
268
|
+
_ctx_brief = "\n# Context: " + " | ".join(_first_lines[:2]) + "\n"
|
|
269
|
+
system_prompt = _base_prompt + _ctx_brief
|
|
270
|
+
else:
|
|
271
|
+
system_prompt = _base_prompt
|
|
272
|
+
|
|
273
|
+
# Inject cross-intent hint so the model knows to use both tool sets in order
|
|
274
|
+
if _is_cross_intent:
|
|
275
|
+
_cross_hint = (
|
|
276
|
+
"\n\n## Task Hint\n"
|
|
277
|
+
"This request spans both **market analysis** and **code generation**. "
|
|
278
|
+
"Suggested order: (1) fetch market data first, (2) use the data to write/run code. "
|
|
279
|
+
"Use get_market_data for live prices, then write_file + run_command for scripts.\n"
|
|
280
|
+
)
|
|
281
|
+
system_prompt = system_prompt + _cross_hint
|
|
282
|
+
|
|
283
|
+
# Prepend global user memory (user profile, project history, preferences)
|
|
284
|
+
try:
|
|
285
|
+
from memory_manager import MemoryManager as _MM
|
|
286
|
+
_mem_block = _MM().load_context(max_chars=500)
|
|
287
|
+
if _mem_block:
|
|
288
|
+
system_prompt = _mem_block + "\n" + system_prompt
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
# Append ariarc project context if available — small models skip this
|
|
293
|
+
if _HAS_ARIARC and not _is_general and not _small_model:
|
|
294
|
+
try:
|
|
295
|
+
_arc = get_ariarc()
|
|
296
|
+
_arc_block = _arc.build_system_prompt_block()
|
|
297
|
+
if _arc_block:
|
|
298
|
+
# Hard-cap ariarc block at 800 chars to prevent context overflow
|
|
299
|
+
_arc_short = _arc_block[:800] + ("…" if len(_arc_block) > 800 else "")
|
|
300
|
+
system_prompt = system_prompt + "\n\n" + _arc_short
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
# Inject live broker context when user is asking about their own portfolio,
|
|
305
|
+
# or when a broker is connected and the message is finance-related.
|
|
306
|
+
if _is_broker_intent(message):
|
|
307
|
+
_broker_ctx = _build_broker_context_block()
|
|
308
|
+
if _broker_ctx:
|
|
309
|
+
system_prompt = system_prompt + "\n\n" + _broker_ctx
|
|
310
|
+
|
|
311
|
+
# Allow /file analyze and other commands to inject a specialist role override
|
|
312
|
+
if system_override:
|
|
313
|
+
system_prompt = system_override + "\n\n" + system_prompt
|
|
314
|
+
|
|
315
|
+
url = f"{ollama_url}/api/chat"
|
|
316
|
+
_mcfg = get_model_cfg(model)
|
|
317
|
+
|
|
318
|
+
if _HAS_MODEL_CAP:
|
|
319
|
+
_cap = get_model_capability(model)
|
|
320
|
+
_num_ctx = _cap.context_window
|
|
321
|
+
_temperature = _cap.temperature
|
|
322
|
+
else:
|
|
323
|
+
_num_ctx = _mcfg.get("num_ctx", 16384)
|
|
324
|
+
_temperature = _mcfg.get("temperature", 0.3)
|
|
325
|
+
_max_tokens = _mcfg.get("max_tokens", min(_mcfg.get("num_ctx", 8192) // 4, 8192))
|
|
326
|
+
_mkey = resolve_model_key(model)
|
|
327
|
+
|
|
328
|
+
# ── 上下文硬截断:保留 80% 上下文给历史,防止溢出 ────────────────────
|
|
329
|
+
# 用 1.5 chars/token(CJK 混合文本实际比率)而非英文假设的 4 chars/token
|
|
330
|
+
_chars_per_tok = 1.5
|
|
331
|
+
_ctx_chars_for_hist = int(_num_ctx * 0.80 * _chars_per_tok) - len(system_prompt) - len(message) - 512
|
|
332
|
+
_ctx_chars_limit = max(_ctx_chars_for_hist, 1000)
|
|
333
|
+
# 从最新历史往前选,确保总字符数不超限
|
|
334
|
+
_trimmed_history: list = []
|
|
335
|
+
_hist_chars = 0
|
|
336
|
+
for _hm in reversed(history):
|
|
337
|
+
_hm_len = len(_hm.get("content",""))
|
|
338
|
+
if _hist_chars + _hm_len > _ctx_chars_limit:
|
|
339
|
+
break
|
|
340
|
+
_trimmed_history.insert(0, _hm)
|
|
341
|
+
_hist_chars += _hm_len
|
|
342
|
+
|
|
343
|
+
messages = [{"role": "system", "content": system_prompt}]
|
|
344
|
+
for msg in _trimmed_history:
|
|
345
|
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
|
346
|
+
# send_message pre-appends the current user turn to self.conversation before
|
|
347
|
+
# calling stream_ollama, so history already ends with a user message.
|
|
348
|
+
# Only add `message` if the last entry is NOT already a user message.
|
|
349
|
+
if not (_trimmed_history and _trimmed_history[-1].get("role") == "user"):
|
|
350
|
+
messages.append({"role": "user", "content": message})
|
|
351
|
+
|
|
352
|
+
# ── 工具注入:通识问答跳过,同时跳过无法可靠调用工具的小模型 ──────────
|
|
353
|
+
# 判断模型是否具备工具调用能力(text_only / format不支持的都跳过)
|
|
354
|
+
_model_can_use_tools = False
|
|
355
|
+
if _HAS_MODEL_CAP and enable_tools and LOCAL_TOOL_SCHEMAS and not _is_general:
|
|
356
|
+
_tool_cap = get_model_capability(model)
|
|
357
|
+
# 只有明确支持工具且 context_window >= 8192 的模型才注入 tool schema
|
|
358
|
+
_model_can_use_tools = (
|
|
359
|
+
_tool_cap.format != "text_only"
|
|
360
|
+
and _tool_cap.context_window >= 8192
|
|
361
|
+
)
|
|
362
|
+
if _model_can_use_tools:
|
|
363
|
+
_tool_sys = build_tool_system_prompt(_schemas_for_context, model)
|
|
364
|
+
if _tool_sys and messages:
|
|
365
|
+
if messages[0].get("role") == "system":
|
|
366
|
+
messages[0]["content"] += _tool_sys
|
|
367
|
+
else:
|
|
368
|
+
messages.insert(0, {"role": "system", "content": _tool_sys.strip()})
|
|
369
|
+
|
|
370
|
+
# ── 实时数据预取:始终为分析/报价查询预取真实市场数据注入 prompt ──────────
|
|
371
|
+
# 无论模型是否支持工具调用,都注入真实数据,防止模型生成占位符($X.XX)
|
|
372
|
+
# 策略:
|
|
373
|
+
# 1. system prompt 替换为"数据已预取"专用 prompt
|
|
374
|
+
# 2. 数据同时注入到用户消息开头(本地模型对最近的 user message 最敏感)
|
|
375
|
+
_skip_market_prefetch = _is_general or _is_visual_artifact_request
|
|
376
|
+
if _HAS_MDC and not _skip_market_prefetch:
|
|
377
|
+
import time as _t_inj
|
|
378
|
+
_t_inj_start = _t_inj.time()
|
|
379
|
+
_market_inject = _try_prefetch_market_data(message, history)
|
|
380
|
+
_t_inj_ms = int((_t_inj.time() - _t_inj_start) * 1000)
|
|
381
|
+
if _market_inject:
|
|
382
|
+
# 过程可见化:⏺/✓ 格式,与工具调用步骤保持一致
|
|
383
|
+
import re as _re_inj
|
|
384
|
+
_inj_m = _re_inj.search(r'## 📊 (\S+) 实时行情(来源:(\S+))', _market_inject)
|
|
385
|
+
if HAS_RICH and show_market_prefetch_status:
|
|
386
|
+
_sym_label = _inj_m.group(1) if _inj_m else "market_data"
|
|
387
|
+
_src_label = _inj_m.group(2) if _inj_m else "local"
|
|
388
|
+
console.print(
|
|
389
|
+
f"\n [#C08050]⏺[/#C08050] [bold]market_data[/bold]"
|
|
390
|
+
f" [dim]{_sym_label} · {_src_label}[/dim]"
|
|
391
|
+
)
|
|
392
|
+
console.print(
|
|
393
|
+
f" [green]✓[/green] [dim]实时行情已注入[/dim]"
|
|
394
|
+
f" [dim]({_t_inj_ms}ms)[/dim]"
|
|
395
|
+
)
|
|
396
|
+
# Replace system prompt with data-first prompt.
|
|
397
|
+
# Use nano variant for 1-3B models (no template placeholders).
|
|
398
|
+
_is_nano_model = _use_lite_prompt or _model_size in ("nano", "small")
|
|
399
|
+
_prefetched_sys = _build_prefetched_analysis_prompt(nano=_is_nano_model, user_message=message)
|
|
400
|
+
if messages and messages[0].get("role") == "system":
|
|
401
|
+
messages[0]["content"] = _prefetched_sys
|
|
402
|
+
else:
|
|
403
|
+
messages.insert(0, {"role": "system", "content": _prefetched_sys})
|
|
404
|
+
# Prepend real data to the user message so the model sees it last
|
|
405
|
+
# (most recent = highest attention weight for local models).
|
|
406
|
+
_augmented_user = (
|
|
407
|
+
_market_inject
|
|
408
|
+
+ "\n---\n"
|
|
409
|
+
"上面是真实实时数据。请只使用这些具体数字作答,不要引用训练记忆中的历史价格。\n\n"
|
|
410
|
+
+ message
|
|
411
|
+
)
|
|
412
|
+
for _mi in reversed(messages):
|
|
413
|
+
if _mi.get("role") == "user":
|
|
414
|
+
_mi["content"] = _augmented_user
|
|
415
|
+
break
|
|
416
|
+
|
|
417
|
+
# ── 体育赛事数据预取:sports query → inject live scores / WC data + Poisson ─
|
|
418
|
+
if _is_general and _is_sports_query(message):
|
|
419
|
+
_sports_ctx = _try_prefetch_sports_data(message)
|
|
420
|
+
if not _sports_ctx:
|
|
421
|
+
_sports_ctx = _recent_sports_quant_context(history)
|
|
422
|
+
if _sports_ctx:
|
|
423
|
+
_has_quant = (
|
|
424
|
+
"泊松模型量化预测" in _sports_ctx
|
|
425
|
+
or ("预期进球" in _sports_ctx and "可能比分" in _sports_ctx)
|
|
426
|
+
)
|
|
427
|
+
if HAS_RICH:
|
|
428
|
+
_label = "[bold #50A0C0]sports_data+quant[/bold #50A0C0]" if _has_quant else "[bold #50A0C0]sports_data[/bold #50A0C0]"
|
|
429
|
+
console.print(f" {_label} [dim]赛事数据{'+ 泊松预测 ' if _has_quant else ''}已注入[/dim]")
|
|
430
|
+
# Print Poisson block directly so user sees it even if LLM ignores context
|
|
431
|
+
if _has_quant and not _ARIA_BOT_MODE:
|
|
432
|
+
console.print(Panel(
|
|
433
|
+
_sports_ctx,
|
|
434
|
+
title="[bold]⚽ 量化预测数据[/bold]",
|
|
435
|
+
border_style="cyan",
|
|
436
|
+
padding=(0, 1),
|
|
437
|
+
))
|
|
438
|
+
for _mi in reversed(messages):
|
|
439
|
+
if _mi.get("role") == "user":
|
|
440
|
+
if _has_quant:
|
|
441
|
+
_injection_note = (
|
|
442
|
+
"\n---\n"
|
|
443
|
+
"## 数据说明\n"
|
|
444
|
+
"以上数据来自 football-data.org 实时 API + 泊松量化模型(Aria 本地计算)。\n"
|
|
445
|
+
f"【比赛信息】块 = API 真实赛程数据,今天是 {datetime.now().strftime('%Y-%m-%d')},世界杯已于 2026-06-11 开幕。\n"
|
|
446
|
+
"【泊松模型量化预测】块 = Aria 使用 Dixon-Coles 泊松分布对此场比赛运行的算法结果。\n\n"
|
|
447
|
+
"## 你的任务\n"
|
|
448
|
+
"1. **直接引用预测数据中的概率数字**(如「加拿大胜率 42.4%」),不要说你没有数据\n"
|
|
449
|
+
"2. 解释为什么会有这样的概率分布(结合两队 FIFA 排名、进攻/防守强度)\n"
|
|
450
|
+
"3. 如果用户要“一个以上/多个/最准比分”,必须按 `top_scorelines` 概率降序列出候选比分和概率\n"
|
|
451
|
+
"4. 分析最可能的比分区间(高频比分说明比赛预计紧张/单方碾压)\n"
|
|
452
|
+
"5. 可以给出走势判断,但不要编造射正率、最近5场客场数据、历史交锋次数等输入数据之外的具体事实\n"
|
|
453
|
+
"6. 注意区分胜平负概率和准确比分概率:热门球队胜率最高,不代表最可能的单一比分一定是该队获胜\n"
|
|
454
|
+
"7. 不要重新声明世界杯未开始或没有数据——上方数据证明它已经开始了\n"
|
|
455
|
+
"8. **严禁**在回复末尾添加任何类似以下内容的免责声明:\n"
|
|
456
|
+
" - 「以上预测基于历史...并不包含实时数据」\n"
|
|
457
|
+
" - 「不包含实时数据或赛前最新信息」\n"
|
|
458
|
+
" - 「若需赛前最新数据,请使用 /football 命令」\n"
|
|
459
|
+
" 上方已有实时 API 数据 + 量化模型,这些免责声明与数据矛盾,请勿添加。\n\n"
|
|
460
|
+
)
|
|
461
|
+
else:
|
|
462
|
+
_injection_note = (
|
|
463
|
+
"\n---\n"
|
|
464
|
+
"以上是从 football-data.org 获取的真实赛事数据(今天 2026-06-12,世界杯已开幕)。\n"
|
|
465
|
+
"请基于这些数据给出分析,若数据不完整可结合训练知识合理推断。\n\n"
|
|
466
|
+
)
|
|
467
|
+
_mi["content"] = _sports_ctx + _injection_note + message
|
|
468
|
+
break
|
|
469
|
+
|
|
470
|
+
# ── 文件路径自动注入:若用户消息引用了本地文件,预读并注入内容 ────────────
|
|
471
|
+
# 无论意图是什么,只要消息里有可读的文件路径就注入(coding / analysis 均有效)
|
|
472
|
+
_file_inject = _try_inject_file_paths(message)
|
|
473
|
+
if _file_inject:
|
|
474
|
+
for _mi in reversed(messages):
|
|
475
|
+
if _mi.get("role") == "user":
|
|
476
|
+
_mi["content"] = _file_inject + _mi["content"]
|
|
477
|
+
break
|
|
478
|
+
|
|
479
|
+
# ── Token budget 分级策略 ────────────────────────────────────────────────
|
|
480
|
+
# 小模型(<8K ctx)防止无限延伸;通识问答分两档:
|
|
481
|
+
# · 纯问候/一句话问题 → 200 tokens(快速)
|
|
482
|
+
# · 知识解释问题("什么是X", "如何…") → 1500 tokens(保证完整性)
|
|
483
|
+
# · 正常问题 → 模型 max_tokens 配置值
|
|
484
|
+
_is_small_model = _HAS_MODEL_CAP and get_model_capability(model).context_window < 8192
|
|
485
|
+
_is_greeting = _is_simple_greeting(message)
|
|
486
|
+
_wants_complete_output = any(k in message.lower() for k in (
|
|
487
|
+
"完整", "完整输出", "完整给出", "全面", "详细", "不要中断", "不要截断",
|
|
488
|
+
"complete", "full output", "comprehensive", "do not stop", "don't stop",
|
|
489
|
+
"do not truncate", "end-to-end",
|
|
490
|
+
))
|
|
491
|
+
|
|
492
|
+
if _is_greeting:
|
|
493
|
+
_effective_max_tokens = 200
|
|
494
|
+
elif _wants_complete_output:
|
|
495
|
+
_complete_cap = max(2048, min(8192, int(_num_ctx * 0.45)))
|
|
496
|
+
_effective_max_tokens = max(_max_tokens, _complete_cap)
|
|
497
|
+
elif _is_general:
|
|
498
|
+
_effective_max_tokens = max(1500, min(_max_tokens, 4096)) # 足够完整回答概念解释,不截断
|
|
499
|
+
elif _use_lite_prompt:
|
|
500
|
+
# Small/nano model: coding tasks need more room for complete scripts;
|
|
501
|
+
# analysis/finance keep a tighter cap to prevent runaway echo generation.
|
|
502
|
+
if _intent == "coding":
|
|
503
|
+
_effective_max_tokens = 2000
|
|
504
|
+
else:
|
|
505
|
+
_effective_max_tokens = 512
|
|
506
|
+
elif _is_small_model:
|
|
507
|
+
_effective_max_tokens = min(_max_tokens, 2048)
|
|
508
|
+
else:
|
|
509
|
+
_effective_max_tokens = _max_tokens
|
|
510
|
+
|
|
511
|
+
# 停止词:覆盖常见 hallucination 模式
|
|
512
|
+
# 包含:英文求助模板、工具执行幻觉、"任务就绪"尾部幻觉(中文小模型常见)
|
|
513
|
+
_stop_seqs = [
|
|
514
|
+
# ── 英文求助/拒绝模板 ─────────────────────────────────────────────
|
|
515
|
+
"I'm sorry, as an AI",
|
|
516
|
+
"I'm sorry for any confusion",
|
|
517
|
+
"I cannot perform",
|
|
518
|
+
"I can't perform",
|
|
519
|
+
"Do You Need Help",
|
|
520
|
+
"Are There Specific Areas",
|
|
521
|
+
"Let us brainstorm together",
|
|
522
|
+
"AWAITING FEEDBACK",
|
|
523
|
+
"Would love more context",
|
|
524
|
+
"Please provide more details",
|
|
525
|
+
"Could you please provide",
|
|
526
|
+
"Without knowing those specifics",
|
|
527
|
+
"os.system('pip install",
|
|
528
|
+
"git clone https://github.com",
|
|
529
|
+
"Let's download these libraries",
|
|
530
|
+
# ── 中文"任务就绪"尾部幻觉(小模型在回答结束后常产生) ────────────
|
|
531
|
+
"好的,我将开始执行任务",
|
|
532
|
+
"好的,我已经准备好了要做的工作",
|
|
533
|
+
"请告诉我您希望我在接下来做什么",
|
|
534
|
+
"请问有什么我可以帮助您的吗",
|
|
535
|
+
"请告诉我你需要什么帮助",
|
|
536
|
+
"我会尽快为您完成这项任务",
|
|
537
|
+
"如果您有任何其他问题,请随时告诉我",
|
|
538
|
+
"如果你有其他问题,请随时提问",
|
|
539
|
+
# ── 英文任务就绪幻觉 ─────────────────────────────────────────────
|
|
540
|
+
"I'm ready to help with your next",
|
|
541
|
+
"Let me know if you need anything else",
|
|
542
|
+
"Is there anything else you'd like me to",
|
|
543
|
+
"Feel free to ask if you have more questions",
|
|
544
|
+
# ── 工具调用幻觉(声称已调用但实际没有 tool_call 事件)────────────
|
|
545
|
+
"I have already called `get_market_data`",
|
|
546
|
+
"I have already called `get_stock_price`",
|
|
547
|
+
"I have already called get_market_data",
|
|
548
|
+
"I have already fetched",
|
|
549
|
+
"I have already retrieved",
|
|
550
|
+
"我已经调用了",
|
|
551
|
+
"我已调用工具",
|
|
552
|
+
"我已经获取了最新数据",
|
|
553
|
+
# ── 模板占位符输出(模型把 system prompt 模板当内容输出)────────────
|
|
554
|
+
"${real_price_from_data",
|
|
555
|
+
"${data['day_range']}",
|
|
556
|
+
"{actual date today}",
|
|
557
|
+
"{real price from data}",
|
|
558
|
+
"List real recent headlines",
|
|
559
|
+
]
|
|
560
|
+
|
|
561
|
+
payload = {
|
|
562
|
+
"model": model, "messages": messages, "stream": True,
|
|
563
|
+
"options": {
|
|
564
|
+
"num_ctx": _num_ctx,
|
|
565
|
+
"temperature": _temperature,
|
|
566
|
+
"top_p": 0.9,
|
|
567
|
+
"repeat_penalty": 1.4,
|
|
568
|
+
"repeat_last_n": 256,
|
|
569
|
+
"num_predict": _effective_max_tokens,
|
|
570
|
+
},
|
|
571
|
+
"stop": _stop_seqs,
|
|
572
|
+
}
|
|
573
|
+
# Only inject native Ollama tools field for capable models.
|
|
574
|
+
# Small (≤4K ctx) or text_only models must never receive tool schemas —
|
|
575
|
+
# they produce malformed partial JSON that leaks into the output stream.
|
|
576
|
+
if _model_can_use_tools:
|
|
577
|
+
_cap2 = get_model_capability(model) if _HAS_MODEL_CAP else None
|
|
578
|
+
if _cap2 and _cap2.tool_calls and _cap2.format == "ollama_native":
|
|
579
|
+
payload["tools"] = _schemas_for_context
|
|
580
|
+
|
|
581
|
+
full_response = ""
|
|
582
|
+
response_segments = []
|
|
583
|
+
tools_used = []
|
|
584
|
+
tool_calls_pending = []
|
|
585
|
+
_tool_call_counts = {}
|
|
586
|
+
_tool_name_counts = {}
|
|
587
|
+
max_tool_rounds = 25 if _wants_complete_output or _intent == "coding" else 12
|
|
588
|
+
_server_retry_budget = 2
|
|
589
|
+
_continuation_count = 0
|
|
590
|
+
usage = {"prompt_tokens": 0, "completion_tokens": 0, "thinking_tokens": 0}
|
|
591
|
+
_last_tool_had_error = False # Track if previous tool failed
|
|
592
|
+
_in_error_recovery = False # Stays True until run_command succeeds (not reset by read_file)
|
|
593
|
+
_nudge_count = 0 # Limit error recovery nudges
|
|
594
|
+
_consecutive_reads = 0 # Track repeated read_file without fixing
|
|
595
|
+
_last_failed_cmd = "" # Track last failed run_command to detect repeats
|
|
596
|
+
_consecutive_cmd_failures = 0 # Count consecutive failures of same command
|
|
597
|
+
# Repetition loop detection — check every 80 chars (was 200, too slow for 200-token responses)
|
|
598
|
+
_rep_check_interval = 80
|
|
599
|
+
_rep_token_count = [0] # mutable for closure
|
|
600
|
+
_rep_cancelled = [False] # signals loop to stop
|
|
601
|
+
|
|
602
|
+
def _tool_signature(tool_name: str, params: dict) -> tuple[str, str]:
|
|
603
|
+
try:
|
|
604
|
+
payload = json.dumps(params or {}, sort_keys=True, ensure_ascii=False, default=str)
|
|
605
|
+
except Exception:
|
|
606
|
+
payload = str(params or {})
|
|
607
|
+
return tool_name, payload
|
|
608
|
+
|
|
609
|
+
def _register_tool_call(tool_name: str, params: dict) -> dict:
|
|
610
|
+
call = {"tool": tool_name, "params": params}
|
|
611
|
+
sig = _tool_signature(tool_name, params)
|
|
612
|
+
seen = _tool_call_counts.get(sig, 0)
|
|
613
|
+
_tool_call_counts[sig] = seen + 1
|
|
614
|
+
_tool_name_counts[tool_name] = _tool_name_counts.get(tool_name, 0) + 1
|
|
615
|
+
# Market data calls are expensive and usually deterministic for a turn.
|
|
616
|
+
# One successful/attempted call is enough; repeated identical calls are
|
|
617
|
+
# almost always model looping.
|
|
618
|
+
limit = 1 if tool_name in {
|
|
619
|
+
"get_market_data", "get_crypto_data", "get_forex_data",
|
|
620
|
+
"get_technical_indicators",
|
|
621
|
+
} else 2
|
|
622
|
+
total_limit = {
|
|
623
|
+
"web_search": 3,
|
|
624
|
+
"web_fetch": 4,
|
|
625
|
+
"search_news": 3,
|
|
626
|
+
}.get(tool_name)
|
|
627
|
+
if seen >= limit:
|
|
628
|
+
call["_aria_duplicate"] = True
|
|
629
|
+
call["_aria_limit_reason"] = "duplicate parameters"
|
|
630
|
+
elif total_limit is not None and _tool_name_counts[tool_name] > total_limit:
|
|
631
|
+
call["_aria_duplicate"] = True
|
|
632
|
+
call["_aria_limit_reason"] = f"turn budget exceeded ({total_limit})"
|
|
633
|
+
return call
|
|
634
|
+
|
|
635
|
+
def _check_repetition(text: str) -> bool:
|
|
636
|
+
"""Return True if the response is looping.
|
|
637
|
+
|
|
638
|
+
Covers three patterns:
|
|
639
|
+
A. Paragraph-level loop: same long block (50-400 chars) reappears
|
|
640
|
+
B. Sentence-level tail loop: short sentence (15-50 chars) appears
|
|
641
|
+
2+ times at the END — catches "好的,我已经准备好了" × 2 style tails
|
|
642
|
+
C. Beginning-restart loop: model generates the full response, then
|
|
643
|
+
starts again from the very beginning. Detects when the opening
|
|
644
|
+
80 chars of the accumulated response reappear after the midpoint.
|
|
645
|
+
This is the most common 1.5B model failure mode.
|
|
646
|
+
"""
|
|
647
|
+
if len(text) < 100:
|
|
648
|
+
return False
|
|
649
|
+
|
|
650
|
+
# Pattern C: restart-from-beginning (fast path, checked first)
|
|
651
|
+
if len(text) > 300:
|
|
652
|
+
_opening = text[:80].strip()
|
|
653
|
+
if _opening and len(_opening) >= 20:
|
|
654
|
+
_after_half = text[len(text) // 2:]
|
|
655
|
+
if _opening in _after_half:
|
|
656
|
+
return True
|
|
657
|
+
|
|
658
|
+
tail = text[-4000:]
|
|
659
|
+
|
|
660
|
+
# Pattern A: medium-to-large probe in trailing window
|
|
661
|
+
for sub_len in (400, 250, 150, 80, 50):
|
|
662
|
+
if len(tail) < sub_len * 2:
|
|
663
|
+
continue
|
|
664
|
+
probe = tail[-sub_len:].strip()
|
|
665
|
+
if len(probe) < 20:
|
|
666
|
+
continue
|
|
667
|
+
if tail[:-sub_len].count(probe) >= 1:
|
|
668
|
+
return True
|
|
669
|
+
|
|
670
|
+
# Pattern B: short sentence repetition at tail (boilerplate hallucination)
|
|
671
|
+
# Split by Chinese sentence-ending punctuation + newlines
|
|
672
|
+
import re as _re2
|
|
673
|
+
# Common non-looping phrases that legitimately repeat (disclaimers, transitions)
|
|
674
|
+
_B_IGNORE = {
|
|
675
|
+
"本内容不构成投资建议", "不构成投资建议", "请注意风险",
|
|
676
|
+
"以上仅供参考", "仅供参考", "请以官方数据为准",
|
|
677
|
+
"如有问题请咨询专业人士", "投资有风险,入市需谨慎",
|
|
678
|
+
"好的", "当然", "当然可以", "明白了", "好的,我来",
|
|
679
|
+
}
|
|
680
|
+
sentences = [s.strip() for s in _re2.split(r'[。!?\n]+', tail) if s.strip()]
|
|
681
|
+
if len(sentences) >= 4:
|
|
682
|
+
# Check if any sentence in the last 3 also appears before it in the tail
|
|
683
|
+
for sent in sentences[-3:]:
|
|
684
|
+
if len(sent) < 15: # raised from 10 → less hair-trigger
|
|
685
|
+
continue
|
|
686
|
+
if sent in _B_IGNORE:
|
|
687
|
+
continue
|
|
688
|
+
# Require 3+ occurrences (raised from 2) to reduce false positives
|
|
689
|
+
if tail.count(sent) >= 3:
|
|
690
|
+
return True
|
|
691
|
+
|
|
692
|
+
return False
|
|
693
|
+
|
|
694
|
+
for tool_round in range(max_tool_rounds):
|
|
695
|
+
# Context compaction: compress older messages if context too large
|
|
696
|
+
if tool_round > 0:
|
|
697
|
+
payload["messages"] = _compact_messages(payload["messages"], model_key=_mkey)
|
|
698
|
+
|
|
699
|
+
full_response = ""
|
|
700
|
+
tool_calls_this_round = []
|
|
701
|
+
_done_reason = ""
|
|
702
|
+
|
|
703
|
+
try:
|
|
704
|
+
async with aiohttp.ClientSession() as session:
|
|
705
|
+
async with session.post(url, json=payload,
|
|
706
|
+
timeout=aiohttp.ClientTimeout(total=300)) as resp:
|
|
707
|
+
if resp.status != 200:
|
|
708
|
+
try:
|
|
709
|
+
_body = await resp.text()
|
|
710
|
+
_json = json.loads(_body) if _body.strip().startswith("{") else {}
|
|
711
|
+
_ollama_err = _json.get("error") or _body[:200]
|
|
712
|
+
except Exception:
|
|
713
|
+
_ollama_err = f"HTTP {resp.status}"
|
|
714
|
+
# Invalidate model cache so next call re-probes
|
|
715
|
+
try:
|
|
716
|
+
from local_llm_provider import _model_cache
|
|
717
|
+
_model_cache.clear()
|
|
718
|
+
except Exception:
|
|
719
|
+
pass
|
|
720
|
+
if resp.status >= 500 and _server_retry_budget > 0:
|
|
721
|
+
_server_retry_budget -= 1
|
|
722
|
+
if HAS_RICH:
|
|
723
|
+
console.print(" [yellow]模型服务暂时错误,已压缩上下文并重试…[/yellow]")
|
|
724
|
+
payload["messages"] = _compact_messages(payload["messages"], model_key=_mkey)
|
|
725
|
+
payload["messages"].append({
|
|
726
|
+
"role": "user",
|
|
727
|
+
"content": (
|
|
728
|
+
"SYSTEM: The model server returned a transient 5xx error. "
|
|
729
|
+
"Continue the current task from the available tool results. "
|
|
730
|
+
"Do not restart or repeat completed work."
|
|
731
|
+
),
|
|
732
|
+
})
|
|
733
|
+
await asyncio.sleep(1.0)
|
|
734
|
+
continue
|
|
735
|
+
return {"success": False, "error": f"Ollama {resp.status}: {_ollama_err}"}
|
|
736
|
+
async for line in resp.content:
|
|
737
|
+
if cancel_event and cancel_event.is_set():
|
|
738
|
+
return {"success": True, "response": full_response,
|
|
739
|
+
"cancelled": True, "provider": "ollama", "usage": usage}
|
|
740
|
+
text = line.decode("utf-8", errors="ignore").strip()
|
|
741
|
+
if not text:
|
|
742
|
+
continue
|
|
743
|
+
try:
|
|
744
|
+
data = json.loads(text)
|
|
745
|
+
except json.JSONDecodeError:
|
|
746
|
+
continue
|
|
747
|
+
|
|
748
|
+
# Check for native tool calls from Ollama
|
|
749
|
+
msg = data.get("message", {})
|
|
750
|
+
if msg.get("tool_calls"):
|
|
751
|
+
for tc in msg["tool_calls"]:
|
|
752
|
+
fn = tc.get("function", {})
|
|
753
|
+
tool_name = fn.get("name", "")
|
|
754
|
+
tool_args = fn.get("arguments", {})
|
|
755
|
+
if isinstance(tool_args, str):
|
|
756
|
+
try:
|
|
757
|
+
tool_args = json.loads(tool_args)
|
|
758
|
+
except json.JSONDecodeError:
|
|
759
|
+
tool_args = {}
|
|
760
|
+
call = _register_tool_call(tool_name, tool_args)
|
|
761
|
+
tool_calls_this_round.append(call)
|
|
762
|
+
if not call.get("_aria_duplicate"):
|
|
763
|
+
tools_used.append(tool_name)
|
|
764
|
+
if on_tool_call and not call.get("_aria_duplicate"):
|
|
765
|
+
on_tool_call(tool_name, tool_args)
|
|
766
|
+
|
|
767
|
+
if data.get("done"):
|
|
768
|
+
# Capture Ollama usage stats from final message
|
|
769
|
+
usage["prompt_tokens"] += data.get("prompt_eval_count", 0)
|
|
770
|
+
usage["completion_tokens"] += data.get("eval_count", 0)
|
|
771
|
+
_done_reason = str(data.get("done_reason") or data.get("stop_reason") or "")
|
|
772
|
+
break
|
|
773
|
+
|
|
774
|
+
token = msg.get("content", "")
|
|
775
|
+
if token:
|
|
776
|
+
full_response += token
|
|
777
|
+
# 重复检测:先检测再流出,避免重复内容流到用户终端
|
|
778
|
+
_rep_token_count[0] += len(token)
|
|
779
|
+
if _rep_token_count[0] >= _rep_check_interval:
|
|
780
|
+
_rep_token_count[0] = 0
|
|
781
|
+
if _check_repetition(full_response):
|
|
782
|
+
# 定位重复起始点:找到最长不重复前缀
|
|
783
|
+
_fr = full_response
|
|
784
|
+
_cut = len(_fr) // 2
|
|
785
|
+
# 尝试精确裁切:找重复开始的位置
|
|
786
|
+
for _probe_len in (300, 200, 150, 100):
|
|
787
|
+
if len(_fr) < _probe_len * 2:
|
|
788
|
+
continue
|
|
789
|
+
_probe = _fr[-_probe_len:]
|
|
790
|
+
_pos = _fr[:-_probe_len].find(_probe)
|
|
791
|
+
if _pos > 0:
|
|
792
|
+
_cut = _pos
|
|
793
|
+
break
|
|
794
|
+
full_response = _fr[:_cut].rstrip()
|
|
795
|
+
_rep_cancelled[0] = True
|
|
796
|
+
if cancel_event:
|
|
797
|
+
cancel_event.set()
|
|
798
|
+
break
|
|
799
|
+
# 流出 token — 过滤条件:
|
|
800
|
+
# 1. 以 <tool_call 开头的 XML 工具调用(内部处理,不显示)
|
|
801
|
+
# 2. 以 { 开头的裸 JSON 工具调用(小模型幻觉,直接屏蔽)
|
|
802
|
+
_fr_lstrip = full_response.lstrip()
|
|
803
|
+
_looks_like_tool_json = (
|
|
804
|
+
_fr_lstrip.startswith("{")
|
|
805
|
+
and ('"name"' in full_response or '"function"' in full_response)
|
|
806
|
+
and '"arguments"' in full_response
|
|
807
|
+
)
|
|
808
|
+
# 3. 孤立的 ``` 围栏(未配对的代码块标记,过滤掉)
|
|
809
|
+
_stripped_tok = token.strip()
|
|
810
|
+
_is_orphan_fence = (
|
|
811
|
+
_stripped_tok.startswith("```")
|
|
812
|
+
and len(_stripped_tok) <= 6 # just ``` or ```py etc
|
|
813
|
+
and full_response.count("```") % 2 == 1 # unpaired
|
|
814
|
+
)
|
|
815
|
+
if on_token and not _fr_lstrip.startswith("<tool_call") \
|
|
816
|
+
and not _looks_like_tool_json \
|
|
817
|
+
and not _is_orphan_fence:
|
|
818
|
+
on_token(token)
|
|
819
|
+
except Exception as e:
|
|
820
|
+
err_msg = str(e) or type(e).__name__
|
|
821
|
+
if any(x in err_msg.lower() for x in ("cannot connect", "connect call failed", "connection refused", "errno 61")):
|
|
822
|
+
return _ollama_unavailable_result(ollama_url, err_msg)
|
|
823
|
+
return {"success": False, "error": f"Ollama: {err_msg}"}
|
|
824
|
+
|
|
825
|
+
# Fallback: parse text-based tool calls if no native ones found
|
|
826
|
+
if not tool_calls_this_round and full_response.strip():
|
|
827
|
+
text_calls = _parse_text_tool_calls(full_response)
|
|
828
|
+
if text_calls:
|
|
829
|
+
tool_calls_this_round = [
|
|
830
|
+
_register_tool_call(tc["tool"], tc["params"])
|
|
831
|
+
for tc in text_calls
|
|
832
|
+
]
|
|
833
|
+
for tc in tool_calls_this_round:
|
|
834
|
+
if not tc.get("_aria_duplicate"):
|
|
835
|
+
tools_used.append(tc["tool"])
|
|
836
|
+
if on_tool_call and not tc.get("_aria_duplicate"):
|
|
837
|
+
on_tool_call(tc["tool"], tc["params"])
|
|
838
|
+
|
|
839
|
+
# If repetition was detected, truncate and return cleanly
|
|
840
|
+
if _rep_cancelled[0]:
|
|
841
|
+
# Remove the repeated tail — keep only the first clean portion
|
|
842
|
+
lines = full_response.strip().splitlines()
|
|
843
|
+
# Find where repetition started: keep up to the point where unique content ends
|
|
844
|
+
seen_paragraphs = set()
|
|
845
|
+
clean_lines = []
|
|
846
|
+
for line in lines:
|
|
847
|
+
key = line.strip()
|
|
848
|
+
if key and len(key) > 20:
|
|
849
|
+
if key in seen_paragraphs:
|
|
850
|
+
break # Hit a repeated paragraph — stop here
|
|
851
|
+
seen_paragraphs.add(key)
|
|
852
|
+
clean_lines.append(line)
|
|
853
|
+
full_response = "\n".join(clean_lines).rstrip()
|
|
854
|
+
if on_token:
|
|
855
|
+
# The repetition note is appended as a final token
|
|
856
|
+
on_token("\n\n*[model stopped — repetition detected]*")
|
|
857
|
+
full_response += "\n\n*[model stopped — repetition detected]*"
|
|
858
|
+
return {
|
|
859
|
+
"success": True, "response": full_response,
|
|
860
|
+
"tools_used": tools_used, "sources": [],
|
|
861
|
+
"tool_calls_pending": [], "usage": usage, "provider": "ollama",
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
# If no tool calls this round
|
|
865
|
+
if not tool_calls_this_round:
|
|
866
|
+
clean_text = full_response.strip().lower()
|
|
867
|
+
|
|
868
|
+
if _done_reason in {"length", "num_predict"} and _continuation_count < 3:
|
|
869
|
+
_continuation_count += 1
|
|
870
|
+
response_segments.append(full_response.rstrip())
|
|
871
|
+
payload["messages"].append({"role": "assistant", "content": full_response})
|
|
872
|
+
payload["messages"].append({
|
|
873
|
+
"role": "user",
|
|
874
|
+
"content": (
|
|
875
|
+
"继续完成上一条回答,从刚才中断处接着写。"
|
|
876
|
+
"不要重写已经输出的内容,不要总结,直到任务完整结束。"
|
|
877
|
+
),
|
|
878
|
+
})
|
|
879
|
+
if HAS_RICH:
|
|
880
|
+
console.print("\n [dim]继续输出未完成内容…[/dim]\n")
|
|
881
|
+
continue
|
|
882
|
+
|
|
883
|
+
# Detect "intent without action" — model says it will do something
|
|
884
|
+
# but didn't output a tool call
|
|
885
|
+
_intent_words = [
|
|
886
|
+
"let me", "i will", "i'll", "let's", "让我", "我会", "我将",
|
|
887
|
+
"让我们", "我来", "接下来", "下面", "我们来", "我需要",
|
|
888
|
+
"再次", "重新", "检查", "修复", "fix", "retry", "check",
|
|
889
|
+
]
|
|
890
|
+
has_intent = any(w in clean_text for w in _intent_words)
|
|
891
|
+
should_nudge = (_in_error_recovery or _last_tool_had_error or has_intent) and _nudge_count < 5
|
|
892
|
+
|
|
893
|
+
if should_nudge and tool_round < max_tool_rounds - 1:
|
|
894
|
+
_nudge_count += 1
|
|
895
|
+
if _in_error_recovery:
|
|
896
|
+
nudge = (
|
|
897
|
+
"SYSTEM: You are in error recovery mode. The script FAILED and is NOT yet fixed. "
|
|
898
|
+
"You MUST call a tool NOW to fix it:\n"
|
|
899
|
+
"- If you already read the file: call edit_file to fix the specific error, or write_file to rewrite.\n"
|
|
900
|
+
"- If you haven't read it: call read_file first.\n"
|
|
901
|
+
"- After fixing: call run_command to retry.\n"
|
|
902
|
+
"Do NOT output text. Output ONLY a <tool_call>."
|
|
903
|
+
)
|
|
904
|
+
elif _last_tool_had_error:
|
|
905
|
+
nudge = (
|
|
906
|
+
"SYSTEM: The previous step FAILED. Fix it NOW by calling a tool:\n"
|
|
907
|
+
"1. read_file to see the code.\n"
|
|
908
|
+
"2. edit_file or write_file to fix.\n"
|
|
909
|
+
"3. run_command to retry.\n"
|
|
910
|
+
"Output a <tool_call> NOW."
|
|
911
|
+
)
|
|
912
|
+
else:
|
|
913
|
+
nudge = (
|
|
914
|
+
"SYSTEM: You said you would do something but did not call a tool. "
|
|
915
|
+
"Do NOT describe what you will do — just DO it. "
|
|
916
|
+
"Output a <tool_call> NOW to take the next action."
|
|
917
|
+
)
|
|
918
|
+
payload["messages"].append({"role": "assistant", "content": full_response})
|
|
919
|
+
payload["messages"].append({"role": "user", "content": nudge})
|
|
920
|
+
continue
|
|
921
|
+
|
|
922
|
+
# Truly done. Tokens were already streamed above; do not print the
|
|
923
|
+
# accumulated response again or the terminal shows duplicate blocks.
|
|
924
|
+
break
|
|
925
|
+
|
|
926
|
+
# Tool calls present — suppress model text (tool UI provides feedback)
|
|
927
|
+
# Large models may emit multiple write_file calls in one round (project scaffolding).
|
|
928
|
+
# Destructive / interactive tools (run_command, edit_file) remain sequential.
|
|
929
|
+
_MUST_SERIALIZE = {"run_command", "edit_file"}
|
|
930
|
+
if len(tool_calls_this_round) > 1:
|
|
931
|
+
_all_safe = all(tc["tool"] not in _MUST_SERIALIZE for tc in tool_calls_this_round)
|
|
932
|
+
_is_large_mdl = _model_size in ("large",)
|
|
933
|
+
if _is_large_mdl and _all_safe:
|
|
934
|
+
tool_calls_this_round = tool_calls_this_round[:5] # max 5 parallel writes
|
|
935
|
+
else:
|
|
936
|
+
tool_calls_this_round = tool_calls_this_round[:1]
|
|
937
|
+
|
|
938
|
+
# Execute tool calls locally and feed results back
|
|
939
|
+
clean_text = _strip_tool_call_tags(full_response)
|
|
940
|
+
payload["messages"].append({"role": "assistant", "content": clean_text,
|
|
941
|
+
"tool_calls": [{"function": {"name": tc["tool"], "arguments": tc["params"]}}
|
|
942
|
+
for tc in tool_calls_this_round]})
|
|
943
|
+
|
|
944
|
+
ollama_cancelled = False
|
|
945
|
+
for tc in tool_calls_this_round:
|
|
946
|
+
# Check cancel between tools
|
|
947
|
+
if cancel_event and cancel_event.is_set():
|
|
948
|
+
ollama_cancelled = True
|
|
949
|
+
break
|
|
950
|
+
|
|
951
|
+
tool_name = tc["tool"]
|
|
952
|
+
# Note: _print_tool_call already called by on_tool_call during streaming
|
|
953
|
+
|
|
954
|
+
if tc.get("_aria_duplicate"):
|
|
955
|
+
reason = tc.get("_aria_limit_reason") or "duplicate tool call"
|
|
956
|
+
summary = (
|
|
957
|
+
f"SYSTEM: Tool call skipped ({reason}): {tool_name}. "
|
|
958
|
+
"Use the existing tool result already available in this turn. "
|
|
959
|
+
"Do not call this tool again in this turn; finish the answer from available evidence."
|
|
960
|
+
)
|
|
961
|
+
payload["messages"].append({
|
|
962
|
+
"role": "tool",
|
|
963
|
+
"content": summary,
|
|
964
|
+
})
|
|
965
|
+
continue
|
|
966
|
+
|
|
967
|
+
# Ask user confirmation for destructive tools
|
|
968
|
+
if tool_name in _CONFIRM_TOOLS:
|
|
969
|
+
try:
|
|
970
|
+
approval = _confirm_tool_execution_decision(
|
|
971
|
+
tool_name,
|
|
972
|
+
tc["params"],
|
|
973
|
+
config_policy=_ACTIVE_COMMAND_POLICY[0],
|
|
974
|
+
)
|
|
975
|
+
_apply_tool_approval(tc["params"], approval)
|
|
976
|
+
if not approval.approved:
|
|
977
|
+
ollama_cancelled = True
|
|
978
|
+
if HAS_RICH:
|
|
979
|
+
console.print("\n [dim]Cancelled[/dim]")
|
|
980
|
+
break
|
|
981
|
+
# Persist "Allow & set balanced" choice
|
|
982
|
+
if approval.upgrade_policy:
|
|
983
|
+
tc["params"].pop("_upgrade_policy", None)
|
|
984
|
+
_ACTIVE_COMMAND_POLICY[0] = "balanced"
|
|
985
|
+
if HAS_RICH:
|
|
986
|
+
console.print(" [dim]策略已升级为 balanced(本会话)[/dim]")
|
|
987
|
+
except KeyboardInterrupt:
|
|
988
|
+
ollama_cancelled = True
|
|
989
|
+
break
|
|
990
|
+
|
|
991
|
+
try:
|
|
992
|
+
tool_t0 = time.time()
|
|
993
|
+
# Inject current policy for run_command so post-approval execution
|
|
994
|
+
# isn't re-blocked by the default "safe" policy
|
|
995
|
+
if tool_name == "run_command" and "policy" not in tc["params"]:
|
|
996
|
+
tc["params"]["policy"] = _ACTIVE_COMMAND_POLICY[0]
|
|
997
|
+
result = execute_local_tool(tool_name, tc["params"])
|
|
998
|
+
tool_dt = time.time() - tool_t0
|
|
999
|
+
except KeyboardInterrupt:
|
|
1000
|
+
ollama_cancelled = True
|
|
1001
|
+
break
|
|
1002
|
+
_print_tool_result(tool_name, result, tool_dt)
|
|
1003
|
+
|
|
1004
|
+
summary = _format_tool_summary(tool_name, result)
|
|
1005
|
+
|
|
1006
|
+
# Track if this tool had an error (for nudge logic)
|
|
1007
|
+
_last_tool_had_error = not result.get("success", False)
|
|
1008
|
+
if result.get("success") and tool_name == "run_command":
|
|
1009
|
+
exit_code = result.get("data", {}).get("exit_code", 0)
|
|
1010
|
+
_last_tool_had_error = (exit_code != 0)
|
|
1011
|
+
|
|
1012
|
+
# Error recovery state machine
|
|
1013
|
+
if _last_tool_had_error:
|
|
1014
|
+
_in_error_recovery = True
|
|
1015
|
+
_consecutive_reads = 0
|
|
1016
|
+
# Detect repeated failed run_command (same command failing 2+ times)
|
|
1017
|
+
if tool_name == "run_command":
|
|
1018
|
+
cmd_str = tc["params"].get("command", "")
|
|
1019
|
+
if cmd_str == _last_failed_cmd:
|
|
1020
|
+
_consecutive_cmd_failures += 1
|
|
1021
|
+
else:
|
|
1022
|
+
_last_failed_cmd = cmd_str
|
|
1023
|
+
_consecutive_cmd_failures = 1
|
|
1024
|
+
if _consecutive_cmd_failures >= 2:
|
|
1025
|
+
summary += ("\n\nSYSTEM: You have run the SAME command and it FAILED again with the same error. "
|
|
1026
|
+
"STOP re-running it. You MUST fix the code first:\n"
|
|
1027
|
+
"1. read_file to see the script content\n"
|
|
1028
|
+
"2. edit_file to fix the specific error (or write_file to rewrite entirely)\n"
|
|
1029
|
+
"3. THEN run_command to retry.\n"
|
|
1030
|
+
"Do NOT run the same command again until you have fixed the code.")
|
|
1031
|
+
elif tool_name in ("read_file", "list_files", "search_code"):
|
|
1032
|
+
# Diagnostic tools do NOT exit error recovery
|
|
1033
|
+
_consecutive_reads += 1
|
|
1034
|
+
# If model read the file 2+ times without fixing, inject directive
|
|
1035
|
+
if _in_error_recovery and _consecutive_reads >= 2:
|
|
1036
|
+
summary += ("\n\nSYSTEM: You have read this file multiple times without fixing it. "
|
|
1037
|
+
"STOP reading. Use edit_file to fix the specific error, "
|
|
1038
|
+
"or use write_file to rewrite the entire script. Then run_command to retry.")
|
|
1039
|
+
elif tool_name in ("edit_file", "write_file"):
|
|
1040
|
+
# Fix was applied — stay in recovery until run_command succeeds
|
|
1041
|
+
_consecutive_reads = 0
|
|
1042
|
+
_consecutive_cmd_failures = 0 # Reset — code was changed
|
|
1043
|
+
_last_failed_cmd = ""
|
|
1044
|
+
elif tool_name == "run_command" and not _last_tool_had_error:
|
|
1045
|
+
# run_command succeeded — exit error recovery
|
|
1046
|
+
_in_error_recovery = False
|
|
1047
|
+
_consecutive_reads = 0
|
|
1048
|
+
_consecutive_cmd_failures = 0
|
|
1049
|
+
_last_failed_cmd = ""
|
|
1050
|
+
_nudge_count = 0
|
|
1051
|
+
|
|
1052
|
+
if on_tool_result:
|
|
1053
|
+
on_tool_result(tool_name, summary)
|
|
1054
|
+
|
|
1055
|
+
# Feed tool result back to Ollama for next round
|
|
1056
|
+
payload["messages"].append({
|
|
1057
|
+
"role": "tool",
|
|
1058
|
+
"content": summary,
|
|
1059
|
+
})
|
|
1060
|
+
|
|
1061
|
+
if ollama_cancelled:
|
|
1062
|
+
return {"success": True, "response": full_response,
|
|
1063
|
+
"cancelled": True, "tools_used": tools_used,
|
|
1064
|
+
"sources": [], "thinking": "", "provider": "ollama", "usage": usage}
|
|
1065
|
+
|
|
1066
|
+
# Continue streaming with tool results in context
|
|
1067
|
+
if HAS_RICH:
|
|
1068
|
+
console.print() # newline before next AI response
|
|
1069
|
+
|
|
1070
|
+
# Write successful stateless response to cache for future reuse
|
|
1071
|
+
if response_segments:
|
|
1072
|
+
full_response = "\n".join(part for part in response_segments + [full_response] if part)
|
|
1073
|
+
|
|
1074
|
+
if _should_cache and full_response and not tools_used:
|
|
1075
|
+
_cache_set(_ck, full_response)
|
|
1076
|
+
|
|
1077
|
+
# ── Code-block executor fallback ─────────────────────────────────────────
|
|
1078
|
+
# Small models often ignore the <tool_call> instruction and write plain code
|
|
1079
|
+
# blocks instead. When the intent is "coding" and the model produced a
|
|
1080
|
+
# Python block but zero tool calls, auto-extract the code and queue
|
|
1081
|
+
# write_file + run_command so the outer agentic loop executes it.
|
|
1082
|
+
_auto_tool_calls: list = []
|
|
1083
|
+
_allow_code_block_autorun = (
|
|
1084
|
+
_intent == "coding"
|
|
1085
|
+
and _has_coding_signal
|
|
1086
|
+
and (_explicit_code_signal or not _is_visual_artifact_request)
|
|
1087
|
+
)
|
|
1088
|
+
if _allow_code_block_autorun and not tools_used and full_response:
|
|
1089
|
+
import re as _re
|
|
1090
|
+
# Accept both complete (``` closed) and truncated (unclosed) code blocks
|
|
1091
|
+
_py_blocks = _re.findall(r"```python\n(.*?)```", full_response, _re.DOTALL)
|
|
1092
|
+
if not _py_blocks:
|
|
1093
|
+
# Fallback: grab everything after the opening fence (handles truncation)
|
|
1094
|
+
_m = _re.search(r"```python\n(.*)", full_response, _re.DOTALL)
|
|
1095
|
+
if _m:
|
|
1096
|
+
_py_blocks = [_m.group(1)]
|
|
1097
|
+
if _py_blocks:
|
|
1098
|
+
_code = _py_blocks[-1].strip()
|
|
1099
|
+
# Basic sanitisation: strip leading spaces from ticker assignments
|
|
1100
|
+
_code = _re.sub(
|
|
1101
|
+
r"""(ticker\s*=\s*['"])(\s+)([A-Z]{1,10})(['"])""",
|
|
1102
|
+
r"\1\3\4", _code
|
|
1103
|
+
)
|
|
1104
|
+
# Auto-add missing `import mplfinance as mpf` when mpf is used
|
|
1105
|
+
if "mpf." in _code and "import mplfinance" not in _code:
|
|
1106
|
+
_code = "import mplfinance as mpf\n" + _code
|
|
1107
|
+
# Auto-add `import matplotlib.pyplot as plt` when plt is used
|
|
1108
|
+
if "plt." in _code and "import matplotlib.pyplot as plt" not in _code:
|
|
1109
|
+
_code = (
|
|
1110
|
+
"import matplotlib; matplotlib.use('Agg')\n"
|
|
1111
|
+
"import matplotlib.pyplot as plt\n" + _code
|
|
1112
|
+
)
|
|
1113
|
+
# Try to extract user-specified filename from the original message
|
|
1114
|
+
_fname_match = _re.search(
|
|
1115
|
+
r'保存(?:到|为|成)?\s*([^\s,,。]+\.py)'
|
|
1116
|
+
r'|save\s+(?:to\s+|as\s+)?([^\s,]+\.py)'
|
|
1117
|
+
r'|(?:named?|called?|filename?)\s+([^\s,]+\.py)',
|
|
1118
|
+
message, _re.IGNORECASE
|
|
1119
|
+
)
|
|
1120
|
+
if _fname_match:
|
|
1121
|
+
_fname = next(g for g in _fname_match.groups() if g)
|
|
1122
|
+
# Strip any path prefix from the extracted name
|
|
1123
|
+
_fname = os.path.basename(_fname)
|
|
1124
|
+
else:
|
|
1125
|
+
_fname = f"aria_generated_{int(time.time())}.py"
|
|
1126
|
+
_fpath = f"~/Documents/Aria Code/generated/{_fname}"
|
|
1127
|
+
|
|
1128
|
+
# Validate Python syntax before writing — prepend warning comment if broken
|
|
1129
|
+
import py_compile as _pyc, tempfile as _tf2
|
|
1130
|
+
try:
|
|
1131
|
+
with _tf2.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as _stmp:
|
|
1132
|
+
_stmp.write(_code)
|
|
1133
|
+
_stmp_path = _stmp.name
|
|
1134
|
+
_pyc.compile(_stmp_path, doraise=True)
|
|
1135
|
+
os.unlink(_stmp_path)
|
|
1136
|
+
except _pyc.PyCompileError as _pce:
|
|
1137
|
+
# Surface the error prominently; file is still saved so user can fix it
|
|
1138
|
+
_err_line = str(_pce).replace(str(_stmp_path), _fname)
|
|
1139
|
+
_code = f"# ⚠️ SYNTAX ERROR (fix before running):\n# {_err_line}\n\n" + _code
|
|
1140
|
+
try:
|
|
1141
|
+
os.unlink(_stmp_path)
|
|
1142
|
+
except Exception:
|
|
1143
|
+
pass
|
|
1144
|
+
except Exception:
|
|
1145
|
+
pass
|
|
1146
|
+
|
|
1147
|
+
_auto_tool_calls = [
|
|
1148
|
+
{"tool": "write_file", "params": {"path": _fpath, "content": _code}},
|
|
1149
|
+
]
|
|
1150
|
+
import shlex as _shlex_auto
|
|
1151
|
+
_auto_tool_calls.append({
|
|
1152
|
+
"tool": "run_command",
|
|
1153
|
+
"params": {
|
|
1154
|
+
"command": f"python3 {_shlex_auto.quote(os.path.expanduser(_fpath))}",
|
|
1155
|
+
"timeout": 120,
|
|
1156
|
+
},
|
|
1157
|
+
})
|
|
1158
|
+
if on_tool_call:
|
|
1159
|
+
on_tool_call("write_file", _auto_tool_calls[0]["params"])
|
|
1160
|
+
on_tool_call("run_command", _auto_tool_calls[1]["params"])
|
|
1161
|
+
|
|
1162
|
+
if _auto_tool_calls:
|
|
1163
|
+
return {"success": True, "response": full_response,
|
|
1164
|
+
"tool_calls_pending": _auto_tool_calls,
|
|
1165
|
+
"tools_used": tools_used, "sources": [], "thinking": "",
|
|
1166
|
+
"provider": "ollama", "usage": usage}
|
|
1167
|
+
|
|
1168
|
+
return {"success": True, "response": full_response,
|
|
1169
|
+
"tools_used": tools_used, "sources": [], "thinking": "", "provider": "ollama",
|
|
1170
|
+
"usage": usage}
|