illusion-code 0.1.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 (214) hide show
  1. illusion/__init__.py +24 -0
  2. illusion/__main__.py +15 -0
  3. illusion/_frontend/dist/index.mjs +39208 -0
  4. illusion/_frontend/package.json +27 -0
  5. illusion/_frontend/src/App.tsx +624 -0
  6. illusion/_frontend/src/components/CommandPicker.tsx +98 -0
  7. illusion/_frontend/src/components/Composer.tsx +55 -0
  8. illusion/_frontend/src/components/ComposerController.tsx +128 -0
  9. illusion/_frontend/src/components/ConversationView.tsx +750 -0
  10. illusion/_frontend/src/components/Footer.tsx +25 -0
  11. illusion/_frontend/src/components/MarkdownContent.tsx +537 -0
  12. illusion/_frontend/src/components/MarkdownTable.tsx +245 -0
  13. illusion/_frontend/src/components/ModalHost.tsx +425 -0
  14. illusion/_frontend/src/components/MultilineTextInput.tsx +250 -0
  15. illusion/_frontend/src/components/PromptInput.tsx +64 -0
  16. illusion/_frontend/src/components/SelectModal.tsx +78 -0
  17. illusion/_frontend/src/components/SidePanel.tsx +175 -0
  18. illusion/_frontend/src/components/Spinner.tsx +77 -0
  19. illusion/_frontend/src/components/StatusBar.tsx +142 -0
  20. illusion/_frontend/src/components/SwarmPanel.tsx +141 -0
  21. illusion/_frontend/src/components/TodoPanel.tsx +126 -0
  22. illusion/_frontend/src/components/ToolCallDisplay.tsx +202 -0
  23. illusion/_frontend/src/components/TranscriptPane.tsx +79 -0
  24. illusion/_frontend/src/components/WelcomeBanner.tsx +37 -0
  25. illusion/_frontend/src/hooks/useBackendSession.ts +468 -0
  26. illusion/_frontend/src/hooks/useTerminalSize.ts +9 -0
  27. illusion/_frontend/src/i18n.ts +78 -0
  28. illusion/_frontend/src/index.tsx +42 -0
  29. illusion/_frontend/src/theme/ThemeContext.tsx +19 -0
  30. illusion/_frontend/src/theme/builtinThemes.ts +89 -0
  31. illusion/_frontend/src/types.ts +110 -0
  32. illusion/_frontend/src/utils/markdown.ts +33 -0
  33. illusion/_frontend/src/utils/thinking.ts +191 -0
  34. illusion/_frontend/tsconfig.json +13 -0
  35. illusion/_web_dist/assets/index-BseIw-ik.css +10 -0
  36. illusion/_web_dist/assets/index-C_0ZWMuW.js +82 -0
  37. illusion/_web_dist/index.html +16 -0
  38. illusion/api/__init__.py +36 -0
  39. illusion/api/client.py +568 -0
  40. illusion/api/codex_client.py +563 -0
  41. illusion/api/compat.py +138 -0
  42. illusion/api/effort.py +128 -0
  43. illusion/api/errors.py +57 -0
  44. illusion/api/openai_client.py +819 -0
  45. illusion/api/provider.py +148 -0
  46. illusion/api/registry.py +479 -0
  47. illusion/api/usage.py +45 -0
  48. illusion/auth/__init__.py +50 -0
  49. illusion/auth/copilot.py +419 -0
  50. illusion/auth/external.py +612 -0
  51. illusion/auth/flows.py +58 -0
  52. illusion/auth/manager.py +214 -0
  53. illusion/auth/storage.py +372 -0
  54. illusion/bridge/__init__.py +38 -0
  55. illusion/bridge/manager.py +190 -0
  56. illusion/bridge/session_runner.py +84 -0
  57. illusion/bridge/types.py +113 -0
  58. illusion/bridge/work_secret.py +131 -0
  59. illusion/cli.py +1228 -0
  60. illusion/commands/__init__.py +32 -0
  61. illusion/commands/registry.py +1934 -0
  62. illusion/config/__init__.py +39 -0
  63. illusion/config/i18n.py +522 -0
  64. illusion/config/paths.py +259 -0
  65. illusion/config/settings.py +564 -0
  66. illusion/coordinator/__init__.py +41 -0
  67. illusion/coordinator/agent_definitions.py +1093 -0
  68. illusion/coordinator/coordinator_mode.py +127 -0
  69. illusion/engine/__init__.py +95 -0
  70. illusion/engine/cost_tracker.py +55 -0
  71. illusion/engine/messages.py +369 -0
  72. illusion/engine/query.py +632 -0
  73. illusion/engine/query_engine.py +343 -0
  74. illusion/engine/stream_events.py +169 -0
  75. illusion/hooks/__init__.py +67 -0
  76. illusion/hooks/events.py +43 -0
  77. illusion/hooks/executor.py +397 -0
  78. illusion/hooks/hot_reload.py +74 -0
  79. illusion/hooks/loader.py +133 -0
  80. illusion/hooks/schemas.py +121 -0
  81. illusion/hooks/types.py +86 -0
  82. illusion/mcp/__init__.py +104 -0
  83. illusion/mcp/client.py +377 -0
  84. illusion/mcp/config.py +140 -0
  85. illusion/mcp/types.py +175 -0
  86. illusion/memory/__init__.py +36 -0
  87. illusion/memory/manager.py +94 -0
  88. illusion/memory/memdir.py +58 -0
  89. illusion/memory/paths.py +57 -0
  90. illusion/memory/scan.py +120 -0
  91. illusion/memory/search.py +83 -0
  92. illusion/memory/types.py +43 -0
  93. illusion/output_styles/__init__.py +15 -0
  94. illusion/output_styles/loader.py +64 -0
  95. illusion/permissions/__init__.py +39 -0
  96. illusion/permissions/checker.py +174 -0
  97. illusion/permissions/modes.py +38 -0
  98. illusion/platforms.py +148 -0
  99. illusion/plugins/__init__.py +71 -0
  100. illusion/plugins/bundled/__init__.py +0 -0
  101. illusion/plugins/installer.py +59 -0
  102. illusion/plugins/loader.py +301 -0
  103. illusion/plugins/schemas.py +51 -0
  104. illusion/plugins/types.py +56 -0
  105. illusion/prompts/__init__.py +29 -0
  106. illusion/prompts/claudemd.py +74 -0
  107. illusion/prompts/context.py +187 -0
  108. illusion/prompts/environment.py +189 -0
  109. illusion/prompts/system_prompt.py +155 -0
  110. illusion/py.typed +0 -0
  111. illusion/sandbox/__init__.py +29 -0
  112. illusion/sandbox/adapter.py +174 -0
  113. illusion/services/__init__.py +59 -0
  114. illusion/services/compact/__init__.py +1015 -0
  115. illusion/services/cron.py +338 -0
  116. illusion/services/cron_scheduler.py +715 -0
  117. illusion/services/file_history.py +258 -0
  118. illusion/services/lsp/__init__.py +455 -0
  119. illusion/services/session_storage.py +237 -0
  120. illusion/services/token_estimation.py +72 -0
  121. illusion/skills/__init__.py +60 -0
  122. illusion/skills/bundled/__init__.py +110 -0
  123. illusion/skills/bundled/content/batch.md +86 -0
  124. illusion/skills/bundled/content/coding-guidelines.md +70 -0
  125. illusion/skills/bundled/content/debug.md +38 -0
  126. illusion/skills/bundled/content/loop.md +82 -0
  127. illusion/skills/bundled/content/remember.md +105 -0
  128. illusion/skills/bundled/content/simplify.md +53 -0
  129. illusion/skills/bundled/content/skillify.md +113 -0
  130. illusion/skills/bundled/content/stuck.md +54 -0
  131. illusion/skills/bundled/content/update-config.md +329 -0
  132. illusion/skills/bundled/content/verify.md +74 -0
  133. illusion/skills/loader.py +219 -0
  134. illusion/skills/registry.py +40 -0
  135. illusion/skills/types.py +24 -0
  136. illusion/state/__init__.py +18 -0
  137. illusion/state/app_state.py +67 -0
  138. illusion/state/store.py +93 -0
  139. illusion/swarm/__init__.py +71 -0
  140. illusion/swarm/agent_executor.py +857 -0
  141. illusion/swarm/in_process.py +259 -0
  142. illusion/swarm/subprocess_backend.py +136 -0
  143. illusion/swarm/team_helpers.py +123 -0
  144. illusion/swarm/types.py +159 -0
  145. illusion/swarm/worktree.py +347 -0
  146. illusion/tasks/__init__.py +33 -0
  147. illusion/tasks/local_agent_task.py +42 -0
  148. illusion/tasks/local_shell_task.py +27 -0
  149. illusion/tasks/manager.py +377 -0
  150. illusion/tasks/stop_task.py +21 -0
  151. illusion/tasks/types.py +88 -0
  152. illusion/tools/__init__.py +126 -0
  153. illusion/tools/agent_tool.py +388 -0
  154. illusion/tools/ask_user_question_tool.py +186 -0
  155. illusion/tools/base.py +149 -0
  156. illusion/tools/bash_tool.py +413 -0
  157. illusion/tools/config_tool.py +90 -0
  158. illusion/tools/cron_tool.py +473 -0
  159. illusion/tools/enter_plan_mode_tool.py +147 -0
  160. illusion/tools/enter_worktree_tool.py +188 -0
  161. illusion/tools/exit_plan_mode_tool.py +69 -0
  162. illusion/tools/exit_worktree_tool.py +225 -0
  163. illusion/tools/file_edit_tool.py +283 -0
  164. illusion/tools/file_read_tool.py +294 -0
  165. illusion/tools/file_write_tool.py +184 -0
  166. illusion/tools/glob_tool.py +165 -0
  167. illusion/tools/grep_tool.py +190 -0
  168. illusion/tools/list_mcp_resources_tool.py +80 -0
  169. illusion/tools/lsp_tool.py +333 -0
  170. illusion/tools/mcp_auth_tool.py +100 -0
  171. illusion/tools/mcp_tool.py +75 -0
  172. illusion/tools/notebook_edit_tool.py +242 -0
  173. illusion/tools/powershell_tool.py +334 -0
  174. illusion/tools/read_mcp_resource_tool.py +63 -0
  175. illusion/tools/repl_tool.py +100 -0
  176. illusion/tools/send_message_tool.py +112 -0
  177. illusion/tools/shell_common.py +187 -0
  178. illusion/tools/skill_tool.py +86 -0
  179. illusion/tools/sleep_tool.py +62 -0
  180. illusion/tools/structured_output_tool.py +58 -0
  181. illusion/tools/task_create_tool.py +98 -0
  182. illusion/tools/task_get_tool.py +94 -0
  183. illusion/tools/task_list_tool.py +94 -0
  184. illusion/tools/task_output_tool.py +55 -0
  185. illusion/tools/task_stop_tool.py +52 -0
  186. illusion/tools/task_update_tool.py +224 -0
  187. illusion/tools/team_create_tool.py +236 -0
  188. illusion/tools/team_delete_tool.py +104 -0
  189. illusion/tools/todo_write_tool.py +198 -0
  190. illusion/tools/tool_search_tool.py +156 -0
  191. illusion/tools/web_fetch_tool.py +264 -0
  192. illusion/tools/web_search_tool.py +186 -0
  193. illusion/ui/__init__.py +23 -0
  194. illusion/ui/app.py +258 -0
  195. illusion/ui/backend_host.py +1180 -0
  196. illusion/ui/input.py +86 -0
  197. illusion/ui/output.py +363 -0
  198. illusion/ui/permission_dialog.py +47 -0
  199. illusion/ui/permission_store.py +99 -0
  200. illusion/ui/protocol.py +384 -0
  201. illusion/ui/react_launcher.py +280 -0
  202. illusion/ui/runtime.py +787 -0
  203. illusion/ui/textual_app.py +603 -0
  204. illusion/ui/web/__init__.py +10 -0
  205. illusion/ui/web/server.py +87 -0
  206. illusion/ui/web/ws_host.py +1197 -0
  207. illusion/utils/__init__.py +0 -0
  208. illusion/utils/ripgrep.py +299 -0
  209. illusion/utils/shell.py +248 -0
  210. illusion_code-0.1.0.dist-info/METADATA +1159 -0
  211. illusion_code-0.1.0.dist-info/RECORD +214 -0
  212. illusion_code-0.1.0.dist-info/WHEEL +4 -0
  213. illusion_code-0.1.0.dist-info/entry_points.txt +2 -0
  214. illusion_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,283 @@
1
+ """
2
+ 字符串替换文件编辑工具
3
+ ======================
4
+
5
+ 本模块提供在现有文件中进行精确字符串替换的功能。
6
+
7
+ 主要组件:
8
+ - FileEditTool: 替换文件中文本的工具
9
+
10
+ 使用示例:
11
+ >>> from illusion.tools import FileEditTool
12
+ >>> tool = FileEditTool()
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from difflib import unified_diff
18
+ from pathlib import Path
19
+
20
+ from pydantic import BaseModel, Field, model_validator
21
+
22
+ from illusion.tools.base import BaseTool, ToolExecutionContext, ToolResult
23
+
24
+
25
+ # 模块级集合,跟踪本次会话中已读取的文件
26
+ # FileReadTool 写入此集合;FileEditTool 从中读取
27
+ _read_files: set[str] = set()
28
+
29
+
30
+ def mark_file_read(abs_path: str) -> None:
31
+ """记录文件已被读取(在 FileReadTool 读取后调用)。"""
32
+ _read_files.add(abs_path)
33
+
34
+
35
+ def has_file_been_read(abs_path: str) -> bool:
36
+ """检查文件是否在本次会话中已被读取。"""
37
+ return abs_path in _read_files
38
+
39
+
40
+ class FileEditToolInput(BaseModel):
41
+ """文件编辑参数。
42
+
43
+ 属性:
44
+ file_path: 要编辑的文件路径
45
+ old_string: 要替换的现有文本
46
+ new_string: 替换文本
47
+ replace_all: 是否替换所有匹配项
48
+
49
+ 兼容旧参数名:path, old_str, new_str 均可传入,会自动映射。
50
+ """
51
+
52
+ file_path: str = Field(description="Path of the file to edit")
53
+ old_string: str = Field(description="Existing text to replace")
54
+ new_string: str = Field(description="Replacement text")
55
+ replace_all: bool = Field(default=False)
56
+
57
+ @model_validator(mode="before")
58
+ @classmethod
59
+ def _normalize_fields(cls, values: dict) -> dict:
60
+ """将旧参数名映射到新参数名,确保向后兼容。"""
61
+ if "path" in values and "file_path" not in values:
62
+ values["file_path"] = values.pop("path")
63
+ if "old_str" in values and "old_string" not in values:
64
+ values["old_string"] = values.pop("old_str")
65
+ if "new_str" in values and "new_string" not in values:
66
+ values["new_string"] = values.pop("new_str")
67
+ return values
68
+
69
+
70
+ class FileEditTool(BaseTool):
71
+ """替换现有文件中的文本。
72
+
73
+ 用于对文件进行精确的字符串替换编辑。
74
+ """
75
+
76
+ name = "edit_file"
77
+ description = """Performs exact string replacements in files.
78
+
79
+ Usage:
80
+ - You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
81
+ - When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + arrow. Everything after that arrow is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
82
+ - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
83
+ - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
84
+ - The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.
85
+ - Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
86
+ - Use the smallest old_string that's clearly unique — usually 2-4 adjacent lines is sufficient. Avoid including 10+ lines of context when less uniquely identifies the target."""
87
+ input_model = FileEditToolInput
88
+
89
+ async def execute(
90
+ self,
91
+ arguments: FileEditToolInput,
92
+ context: ToolExecutionContext,
93
+ ) -> ToolResult:
94
+ """执行文件编辑操作,替换指定文本并返回差异信息
95
+
96
+ Args:
97
+ arguments: 文件编辑参数
98
+ context: 工具执行上下文
99
+
100
+ Returns:
101
+ ToolResult: 包含编辑结果和差异文本的执行结果
102
+ """
103
+ # 解析文件路径
104
+ path = _resolve_path(context.cwd, arguments.file_path)
105
+
106
+ # 拒绝 notebook 文件 — 模型应使用 NotebookEdit
107
+ if path.suffix.lower() == ".ipynb":
108
+ return ToolResult(
109
+ output=".ipynb files must be edited with the notebook_edit tool, not edit_file.",
110
+ is_error=True,
111
+ )
112
+
113
+ # 处理新文件创建:仅当 old_string 为空时允许
114
+ if not path.exists():
115
+ if arguments.old_string:
116
+ return ToolResult(
117
+ output=f"File not found: {path}. To create a new file, set old_string to empty string.",
118
+ is_error=True,
119
+ )
120
+ # 创建新文件
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ path.write_text(arguments.new_string, encoding="utf-8")
123
+ mark_file_read(str(path))
124
+ # 生成新文件内容预览
125
+ preview = _generate_create_preview(str(path), arguments.new_string)
126
+ return ToolResult(output=f"Created {path}\n{preview}")
127
+
128
+ # 读后编辑强制检查
129
+ if not has_file_been_read(str(path)):
130
+ return ToolResult(
131
+ output=(
132
+ f"You must read the file at {path} using the Read tool "
133
+ "before you can edit it. This tool will error if you attempt "
134
+ "an edit without reading the file first."
135
+ ),
136
+ is_error=True,
137
+ )
138
+
139
+ # 空操作保护
140
+ if arguments.old_string == arguments.new_string:
141
+ return ToolResult(
142
+ output="old_string and new_string are identical — no changes needed.",
143
+ is_error=True,
144
+ )
145
+
146
+ # 非空文件上的空 old_string
147
+ original = path.read_text(encoding="utf-8")
148
+ if not arguments.old_string and original.strip():
149
+ return ToolResult(
150
+ output=(
151
+ "old_string is empty but the file is not empty. "
152
+ "To replace the entire file content, use the Write tool instead."
153
+ ),
154
+ is_error=True,
155
+ )
156
+
157
+ # 空文件上的空 old_string = 写入新内容
158
+ if not arguments.old_string and not original.strip():
159
+ path.write_text(arguments.new_string, encoding="utf-8")
160
+ diff_text = _generate_diff(str(path), original, arguments.new_string)
161
+ return ToolResult(output=f"Updated {path}\n{diff_text}")
162
+
163
+ # 检查 old_string 是否存在于文件中
164
+ if arguments.old_string not in original:
165
+ # 尝试提供关于文件中内容的帮助上下文
166
+ _similar = _find_similar_lines(original, arguments.old_string)
167
+ msg = "old_string was not found in the file."
168
+ if _similar:
169
+ msg += f"\n\nThe closest matches in the file are:\n{_similar}"
170
+ return ToolResult(output=msg, is_error=True)
171
+
172
+ # 唯一性检查(当不是替换所有时)
173
+ if not arguments.replace_all:
174
+ count = original.count(arguments.old_string)
175
+ if count > 1:
176
+ return ToolResult(
177
+ output=(
178
+ f"old_string appears {count} times in the file. "
179
+ "Either provide a larger string with more surrounding context to make it unique, "
180
+ "or use replace_all=true to change every instance."
181
+ ),
182
+ is_error=True,
183
+ )
184
+
185
+ # 应用编辑
186
+ if arguments.replace_all:
187
+ updated = original.replace(arguments.old_string, arguments.new_string)
188
+ else:
189
+ updated = original.replace(arguments.old_string, arguments.new_string, 1)
190
+
191
+ path.write_text(updated, encoding="utf-8")
192
+ # 生成差异文本
193
+ diff_text = _generate_diff(str(path), original, updated)
194
+ return ToolResult(output=f"Updated {path}\n{diff_text}")
195
+
196
+
197
+ def _generate_diff(file_path: str, original: str, updated: str, context_lines: int = 3) -> str:
198
+ """生成统一差异格式的文本
199
+
200
+ Args:
201
+ file_path: 文件路径
202
+ original: 原始内容
203
+ updated: 更新后内容
204
+ context_lines: 上下文行数
205
+
206
+ Returns:
207
+ str: 差异文本
208
+ """
209
+ original_lines = original.splitlines(keepends=True)
210
+ updated_lines = updated.splitlines(keepends=True)
211
+ diff_lines = list(unified_diff(
212
+ original_lines,
213
+ updated_lines,
214
+ fromfile=f"a/{file_path}",
215
+ tofile=f"b/{file_path}",
216
+ n=context_lines,
217
+ ))
218
+ if not diff_lines:
219
+ return ""
220
+ return "".join(diff_lines).rstrip()
221
+
222
+
223
+ def _generate_create_preview(file_path: str, content: str, max_lines: int = 10) -> str:
224
+ """生成新文件创建的内容预览
225
+
226
+ Args:
227
+ file_path: 文件路径
228
+ content: 文件内容
229
+ max_lines: 最大预览行数
230
+
231
+ Returns:
232
+ str: 预览文本
233
+ """
234
+ lines = content.splitlines()
235
+ total = len(lines)
236
+ if total <= max_lines:
237
+ return content
238
+ preview_lines = lines[:max_lines]
239
+ remaining = total - max_lines
240
+ return "\n".join(preview_lines) + f"\n... +{remaining} lines"
241
+
242
+
243
+ def _resolve_path(base: Path, candidate: str) -> Path:
244
+ """解析相对路径为绝对路径。
245
+
246
+ 参数:
247
+ base: 基础目录
248
+ candidate: 候选路径(可能是相对路径)
249
+
250
+ 返回:
251
+ 解析后的绝对路径
252
+ """
253
+ path = Path(candidate).expanduser()
254
+ if not path.is_absolute():
255
+ path = base / path
256
+ return path.resolve()
257
+
258
+
259
+ def _find_similar_lines(content: str, target: str, max_lines: int = 5) -> str:
260
+ """在内容中找到与目标字符串部分匹配的行。
261
+
262
+ 返回格式化的字符串,显示最接近的匹配,或空字符串。
263
+ """
264
+ target_lines = [line.strip() for line in target.splitlines() if line.strip()]
265
+ if not target_lines:
266
+ return ""
267
+
268
+ content_lines = content.splitlines()
269
+ matches: list[str] = []
270
+ first_target = target_lines[0].lower()
271
+
272
+ for i, line in enumerate(content_lines):
273
+ stripped = line.strip().lower()
274
+ # 检查此行是否包含第一个目标行或被其包含
275
+ if first_target in stripped or stripped in first_target:
276
+ start = max(0, i - 1)
277
+ end = min(len(content_lines), i + 2)
278
+ block = "\n".join(f" {j+1}: {content_lines[j]}" for j in range(start, end))
279
+ matches.append(block)
280
+ if len(matches) >= max_lines:
281
+ break
282
+
283
+ return "\n\n".join(matches) if matches else ""
@@ -0,0 +1,294 @@
1
+ """
2
+ 文件读取工具
3
+ ===========
4
+
5
+ 本模块提供读取本地文件系统文件的功能,支持文本文件和图片文件。
6
+
7
+ 主要组件:
8
+ - FileReadTool: 读取文本文件和图片文件的工具
9
+
10
+ 使用示例:
11
+ >>> from illusion.tools import FileReadTool
12
+ >>> tool = FileReadTool()
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import json
19
+ import mimetypes
20
+ from pathlib import Path
21
+
22
+ from pydantic import BaseModel, Field, model_validator
23
+
24
+ from illusion.tools.base import BaseTool, ToolExecutionContext, ToolResult
25
+
26
+ # 图片文件扩展名集合
27
+ _IMAGE_EXTENSIONS: frozenset[str] = frozenset({
28
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg",
29
+ })
30
+
31
+ # 图片文件大小限制(字节)
32
+ _IMAGE_SIZE_LIMIT: int = 20 * 1024 * 1024 # 20 MB
33
+
34
+
35
+ def _is_image_file(path: Path) -> bool:
36
+ """检测文件是否为图片文件。"""
37
+ return path.suffix.lower() in _IMAGE_EXTENSIONS
38
+
39
+
40
+ def _get_media_type(path: Path) -> str:
41
+ """获取文件的 MIME 类型。"""
42
+ media_type, _ = mimetypes.guess_type(str(path))
43
+ if media_type:
44
+ return media_type
45
+ fallback = {
46
+ ".svg": "image/svg+xml",
47
+ }
48
+ return fallback.get(path.suffix.lower(), "application/octet-stream")
49
+
50
+
51
+ class FileReadToolInput(BaseModel):
52
+ """文件读取参数。
53
+
54
+ 属性:
55
+ file_path: 要读取的文件路径
56
+ offset: 起始行号(从 0 开始)
57
+ limit: 返回的行数限制
58
+
59
+ 兼容旧参数名:path 也可传入,会自动映射。
60
+ """
61
+
62
+ file_path: str = Field(description="Path of the file to read")
63
+ offset: int = Field(default=0, ge=0, description="Zero-based starting line")
64
+ limit: int = Field(default=2000, ge=1, le=2000, description="Number of lines to return")
65
+
66
+ @model_validator(mode="before")
67
+ @classmethod
68
+ def _normalize_fields(cls, values: dict) -> dict:
69
+ """将旧参数名映射到新参数名,确保向后兼容。"""
70
+ if "path" in values and "file_path" not in values:
71
+ values["file_path"] = values.pop("path")
72
+ return values
73
+
74
+
75
+ class FileReadTool(BaseTool):
76
+ """读取文本文件和图片文件。
77
+
78
+ 支持图片(PNG, JPG, GIF, WebP 等),通过 base64 编码传递给多模态模型。
79
+ """
80
+
81
+ name = "read_file"
82
+ description = """Reads a file from the local filesystem. You can access any file directly by using this tool.
83
+ Assume this tool is able to read all files on the machine. If the User provides a file_path to a file assume that file_path is valid. It is okay to read a file that does not exist; an error will be returned.
84
+
85
+ Usage:
86
+ - The file_path parameter must be an absolute path, not a relative path
87
+ - By default, it reads up to 2000 lines starting from the beginning of the file
88
+ - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
89
+ - Results are returned using cat -n format, with line numbers starting at 1
90
+ - This tool allows Illusion Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Illusion Code is a multimodal LLM.
91
+ - This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
92
+ - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.
93
+ - You will regularly be asked to read screenshots. If the user provides a file_path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.
94
+ - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents."""
95
+ input_model = FileReadToolInput
96
+
97
+ def is_read_only(self, arguments: FileReadToolInput) -> bool:
98
+ del arguments
99
+ return True
100
+
101
+ async def execute(
102
+ self,
103
+ arguments: FileReadToolInput,
104
+ context: ToolExecutionContext,
105
+ ) -> ToolResult:
106
+ # 解析文件路径
107
+ path = _resolve_path(context.cwd, arguments.file_path)
108
+ # 检查文件是否存在
109
+ if not path.exists():
110
+ return ToolResult(output=f"File not found: {path}", is_error=True)
111
+ # 检查是否为目录
112
+ if path.is_dir():
113
+ return ToolResult(output=f"Cannot read directory: {path}", is_error=True)
114
+
115
+ # 检测是否为 Jupyter notebook
116
+ if path.suffix.lower() == ".ipynb":
117
+ return self._read_notebook_file(path, arguments)
118
+
119
+ # 检测是否为图片文件
120
+ if _is_image_file(path):
121
+ return self._read_image_file(path)
122
+
123
+ # 读取文本文件
124
+ return self._read_text_file(path, arguments)
125
+
126
+ def _read_image_file(self, path: Path) -> ToolResult:
127
+ """读取图片文件并返回 base64 编码数据。"""
128
+ raw = path.read_bytes()
129
+ file_size = len(raw)
130
+
131
+ # 检查文件大小限制
132
+ if file_size > _IMAGE_SIZE_LIMIT:
133
+ limit_mb = _IMAGE_SIZE_LIMIT // (1024 * 1024)
134
+ return ToolResult(
135
+ output=f"Image file too large: {file_size} bytes exceeds {limit_mb} MB limit",
136
+ is_error=True,
137
+ )
138
+
139
+ media_type = _get_media_type(path)
140
+ encoded = base64.b64encode(raw).decode("ascii")
141
+
142
+ # 生成输出描述
143
+ size_str = _human_size(file_size)
144
+ output = f"[image file: {path} ({size_str}, {media_type})]"
145
+
146
+ return ToolResult(
147
+ output=output,
148
+ metadata={
149
+ "media_category": "image",
150
+ "media_type": media_type,
151
+ "media_data": encoded,
152
+ "media_path": str(path),
153
+ "media_size": file_size,
154
+ },
155
+ )
156
+
157
+ def _read_notebook_file(self, path: Path, arguments: FileReadToolInput) -> ToolResult:
158
+ """读取 Jupyter notebook 文件,解析所有单元格及其输出。"""
159
+ try:
160
+ raw = path.read_text(encoding="utf-8")
161
+ nb = json.loads(raw)
162
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
163
+ return ToolResult(output=f"Failed to parse notebook {path}: {e}", is_error=True)
164
+
165
+ cells = nb.get("cells", [])
166
+ if not cells:
167
+ return ToolResult(output=f"System reminder: The notebook at {path} exists but has no cells.")
168
+
169
+ # 应用 offset/limit 到单元格级别
170
+ selected_cells = cells[arguments.offset : arguments.offset + arguments.limit]
171
+ if not selected_cells:
172
+ return ToolResult(
173
+ output=f"System reminder: No cells in selected range (offset={arguments.offset}, limit={arguments.limit}) for {path}"
174
+ )
175
+
176
+ output_parts: list[str] = []
177
+ for cell in selected_cells:
178
+ idx = cells.index(cell)
179
+ cell_type = cell.get("cell_type", "code")
180
+ source = "".join(cell.get("source", ""))
181
+ if isinstance(source, list):
182
+ source = "".join(source)
183
+
184
+ if cell_type == "markdown":
185
+ output_parts.append(f"## Cell {idx} (markdown)\n{source}")
186
+ elif cell_type == "code":
187
+ exec_count = cell.get("execution_count")
188
+ status = f"executed, count={exec_count}" if exec_count is not None else "not executed"
189
+ output_parts.append(f"## Cell {idx} (code) [{status}]")
190
+ if source.strip():
191
+ output_parts.append(source)
192
+ else:
193
+ output_parts.append("(empty cell)")
194
+
195
+ # 格式化输出
196
+ outputs = cell.get("outputs", [])
197
+ if outputs:
198
+ output_parts.append("\n**Outputs:**")
199
+ for out in outputs:
200
+ out_type = out.get("output_type", "")
201
+ if out_type == "stream":
202
+ text = "".join(out.get("text", ""))
203
+ if isinstance(text, list):
204
+ text = "".join(text)
205
+ name = out.get("name", "stdout")
206
+ output_parts.append(f"[{name}]:\n{text}")
207
+ elif out_type == "execute_result":
208
+ data = out.get("data", {})
209
+ text = data.get("text/plain", "")
210
+ if isinstance(text, list):
211
+ text = "".join(text)
212
+ output_parts.append(f"[result]: {text}")
213
+ elif out_type == "error":
214
+ ename = out.get("ename", "Error")
215
+ evalue = out.get("evalue", "")
216
+ traceback = out.get("traceback", [])
217
+ if isinstance(traceback, list):
218
+ traceback_text = "\n".join(traceback)
219
+ else:
220
+ traceback_text = str(traceback)
221
+ output_parts.append(f"[error]: {ename}: {evalue}\n{traceback_text}")
222
+ elif out_type == "display_data":
223
+ data = out.get("data", {})
224
+ text = data.get("text/plain", "")
225
+ if isinstance(text, list):
226
+ text = "".join(text)
227
+ if text:
228
+ output_parts.append(f"[display]: {text}")
229
+ if "image/png" in data:
230
+ output_parts.append("[display]: <image/png>")
231
+ elif exec_count is not None:
232
+ output_parts.append("\n**Outputs:** (none)")
233
+ else:
234
+ source = "".join(cell.get("source", ""))
235
+ if isinstance(source, list):
236
+ source = "".join(source)
237
+ output_parts.append(f"## Cell {idx} ({cell_type})\n{source}")
238
+
239
+ output_parts.append("") # 单元格间空行
240
+
241
+ # 注册文件已被读取
242
+ from illusion.tools.file_edit_tool import mark_file_read
243
+ mark_file_read(str(path))
244
+
245
+ return ToolResult(output="\n".join(output_parts).strip())
246
+
247
+ def _read_text_file(self, path: Path, arguments: FileReadToolInput) -> ToolResult:
248
+ """读取文本文件。"""
249
+ raw = path.read_bytes()
250
+ if b"\x00" in raw:
251
+ return ToolResult(output=f"Binary file cannot be read as text: {path}", is_error=True)
252
+
253
+ text = raw.decode("utf-8", errors="replace")
254
+ lines = text.splitlines()
255
+ selected = lines[arguments.offset : arguments.offset + arguments.limit]
256
+ numbered = [
257
+ f"{arguments.offset + index + 1:>6}\t{line}"
258
+ for index, line in enumerate(selected)
259
+ ]
260
+ if not numbered:
261
+ return ToolResult(
262
+ output=f"System reminder: The file at {path} exists but has empty contents."
263
+ )
264
+
265
+ # 注册文件已被读取(用于读后编辑强制检查)
266
+ from illusion.tools.file_edit_tool import mark_file_read
267
+ mark_file_read(str(path))
268
+
269
+ return ToolResult(output="\n".join(numbered))
270
+
271
+
272
+ def _human_size(size: int) -> str:
273
+ """将字节数转为人类可读的大小字符串。"""
274
+ if size < 1024:
275
+ return f"{size} B"
276
+ if size < 1024 * 1024:
277
+ return f"{size / 1024:.1f} KB"
278
+ return f"{size / (1024 * 1024):.1f} MB"
279
+
280
+
281
+ def _resolve_path(base: Path, candidate: str) -> Path:
282
+ """解析相对路径为绝对路径。
283
+
284
+ 参数:
285
+ base: 基础目录
286
+ candidate: 候选路径(可能是相对路径)
287
+
288
+ 返回:
289
+ 解析后的绝对路径
290
+ """
291
+ path = Path(candidate).expanduser()
292
+ if not path.is_absolute():
293
+ path = base / path
294
+ return path.resolve()