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.
- acp/__init__.py +13 -0
- acp/bridge/README.md +15 -2
- acp/bridge/__init__.py +3 -2
- acp/bridge/__main__.py +60 -19
- acp/bridge/ws_server.py +173 -0
- acp/bridge/ws_server_cli.py +89 -0
- acp/notifications.py +2 -1
- acp/stdio.py +39 -9
- acp/transports.py +362 -2
- acp/utils.py +15 -2
- agentpool/__init__.py +4 -1
- agentpool/agents/__init__.py +2 -0
- agentpool/agents/acp_agent/acp_agent.py +203 -88
- agentpool/agents/acp_agent/acp_converters.py +46 -21
- agentpool/agents/acp_agent/client_handler.py +157 -3
- agentpool/agents/acp_agent/session_state.py +4 -1
- agentpool/agents/agent.py +314 -107
- agentpool/agents/agui_agent/__init__.py +0 -2
- agentpool/agents/agui_agent/agui_agent.py +90 -21
- agentpool/agents/agui_agent/agui_converters.py +0 -131
- agentpool/agents/base_agent.py +163 -1
- agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
- agentpool/agents/claude_code_agent/converters.py +71 -3
- agentpool/agents/claude_code_agent/history.py +474 -0
- agentpool/agents/context.py +40 -0
- agentpool/agents/events/__init__.py +2 -0
- agentpool/agents/events/builtin_handlers.py +2 -1
- agentpool/agents/events/event_emitter.py +29 -2
- agentpool/agents/events/events.py +20 -0
- agentpool/agents/modes.py +54 -0
- agentpool/agents/tool_call_accumulator.py +213 -0
- agentpool/common_types.py +21 -0
- agentpool/config_resources/__init__.py +38 -1
- agentpool/config_resources/claude_code_agent.yml +3 -0
- agentpool/delegation/pool.py +37 -29
- agentpool/delegation/team.py +1 -0
- agentpool/delegation/teamrun.py +1 -0
- agentpool/diagnostics/__init__.py +53 -0
- agentpool/diagnostics/lsp_manager.py +1593 -0
- agentpool/diagnostics/lsp_proxy.py +41 -0
- agentpool/diagnostics/lsp_proxy_script.py +229 -0
- agentpool/diagnostics/models.py +398 -0
- agentpool/mcp_server/__init__.py +0 -2
- agentpool/mcp_server/client.py +12 -3
- agentpool/mcp_server/manager.py +25 -31
- agentpool/mcp_server/registries/official_registry_client.py +25 -0
- agentpool/mcp_server/tool_bridge.py +78 -66
- agentpool/messaging/__init__.py +0 -2
- agentpool/messaging/compaction.py +72 -197
- agentpool/messaging/message_history.py +12 -0
- agentpool/messaging/messages.py +52 -9
- agentpool/messaging/processing.py +3 -1
- agentpool/models/acp_agents/base.py +0 -22
- agentpool/models/acp_agents/mcp_capable.py +8 -148
- agentpool/models/acp_agents/non_mcp.py +129 -72
- agentpool/models/agents.py +35 -13
- agentpool/models/claude_code_agents.py +33 -2
- agentpool/models/manifest.py +43 -0
- agentpool/repomap.py +1 -1
- agentpool/resource_providers/__init__.py +9 -1
- agentpool/resource_providers/aggregating.py +52 -3
- agentpool/resource_providers/base.py +57 -1
- agentpool/resource_providers/mcp_provider.py +23 -0
- agentpool/resource_providers/plan_provider.py +130 -41
- agentpool/resource_providers/pool.py +2 -0
- agentpool/resource_providers/static.py +2 -0
- agentpool/sessions/__init__.py +2 -1
- agentpool/sessions/manager.py +31 -2
- agentpool/sessions/models.py +50 -0
- agentpool/skills/registry.py +13 -8
- agentpool/storage/manager.py +217 -1
- agentpool/testing.py +537 -19
- agentpool/utils/file_watcher.py +269 -0
- agentpool/utils/identifiers.py +121 -0
- agentpool/utils/pydantic_ai_helpers.py +46 -0
- agentpool/utils/streams.py +690 -1
- agentpool/utils/subprocess_utils.py +155 -0
- agentpool/utils/token_breakdown.py +461 -0
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
- agentpool_cli/__main__.py +4 -0
- agentpool_cli/serve_acp.py +41 -20
- agentpool_cli/serve_agui.py +87 -0
- agentpool_cli/serve_opencode.py +119 -0
- agentpool_commands/__init__.py +30 -0
- agentpool_commands/agents.py +74 -1
- agentpool_commands/history.py +62 -0
- agentpool_commands/mcp.py +176 -0
- agentpool_commands/models.py +56 -3
- agentpool_commands/tools.py +57 -0
- agentpool_commands/utils.py +51 -0
- agentpool_config/builtin_tools.py +77 -22
- agentpool_config/commands.py +24 -1
- agentpool_config/compaction.py +258 -0
- agentpool_config/mcp_server.py +131 -1
- agentpool_config/storage.py +46 -1
- agentpool_config/tools.py +7 -1
- agentpool_config/toolsets.py +92 -148
- agentpool_server/acp_server/acp_agent.py +134 -150
- agentpool_server/acp_server/commands/acp_commands.py +216 -51
- agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
- agentpool_server/acp_server/server.py +23 -79
- agentpool_server/acp_server/session.py +181 -19
- agentpool_server/opencode_server/.rules +95 -0
- agentpool_server/opencode_server/ENDPOINTS.md +362 -0
- agentpool_server/opencode_server/__init__.py +27 -0
- agentpool_server/opencode_server/command_validation.py +172 -0
- agentpool_server/opencode_server/converters.py +869 -0
- agentpool_server/opencode_server/dependencies.py +24 -0
- agentpool_server/opencode_server/input_provider.py +269 -0
- agentpool_server/opencode_server/models/__init__.py +228 -0
- agentpool_server/opencode_server/models/agent.py +53 -0
- agentpool_server/opencode_server/models/app.py +60 -0
- agentpool_server/opencode_server/models/base.py +26 -0
- agentpool_server/opencode_server/models/common.py +23 -0
- agentpool_server/opencode_server/models/config.py +37 -0
- agentpool_server/opencode_server/models/events.py +647 -0
- agentpool_server/opencode_server/models/file.py +88 -0
- agentpool_server/opencode_server/models/mcp.py +25 -0
- agentpool_server/opencode_server/models/message.py +162 -0
- agentpool_server/opencode_server/models/parts.py +190 -0
- agentpool_server/opencode_server/models/provider.py +81 -0
- agentpool_server/opencode_server/models/pty.py +43 -0
- agentpool_server/opencode_server/models/session.py +99 -0
- agentpool_server/opencode_server/routes/__init__.py +25 -0
- agentpool_server/opencode_server/routes/agent_routes.py +442 -0
- agentpool_server/opencode_server/routes/app_routes.py +139 -0
- agentpool_server/opencode_server/routes/config_routes.py +241 -0
- agentpool_server/opencode_server/routes/file_routes.py +392 -0
- agentpool_server/opencode_server/routes/global_routes.py +94 -0
- agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
- agentpool_server/opencode_server/routes/message_routes.py +705 -0
- agentpool_server/opencode_server/routes/pty_routes.py +299 -0
- agentpool_server/opencode_server/routes/session_routes.py +1205 -0
- agentpool_server/opencode_server/routes/tui_routes.py +139 -0
- agentpool_server/opencode_server/server.py +430 -0
- agentpool_server/opencode_server/state.py +121 -0
- agentpool_server/opencode_server/time_utils.py +8 -0
- agentpool_storage/__init__.py +16 -0
- agentpool_storage/base.py +103 -0
- agentpool_storage/claude_provider.py +907 -0
- agentpool_storage/file_provider.py +129 -0
- agentpool_storage/memory_provider.py +61 -0
- agentpool_storage/models.py +3 -0
- agentpool_storage/opencode_provider.py +730 -0
- agentpool_storage/project_store.py +325 -0
- agentpool_storage/session_store.py +6 -0
- agentpool_storage/sql_provider/__init__.py +4 -2
- agentpool_storage/sql_provider/models.py +48 -0
- agentpool_storage/sql_provider/sql_provider.py +134 -1
- agentpool_storage/sql_provider/utils.py +10 -1
- agentpool_storage/text_log_provider.py +1 -0
- agentpool_toolsets/builtin/__init__.py +0 -8
- agentpool_toolsets/builtin/code.py +95 -56
- agentpool_toolsets/builtin/debug.py +16 -21
- agentpool_toolsets/builtin/execution_environment.py +99 -103
- agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
- agentpool_toolsets/builtin/skills.py +86 -4
- agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
- agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
- agentpool_toolsets/fsspec_toolset/grep.py +74 -2
- agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
- agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
- agentpool_toolsets/mcp_discovery/__init__.py +5 -0
- agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
- agentpool_toolsets/mcp_discovery/toolset.py +454 -0
- agentpool_toolsets/mcp_run_toolset.py +84 -6
- agentpool_toolsets/builtin/agent_management.py +0 -239
- agentpool_toolsets/builtin/history.py +0 -36
- agentpool_toolsets/builtin/integration.py +0 -85
- agentpool_toolsets/builtin/tool_management.py +0 -90
- {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
630
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
cache_request: ModelRequest = ModelRequest(parts=[CachePoint()])
|
|
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
|
-
|
|
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":
|
|
Binary file
|