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.
Files changed (75) 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 +33 -13
  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 +500 -406
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +17 -9
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +7 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/anthropic.py +107 -4
  23. ripperdoc/core/providers/base.py +33 -4
  24. ripperdoc/core/providers/gemini.py +169 -50
  25. ripperdoc/core/providers/openai.py +257 -23
  26. ripperdoc/core/query.py +294 -61
  27. ripperdoc/core/query_utils.py +50 -6
  28. ripperdoc/core/skills.py +295 -0
  29. ripperdoc/core/system_prompt.py +13 -7
  30. ripperdoc/core/tool.py +8 -6
  31. ripperdoc/sdk/client.py +14 -1
  32. ripperdoc/tools/ask_user_question_tool.py +20 -22
  33. ripperdoc/tools/background_shell.py +19 -13
  34. ripperdoc/tools/bash_tool.py +356 -209
  35. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  36. ripperdoc/tools/enter_plan_mode_tool.py +5 -2
  37. ripperdoc/tools/exit_plan_mode_tool.py +6 -3
  38. ripperdoc/tools/file_edit_tool.py +53 -10
  39. ripperdoc/tools/file_read_tool.py +17 -7
  40. ripperdoc/tools/file_write_tool.py +49 -13
  41. ripperdoc/tools/glob_tool.py +10 -9
  42. ripperdoc/tools/grep_tool.py +182 -51
  43. ripperdoc/tools/ls_tool.py +6 -6
  44. ripperdoc/tools/mcp_tools.py +106 -456
  45. ripperdoc/tools/multi_edit_tool.py +49 -9
  46. ripperdoc/tools/notebook_edit_tool.py +57 -13
  47. ripperdoc/tools/skill_tool.py +205 -0
  48. ripperdoc/tools/task_tool.py +7 -8
  49. ripperdoc/tools/todo_tool.py +12 -12
  50. ripperdoc/tools/tool_search_tool.py +5 -6
  51. ripperdoc/utils/coerce.py +34 -0
  52. ripperdoc/utils/context_length_errors.py +252 -0
  53. ripperdoc/utils/file_watch.py +5 -4
  54. ripperdoc/utils/json_utils.py +4 -4
  55. ripperdoc/utils/log.py +3 -3
  56. ripperdoc/utils/mcp.py +36 -15
  57. ripperdoc/utils/memory.py +9 -6
  58. ripperdoc/utils/message_compaction.py +16 -11
  59. ripperdoc/utils/messages.py +73 -8
  60. ripperdoc/utils/path_ignore.py +677 -0
  61. ripperdoc/utils/permissions/__init__.py +7 -1
  62. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  63. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  64. ripperdoc/utils/prompt.py +1 -1
  65. ripperdoc/utils/safe_get_cwd.py +5 -2
  66. ripperdoc/utils/session_history.py +38 -19
  67. ripperdoc/utils/todo.py +6 -2
  68. ripperdoc/utils/token_estimation.py +4 -3
  69. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
  70. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  71. ripperdoc-0.2.4.dist-info/RECORD +0 -99
  72. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  73. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  74. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  75. {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
@@ -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
@@ -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 Exception: # pragma: no cover - handled gracefully at runtime
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.exception("[mcp] MCP SDK not available at import time")
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 Exception:
93
- logger.exception(
94
- "[mcp] Failed to coerce env/header value to string",
95
- 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},
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 Exception as exc: # pragma: no cover - network/process errors
370
- logger.exception(
371
- "Failed to connect to MCP server",
372
- 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},
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
- return _runtime_var.get()
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
- 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)})
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 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)
@@ -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 Exception:
173
- logger.exception(
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 Exception as exc:
229
- logger.exception(
230
- "Failed to estimate tokens for tool schema",
231
- 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)},
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 Exception:
403
- 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")
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 Exception as exc:
440
- 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
+ )
441
446
 
442
447
 
443
448
  def _normalize_tool_use_id(block: Any) -> str:
@@ -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 Exception:
79
- logger.exception("[_content_block_to_openai] Failed to serialize tool arguments")
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 Exception:
210
+ except (AttributeError, TypeError, ValueError) as exc:
191
211
  # Fallback: keep as-is if conversion fails
192
- logger.exception("[create_user_message] Failed to normalize tool_use_result")
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]]], cost_usd: float = 0.0, duration_ms: float = 0.0
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(role=MessageRole.ASSISTANT, content=message_content)
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 Exception:
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 = []