ripperdoc 0.2.2__py3-none-any.whl → 0.2.4__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 (61) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +9 -2
  3. ripperdoc/cli/commands/agents_cmd.py +8 -4
  4. ripperdoc/cli/commands/context_cmd.py +3 -3
  5. ripperdoc/cli/commands/cost_cmd.py +5 -0
  6. ripperdoc/cli/commands/doctor_cmd.py +12 -4
  7. ripperdoc/cli/commands/memory_cmd.py +6 -13
  8. ripperdoc/cli/commands/models_cmd.py +36 -6
  9. ripperdoc/cli/commands/resume_cmd.py +4 -2
  10. ripperdoc/cli/commands/status_cmd.py +1 -1
  11. ripperdoc/cli/ui/rich_ui.py +135 -2
  12. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  13. ripperdoc/core/agents.py +174 -6
  14. ripperdoc/core/config.py +9 -1
  15. ripperdoc/core/default_tools.py +6 -0
  16. ripperdoc/core/providers/__init__.py +47 -0
  17. ripperdoc/core/providers/anthropic.py +147 -0
  18. ripperdoc/core/providers/base.py +236 -0
  19. ripperdoc/core/providers/gemini.py +496 -0
  20. ripperdoc/core/providers/openai.py +253 -0
  21. ripperdoc/core/query.py +337 -141
  22. ripperdoc/core/query_utils.py +65 -24
  23. ripperdoc/core/system_prompt.py +67 -61
  24. ripperdoc/core/tool.py +12 -3
  25. ripperdoc/sdk/client.py +12 -1
  26. ripperdoc/tools/ask_user_question_tool.py +433 -0
  27. ripperdoc/tools/background_shell.py +104 -18
  28. ripperdoc/tools/bash_tool.py +33 -13
  29. ripperdoc/tools/enter_plan_mode_tool.py +223 -0
  30. ripperdoc/tools/exit_plan_mode_tool.py +150 -0
  31. ripperdoc/tools/file_edit_tool.py +13 -0
  32. ripperdoc/tools/file_read_tool.py +16 -0
  33. ripperdoc/tools/file_write_tool.py +13 -0
  34. ripperdoc/tools/glob_tool.py +5 -1
  35. ripperdoc/tools/ls_tool.py +14 -10
  36. ripperdoc/tools/mcp_tools.py +113 -4
  37. ripperdoc/tools/multi_edit_tool.py +12 -0
  38. ripperdoc/tools/notebook_edit_tool.py +12 -0
  39. ripperdoc/tools/task_tool.py +88 -5
  40. ripperdoc/tools/todo_tool.py +1 -3
  41. ripperdoc/tools/tool_search_tool.py +8 -4
  42. ripperdoc/utils/file_watch.py +134 -0
  43. ripperdoc/utils/git_utils.py +36 -38
  44. ripperdoc/utils/json_utils.py +1 -2
  45. ripperdoc/utils/log.py +3 -4
  46. ripperdoc/utils/mcp.py +49 -10
  47. ripperdoc/utils/memory.py +1 -3
  48. ripperdoc/utils/message_compaction.py +5 -11
  49. ripperdoc/utils/messages.py +9 -13
  50. ripperdoc/utils/output_utils.py +1 -3
  51. ripperdoc/utils/prompt.py +17 -0
  52. ripperdoc/utils/session_usage.py +7 -0
  53. ripperdoc/utils/shell_utils.py +159 -0
  54. ripperdoc/utils/token_estimation.py +33 -0
  55. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/METADATA +3 -1
  56. ripperdoc-0.2.4.dist-info/RECORD +99 -0
  57. ripperdoc-0.2.2.dist-info/RECORD +0 -86
  58. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/WHEEL +0 -0
  59. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/entry_points.txt +0 -0
  60. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/licenses/LICENSE +0 -0
  61. {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,236 @@
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
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
+
39
+
40
+ class ProviderClient(ABC):
41
+ """Abstract base for model provider clients."""
42
+
43
+ @abstractmethod
44
+ async def call(
45
+ self,
46
+ *,
47
+ model_profile: ModelProfile,
48
+ system_prompt: str,
49
+ normalized_messages: List[Dict[str, Any]],
50
+ tools: List[Tool[Any, Any]],
51
+ tool_mode: str,
52
+ stream: bool,
53
+ progress_callback: Optional[ProgressCallback],
54
+ request_timeout: Optional[float],
55
+ max_retries: int,
56
+ ) -> ProviderResponse:
57
+ """Execute a model call and return a normalized response."""
58
+
59
+
60
+ def sanitize_tool_history(normalized_messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
61
+ """Strip tool_use blocks that lack a following tool_result to satisfy provider constraints."""
62
+
63
+ def _tool_result_ids(msg: Dict[str, Any]) -> set[str]:
64
+ ids: set[str] = set()
65
+ content = msg.get("content")
66
+ if isinstance(content, list):
67
+ for part in content:
68
+ part_type = getattr(
69
+ part, "get", lambda k, default=None: part.__dict__.get(k, default)
70
+ )("type", None)
71
+ if part_type == "tool_result":
72
+ tid = (
73
+ getattr(part, "tool_use_id", None)
74
+ or getattr(part, "id", None)
75
+ or part.get("tool_use_id")
76
+ or part.get("id")
77
+ )
78
+ if tid:
79
+ ids.add(str(tid))
80
+ return ids
81
+
82
+ # Build a lookahead map so we can pair tool_use blocks with tool_results that may
83
+ # appear in any later message (not just the immediate next one).
84
+ tool_results_after: List[set[str]] = []
85
+ if normalized_messages:
86
+ tool_results_after = [set() for _ in normalized_messages]
87
+ future_ids: set[str] = set()
88
+ for idx in range(len(normalized_messages) - 1, -1, -1):
89
+ tool_results_after[idx] = set(future_ids)
90
+ future_ids.update(_tool_result_ids(normalized_messages[idx]))
91
+
92
+ sanitized: List[Dict[str, Any]] = []
93
+ for idx, message in enumerate(normalized_messages):
94
+ if message.get("role") != "assistant":
95
+ sanitized.append(message)
96
+ continue
97
+
98
+ content = message.get("content")
99
+ if not isinstance(content, list):
100
+ sanitized.append(message)
101
+ continue
102
+
103
+ tool_use_blocks = [
104
+ part
105
+ for part in content
106
+ if (
107
+ getattr(part, "type", None)
108
+ or (part.get("type") if isinstance(part, dict) else None)
109
+ )
110
+ == "tool_use"
111
+ ]
112
+ if not tool_use_blocks:
113
+ sanitized.append(message)
114
+ continue
115
+
116
+ future_results = tool_results_after[idx] if tool_results_after else set()
117
+
118
+ # Identify unpaired tool_use IDs
119
+ unpaired_ids: set[str] = set()
120
+ for block in tool_use_blocks:
121
+ block_id = (
122
+ getattr(block, "tool_use_id", None)
123
+ or getattr(block, "id", None)
124
+ or (block.get("tool_use_id") if isinstance(block, dict) else None)
125
+ or (block.get("id") if isinstance(block, dict) else None)
126
+ )
127
+ if block_id and str(block_id) not in future_results:
128
+ unpaired_ids.add(str(block_id))
129
+
130
+ if not unpaired_ids:
131
+ sanitized.append(message)
132
+ continue
133
+
134
+ # Drop unpaired tool_use blocks
135
+ filtered_content = []
136
+ for part in content:
137
+ part_type = getattr(part, "type", None) or (
138
+ part.get("type") if isinstance(part, dict) else None
139
+ )
140
+ if part_type == "tool_use":
141
+ block_id = (
142
+ getattr(part, "tool_use_id", None)
143
+ or getattr(part, "id", None)
144
+ or (part.get("tool_use_id") if isinstance(part, dict) else None)
145
+ or (part.get("id") if isinstance(part, dict) else None)
146
+ )
147
+ if block_id and str(block_id) in unpaired_ids:
148
+ continue
149
+ filtered_content.append(part)
150
+
151
+ if not filtered_content:
152
+ logger.debug(
153
+ "[provider_clients] Dropped assistant message with unpaired tool_use blocks",
154
+ extra={"unpaired_ids": list(unpaired_ids)},
155
+ )
156
+ continue
157
+
158
+ sanitized.append({**message, "content": filtered_content})
159
+ logger.debug(
160
+ "[provider_clients] Sanitized message to remove unpaired tool_use blocks",
161
+ extra={"unpaired_ids": list(unpaired_ids)},
162
+ )
163
+
164
+ return sanitized
165
+
166
+
167
+ def _retry_delay_seconds(attempt: int, base_delay: float = 0.5, max_delay: float = 32.0) -> float:
168
+ """Calculate exponential backoff with jitter."""
169
+ capped_base: float = float(min(base_delay * (2 ** max(0, attempt - 1)), max_delay))
170
+ jitter: float = float(random.random() * 0.25 * capped_base)
171
+ return float(capped_base + jitter)
172
+
173
+ async def iter_with_timeout(
174
+ stream: Iterable[Any] | AsyncIterable[Any], timeout: Optional[float]
175
+ ) -> AsyncIterator[Any]:
176
+ """Yield items from an async or sync iterable, enforcing per-item timeout if provided."""
177
+ if timeout is None or timeout <= 0:
178
+ if hasattr(stream, "__aiter__"):
179
+ async for item in stream: # type: ignore[async-for]
180
+ yield item
181
+ else:
182
+ for item in stream:
183
+ yield item
184
+ return
185
+
186
+ if hasattr(stream, "__aiter__"):
187
+ aiter = stream.__aiter__() # type: ignore[attr-defined]
188
+ while True:
189
+ try:
190
+ yield await asyncio.wait_for(aiter.__anext__(), timeout=timeout) # type: ignore[attr-defined]
191
+ except StopAsyncIteration:
192
+ break
193
+ else:
194
+ iterator = iter(stream)
195
+ while True:
196
+ try:
197
+ next_item = await asyncio.wait_for(asyncio.to_thread(next, iterator), timeout=timeout)
198
+ except StopIteration:
199
+ break
200
+ yield next_item
201
+
202
+
203
+ async def call_with_timeout_and_retries(
204
+ coro_factory: Callable[[], Awaitable[Any]],
205
+ request_timeout: Optional[float],
206
+ max_retries: int,
207
+ ) -> Any:
208
+ """Run a coroutine with timeout and limited retries (exponential backoff)."""
209
+ attempts = max(0, int(max_retries)) + 1
210
+ last_error: Optional[Exception] = None
211
+
212
+ for attempt in range(1, attempts + 1):
213
+ try:
214
+ if request_timeout and request_timeout > 0:
215
+ return await asyncio.wait_for(coro_factory(), timeout=request_timeout)
216
+ return await coro_factory()
217
+ except asyncio.TimeoutError as exc:
218
+ last_error = exc
219
+ if attempt == attempts:
220
+ break
221
+ delay_seconds = _retry_delay_seconds(attempt)
222
+ logger.warning(
223
+ "[provider_clients] Request timed out; retrying",
224
+ extra={
225
+ "attempt": attempt,
226
+ "max_retries": attempts - 1,
227
+ "delay_seconds": round(delay_seconds, 3),
228
+ },
229
+ )
230
+ await asyncio.sleep(delay_seconds)
231
+ except Exception:
232
+ # Non-timeout errors are not retried; surface immediately.
233
+ raise
234
+ if last_error:
235
+ raise RuntimeError(f"Request timed out after {attempts} attempts") from last_error
236
+ raise RuntimeError("Unexpected error executing request with retries")