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