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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. acp/__init__.py +13 -4
  2. acp/acp_requests.py +20 -77
  3. acp/agent/connection.py +8 -0
  4. acp/agent/implementations/debug_server/debug_server.py +6 -2
  5. acp/agent/protocol.py +6 -0
  6. acp/bridge/README.md +15 -2
  7. acp/bridge/__init__.py +3 -2
  8. acp/bridge/__main__.py +60 -19
  9. acp/bridge/ws_server.py +173 -0
  10. acp/bridge/ws_server_cli.py +89 -0
  11. acp/client/connection.py +38 -29
  12. acp/client/implementations/default_client.py +3 -2
  13. acp/client/implementations/headless_client.py +2 -2
  14. acp/connection.py +2 -2
  15. acp/notifications.py +20 -50
  16. acp/schema/__init__.py +2 -0
  17. acp/schema/agent_responses.py +21 -0
  18. acp/schema/client_requests.py +3 -3
  19. acp/schema/session_state.py +63 -29
  20. acp/stdio.py +39 -9
  21. acp/task/supervisor.py +2 -2
  22. acp/transports.py +362 -2
  23. acp/utils.py +17 -4
  24. agentpool/__init__.py +6 -1
  25. agentpool/agents/__init__.py +2 -0
  26. agentpool/agents/acp_agent/acp_agent.py +407 -277
  27. agentpool/agents/acp_agent/acp_converters.py +196 -38
  28. agentpool/agents/acp_agent/client_handler.py +191 -26
  29. agentpool/agents/acp_agent/session_state.py +17 -6
  30. agentpool/agents/agent.py +607 -572
  31. agentpool/agents/agui_agent/__init__.py +0 -2
  32. agentpool/agents/agui_agent/agui_agent.py +176 -110
  33. agentpool/agents/agui_agent/agui_converters.py +0 -131
  34. agentpool/agents/agui_agent/helpers.py +3 -4
  35. agentpool/agents/base_agent.py +632 -17
  36. agentpool/agents/claude_code_agent/FORKING.md +191 -0
  37. agentpool/agents/claude_code_agent/__init__.py +13 -1
  38. agentpool/agents/claude_code_agent/claude_code_agent.py +1058 -291
  39. agentpool/agents/claude_code_agent/converters.py +74 -143
  40. agentpool/agents/claude_code_agent/history.py +474 -0
  41. agentpool/agents/claude_code_agent/models.py +77 -0
  42. agentpool/agents/claude_code_agent/static_info.py +100 -0
  43. agentpool/agents/claude_code_agent/usage.py +242 -0
  44. agentpool/agents/context.py +40 -0
  45. agentpool/agents/events/__init__.py +24 -0
  46. agentpool/agents/events/builtin_handlers.py +67 -1
  47. agentpool/agents/events/event_emitter.py +32 -2
  48. agentpool/agents/events/events.py +104 -3
  49. agentpool/agents/events/infer_info.py +145 -0
  50. agentpool/agents/events/processors.py +254 -0
  51. agentpool/agents/interactions.py +41 -6
  52. agentpool/agents/modes.py +67 -0
  53. agentpool/agents/slashed_agent.py +5 -4
  54. agentpool/agents/tool_call_accumulator.py +213 -0
  55. agentpool/agents/tool_wrapping.py +18 -6
  56. agentpool/common_types.py +56 -21
  57. agentpool/config_resources/__init__.py +38 -1
  58. agentpool/config_resources/acp_assistant.yml +2 -2
  59. agentpool/config_resources/agents.yml +3 -0
  60. agentpool/config_resources/agents_template.yml +1 -0
  61. agentpool/config_resources/claude_code_agent.yml +10 -6
  62. agentpool/config_resources/external_acp_agents.yml +2 -1
  63. agentpool/delegation/base_team.py +4 -30
  64. agentpool/delegation/pool.py +136 -289
  65. agentpool/delegation/team.py +58 -57
  66. agentpool/delegation/teamrun.py +51 -55
  67. agentpool/diagnostics/__init__.py +53 -0
  68. agentpool/diagnostics/lsp_manager.py +1593 -0
  69. agentpool/diagnostics/lsp_proxy.py +41 -0
  70. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  71. agentpool/diagnostics/models.py +398 -0
  72. agentpool/functional/run.py +10 -4
  73. agentpool/mcp_server/__init__.py +0 -2
  74. agentpool/mcp_server/client.py +76 -32
  75. agentpool/mcp_server/conversions.py +54 -13
  76. agentpool/mcp_server/manager.py +34 -54
  77. agentpool/mcp_server/registries/official_registry_client.py +35 -1
  78. agentpool/mcp_server/tool_bridge.py +186 -139
  79. agentpool/messaging/__init__.py +0 -2
  80. agentpool/messaging/compaction.py +72 -197
  81. agentpool/messaging/connection_manager.py +11 -10
  82. agentpool/messaging/event_manager.py +5 -5
  83. agentpool/messaging/message_container.py +6 -30
  84. agentpool/messaging/message_history.py +99 -8
  85. agentpool/messaging/messagenode.py +52 -14
  86. agentpool/messaging/messages.py +54 -35
  87. agentpool/messaging/processing.py +12 -22
  88. agentpool/models/__init__.py +1 -1
  89. agentpool/models/acp_agents/base.py +6 -24
  90. agentpool/models/acp_agents/mcp_capable.py +126 -157
  91. agentpool/models/acp_agents/non_mcp.py +129 -95
  92. agentpool/models/agents.py +98 -76
  93. agentpool/models/agui_agents.py +1 -1
  94. agentpool/models/claude_code_agents.py +144 -19
  95. agentpool/models/file_parsing.py +0 -1
  96. agentpool/models/manifest.py +113 -50
  97. agentpool/prompts/conversion_manager.py +1 -1
  98. agentpool/prompts/prompts.py +5 -2
  99. agentpool/repomap.py +1 -1
  100. agentpool/resource_providers/__init__.py +11 -1
  101. agentpool/resource_providers/aggregating.py +56 -5
  102. agentpool/resource_providers/base.py +70 -4
  103. agentpool/resource_providers/codemode/code_executor.py +72 -5
  104. agentpool/resource_providers/codemode/helpers.py +2 -2
  105. agentpool/resource_providers/codemode/provider.py +64 -12
  106. agentpool/resource_providers/codemode/remote_mcp_execution.py +2 -2
  107. agentpool/resource_providers/codemode/remote_provider.py +9 -12
  108. agentpool/resource_providers/filtering.py +3 -1
  109. agentpool/resource_providers/mcp_provider.py +89 -12
  110. agentpool/resource_providers/plan_provider.py +228 -46
  111. agentpool/resource_providers/pool.py +7 -3
  112. agentpool/resource_providers/resource_info.py +111 -0
  113. agentpool/resource_providers/static.py +4 -2
  114. agentpool/sessions/__init__.py +4 -1
  115. agentpool/sessions/manager.py +33 -5
  116. agentpool/sessions/models.py +59 -6
  117. agentpool/sessions/protocol.py +28 -0
  118. agentpool/sessions/session.py +11 -55
  119. agentpool/skills/registry.py +13 -8
  120. agentpool/storage/manager.py +572 -49
  121. agentpool/talk/registry.py +4 -4
  122. agentpool/talk/talk.py +9 -10
  123. agentpool/testing.py +538 -20
  124. agentpool/tool_impls/__init__.py +6 -0
  125. agentpool/tool_impls/agent_cli/__init__.py +42 -0
  126. agentpool/tool_impls/agent_cli/tool.py +95 -0
  127. agentpool/tool_impls/bash/__init__.py +64 -0
  128. agentpool/tool_impls/bash/helpers.py +35 -0
  129. agentpool/tool_impls/bash/tool.py +171 -0
  130. agentpool/tool_impls/delete_path/__init__.py +70 -0
  131. agentpool/tool_impls/delete_path/tool.py +142 -0
  132. agentpool/tool_impls/download_file/__init__.py +80 -0
  133. agentpool/tool_impls/download_file/tool.py +183 -0
  134. agentpool/tool_impls/execute_code/__init__.py +55 -0
  135. agentpool/tool_impls/execute_code/tool.py +163 -0
  136. agentpool/tool_impls/grep/__init__.py +80 -0
  137. agentpool/tool_impls/grep/tool.py +200 -0
  138. agentpool/tool_impls/list_directory/__init__.py +73 -0
  139. agentpool/tool_impls/list_directory/tool.py +197 -0
  140. agentpool/tool_impls/question/__init__.py +42 -0
  141. agentpool/tool_impls/question/tool.py +127 -0
  142. agentpool/tool_impls/read/__init__.py +104 -0
  143. agentpool/tool_impls/read/tool.py +305 -0
  144. agentpool/tools/__init__.py +2 -1
  145. agentpool/tools/base.py +114 -34
  146. agentpool/tools/manager.py +57 -1
  147. agentpool/ui/base.py +2 -2
  148. agentpool/ui/mock_provider.py +2 -2
  149. agentpool/ui/stdlib_provider.py +2 -2
  150. agentpool/utils/file_watcher.py +269 -0
  151. agentpool/utils/identifiers.py +121 -0
  152. agentpool/utils/pydantic_ai_helpers.py +46 -0
  153. agentpool/utils/streams.py +616 -2
  154. agentpool/utils/subprocess_utils.py +155 -0
  155. agentpool/utils/token_breakdown.py +461 -0
  156. agentpool/vfs_registry.py +7 -2
  157. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/METADATA +41 -27
  158. agentpool-2.5.0.dist-info/RECORD +579 -0
  159. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/WHEEL +1 -1
  160. agentpool_cli/__main__.py +24 -0
  161. agentpool_cli/create.py +1 -1
  162. agentpool_cli/serve_acp.py +100 -21
  163. agentpool_cli/serve_agui.py +87 -0
  164. agentpool_cli/serve_opencode.py +119 -0
  165. agentpool_cli/ui.py +557 -0
  166. agentpool_commands/__init__.py +42 -5
  167. agentpool_commands/agents.py +75 -2
  168. agentpool_commands/history.py +62 -0
  169. agentpool_commands/mcp.py +176 -0
  170. agentpool_commands/models.py +56 -3
  171. agentpool_commands/pool.py +260 -0
  172. agentpool_commands/session.py +1 -1
  173. agentpool_commands/text_sharing/__init__.py +119 -0
  174. agentpool_commands/text_sharing/base.py +123 -0
  175. agentpool_commands/text_sharing/github_gist.py +80 -0
  176. agentpool_commands/text_sharing/opencode.py +462 -0
  177. agentpool_commands/text_sharing/paste_rs.py +59 -0
  178. agentpool_commands/text_sharing/pastebin.py +116 -0
  179. agentpool_commands/text_sharing/shittycodingagent.py +112 -0
  180. agentpool_commands/tools.py +57 -0
  181. agentpool_commands/utils.py +80 -30
  182. agentpool_config/__init__.py +30 -2
  183. agentpool_config/agentpool_tools.py +498 -0
  184. agentpool_config/builtin_tools.py +77 -22
  185. agentpool_config/commands.py +24 -1
  186. agentpool_config/compaction.py +258 -0
  187. agentpool_config/converters.py +1 -1
  188. agentpool_config/event_handlers.py +42 -0
  189. agentpool_config/events.py +1 -1
  190. agentpool_config/forward_targets.py +1 -4
  191. agentpool_config/jinja.py +3 -3
  192. agentpool_config/mcp_server.py +132 -6
  193. agentpool_config/nodes.py +1 -1
  194. agentpool_config/observability.py +44 -0
  195. agentpool_config/session.py +0 -3
  196. agentpool_config/storage.py +82 -38
  197. agentpool_config/task.py +3 -3
  198. agentpool_config/tools.py +11 -22
  199. agentpool_config/toolsets.py +109 -233
  200. agentpool_server/a2a_server/agent_worker.py +307 -0
  201. agentpool_server/a2a_server/server.py +23 -18
  202. agentpool_server/acp_server/acp_agent.py +234 -181
  203. agentpool_server/acp_server/commands/acp_commands.py +151 -156
  204. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +18 -17
  205. agentpool_server/acp_server/event_converter.py +651 -0
  206. agentpool_server/acp_server/input_provider.py +53 -10
  207. agentpool_server/acp_server/server.py +24 -90
  208. agentpool_server/acp_server/session.py +173 -331
  209. agentpool_server/acp_server/session_manager.py +8 -34
  210. agentpool_server/agui_server/server.py +3 -1
  211. agentpool_server/mcp_server/server.py +5 -2
  212. agentpool_server/opencode_server/.rules +95 -0
  213. agentpool_server/opencode_server/ENDPOINTS.md +401 -0
  214. agentpool_server/opencode_server/OPENCODE_UI_TOOLS_COMPLETE.md +202 -0
  215. agentpool_server/opencode_server/__init__.py +19 -0
  216. agentpool_server/opencode_server/command_validation.py +172 -0
  217. agentpool_server/opencode_server/converters.py +975 -0
  218. agentpool_server/opencode_server/dependencies.py +24 -0
  219. agentpool_server/opencode_server/input_provider.py +421 -0
  220. agentpool_server/opencode_server/models/__init__.py +250 -0
  221. agentpool_server/opencode_server/models/agent.py +53 -0
  222. agentpool_server/opencode_server/models/app.py +72 -0
  223. agentpool_server/opencode_server/models/base.py +26 -0
  224. agentpool_server/opencode_server/models/common.py +23 -0
  225. agentpool_server/opencode_server/models/config.py +37 -0
  226. agentpool_server/opencode_server/models/events.py +821 -0
  227. agentpool_server/opencode_server/models/file.py +88 -0
  228. agentpool_server/opencode_server/models/mcp.py +44 -0
  229. agentpool_server/opencode_server/models/message.py +179 -0
  230. agentpool_server/opencode_server/models/parts.py +323 -0
  231. agentpool_server/opencode_server/models/provider.py +81 -0
  232. agentpool_server/opencode_server/models/pty.py +43 -0
  233. agentpool_server/opencode_server/models/question.py +56 -0
  234. agentpool_server/opencode_server/models/session.py +111 -0
  235. agentpool_server/opencode_server/routes/__init__.py +29 -0
  236. agentpool_server/opencode_server/routes/agent_routes.py +473 -0
  237. agentpool_server/opencode_server/routes/app_routes.py +202 -0
  238. agentpool_server/opencode_server/routes/config_routes.py +302 -0
  239. agentpool_server/opencode_server/routes/file_routes.py +571 -0
  240. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  241. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  242. agentpool_server/opencode_server/routes/message_routes.py +761 -0
  243. agentpool_server/opencode_server/routes/permission_routes.py +63 -0
  244. agentpool_server/opencode_server/routes/pty_routes.py +300 -0
  245. agentpool_server/opencode_server/routes/question_routes.py +128 -0
  246. agentpool_server/opencode_server/routes/session_routes.py +1276 -0
  247. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  248. agentpool_server/opencode_server/server.py +475 -0
  249. agentpool_server/opencode_server/state.py +151 -0
  250. agentpool_server/opencode_server/time_utils.py +8 -0
  251. agentpool_storage/__init__.py +12 -0
  252. agentpool_storage/base.py +184 -2
  253. agentpool_storage/claude_provider/ARCHITECTURE.md +433 -0
  254. agentpool_storage/claude_provider/__init__.py +42 -0
  255. agentpool_storage/claude_provider/provider.py +1089 -0
  256. agentpool_storage/file_provider.py +278 -15
  257. agentpool_storage/memory_provider.py +193 -12
  258. agentpool_storage/models.py +3 -0
  259. agentpool_storage/opencode_provider/ARCHITECTURE.md +386 -0
  260. agentpool_storage/opencode_provider/__init__.py +16 -0
  261. agentpool_storage/opencode_provider/helpers.py +414 -0
  262. agentpool_storage/opencode_provider/provider.py +895 -0
  263. agentpool_storage/project_store.py +325 -0
  264. agentpool_storage/session_store.py +26 -6
  265. agentpool_storage/sql_provider/__init__.py +4 -2
  266. agentpool_storage/sql_provider/models.py +48 -0
  267. agentpool_storage/sql_provider/sql_provider.py +269 -3
  268. agentpool_storage/sql_provider/utils.py +12 -13
  269. agentpool_storage/zed_provider/__init__.py +16 -0
  270. agentpool_storage/zed_provider/helpers.py +281 -0
  271. agentpool_storage/zed_provider/models.py +130 -0
  272. agentpool_storage/zed_provider/provider.py +442 -0
  273. agentpool_storage/zed_provider.py +803 -0
  274. agentpool_toolsets/__init__.py +0 -2
  275. agentpool_toolsets/builtin/__init__.py +2 -12
  276. agentpool_toolsets/builtin/code.py +96 -57
  277. agentpool_toolsets/builtin/debug.py +118 -48
  278. agentpool_toolsets/builtin/execution_environment.py +115 -230
  279. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  280. agentpool_toolsets/builtin/skills.py +9 -4
  281. agentpool_toolsets/builtin/subagent_tools.py +64 -51
  282. agentpool_toolsets/builtin/workers.py +4 -2
  283. agentpool_toolsets/composio_toolset.py +2 -2
  284. agentpool_toolsets/entry_points.py +3 -1
  285. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  286. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  287. agentpool_toolsets/fsspec_toolset/grep.py +99 -7
  288. agentpool_toolsets/fsspec_toolset/helpers.py +3 -2
  289. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  290. agentpool_toolsets/fsspec_toolset/toolset.py +500 -95
  291. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  292. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  293. agentpool_toolsets/mcp_discovery/toolset.py +511 -0
  294. agentpool_toolsets/mcp_run_toolset.py +87 -12
  295. agentpool_toolsets/notifications.py +33 -33
  296. agentpool_toolsets/openapi.py +3 -1
  297. agentpool_toolsets/search_toolset.py +3 -1
  298. agentpool-2.1.9.dist-info/RECORD +0 -474
  299. agentpool_config/resources.py +0 -33
  300. agentpool_server/acp_server/acp_tools.py +0 -43
  301. agentpool_server/acp_server/commands/spawn.py +0 -210
  302. agentpool_storage/text_log_provider.py +0 -275
  303. agentpool_toolsets/builtin/agent_management.py +0 -239
  304. agentpool_toolsets/builtin/chain.py +0 -288
  305. agentpool_toolsets/builtin/history.py +0 -36
  306. agentpool_toolsets/builtin/integration.py +0 -85
  307. agentpool_toolsets/builtin/tool_management.py +0 -90
  308. agentpool_toolsets/builtin/user_interaction.py +0 -52
  309. agentpool_toolsets/semantic_memory_toolset.py +0 -536
  310. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/entry_points.txt +0 -0
  311. {agentpool-2.1.9.dist-info → agentpool-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -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(
@@ -256,19 +328,27 @@ def _parse_ripgrep_json_output(stdout: str, max_output_bytes: int) -> dict[str,
256
328
  matches: list[dict[str, Any]] = []
257
329
  total_bytes = 0
258
330
  was_truncated = False
331
+ match_count = 0 # Track actual matches (not context lines)
259
332
 
260
333
  for line in stdout.splitlines():
261
334
  if not line.strip():
262
335
  continue
263
336
  try:
264
337
  data = anyenv.load_json(line, return_type=dict)
265
- if data.get("type") == "match":
338
+ entry_type = data.get("type")
339
+
340
+ if entry_type in ("match", "context"):
266
341
  match_data = data.get("data", {})
267
342
  path = match_data.get("path", {}).get("text", "")
268
343
  line_num = match_data.get("line_number", 0)
269
344
  content = match_data.get("lines", {}).get("text", "").rstrip("\n")
270
345
 
271
- match_entry = {"path": path, "line": line_num, "content": content}
346
+ match_entry = {
347
+ "path": path,
348
+ "line": line_num,
349
+ "content": content,
350
+ "is_match": entry_type == "match",
351
+ }
272
352
  entry_size = len(path) + len(str(line_num)) + len(content) + 10
273
353
 
274
354
  if total_bytes + entry_size > max_output_bytes:
@@ -277,6 +357,9 @@ def _parse_ripgrep_json_output(stdout: str, max_output_bytes: int) -> dict[str,
277
357
 
278
358
  matches.append(match_entry)
279
359
  total_bytes += entry_size
360
+
361
+ if entry_type == "match":
362
+ match_count += 1
280
363
  except anyenv.JsonLoadError:
281
364
  continue
282
365
 
@@ -291,11 +374,13 @@ def _parse_ripgrep_json_output(stdout: str, max_output_bytes: int) -> dict[str,
291
374
  # Truncate long lines for readability
292
375
  if len(content) > 100: # noqa: PLR2004
293
376
  content = content[:97] + "..."
294
- table_lines.append(f"| {m['path']} | {m['line']} | {content} |")
377
+ # Mark match lines with indicator if there are context lines
378
+ prefix = "→ " if m.get("is_match") and match_count < len(matches) else ""
379
+ table_lines.append(f"| {m['path']} | {m['line']} | {prefix}{content} |")
295
380
 
296
381
  return {
297
382
  "matches": "\n".join(table_lines),
298
- "match_count": len(matches),
383
+ "match_count": match_count,
299
384
  "was_truncated": was_truncated,
300
385
  }
301
386
 
@@ -334,10 +419,17 @@ def _parse_gnu_grep_output(stdout: str, max_output_bytes: int) -> dict[str, Any]
334
419
 
335
420
  table_lines = ["| File | Line | Content |", "|------|------|---------|"]
336
421
  for m in matches:
422
+ # Handle separator lines
423
+ if m.get("is_separator"):
424
+ table_lines.append("| | | |")
425
+ continue
426
+
337
427
  content = m["content"].replace("|", "\\|")
338
428
  if len(content) > 100: # noqa: PLR2004
339
429
  content = content[:97] + "..."
340
- table_lines.append(f"| {m['path']} | {m['line']} | {content} |")
430
+ # Mark match lines with indicator if there are context lines
431
+ prefix = "→ " if m.get("is_match") else ""
432
+ table_lines.append(f"| {m['path']} | {m['line']} | {prefix}{content} |")
341
433
 
342
434
  return {
343
435
  "matches": "\n".join(table_lines),
@@ -550,8 +550,9 @@ def truncate_lines(
550
550
  Returns:
551
551
  Tuple of (truncated_lines, was_truncated)
552
552
  """
553
- # Apply offset
554
- start_idx = max(0, offset)
553
+ # Apply offset (supports negative indexing like Python lists)
554
+ start_idx = max(0, len(lines) + offset) if offset < 0 else min(offset, len(lines))
555
+
555
556
  if start_idx >= len(lines):
556
557
  return [], False
557
558
 
@@ -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