ripperdoc 0.2.6__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 (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. ripperdoc-0.2.6.dist-info/top_level.txt +1 -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
+ ]
@@ -0,0 +1,226 @@
1
+ """Enter plan mode tool for complex task planning.
2
+
3
+ This tool allows the AI to request entering plan mode for complex tasks
4
+ that require careful exploration and design before implementation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from textwrap import dedent
10
+ from typing import AsyncGenerator, Optional
11
+
12
+ from pydantic import BaseModel
13
+
14
+ from ripperdoc.core.tool import (
15
+ Tool,
16
+ ToolOutput,
17
+ ToolResult,
18
+ ToolUseContext,
19
+ ValidationResult,
20
+ )
21
+ from ripperdoc.utils.log import get_logger
22
+
23
+ logger = get_logger()
24
+
25
+ TOOL_NAME = "EnterPlanMode"
26
+ ASK_USER_QUESTION_TOOL = "AskUserQuestion"
27
+
28
+ ENTER_PLAN_MODE_PROMPT = dedent(
29
+ """\
30
+ Use this tool when you encounter a complex task that requires careful planning and exploration before implementation. This tool transitions you into plan mode where you can thoroughly explore the codebase and design an implementation approach.
31
+
32
+ ## When to Use This Tool
33
+
34
+ Use EnterPlanMode when ANY of these conditions apply:
35
+
36
+ 1. **Multiple Valid Approaches**: The task can be solved in several different ways, each with trade-offs
37
+ - Example: "Add caching to the API" - could use Redis, in-memory, file-based, etc.
38
+ - Example: "Improve performance" - many optimization strategies possible
39
+
40
+ 2. **Significant Architectural Decisions**: The task requires choosing between architectural patterns
41
+ - Example: "Add real-time updates" - WebSockets vs SSE vs polling
42
+ - Example: "Implement state management" - Redux vs Context vs custom solution
43
+
44
+ 3. **Large-Scale Changes**: The task touches many files or systems
45
+ - Example: "Refactor the authentication system"
46
+ - Example: "Migrate from REST to GraphQL"
47
+
48
+ 4. **Unclear Requirements**: You need to explore before understanding the full scope
49
+ - Example: "Make the app faster" - need to profile and identify bottlenecks
50
+ - Example: "Fix the bug in checkout" - need to investigate root cause
51
+
52
+ 5. **User Input Needed**: You'll need to ask clarifying questions before starting
53
+ - If you would use {ask_tool} to clarify the approach, consider EnterPlanMode instead
54
+ - Plan mode lets you explore first, then present options with context
55
+
56
+ ## When NOT to Use This Tool
57
+
58
+ Do NOT use EnterPlanMode for:
59
+ - Simple, straightforward tasks with obvious implementation
60
+ - Small bug fixes where the solution is clear
61
+ - Adding a single function or small feature
62
+ - Tasks you're already confident how to implement
63
+ - Research-only tasks (use the Task tool with explore agent instead)
64
+
65
+ ## What Happens in Plan Mode
66
+
67
+ In plan mode, you'll:
68
+ 1. Thoroughly explore the codebase using Glob, Grep, and Read tools
69
+ 2. Understand existing patterns and architecture
70
+ 3. Design an implementation approach
71
+ 4. Present your plan to the user for approval
72
+ 5. Use {ask_tool} if you need to clarify approaches
73
+ 6. Exit plan mode with ExitPlanMode when ready to implement
74
+
75
+ ## Examples
76
+
77
+ ### GOOD - Use EnterPlanMode:
78
+ User: "Add user authentication to the app"
79
+ - This requires architectural decisions (session vs JWT, where to store tokens, middleware structure)
80
+
81
+ User: "Optimize the database queries"
82
+ - Multiple approaches possible, need to profile first, significant impact
83
+
84
+ User: "Implement dark mode"
85
+ - Architectural decision on theme system, affects many components
86
+
87
+ ### BAD - Don't use EnterPlanMode:
88
+ User: "Fix the typo in the README"
89
+ - Straightforward, no planning needed
90
+
91
+ User: "Add a console.log to debug this function"
92
+ - Simple, obvious implementation
93
+
94
+ User: "What files handle routing?"
95
+ - Research task, not implementation planning
96
+
97
+ ## Important Notes
98
+
99
+ - This tool REQUIRES user approval - they must consent to entering plan mode
100
+ - Be thoughtful about when to use it - unnecessary plan mode slows down simple tasks
101
+ - If unsure whether to use it, err on the side of starting implementation
102
+ - You can always ask the user "Would you like me to plan this out first?"
103
+ """
104
+ ).format(ask_tool=ASK_USER_QUESTION_TOOL)
105
+
106
+
107
+ PLAN_MODE_INSTRUCTIONS = dedent(
108
+ """\
109
+ In plan mode, you should:
110
+ 1. Thoroughly explore the codebase to understand existing patterns
111
+ 2. Identify similar features and architectural approaches
112
+ 3. Consider multiple approaches and their trade-offs
113
+ 4. Use AskUserQuestion if you need to clarify the approach
114
+ 5. Design a concrete implementation strategy
115
+ 6. When ready, use ExitPlanMode to present your plan for approval
116
+
117
+ Remember: DO NOT write or edit any files yet. This is a read-only exploration and planning phase."""
118
+ )
119
+
120
+
121
+ class EnterPlanModeToolInput(BaseModel):
122
+ """Input for the EnterPlanMode tool.
123
+
124
+ This tool takes no input parameters - it simply requests to enter plan mode.
125
+ """
126
+
127
+ pass
128
+
129
+
130
+ class EnterPlanModeToolOutput(BaseModel):
131
+ """Output from the EnterPlanMode tool."""
132
+
133
+ message: str
134
+ entered: bool = True
135
+
136
+
137
+ class EnterPlanModeTool(Tool[EnterPlanModeToolInput, EnterPlanModeToolOutput]):
138
+ """Tool for entering plan mode for complex tasks."""
139
+
140
+ @property
141
+ def name(self) -> str:
142
+ return TOOL_NAME
143
+
144
+ async def description(self) -> str:
145
+ return (
146
+ "Requests permission to enter plan mode for complex tasks "
147
+ "requiring exploration and design"
148
+ )
149
+
150
+ @property
151
+ def input_schema(self) -> type[EnterPlanModeToolInput]:
152
+ return EnterPlanModeToolInput
153
+
154
+ async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
155
+ return ENTER_PLAN_MODE_PROMPT
156
+
157
+ def user_facing_name(self) -> str:
158
+ return ""
159
+
160
+ def is_read_only(self) -> bool:
161
+ return True
162
+
163
+ def is_concurrency_safe(self) -> bool:
164
+ return True
165
+
166
+ def needs_permissions(
167
+ self,
168
+ input_data: Optional[EnterPlanModeToolInput] = None, # noqa: ARG002
169
+ ) -> bool:
170
+ return True
171
+
172
+ async def validate_input(
173
+ self,
174
+ input_data: EnterPlanModeToolInput,
175
+ context: Optional[ToolUseContext] = None,
176
+ ) -> ValidationResult:
177
+ """Validate that this tool is not being used in an agent context."""
178
+ if context and context.agent_id:
179
+ return ValidationResult(
180
+ result=False,
181
+ message="EnterPlanMode tool cannot be used in agent contexts",
182
+ )
183
+ return ValidationResult(result=True)
184
+
185
+ def render_result_for_assistant(self, output: EnterPlanModeToolOutput) -> str:
186
+ """Render the tool output for the AI assistant."""
187
+ if not output.entered:
188
+ return "User declined to enter plan mode. Continue with normal implementation."
189
+ return f"{output.message}\n\n{PLAN_MODE_INSTRUCTIONS}"
190
+
191
+ def render_tool_use_message(
192
+ self,
193
+ input_data: EnterPlanModeToolInput,
194
+ verbose: bool = False, # noqa: ARG002
195
+ ) -> str:
196
+ """Render the tool use message for display."""
197
+ return "Requesting to enter plan mode"
198
+
199
+ async def call(
200
+ self,
201
+ input_data: EnterPlanModeToolInput, # noqa: ARG002
202
+ context: ToolUseContext,
203
+ ) -> AsyncGenerator[ToolOutput, None]:
204
+ """Execute the tool to enter plan mode."""
205
+ if context.agent_id:
206
+ output = EnterPlanModeToolOutput(
207
+ message="EnterPlanMode tool cannot be used in agent contexts",
208
+ entered=False,
209
+ )
210
+ yield ToolResult(
211
+ data=output,
212
+ result_for_assistant=self.render_result_for_assistant(output),
213
+ )
214
+ return
215
+
216
+ output = EnterPlanModeToolOutput(
217
+ message=(
218
+ "Entered plan mode. You should now focus on exploring "
219
+ "the codebase and designing an implementation approach."
220
+ ),
221
+ entered=True,
222
+ )
223
+ yield ToolResult(
224
+ data=output,
225
+ result_for_assistant=self.render_result_for_assistant(output),
226
+ )