ripperdoc 0.2.8__py3-none-any.whl → 0.2.10__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 (94) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +257 -123
  3. ripperdoc/cli/commands/__init__.py +2 -1
  4. ripperdoc/cli/commands/agents_cmd.py +138 -8
  5. ripperdoc/cli/commands/clear_cmd.py +9 -4
  6. ripperdoc/cli/commands/config_cmd.py +1 -1
  7. ripperdoc/cli/commands/context_cmd.py +3 -2
  8. ripperdoc/cli/commands/doctor_cmd.py +18 -4
  9. ripperdoc/cli/commands/exit_cmd.py +1 -0
  10. ripperdoc/cli/commands/hooks_cmd.py +27 -53
  11. ripperdoc/cli/commands/models_cmd.py +27 -10
  12. ripperdoc/cli/commands/permissions_cmd.py +27 -9
  13. ripperdoc/cli/commands/resume_cmd.py +9 -3
  14. ripperdoc/cli/commands/stats_cmd.py +244 -0
  15. ripperdoc/cli/commands/status_cmd.py +4 -4
  16. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  17. ripperdoc/cli/ui/file_mention_completer.py +2 -1
  18. ripperdoc/cli/ui/interrupt_handler.py +2 -3
  19. ripperdoc/cli/ui/message_display.py +4 -2
  20. ripperdoc/cli/ui/panels.py +1 -0
  21. ripperdoc/cli/ui/provider_options.py +247 -0
  22. ripperdoc/cli/ui/rich_ui.py +403 -81
  23. ripperdoc/cli/ui/spinner.py +54 -18
  24. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  25. ripperdoc/cli/ui/tool_renderers.py +8 -2
  26. ripperdoc/cli/ui/wizard.py +213 -0
  27. ripperdoc/core/agents.py +19 -6
  28. ripperdoc/core/config.py +51 -17
  29. ripperdoc/core/custom_commands.py +7 -6
  30. ripperdoc/core/default_tools.py +101 -12
  31. ripperdoc/core/hooks/config.py +1 -3
  32. ripperdoc/core/hooks/events.py +27 -28
  33. ripperdoc/core/hooks/executor.py +4 -6
  34. ripperdoc/core/hooks/integration.py +12 -21
  35. ripperdoc/core/hooks/llm_callback.py +59 -0
  36. ripperdoc/core/hooks/manager.py +40 -15
  37. ripperdoc/core/permissions.py +118 -12
  38. ripperdoc/core/providers/anthropic.py +109 -36
  39. ripperdoc/core/providers/gemini.py +70 -5
  40. ripperdoc/core/providers/openai.py +89 -24
  41. ripperdoc/core/query.py +273 -68
  42. ripperdoc/core/query_utils.py +2 -0
  43. ripperdoc/core/skills.py +9 -3
  44. ripperdoc/core/system_prompt.py +4 -2
  45. ripperdoc/core/tool.py +17 -8
  46. ripperdoc/sdk/client.py +79 -4
  47. ripperdoc/tools/ask_user_question_tool.py +5 -3
  48. ripperdoc/tools/background_shell.py +307 -135
  49. ripperdoc/tools/bash_output_tool.py +1 -1
  50. ripperdoc/tools/bash_tool.py +63 -24
  51. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  52. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  53. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  54. ripperdoc/tools/file_edit_tool.py +167 -54
  55. ripperdoc/tools/file_read_tool.py +28 -4
  56. ripperdoc/tools/file_write_tool.py +13 -10
  57. ripperdoc/tools/glob_tool.py +3 -2
  58. ripperdoc/tools/grep_tool.py +3 -2
  59. ripperdoc/tools/kill_bash_tool.py +1 -1
  60. ripperdoc/tools/ls_tool.py +1 -1
  61. ripperdoc/tools/lsp_tool.py +615 -0
  62. ripperdoc/tools/mcp_tools.py +13 -10
  63. ripperdoc/tools/multi_edit_tool.py +8 -7
  64. ripperdoc/tools/notebook_edit_tool.py +7 -4
  65. ripperdoc/tools/skill_tool.py +1 -1
  66. ripperdoc/tools/task_tool.py +519 -69
  67. ripperdoc/tools/todo_tool.py +2 -2
  68. ripperdoc/tools/tool_search_tool.py +3 -2
  69. ripperdoc/utils/conversation_compaction.py +9 -5
  70. ripperdoc/utils/file_watch.py +214 -5
  71. ripperdoc/utils/json_utils.py +2 -1
  72. ripperdoc/utils/lsp.py +806 -0
  73. ripperdoc/utils/mcp.py +11 -3
  74. ripperdoc/utils/memory.py +4 -2
  75. ripperdoc/utils/message_compaction.py +21 -7
  76. ripperdoc/utils/message_formatting.py +14 -7
  77. ripperdoc/utils/messages.py +126 -67
  78. ripperdoc/utils/path_ignore.py +35 -8
  79. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  80. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  81. ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
  82. ripperdoc/utils/safe_get_cwd.py +2 -1
  83. ripperdoc/utils/session_heatmap.py +244 -0
  84. ripperdoc/utils/session_history.py +13 -6
  85. ripperdoc/utils/session_stats.py +293 -0
  86. ripperdoc/utils/todo.py +2 -1
  87. ripperdoc/utils/token_estimation.py +6 -1
  88. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
  89. ripperdoc-0.2.10.dist-info/RECORD +129 -0
  90. ripperdoc-0.2.8.dist-info/RECORD +0 -121
  91. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
  92. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
  93. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
  94. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
ripperdoc/core/query.py CHANGED
@@ -26,7 +26,7 @@ from typing import (
26
26
 
27
27
  from pydantic import ValidationError
28
28
 
29
- from ripperdoc.core.config import provider_protocol
29
+ from ripperdoc.core.config import ModelProfile, provider_protocol
30
30
  from ripperdoc.core.providers import ProviderClient, get_provider_client
31
31
  from ripperdoc.core.permissions import PermissionResult
32
32
  from ripperdoc.core.hooks.manager import hook_manager
@@ -43,7 +43,11 @@ from ripperdoc.core.query_utils import (
43
43
  from ripperdoc.core.tool import Tool, ToolProgress, ToolResult, ToolUseContext
44
44
  from ripperdoc.utils.coerce import parse_optional_int
45
45
  from ripperdoc.utils.context_length_errors import detect_context_length_error
46
- from ripperdoc.utils.file_watch import ChangedFileNotice, FileSnapshot, detect_changed_files
46
+ from ripperdoc.utils.file_watch import (
47
+ BoundedFileCache,
48
+ ChangedFileNotice,
49
+ detect_changed_files,
50
+ )
47
51
  from ripperdoc.utils.log import get_logger
48
52
  from ripperdoc.utils.messages import (
49
53
  AssistantMessage,
@@ -65,6 +69,42 @@ DEFAULT_REQUEST_TIMEOUT_SEC = float(os.getenv("RIPPERDOC_API_TIMEOUT", "120"))
65
69
  MAX_LLM_RETRIES = int(os.getenv("RIPPERDOC_MAX_RETRIES", "10"))
66
70
 
67
71
 
72
+ def infer_thinking_mode(model_profile: ModelProfile) -> Optional[str]:
73
+ """Infer thinking mode from ModelProfile if not explicitly configured.
74
+
75
+ This function checks the model_profile.thinking_mode first. If it's set,
76
+ returns that value. Otherwise, auto-detects based on api_base and model name.
77
+
78
+ Args:
79
+ model_profile: The model profile to analyze
80
+
81
+ Returns:
82
+ Thinking mode string ("deepseek", "qwen", "openrouter", "gemini_openai")
83
+ or None if no thinking mode should be applied.
84
+ """
85
+ # Use explicit config if set
86
+ explicit_mode = model_profile.thinking_mode
87
+ if explicit_mode:
88
+ return explicit_mode
89
+
90
+ # Auto-detect based on API base and model name
91
+ base = (model_profile.api_base or "").lower()
92
+ name = (model_profile.model or "").lower()
93
+
94
+ if "deepseek" in base or name.startswith("deepseek"):
95
+ return "deepseek"
96
+ if "dashscope" in base or "qwen" in name:
97
+ return "qwen"
98
+ if "openrouter.ai" in base:
99
+ return "openrouter"
100
+ if "generativelanguage.googleapis.com" in base or name.startswith("gemini"):
101
+ return "gemini_openai"
102
+ if "openai" in base:
103
+ return "openai"
104
+
105
+ return None
106
+
107
+
68
108
  def _resolve_tool(
69
109
  tool_registry: "ToolRegistry", tool_name: str, tool_use_id: str
70
110
  ) -> tuple[Optional[Tool[Any, Any]], Optional[UserMessage]]:
@@ -95,7 +135,7 @@ async def _check_tool_permissions(
95
135
  parsed_input: Any,
96
136
  query_context: "QueryContext",
97
137
  can_use_tool_fn: Optional[ToolPermissionCallable],
98
- ) -> tuple[bool, Optional[str]]:
138
+ ) -> tuple[bool, Optional[str], Optional[Any]]:
99
139
  """Evaluate whether a tool call is allowed."""
100
140
  try:
101
141
  if can_use_tool_fn is not None:
@@ -103,14 +143,16 @@ async def _check_tool_permissions(
103
143
  if inspect.isawaitable(decision):
104
144
  decision = await decision
105
145
  if isinstance(decision, PermissionResult):
106
- return decision.result, decision.message
146
+ return decision.result, decision.message, decision.updated_input
107
147
  if isinstance(decision, dict) and "result" in decision:
108
- return bool(decision.get("result")), decision.get("message")
148
+ return bool(decision.get("result")), decision.get("message"), decision.get(
149
+ "updated_input"
150
+ )
109
151
  if isinstance(decision, tuple) and len(decision) == 2:
110
- return bool(decision[0]), decision[1]
111
- return bool(decision), None
152
+ return bool(decision[0]), decision[1], None
153
+ return bool(decision), None, None
112
154
 
113
- if query_context.safe_mode and tool.needs_permissions(parsed_input):
155
+ if not query_context.yolo_mode and tool.needs_permissions(parsed_input):
114
156
  loop = asyncio.get_running_loop()
115
157
  input_preview = (
116
158
  parsed_input.model_dump()
@@ -119,15 +161,15 @@ async def _check_tool_permissions(
119
161
  )
120
162
  prompt = f"Allow tool '{tool.name}' with input {input_preview}? [y/N]: "
121
163
  response = await loop.run_in_executor(None, lambda: input(prompt))
122
- return response.strip().lower() in ("y", "yes"), None
164
+ return response.strip().lower() in ("y", "yes"), None, None
123
165
 
124
- return True, None
166
+ return True, None, None
125
167
  except (TypeError, AttributeError, ValueError) as exc:
126
168
  logger.warning(
127
169
  f"Error checking permissions for tool '{tool.name}': {type(exc).__name__}: {exc}",
128
170
  extra={"tool": getattr(tool, "name", None), "error_type": type(exc).__name__},
129
171
  )
130
- return False, None
172
+ return False, None, None
131
173
 
132
174
 
133
175
  def _format_changed_file_notice(notices: List[ChangedFileNotice]) -> str:
@@ -146,6 +188,18 @@ def _format_changed_file_notice(notices: List[ChangedFileNotice]) -> str:
146
188
  return "\n".join(lines)
147
189
 
148
190
 
191
+ def _append_hook_context(context: Dict[str, str], label: str, payload: Optional[str]) -> None:
192
+ """Append hook-supplied context to the shared context dict."""
193
+ if not payload:
194
+ return
195
+ key = f"Hook:{label}"
196
+ existing = context.get(key)
197
+ if existing:
198
+ context[key] = f"{existing}\n{payload}"
199
+ else:
200
+ context[key] = payload
201
+
202
+
149
203
  async def _run_tool_use_generator(
150
204
  tool: Tool[Any, Any],
151
205
  tool_use_id: str,
@@ -153,13 +207,16 @@ async def _run_tool_use_generator(
153
207
  parsed_input: Any,
154
208
  sibling_ids: set[str],
155
209
  tool_context: ToolUseContext,
210
+ context: Dict[str, str],
156
211
  ) -> AsyncGenerator[Union[UserMessage, ProgressMessage], None]:
157
212
  """Execute a single tool_use and yield progress/results."""
158
213
  # Get tool input as dict for hooks
159
214
  tool_input_dict = (
160
215
  parsed_input.model_dump()
161
216
  if hasattr(parsed_input, "model_dump")
162
- else dict(parsed_input) if isinstance(parsed_input, dict) else {}
217
+ else dict(parsed_input)
218
+ if isinstance(parsed_input, dict)
219
+ else {}
163
220
  )
164
221
 
165
222
  # Run PreToolUse hooks
@@ -197,9 +254,11 @@ async def _run_tool_use_generator(
197
254
  f"[query] PreToolUse hook added context for {tool_name}",
198
255
  extra={"context": pre_result.additional_context[:100]},
199
256
  )
257
+ _append_hook_context(context, f"PreToolUse:{tool_name}", pre_result.additional_context)
258
+ if pre_result.system_message:
259
+ _append_hook_context(context, f"PreToolUse:{tool_name}:system", pre_result.system_message)
200
260
 
201
261
  tool_output = None
202
- tool_error = None
203
262
 
204
263
  try:
205
264
  async for output in tool.call(parsed_input, tool_context):
@@ -224,18 +283,28 @@ async def _run_tool_use_generator(
224
283
  except CancelledError:
225
284
  raise # Don't suppress task cancellation
226
285
  except (RuntimeError, ValueError, TypeError, OSError, IOError, AttributeError, KeyError) as exc:
227
- tool_error = str(exc)
228
286
  logger.warning(
229
287
  "Error executing tool '%s': %s: %s",
230
- tool_name, type(exc).__name__, exc,
288
+ tool_name,
289
+ type(exc).__name__,
290
+ exc,
231
291
  extra={"tool": tool_name, "tool_use_id": tool_use_id},
232
292
  )
233
293
  yield tool_result_message(tool_use_id, f"Error executing tool: {str(exc)}", is_error=True)
234
294
 
235
295
  # Run PostToolUse hooks
236
- await hook_manager.run_post_tool_use_async(
296
+ post_result = await hook_manager.run_post_tool_use_async(
237
297
  tool_name, tool_input_dict, tool_response=tool_output, tool_use_id=tool_use_id
238
298
  )
299
+ if post_result.additional_context:
300
+ _append_hook_context(context, f"PostToolUse:{tool_name}", post_result.additional_context)
301
+ if post_result.system_message:
302
+ _append_hook_context(
303
+ context, f"PostToolUse:{tool_name}:system", post_result.system_message
304
+ )
305
+ if post_result.should_block:
306
+ reason = post_result.block_reason or post_result.stop_reason or "Blocked by hook."
307
+ yield create_user_message(f"PostToolUse hook blocked: {reason}")
239
308
 
240
309
 
241
310
  def _group_tool_calls_by_concurrency(prepared_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@@ -307,6 +376,7 @@ async def _run_concurrent_tool_uses(
307
376
  """Drain multiple tool generators concurrently and stream outputs."""
308
377
  if not generators:
309
378
  return
379
+ yield # Make this a proper async generator that yields nothing
310
380
 
311
381
  queue: asyncio.Queue[Optional[Union[UserMessage, ProgressMessage]]] = asyncio.Queue()
312
382
 
@@ -321,7 +391,8 @@ async def _run_concurrent_tool_uses(
321
391
  except (RuntimeError, ValueError, TypeError) as exc:
322
392
  logger.warning(
323
393
  "[query] Error while consuming tool generator: %s: %s",
324
- type(exc).__name__, exc,
394
+ type(exc).__name__,
395
+ exc,
325
396
  )
326
397
  finally:
327
398
  await queue.put(None)
@@ -374,7 +445,8 @@ class ToolRegistry:
374
445
  except (TypeError, AttributeError) as exc:
375
446
  logger.warning(
376
447
  "[tool_registry] Tool.defer_loading failed: %s: %s",
377
- type(exc).__name__, exc,
448
+ type(exc).__name__,
449
+ exc,
378
450
  extra={"tool": getattr(tool, "name", None)},
379
451
  )
380
452
  deferred = False
@@ -461,7 +533,8 @@ def _apply_skill_context_updates(
461
533
  except (KeyError, ValueError, TypeError) as exc:
462
534
  logger.warning(
463
535
  "[query] Failed to activate tools listed in skill output: %s: %s",
464
- type(exc).__name__, exc,
536
+ type(exc).__name__,
537
+ exc,
465
538
  )
466
539
 
467
540
  model_hint = data.get("model")
@@ -487,25 +560,43 @@ def _apply_skill_context_updates(
487
560
  class QueryContext:
488
561
  """Context for a query session."""
489
562
 
563
+ # Thresholds for memory warnings
564
+ MESSAGE_COUNT_WARNING_THRESHOLD = int(
565
+ os.getenv("RIPPERDOC_MESSAGE_WARNING_THRESHOLD", "500")
566
+ )
567
+ MESSAGE_COUNT_CRITICAL_THRESHOLD = int(
568
+ os.getenv("RIPPERDOC_MESSAGE_CRITICAL_THRESHOLD", "1000")
569
+ )
570
+
490
571
  def __init__(
491
572
  self,
492
573
  tools: List[Tool[Any, Any]],
493
574
  max_thinking_tokens: int = 0,
494
- safe_mode: bool = False,
575
+ yolo_mode: bool = False,
495
576
  model: str = "main",
496
577
  verbose: bool = False,
497
578
  pause_ui: Optional[Callable[[], None]] = None,
498
579
  resume_ui: Optional[Callable[[], None]] = None,
580
+ stop_hook: str = "stop",
581
+ file_cache_max_entries: int = 500,
582
+ file_cache_max_memory_mb: float = 50.0,
499
583
  ) -> None:
500
584
  self.tool_registry = ToolRegistry(tools)
501
585
  self.max_thinking_tokens = max_thinking_tokens
502
- self.safe_mode = safe_mode
586
+ self.yolo_mode = yolo_mode
503
587
  self.model = model
504
588
  self.verbose = verbose
505
589
  self.abort_controller = asyncio.Event()
506
- self.file_state_cache: Dict[str, FileSnapshot] = {}
590
+ # Use BoundedFileCache instead of plain Dict to prevent unbounded growth
591
+ self.file_state_cache: BoundedFileCache = BoundedFileCache(
592
+ max_entries=file_cache_max_entries,
593
+ max_memory_mb=file_cache_max_memory_mb,
594
+ )
507
595
  self.pause_ui = pause_ui
508
596
  self.resume_ui = resume_ui
597
+ self.stop_hook = stop_hook
598
+ self.stop_hook_active = False
599
+ self._last_message_warning_count = 0
509
600
 
510
601
  @property
511
602
  def tools(self) -> List[Tool[Any, Any]]:
@@ -525,6 +616,44 @@ class QueryContext:
525
616
  """Return all known tools (active + deferred)."""
526
617
  return self.tool_registry.all_tools
527
618
 
619
+ def check_message_count(self, message_count: int) -> None:
620
+ """Check message count and log warnings if thresholds are exceeded.
621
+
622
+ This helps detect potential memory issues in long sessions.
623
+ """
624
+ if message_count >= self.MESSAGE_COUNT_CRITICAL_THRESHOLD:
625
+ if self._last_message_warning_count < self.MESSAGE_COUNT_CRITICAL_THRESHOLD:
626
+ logger.warning(
627
+ "[query] Critical: Message history is very large. "
628
+ "Consider compacting or starting a new session.",
629
+ extra={
630
+ "message_count": message_count,
631
+ "threshold": self.MESSAGE_COUNT_CRITICAL_THRESHOLD,
632
+ "file_cache_stats": self.file_state_cache.stats(),
633
+ },
634
+ )
635
+ self._last_message_warning_count = message_count
636
+ elif message_count >= self.MESSAGE_COUNT_WARNING_THRESHOLD:
637
+ # Only warn once per threshold crossing
638
+ if self._last_message_warning_count < self.MESSAGE_COUNT_WARNING_THRESHOLD:
639
+ logger.info(
640
+ "[query] Message history growing large; automatic compaction may trigger soon",
641
+ extra={
642
+ "message_count": message_count,
643
+ "threshold": self.MESSAGE_COUNT_WARNING_THRESHOLD,
644
+ "file_cache_stats": self.file_state_cache.stats(),
645
+ },
646
+ )
647
+ self._last_message_warning_count = message_count
648
+
649
+ def get_memory_stats(self) -> Dict[str, Any]:
650
+ """Return memory usage statistics for monitoring."""
651
+ return {
652
+ "file_cache": self.file_state_cache.stats(),
653
+ "tool_count": len(self.tool_registry.all_tools),
654
+ "active_tool_count": len(self.tool_registry.active_tools),
655
+ }
656
+
528
657
 
529
658
  async def query_llm(
530
659
  messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
@@ -557,7 +686,6 @@ async def query_llm(
557
686
  AssistantMessage with the model's response
558
687
  """
559
688
  request_timeout = request_timeout or DEFAULT_REQUEST_TIMEOUT_SEC
560
- request_timeout = request_timeout or DEFAULT_REQUEST_TIMEOUT_SEC
561
689
  model_profile = resolve_model_profile(model)
562
690
 
563
691
  # Normalize messages based on protocol family (Anthropic allows tool blocks; OpenAI-style prefers text-only)
@@ -572,8 +700,22 @@ async def query_llm(
572
700
  else:
573
701
  messages_for_model = messages
574
702
 
703
+ # Get thinking_mode for provider-specific handling
704
+ # Apply when thinking is enabled (max_thinking_tokens > 0) OR when using a
705
+ # reasoning model like deepseek-reasoner which has thinking enabled by default
706
+ thinking_mode: Optional[str] = None
707
+ if protocol == "openai":
708
+ model_name = (model_profile.model or "").lower()
709
+ # DeepSeek Reasoner models have thinking enabled by default
710
+ is_reasoning_model = "reasoner" in model_name or "r1" in model_name
711
+ if max_thinking_tokens > 0 or is_reasoning_model:
712
+ thinking_mode = infer_thinking_mode(model_profile)
713
+
575
714
  normalized_messages: List[Dict[str, Any]] = normalize_messages_for_api(
576
- messages_for_model, protocol=protocol, tool_mode=tool_mode
715
+ messages_for_model,
716
+ protocol=protocol,
717
+ tool_mode=tool_mode,
718
+ thinking_mode=thinking_mode,
577
719
  )
578
720
  logger.info(
579
721
  "[query_llm] Preparing model request",
@@ -584,6 +726,7 @@ async def query_llm(
584
726
  "normalized_messages": len(normalized_messages),
585
727
  "tool_count": len(tools),
586
728
  "max_thinking_tokens": max_thinking_tokens,
729
+ "thinking_mode": thinking_mode,
587
730
  "tool_mode": tool_mode,
588
731
  },
589
732
  )
@@ -601,13 +744,25 @@ async def query_llm(
601
744
  start_time = time.time()
602
745
 
603
746
  try:
604
- client: Optional[ProviderClient] = get_provider_client(model_profile.provider)
747
+ try:
748
+ client: Optional[ProviderClient] = get_provider_client(model_profile.provider)
749
+ except RuntimeError as exc:
750
+ duration_ms = (time.time() - start_time) * 1000
751
+ error_msg = create_assistant_message(
752
+ content=str(exc),
753
+ duration_ms=duration_ms,
754
+ )
755
+ error_msg.is_api_error_message = True
756
+ return error_msg
605
757
  if client is None:
606
758
  duration_ms = (time.time() - start_time) * 1000
759
+ provider_label = getattr(model_profile.provider, "value", None) or str(
760
+ model_profile.provider
761
+ )
607
762
  error_msg = create_assistant_message(
608
763
  content=(
609
- "Gemini protocol is not supported yet in Ripperdoc. "
610
- "Please configure an Anthropic or OpenAI-compatible model."
764
+ f"No provider client available for '{provider_label}'. "
765
+ "Check your model configuration and provider dependencies."
611
766
  ),
612
767
  duration_ms=duration_ms,
613
768
  )
@@ -659,6 +814,13 @@ async def query_llm(
659
814
  cost_usd=provider_response.cost_usd,
660
815
  duration_ms=provider_response.duration_ms,
661
816
  metadata=provider_response.metadata,
817
+ model=model_profile.model,
818
+ input_tokens=provider_response.usage_tokens.get("input_tokens", 0),
819
+ output_tokens=provider_response.usage_tokens.get("output_tokens", 0),
820
+ cache_read_tokens=provider_response.usage_tokens.get("cache_read_input_tokens", 0),
821
+ cache_creation_tokens=provider_response.usage_tokens.get(
822
+ "cache_creation_input_tokens", 0
823
+ ),
662
824
  )
663
825
 
664
826
  except CancelledError:
@@ -667,7 +829,8 @@ async def query_llm(
667
829
  # Return error message
668
830
  logger.warning(
669
831
  "Error querying AI model: %s: %s",
670
- type(e).__name__, e,
832
+ type(e).__name__,
833
+ e,
671
834
  extra={
672
835
  "model": getattr(model_profile, "model", None),
673
836
  "model_pointer": model,
@@ -758,9 +921,7 @@ async def _run_query_iteration(
758
921
 
759
922
  model_profile = resolve_model_profile(query_context.model)
760
923
  tool_mode = determine_tool_mode(model_profile)
761
- tools_for_model: List[Tool[Any, Any]] = (
762
- [] if tool_mode == "text" else query_context.all_tools()
763
- )
924
+ tools_for_model: List[Tool[Any, Any]] = [] if tool_mode == "text" else query_context.all_tools()
764
925
 
765
926
  full_system_prompt = build_full_system_prompt(
766
927
  system_prompt, context, tool_mode, query_context.all_tools()
@@ -775,7 +936,7 @@ async def _run_query_iteration(
775
936
  )
776
937
 
777
938
  # Stream LLM response
778
- progress_queue: asyncio.Queue[Optional[ProgressMessage]] = asyncio.Queue()
939
+ progress_queue: asyncio.Queue[Optional[ProgressMessage]] = asyncio.Queue(maxsize=1000)
779
940
 
780
941
  async def _stream_progress(chunk: str) -> None:
781
942
  if not chunk:
@@ -828,23 +989,23 @@ async def _run_query_iteration(
828
989
  progress = progress_queue.get_nowait()
829
990
  except asyncio.QueueEmpty:
830
991
  waiter = asyncio.create_task(progress_queue.get())
831
- # Use timeout to periodically check abort_controller during LLM request
992
+ abort_waiter = asyncio.create_task(query_context.abort_controller.wait())
832
993
  done, pending = await asyncio.wait(
833
- {assistant_task, waiter},
994
+ {assistant_task, waiter, abort_waiter},
834
995
  return_when=asyncio.FIRST_COMPLETED,
835
- timeout=0.1 # Check abort_controller every 100ms
836
996
  )
837
- if not done:
838
- # Timeout - cancel waiter and continue loop to check abort_controller
839
- waiter.cancel()
840
- try:
841
- await waiter
842
- except asyncio.CancelledError:
843
- pass
997
+ for task in pending:
998
+ # Don't cancel assistant_task here - it should only be cancelled
999
+ # through abort_controller in the main loop
1000
+ if task is not assistant_task:
1001
+ task.cancel()
1002
+ try:
1003
+ await task
1004
+ except asyncio.CancelledError:
1005
+ pass
1006
+ if abort_waiter in done:
844
1007
  continue
845
1008
  if assistant_task in done:
846
- for task in pending:
847
- task.cancel()
848
1009
  assistant_message = await assistant_task
849
1010
  break
850
1011
  progress = waiter.result()
@@ -857,7 +1018,8 @@ async def _run_query_iteration(
857
1018
  if residual:
858
1019
  yield residual
859
1020
 
860
- assert assistant_message is not None
1021
+ if assistant_message is None:
1022
+ raise RuntimeError("assistant_message was unexpectedly None after LLM query")
861
1023
  result.assistant_message = assistant_message
862
1024
 
863
1025
  # Check for abort
@@ -882,6 +1044,27 @@ async def _run_query_iteration(
882
1044
 
883
1045
  if not tool_use_blocks:
884
1046
  logger.debug("[query] No tool_use blocks; returning response to user.")
1047
+ stop_hook = query_context.stop_hook
1048
+ stop_result = (
1049
+ await hook_manager.run_subagent_stop_async(
1050
+ stop_hook_active=query_context.stop_hook_active
1051
+ )
1052
+ if stop_hook == "subagent"
1053
+ else await hook_manager.run_stop_async(stop_hook_active=query_context.stop_hook_active)
1054
+ )
1055
+ if stop_result.additional_context:
1056
+ _append_hook_context(context, f"{stop_hook}:context", stop_result.additional_context)
1057
+ if stop_result.system_message:
1058
+ _append_hook_context(context, f"{stop_hook}:system", stop_result.system_message)
1059
+ if stop_result.should_block:
1060
+ reason = stop_result.block_reason or stop_result.stop_reason or "Blocked by hook."
1061
+ result.tool_results = [create_user_message(f"{stop_hook} hook blocked: {reason}")]
1062
+ for msg in result.tool_results:
1063
+ yield msg
1064
+ query_context.stop_hook_active = True
1065
+ result.should_stop = False
1066
+ return
1067
+ query_context.stop_hook_active = False
885
1068
  result.should_stop = True
886
1069
  return
887
1070
 
@@ -890,8 +1073,7 @@ async def _run_query_iteration(
890
1073
  tool_results: List[UserMessage] = []
891
1074
  permission_denied = False
892
1075
  sibling_ids = set(
893
- getattr(t, "tool_use_id", None) or getattr(t, "id", None) or ""
894
- for t in tool_use_blocks
1076
+ getattr(t, "tool_use_id", None) or getattr(t, "id", None) or "" for t in tool_use_blocks
895
1077
  )
896
1078
  prepared_calls: List[Dict[str, Any]] = []
897
1079
 
@@ -899,22 +1081,17 @@ async def _run_query_iteration(
899
1081
  tool_name = tool_use.name
900
1082
  if not tool_name:
901
1083
  continue
902
- tool_use_id = (
903
- getattr(tool_use, "tool_use_id", None) or getattr(tool_use, "id", None) or ""
904
- )
1084
+ tool_use_id = getattr(tool_use, "tool_use_id", None) or getattr(tool_use, "id", None) or ""
905
1085
  tool_input = getattr(tool_use, "input", {}) or {}
906
1086
 
907
- tool, missing_msg = _resolve_tool(
908
- query_context.tool_registry, tool_name, tool_use_id
909
- )
1087
+ tool, missing_msg = _resolve_tool(query_context.tool_registry, tool_name, tool_use_id)
910
1088
  if missing_msg:
911
- logger.warning(
912
- f"[query] Tool '{tool_name}' not found for tool_use_id={tool_use_id}"
913
- )
1089
+ logger.warning(f"[query] Tool '{tool_name}' not found for tool_use_id={tool_use_id}")
914
1090
  tool_results.append(missing_msg)
915
1091
  yield missing_msg
916
1092
  continue
917
- assert tool is not None
1093
+ if tool is None:
1094
+ raise RuntimeError(f"Tool '{tool_name}' resolved to None unexpectedly")
918
1095
 
919
1096
  try:
920
1097
  parsed_input = tool.input_schema(**tool_input)
@@ -924,11 +1101,12 @@ async def _run_query_iteration(
924
1101
  )
925
1102
 
926
1103
  tool_context = ToolUseContext(
927
- safe_mode=query_context.safe_mode,
1104
+ yolo_mode=query_context.yolo_mode,
928
1105
  verbose=query_context.verbose,
929
1106
  permission_checker=can_use_tool_fn,
930
1107
  tool_registry=query_context.tool_registry,
931
1108
  file_state_cache=query_context.file_state_cache,
1109
+ conversation_messages=messages,
932
1110
  abort_signal=query_context.abort_controller,
933
1111
  pause_ui=query_context.pause_ui,
934
1112
  resume_ui=query_context.resume_ui,
@@ -937,8 +1115,7 @@ async def _run_query_iteration(
937
1115
  validation = await tool.validate_input(parsed_input, tool_context)
938
1116
  if not validation.result:
939
1117
  logger.debug(
940
- f"[query] Validation failed for tool_use_id={tool_use_id}: "
941
- f"{validation.message}"
1118
+ f"[query] Validation failed for tool_use_id={tool_use_id}: {validation.message}"
942
1119
  )
943
1120
  result_msg = tool_result_message(
944
1121
  tool_use_id,
@@ -949,23 +1126,43 @@ async def _run_query_iteration(
949
1126
  yield result_msg
950
1127
  continue
951
1128
 
952
- if query_context.safe_mode or can_use_tool_fn is not None:
953
- allowed, denial_message = await _check_tool_permissions(
1129
+ if not query_context.yolo_mode or can_use_tool_fn is not None:
1130
+ allowed, denial_message, updated_input = await _check_tool_permissions(
954
1131
  tool, parsed_input, query_context, can_use_tool_fn
955
1132
  )
956
1133
  if not allowed:
957
1134
  logger.debug(
958
- f"[query] Permission denied for tool_use_id={tool_use_id}: "
959
- f"{denial_message}"
960
- )
961
- denial_text = (
962
- denial_message or f"User aborted the tool invocation: {tool_name}"
1135
+ f"[query] Permission denied for tool_use_id={tool_use_id}: {denial_message}"
963
1136
  )
1137
+ denial_text = denial_message or f"User aborted the tool invocation: {tool_name}"
964
1138
  denial_msg = tool_result_message(tool_use_id, denial_text, is_error=True)
965
1139
  tool_results.append(denial_msg)
966
1140
  yield denial_msg
967
1141
  permission_denied = True
968
1142
  break
1143
+ if updated_input:
1144
+ try:
1145
+ parsed_input = tool.input_schema(**updated_input)
1146
+ except ValidationError as ve:
1147
+ detail_text = format_pydantic_errors(ve)
1148
+ error_msg = tool_result_message(
1149
+ tool_use_id,
1150
+ f"Invalid permission-updated input for tool '{tool_name}': {detail_text}",
1151
+ is_error=True,
1152
+ )
1153
+ tool_results.append(error_msg)
1154
+ yield error_msg
1155
+ continue
1156
+ validation = await tool.validate_input(parsed_input, tool_context)
1157
+ if not validation.result:
1158
+ error_msg = tool_result_message(
1159
+ tool_use_id,
1160
+ validation.message or "Tool input validation failed.",
1161
+ is_error=True,
1162
+ )
1163
+ tool_results.append(error_msg)
1164
+ yield error_msg
1165
+ continue
969
1166
 
970
1167
  prepared_calls.append(
971
1168
  {
@@ -977,6 +1174,7 @@ async def _run_query_iteration(
977
1174
  parsed_input,
978
1175
  sibling_ids,
979
1176
  tool_context,
1177
+ context,
980
1178
  ),
981
1179
  }
982
1180
  )
@@ -1070,7 +1268,7 @@ async def query(
1070
1268
  extra={
1071
1269
  "message_count": len(messages),
1072
1270
  "tool_count": len(query_context.tools),
1073
- "safe_mode": query_context.safe_mode,
1271
+ "yolo_mode": query_context.yolo_mode,
1074
1272
  "model_pointer": query_context.model,
1075
1273
  },
1076
1274
  )
@@ -1078,6 +1276,9 @@ async def query(
1078
1276
  # do not interfere with the loop or normalization.
1079
1277
  messages = list(messages)
1080
1278
 
1279
+ # Check initial message count for memory warnings
1280
+ query_context.check_message_count(len(messages))
1281
+
1081
1282
  for iteration in range(1, MAX_QUERY_ITERATIONS + 1):
1082
1283
  result = IterationResult()
1083
1284
 
@@ -1100,6 +1301,10 @@ async def query(
1100
1301
  messages = messages + [result.assistant_message] + result.tool_results # type: ignore[operator]
1101
1302
  else:
1102
1303
  messages = messages + result.tool_results # type: ignore[operator]
1304
+
1305
+ # Check message count after each iteration for memory warnings
1306
+ query_context.check_message_count(len(messages))
1307
+
1103
1308
  logger.debug(
1104
1309
  f"[query] Continuing loop with {len(messages)} messages after tools; "
1105
1310
  f"tool_results_count={len(result.tool_results)}"
@@ -462,11 +462,13 @@ def log_openai_messages(normalized_messages: List[Dict[str, Any]]) -> None:
462
462
  role = message.get("role")
463
463
  tool_calls = message.get("tool_calls")
464
464
  tool_call_id = message.get("tool_call_id")
465
+ has_reasoning = "reasoning_content" in message and message.get("reasoning_content")
465
466
  ids = [tc.get("id") for tc in tool_calls] if tool_calls else []
466
467
  summary_parts.append(
467
468
  f"{idx}:{role}"
468
469
  + (f" tool_calls={ids}" if ids else "")
469
470
  + (f" tool_call_id={tool_call_id}" if tool_call_id else "")
471
+ + (" +reasoning" if has_reasoning else "")
470
472
  )
471
473
  logger.debug(f"[query_llm] OpenAI normalized messages: {' | '.join(summary_parts)}")
472
474