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.
- ripperdoc/__init__.py +3 -0
- ripperdoc/__main__.py +25 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +317 -0
- ripperdoc/cli/commands/__init__.py +76 -0
- ripperdoc/cli/commands/agents_cmd.py +234 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +19 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +114 -0
- ripperdoc/cli/commands/cost_cmd.py +77 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +65 -0
- ripperdoc/cli/commands/models_cmd.py +327 -0
- ripperdoc/cli/commands/resume_cmd.py +97 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +240 -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 +297 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1010 -0
- ripperdoc/cli/ui/spinner.py +50 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +306 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +382 -0
- ripperdoc/core/default_tools.py +57 -0
- ripperdoc/core/permissions.py +227 -0
- ripperdoc/core/query.py +682 -0
- ripperdoc/core/system_prompt.py +418 -0
- ripperdoc/core/tool.py +214 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +309 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/background_shell.py +291 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +822 -0
- ripperdoc/tools/file_edit_tool.py +281 -0
- ripperdoc/tools/file_read_tool.py +168 -0
- ripperdoc/tools/file_write_tool.py +141 -0
- ripperdoc/tools/glob_tool.py +134 -0
- ripperdoc/tools/grep_tool.py +232 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +298 -0
- ripperdoc/tools/mcp_tools.py +804 -0
- ripperdoc/tools/multi_edit_tool.py +393 -0
- ripperdoc/tools/notebook_edit_tool.py +325 -0
- ripperdoc/tools/task_tool.py +282 -0
- ripperdoc/tools/todo_tool.py +362 -0
- ripperdoc/tools/tool_search_tool.py +366 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/log.py +76 -0
- ripperdoc/utils/mcp.py +427 -0
- ripperdoc/utils/memory.py +239 -0
- ripperdoc/utils/message_compaction.py +640 -0
- ripperdoc/utils/messages.py +399 -0
- ripperdoc/utils/output_utils.py +233 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +21 -0
- ripperdoc/utils/permissions/path_validation_utils.py +165 -0
- ripperdoc/utils/permissions/shell_command_validation.py +74 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/safe_get_cwd.py +24 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +223 -0
- ripperdoc/utils/session_usage.py +110 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/todo.py +199 -0
- ripperdoc-0.1.0.dist-info/METADATA +178 -0
- ripperdoc-0.1.0.dist-info/RECORD +81 -0
- ripperdoc-0.1.0.dist-info/WHEEL +5 -0
- ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
- ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.1.0.dist-info/top_level.txt +1 -0
ripperdoc/sdk/client.py
ADDED
|
@@ -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))
|