ripperdoc 0.2.3__py3-none-any.whl → 0.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/__main__.py +0 -5
  3. ripperdoc/cli/cli.py +37 -16
  4. ripperdoc/cli/commands/__init__.py +2 -0
  5. ripperdoc/cli/commands/agents_cmd.py +12 -9
  6. ripperdoc/cli/commands/compact_cmd.py +7 -3
  7. ripperdoc/cli/commands/context_cmd.py +35 -15
  8. ripperdoc/cli/commands/doctor_cmd.py +27 -14
  9. ripperdoc/cli/commands/exit_cmd.py +1 -1
  10. ripperdoc/cli/commands/mcp_cmd.py +13 -8
  11. ripperdoc/cli/commands/memory_cmd.py +5 -5
  12. ripperdoc/cli/commands/models_cmd.py +47 -16
  13. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  14. ripperdoc/cli/commands/resume_cmd.py +1 -2
  15. ripperdoc/cli/commands/tasks_cmd.py +24 -13
  16. ripperdoc/cli/ui/rich_ui.py +523 -396
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +172 -4
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +13 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/__init__.py +31 -15
  23. ripperdoc/core/providers/anthropic.py +122 -8
  24. ripperdoc/core/providers/base.py +93 -15
  25. ripperdoc/core/providers/gemini.py +539 -96
  26. ripperdoc/core/providers/openai.py +371 -26
  27. ripperdoc/core/query.py +301 -62
  28. ripperdoc/core/query_utils.py +51 -7
  29. ripperdoc/core/skills.py +295 -0
  30. ripperdoc/core/system_prompt.py +79 -67
  31. ripperdoc/core/tool.py +15 -6
  32. ripperdoc/sdk/client.py +14 -1
  33. ripperdoc/tools/ask_user_question_tool.py +431 -0
  34. ripperdoc/tools/background_shell.py +82 -26
  35. ripperdoc/tools/bash_tool.py +356 -209
  36. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  37. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  38. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  39. ripperdoc/tools/file_edit_tool.py +53 -10
  40. ripperdoc/tools/file_read_tool.py +17 -7
  41. ripperdoc/tools/file_write_tool.py +49 -13
  42. ripperdoc/tools/glob_tool.py +10 -9
  43. ripperdoc/tools/grep_tool.py +182 -51
  44. ripperdoc/tools/ls_tool.py +6 -6
  45. ripperdoc/tools/mcp_tools.py +172 -413
  46. ripperdoc/tools/multi_edit_tool.py +49 -9
  47. ripperdoc/tools/notebook_edit_tool.py +57 -13
  48. ripperdoc/tools/skill_tool.py +205 -0
  49. ripperdoc/tools/task_tool.py +91 -9
  50. ripperdoc/tools/todo_tool.py +12 -12
  51. ripperdoc/tools/tool_search_tool.py +5 -6
  52. ripperdoc/utils/coerce.py +34 -0
  53. ripperdoc/utils/context_length_errors.py +252 -0
  54. ripperdoc/utils/file_watch.py +5 -4
  55. ripperdoc/utils/json_utils.py +4 -4
  56. ripperdoc/utils/log.py +3 -3
  57. ripperdoc/utils/mcp.py +82 -22
  58. ripperdoc/utils/memory.py +9 -6
  59. ripperdoc/utils/message_compaction.py +19 -16
  60. ripperdoc/utils/messages.py +73 -8
  61. ripperdoc/utils/path_ignore.py +677 -0
  62. ripperdoc/utils/permissions/__init__.py +7 -1
  63. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  64. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  65. ripperdoc/utils/prompt.py +1 -1
  66. ripperdoc/utils/safe_get_cwd.py +5 -2
  67. ripperdoc/utils/session_history.py +38 -19
  68. ripperdoc/utils/todo.py +6 -2
  69. ripperdoc/utils/token_estimation.py +34 -0
  70. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +14 -1
  71. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  72. ripperdoc-0.2.3.dist-info/RECORD +0 -95
  73. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  74. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
ripperdoc/core/query.py CHANGED
@@ -9,6 +9,7 @@ import inspect
9
9
  import os
10
10
  import time
11
11
  from asyncio import CancelledError
12
+ from dataclasses import dataclass, field
12
13
  from typing import (
13
14
  Any,
14
15
  AsyncGenerator,
@@ -39,6 +40,8 @@ from ripperdoc.core.query_utils import (
39
40
  tool_result_message,
40
41
  )
41
42
  from ripperdoc.core.tool import Tool, ToolProgress, ToolResult, ToolUseContext
43
+ from ripperdoc.utils.coerce import parse_optional_int
44
+ from ripperdoc.utils.context_length_errors import detect_context_length_error
42
45
  from ripperdoc.utils.file_watch import ChangedFileNotice, FileSnapshot, detect_changed_files
43
46
  from ripperdoc.utils.log import get_logger
44
47
  from ripperdoc.utils.messages import (
@@ -58,7 +61,7 @@ from ripperdoc.utils.messages import (
58
61
  logger = get_logger()
59
62
 
60
63
  DEFAULT_REQUEST_TIMEOUT_SEC = float(os.getenv("RIPPERDOC_API_TIMEOUT", "120"))
61
- MAX_LLM_RETRIES = 1
64
+ MAX_LLM_RETRIES = int(os.getenv("RIPPERDOC_MAX_RETRIES", "10"))
62
65
 
63
66
 
64
67
  def _resolve_tool(
@@ -118,10 +121,10 @@ async def _check_tool_permissions(
118
121
  return response.strip().lower() in ("y", "yes"), None
119
122
 
120
123
  return True, None
121
- except Exception:
122
- logger.exception(
123
- f"Error checking permissions for tool '{tool.name}'",
124
- extra={"tool": getattr(tool, "name", None)},
124
+ except (TypeError, AttributeError, ValueError) as exc:
125
+ logger.warning(
126
+ f"Error checking permissions for tool '{tool.name}': {type(exc).__name__}: {exc}",
127
+ extra={"tool": getattr(tool, "name", None), "error_type": type(exc).__name__},
125
128
  )
126
129
  return False, None
127
130
 
@@ -170,9 +173,12 @@ async def _run_tool_use_generator(
170
173
  f"[query] Tool completed tool_use_id={tool_use_id} name={tool_name} "
171
174
  f"result_len={len(result_content)}"
172
175
  )
173
- except Exception as exc:
174
- logger.exception(
175
- f"Error executing tool '{tool_name}'",
176
+ except CancelledError:
177
+ raise # Don't suppress task cancellation
178
+ except (RuntimeError, ValueError, TypeError, OSError, IOError, AttributeError, KeyError) as exc:
179
+ logger.warning(
180
+ "Error executing tool '%s': %s: %s",
181
+ tool_name, type(exc).__name__, exc,
176
182
  extra={"tool": tool_name, "tool_use_id": tool_use_id},
177
183
  )
178
184
  yield tool_result_message(tool_use_id, f"Error executing tool: {str(exc)}", is_error=True)
@@ -254,8 +260,15 @@ async def _run_concurrent_tool_uses(
254
260
  try:
255
261
  async for message in gen:
256
262
  await queue.put(message)
257
- except Exception:
258
- logger.exception("[query] Unexpected error while consuming tool generator")
263
+ except asyncio.CancelledError:
264
+ raise # Don't suppress cancellation
265
+ except (StopAsyncIteration, GeneratorExit):
266
+ pass # Normal generator termination
267
+ except (RuntimeError, ValueError, TypeError) as exc:
268
+ logger.warning(
269
+ "[query] Error while consuming tool generator: %s: %s",
270
+ type(exc).__name__, exc,
271
+ )
259
272
  finally:
260
273
  await queue.put(None)
261
274
 
@@ -304,9 +317,10 @@ class ToolRegistry:
304
317
  self._order.append(name)
305
318
  try:
306
319
  deferred = tool.defer_loading()
307
- except Exception:
308
- logger.exception(
309
- "[tool_registry] Tool.defer_loading failed",
320
+ except (TypeError, AttributeError) as exc:
321
+ logger.warning(
322
+ "[tool_registry] Tool.defer_loading failed: %s: %s",
323
+ type(exc).__name__, exc,
310
324
  extra={"tool": getattr(tool, "name", None)},
311
325
  )
312
326
  deferred = False
@@ -367,6 +381,55 @@ class ToolRegistry:
367
381
  yield name, tool
368
382
 
369
383
 
384
+ def _apply_skill_context_updates(
385
+ tool_results: List[UserMessage], query_context: "QueryContext"
386
+ ) -> None:
387
+ """Update query context based on Skill tool outputs."""
388
+ for message in tool_results:
389
+ data = getattr(message, "tool_use_result", None)
390
+ if not isinstance(data, dict):
391
+ continue
392
+ skill_name = (
393
+ data.get("skill")
394
+ or data.get("command_name")
395
+ or data.get("commandName")
396
+ or data.get("command")
397
+ )
398
+ if not skill_name:
399
+ continue
400
+
401
+ allowed_tools = data.get("allowed_tools") or data.get("allowedTools") or []
402
+ if allowed_tools and getattr(query_context, "tool_registry", None):
403
+ try:
404
+ query_context.tool_registry.activate_tools(
405
+ [tool for tool in allowed_tools if isinstance(tool, str) and tool.strip()]
406
+ )
407
+ except (KeyError, ValueError, TypeError) as exc:
408
+ logger.warning(
409
+ "[query] Failed to activate tools listed in skill output: %s: %s",
410
+ type(exc).__name__, exc,
411
+ )
412
+
413
+ model_hint = data.get("model")
414
+ if isinstance(model_hint, str) and model_hint.strip():
415
+ logger.debug(
416
+ "[query] Applying model hint from skill",
417
+ extra={"skill": skill_name, "model": model_hint},
418
+ )
419
+ query_context.model = model_hint.strip()
420
+
421
+ max_tokens = data.get("max_thinking_tokens")
422
+ if max_tokens is None:
423
+ max_tokens = data.get("maxThinkingTokens")
424
+ parsed_max = parse_optional_int(max_tokens)
425
+ if parsed_max is not None:
426
+ logger.debug(
427
+ "[query] Applying max thinking tokens from skill",
428
+ extra={"skill": skill_name, "max_thinking_tokens": parsed_max},
429
+ )
430
+ query_context.max_thinking_tokens = parsed_max
431
+
432
+
370
433
  class QueryContext:
371
434
  """Context for a query session."""
372
435
 
@@ -377,6 +440,8 @@ class QueryContext:
377
440
  safe_mode: bool = False,
378
441
  model: str = "main",
379
442
  verbose: bool = False,
443
+ pause_ui: Optional[Callable[[], None]] = None,
444
+ resume_ui: Optional[Callable[[], None]] = None,
380
445
  ) -> None:
381
446
  self.tool_registry = ToolRegistry(tools)
382
447
  self.max_thinking_tokens = max_thinking_tokens
@@ -385,6 +450,8 @@ class QueryContext:
385
450
  self.verbose = verbose
386
451
  self.abort_controller = asyncio.Event()
387
452
  self.file_state_cache: Dict[str, FileSnapshot] = {}
453
+ self.pause_ui = pause_ui
454
+ self.resume_ui = resume_ui
388
455
 
389
456
  @property
390
457
  def tools(self) -> List[Tool[Any, Any]]:
@@ -411,7 +478,7 @@ async def query_llm(
411
478
  tools: List[Tool[Any, Any]],
412
479
  max_thinking_tokens: int = 0,
413
480
  model: str = "main",
414
- abort_signal: Optional[asyncio.Event] = None,
481
+ _abort_signal: Optional[asyncio.Event] = None,
415
482
  *,
416
483
  progress_callback: Optional[Callable[[str], Awaitable[None]]] = None,
417
484
  request_timeout: Optional[float] = None,
@@ -426,7 +493,7 @@ async def query_llm(
426
493
  tools: Available tools
427
494
  max_thinking_tokens: Maximum tokens for thinking (0 = disabled)
428
495
  model: Model pointer to use
429
- abort_signal: Event to signal abortion
496
+ _abort_signal: Event to signal abortion (currently unused, reserved for future)
430
497
  progress_callback: Optional async callback invoked with streamed text chunks
431
498
  request_timeout: Max seconds to wait for a provider response before retrying
432
499
  max_retries: Number of retries on timeout/errors (total attempts = retries + 1)
@@ -503,18 +570,50 @@ async def query_llm(
503
570
  progress_callback=progress_callback,
504
571
  request_timeout=request_timeout,
505
572
  max_retries=max_retries,
573
+ max_thinking_tokens=max_thinking_tokens,
506
574
  )
507
575
 
576
+ # Check if provider returned an error response
577
+ if provider_response.is_error:
578
+ logger.warning(
579
+ "[query_llm] Provider returned error response",
580
+ extra={
581
+ "model": model_profile.model,
582
+ "error_code": provider_response.error_code,
583
+ "error_message": provider_response.error_message,
584
+ },
585
+ )
586
+ metadata: Dict[str, Any] = {
587
+ "api_error": True,
588
+ "error_code": provider_response.error_code,
589
+ "error_message": provider_response.error_message,
590
+ }
591
+ # Add context length info if applicable
592
+ if provider_response.error_code == "context_length_exceeded":
593
+ metadata["context_length_exceeded"] = True
594
+
595
+ error_msg = create_assistant_message(
596
+ content=provider_response.content_blocks,
597
+ duration_ms=provider_response.duration_ms,
598
+ metadata=metadata,
599
+ )
600
+ error_msg.is_api_error_message = True
601
+ return error_msg
602
+
508
603
  return create_assistant_message(
509
604
  content=provider_response.content_blocks,
510
605
  cost_usd=provider_response.cost_usd,
511
606
  duration_ms=provider_response.duration_ms,
607
+ metadata=provider_response.metadata,
512
608
  )
513
609
 
514
- except Exception as e:
610
+ except CancelledError:
611
+ raise # Don't suppress task cancellation
612
+ except (RuntimeError, ValueError, TypeError, OSError, ConnectionError, TimeoutError) as e:
515
613
  # Return error message
516
- logger.exception(
517
- "Error querying AI model",
614
+ logger.warning(
615
+ "Error querying AI model: %s: %s",
616
+ type(e).__name__, e,
518
617
  extra={
519
618
  "model": getattr(model_profile, "model", None),
520
619
  "model_pointer": model,
@@ -524,56 +623,90 @@ async def query_llm(
524
623
  },
525
624
  )
526
625
  duration_ms = (time.time() - start_time) * 1000
626
+ context_error = detect_context_length_error(e)
627
+ metadata = None
628
+ content = f"Error querying AI model: {str(e)}"
629
+
630
+ if context_error:
631
+ content = f"The request exceeded the model's context window. {context_error.message}"
632
+ metadata = {
633
+ "context_length_exceeded": True,
634
+ "context_length_provider": context_error.provider,
635
+ "context_length_error_code": context_error.error_code,
636
+ "context_length_status_code": context_error.status_code,
637
+ }
638
+ logger.info(
639
+ "[query_llm] Detected context-length error; consider compacting history",
640
+ extra={
641
+ "provider": context_error.provider,
642
+ "error_code": context_error.error_code,
643
+ "status_code": context_error.status_code,
644
+ },
645
+ )
646
+
527
647
  error_msg = create_assistant_message(
528
- content=f"Error querying AI model: {str(e)}", duration_ms=duration_ms
648
+ content=content, duration_ms=duration_ms, metadata=metadata
529
649
  )
530
650
  error_msg.is_api_error_message = True
531
651
  return error_msg
532
652
 
533
653
 
534
- async def query(
654
+ MAX_QUERY_ITERATIONS = int(os.getenv("RIPPERDOC_MAX_QUERY_ITERATIONS", "1024"))
655
+
656
+
657
+ @dataclass
658
+ class IterationResult:
659
+ """Result of a single query iteration.
660
+
661
+ This is used as an "out parameter" to communicate results from
662
+ _run_query_iteration back to the main query loop.
663
+ """
664
+
665
+ assistant_message: Optional[AssistantMessage] = None
666
+ tool_results: List[UserMessage] = field(default_factory=list)
667
+ should_stop: bool = False # True means exit the query loop entirely
668
+
669
+
670
+ async def _run_query_iteration(
535
671
  messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
536
672
  system_prompt: str,
537
673
  context: Dict[str, str],
538
674
  query_context: QueryContext,
539
- can_use_tool_fn: Optional[ToolPermissionCallable] = None,
675
+ can_use_tool_fn: Optional[ToolPermissionCallable],
676
+ iteration: int,
677
+ result: IterationResult,
540
678
  ) -> AsyncGenerator[Union[UserMessage, AssistantMessage, ProgressMessage], None]:
541
- """Execute a query with tool support.
679
+ """Run a single iteration of the query loop.
542
680
 
543
- This is the main query loop that:
544
- 1. Sends messages to the AI
545
- 2. Handles tool use responses
546
- 3. Executes tools
547
- 4. Recursively continues the conversation
681
+ This function handles one round of:
682
+ 1. Calling the LLM
683
+ 2. Streaming progress
684
+ 3. Processing tool calls (if any)
548
685
 
549
686
  Args:
550
- messages: Conversation history
687
+ messages: Current conversation history
551
688
  system_prompt: Base system prompt
552
689
  context: Additional context dictionary
553
690
  query_context: Query configuration
554
691
  can_use_tool_fn: Optional function to check tool permissions
692
+ iteration: Current iteration number (for logging)
693
+ result: IterationResult object to store results
555
694
 
556
695
  Yields:
557
- Messages (user, assistant, progress) as they are generated
696
+ Messages (progress, assistant, tool results) as they are generated
558
697
  """
559
- logger.info(
560
- "[query] Starting query loop",
561
- extra={
562
- "message_count": len(messages),
563
- "tool_count": len(query_context.tools),
564
- "safe_mode": query_context.safe_mode,
565
- "model_pointer": query_context.model,
566
- },
567
- )
568
- # Work on a copy so external mutations (e.g., UI appending messages while consuming)
569
- # do not interfere with recursion or normalization.
570
- messages = list(messages)
698
+ logger.debug(f"[query] Iteration {iteration}/{MAX_QUERY_ITERATIONS}")
699
+
700
+ # Check for file changes at the start of each iteration
571
701
  change_notices = detect_changed_files(query_context.file_state_cache)
572
702
  if change_notices:
573
703
  messages.append(create_user_message(_format_changed_file_notice(change_notices)))
704
+
574
705
  model_profile = resolve_model_profile(query_context.model)
575
706
  tool_mode = determine_tool_mode(model_profile)
576
- tools_for_model: List[Tool[Any, Any]] = [] if tool_mode == "text" else query_context.all_tools()
707
+ tools_for_model: List[Tool[Any, Any]] = (
708
+ [] if tool_mode == "text" else query_context.all_tools()
709
+ )
577
710
 
578
711
  full_system_prompt = build_full_system_prompt(
579
712
  system_prompt, context, tool_mode, query_context.all_tools()
@@ -587,6 +720,7 @@ async def query(
587
720
  },
588
721
  )
589
722
 
723
+ # Stream LLM response
590
724
  progress_queue: asyncio.Queue[Optional[ProgressMessage]] = asyncio.Queue()
591
725
 
592
726
  async def _stream_progress(chunk: str) -> None:
@@ -600,8 +734,10 @@ async def query(
600
734
  content=chunk,
601
735
  )
602
736
  )
603
- except Exception:
604
- logger.exception("[query] Failed to enqueue stream progress chunk")
737
+ except asyncio.QueueFull:
738
+ logger.warning("[query] Progress queue full, dropping chunk")
739
+ except (RuntimeError, ValueError) as exc:
740
+ logger.warning("[query] Failed to enqueue stream progress chunk: %s", exc)
605
741
 
606
742
  assistant_task = asyncio.create_task(
607
743
  query_llm(
@@ -620,6 +756,7 @@ async def query(
620
756
 
621
757
  assistant_message: Optional[AssistantMessage] = None
622
758
 
759
+ # Wait for LLM response while yielding progress
623
760
  while True:
624
761
  if query_context.abort_controller.is_set():
625
762
  assistant_task.cancel()
@@ -628,6 +765,7 @@ async def query(
628
765
  except CancelledError:
629
766
  pass
630
767
  yield create_assistant_message(INTERRUPT_MESSAGE)
768
+ result.should_stop = True
631
769
  return
632
770
  if assistant_task.done():
633
771
  assistant_message = await assistant_task
@@ -648,20 +786,24 @@ async def query(
648
786
  if progress:
649
787
  yield progress
650
788
 
789
+ # Drain remaining progress messages
651
790
  while not progress_queue.empty():
652
791
  residual = progress_queue.get_nowait()
653
792
  if residual:
654
793
  yield residual
655
794
 
656
795
  assert assistant_message is not None
796
+ result.assistant_message = assistant_message
657
797
 
658
798
  # Check for abort
659
799
  if query_context.abort_controller.is_set():
660
800
  yield create_assistant_message(INTERRUPT_MESSAGE)
801
+ result.should_stop = True
661
802
  return
662
803
 
663
804
  yield assistant_message
664
805
 
806
+ # Extract and process tool calls
665
807
  tool_use_blocks: List[MessageContent] = extract_tool_use_blocks(assistant_message)
666
808
  text_blocks = (
667
809
  len(assistant_message.message.content)
@@ -675,13 +817,16 @@ async def query(
675
817
 
676
818
  if not tool_use_blocks:
677
819
  logger.debug("[query] No tool_use blocks; returning response to user.")
820
+ result.should_stop = True
678
821
  return
679
822
 
823
+ # Process tool calls
680
824
  logger.debug(f"[query] Executing {len(tool_use_blocks)} tool_use block(s).")
681
825
  tool_results: List[UserMessage] = []
682
826
  permission_denied = False
683
827
  sibling_ids = set(
684
- getattr(t, "tool_use_id", None) or getattr(t, "id", None) or "" for t in tool_use_blocks
828
+ getattr(t, "tool_use_id", None) or getattr(t, "id", None) or ""
829
+ for t in tool_use_blocks
685
830
  )
686
831
  prepared_calls: List[Dict[str, Any]] = []
687
832
 
@@ -689,12 +834,18 @@ async def query(
689
834
  tool_name = tool_use.name
690
835
  if not tool_name:
691
836
  continue
692
- tool_use_id = getattr(tool_use, "tool_use_id", None) or getattr(tool_use, "id", None) or ""
837
+ tool_use_id = (
838
+ getattr(tool_use, "tool_use_id", None) or getattr(tool_use, "id", None) or ""
839
+ )
693
840
  tool_input = getattr(tool_use, "input", {}) or {}
694
841
 
695
- tool, missing_msg = _resolve_tool(query_context.tool_registry, tool_name, tool_use_id)
842
+ tool, missing_msg = _resolve_tool(
843
+ query_context.tool_registry, tool_name, tool_use_id
844
+ )
696
845
  if missing_msg:
697
- logger.warning(f"[query] Tool '{tool_name}' not found for tool_use_id={tool_use_id}")
846
+ logger.warning(
847
+ f"[query] Tool '{tool_name}' not found for tool_use_id={tool_use_id}"
848
+ )
698
849
  tool_results.append(missing_msg)
699
850
  yield missing_msg
700
851
  continue
@@ -714,12 +865,15 @@ async def query(
714
865
  tool_registry=query_context.tool_registry,
715
866
  file_state_cache=query_context.file_state_cache,
716
867
  abort_signal=query_context.abort_controller,
868
+ pause_ui=query_context.pause_ui,
869
+ resume_ui=query_context.resume_ui,
717
870
  )
718
871
 
719
872
  validation = await tool.validate_input(parsed_input, tool_context)
720
873
  if not validation.result:
721
874
  logger.debug(
722
- f"[query] Validation failed for tool_use_id={tool_use_id}: {validation.message}"
875
+ f"[query] Validation failed for tool_use_id={tool_use_id}: "
876
+ f"{validation.message}"
723
877
  )
724
878
  result_msg = tool_result_message(
725
879
  tool_use_id,
@@ -736,9 +890,12 @@ async def query(
736
890
  )
737
891
  if not allowed:
738
892
  logger.debug(
739
- f"[query] Permission denied for tool_use_id={tool_use_id}: {denial_message}"
893
+ f"[query] Permission denied for tool_use_id={tool_use_id}: "
894
+ f"{denial_message}"
895
+ )
896
+ denial_text = (
897
+ denial_message or f"User aborted the tool invocation: {tool_name}"
740
898
  )
741
- denial_text = denial_message or f"User aborted the tool invocation: {tool_name}"
742
899
  denial_msg = tool_result_message(tool_use_id, denial_text, is_error=True)
743
900
  tool_results.append(denial_msg)
744
901
  yield denial_msg
@@ -769,9 +926,22 @@ async def query(
769
926
  tool_results.append(error_msg)
770
927
  yield error_msg
771
928
  continue
772
- except Exception as e:
773
- logger.exception(
774
- f"Error executing tool '{tool_name}'",
929
+ except CancelledError:
930
+ raise # Don't suppress task cancellation
931
+ except (
932
+ RuntimeError,
933
+ ValueError,
934
+ TypeError,
935
+ OSError,
936
+ IOError,
937
+ AttributeError,
938
+ KeyError,
939
+ ) as e:
940
+ logger.warning(
941
+ "Error executing tool '%s': %s: %s",
942
+ tool_name,
943
+ type(e).__name__,
944
+ e,
775
945
  extra={"tool": tool_name, "tool_use_id": tool_use_id},
776
946
  )
777
947
  error_msg = tool_result_message(
@@ -784,25 +954,94 @@ async def query(
784
954
  break
785
955
 
786
956
  if permission_denied:
957
+ result.tool_results = tool_results
958
+ result.should_stop = True
787
959
  return
788
960
 
789
961
  if prepared_calls:
790
962
  async for message in _run_tools_concurrently(prepared_calls, tool_results):
791
963
  yield message
792
964
 
965
+ _apply_skill_context_updates(tool_results, query_context)
966
+
793
967
  # Check for abort after tools
794
968
  if query_context.abort_controller.is_set():
795
969
  yield create_assistant_message(INTERRUPT_MESSAGE_FOR_TOOL_USE)
970
+ result.tool_results = tool_results
971
+ result.should_stop = True
796
972
  return
797
973
 
798
- if permission_denied:
799
- return
974
+ result.tool_results = tool_results
975
+ # should_stop remains False, indicating the loop should continue
800
976
 
801
- new_messages = messages + [assistant_message] + tool_results
802
- logger.debug(
803
- f"[query] Recursing with {len(new_messages)} messages after tools; "
804
- f"tool_results_count={len(tool_results)}"
977
+
978
+ async def query(
979
+ messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
980
+ system_prompt: str,
981
+ context: Dict[str, str],
982
+ query_context: QueryContext,
983
+ can_use_tool_fn: Optional[ToolPermissionCallable] = None,
984
+ ) -> AsyncGenerator[Union[UserMessage, AssistantMessage, ProgressMessage], None]:
985
+ """Execute a query with tool support.
986
+
987
+ This is the main query loop that:
988
+ 1. Sends messages to the AI
989
+ 2. Handles tool use responses
990
+ 3. Executes tools
991
+ 4. Continues the conversation in a loop until no more tool calls
992
+
993
+ Args:
994
+ messages: Conversation history
995
+ system_prompt: Base system prompt
996
+ context: Additional context dictionary
997
+ query_context: Query configuration
998
+ can_use_tool_fn: Optional function to check tool permissions
999
+
1000
+ Yields:
1001
+ Messages (user, assistant, progress) as they are generated
1002
+ """
1003
+ logger.info(
1004
+ "[query] Starting query loop",
1005
+ extra={
1006
+ "message_count": len(messages),
1007
+ "tool_count": len(query_context.tools),
1008
+ "safe_mode": query_context.safe_mode,
1009
+ "model_pointer": query_context.model,
1010
+ },
805
1011
  )
1012
+ # Work on a copy so external mutations (e.g., UI appending messages while consuming)
1013
+ # do not interfere with the loop or normalization.
1014
+ messages = list(messages)
1015
+
1016
+ for iteration in range(1, MAX_QUERY_ITERATIONS + 1):
1017
+ result = IterationResult()
806
1018
 
807
- async for msg in query(new_messages, system_prompt, context, query_context, can_use_tool_fn):
808
- yield msg
1019
+ async for msg in _run_query_iteration(
1020
+ messages,
1021
+ system_prompt,
1022
+ context,
1023
+ query_context,
1024
+ can_use_tool_fn,
1025
+ iteration,
1026
+ result,
1027
+ ):
1028
+ yield msg
1029
+
1030
+ if result.should_stop:
1031
+ return
1032
+
1033
+ # Update messages for next iteration
1034
+ messages = messages + [result.assistant_message] + result.tool_results
1035
+ logger.debug(
1036
+ f"[query] Continuing loop with {len(messages)} messages after tools; "
1037
+ f"tool_results_count={len(result.tool_results)}"
1038
+ )
1039
+
1040
+ # Reached max iterations
1041
+ logger.warning(
1042
+ f"[query] Reached maximum iterations ({MAX_QUERY_ITERATIONS}), stopping query loop"
1043
+ )
1044
+ yield create_assistant_message(
1045
+ f"Reached maximum query iterations ({MAX_QUERY_ITERATIONS}). "
1046
+ "Please continue the conversation to proceed."
1047
+ )