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,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))