ripperdoc 0.2.3__py3-none-any.whl → 0.2.5__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/__main__.py +0 -5
- ripperdoc/cli/cli.py +37 -16
- ripperdoc/cli/commands/__init__.py +2 -0
- ripperdoc/cli/commands/agents_cmd.py +12 -9
- ripperdoc/cli/commands/compact_cmd.py +7 -3
- ripperdoc/cli/commands/context_cmd.py +35 -15
- ripperdoc/cli/commands/doctor_cmd.py +27 -14
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/mcp_cmd.py +13 -8
- ripperdoc/cli/commands/memory_cmd.py +5 -5
- ripperdoc/cli/commands/models_cmd.py +47 -16
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +1 -2
- ripperdoc/cli/commands/tasks_cmd.py +24 -13
- ripperdoc/cli/ui/rich_ui.py +523 -396
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/agents.py +172 -4
- ripperdoc/core/config.py +130 -6
- ripperdoc/core/default_tools.py +13 -2
- ripperdoc/core/permissions.py +20 -14
- ripperdoc/core/providers/__init__.py +31 -15
- ripperdoc/core/providers/anthropic.py +122 -8
- ripperdoc/core/providers/base.py +93 -15
- ripperdoc/core/providers/gemini.py +539 -96
- ripperdoc/core/providers/openai.py +371 -26
- ripperdoc/core/query.py +301 -62
- ripperdoc/core/query_utils.py +51 -7
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +79 -67
- ripperdoc/core/tool.py +15 -6
- ripperdoc/sdk/client.py +14 -1
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +82 -26
- ripperdoc/tools/bash_tool.py +356 -209
- 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 +53 -10
- ripperdoc/tools/file_read_tool.py +17 -7
- ripperdoc/tools/file_write_tool.py +49 -13
- ripperdoc/tools/glob_tool.py +10 -9
- ripperdoc/tools/grep_tool.py +182 -51
- ripperdoc/tools/ls_tool.py +6 -6
- ripperdoc/tools/mcp_tools.py +172 -413
- ripperdoc/tools/multi_edit_tool.py +49 -9
- ripperdoc/tools/notebook_edit_tool.py +57 -13
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +91 -9
- ripperdoc/tools/todo_tool.py +12 -12
- ripperdoc/tools/tool_search_tool.py +5 -6
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/file_watch.py +5 -4
- ripperdoc/utils/json_utils.py +4 -4
- ripperdoc/utils/log.py +3 -3
- ripperdoc/utils/mcp.py +82 -22
- ripperdoc/utils/memory.py +9 -6
- ripperdoc/utils/message_compaction.py +19 -16
- ripperdoc/utils/messages.py +73 -8
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/permissions/__init__.py +7 -1
- ripperdoc/utils/permissions/path_validation_utils.py +5 -3
- ripperdoc/utils/permissions/shell_command_validation.py +496 -18
- ripperdoc/utils/prompt.py +1 -1
- ripperdoc/utils/safe_get_cwd.py +5 -2
- ripperdoc/utils/session_history.py +38 -19
- ripperdoc/utils/todo.py +6 -2
- ripperdoc/utils/token_estimation.py +34 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +14 -1
- ripperdoc-0.2.5.dist-info/RECORD +107 -0
- ripperdoc-0.2.3.dist-info/RECORD +0 -95
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
|
@@ -119,9 +119,7 @@ class ToolSearchTool(Tool[ToolSearchInput, ToolSearchOutput]):
|
|
|
119
119
|
def is_concurrency_safe(self) -> bool:
|
|
120
120
|
return True
|
|
121
121
|
|
|
122
|
-
def needs_permissions(
|
|
123
|
-
self, input_data: Optional[ToolSearchInput] = None
|
|
124
|
-
) -> bool: # noqa: ARG002
|
|
122
|
+
def needs_permissions(self, input_data: Optional[ToolSearchInput] = None) -> bool: # noqa: ARG002
|
|
125
123
|
return False
|
|
126
124
|
|
|
127
125
|
async def validate_input(
|
|
@@ -191,10 +189,11 @@ class ToolSearchTool(Tool[ToolSearchInput, ToolSearchOutput]):
|
|
|
191
189
|
description = await build_tool_description(
|
|
192
190
|
tool, include_examples=include_examples, max_examples=2
|
|
193
191
|
)
|
|
194
|
-
except
|
|
192
|
+
except (OSError, RuntimeError, ValueError, TypeError, AttributeError, KeyError) as exc:
|
|
195
193
|
description = ""
|
|
196
|
-
logger.
|
|
197
|
-
"[tool_search] Failed to build tool description",
|
|
194
|
+
logger.warning(
|
|
195
|
+
"[tool_search] Failed to build tool description: %s: %s",
|
|
196
|
+
type(exc).__name__, exc,
|
|
198
197
|
extra={"tool_name": getattr(tool, "name", None)},
|
|
199
198
|
)
|
|
200
199
|
doc_text = " ".join([name, tool.user_facing_name(), description])
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Lightweight parsing helpers for permissive type coercion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_boolish(value: object, default: bool = False) -> bool:
|
|
9
|
+
"""Parse a truthy/falsey value from common representations."""
|
|
10
|
+
if value is None:
|
|
11
|
+
return default
|
|
12
|
+
if isinstance(value, bool):
|
|
13
|
+
return value
|
|
14
|
+
if isinstance(value, (int, float)):
|
|
15
|
+
return bool(value)
|
|
16
|
+
if isinstance(value, str):
|
|
17
|
+
normalized = value.strip().lower()
|
|
18
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
19
|
+
return True
|
|
20
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
21
|
+
return False
|
|
22
|
+
return default
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_optional_int(value: object) -> Optional[int]:
|
|
26
|
+
"""Best-effort int parsing; returns None on failure."""
|
|
27
|
+
try:
|
|
28
|
+
if value is None:
|
|
29
|
+
return None
|
|
30
|
+
if isinstance(value, bool):
|
|
31
|
+
return int(value)
|
|
32
|
+
return int(str(value).strip())
|
|
33
|
+
except (ValueError, TypeError):
|
|
34
|
+
return None
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Detection helpers for context-window overflow errors across providers.
|
|
2
|
+
|
|
3
|
+
Observed provider responses when the request is too large:
|
|
4
|
+
- OpenAI/OpenRouter style (400 BadRequestError): error.code/context_length_exceeded with
|
|
5
|
+
a message like "This model's maximum context length is 128000 tokens. However, you
|
|
6
|
+
requested 130000 tokens (... in the messages, ... in the completion)."
|
|
7
|
+
- Anthropic (400 BadRequestError): invalid_request_error with a message such as
|
|
8
|
+
"prompt is too long for model claude-3-5-sonnet. max tokens: 200000 prompt tokens: 240000".
|
|
9
|
+
- Gemini / google-genai (FAILED_PRECONDITION or INVALID_ARGUMENT): APIError message like
|
|
10
|
+
"The input to the model was too long. The requested input has X tokens, which exceeds
|
|
11
|
+
the maximum of Y tokens for models/gemini-...".
|
|
12
|
+
|
|
13
|
+
These helpers allow callers to detect the condition and trigger auto-compaction.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, List, Optional, Set
|
|
20
|
+
|
|
21
|
+
ContextLengthErrorCode = Optional[str]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ContextLengthErrorInfo:
|
|
26
|
+
"""Normalized metadata about a context-length error."""
|
|
27
|
+
|
|
28
|
+
provider: Optional[str]
|
|
29
|
+
message: str
|
|
30
|
+
error_code: ContextLengthErrorCode = None
|
|
31
|
+
status_code: Optional[int] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_CONTEXT_PATTERNS = [
|
|
35
|
+
"context_length_exceeded",
|
|
36
|
+
"maximum context length",
|
|
37
|
+
"max context length",
|
|
38
|
+
"maximum context window",
|
|
39
|
+
"max context window",
|
|
40
|
+
"context length is",
|
|
41
|
+
"context length was exceeded",
|
|
42
|
+
"context window of",
|
|
43
|
+
"token limit exceeded",
|
|
44
|
+
"token length exceeded",
|
|
45
|
+
"prompt is too long",
|
|
46
|
+
"input is too long",
|
|
47
|
+
"request is too large",
|
|
48
|
+
"exceeds the maximum context",
|
|
49
|
+
"exceeds the model's context",
|
|
50
|
+
"requested input has",
|
|
51
|
+
"too many tokens",
|
|
52
|
+
"reduce the length of the messages",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def detect_context_length_error(error: Any) -> Optional[ContextLengthErrorInfo]:
|
|
57
|
+
"""Return normalized context-length error info if the exception matches."""
|
|
58
|
+
if error is None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
provider = _guess_provider(error)
|
|
62
|
+
status_code = _extract_status_code(error)
|
|
63
|
+
codes = _extract_codes(error)
|
|
64
|
+
messages = _collect_strings(error)
|
|
65
|
+
|
|
66
|
+
# Check explicit error codes first.
|
|
67
|
+
for code in codes:
|
|
68
|
+
normalized = code.lower()
|
|
69
|
+
if any(
|
|
70
|
+
keyword in normalized
|
|
71
|
+
for keyword in (
|
|
72
|
+
"context_length",
|
|
73
|
+
"max_tokens",
|
|
74
|
+
"token_length",
|
|
75
|
+
"prompt_too_long",
|
|
76
|
+
"input_too_large",
|
|
77
|
+
"token_limit",
|
|
78
|
+
)
|
|
79
|
+
):
|
|
80
|
+
message = messages[0] if messages else code
|
|
81
|
+
return ContextLengthErrorInfo(
|
|
82
|
+
provider=provider,
|
|
83
|
+
message=message,
|
|
84
|
+
error_code=code,
|
|
85
|
+
status_code=status_code,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Fall back to message-based detection.
|
|
89
|
+
for text in messages:
|
|
90
|
+
if _looks_like_context_length_message(text):
|
|
91
|
+
return ContextLengthErrorInfo(
|
|
92
|
+
provider=provider,
|
|
93
|
+
message=text,
|
|
94
|
+
error_code=codes[0] if codes else None,
|
|
95
|
+
status_code=status_code,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _looks_like_context_length_message(text: str) -> bool:
|
|
102
|
+
lower = text.lower()
|
|
103
|
+
if any(pattern in lower for pattern in _CONTEXT_PATTERNS):
|
|
104
|
+
return True
|
|
105
|
+
if "too long" in lower and (
|
|
106
|
+
"prompt" in lower or "input" in lower or "context" in lower or "token" in lower
|
|
107
|
+
):
|
|
108
|
+
return True
|
|
109
|
+
if "exceed" in lower and ("token" in lower or "context" in lower):
|
|
110
|
+
return True
|
|
111
|
+
if "max" in lower and "token" in lower and ("context" in lower or "limit" in lower):
|
|
112
|
+
return True
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _guess_provider(error: Any) -> Optional[str]:
|
|
117
|
+
module = getattr(getattr(error, "__class__", None), "__module__", "") or ""
|
|
118
|
+
name = getattr(getattr(error, "__class__", None), "__name__", "").lower()
|
|
119
|
+
if "openai" in module or "openai" in name:
|
|
120
|
+
return "openai"
|
|
121
|
+
if "anthropic" in module or "claude" in module:
|
|
122
|
+
return "anthropic"
|
|
123
|
+
if "google.genai" in module or "vertexai" in module:
|
|
124
|
+
return "gemini"
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _extract_status_code(error: Any) -> Optional[int]:
|
|
129
|
+
for attr in ("status_code", "http_status", "code"):
|
|
130
|
+
value = getattr(error, attr, None)
|
|
131
|
+
if isinstance(value, int):
|
|
132
|
+
return value
|
|
133
|
+
if isinstance(value, str) and value.isdigit():
|
|
134
|
+
return int(value)
|
|
135
|
+
|
|
136
|
+
for payload in (
|
|
137
|
+
_safe_getattr(error, "body"),
|
|
138
|
+
_safe_getattr(error, "details"),
|
|
139
|
+
_safe_getattr(error, "error"),
|
|
140
|
+
):
|
|
141
|
+
if isinstance(payload, dict):
|
|
142
|
+
for key in ("status_code", "code"):
|
|
143
|
+
value = payload.get(key)
|
|
144
|
+
if isinstance(value, int):
|
|
145
|
+
return value
|
|
146
|
+
if isinstance(value, str) and value.isdigit():
|
|
147
|
+
return int(value)
|
|
148
|
+
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _extract_codes(error: Any) -> List[str]:
|
|
153
|
+
codes: List[str] = []
|
|
154
|
+
seen: Set[str] = set()
|
|
155
|
+
|
|
156
|
+
def _add(value: Any) -> None:
|
|
157
|
+
if value is None:
|
|
158
|
+
return
|
|
159
|
+
if isinstance(value, int):
|
|
160
|
+
value = str(value)
|
|
161
|
+
if not isinstance(value, str):
|
|
162
|
+
return
|
|
163
|
+
normalized = value.strip()
|
|
164
|
+
if not normalized or normalized in seen:
|
|
165
|
+
return
|
|
166
|
+
seen.add(normalized)
|
|
167
|
+
codes.append(normalized)
|
|
168
|
+
|
|
169
|
+
for attr in ("code", "error_code", "type", "status"):
|
|
170
|
+
_add(_safe_getattr(error, attr))
|
|
171
|
+
|
|
172
|
+
for payload in (
|
|
173
|
+
_safe_getattr(error, "body"),
|
|
174
|
+
_safe_getattr(error, "details"),
|
|
175
|
+
_safe_getattr(error, "error"),
|
|
176
|
+
):
|
|
177
|
+
if isinstance(payload, dict):
|
|
178
|
+
for key in ("code", "type", "status"):
|
|
179
|
+
_add(payload.get(key))
|
|
180
|
+
nested = payload.get("error")
|
|
181
|
+
if isinstance(nested, dict):
|
|
182
|
+
for key in ("code", "type", "status"):
|
|
183
|
+
_add(nested.get(key))
|
|
184
|
+
|
|
185
|
+
if isinstance(error, dict):
|
|
186
|
+
for key in ("code", "type", "status"):
|
|
187
|
+
_add(error.get(key))
|
|
188
|
+
|
|
189
|
+
return codes
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _collect_strings(error: Any) -> List[str]:
|
|
193
|
+
"""Collect human-readable strings from an exception/payload."""
|
|
194
|
+
texts: List[str] = []
|
|
195
|
+
seen_texts: Set[str] = set()
|
|
196
|
+
seen_objs: Set[int] = set()
|
|
197
|
+
|
|
198
|
+
def _add_text(value: Any) -> None:
|
|
199
|
+
if not isinstance(value, str):
|
|
200
|
+
return
|
|
201
|
+
normalized = value.strip()
|
|
202
|
+
if not normalized or normalized in seen_texts:
|
|
203
|
+
return
|
|
204
|
+
seen_texts.add(normalized)
|
|
205
|
+
texts.append(normalized)
|
|
206
|
+
|
|
207
|
+
def _walk(obj: Any) -> None:
|
|
208
|
+
if obj is None:
|
|
209
|
+
return
|
|
210
|
+
obj_id = id(obj)
|
|
211
|
+
if obj_id in seen_objs:
|
|
212
|
+
return
|
|
213
|
+
seen_objs.add(obj_id)
|
|
214
|
+
|
|
215
|
+
if isinstance(obj, str):
|
|
216
|
+
_add_text(obj)
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
if isinstance(obj, BaseException):
|
|
220
|
+
_add_text(_safe_getattr(obj, "message"))
|
|
221
|
+
for arg in getattr(obj, "args", ()):
|
|
222
|
+
_walk(arg)
|
|
223
|
+
for attr in ("body", "error", "details"):
|
|
224
|
+
_walk(_safe_getattr(obj, attr))
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if isinstance(obj, dict):
|
|
228
|
+
for val in obj.values():
|
|
229
|
+
_walk(val)
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
if isinstance(obj, (list, tuple, set)):
|
|
233
|
+
for item in obj:
|
|
234
|
+
_walk(item)
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
_add_text(_safe_getattr(obj, "message"))
|
|
238
|
+
|
|
239
|
+
_walk(error)
|
|
240
|
+
try:
|
|
241
|
+
_add_text(str(error))
|
|
242
|
+
except (TypeError, ValueError):
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
return texts
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _safe_getattr(obj: Any, attr: str) -> Any:
|
|
249
|
+
try:
|
|
250
|
+
return getattr(obj, attr, None)
|
|
251
|
+
except (TypeError, AttributeError):
|
|
252
|
+
return None
|
ripperdoc/utils/file_watch.py
CHANGED
|
@@ -102,10 +102,11 @@ def detect_changed_files(
|
|
|
102
102
|
|
|
103
103
|
try:
|
|
104
104
|
new_content = _read_portion(file_path, snapshot.offset, snapshot.limit)
|
|
105
|
-
except
|
|
106
|
-
logger.
|
|
107
|
-
"[file_watch] Failed reading changed file",
|
|
108
|
-
|
|
105
|
+
except (OSError, IOError, UnicodeDecodeError, ValueError) as exc: # pragma: no cover - best-effort telemetry
|
|
106
|
+
logger.warning(
|
|
107
|
+
"[file_watch] Failed reading changed file: %s: %s",
|
|
108
|
+
type(exc).__name__, exc,
|
|
109
|
+
extra={"file_path": file_path},
|
|
109
110
|
)
|
|
110
111
|
notices.append(
|
|
111
112
|
ChangedFileNotice(
|
ripperdoc/utils/json_utils.py
CHANGED
|
@@ -17,11 +17,11 @@ def safe_parse_json(json_text: Optional[str], log_error: bool = True) -> Optiona
|
|
|
17
17
|
return None
|
|
18
18
|
try:
|
|
19
19
|
return json.loads(json_text)
|
|
20
|
-
except
|
|
20
|
+
except (json.JSONDecodeError, TypeError, ValueError) as exc:
|
|
21
21
|
if log_error:
|
|
22
22
|
logger.debug(
|
|
23
|
-
"[json_utils] Failed to parse JSON",
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
"[json_utils] Failed to parse JSON: %s: %s",
|
|
24
|
+
type(exc).__name__, exc,
|
|
25
|
+
extra={"length": len(json_text)},
|
|
26
26
|
)
|
|
27
27
|
return None
|
ripperdoc/utils/log.py
CHANGED
|
@@ -55,7 +55,7 @@ class StructuredFormatter(logging.Formatter):
|
|
|
55
55
|
if extras:
|
|
56
56
|
try:
|
|
57
57
|
serialized = json.dumps(extras, sort_keys=True, ensure_ascii=True, default=str)
|
|
58
|
-
except
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
59
|
serialized = str(extras)
|
|
60
60
|
return f"{message} | {serialized}"
|
|
61
61
|
return message
|
|
@@ -97,9 +97,9 @@ class RipperdocLogger:
|
|
|
97
97
|
if self._file_handler:
|
|
98
98
|
try:
|
|
99
99
|
self.logger.removeHandler(self._file_handler)
|
|
100
|
-
except
|
|
100
|
+
except (ValueError, RuntimeError):
|
|
101
101
|
# Swallow errors while rotating handlers; console logging should continue.
|
|
102
|
-
|
|
102
|
+
pass
|
|
103
103
|
|
|
104
104
|
# Use UTF-8 to avoid Windows code page encoding errors when logs contain non-ASCII text.
|
|
105
105
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
ripperdoc/utils/mcp.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import contextvars
|
|
7
7
|
import json
|
|
8
|
+
import shlex
|
|
8
9
|
from contextlib import AsyncExitStack
|
|
9
10
|
from dataclasses import dataclass, field, replace
|
|
10
11
|
from pathlib import Path
|
|
@@ -12,23 +13,23 @@ from typing import Any, Dict, List, Optional
|
|
|
12
13
|
|
|
13
14
|
from ripperdoc import __version__
|
|
14
15
|
from ripperdoc.utils.log import get_logger
|
|
15
|
-
from ripperdoc.utils.
|
|
16
|
+
from ripperdoc.utils.token_estimation import estimate_tokens
|
|
16
17
|
|
|
17
18
|
logger = get_logger()
|
|
18
19
|
|
|
19
20
|
try:
|
|
20
|
-
import mcp.types as mcp_types
|
|
21
|
-
from mcp.client.session import ClientSession
|
|
22
|
-
from mcp.client.sse import sse_client
|
|
23
|
-
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
24
|
-
from mcp.client.streamable_http import streamablehttp_client
|
|
21
|
+
import mcp.types as mcp_types # type: ignore[import-not-found]
|
|
22
|
+
from mcp.client.session import ClientSession # type: ignore[import-not-found]
|
|
23
|
+
from mcp.client.sse import sse_client # type: ignore[import-not-found]
|
|
24
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client # type: ignore[import-not-found]
|
|
25
|
+
from mcp.client.streamable_http import streamablehttp_client # type: ignore[import-not-found]
|
|
25
26
|
|
|
26
27
|
MCP_AVAILABLE = True
|
|
27
|
-
except
|
|
28
|
+
except (ImportError, ModuleNotFoundError): # pragma: no cover - handled gracefully at runtime
|
|
28
29
|
MCP_AVAILABLE = False
|
|
29
30
|
ClientSession = object # type: ignore
|
|
30
31
|
mcp_types = None # type: ignore
|
|
31
|
-
logger.
|
|
32
|
+
logger.debug("[mcp] MCP SDK not available at import time")
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
@dataclass
|
|
@@ -88,19 +89,56 @@ def _ensure_str_dict(raw: object) -> Dict[str, str]:
|
|
|
88
89
|
for key, value in raw.items():
|
|
89
90
|
try:
|
|
90
91
|
result[str(key)] = str(value)
|
|
91
|
-
except
|
|
92
|
-
logger.
|
|
93
|
-
"[mcp] Failed to coerce env/header value to string",
|
|
94
|
-
|
|
92
|
+
except (TypeError, ValueError) as exc:
|
|
93
|
+
logger.warning(
|
|
94
|
+
"[mcp] Failed to coerce env/header value to string: %s: %s",
|
|
95
|
+
type(exc).__name__, exc,
|
|
96
|
+
extra={"key": key},
|
|
95
97
|
)
|
|
96
98
|
continue
|
|
97
99
|
return result
|
|
98
100
|
|
|
99
101
|
|
|
102
|
+
def _normalize_command(raw_command: Any, raw_args: Any) -> tuple[Optional[str], List[str]]:
|
|
103
|
+
"""Normalize MCP server command/args.
|
|
104
|
+
|
|
105
|
+
Supports:
|
|
106
|
+
- command as list -> first element is executable, rest are args
|
|
107
|
+
- command as string with spaces -> shlex.split into executable/args (when args empty)
|
|
108
|
+
- command as plain string -> used as-is
|
|
109
|
+
"""
|
|
110
|
+
args: List[str] = []
|
|
111
|
+
if isinstance(raw_args, list):
|
|
112
|
+
args = [str(a) for a in raw_args]
|
|
113
|
+
|
|
114
|
+
# Command provided as list: treat first token as command.
|
|
115
|
+
if isinstance(raw_command, list):
|
|
116
|
+
tokens = [str(t) for t in raw_command if str(t)]
|
|
117
|
+
if not tokens:
|
|
118
|
+
return None, args
|
|
119
|
+
return tokens[0], tokens[1:] + args
|
|
120
|
+
|
|
121
|
+
if not isinstance(raw_command, str):
|
|
122
|
+
return None, args
|
|
123
|
+
|
|
124
|
+
command_str = raw_command.strip()
|
|
125
|
+
if not command_str:
|
|
126
|
+
return None, args
|
|
127
|
+
|
|
128
|
+
if not args and (" " in command_str or "\t" in command_str):
|
|
129
|
+
try:
|
|
130
|
+
tokens = shlex.split(command_str)
|
|
131
|
+
except ValueError:
|
|
132
|
+
tokens = [command_str]
|
|
133
|
+
if tokens:
|
|
134
|
+
return tokens[0], tokens[1:]
|
|
135
|
+
|
|
136
|
+
return command_str, args
|
|
137
|
+
|
|
138
|
+
|
|
100
139
|
def _parse_server(name: str, raw: Dict[str, Any]) -> McpServerInfo:
|
|
101
140
|
server_type = str(raw.get("type") or raw.get("transport") or "").strip().lower()
|
|
102
|
-
command = raw.get("command")
|
|
103
|
-
args = raw.get("args") if isinstance(raw.get("args"), list) else []
|
|
141
|
+
command, args = _normalize_command(raw.get("command"), raw.get("args"))
|
|
104
142
|
url = str(raw.get("url") or raw.get("uri") or "").strip() or None
|
|
105
143
|
|
|
106
144
|
if not server_type:
|
|
@@ -121,7 +159,7 @@ def _parse_server(name: str, raw: Dict[str, Any]) -> McpServerInfo:
|
|
|
121
159
|
type=server_type,
|
|
122
160
|
url=url,
|
|
123
161
|
description=description,
|
|
124
|
-
command=
|
|
162
|
+
command=command,
|
|
125
163
|
args=[str(a) for a in args] if args else [],
|
|
126
164
|
env=env,
|
|
127
165
|
headers=headers,
|
|
@@ -327,10 +365,11 @@ class McpRuntime:
|
|
|
327
365
|
"capabilities": list(info.capabilities.keys()),
|
|
328
366
|
},
|
|
329
367
|
)
|
|
330
|
-
except
|
|
331
|
-
logger.
|
|
332
|
-
"Failed to connect to MCP server",
|
|
333
|
-
|
|
368
|
+
except (OSError, RuntimeError, ConnectionError, ValueError, TimeoutError) as exc: # pragma: no cover - network/process errors
|
|
369
|
+
logger.warning(
|
|
370
|
+
"Failed to connect to MCP server: %s: %s",
|
|
371
|
+
type(exc).__name__, exc,
|
|
372
|
+
extra={"server": config.name},
|
|
334
373
|
)
|
|
335
374
|
info.status = "failed"
|
|
336
375
|
info.error = str(exc)
|
|
@@ -347,6 +386,12 @@ class McpRuntime:
|
|
|
347
386
|
)
|
|
348
387
|
try:
|
|
349
388
|
await self._exit_stack.aclose()
|
|
389
|
+
except BaseException as exc: # pragma: no cover - defensive shutdown
|
|
390
|
+
# Swallow noisy ExceptionGroups from stdio_client cancel scopes during exit.
|
|
391
|
+
logger.debug(
|
|
392
|
+
"[mcp] Suppressed MCP shutdown error",
|
|
393
|
+
extra={"error": str(exc), "project_path": str(self.project_path)},
|
|
394
|
+
)
|
|
350
395
|
finally:
|
|
351
396
|
self.sessions.clear()
|
|
352
397
|
self.servers.clear()
|
|
@@ -355,10 +400,16 @@ class McpRuntime:
|
|
|
355
400
|
_runtime_var: contextvars.ContextVar[Optional[McpRuntime]] = contextvars.ContextVar(
|
|
356
401
|
"ripperdoc_mcp_runtime", default=None
|
|
357
402
|
)
|
|
403
|
+
# Fallback for synchronous contexts (e.g., run_until_complete) where contextvars
|
|
404
|
+
# don't propagate values back to the caller.
|
|
405
|
+
_global_runtime: Optional[McpRuntime] = None
|
|
358
406
|
|
|
359
407
|
|
|
360
408
|
def _get_runtime() -> Optional[McpRuntime]:
|
|
361
|
-
|
|
409
|
+
runtime = _runtime_var.get()
|
|
410
|
+
if runtime:
|
|
411
|
+
return runtime
|
|
412
|
+
return _global_runtime
|
|
362
413
|
|
|
363
414
|
|
|
364
415
|
def get_existing_mcp_runtime() -> Optional[McpRuntime]:
|
|
@@ -370,6 +421,7 @@ async def ensure_mcp_runtime(project_path: Optional[Path] = None) -> McpRuntime:
|
|
|
370
421
|
runtime = _get_runtime()
|
|
371
422
|
project_path = project_path or Path.cwd()
|
|
372
423
|
if runtime and not runtime._closed and runtime.project_path == project_path:
|
|
424
|
+
_runtime_var.set(runtime)
|
|
373
425
|
logger.debug(
|
|
374
426
|
"[mcp] Reusing existing MCP runtime",
|
|
375
427
|
extra={
|
|
@@ -390,6 +442,9 @@ async def ensure_mcp_runtime(project_path: Optional[Path] = None) -> McpRuntime:
|
|
|
390
442
|
configs = _load_server_configs(project_path)
|
|
391
443
|
await runtime.connect(configs)
|
|
392
444
|
_runtime_var.set(runtime)
|
|
445
|
+
# Keep a module-level reference so sync callers that hop event loops can reuse it.
|
|
446
|
+
global _global_runtime
|
|
447
|
+
_global_runtime = runtime
|
|
393
448
|
return runtime
|
|
394
449
|
|
|
395
450
|
|
|
@@ -397,8 +452,13 @@ async def shutdown_mcp_runtime() -> None:
|
|
|
397
452
|
runtime = _get_runtime()
|
|
398
453
|
if not runtime:
|
|
399
454
|
return
|
|
400
|
-
|
|
455
|
+
try:
|
|
456
|
+
await runtime.aclose()
|
|
457
|
+
except BaseException as exc: # pragma: no cover - defensive for ExceptionGroup
|
|
458
|
+
logger.debug("[mcp] Suppressed MCP runtime shutdown error", extra={"error": str(exc)})
|
|
401
459
|
_runtime_var.set(None)
|
|
460
|
+
global _global_runtime
|
|
461
|
+
_global_runtime = None
|
|
402
462
|
|
|
403
463
|
|
|
404
464
|
async def load_mcp_servers_async(project_path: Optional[Path] = None) -> List[McpServerInfo]:
|
|
@@ -482,7 +542,7 @@ def format_mcp_instructions(servers: List[McpServerInfo]) -> str:
|
|
|
482
542
|
def estimate_mcp_tokens(servers: List[McpServerInfo]) -> int:
|
|
483
543
|
"""Estimate token usage for MCP instructions."""
|
|
484
544
|
mcp_text = format_mcp_instructions(servers)
|
|
485
|
-
return
|
|
545
|
+
return estimate_tokens(mcp_text)
|
|
486
546
|
|
|
487
547
|
|
|
488
548
|
__all__ = [
|
ripperdoc/utils/memory.py
CHANGED
|
@@ -45,9 +45,10 @@ def _is_path_under_directory(path: Path, directory: Path) -> bool:
|
|
|
45
45
|
try:
|
|
46
46
|
path.resolve().relative_to(directory.resolve())
|
|
47
47
|
return True
|
|
48
|
-
except
|
|
49
|
-
logger.
|
|
50
|
-
"[memory] Failed to compare path containment",
|
|
48
|
+
except (ValueError, OSError) as exc:
|
|
49
|
+
logger.warning(
|
|
50
|
+
"[memory] Failed to compare path containment: %s: %s",
|
|
51
|
+
type(exc).__name__, exc,
|
|
51
52
|
extra={"path": str(path), "directory": str(directory)},
|
|
52
53
|
)
|
|
53
54
|
return False
|
|
@@ -122,9 +123,11 @@ def _collect_files(
|
|
|
122
123
|
resolved_path = file_path.expanduser()
|
|
123
124
|
try:
|
|
124
125
|
resolved_path = resolved_path.resolve()
|
|
125
|
-
except
|
|
126
|
-
logger.
|
|
127
|
-
"[memory] Failed to resolve memory file path
|
|
126
|
+
except (OSError, ValueError) as exc:
|
|
127
|
+
logger.warning(
|
|
128
|
+
"[memory] Failed to resolve memory file path: %s: %s",
|
|
129
|
+
type(exc).__name__, exc,
|
|
130
|
+
extra={"path": str(resolved_path)},
|
|
128
131
|
)
|
|
129
132
|
|
|
130
133
|
resolved_key = str(resolved_path)
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
-
import math
|
|
7
6
|
import os
|
|
8
7
|
from dataclasses import dataclass
|
|
9
8
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Union
|
|
10
9
|
|
|
11
10
|
from ripperdoc.core.config import GlobalConfig, ModelProfile, get_global_config
|
|
12
11
|
from ripperdoc.utils.log import get_logger
|
|
12
|
+
from ripperdoc.utils.token_estimation import estimate_tokens
|
|
13
13
|
from ripperdoc.utils.messages import (
|
|
14
14
|
AssistantMessage,
|
|
15
15
|
MessageContent,
|
|
@@ -140,10 +140,8 @@ def _parse_truthy_env_value(value: Optional[str]) -> bool:
|
|
|
140
140
|
|
|
141
141
|
|
|
142
142
|
def estimate_tokens_from_text(text: str) -> int:
|
|
143
|
-
"""
|
|
144
|
-
|
|
145
|
-
return 0
|
|
146
|
-
return max(1, math.ceil(len(text) / 4))
|
|
143
|
+
"""Estimate token count using shared token estimation helper."""
|
|
144
|
+
return estimate_tokens(text)
|
|
147
145
|
|
|
148
146
|
|
|
149
147
|
def _stringify_content(content: Union[str, List[MessageContent], None]) -> str:
|
|
@@ -171,9 +169,10 @@ def _stringify_content(content: Union[str, List[MessageContent], None]) -> str:
|
|
|
171
169
|
if block_type == "tool_use" and part.get("input") is not None:
|
|
172
170
|
try:
|
|
173
171
|
parts.append(json.dumps(part.get("input"), ensure_ascii=False))
|
|
174
|
-
except
|
|
175
|
-
logger.
|
|
176
|
-
"[message_compaction] Failed to serialize tool_use input for token estimate"
|
|
172
|
+
except (TypeError, ValueError) as exc:
|
|
173
|
+
logger.warning(
|
|
174
|
+
"[message_compaction] Failed to serialize tool_use input for token estimate: %s: %s",
|
|
175
|
+
type(exc).__name__, exc,
|
|
177
176
|
)
|
|
178
177
|
parts.append(str(part.get("input")))
|
|
179
178
|
|
|
@@ -227,10 +226,11 @@ def _estimate_tool_schema_tokens(tools: Sequence[Any]) -> int:
|
|
|
227
226
|
schema = tool.input_schema.model_json_schema()
|
|
228
227
|
schema_text = json.dumps(schema, sort_keys=True)
|
|
229
228
|
total += estimate_tokens_from_text(schema_text)
|
|
230
|
-
except
|
|
231
|
-
logger.
|
|
232
|
-
"Failed to estimate tokens for tool schema",
|
|
233
|
-
|
|
229
|
+
except (AttributeError, TypeError, KeyError, ValueError) as exc:
|
|
230
|
+
logger.warning(
|
|
231
|
+
"Failed to estimate tokens for tool schema: %s: %s",
|
|
232
|
+
type(exc).__name__, exc,
|
|
233
|
+
extra={"tool": getattr(tool, "name", None)},
|
|
234
234
|
)
|
|
235
235
|
continue
|
|
236
236
|
return total
|
|
@@ -401,8 +401,8 @@ def find_latest_assistant_usage_tokens(
|
|
|
401
401
|
tokens += int(value)
|
|
402
402
|
if tokens > 0:
|
|
403
403
|
return tokens
|
|
404
|
-
except
|
|
405
|
-
logger.debug("[message_compaction] Failed to parse usage tokens"
|
|
404
|
+
except (TypeError, ValueError, AttributeError):
|
|
405
|
+
logger.debug("[message_compaction] Failed to parse usage tokens")
|
|
406
406
|
continue
|
|
407
407
|
return 0
|
|
408
408
|
|
|
@@ -438,8 +438,11 @@ def _run_cleanup_callbacks() -> None:
|
|
|
438
438
|
for callback in callbacks:
|
|
439
439
|
try:
|
|
440
440
|
callback()
|
|
441
|
-
except
|
|
442
|
-
logger.debug(
|
|
441
|
+
except (RuntimeError, TypeError, ValueError, AttributeError) as exc:
|
|
442
|
+
logger.debug(
|
|
443
|
+
"[message_compaction] Cleanup callback failed: %s: %s",
|
|
444
|
+
type(exc).__name__, exc,
|
|
445
|
+
)
|
|
443
446
|
|
|
444
447
|
|
|
445
448
|
def _normalize_tool_use_id(block: Any) -> str:
|