agentpool 2.1.9__py3-none-any.whl → 2.5.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 (311) hide show
  1. acp/__init__.py +13 -4
  2. acp/acp_requests.py +20 -77
  3. acp/agent/connection.py +8 -0
  4. acp/agent/implementations/debug_server/debug_server.py +6 -2
  5. acp/agent/protocol.py +6 -0
  6. acp/bridge/README.md +15 -2
  7. acp/bridge/__init__.py +3 -2
  8. acp/bridge/__main__.py +60 -19
  9. acp/bridge/ws_server.py +173 -0
  10. acp/bridge/ws_server_cli.py +89 -0
  11. acp/client/connection.py +38 -29
  12. acp/client/implementations/default_client.py +3 -2
  13. acp/client/implementations/headless_client.py +2 -2
  14. acp/connection.py +2 -2
  15. acp/notifications.py +20 -50
  16. acp/schema/__init__.py +2 -0
  17. acp/schema/agent_responses.py +21 -0
  18. acp/schema/client_requests.py +3 -3
  19. acp/schema/session_state.py +63 -29
  20. acp/stdio.py +39 -9
  21. acp/task/supervisor.py +2 -2
  22. acp/transports.py +362 -2
  23. acp/utils.py +17 -4
  24. agentpool/__init__.py +6 -1
  25. agentpool/agents/__init__.py +2 -0
  26. agentpool/agents/acp_agent/acp_agent.py +407 -277
  27. agentpool/agents/acp_agent/acp_converters.py +196 -38
  28. agentpool/agents/acp_agent/client_handler.py +191 -26
  29. agentpool/agents/acp_agent/session_state.py +17 -6
  30. agentpool/agents/agent.py +607 -572
  31. agentpool/agents/agui_agent/__init__.py +0 -2
  32. agentpool/agents/agui_agent/agui_agent.py +176 -110
  33. agentpool/agents/agui_agent/agui_converters.py +0 -131
  34. agentpool/agents/agui_agent/helpers.py +3 -4
  35. agentpool/agents/base_agent.py +632 -17
  36. agentpool/agents/claude_code_agent/FORKING.md +191 -0
  37. agentpool/agents/claude_code_agent/__init__.py +13 -1
  38. agentpool/agents/claude_code_agent/claude_code_agent.py +1058 -291
  39. agentpool/agents/claude_code_agent/converters.py +74 -143
  40. agentpool/agents/claude_code_agent/history.py +474 -0
  41. agentpool/agents/claude_code_agent/models.py +77 -0
  42. agentpool/agents/claude_code_agent/static_info.py +100 -0
  43. agentpool/agents/claude_code_agent/usage.py +242 -0
  44. agentpool/agents/context.py +40 -0
  45. agentpool/agents/events/__init__.py +24 -0
  46. agentpool/agents/events/builtin_handlers.py +67 -1
  47. agentpool/agents/events/event_emitter.py +32 -2
  48. agentpool/agents/events/events.py +104 -3
  49. agentpool/agents/events/infer_info.py +145 -0
  50. agentpool/agents/events/processors.py +254 -0
  51. agentpool/agents/interactions.py +41 -6
  52. agentpool/agents/modes.py +67 -0
  53. agentpool/agents/slashed_agent.py +5 -4
  54. agentpool/agents/tool_call_accumulator.py +213 -0
  55. agentpool/agents/tool_wrapping.py +18 -6
  56. agentpool/common_types.py +56 -21
  57. agentpool/config_resources/__init__.py +38 -1
  58. agentpool/config_resources/acp_assistant.yml +2 -2
  59. agentpool/config_resources/agents.yml +3 -0
  60. agentpool/config_resources/agents_template.yml +1 -0
  61. agentpool/config_resources/claude_code_agent.yml +10 -6
  62. agentpool/config_resources/external_acp_agents.yml +2 -1
  63. agentpool/delegation/base_team.py +4 -30
  64. agentpool/delegation/pool.py +136 -289
  65. agentpool/delegation/team.py +58 -57
  66. agentpool/delegation/teamrun.py +51 -55
  67. agentpool/diagnostics/__init__.py +53 -0
  68. agentpool/diagnostics/lsp_manager.py +1593 -0
  69. agentpool/diagnostics/lsp_proxy.py +41 -0
  70. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  71. agentpool/diagnostics/models.py +398 -0
  72. agentpool/functional/run.py +10 -4
  73. agentpool/mcp_server/__init__.py +0 -2
  74. agentpool/mcp_server/client.py +76 -32
  75. agentpool/mcp_server/conversions.py +54 -13
  76. agentpool/mcp_server/manager.py +34 -54
  77. agentpool/mcp_server/registries/official_registry_client.py +35 -1
  78. agentpool/mcp_server/tool_bridge.py +186 -139
  79. agentpool/messaging/__init__.py +0 -2
  80. agentpool/messaging/compaction.py +72 -197
  81. agentpool/messaging/connection_manager.py +11 -10
  82. agentpool/messaging/event_manager.py +5 -5
  83. agentpool/messaging/message_container.py +6 -30
  84. agentpool/messaging/message_history.py +99 -8
  85. agentpool/messaging/messagenode.py +52 -14
  86. agentpool/messaging/messages.py +54 -35
  87. agentpool/messaging/processing.py +12 -22
  88. agentpool/models/__init__.py +1 -1
  89. agentpool/models/acp_agents/base.py +6 -24
  90. agentpool/models/acp_agents/mcp_capable.py +126 -157
  91. agentpool/models/acp_agents/non_mcp.py +129 -95
  92. agentpool/models/agents.py +98 -76
  93. agentpool/models/agui_agents.py +1 -1
  94. agentpool/models/claude_code_agents.py +144 -19
  95. agentpool/models/file_parsing.py +0 -1
  96. agentpool/models/manifest.py +113 -50
  97. agentpool/prompts/conversion_manager.py +1 -1
  98. agentpool/prompts/prompts.py +5 -2
  99. agentpool/repomap.py +1 -1
  100. agentpool/resource_providers/__init__.py +11 -1
  101. agentpool/resource_providers/aggregating.py +56 -5
  102. agentpool/resource_providers/base.py +70 -4
  103. agentpool/resource_providers/codemode/code_executor.py +72 -5
  104. agentpool/resource_providers/codemode/helpers.py +2 -2
  105. agentpool/resource_providers/codemode/provider.py +64 -12
  106. agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
  107. agentpool/resource_providers/codemode/remote_provider.py +9 -12
  108. agentpool/resource_providers/filtering.py +3 -1
  109. agentpool/resource_providers/mcp_provider.py +89 -12
  110. agentpool/resource_providers/plan_provider.py +228 -46
  111. agentpool/resource_providers/pool.py +7 -3
  112. agentpool/resource_providers/resource_info.py +111 -0
  113. agentpool/resource_providers/static.py +4 -2
  114. agentpool/sessions/__init__.py +4 -1
  115. agentpool/sessions/manager.py +33 -5
  116. agentpool/sessions/models.py +59 -6
  117. agentpool/sessions/protocol.py +28 -0
  118. agentpool/sessions/session.py +11 -55
  119. agentpool/skills/registry.py +13 -8
  120. agentpool/storage/manager.py +572 -49
  121. agentpool/talk/registry.py +4 -4
  122. agentpool/talk/talk.py +9 -10
  123. agentpool/testing.py +538 -20
  124. agentpool/tool_impls/__init__.py +6 -0
  125. agentpool/tool_impls/agent_cli/__init__.py +42 -0
  126. agentpool/tool_impls/agent_cli/tool.py +95 -0
  127. agentpool/tool_impls/bash/__init__.py +64 -0
  128. agentpool/tool_impls/bash/helpers.py +35 -0
  129. agentpool/tool_impls/bash/tool.py +171 -0
  130. agentpool/tool_impls/delete_path/__init__.py +70 -0
  131. agentpool/tool_impls/delete_path/tool.py +142 -0
  132. agentpool/tool_impls/download_file/__init__.py +80 -0
  133. agentpool/tool_impls/download_file/tool.py +183 -0
  134. agentpool/tool_impls/execute_code/__init__.py +55 -0
  135. agentpool/tool_impls/execute_code/tool.py +163 -0
  136. agentpool/tool_impls/grep/__init__.py +80 -0
  137. agentpool/tool_impls/grep/tool.py +200 -0
  138. agentpool/tool_impls/list_directory/__init__.py +73 -0
  139. agentpool/tool_impls/list_directory/tool.py +197 -0
  140. agentpool/tool_impls/question/__init__.py +42 -0
  141. agentpool/tool_impls/question/tool.py +127 -0
  142. agentpool/tool_impls/read/__init__.py +104 -0
  143. agentpool/tool_impls/read/tool.py +305 -0
  144. agentpool/tools/__init__.py +2 -1
  145. agentpool/tools/base.py +114 -34
  146. agentpool/tools/manager.py +57 -1
  147. agentpool/ui/base.py +2 -2
  148. agentpool/ui/mock_provider.py +2 -2
  149. agentpool/ui/stdlib_provider.py +2 -2
  150. agentpool/utils/file_watcher.py +269 -0
  151. agentpool/utils/identifiers.py +121 -0
  152. agentpool/utils/pydantic_ai_helpers.py +46 -0
  153. agentpool/utils/streams.py +616 -2
  154. agentpool/utils/subprocess_utils.py +155 -0
  155. agentpool/utils/token_breakdown.py +461 -0
  156. agentpool/vfs_registry.py +7 -2
  157. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/METADATA +41 -27
  158. agentpool-2.5.0.dist-info/RECORD +579 -0
  159. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
  160. agentpool_cli/__main__.py +24 -0
  161. agentpool_cli/create.py +1 -1
  162. agentpool_cli/serve_acp.py +100 -21
  163. agentpool_cli/serve_agui.py +87 -0
  164. agentpool_cli/serve_opencode.py +119 -0
  165. agentpool_cli/ui.py +557 -0
  166. agentpool_commands/__init__.py +42 -5
  167. agentpool_commands/agents.py +75 -2
  168. agentpool_commands/history.py +62 -0
  169. agentpool_commands/mcp.py +176 -0
  170. agentpool_commands/models.py +56 -3
  171. agentpool_commands/pool.py +260 -0
  172. agentpool_commands/session.py +1 -1
  173. agentpool_commands/text_sharing/__init__.py +119 -0
  174. agentpool_commands/text_sharing/base.py +123 -0
  175. agentpool_commands/text_sharing/github_gist.py +80 -0
  176. agentpool_commands/text_sharing/opencode.py +462 -0
  177. agentpool_commands/text_sharing/paste_rs.py +59 -0
  178. agentpool_commands/text_sharing/pastebin.py +116 -0
  179. agentpool_commands/text_sharing/shittycodingagent.py +112 -0
  180. agentpool_commands/tools.py +57 -0
  181. agentpool_commands/utils.py +80 -30
  182. agentpool_config/__init__.py +30 -2
  183. agentpool_config/agentpool_tools.py +498 -0
  184. agentpool_config/builtin_tools.py +77 -22
  185. agentpool_config/commands.py +24 -1
  186. agentpool_config/compaction.py +258 -0
  187. agentpool_config/converters.py +1 -1
  188. agentpool_config/event_handlers.py +42 -0
  189. agentpool_config/events.py +1 -1
  190. agentpool_config/forward_targets.py +1 -4
  191. agentpool_config/jinja.py +3 -3
  192. agentpool_config/mcp_server.py +132 -6
  193. agentpool_config/nodes.py +1 -1
  194. agentpool_config/observability.py +44 -0
  195. agentpool_config/session.py +0 -3
  196. agentpool_config/storage.py +82 -38
  197. agentpool_config/task.py +3 -3
  198. agentpool_config/tools.py +11 -22
  199. agentpool_config/toolsets.py +109 -233
  200. agentpool_server/a2a_server/agent_worker.py +307 -0
  201. agentpool_server/a2a_server/server.py +23 -18
  202. agentpool_server/acp_server/acp_agent.py +234 -181
  203. agentpool_server/acp_server/commands/acp_commands.py +151 -156
  204. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +18 -17
  205. agentpool_server/acp_server/event_converter.py +651 -0
  206. agentpool_server/acp_server/input_provider.py +53 -10
  207. agentpool_server/acp_server/server.py +24 -90
  208. agentpool_server/acp_server/session.py +173 -331
  209. agentpool_server/acp_server/session_manager.py +8 -34
  210. agentpool_server/agui_server/server.py +3 -1
  211. agentpool_server/mcp_server/server.py +5 -2
  212. agentpool_server/opencode_server/.rules +95 -0
  213. agentpool_server/opencode_server/ENDPOINTS.md +401 -0
  214. agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
  215. agentpool_server/opencode_server/__init__.py +19 -0
  216. agentpool_server/opencode_server/command_validation.py +172 -0
  217. agentpool_server/opencode_server/converters.py +975 -0
  218. agentpool_server/opencode_server/dependencies.py +24 -0
  219. agentpool_server/opencode_server/input_provider.py +421 -0
  220. agentpool_server/opencode_server/models/__init__.py +250 -0
  221. agentpool_server/opencode_server/models/agent.py +53 -0
  222. agentpool_server/opencode_server/models/app.py +72 -0
  223. agentpool_server/opencode_server/models/base.py +26 -0
  224. agentpool_server/opencode_server/models/common.py +23 -0
  225. agentpool_server/opencode_server/models/config.py +37 -0
  226. agentpool_server/opencode_server/models/events.py +821 -0
  227. agentpool_server/opencode_server/models/file.py +88 -0
  228. agentpool_server/opencode_server/models/mcp.py +44 -0
  229. agentpool_server/opencode_server/models/message.py +179 -0
  230. agentpool_server/opencode_server/models/parts.py +323 -0
  231. agentpool_server/opencode_server/models/provider.py +81 -0
  232. agentpool_server/opencode_server/models/pty.py +43 -0
  233. agentpool_server/opencode_server/models/question.py +56 -0
  234. agentpool_server/opencode_server/models/session.py +111 -0
  235. agentpool_server/opencode_server/routes/__init__.py +29 -0
  236. agentpool_server/opencode_server/routes/agent_routes.py +473 -0
  237. agentpool_server/opencode_server/routes/app_routes.py +202 -0
  238. agentpool_server/opencode_server/routes/config_routes.py +302 -0
  239. agentpool_server/opencode_server/routes/file_routes.py +571 -0
  240. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  241. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  242. agentpool_server/opencode_server/routes/message_routes.py +761 -0
  243. agentpool_server/opencode_server/routes/permission_routes.py +63 -0
  244. agentpool_server/opencode_server/routes/pty_routes.py +300 -0
  245. agentpool_server/opencode_server/routes/question_routes.py +128 -0
  246. agentpool_server/opencode_server/routes/session_routes.py +1276 -0
  247. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  248. agentpool_server/opencode_server/server.py +475 -0
  249. agentpool_server/opencode_server/state.py +151 -0
  250. agentpool_server/opencode_server/time_utils.py +8 -0
  251. agentpool_storage/__init__.py +12 -0
  252. agentpool_storage/base.py +184 -2
  253. agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
  254. agentpool_storage/claude_provider/__init__.py +42 -0
  255. agentpool_storage/claude_provider/provider.py +1089 -0
  256. agentpool_storage/file_provider.py +278 -15
  257. agentpool_storage/memory_provider.py +193 -12
  258. agentpool_storage/models.py +3 -0
  259. agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
  260. agentpool_storage/opencode_provider/__init__.py +16 -0
  261. agentpool_storage/opencode_provider/helpers.py +414 -0
  262. agentpool_storage/opencode_provider/provider.py +895 -0
  263. agentpool_storage/project_store.py +325 -0
  264. agentpool_storage/session_store.py +26 -6
  265. agentpool_storage/sql_provider/__init__.py +4 -2
  266. agentpool_storage/sql_provider/models.py +48 -0
  267. agentpool_storage/sql_provider/sql_provider.py +269 -3
  268. agentpool_storage/sql_provider/utils.py +12 -13
  269. agentpool_storage/zed_provider/__init__.py +16 -0
  270. agentpool_storage/zed_provider/helpers.py +281 -0
  271. agentpool_storage/zed_provider/models.py +130 -0
  272. agentpool_storage/zed_provider/provider.py +442 -0
  273. agentpool_storage/zed_provider.py +803 -0
  274. agentpool_toolsets/__init__.py +0 -2
  275. agentpool_toolsets/builtin/__init__.py +2 -12
  276. agentpool_toolsets/builtin/code.py +96 -57
  277. agentpool_toolsets/builtin/debug.py +118 -48
  278. agentpool_toolsets/builtin/execution_environment.py +115 -230
  279. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  280. agentpool_toolsets/builtin/skills.py +9 -4
  281. agentpool_toolsets/builtin/subagent_tools.py +64 -51
  282. agentpool_toolsets/builtin/workers.py +4 -2
  283. agentpool_toolsets/composio_toolset.py +2 -2
  284. agentpool_toolsets/entry_points.py +3 -1
  285. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  286. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  287. agentpool_toolsets/fsspec_toolset/grep.py +99 -7
  288. agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
  289. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  290. agentpool_toolsets/fsspec_toolset/toolset.py +500 -95
  291. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  292. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  293. agentpool_toolsets/mcp_discovery/toolset.py +511 -0
  294. agentpool_toolsets/mcp_run_toolset.py +87 -12
  295. agentpool_toolsets/notifications.py +33 -33
  296. agentpool_toolsets/openapi.py +3 -1
  297. agentpool_toolsets/search_toolset.py +3 -1
  298. agentpool-2.1.9.dist-info/RECORD +0 -474
  299. agentpool_config/resources.py +0 -33
  300. agentpool_server/acp_server/acp_tools.py +0 -43
  301. agentpool_server/acp_server/commands/spawn.py +0 -210
  302. agentpool_storage/text_log_provider.py +0 -275
  303. agentpool_toolsets/builtin/agent_management.py +0 -239
  304. agentpool_toolsets/builtin/chain.py +0 -288
  305. agentpool_toolsets/builtin/history.py +0 -36
  306. agentpool_toolsets/builtin/integration.py +0 -85
  307. agentpool_toolsets/builtin/tool_management.py +0 -90
  308. agentpool_toolsets/builtin/user_interaction.py +0 -52
  309. agentpool_toolsets/semantic_memory_toolset.py +0 -536
  310. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
  311. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -7,18 +7,20 @@ from fnmatch import fnmatch
7
7
  import os
8
8
  from pathlib import Path
9
9
  import time
10
- from typing import TYPE_CHECKING, Any
10
+ from typing import TYPE_CHECKING, Any, Literal
11
11
  from urllib.parse import urlparse
12
12
 
13
13
  import anyio
14
14
  from exxec.base import ExecutionEnvironment
15
15
  from pydantic_ai import (
16
16
  BinaryContent,
17
+ ModelResponse,
17
18
  PartDeltaEvent,
18
19
  PartStartEvent,
19
20
  RunContext, # noqa: TC002
20
21
  TextPart,
21
22
  TextPartDelta,
23
+ ToolCallPart,
22
24
  )
23
25
  from upathtools import is_directory
24
26
 
@@ -26,9 +28,19 @@ from agentpool.agents.context import AgentContext # noqa: TC001
26
28
  from agentpool.log import get_logger
27
29
  from agentpool.mime_utils import guess_type, is_binary_content, is_binary_mime
28
30
  from agentpool.resource_providers import ResourceProvider
31
+ from agentpool.tool_impls.delete_path import create_delete_path_tool
32
+ from agentpool.tool_impls.download_file import create_download_file_tool
33
+ from agentpool.tool_impls.grep import create_grep_tool
34
+ from agentpool.tool_impls.list_directory import create_list_directory_tool
35
+ from agentpool.tool_impls.read import create_read_tool
36
+ from agentpool.tools.base import ToolResult # noqa: TC001
29
37
  from agentpool_toolsets.builtin.file_edit import replace_content
30
38
  from agentpool_toolsets.builtin.file_edit.fuzzy_matcher import StreamingFuzzyMatcher
31
- from agentpool_toolsets.fsspec_toolset.diagnostics import DiagnosticsManager
39
+ from agentpool_toolsets.fsspec_toolset.diagnostics import (
40
+ DiagnosticsConfig,
41
+ DiagnosticsManager,
42
+ format_diagnostics_table,
43
+ )
32
44
  from agentpool_toolsets.fsspec_toolset.grep import GrepBackend
33
45
  from agentpool_toolsets.fsspec_toolset.helpers import (
34
46
  format_directory_listing,
@@ -43,9 +55,11 @@ from agentpool_toolsets.fsspec_toolset.streaming_diff_parser import (
43
55
 
44
56
 
45
57
  if TYPE_CHECKING:
58
+ from collections.abc import Sequence
59
+
46
60
  import fsspec
47
61
  from fsspec.asyn import AsyncFileSystem
48
- from pydantic_ai.messages import ModelResponse
62
+ from pydantic_ai import ModelRequest
49
63
 
50
64
  from agentpool.agents.base_agent import BaseAgent
51
65
  from agentpool.common_types import ModelType
@@ -78,6 +92,9 @@ class FSSpecTools(ResourceProvider):
78
92
  enable_diagnostics: bool = False,
79
93
  large_file_tokens: int = 12_000,
80
94
  map_max_tokens: int = 2048,
95
+ edit_tool: Literal["simple", "batch", "agentic"] = "simple",
96
+ max_image_size: int | None = 2000,
97
+ max_image_bytes: int | None = None,
81
98
  ) -> None:
82
99
  """Initialize with an fsspec filesystem or execution environment.
83
100
 
@@ -94,6 +111,12 @@ class FSSpecTools(ResourceProvider):
94
111
  enable_diagnostics: Run LSP CLI diagnostics after file writes (default: False)
95
112
  large_file_tokens: Token threshold for switching to structure map (default: 12000)
96
113
  map_max_tokens: Maximum tokens for structure map output (default: 2048)
114
+ edit_tool: Which edit variant to expose ("simple" or "batch")
115
+ max_image_size: Max width/height for images in pixels. Larger images are
116
+ auto-resized for better model compatibility. Set to None to disable.
117
+ max_image_bytes: Max file size for images in bytes. Images exceeding this
118
+ are compressed using progressive quality/dimension reduction.
119
+ Default: 4.5MB (below Anthropic's 5MB limit).
97
120
  """
98
121
  from fsspec.asyn import AsyncFileSystem
99
122
  from fsspec.implementations.asyn_wrapper import AsyncFileSystemWrapper
@@ -124,8 +147,11 @@ class FSSpecTools(ResourceProvider):
124
147
  self._large_file_tokens = large_file_tokens
125
148
  self._map_max_tokens = map_max_tokens
126
149
  self._repomap: RepoMap | None = None
150
+ self._edit_tool = edit_tool
151
+ self._max_image_size = max_image_size
152
+ self._max_image_bytes = max_image_bytes
127
153
 
128
- def get_fs(self, agent_ctx: AgentContext) -> AsyncFileSystem:
154
+ def _get_fs(self, agent_ctx: AgentContext) -> AsyncFileSystem:
129
155
  """Get filesystem, falling back to agent's env if not set.
130
156
 
131
157
  Args:
@@ -139,11 +165,15 @@ class FSSpecTools(ResourceProvider):
139
165
  fs = agent_ctx.agent.env.get_fs()
140
166
  return fs if isinstance(fs, AsyncFileSystem) else AsyncFileSystemWrapper(fs)
141
167
 
142
- def _get_diagnostics_manager(self, agent_ctx: AgentContext) -> DiagnosticsManager:
168
+ def _get_diagnostics_manager(self, agent_ctx: AgentContext) -> DiagnosticsManager | None:
143
169
  """Get or create the diagnostics manager."""
170
+ if not self._enable_diagnostics:
171
+ return None
144
172
  if self._diagnostics is None:
145
173
  env = self.execution_env or agent_ctx.agent.env
146
- self._diagnostics = DiagnosticsManager(env if self._enable_diagnostics else None)
174
+ # Default to rust-only for fast feedback after edits
175
+ config = DiagnosticsConfig(rust_only=True, max_servers_per_language=1)
176
+ self._diagnostics = DiagnosticsManager(env, config=config)
147
177
  return self._diagnostics
148
178
 
149
179
  async def _run_diagnostics(self, agent_ctx: AgentContext, path: str) -> str | None:
@@ -151,12 +181,12 @@ class FSSpecTools(ResourceProvider):
151
181
 
152
182
  Returns formatted diagnostics string if issues found, None otherwise.
153
183
  """
154
- if not self._enable_diagnostics:
155
- return None
156
184
  mgr = self._get_diagnostics_manager(agent_ctx)
157
- diagnostics = await mgr.run_for_file(path)
158
- if diagnostics:
159
- return mgr.format_diagnostics(diagnostics)
185
+ if mgr is None:
186
+ return None
187
+ result = await mgr.run_for_file(path)
188
+ if result.diagnostics:
189
+ return format_diagnostics_table(result.diagnostics)
160
190
  return None
161
191
 
162
192
  async def _get_file_map(self, path: str, agent_ctx: AgentContext) -> str | None:
@@ -177,7 +207,7 @@ class FSSpecTools(ResourceProvider):
177
207
  # Lazy init repomap - use file's directory as root
178
208
  if self._repomap is None:
179
209
  root = str(Path(path).parent)
180
- fs = self.get_fs(agent_ctx)
210
+ fs = self._get_fs(agent_ctx)
181
211
  self._repomap = RepoMap(fs, root, max_tokens=self._map_max_tokens)
182
212
 
183
213
  return await self._repomap.get_file_map(path, max_tokens=self._map_max_tokens)
@@ -202,31 +232,66 @@ class FSSpecTools(ResourceProvider):
202
232
  return str(Path(cwd) / path)
203
233
  return path
204
234
 
205
- async def get_tools(self) -> list[Tool]:
235
+ async def get_tools(self) -> Sequence[Tool]:
206
236
  """Get filesystem tools."""
207
237
  if self._tools is not None:
208
238
  return self._tools
209
239
 
240
+ # Create standalone tools with toolset's configuration
241
+ list_dir_tool = create_list_directory_tool(
242
+ env=self.execution_env,
243
+ cwd=self.cwd,
244
+ )
245
+
246
+ read_tool = create_read_tool(
247
+ env=self.execution_env,
248
+ converter=self.converter, # Pass converter for automatic markdown conversion
249
+ cwd=self.cwd,
250
+ max_file_size_kb=self.max_file_size // 1024,
251
+ max_image_size=self._max_image_size,
252
+ max_image_bytes=self._max_image_bytes,
253
+ large_file_tokens=self._large_file_tokens,
254
+ map_max_tokens=self._map_max_tokens,
255
+ )
256
+
257
+ grep_tool = create_grep_tool(
258
+ env=self.execution_env,
259
+ cwd=self.cwd,
260
+ max_output_kb=self.max_grep_output // 1024,
261
+ use_subprocess_grep=self.use_subprocess_grep,
262
+ )
263
+
264
+ delete_tool = create_delete_path_tool(
265
+ env=self.execution_env,
266
+ cwd=self.cwd,
267
+ )
268
+
269
+ download_tool = create_download_file_tool(
270
+ env=self.execution_env,
271
+ cwd=self.cwd,
272
+ )
273
+
210
274
  self._tools = [
211
- self.create_tool(self.list_directory, category="read", read_only=True, idempotent=True),
212
- self.create_tool(self.read_file, category="read", read_only=True, idempotent=True),
213
- self.create_tool(self.grep, category="search", read_only=True, idempotent=True),
214
- self.create_tool(self.write_file, category="edit"),
215
- self.create_tool(self.delete_path, category="delete", destructive=True),
216
- self.create_tool(self.edit_file, category="edit"),
217
- self.create_tool(self.agentic_edit, category="edit"),
218
- self.create_tool(self.download_file, category="read", open_world=True),
275
+ list_dir_tool,
276
+ read_tool,
277
+ grep_tool,
278
+ self.create_tool(self.write, category="edit"),
279
+ delete_tool,
280
+ download_tool,
219
281
  ]
220
282
 
221
- if self.converter: # Only add read_as_markdown if converter is available
283
+ # Add edit tool based on config - mutually exclusive
284
+ if self._edit_tool == "agentic":
285
+ self._tools.append(self.create_tool(self.agentic_edit, category="edit"))
286
+ elif self._edit_tool == "batch":
222
287
  self._tools.append(
223
- self.create_tool(
224
- self.read_as_markdown,
225
- category="read",
226
- read_only=True,
227
- idempotent=True,
228
- )
288
+ self.create_tool(self.edit_batch, category="edit", name_override="edit")
229
289
  )
290
+ else: # simple
291
+ self._tools.append(self.create_tool(self.edit, category="edit"))
292
+
293
+ # Add regex line editing tool
294
+ self._tools.append(self.create_tool(self.regex_replace_lines, category="edit"))
230
295
 
231
296
  return self._tools
232
297
 
@@ -258,7 +323,7 @@ class FSSpecTools(ResourceProvider):
258
323
  await agent_ctx.events.tool_call_start(title=msg, kind="read", locations=[path])
259
324
 
260
325
  try:
261
- fs = self.get_fs(agent_ctx)
326
+ fs = self._get_fs(agent_ctx)
262
327
  # Check if path exists
263
328
  if not await fs._exists(path):
264
329
  error_msg = f"Path does not exist: {path}"
@@ -329,14 +394,14 @@ class FSSpecTools(ResourceProvider):
329
394
  else:
330
395
  return result
331
396
 
332
- async def read_file( # noqa: D417
397
+ async def read( # noqa: D417
333
398
  self,
334
399
  agent_ctx: AgentContext,
335
400
  path: str,
336
401
  encoding: str = "utf-8",
337
402
  line: int | None = None,
338
403
  limit: int | None = None,
339
- ) -> str | BinaryContent:
404
+ ) -> str | BinaryContent | list[str | BinaryContent]:
340
405
  """Read the context of a text file, or use vision capabilites to read images or documents.
341
406
 
342
407
  Args:
@@ -346,30 +411,57 @@ class FSSpecTools(ResourceProvider):
346
411
  limit: Optional maximum number of lines to read (text files only)
347
412
 
348
413
  Returns:
349
- Text content for text files, BinaryContent for binary files, or dict with error
414
+ Text content for text files, BinaryContent for binary files (with optional
415
+ dimension note as list when image was resized), or dict with error
350
416
  """
351
417
  path = self._resolve_path(path, agent_ctx)
352
418
  msg = f"Reading file: {path}"
353
419
  from agentpool.agents.events import LocationContentItem
354
420
 
421
+ # Emit progress - use 0 for line if negative (can't resolve until we read file)
422
+ # LocationContentItem/ToolCallLocation require line >= 0 per ACP spec
423
+ display_line = line if (line is not None and line > 0) else 0
355
424
  await agent_ctx.events.tool_call_progress(
356
425
  title=msg,
357
- items=[LocationContentItem(path=path)],
426
+ items=[LocationContentItem(path=path, line=display_line)],
358
427
  )
359
428
  try:
360
429
  mime_type = guess_type(path)
361
430
  # Fast path: known binary MIME types (images, audio, video, etc.)
362
431
  if is_binary_mime(mime_type):
363
- data = await self.get_fs(agent_ctx)._cat_file(path)
432
+ data = await self._get_fs(agent_ctx)._cat_file(path)
364
433
  await agent_ctx.events.file_operation("read", path=path, success=True)
365
434
  mime = mime_type or "application/octet-stream"
435
+ # Resize images if needed
436
+ if self._max_image_size and mime.startswith("image/"):
437
+ from agentpool_toolsets.fsspec_toolset.image_utils import (
438
+ resize_image_if_needed,
439
+ )
440
+
441
+ data, mime, note = resize_image_if_needed(
442
+ data, mime, self._max_image_size, self._max_image_bytes
443
+ )
444
+ if note:
445
+ # Return resized image with dimension note for coordinate mapping
446
+ return [note, BinaryContent(data=data, media_type=mime, identifier=path)]
366
447
  return BinaryContent(data=data, media_type=mime, identifier=path)
367
448
  # Read content and probe for binary (git-style null byte detection)
368
- data = await self.get_fs(agent_ctx)._cat_file(path)
449
+ data = await self._get_fs(agent_ctx)._cat_file(path)
369
450
  if is_binary_content(data):
370
451
  # Binary file - return as BinaryContent for native model handling
371
452
  await agent_ctx.events.file_operation("read", path=path, success=True)
372
453
  mime = mime_type or "application/octet-stream"
454
+ # Resize images if needed
455
+ if self._max_image_size and mime.startswith("image/"):
456
+ from agentpool_toolsets.fsspec_toolset.image_utils import (
457
+ resize_image_if_needed,
458
+ )
459
+
460
+ data, mime, note = resize_image_if_needed(
461
+ data, mime, self._max_image_size, self._max_image_bytes
462
+ )
463
+ if note:
464
+ return [note, BinaryContent(data=data, media_type=mime, identifier=path)]
373
465
  return BinaryContent(data=data, media_type=mime, identifier=path)
374
466
  content = data.decode(encoding)
375
467
 
@@ -395,7 +487,11 @@ class FSSpecTools(ResourceProvider):
395
487
  lines, offset, limit, self.max_file_size
396
488
  )
397
489
  content = "\n".join(result_lines)
398
- await agent_ctx.events.file_operation("read", path=path, success=True)
490
+ # Don't pass negative line numbers to events (ACP requires >= 0)
491
+ display_line = line if (line and line > 0) else 0
492
+ await agent_ctx.events.file_operation(
493
+ "read", path=path, success=True, line=display_line
494
+ )
399
495
  if was_truncated:
400
496
  content += f"\n\n[Content truncated at {self.max_file_size} bytes]"
401
497
 
@@ -406,9 +502,11 @@ class FSSpecTools(ResourceProvider):
406
502
  # Emit file content for UI display (formatted at ACP layer)
407
503
  from agentpool.agents.events import FileContentItem
408
504
 
505
+ # Use non-negative line for display (negative lines are internal Python convention)
506
+ display_start_line = max(1, line) if line and line > 0 else None
409
507
  await agent_ctx.events.tool_call_progress(
410
508
  title=f"Read: {path}",
411
- items=[FileContentItem(content=content, path=path)],
509
+ items=[FileContentItem(content=content, path=path, start_line=display_start_line)],
412
510
  replace_content=True,
413
511
  )
414
512
  # Return raw content for agent
@@ -445,14 +543,14 @@ class FSSpecTools(ResourceProvider):
445
543
  else:
446
544
  return content
447
545
 
448
- async def write_file( # noqa: D417
546
+ async def write( # noqa: D417
449
547
  self,
450
548
  agent_ctx: AgentContext,
451
549
  path: str,
452
550
  content: str,
453
551
  mode: str = "w",
454
552
  overwrite: bool = False,
455
- ) -> dict[str, Any]:
553
+ ) -> str | ToolResult:
456
554
  """Write content to a file.
457
555
 
458
556
  Args:
@@ -462,8 +560,11 @@ class FSSpecTools(ResourceProvider):
462
560
  overwrite: Must be True to overwrite existing files (safety check)
463
561
 
464
562
  Returns:
465
- Dictionary with success info or error details
563
+ Success message or ToolResult with metadata
466
564
  """
565
+ from agentpool.agents.events import DiffContentItem
566
+ from agentpool.tools.base import ToolResult
567
+
467
568
  path = self._resolve_path(path, agent_ctx)
468
569
  msg = f"Writing file: {path}"
469
570
  await agent_ctx.events.tool_call_start(title=msg, kind="edit", locations=[path])
@@ -474,7 +575,7 @@ class FSSpecTools(ResourceProvider):
474
575
  if mode not in ("w", "a"):
475
576
  msg = f"Invalid mode '{mode}'. Use 'w' (write) or 'a' (append)"
476
577
  await agent_ctx.events.file_operation("write", path=path, success=False, error=msg)
477
- return {"error": msg}
578
+ return f"Error: {msg}"
478
579
 
479
580
  # Check size limit
480
581
  if content_bytes > self.max_file_size:
@@ -483,10 +584,10 @@ class FSSpecTools(ResourceProvider):
483
584
  f"({self.max_file_size} bytes)"
484
585
  )
485
586
  await agent_ctx.events.file_operation("write", path=path, success=False, error=msg)
486
- return {"error": msg}
587
+ return f"Error: {msg}"
487
588
 
488
589
  # Check if file exists and overwrite protection
489
- fs = self.get_fs(agent_ctx)
590
+ fs = self._get_fs(agent_ctx)
490
591
  file_exists = await fs._exists(path)
491
592
 
492
593
  if file_exists and mode == "w" and not overwrite:
@@ -495,7 +596,7 @@ class FSSpecTools(ResourceProvider):
495
596
  f"This is a safety measure to prevent accidental data loss."
496
597
  )
497
598
  await agent_ctx.events.file_operation("write", path=path, success=False, error=msg)
498
- return {"error": msg}
599
+ return f"Error: {msg}"
499
600
 
500
601
  # Handle append mode: read existing content and prepend it
501
602
  if mode == "a" and file_exists:
@@ -508,30 +609,47 @@ class FSSpecTools(ResourceProvider):
508
609
  pass # If we can't read, just write new content
509
610
 
510
611
  await self._write(agent_ctx, path, content)
612
+ await agent_ctx.events.tool_call_progress(
613
+ title=f"Wrote: {path}",
614
+ items=[
615
+ DiffContentItem(path=path, old_text="", new_text=content),
616
+ ],
617
+ )
511
618
 
512
- try:
513
- info = await fs._info(path)
514
- size = info.get("size", content_bytes)
515
- except (OSError, KeyError):
516
- size = content_bytes
517
-
518
- result: dict[str, Any] = {
519
- "path": path,
520
- "size": size,
521
- "mode": mode,
522
- "file_existed": file_exists,
523
- "bytes_written": content_bytes,
524
- }
525
- await agent_ctx.events.file_operation("write", path=path, success=True)
526
-
527
- # Run diagnostics if enabled
619
+ # Run diagnostics if enabled (include in message for agent)
620
+ diagnostics_msg = ""
528
621
  if diagnostics_output := await self._run_diagnostics(agent_ctx, path):
529
- result["diagnostics"] = diagnostics_output
622
+ diagnostics_msg = f"\n\nDiagnostics:\n{diagnostics_output}"
623
+
624
+ action = "Appended to" if mode == "a" and file_exists else "Wrote"
625
+ success_msg = f"{action} {path} ({content_bytes} bytes){diagnostics_msg}"
626
+
627
+ # TODO: Include diagnostics in metadata for UI display
628
+ # Expected metadata shape:
629
+ # {
630
+ # "diagnostics": {
631
+ # "<file_path>": [
632
+ # {
633
+ # "range": {"start": {"line": 0, "character": 0}, "end": {...}},
634
+ # "message": "...",
635
+ # "severity": 1 # 1=error, 2=warning, 3=info, 4=hint
636
+ # }
637
+ # ]
638
+ # }
639
+ # }
640
+
641
+ return ToolResult(
642
+ content=success_msg, # Agent sees this (includes diagnostics text)
643
+ metadata={
644
+ # Include file content for UI display (used by OpenCode TUI)
645
+ "filePath": str(Path(path).absolute()),
646
+ "content": content,
647
+ # TODO: Add structured diagnostics here for UI
648
+ },
649
+ )
530
650
  except Exception as e: # noqa: BLE001
531
651
  await agent_ctx.events.file_operation("write", path=path, success=False, error=str(e))
532
- return {"error": f"Failed to write file {path}: {e}"}
533
- else:
534
- return result
652
+ return f"Error: Failed to write file {path}: {e}"
535
653
 
536
654
  async def delete_path( # noqa: D417
537
655
  self, agent_ctx: AgentContext, path: str, recursive: bool = False
@@ -550,7 +668,7 @@ class FSSpecTools(ResourceProvider):
550
668
  await agent_ctx.events.tool_call_start(title=msg, kind="delete", locations=[path])
551
669
  try:
552
670
  # Check if path exists and get its type
553
- fs = self.get_fs(agent_ctx)
671
+ fs = self._get_fs(agent_ctx)
554
672
  try:
555
673
  info = await fs._info(path)
556
674
  path_type = info.get("type", "unknown")
@@ -599,7 +717,7 @@ class FSSpecTools(ResourceProvider):
599
717
  await agent_ctx.events.file_operation("delete", path=path, success=True)
600
718
  return result
601
719
 
602
- async def edit_file( # noqa: D417
720
+ async def edit( # noqa: D417
603
721
  self,
604
722
  agent_ctx: AgentContext,
605
723
  path: str,
@@ -607,7 +725,8 @@ class FSSpecTools(ResourceProvider):
607
725
  new_string: str,
608
726
  description: str,
609
727
  replace_all: bool = False,
610
- ) -> str:
728
+ line_hint: int | None = None,
729
+ ) -> str | ToolResult:
611
730
  r"""Edit a file by replacing specific content with smart matching.
612
731
 
613
732
  Uses sophisticated matching strategies to handle whitespace, indentation,
@@ -619,32 +738,95 @@ class FSSpecTools(ResourceProvider):
619
738
  new_string: Text content to replace it with
620
739
  description: Human-readable description of what the edit accomplishes
621
740
  replace_all: Whether to replace all occurrences (default: False)
741
+ line_hint: Line number hint to disambiguate when multiple matches exist.
742
+ If the pattern matches multiple locations, the match closest to this
743
+ line will be used. Useful after getting a "multiple matches" error.
622
744
 
623
745
  Returns:
624
746
  Success message with edit summary
625
747
  """
748
+ return await self.edit_batch(
749
+ agent_ctx,
750
+ path,
751
+ replacements=[(old_string, new_string)],
752
+ description=description,
753
+ replace_all=replace_all,
754
+ line_hint=line_hint,
755
+ )
756
+
757
+ async def edit_batch( # noqa: D417
758
+ self,
759
+ agent_ctx: AgentContext,
760
+ path: str,
761
+ replacements: list[tuple[str, str]],
762
+ description: str,
763
+ replace_all: bool = False,
764
+ line_hint: int | None = None,
765
+ ) -> str | ToolResult:
766
+ r"""Edit a file by applying multiple replacements in one operation.
767
+
768
+ Uses sophisticated matching strategies to handle whitespace, indentation,
769
+ and other variations. Shows the changes as a diff in the UI.
770
+
771
+ Replacements are applied sequentially, so later replacements see the result
772
+ of earlier ones. Each old_string must uniquely match one location (unless
773
+ replace_all=True). If a pattern matches multiple locations, include more
774
+ surrounding context to disambiguate.
775
+
776
+ Args:
777
+ path: File path (absolute or relative to session cwd)
778
+ replacements: List of (old_string, new_string) tuples to apply sequentially.
779
+ IMPORTANT: Must be a list of pairs, like:
780
+ [("old text", "new text"), ("another old", "another new")]
781
+
782
+ Each old_string should include enough context to uniquely identify
783
+ the target location. For multi-line edits, include the full block.
784
+ description: Human-readable description of what the edit accomplishes
785
+ replace_all: Whether to replace all occurrences of each pattern (default: False)
786
+ line_hint: Line number hint to disambiguate when multiple matches exist.
787
+ Only applies when there is a single replacement. If the pattern matches
788
+ multiple locations, the match closest to this line will be used.
789
+
790
+ Returns:
791
+ Success message with edit summary
792
+
793
+ Example:
794
+ replacements=[
795
+ ("def old_name(", "def new_name("),
796
+ ("old_name()", "new_name()"), # Update call sites
797
+ ]
798
+ """
626
799
  path = self._resolve_path(path, agent_ctx)
627
800
  msg = f"Editing file: {path}"
628
801
  await agent_ctx.events.tool_call_start(title=msg, kind="edit", locations=[path])
629
- if old_string == new_string:
630
- return "Error: old_string and new_string must be different"
631
802
 
632
- # Send initial pending notification
633
- await agent_ctx.events.file_operation("edit", path=path, success=True)
803
+ if not replacements:
804
+ return "Error: replacements list cannot be empty"
805
+
806
+ for old_str, new_str in replacements:
807
+ if old_str == new_str:
808
+ return f"Error: old_string and new_string must be different: {old_str!r}"
634
809
 
635
810
  try: # Read current file content
636
811
  original_content = await self._read(agent_ctx, path)
637
812
  if isinstance(original_content, bytes):
638
813
  original_content = original_content.decode("utf-8")
639
814
 
640
- try: # Apply smart content replacement
641
- new_content = replace_content(original_content, old_string, new_string, replace_all)
642
- except ValueError as e:
643
- error_msg = f"Edit failed: {e}"
644
- await agent_ctx.events.file_operation(
645
- "edit", path=path, success=False, error=error_msg
646
- )
647
- return error_msg
815
+ # Apply all replacements sequentially
816
+ new_content = original_content
817
+ # line_hint only makes sense for single replacements
818
+ hint = line_hint if len(replacements) == 1 else None
819
+ for old_str, new_str in replacements:
820
+ try:
821
+ new_content = replace_content(
822
+ new_content, old_str, new_str, replace_all, line_hint=hint
823
+ )
824
+ except ValueError as e:
825
+ error_msg = f"Edit failed on replacement {old_str!r}: {e}"
826
+ await agent_ctx.events.file_operation(
827
+ "edit", path=path, success=False, error=error_msg
828
+ )
829
+ return error_msg
648
830
 
649
831
  await self._write(agent_ctx, path, new_content)
650
832
  success_msg = f"Successfully edited {Path(path).name}: {description}"
@@ -666,9 +848,238 @@ class FSSpecTools(ResourceProvider):
666
848
  error_msg = f"Error editing file: {e}"
667
849
  await agent_ctx.events.file_operation("edit", path=path, success=False, error=error_msg)
668
850
  return error_msg
851
+ else:
852
+ # Generate unified diff for OpenCode UI
853
+ from difflib import unified_diff
854
+
855
+ from agentpool.tools.base import ToolResult
856
+
857
+ # Ensure content ends with newline for proper diff formatting
858
+ original_for_diff = (
859
+ original_content if original_content.endswith("\n") else original_content + "\n"
860
+ )
861
+ new_for_diff = new_content if new_content.endswith("\n") else new_content + "\n"
862
+
863
+ diff_lines = unified_diff(
864
+ original_for_diff.splitlines(keepends=True),
865
+ new_for_diff.splitlines(keepends=True),
866
+ fromfile=f"a/{Path(path).name}",
867
+ tofile=f"b/{Path(path).name}",
868
+ )
869
+ diff = "".join(diff_lines)
870
+
871
+ # Count additions and deletions
872
+ original_lines = set(original_content.splitlines())
873
+ new_lines = set(new_content.splitlines())
874
+ additions = len(new_lines - original_lines)
875
+ deletions = len(original_lines - new_lines)
876
+
877
+ return ToolResult(
878
+ content=success_msg,
879
+ metadata={
880
+ "diff": diff,
881
+ "filediff": {
882
+ "file": str(Path(path).absolute()),
883
+ "before": original_content,
884
+ "after": new_content,
885
+ "additions": additions,
886
+ "deletions": deletions,
887
+ },
888
+ },
889
+ )
890
+
891
+ async def regex_replace_lines( # noqa: PLR0915
892
+ self,
893
+ agent_ctx: AgentContext,
894
+ path: str,
895
+ start: int | str,
896
+ end: int | str,
897
+ pattern: str,
898
+ replacement: str,
899
+ *,
900
+ count: int = 0,
901
+ ) -> str:
902
+ r"""Apply regex replacement to a line range specified by line numbers or text markers.
903
+
904
+ Useful for systematic edits:
905
+ - Remove/add indentation
906
+ - Comment/uncomment blocks
907
+ - Rename variables within scope
908
+ - Delete line ranges
909
+
910
+ Args:
911
+ agent_ctx: Agent execution context
912
+ path: File path to edit
913
+ start: Start of range - int (1-based line number) or str (unique text marker)
914
+ end: End of range - int (1-based line number) or str (first occurrence after start)
915
+ pattern: Regex pattern to search for within the range
916
+ replacement: Replacement string (supports \1, \2 capture groups; empty removes)
917
+ count: Max replacements per line (0 = unlimited)
918
+
919
+ Returns:
920
+ Success message with statistics
921
+
922
+ Examples:
923
+ # Remove a function
924
+ regex_replace_lines(ctx, "file.py", "def old_func(", " return", r".*\n", "")
925
+
926
+ # Indent by line numbers
927
+ regex_replace_lines(ctx, "file.py", 10, 20, r"^", " ")
928
+
929
+ # Uncomment a section
930
+ regex_replace_lines(ctx, "file.py", "# START", "# END", r"^# ", "")
931
+ """
932
+ import re
933
+
934
+ path = self._resolve_path(path, agent_ctx)
935
+ msg = f"Regex editing file: {path}"
936
+ await agent_ctx.events.tool_call_start(title=msg, kind="edit", locations=[path])
937
+
938
+ try:
939
+ # Read original content
940
+ original_content = await self._read(agent_ctx, path)
941
+ if isinstance(original_content, bytes):
942
+ original_content = original_content.decode("utf-8")
943
+
944
+ lines = original_content.splitlines(keepends=True)
945
+ total_lines = len(lines)
946
+
947
+ # Resolve start position
948
+ if isinstance(start, int):
949
+ if start < 1:
950
+ msg = f"start line must be >= 1, got {start}"
951
+ raise ValueError(msg) # noqa: TRY301
952
+ start_line = start
953
+ else:
954
+ # Find unique occurrence of start string (raises ValueError if not found/unique)
955
+ start_line = self._find_unique_line(lines, start, "start")
956
+
957
+ # Resolve end position
958
+ if isinstance(end, int):
959
+ if end < start_line:
960
+ msg = f"end line {end} must be >= start line {start_line}"
961
+ raise ValueError(msg) # noqa: TRY301
962
+ end_line = end
963
+ else:
964
+ # Find first occurrence of end string after start (raises ValueError if not found)
965
+ end_line = self._find_first_after(lines, end, start_line, "end")
966
+
967
+ # Validate range
968
+ if end_line > total_lines:
969
+ msg = f"end_line {end_line} exceeds file length {total_lines}"
970
+ raise ValueError(msg) # noqa: TRY301
971
+
972
+ # Convert to 0-based indexing for array access
973
+ start_idx = start_line - 1
974
+ end_idx = end_line # end_line is inclusive, but list slice is exclusive
975
+
976
+ # Compile regex pattern
977
+ regex = re.compile(pattern)
978
+
979
+ # Apply replacements to the specified line range
980
+ modified_count = 0
981
+ replacement_count = 0
982
+
983
+ for i in range(start_idx, end_idx):
984
+ original = lines[i]
985
+ modified, num_subs = regex.subn(replacement, original, count=count)
986
+ if num_subs > 0:
987
+ lines[i] = modified
988
+ modified_count += 1
989
+ replacement_count += num_subs
990
+
991
+ # Build new content
992
+ new_content = "".join(lines)
993
+
994
+ # Write back
995
+ await self._write(agent_ctx, path, new_content)
996
+
997
+ # Build success message
998
+ success_msg = (
999
+ f"Successfully applied regex to lines {start_line}-{end_line} in {Path(path).name}"
1000
+ )
1001
+ if modified_count > 0:
1002
+ success_msg += (
1003
+ f" ({modified_count} lines modified, {replacement_count} replacements)"
1004
+ )
1005
+
1006
+ # Emit file edit event for diff display
1007
+ await agent_ctx.events.file_edit_progress(
1008
+ path=path,
1009
+ old_text=original_content,
1010
+ new_text=new_content,
1011
+ status="completed",
1012
+ )
1013
+
1014
+ # Run diagnostics if enabled
1015
+ if diagnostics_output := await self._run_diagnostics(agent_ctx, path):
1016
+ success_msg += f"\n\nDiagnostics:\n{diagnostics_output}"
1017
+ except Exception as e: # noqa: BLE001
1018
+ error_msg = f"Error applying regex to file: {e}"
1019
+ await agent_ctx.events.file_operation("edit", path=path, success=False, error=error_msg)
1020
+ return error_msg
669
1021
  else:
670
1022
  return success_msg
671
1023
 
1024
+ @staticmethod
1025
+ def _find_unique_line(lines: list[str], search_text: str, param_name: str) -> int:
1026
+ """Find unique occurrence of text in lines.
1027
+
1028
+ Args:
1029
+ lines: File lines
1030
+ search_text: Text to search for
1031
+ param_name: Parameter name for error messages
1032
+
1033
+ Returns:
1034
+ Line number (1-based)
1035
+
1036
+ Raises:
1037
+ ValueError: If text not found or matches multiple lines
1038
+ """
1039
+ matches = []
1040
+ for i, line in enumerate(lines, start=1):
1041
+ if search_text in line:
1042
+ matches.append(i)
1043
+
1044
+ if not matches:
1045
+ msg = f"{param_name} text not found: {search_text!r}"
1046
+ raise ValueError(msg)
1047
+ if len(matches) > 1:
1048
+ match_lines = ", ".join(str(m) for m in matches[:5])
1049
+ more = f" and {len(matches) - 5} more" if len(matches) > 5 else "" # noqa: PLR2004
1050
+ msg = (
1051
+ f"{param_name} text matches multiple lines ({match_lines}{more}). "
1052
+ f"Include more context to make it unique."
1053
+ )
1054
+ raise ValueError(msg)
1055
+
1056
+ return matches[0]
1057
+
1058
+ @staticmethod
1059
+ def _find_first_after(
1060
+ lines: list[str], search_text: str, after_line: int, param_name: str
1061
+ ) -> int:
1062
+ """Find first occurrence of text after a given line.
1063
+
1064
+ Args:
1065
+ lines: File lines
1066
+ search_text: Text to search for
1067
+ after_line: Line number to search after (1-based)
1068
+ param_name: Parameter name for error messages
1069
+
1070
+ Returns:
1071
+ Line number (1-based)
1072
+
1073
+ Raises:
1074
+ ValueError: If text not found after the specified line
1075
+ """
1076
+ for i in range(after_line - 1, len(lines)):
1077
+ if search_text in lines[i]:
1078
+ return i + 1
1079
+
1080
+ msg = f"{param_name} text not found after line {after_line}: {search_text!r}"
1081
+ raise ValueError(msg)
1082
+
672
1083
  async def grep( # noqa: D417
673
1084
  self,
674
1085
  agent_ctx: AgentContext,
@@ -731,7 +1142,7 @@ class FSSpecTools(ResourceProvider):
731
1142
 
732
1143
  # Fallback to fsspec grep if subprocess didn't work
733
1144
  if result is None or "error" in result:
734
- fs = self.get_fs(agent_ctx)
1145
+ fs = self._get_fs(agent_ctx)
735
1146
  result = await grep_with_fsspec(
736
1147
  fs=fs,
737
1148
  pattern=pattern,
@@ -774,12 +1185,12 @@ class FSSpecTools(ResourceProvider):
774
1185
  async def _read(self, agent_ctx: AgentContext, path: str, encoding: str = "utf-8") -> str:
775
1186
  # with self.fs.open(path, "r", encoding="utf-8") as f:
776
1187
  # return f.read()
777
- return await self.get_fs(agent_ctx)._cat(path) # type: ignore[no-any-return]
1188
+ return await self._get_fs(agent_ctx)._cat(path) # type: ignore[no-any-return]
778
1189
 
779
1190
  async def _write(self, agent_ctx: AgentContext, path: str, content: str | bytes) -> None:
780
1191
  if isinstance(content, str):
781
1192
  content = content.encode()
782
- await self.get_fs(agent_ctx)._pipe_file(path, content)
1193
+ await self._get_fs(agent_ctx)._pipe_file(path, content)
783
1194
 
784
1195
  async def download_file( # noqa: D417
785
1196
  self,
@@ -813,7 +1224,7 @@ class FSSpecTools(ResourceProvider):
813
1224
  full_path = f"{target_dir.rstrip('/')}/{filename}"
814
1225
 
815
1226
  try:
816
- fs = self.get_fs(agent_ctx)
1227
+ fs = self._get_fs(agent_ctx)
817
1228
  # Ensure target directory exists
818
1229
  await fs._makedirs(target_dir, exist_ok=True)
819
1230
 
@@ -876,7 +1287,7 @@ class FSSpecTools(ResourceProvider):
876
1287
  await agent_ctx.events.file_operation("read", path=url, success=False, error=error_msg)
877
1288
  return {"error": error_msg}
878
1289
 
879
- async def agentic_edit( # noqa: D417, PLR0915
1290
+ async def agentic_edit( # noqa: D417
880
1291
  self,
881
1292
  run_ctx: RunContext,
882
1293
  agent_ctx: AgentContext,
@@ -906,8 +1317,6 @@ class FSSpecTools(ResourceProvider):
906
1317
  Returns:
907
1318
  Success message with edit summary
908
1319
  """
909
- from pydantic_ai.messages import CachePoint, ModelRequest
910
-
911
1320
  from agentpool.messaging import ChatMessage, MessageHistory
912
1321
 
913
1322
  path = self._resolve_path(path, agent_ctx)
@@ -941,10 +1350,8 @@ class FSSpecTools(ResourceProvider):
941
1350
  # 1. Stored history (previous runs) from agent.conversation
942
1351
  # 2. Current run messages from run_ctx.messages (not yet stored)
943
1352
  stored_history = agent.conversation.get_history()
944
-
945
1353
  # Build complete message list
946
1354
  all_messages: list[ModelRequest | ModelResponse] = []
947
-
948
1355
  # Add stored history from previous runs
949
1356
  for chat_msg in stored_history:
950
1357
  all_messages.extend(chat_msg.to_pydantic_ai())
@@ -952,7 +1359,6 @@ class FSSpecTools(ResourceProvider):
952
1359
  # Add current run's messages (not yet in stored history)
953
1360
  # But exclude the last message if it contains the current agentic_edit tool call
954
1361
  # to avoid the sub-agent seeing "I'm calling agentic_edit" in its context
955
- from pydantic_ai.messages import ModelResponse, ToolCallPart
956
1362
 
957
1363
  for msg in run_ctx.messages:
958
1364
  if isinstance(msg, ModelResponse):
@@ -967,17 +1373,16 @@ class FSSpecTools(ResourceProvider):
967
1373
  else:
968
1374
  all_messages.append(msg)
969
1375
 
970
- # Inject CachePoint to cache everything up to this point
971
- if all_messages:
972
- cache_request: ModelRequest = ModelRequest(parts=[CachePoint()]) # type: ignore[list-item]
973
- all_messages.append(cache_request)
1376
+ # Inject CachePoint to cache everything up to this point
1377
+ # if all_messages:
1378
+ # cache_request: ModelRequest = ModelRequest(parts=[CachePoint()])
1379
+ # all_messages.append(cache_request)
974
1380
 
975
1381
  # Wrap in a single ChatMessage for the forked history
976
1382
  fork_history = MessageHistory(
977
1383
  messages=[ChatMessage(messages=all_messages, role="user", content="")]
978
1384
  )
979
- else:
980
- fork_history = MessageHistory()
1385
+ fork_history = MessageHistory()
981
1386
 
982
1387
  # Stream the edit using the same agent but with forked history
983
1388
  if mode == "edit" and matcher == "zed":