vanna 0.7.9__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. vanna/__init__.py +167 -395
  2. vanna/agents/__init__.py +7 -0
  3. vanna/capabilities/__init__.py +17 -0
  4. vanna/capabilities/agent_memory/__init__.py +21 -0
  5. vanna/capabilities/agent_memory/base.py +103 -0
  6. vanna/capabilities/agent_memory/models.py +53 -0
  7. vanna/capabilities/file_system/__init__.py +14 -0
  8. vanna/capabilities/file_system/base.py +71 -0
  9. vanna/capabilities/file_system/models.py +25 -0
  10. vanna/capabilities/sql_runner/__init__.py +13 -0
  11. vanna/capabilities/sql_runner/base.py +37 -0
  12. vanna/capabilities/sql_runner/models.py +13 -0
  13. vanna/components/__init__.py +92 -0
  14. vanna/components/base.py +11 -0
  15. vanna/components/rich/__init__.py +83 -0
  16. vanna/components/rich/containers/__init__.py +7 -0
  17. vanna/components/rich/containers/card.py +20 -0
  18. vanna/components/rich/data/__init__.py +9 -0
  19. vanna/components/rich/data/chart.py +17 -0
  20. vanna/components/rich/data/dataframe.py +93 -0
  21. vanna/components/rich/feedback/__init__.py +21 -0
  22. vanna/components/rich/feedback/badge.py +16 -0
  23. vanna/components/rich/feedback/icon_text.py +14 -0
  24. vanna/components/rich/feedback/log_viewer.py +41 -0
  25. vanna/components/rich/feedback/notification.py +19 -0
  26. vanna/components/rich/feedback/progress.py +37 -0
  27. vanna/components/rich/feedback/status_card.py +28 -0
  28. vanna/components/rich/feedback/status_indicator.py +14 -0
  29. vanna/components/rich/interactive/__init__.py +21 -0
  30. vanna/components/rich/interactive/button.py +95 -0
  31. vanna/components/rich/interactive/task_list.py +58 -0
  32. vanna/components/rich/interactive/ui_state.py +93 -0
  33. vanna/components/rich/specialized/__init__.py +7 -0
  34. vanna/components/rich/specialized/artifact.py +20 -0
  35. vanna/components/rich/text.py +16 -0
  36. vanna/components/simple/__init__.py +15 -0
  37. vanna/components/simple/image.py +15 -0
  38. vanna/components/simple/link.py +15 -0
  39. vanna/components/simple/text.py +11 -0
  40. vanna/core/__init__.py +193 -0
  41. vanna/core/_compat.py +19 -0
  42. vanna/core/agent/__init__.py +10 -0
  43. vanna/core/agent/agent.py +1407 -0
  44. vanna/core/agent/config.py +123 -0
  45. vanna/core/audit/__init__.py +28 -0
  46. vanna/core/audit/base.py +299 -0
  47. vanna/core/audit/models.py +131 -0
  48. vanna/core/component_manager.py +329 -0
  49. vanna/core/components.py +53 -0
  50. vanna/core/enhancer/__init__.py +11 -0
  51. vanna/core/enhancer/base.py +94 -0
  52. vanna/core/enhancer/default.py +118 -0
  53. vanna/core/enricher/__init__.py +10 -0
  54. vanna/core/enricher/base.py +59 -0
  55. vanna/core/errors.py +47 -0
  56. vanna/core/evaluation/__init__.py +81 -0
  57. vanna/core/evaluation/base.py +186 -0
  58. vanna/core/evaluation/dataset.py +254 -0
  59. vanna/core/evaluation/evaluators.py +376 -0
  60. vanna/core/evaluation/report.py +289 -0
  61. vanna/core/evaluation/runner.py +313 -0
  62. vanna/core/filter/__init__.py +10 -0
  63. vanna/core/filter/base.py +67 -0
  64. vanna/core/lifecycle/__init__.py +10 -0
  65. vanna/core/lifecycle/base.py +83 -0
  66. vanna/core/llm/__init__.py +16 -0
  67. vanna/core/llm/base.py +40 -0
  68. vanna/core/llm/models.py +61 -0
  69. vanna/core/middleware/__init__.py +10 -0
  70. vanna/core/middleware/base.py +69 -0
  71. vanna/core/observability/__init__.py +11 -0
  72. vanna/core/observability/base.py +88 -0
  73. vanna/core/observability/models.py +47 -0
  74. vanna/core/recovery/__init__.py +11 -0
  75. vanna/core/recovery/base.py +84 -0
  76. vanna/core/recovery/models.py +32 -0
  77. vanna/core/registry.py +278 -0
  78. vanna/core/rich_component.py +156 -0
  79. vanna/core/simple_component.py +27 -0
  80. vanna/core/storage/__init__.py +14 -0
  81. vanna/core/storage/base.py +46 -0
  82. vanna/core/storage/models.py +46 -0
  83. vanna/core/system_prompt/__init__.py +13 -0
  84. vanna/core/system_prompt/base.py +36 -0
  85. vanna/core/system_prompt/default.py +157 -0
  86. vanna/core/tool/__init__.py +18 -0
  87. vanna/core/tool/base.py +70 -0
  88. vanna/core/tool/models.py +84 -0
  89. vanna/core/user/__init__.py +17 -0
  90. vanna/core/user/base.py +29 -0
  91. vanna/core/user/models.py +25 -0
  92. vanna/core/user/request_context.py +70 -0
  93. vanna/core/user/resolver.py +42 -0
  94. vanna/core/validation.py +164 -0
  95. vanna/core/workflow/__init__.py +12 -0
  96. vanna/core/workflow/base.py +254 -0
  97. vanna/core/workflow/default.py +789 -0
  98. vanna/examples/__init__.py +1 -0
  99. vanna/examples/__main__.py +44 -0
  100. vanna/examples/anthropic_quickstart.py +80 -0
  101. vanna/examples/artifact_example.py +293 -0
  102. vanna/examples/claude_sqlite_example.py +236 -0
  103. vanna/examples/coding_agent_example.py +300 -0
  104. vanna/examples/custom_system_prompt_example.py +174 -0
  105. vanna/examples/default_workflow_handler_example.py +208 -0
  106. vanna/examples/email_auth_example.py +340 -0
  107. vanna/examples/evaluation_example.py +269 -0
  108. vanna/examples/extensibility_example.py +262 -0
  109. vanna/examples/minimal_example.py +67 -0
  110. vanna/examples/mock_auth_example.py +227 -0
  111. vanna/examples/mock_custom_tool.py +311 -0
  112. vanna/examples/mock_quickstart.py +79 -0
  113. vanna/examples/mock_quota_example.py +145 -0
  114. vanna/examples/mock_rich_components_demo.py +396 -0
  115. vanna/examples/mock_sqlite_example.py +223 -0
  116. vanna/examples/openai_quickstart.py +83 -0
  117. vanna/examples/primitive_components_demo.py +305 -0
  118. vanna/examples/quota_lifecycle_example.py +139 -0
  119. vanna/examples/visualization_example.py +251 -0
  120. vanna/integrations/__init__.py +17 -0
  121. vanna/integrations/anthropic/__init__.py +9 -0
  122. vanna/integrations/anthropic/llm.py +270 -0
  123. vanna/integrations/azureopenai/__init__.py +9 -0
  124. vanna/integrations/azureopenai/llm.py +329 -0
  125. vanna/integrations/azuresearch/__init__.py +7 -0
  126. vanna/integrations/azuresearch/agent_memory.py +413 -0
  127. vanna/integrations/bigquery/__init__.py +5 -0
  128. vanna/integrations/bigquery/sql_runner.py +81 -0
  129. vanna/integrations/chromadb/__init__.py +104 -0
  130. vanna/integrations/chromadb/agent_memory.py +416 -0
  131. vanna/integrations/clickhouse/__init__.py +5 -0
  132. vanna/integrations/clickhouse/sql_runner.py +82 -0
  133. vanna/integrations/duckdb/__init__.py +5 -0
  134. vanna/integrations/duckdb/sql_runner.py +65 -0
  135. vanna/integrations/faiss/__init__.py +7 -0
  136. vanna/integrations/faiss/agent_memory.py +431 -0
  137. vanna/integrations/google/__init__.py +9 -0
  138. vanna/integrations/google/gemini.py +370 -0
  139. vanna/integrations/hive/__init__.py +5 -0
  140. vanna/integrations/hive/sql_runner.py +87 -0
  141. vanna/integrations/local/__init__.py +17 -0
  142. vanna/integrations/local/agent_memory/__init__.py +7 -0
  143. vanna/integrations/local/agent_memory/in_memory.py +285 -0
  144. vanna/integrations/local/audit.py +59 -0
  145. vanna/integrations/local/file_system.py +242 -0
  146. vanna/integrations/local/file_system_conversation_store.py +255 -0
  147. vanna/integrations/local/storage.py +62 -0
  148. vanna/integrations/marqo/__init__.py +7 -0
  149. vanna/integrations/marqo/agent_memory.py +354 -0
  150. vanna/integrations/milvus/__init__.py +7 -0
  151. vanna/integrations/milvus/agent_memory.py +458 -0
  152. vanna/integrations/mock/__init__.py +9 -0
  153. vanna/integrations/mock/llm.py +65 -0
  154. vanna/integrations/mssql/__init__.py +5 -0
  155. vanna/integrations/mssql/sql_runner.py +66 -0
  156. vanna/integrations/mysql/__init__.py +5 -0
  157. vanna/integrations/mysql/sql_runner.py +92 -0
  158. vanna/integrations/ollama/__init__.py +7 -0
  159. vanna/integrations/ollama/llm.py +252 -0
  160. vanna/integrations/openai/__init__.py +10 -0
  161. vanna/integrations/openai/llm.py +267 -0
  162. vanna/integrations/openai/responses.py +163 -0
  163. vanna/integrations/opensearch/__init__.py +7 -0
  164. vanna/integrations/opensearch/agent_memory.py +411 -0
  165. vanna/integrations/oracle/__init__.py +5 -0
  166. vanna/integrations/oracle/sql_runner.py +75 -0
  167. vanna/integrations/pinecone/__init__.py +7 -0
  168. vanna/integrations/pinecone/agent_memory.py +329 -0
  169. vanna/integrations/plotly/__init__.py +5 -0
  170. vanna/integrations/plotly/chart_generator.py +313 -0
  171. vanna/integrations/postgres/__init__.py +9 -0
  172. vanna/integrations/postgres/sql_runner.py +112 -0
  173. vanna/integrations/premium/agent_memory/__init__.py +7 -0
  174. vanna/integrations/premium/agent_memory/premium.py +186 -0
  175. vanna/integrations/presto/__init__.py +5 -0
  176. vanna/integrations/presto/sql_runner.py +107 -0
  177. vanna/integrations/qdrant/__init__.py +7 -0
  178. vanna/integrations/qdrant/agent_memory.py +461 -0
  179. vanna/integrations/snowflake/__init__.py +5 -0
  180. vanna/integrations/snowflake/sql_runner.py +147 -0
  181. vanna/integrations/sqlite/__init__.py +9 -0
  182. vanna/integrations/sqlite/sql_runner.py +65 -0
  183. vanna/integrations/weaviate/__init__.py +7 -0
  184. vanna/integrations/weaviate/agent_memory.py +428 -0
  185. vanna/{ZhipuAI → legacy/ZhipuAI}/ZhipuAI_embeddings.py +11 -11
  186. vanna/legacy/__init__.py +403 -0
  187. vanna/legacy/adapter.py +463 -0
  188. vanna/{advanced → legacy/advanced}/__init__.py +3 -1
  189. vanna/{anthropic → legacy/anthropic}/anthropic_chat.py +9 -7
  190. vanna/{azuresearch → legacy/azuresearch}/azuresearch_vector.py +79 -41
  191. vanna/{base → legacy/base}/base.py +224 -217
  192. vanna/legacy/bedrock/__init__.py +1 -0
  193. vanna/{bedrock → legacy/bedrock}/bedrock_converse.py +13 -12
  194. vanna/{chromadb → legacy/chromadb}/chromadb_vector.py +3 -1
  195. vanna/legacy/cohere/__init__.py +2 -0
  196. vanna/{cohere → legacy/cohere}/cohere_chat.py +19 -14
  197. vanna/{cohere → legacy/cohere}/cohere_embeddings.py +25 -19
  198. vanna/{deepseek → legacy/deepseek}/deepseek_chat.py +5 -6
  199. vanna/legacy/faiss/__init__.py +1 -0
  200. vanna/{faiss → legacy/faiss}/faiss.py +113 -59
  201. vanna/{flask → legacy/flask}/__init__.py +84 -43
  202. vanna/{flask → legacy/flask}/assets.py +5 -5
  203. vanna/{flask → legacy/flask}/auth.py +5 -4
  204. vanna/{google → legacy/google}/bigquery_vector.py +75 -42
  205. vanna/{google → legacy/google}/gemini_chat.py +7 -3
  206. vanna/{hf → legacy/hf}/hf.py +0 -1
  207. vanna/{milvus → legacy/milvus}/milvus_vector.py +58 -35
  208. vanna/{mock → legacy/mock}/llm.py +0 -1
  209. vanna/legacy/mock/vectordb.py +67 -0
  210. vanna/legacy/ollama/ollama.py +110 -0
  211. vanna/{openai → legacy/openai}/openai_chat.py +2 -6
  212. vanna/legacy/opensearch/opensearch_vector.py +369 -0
  213. vanna/legacy/opensearch/opensearch_vector_semantic.py +200 -0
  214. vanna/legacy/oracle/oracle_vector.py +584 -0
  215. vanna/{pgvector → legacy/pgvector}/pgvector.py +42 -13
  216. vanna/{qdrant → legacy/qdrant}/qdrant.py +2 -6
  217. vanna/legacy/qianfan/Qianfan_Chat.py +170 -0
  218. vanna/legacy/qianfan/Qianfan_embeddings.py +36 -0
  219. vanna/legacy/qianwen/QianwenAI_chat.py +132 -0
  220. vanna/{remote.py → legacy/remote.py} +28 -26
  221. vanna/{utils.py → legacy/utils.py} +6 -11
  222. vanna/{vannadb → legacy/vannadb}/vannadb_vector.py +115 -46
  223. vanna/{vllm → legacy/vllm}/vllm.py +5 -6
  224. vanna/{weaviate → legacy/weaviate}/weaviate_vector.py +59 -40
  225. vanna/{xinference → legacy/xinference}/xinference.py +6 -6
  226. vanna/py.typed +0 -0
  227. vanna/servers/__init__.py +16 -0
  228. vanna/servers/__main__.py +8 -0
  229. vanna/servers/base/__init__.py +18 -0
  230. vanna/servers/base/chat_handler.py +65 -0
  231. vanna/servers/base/models.py +111 -0
  232. vanna/servers/base/rich_chat_handler.py +141 -0
  233. vanna/servers/base/templates.py +331 -0
  234. vanna/servers/cli/__init__.py +7 -0
  235. vanna/servers/cli/server_runner.py +204 -0
  236. vanna/servers/fastapi/__init__.py +7 -0
  237. vanna/servers/fastapi/app.py +163 -0
  238. vanna/servers/fastapi/routes.py +183 -0
  239. vanna/servers/flask/__init__.py +7 -0
  240. vanna/servers/flask/app.py +132 -0
  241. vanna/servers/flask/routes.py +137 -0
  242. vanna/tools/__init__.py +41 -0
  243. vanna/tools/agent_memory.py +322 -0
  244. vanna/tools/file_system.py +879 -0
  245. vanna/tools/python.py +222 -0
  246. vanna/tools/run_sql.py +165 -0
  247. vanna/tools/visualize_data.py +195 -0
  248. vanna/utils/__init__.py +0 -0
  249. vanna/web_components/__init__.py +44 -0
  250. vanna-2.0.0.dist-info/METADATA +485 -0
  251. vanna-2.0.0.dist-info/RECORD +289 -0
  252. vanna-2.0.0.dist-info/entry_points.txt +3 -0
  253. vanna/bedrock/__init__.py +0 -1
  254. vanna/cohere/__init__.py +0 -2
  255. vanna/faiss/__init__.py +0 -1
  256. vanna/mock/vectordb.py +0 -55
  257. vanna/ollama/ollama.py +0 -103
  258. vanna/opensearch/opensearch_vector.py +0 -392
  259. vanna/opensearch/opensearch_vector_semantic.py +0 -175
  260. vanna/oracle/oracle_vector.py +0 -585
  261. vanna/qianfan/Qianfan_Chat.py +0 -165
  262. vanna/qianfan/Qianfan_embeddings.py +0 -36
  263. vanna/qianwen/QianwenAI_chat.py +0 -133
  264. vanna-0.7.9.dist-info/METADATA +0 -408
  265. vanna-0.7.9.dist-info/RECORD +0 -79
  266. /vanna/{ZhipuAI → legacy/ZhipuAI}/ZhipuAI_Chat.py +0 -0
  267. /vanna/{ZhipuAI → legacy/ZhipuAI}/__init__.py +0 -0
  268. /vanna/{anthropic → legacy/anthropic}/__init__.py +0 -0
  269. /vanna/{azuresearch → legacy/azuresearch}/__init__.py +0 -0
  270. /vanna/{base → legacy/base}/__init__.py +0 -0
  271. /vanna/{chromadb → legacy/chromadb}/__init__.py +0 -0
  272. /vanna/{deepseek → legacy/deepseek}/__init__.py +0 -0
  273. /vanna/{exceptions → legacy/exceptions}/__init__.py +0 -0
  274. /vanna/{google → legacy/google}/__init__.py +0 -0
  275. /vanna/{hf → legacy/hf}/__init__.py +0 -0
  276. /vanna/{local.py → legacy/local.py} +0 -0
  277. /vanna/{marqo → legacy/marqo}/__init__.py +0 -0
  278. /vanna/{marqo → legacy/marqo}/marqo.py +0 -0
  279. /vanna/{milvus → legacy/milvus}/__init__.py +0 -0
  280. /vanna/{mistral → legacy/mistral}/__init__.py +0 -0
  281. /vanna/{mistral → legacy/mistral}/mistral.py +0 -0
  282. /vanna/{mock → legacy/mock}/__init__.py +0 -0
  283. /vanna/{mock → legacy/mock}/embedding.py +0 -0
  284. /vanna/{ollama → legacy/ollama}/__init__.py +0 -0
  285. /vanna/{openai → legacy/openai}/__init__.py +0 -0
  286. /vanna/{openai → legacy/openai}/openai_embeddings.py +0 -0
  287. /vanna/{opensearch → legacy/opensearch}/__init__.py +0 -0
  288. /vanna/{oracle → legacy/oracle}/__init__.py +0 -0
  289. /vanna/{pgvector → legacy/pgvector}/__init__.py +0 -0
  290. /vanna/{pinecone → legacy/pinecone}/__init__.py +0 -0
  291. /vanna/{pinecone → legacy/pinecone}/pinecone_vector.py +0 -0
  292. /vanna/{qdrant → legacy/qdrant}/__init__.py +0 -0
  293. /vanna/{qianfan → legacy/qianfan}/__init__.py +0 -0
  294. /vanna/{qianwen → legacy/qianwen}/QianwenAI_embeddings.py +0 -0
  295. /vanna/{qianwen → legacy/qianwen}/__init__.py +0 -0
  296. /vanna/{types → legacy/types}/__init__.py +0 -0
  297. /vanna/{vannadb → legacy/vannadb}/__init__.py +0 -0
  298. /vanna/{vllm → legacy/vllm}/__init__.py +0 -0
  299. /vanna/{weaviate → legacy/weaviate}/__init__.py +0 -0
  300. /vanna/{xinference → legacy/xinference}/__init__.py +0 -0
  301. {vanna-0.7.9.dist-info → vanna-2.0.0.dist-info}/WHEEL +0 -0
  302. {vanna-0.7.9.dist-info → vanna-2.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,879 @@
1
+ """
2
+ File system tools with dependency injection support.
3
+
4
+ This module provides file system operations through an abstract FileSystem interface,
5
+ allowing for different implementations (local, remote, sandboxed, etc.).
6
+ The tools accept a FileSystem instance via dependency injection.
7
+ """
8
+
9
+ import asyncio
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, List, Optional, Type
14
+ import difflib
15
+ import hashlib
16
+
17
+ from pydantic import BaseModel, Field, model_validator
18
+
19
+ from vanna.core.tool import Tool, ToolContext, ToolResult
20
+ from vanna.components import (
21
+ UiComponent,
22
+ CardComponent,
23
+ NotificationComponent,
24
+ ComponentType,
25
+ SimpleTextComponent,
26
+ )
27
+
28
+ MAX_SEARCH_FILE_BYTES = 1_000_000
29
+ FILENAME_MATCH_SNIPPET = "[filename match]"
30
+
31
+
32
+ @dataclass
33
+ class FileSearchMatch:
34
+ """Represents a single search result within a file system."""
35
+
36
+ path: str
37
+ snippet: Optional[str] = None
38
+
39
+
40
+ @dataclass
41
+ class CommandResult:
42
+ """Represents the result of executing a shell command."""
43
+
44
+ stdout: str
45
+ stderr: str
46
+ returncode: int
47
+
48
+
49
+ def _make_snippet(text: str, query: str, context_window: int = 60) -> Optional[str]:
50
+ """Return a short snippet around the first occurrence of query in text."""
51
+
52
+ lowered = text.lower()
53
+ index = lowered.find(query.lower())
54
+ if index == -1:
55
+ return None
56
+
57
+ start = max(0, index - context_window)
58
+ end = min(len(text), index + len(query) + context_window)
59
+ snippet = text[start:end].replace("\n", " ").strip()
60
+
61
+ if start > 0:
62
+ snippet = f"…{snippet}"
63
+ if end < len(text):
64
+ snippet = f"{snippet}…"
65
+
66
+ return snippet
67
+
68
+
69
+ class FileSystem(ABC):
70
+ """Abstract base class for file system operations."""
71
+
72
+ @abstractmethod
73
+ async def list_files(self, directory: str, context: ToolContext) -> List[str]:
74
+ """List files in a directory."""
75
+ pass
76
+
77
+ @abstractmethod
78
+ async def read_file(self, filename: str, context: ToolContext) -> str:
79
+ """Read the contents of a file."""
80
+ pass
81
+
82
+ @abstractmethod
83
+ async def write_file(
84
+ self, filename: str, content: str, context: ToolContext, overwrite: bool = False
85
+ ) -> None:
86
+ """Write content to a file."""
87
+ pass
88
+
89
+ @abstractmethod
90
+ async def exists(self, path: str, context: ToolContext) -> bool:
91
+ """Check if a file or directory exists."""
92
+ pass
93
+
94
+ @abstractmethod
95
+ async def is_directory(self, path: str, context: ToolContext) -> bool:
96
+ """Check if a path is a directory."""
97
+ pass
98
+
99
+ @abstractmethod
100
+ async def search_files(
101
+ self,
102
+ query: str,
103
+ context: ToolContext,
104
+ *,
105
+ max_results: int = 20,
106
+ include_content: bool = False,
107
+ ) -> List[FileSearchMatch]:
108
+ """Search for files matching a query within the accessible namespace."""
109
+ pass
110
+
111
+ @abstractmethod
112
+ async def run_bash(
113
+ self,
114
+ command: str,
115
+ context: ToolContext,
116
+ *,
117
+ timeout: Optional[float] = None,
118
+ ) -> CommandResult:
119
+ """Execute a bash command within the accessible namespace."""
120
+ pass
121
+
122
+
123
+ class LocalFileSystem(FileSystem):
124
+ """Local file system implementation with per-user isolation."""
125
+
126
+ def __init__(self, working_directory: str = "."):
127
+ """Initialize with a working directory.
128
+
129
+ Args:
130
+ working_directory: Base directory where user-specific folders will be created
131
+ """
132
+ self.working_directory = Path(working_directory)
133
+
134
+ def _get_user_directory(self, context: ToolContext) -> Path:
135
+ """Get the user-specific directory by hashing the user ID.
136
+
137
+ Args:
138
+ context: Tool context containing user information
139
+
140
+ Returns:
141
+ Path to the user-specific directory
142
+ """
143
+ # Hash the user ID to create a directory name
144
+ user_hash = hashlib.sha256(context.user.id.encode()).hexdigest()[:16]
145
+ user_dir = self.working_directory / user_hash
146
+
147
+ # Create the directory if it doesn't exist
148
+ user_dir.mkdir(parents=True, exist_ok=True)
149
+
150
+ return user_dir
151
+
152
+ def _resolve_path(self, path: str, context: ToolContext) -> Path:
153
+ """Resolve a path relative to the user's directory.
154
+
155
+ Args:
156
+ path: Path relative to user directory
157
+ context: Tool context containing user information
158
+
159
+ Returns:
160
+ Absolute path within user's directory
161
+ """
162
+ user_dir = self._get_user_directory(context)
163
+ resolved = user_dir / path
164
+
165
+ # Ensure the path is within the user's directory (prevent directory traversal)
166
+ try:
167
+ resolved.resolve().relative_to(user_dir.resolve())
168
+ except ValueError:
169
+ raise PermissionError(
170
+ f"Access denied: path '{path}' is outside user directory"
171
+ )
172
+
173
+ return resolved
174
+
175
+ async def list_files(self, directory: str, context: ToolContext) -> List[str]:
176
+ """List files in a directory within the user's isolated space."""
177
+ directory_path = self._resolve_path(directory, context)
178
+
179
+ if not directory_path.exists():
180
+ raise FileNotFoundError(f"Directory '{directory}' does not exist")
181
+
182
+ if not directory_path.is_dir():
183
+ raise NotADirectoryError(f"'{directory}' is not a directory")
184
+
185
+ files = []
186
+ for item in directory_path.iterdir():
187
+ if item.is_file():
188
+ files.append(item.name)
189
+
190
+ return sorted(files)
191
+
192
+ async def read_file(self, filename: str, context: ToolContext) -> str:
193
+ """Read the contents of a file within the user's isolated space."""
194
+ file_path = self._resolve_path(filename, context)
195
+
196
+ if not file_path.exists():
197
+ raise FileNotFoundError(f"File '{filename}' does not exist")
198
+
199
+ if not file_path.is_file():
200
+ raise IsADirectoryError(f"'{filename}' is a directory, not a file")
201
+
202
+ return file_path.read_text(encoding="utf-8")
203
+
204
+ async def write_file(
205
+ self, filename: str, content: str, context: ToolContext, overwrite: bool = False
206
+ ) -> None:
207
+ """Write content to a file within the user's isolated space."""
208
+ file_path = self._resolve_path(filename, context)
209
+
210
+ # Create parent directories if they don't exist
211
+ file_path.parent.mkdir(parents=True, exist_ok=True)
212
+
213
+ if file_path.exists() and not overwrite:
214
+ raise FileExistsError(
215
+ f"File '{filename}' already exists. Use overwrite=True to replace it."
216
+ )
217
+
218
+ file_path.write_text(content, encoding="utf-8")
219
+
220
+ async def exists(self, path: str, context: ToolContext) -> bool:
221
+ """Check if a file or directory exists within the user's isolated space."""
222
+ try:
223
+ resolved_path = self._resolve_path(path, context)
224
+ return resolved_path.exists()
225
+ except PermissionError:
226
+ return False
227
+
228
+ async def is_directory(self, path: str, context: ToolContext) -> bool:
229
+ """Check if a path is a directory within the user's isolated space."""
230
+ try:
231
+ resolved_path = self._resolve_path(path, context)
232
+ return resolved_path.exists() and resolved_path.is_dir()
233
+ except PermissionError:
234
+ return False
235
+
236
+ async def search_files(
237
+ self,
238
+ query: str,
239
+ context: ToolContext,
240
+ *,
241
+ max_results: int = 20,
242
+ include_content: bool = False,
243
+ ) -> List[FileSearchMatch]:
244
+ """Search for files within the user's isolated space."""
245
+
246
+ trimmed_query = query.strip()
247
+ if not trimmed_query:
248
+ raise ValueError("Search query must not be empty")
249
+
250
+ user_dir = self._get_user_directory(context)
251
+ matches: List[FileSearchMatch] = []
252
+ query_lower = trimmed_query.lower()
253
+
254
+ for path in user_dir.rglob("*"):
255
+ if len(matches) >= max_results:
256
+ break
257
+
258
+ if not path.is_file():
259
+ continue
260
+
261
+ relative_path = path.relative_to(user_dir).as_posix()
262
+ include_entry = False
263
+ snippet: Optional[str] = None
264
+
265
+ if query_lower in path.name.lower():
266
+ include_entry = True
267
+ snippet = FILENAME_MATCH_SNIPPET
268
+
269
+ content: Optional[str] = None
270
+ if include_content:
271
+ try:
272
+ size = path.stat().st_size
273
+ except OSError:
274
+ if include_entry:
275
+ matches.append(
276
+ FileSearchMatch(path=relative_path, snippet=snippet)
277
+ )
278
+ continue
279
+
280
+ if size <= MAX_SEARCH_FILE_BYTES:
281
+ try:
282
+ content = path.read_text(encoding="utf-8")
283
+ except (UnicodeDecodeError, OSError):
284
+ content = None
285
+ elif not include_entry:
286
+ # Skip oversized files if they do not match by name
287
+ continue
288
+
289
+ if include_content and content is not None:
290
+ if query_lower in content.lower():
291
+ snippet = _make_snippet(content, trimmed_query) or snippet
292
+ include_entry = True
293
+ elif not include_entry:
294
+ continue
295
+
296
+ if include_entry:
297
+ matches.append(FileSearchMatch(path=relative_path, snippet=snippet))
298
+
299
+ return matches
300
+
301
+ async def run_bash(
302
+ self,
303
+ command: str,
304
+ context: ToolContext,
305
+ *,
306
+ timeout: Optional[float] = None,
307
+ ) -> CommandResult:
308
+ """Execute a bash command within the user's isolated space."""
309
+
310
+ if not command.strip():
311
+ raise ValueError("Command must not be empty")
312
+
313
+ user_dir = self._get_user_directory(context)
314
+
315
+ process = await asyncio.create_subprocess_shell(
316
+ command,
317
+ stdout=asyncio.subprocess.PIPE,
318
+ stderr=asyncio.subprocess.PIPE,
319
+ cwd=str(user_dir),
320
+ )
321
+
322
+ try:
323
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
324
+ process.communicate(), timeout=timeout
325
+ )
326
+ except asyncio.TimeoutError as exc:
327
+ process.kill()
328
+ await process.wait()
329
+ raise TimeoutError(f"Command timed out after {timeout} seconds") from exc
330
+
331
+ stdout = stdout_bytes.decode("utf-8", errors="replace")
332
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
333
+
334
+ return CommandResult(
335
+ stdout=stdout, stderr=stderr, returncode=process.returncode or 0
336
+ )
337
+
338
+
339
+ class SearchFilesArgs(BaseModel):
340
+ """Arguments for searching files."""
341
+
342
+ query: str = Field(description="Text to search for in file names or contents")
343
+ include_content: bool = Field(
344
+ default=True,
345
+ description="Whether to search within file contents in addition to file names",
346
+ )
347
+ max_results: int = Field(
348
+ default=20,
349
+ ge=1,
350
+ le=100,
351
+ description="Maximum number of matches to return",
352
+ )
353
+
354
+
355
+ class SearchFilesTool(Tool[SearchFilesArgs]):
356
+ """Tool to search for files using the injected file system implementation."""
357
+
358
+ def __init__(self, file_system: Optional[FileSystem] = None):
359
+ self.file_system = file_system or LocalFileSystem()
360
+
361
+ @property
362
+ def name(self) -> str:
363
+ return "search_files"
364
+
365
+ @property
366
+ def description(self) -> str:
367
+ return "Search for files by name or content"
368
+
369
+ def get_args_schema(self) -> Type[SearchFilesArgs]:
370
+ return SearchFilesArgs
371
+
372
+ async def execute(self, context: ToolContext, args: SearchFilesArgs) -> ToolResult:
373
+ try:
374
+ matches = await self.file_system.search_files(
375
+ args.query,
376
+ context,
377
+ max_results=args.max_results,
378
+ include_content=args.include_content,
379
+ )
380
+ except Exception as exc:
381
+ error_msg = f"Error searching files: {exc}"
382
+ return ToolResult(
383
+ success=False,
384
+ result_for_llm=error_msg,
385
+ ui_component=UiComponent(
386
+ rich_component=NotificationComponent(
387
+ type=ComponentType.NOTIFICATION,
388
+ level="error",
389
+ message=error_msg,
390
+ ),
391
+ simple_component=SimpleTextComponent(text=error_msg),
392
+ ),
393
+ error=str(exc),
394
+ )
395
+
396
+ if not matches:
397
+ message = f"No matches found for '{args.query}'."
398
+ return ToolResult(
399
+ success=True,
400
+ result_for_llm=message,
401
+ ui_component=UiComponent(
402
+ rich_component=NotificationComponent(
403
+ type=ComponentType.NOTIFICATION,
404
+ level="info",
405
+ message=message,
406
+ ),
407
+ simple_component=SimpleTextComponent(text=message),
408
+ ),
409
+ )
410
+
411
+ lines: List[str] = []
412
+ for match in matches:
413
+ snippet = match.snippet
414
+ if snippet == FILENAME_MATCH_SNIPPET:
415
+ snippet_text = "(matched filename)"
416
+ elif snippet:
417
+ snippet_text = snippet
418
+ else:
419
+ snippet_text = ""
420
+
421
+ if snippet_text and len(snippet_text) > 200:
422
+ snippet_text = f"{snippet_text[:197]}…"
423
+
424
+ if snippet_text:
425
+ lines.append(f"- {match.path}: {snippet_text}")
426
+ else:
427
+ lines.append(f"- {match.path}")
428
+
429
+ summary = f"Found {len(matches)} match(es) for '{args.query}' (max {args.max_results})."
430
+ content = "\n".join(lines)
431
+
432
+ return ToolResult(
433
+ success=True,
434
+ result_for_llm=f"{summary}\n{content}",
435
+ ui_component=UiComponent(
436
+ rich_component=CardComponent(
437
+ type=ComponentType.CARD,
438
+ title=f"Search results for '{args.query}'",
439
+ content=content,
440
+ ),
441
+ simple_component=SimpleTextComponent(text=summary),
442
+ ),
443
+ )
444
+
445
+
446
+ class ListFilesArgs(BaseModel):
447
+ """Arguments for listing files."""
448
+
449
+ directory: str = Field(
450
+ default=".", description="Directory to list (defaults to current)"
451
+ )
452
+
453
+
454
+ class ListFilesTool(Tool[ListFilesArgs]):
455
+ """Tool to list files in a directory using dependency injection for file system access."""
456
+
457
+ def __init__(self, file_system: Optional[FileSystem] = None):
458
+ """Initialize with optional file system dependency."""
459
+ self.file_system = file_system or LocalFileSystem()
460
+
461
+ @property
462
+ def name(self) -> str:
463
+ return "list_files"
464
+
465
+ @property
466
+ def description(self) -> str:
467
+ return "List files in a directory"
468
+
469
+ def get_args_schema(self) -> Type[ListFilesArgs]:
470
+ return ListFilesArgs
471
+
472
+ async def execute(self, context: ToolContext, args: ListFilesArgs) -> ToolResult:
473
+ try:
474
+ files = await self.file_system.list_files(args.directory, context)
475
+
476
+ if not files:
477
+ result = f"No files found in directory '{args.directory}'"
478
+ files_list = "No files found"
479
+ else:
480
+ files_list = "\n".join(f"- {f}" for f in files)
481
+ result = f"Files in '{args.directory}':\n{files_list}"
482
+
483
+ return ToolResult(
484
+ success=True,
485
+ result_for_llm=result,
486
+ ui_component=UiComponent(
487
+ rich_component=CardComponent(
488
+ type=ComponentType.CARD,
489
+ title=f"Files in {args.directory}",
490
+ content=files_list,
491
+ ),
492
+ simple_component=SimpleTextComponent(text=result),
493
+ ),
494
+ )
495
+ except Exception as e:
496
+ error_msg = f"Error listing files: {str(e)}"
497
+ return ToolResult(
498
+ success=False,
499
+ result_for_llm=error_msg,
500
+ ui_component=UiComponent(
501
+ rich_component=NotificationComponent(
502
+ type=ComponentType.NOTIFICATION,
503
+ level="error",
504
+ message=error_msg,
505
+ ),
506
+ simple_component=SimpleTextComponent(text=error_msg),
507
+ ),
508
+ error=str(e),
509
+ )
510
+
511
+
512
+ class ReadFileArgs(BaseModel):
513
+ """Arguments for reading a file."""
514
+
515
+ filename: str = Field(description="Name of the file to read")
516
+
517
+
518
+ class ReadFileTool(Tool[ReadFileArgs]):
519
+ """Tool to read file contents using dependency injection for file system access."""
520
+
521
+ def __init__(self, file_system: Optional[FileSystem] = None):
522
+ """Initialize with optional file system dependency."""
523
+ self.file_system = file_system or LocalFileSystem()
524
+
525
+ @property
526
+ def name(self) -> str:
527
+ return "read_file"
528
+
529
+ @property
530
+ def description(self) -> str:
531
+ return "Read the contents of a file"
532
+
533
+ def get_args_schema(self) -> Type[ReadFileArgs]:
534
+ return ReadFileArgs
535
+
536
+ async def execute(self, context: ToolContext, args: ReadFileArgs) -> ToolResult:
537
+ try:
538
+ content = await self.file_system.read_file(args.filename, context)
539
+ result = f"Content of '{args.filename}':\n\n{content}"
540
+
541
+ return ToolResult(
542
+ success=True,
543
+ result_for_llm=result,
544
+ ui_component=UiComponent(
545
+ rich_component=CardComponent(
546
+ type=ComponentType.CARD,
547
+ title=f"Contents of {args.filename}",
548
+ content=content,
549
+ ),
550
+ simple_component=SimpleTextComponent(
551
+ text=f"File content:\n{content}"
552
+ ),
553
+ ),
554
+ )
555
+ except Exception as e:
556
+ error_msg = f"Error reading file: {str(e)}"
557
+ return ToolResult(
558
+ success=False,
559
+ result_for_llm=error_msg,
560
+ ui_component=UiComponent(
561
+ rich_component=NotificationComponent(
562
+ type=ComponentType.NOTIFICATION,
563
+ level="error",
564
+ message=error_msg,
565
+ ),
566
+ simple_component=SimpleTextComponent(text=error_msg),
567
+ ),
568
+ error=str(e),
569
+ )
570
+
571
+
572
+ class WriteFileArgs(BaseModel):
573
+ """Arguments for writing a file."""
574
+
575
+ filename: str = Field(description="Name of the file to write")
576
+ content: str = Field(description="Content to write to the file")
577
+ overwrite: bool = Field(
578
+ default=False, description="Whether to overwrite existing files"
579
+ )
580
+
581
+
582
+ class WriteFileTool(Tool[WriteFileArgs]):
583
+ """Tool to write content to a file using dependency injection for file system access."""
584
+
585
+ def __init__(self, file_system: Optional[FileSystem] = None):
586
+ """Initialize with optional file system dependency."""
587
+ self.file_system = file_system or LocalFileSystem()
588
+
589
+ @property
590
+ def name(self) -> str:
591
+ return "write_file"
592
+
593
+ @property
594
+ def description(self) -> str:
595
+ return "Write content to a file"
596
+
597
+ def get_args_schema(self) -> Type[WriteFileArgs]:
598
+ return WriteFileArgs
599
+
600
+ async def execute(self, context: ToolContext, args: WriteFileArgs) -> ToolResult:
601
+ try:
602
+ await self.file_system.write_file(
603
+ args.filename, args.content, context, args.overwrite
604
+ )
605
+
606
+ success_msg = f"Successfully wrote {len(args.content)} characters to '{args.filename}'"
607
+
608
+ return ToolResult(
609
+ success=True,
610
+ result_for_llm=success_msg,
611
+ ui_component=UiComponent(
612
+ rich_component=NotificationComponent(
613
+ type=ComponentType.NOTIFICATION,
614
+ level="success",
615
+ message=f"File '{args.filename}' written successfully",
616
+ ),
617
+ simple_component=SimpleTextComponent(
618
+ text=f"Wrote to {args.filename}"
619
+ ),
620
+ ),
621
+ )
622
+ except Exception as e:
623
+ error_msg = f"Error writing file: {str(e)}"
624
+ return ToolResult(
625
+ success=False,
626
+ result_for_llm=error_msg,
627
+ ui_component=UiComponent(
628
+ rich_component=NotificationComponent(
629
+ type=ComponentType.NOTIFICATION,
630
+ level="error",
631
+ message=error_msg,
632
+ ),
633
+ simple_component=SimpleTextComponent(text=error_msg),
634
+ ),
635
+ error=str(e),
636
+ )
637
+
638
+
639
+ class LineEdit(BaseModel):
640
+ """Definition of a single line-based edit operation."""
641
+
642
+ start_line: int = Field(
643
+ ge=1, description="First line (1-based) affected by this edit"
644
+ )
645
+ end_line: Optional[int] = Field(
646
+ default=None,
647
+ description=(
648
+ "Last line (1-based, inclusive) to replace. Set to start_line - 1 to insert before start_line. "
649
+ "Defaults to start_line, replacing a single line."
650
+ ),
651
+ )
652
+ new_content: str = Field(
653
+ default="", description="Replacement text (preserves provided newlines)"
654
+ )
655
+
656
+ @model_validator(mode="after")
657
+ def validate_line_range(self) -> "LineEdit":
658
+ effective_end = self.start_line if self.end_line is None else self.end_line
659
+
660
+ if effective_end < self.start_line - 1:
661
+ raise ValueError("end_line must be >= start_line - 1")
662
+
663
+ return self
664
+
665
+
666
+ class EditFileArgs(BaseModel):
667
+ """Arguments for editing one or more sections within a file."""
668
+
669
+ filename: str = Field(description="Path to the file to edit")
670
+ edits: List[LineEdit] = Field(
671
+ description="List of edits to apply. Later entries should reference higher line numbers.",
672
+ min_length=1,
673
+ )
674
+
675
+
676
+ class EditFileTool(Tool[EditFileArgs]):
677
+ """Tool to apply line-based edits to an existing file."""
678
+
679
+ def __init__(self, file_system: Optional[FileSystem] = None):
680
+ self.file_system = file_system or LocalFileSystem()
681
+
682
+ @property
683
+ def name(self) -> str:
684
+ return "edit_file"
685
+
686
+ @property
687
+ def description(self) -> str:
688
+ return "Modify specific lines within a file"
689
+
690
+ def get_args_schema(self) -> Type[EditFileArgs]:
691
+ return EditFileArgs
692
+
693
+ async def execute(self, context: ToolContext, args: EditFileArgs) -> ToolResult:
694
+ try:
695
+ original_content = await self.file_system.read_file(args.filename, context)
696
+ except Exception as exc:
697
+ error_msg = f"Error loading file '{args.filename}': {exc}"
698
+ return ToolResult(
699
+ success=False,
700
+ result_for_llm=error_msg,
701
+ ui_component=UiComponent(
702
+ rich_component=NotificationComponent(
703
+ type=ComponentType.NOTIFICATION,
704
+ level="error",
705
+ message=error_msg,
706
+ ),
707
+ simple_component=SimpleTextComponent(text=error_msg),
708
+ ),
709
+ error=str(exc),
710
+ )
711
+
712
+ lines = original_content.splitlines(keepends=True)
713
+ applied_edits: List[str] = []
714
+
715
+ # Apply edits starting from the bottom so line numbers remain valid for each operation
716
+ for edit in sorted(args.edits, key=lambda e: e.start_line, reverse=True):
717
+ start_line = edit.start_line
718
+ end_line = edit.end_line if edit.end_line is not None else edit.start_line
719
+
720
+ if start_line < 1:
721
+ return self._range_error(
722
+ args.filename, start_line, end_line, "start_line must be >= 1"
723
+ )
724
+
725
+ if end_line < start_line - 1:
726
+ return self._range_error(
727
+ args.filename,
728
+ start_line,
729
+ end_line,
730
+ "end_line must be >= start_line - 1",
731
+ )
732
+
733
+ is_insertion = end_line == start_line - 1
734
+
735
+ if not is_insertion and start_line > len(lines):
736
+ return self._range_error(
737
+ args.filename,
738
+ start_line,
739
+ end_line,
740
+ f"start_line {start_line} is beyond the end of the file (len={len(lines)})",
741
+ )
742
+
743
+ if is_insertion:
744
+ if start_line > len(lines) + 1:
745
+ return self._range_error(
746
+ args.filename,
747
+ start_line,
748
+ end_line,
749
+ "Cannot insert beyond one line past the end of the file",
750
+ )
751
+ start_index = min(start_line - 1, len(lines))
752
+ end_index = start_index
753
+ else:
754
+ if end_line > len(lines):
755
+ return self._range_error(
756
+ args.filename,
757
+ start_line,
758
+ end_line,
759
+ f"end_line {end_line} is beyond the end of the file (len={len(lines)})",
760
+ )
761
+ start_index = start_line - 1
762
+ end_index = end_line
763
+
764
+ replacement_lines = edit.new_content.splitlines(keepends=True)
765
+ lines[start_index:end_index] = replacement_lines
766
+
767
+ if is_insertion:
768
+ inserted_count = len(replacement_lines)
769
+ applied_edits.append(
770
+ f"Inserted {inserted_count} line(s) at line {start_line}"
771
+ )
772
+ else:
773
+ removed_count = end_line - start_line + 1
774
+ applied_edits.append(
775
+ f"Replaced lines {start_line}-{end_line} (removed {removed_count} line(s))"
776
+ )
777
+
778
+ new_content = "".join(lines)
779
+
780
+ if new_content == original_content:
781
+ message = (
782
+ f"No changes applied to '{args.filename}' (content already up to date)."
783
+ )
784
+ return ToolResult(
785
+ success=True,
786
+ result_for_llm=message,
787
+ ui_component=UiComponent(
788
+ rich_component=NotificationComponent(
789
+ type=ComponentType.NOTIFICATION,
790
+ level="info",
791
+ message=message,
792
+ ),
793
+ simple_component=SimpleTextComponent(text=message),
794
+ ),
795
+ )
796
+
797
+ try:
798
+ await self.file_system.write_file(
799
+ args.filename, new_content, context, overwrite=True
800
+ )
801
+ except Exception as exc:
802
+ error_msg = f"Error writing updated contents to '{args.filename}': {exc}"
803
+ return ToolResult(
804
+ success=False,
805
+ result_for_llm=error_msg,
806
+ ui_component=UiComponent(
807
+ rich_component=NotificationComponent(
808
+ type=ComponentType.NOTIFICATION,
809
+ level="error",
810
+ message=error_msg,
811
+ ),
812
+ simple_component=SimpleTextComponent(text=error_msg),
813
+ ),
814
+ error=str(exc),
815
+ )
816
+
817
+ diff_lines = list(
818
+ difflib.unified_diff(
819
+ original_content.splitlines(),
820
+ new_content.splitlines(),
821
+ fromfile=f"a/{args.filename}",
822
+ tofile=f"b/{args.filename}",
823
+ lineterm="",
824
+ )
825
+ )
826
+
827
+ diff_text = (
828
+ "\n".join(diff_lines) if diff_lines else "(No textual diff available)"
829
+ )
830
+ summary = (
831
+ f"Updated '{args.filename}' with {len(args.edits)} edit(s).\n"
832
+ + "\n".join(reversed(applied_edits))
833
+ )
834
+
835
+ return ToolResult(
836
+ success=True,
837
+ result_for_llm=f"{summary}\n\n{diff_text}",
838
+ ui_component=UiComponent(
839
+ rich_component=CardComponent(
840
+ type=ComponentType.CARD,
841
+ title=f"Edited {args.filename}",
842
+ content=diff_text,
843
+ ),
844
+ simple_component=SimpleTextComponent(text=summary),
845
+ ),
846
+ )
847
+
848
+ def _range_error(
849
+ self, filename: str, start_line: int, end_line: int, message: str
850
+ ) -> ToolResult:
851
+ error_msg = f"Invalid edit range for '{filename}': start_line={start_line}, end_line={end_line}. {message}"
852
+ return ToolResult(
853
+ success=False,
854
+ result_for_llm=error_msg,
855
+ ui_component=UiComponent(
856
+ rich_component=NotificationComponent(
857
+ type=ComponentType.NOTIFICATION,
858
+ level="error",
859
+ message=error_msg,
860
+ ),
861
+ simple_component=SimpleTextComponent(text=error_msg),
862
+ ),
863
+ error=message,
864
+ )
865
+
866
+
867
+ # Convenience function for creating tools with default local file system
868
+ def create_file_system_tools(
869
+ file_system: Optional[FileSystem] = None,
870
+ ) -> List[Tool[Any]]:
871
+ """Create a set of file system tools with optional dependency injection."""
872
+ fs = file_system or LocalFileSystem()
873
+ return [
874
+ ListFilesTool(fs),
875
+ SearchFilesTool(fs),
876
+ ReadFileTool(fs),
877
+ WriteFileTool(fs),
878
+ EditFileTool(fs),
879
+ ]