ripperdoc 0.2.10__py3-none-any.whl → 0.3.0__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 (70) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +164 -57
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +3 -7
  5. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  6. ripperdoc/cli/commands/memory_cmd.py +2 -1
  7. ripperdoc/cli/commands/models_cmd.py +61 -5
  8. ripperdoc/cli/commands/resume_cmd.py +1 -0
  9. ripperdoc/cli/commands/skills_cmd.py +103 -0
  10. ripperdoc/cli/commands/stats_cmd.py +4 -4
  11. ripperdoc/cli/commands/status_cmd.py +10 -0
  12. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  13. ripperdoc/cli/commands/themes_cmd.py +139 -0
  14. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  15. ripperdoc/cli/ui/helpers.py +6 -3
  16. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  17. ripperdoc/cli/ui/panels.py +13 -8
  18. ripperdoc/cli/ui/rich_ui.py +451 -32
  19. ripperdoc/cli/ui/spinner.py +68 -5
  20. ripperdoc/cli/ui/tool_renderers.py +10 -9
  21. ripperdoc/cli/ui/wizard.py +18 -11
  22. ripperdoc/core/agents.py +4 -0
  23. ripperdoc/core/config.py +235 -0
  24. ripperdoc/core/default_tools.py +1 -0
  25. ripperdoc/core/hooks/llm_callback.py +0 -1
  26. ripperdoc/core/hooks/manager.py +6 -0
  27. ripperdoc/core/permissions.py +82 -5
  28. ripperdoc/core/providers/openai.py +55 -9
  29. ripperdoc/core/query.py +349 -108
  30. ripperdoc/core/query_utils.py +17 -14
  31. ripperdoc/core/skills.py +1 -0
  32. ripperdoc/core/theme.py +298 -0
  33. ripperdoc/core/tool.py +8 -3
  34. ripperdoc/protocol/__init__.py +14 -0
  35. ripperdoc/protocol/models.py +300 -0
  36. ripperdoc/protocol/stdio.py +1453 -0
  37. ripperdoc/tools/background_shell.py +49 -5
  38. ripperdoc/tools/bash_tool.py +75 -9
  39. ripperdoc/tools/file_edit_tool.py +98 -29
  40. ripperdoc/tools/file_read_tool.py +139 -8
  41. ripperdoc/tools/file_write_tool.py +46 -3
  42. ripperdoc/tools/grep_tool.py +98 -8
  43. ripperdoc/tools/lsp_tool.py +9 -15
  44. ripperdoc/tools/multi_edit_tool.py +26 -3
  45. ripperdoc/tools/skill_tool.py +52 -1
  46. ripperdoc/tools/task_tool.py +33 -8
  47. ripperdoc/utils/file_watch.py +12 -6
  48. ripperdoc/utils/image_utils.py +125 -0
  49. ripperdoc/utils/log.py +30 -3
  50. ripperdoc/utils/lsp.py +9 -3
  51. ripperdoc/utils/mcp.py +80 -18
  52. ripperdoc/utils/message_formatting.py +2 -2
  53. ripperdoc/utils/messages.py +177 -32
  54. ripperdoc/utils/pending_messages.py +50 -0
  55. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  56. ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
  57. ripperdoc/utils/platform.py +198 -0
  58. ripperdoc/utils/session_heatmap.py +1 -3
  59. ripperdoc/utils/session_history.py +2 -2
  60. ripperdoc/utils/session_stats.py +1 -0
  61. ripperdoc/utils/shell_utils.py +8 -5
  62. ripperdoc/utils/todo.py +0 -6
  63. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +49 -17
  64. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/RECORD +68 -61
  65. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  66. ripperdoc/sdk/__init__.py +0 -9
  67. ripperdoc/sdk/client.py +0 -408
  68. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  69. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  70. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
ripperdoc/core/query.py CHANGED
@@ -48,6 +48,7 @@ from ripperdoc.utils.file_watch import (
48
48
  ChangedFileNotice,
49
49
  detect_changed_files,
50
50
  )
51
+ from ripperdoc.utils.pending_messages import PendingMessageQueue
51
52
  from ripperdoc.utils.log import get_logger
52
53
  from ripperdoc.utils.messages import (
53
54
  AssistantMessage,
@@ -67,6 +68,10 @@ logger = get_logger()
67
68
 
68
69
  DEFAULT_REQUEST_TIMEOUT_SEC = float(os.getenv("RIPPERDOC_API_TIMEOUT", "120"))
69
70
  MAX_LLM_RETRIES = int(os.getenv("RIPPERDOC_MAX_RETRIES", "10"))
71
+ # Timeout for individual tool execution (can be overridden per tool if needed)
72
+ DEFAULT_TOOL_TIMEOUT_SEC = float(os.getenv("RIPPERDOC_TOOL_TIMEOUT", "300")) # 5 minutes
73
+ # Timeout for concurrent tool execution (total for all tools)
74
+ DEFAULT_CONCURRENT_TOOL_TIMEOUT_SEC = float(os.getenv("RIPPERDOC_CONCURRENT_TOOL_TIMEOUT", "600")) # 10 minutes
70
75
 
71
76
 
72
77
  def infer_thinking_mode(model_profile: ModelProfile) -> Optional[str]:
@@ -85,6 +90,9 @@ def infer_thinking_mode(model_profile: ModelProfile) -> Optional[str]:
85
90
  # Use explicit config if set
86
91
  explicit_mode = model_profile.thinking_mode
87
92
  if explicit_mode:
93
+ # "none", "disabled", "off" means thinking is explicitly disabled
94
+ if explicit_mode.lower() in ("disabled", "off"):
95
+ return None
88
96
  return explicit_mode
89
97
 
90
98
  # Auto-detect based on API base and model name
@@ -145,8 +153,10 @@ async def _check_tool_permissions(
145
153
  if isinstance(decision, PermissionResult):
146
154
  return decision.result, decision.message, decision.updated_input
147
155
  if isinstance(decision, dict) and "result" in decision:
148
- return bool(decision.get("result")), decision.get("message"), decision.get(
149
- "updated_input"
156
+ return (
157
+ bool(decision.get("result")),
158
+ decision.get("message"),
159
+ decision.get("updated_input"),
150
160
  )
151
161
  if isinstance(decision, tuple) and len(decision) == 2:
152
162
  return bool(decision[0]), decision[1], None
@@ -210,6 +220,11 @@ async def _run_tool_use_generator(
210
220
  context: Dict[str, str],
211
221
  ) -> AsyncGenerator[Union[UserMessage, ProgressMessage], None]:
212
222
  """Execute a single tool_use and yield progress/results."""
223
+ logger.debug(
224
+ "[query] _run_tool_use_generator ENTER: tool='%s' tool_use_id=%s",
225
+ tool_name,
226
+ tool_use_id,
227
+ )
213
228
  # Get tool input as dict for hooks
214
229
  tool_input_dict = (
215
230
  parsed_input.model_dump()
@@ -240,8 +255,14 @@ async def _run_tool_use_generator(
240
255
  )
241
256
  # Re-parse the input with the updated values
242
257
  try:
243
- parsed_input = tool.input_schema(**pre_result.updated_input)
244
- tool_input_dict = pre_result.updated_input
258
+ # Ensure updated_input is a dict, not a Pydantic model
259
+ updated_input = pre_result.updated_input
260
+ if hasattr(updated_input, "model_dump"):
261
+ updated_input = updated_input.model_dump()
262
+ elif not isinstance(updated_input, dict):
263
+ updated_input = {"value": str(updated_input)}
264
+ parsed_input = tool.input_schema(**updated_input)
265
+ tool_input_dict = updated_input
245
266
  except (ValueError, TypeError) as exc:
246
267
  logger.warning(
247
268
  f"[query] Failed to apply updated input from hook: {exc}",
@@ -261,26 +282,51 @@ async def _run_tool_use_generator(
261
282
  tool_output = None
262
283
 
263
284
  try:
264
- async for output in tool.call(parsed_input, tool_context):
265
- if isinstance(output, ToolProgress):
266
- yield create_progress_message(
267
- tool_use_id=tool_use_id,
268
- sibling_tool_use_ids=sibling_ids,
269
- content=output.content,
270
- )
271
- logger.debug(f"[query] Progress from tool_use_id={tool_use_id}: {output.content}")
272
- elif isinstance(output, ToolResult):
273
- tool_output = output.data
274
- result_content = output.result_for_assistant or str(output.data)
275
- result_msg = tool_result_message(
276
- tool_use_id, result_content, tool_use_result=output.data
277
- )
278
- yield result_msg
279
- logger.debug(
280
- f"[query] Tool completed tool_use_id={tool_use_id} name={tool_name} "
281
- f"result_len={len(result_content)}"
282
- )
285
+ logger.debug("[query] _run_tool_use_generator: BEFORE tool.call() for '%s'", tool_name)
286
+ # Wrap tool execution with timeout to prevent hangs
287
+ try:
288
+ async with asyncio.timeout(DEFAULT_TOOL_TIMEOUT_SEC):
289
+ async for output in tool.call(parsed_input, tool_context):
290
+ logger.debug(
291
+ "[query] _run_tool_use_generator: tool='%s' yielded output type=%s",
292
+ tool_name,
293
+ type(output).__name__,
294
+ )
295
+ if isinstance(output, ToolProgress):
296
+ yield create_progress_message(
297
+ tool_use_id=tool_use_id,
298
+ sibling_tool_use_ids=sibling_ids,
299
+ content=output.content,
300
+ is_subagent_message=getattr(output, 'is_subagent_message', False),
301
+ )
302
+ logger.debug(
303
+ f"[query] Progress from tool_use_id={tool_use_id}: {output.content}"
304
+ )
305
+ elif isinstance(output, ToolResult):
306
+ tool_output = output.data
307
+ result_content = output.result_for_assistant or str(output.data)
308
+ result_msg = tool_result_message(
309
+ tool_use_id, result_content, tool_use_result=output.data
310
+ )
311
+ yield result_msg
312
+ logger.debug(
313
+ f"[query] Tool completed tool_use_id={tool_use_id} name={tool_name} "
314
+ f"result_len={len(result_content)}"
315
+ )
316
+ except asyncio.TimeoutError:
317
+ logger.error(
318
+ f"[query] Tool '{tool_name}' timed out after {DEFAULT_TOOL_TIMEOUT_SEC}s",
319
+ extra={"tool": tool_name, "tool_use_id": tool_use_id},
320
+ )
321
+ yield tool_result_message(
322
+ tool_use_id,
323
+ f"Tool '{tool_name}' timed out after {DEFAULT_TOOL_TIMEOUT_SEC:.0f} seconds",
324
+ is_error=True,
325
+ )
326
+ return # Exit early on timeout
327
+ logger.debug("[query] _run_tool_use_generator: AFTER tool.call() loop for '%s'", tool_name)
283
328
  except CancelledError:
329
+ logger.debug("[query] _run_tool_use_generator: tool='%s' CANCELLED", tool_name)
284
330
  raise # Don't suppress task cancellation
285
331
  except (RuntimeError, ValueError, TypeError, OSError, IOError, AttributeError, KeyError) as exc:
286
332
  logger.warning(
@@ -299,13 +345,15 @@ async def _run_tool_use_generator(
299
345
  if post_result.additional_context:
300
346
  _append_hook_context(context, f"PostToolUse:{tool_name}", post_result.additional_context)
301
347
  if post_result.system_message:
302
- _append_hook_context(
303
- context, f"PostToolUse:{tool_name}:system", post_result.system_message
304
- )
348
+ _append_hook_context(context, f"PostToolUse:{tool_name}:system", post_result.system_message)
305
349
  if post_result.should_block:
306
350
  reason = post_result.block_reason or post_result.stop_reason or "Blocked by hook."
307
351
  yield create_user_message(f"PostToolUse hook blocked: {reason}")
308
352
 
353
+ logger.debug(
354
+ "[query] _run_tool_use_generator DONE: tool='%s' tool_use_id=%s", tool_name, tool_use_id
355
+ )
356
+
309
357
 
310
358
  def _group_tool_calls_by_concurrency(prepared_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
311
359
  """Group consecutive tool calls by their concurrency safety."""
@@ -337,9 +385,18 @@ async def _execute_tools_in_parallel(
337
385
  items: List[Dict[str, Any]], tool_results: List[UserMessage]
338
386
  ) -> AsyncGenerator[Union[UserMessage, ProgressMessage], None]:
339
387
  """Run tool generators concurrently."""
340
- generators = [call["generator"] for call in items if call.get("generator")]
341
- async for message in _run_concurrent_tool_uses(generators, tool_results):
388
+ logger.debug("[query] _execute_tools_in_parallel ENTER: %d items", len(items))
389
+ valid_items = [call for call in items if call.get("generator")]
390
+ generators = [call["generator"] for call in valid_items]
391
+ tool_names = [call.get("tool_name", "unknown") for call in valid_items]
392
+ logger.debug(
393
+ "[query] _execute_tools_in_parallel: %d valid generators, tools=%s",
394
+ len(generators),
395
+ tool_names,
396
+ )
397
+ async for message in _run_concurrent_tool_uses(generators, tool_names, tool_results):
342
398
  yield message
399
+ logger.debug("[query] _execute_tools_in_parallel DONE")
343
400
 
344
401
 
345
402
  async def _run_tools_concurrently(
@@ -371,46 +428,164 @@ async def _run_tools_serially(
371
428
 
372
429
  async def _run_concurrent_tool_uses(
373
430
  generators: List[AsyncGenerator[Union[UserMessage, ProgressMessage], None]],
431
+ tool_names: List[str],
374
432
  tool_results: List[UserMessage],
375
433
  ) -> AsyncGenerator[Union[UserMessage, ProgressMessage], None]:
376
- """Drain multiple tool generators concurrently and stream outputs."""
434
+ """Drain multiple tool generators concurrently and stream outputs with overall timeout."""
435
+ logger.debug(
436
+ "[query] _run_concurrent_tool_uses ENTER: %d generators, tools=%s, timeout=%s",
437
+ len(generators),
438
+ tool_names,
439
+ DEFAULT_CONCURRENT_TOOL_TIMEOUT_SEC,
440
+ )
377
441
  if not generators:
442
+ logger.debug("[query] _run_concurrent_tool_uses: no generators, returning")
378
443
  return
379
- yield # Make this a proper async generator that yields nothing
444
+ yield # Make this a proper async generator that yields nothing (unreachable but required)
380
445
 
381
446
  queue: asyncio.Queue[Optional[Union[UserMessage, ProgressMessage]]] = asyncio.Queue()
382
447
 
383
- async def _consume(gen: AsyncGenerator[Union[UserMessage, ProgressMessage], None]) -> None:
448
+ async def _consume(
449
+ gen: AsyncGenerator[Union[UserMessage, ProgressMessage], None],
450
+ gen_index: int,
451
+ tool_name: str,
452
+ ) -> Optional[Exception]:
453
+ """Consume a tool generator and return any exception that occurred."""
454
+ logger.debug(
455
+ "[query] _consume START: tool='%s' index=%d gen=%s",
456
+ tool_name,
457
+ gen_index,
458
+ type(gen).__name__,
459
+ )
460
+ captured_exception: Optional[Exception] = None
461
+ message_count = 0
384
462
  try:
463
+ logger.debug("[query] _consume: entering async for loop for '%s'", tool_name)
385
464
  async for message in gen:
465
+ message_count += 1
466
+ msg_type = type(message).__name__
467
+ logger.debug(
468
+ "[query] _consume: tool='%s' received message #%d type=%s",
469
+ tool_name,
470
+ message_count,
471
+ msg_type,
472
+ )
386
473
  await queue.put(message)
474
+ logger.debug("[query] _consume: tool='%s' put message to queue", tool_name)
475
+ logger.debug(
476
+ "[query] _consume: tool='%s' async for loop finished, total messages=%d",
477
+ tool_name,
478
+ message_count,
479
+ )
387
480
  except asyncio.CancelledError:
481
+ logger.debug("[query] _consume: tool='%s' was CANCELLED", tool_name)
388
482
  raise # Don't suppress cancellation
389
483
  except (StopAsyncIteration, GeneratorExit):
484
+ logger.debug("[query] _consume: tool='%s' StopAsyncIteration/GeneratorExit", tool_name)
390
485
  pass # Normal generator termination
391
- except (RuntimeError, ValueError, TypeError) as exc:
486
+ except Exception as exc:
487
+ # Capture exception for reporting to caller
488
+ captured_exception = exc
392
489
  logger.warning(
393
- "[query] Error while consuming tool generator: %s: %s",
490
+ "[query] Error while consuming tool '%s' (task %d): %s: %s",
491
+ tool_name,
492
+ gen_index,
394
493
  type(exc).__name__,
395
494
  exc,
396
495
  )
397
496
  finally:
497
+ logger.debug("[query] _consume FINALLY: tool='%s' putting None to queue", tool_name)
398
498
  await queue.put(None)
499
+ logger.debug("[query] _consume DONE: tool='%s' messages=%d", tool_name, message_count)
500
+ return captured_exception
399
501
 
400
- tasks = [asyncio.create_task(_consume(gen)) for gen in generators]
502
+ logger.debug("[query] _run_concurrent_tool_uses: creating %d tasks", len(generators))
503
+ tasks = [
504
+ asyncio.create_task(_consume(gen, i, tool_names[i])) for i, gen in enumerate(generators)
505
+ ]
401
506
  active = len(tasks)
507
+ logger.debug("[query] _run_concurrent_tool_uses: %d tasks created, entering while loop", active)
402
508
 
403
509
  try:
404
- while active:
405
- message = await queue.get()
406
- if message is None:
407
- active -= 1
408
- continue
409
- if isinstance(message, UserMessage):
410
- tool_results.append(message)
411
- yield message
510
+ # Add overall timeout for entire concurrent execution
511
+ async with asyncio.timeout(DEFAULT_CONCURRENT_TOOL_TIMEOUT_SEC):
512
+ while active:
513
+ logger.debug(
514
+ "[query] _run_concurrent_tool_uses: waiting for queue.get(), active=%d", active
515
+ )
516
+ try:
517
+ message = await asyncio.wait_for(
518
+ queue.get(), timeout=DEFAULT_CONCURRENT_TOOL_TIMEOUT_SEC
519
+ )
520
+ except asyncio.TimeoutError:
521
+ logger.error(
522
+ "[query] Concurrent tool execution timed out waiting for messages"
523
+ )
524
+ # Cancel all remaining tasks
525
+ for task in tasks:
526
+ if not task.done():
527
+ task.cancel()
528
+ raise
529
+
530
+ logger.debug(
531
+ "[query] _run_concurrent_tool_uses: got message type=%s, active=%d",
532
+ type(message).__name__ if message else "None",
533
+ active,
534
+ )
535
+ if message is None:
536
+ active -= 1
537
+ logger.debug(
538
+ "[query] _run_concurrent_tool_uses: None received, active now=%d", active
539
+ )
540
+ continue
541
+ if isinstance(message, UserMessage):
542
+ tool_results.append(message)
543
+ yield message
544
+ logger.debug("[query] _run_concurrent_tool_uses: while loop finished, all tools done")
545
+ except asyncio.TimeoutError:
546
+ logger.error(
547
+ f"[query] Concurrent tool execution timed out after {DEFAULT_CONCURRENT_TOOL_TIMEOUT_SEC}s",
548
+ extra={"tool_names": tool_names},
549
+ )
550
+ # Ensure all tasks are cancelled
551
+ for task in tasks:
552
+ if not task.done():
553
+ task.cancel()
554
+ raise
412
555
  finally:
413
- await asyncio.gather(*tasks, return_exceptions=True)
556
+ # Wait for all tasks and collect any exceptions
557
+ results = await asyncio.gather(*tasks, return_exceptions=True)
558
+ exceptions_found: List[tuple[int, str, BaseException]] = []
559
+ for i, result in enumerate(results):
560
+ if isinstance(result, asyncio.CancelledError):
561
+ continue
562
+ elif isinstance(result, Exception):
563
+ # Exception from gather itself (shouldn't happen with return_exceptions=True)
564
+ exceptions_found.append((i, tool_names[i], result))
565
+ elif result is not None:
566
+ # Exception returned by _consume
567
+ exceptions_found.append((i, tool_names[i], result))
568
+
569
+ # Log all exceptions for debugging
570
+ for i, name, exc in exceptions_found:
571
+ logger.warning(
572
+ "[query] Concurrent tool '%s' (task %d) failed: %s: %s",
573
+ name,
574
+ i,
575
+ type(exc).__name__,
576
+ exc,
577
+ )
578
+
579
+ # Re-raise first exception if any occurred, so caller knows something failed
580
+ if exceptions_found:
581
+ first_name = exceptions_found[0][1]
582
+ first_exc = exceptions_found[0][2]
583
+ logger.error(
584
+ "[query] %d tool(s) failed during concurrent execution, first error in '%s': %s",
585
+ len(exceptions_found),
586
+ first_name,
587
+ first_exc,
588
+ )
414
589
 
415
590
 
416
591
  class ToolRegistry:
@@ -483,6 +658,9 @@ class ToolRegistry:
483
658
  """Activate deferred tools by name."""
484
659
  activated: List[str] = []
485
660
  missing: List[str] = []
661
+
662
+ # First pass: collect tools to activate (no mutations)
663
+ to_activate: List[str] = []
486
664
  for raw_name in names:
487
665
  name = (raw_name or "").strip()
488
666
  if not name:
@@ -491,12 +669,17 @@ class ToolRegistry:
491
669
  continue
492
670
  tool = self._tool_map.get(name)
493
671
  if tool:
494
- self._active.append(name)
495
- self._active_set.add(name)
496
- self._deferred.discard(name)
497
- activated.append(name)
672
+ to_activate.append(name)
498
673
  else:
499
674
  missing.append(name)
675
+
676
+ # Second pass: atomically update all data structures
677
+ if to_activate:
678
+ self._active.extend(to_activate)
679
+ self._active_set.update(to_activate)
680
+ self._deferred.difference_update(to_activate)
681
+ activated.extend(to_activate)
682
+
500
683
  return activated, missing
501
684
 
502
685
  def iter_named_tools(self) -> Iterable[tuple[str, Tool[Any, Any]]]:
@@ -560,14 +743,6 @@ def _apply_skill_context_updates(
560
743
  class QueryContext:
561
744
  """Context for a query session."""
562
745
 
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
-
571
746
  def __init__(
572
747
  self,
573
748
  tools: List[Tool[Any, Any]],
@@ -580,6 +755,9 @@ class QueryContext:
580
755
  stop_hook: str = "stop",
581
756
  file_cache_max_entries: int = 500,
582
757
  file_cache_max_memory_mb: float = 50.0,
758
+ pending_message_queue: Optional[PendingMessageQueue] = None,
759
+ max_turns: Optional[int] = None,
760
+ permission_mode: str = "default",
583
761
  ) -> None:
584
762
  self.tool_registry = ToolRegistry(tools)
585
763
  self.max_thinking_tokens = max_thinking_tokens
@@ -587,6 +765,9 @@ class QueryContext:
587
765
  self.model = model
588
766
  self.verbose = verbose
589
767
  self.abort_controller = asyncio.Event()
768
+ self.pending_message_queue: PendingMessageQueue = (
769
+ pending_message_queue if pending_message_queue is not None else PendingMessageQueue()
770
+ )
590
771
  # Use BoundedFileCache instead of plain Dict to prevent unbounded growth
591
772
  self.file_state_cache: BoundedFileCache = BoundedFileCache(
592
773
  max_entries=file_cache_max_entries,
@@ -596,7 +777,8 @@ class QueryContext:
596
777
  self.resume_ui = resume_ui
597
778
  self.stop_hook = stop_hook
598
779
  self.stop_hook_active = False
599
- self._last_message_warning_count = 0
780
+ self.max_turns = max_turns
781
+ self.permission_mode = permission_mode
600
782
 
601
783
  @property
602
784
  def tools(self) -> List[Tool[Any, Any]]:
@@ -616,36 +798,6 @@ class QueryContext:
616
798
  """Return all known tools (active + deferred)."""
617
799
  return self.tool_registry.all_tools
618
800
 
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
801
  def get_memory_stats(self) -> Dict[str, Any]:
650
802
  """Return memory usage statistics for monitoring."""
651
803
  return {
@@ -654,6 +806,14 @@ class QueryContext:
654
806
  "active_tool_count": len(self.tool_registry.active_tools),
655
807
  }
656
808
 
809
+ def drain_pending_messages(self) -> List[UserMessage]:
810
+ """Drain queued messages waiting to be injected into the conversation."""
811
+ return self.pending_message_queue.drain()
812
+
813
+ def enqueue_user_message(self, text: str, metadata: Optional[Dict[str, Any]] = None) -> None:
814
+ """Queue a user-style message to inject once the current loop finishes."""
815
+ self.pending_message_queue.enqueue_text(text, metadata=metadata)
816
+
657
817
 
658
818
  async def query_llm(
659
819
  messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
@@ -751,6 +911,7 @@ async def query_llm(
751
911
  error_msg = create_assistant_message(
752
912
  content=str(exc),
753
913
  duration_ms=duration_ms,
914
+ model=model_profile.model,
754
915
  )
755
916
  error_msg.is_api_error_message = True
756
917
  return error_msg
@@ -765,6 +926,7 @@ async def query_llm(
765
926
  "Check your model configuration and provider dependencies."
766
927
  ),
767
928
  duration_ms=duration_ms,
929
+ model=model_profile.model,
768
930
  )
769
931
  error_msg.is_api_error_message = True
770
932
  return error_msg
@@ -805,6 +967,7 @@ async def query_llm(
805
967
  content=provider_response.content_blocks,
806
968
  duration_ms=provider_response.duration_ms,
807
969
  metadata=metadata,
970
+ model=model_profile.model,
808
971
  )
809
972
  error_msg.is_api_error_message = True
810
973
  return error_msg
@@ -862,7 +1025,10 @@ async def query_llm(
862
1025
  )
863
1026
 
864
1027
  error_msg = create_assistant_message(
865
- content=content, duration_ms=duration_ms, metadata=error_metadata
1028
+ content=content,
1029
+ duration_ms=duration_ms,
1030
+ metadata=error_metadata,
1031
+ model=model_profile.model,
866
1032
  )
867
1033
  error_msg.is_api_error_message = True
868
1034
  return error_msg
@@ -912,7 +1078,7 @@ async def _run_query_iteration(
912
1078
  Yields:
913
1079
  Messages (progress, assistant, tool results) as they are generated
914
1080
  """
915
- logger.debug(f"[query] Iteration {iteration}/{MAX_QUERY_ITERATIONS}")
1081
+ logger.info(f"[query] Starting iteration {iteration}/{MAX_QUERY_ITERATIONS}")
916
1082
 
917
1083
  # Check for file changes at the start of each iteration
918
1084
  change_notices = detect_changed_files(query_context.file_state_cache)
@@ -942,15 +1108,19 @@ async def _run_query_iteration(
942
1108
  if not chunk:
943
1109
  return
944
1110
  try:
945
- await progress_queue.put(
946
- create_progress_message(
947
- tool_use_id="stream",
948
- sibling_tool_use_ids=set(),
949
- content=chunk,
950
- )
1111
+ msg = create_progress_message(
1112
+ tool_use_id="stream",
1113
+ sibling_tool_use_ids=set(),
1114
+ content=chunk,
951
1115
  )
952
- except asyncio.QueueFull:
953
- logger.warning("[query] Progress queue full, dropping chunk")
1116
+ try:
1117
+ progress_queue.put_nowait(msg)
1118
+ except asyncio.QueueFull:
1119
+ # Queue full - wait with timeout instead of dropping immediately
1120
+ try:
1121
+ await asyncio.wait_for(progress_queue.put(msg), timeout=0.5)
1122
+ except asyncio.TimeoutError:
1123
+ logger.warning("[query] Progress queue full after timeout, dropping chunk")
954
1124
  except (RuntimeError, ValueError) as exc:
955
1125
  logger.warning("[query] Failed to enqueue stream progress chunk: %s", exc)
956
1126
 
@@ -969,6 +1139,8 @@ async def _run_query_iteration(
969
1139
  )
970
1140
  )
971
1141
 
1142
+ logger.debug("[query] Created query_llm task, waiting for response...")
1143
+
972
1144
  assistant_message: Optional[AssistantMessage] = None
973
1145
 
974
1146
  # Wait for LLM response while yielding progress
@@ -979,7 +1151,7 @@ async def _run_query_iteration(
979
1151
  await assistant_task
980
1152
  except CancelledError:
981
1153
  pass
982
- yield create_assistant_message(INTERRUPT_MESSAGE)
1154
+ yield create_assistant_message(INTERRUPT_MESSAGE, model=model_profile.model)
983
1155
  result.should_stop = True
984
1156
  return
985
1157
  if assistant_task.done():
@@ -1024,7 +1196,7 @@ async def _run_query_iteration(
1024
1196
 
1025
1197
  # Check for abort
1026
1198
  if query_context.abort_controller.is_set():
1027
- yield create_assistant_message(INTERRUPT_MESSAGE)
1199
+ yield create_assistant_message(INTERRUPT_MESSAGE, model=model_profile.model)
1028
1200
  result.should_stop = True
1029
1201
  return
1030
1202
 
@@ -1043,8 +1215,14 @@ async def _run_query_iteration(
1043
1215
  )
1044
1216
 
1045
1217
  if not tool_use_blocks:
1046
- logger.debug("[query] No tool_use blocks; returning response to user.")
1218
+ logger.debug(
1219
+ "[query] No tool_use blocks; running stop hook and returning response to user."
1220
+ )
1047
1221
  stop_hook = query_context.stop_hook
1222
+ logger.debug(
1223
+ f"[query] stop_hook={stop_hook}, stop_hook_active={query_context.stop_hook_active}"
1224
+ )
1225
+ logger.debug("[query] BEFORE calling hook_manager.run_stop_async")
1048
1226
  stop_result = (
1049
1227
  await hook_manager.run_subagent_stop_async(
1050
1228
  stop_hook_active=query_context.stop_hook_active
@@ -1052,10 +1230,14 @@ async def _run_query_iteration(
1052
1230
  if stop_hook == "subagent"
1053
1231
  else await hook_manager.run_stop_async(stop_hook_active=query_context.stop_hook_active)
1054
1232
  )
1233
+ logger.debug("[query] AFTER calling hook_manager.run_stop_async")
1234
+ logger.debug("[query] Checking additional_context")
1055
1235
  if stop_result.additional_context:
1056
1236
  _append_hook_context(context, f"{stop_hook}:context", stop_result.additional_context)
1237
+ logger.debug("[query] Checking system_message")
1057
1238
  if stop_result.system_message:
1058
1239
  _append_hook_context(context, f"{stop_hook}:system", stop_result.system_message)
1240
+ logger.debug("[query] Checking should_block")
1059
1241
  if stop_result.should_block:
1060
1242
  reason = stop_result.block_reason or stop_result.stop_reason or "Blocked by hook."
1061
1243
  result.tool_results = [create_user_message(f"{stop_hook} hook blocked: {reason}")]
@@ -1064,6 +1246,7 @@ async def _run_query_iteration(
1064
1246
  query_context.stop_hook_active = True
1065
1247
  result.should_stop = False
1066
1248
  return
1249
+ logger.debug("[query] Setting should_stop=True and returning")
1067
1250
  query_context.stop_hook_active = False
1068
1251
  result.should_stop = True
1069
1252
  return
@@ -1084,6 +1267,17 @@ async def _run_query_iteration(
1084
1267
  tool_use_id = getattr(tool_use, "tool_use_id", None) or getattr(tool_use, "id", None) or ""
1085
1268
  tool_input = getattr(tool_use, "input", {}) or {}
1086
1269
 
1270
+ # Handle case where input is a Pydantic model instead of a dict
1271
+ # This can happen when the API response contains structured tool input objects
1272
+ # Always try to convert if it has model_dump or dict methods
1273
+ if tool_input and hasattr(tool_input, "model_dump"):
1274
+ tool_input = tool_input.model_dump()
1275
+ elif tool_input and hasattr(tool_input, "dict") and callable(getattr(tool_input, "dict")):
1276
+ tool_input = tool_input.dict()
1277
+ elif tool_input and not isinstance(tool_input, dict):
1278
+ # Last resort: convert unknown type to string representation
1279
+ tool_input = {"value": str(tool_input)}
1280
+
1087
1281
  tool, missing_msg = _resolve_tool(query_context.tool_registry, tool_name, tool_use_id)
1088
1282
  if missing_msg:
1089
1283
  logger.warning(f"[query] Tool '{tool_name}' not found for tool_use_id={tool_use_id}")
@@ -1101,6 +1295,7 @@ async def _run_query_iteration(
1101
1295
  )
1102
1296
 
1103
1297
  tool_context = ToolUseContext(
1298
+ message_id=tool_use_id, # Set message_id for parent_tool_use_id tracking
1104
1299
  yolo_mode=query_context.yolo_mode,
1105
1300
  verbose=query_context.verbose,
1106
1301
  permission_checker=can_use_tool_fn,
@@ -1110,6 +1305,7 @@ async def _run_query_iteration(
1110
1305
  abort_signal=query_context.abort_controller,
1111
1306
  pause_ui=query_context.pause_ui,
1112
1307
  resume_ui=query_context.resume_ui,
1308
+ pending_message_queue=query_context.pending_message_queue,
1113
1309
  )
1114
1310
 
1115
1311
  validation = await tool.validate_input(parsed_input, tool_context)
@@ -1142,7 +1338,13 @@ async def _run_query_iteration(
1142
1338
  break
1143
1339
  if updated_input:
1144
1340
  try:
1145
- parsed_input = tool.input_schema(**updated_input)
1341
+ # Ensure updated_input is a dict, not a Pydantic model
1342
+ normalized_input = updated_input
1343
+ if hasattr(normalized_input, "model_dump"):
1344
+ normalized_input = normalized_input.model_dump()
1345
+ elif not isinstance(normalized_input, dict):
1346
+ normalized_input = {"value": str(normalized_input)}
1347
+ parsed_input = tool.input_schema(**normalized_input)
1146
1348
  except ValidationError as ve:
1147
1349
  detail_text = format_pydantic_errors(ve)
1148
1350
  error_msg = tool_result_message(
@@ -1166,6 +1368,7 @@ async def _run_query_iteration(
1166
1368
 
1167
1369
  prepared_calls.append(
1168
1370
  {
1371
+ "tool_name": tool_name,
1169
1372
  "is_concurrency_safe": tool.is_concurrency_safe(),
1170
1373
  "generator": _run_tool_use_generator(
1171
1374
  tool,
@@ -1229,7 +1432,7 @@ async def _run_query_iteration(
1229
1432
 
1230
1433
  # Check for abort after tools
1231
1434
  if query_context.abort_controller.is_set():
1232
- yield create_assistant_message(INTERRUPT_MESSAGE_FOR_TOOL_USE)
1435
+ yield create_assistant_message(INTERRUPT_MESSAGE_FOR_TOOL_USE, model=model_profile.model)
1233
1436
  result.tool_results = tool_results
1234
1437
  result.should_stop = True
1235
1438
  return
@@ -1253,6 +1456,26 @@ async def query(
1253
1456
  3. Executes tools
1254
1457
  4. Continues the conversation in a loop until no more tool calls
1255
1458
 
1459
+ Args:
1460
+ messages: Conversation history
1461
+ system_prompt: Base system prompt
1462
+ context: Additional context dictionary
1463
+ query_context: Query configuration
1464
+ can_use_tool_fn: Optional function to check tool permissions
1465
+
1466
+ Yields:
1467
+ Messages (user, assistant, progress) as they are generated
1468
+ """
1469
+ # Resolve model once for use in messages (e.g., max iterations, errors)
1470
+ model_profile = resolve_model_profile(query_context.model)
1471
+ """Execute a query with tool support.
1472
+
1473
+ This is the main query loop that:
1474
+ 1. Sends messages to the AI
1475
+ 2. Handles tool use responses
1476
+ 3. Executes tools
1477
+ 4. Continues the conversation in a loop until no more tool calls
1478
+
1256
1479
  Args:
1257
1480
  messages: Conversation history
1258
1481
  system_prompt: Base system prompt
@@ -1270,16 +1493,22 @@ async def query(
1270
1493
  "tool_count": len(query_context.tools),
1271
1494
  "yolo_mode": query_context.yolo_mode,
1272
1495
  "model_pointer": query_context.model,
1496
+ "max_turns": query_context.max_turns,
1497
+ "permission_mode": query_context.permission_mode,
1273
1498
  },
1274
1499
  )
1275
1500
  # Work on a copy so external mutations (e.g., UI appending messages while consuming)
1276
1501
  # do not interfere with the loop or normalization.
1277
1502
  messages = list(messages)
1278
1503
 
1279
- # Check initial message count for memory warnings
1280
- query_context.check_message_count(len(messages))
1281
-
1282
1504
  for iteration in range(1, MAX_QUERY_ITERATIONS + 1):
1505
+ # Inject any pending messages queued by background events or user interjections
1506
+ pending_messages = query_context.drain_pending_messages()
1507
+ if pending_messages:
1508
+ messages.extend(pending_messages)
1509
+ for pending in pending_messages:
1510
+ yield pending
1511
+
1283
1512
  result = IterationResult()
1284
1513
 
1285
1514
  async for msg in _run_query_iteration(
@@ -1294,6 +1523,20 @@ async def query(
1294
1523
  yield msg
1295
1524
 
1296
1525
  if result.should_stop:
1526
+ # Before stopping, check if new pending messages arrived during this iteration.
1527
+ trailing_pending = query_context.drain_pending_messages()
1528
+ if trailing_pending:
1529
+ # type: ignore[operator,list-item]
1530
+ next_messages = (
1531
+ messages + [result.assistant_message] + result.tool_results
1532
+ if result.assistant_message is not None
1533
+ else messages + result.tool_results # type: ignore[operator]
1534
+ ) # type: ignore[operator]
1535
+ next_messages = next_messages + trailing_pending # type: ignore[operator,list-item]
1536
+ for pending in trailing_pending:
1537
+ yield pending
1538
+ messages = next_messages
1539
+ continue
1297
1540
  return
1298
1541
 
1299
1542
  # Update messages for next iteration
@@ -1302,9 +1545,6 @@ async def query(
1302
1545
  else:
1303
1546
  messages = messages + result.tool_results # type: ignore[operator]
1304
1547
 
1305
- # Check message count after each iteration for memory warnings
1306
- query_context.check_message_count(len(messages))
1307
-
1308
1548
  logger.debug(
1309
1549
  f"[query] Continuing loop with {len(messages)} messages after tools; "
1310
1550
  f"tool_results_count={len(result.tool_results)}"
@@ -1316,5 +1556,6 @@ async def query(
1316
1556
  )
1317
1557
  yield create_assistant_message(
1318
1558
  f"Reached maximum query iterations ({MAX_QUERY_ITERATIONS}). "
1319
- "Please continue the conversation to proceed."
1559
+ "Please continue the conversation to proceed.",
1560
+ model=model_profile.model,
1320
1561
  )