ripperdoc 0.2.4__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 +33 -13
- 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 +500 -406
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/agents.py +17 -9
- ripperdoc/core/config.py +130 -6
- ripperdoc/core/default_tools.py +7 -2
- ripperdoc/core/permissions.py +20 -14
- ripperdoc/core/providers/anthropic.py +107 -4
- ripperdoc/core/providers/base.py +33 -4
- ripperdoc/core/providers/gemini.py +169 -50
- ripperdoc/core/providers/openai.py +257 -23
- ripperdoc/core/query.py +294 -61
- ripperdoc/core/query_utils.py +50 -6
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +13 -7
- ripperdoc/core/tool.py +8 -6
- ripperdoc/sdk/client.py +14 -1
- ripperdoc/tools/ask_user_question_tool.py +20 -22
- ripperdoc/tools/background_shell.py +19 -13
- ripperdoc/tools/bash_tool.py +356 -209
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +5 -2
- ripperdoc/tools/exit_plan_mode_tool.py +6 -3
- 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 +106 -456
- 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 +7 -8
- 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 +36 -15
- ripperdoc/utils/memory.py +9 -6
- ripperdoc/utils/message_compaction.py +16 -11
- 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 +4 -3
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
- ripperdoc-0.2.5.dist-info/RECORD +107 -0
- ripperdoc-0.2.4.dist-info/RECORD +0 -99
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
|
@@ -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
|
@@ -25,11 +25,11 @@ try:
|
|
|
25
25
|
from mcp.client.streamable_http import streamablehttp_client # type: ignore[import-not-found]
|
|
26
26
|
|
|
27
27
|
MCP_AVAILABLE = True
|
|
28
|
-
except
|
|
28
|
+
except (ImportError, ModuleNotFoundError): # pragma: no cover - handled gracefully at runtime
|
|
29
29
|
MCP_AVAILABLE = False
|
|
30
30
|
ClientSession = object # type: ignore
|
|
31
31
|
mcp_types = None # type: ignore
|
|
32
|
-
logger.
|
|
32
|
+
logger.debug("[mcp] MCP SDK not available at import time")
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
@dataclass
|
|
@@ -89,18 +89,17 @@ def _ensure_str_dict(raw: object) -> Dict[str, str]:
|
|
|
89
89
|
for key, value in raw.items():
|
|
90
90
|
try:
|
|
91
91
|
result[str(key)] = str(value)
|
|
92
|
-
except
|
|
93
|
-
logger.
|
|
94
|
-
"[mcp] Failed to coerce env/header value to string",
|
|
95
|
-
|
|
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},
|
|
96
97
|
)
|
|
97
98
|
continue
|
|
98
99
|
return result
|
|
99
100
|
|
|
100
101
|
|
|
101
|
-
def _normalize_command(
|
|
102
|
-
raw_command: Any, raw_args: Any
|
|
103
|
-
) -> tuple[Optional[str], List[str]]:
|
|
102
|
+
def _normalize_command(raw_command: Any, raw_args: Any) -> tuple[Optional[str], List[str]]:
|
|
104
103
|
"""Normalize MCP server command/args.
|
|
105
104
|
|
|
106
105
|
Supports:
|
|
@@ -366,10 +365,11 @@ class McpRuntime:
|
|
|
366
365
|
"capabilities": list(info.capabilities.keys()),
|
|
367
366
|
},
|
|
368
367
|
)
|
|
369
|
-
except
|
|
370
|
-
logger.
|
|
371
|
-
"Failed to connect to MCP server",
|
|
372
|
-
|
|
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},
|
|
373
373
|
)
|
|
374
374
|
info.status = "failed"
|
|
375
375
|
info.error = str(exc)
|
|
@@ -386,6 +386,12 @@ class McpRuntime:
|
|
|
386
386
|
)
|
|
387
387
|
try:
|
|
388
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
|
+
)
|
|
389
395
|
finally:
|
|
390
396
|
self.sessions.clear()
|
|
391
397
|
self.servers.clear()
|
|
@@ -394,10 +400,16 @@ class McpRuntime:
|
|
|
394
400
|
_runtime_var: contextvars.ContextVar[Optional[McpRuntime]] = contextvars.ContextVar(
|
|
395
401
|
"ripperdoc_mcp_runtime", default=None
|
|
396
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
|
|
397
406
|
|
|
398
407
|
|
|
399
408
|
def _get_runtime() -> Optional[McpRuntime]:
|
|
400
|
-
|
|
409
|
+
runtime = _runtime_var.get()
|
|
410
|
+
if runtime:
|
|
411
|
+
return runtime
|
|
412
|
+
return _global_runtime
|
|
401
413
|
|
|
402
414
|
|
|
403
415
|
def get_existing_mcp_runtime() -> Optional[McpRuntime]:
|
|
@@ -409,6 +421,7 @@ async def ensure_mcp_runtime(project_path: Optional[Path] = None) -> McpRuntime:
|
|
|
409
421
|
runtime = _get_runtime()
|
|
410
422
|
project_path = project_path or Path.cwd()
|
|
411
423
|
if runtime and not runtime._closed and runtime.project_path == project_path:
|
|
424
|
+
_runtime_var.set(runtime)
|
|
412
425
|
logger.debug(
|
|
413
426
|
"[mcp] Reusing existing MCP runtime",
|
|
414
427
|
extra={
|
|
@@ -429,6 +442,9 @@ async def ensure_mcp_runtime(project_path: Optional[Path] = None) -> McpRuntime:
|
|
|
429
442
|
configs = _load_server_configs(project_path)
|
|
430
443
|
await runtime.connect(configs)
|
|
431
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
|
|
432
448
|
return runtime
|
|
433
449
|
|
|
434
450
|
|
|
@@ -436,8 +452,13 @@ async def shutdown_mcp_runtime() -> None:
|
|
|
436
452
|
runtime = _get_runtime()
|
|
437
453
|
if not runtime:
|
|
438
454
|
return
|
|
439
|
-
|
|
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)})
|
|
440
459
|
_runtime_var.set(None)
|
|
460
|
+
global _global_runtime
|
|
461
|
+
_global_runtime = None
|
|
441
462
|
|
|
442
463
|
|
|
443
464
|
async def load_mcp_servers_async(project_path: Optional[Path] = None) -> List[McpServerInfo]:
|
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)
|
|
@@ -169,9 +169,10 @@ def _stringify_content(content: Union[str, List[MessageContent], None]) -> str:
|
|
|
169
169
|
if block_type == "tool_use" and part.get("input") is not None:
|
|
170
170
|
try:
|
|
171
171
|
parts.append(json.dumps(part.get("input"), ensure_ascii=False))
|
|
172
|
-
except
|
|
173
|
-
logger.
|
|
174
|
-
"[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,
|
|
175
176
|
)
|
|
176
177
|
parts.append(str(part.get("input")))
|
|
177
178
|
|
|
@@ -225,10 +226,11 @@ def _estimate_tool_schema_tokens(tools: Sequence[Any]) -> int:
|
|
|
225
226
|
schema = tool.input_schema.model_json_schema()
|
|
226
227
|
schema_text = json.dumps(schema, sort_keys=True)
|
|
227
228
|
total += estimate_tokens_from_text(schema_text)
|
|
228
|
-
except
|
|
229
|
-
logger.
|
|
230
|
-
"Failed to estimate tokens for tool schema",
|
|
231
|
-
|
|
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)},
|
|
232
234
|
)
|
|
233
235
|
continue
|
|
234
236
|
return total
|
|
@@ -399,8 +401,8 @@ def find_latest_assistant_usage_tokens(
|
|
|
399
401
|
tokens += int(value)
|
|
400
402
|
if tokens > 0:
|
|
401
403
|
return tokens
|
|
402
|
-
except
|
|
403
|
-
logger.debug("[message_compaction] Failed to parse usage tokens"
|
|
404
|
+
except (TypeError, ValueError, AttributeError):
|
|
405
|
+
logger.debug("[message_compaction] Failed to parse usage tokens")
|
|
404
406
|
continue
|
|
405
407
|
return 0
|
|
406
408
|
|
|
@@ -436,8 +438,11 @@ def _run_cleanup_callbacks() -> None:
|
|
|
436
438
|
for callback in callbacks:
|
|
437
439
|
try:
|
|
438
440
|
callback()
|
|
439
|
-
except
|
|
440
|
-
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
|
+
)
|
|
441
446
|
|
|
442
447
|
|
|
443
448
|
def _normalize_tool_use_id(block: Any) -> str:
|
ripperdoc/utils/messages.py
CHANGED
|
@@ -6,7 +6,7 @@ for communication with AI models.
|
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
from typing import Any, Dict, List, Optional, Union
|
|
9
|
-
from pydantic import BaseModel, ConfigDict
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
10
|
from uuid import uuid4
|
|
11
11
|
from enum import Enum
|
|
12
12
|
from ripperdoc.utils.log import get_logger
|
|
@@ -27,6 +27,9 @@ class MessageContent(BaseModel):
|
|
|
27
27
|
|
|
28
28
|
type: str
|
|
29
29
|
text: Optional[str] = None
|
|
30
|
+
thinking: Optional[str] = None
|
|
31
|
+
signature: Optional[str] = None
|
|
32
|
+
data: Optional[str] = None
|
|
30
33
|
# Some providers return tool_use IDs as "id", others as "tool_use_id"
|
|
31
34
|
id: Optional[str] = None
|
|
32
35
|
tool_use_id: Optional[str] = None
|
|
@@ -38,6 +41,18 @@ class MessageContent(BaseModel):
|
|
|
38
41
|
def _content_block_to_api(block: MessageContent) -> Dict[str, Any]:
|
|
39
42
|
"""Convert a MessageContent block to API-ready dict for tool protocols."""
|
|
40
43
|
block_type = getattr(block, "type", None)
|
|
44
|
+
if block_type == "thinking":
|
|
45
|
+
return {
|
|
46
|
+
"type": "thinking",
|
|
47
|
+
"thinking": getattr(block, "thinking", None) or getattr(block, "text", None) or "",
|
|
48
|
+
"signature": getattr(block, "signature", None),
|
|
49
|
+
}
|
|
50
|
+
if block_type == "redacted_thinking":
|
|
51
|
+
return {
|
|
52
|
+
"type": "redacted_thinking",
|
|
53
|
+
"data": getattr(block, "data", None) or getattr(block, "text", None) or "",
|
|
54
|
+
"signature": getattr(block, "signature", None),
|
|
55
|
+
}
|
|
41
56
|
if block_type == "tool_use":
|
|
42
57
|
return {
|
|
43
58
|
"type": "tool_use",
|
|
@@ -75,8 +90,11 @@ def _content_block_to_openai(block: MessageContent) -> Dict[str, Any]:
|
|
|
75
90
|
args = getattr(block, "input", None) or {}
|
|
76
91
|
try:
|
|
77
92
|
args_str = json.dumps(args)
|
|
78
|
-
except
|
|
79
|
-
logger.
|
|
93
|
+
except (TypeError, ValueError) as exc:
|
|
94
|
+
logger.warning(
|
|
95
|
+
"[_content_block_to_openai] Failed to serialize tool arguments: %s: %s",
|
|
96
|
+
type(exc).__name__, exc,
|
|
97
|
+
)
|
|
80
98
|
args_str = "{}"
|
|
81
99
|
tool_call_id = (
|
|
82
100
|
getattr(block, "id", None) or getattr(block, "tool_use_id", "") or str(uuid4())
|
|
@@ -118,6 +136,8 @@ class Message(BaseModel):
|
|
|
118
136
|
|
|
119
137
|
role: MessageRole
|
|
120
138
|
content: Union[str, List[MessageContent]]
|
|
139
|
+
reasoning: Optional[Any] = None
|
|
140
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
121
141
|
uuid: str = ""
|
|
122
142
|
|
|
123
143
|
def __init__(self, **data: object) -> None:
|
|
@@ -187,9 +207,12 @@ def create_user_message(
|
|
|
187
207
|
try:
|
|
188
208
|
if hasattr(tool_use_result, "model_dump"):
|
|
189
209
|
tool_use_result = tool_use_result.model_dump()
|
|
190
|
-
except
|
|
210
|
+
except (AttributeError, TypeError, ValueError) as exc:
|
|
191
211
|
# Fallback: keep as-is if conversion fails
|
|
192
|
-
logger.
|
|
212
|
+
logger.warning(
|
|
213
|
+
"[create_user_message] Failed to normalize tool_use_result: %s: %s",
|
|
214
|
+
type(exc).__name__, exc,
|
|
215
|
+
)
|
|
193
216
|
|
|
194
217
|
message = Message(role=MessageRole.USER, content=message_content)
|
|
195
218
|
|
|
@@ -208,7 +231,11 @@ def create_user_message(
|
|
|
208
231
|
|
|
209
232
|
|
|
210
233
|
def create_assistant_message(
|
|
211
|
-
content: Union[str, List[Dict[str, Any]]],
|
|
234
|
+
content: Union[str, List[Dict[str, Any]]],
|
|
235
|
+
cost_usd: float = 0.0,
|
|
236
|
+
duration_ms: float = 0.0,
|
|
237
|
+
reasoning: Optional[Any] = None,
|
|
238
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
212
239
|
) -> AssistantMessage:
|
|
213
240
|
"""Create an assistant message."""
|
|
214
241
|
if isinstance(content, str):
|
|
@@ -216,7 +243,12 @@ def create_assistant_message(
|
|
|
216
243
|
else:
|
|
217
244
|
message_content = [MessageContent(**item) for item in content]
|
|
218
245
|
|
|
219
|
-
message = Message(
|
|
246
|
+
message = Message(
|
|
247
|
+
role=MessageRole.ASSISTANT,
|
|
248
|
+
content=message_content,
|
|
249
|
+
reasoning=reasoning,
|
|
250
|
+
metadata=metadata or {},
|
|
251
|
+
)
|
|
220
252
|
|
|
221
253
|
return AssistantMessage(message=message, cost_usd=cost_usd, duration_ms=duration_ms)
|
|
222
254
|
|
|
@@ -264,6 +296,28 @@ def normalize_messages_for_api(
|
|
|
264
296
|
return msg.get("content")
|
|
265
297
|
return None
|
|
266
298
|
|
|
299
|
+
def _msg_metadata(msg: Any) -> Dict[str, Any]:
|
|
300
|
+
message_obj = getattr(msg, "message", None)
|
|
301
|
+
if message_obj is not None and hasattr(message_obj, "metadata"):
|
|
302
|
+
try:
|
|
303
|
+
meta = getattr(message_obj, "metadata", {}) or {}
|
|
304
|
+
meta_dict = dict(meta) if isinstance(meta, dict) else {}
|
|
305
|
+
except (TypeError, ValueError):
|
|
306
|
+
meta_dict = {}
|
|
307
|
+
reasoning_val = getattr(message_obj, "reasoning", None)
|
|
308
|
+
if reasoning_val is not None and "reasoning" not in meta_dict:
|
|
309
|
+
meta_dict["reasoning"] = reasoning_val
|
|
310
|
+
return meta_dict
|
|
311
|
+
if isinstance(msg, dict):
|
|
312
|
+
message_payload = msg.get("message")
|
|
313
|
+
if isinstance(message_payload, dict):
|
|
314
|
+
meta = message_payload.get("metadata") or {}
|
|
315
|
+
meta_dict = dict(meta) if isinstance(meta, dict) else {}
|
|
316
|
+
if "reasoning" not in meta_dict and "reasoning" in message_payload:
|
|
317
|
+
meta_dict["reasoning"] = message_payload.get("reasoning")
|
|
318
|
+
return meta_dict
|
|
319
|
+
return {}
|
|
320
|
+
|
|
267
321
|
def _block_type(block: Any) -> Optional[str]:
|
|
268
322
|
if hasattr(block, "type"):
|
|
269
323
|
return getattr(block, "type", None)
|
|
@@ -299,7 +353,7 @@ def normalize_messages_for_api(
|
|
|
299
353
|
if input_data not in (None, {}):
|
|
300
354
|
try:
|
|
301
355
|
input_preview = json.dumps(input_data)
|
|
302
|
-
except
|
|
356
|
+
except (TypeError, ValueError):
|
|
303
357
|
input_preview = str(input_data)
|
|
304
358
|
tool_id = _block_attr(blk, "tool_use_id") or _block_attr(blk, "id")
|
|
305
359
|
desc = "Tool call"
|
|
@@ -352,6 +406,7 @@ def normalize_messages_for_api(
|
|
|
352
406
|
|
|
353
407
|
if msg_type == "user":
|
|
354
408
|
user_content = _msg_content(msg)
|
|
409
|
+
meta = _msg_metadata(msg)
|
|
355
410
|
if isinstance(user_content, list):
|
|
356
411
|
if protocol == "openai":
|
|
357
412
|
# Map each block to an OpenAI-style message
|
|
@@ -362,6 +417,11 @@ def normalize_messages_for_api(
|
|
|
362
417
|
mapped = _content_block_to_openai(block)
|
|
363
418
|
if mapped:
|
|
364
419
|
openai_msgs.append(mapped)
|
|
420
|
+
if meta and openai_msgs:
|
|
421
|
+
for candidate in openai_msgs:
|
|
422
|
+
for key in ("reasoning_content", "reasoning_details", "reasoning"):
|
|
423
|
+
if key in meta and meta[key] is not None:
|
|
424
|
+
candidate[key] = meta[key]
|
|
365
425
|
normalized.extend(openai_msgs)
|
|
366
426
|
continue
|
|
367
427
|
api_blocks = []
|
|
@@ -374,6 +434,7 @@ def normalize_messages_for_api(
|
|
|
374
434
|
normalized.append({"role": "user", "content": user_content}) # type: ignore
|
|
375
435
|
elif msg_type == "assistant":
|
|
376
436
|
asst_content = _msg_content(msg)
|
|
437
|
+
meta = _msg_metadata(msg)
|
|
377
438
|
if isinstance(asst_content, list):
|
|
378
439
|
if protocol == "openai":
|
|
379
440
|
assistant_openai_msgs: List[Dict[str, Any]] = []
|
|
@@ -417,6 +478,10 @@ def normalize_messages_for_api(
|
|
|
417
478
|
"tool_calls": tool_calls,
|
|
418
479
|
}
|
|
419
480
|
)
|
|
481
|
+
if meta and assistant_openai_msgs:
|
|
482
|
+
for key in ("reasoning_content", "reasoning_details", "reasoning"):
|
|
483
|
+
if key in meta and meta[key] is not None:
|
|
484
|
+
assistant_openai_msgs[-1][key] = meta[key]
|
|
420
485
|
normalized.extend(assistant_openai_msgs)
|
|
421
486
|
continue
|
|
422
487
|
api_blocks = []
|