ripperdoc 0.2.0__py3-none-any.whl → 0.2.2__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 (51) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +66 -8
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +22 -0
  5. ripperdoc/cli/commands/context_cmd.py +11 -1
  6. ripperdoc/cli/commands/doctor_cmd.py +200 -0
  7. ripperdoc/cli/commands/memory_cmd.py +209 -0
  8. ripperdoc/cli/commands/models_cmd.py +25 -0
  9. ripperdoc/cli/commands/tasks_cmd.py +27 -0
  10. ripperdoc/cli/ui/rich_ui.py +156 -9
  11. ripperdoc/core/agents.py +4 -2
  12. ripperdoc/core/config.py +48 -3
  13. ripperdoc/core/default_tools.py +16 -2
  14. ripperdoc/core/permissions.py +19 -0
  15. ripperdoc/core/query.py +231 -297
  16. ripperdoc/core/query_utils.py +537 -0
  17. ripperdoc/core/system_prompt.py +2 -1
  18. ripperdoc/core/tool.py +13 -0
  19. ripperdoc/tools/background_shell.py +9 -3
  20. ripperdoc/tools/bash_tool.py +15 -0
  21. ripperdoc/tools/file_edit_tool.py +7 -0
  22. ripperdoc/tools/file_read_tool.py +7 -0
  23. ripperdoc/tools/file_write_tool.py +7 -0
  24. ripperdoc/tools/glob_tool.py +55 -15
  25. ripperdoc/tools/grep_tool.py +7 -0
  26. ripperdoc/tools/ls_tool.py +242 -73
  27. ripperdoc/tools/mcp_tools.py +32 -10
  28. ripperdoc/tools/multi_edit_tool.py +11 -0
  29. ripperdoc/tools/notebook_edit_tool.py +6 -3
  30. ripperdoc/tools/task_tool.py +7 -0
  31. ripperdoc/tools/todo_tool.py +159 -25
  32. ripperdoc/tools/tool_search_tool.py +9 -0
  33. ripperdoc/utils/git_utils.py +276 -0
  34. ripperdoc/utils/json_utils.py +28 -0
  35. ripperdoc/utils/log.py +130 -29
  36. ripperdoc/utils/mcp.py +71 -6
  37. ripperdoc/utils/memory.py +14 -1
  38. ripperdoc/utils/message_compaction.py +26 -5
  39. ripperdoc/utils/messages.py +63 -4
  40. ripperdoc/utils/output_utils.py +36 -9
  41. ripperdoc/utils/permissions/path_validation_utils.py +6 -0
  42. ripperdoc/utils/safe_get_cwd.py +4 -0
  43. ripperdoc/utils/session_history.py +27 -9
  44. ripperdoc/utils/todo.py +2 -2
  45. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/METADATA +4 -2
  46. ripperdoc-0.2.2.dist-info/RECORD +86 -0
  47. ripperdoc-0.2.0.dist-info/RECORD +0 -81
  48. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/WHEEL +0 -0
  49. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/entry_points.txt +0 -0
  50. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/licenses/LICENSE +0 -0
  51. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,537 @@
1
+ """Utility helpers for query handling, tool schemas, and message normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any, Dict, List, Optional, Union
8
+ from uuid import uuid4
9
+
10
+ from json_repair import repair_json
11
+ from pydantic import ValidationError
12
+
13
+ from ripperdoc.core.config import ModelProfile, ProviderType, get_global_config
14
+ from ripperdoc.core.tool import Tool, build_tool_description, tool_input_examples
15
+ from ripperdoc.utils.json_utils import safe_parse_json
16
+ from ripperdoc.utils.log import get_logger
17
+ from ripperdoc.utils.messages import (
18
+ AssistantMessage,
19
+ MessageContent,
20
+ ProgressMessage,
21
+ UserMessage,
22
+ create_assistant_message,
23
+ create_user_message,
24
+ )
25
+
26
+ logger = get_logger()
27
+
28
+
29
+ def _safe_int(value: Any) -> int:
30
+ """Best-effort int conversion for usage counters."""
31
+ try:
32
+ if value is None:
33
+ return 0
34
+ return int(value)
35
+ except (TypeError, ValueError):
36
+ return 0
37
+
38
+
39
+ def _get_usage_field(usage: Any, field: str) -> int:
40
+ """Fetch a usage field from either a dict or object."""
41
+ if usage is None:
42
+ return 0
43
+ if isinstance(usage, dict):
44
+ return _safe_int(usage.get(field))
45
+ return _safe_int(getattr(usage, field, 0))
46
+
47
+
48
+ def anthropic_usage_tokens(usage: Any) -> Dict[str, int]:
49
+ """Extract token counts from an Anthropic response usage payload."""
50
+ return {
51
+ "input_tokens": _get_usage_field(usage, "input_tokens"),
52
+ "output_tokens": _get_usage_field(usage, "output_tokens"),
53
+ "cache_read_input_tokens": _get_usage_field(usage, "cache_read_input_tokens"),
54
+ "cache_creation_input_tokens": _get_usage_field(usage, "cache_creation_input_tokens"),
55
+ }
56
+
57
+
58
+ def openai_usage_tokens(usage: Any) -> Dict[str, int]:
59
+ """Extract token counts from an OpenAI-compatible response usage payload."""
60
+ prompt_details = None
61
+ if isinstance(usage, dict):
62
+ prompt_details = usage.get("prompt_tokens_details")
63
+ else:
64
+ prompt_details = getattr(usage, "prompt_tokens_details", None)
65
+
66
+ cache_read_tokens = _get_usage_field(prompt_details, "cached_tokens") if prompt_details else 0
67
+
68
+ return {
69
+ "input_tokens": _get_usage_field(usage, "prompt_tokens"),
70
+ "output_tokens": _get_usage_field(usage, "completion_tokens"),
71
+ "cache_read_input_tokens": cache_read_tokens,
72
+ "cache_creation_input_tokens": 0,
73
+ }
74
+
75
+
76
+ def resolve_model_profile(model: str) -> ModelProfile:
77
+ """Resolve a model pointer to a concrete profile or raise if missing."""
78
+ config = get_global_config()
79
+ profile_name = getattr(config.model_pointers, model, None) or model
80
+ model_profile = config.model_profiles.get(profile_name)
81
+ if model_profile is None:
82
+ fallback_profile = getattr(config.model_pointers, "main", "default")
83
+ model_profile = config.model_profiles.get(fallback_profile)
84
+ if not model_profile:
85
+ raise ValueError(f"No model profile found for pointer: {model}")
86
+ return model_profile
87
+
88
+
89
+ def determine_tool_mode(model_profile: ModelProfile) -> str:
90
+ """Return configured tool mode for provider."""
91
+ if model_profile.provider != ProviderType.OPENAI_COMPATIBLE:
92
+ return "native"
93
+ configured = getattr(model_profile, "openai_tool_mode", "native") or "native"
94
+ configured = configured.lower()
95
+ if configured not in {"native", "text"}:
96
+ configured = getattr(get_global_config(), "openai_tool_mode", "native") or "native"
97
+ configured = configured.lower()
98
+ return configured if configured in {"native", "text"} else "native"
99
+
100
+
101
+ def _parse_text_mode_json_blocks(text: str) -> Optional[List[Dict[str, Any]]]:
102
+ """Parse a JSON code block or raw JSON string into content blocks for text mode."""
103
+ if not text or not isinstance(text, str):
104
+ return None
105
+
106
+ code_blocks = re.findall(
107
+ r"```(?:\s*json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE
108
+ )
109
+ candidates = [blk.strip() for blk in code_blocks if blk.strip()]
110
+
111
+ def _normalize_blocks(parsed: Any) -> Optional[List[Dict[str, Any]]]:
112
+ raw_blocks = parsed if isinstance(parsed, list) else [parsed]
113
+ normalized: List[Dict[str, Any]] = []
114
+ for raw in raw_blocks:
115
+ if not isinstance(raw, dict):
116
+ continue
117
+ block_type = raw.get("type")
118
+ if block_type == "text":
119
+ text_value = raw.get("text") or raw.get("content")
120
+ if isinstance(text_value, str) and text_value:
121
+ normalized.append({"type": "text", "text": text_value})
122
+ elif block_type == "tool_use":
123
+ tool_name = raw.get("tool") or raw.get("name")
124
+ if not isinstance(tool_name, str) or not tool_name:
125
+ continue
126
+ tool_use_id = raw.get("tool_use_id") or raw.get("id") or str(uuid4())
127
+ input_value = raw.get("input") or {}
128
+ if not isinstance(input_value, dict):
129
+ input_value = _normalize_tool_args(input_value)
130
+ normalized.append(
131
+ {
132
+ "type": "tool_use",
133
+ "tool_use_id": str(tool_use_id),
134
+ "name": tool_name,
135
+ "input": input_value,
136
+ }
137
+ )
138
+ return normalized if normalized else None
139
+
140
+ last_error: Optional[str] = None
141
+
142
+ for candidate in candidates:
143
+ if not candidate:
144
+ continue
145
+
146
+ parsed: Any = None
147
+ try:
148
+ parsed = json.loads(candidate)
149
+ except json.JSONDecodeError as exc:
150
+ last_error = str(exc)
151
+ parsed = repair_json(candidate, return_objects=True, ensure_ascii=False)
152
+
153
+ if parsed is None or parsed == "":
154
+ continue
155
+
156
+ normalized = _normalize_blocks(parsed)
157
+ if normalized:
158
+ return normalized
159
+
160
+ last_error = "Parsed JSON did not contain valid content blocks."
161
+
162
+ if last_error:
163
+ error_text = (
164
+ f"JSON parsing failed: {last_error} "
165
+ "Please resend a valid JSON array of content blocks inside a ```json``` code block."
166
+ )
167
+ return [{"type": "text", "text": error_text}]
168
+
169
+ return None
170
+
171
+
172
+ def _tool_prompt_for_text_mode(tools: List[Tool[Any, Any]]) -> str:
173
+ """Build a system hint describing available tools and the expected JSON format."""
174
+ if not tools:
175
+ return ""
176
+
177
+ lines = [
178
+ "You are in text-only tool mode. Tools are not auto-invoked by the API.",
179
+ "Respond with one Markdown `json` code block containing a JSON array of content blocks.",
180
+ 'Each block must include `type`; use {"type": "text", "text": "<message>"} for text and '
181
+ '{"type": "tool_use", "tool_use_id": "<tool_id>", "tool": "<tool_name>", "input": { ... required params ... }} '
182
+ "for tool calls. Add multiple `tool_use` blocks if you need multiple tools.",
183
+ "Include your natural language reply as a `text` block, followed by any `tool_use` blocks.",
184
+ "Only include the JSON array inside the code block - no extra prose.",
185
+ "Available tools:",
186
+ ]
187
+
188
+ for tool in tools:
189
+ required_fields: List[str] = []
190
+ try:
191
+ for fname, finfo in getattr(tool.input_schema, "model_fields", {}).items():
192
+ is_req = False
193
+ if hasattr(finfo, "is_required"):
194
+ try:
195
+ is_req = bool(finfo.is_required())
196
+ except Exception:
197
+ is_req = False
198
+ required_fields.append(f"{fname}{' (required)' if is_req else ''}")
199
+ except Exception:
200
+ required_fields = []
201
+
202
+ required_str = ", ".join(required_fields) if required_fields else "see input schema"
203
+ lines.append(f"- {tool.name}: fields {required_str}")
204
+
205
+ schema_json = ""
206
+ try:
207
+ schema_json = json.dumps(tool.input_schema.model_json_schema(), ensure_ascii=False, indent=2)
208
+ except (AttributeError, TypeError, ValueError) as exc:
209
+ logger.debug(
210
+ "[tool_prompt] Failed to render input_schema",
211
+ extra={"tool": getattr(tool, "name", None), "error": str(exc)},
212
+ )
213
+ if schema_json:
214
+ lines.append(" input schema (JSON):")
215
+ lines.append(" ```json")
216
+ lines.append(f" {schema_json}")
217
+ lines.append(" ```")
218
+
219
+ example_blocks = [
220
+ {"type": "text", "text": "好的,我来帮你查看一下README.md文件"},
221
+ {"type": "tool_use", "tool_use_id": "tool_id_000001", "tool": "View", "input": {"file_path": "README.md"}},
222
+ ]
223
+ lines.append("Example:")
224
+ lines.append("```json")
225
+ lines.append(json.dumps(example_blocks, ensure_ascii=False, indent=2))
226
+ lines.append("```")
227
+
228
+ return "\n".join(lines)
229
+
230
+
231
+ def text_mode_history(messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]]) -> List[Union[UserMessage, AssistantMessage]]:
232
+ """Convert a message history into text-only form for text mode."""
233
+
234
+ def _normalize_block(block: Any) -> Optional[Dict[str, Any]]:
235
+ blk = MessageContent(**block) if isinstance(block, dict) else block
236
+ btype = getattr(blk, "type", None)
237
+ if btype == "text":
238
+ text_val = getattr(blk, "text", None) or getattr(blk, "content", None) or ""
239
+ return {"type": "text", "text": text_val}
240
+ if btype == "tool_use":
241
+ return {
242
+ "type": "tool_use",
243
+ "tool_use_id": getattr(blk, "tool_use_id", None) or getattr(blk, "id", None) or "",
244
+ "tool": getattr(blk, "name", None) or "",
245
+ "input": getattr(blk, "input", None) or {},
246
+ }
247
+ if btype == "tool_result":
248
+ result_block: Dict[str, Any] = {
249
+ "type": "tool_result",
250
+ "tool_use_id": getattr(blk, "tool_use_id", None) or getattr(blk, "id", None) or "",
251
+ "text": getattr(blk, "text", None) or getattr(blk, "content", None) or "",
252
+ }
253
+ is_error = getattr(blk, "is_error", None)
254
+ if is_error is not None:
255
+ result_block["is_error"] = is_error
256
+ return result_block
257
+ text_val = getattr(blk, "text", None) or getattr(blk, "content", None)
258
+ if text_val is not None:
259
+ return {"type": "text", "text": text_val}
260
+ return None
261
+
262
+ converted: List[Union[UserMessage, AssistantMessage]] = []
263
+ for msg in messages:
264
+ msg_type = getattr(msg, "type", None)
265
+ if msg_type == "progress" or msg_type is None:
266
+ continue
267
+ content = getattr(getattr(msg, "message", None), "content", None)
268
+ text_content: Optional[str] = None
269
+ if isinstance(content, list):
270
+ normalized_blocks = []
271
+ for block in content:
272
+ block_type = getattr(block, "type", None) or (block.get("type") if isinstance(block, dict) else None)
273
+ block_text = getattr(block, "text", None) if hasattr(block, "text") else (
274
+ block.get("text") if isinstance(block, dict) else None
275
+ )
276
+ if block_type == "text" and isinstance(block_text, str):
277
+ parsed_nested = _parse_text_mode_json_blocks(block_text)
278
+ if parsed_nested:
279
+ normalized_blocks.extend(parsed_nested)
280
+ continue
281
+ norm = _normalize_block(block)
282
+ if norm:
283
+ normalized_blocks.append(norm)
284
+ if normalized_blocks:
285
+ json_payload = json.dumps(normalized_blocks, ensure_ascii=False, indent=2)
286
+ text_content = f"```json\n{json_payload}\n```"
287
+ elif isinstance(content, str):
288
+ parsed_blocks = _parse_text_mode_json_blocks(content)
289
+ if parsed_blocks:
290
+ text_content = f"```json\n{json.dumps(parsed_blocks, ensure_ascii=False, indent=2)}\n```"
291
+ else:
292
+ text_content = content
293
+ else:
294
+ text_content = content if isinstance(content, str) else None
295
+ if not text_content:
296
+ continue
297
+ if msg_type == "user":
298
+ converted.append(create_user_message(text_content))
299
+ elif msg_type == "assistant":
300
+ converted.append(create_assistant_message(text_content))
301
+ return converted
302
+
303
+
304
+ def _maybe_convert_json_block_to_tool_use(content_blocks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
305
+ """Convert any text blocks containing JSON content to structured content blocks."""
306
+ if not content_blocks:
307
+ return content_blocks
308
+
309
+ new_blocks: List[Dict[str, Any]] = []
310
+ converted_count = 0
311
+
312
+ for block in content_blocks:
313
+ if block.get("type") != "text":
314
+ new_blocks.append(block)
315
+ continue
316
+
317
+ text = block.get("text")
318
+ if not isinstance(text, str):
319
+ new_blocks.append(block)
320
+ continue
321
+
322
+ parsed_blocks = _parse_text_mode_json_blocks(text)
323
+ if not parsed_blocks:
324
+ new_blocks.append(block)
325
+ continue
326
+
327
+ for parsed in parsed_blocks:
328
+ if parsed.get("type") == "tool_use":
329
+ new_blocks.append(
330
+ {
331
+ "type": "tool_use",
332
+ "tool_use_id": parsed.get("tool_use_id") or str(uuid4()),
333
+ "name": parsed.get("name") or parsed.get("tool"),
334
+ "input": parsed.get("input") or {},
335
+ }
336
+ )
337
+ elif parsed.get("type") == "text":
338
+ new_blocks.append({"type": "text", "text": parsed.get("text") or ""})
339
+ converted_count += 1
340
+
341
+ if converted_count:
342
+ logger.debug(
343
+ "[query_llm] Converting JSON code block to structured content blocks",
344
+ extra={"block_count": len(new_blocks)},
345
+ )
346
+ return new_blocks
347
+
348
+
349
+ def _normalize_tool_args(raw_args: Any) -> Dict[str, Any]:
350
+ """Ensure tool arguments are returned as a dict, handling double-encoded strings."""
351
+ candidate = raw_args
352
+
353
+ for _ in range(2):
354
+ if isinstance(candidate, dict):
355
+ return candidate
356
+ if isinstance(candidate, str):
357
+ candidate = safe_parse_json(candidate, log_error=False)
358
+ continue
359
+ break
360
+
361
+ if isinstance(candidate, dict):
362
+ return candidate
363
+
364
+ preview = str(raw_args)
365
+ preview = preview[:200] if len(preview) > 200 else preview
366
+ logger.debug(
367
+ "[query_llm] Tool arguments not a dict; defaulting to empty object",
368
+ extra={"preview": preview},
369
+ )
370
+ return {}
371
+
372
+
373
+ def build_full_system_prompt(
374
+ system_prompt: str, context: Dict[str, str], tool_mode: str, tools: List[Tool[Any, Any]]
375
+ ) -> str:
376
+ """Compose the final system prompt including context and tool hints."""
377
+ full_prompt = system_prompt
378
+ if context:
379
+ context_str = "\n".join(f"{k}: {v}" for k, v in context.items())
380
+ full_prompt = f"{system_prompt}\n\nContext:\n{context_str}"
381
+ if tool_mode == "text":
382
+ tool_hint = _tool_prompt_for_text_mode(tools)
383
+ if tool_hint:
384
+ full_prompt = f"{full_prompt}\n\n{tool_hint}"
385
+ return full_prompt
386
+
387
+
388
+ def log_openai_messages(normalized_messages: List[Dict[str, Any]]) -> None:
389
+ """Trace normalized messages for OpenAI calls to simplify debugging."""
390
+ summary_parts = []
391
+ for idx, message in enumerate(normalized_messages):
392
+ role = message.get("role")
393
+ tool_calls = message.get("tool_calls")
394
+ tool_call_id = message.get("tool_call_id")
395
+ ids = [tc.get("id") for tc in tool_calls] if tool_calls else []
396
+ summary_parts.append(
397
+ f"{idx}:{role}"
398
+ + (f" tool_calls={ids}" if ids else "")
399
+ + (f" tool_call_id={tool_call_id}" if tool_call_id else "")
400
+ )
401
+ logger.debug(f"[query_llm] OpenAI normalized messages: {' | '.join(summary_parts)}")
402
+
403
+
404
+ async def build_anthropic_tool_schemas(tools: List[Tool[Any, Any]]) -> List[Dict[str, Any]]:
405
+ """Render tool schemas in Anthropic format."""
406
+ schemas = []
407
+ for tool in tools:
408
+ description = await build_tool_description(tool, include_examples=True, max_examples=2)
409
+ schema: Dict[str, Any] = {
410
+ "name": tool.name,
411
+ "description": description,
412
+ "input_schema": tool.input_schema.model_json_schema(),
413
+ "defer_loading": bool(getattr(tool, "defer_loading", lambda: False)()),
414
+ }
415
+ examples = tool_input_examples(tool, limit=5)
416
+ if examples:
417
+ schema["input_examples"] = examples
418
+ schemas.append(schema)
419
+ return schemas
420
+
421
+
422
+ async def build_openai_tool_schemas(tools: List[Tool[Any, Any]]) -> List[Dict[str, Any]]:
423
+ """Render tool schemas in OpenAI function-calling format."""
424
+ openai_tools = []
425
+ for tool in tools:
426
+ description = await build_tool_description(tool, include_examples=True, max_examples=2)
427
+ openai_tools.append(
428
+ {
429
+ "type": "function",
430
+ "function": {
431
+ "name": tool.name,
432
+ "description": description,
433
+ "parameters": tool.input_schema.model_json_schema(),
434
+ },
435
+ }
436
+ )
437
+ return openai_tools
438
+
439
+
440
+ def content_blocks_from_anthropic_response(
441
+ response: Any, tool_mode: str
442
+ ) -> List[Dict[str, Any]]:
443
+ """Normalize Anthropic response content to our internal block format."""
444
+ blocks: List[Dict[str, Any]] = []
445
+ for block in getattr(response, "content", []) or []:
446
+ btype = getattr(block, "type", None)
447
+ if btype == "text":
448
+ blocks.append({"type": "text", "text": getattr(block, "text", "")})
449
+ elif btype == "tool_use":
450
+ raw_input = getattr(block, "input", {}) or {}
451
+ blocks.append(
452
+ {
453
+ "type": "tool_use",
454
+ "tool_use_id": getattr(block, "id", None) or str(uuid4()),
455
+ "name": getattr(block, "name", None),
456
+ "input": _normalize_tool_args(raw_input),
457
+ }
458
+ )
459
+
460
+ if tool_mode == "text":
461
+ blocks = _maybe_convert_json_block_to_tool_use(blocks)
462
+ return blocks
463
+
464
+
465
+ def content_blocks_from_openai_choice(choice: Any, tool_mode: str) -> List[Dict[str, Any]]:
466
+ """Normalize OpenAI-compatible choice to our internal block format."""
467
+ content_blocks = []
468
+ if getattr(choice.message, "content", None):
469
+ content_blocks.append({"type": "text", "text": choice.message.content})
470
+
471
+ if getattr(choice.message, "tool_calls", None):
472
+ for tool_call in choice.message.tool_calls:
473
+ raw_args = getattr(tool_call.function, "arguments", None)
474
+ parsed_args = safe_parse_json(raw_args)
475
+ if parsed_args is None and raw_args:
476
+ arg_preview = str(raw_args)
477
+ arg_preview = arg_preview[:200] if len(arg_preview) > 200 else arg_preview
478
+ logger.debug(
479
+ "[query_llm] Failed to parse tool arguments; falling back to empty dict",
480
+ extra={
481
+ "tool_call_id": getattr(tool_call, "id", None),
482
+ "tool_name": getattr(tool_call.function, "name", None),
483
+ "arguments_preview": arg_preview,
484
+ },
485
+ )
486
+ parsed_args = _normalize_tool_args(parsed_args if parsed_args is not None else raw_args)
487
+ content_blocks.append(
488
+ {
489
+ "type": "tool_use",
490
+ "tool_use_id": tool_call.id,
491
+ "name": tool_call.function.name,
492
+ "input": parsed_args,
493
+ }
494
+ )
495
+ elif tool_mode == "text":
496
+ content_blocks = _maybe_convert_json_block_to_tool_use(content_blocks)
497
+ return content_blocks
498
+
499
+
500
+ def extract_tool_use_blocks(
501
+ assistant_message: AssistantMessage,
502
+ ) -> List[MessageContent]:
503
+ """Return all tool_use blocks from an assistant message."""
504
+ content = getattr(assistant_message.message, "content", None)
505
+ if not isinstance(content, list):
506
+ return []
507
+
508
+ tool_blocks: List[MessageContent] = []
509
+ for block in content:
510
+ normalized = MessageContent(**block) if isinstance(block, dict) else block
511
+ if getattr(normalized, "type", None) == "tool_use":
512
+ tool_blocks.append(normalized)
513
+ return tool_blocks
514
+
515
+
516
+ def tool_result_message(
517
+ tool_use_id: str, text: str, is_error: bool = False, tool_use_result: Any = None
518
+ ) -> UserMessage:
519
+ """Build a user message representing a tool_result block."""
520
+ block: Dict[str, Any] = {"type": "tool_result", "tool_use_id": tool_use_id, "text": text}
521
+ if is_error:
522
+ block["is_error"] = True
523
+ return create_user_message([block], tool_use_result=tool_use_result)
524
+
525
+
526
+ def format_pydantic_errors(error: ValidationError) -> str:
527
+ """Render a compact validation error summary."""
528
+ details = []
529
+ for err in error.errors():
530
+ loc: list[Any] = list(err.get("loc") or [])
531
+ loc_str = ".".join(str(part) for part in loc) if loc else ""
532
+ msg = err.get("msg") or ""
533
+ if loc_str and msg:
534
+ details.append(f"{loc_str}: {msg}")
535
+ elif msg:
536
+ details.append(msg)
537
+ return "; ".join(details) or str(error)
@@ -35,6 +35,7 @@ def _detect_git_repo(cwd: Path) -> bool:
35
35
  )
36
36
  return result.returncode == 0 and result.stdout.strip().lower() == "true"
37
37
  except Exception:
38
+ logger.exception("[system_prompt] Failed to detect git repository", extra={"cwd": str(cwd)})
38
39
  return False
39
40
 
40
41
 
@@ -381,7 +382,7 @@ def build_system_prompt(
381
382
  Provide detailed prompts so the agent can work autonomously and return a concise report."""
382
383
  ).strip()
383
384
  except Exception as exc:
384
- logger.error(f"Failed to load agent definitions: {exc}")
385
+ logger.exception("Failed to load agent definitions", extra={"error": str(exc)})
385
386
  agent_section = (
386
387
  "# Subagents\nTask tool available, but agent definitions could not be loaded."
387
388
  )
ripperdoc/core/tool.py CHANGED
@@ -8,6 +8,10 @@ import json
8
8
  from abc import ABC, abstractmethod
9
9
  from typing import Any, AsyncGenerator, Dict, List, Optional, TypeVar, Generic, Union
10
10
  from pydantic import BaseModel, ConfigDict, Field
11
+ from ripperdoc.utils.log import get_logger
12
+
13
+
14
+ logger = get_logger()
11
15
 
12
16
 
13
17
  class ToolResult(BaseModel):
@@ -37,6 +41,7 @@ class ToolUseContext(BaseModel):
37
41
  permission_checker: Optional[Any] = None
38
42
  read_file_timestamps: Dict[str, float] = {}
39
43
  tool_registry: Optional[Any] = None
44
+ abort_signal: Optional[Any] = None
40
45
  model_config = ConfigDict(arbitrary_types_allowed=True)
41
46
 
42
47
 
@@ -195,6 +200,10 @@ async def build_tool_description(
195
200
  if parts:
196
201
  return f"{description_text}\n\nInput examples:\n" + "\n\n".join(parts)
197
202
  except Exception:
203
+ logger.exception(
204
+ "[tool] Failed to build input example section",
205
+ extra={"tool": getattr(tool, 'name', None)},
206
+ )
198
207
  return description_text
199
208
 
200
209
  return description_text
@@ -210,5 +219,9 @@ def tool_input_examples(tool: Tool[Any, Any], limit: int = 5) -> List[Dict[str,
210
219
  try:
211
220
  results.append(example.example)
212
221
  except Exception:
222
+ logger.exception(
223
+ "[tool] Failed to format tool input example",
224
+ extra={"tool": getattr(tool, 'name', None)},
225
+ )
213
226
  continue
214
227
  return results
@@ -95,7 +95,10 @@ async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
95
95
  sink.append(text)
96
96
  except Exception as exc:
97
97
  # Best effort; ignore stream read errors to avoid leaking tasks.
98
- logger.debug(f"Stream pump error for background task: {exc}")
98
+ logger.debug(
99
+ f"Stream pump error for background task: {exc}",
100
+ exc_info=True,
101
+ )
99
102
 
100
103
 
101
104
  async def _finalize_reader_tasks(reader_tasks: List[asyncio.Task], timeout: float = 1.0) -> None:
@@ -133,8 +136,11 @@ async def _monitor_task(task: BackgroundTask) -> None:
133
136
  task.exit_code = -1
134
137
  except asyncio.CancelledError:
135
138
  return
136
- except Exception as exc:
137
- logger.error(f"Error monitoring background task {task.id}: {exc}")
139
+ except Exception:
140
+ logger.exception(
141
+ "Error monitoring background task",
142
+ extra={"task_id": task.id, "command": task.command},
143
+ )
138
144
  with _tasks_lock:
139
145
  task.exit_code = -1
140
146
  finally:
@@ -49,6 +49,9 @@ from ripperdoc.utils.permissions.tool_permission_utils import (
49
49
  from ripperdoc.utils.permissions import PermissionDecision
50
50
  from ripperdoc.utils.sandbox_utils import create_sandbox_wrapper, is_sandbox_available
51
51
  from ripperdoc.utils.safe_get_cwd import get_original_cwd, safe_get_cwd
52
+ from ripperdoc.utils.log import get_logger
53
+
54
+ logger = get_logger()
52
55
 
53
56
 
54
57
  DEFAULT_TIMEOUT_MS = get_bash_default_timeout_ms()
@@ -516,6 +519,10 @@ build projects, run tests, and interact with the file system."""
516
519
  final_command = wrapper.final_command
517
520
  sandbox_cleanup = wrapper.cleanup
518
521
  except Exception as exc:
522
+ logger.exception(
523
+ "[bash_tool] Failed to enable sandbox",
524
+ extra={"command": effective_command, "error": str(exc)},
525
+ )
519
526
  error_output = BashToolOutput(
520
527
  stdout="",
521
528
  stderr=f"Failed to enable sandbox: {exc}",
@@ -561,6 +568,10 @@ build projects, run tests, and interact with the file system."""
561
568
  try:
562
569
  from ripperdoc.tools.background_shell import start_background_command
563
570
  except Exception as e: # pragma: no cover - defensive import
571
+ logger.exception(
572
+ "[bash_tool] Failed to import background shell runner",
573
+ extra={"command": effective_command},
574
+ )
564
575
  error_output = BashToolOutput(
565
576
  stdout="",
566
577
  stderr=f"Failed to start background task: {str(e)}",
@@ -767,6 +778,10 @@ build projects, run tests, and interact with the file system."""
767
778
  )
768
779
 
769
780
  except Exception as e:
781
+ logger.exception(
782
+ "[bash_tool] Error executing command",
783
+ extra={"command": effective_command, "error": str(e)},
784
+ )
770
785
  error_output = BashToolOutput(
771
786
  stdout="",
772
787
  stderr=f"Error executing command: {str(e)}",
@@ -15,6 +15,9 @@ from ripperdoc.core.tool import (
15
15
  ToolUseExample,
16
16
  ValidationResult,
17
17
  )
18
+ from ripperdoc.utils.log import get_logger
19
+
20
+ logger = get_logger()
18
21
 
19
22
 
20
23
  class FileEditToolInput(BaseModel):
@@ -268,6 +271,10 @@ match exactly (including whitespace and indentation)."""
268
271
  )
269
272
 
270
273
  except Exception as e:
274
+ logger.exception(
275
+ "[file_edit_tool] Error editing file",
276
+ extra={"file_path": input_data.file_path, "error": str(e)},
277
+ )
271
278
  error_output = FileEditToolOutput(
272
279
  file_path=input_data.file_path,
273
280
  replacements_made=0,