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.
- ripperdoc/__init__.py +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Grep tool for searching code.
|
|
2
|
+
|
|
3
|
+
Allows the AI to search for patterns in files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import AsyncGenerator, Optional, List, Tuple
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from ripperdoc.core.tool import (
|
|
14
|
+
Tool,
|
|
15
|
+
ToolUseContext,
|
|
16
|
+
ToolResult,
|
|
17
|
+
ToolOutput,
|
|
18
|
+
ToolUseExample,
|
|
19
|
+
ValidationResult,
|
|
20
|
+
)
|
|
21
|
+
from ripperdoc.utils.log import get_logger
|
|
22
|
+
|
|
23
|
+
logger = get_logger()
|
|
24
|
+
|
|
25
|
+
MAX_GREP_OUTPUT_CHARS = 20000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
GREP_USAGE = (
|
|
29
|
+
"A powerful search tool built on ripgrep.\n\n"
|
|
30
|
+
"Usage:\n"
|
|
31
|
+
"- ALWAYS use the Grep tool for search tasks. NEVER invoke `grep` or `rg` as a Bash command; this tool is optimized for permissions and access.\n"
|
|
32
|
+
'- Supports regex patterns (e.g., "log.*Error", "function\\s+\\w+")\n'
|
|
33
|
+
'- Filter files with the glob parameter (e.g., "*.js", "**/*.tsx")\n'
|
|
34
|
+
'- Output modes: "content" shows matching lines, "files_with_matches" (default) shows only file paths, "count" shows match counts\n'
|
|
35
|
+
"- Use head_limit to cap the number of returned entries (similar to piping through head -N) to avoid overwhelming output\n"
|
|
36
|
+
f"- Outputs are automatically truncated to around {MAX_GREP_OUTPUT_CHARS} characters to stay within context limits; narrow patterns for more detail\n"
|
|
37
|
+
"- For open-ended searches that need multiple rounds, iterate with Glob and Grep rather than shell commands\n"
|
|
38
|
+
"- Patterns are line-based; craft patterns accordingly and escape braces if needed (e.g., use `interface\\{\\}` to find `interface{}`)"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def truncate_with_ellipsis(
|
|
43
|
+
text: str, max_chars: int = MAX_GREP_OUTPUT_CHARS
|
|
44
|
+
) -> Tuple[str, bool, int]:
|
|
45
|
+
"""Trim long output and note how many lines were removed."""
|
|
46
|
+
if len(text) <= max_chars:
|
|
47
|
+
return text, False, 0
|
|
48
|
+
|
|
49
|
+
remaining = text[max_chars:]
|
|
50
|
+
truncated_lines = remaining.count("\n") + (1 if remaining else 0)
|
|
51
|
+
truncated_text = f"{text[:max_chars]}\n\n... [{truncated_lines} lines truncated] ..."
|
|
52
|
+
return truncated_text, True, truncated_lines
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def apply_head_limit(lines: List[str], head_limit: Optional[int]) -> Tuple[List[str], int]:
|
|
56
|
+
"""Limit the number of lines returned, recording how many were omitted."""
|
|
57
|
+
if head_limit is None or head_limit <= 0:
|
|
58
|
+
return lines, 0
|
|
59
|
+
if len(lines) <= head_limit:
|
|
60
|
+
return lines, 0
|
|
61
|
+
return lines[:head_limit], len(lines) - head_limit
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _split_globs(glob_value: str) -> List[str]:
|
|
65
|
+
"""Split a glob string by whitespace and commas."""
|
|
66
|
+
if not glob_value:
|
|
67
|
+
return []
|
|
68
|
+
globs: List[str] = []
|
|
69
|
+
for token in re.split(r"\s+", glob_value.strip()):
|
|
70
|
+
if not token:
|
|
71
|
+
continue
|
|
72
|
+
globs.extend([part for part in token.split(",") if part])
|
|
73
|
+
return globs
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalize_glob_for_grep(glob_pattern: str) -> str:
|
|
77
|
+
"""grep --include matches basenames; drop path components to avoid mismatches like **/*.py."""
|
|
78
|
+
return glob_pattern.split("/")[-1] or glob_pattern
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GrepToolInput(BaseModel):
|
|
82
|
+
"""Input schema for GrepTool."""
|
|
83
|
+
|
|
84
|
+
pattern: str = Field(description="Regular expression pattern to search for")
|
|
85
|
+
path: Optional[str] = Field(
|
|
86
|
+
default=None, description="Directory or file to search in (default: current directory)"
|
|
87
|
+
)
|
|
88
|
+
glob: Optional[str] = Field(default=None, description="File pattern to filter (e.g., '*.py')")
|
|
89
|
+
case_insensitive: bool = Field(default=False, description="Case insensitive search")
|
|
90
|
+
output_mode: str = Field(
|
|
91
|
+
default="files_with_matches",
|
|
92
|
+
description="Output mode: 'files_with_matches', 'content', or 'count'",
|
|
93
|
+
)
|
|
94
|
+
head_limit: Optional[int] = Field(
|
|
95
|
+
default=None,
|
|
96
|
+
description="Limit output to the first N results (similar to piping to head -N) to avoid huge responses.",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class GrepMatch(BaseModel):
|
|
101
|
+
"""A single grep match."""
|
|
102
|
+
|
|
103
|
+
file: str
|
|
104
|
+
line_number: Optional[int] = None
|
|
105
|
+
content: Optional[str] = None
|
|
106
|
+
count: Optional[int] = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class GrepToolOutput(BaseModel):
|
|
110
|
+
"""Output from grep search."""
|
|
111
|
+
|
|
112
|
+
matches: List[GrepMatch]
|
|
113
|
+
pattern: str
|
|
114
|
+
total_files: int
|
|
115
|
+
total_matches: int
|
|
116
|
+
output_mode: str = "files_with_matches"
|
|
117
|
+
head_limit: Optional[int] = None
|
|
118
|
+
omitted_results: int = 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class GrepTool(Tool[GrepToolInput, GrepToolOutput]):
|
|
122
|
+
"""Tool for searching code with grep."""
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def name(self) -> str:
|
|
126
|
+
return "Grep"
|
|
127
|
+
|
|
128
|
+
async def description(self) -> str:
|
|
129
|
+
return GREP_USAGE
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def input_schema(self) -> type[GrepToolInput]:
|
|
133
|
+
return GrepToolInput
|
|
134
|
+
|
|
135
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
136
|
+
return [
|
|
137
|
+
ToolUseExample(
|
|
138
|
+
description="Find TODO comments in TypeScript files",
|
|
139
|
+
example={"pattern": "TODO", "glob": "**/*.ts", "output_mode": "content"},
|
|
140
|
+
),
|
|
141
|
+
ToolUseExample(
|
|
142
|
+
description="List files referencing a function name",
|
|
143
|
+
example={
|
|
144
|
+
"pattern": "fetchUserData",
|
|
145
|
+
"output_mode": "files_with_matches",
|
|
146
|
+
"path": "/repo/src",
|
|
147
|
+
},
|
|
148
|
+
),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
async def prompt(self, _safe_mode: bool = False) -> str:
|
|
152
|
+
return GREP_USAGE
|
|
153
|
+
|
|
154
|
+
def is_read_only(self) -> bool:
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
def is_concurrency_safe(self) -> bool:
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
def needs_permissions(self, _input_data: Optional[GrepToolInput] = None) -> bool:
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
async def validate_input(
|
|
164
|
+
self, input_data: GrepToolInput, _context: Optional[ToolUseContext] = None
|
|
165
|
+
) -> ValidationResult:
|
|
166
|
+
valid_modes = ["files_with_matches", "content", "count"]
|
|
167
|
+
if input_data.output_mode not in valid_modes:
|
|
168
|
+
return ValidationResult(
|
|
169
|
+
result=False, message=f"Invalid output_mode. Must be one of: {valid_modes}"
|
|
170
|
+
)
|
|
171
|
+
if input_data.head_limit is not None and input_data.head_limit <= 0:
|
|
172
|
+
return ValidationResult(result=False, message="head_limit must be positive")
|
|
173
|
+
return ValidationResult(result=True)
|
|
174
|
+
|
|
175
|
+
def render_result_for_assistant(self, output: GrepToolOutput) -> str:
|
|
176
|
+
"""Format output for the AI."""
|
|
177
|
+
if output.total_files == 0 or output.total_matches == 0:
|
|
178
|
+
return f"No matches found for pattern: {output.pattern}"
|
|
179
|
+
|
|
180
|
+
lines: List[str] = []
|
|
181
|
+
summary: str
|
|
182
|
+
|
|
183
|
+
if output.output_mode == "files_with_matches":
|
|
184
|
+
summary = f"Found {output.total_files} file(s) matching '{output.pattern}'."
|
|
185
|
+
lines = [match.file for match in output.matches if match.file]
|
|
186
|
+
elif output.output_mode == "count":
|
|
187
|
+
summary = (
|
|
188
|
+
f"Found {output.total_matches} total match(es) across {output.total_files} file(s) "
|
|
189
|
+
f"for '{output.pattern}'."
|
|
190
|
+
)
|
|
191
|
+
lines = [
|
|
192
|
+
f"{match.file}: {match.count if match.count is not None else 0}"
|
|
193
|
+
for match in output.matches
|
|
194
|
+
if match.file
|
|
195
|
+
]
|
|
196
|
+
else:
|
|
197
|
+
summary = (
|
|
198
|
+
f"Found {output.total_matches} match(es) in {output.total_files} file(s) "
|
|
199
|
+
f"for '{output.pattern}':"
|
|
200
|
+
)
|
|
201
|
+
for match in output.matches:
|
|
202
|
+
if match.content is None:
|
|
203
|
+
continue
|
|
204
|
+
line_number = f":{match.line_number}" if match.line_number is not None else ""
|
|
205
|
+
lines.append(f"{match.file}{line_number}: {match.content}")
|
|
206
|
+
|
|
207
|
+
if output.omitted_results:
|
|
208
|
+
lines.append(
|
|
209
|
+
f"... and {output.omitted_results} more result(s) not shown"
|
|
210
|
+
f"{' (use head_limit to control output size)' if output.head_limit else ''}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
result = summary
|
|
214
|
+
if lines:
|
|
215
|
+
result += "\n\n" + "\n".join(lines)
|
|
216
|
+
|
|
217
|
+
truncated_result, did_truncate, _ = truncate_with_ellipsis(result)
|
|
218
|
+
if did_truncate:
|
|
219
|
+
truncated_result += (
|
|
220
|
+
"\n(Output truncated; refine the pattern or lower head_limit for more detail.)"
|
|
221
|
+
)
|
|
222
|
+
return truncated_result
|
|
223
|
+
|
|
224
|
+
def render_tool_use_message(self, input_data: GrepToolInput, _verbose: bool = False) -> str:
|
|
225
|
+
"""Format the tool use for display."""
|
|
226
|
+
msg = f"Grep: {input_data.pattern}"
|
|
227
|
+
if input_data.glob:
|
|
228
|
+
msg += f" in {input_data.glob}"
|
|
229
|
+
if input_data.head_limit:
|
|
230
|
+
msg += f" (head_limit={input_data.head_limit})"
|
|
231
|
+
return msg
|
|
232
|
+
|
|
233
|
+
async def call(
|
|
234
|
+
self, input_data: GrepToolInput, _context: ToolUseContext
|
|
235
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
236
|
+
"""Search for the pattern."""
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
search_path = input_data.path or "."
|
|
240
|
+
|
|
241
|
+
use_ripgrep = shutil.which("rg") is not None
|
|
242
|
+
pattern = input_data.pattern
|
|
243
|
+
|
|
244
|
+
if use_ripgrep:
|
|
245
|
+
cmd = ["rg", "--color", "never"]
|
|
246
|
+
if input_data.case_insensitive:
|
|
247
|
+
cmd.append("-i")
|
|
248
|
+
if input_data.output_mode == "files_with_matches":
|
|
249
|
+
cmd.append("-l")
|
|
250
|
+
elif input_data.output_mode == "count":
|
|
251
|
+
cmd.append("-c")
|
|
252
|
+
else:
|
|
253
|
+
cmd.append("-n")
|
|
254
|
+
|
|
255
|
+
for glob_pattern in _split_globs(input_data.glob or ""):
|
|
256
|
+
cmd.extend(["--glob", glob_pattern])
|
|
257
|
+
|
|
258
|
+
if pattern.startswith("-"):
|
|
259
|
+
cmd.extend(["-e", pattern])
|
|
260
|
+
else:
|
|
261
|
+
cmd.append(pattern)
|
|
262
|
+
|
|
263
|
+
cmd.append(search_path)
|
|
264
|
+
else:
|
|
265
|
+
# Fallback to grep (note: grep --include matches basenames only)
|
|
266
|
+
cmd = ["grep", "-r", "--color=never", "-P"]
|
|
267
|
+
|
|
268
|
+
if input_data.case_insensitive:
|
|
269
|
+
cmd.append("-i")
|
|
270
|
+
|
|
271
|
+
if input_data.output_mode == "files_with_matches":
|
|
272
|
+
cmd.extend(["-l"]) # Files with matches
|
|
273
|
+
elif input_data.output_mode == "count":
|
|
274
|
+
cmd.extend(["-c"]) # Count per file
|
|
275
|
+
else:
|
|
276
|
+
cmd.extend(["-n"]) # Line numbers
|
|
277
|
+
|
|
278
|
+
for glob_pattern in _split_globs(input_data.glob or ""):
|
|
279
|
+
cmd.extend(["--include", _normalize_glob_for_grep(glob_pattern)])
|
|
280
|
+
|
|
281
|
+
if pattern.startswith("-"):
|
|
282
|
+
cmd.extend(["-e", pattern])
|
|
283
|
+
else:
|
|
284
|
+
cmd.append(pattern)
|
|
285
|
+
|
|
286
|
+
cmd.append(search_path)
|
|
287
|
+
|
|
288
|
+
# Run grep asynchronously
|
|
289
|
+
process = await asyncio.create_subprocess_exec(
|
|
290
|
+
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
stdout, stderr = await process.communicate()
|
|
294
|
+
returncode = process.returncode
|
|
295
|
+
|
|
296
|
+
# Parse output
|
|
297
|
+
matches: List[GrepMatch] = []
|
|
298
|
+
total_matches = 0
|
|
299
|
+
total_files = 0
|
|
300
|
+
omitted_results = 0
|
|
301
|
+
stdout_text = stdout.decode("utf-8", errors="ignore") if stdout else ""
|
|
302
|
+
lines = [line for line in stdout_text.split("\n") if line]
|
|
303
|
+
|
|
304
|
+
if returncode in (0, 1): # 0 = matches found, 1 = no matches (ripgrep/grep)
|
|
305
|
+
display_lines, omitted_results = apply_head_limit(lines, input_data.head_limit)
|
|
306
|
+
|
|
307
|
+
if input_data.output_mode == "files_with_matches":
|
|
308
|
+
total_files = len(set(lines))
|
|
309
|
+
total_matches = len(lines)
|
|
310
|
+
matches = [GrepMatch(file=line) for line in display_lines]
|
|
311
|
+
|
|
312
|
+
elif input_data.output_mode == "count":
|
|
313
|
+
total_files = len(set(line.split(":", 1)[0] for line in lines if line))
|
|
314
|
+
total_match_count = 0
|
|
315
|
+
for line in lines:
|
|
316
|
+
parts = line.rsplit(":", 1)
|
|
317
|
+
if len(parts) == 2 and parts[1].isdigit():
|
|
318
|
+
total_match_count += int(parts[1])
|
|
319
|
+
total_matches = total_match_count
|
|
320
|
+
|
|
321
|
+
for line in display_lines:
|
|
322
|
+
parts = line.rsplit(":", 1)
|
|
323
|
+
if len(parts) == 2:
|
|
324
|
+
matches.append(
|
|
325
|
+
GrepMatch(
|
|
326
|
+
file=parts[0], count=int(parts[1]) if parts[1].isdigit() else 0
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
else: # content mode
|
|
331
|
+
total_files = len({line.split(":", 1)[0] for line in lines if line})
|
|
332
|
+
total_matches = len(lines)
|
|
333
|
+
for line in display_lines:
|
|
334
|
+
parts = line.split(":", 2)
|
|
335
|
+
if len(parts) >= 3:
|
|
336
|
+
matches.append(
|
|
337
|
+
GrepMatch(
|
|
338
|
+
file=parts[0],
|
|
339
|
+
line_number=int(parts[1]) if parts[1].isdigit() else None,
|
|
340
|
+
content=parts[2] if len(parts) > 2 else "",
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
output = GrepToolOutput(
|
|
345
|
+
matches=matches,
|
|
346
|
+
pattern=input_data.pattern,
|
|
347
|
+
total_files=total_files,
|
|
348
|
+
total_matches=total_matches,
|
|
349
|
+
output_mode=input_data.output_mode,
|
|
350
|
+
head_limit=input_data.head_limit,
|
|
351
|
+
omitted_results=omitted_results,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
yield ToolResult(
|
|
355
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as e:
|
|
359
|
+
logger.warning(
|
|
360
|
+
"[grep_tool] Error executing grep: %s: %s",
|
|
361
|
+
type(e).__name__, e,
|
|
362
|
+
extra={"pattern": input_data.pattern, "path": input_data.path},
|
|
363
|
+
)
|
|
364
|
+
error_output = GrepToolOutput(
|
|
365
|
+
matches=[], pattern=input_data.pattern, total_files=0, total_matches=0
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
yield ToolResult(
|
|
369
|
+
data=error_output, result_for_assistant=f"Error executing grep: {str(e)}"
|
|
370
|
+
)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Tool to terminate a running background bash task."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import AsyncGenerator, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from ripperdoc.core.tool import Tool, ToolResult, ToolUseContext, ValidationResult
|
|
10
|
+
from ripperdoc.tools.background_shell import (
|
|
11
|
+
get_background_status,
|
|
12
|
+
kill_background_task,
|
|
13
|
+
)
|
|
14
|
+
from ripperdoc.utils.permissions import PermissionDecision
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
KILL_BASH_PROMPT = """
|
|
18
|
+
- Kills a running background bash shell by its ID
|
|
19
|
+
- Takes a shell_id parameter identifying the shell to kill
|
|
20
|
+
- Returns a success or failure status
|
|
21
|
+
- Use this tool when you need to terminate a long-running shell
|
|
22
|
+
- Shell IDs can be found using the Bash tool (run_in_background) and BashOutput
|
|
23
|
+
""".strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KillBashInput(BaseModel):
|
|
27
|
+
"""Input schema for KillBash."""
|
|
28
|
+
|
|
29
|
+
task_id: str = Field(
|
|
30
|
+
description="Background task id to kill",
|
|
31
|
+
validation_alias=AliasChoices("task_id", "shell_id"),
|
|
32
|
+
serialization_alias="task_id",
|
|
33
|
+
)
|
|
34
|
+
model_config = ConfigDict(validate_by_alias=True, validate_by_name=True, extra="ignore")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class KillBashOutput(BaseModel):
|
|
38
|
+
"""Result of attempting to kill a background task."""
|
|
39
|
+
|
|
40
|
+
task_id: str
|
|
41
|
+
success: bool
|
|
42
|
+
message: str
|
|
43
|
+
status: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class KillBashTool(Tool[KillBashInput, KillBashOutput]):
|
|
47
|
+
"""Terminate a background bash command."""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def name(self) -> str:
|
|
51
|
+
return "KillBash"
|
|
52
|
+
|
|
53
|
+
async def description(self) -> str:
|
|
54
|
+
return "Kill a background bash shell by ID"
|
|
55
|
+
|
|
56
|
+
async def prompt(self, safe_mode: bool = False) -> str:
|
|
57
|
+
return KILL_BASH_PROMPT
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def input_schema(self) -> type[KillBashInput]:
|
|
61
|
+
return KillBashInput
|
|
62
|
+
|
|
63
|
+
def is_read_only(self) -> bool:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
def is_concurrency_safe(self) -> bool:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
def needs_permissions(self, input_data: Optional[KillBashInput] = None) -> bool:
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
async def check_permissions(
|
|
73
|
+
self, input_data: KillBashInput, permission_context: object
|
|
74
|
+
) -> PermissionDecision:
|
|
75
|
+
# Killing is destructive; require explicit confirmation upstream.
|
|
76
|
+
return PermissionDecision(behavior="allow", updated_input=input_data)
|
|
77
|
+
|
|
78
|
+
async def validate_input(
|
|
79
|
+
self, input_data: KillBashInput, context: Optional[ToolUseContext] = None
|
|
80
|
+
) -> ValidationResult:
|
|
81
|
+
try:
|
|
82
|
+
get_background_status(input_data.task_id, consume=False)
|
|
83
|
+
except KeyError:
|
|
84
|
+
return ValidationResult(
|
|
85
|
+
result=False, message=f"No background task found with id '{input_data.task_id}'"
|
|
86
|
+
)
|
|
87
|
+
return ValidationResult(result=True)
|
|
88
|
+
|
|
89
|
+
def render_result_for_assistant(self, output: KillBashOutput) -> str:
|
|
90
|
+
return output.message
|
|
91
|
+
|
|
92
|
+
def render_tool_use_message(self, input_data: KillBashInput, verbose: bool = False) -> str:
|
|
93
|
+
return f"$ kill-background {input_data.task_id}"
|
|
94
|
+
|
|
95
|
+
async def call(
|
|
96
|
+
self, input_data: KillBashInput, context: ToolUseContext
|
|
97
|
+
) -> AsyncGenerator[ToolResult, None]:
|
|
98
|
+
try:
|
|
99
|
+
status = get_background_status(input_data.task_id, consume=False)
|
|
100
|
+
except KeyError:
|
|
101
|
+
output = KillBashOutput(
|
|
102
|
+
task_id=input_data.task_id,
|
|
103
|
+
success=False,
|
|
104
|
+
message=f"No shell found with ID: {input_data.task_id}",
|
|
105
|
+
status=None,
|
|
106
|
+
)
|
|
107
|
+
yield ToolResult(
|
|
108
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
109
|
+
)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
if status["status"] != "running":
|
|
113
|
+
output = KillBashOutput(
|
|
114
|
+
task_id=input_data.task_id,
|
|
115
|
+
success=False,
|
|
116
|
+
message=f"Shell {input_data.task_id} is not running (status: {status['status']}).",
|
|
117
|
+
status=status["status"],
|
|
118
|
+
)
|
|
119
|
+
yield ToolResult(
|
|
120
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
killed = await kill_background_task(input_data.task_id)
|
|
125
|
+
message = (
|
|
126
|
+
f"Successfully killed shell: {input_data.task_id} ({status['command']})"
|
|
127
|
+
if killed
|
|
128
|
+
else f"Failed to kill shell: {input_data.task_id}"
|
|
129
|
+
)
|
|
130
|
+
output = KillBashOutput(
|
|
131
|
+
task_id=input_data.task_id,
|
|
132
|
+
success=killed,
|
|
133
|
+
message=message,
|
|
134
|
+
status="killed" if killed else status["status"],
|
|
135
|
+
)
|
|
136
|
+
yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))
|