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.
Files changed (284) hide show
  1. agents/__init__.py +32 -0
  2. agents/base.py +190 -0
  3. agents/deep/__init__.py +37 -0
  4. agents/deep/calibration_loop.py +144 -0
  5. agents/deep/critic.py +125 -0
  6. agents/deep/deepen.py +193 -0
  7. agents/deep/models.py +149 -0
  8. agents/deep/pipeline.py +164 -0
  9. agents/deep/quant_fusion.py +192 -0
  10. agents/deep/themes.py +95 -0
  11. agents/deep/tiers.py +106 -0
  12. agents/financial/__init__.py +10 -0
  13. agents/financial/catalyst.py +279 -0
  14. agents/financial/debate.py +145 -0
  15. agents/financial/earnings.py +303 -0
  16. agents/financial/fundamental.py +159 -0
  17. agents/financial/macro.py +99 -0
  18. agents/financial/news.py +207 -0
  19. agents/financial/risk.py +132 -0
  20. agents/financial/sector.py +279 -0
  21. agents/financial/synthesis.py +274 -0
  22. agents/financial/technical.py +258 -0
  23. agents/portfolio_agent.py +333 -0
  24. agents/realty/__init__.py +62 -0
  25. agents/realty/asset_diagnosis.py +150 -0
  26. agents/realty/business_match.py +165 -0
  27. agents/realty/cashflow_verify.py +208 -0
  28. agents/realty/contract_rules.py +209 -0
  29. agents/realty/energy_anomaly.py +188 -0
  30. agents/realty/exit_settlement.py +207 -0
  31. agents/realty/fulfillment_risk.py +205 -0
  32. agents/realty/ops_optimize.py +159 -0
  33. agents/realty/revenue_share.py +214 -0
  34. agents/registry.py +144 -0
  35. agents/sports/__init__.py +0 -0
  36. agents/sports/football_agent.py +169 -0
  37. agents/team.py +289 -0
  38. aliyun_data_client.py +660 -0
  39. apps/README.md +12 -0
  40. apps/__init__.py +2 -0
  41. apps/channels/README.md +15 -0
  42. apps/cli/README.md +13 -0
  43. apps/cli/__init__.py +2 -0
  44. apps/cli/bootstrap.py +99 -0
  45. apps/cli/codegen_paths.py +29 -0
  46. apps/cli/commands/__init__.py +16 -0
  47. apps/cli/commands/analysis_cmds.py +288 -0
  48. apps/cli/commands/backtest_cmds.py +1887 -0
  49. apps/cli/commands/broker_cmds.py +1154 -0
  50. apps/cli/commands/business_workflow_cmds.py +289 -0
  51. apps/cli/commands/catalog.py +84 -0
  52. apps/cli/commands/data_cmds.py +405 -0
  53. apps/cli/commands/diagnostic_cmds.py +179 -0
  54. apps/cli/commands/diagnostic_ops_cmds.py +696 -0
  55. apps/cli/commands/finance_render.py +12 -0
  56. apps/cli/commands/market.py +399 -0
  57. apps/cli/commands/market_cmds.py +1276 -0
  58. apps/cli/commands/market_context.py +425 -0
  59. apps/cli/commands/market_render.py +7 -0
  60. apps/cli/commands/model_cmds.py +1579 -0
  61. apps/cli/commands/ops_cmds.py +668 -0
  62. apps/cli/commands/portfolio_cmds.py +962 -0
  63. apps/cli/commands/report.py +377 -0
  64. apps/cli/commands/scaffold_templates.py +617 -0
  65. apps/cli/commands/session_cmds.py +179 -0
  66. apps/cli/commands/session_ux_cmds.py +280 -0
  67. apps/cli/commands/team.py +588 -0
  68. apps/cli/commands/team_render.py +8 -0
  69. apps/cli/commands/ui_cmds.py +358 -0
  70. apps/cli/commands/workflow_cmds.py +279 -0
  71. apps/cli/commands/workspace_cmds.py +1414 -0
  72. apps/cli/config_paths.py +70 -0
  73. apps/cli/config_store.py +61 -0
  74. apps/cli/deterministic.py +122 -0
  75. apps/cli/direct.py +48 -0
  76. apps/cli/github_app_auth.py +135 -0
  77. apps/cli/handlers/__init__.py +11 -0
  78. apps/cli/handlers/broker_handlers.py +122 -0
  79. apps/cli/handlers/chart_handlers.py +1309 -0
  80. apps/cli/handlers/market_handlers.py +2509 -0
  81. apps/cli/handlers/realty_handlers.py +114 -0
  82. apps/cli/handlers/strategy_advice.py +82 -0
  83. apps/cli/hooks.py +180 -0
  84. apps/cli/i18n.py +284 -0
  85. apps/cli/intent.py +136 -0
  86. apps/cli/intent_router.py +217 -0
  87. apps/cli/lifecycle_hooks.py +48 -0
  88. apps/cli/main.py +29 -0
  89. apps/cli/market_metadata.py +135 -0
  90. apps/cli/market_universe.py +265 -0
  91. apps/cli/message_processing.py +257 -0
  92. apps/cli/plan_mode.py +139 -0
  93. apps/cli/plotly_html.py +15 -0
  94. apps/cli/prediction_feedback.py +202 -0
  95. apps/cli/preflight.py +497 -0
  96. apps/cli/project_aria.py +60 -0
  97. apps/cli/prompts/__init__.py +0 -0
  98. apps/cli/prompts/coding.py +658 -0
  99. apps/cli/prompts/system_prompts.py +531 -0
  100. apps/cli/prompts/ui.py +434 -0
  101. apps/cli/providers/__init__.py +1 -0
  102. apps/cli/providers/base.py +271 -0
  103. apps/cli/providers/chat_routing.py +80 -0
  104. apps/cli/providers/llm/__init__.py +1 -0
  105. apps/cli/providers/llm/ollama_stream.py +1170 -0
  106. apps/cli/providers/llm/sse_stream.py +216 -0
  107. apps/cli/providers/runtime_bridge.py +185 -0
  108. apps/cli/runtime_consumer.py +489 -0
  109. apps/cli/session_export.py +87 -0
  110. apps/cli/session_jsonl.py +207 -0
  111. apps/cli/session_store.py +112 -0
  112. apps/cli/todo_tracker.py +190 -0
  113. apps/cli/tools/__init__.py +40 -0
  114. apps/cli/tools/context.py +46 -0
  115. apps/cli/tools/file_tools.py +112 -0
  116. apps/cli/tools/market_tools.py +549 -0
  117. apps/cli/tools/notebook_tools.py +111 -0
  118. apps/cli/tools/system_tools.py +669 -0
  119. apps/cli/tools/write_tools.py +715 -0
  120. apps/cli/tradingview_bridge.py +434 -0
  121. apps/cli/update_check.py +152 -0
  122. apps/cli/utils/__init__.py +0 -0
  123. apps/cli/utils/market_detect.py +1578 -0
  124. apps/daemon/README.md +14 -0
  125. apps/vscode/README.md +115 -0
  126. apps/vscode/package.json +70 -0
  127. aria_cli.py +11636 -0
  128. aria_code-4.1.3.dist-info/METADATA +952 -0
  129. aria_code-4.1.3.dist-info/RECORD +284 -0
  130. aria_code-4.1.3.dist-info/WHEEL +5 -0
  131. aria_code-4.1.3.dist-info/entry_points.txt +2 -0
  132. aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
  133. aria_code-4.1.3.dist-info/top_level.txt +50 -0
  134. aria_daemon.py +1295 -0
  135. aria_feishu_bot.py +1359 -0
  136. aria_relay_client.py +182 -0
  137. aria_relay_server.py +405 -0
  138. aria_telegram_bot.py +202 -0
  139. ariarc.py +328 -0
  140. artifacts.py +491 -0
  141. backtest_report.py +472 -0
  142. brokers/__init__.py +72 -0
  143. brokers/base.py +207 -0
  144. brokers/capabilities.py +264 -0
  145. brokers/cn/__init__.py +10 -0
  146. brokers/cn/easytrader_broker.py +193 -0
  147. brokers/cn/futu_broker.py +194 -0
  148. brokers/cn/longbridge_broker.py +190 -0
  149. brokers/cn/tiger_broker.py +196 -0
  150. brokers/cn/xtquant_broker.py +175 -0
  151. brokers/config.py +364 -0
  152. brokers/intl/__init__.py +5 -0
  153. brokers/intl/alpaca_broker.py +183 -0
  154. brokers/intl/ibkr_broker.py +215 -0
  155. brokers/intl/webull_broker.py +156 -0
  156. brokers/paper_broker.py +259 -0
  157. brokers/planning.py +296 -0
  158. brokers/registry.py +181 -0
  159. brokers/trading.py +237 -0
  160. change_store.py +127 -0
  161. command_safety.py +19 -0
  162. computer_use_tools.py +504 -0
  163. dashboard_generator.py +578 -0
  164. data_analysis_tools.py +808 -0
  165. data_cleaner.py +483 -0
  166. data_service.py +481 -0
  167. datasources/__init__.py +23 -0
  168. datasources/base.py +166 -0
  169. datasources/router.py +221 -0
  170. datasources/sources/__init__.py +15 -0
  171. datasources/sources/akshare_source.py +269 -0
  172. datasources/sources/alpha_vantage_source.py +202 -0
  173. datasources/sources/edgar_source.py +218 -0
  174. datasources/sources/finnhub_source.py +197 -0
  175. datasources/sources/fred_source.py +219 -0
  176. datasources/sources/tushare_source.py +141 -0
  177. datasources/sources/web_scraper_source.py +278 -0
  178. datasources/sources/world_bank_source.py +205 -0
  179. datasources/sources/yfinance_source.py +152 -0
  180. demo_player.py +204 -0
  181. doctor.py +508 -0
  182. file_analysis_tools.py +734 -0
  183. finance_formulas.py +389 -0
  184. football_data_client.py +1670 -0
  185. intent_classifier.py +358 -0
  186. local_finance_tools.py +3221 -0
  187. local_llm_provider.py +552 -0
  188. macro_tools.py +368 -0
  189. market_data_client.py +1899 -0
  190. mcp_client.py +506 -0
  191. memory_manager.py +245 -0
  192. model_capability.py +416 -0
  193. notification_tools.py +248 -0
  194. packages/__init__.py +23 -0
  195. packages/aria_agents/__init__.py +5 -0
  196. packages/aria_agents/manifest.py +69 -0
  197. packages/aria_core/__init__.py +34 -0
  198. packages/aria_core/architecture.py +192 -0
  199. packages/aria_core/export.py +124 -0
  200. packages/aria_core/manifest.py +65 -0
  201. packages/aria_infra/__init__.py +15 -0
  202. packages/aria_infra/arthera.py +52 -0
  203. packages/aria_infra/doctor.py +246 -0
  204. packages/aria_infra/product.py +37 -0
  205. packages/aria_mcp/__init__.py +25 -0
  206. packages/aria_mcp/bridge.py +38 -0
  207. packages/aria_mcp/config.py +97 -0
  208. packages/aria_mcp/tools.py +61 -0
  209. packages/aria_sdk/__init__.py +19 -0
  210. packages/aria_sdk/client.py +396 -0
  211. packages/aria_sdk/providers.py +70 -0
  212. packages/aria_sdk/streaming.py +73 -0
  213. packages/aria_sdk/types.py +86 -0
  214. packages/aria_services/__init__.py +55 -0
  215. packages/aria_services/context.py +258 -0
  216. packages/aria_services/data.py +11 -0
  217. packages/aria_services/provider_health.py +189 -0
  218. packages/aria_services/registry.py +213 -0
  219. packages/aria_services/usage.py +138 -0
  220. packages/aria_skills/__init__.py +5 -0
  221. packages/aria_skills/registry.py +59 -0
  222. packages/aria_tools/__init__.py +5 -0
  223. packages/aria_tools/registry.py +128 -0
  224. packages/quant_engine/__init__.py +6 -0
  225. packages/quant_engine/sports/__init__.py +72 -0
  226. packages/quant_engine/sports/calibrator.py +353 -0
  227. packages/quant_engine/sports/dixon_coles.py +234 -0
  228. packages/quant_engine/sports/elo.py +299 -0
  229. packages/quant_engine/sports/form.py +188 -0
  230. packages/quant_engine/sports/h2h.py +195 -0
  231. packages/quant_engine/sports/ml_model.py +354 -0
  232. packages/quant_engine/sports/predictor.py +311 -0
  233. packages/quant_engine/sports/tracker.py +664 -0
  234. packages/quant_engine/stochastic/__init__.py +27 -0
  235. packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  236. packages/quant_engine/stochastic/ito_calculus.py +477 -0
  237. packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  238. packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  239. packages/quant_engine/stochastic/options_pricing.py +573 -0
  240. packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  241. plan_utils.py +194 -0
  242. plugin_loader.py +328 -0
  243. portfolio_ledger.py +262 -0
  244. privacy/__init__.py +5 -0
  245. privacy/feedback.py +123 -0
  246. project_tools.py +525 -0
  247. providers/__init__.py +30 -0
  248. providers/llm/__init__.py +19 -0
  249. providers/llm/anthropic.py +184 -0
  250. providers/llm/base.py +139 -0
  251. providers/llm/ollama.py +128 -0
  252. providers/llm/openai_compat.py +282 -0
  253. providers/llm/registry.py +358 -0
  254. realty_data_tools.py +659 -0
  255. report_generator.py +1314 -0
  256. runtime/__init__.py +103 -0
  257. runtime/agent_loop.py +1183 -0
  258. runtime/approval.py +51 -0
  259. runtime/events.py +102 -0
  260. runtime/gateway.py +128 -0
  261. runtime/lsp.py +346 -0
  262. runtime/subagent.py +258 -0
  263. runtime/tool_executor.py +104 -0
  264. runtime/tool_policy.py +106 -0
  265. safety/__init__.py +21 -0
  266. safety/permissions.py +275 -0
  267. setup_wizard.py +653 -0
  268. strategy_vault.py +420 -0
  269. ui/__init__.py +100 -0
  270. ui/banner.py +310 -0
  271. ui/completer.py +391 -0
  272. ui/console.py +271 -0
  273. ui/image_render.py +243 -0
  274. ui/input_box.py +376 -0
  275. ui/picker.py +195 -0
  276. ui/render/__init__.py +11 -0
  277. ui/render/finance.py +1480 -0
  278. ui/render/market.py +225 -0
  279. ui/render/output.py +681 -0
  280. ui/render/team.py +346 -0
  281. ui/robot.py +235 -0
  282. workspace/__init__.py +6 -0
  283. workspace/files.py +170 -0
  284. workspace/verify.py +113 -0
plan_utils.py ADDED
@@ -0,0 +1,194 @@
1
+ """
2
+ plan_utils.py — Plan parsing helpers for Aria Code CLI workflow commands.
3
+
4
+ Supports several natural input styles:
5
+
6
+ Numbered steps (most common):
7
+ 1. Fetch AAPL quote
8
+ 2. Generate 6-month chart
9
+ 3. Output analysis report
10
+
11
+ Bullet list:
12
+ - Fetch quote
13
+ - Generate chart
14
+ - Output report
15
+
16
+ Inline arrow / semicolon chain:
17
+ fetch quote -> generate chart -> output report
18
+ fetch quote; generate chart; output report
19
+
20
+ Mixed (numbered + description):
21
+ Step 1: Fetch AAPL quote
22
+ Step 2: Generate chart with SMA20
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from typing import List, Optional
30
+
31
+
32
+ # ── Data model ────────────────────────────────────────────────────────────────
33
+
34
+ @dataclass
35
+ class PlanStep:
36
+ """A single executable step in a plan."""
37
+ index: int # 1-based position
38
+ description: str # human-readable description
39
+ name: Optional[str] = None # optional short name / label
40
+ deps: List[int] = field(default_factory=list) # dependency indices
41
+
42
+ def __str__(self) -> str:
43
+ dep_str = f" [deps: {','.join(str(d) for d in self.deps)}]" if self.deps else ""
44
+ return f"{self.index}. {self.description}{dep_str}"
45
+
46
+
47
+ # ── Patterns ──────────────────────────────────────────────────────────────────
48
+
49
+ # "1. text", "1) text", "Step 1: text", "Step 1 — text"
50
+ _RE_NUMBERED = re.compile(
51
+ r"^(?:step\s*)?(\d+)[.):\-–—]\s*(.+)$",
52
+ re.IGNORECASE,
53
+ )
54
+
55
+ # "- text", "• text", "* text", "· text"
56
+ _RE_BULLET = re.compile(r"^[-•*·]\s+(.+)$")
57
+
58
+ # Metadata tags like [name: Build] or [deps: 1,3]
59
+ _RE_META_NAME = re.compile(r"\[name:\s*([^\]]+)\]", re.IGNORECASE)
60
+ _RE_META_DEPS = re.compile(r"\[deps:\s*([^\]]+)\]", re.IGNORECASE)
61
+
62
+
63
+ def _strip_meta(text: str) -> tuple[str, Optional[str], List[int]]:
64
+ """Extract and remove [name:...] and [deps:...] metadata from text."""
65
+ name: Optional[str] = None
66
+ deps: List[int] = []
67
+
68
+ m = _RE_META_NAME.search(text)
69
+ if m:
70
+ name = m.group(1).strip()
71
+ text = text[:m.start()] + text[m.end():]
72
+
73
+ m = _RE_META_DEPS.search(text)
74
+ if m:
75
+ raw = m.group(1)
76
+ deps = [int(x.strip()) for x in re.split(r"[,;\s]+", raw) if x.strip().isdigit()]
77
+ text = text[:m.start()] + text[m.end():]
78
+
79
+ return text.strip(), name, deps
80
+
81
+
82
+ # ── Public API ────────────────────────────────────────────────────────────────
83
+
84
+ def parse_plan_steps(raw: str) -> List[str]:
85
+ """
86
+ Parse '/plan' argument string into a list of plain step description strings.
87
+
88
+ This is the backwards-compatible API used by aria_cli.py.
89
+
90
+ Returns a list of non-empty step strings, in order.
91
+ """
92
+ return [s.description for s in parse_plan(raw)]
93
+
94
+
95
+ def parse_plan(raw: str) -> List[PlanStep]:
96
+ """
97
+ Full parser — returns a list of PlanStep objects with index, description,
98
+ optional name, and dependency list.
99
+
100
+ Handles mixed input styles (numbered, bulleted, arrow/semicolon chained).
101
+ """
102
+ if not raw or not raw.strip():
103
+ return []
104
+
105
+ text = raw.strip()
106
+
107
+ # ── Strategy 1: multiline numbered or bulleted steps ─────────────────────
108
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
109
+ if len(lines) >= 2:
110
+ steps = _parse_lines(lines)
111
+ if steps:
112
+ return steps
113
+
114
+ # ── Strategy 2: inline arrow chain fetch quote -> chart -> report ───────
115
+ # Also handles semicolons within arrow-separated parts:
116
+ # "git status -> rg TODO . ; pytest -q" → 3 steps
117
+ if "->" in text:
118
+ arrow_parts = [p.strip() for p in text.replace("→", "->").split("->") if p.strip()]
119
+ parts: List[str] = []
120
+ for ap in arrow_parts:
121
+ if ";" in ap:
122
+ parts.extend(p.strip() for p in ap.split(";") if p.strip())
123
+ else:
124
+ parts.append(ap)
125
+ return [PlanStep(index=i + 1, description=p) for i, p in enumerate(parts)]
126
+
127
+ # ── Strategy 3: semicolon-separated steps ────────────────────────────────
128
+ if ";" in text:
129
+ parts = [p.strip() for p in text.split(";") if p.strip()]
130
+ return [PlanStep(index=i + 1, description=p) for i, p in enumerate(parts)]
131
+
132
+ # ── Strategy 4: single step ───────────────────────────────────────────────
133
+ desc, name, deps = _strip_meta(text)
134
+ return [PlanStep(index=1, description=desc, name=name, deps=deps)] if desc else []
135
+
136
+
137
+ def _parse_lines(lines: List[str]) -> List[PlanStep]:
138
+ """Try to extract ordered steps from a list of text lines."""
139
+ steps: List[PlanStep] = []
140
+ expected_idx = 1
141
+
142
+ for line in lines:
143
+ # Try numbered pattern
144
+ m = _RE_NUMBERED.match(line)
145
+ if m:
146
+ idx = int(m.group(1))
147
+ desc_raw = m.group(2).strip()
148
+ desc, name, deps = _strip_meta(desc_raw)
149
+ if desc:
150
+ steps.append(PlanStep(index=idx, description=desc, name=name, deps=deps))
151
+ expected_idx = idx + 1
152
+ continue
153
+
154
+ # Try bullet pattern
155
+ m = _RE_BULLET.match(line)
156
+ if m:
157
+ desc_raw = m.group(1).strip()
158
+ desc, name, deps = _strip_meta(desc_raw)
159
+ if desc:
160
+ steps.append(PlanStep(index=expected_idx, description=desc, name=name, deps=deps))
161
+ expected_idx += 1
162
+ continue
163
+
164
+ return steps
165
+
166
+
167
+ # ── Formatting helpers ────────────────────────────────────────────────────────
168
+
169
+ def format_plan(steps: List[PlanStep], title: str = "Plan") -> str:
170
+ """Format a plan for terminal display."""
171
+ if not steps:
172
+ return f"[{title}] (empty)"
173
+ lines = [f"── {title} ({len(steps)} steps) ──"]
174
+ for s in steps:
175
+ dep_str = f" (after {', '.join(str(d) for d in s.deps)})" if s.deps else ""
176
+ label = f" [{s.name}]" if s.name else ""
177
+ lines.append(f" {s.index}.{label} {s.description}{dep_str}")
178
+ return "\n".join(lines)
179
+
180
+
181
+ def steps_to_prompt(steps: List[PlanStep], context: str = "") -> str:
182
+ """
183
+ Convert a list of PlanStep objects to a structured prompt string
184
+ that the AI can execute step-by-step.
185
+ """
186
+ intro = f"{context}\n\n" if context else ""
187
+ numbered = "\n".join(f"{s.index}. {s.description}" for s in steps)
188
+ return (
189
+ f"{intro}"
190
+ f"Execute the following plan steps in order:\n\n"
191
+ f"{numbered}\n\n"
192
+ "Complete each step fully before moving to the next. "
193
+ "After all steps, provide a brief summary of what was accomplished."
194
+ )
plugin_loader.py ADDED
@@ -0,0 +1,328 @@
1
+ """
2
+ plugin_loader.py — Auto-discovery of custom tool plugins for Aria Code.
3
+
4
+ Scans the current directory and its parents for ``aria_tools.py`` files and
5
+ loads any tools they export. This lets project-specific tools appear
6
+ automatically in the model's tool loop without modifying aria_cli.py.
7
+
8
+ Plugin contract (aria_tools.py)::
9
+
10
+ # Minimal example
11
+ def get_my_tools():
12
+ return [
13
+ {
14
+ "name": "my_custom_tool",
15
+ "description": "Does something useful for this project",
16
+ "parameters": {
17
+ "type": "object",
18
+ "properties": {
19
+ "input": {"type": "string", "description": "Input data"},
20
+ },
21
+ "required": ["input"],
22
+ },
23
+ "handler": lambda params: {"result": params["input"].upper()},
24
+ }
25
+ ]
26
+
27
+ # Extended example with finance tools
28
+ import akshare as ak
29
+
30
+ def get_my_tools():
31
+ def fetch_my_positions(params):
32
+ # read from broker API / local file
33
+ return {"positions": [...]}
34
+
35
+ return [
36
+ {
37
+ "name": "get_my_positions",
38
+ "description": "Return current portfolio positions from local CSV",
39
+ "parameters": {"type": "object", "properties": {}, "required": []},
40
+ "handler": fetch_my_positions,
41
+ },
42
+ ]
43
+
44
+ Discovery order
45
+ ---------------
46
+ 1. ``./aria_tools.py`` (current working directory)
47
+ 2. ``../aria_tools.py`` (one level up)
48
+ 3. …up to $HOME
49
+
50
+ The first file found is used. Set ``ARIA_TOOLS_PATH`` env var to override.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import importlib.util
56
+ import logging
57
+ import os
58
+ import pathlib
59
+ import sys
60
+ import traceback
61
+ from typing import Any, Callable, Dict, List, Optional, Tuple
62
+
63
+ logger = logging.getLogger(__name__)
64
+
65
+ # Env override
66
+ ARIA_TOOLS_ENV = "ARIA_TOOLS_PATH"
67
+ PLUGIN_FILENAME = "aria_tools.py"
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Discovery
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def find_plugin_file(start_dir: Optional[str] = None) -> Optional[pathlib.Path]:
75
+ """
76
+ Walk upward from *start_dir* looking for aria_tools.py.
77
+ Returns the first match, or None.
78
+ """
79
+ # Env var override takes priority
80
+ env_path = os.getenv(ARIA_TOOLS_ENV)
81
+ if env_path:
82
+ p = pathlib.Path(env_path).expanduser().resolve()
83
+ if p.exists():
84
+ return p
85
+
86
+ home = pathlib.Path.home()
87
+ current = pathlib.Path(start_dir or os.getcwd()).resolve()
88
+
89
+ while True:
90
+ candidate = current / PLUGIN_FILENAME
91
+ if candidate.exists() and candidate.is_file():
92
+ return candidate
93
+ if current == home or current.parent == current:
94
+ break
95
+ current = current.parent
96
+ return None
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Loader
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def load_plugin(plugin_path: pathlib.Path) -> List[Dict[str, Any]]:
104
+ """
105
+ Import *plugin_path* as a module and call its ``get_my_tools()`` function.
106
+
107
+ Returns list of tool dicts with keys:
108
+ name, description, parameters (JSON Schema), handler (callable)
109
+ """
110
+ try:
111
+ spec = importlib.util.spec_from_file_location("aria_tools_plugin", plugin_path)
112
+ module = importlib.util.module_from_spec(spec)
113
+ # Add plugin directory to sys.path so it can import local modules
114
+ plugin_dir = str(plugin_path.parent)
115
+ if plugin_dir not in sys.path:
116
+ sys.path.insert(0, plugin_dir)
117
+ spec.loader.exec_module(module)
118
+ except Exception as exc:
119
+ logger.warning("Plugin load error (%s): %s", plugin_path, exc)
120
+ logger.debug(traceback.format_exc())
121
+ return []
122
+
123
+ # Try standard function name first, then fallback names
124
+ for fn_name in ("get_my_tools", "get_tools", "register_tools", "tools"):
125
+ fn = getattr(module, fn_name, None)
126
+ if callable(fn):
127
+ try:
128
+ tools = fn()
129
+ if isinstance(tools, list):
130
+ logger.info("Plugin %s: loaded %d tools via %s()", plugin_path.name, len(tools), fn_name)
131
+ return _validate_tools(tools, plugin_path)
132
+ except Exception as exc:
133
+ logger.warning("Plugin %s: %s() raised: %s", plugin_path.name, fn_name, exc)
134
+ continue
135
+ # Also support module-level TOOLS list
136
+ if fn_name == "tools" and isinstance(fn, list):
137
+ return _validate_tools(fn, plugin_path)
138
+
139
+ logger.warning("Plugin %s: no get_my_tools() / get_tools() function found", plugin_path.name)
140
+ return []
141
+
142
+
143
+ def _validate_tools(raw: List[Any], plugin_path: pathlib.Path) -> List[Dict[str, Any]]:
144
+ """Validate and normalise plugin tool definitions."""
145
+ valid = []
146
+ for item in raw:
147
+ if not isinstance(item, dict):
148
+ continue
149
+ name = item.get("name", "").strip()
150
+ desc = item.get("description", "")
151
+ handler = item.get("handler") or item.get("fn") or item.get("function")
152
+ params = item.get("parameters") or item.get("params") or {
153
+ "type": "object", "properties": {}, "required": []
154
+ }
155
+ if not name:
156
+ logger.debug("Plugin tool missing 'name', skipping")
157
+ continue
158
+ if not callable(handler):
159
+ logger.debug("Plugin tool %r has no callable handler, skipping", name)
160
+ continue
161
+ valid.append({
162
+ "name": name,
163
+ "description": desc,
164
+ "parameters": params,
165
+ "handler": handler,
166
+ "source": str(plugin_path),
167
+ })
168
+ return valid
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Registration
173
+ # ---------------------------------------------------------------------------
174
+
175
+ def register_plugin_tools(
176
+ tool_registry: Dict,
177
+ schema_registry: List,
178
+ start_dir: Optional[str] = None,
179
+ overwrite: bool = False,
180
+ ) -> Tuple[int, Optional[pathlib.Path]]:
181
+ """
182
+ Auto-discover and register plugin tools.
183
+
184
+ Returns (count_registered, plugin_path_or_None).
185
+ """
186
+ plugin_path = find_plugin_file(start_dir)
187
+ if plugin_path is None:
188
+ return 0, None
189
+
190
+ tools = load_plugin(plugin_path)
191
+ if not tools:
192
+ return 0, plugin_path
193
+
194
+ added = 0
195
+ existing_names = set(tool_registry.keys())
196
+ existing_schema_names = {
197
+ s.get("function", {}).get("name") for s in schema_registry
198
+ }
199
+
200
+ for tool in tools:
201
+ name = tool["name"]
202
+ handler = tool["handler"]
203
+ desc = tool["description"]
204
+ params = tool["parameters"]
205
+
206
+ if name in existing_names and not overwrite:
207
+ logger.debug("Plugin tool %r skipped (already registered)", name)
208
+ continue
209
+
210
+ # Wrap handler with error guard
211
+ def _safe_handler(p: dict, h: Callable = handler) -> dict:
212
+ try:
213
+ result = h(p)
214
+ if not isinstance(result, dict):
215
+ result = {"result": result}
216
+ return result
217
+ except Exception as exc:
218
+ return {"success": False, "error": str(exc), "source": "plugin"}
219
+
220
+ tool_registry[name] = (_safe_handler, desc)
221
+ added += 1
222
+
223
+ if name not in existing_schema_names:
224
+ schema_registry.append({
225
+ "type": "function",
226
+ "function": {
227
+ "name": name,
228
+ "description": desc,
229
+ "parameters": params,
230
+ },
231
+ })
232
+
233
+ if added:
234
+ logger.info("Plugin %s: registered %d tools", plugin_path.name, added)
235
+
236
+ return added, plugin_path
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Hot-reload support
241
+ # ---------------------------------------------------------------------------
242
+
243
+ class PluginWatcher:
244
+ """
245
+ Watch the plugin file for changes and reload tools automatically.
246
+ Uses simple mtime polling (no watchdog dependency needed).
247
+ """
248
+
249
+ def __init__(
250
+ self,
251
+ tool_registry: Dict,
252
+ schema_registry: List,
253
+ start_dir: Optional[str] = None,
254
+ poll_interval: float = 3.0,
255
+ ):
256
+ self._tool_registry = tool_registry
257
+ self._schema_registry = schema_registry
258
+ self._start_dir = start_dir
259
+ self._poll_interval = poll_interval
260
+ self._plugin_path: Optional[pathlib.Path] = None
261
+ self._last_mtime: float = 0.0
262
+ self._task: Optional[Any] = None # asyncio.Task
263
+ self._plugin_tool_names: List[str] = []
264
+
265
+ async def start(self):
266
+ """Start the background polling task."""
267
+ import asyncio
268
+ # Initial load
269
+ n, path = register_plugin_tools(
270
+ self._tool_registry, self._schema_registry, self._start_dir
271
+ )
272
+ if path:
273
+ self._plugin_path = path
274
+ self._last_mtime = path.stat().st_mtime
275
+ self._plugin_tool_names = [
276
+ t["name"] for t in load_plugin(path)
277
+ ]
278
+ if n:
279
+ logger.info("PluginWatcher: initial load %d tools from %s", n, path.name)
280
+
281
+ self._task = asyncio.create_task(self._watch_loop())
282
+
283
+ async def _watch_loop(self):
284
+ import asyncio
285
+ while True:
286
+ await asyncio.sleep(self._poll_interval)
287
+ if self._plugin_path is None:
288
+ # Try to find newly created plugin
289
+ found = find_plugin_file(self._start_dir)
290
+ if found:
291
+ self._plugin_path = found
292
+ self._last_mtime = 0.0
293
+ if self._plugin_path and self._plugin_path.exists():
294
+ mtime = self._plugin_path.stat().st_mtime
295
+ if mtime != self._last_mtime:
296
+ self._last_mtime = mtime
297
+ await self._reload()
298
+
299
+ async def _reload(self):
300
+ if not self._plugin_path:
301
+ return
302
+ # Remove previously registered plugin tools
303
+ for name in self._plugin_tool_names:
304
+ self._tool_registry.pop(name, None)
305
+ # Remove from schema registry
306
+ self._schema_registry[:] = [
307
+ s for s in self._schema_registry
308
+ if s.get("function", {}).get("name") not in self._plugin_tool_names
309
+ ]
310
+ # Re-register
311
+ n, _ = register_plugin_tools(
312
+ self._tool_registry, self._schema_registry,
313
+ str(self._plugin_path.parent), overwrite=True
314
+ )
315
+ self._plugin_tool_names = [
316
+ t["name"] for t in load_plugin(self._plugin_path)
317
+ ]
318
+ logger.info("PluginWatcher: hot-reloaded %d tools from %s", n, self._plugin_path.name)
319
+
320
+ async def stop(self):
321
+ if self._task:
322
+ self._task.cancel()
323
+ try:
324
+ import asyncio
325
+ await asyncio.wait_for(self._task, timeout=1.0)
326
+ except Exception:
327
+ pass
328
+ self._task = None