agentpool 2.2.3__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 (250) hide show
  1. acp/__init__.py +0 -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/client/connection.py +38 -29
  7. acp/client/implementations/default_client.py +3 -2
  8. acp/client/implementations/headless_client.py +2 -2
  9. acp/connection.py +2 -2
  10. acp/notifications.py +18 -49
  11. acp/schema/__init__.py +2 -0
  12. acp/schema/agent_responses.py +21 -0
  13. acp/schema/client_requests.py +3 -3
  14. acp/schema/session_state.py +63 -29
  15. acp/task/supervisor.py +2 -2
  16. acp/utils.py +2 -2
  17. agentpool/__init__.py +2 -0
  18. agentpool/agents/acp_agent/acp_agent.py +278 -263
  19. agentpool/agents/acp_agent/acp_converters.py +150 -17
  20. agentpool/agents/acp_agent/client_handler.py +35 -24
  21. agentpool/agents/acp_agent/session_state.py +14 -6
  22. agentpool/agents/agent.py +471 -643
  23. agentpool/agents/agui_agent/agui_agent.py +104 -107
  24. agentpool/agents/agui_agent/helpers.py +3 -4
  25. agentpool/agents/base_agent.py +485 -32
  26. agentpool/agents/claude_code_agent/FORKING.md +191 -0
  27. agentpool/agents/claude_code_agent/__init__.py +13 -1
  28. agentpool/agents/claude_code_agent/claude_code_agent.py +654 -334
  29. agentpool/agents/claude_code_agent/converters.py +4 -141
  30. agentpool/agents/claude_code_agent/models.py +77 -0
  31. agentpool/agents/claude_code_agent/static_info.py +100 -0
  32. agentpool/agents/claude_code_agent/usage.py +242 -0
  33. agentpool/agents/events/__init__.py +22 -0
  34. agentpool/agents/events/builtin_handlers.py +65 -0
  35. agentpool/agents/events/event_emitter.py +3 -0
  36. agentpool/agents/events/events.py +84 -3
  37. agentpool/agents/events/infer_info.py +145 -0
  38. agentpool/agents/events/processors.py +254 -0
  39. agentpool/agents/interactions.py +41 -6
  40. agentpool/agents/modes.py +13 -0
  41. agentpool/agents/slashed_agent.py +5 -4
  42. agentpool/agents/tool_wrapping.py +18 -6
  43. agentpool/common_types.py +35 -21
  44. agentpool/config_resources/acp_assistant.yml +2 -2
  45. agentpool/config_resources/agents.yml +3 -0
  46. agentpool/config_resources/agents_template.yml +1 -0
  47. agentpool/config_resources/claude_code_agent.yml +9 -8
  48. agentpool/config_resources/external_acp_agents.yml +2 -1
  49. agentpool/delegation/base_team.py +4 -30
  50. agentpool/delegation/pool.py +104 -265
  51. agentpool/delegation/team.py +57 -57
  52. agentpool/delegation/teamrun.py +50 -55
  53. agentpool/functional/run.py +10 -4
  54. agentpool/mcp_server/client.py +73 -38
  55. agentpool/mcp_server/conversions.py +54 -13
  56. agentpool/mcp_server/manager.py +9 -23
  57. agentpool/mcp_server/registries/official_registry_client.py +10 -1
  58. agentpool/mcp_server/tool_bridge.py +114 -79
  59. agentpool/messaging/connection_manager.py +11 -10
  60. agentpool/messaging/event_manager.py +5 -5
  61. agentpool/messaging/message_container.py +6 -30
  62. agentpool/messaging/message_history.py +87 -8
  63. agentpool/messaging/messagenode.py +52 -14
  64. agentpool/messaging/messages.py +2 -26
  65. agentpool/messaging/processing.py +10 -22
  66. agentpool/models/__init__.py +1 -1
  67. agentpool/models/acp_agents/base.py +6 -2
  68. agentpool/models/acp_agents/mcp_capable.py +124 -15
  69. agentpool/models/acp_agents/non_mcp.py +0 -23
  70. agentpool/models/agents.py +66 -66
  71. agentpool/models/agui_agents.py +1 -1
  72. agentpool/models/claude_code_agents.py +111 -17
  73. agentpool/models/file_parsing.py +0 -1
  74. agentpool/models/manifest.py +70 -50
  75. agentpool/prompts/conversion_manager.py +1 -1
  76. agentpool/prompts/prompts.py +5 -2
  77. agentpool/resource_providers/__init__.py +2 -0
  78. agentpool/resource_providers/aggregating.py +4 -2
  79. agentpool/resource_providers/base.py +13 -3
  80. agentpool/resource_providers/codemode/code_executor.py +72 -5
  81. agentpool/resource_providers/codemode/helpers.py +2 -2
  82. agentpool/resource_providers/codemode/provider.py +64 -12
  83. agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
  84. agentpool/resource_providers/codemode/remote_provider.py +9 -12
  85. agentpool/resource_providers/filtering.py +3 -1
  86. agentpool/resource_providers/mcp_provider.py +66 -12
  87. agentpool/resource_providers/plan_provider.py +111 -18
  88. agentpool/resource_providers/pool.py +5 -3
  89. agentpool/resource_providers/resource_info.py +111 -0
  90. agentpool/resource_providers/static.py +2 -2
  91. agentpool/sessions/__init__.py +2 -0
  92. agentpool/sessions/manager.py +2 -3
  93. agentpool/sessions/models.py +9 -6
  94. agentpool/sessions/protocol.py +28 -0
  95. agentpool/sessions/session.py +11 -55
  96. agentpool/storage/manager.py +361 -54
  97. agentpool/talk/registry.py +4 -4
  98. agentpool/talk/talk.py +9 -10
  99. agentpool/testing.py +1 -1
  100. agentpool/tool_impls/__init__.py +6 -0
  101. agentpool/tool_impls/agent_cli/__init__.py +42 -0
  102. agentpool/tool_impls/agent_cli/tool.py +95 -0
  103. agentpool/tool_impls/bash/__init__.py +64 -0
  104. agentpool/tool_impls/bash/helpers.py +35 -0
  105. agentpool/tool_impls/bash/tool.py +171 -0
  106. agentpool/tool_impls/delete_path/__init__.py +70 -0
  107. agentpool/tool_impls/delete_path/tool.py +142 -0
  108. agentpool/tool_impls/download_file/__init__.py +80 -0
  109. agentpool/tool_impls/download_file/tool.py +183 -0
  110. agentpool/tool_impls/execute_code/__init__.py +55 -0
  111. agentpool/tool_impls/execute_code/tool.py +163 -0
  112. agentpool/tool_impls/grep/__init__.py +80 -0
  113. agentpool/tool_impls/grep/tool.py +200 -0
  114. agentpool/tool_impls/list_directory/__init__.py +73 -0
  115. agentpool/tool_impls/list_directory/tool.py +197 -0
  116. agentpool/tool_impls/question/__init__.py +42 -0
  117. agentpool/tool_impls/question/tool.py +127 -0
  118. agentpool/tool_impls/read/__init__.py +104 -0
  119. agentpool/tool_impls/read/tool.py +305 -0
  120. agentpool/tools/__init__.py +2 -1
  121. agentpool/tools/base.py +114 -34
  122. agentpool/tools/manager.py +57 -1
  123. agentpool/ui/base.py +2 -2
  124. agentpool/ui/mock_provider.py +2 -2
  125. agentpool/ui/stdlib_provider.py +2 -2
  126. agentpool/utils/streams.py +21 -96
  127. agentpool/vfs_registry.py +7 -2
  128. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/METADATA +16 -22
  129. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/RECORD +242 -195
  130. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
  131. agentpool_cli/__main__.py +20 -0
  132. agentpool_cli/create.py +1 -1
  133. agentpool_cli/serve_acp.py +59 -1
  134. agentpool_cli/serve_opencode.py +1 -1
  135. agentpool_cli/ui.py +557 -0
  136. agentpool_commands/__init__.py +12 -5
  137. agentpool_commands/agents.py +1 -1
  138. agentpool_commands/pool.py +260 -0
  139. agentpool_commands/session.py +1 -1
  140. agentpool_commands/text_sharing/__init__.py +119 -0
  141. agentpool_commands/text_sharing/base.py +123 -0
  142. agentpool_commands/text_sharing/github_gist.py +80 -0
  143. agentpool_commands/text_sharing/opencode.py +462 -0
  144. agentpool_commands/text_sharing/paste_rs.py +59 -0
  145. agentpool_commands/text_sharing/pastebin.py +116 -0
  146. agentpool_commands/text_sharing/shittycodingagent.py +112 -0
  147. agentpool_commands/utils.py +31 -32
  148. agentpool_config/__init__.py +30 -2
  149. agentpool_config/agentpool_tools.py +498 -0
  150. agentpool_config/converters.py +1 -1
  151. agentpool_config/event_handlers.py +42 -0
  152. agentpool_config/events.py +1 -1
  153. agentpool_config/forward_targets.py +1 -4
  154. agentpool_config/jinja.py +3 -3
  155. agentpool_config/mcp_server.py +1 -5
  156. agentpool_config/nodes.py +1 -1
  157. agentpool_config/observability.py +44 -0
  158. agentpool_config/session.py +0 -3
  159. agentpool_config/storage.py +38 -39
  160. agentpool_config/task.py +3 -3
  161. agentpool_config/tools.py +11 -28
  162. agentpool_config/toolsets.py +22 -90
  163. agentpool_server/a2a_server/agent_worker.py +307 -0
  164. agentpool_server/a2a_server/server.py +23 -18
  165. agentpool_server/acp_server/acp_agent.py +125 -56
  166. agentpool_server/acp_server/commands/acp_commands.py +46 -216
  167. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +8 -7
  168. agentpool_server/acp_server/event_converter.py +651 -0
  169. agentpool_server/acp_server/input_provider.py +53 -10
  170. agentpool_server/acp_server/server.py +1 -11
  171. agentpool_server/acp_server/session.py +90 -410
  172. agentpool_server/acp_server/session_manager.py +8 -34
  173. agentpool_server/agui_server/server.py +3 -1
  174. agentpool_server/mcp_server/server.py +5 -2
  175. agentpool_server/opencode_server/ENDPOINTS.md +53 -14
  176. agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
  177. agentpool_server/opencode_server/__init__.py +0 -8
  178. agentpool_server/opencode_server/converters.py +132 -26
  179. agentpool_server/opencode_server/input_provider.py +160 -8
  180. agentpool_server/opencode_server/models/__init__.py +42 -20
  181. agentpool_server/opencode_server/models/app.py +12 -0
  182. agentpool_server/opencode_server/models/events.py +203 -29
  183. agentpool_server/opencode_server/models/mcp.py +19 -0
  184. agentpool_server/opencode_server/models/message.py +18 -1
  185. agentpool_server/opencode_server/models/parts.py +134 -1
  186. agentpool_server/opencode_server/models/question.py +56 -0
  187. agentpool_server/opencode_server/models/session.py +13 -1
  188. agentpool_server/opencode_server/routes/__init__.py +4 -0
  189. agentpool_server/opencode_server/routes/agent_routes.py +33 -2
  190. agentpool_server/opencode_server/routes/app_routes.py +66 -3
  191. agentpool_server/opencode_server/routes/config_routes.py +66 -5
  192. agentpool_server/opencode_server/routes/file_routes.py +184 -5
  193. agentpool_server/opencode_server/routes/global_routes.py +1 -1
  194. agentpool_server/opencode_server/routes/lsp_routes.py +1 -1
  195. agentpool_server/opencode_server/routes/message_routes.py +122 -66
  196. agentpool_server/opencode_server/routes/permission_routes.py +63 -0
  197. agentpool_server/opencode_server/routes/pty_routes.py +23 -22
  198. agentpool_server/opencode_server/routes/question_routes.py +128 -0
  199. agentpool_server/opencode_server/routes/session_routes.py +139 -68
  200. agentpool_server/opencode_server/routes/tui_routes.py +1 -1
  201. agentpool_server/opencode_server/server.py +47 -2
  202. agentpool_server/opencode_server/state.py +30 -0
  203. agentpool_storage/__init__.py +0 -4
  204. agentpool_storage/base.py +81 -2
  205. agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
  206. agentpool_storage/claude_provider/__init__.py +42 -0
  207. agentpool_storage/{claude_provider.py → claude_provider/provider.py} +190 -8
  208. agentpool_storage/file_provider.py +149 -15
  209. agentpool_storage/memory_provider.py +132 -12
  210. agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
  211. agentpool_storage/opencode_provider/__init__.py +16 -0
  212. agentpool_storage/opencode_provider/helpers.py +414 -0
  213. agentpool_storage/opencode_provider/provider.py +895 -0
  214. agentpool_storage/session_store.py +20 -6
  215. agentpool_storage/sql_provider/sql_provider.py +135 -2
  216. agentpool_storage/sql_provider/utils.py +2 -12
  217. agentpool_storage/zed_provider/__init__.py +16 -0
  218. agentpool_storage/zed_provider/helpers.py +281 -0
  219. agentpool_storage/zed_provider/models.py +130 -0
  220. agentpool_storage/zed_provider/provider.py +442 -0
  221. agentpool_storage/zed_provider.py +803 -0
  222. agentpool_toolsets/__init__.py +0 -2
  223. agentpool_toolsets/builtin/__init__.py +2 -4
  224. agentpool_toolsets/builtin/code.py +4 -4
  225. agentpool_toolsets/builtin/debug.py +115 -40
  226. agentpool_toolsets/builtin/execution_environment.py +54 -165
  227. agentpool_toolsets/builtin/skills.py +0 -77
  228. agentpool_toolsets/builtin/subagent_tools.py +64 -51
  229. agentpool_toolsets/builtin/workers.py +4 -2
  230. agentpool_toolsets/composio_toolset.py +2 -2
  231. agentpool_toolsets/entry_points.py +3 -1
  232. agentpool_toolsets/fsspec_toolset/grep.py +25 -5
  233. agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
  234. agentpool_toolsets/fsspec_toolset/toolset.py +350 -66
  235. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  236. agentpool_toolsets/mcp_discovery/toolset.py +74 -17
  237. agentpool_toolsets/mcp_run_toolset.py +8 -11
  238. agentpool_toolsets/notifications.py +33 -33
  239. agentpool_toolsets/openapi.py +3 -1
  240. agentpool_toolsets/search_toolset.py +3 -1
  241. agentpool_config/resources.py +0 -33
  242. agentpool_server/acp_server/acp_tools.py +0 -43
  243. agentpool_server/acp_server/commands/spawn.py +0 -210
  244. agentpool_storage/opencode_provider.py +0 -730
  245. agentpool_storage/text_log_provider.py +0 -276
  246. agentpool_toolsets/builtin/chain.py +0 -288
  247. agentpool_toolsets/builtin/user_interaction.py +0 -52
  248. agentpool_toolsets/semantic_memory_toolset.py +0 -536
  249. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
  250. {agentpool-2.2.3.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -14,11 +14,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,6 +28,12 @@ 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
39
  from agentpool_toolsets.fsspec_toolset.diagnostics import (
@@ -47,9 +55,11 @@ from agentpool_toolsets.fsspec_toolset.streaming_diff_parser import (
47
55
 
48
56
 
49
57
  if TYPE_CHECKING:
58
+ from collections.abc import Sequence
59
+
50
60
  import fsspec
51
61
  from fsspec.asyn import AsyncFileSystem
52
- from pydantic_ai.messages import ModelRequest, ModelResponse
62
+ from pydantic_ai import ModelRequest
53
63
 
54
64
  from agentpool.agents.base_agent import BaseAgent
55
65
  from agentpool.common_types import ModelType
@@ -141,7 +151,7 @@ class FSSpecTools(ResourceProvider):
141
151
  self._max_image_size = max_image_size
142
152
  self._max_image_bytes = max_image_bytes
143
153
 
144
- def get_fs(self, agent_ctx: AgentContext) -> AsyncFileSystem:
154
+ def _get_fs(self, agent_ctx: AgentContext) -> AsyncFileSystem:
145
155
  """Get filesystem, falling back to agent's env if not set.
146
156
 
147
157
  Args:
@@ -197,7 +207,7 @@ class FSSpecTools(ResourceProvider):
197
207
  # Lazy init repomap - use file's directory as root
198
208
  if self._repomap is None:
199
209
  root = str(Path(path).parent)
200
- fs = self.get_fs(agent_ctx)
210
+ fs = self._get_fs(agent_ctx)
201
211
  self._repomap = RepoMap(fs, root, max_tokens=self._map_max_tokens)
202
212
 
203
213
  return await self._repomap.get_file_map(path, max_tokens=self._map_max_tokens)
@@ -222,18 +232,52 @@ class FSSpecTools(ResourceProvider):
222
232
  return str(Path(cwd) / path)
223
233
  return path
224
234
 
225
- async def get_tools(self) -> list[Tool]:
235
+ async def get_tools(self) -> Sequence[Tool]:
226
236
  """Get filesystem tools."""
227
237
  if self._tools is not None:
228
238
  return self._tools
229
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
+
230
274
  self._tools = [
231
- self.create_tool(self.list_directory, category="read", read_only=True, idempotent=True),
232
- self.create_tool(self.read, category="read", read_only=True, idempotent=True),
233
- self.create_tool(self.grep, category="search", read_only=True, idempotent=True),
275
+ list_dir_tool,
276
+ read_tool,
277
+ grep_tool,
234
278
  self.create_tool(self.write, category="edit"),
235
- self.create_tool(self.delete_path, category="delete", destructive=True),
236
- self.create_tool(self.download_file, category="read", open_world=True),
279
+ delete_tool,
280
+ download_tool,
237
281
  ]
238
282
 
239
283
  # Add edit tool based on config - mutually exclusive
@@ -246,15 +290,8 @@ class FSSpecTools(ResourceProvider):
246
290
  else: # simple
247
291
  self._tools.append(self.create_tool(self.edit, category="edit"))
248
292
 
249
- if self.converter: # Only add read_as_markdown if converter is available
250
- self._tools.append(
251
- self.create_tool(
252
- self.read_as_markdown,
253
- category="read",
254
- read_only=True,
255
- idempotent=True,
256
- )
257
- )
293
+ # Add regex line editing tool
294
+ self._tools.append(self.create_tool(self.regex_replace_lines, category="edit"))
258
295
 
259
296
  return self._tools
260
297
 
@@ -286,7 +323,7 @@ class FSSpecTools(ResourceProvider):
286
323
  await agent_ctx.events.tool_call_start(title=msg, kind="read", locations=[path])
287
324
 
288
325
  try:
289
- fs = self.get_fs(agent_ctx)
326
+ fs = self._get_fs(agent_ctx)
290
327
  # Check if path exists
291
328
  if not await fs._exists(path):
292
329
  error_msg = f"Path does not exist: {path}"
@@ -381,15 +418,18 @@ class FSSpecTools(ResourceProvider):
381
418
  msg = f"Reading file: {path}"
382
419
  from agentpool.agents.events import LocationContentItem
383
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
384
424
  await agent_ctx.events.tool_call_progress(
385
425
  title=msg,
386
- items=[LocationContentItem(path=path)],
426
+ items=[LocationContentItem(path=path, line=display_line)],
387
427
  )
388
428
  try:
389
429
  mime_type = guess_type(path)
390
430
  # Fast path: known binary MIME types (images, audio, video, etc.)
391
431
  if is_binary_mime(mime_type):
392
- data = await self.get_fs(agent_ctx)._cat_file(path)
432
+ data = await self._get_fs(agent_ctx)._cat_file(path)
393
433
  await agent_ctx.events.file_operation("read", path=path, success=True)
394
434
  mime = mime_type or "application/octet-stream"
395
435
  # Resize images if needed
@@ -406,7 +446,7 @@ class FSSpecTools(ResourceProvider):
406
446
  return [note, BinaryContent(data=data, media_type=mime, identifier=path)]
407
447
  return BinaryContent(data=data, media_type=mime, identifier=path)
408
448
  # Read content and probe for binary (git-style null byte detection)
409
- data = await self.get_fs(agent_ctx)._cat_file(path)
449
+ data = await self._get_fs(agent_ctx)._cat_file(path)
410
450
  if is_binary_content(data):
411
451
  # Binary file - return as BinaryContent for native model handling
412
452
  await agent_ctx.events.file_operation("read", path=path, success=True)
@@ -447,7 +487,11 @@ class FSSpecTools(ResourceProvider):
447
487
  lines, offset, limit, self.max_file_size
448
488
  )
449
489
  content = "\n".join(result_lines)
450
- 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
+ )
451
495
  if was_truncated:
452
496
  content += f"\n\n[Content truncated at {self.max_file_size} bytes]"
453
497
 
@@ -458,9 +502,11 @@ class FSSpecTools(ResourceProvider):
458
502
  # Emit file content for UI display (formatted at ACP layer)
459
503
  from agentpool.agents.events import FileContentItem
460
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
461
507
  await agent_ctx.events.tool_call_progress(
462
508
  title=f"Read: {path}",
463
- items=[FileContentItem(content=content, path=path)],
509
+ items=[FileContentItem(content=content, path=path, start_line=display_start_line)],
464
510
  replace_content=True,
465
511
  )
466
512
  # Return raw content for agent
@@ -504,7 +550,7 @@ class FSSpecTools(ResourceProvider):
504
550
  content: str,
505
551
  mode: str = "w",
506
552
  overwrite: bool = False,
507
- ) -> dict[str, Any]:
553
+ ) -> str | ToolResult:
508
554
  """Write content to a file.
509
555
 
510
556
  Args:
@@ -514,8 +560,11 @@ class FSSpecTools(ResourceProvider):
514
560
  overwrite: Must be True to overwrite existing files (safety check)
515
561
 
516
562
  Returns:
517
- Dictionary with success info or error details
563
+ Success message or ToolResult with metadata
518
564
  """
565
+ from agentpool.agents.events import DiffContentItem
566
+ from agentpool.tools.base import ToolResult
567
+
519
568
  path = self._resolve_path(path, agent_ctx)
520
569
  msg = f"Writing file: {path}"
521
570
  await agent_ctx.events.tool_call_start(title=msg, kind="edit", locations=[path])
@@ -526,7 +575,7 @@ class FSSpecTools(ResourceProvider):
526
575
  if mode not in ("w", "a"):
527
576
  msg = f"Invalid mode '{mode}'. Use 'w' (write) or 'a' (append)"
528
577
  await agent_ctx.events.file_operation("write", path=path, success=False, error=msg)
529
- return {"error": msg}
578
+ return f"Error: {msg}"
530
579
 
531
580
  # Check size limit
532
581
  if content_bytes > self.max_file_size:
@@ -535,10 +584,10 @@ class FSSpecTools(ResourceProvider):
535
584
  f"({self.max_file_size} bytes)"
536
585
  )
537
586
  await agent_ctx.events.file_operation("write", path=path, success=False, error=msg)
538
- return {"error": msg}
587
+ return f"Error: {msg}"
539
588
 
540
589
  # Check if file exists and overwrite protection
541
- fs = self.get_fs(agent_ctx)
590
+ fs = self._get_fs(agent_ctx)
542
591
  file_exists = await fs._exists(path)
543
592
 
544
593
  if file_exists and mode == "w" and not overwrite:
@@ -547,7 +596,7 @@ class FSSpecTools(ResourceProvider):
547
596
  f"This is a safety measure to prevent accidental data loss."
548
597
  )
549
598
  await agent_ctx.events.file_operation("write", path=path, success=False, error=msg)
550
- return {"error": msg}
599
+ return f"Error: {msg}"
551
600
 
552
601
  # Handle append mode: read existing content and prepend it
553
602
  if mode == "a" and file_exists:
@@ -560,23 +609,6 @@ class FSSpecTools(ResourceProvider):
560
609
  pass # If we can't read, just write new content
561
610
 
562
611
  await self._write(agent_ctx, path, content)
563
-
564
- try:
565
- info = await fs._info(path)
566
- size = info.get("size", content_bytes)
567
- except (OSError, KeyError):
568
- size = content_bytes
569
-
570
- result: dict[str, Any] = {
571
- "path": path,
572
- "size": size,
573
- "mode": mode,
574
- "file_existed": file_exists,
575
- "bytes_written": content_bytes,
576
- }
577
- # Emit file operation with content for UI display
578
- from agentpool.agents.events import DiffContentItem
579
-
580
612
  await agent_ctx.events.tool_call_progress(
581
613
  title=f"Wrote: {path}",
582
614
  items=[
@@ -584,14 +616,40 @@ class FSSpecTools(ResourceProvider):
584
616
  ],
585
617
  )
586
618
 
587
- # Run diagnostics if enabled
619
+ # Run diagnostics if enabled (include in message for agent)
620
+ diagnostics_msg = ""
588
621
  if diagnostics_output := await self._run_diagnostics(agent_ctx, path):
589
- 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
+ )
590
650
  except Exception as e: # noqa: BLE001
591
651
  await agent_ctx.events.file_operation("write", path=path, success=False, error=str(e))
592
- return {"error": f"Failed to write file {path}: {e}"}
593
- else:
594
- return result
652
+ return f"Error: Failed to write file {path}: {e}"
595
653
 
596
654
  async def delete_path( # noqa: D417
597
655
  self, agent_ctx: AgentContext, path: str, recursive: bool = False
@@ -610,7 +668,7 @@ class FSSpecTools(ResourceProvider):
610
668
  await agent_ctx.events.tool_call_start(title=msg, kind="delete", locations=[path])
611
669
  try:
612
670
  # Check if path exists and get its type
613
- fs = self.get_fs(agent_ctx)
671
+ fs = self._get_fs(agent_ctx)
614
672
  try:
615
673
  info = await fs._info(path)
616
674
  path_type = info.get("type", "unknown")
@@ -668,7 +726,7 @@ class FSSpecTools(ResourceProvider):
668
726
  description: str,
669
727
  replace_all: bool = False,
670
728
  line_hint: int | None = None,
671
- ) -> str:
729
+ ) -> str | ToolResult:
672
730
  r"""Edit a file by replacing specific content with smart matching.
673
731
 
674
732
  Uses sophisticated matching strategies to handle whitespace, indentation,
@@ -704,7 +762,7 @@ class FSSpecTools(ResourceProvider):
704
762
  description: str,
705
763
  replace_all: bool = False,
706
764
  line_hint: int | None = None,
707
- ) -> str:
765
+ ) -> str | ToolResult:
708
766
  r"""Edit a file by applying multiple replacements in one operation.
709
767
 
710
768
  Uses sophisticated matching strategies to handle whitespace, indentation,
@@ -718,6 +776,9 @@ class FSSpecTools(ResourceProvider):
718
776
  Args:
719
777
  path: File path (absolute or relative to session cwd)
720
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
+
721
782
  Each old_string should include enough context to uniquely identify
722
783
  the target location. For multi-line edits, include the full block.
723
784
  description: Human-readable description of what the edit accomplishes
@@ -746,9 +807,6 @@ class FSSpecTools(ResourceProvider):
746
807
  if old_str == new_str:
747
808
  return f"Error: old_string and new_string must be different: {old_str!r}"
748
809
 
749
- # Send initial pending notification
750
- await agent_ctx.events.file_operation("edit", path=path, success=True)
751
-
752
810
  try: # Read current file content
753
811
  original_content = await self._read(agent_ctx, path)
754
812
  if isinstance(original_content, bytes):
@@ -790,9 +848,238 @@ class FSSpecTools(ResourceProvider):
790
848
  error_msg = f"Error editing file: {e}"
791
849
  await agent_ctx.events.file_operation("edit", path=path, success=False, error=error_msg)
792
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
793
1021
  else:
794
1022
  return success_msg
795
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
+
796
1083
  async def grep( # noqa: D417
797
1084
  self,
798
1085
  agent_ctx: AgentContext,
@@ -855,7 +1142,7 @@ class FSSpecTools(ResourceProvider):
855
1142
 
856
1143
  # Fallback to fsspec grep if subprocess didn't work
857
1144
  if result is None or "error" in result:
858
- fs = self.get_fs(agent_ctx)
1145
+ fs = self._get_fs(agent_ctx)
859
1146
  result = await grep_with_fsspec(
860
1147
  fs=fs,
861
1148
  pattern=pattern,
@@ -898,12 +1185,12 @@ class FSSpecTools(ResourceProvider):
898
1185
  async def _read(self, agent_ctx: AgentContext, path: str, encoding: str = "utf-8") -> str:
899
1186
  # with self.fs.open(path, "r", encoding="utf-8") as f:
900
1187
  # return f.read()
901
- 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]
902
1189
 
903
1190
  async def _write(self, agent_ctx: AgentContext, path: str, content: str | bytes) -> None:
904
1191
  if isinstance(content, str):
905
1192
  content = content.encode()
906
- await self.get_fs(agent_ctx)._pipe_file(path, content)
1193
+ await self._get_fs(agent_ctx)._pipe_file(path, content)
907
1194
 
908
1195
  async def download_file( # noqa: D417
909
1196
  self,
@@ -937,7 +1224,7 @@ class FSSpecTools(ResourceProvider):
937
1224
  full_path = f"{target_dir.rstrip('/')}/{filename}"
938
1225
 
939
1226
  try:
940
- fs = self.get_fs(agent_ctx)
1227
+ fs = self._get_fs(agent_ctx)
941
1228
  # Ensure target directory exists
942
1229
  await fs._makedirs(target_dir, exist_ok=True)
943
1230
 
@@ -1000,7 +1287,7 @@ class FSSpecTools(ResourceProvider):
1000
1287
  await agent_ctx.events.file_operation("read", path=url, success=False, error=error_msg)
1001
1288
  return {"error": error_msg}
1002
1289
 
1003
- async def agentic_edit( # noqa: D417, PLR0915
1290
+ async def agentic_edit( # noqa: D417
1004
1291
  self,
1005
1292
  run_ctx: RunContext,
1006
1293
  agent_ctx: AgentContext,
@@ -1063,10 +1350,8 @@ class FSSpecTools(ResourceProvider):
1063
1350
  # 1. Stored history (previous runs) from agent.conversation
1064
1351
  # 2. Current run messages from run_ctx.messages (not yet stored)
1065
1352
  stored_history = agent.conversation.get_history()
1066
-
1067
1353
  # Build complete message list
1068
1354
  all_messages: list[ModelRequest | ModelResponse] = []
1069
-
1070
1355
  # Add stored history from previous runs
1071
1356
  for chat_msg in stored_history:
1072
1357
  all_messages.extend(chat_msg.to_pydantic_ai())
@@ -1074,7 +1359,6 @@ class FSSpecTools(ResourceProvider):
1074
1359
  # Add current run's messages (not yet in stored history)
1075
1360
  # But exclude the last message if it contains the current agentic_edit tool call
1076
1361
  # to avoid the sub-agent seeing "I'm calling agentic_edit" in its context
1077
- from pydantic_ai.messages import ModelResponse, ToolCallPart
1078
1362
 
1079
1363
  for msg in run_ctx.messages:
1080
1364
  if isinstance(msg, ModelResponse):