agentpool 2.1.9__py3-none-any.whl → 2.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. acp/__init__.py +13 -0
  2. acp/bridge/README.md +15 -2
  3. acp/bridge/__init__.py +3 -2
  4. acp/bridge/__main__.py +60 -19
  5. acp/bridge/ws_server.py +173 -0
  6. acp/bridge/ws_server_cli.py +89 -0
  7. acp/notifications.py +2 -1
  8. acp/stdio.py +39 -9
  9. acp/transports.py +362 -2
  10. acp/utils.py +15 -2
  11. agentpool/__init__.py +4 -1
  12. agentpool/agents/__init__.py +2 -0
  13. agentpool/agents/acp_agent/acp_agent.py +203 -88
  14. agentpool/agents/acp_agent/acp_converters.py +46 -21
  15. agentpool/agents/acp_agent/client_handler.py +157 -3
  16. agentpool/agents/acp_agent/session_state.py +4 -1
  17. agentpool/agents/agent.py +314 -107
  18. agentpool/agents/agui_agent/__init__.py +0 -2
  19. agentpool/agents/agui_agent/agui_agent.py +90 -21
  20. agentpool/agents/agui_agent/agui_converters.py +0 -131
  21. agentpool/agents/base_agent.py +163 -1
  22. agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
  23. agentpool/agents/claude_code_agent/converters.py +71 -3
  24. agentpool/agents/claude_code_agent/history.py +474 -0
  25. agentpool/agents/context.py +40 -0
  26. agentpool/agents/events/__init__.py +2 -0
  27. agentpool/agents/events/builtin_handlers.py +2 -1
  28. agentpool/agents/events/event_emitter.py +29 -2
  29. agentpool/agents/events/events.py +20 -0
  30. agentpool/agents/modes.py +54 -0
  31. agentpool/agents/tool_call_accumulator.py +213 -0
  32. agentpool/common_types.py +21 -0
  33. agentpool/config_resources/__init__.py +38 -1
  34. agentpool/config_resources/claude_code_agent.yml +3 -0
  35. agentpool/delegation/pool.py +37 -29
  36. agentpool/delegation/team.py +1 -0
  37. agentpool/delegation/teamrun.py +1 -0
  38. agentpool/diagnostics/__init__.py +53 -0
  39. agentpool/diagnostics/lsp_manager.py +1593 -0
  40. agentpool/diagnostics/lsp_proxy.py +41 -0
  41. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  42. agentpool/diagnostics/models.py +398 -0
  43. agentpool/mcp_server/__init__.py +0 -2
  44. agentpool/mcp_server/client.py +12 -3
  45. agentpool/mcp_server/manager.py +25 -31
  46. agentpool/mcp_server/registries/official_registry_client.py +25 -0
  47. agentpool/mcp_server/tool_bridge.py +78 -66
  48. agentpool/messaging/__init__.py +0 -2
  49. agentpool/messaging/compaction.py +72 -197
  50. agentpool/messaging/message_history.py +12 -0
  51. agentpool/messaging/messages.py +52 -9
  52. agentpool/messaging/processing.py +3 -1
  53. agentpool/models/acp_agents/base.py +0 -22
  54. agentpool/models/acp_agents/mcp_capable.py +8 -148
  55. agentpool/models/acp_agents/non_mcp.py +129 -72
  56. agentpool/models/agents.py +35 -13
  57. agentpool/models/claude_code_agents.py +33 -2
  58. agentpool/models/manifest.py +43 -0
  59. agentpool/repomap.py +1 -1
  60. agentpool/resource_providers/__init__.py +9 -1
  61. agentpool/resource_providers/aggregating.py +52 -3
  62. agentpool/resource_providers/base.py +57 -1
  63. agentpool/resource_providers/mcp_provider.py +23 -0
  64. agentpool/resource_providers/plan_provider.py +130 -41
  65. agentpool/resource_providers/pool.py +2 -0
  66. agentpool/resource_providers/static.py +2 -0
  67. agentpool/sessions/__init__.py +2 -1
  68. agentpool/sessions/manager.py +31 -2
  69. agentpool/sessions/models.py +50 -0
  70. agentpool/skills/registry.py +13 -8
  71. agentpool/storage/manager.py +217 -1
  72. agentpool/testing.py +537 -19
  73. agentpool/utils/file_watcher.py +269 -0
  74. agentpool/utils/identifiers.py +121 -0
  75. agentpool/utils/pydantic_ai_helpers.py +46 -0
  76. agentpool/utils/streams.py +690 -1
  77. agentpool/utils/subprocess_utils.py +155 -0
  78. agentpool/utils/token_breakdown.py +461 -0
  79. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
  80. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
  81. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
  82. agentpool_cli/__main__.py +4 -0
  83. agentpool_cli/serve_acp.py +41 -20
  84. agentpool_cli/serve_agui.py +87 -0
  85. agentpool_cli/serve_opencode.py +119 -0
  86. agentpool_commands/__init__.py +30 -0
  87. agentpool_commands/agents.py +74 -1
  88. agentpool_commands/history.py +62 -0
  89. agentpool_commands/mcp.py +176 -0
  90. agentpool_commands/models.py +56 -3
  91. agentpool_commands/tools.py +57 -0
  92. agentpool_commands/utils.py +51 -0
  93. agentpool_config/builtin_tools.py +77 -22
  94. agentpool_config/commands.py +24 -1
  95. agentpool_config/compaction.py +258 -0
  96. agentpool_config/mcp_server.py +131 -1
  97. agentpool_config/storage.py +46 -1
  98. agentpool_config/tools.py +7 -1
  99. agentpool_config/toolsets.py +92 -148
  100. agentpool_server/acp_server/acp_agent.py +134 -150
  101. agentpool_server/acp_server/commands/acp_commands.py +216 -51
  102. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
  103. agentpool_server/acp_server/server.py +23 -79
  104. agentpool_server/acp_server/session.py +181 -19
  105. agentpool_server/opencode_server/.rules +95 -0
  106. agentpool_server/opencode_server/ENDPOINTS.md +362 -0
  107. agentpool_server/opencode_server/__init__.py +27 -0
  108. agentpool_server/opencode_server/command_validation.py +172 -0
  109. agentpool_server/opencode_server/converters.py +869 -0
  110. agentpool_server/opencode_server/dependencies.py +24 -0
  111. agentpool_server/opencode_server/input_provider.py +269 -0
  112. agentpool_server/opencode_server/models/__init__.py +228 -0
  113. agentpool_server/opencode_server/models/agent.py +53 -0
  114. agentpool_server/opencode_server/models/app.py +60 -0
  115. agentpool_server/opencode_server/models/base.py +26 -0
  116. agentpool_server/opencode_server/models/common.py +23 -0
  117. agentpool_server/opencode_server/models/config.py +37 -0
  118. agentpool_server/opencode_server/models/events.py +647 -0
  119. agentpool_server/opencode_server/models/file.py +88 -0
  120. agentpool_server/opencode_server/models/mcp.py +25 -0
  121. agentpool_server/opencode_server/models/message.py +162 -0
  122. agentpool_server/opencode_server/models/parts.py +190 -0
  123. agentpool_server/opencode_server/models/provider.py +81 -0
  124. agentpool_server/opencode_server/models/pty.py +43 -0
  125. agentpool_server/opencode_server/models/session.py +99 -0
  126. agentpool_server/opencode_server/routes/__init__.py +25 -0
  127. agentpool_server/opencode_server/routes/agent_routes.py +442 -0
  128. agentpool_server/opencode_server/routes/app_routes.py +139 -0
  129. agentpool_server/opencode_server/routes/config_routes.py +241 -0
  130. agentpool_server/opencode_server/routes/file_routes.py +392 -0
  131. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  132. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  133. agentpool_server/opencode_server/routes/message_routes.py +705 -0
  134. agentpool_server/opencode_server/routes/pty_routes.py +299 -0
  135. agentpool_server/opencode_server/routes/session_routes.py +1205 -0
  136. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  137. agentpool_server/opencode_server/server.py +430 -0
  138. agentpool_server/opencode_server/state.py +121 -0
  139. agentpool_server/opencode_server/time_utils.py +8 -0
  140. agentpool_storage/__init__.py +16 -0
  141. agentpool_storage/base.py +103 -0
  142. agentpool_storage/claude_provider.py +907 -0
  143. agentpool_storage/file_provider.py +129 -0
  144. agentpool_storage/memory_provider.py +61 -0
  145. agentpool_storage/models.py +3 -0
  146. agentpool_storage/opencode_provider.py +730 -0
  147. agentpool_storage/project_store.py +325 -0
  148. agentpool_storage/session_store.py +6 -0
  149. agentpool_storage/sql_provider/__init__.py +4 -2
  150. agentpool_storage/sql_provider/models.py +48 -0
  151. agentpool_storage/sql_provider/sql_provider.py +134 -1
  152. agentpool_storage/sql_provider/utils.py +10 -1
  153. agentpool_storage/text_log_provider.py +1 -0
  154. agentpool_toolsets/builtin/__init__.py +0 -8
  155. agentpool_toolsets/builtin/code.py +95 -56
  156. agentpool_toolsets/builtin/debug.py +16 -21
  157. agentpool_toolsets/builtin/execution_environment.py +99 -103
  158. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  159. agentpool_toolsets/builtin/skills.py +86 -4
  160. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  161. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  162. agentpool_toolsets/fsspec_toolset/grep.py +74 -2
  163. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  164. agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
  165. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  166. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  167. agentpool_toolsets/mcp_discovery/toolset.py +454 -0
  168. agentpool_toolsets/mcp_run_toolset.py +84 -6
  169. agentpool_toolsets/builtin/agent_management.py +0 -239
  170. agentpool_toolsets/builtin/history.py +0 -36
  171. agentpool_toolsets/builtin/integration.py +0 -85
  172. agentpool_toolsets/builtin/tool_management.py +0 -90
  173. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
  174. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -85,6 +85,66 @@ async def detect_grep_backend(env: ExecutionEnvironment) -> GrepBackend:
85
85
  return GrepBackend.PYTHON
86
86
 
87
87
 
88
+ def _is_path_inside_ignored_dir(path: str) -> bool:
89
+ """Check if path is explicitly inside a commonly ignored directory.
90
+
91
+ Args:
92
+ path: The search path
93
+
94
+ Returns:
95
+ True if path is inside a directory that would typically be gitignored
96
+ """
97
+ # Common ignored directory patterns (without trailing slash)
98
+ ignored_dirs = [
99
+ ".venv",
100
+ "venv",
101
+ ".env",
102
+ "env",
103
+ "node_modules",
104
+ ".git",
105
+ "__pycache__",
106
+ ".pytest_cache",
107
+ ".mypy_cache",
108
+ ".tox",
109
+ ".nox",
110
+ ]
111
+ for ignored in ignored_dirs:
112
+ if (
113
+ path.startswith((f"{ignored}/", f"./{ignored}/"))
114
+ or f"/{ignored}/" in path
115
+ or path == ignored
116
+ ):
117
+ return True
118
+ return False
119
+
120
+
121
+ def _filter_exclude_patterns(path: str, exclude_patterns: list[str]) -> list[str]:
122
+ """Filter out exclude patterns that the search path is explicitly inside.
123
+
124
+ If user explicitly searches inside an excluded directory (e.g., .venv/),
125
+ don't exclude that directory from results.
126
+
127
+ Args:
128
+ path: The search path
129
+ exclude_patterns: List of patterns to potentially exclude
130
+
131
+ Returns:
132
+ Filtered list of exclude patterns
133
+ """
134
+ filtered = []
135
+ for pattern in exclude_patterns:
136
+ # Normalize pattern (remove trailing slash for comparison)
137
+ pattern_normalized = pattern.rstrip("/")
138
+ # Check if the search path starts with or contains this excluded dir
139
+ # e.g., path=".venv/lib/python" should not exclude ".venv/"
140
+ if not (
141
+ path.startswith((pattern_normalized, f"./{pattern_normalized}"))
142
+ or f"/{pattern_normalized}/" in path
143
+ ):
144
+ filtered.append(pattern)
145
+ return filtered
146
+
147
+
88
148
  async def grep_with_subprocess(
89
149
  env: ExecutionEnvironment,
90
150
  pattern: str,
@@ -124,11 +184,23 @@ async def grep_with_subprocess(
124
184
  msg = "Subprocess grep requested but no grep/ripgrep found"
125
185
  raise ValueError(msg)
126
186
 
127
- exclude = exclude_patterns or DEFAULT_EXCLUDE_PATTERNS
187
+ base_exclude = exclude_patterns or DEFAULT_EXCLUDE_PATTERNS
188
+ # Filter out patterns that the user is explicitly searching inside
189
+ exclude = _filter_exclude_patterns(path, base_exclude)
190
+
191
+ # Disable gitignore if explicitly searching inside an ignored directory
192
+ # (e.g., searching .venv/ when .venv is in .gitignore)
193
+ effective_use_gitignore = use_gitignore and not _is_path_inside_ignored_dir(path)
128
194
 
129
195
  if backend == GrepBackend.RIPGREP:
130
196
  cmd_list = _build_ripgrep_command(
131
- pattern, path, case_sensitive, max_matches, exclude, use_gitignore, context_lines
197
+ pattern,
198
+ path,
199
+ case_sensitive,
200
+ max_matches,
201
+ exclude,
202
+ effective_use_gitignore,
203
+ context_lines,
132
204
  )
133
205
  else:
134
206
  cmd_list = _build_gnu_grep_command(
@@ -0,0 +1,161 @@
1
+ """Image processing utilities for the fsspec toolset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from io import BytesIO
6
+
7
+
8
+ # 4.5MB - provides headroom below Anthropic's 5MB limit
9
+ DEFAULT_MAX_BYTES = int(4.5 * 1024 * 1024)
10
+
11
+ # Minimum dimension for resized images
12
+ MIN_DIMENSION = 100
13
+
14
+
15
+ def _pick_smaller(
16
+ a: tuple[bytes, str],
17
+ b: tuple[bytes, str],
18
+ ) -> tuple[bytes, str]:
19
+ """Pick the smaller of two image buffers."""
20
+ return a if len(a[0]) <= len(b[0]) else b
21
+
22
+
23
+ def _make_resize_note(
24
+ original_width: int,
25
+ original_height: int,
26
+ final_width: int,
27
+ final_height: int,
28
+ suffix: str = "",
29
+ ) -> str:
30
+ """Create a resize note string."""
31
+ scale = original_width / final_width if final_width > 0 else 1.0
32
+ base = f"[Image resized: {original_width}x{original_height} → {final_width}x{final_height}"
33
+ return f"{base}, scale={scale:.2f}x{suffix}]"
34
+
35
+
36
+ def resize_image_if_needed(
37
+ data: bytes,
38
+ media_type: str,
39
+ max_size: int,
40
+ max_bytes: int | None = None,
41
+ jpeg_quality: int = 80,
42
+ ) -> tuple[bytes, str, str | None]:
43
+ """Resize image if it exceeds limits, preserving aspect ratio.
44
+
45
+ Returns the original image if it already fits within all limits.
46
+
47
+ Strategy for staying under max_bytes:
48
+ 1. First resize to max_size dimensions
49
+ 2. Try both PNG and JPEG formats, pick the smaller one
50
+ 3. If still too large, try JPEG with decreasing quality
51
+ 4. If still too large, progressively reduce dimensions
52
+
53
+ Args:
54
+ data: Raw image bytes
55
+ media_type: MIME type of the image
56
+ max_size: Maximum width/height in pixels
57
+ max_bytes: Maximum file size in bytes (default: 4.5MB)
58
+ jpeg_quality: Initial quality for JPEG output (1-100)
59
+
60
+ Returns:
61
+ Tuple of (image_data, media_type, dimension_note).
62
+ dimension_note is None if no resizing was needed, otherwise contains
63
+ a message about the resize for the model to map coordinates.
64
+ """
65
+ from PIL import Image
66
+
67
+ max_bytes = max_bytes if max_bytes is not None else DEFAULT_MAX_BYTES
68
+
69
+ img = Image.open(BytesIO(data))
70
+ original_width, original_height = img.size
71
+
72
+ # Check if already within all limits (dimensions AND size)
73
+ original_size = len(data)
74
+ if original_width <= max_size and original_height <= max_size and original_size <= max_bytes:
75
+ return data, media_type, None
76
+
77
+ # Calculate initial dimensions respecting max limits
78
+ target_width = original_width
79
+ target_height = original_height
80
+
81
+ if target_width > max_size:
82
+ target_height = round(target_height * max_size / target_width)
83
+ target_width = max_size
84
+ if target_height > max_size:
85
+ target_width = round(target_width * max_size / target_height)
86
+ target_height = max_size
87
+
88
+ def try_both_formats(
89
+ img: Image.Image,
90
+ width: int,
91
+ height: int,
92
+ quality: int,
93
+ ) -> tuple[bytes, str]:
94
+ """Resize and encode in both formats, returning the smaller one."""
95
+ resized = img.resize((width, height), Image.Resampling.LANCZOS)
96
+
97
+ # Convert to RGB for JPEG if needed (handles RGBA, P mode, etc.)
98
+ if resized.mode in ("RGBA", "LA", "P"):
99
+ rgb_img = Image.new("RGB", resized.size, (255, 255, 255))
100
+ if resized.mode == "P":
101
+ resized = resized.convert("RGBA")
102
+ rgb_img.paste(resized, mask=resized.split()[-1] if resized.mode == "RGBA" else None)
103
+ jpeg_source = rgb_img
104
+ else:
105
+ jpeg_source = resized.convert("RGB") if resized.mode != "RGB" else resized
106
+
107
+ # Try PNG
108
+ png_buf = BytesIO()
109
+ resized.save(png_buf, format="PNG", optimize=True)
110
+ png_data = png_buf.getvalue()
111
+
112
+ # Try JPEG
113
+ jpeg_buf = BytesIO()
114
+ jpeg_source.save(jpeg_buf, format="JPEG", quality=quality, optimize=True)
115
+ jpeg_data = jpeg_buf.getvalue()
116
+
117
+ return _pick_smaller((png_data, "image/png"), (jpeg_data, "image/jpeg"))
118
+
119
+ # Quality and scale steps for progressive reduction
120
+ quality_steps = [85, 70, 55, 40]
121
+ scale_steps = [1.0, 0.75, 0.5, 0.35, 0.25]
122
+
123
+ final_width = target_width
124
+ final_height = target_height
125
+
126
+ # First attempt: resize to target dimensions, try both formats
127
+ best_data, best_type = try_both_formats(img, target_width, target_height, jpeg_quality)
128
+
129
+ if len(best_data) <= max_bytes:
130
+ note = _make_resize_note(original_width, original_height, final_width, final_height)
131
+ return best_data, best_type, note
132
+
133
+ # Still too large - try with decreasing quality
134
+ for quality in quality_steps:
135
+ best_data, best_type = try_both_formats(img, target_width, target_height, quality)
136
+
137
+ if len(best_data) <= max_bytes:
138
+ note = _make_resize_note(original_width, original_height, final_width, final_height)
139
+ return best_data, best_type, note
140
+
141
+ # Still too large - reduce dimensions progressively
142
+ for scale_factor in scale_steps:
143
+ final_width = round(target_width * scale_factor)
144
+ final_height = round(target_height * scale_factor)
145
+
146
+ # Skip if dimensions are too small
147
+ if final_width < MIN_DIMENSION or final_height < MIN_DIMENSION:
148
+ break
149
+
150
+ for quality in quality_steps:
151
+ best_data, best_type = try_both_formats(img, final_width, final_height, quality)
152
+
153
+ if len(best_data) <= max_bytes:
154
+ note = _make_resize_note(original_width, original_height, final_width, final_height)
155
+ return best_data, best_type, note
156
+
157
+ # Last resort: return smallest version we produced even if over limit
158
+ note = _make_resize_note(
159
+ original_width, original_height, final_width, final_height, " (may exceed size limit)"
160
+ )
161
+ return best_data, best_type, note
@@ -7,7 +7,7 @@ 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
@@ -28,7 +28,11 @@ from agentpool.mime_utils import guess_type, is_binary_content, is_binary_mime
28
28
  from agentpool.resource_providers import ResourceProvider
29
29
  from agentpool_toolsets.builtin.file_edit import replace_content
30
30
  from agentpool_toolsets.builtin.file_edit.fuzzy_matcher import StreamingFuzzyMatcher
31
- from agentpool_toolsets.fsspec_toolset.diagnostics import DiagnosticsManager
31
+ from agentpool_toolsets.fsspec_toolset.diagnostics import (
32
+ DiagnosticsConfig,
33
+ DiagnosticsManager,
34
+ format_diagnostics_table,
35
+ )
32
36
  from agentpool_toolsets.fsspec_toolset.grep import GrepBackend
33
37
  from agentpool_toolsets.fsspec_toolset.helpers import (
34
38
  format_directory_listing,
@@ -45,7 +49,7 @@ from agentpool_toolsets.fsspec_toolset.streaming_diff_parser import (
45
49
  if TYPE_CHECKING:
46
50
  import fsspec
47
51
  from fsspec.asyn import AsyncFileSystem
48
- from pydantic_ai.messages import ModelResponse
52
+ from pydantic_ai.messages import ModelRequest, ModelResponse
49
53
 
50
54
  from agentpool.agents.base_agent import BaseAgent
51
55
  from agentpool.common_types import ModelType
@@ -78,6 +82,9 @@ class FSSpecTools(ResourceProvider):
78
82
  enable_diagnostics: bool = False,
79
83
  large_file_tokens: int = 12_000,
80
84
  map_max_tokens: int = 2048,
85
+ edit_tool: Literal["simple", "batch", "agentic"] = "simple",
86
+ max_image_size: int | None = 2000,
87
+ max_image_bytes: int | None = None,
81
88
  ) -> None:
82
89
  """Initialize with an fsspec filesystem or execution environment.
83
90
 
@@ -94,6 +101,12 @@ class FSSpecTools(ResourceProvider):
94
101
  enable_diagnostics: Run LSP CLI diagnostics after file writes (default: False)
95
102
  large_file_tokens: Token threshold for switching to structure map (default: 12000)
96
103
  map_max_tokens: Maximum tokens for structure map output (default: 2048)
104
+ edit_tool: Which edit variant to expose ("simple" or "batch")
105
+ max_image_size: Max width/height for images in pixels. Larger images are
106
+ auto-resized for better model compatibility. Set to None to disable.
107
+ max_image_bytes: Max file size for images in bytes. Images exceeding this
108
+ are compressed using progressive quality/dimension reduction.
109
+ Default: 4.5MB (below Anthropic's 5MB limit).
97
110
  """
98
111
  from fsspec.asyn import AsyncFileSystem
99
112
  from fsspec.implementations.asyn_wrapper import AsyncFileSystemWrapper
@@ -124,6 +137,9 @@ class FSSpecTools(ResourceProvider):
124
137
  self._large_file_tokens = large_file_tokens
125
138
  self._map_max_tokens = map_max_tokens
126
139
  self._repomap: RepoMap | None = None
140
+ self._edit_tool = edit_tool
141
+ self._max_image_size = max_image_size
142
+ self._max_image_bytes = max_image_bytes
127
143
 
128
144
  def get_fs(self, agent_ctx: AgentContext) -> AsyncFileSystem:
129
145
  """Get filesystem, falling back to agent's env if not set.
@@ -139,11 +155,15 @@ class FSSpecTools(ResourceProvider):
139
155
  fs = agent_ctx.agent.env.get_fs()
140
156
  return fs if isinstance(fs, AsyncFileSystem) else AsyncFileSystemWrapper(fs)
141
157
 
142
- def _get_diagnostics_manager(self, agent_ctx: AgentContext) -> DiagnosticsManager:
158
+ def _get_diagnostics_manager(self, agent_ctx: AgentContext) -> DiagnosticsManager | None:
143
159
  """Get or create the diagnostics manager."""
160
+ if not self._enable_diagnostics:
161
+ return None
144
162
  if self._diagnostics is None:
145
163
  env = self.execution_env or agent_ctx.agent.env
146
- self._diagnostics = DiagnosticsManager(env if self._enable_diagnostics else None)
164
+ # Default to rust-only for fast feedback after edits
165
+ config = DiagnosticsConfig(rust_only=True, max_servers_per_language=1)
166
+ self._diagnostics = DiagnosticsManager(env, config=config)
147
167
  return self._diagnostics
148
168
 
149
169
  async def _run_diagnostics(self, agent_ctx: AgentContext, path: str) -> str | None:
@@ -151,12 +171,12 @@ class FSSpecTools(ResourceProvider):
151
171
 
152
172
  Returns formatted diagnostics string if issues found, None otherwise.
153
173
  """
154
- if not self._enable_diagnostics:
155
- return None
156
174
  mgr = self._get_diagnostics_manager(agent_ctx)
157
- diagnostics = await mgr.run_for_file(path)
158
- if diagnostics:
159
- return mgr.format_diagnostics(diagnostics)
175
+ if mgr is None:
176
+ return None
177
+ result = await mgr.run_for_file(path)
178
+ if result.diagnostics:
179
+ return format_diagnostics_table(result.diagnostics)
160
180
  return None
161
181
 
162
182
  async def _get_file_map(self, path: str, agent_ctx: AgentContext) -> str | None:
@@ -209,15 +229,23 @@ class FSSpecTools(ResourceProvider):
209
229
 
210
230
  self._tools = [
211
231
  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),
232
+ self.create_tool(self.read, category="read", read_only=True, idempotent=True),
213
233
  self.create_tool(self.grep, category="search", read_only=True, idempotent=True),
214
- self.create_tool(self.write_file, category="edit"),
234
+ self.create_tool(self.write, category="edit"),
215
235
  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
236
  self.create_tool(self.download_file, category="read", open_world=True),
219
237
  ]
220
238
 
239
+ # Add edit tool based on config - mutually exclusive
240
+ if self._edit_tool == "agentic":
241
+ self._tools.append(self.create_tool(self.agentic_edit, category="edit"))
242
+ elif self._edit_tool == "batch":
243
+ self._tools.append(
244
+ self.create_tool(self.edit_batch, category="edit", name_override="edit")
245
+ )
246
+ else: # simple
247
+ self._tools.append(self.create_tool(self.edit, category="edit"))
248
+
221
249
  if self.converter: # Only add read_as_markdown if converter is available
222
250
  self._tools.append(
223
251
  self.create_tool(
@@ -329,14 +357,14 @@ class FSSpecTools(ResourceProvider):
329
357
  else:
330
358
  return result
331
359
 
332
- async def read_file( # noqa: D417
360
+ async def read( # noqa: D417
333
361
  self,
334
362
  agent_ctx: AgentContext,
335
363
  path: str,
336
364
  encoding: str = "utf-8",
337
365
  line: int | None = None,
338
366
  limit: int | None = None,
339
- ) -> str | BinaryContent:
367
+ ) -> str | BinaryContent | list[str | BinaryContent]:
340
368
  """Read the context of a text file, or use vision capabilites to read images or documents.
341
369
 
342
370
  Args:
@@ -346,7 +374,8 @@ class FSSpecTools(ResourceProvider):
346
374
  limit: Optional maximum number of lines to read (text files only)
347
375
 
348
376
  Returns:
349
- Text content for text files, BinaryContent for binary files, or dict with error
377
+ Text content for text files, BinaryContent for binary files (with optional
378
+ dimension note as list when image was resized), or dict with error
350
379
  """
351
380
  path = self._resolve_path(path, agent_ctx)
352
381
  msg = f"Reading file: {path}"
@@ -363,6 +392,18 @@ class FSSpecTools(ResourceProvider):
363
392
  data = await self.get_fs(agent_ctx)._cat_file(path)
364
393
  await agent_ctx.events.file_operation("read", path=path, success=True)
365
394
  mime = mime_type or "application/octet-stream"
395
+ # Resize images if needed
396
+ if self._max_image_size and mime.startswith("image/"):
397
+ from agentpool_toolsets.fsspec_toolset.image_utils import (
398
+ resize_image_if_needed,
399
+ )
400
+
401
+ data, mime, note = resize_image_if_needed(
402
+ data, mime, self._max_image_size, self._max_image_bytes
403
+ )
404
+ if note:
405
+ # Return resized image with dimension note for coordinate mapping
406
+ return [note, BinaryContent(data=data, media_type=mime, identifier=path)]
366
407
  return BinaryContent(data=data, media_type=mime, identifier=path)
367
408
  # Read content and probe for binary (git-style null byte detection)
368
409
  data = await self.get_fs(agent_ctx)._cat_file(path)
@@ -370,6 +411,17 @@ class FSSpecTools(ResourceProvider):
370
411
  # Binary file - return as BinaryContent for native model handling
371
412
  await agent_ctx.events.file_operation("read", path=path, success=True)
372
413
  mime = mime_type or "application/octet-stream"
414
+ # Resize images if needed
415
+ if self._max_image_size and mime.startswith("image/"):
416
+ from agentpool_toolsets.fsspec_toolset.image_utils import (
417
+ resize_image_if_needed,
418
+ )
419
+
420
+ data, mime, note = resize_image_if_needed(
421
+ data, mime, self._max_image_size, self._max_image_bytes
422
+ )
423
+ if note:
424
+ return [note, BinaryContent(data=data, media_type=mime, identifier=path)]
373
425
  return BinaryContent(data=data, media_type=mime, identifier=path)
374
426
  content = data.decode(encoding)
375
427
 
@@ -445,7 +497,7 @@ class FSSpecTools(ResourceProvider):
445
497
  else:
446
498
  return content
447
499
 
448
- async def write_file( # noqa: D417
500
+ async def write( # noqa: D417
449
501
  self,
450
502
  agent_ctx: AgentContext,
451
503
  path: str,
@@ -522,7 +574,15 @@ class FSSpecTools(ResourceProvider):
522
574
  "file_existed": file_exists,
523
575
  "bytes_written": content_bytes,
524
576
  }
525
- await agent_ctx.events.file_operation("write", path=path, success=True)
577
+ # Emit file operation with content for UI display
578
+ from agentpool.agents.events import DiffContentItem
579
+
580
+ await agent_ctx.events.tool_call_progress(
581
+ title=f"Wrote: {path}",
582
+ items=[
583
+ DiffContentItem(path=path, old_text="", new_text=content),
584
+ ],
585
+ )
526
586
 
527
587
  # Run diagnostics if enabled
528
588
  if diagnostics_output := await self._run_diagnostics(agent_ctx, path):
@@ -599,7 +659,7 @@ class FSSpecTools(ResourceProvider):
599
659
  await agent_ctx.events.file_operation("delete", path=path, success=True)
600
660
  return result
601
661
 
602
- async def edit_file( # noqa: D417
662
+ async def edit( # noqa: D417
603
663
  self,
604
664
  agent_ctx: AgentContext,
605
665
  path: str,
@@ -607,6 +667,7 @@ class FSSpecTools(ResourceProvider):
607
667
  new_string: str,
608
668
  description: str,
609
669
  replace_all: bool = False,
670
+ line_hint: int | None = None,
610
671
  ) -> str:
611
672
  r"""Edit a file by replacing specific content with smart matching.
612
673
 
@@ -619,15 +680,71 @@ class FSSpecTools(ResourceProvider):
619
680
  new_string: Text content to replace it with
620
681
  description: Human-readable description of what the edit accomplishes
621
682
  replace_all: Whether to replace all occurrences (default: False)
683
+ line_hint: Line number hint to disambiguate when multiple matches exist.
684
+ If the pattern matches multiple locations, the match closest to this
685
+ line will be used. Useful after getting a "multiple matches" error.
686
+
687
+ Returns:
688
+ Success message with edit summary
689
+ """
690
+ return await self.edit_batch(
691
+ agent_ctx,
692
+ path,
693
+ replacements=[(old_string, new_string)],
694
+ description=description,
695
+ replace_all=replace_all,
696
+ line_hint=line_hint,
697
+ )
698
+
699
+ async def edit_batch( # noqa: D417
700
+ self,
701
+ agent_ctx: AgentContext,
702
+ path: str,
703
+ replacements: list[tuple[str, str]],
704
+ description: str,
705
+ replace_all: bool = False,
706
+ line_hint: int | None = None,
707
+ ) -> str:
708
+ r"""Edit a file by applying multiple replacements in one operation.
709
+
710
+ Uses sophisticated matching strategies to handle whitespace, indentation,
711
+ and other variations. Shows the changes as a diff in the UI.
712
+
713
+ Replacements are applied sequentially, so later replacements see the result
714
+ of earlier ones. Each old_string must uniquely match one location (unless
715
+ replace_all=True). If a pattern matches multiple locations, include more
716
+ surrounding context to disambiguate.
717
+
718
+ Args:
719
+ path: File path (absolute or relative to session cwd)
720
+ replacements: List of (old_string, new_string) tuples to apply sequentially.
721
+ Each old_string should include enough context to uniquely identify
722
+ the target location. For multi-line edits, include the full block.
723
+ description: Human-readable description of what the edit accomplishes
724
+ replace_all: Whether to replace all occurrences of each pattern (default: False)
725
+ line_hint: Line number hint to disambiguate when multiple matches exist.
726
+ Only applies when there is a single replacement. If the pattern matches
727
+ multiple locations, the match closest to this line will be used.
622
728
 
623
729
  Returns:
624
730
  Success message with edit summary
731
+
732
+ Example:
733
+ replacements=[
734
+ ("def old_name(", "def new_name("),
735
+ ("old_name()", "new_name()"), # Update call sites
736
+ ]
625
737
  """
626
738
  path = self._resolve_path(path, agent_ctx)
627
739
  msg = f"Editing file: {path}"
628
740
  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"
741
+
742
+ if not replacements:
743
+ return "Error: replacements list cannot be empty"
744
+
745
+ for old_str, new_str in replacements:
746
+ if old_str == new_str:
747
+ return f"Error: old_string and new_string must be different: {old_str!r}"
631
748
 
632
749
  # Send initial pending notification
633
750
  await agent_ctx.events.file_operation("edit", path=path, success=True)
@@ -637,14 +754,21 @@ class FSSpecTools(ResourceProvider):
637
754
  if isinstance(original_content, bytes):
638
755
  original_content = original_content.decode("utf-8")
639
756
 
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
757
+ # Apply all replacements sequentially
758
+ new_content = original_content
759
+ # line_hint only makes sense for single replacements
760
+ hint = line_hint if len(replacements) == 1 else None
761
+ for old_str, new_str in replacements:
762
+ try:
763
+ new_content = replace_content(
764
+ new_content, old_str, new_str, replace_all, line_hint=hint
765
+ )
766
+ except ValueError as e:
767
+ error_msg = f"Edit failed on replacement {old_str!r}: {e}"
768
+ await agent_ctx.events.file_operation(
769
+ "edit", path=path, success=False, error=error_msg
770
+ )
771
+ return error_msg
648
772
 
649
773
  await self._write(agent_ctx, path, new_content)
650
774
  success_msg = f"Successfully edited {Path(path).name}: {description}"
@@ -906,8 +1030,6 @@ class FSSpecTools(ResourceProvider):
906
1030
  Returns:
907
1031
  Success message with edit summary
908
1032
  """
909
- from pydantic_ai.messages import CachePoint, ModelRequest
910
-
911
1033
  from agentpool.messaging import ChatMessage, MessageHistory
912
1034
 
913
1035
  path = self._resolve_path(path, agent_ctx)
@@ -967,17 +1089,16 @@ class FSSpecTools(ResourceProvider):
967
1089
  else:
968
1090
  all_messages.append(msg)
969
1091
 
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)
1092
+ # Inject CachePoint to cache everything up to this point
1093
+ # if all_messages:
1094
+ # cache_request: ModelRequest = ModelRequest(parts=[CachePoint()])
1095
+ # all_messages.append(cache_request)
974
1096
 
975
1097
  # Wrap in a single ChatMessage for the forked history
976
1098
  fork_history = MessageHistory(
977
1099
  messages=[ChatMessage(messages=all_messages, role="user", content="")]
978
1100
  )
979
- else:
980
- fork_history = MessageHistory()
1101
+ fork_history = MessageHistory()
981
1102
 
982
1103
  # Stream the edit using the same agent but with forked history
983
1104
  if mode == "edit" and matcher == "zed":
@@ -0,0 +1,5 @@
1
+ """MCP Discovery Toolset - dynamic MCP server exploration and tool execution."""
2
+
3
+ from agentpool_toolsets.mcp_discovery.toolset import MCPDiscoveryToolset
4
+
5
+ __all__ = ["MCPDiscoveryToolset"]