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.
Files changed (76) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/__main__.py +0 -5
  3. ripperdoc/cli/cli.py +37 -16
  4. ripperdoc/cli/commands/__init__.py +2 -0
  5. ripperdoc/cli/commands/agents_cmd.py +12 -9
  6. ripperdoc/cli/commands/compact_cmd.py +7 -3
  7. ripperdoc/cli/commands/context_cmd.py +35 -15
  8. ripperdoc/cli/commands/doctor_cmd.py +27 -14
  9. ripperdoc/cli/commands/exit_cmd.py +1 -1
  10. ripperdoc/cli/commands/mcp_cmd.py +13 -8
  11. ripperdoc/cli/commands/memory_cmd.py +5 -5
  12. ripperdoc/cli/commands/models_cmd.py +47 -16
  13. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  14. ripperdoc/cli/commands/resume_cmd.py +1 -2
  15. ripperdoc/cli/commands/tasks_cmd.py +24 -13
  16. ripperdoc/cli/ui/rich_ui.py +523 -396
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +172 -4
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +13 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/__init__.py +31 -15
  23. ripperdoc/core/providers/anthropic.py +122 -8
  24. ripperdoc/core/providers/base.py +93 -15
  25. ripperdoc/core/providers/gemini.py +539 -96
  26. ripperdoc/core/providers/openai.py +371 -26
  27. ripperdoc/core/query.py +301 -62
  28. ripperdoc/core/query_utils.py +51 -7
  29. ripperdoc/core/skills.py +295 -0
  30. ripperdoc/core/system_prompt.py +79 -67
  31. ripperdoc/core/tool.py +15 -6
  32. ripperdoc/sdk/client.py +14 -1
  33. ripperdoc/tools/ask_user_question_tool.py +431 -0
  34. ripperdoc/tools/background_shell.py +82 -26
  35. ripperdoc/tools/bash_tool.py +356 -209
  36. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  37. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  38. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  39. ripperdoc/tools/file_edit_tool.py +53 -10
  40. ripperdoc/tools/file_read_tool.py +17 -7
  41. ripperdoc/tools/file_write_tool.py +49 -13
  42. ripperdoc/tools/glob_tool.py +10 -9
  43. ripperdoc/tools/grep_tool.py +182 -51
  44. ripperdoc/tools/ls_tool.py +6 -6
  45. ripperdoc/tools/mcp_tools.py +172 -413
  46. ripperdoc/tools/multi_edit_tool.py +49 -9
  47. ripperdoc/tools/notebook_edit_tool.py +57 -13
  48. ripperdoc/tools/skill_tool.py +205 -0
  49. ripperdoc/tools/task_tool.py +91 -9
  50. ripperdoc/tools/todo_tool.py +12 -12
  51. ripperdoc/tools/tool_search_tool.py +5 -6
  52. ripperdoc/utils/coerce.py +34 -0
  53. ripperdoc/utils/context_length_errors.py +252 -0
  54. ripperdoc/utils/file_watch.py +5 -4
  55. ripperdoc/utils/json_utils.py +4 -4
  56. ripperdoc/utils/log.py +3 -3
  57. ripperdoc/utils/mcp.py +82 -22
  58. ripperdoc/utils/memory.py +9 -6
  59. ripperdoc/utils/message_compaction.py +19 -16
  60. ripperdoc/utils/messages.py +73 -8
  61. ripperdoc/utils/path_ignore.py +677 -0
  62. ripperdoc/utils/permissions/__init__.py +7 -1
  63. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  64. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  65. ripperdoc/utils/prompt.py +1 -1
  66. ripperdoc/utils/safe_get_cwd.py +5 -2
  67. ripperdoc/utils/session_history.py +38 -19
  68. ripperdoc/utils/todo.py +6 -2
  69. ripperdoc/utils/token_estimation.py +34 -0
  70. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +14 -1
  71. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  72. ripperdoc-0.2.3.dist-info/RECORD +0 -95
  73. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  74. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  76. {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 Exception:
192
+ except (OSError, RuntimeError, ValueError, TypeError, AttributeError, KeyError) as exc:
195
193
  description = ""
196
- logger.exception(
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
@@ -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 Exception as exc: # pragma: no cover - best-effort telemetry
106
- logger.exception(
107
- "[file_watch] Failed reading changed file",
108
- extra={"file_path": file_path, "error": str(exc)},
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(
@@ -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 Exception as exc:
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
- extra={"error": str(exc), "length": len(json_text)},
25
- exc_info=True,
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 Exception:
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 Exception:
100
+ except (ValueError, RuntimeError):
101
101
  # Swallow errors while rotating handlers; console logging should continue.
102
- self.logger.exception("[logging] Failed to remove existing file handler")
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.message_compaction import estimate_tokens_from_text
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 Exception: # pragma: no cover - handled gracefully at runtime
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.exception("[mcp] MCP SDK not available at import time")
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 Exception:
92
- logger.exception(
93
- "[mcp] Failed to coerce env/header value to string",
94
- extra={"key": key, "value": value},
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=str(command) if isinstance(command, str) else None,
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 Exception as exc: # pragma: no cover - network/process errors
331
- logger.exception(
332
- "Failed to connect to MCP server",
333
- extra={"server": config.name, "error": str(exc)},
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
- return _runtime_var.get()
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
- await runtime.aclose()
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 estimate_tokens_from_text(mcp_text)
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 Exception:
49
- logger.exception(
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 Exception:
126
- logger.exception(
127
- "[memory] Failed to resolve memory file path", extra={"path": str(resolved_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
- """Rough token estimate using a 4-characters-per-token rule."""
144
- if not text:
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 Exception:
175
- logger.exception(
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 Exception as exc:
231
- logger.exception(
232
- "Failed to estimate tokens for tool schema",
233
- extra={"tool": getattr(tool, "name", None), "error": str(exc)},
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 Exception:
405
- logger.debug("[message_compaction] Failed to parse usage tokens", exc_info=True)
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 Exception as exc:
442
- logger.debug(f"[message_compaction] Cleanup callback failed: {exc}", exc_info=True)
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: