ripperdoc 0.2.4__py3-none-any.whl → 0.2.5__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 (75) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/__main__.py +0 -5
  3. ripperdoc/cli/cli.py +37 -16
  4. ripperdoc/cli/commands/__init__.py +2 -0
  5. ripperdoc/cli/commands/agents_cmd.py +12 -9
  6. ripperdoc/cli/commands/compact_cmd.py +7 -3
  7. ripperdoc/cli/commands/context_cmd.py +33 -13
  8. ripperdoc/cli/commands/doctor_cmd.py +27 -14
  9. ripperdoc/cli/commands/exit_cmd.py +1 -1
  10. ripperdoc/cli/commands/mcp_cmd.py +13 -8
  11. ripperdoc/cli/commands/memory_cmd.py +5 -5
  12. ripperdoc/cli/commands/models_cmd.py +47 -16
  13. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  14. ripperdoc/cli/commands/resume_cmd.py +1 -2
  15. ripperdoc/cli/commands/tasks_cmd.py +24 -13
  16. ripperdoc/cli/ui/rich_ui.py +500 -406
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +17 -9
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +7 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/anthropic.py +107 -4
  23. ripperdoc/core/providers/base.py +33 -4
  24. ripperdoc/core/providers/gemini.py +169 -50
  25. ripperdoc/core/providers/openai.py +257 -23
  26. ripperdoc/core/query.py +294 -61
  27. ripperdoc/core/query_utils.py +50 -6
  28. ripperdoc/core/skills.py +295 -0
  29. ripperdoc/core/system_prompt.py +13 -7
  30. ripperdoc/core/tool.py +8 -6
  31. ripperdoc/sdk/client.py +14 -1
  32. ripperdoc/tools/ask_user_question_tool.py +20 -22
  33. ripperdoc/tools/background_shell.py +19 -13
  34. ripperdoc/tools/bash_tool.py +356 -209
  35. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  36. ripperdoc/tools/enter_plan_mode_tool.py +5 -2
  37. ripperdoc/tools/exit_plan_mode_tool.py +6 -3
  38. ripperdoc/tools/file_edit_tool.py +53 -10
  39. ripperdoc/tools/file_read_tool.py +17 -7
  40. ripperdoc/tools/file_write_tool.py +49 -13
  41. ripperdoc/tools/glob_tool.py +10 -9
  42. ripperdoc/tools/grep_tool.py +182 -51
  43. ripperdoc/tools/ls_tool.py +6 -6
  44. ripperdoc/tools/mcp_tools.py +106 -456
  45. ripperdoc/tools/multi_edit_tool.py +49 -9
  46. ripperdoc/tools/notebook_edit_tool.py +57 -13
  47. ripperdoc/tools/skill_tool.py +205 -0
  48. ripperdoc/tools/task_tool.py +7 -8
  49. ripperdoc/tools/todo_tool.py +12 -12
  50. ripperdoc/tools/tool_search_tool.py +5 -6
  51. ripperdoc/utils/coerce.py +34 -0
  52. ripperdoc/utils/context_length_errors.py +252 -0
  53. ripperdoc/utils/file_watch.py +5 -4
  54. ripperdoc/utils/json_utils.py +4 -4
  55. ripperdoc/utils/log.py +3 -3
  56. ripperdoc/utils/mcp.py +36 -15
  57. ripperdoc/utils/memory.py +9 -6
  58. ripperdoc/utils/message_compaction.py +16 -11
  59. ripperdoc/utils/messages.py +73 -8
  60. ripperdoc/utils/path_ignore.py +677 -0
  61. ripperdoc/utils/permissions/__init__.py +7 -1
  62. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  63. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  64. ripperdoc/utils/prompt.py +1 -1
  65. ripperdoc/utils/safe_get_cwd.py +5 -2
  66. ripperdoc/utils/session_history.py +38 -19
  67. ripperdoc/utils/todo.py +6 -2
  68. ripperdoc/utils/token_estimation.py +4 -3
  69. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
  70. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  71. ripperdoc-0.2.4.dist-info/RECORD +0 -99
  72. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  73. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  74. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  75. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,428 @@
1
+ """Dynamic MCP tool wrapper for runtime MCP server tools.
2
+
3
+ This module provides the DynamicMcpTool class that wraps MCP server tools
4
+ at runtime, along with helper functions for loading and merging these tools.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any, AsyncGenerator, Dict, List, Optional
13
+
14
+ from pydantic import BaseModel, ConfigDict
15
+
16
+ from ripperdoc.core.tool import (
17
+ Tool,
18
+ ToolUseContext,
19
+ ToolResult,
20
+ ToolOutput,
21
+ )
22
+ from ripperdoc.utils.log import get_logger
23
+ from ripperdoc.utils.mcp import (
24
+ ensure_mcp_runtime,
25
+ get_existing_mcp_runtime,
26
+ )
27
+
28
+ logger = get_logger()
29
+
30
+
31
+ class McpToolCallOutput(BaseModel):
32
+ """Standardized output for MCP tool calls."""
33
+
34
+ server: str
35
+ tool: str
36
+ content: Optional[str] = None
37
+ text: Optional[str] = None
38
+ content_blocks: Optional[List[Any]] = None
39
+ structured_content: Optional[dict] = None
40
+ is_error: bool = False
41
+ token_estimate: Optional[int] = None
42
+ warning: Optional[str] = None
43
+
44
+
45
+ def _sanitize_name(name: str) -> str:
46
+ """Sanitize a name for use in tool identifiers."""
47
+ return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in name)
48
+
49
+
50
+ def _create_dynamic_input_model(schema: Optional[Dict[str, Any]]) -> type[BaseModel]:
51
+ """Create a dynamic Pydantic model from a JSON schema."""
52
+ raw_schema = schema if isinstance(schema, dict) else {"type": "object"}
53
+ raw_schema = raw_schema or {"type": "object"}
54
+
55
+ class DynamicMcpInput(BaseModel):
56
+ model_config = ConfigDict(extra="allow")
57
+
58
+ @classmethod
59
+ def model_json_schema(cls, *_args: Any, **_kwargs: Any) -> Dict[str, Any]:
60
+ return raw_schema
61
+
62
+ DynamicMcpInput.__name__ = (
63
+ f"McpInput_{abs(hash(json.dumps(raw_schema, sort_keys=True, default=str))) % 10_000_000}"
64
+ )
65
+ return DynamicMcpInput
66
+
67
+
68
+ def _annotation_flag(tool_info: Any, key: str) -> bool:
69
+ """Extract a boolean flag from tool annotations."""
70
+ annotations = getattr(tool_info, "annotations", {}) or {}
71
+ if hasattr(annotations, "get"):
72
+ try:
73
+ return bool(annotations.get(key, False))
74
+ except (AttributeError, TypeError, KeyError) as exc:
75
+ logger.debug(
76
+ "[mcp_tools] Failed to read annotation flag: %s: %s",
77
+ type(exc).__name__, exc,
78
+ )
79
+ return False
80
+ return False
81
+
82
+
83
+ def _render_mcp_tool_result_for_assistant(output: McpToolCallOutput) -> str:
84
+ """Render MCP tool output for the assistant."""
85
+ if output.text or output.content:
86
+ return output.text or output.content or ""
87
+ if output.is_error:
88
+ return "MCP tool call failed."
89
+ return f"MCP tool '{output.tool}' returned no content."
90
+
91
+
92
+ def _content_block_to_text(block: Any) -> str:
93
+ """Convert a content block to text representation."""
94
+ block_type = getattr(block, "type", None) or (
95
+ block.get("type") if isinstance(block, dict) else None
96
+ )
97
+ if block_type == "text":
98
+ return str(getattr(block, "text", None) or block.get("text", ""))
99
+ if block_type == "resource":
100
+ resource = getattr(block, "resource", None) or block.get("resource")
101
+ prefix = "resource"
102
+ if isinstance(resource, dict):
103
+ uri = resource.get("uri") or ""
104
+ text = resource.get("text") or ""
105
+ blob = resource.get("blob")
106
+ if text:
107
+ return f"[Resource {uri}] {text}"
108
+ if blob:
109
+ return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
110
+ if hasattr(resource, "uri"):
111
+ uri = getattr(resource, "uri", "")
112
+ text = getattr(resource, "text", None)
113
+ blob = getattr(resource, "blob", None)
114
+ if text:
115
+ return f"[Resource {uri}] {text}"
116
+ if blob:
117
+ return f"[Resource {uri}] (binary content {len(str(blob))} chars)"
118
+ return prefix
119
+ if block_type == "resource_link":
120
+ uri = getattr(block, "uri", None) or (block.get("uri") if isinstance(block, dict) else None)
121
+ return f"[Resource link] {uri}" if uri else "[Resource link]"
122
+ if block_type == "image":
123
+ mime = getattr(block, "mimeType", None) or (
124
+ block.get("mimeType") if isinstance(block, dict) else None
125
+ )
126
+ return f"[Image content {mime or ''}]".strip()
127
+ if block_type == "audio":
128
+ mime = getattr(block, "mimeType", None) or (
129
+ block.get("mimeType") if isinstance(block, dict) else None
130
+ )
131
+ return f"[Audio content {mime or ''}]".strip()
132
+ return str(block)
133
+
134
+
135
+ def _render_content_blocks(blocks: List[Any]) -> str:
136
+ """Render multiple content blocks to text."""
137
+ if not blocks:
138
+ return ""
139
+ parts = [_content_block_to_text(block) for block in blocks]
140
+ return "\n".join([p for p in parts if p])
141
+
142
+
143
+ def _normalize_content_block(block: Any) -> Any:
144
+ """Convert MCP content blocks to JSON-serializable structures."""
145
+ if isinstance(block, dict):
146
+ return block
147
+ result: Dict[str, Any] = {}
148
+ for attr in (
149
+ "type",
150
+ "text",
151
+ "mimeType",
152
+ "data",
153
+ "name",
154
+ "uri",
155
+ "description",
156
+ "resource",
157
+ "blob",
158
+ ):
159
+ if hasattr(block, attr):
160
+ result[attr] = getattr(block, attr)
161
+ if result:
162
+ return result
163
+ return str(block)
164
+
165
+
166
+ def _normalize_content_blocks(blocks: Optional[List[Any]]) -> Optional[List[Any]]:
167
+ """Normalize a list of content blocks."""
168
+ if not blocks:
169
+ return None
170
+ return [_normalize_content_block(block) for block in blocks]
171
+
172
+
173
+ class DynamicMcpTool(Tool[BaseModel, McpToolCallOutput]):
174
+ """Runtime wrapper for an MCP tool exposed by a connected server."""
175
+
176
+ is_mcp = True
177
+
178
+ def __init__(self, server_name: str, tool_info: Any, project_path: Path) -> None:
179
+ self.server_name = server_name
180
+ self.tool_info = tool_info
181
+ self.project_path = project_path
182
+ self._input_model = _create_dynamic_input_model(getattr(tool_info, "input_schema", None))
183
+ self._name = f"mcp__{_sanitize_name(server_name)}__{_sanitize_name(tool_info.name)}"
184
+ self._user_facing = (
185
+ f"{server_name} - {getattr(tool_info, 'description', '') or tool_info.name} (MCP)"
186
+ )
187
+
188
+ @property
189
+ def name(self) -> str:
190
+ return self._name
191
+
192
+ async def description(self) -> str:
193
+ desc = getattr(self.tool_info, "description", "") or ""
194
+ schema = getattr(self.tool_info, "input_schema", None)
195
+ schema_snippet = json.dumps(schema, indent=2) if schema else ""
196
+ if schema_snippet:
197
+ schema_snippet = (
198
+ schema_snippet if len(schema_snippet) < 800 else schema_snippet[:800] + "..."
199
+ )
200
+ return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}\nInput schema:\n{schema_snippet}"
201
+ return f"{desc}\n\n[MCP tool]\nServer: {self.server_name}\nTool: {self.tool_info.name}"
202
+
203
+ @property
204
+ def input_schema(self) -> type[BaseModel]:
205
+ return self._input_model
206
+
207
+ async def prompt(self, _safe_mode: bool = False) -> str:
208
+ return await self.description()
209
+
210
+ def is_read_only(self) -> bool:
211
+ return _annotation_flag(self.tool_info, "readOnlyHint")
212
+
213
+ def is_concurrency_safe(self) -> bool:
214
+ return self.is_read_only()
215
+
216
+ def is_destructive(self) -> bool:
217
+ return _annotation_flag(self.tool_info, "destructiveHint")
218
+
219
+ def is_open_world(self) -> bool:
220
+ return _annotation_flag(self.tool_info, "openWorldHint")
221
+
222
+ def defer_loading(self) -> bool:
223
+ """Avoid loading all MCP tools into the initial context."""
224
+ return True
225
+
226
+ def needs_permissions(self, _input_data: Optional[BaseModel] = None) -> bool:
227
+ return not self.is_read_only()
228
+
229
+ def render_result_for_assistant(self, output: McpToolCallOutput) -> str:
230
+ return _render_mcp_tool_result_for_assistant(output)
231
+
232
+ def render_tool_use_message(self, input_data: BaseModel, verbose: bool = False) -> str:
233
+ args = input_data.model_dump(exclude_none=True)
234
+ arg_preview = json.dumps(args) if verbose and args else ""
235
+ suffix = f" with args {arg_preview}" if arg_preview else ""
236
+ return f"MCP {self.server_name}:{self.tool_info.name}{suffix}"
237
+
238
+ def user_facing_name(self) -> str:
239
+ return self._user_facing
240
+
241
+ async def call(
242
+ self,
243
+ input_data: BaseModel,
244
+ _context: ToolUseContext,
245
+ ) -> AsyncGenerator[ToolOutput, None]:
246
+ from ripperdoc.tools.mcp_tools import _evaluate_mcp_output_size
247
+
248
+ runtime = await ensure_mcp_runtime(self.project_path)
249
+ session = runtime.sessions.get(self.server_name) if runtime else None
250
+ if not session:
251
+ result = McpToolCallOutput(
252
+ server=self.server_name,
253
+ tool=self.tool_info.name,
254
+ content=None,
255
+ text=None,
256
+ content_blocks=None,
257
+ structured_content=None,
258
+ is_error=True,
259
+ )
260
+ yield ToolResult(
261
+ data=result,
262
+ result_for_assistant=f"MCP server '{self.server_name}' is not connected.",
263
+ )
264
+ return
265
+
266
+ try:
267
+ args = input_data.model_dump(exclude_none=True)
268
+ call_result = await session.call_tool(
269
+ self.tool_info.name,
270
+ args or {},
271
+ )
272
+ raw_blocks = getattr(call_result, "content", None)
273
+ content_blocks = _normalize_content_blocks(raw_blocks)
274
+ content_text = _render_content_blocks(content_blocks) if content_blocks else None
275
+ structured = (
276
+ call_result.structuredContent if hasattr(call_result, "structuredContent") else None
277
+ )
278
+ assistant_text = content_text
279
+ if structured:
280
+ assistant_text = (assistant_text + "\n" if assistant_text else "") + json.dumps(
281
+ structured, indent=2
282
+ )
283
+ output = McpToolCallOutput(
284
+ server=self.server_name,
285
+ tool=self.tool_info.name,
286
+ content=assistant_text or None,
287
+ text=content_text,
288
+ content_blocks=content_blocks,
289
+ structured_content=structured,
290
+ is_error=getattr(call_result, "isError", False),
291
+ )
292
+ base_result_text = self.render_result_for_assistant(output)
293
+ warning_text, error_text, token_estimate = _evaluate_mcp_output_size(
294
+ base_result_text, self.server_name, self.tool_info.name
295
+ )
296
+
297
+ if error_text:
298
+ limited_output = McpToolCallOutput(
299
+ server=self.server_name,
300
+ tool=self.tool_info.name,
301
+ content=None,
302
+ text=None,
303
+ content_blocks=None,
304
+ structured_content=None,
305
+ is_error=True,
306
+ token_estimate=token_estimate,
307
+ warning=None,
308
+ )
309
+ yield ToolResult(data=limited_output, result_for_assistant=error_text)
310
+ return
311
+
312
+ annotated_output = output.model_copy(
313
+ update={"token_estimate": token_estimate, "warning": warning_text}
314
+ )
315
+
316
+ final_text = base_result_text or ""
317
+ if not final_text and warning_text:
318
+ final_text = warning_text
319
+
320
+ yield ToolResult(
321
+ data=annotated_output,
322
+ result_for_assistant=final_text,
323
+ )
324
+ except (OSError, RuntimeError, ConnectionError, ValueError, KeyError, TypeError) as exc: # pragma: no cover - runtime errors
325
+ output = McpToolCallOutput(
326
+ server=self.server_name,
327
+ tool=self.tool_info.name,
328
+ content=None,
329
+ text=None,
330
+ content_blocks=None,
331
+ structured_content=None,
332
+ is_error=True,
333
+ )
334
+ logger.warning(
335
+ "Error calling MCP tool: %s: %s",
336
+ type(exc).__name__, exc,
337
+ extra={
338
+ "server": self.server_name,
339
+ "tool": self.tool_info.name,
340
+ },
341
+ )
342
+ yield ToolResult(
343
+ data=output,
344
+ result_for_assistant=f"Error calling MCP tool '{self.tool_info.name}' on '{self.server_name}': {exc}",
345
+ )
346
+
347
+
348
+ def _build_dynamic_mcp_tools(runtime: Optional[Any]) -> List[DynamicMcpTool]:
349
+ """Build DynamicMcpTool instances from a runtime's connected servers."""
350
+ if not runtime or not getattr(runtime, "servers", None):
351
+ return []
352
+ tools: List[DynamicMcpTool] = []
353
+ for server in runtime.servers:
354
+ if getattr(server, "status", "") != "connected":
355
+ continue
356
+ if not getattr(server, "tools", None):
357
+ continue
358
+ for tool in server.tools:
359
+ tools.append(
360
+ DynamicMcpTool(server.name, tool, getattr(runtime, "project_path", Path.cwd()))
361
+ )
362
+ return tools
363
+
364
+
365
+ def load_dynamic_mcp_tools_sync(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
366
+ """Best-effort synchronous loader for MCP tools."""
367
+ runtime = get_existing_mcp_runtime()
368
+ if runtime and not getattr(runtime, "_closed", False):
369
+ return _build_dynamic_mcp_tools(runtime)
370
+
371
+ try:
372
+ loop = asyncio.get_running_loop()
373
+ if loop.is_running():
374
+ return []
375
+ except RuntimeError:
376
+ pass
377
+
378
+ async def _load_and_keep() -> List[DynamicMcpTool]:
379
+ runtime = await ensure_mcp_runtime(project_path)
380
+ return _build_dynamic_mcp_tools(runtime)
381
+
382
+ try:
383
+ return asyncio.run(_load_and_keep())
384
+ except (OSError, RuntimeError, ConnectionError, ValueError) as exc: # pragma: no cover - SDK/runtime failures
385
+ logger.warning(
386
+ "Failed to initialize MCP runtime for dynamic tools (sync): %s: %s",
387
+ type(exc).__name__, exc,
388
+ )
389
+ return []
390
+
391
+
392
+ async def load_dynamic_mcp_tools_async(project_path: Optional[Path] = None) -> List[DynamicMcpTool]:
393
+ """Async loader for MCP tools when already in an event loop."""
394
+ try:
395
+ runtime = await ensure_mcp_runtime(project_path)
396
+ except (OSError, RuntimeError, ConnectionError, ValueError) as exc: # pragma: no cover - SDK/runtime failures
397
+ logger.warning(
398
+ "Failed to initialize MCP runtime for dynamic tools (async): %s: %s",
399
+ type(exc).__name__, exc,
400
+ )
401
+ return []
402
+ return _build_dynamic_mcp_tools(runtime)
403
+
404
+
405
+ def merge_tools_with_dynamic(base_tools: List[Any], dynamic_tools: List[Any]) -> List[Any]:
406
+ """Merge dynamic MCP tools into the existing tool list and rebuild the Task tool."""
407
+ from ripperdoc.tools.task_tool import TaskTool # Local import to avoid cycles
408
+
409
+ base_without_task = [tool for tool in base_tools if getattr(tool, "name", None) != "Task"]
410
+ existing_names = {getattr(tool, "name", None) for tool in base_without_task}
411
+
412
+ for tool in dynamic_tools:
413
+ if getattr(tool, "name", None) in existing_names:
414
+ continue
415
+ base_without_task.append(tool)
416
+ existing_names.add(getattr(tool, "name", None))
417
+
418
+ task_tool = TaskTool(lambda: base_without_task)
419
+ return base_without_task + [task_tool]
420
+
421
+
422
+ __all__ = [
423
+ "DynamicMcpTool",
424
+ "McpToolCallOutput",
425
+ "load_dynamic_mcp_tools_async",
426
+ "load_dynamic_mcp_tools_sync",
427
+ "merge_tools_with_dynamic",
428
+ ]
@@ -164,7 +164,8 @@ class EnterPlanModeTool(Tool[EnterPlanModeToolInput, EnterPlanModeToolOutput]):
164
164
  return True
165
165
 
166
166
  def needs_permissions(
167
- self, input_data: Optional[EnterPlanModeToolInput] = None # noqa: ARG002
167
+ self,
168
+ input_data: Optional[EnterPlanModeToolInput] = None, # noqa: ARG002
168
169
  ) -> bool:
169
170
  return True
170
171
 
@@ -188,7 +189,9 @@ class EnterPlanModeTool(Tool[EnterPlanModeToolInput, EnterPlanModeToolOutput]):
188
189
  return f"{output.message}\n\n{PLAN_MODE_INSTRUCTIONS}"
189
190
 
190
191
  def render_tool_use_message(
191
- self, input_data: EnterPlanModeToolInput, verbose: bool = False # noqa: ARG002
192
+ self,
193
+ input_data: EnterPlanModeToolInput,
194
+ verbose: bool = False, # noqa: ARG002
192
195
  ) -> str:
193
196
  """Render the tool use message for display."""
194
197
  return "Requesting to enter plan mode"
@@ -97,7 +97,8 @@ class ExitPlanModeTool(Tool[ExitPlanModeToolInput, ExitPlanModeToolOutput]):
97
97
  return True
98
98
 
99
99
  def needs_permissions(
100
- self, input_data: Optional[ExitPlanModeToolInput] = None # noqa: ARG002
100
+ self,
101
+ input_data: Optional[ExitPlanModeToolInput] = None, # noqa: ARG002
101
102
  ) -> bool:
102
103
  return True
103
104
 
@@ -119,7 +120,9 @@ class ExitPlanModeTool(Tool[ExitPlanModeToolInput, ExitPlanModeToolOutput]):
119
120
  return f"Exit plan mode and start coding now. Plan:\n{output.plan}"
120
121
 
121
122
  def render_tool_use_message(
122
- self, input_data: ExitPlanModeToolInput, verbose: bool = False # noqa: ARG002
123
+ self,
124
+ input_data: ExitPlanModeToolInput,
125
+ verbose: bool = False, # noqa: ARG002
123
126
  ) -> str:
124
127
  """Render the tool use message for display."""
125
128
  plan = input_data.plan
@@ -136,7 +139,7 @@ class ExitPlanModeTool(Tool[ExitPlanModeToolInput, ExitPlanModeToolOutput]):
136
139
  if context.on_exit_plan_mode:
137
140
  try:
138
141
  context.on_exit_plan_mode()
139
- except Exception:
142
+ except (RuntimeError, ValueError, TypeError):
140
143
  logger.debug("[exit_plan_mode_tool] Failed to call on_exit_plan_mode")
141
144
 
142
145
  is_agent = bool(context.agent_id)
@@ -4,6 +4,7 @@ Allows the AI to edit files by replacing text.
4
4
  """
5
5
 
6
6
  import os
7
+ from pathlib import Path
7
8
  from typing import AsyncGenerator, List, Optional
8
9
  from pydantic import BaseModel, Field
9
10
 
@@ -17,6 +18,7 @@ from ripperdoc.core.tool import (
17
18
  )
18
19
  from ripperdoc.utils.log import get_logger
19
20
  from ripperdoc.utils.file_watch import record_snapshot
21
+ from ripperdoc.utils.path_ignore import check_path_for_tool
20
22
 
21
23
  logger = get_logger()
22
24
 
@@ -108,20 +110,59 @@ match exactly (including whitespace and indentation)."""
108
110
  ) -> ValidationResult:
109
111
  # Check if file exists
110
112
  if not os.path.exists(input_data.file_path):
111
- return ValidationResult(result=False, message=f"File not found: {input_data.file_path}")
113
+ return ValidationResult(
114
+ result=False,
115
+ message=f"File not found: {input_data.file_path}",
116
+ error_code=1,
117
+ )
112
118
 
113
119
  # Check if it's a file
114
120
  if not os.path.isfile(input_data.file_path):
115
121
  return ValidationResult(
116
- result=False, message=f"Path is not a file: {input_data.file_path}"
122
+ result=False,
123
+ message=f"Path is not a file: {input_data.file_path}",
124
+ error_code=2,
117
125
  )
118
126
 
119
127
  # Check that old_string and new_string are different
120
128
  if input_data.old_string == input_data.new_string:
121
129
  return ValidationResult(
122
- result=False, message="old_string and new_string must be different"
130
+ result=False,
131
+ message="old_string and new_string must be different",
132
+ error_code=3,
133
+ )
134
+
135
+ # Check if file has been read before editing
136
+ file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
137
+ file_path = os.path.abspath(input_data.file_path)
138
+ file_snapshot = file_state_cache.get(file_path)
139
+
140
+ if not file_snapshot:
141
+ return ValidationResult(
142
+ result=False,
143
+ message="File has not been read yet. Read it first before editing.",
144
+ error_code=4,
123
145
  )
124
146
 
147
+ # Check if file has been modified since it was read
148
+ try:
149
+ current_mtime = os.path.getmtime(file_path)
150
+ if current_mtime > file_snapshot.timestamp:
151
+ return ValidationResult(
152
+ result=False,
153
+ message="File has been modified since read, either by the user or by a linter. "
154
+ "Read it again before attempting to edit it.",
155
+ error_code=5,
156
+ )
157
+ except OSError:
158
+ pass # File mtime check failed, proceed anyway
159
+
160
+ # Check if path is ignored (warning for edit operations)
161
+ file_path_obj = Path(file_path)
162
+ should_proceed, warning_msg = check_path_for_tool(file_path_obj, tool_name="Edit", warn_only=True)
163
+ if warning_msg:
164
+ logger.warning("[file_edit_tool] %s", warning_msg)
165
+
125
166
  return ValidationResult(result=True)
126
167
 
127
168
  def render_result_for_assistant(self, output: FileEditToolOutput) -> str:
@@ -192,9 +233,10 @@ match exactly (including whitespace and indentation)."""
192
233
  new_content,
193
234
  getattr(context, "file_state_cache", {}),
194
235
  )
195
- except Exception:
196
- logger.exception(
197
- "[file_edit_tool] Failed to record file snapshot",
236
+ except (OSError, IOError, RuntimeError) as exc:
237
+ logger.warning(
238
+ "[file_edit_tool] Failed to record file snapshot: %s: %s",
239
+ type(exc).__name__, exc,
198
240
  extra={"file_path": input_data.file_path},
199
241
  )
200
242
 
@@ -283,10 +325,11 @@ match exactly (including whitespace and indentation)."""
283
325
  data=output, result_for_assistant=self.render_result_for_assistant(output)
284
326
  )
285
327
 
286
- except Exception as e:
287
- logger.exception(
288
- "[file_edit_tool] Error editing file",
289
- extra={"file_path": input_data.file_path, "error": str(e)},
328
+ except (OSError, IOError, PermissionError, UnicodeDecodeError, ValueError) as e:
329
+ logger.warning(
330
+ "[file_edit_tool] Error editing file: %s: %s",
331
+ type(e).__name__, e,
332
+ extra={"file_path": input_data.file_path},
290
333
  )
291
334
  error_output = FileEditToolOutput(
292
335
  file_path=input_data.file_path,
@@ -4,6 +4,7 @@ Allows the AI to read file contents.
4
4
  """
5
5
 
6
6
  import os
7
+ from pathlib import Path
7
8
  from typing import AsyncGenerator, List, Optional
8
9
  from pydantic import BaseModel, Field
9
10
 
@@ -17,6 +18,7 @@ from ripperdoc.core.tool import (
17
18
  )
18
19
  from ripperdoc.utils.log import get_logger
19
20
  from ripperdoc.utils.file_watch import record_snapshot
21
+ from ripperdoc.utils.path_ignore import check_path_for_tool, is_path_ignored
20
22
 
21
23
  logger = get_logger()
22
24
 
@@ -102,6 +104,12 @@ and limit to read only a portion of the file."""
102
104
  result=False, message=f"Path is not a file: {input_data.file_path}"
103
105
  )
104
106
 
107
+ # Check if path is ignored (warning only for read operations)
108
+ file_path = Path(input_data.file_path)
109
+ should_proceed, warning_msg = check_path_for_tool(file_path, tool_name="Read", warn_only=True)
110
+ if warning_msg:
111
+ logger.info("[file_read_tool] %s", warning_msg)
112
+
105
113
  return ValidationResult(result=True)
106
114
 
107
115
  def render_result_for_assistant(self, output: FileReadToolOutput) -> str:
@@ -153,9 +161,10 @@ and limit to read only a portion of the file."""
153
161
  offset=offset,
154
162
  limit=limit,
155
163
  )
156
- except Exception:
157
- logger.exception(
158
- "[file_read_tool] Failed to record file snapshot",
164
+ except (OSError, IOError, RuntimeError) as exc:
165
+ logger.warning(
166
+ "[file_read_tool] Failed to record file snapshot: %s: %s",
167
+ type(exc).__name__, exc,
159
168
  extra={"file_path": input_data.file_path},
160
169
  )
161
170
 
@@ -171,10 +180,11 @@ and limit to read only a portion of the file."""
171
180
  data=output, result_for_assistant=self.render_result_for_assistant(output)
172
181
  )
173
182
 
174
- except Exception as e:
175
- logger.exception(
176
- "[file_read_tool] Error reading file",
177
- extra={"file_path": input_data.file_path, "error": str(e)},
183
+ except (OSError, IOError, UnicodeDecodeError, ValueError) as e:
184
+ logger.warning(
185
+ "[file_read_tool] Error reading file: %s: %s",
186
+ type(e).__name__, e,
187
+ extra={"file_path": input_data.file_path},
178
188
  )
179
189
  # Create an error output
180
190
  error_output = FileReadToolOutput(