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,265 @@
|
|
|
1
|
+
"""Shared abstractions for provider clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import (
|
|
10
|
+
Any,
|
|
11
|
+
AsyncIterable,
|
|
12
|
+
AsyncIterator,
|
|
13
|
+
Awaitable,
|
|
14
|
+
Callable,
|
|
15
|
+
Dict,
|
|
16
|
+
Iterable,
|
|
17
|
+
List,
|
|
18
|
+
Optional,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from ripperdoc.core.config import ModelProfile
|
|
22
|
+
from ripperdoc.core.tool import Tool
|
|
23
|
+
from ripperdoc.utils.log import get_logger
|
|
24
|
+
|
|
25
|
+
logger = get_logger()
|
|
26
|
+
|
|
27
|
+
ProgressCallback = Callable[[str], Awaitable[None]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ProviderResponse:
|
|
32
|
+
"""Normalized provider response payload."""
|
|
33
|
+
|
|
34
|
+
content_blocks: List[Dict[str, Any]]
|
|
35
|
+
usage_tokens: Dict[str, int]
|
|
36
|
+
cost_usd: float
|
|
37
|
+
duration_ms: float
|
|
38
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
# Error handling fields
|
|
40
|
+
is_error: bool = False
|
|
41
|
+
error_code: Optional[str] = None # e.g., "permission_denied", "context_length_exceeded"
|
|
42
|
+
error_message: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def create_error(
|
|
46
|
+
cls,
|
|
47
|
+
error_code: str,
|
|
48
|
+
error_message: str,
|
|
49
|
+
duration_ms: float = 0.0,
|
|
50
|
+
) -> "ProviderResponse":
|
|
51
|
+
"""Create an error response with a text block containing the error message."""
|
|
52
|
+
return cls(
|
|
53
|
+
content_blocks=[{"type": "text", "text": f"[API Error] {error_message}"}],
|
|
54
|
+
usage_tokens={},
|
|
55
|
+
cost_usd=0.0,
|
|
56
|
+
duration_ms=duration_ms,
|
|
57
|
+
is_error=True,
|
|
58
|
+
error_code=error_code,
|
|
59
|
+
error_message=error_message,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ProviderClient(ABC):
|
|
64
|
+
"""Abstract base for model provider clients."""
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
async def call(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
model_profile: ModelProfile,
|
|
71
|
+
system_prompt: str,
|
|
72
|
+
normalized_messages: List[Dict[str, Any]],
|
|
73
|
+
tools: List[Tool[Any, Any]],
|
|
74
|
+
tool_mode: str,
|
|
75
|
+
stream: bool,
|
|
76
|
+
progress_callback: Optional[ProgressCallback],
|
|
77
|
+
request_timeout: Optional[float],
|
|
78
|
+
max_retries: int,
|
|
79
|
+
max_thinking_tokens: int,
|
|
80
|
+
) -> ProviderResponse:
|
|
81
|
+
"""Execute a model call and return a normalized response."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def sanitize_tool_history(normalized_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
85
|
+
"""Strip tool_use blocks that lack a following tool_result to satisfy provider constraints."""
|
|
86
|
+
|
|
87
|
+
def _tool_result_ids(msg: Dict[str, Any]) -> set[str]:
|
|
88
|
+
ids: set[str] = set()
|
|
89
|
+
content = msg.get("content")
|
|
90
|
+
if isinstance(content, list):
|
|
91
|
+
for part in content:
|
|
92
|
+
part_type = getattr(
|
|
93
|
+
part, "get", lambda k, default=None: part.__dict__.get(k, default)
|
|
94
|
+
)("type", None)
|
|
95
|
+
if part_type == "tool_result":
|
|
96
|
+
tid = (
|
|
97
|
+
getattr(part, "tool_use_id", None)
|
|
98
|
+
or getattr(part, "id", None)
|
|
99
|
+
or part.get("tool_use_id")
|
|
100
|
+
or part.get("id")
|
|
101
|
+
)
|
|
102
|
+
if tid:
|
|
103
|
+
ids.add(str(tid))
|
|
104
|
+
return ids
|
|
105
|
+
|
|
106
|
+
# Build a lookahead map so we can pair tool_use blocks with tool_results that may
|
|
107
|
+
# appear in any later message (not just the immediate next one).
|
|
108
|
+
tool_results_after: List[set[str]] = []
|
|
109
|
+
if normalized_messages:
|
|
110
|
+
tool_results_after = [set() for _ in normalized_messages]
|
|
111
|
+
future_ids: set[str] = set()
|
|
112
|
+
for idx in range(len(normalized_messages) - 1, -1, -1):
|
|
113
|
+
tool_results_after[idx] = set(future_ids)
|
|
114
|
+
future_ids.update(_tool_result_ids(normalized_messages[idx]))
|
|
115
|
+
|
|
116
|
+
sanitized: List[Dict[str, Any]] = []
|
|
117
|
+
for idx, message in enumerate(normalized_messages):
|
|
118
|
+
if message.get("role") != "assistant":
|
|
119
|
+
sanitized.append(message)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
content = message.get("content")
|
|
123
|
+
if not isinstance(content, list):
|
|
124
|
+
sanitized.append(message)
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
tool_use_blocks = [
|
|
128
|
+
part
|
|
129
|
+
for part in content
|
|
130
|
+
if (
|
|
131
|
+
getattr(part, "type", None)
|
|
132
|
+
or (part.get("type") if isinstance(part, dict) else None)
|
|
133
|
+
)
|
|
134
|
+
== "tool_use"
|
|
135
|
+
]
|
|
136
|
+
if not tool_use_blocks:
|
|
137
|
+
sanitized.append(message)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
future_results = tool_results_after[idx] if tool_results_after else set()
|
|
141
|
+
|
|
142
|
+
# Identify unpaired tool_use IDs
|
|
143
|
+
unpaired_ids: set[str] = set()
|
|
144
|
+
for block in tool_use_blocks:
|
|
145
|
+
block_id = (
|
|
146
|
+
getattr(block, "tool_use_id", None)
|
|
147
|
+
or getattr(block, "id", None)
|
|
148
|
+
or (block.get("tool_use_id") if isinstance(block, dict) else None)
|
|
149
|
+
or (block.get("id") if isinstance(block, dict) else None)
|
|
150
|
+
)
|
|
151
|
+
if block_id and str(block_id) not in future_results:
|
|
152
|
+
unpaired_ids.add(str(block_id))
|
|
153
|
+
|
|
154
|
+
if not unpaired_ids:
|
|
155
|
+
sanitized.append(message)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
# Drop unpaired tool_use blocks
|
|
159
|
+
filtered_content = []
|
|
160
|
+
for part in content:
|
|
161
|
+
part_type = getattr(part, "type", None) or (
|
|
162
|
+
part.get("type") if isinstance(part, dict) else None
|
|
163
|
+
)
|
|
164
|
+
if part_type == "tool_use":
|
|
165
|
+
block_id = (
|
|
166
|
+
getattr(part, "tool_use_id", None)
|
|
167
|
+
or getattr(part, "id", None)
|
|
168
|
+
or (part.get("tool_use_id") if isinstance(part, dict) else None)
|
|
169
|
+
or (part.get("id") if isinstance(part, dict) else None)
|
|
170
|
+
)
|
|
171
|
+
if block_id and str(block_id) in unpaired_ids:
|
|
172
|
+
continue
|
|
173
|
+
filtered_content.append(part)
|
|
174
|
+
|
|
175
|
+
if not filtered_content:
|
|
176
|
+
logger.debug(
|
|
177
|
+
"[provider_clients] Dropped assistant message with unpaired tool_use blocks",
|
|
178
|
+
extra={"unpaired_ids": list(unpaired_ids)},
|
|
179
|
+
)
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
sanitized.append({**message, "content": filtered_content})
|
|
183
|
+
logger.debug(
|
|
184
|
+
"[provider_clients] Sanitized message to remove unpaired tool_use blocks",
|
|
185
|
+
extra={"unpaired_ids": list(unpaired_ids)},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return sanitized
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _retry_delay_seconds(attempt: int, base_delay: float = 0.5, max_delay: float = 32.0) -> float:
|
|
192
|
+
"""Calculate exponential backoff with jitter."""
|
|
193
|
+
capped_base: float = float(min(base_delay * (2 ** max(0, attempt - 1)), max_delay))
|
|
194
|
+
jitter: float = float(random.random() * 0.25 * capped_base)
|
|
195
|
+
return float(capped_base + jitter)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def iter_with_timeout(
|
|
199
|
+
stream: Iterable[Any] | AsyncIterable[Any], timeout: Optional[float]
|
|
200
|
+
) -> AsyncIterator[Any]:
|
|
201
|
+
"""Yield items from an async or sync iterable, enforcing per-item timeout if provided."""
|
|
202
|
+
if timeout is None or timeout <= 0:
|
|
203
|
+
if hasattr(stream, "__aiter__"):
|
|
204
|
+
async for item in stream: # type: ignore[async-for]
|
|
205
|
+
yield item
|
|
206
|
+
else:
|
|
207
|
+
for item in stream:
|
|
208
|
+
yield item
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
if hasattr(stream, "__aiter__"):
|
|
212
|
+
aiter = stream.__aiter__() # type: ignore[attr-defined]
|
|
213
|
+
while True:
|
|
214
|
+
try:
|
|
215
|
+
yield await asyncio.wait_for(aiter.__anext__(), timeout=timeout) # type: ignore[attr-defined]
|
|
216
|
+
except StopAsyncIteration:
|
|
217
|
+
break
|
|
218
|
+
else:
|
|
219
|
+
iterator = iter(stream)
|
|
220
|
+
while True:
|
|
221
|
+
try:
|
|
222
|
+
next_item = await asyncio.wait_for(
|
|
223
|
+
asyncio.to_thread(next, iterator), timeout=timeout
|
|
224
|
+
)
|
|
225
|
+
except StopIteration:
|
|
226
|
+
break
|
|
227
|
+
yield next_item
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def call_with_timeout_and_retries(
|
|
231
|
+
coro_factory: Callable[[], Awaitable[Any]],
|
|
232
|
+
request_timeout: Optional[float],
|
|
233
|
+
max_retries: int,
|
|
234
|
+
) -> Any:
|
|
235
|
+
"""Run a coroutine with timeout and limited retries (exponential backoff)."""
|
|
236
|
+
attempts = max(0, int(max_retries)) + 1
|
|
237
|
+
last_error: Optional[Exception] = None
|
|
238
|
+
|
|
239
|
+
for attempt in range(1, attempts + 1):
|
|
240
|
+
try:
|
|
241
|
+
if request_timeout and request_timeout > 0:
|
|
242
|
+
return await asyncio.wait_for(coro_factory(), timeout=request_timeout)
|
|
243
|
+
return await coro_factory()
|
|
244
|
+
except asyncio.TimeoutError as exc:
|
|
245
|
+
last_error = exc
|
|
246
|
+
if attempt == attempts:
|
|
247
|
+
break
|
|
248
|
+
delay_seconds = _retry_delay_seconds(attempt)
|
|
249
|
+
logger.warning(
|
|
250
|
+
"[provider_clients] Request timed out; retrying",
|
|
251
|
+
extra={
|
|
252
|
+
"attempt": attempt,
|
|
253
|
+
"max_retries": attempts - 1,
|
|
254
|
+
"delay_seconds": round(delay_seconds, 3),
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
await asyncio.sleep(delay_seconds)
|
|
258
|
+
except asyncio.CancelledError:
|
|
259
|
+
raise # Don't suppress task cancellation
|
|
260
|
+
except (RuntimeError, ValueError, TypeError, OSError, ConnectionError) as exc:
|
|
261
|
+
# Non-timeout errors are not retried; surface immediately.
|
|
262
|
+
raise exc
|
|
263
|
+
if last_error:
|
|
264
|
+
raise RuntimeError(f"Request timed out after {attempts} attempts") from last_error
|
|
265
|
+
raise RuntimeError("Unexpected error executing request with retries")
|