ripperdoc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +25 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +317 -0
  5. ripperdoc/cli/commands/__init__.py +76 -0
  6. ripperdoc/cli/commands/agents_cmd.py +234 -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 +19 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +114 -0
  12. ripperdoc/cli/commands/cost_cmd.py +77 -0
  13. ripperdoc/cli/commands/exit_cmd.py +19 -0
  14. ripperdoc/cli/commands/help_cmd.py +20 -0
  15. ripperdoc/cli/commands/mcp_cmd.py +65 -0
  16. ripperdoc/cli/commands/models_cmd.py +327 -0
  17. ripperdoc/cli/commands/resume_cmd.py +97 -0
  18. ripperdoc/cli/commands/status_cmd.py +167 -0
  19. ripperdoc/cli/commands/tasks_cmd.py +240 -0
  20. ripperdoc/cli/commands/todos_cmd.py +69 -0
  21. ripperdoc/cli/commands/tools_cmd.py +19 -0
  22. ripperdoc/cli/ui/__init__.py +1 -0
  23. ripperdoc/cli/ui/context_display.py +297 -0
  24. ripperdoc/cli/ui/helpers.py +22 -0
  25. ripperdoc/cli/ui/rich_ui.py +1010 -0
  26. ripperdoc/cli/ui/spinner.py +50 -0
  27. ripperdoc/core/__init__.py +1 -0
  28. ripperdoc/core/agents.py +306 -0
  29. ripperdoc/core/commands.py +33 -0
  30. ripperdoc/core/config.py +382 -0
  31. ripperdoc/core/default_tools.py +57 -0
  32. ripperdoc/core/permissions.py +227 -0
  33. ripperdoc/core/query.py +682 -0
  34. ripperdoc/core/system_prompt.py +418 -0
  35. ripperdoc/core/tool.py +214 -0
  36. ripperdoc/sdk/__init__.py +9 -0
  37. ripperdoc/sdk/client.py +309 -0
  38. ripperdoc/tools/__init__.py +1 -0
  39. ripperdoc/tools/background_shell.py +291 -0
  40. ripperdoc/tools/bash_output_tool.py +98 -0
  41. ripperdoc/tools/bash_tool.py +822 -0
  42. ripperdoc/tools/file_edit_tool.py +281 -0
  43. ripperdoc/tools/file_read_tool.py +168 -0
  44. ripperdoc/tools/file_write_tool.py +141 -0
  45. ripperdoc/tools/glob_tool.py +134 -0
  46. ripperdoc/tools/grep_tool.py +232 -0
  47. ripperdoc/tools/kill_bash_tool.py +136 -0
  48. ripperdoc/tools/ls_tool.py +298 -0
  49. ripperdoc/tools/mcp_tools.py +804 -0
  50. ripperdoc/tools/multi_edit_tool.py +393 -0
  51. ripperdoc/tools/notebook_edit_tool.py +325 -0
  52. ripperdoc/tools/task_tool.py +282 -0
  53. ripperdoc/tools/todo_tool.py +362 -0
  54. ripperdoc/tools/tool_search_tool.py +366 -0
  55. ripperdoc/utils/__init__.py +1 -0
  56. ripperdoc/utils/bash_constants.py +51 -0
  57. ripperdoc/utils/bash_output_utils.py +43 -0
  58. ripperdoc/utils/exit_code_handlers.py +241 -0
  59. ripperdoc/utils/log.py +76 -0
  60. ripperdoc/utils/mcp.py +427 -0
  61. ripperdoc/utils/memory.py +239 -0
  62. ripperdoc/utils/message_compaction.py +640 -0
  63. ripperdoc/utils/messages.py +399 -0
  64. ripperdoc/utils/output_utils.py +233 -0
  65. ripperdoc/utils/path_utils.py +46 -0
  66. ripperdoc/utils/permissions/__init__.py +21 -0
  67. ripperdoc/utils/permissions/path_validation_utils.py +165 -0
  68. ripperdoc/utils/permissions/shell_command_validation.py +74 -0
  69. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  70. ripperdoc/utils/safe_get_cwd.py +24 -0
  71. ripperdoc/utils/sandbox_utils.py +38 -0
  72. ripperdoc/utils/session_history.py +223 -0
  73. ripperdoc/utils/session_usage.py +110 -0
  74. ripperdoc/utils/shell_token_utils.py +95 -0
  75. ripperdoc/utils/todo.py +199 -0
  76. ripperdoc-0.1.0.dist-info/METADATA +178 -0
  77. ripperdoc-0.1.0.dist-info/RECORD +81 -0
  78. ripperdoc-0.1.0.dist-info/WHEEL +5 -0
  79. ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
  80. ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
  81. ripperdoc-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,309 @@
1
+ """Headless Python SDK for Ripperdoc.
2
+
3
+ `query` helper for simple calls and a `RipperdocClient` for long-lived
4
+ sessions that keep conversation history.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import (
14
+ Any,
15
+ AsyncIterator,
16
+ Awaitable,
17
+ Callable,
18
+ Dict,
19
+ List,
20
+ Optional,
21
+ Sequence,
22
+ Union,
23
+ )
24
+
25
+ from ripperdoc.core.default_tools import get_default_tools
26
+ from ripperdoc.core.query import QueryContext, query as _core_query
27
+ from ripperdoc.core.system_prompt import build_system_prompt
28
+ from ripperdoc.core.tool import Tool
29
+ from ripperdoc.tools.task_tool import TaskTool
30
+ from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
31
+ from ripperdoc.utils.memory import build_memory_instructions
32
+ from ripperdoc.utils.messages import (
33
+ AssistantMessage,
34
+ ProgressMessage,
35
+ UserMessage,
36
+ create_user_message,
37
+ )
38
+ from ripperdoc.utils.mcp import (
39
+ format_mcp_instructions,
40
+ load_mcp_servers_async,
41
+ shutdown_mcp_runtime,
42
+ )
43
+
44
+ MessageType = Union[UserMessage, AssistantMessage, ProgressMessage]
45
+ PermissionChecker = Callable[[Tool[Any, Any], Any], Union[Awaitable[Any], Any]]
46
+ QueryRunner = Callable[
47
+ [
48
+ List[MessageType],
49
+ str,
50
+ Dict[str, str],
51
+ QueryContext,
52
+ Optional[PermissionChecker],
53
+ ],
54
+ AsyncIterator[MessageType],
55
+ ]
56
+
57
+ _END_OF_STREAM = object()
58
+
59
+
60
+ def _coerce_to_path(path: Union[str, Path]) -> Path:
61
+ return path if isinstance(path, Path) else Path(path)
62
+
63
+
64
+ @dataclass
65
+ class RipperdocOptions:
66
+ """Configuration for SDK usage."""
67
+
68
+ tools: Optional[Sequence[Tool[Any, Any]]] = None
69
+ allowed_tools: Optional[Sequence[str]] = None
70
+ disallowed_tools: Optional[Sequence[str]] = None
71
+ safe_mode: bool = False
72
+ verbose: bool = False
73
+ model: str = "main"
74
+ max_thinking_tokens: int = 0
75
+ context: Dict[str, str] = field(default_factory=dict)
76
+ system_prompt: Optional[str] = None
77
+ additional_instructions: Optional[Union[str, Sequence[str]]] = None
78
+ permission_checker: Optional[PermissionChecker] = None
79
+ cwd: Optional[Union[str, Path]] = None
80
+
81
+ def build_tools(self) -> List[Tool[Any, Any]]:
82
+ """Create the tool set with allow/deny filters applied."""
83
+ base_tools = list(self.tools) if self.tools is not None else get_default_tools()
84
+ allowed = set(self.allowed_tools) if self.allowed_tools is not None else None
85
+ disallowed = set(self.disallowed_tools or [])
86
+
87
+ filtered: List[Tool[Any, Any]] = []
88
+ for tool in base_tools:
89
+ name = getattr(tool, "name", tool.__class__.__name__)
90
+ if allowed is not None and name not in allowed:
91
+ continue
92
+ if name in disallowed:
93
+ continue
94
+ filtered.append(tool)
95
+
96
+ if allowed is not None and not filtered:
97
+ raise ValueError("No tools remain after applying allowed_tools/disallowed_tools.")
98
+
99
+ # The default Task tool captures the original base tools. If filters are
100
+ # applied, recreate it so the subagent only sees the filtered set.
101
+ if (self.allowed_tools or self.disallowed_tools) and self.tools is None:
102
+ has_task = any(getattr(tool, "name", None) == "Task" for tool in filtered)
103
+ if has_task:
104
+ filtered_base = [tool for tool in filtered if getattr(tool, "name", None) != "Task"]
105
+
106
+ def _filtered_base_provider() -> List[Tool[Any, Any]]:
107
+ return filtered_base
108
+
109
+ filtered = [
110
+ (
111
+ TaskTool(_filtered_base_provider)
112
+ if getattr(tool, "name", None) == "Task"
113
+ else tool
114
+ )
115
+ for tool in filtered
116
+ ]
117
+
118
+ return filtered
119
+
120
+ def extra_instructions(self) -> List[str]:
121
+ """Normalize additional instructions to a list."""
122
+ if self.additional_instructions is None:
123
+ return []
124
+ if isinstance(self.additional_instructions, str):
125
+ return [self.additional_instructions]
126
+ return [text for text in self.additional_instructions if text]
127
+
128
+
129
+ class RipperdocClient:
130
+ """Persistent Ripperdoc session with conversation history."""
131
+
132
+ def __init__(
133
+ self,
134
+ options: Optional[RipperdocOptions] = None,
135
+ query_runner: Optional[QueryRunner] = None,
136
+ ) -> None:
137
+ self.options = options or RipperdocOptions()
138
+ self._tools = self.options.build_tools()
139
+ self._query_runner = query_runner or _core_query
140
+
141
+ self._history: List[MessageType] = []
142
+ self._queue: asyncio.Queue = asyncio.Queue()
143
+ self._current_task: Optional[asyncio.Task] = None
144
+ self._current_context: Optional[QueryContext] = None
145
+ self._connected = False
146
+ self._previous_cwd: Optional[Path] = None
147
+
148
+ @property
149
+ def tools(self) -> List[Tool[Any, Any]]:
150
+ return self._tools
151
+
152
+ @property
153
+ def history(self) -> List[MessageType]:
154
+ return list(self._history)
155
+
156
+ async def __aenter__(self) -> "RipperdocClient":
157
+ await self.connect()
158
+ return self
159
+
160
+ async def __aexit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
161
+ await self.disconnect()
162
+
163
+ async def connect(self, prompt: Optional[str] = None) -> None:
164
+ """Prepare the session and optionally send an initial prompt."""
165
+ if not self._connected:
166
+ if self.options.cwd is not None:
167
+ self._previous_cwd = Path.cwd()
168
+ os.chdir(_coerce_to_path(self.options.cwd))
169
+ self._connected = True
170
+
171
+ if prompt:
172
+ await self.query(prompt)
173
+
174
+ async def disconnect(self) -> None:
175
+ """Tear down the session and restore the working directory."""
176
+ if self._current_context:
177
+ self._current_context.abort_controller.set()
178
+
179
+ if self._current_task and not self._current_task.done():
180
+ self._current_task.cancel()
181
+ try:
182
+ await self._current_task
183
+ except asyncio.CancelledError:
184
+ pass
185
+
186
+ if self._previous_cwd:
187
+ os.chdir(self._previous_cwd)
188
+ self._previous_cwd = None
189
+
190
+ self._connected = False
191
+ await shutdown_mcp_runtime()
192
+
193
+ async def query(self, prompt: str) -> None:
194
+ """Send a prompt and start streaming the response."""
195
+ if self._current_task and not self._current_task.done():
196
+ raise RuntimeError(
197
+ "A query is already in progress; wait for it to finish or interrupt it."
198
+ )
199
+
200
+ if not self._connected:
201
+ await self.connect()
202
+
203
+ self._queue = asyncio.Queue()
204
+
205
+ user_message = create_user_message(prompt)
206
+ history = list(self._history) + [user_message]
207
+ self._history.append(user_message)
208
+
209
+ system_prompt = await self._build_system_prompt(prompt)
210
+ context = dict(self.options.context)
211
+
212
+ query_context = QueryContext(
213
+ tools=self._tools,
214
+ max_thinking_tokens=self.options.max_thinking_tokens,
215
+ safe_mode=self.options.safe_mode,
216
+ model=self.options.model,
217
+ verbose=self.options.verbose,
218
+ )
219
+ self._current_context = query_context
220
+
221
+ async def _runner() -> None:
222
+ try:
223
+ async for message in self._query_runner(
224
+ history,
225
+ system_prompt,
226
+ context,
227
+ query_context,
228
+ self.options.permission_checker,
229
+ ):
230
+ if getattr(message, "type", None) in ("user", "assistant"):
231
+ self._history.append(message) # type: ignore[arg-type]
232
+ await self._queue.put(message)
233
+ finally:
234
+ await self._queue.put(_END_OF_STREAM)
235
+
236
+ self._current_task = asyncio.create_task(_runner())
237
+
238
+ async def receive_messages(self) -> AsyncIterator[MessageType]:
239
+ """Yield messages for the active query."""
240
+ if self._current_task is None:
241
+ raise RuntimeError("No active query to receive messages from.")
242
+
243
+ while True:
244
+ message = await self._queue.get()
245
+ if message is _END_OF_STREAM:
246
+ break
247
+ yield message # type: ignore[misc]
248
+
249
+ async def receive_response(self) -> AsyncIterator[MessageType]:
250
+ """Alias for receive_messages."""
251
+ async for message in self.receive_messages():
252
+ yield message
253
+
254
+ async def interrupt(self) -> None:
255
+ """Request cancellation of the active query."""
256
+ if self._current_context:
257
+ self._current_context.abort_controller.set()
258
+
259
+ if self._current_task and not self._current_task.done():
260
+ self._current_task.cancel()
261
+ try:
262
+ await self._current_task
263
+ except asyncio.CancelledError:
264
+ pass
265
+
266
+ await self._queue.put(_END_OF_STREAM)
267
+
268
+ async def _build_system_prompt(self, user_prompt: str) -> str:
269
+ if self.options.system_prompt:
270
+ return self.options.system_prompt
271
+
272
+ instructions: List[str] = []
273
+ instructions.extend(self.options.extra_instructions())
274
+ memory = build_memory_instructions()
275
+ if memory:
276
+ instructions.append(memory)
277
+
278
+ project_path = _coerce_to_path(self.options.cwd or Path.cwd())
279
+ dynamic_tools = await load_dynamic_mcp_tools_async(project_path)
280
+ if dynamic_tools:
281
+ self._tools = merge_tools_with_dynamic(self._tools, dynamic_tools)
282
+
283
+ servers = await load_mcp_servers_async(project_path)
284
+ mcp_instructions = format_mcp_instructions(servers)
285
+
286
+ return build_system_prompt(
287
+ self._tools,
288
+ user_prompt,
289
+ dict(self.options.context),
290
+ instructions or None,
291
+ mcp_instructions=mcp_instructions,
292
+ )
293
+
294
+
295
+ async def query(
296
+ prompt: str,
297
+ options: Optional[RipperdocOptions] = None,
298
+ query_runner: Optional[QueryRunner] = None,
299
+ ) -> AsyncIterator[MessageType]:
300
+ """One-shot helper: run a prompt in a fresh session."""
301
+ client = RipperdocClient(options=options, query_runner=query_runner)
302
+ await client.connect()
303
+ await client.query(prompt)
304
+
305
+ try:
306
+ async for message in client.receive_messages():
307
+ yield message
308
+ finally:
309
+ await client.disconnect()
@@ -0,0 +1 @@
1
+ """Tool implementations for Ripperdoc."""
@@ -0,0 +1,291 @@
1
+ """Lightweight background shell manager for BashTool.
2
+
3
+ Allows starting shell commands that keep running while the caller continues.
4
+ Output can be polled via the BashOutput tool and commands can be terminated
5
+ via the KillBash tool.
6
+ """
7
+
8
+ import asyncio
9
+ import concurrent.futures
10
+ import contextlib
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from ripperdoc.utils.log import get_logger
18
+
19
+
20
+ logger = get_logger()
21
+
22
+
23
+ @dataclass
24
+ class BackgroundTask:
25
+ """In-memory record of a background shell command."""
26
+
27
+ id: str
28
+ command: str
29
+ process: asyncio.subprocess.Process
30
+ start_time: float
31
+ timeout: Optional[float] = None
32
+ stdout_chunks: List[str] = field(default_factory=list)
33
+ stderr_chunks: List[str] = field(default_factory=list)
34
+ exit_code: Optional[int] = None
35
+ killed: bool = False
36
+ timed_out: bool = False
37
+ reader_tasks: List[asyncio.Task] = field(default_factory=list)
38
+ done_event: asyncio.Event = field(default_factory=asyncio.Event)
39
+
40
+
41
+ _tasks: Dict[str, BackgroundTask] = {}
42
+ _tasks_lock = threading.Lock()
43
+ _background_loop: Optional[asyncio.AbstractEventLoop] = None
44
+ _background_thread: Optional[threading.Thread] = None
45
+ _loop_lock = threading.Lock()
46
+
47
+
48
+ def _ensure_background_loop() -> asyncio.AbstractEventLoop:
49
+ """Create (or return) a dedicated loop for background processes."""
50
+ global _background_loop, _background_thread
51
+
52
+ if _background_loop and _background_loop.is_running():
53
+ return _background_loop
54
+
55
+ with _loop_lock:
56
+ if _background_loop and _background_loop.is_running():
57
+ return _background_loop
58
+
59
+ loop = asyncio.new_event_loop()
60
+ ready = threading.Event()
61
+
62
+ def _run_loop() -> None:
63
+ asyncio.set_event_loop(loop)
64
+ ready.set()
65
+ loop.run_forever()
66
+
67
+ thread = threading.Thread(
68
+ target=_run_loop,
69
+ name="ripperdoc-bg-loop",
70
+ daemon=True,
71
+ )
72
+ thread.start()
73
+ ready.wait()
74
+
75
+ _background_loop = loop
76
+ _background_thread = thread
77
+ return loop
78
+
79
+
80
+ def _submit_to_background_loop(coro: Any) -> concurrent.futures.Future:
81
+ """Run a coroutine on the background loop and return a thread-safe future."""
82
+ loop = _ensure_background_loop()
83
+ return asyncio.run_coroutine_threadsafe(coro, loop)
84
+
85
+
86
+ async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
87
+ """Continuously read from a stream into a buffer."""
88
+ try:
89
+ while True:
90
+ chunk = await stream.read(4096)
91
+ if not chunk:
92
+ break
93
+ text = chunk.decode("utf-8", errors="replace")
94
+ with _tasks_lock:
95
+ sink.append(text)
96
+ except Exception as exc:
97
+ # Best effort; ignore stream read errors to avoid leaking tasks.
98
+ logger.debug(f"Stream pump error for background task: {exc}")
99
+
100
+
101
+ async def _finalize_reader_tasks(reader_tasks: List[asyncio.Task], timeout: float = 1.0) -> None:
102
+ """Wait for stream reader tasks to finish, cancelling if they hang."""
103
+ if not reader_tasks:
104
+ return
105
+
106
+ try:
107
+ await asyncio.wait_for(
108
+ asyncio.gather(*reader_tasks, return_exceptions=True), timeout=timeout
109
+ )
110
+ except asyncio.TimeoutError:
111
+ for task in reader_tasks:
112
+ if not task.done():
113
+ task.cancel()
114
+ await asyncio.gather(*reader_tasks, return_exceptions=True)
115
+
116
+
117
+ async def _monitor_task(task: BackgroundTask) -> None:
118
+ """Wait for a background process to finish or timeout, then mark status."""
119
+ try:
120
+ if task.timeout:
121
+ await asyncio.wait_for(task.process.wait(), timeout=task.timeout)
122
+ else:
123
+ await task.process.wait()
124
+ with _tasks_lock:
125
+ task.exit_code = task.process.returncode
126
+ except asyncio.TimeoutError:
127
+ logger.warning(f"Background task {task.id} timed out after {task.timeout}s: {task.command}")
128
+ with _tasks_lock:
129
+ task.timed_out = True
130
+ task.process.kill()
131
+ await task.process.wait()
132
+ with _tasks_lock:
133
+ task.exit_code = -1
134
+ except asyncio.CancelledError:
135
+ return
136
+ except Exception as exc:
137
+ logger.error(f"Error monitoring background task {task.id}: {exc}")
138
+ with _tasks_lock:
139
+ task.exit_code = -1
140
+ finally:
141
+ # Ensure readers are finished before marking done.
142
+ await _finalize_reader_tasks(task.reader_tasks)
143
+ task.done_event.set()
144
+
145
+
146
+ async def _start_background_command(
147
+ command: str, timeout: Optional[float] = None, shell_executable: Optional[str] = None
148
+ ) -> str:
149
+ """Launch a background shell command on the dedicated loop."""
150
+ if shell_executable:
151
+ process = await asyncio.create_subprocess_exec(
152
+ shell_executable,
153
+ "-c",
154
+ command,
155
+ stdout=asyncio.subprocess.PIPE,
156
+ stderr=asyncio.subprocess.PIPE,
157
+ stdin=asyncio.subprocess.DEVNULL,
158
+ start_new_session=False,
159
+ )
160
+ else:
161
+ process = await asyncio.create_subprocess_shell(
162
+ command,
163
+ stdout=asyncio.subprocess.PIPE,
164
+ stderr=asyncio.subprocess.PIPE,
165
+ stdin=asyncio.subprocess.DEVNULL,
166
+ start_new_session=False,
167
+ )
168
+
169
+ task_id = f"bash_{uuid.uuid4().hex[:8]}"
170
+ record = BackgroundTask(
171
+ id=task_id,
172
+ command=command,
173
+ process=process,
174
+ start_time=_loop_time(),
175
+ timeout=timeout,
176
+ )
177
+ with _tasks_lock:
178
+ _tasks[task_id] = record
179
+
180
+ # Start stream pumps and monitor task.
181
+ if process.stdout:
182
+ record.reader_tasks.append(
183
+ asyncio.create_task(_pump_stream(process.stdout, record.stdout_chunks))
184
+ )
185
+ if process.stderr:
186
+ record.reader_tasks.append(
187
+ asyncio.create_task(_pump_stream(process.stderr, record.stderr_chunks))
188
+ )
189
+ asyncio.create_task(_monitor_task(record))
190
+
191
+ return task_id
192
+
193
+
194
+ async def start_background_command(
195
+ command: str, timeout: Optional[float] = None, shell_executable: Optional[str] = None
196
+ ) -> str:
197
+ """Launch a background shell command and return its task id."""
198
+ future = _submit_to_background_loop(
199
+ _start_background_command(command, timeout, shell_executable)
200
+ )
201
+ return await asyncio.wrap_future(future)
202
+
203
+
204
+ def _compute_status(task: BackgroundTask) -> str:
205
+ """Return a human-friendly status string."""
206
+ if task.killed:
207
+ return "killed"
208
+ if task.timed_out:
209
+ return "failed"
210
+ if task.exit_code is None:
211
+ return "running"
212
+ return "completed" if task.exit_code == 0 else "failed"
213
+
214
+
215
+ def _loop_time() -> float:
216
+ """Return a monotonic timestamp without requiring a running event loop."""
217
+ try:
218
+ return asyncio.get_running_loop().time()
219
+ except RuntimeError:
220
+ return time.monotonic()
221
+
222
+
223
+ def get_background_status(task_id: str, consume: bool = True) -> dict:
224
+ """Fetch the current status and buffered output of a background command.
225
+
226
+ If consume is True, buffered stdout/stderr are cleared after reading.
227
+ """
228
+ with _tasks_lock:
229
+ if task_id not in _tasks:
230
+ raise KeyError(f"No background task found with id '{task_id}'")
231
+
232
+ task = _tasks[task_id]
233
+ stdout = "".join(task.stdout_chunks)
234
+ stderr = "".join(task.stderr_chunks)
235
+
236
+ if consume:
237
+ task.stdout_chunks.clear()
238
+ task.stderr_chunks.clear()
239
+
240
+ return {
241
+ "id": task.id,
242
+ "command": task.command,
243
+ "status": _compute_status(task),
244
+ "stdout": stdout,
245
+ "stderr": stderr,
246
+ "exit_code": task.exit_code,
247
+ "timed_out": task.timed_out,
248
+ "killed": task.killed,
249
+ "duration_ms": (_loop_time() - task.start_time) * 1000.0,
250
+ }
251
+
252
+
253
+ async def kill_background_task(task_id: str) -> bool:
254
+ """Attempt to kill a running background task."""
255
+ KILL_WAIT_SECONDS = 2.0
256
+
257
+ async def _kill(task_id: str) -> bool:
258
+ with _tasks_lock:
259
+ task = _tasks.get(task_id)
260
+ if not task:
261
+ return False
262
+
263
+ if task.exit_code is not None:
264
+ return False
265
+
266
+ try:
267
+ task.killed = True
268
+ task.process.kill()
269
+ try:
270
+ await asyncio.wait_for(task.process.wait(), timeout=KILL_WAIT_SECONDS)
271
+ except asyncio.TimeoutError:
272
+ # Best effort: force kill and don't block.
273
+ with contextlib.suppress(ProcessLookupError, PermissionError):
274
+ task.process.kill()
275
+ await asyncio.wait_for(task.process.wait(), timeout=1.0)
276
+
277
+ with _tasks_lock:
278
+ task.exit_code = task.process.returncode or -1
279
+ return True
280
+ finally:
281
+ await _finalize_reader_tasks(task.reader_tasks)
282
+ task.done_event.set()
283
+
284
+ future = _submit_to_background_loop(_kill(task_id))
285
+ return await asyncio.wrap_future(future)
286
+
287
+
288
+ def list_background_tasks() -> List[str]:
289
+ """Return known background task ids."""
290
+ with _tasks_lock:
291
+ return list(_tasks.keys())
@@ -0,0 +1,98 @@
1
+ """Tool to retrieve output from background bash tasks."""
2
+
3
+ from typing import Any, AsyncGenerator, Optional
4
+ from pydantic import BaseModel, Field
5
+
6
+ from ripperdoc.core.tool import Tool, ToolUseContext, ToolResult, ValidationResult
7
+ from ripperdoc.tools.background_shell import get_background_status
8
+
9
+
10
+ class BashOutputInput(BaseModel):
11
+ """Input schema for BashOutput."""
12
+
13
+ task_id: str = Field(
14
+ description="Background task id returned by BashTool when run_in_background is true"
15
+ )
16
+ consume: bool = Field(
17
+ default=True, description="Whether to clear buffered output after reading (default: True)"
18
+ )
19
+
20
+
21
+ class BashOutputData(BaseModel):
22
+ """Snapshot of a background task."""
23
+
24
+ task_id: str
25
+ command: str
26
+ status: str
27
+ stdout: str
28
+ stderr: str
29
+ exit_code: Optional[int]
30
+ duration_ms: float
31
+
32
+
33
+ class BashOutputTool(Tool[BashOutputInput, BashOutputData]):
34
+ """Read buffered output from a background bash task."""
35
+
36
+ @property
37
+ def name(self) -> str:
38
+ return "BashOutput"
39
+
40
+ async def description(self) -> str:
41
+ return "Read output and status from a background bash command started with BashTool(run_in_background=True)."
42
+
43
+ async def prompt(self, safe_mode: bool = False) -> str:
44
+ return "Fetch buffered output and status for a background bash task by id."
45
+
46
+ @property
47
+ def input_schema(self) -> type[BashOutputInput]:
48
+ return BashOutputInput
49
+
50
+ def is_read_only(self) -> bool:
51
+ return True
52
+
53
+ def is_concurrency_safe(self) -> bool:
54
+ return True
55
+
56
+ def needs_permissions(self, input_data: Any = None) -> bool:
57
+ return False
58
+
59
+ async def validate_input(
60
+ self, input_data: BashOutputInput, context: Optional[ToolUseContext] = None
61
+ ) -> ValidationResult:
62
+ try:
63
+ get_background_status(input_data.task_id, consume=False)
64
+ except KeyError:
65
+ return ValidationResult(
66
+ result=False, message=f"No background task found with id '{input_data.task_id}'"
67
+ )
68
+ return ValidationResult(result=True)
69
+
70
+ def render_result_for_assistant(self, output: BashOutputData) -> str:
71
+ parts = [
72
+ f"status: {output.status}",
73
+ f"exit code: {output.exit_code if output.exit_code is not None else 'running'}",
74
+ ]
75
+ if output.stdout:
76
+ parts.append(f"stdout:\n{output.stdout}")
77
+ if output.stderr:
78
+ parts.append(f"stderr:\n{output.stderr}")
79
+ return "\n\n".join(parts)
80
+
81
+ def render_tool_use_message(self, input_data: BashOutputInput, verbose: bool = False) -> str:
82
+ suffix = " (consume=0)" if not input_data.consume else ""
83
+ return f"$ bash-output {input_data.task_id}{suffix}"
84
+
85
+ async def call(
86
+ self, input_data: BashOutputInput, context: ToolUseContext
87
+ ) -> AsyncGenerator[ToolResult, None]:
88
+ status = get_background_status(input_data.task_id, consume=input_data.consume)
89
+ output = BashOutputData(
90
+ task_id=status["id"],
91
+ command=status["command"],
92
+ status=status["status"],
93
+ stdout=status["stdout"],
94
+ stderr=status["stderr"],
95
+ exit_code=status["exit_code"],
96
+ duration_ms=status["duration_ms"],
97
+ )
98
+ yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))