ripperdoc 0.2.3__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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/commands/context_cmd.py +3 -3
- ripperdoc/cli/ui/rich_ui.py +35 -2
- ripperdoc/core/agents.py +160 -0
- ripperdoc/core/default_tools.py +6 -0
- ripperdoc/core/providers/__init__.py +31 -15
- ripperdoc/core/providers/anthropic.py +15 -4
- ripperdoc/core/providers/base.py +63 -14
- ripperdoc/core/providers/gemini.py +415 -91
- ripperdoc/core/providers/openai.py +125 -14
- ripperdoc/core/query.py +7 -1
- ripperdoc/core/query_utils.py +1 -1
- ripperdoc/core/system_prompt.py +67 -61
- ripperdoc/core/tool.py +7 -0
- ripperdoc/tools/ask_user_question_tool.py +433 -0
- ripperdoc/tools/background_shell.py +70 -20
- ripperdoc/tools/enter_plan_mode_tool.py +223 -0
- ripperdoc/tools/exit_plan_mode_tool.py +150 -0
- ripperdoc/tools/mcp_tools.py +113 -4
- ripperdoc/tools/task_tool.py +88 -5
- ripperdoc/utils/mcp.py +49 -10
- ripperdoc/utils/message_compaction.py +3 -5
- ripperdoc/utils/token_estimation.py +33 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.4.dist-info}/METADATA +3 -1
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.4.dist-info}/RECORD +29 -25
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.4.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.4.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.4.dist-info}/top_level.txt +0 -0
ripperdoc/__init__.py
CHANGED
|
@@ -9,11 +9,11 @@ from ripperdoc.core.query import QueryContext
|
|
|
9
9
|
from ripperdoc.core.system_prompt import build_system_prompt
|
|
10
10
|
from ripperdoc.utils.memory import build_memory_instructions
|
|
11
11
|
from ripperdoc.utils.message_compaction import (
|
|
12
|
-
estimate_tokens_from_text,
|
|
13
12
|
get_remaining_context_tokens,
|
|
14
13
|
resolve_auto_compact_enabled,
|
|
15
14
|
summarize_context_usage,
|
|
16
15
|
)
|
|
16
|
+
from ripperdoc.utils.token_estimation import estimate_tokens
|
|
17
17
|
from ripperdoc.utils.mcp import (
|
|
18
18
|
estimate_mcp_tokens,
|
|
19
19
|
format_mcp_instructions,
|
|
@@ -60,7 +60,7 @@ def _handle(ui: Any, _: str) -> bool:
|
|
|
60
60
|
mcp_instructions=mcp_instructions,
|
|
61
61
|
)
|
|
62
62
|
memory_instructions = build_memory_instructions()
|
|
63
|
-
memory_tokens =
|
|
63
|
+
memory_tokens = estimate_tokens(memory_instructions) if memory_instructions else 0
|
|
64
64
|
mcp_tokens = estimate_mcp_tokens(servers) if mcp_instructions else 0
|
|
65
65
|
|
|
66
66
|
breakdown = summarize_context_usage(
|
|
@@ -98,7 +98,7 @@ def _handle(ui: Any, _: str) -> bool:
|
|
|
98
98
|
display = f"{display} ({server})"
|
|
99
99
|
try:
|
|
100
100
|
schema = tool.input_schema.model_json_schema()
|
|
101
|
-
token_est =
|
|
101
|
+
token_est = estimate_tokens(json.dumps(schema, sort_keys=True))
|
|
102
102
|
except Exception:
|
|
103
103
|
token_est = 0
|
|
104
104
|
lines.append(f" └ {display}: {format_tokens(token_est)} tokens")
|
ripperdoc/cli/ui/rich_ui.py
CHANGED
|
@@ -39,12 +39,12 @@ from ripperdoc.cli.ui.context_display import context_usage_lines
|
|
|
39
39
|
from ripperdoc.utils.message_compaction import (
|
|
40
40
|
compact_messages,
|
|
41
41
|
estimate_conversation_tokens,
|
|
42
|
-
estimate_tokens_from_text,
|
|
43
42
|
estimate_used_tokens,
|
|
44
43
|
get_context_usage_status,
|
|
45
44
|
get_remaining_context_tokens,
|
|
46
45
|
resolve_auto_compact_enabled,
|
|
47
46
|
)
|
|
47
|
+
from ripperdoc.utils.token_estimation import estimate_tokens
|
|
48
48
|
from ripperdoc.utils.mcp import (
|
|
49
49
|
format_mcp_instructions,
|
|
50
50
|
load_mcp_servers_async,
|
|
@@ -448,6 +448,13 @@ class RichUI:
|
|
|
448
448
|
else:
|
|
449
449
|
success = getattr(tool_data, "success", None)
|
|
450
450
|
failed = failed or (success is False)
|
|
451
|
+
failed = failed or bool(self._get_tool_field(tool_data, "is_error"))
|
|
452
|
+
|
|
453
|
+
warning_text = None
|
|
454
|
+
token_estimate = None
|
|
455
|
+
if tool_data is not None:
|
|
456
|
+
warning_text = self._get_tool_field(tool_data, "warning")
|
|
457
|
+
token_estimate = self._get_tool_field(tool_data, "token_estimate")
|
|
451
458
|
|
|
452
459
|
if failed:
|
|
453
460
|
if content:
|
|
@@ -456,6 +463,17 @@ class RichUI:
|
|
|
456
463
|
self.console.print(f" ⎿ [red]{escape(sender)} failed[/red]")
|
|
457
464
|
return
|
|
458
465
|
|
|
466
|
+
if warning_text:
|
|
467
|
+
self.console.print(f" ⎿ [yellow]{escape(str(warning_text))}[/yellow]")
|
|
468
|
+
if token_estimate:
|
|
469
|
+
self.console.print(
|
|
470
|
+
f" [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]"
|
|
471
|
+
)
|
|
472
|
+
elif token_estimate and self.verbose:
|
|
473
|
+
self.console.print(
|
|
474
|
+
f" ⎿ [dim]Estimated tokens: {escape(str(token_estimate))}[/dim]"
|
|
475
|
+
)
|
|
476
|
+
|
|
459
477
|
if not content:
|
|
460
478
|
self.console.print(" ⎿ [dim]Tool completed[/]")
|
|
461
479
|
return
|
|
@@ -832,6 +850,21 @@ class RichUI:
|
|
|
832
850
|
|
|
833
851
|
prompt_tokens_est = estimate_conversation_tokens(messages, protocol=protocol)
|
|
834
852
|
spinner = ThinkingSpinner(console, prompt_tokens_est)
|
|
853
|
+
|
|
854
|
+
# Define pause/resume callbacks for tools that need user interaction
|
|
855
|
+
def pause_ui() -> None:
|
|
856
|
+
if spinner:
|
|
857
|
+
spinner.stop()
|
|
858
|
+
|
|
859
|
+
def resume_ui() -> None:
|
|
860
|
+
if spinner:
|
|
861
|
+
spinner.start()
|
|
862
|
+
spinner.update("Thinking...")
|
|
863
|
+
|
|
864
|
+
# Set the UI callbacks on the query context
|
|
865
|
+
self.query_context.pause_ui = pause_ui
|
|
866
|
+
self.query_context.resume_ui = resume_ui
|
|
867
|
+
|
|
835
868
|
# Wrap permission checker to pause the spinner while waiting for user input.
|
|
836
869
|
base_permission_checker = self._permission_checker
|
|
837
870
|
|
|
@@ -947,7 +980,7 @@ class RichUI:
|
|
|
947
980
|
elif message.content.startswith("Subagent"):
|
|
948
981
|
self.display_message("Subagent", message.content, is_tool=True)
|
|
949
982
|
if message.tool_use_id == "stream":
|
|
950
|
-
delta_tokens =
|
|
983
|
+
delta_tokens = estimate_tokens(message.content)
|
|
951
984
|
output_token_est += delta_tokens
|
|
952
985
|
spinner.update_tokens(output_token_est)
|
|
953
986
|
else:
|
ripperdoc/core/agents.py
CHANGED
|
@@ -11,11 +11,63 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
|
11
11
|
import yaml
|
|
12
12
|
|
|
13
13
|
from ripperdoc.utils.log import get_logger
|
|
14
|
+
from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
|
|
15
|
+
from ripperdoc.tools.bash_output_tool import BashOutputTool
|
|
16
|
+
from ripperdoc.tools.bash_tool import BashTool
|
|
17
|
+
from ripperdoc.tools.enter_plan_mode_tool import EnterPlanModeTool
|
|
18
|
+
from ripperdoc.tools.exit_plan_mode_tool import ExitPlanModeTool
|
|
19
|
+
from ripperdoc.tools.file_edit_tool import FileEditTool
|
|
20
|
+
from ripperdoc.tools.file_read_tool import FileReadTool
|
|
21
|
+
from ripperdoc.tools.file_write_tool import FileWriteTool
|
|
22
|
+
from ripperdoc.tools.glob_tool import GlobTool
|
|
23
|
+
from ripperdoc.tools.grep_tool import GrepTool
|
|
24
|
+
from ripperdoc.tools.kill_bash_tool import KillBashTool
|
|
25
|
+
from ripperdoc.tools.ls_tool import LSTool
|
|
26
|
+
from ripperdoc.tools.multi_edit_tool import MultiEditTool
|
|
27
|
+
from ripperdoc.tools.notebook_edit_tool import NotebookEditTool
|
|
28
|
+
from ripperdoc.tools.todo_tool import TodoReadTool, TodoWriteTool
|
|
29
|
+
from ripperdoc.tools.tool_search_tool import ToolSearchTool
|
|
30
|
+
from ripperdoc.tools.mcp_tools import (
|
|
31
|
+
ListMcpResourcesTool,
|
|
32
|
+
ListMcpServersTool,
|
|
33
|
+
ReadMcpResourceTool,
|
|
34
|
+
)
|
|
14
35
|
|
|
15
36
|
|
|
16
37
|
logger = get_logger()
|
|
17
38
|
|
|
18
39
|
|
|
40
|
+
def _safe_tool_name(factory: Any, fallback: str) -> str:
|
|
41
|
+
try:
|
|
42
|
+
name = getattr(factory(), "name", None)
|
|
43
|
+
return str(name) if name else fallback
|
|
44
|
+
except Exception:
|
|
45
|
+
return fallback
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
GLOB_TOOL_NAME = _safe_tool_name(GlobTool, "Glob")
|
|
49
|
+
GREP_TOOL_NAME = _safe_tool_name(GrepTool, "Grep")
|
|
50
|
+
VIEW_TOOL_NAME = _safe_tool_name(FileReadTool, "View")
|
|
51
|
+
FILE_EDIT_TOOL_NAME = _safe_tool_name(FileEditTool, "FileEdit")
|
|
52
|
+
MULTI_EDIT_TOOL_NAME = _safe_tool_name(MultiEditTool, "MultiEdit")
|
|
53
|
+
NOTEBOOK_EDIT_TOOL_NAME = _safe_tool_name(NotebookEditTool, "NotebookEdit")
|
|
54
|
+
FILE_WRITE_TOOL_NAME = _safe_tool_name(FileWriteTool, "FileWrite")
|
|
55
|
+
LS_TOOL_NAME = _safe_tool_name(LSTool, "LS")
|
|
56
|
+
BASH_TOOL_NAME = _safe_tool_name(BashTool, "Bash")
|
|
57
|
+
BASH_OUTPUT_TOOL_NAME = _safe_tool_name(BashOutputTool, "BashOutput")
|
|
58
|
+
KILL_BASH_TOOL_NAME = _safe_tool_name(KillBashTool, "KillBash")
|
|
59
|
+
TODO_READ_TOOL_NAME = _safe_tool_name(TodoReadTool, "TodoRead")
|
|
60
|
+
TODO_WRITE_TOOL_NAME = _safe_tool_name(TodoWriteTool, "TodoWrite")
|
|
61
|
+
ASK_USER_QUESTION_TOOL_NAME = _safe_tool_name(AskUserQuestionTool, "AskUserQuestion")
|
|
62
|
+
ENTER_PLAN_MODE_TOOL_NAME = _safe_tool_name(EnterPlanModeTool, "EnterPlanMode")
|
|
63
|
+
EXIT_PLAN_MODE_TOOL_NAME = _safe_tool_name(ExitPlanModeTool, "ExitPlanMode")
|
|
64
|
+
TOOL_SEARCH_TOOL_NAME = _safe_tool_name(ToolSearchTool, "ToolSearch")
|
|
65
|
+
MCP_LIST_SERVERS_TOOL_NAME = _safe_tool_name(ListMcpServersTool, "ListMcpServers")
|
|
66
|
+
MCP_LIST_RESOURCES_TOOL_NAME = _safe_tool_name(ListMcpResourcesTool, "ListMcpResources")
|
|
67
|
+
MCP_READ_RESOURCE_TOOL_NAME = _safe_tool_name(ReadMcpResourceTool, "ReadMcpResource")
|
|
68
|
+
TASK_TOOL_NAME = "Task"
|
|
69
|
+
|
|
70
|
+
|
|
19
71
|
AGENT_DIR_NAME = "agents"
|
|
20
72
|
|
|
21
73
|
|
|
@@ -64,6 +116,86 @@ CODE_REVIEW_AGENT_PROMPT = (
|
|
|
64
116
|
"Provide clear, actionable feedback that the parent agent can relay to the user."
|
|
65
117
|
)
|
|
66
118
|
|
|
119
|
+
EXPLORE_AGENT_PROMPT = (
|
|
120
|
+
"You are a file search specialist. "
|
|
121
|
+
"You excel at thoroughly navigating and exploring codebases.\n\n"
|
|
122
|
+
"=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===\n"
|
|
123
|
+
"This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:\n"
|
|
124
|
+
"- Creating new files (no Write, touch, or file creation of any kind)\n"
|
|
125
|
+
"- Modifying existing files (no Edit operations)\n"
|
|
126
|
+
"- Deleting files (no rm or deletion)\n"
|
|
127
|
+
"- Moving or copying files (no mv or cp)\n"
|
|
128
|
+
"- Creating temporary files anywhere, including /tmp\n"
|
|
129
|
+
"- Using redirect operators (>, >>, |) or heredocs to write to files\n"
|
|
130
|
+
"- Running ANY commands that change system state\n\n"
|
|
131
|
+
"Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access "
|
|
132
|
+
"to file editing tools - attempting to edit files will fail.\n\n"
|
|
133
|
+
"Your strengths:\n"
|
|
134
|
+
"- Rapidly finding files using glob patterns\n"
|
|
135
|
+
"- Searching code and text with powerful regex patterns\n"
|
|
136
|
+
"- Reading and analyzing file contents\n\n"
|
|
137
|
+
"Guidelines:\n"
|
|
138
|
+
f"- Use {GLOB_TOOL_NAME} for broad file pattern matching\n"
|
|
139
|
+
f"- Use {GREP_TOOL_NAME} for searching file contents with regex\n"
|
|
140
|
+
f"- Use {VIEW_TOOL_NAME} when you know the specific file path you need to read\n"
|
|
141
|
+
f"- Use {BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)\n"
|
|
142
|
+
f"- NEVER use {BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification\n"
|
|
143
|
+
"- Adapt your search approach based on the thoroughness level specified by the caller\n"
|
|
144
|
+
"- Return file paths as absolute paths in your final response\n"
|
|
145
|
+
"- For clear communication, avoid using emojis\n"
|
|
146
|
+
"- Communicate your final report directly as a regular message - do NOT attempt to create files\n\n"
|
|
147
|
+
"NOTE: You are meant to be a fast agent that returns output as quickly as possible. In order to achieve this you must:\n"
|
|
148
|
+
"- Make efficient use of the tools that you have at your disposal: be smart about how you search for files and implementations\n"
|
|
149
|
+
"- Wherever possible you should try to spawn multiple parallel tool calls for grepping and reading files\n\n"
|
|
150
|
+
"Complete the user's search request efficiently and report your findings clearly."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
PLAN_AGENT_PROMPT = (
|
|
154
|
+
"You are a software architect and planning specialist. Your role is "
|
|
155
|
+
"to explore the codebase and design implementation plans.\n\n"
|
|
156
|
+
"=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===\n"
|
|
157
|
+
"This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:\n"
|
|
158
|
+
"- Creating new files (no Write, touch, or file creation of any kind)\n"
|
|
159
|
+
"- Modifying existing files (no Edit operations)\n"
|
|
160
|
+
"- Deleting files (no rm or deletion)\n"
|
|
161
|
+
"- Moving or copying files (no mv or cp)\n"
|
|
162
|
+
"- Creating temporary files anywhere, including /tmp\n"
|
|
163
|
+
"- Using redirect operators (>, >>, |) or heredocs to write to files\n"
|
|
164
|
+
"- Running ANY commands that change system state\n\n"
|
|
165
|
+
"Your role is EXCLUSIVELY to explore the codebase and design implementation plans. "
|
|
166
|
+
"You do NOT have access to file editing tools - attempting to edit files will fail.\n\n"
|
|
167
|
+
"You will be provided with a set of requirements and optionally a perspective on how "
|
|
168
|
+
"to approach the design process.\n\n"
|
|
169
|
+
"## Your Process\n\n"
|
|
170
|
+
"1. **Understand Requirements**: Focus on the requirements provided and apply your "
|
|
171
|
+
"assigned perspective throughout the design process.\n\n"
|
|
172
|
+
"2. **Explore Thoroughly**:\n"
|
|
173
|
+
" - Read any files provided to you in the initial prompt\n"
|
|
174
|
+
f" - Find existing patterns and conventions using {GLOB_TOOL_NAME}, {GREP_TOOL_NAME}, and {VIEW_TOOL_NAME}\n"
|
|
175
|
+
" - Understand the current architecture\n"
|
|
176
|
+
" - Identify similar features as reference\n"
|
|
177
|
+
" - Trace through relevant code paths\n"
|
|
178
|
+
f" - Use {BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)\n"
|
|
179
|
+
f" - NEVER use {BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification\n\n"
|
|
180
|
+
"3. **Design Solution**:\n"
|
|
181
|
+
" - Create implementation approach based on your assigned perspective\n"
|
|
182
|
+
" - Consider trade-offs and architectural decisions\n"
|
|
183
|
+
" - Follow existing patterns where appropriate\n\n"
|
|
184
|
+
"4. **Detail the Plan**:\n"
|
|
185
|
+
" - Provide step-by-step implementation strategy\n"
|
|
186
|
+
" - Identify dependencies and sequencing\n"
|
|
187
|
+
" - Anticipate potential challenges\n\n"
|
|
188
|
+
"## Required Output\n\n"
|
|
189
|
+
"End your response with:\n\n"
|
|
190
|
+
"### Critical Files for Implementation\n"
|
|
191
|
+
"List 3-5 files most critical for implementing this plan:\n"
|
|
192
|
+
"- path/to/file1.ts - [Brief reason: e.g., \"Core logic to modify\"]\n"
|
|
193
|
+
"- path/to/file2.ts - [Brief reason: e.g., \"Interfaces to implement\"]\n"
|
|
194
|
+
"- path/to/file3.ts - [Brief reason: e.g., \"Pattern to follow\"]\n\n"
|
|
195
|
+
"REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or "
|
|
196
|
+
"modify any files. You do NOT have access to file editing tools."
|
|
197
|
+
)
|
|
198
|
+
|
|
67
199
|
|
|
68
200
|
def _built_in_agents() -> List[AgentDefinition]:
|
|
69
201
|
return [
|
|
@@ -89,6 +221,34 @@ def _built_in_agents() -> List[AgentDefinition]:
|
|
|
89
221
|
location=AgentLocation.BUILT_IN,
|
|
90
222
|
color="yellow",
|
|
91
223
|
),
|
|
224
|
+
AgentDefinition(
|
|
225
|
+
agent_type="explore",
|
|
226
|
+
when_to_use=(
|
|
227
|
+
'Fast agent specialized for exploring codebases. Use this when you need to quickly find '
|
|
228
|
+
'files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), '
|
|
229
|
+
'or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, '
|
|
230
|
+
'specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, '
|
|
231
|
+
'or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
|
|
232
|
+
),
|
|
233
|
+
tools=["View", "Glob", "Grep"],
|
|
234
|
+
system_prompt=EXPLORE_AGENT_PROMPT,
|
|
235
|
+
location=AgentLocation.BUILT_IN,
|
|
236
|
+
color="green",
|
|
237
|
+
model="task",
|
|
238
|
+
),
|
|
239
|
+
AgentDefinition(
|
|
240
|
+
agent_type="plan",
|
|
241
|
+
when_to_use=(
|
|
242
|
+
"Software architect agent for designing implementation plans. Use this when "
|
|
243
|
+
"you need to plan the implementation strategy for a task. Returns step-by-step "
|
|
244
|
+
"plans, identifies critical files, and considers architectural trade-offs."
|
|
245
|
+
),
|
|
246
|
+
tools=["View", "Glob", "Grep"],
|
|
247
|
+
system_prompt=PLAN_AGENT_PROMPT,
|
|
248
|
+
location=AgentLocation.BUILT_IN,
|
|
249
|
+
color="blue",
|
|
250
|
+
model=None,
|
|
251
|
+
),
|
|
92
252
|
]
|
|
93
253
|
|
|
94
254
|
|
ripperdoc/core/default_tools.py
CHANGED
|
@@ -18,6 +18,9 @@ from ripperdoc.tools.glob_tool import GlobTool
|
|
|
18
18
|
from ripperdoc.tools.ls_tool import LSTool
|
|
19
19
|
from ripperdoc.tools.grep_tool import GrepTool
|
|
20
20
|
from ripperdoc.tools.todo_tool import TodoReadTool, TodoWriteTool
|
|
21
|
+
from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
|
|
22
|
+
from ripperdoc.tools.enter_plan_mode_tool import EnterPlanModeTool
|
|
23
|
+
from ripperdoc.tools.exit_plan_mode_tool import ExitPlanModeTool
|
|
21
24
|
from ripperdoc.tools.task_tool import TaskTool
|
|
22
25
|
from ripperdoc.tools.tool_search_tool import ToolSearchTool
|
|
23
26
|
from ripperdoc.tools.mcp_tools import (
|
|
@@ -47,6 +50,9 @@ def get_default_tools() -> List[Tool[Any, Any]]:
|
|
|
47
50
|
GrepTool(),
|
|
48
51
|
TodoReadTool(),
|
|
49
52
|
TodoWriteTool(),
|
|
53
|
+
AskUserQuestionTool(),
|
|
54
|
+
EnterPlanModeTool(),
|
|
55
|
+
ExitPlanModeTool(),
|
|
50
56
|
ToolSearchTool(),
|
|
51
57
|
ListMcpServersTool(),
|
|
52
58
|
ListMcpResourcesTool(),
|
|
@@ -1,31 +1,47 @@
|
|
|
1
|
-
"""Provider client registry."""
|
|
1
|
+
"""Provider client registry with optional dependencies."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Optional, TYPE_CHECKING, Type, cast
|
|
6
7
|
|
|
7
8
|
from ripperdoc.core.config import ProviderType
|
|
8
|
-
from ripperdoc.core.providers.anthropic import AnthropicClient
|
|
9
9
|
from ripperdoc.core.providers.base import ProviderClient
|
|
10
|
-
from ripperdoc.
|
|
11
|
-
|
|
10
|
+
from ripperdoc.utils.log import get_logger
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover - type checking only
|
|
13
|
+
from ripperdoc.core.providers.anthropic import AnthropicClient # noqa: F401
|
|
14
|
+
from ripperdoc.core.providers.gemini import GeminiClient # noqa: F401
|
|
15
|
+
from ripperdoc.core.providers.openai import OpenAIClient # noqa: F401
|
|
16
|
+
|
|
17
|
+
logger = get_logger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_client(module: str, cls: str, extra: str) -> Type[ProviderClient]:
|
|
21
|
+
"""Dynamically import a provider client, pointing users to the right extra."""
|
|
22
|
+
try:
|
|
23
|
+
mod = importlib.import_module(f"ripperdoc.core.providers.{module}")
|
|
24
|
+
client_cls = cast(Type[ProviderClient], getattr(mod, cls, None))
|
|
25
|
+
if client_cls is None:
|
|
26
|
+
raise ImportError(f"{cls} not found in {module}")
|
|
27
|
+
return client_cls
|
|
28
|
+
except ImportError as exc:
|
|
29
|
+
raise RuntimeError(
|
|
30
|
+
f"{cls} requires optional dependency group '{extra}'. "
|
|
31
|
+
f"Install with `pip install ripperdoc[{extra}]`."
|
|
32
|
+
) from exc
|
|
12
33
|
|
|
13
34
|
|
|
14
35
|
def get_provider_client(provider: ProviderType) -> Optional[ProviderClient]:
|
|
15
36
|
"""Return a provider client for the given protocol."""
|
|
16
37
|
if provider == ProviderType.ANTHROPIC:
|
|
17
|
-
return AnthropicClient()
|
|
38
|
+
return _load_client("anthropic", "AnthropicClient", "anthropic")()
|
|
18
39
|
if provider == ProviderType.OPENAI_COMPATIBLE:
|
|
19
|
-
return OpenAIClient()
|
|
40
|
+
return _load_client("openai", "OpenAIClient", "openai")()
|
|
20
41
|
if provider == ProviderType.GEMINI:
|
|
21
|
-
return GeminiClient()
|
|
42
|
+
return _load_client("gemini", "GeminiClient", "gemini")()
|
|
43
|
+
logger.warning("[providers] Unsupported provider", extra={"provider": provider})
|
|
22
44
|
return None
|
|
23
45
|
|
|
24
46
|
|
|
25
|
-
__all__ = [
|
|
26
|
-
"ProviderClient",
|
|
27
|
-
"AnthropicClient",
|
|
28
|
-
"GeminiClient",
|
|
29
|
-
"OpenAIClient",
|
|
30
|
-
"get_provider_client",
|
|
31
|
-
]
|
|
47
|
+
__all__ = ["ProviderClient", "get_provider_client"]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import time
|
|
6
7
|
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
|
7
8
|
|
|
@@ -13,6 +14,7 @@ from ripperdoc.core.providers.base import (
|
|
|
13
14
|
ProviderClient,
|
|
14
15
|
ProviderResponse,
|
|
15
16
|
call_with_timeout_and_retries,
|
|
17
|
+
iter_with_timeout,
|
|
16
18
|
sanitize_tool_history,
|
|
17
19
|
)
|
|
18
20
|
from ripperdoc.core.query_utils import (
|
|
@@ -68,15 +70,21 @@ class AnthropicClient(ProviderClient):
|
|
|
68
70
|
async with await self._client(anthropic_kwargs) as client:
|
|
69
71
|
|
|
70
72
|
async def _stream_request() -> Any:
|
|
71
|
-
|
|
73
|
+
stream_cm = client.messages.stream(
|
|
72
74
|
model=model_profile.model,
|
|
73
75
|
max_tokens=model_profile.max_tokens,
|
|
74
76
|
system=system_prompt,
|
|
75
77
|
messages=normalized_messages, # type: ignore[arg-type]
|
|
76
78
|
tools=tool_schemas if tool_schemas else None, # type: ignore
|
|
77
79
|
temperature=model_profile.temperature,
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
+
)
|
|
81
|
+
stream_resp = (
|
|
82
|
+
await asyncio.wait_for(stream_cm.__aenter__(), timeout=request_timeout)
|
|
83
|
+
if request_timeout and request_timeout > 0
|
|
84
|
+
else await stream_cm.__aenter__()
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
async for text in iter_with_timeout(stream_resp.text_stream, request_timeout):
|
|
80
88
|
if text:
|
|
81
89
|
collected_text.append(text)
|
|
82
90
|
if progress_callback:
|
|
@@ -90,6 +98,8 @@ class AnthropicClient(ProviderClient):
|
|
|
90
98
|
if getter:
|
|
91
99
|
return await getter()
|
|
92
100
|
return None
|
|
101
|
+
finally:
|
|
102
|
+
await stream_cm.__aexit__(None, None, None)
|
|
93
103
|
|
|
94
104
|
async def _non_stream_request() -> Any:
|
|
95
105
|
return await client.messages.create(
|
|
@@ -101,9 +111,10 @@ class AnthropicClient(ProviderClient):
|
|
|
101
111
|
temperature=model_profile.temperature,
|
|
102
112
|
)
|
|
103
113
|
|
|
114
|
+
timeout_for_call = None if stream else request_timeout
|
|
104
115
|
response = await call_with_timeout_and_retries(
|
|
105
116
|
_stream_request if stream else _non_stream_request,
|
|
106
|
-
|
|
117
|
+
timeout_for_call,
|
|
107
118
|
max_retries,
|
|
108
119
|
)
|
|
109
120
|
|
ripperdoc/core/providers/base.py
CHANGED
|
@@ -3,9 +3,20 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import random
|
|
6
7
|
from abc import ABC, abstractmethod
|
|
7
8
|
from dataclasses import dataclass
|
|
8
|
-
from typing import
|
|
9
|
+
from typing import (
|
|
10
|
+
Any,
|
|
11
|
+
AsyncIterable,
|
|
12
|
+
AsyncIterator,
|
|
13
|
+
Awaitable,
|
|
14
|
+
Callable,
|
|
15
|
+
Dict,
|
|
16
|
+
Iterable,
|
|
17
|
+
List,
|
|
18
|
+
Optional,
|
|
19
|
+
)
|
|
9
20
|
|
|
10
21
|
from ripperdoc.core.config import ModelProfile
|
|
11
22
|
from ripperdoc.core.tool import Tool
|
|
@@ -153,35 +164,73 @@ def sanitize_tool_history(normalized_messages: List[Dict[str, Any]]) -> List[Dic
|
|
|
153
164
|
return sanitized
|
|
154
165
|
|
|
155
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
|
+
|
|
156
203
|
async def call_with_timeout_and_retries(
|
|
157
204
|
coro_factory: Callable[[], Awaitable[Any]],
|
|
158
205
|
request_timeout: Optional[float],
|
|
159
206
|
max_retries: int,
|
|
160
207
|
) -> Any:
|
|
161
|
-
"""Run a coroutine with timeout and limited retries."""
|
|
208
|
+
"""Run a coroutine with timeout and limited retries (exponential backoff)."""
|
|
162
209
|
attempts = max(0, int(max_retries)) + 1
|
|
163
210
|
last_error: Optional[Exception] = None
|
|
211
|
+
|
|
164
212
|
for attempt in range(1, attempts + 1):
|
|
165
213
|
try:
|
|
166
214
|
if request_timeout and request_timeout > 0:
|
|
167
215
|
return await asyncio.wait_for(coro_factory(), timeout=request_timeout)
|
|
168
216
|
return await coro_factory()
|
|
169
217
|
except asyncio.TimeoutError as exc:
|
|
170
|
-
last_error = exc
|
|
171
|
-
logger.warning(
|
|
172
|
-
"[provider_clients] Request timed out; retrying",
|
|
173
|
-
extra={"attempt": attempt, "max_retries": attempts - 1},
|
|
174
|
-
)
|
|
175
|
-
if attempt == attempts:
|
|
176
|
-
raise
|
|
177
|
-
except Exception as exc:
|
|
178
218
|
last_error = exc
|
|
179
219
|
if attempt == attempts:
|
|
180
|
-
|
|
220
|
+
break
|
|
221
|
+
delay_seconds = _retry_delay_seconds(attempt)
|
|
181
222
|
logger.warning(
|
|
182
|
-
"[provider_clients] Request
|
|
183
|
-
extra={
|
|
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
|
+
},
|
|
184
229
|
)
|
|
230
|
+
await asyncio.sleep(delay_seconds)
|
|
231
|
+
except Exception:
|
|
232
|
+
# Non-timeout errors are not retried; surface immediately.
|
|
233
|
+
raise
|
|
185
234
|
if last_error:
|
|
186
|
-
raise last_error
|
|
235
|
+
raise RuntimeError(f"Request timed out after {attempts} attempts") from last_error
|
|
187
236
|
raise RuntimeError("Unexpected error executing request with retries")
|