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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/__main__.py +0 -5
  3. ripperdoc/cli/cli.py +37 -16
  4. ripperdoc/cli/commands/__init__.py +2 -0
  5. ripperdoc/cli/commands/agents_cmd.py +12 -9
  6. ripperdoc/cli/commands/compact_cmd.py +7 -3
  7. ripperdoc/cli/commands/context_cmd.py +33 -13
  8. ripperdoc/cli/commands/doctor_cmd.py +27 -14
  9. ripperdoc/cli/commands/exit_cmd.py +1 -1
  10. ripperdoc/cli/commands/mcp_cmd.py +13 -8
  11. ripperdoc/cli/commands/memory_cmd.py +5 -5
  12. ripperdoc/cli/commands/models_cmd.py +47 -16
  13. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  14. ripperdoc/cli/commands/resume_cmd.py +1 -2
  15. ripperdoc/cli/commands/tasks_cmd.py +24 -13
  16. ripperdoc/cli/ui/rich_ui.py +500 -406
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +17 -9
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +7 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/anthropic.py +107 -4
  23. ripperdoc/core/providers/base.py +33 -4
  24. ripperdoc/core/providers/gemini.py +169 -50
  25. ripperdoc/core/providers/openai.py +257 -23
  26. ripperdoc/core/query.py +294 -61
  27. ripperdoc/core/query_utils.py +50 -6
  28. ripperdoc/core/skills.py +295 -0
  29. ripperdoc/core/system_prompt.py +13 -7
  30. ripperdoc/core/tool.py +8 -6
  31. ripperdoc/sdk/client.py +14 -1
  32. ripperdoc/tools/ask_user_question_tool.py +20 -22
  33. ripperdoc/tools/background_shell.py +19 -13
  34. ripperdoc/tools/bash_tool.py +356 -209
  35. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  36. ripperdoc/tools/enter_plan_mode_tool.py +5 -2
  37. ripperdoc/tools/exit_plan_mode_tool.py +6 -3
  38. ripperdoc/tools/file_edit_tool.py +53 -10
  39. ripperdoc/tools/file_read_tool.py +17 -7
  40. ripperdoc/tools/file_write_tool.py +49 -13
  41. ripperdoc/tools/glob_tool.py +10 -9
  42. ripperdoc/tools/grep_tool.py +182 -51
  43. ripperdoc/tools/ls_tool.py +6 -6
  44. ripperdoc/tools/mcp_tools.py +106 -456
  45. ripperdoc/tools/multi_edit_tool.py +49 -9
  46. ripperdoc/tools/notebook_edit_tool.py +57 -13
  47. ripperdoc/tools/skill_tool.py +205 -0
  48. ripperdoc/tools/task_tool.py +7 -8
  49. ripperdoc/tools/todo_tool.py +12 -12
  50. ripperdoc/tools/tool_search_tool.py +5 -6
  51. ripperdoc/utils/coerce.py +34 -0
  52. ripperdoc/utils/context_length_errors.py +252 -0
  53. ripperdoc/utils/file_watch.py +5 -4
  54. ripperdoc/utils/json_utils.py +4 -4
  55. ripperdoc/utils/log.py +3 -3
  56. ripperdoc/utils/mcp.py +36 -15
  57. ripperdoc/utils/memory.py +9 -6
  58. ripperdoc/utils/message_compaction.py +16 -11
  59. ripperdoc/utils/messages.py +73 -8
  60. ripperdoc/utils/path_ignore.py +677 -0
  61. ripperdoc/utils/permissions/__init__.py +7 -1
  62. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  63. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  64. ripperdoc/utils/prompt.py +1 -1
  65. ripperdoc/utils/safe_get_cwd.py +5 -2
  66. ripperdoc/utils/session_history.py +38 -19
  67. ripperdoc/utils/todo.py +6 -2
  68. ripperdoc/utils/token_estimation.py +4 -3
  69. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
  70. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  71. ripperdoc-0.2.4.dist-info/RECORD +0 -99
  72. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  73. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  74. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  75. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
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 (
@@ -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
 
@@ -415,7 +478,7 @@ async def query_llm(
415
478
  tools: List[Tool[Any, Any]],
416
479
  max_thinking_tokens: int = 0,
417
480
  model: str = "main",
418
- abort_signal: Optional[asyncio.Event] = None,
481
+ _abort_signal: Optional[asyncio.Event] = None,
419
482
  *,
420
483
  progress_callback: Optional[Callable[[str], Awaitable[None]]] = None,
421
484
  request_timeout: Optional[float] = None,
@@ -430,7 +493,7 @@ async def query_llm(
430
493
  tools: Available tools
431
494
  max_thinking_tokens: Maximum tokens for thinking (0 = disabled)
432
495
  model: Model pointer to use
433
- abort_signal: Event to signal abortion
496
+ _abort_signal: Event to signal abortion (currently unused, reserved for future)
434
497
  progress_callback: Optional async callback invoked with streamed text chunks
435
498
  request_timeout: Max seconds to wait for a provider response before retrying
436
499
  max_retries: Number of retries on timeout/errors (total attempts = retries + 1)
@@ -507,18 +570,50 @@ async def query_llm(
507
570
  progress_callback=progress_callback,
508
571
  request_timeout=request_timeout,
509
572
  max_retries=max_retries,
573
+ max_thinking_tokens=max_thinking_tokens,
510
574
  )
511
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
+
512
603
  return create_assistant_message(
513
604
  content=provider_response.content_blocks,
514
605
  cost_usd=provider_response.cost_usd,
515
606
  duration_ms=provider_response.duration_ms,
607
+ metadata=provider_response.metadata,
516
608
  )
517
609
 
518
- except Exception as e:
610
+ except CancelledError:
611
+ raise # Don't suppress task cancellation
612
+ except (RuntimeError, ValueError, TypeError, OSError, ConnectionError, TimeoutError) as e:
519
613
  # Return error message
520
- logger.exception(
521
- "Error querying AI model",
614
+ logger.warning(
615
+ "Error querying AI model: %s: %s",
616
+ type(e).__name__, e,
522
617
  extra={
523
618
  "model": getattr(model_profile, "model", None),
524
619
  "model_pointer": model,
@@ -528,56 +623,90 @@ async def query_llm(
528
623
  },
529
624
  )
530
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
+
531
647
  error_msg = create_assistant_message(
532
- content=f"Error querying AI model: {str(e)}", duration_ms=duration_ms
648
+ content=content, duration_ms=duration_ms, metadata=metadata
533
649
  )
534
650
  error_msg.is_api_error_message = True
535
651
  return error_msg
536
652
 
537
653
 
538
- 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(
539
671
  messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
540
672
  system_prompt: str,
541
673
  context: Dict[str, str],
542
674
  query_context: QueryContext,
543
- can_use_tool_fn: Optional[ToolPermissionCallable] = None,
675
+ can_use_tool_fn: Optional[ToolPermissionCallable],
676
+ iteration: int,
677
+ result: IterationResult,
544
678
  ) -> AsyncGenerator[Union[UserMessage, AssistantMessage, ProgressMessage], None]:
545
- """Execute a query with tool support.
679
+ """Run a single iteration of the query loop.
546
680
 
547
- This is the main query loop that:
548
- 1. Sends messages to the AI
549
- 2. Handles tool use responses
550
- 3. Executes tools
551
- 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)
552
685
 
553
686
  Args:
554
- messages: Conversation history
687
+ messages: Current conversation history
555
688
  system_prompt: Base system prompt
556
689
  context: Additional context dictionary
557
690
  query_context: Query configuration
558
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
559
694
 
560
695
  Yields:
561
- Messages (user, assistant, progress) as they are generated
696
+ Messages (progress, assistant, tool results) as they are generated
562
697
  """
563
- logger.info(
564
- "[query] Starting query loop",
565
- extra={
566
- "message_count": len(messages),
567
- "tool_count": len(query_context.tools),
568
- "safe_mode": query_context.safe_mode,
569
- "model_pointer": query_context.model,
570
- },
571
- )
572
- # Work on a copy so external mutations (e.g., UI appending messages while consuming)
573
- # do not interfere with recursion or normalization.
574
- 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
575
701
  change_notices = detect_changed_files(query_context.file_state_cache)
576
702
  if change_notices:
577
703
  messages.append(create_user_message(_format_changed_file_notice(change_notices)))
704
+
578
705
  model_profile = resolve_model_profile(query_context.model)
579
706
  tool_mode = determine_tool_mode(model_profile)
580
- 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
+ )
581
710
 
582
711
  full_system_prompt = build_full_system_prompt(
583
712
  system_prompt, context, tool_mode, query_context.all_tools()
@@ -591,6 +720,7 @@ async def query(
591
720
  },
592
721
  )
593
722
 
723
+ # Stream LLM response
594
724
  progress_queue: asyncio.Queue[Optional[ProgressMessage]] = asyncio.Queue()
595
725
 
596
726
  async def _stream_progress(chunk: str) -> None:
@@ -604,8 +734,10 @@ async def query(
604
734
  content=chunk,
605
735
  )
606
736
  )
607
- except Exception:
608
- 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)
609
741
 
610
742
  assistant_task = asyncio.create_task(
611
743
  query_llm(
@@ -624,6 +756,7 @@ async def query(
624
756
 
625
757
  assistant_message: Optional[AssistantMessage] = None
626
758
 
759
+ # Wait for LLM response while yielding progress
627
760
  while True:
628
761
  if query_context.abort_controller.is_set():
629
762
  assistant_task.cancel()
@@ -632,6 +765,7 @@ async def query(
632
765
  except CancelledError:
633
766
  pass
634
767
  yield create_assistant_message(INTERRUPT_MESSAGE)
768
+ result.should_stop = True
635
769
  return
636
770
  if assistant_task.done():
637
771
  assistant_message = await assistant_task
@@ -652,20 +786,24 @@ async def query(
652
786
  if progress:
653
787
  yield progress
654
788
 
789
+ # Drain remaining progress messages
655
790
  while not progress_queue.empty():
656
791
  residual = progress_queue.get_nowait()
657
792
  if residual:
658
793
  yield residual
659
794
 
660
795
  assert assistant_message is not None
796
+ result.assistant_message = assistant_message
661
797
 
662
798
  # Check for abort
663
799
  if query_context.abort_controller.is_set():
664
800
  yield create_assistant_message(INTERRUPT_MESSAGE)
801
+ result.should_stop = True
665
802
  return
666
803
 
667
804
  yield assistant_message
668
805
 
806
+ # Extract and process tool calls
669
807
  tool_use_blocks: List[MessageContent] = extract_tool_use_blocks(assistant_message)
670
808
  text_blocks = (
671
809
  len(assistant_message.message.content)
@@ -679,13 +817,16 @@ async def query(
679
817
 
680
818
  if not tool_use_blocks:
681
819
  logger.debug("[query] No tool_use blocks; returning response to user.")
820
+ result.should_stop = True
682
821
  return
683
822
 
823
+ # Process tool calls
684
824
  logger.debug(f"[query] Executing {len(tool_use_blocks)} tool_use block(s).")
685
825
  tool_results: List[UserMessage] = []
686
826
  permission_denied = False
687
827
  sibling_ids = set(
688
- 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
689
830
  )
690
831
  prepared_calls: List[Dict[str, Any]] = []
691
832
 
@@ -693,12 +834,18 @@ async def query(
693
834
  tool_name = tool_use.name
694
835
  if not tool_name:
695
836
  continue
696
- 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
+ )
697
840
  tool_input = getattr(tool_use, "input", {}) or {}
698
841
 
699
- 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
+ )
700
845
  if missing_msg:
701
- 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
+ )
702
849
  tool_results.append(missing_msg)
703
850
  yield missing_msg
704
851
  continue
@@ -725,7 +872,8 @@ async def query(
725
872
  validation = await tool.validate_input(parsed_input, tool_context)
726
873
  if not validation.result:
727
874
  logger.debug(
728
- 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}"
729
877
  )
730
878
  result_msg = tool_result_message(
731
879
  tool_use_id,
@@ -742,9 +890,12 @@ async def query(
742
890
  )
743
891
  if not allowed:
744
892
  logger.debug(
745
- 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}"
746
898
  )
747
- denial_text = denial_message or f"User aborted the tool invocation: {tool_name}"
748
899
  denial_msg = tool_result_message(tool_use_id, denial_text, is_error=True)
749
900
  tool_results.append(denial_msg)
750
901
  yield denial_msg
@@ -775,9 +926,22 @@ async def query(
775
926
  tool_results.append(error_msg)
776
927
  yield error_msg
777
928
  continue
778
- except Exception as e:
779
- logger.exception(
780
- 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,
781
945
  extra={"tool": tool_name, "tool_use_id": tool_use_id},
782
946
  )
783
947
  error_msg = tool_result_message(
@@ -790,25 +954,94 @@ async def query(
790
954
  break
791
955
 
792
956
  if permission_denied:
957
+ result.tool_results = tool_results
958
+ result.should_stop = True
793
959
  return
794
960
 
795
961
  if prepared_calls:
796
962
  async for message in _run_tools_concurrently(prepared_calls, tool_results):
797
963
  yield message
798
964
 
965
+ _apply_skill_context_updates(tool_results, query_context)
966
+
799
967
  # Check for abort after tools
800
968
  if query_context.abort_controller.is_set():
801
969
  yield create_assistant_message(INTERRUPT_MESSAGE_FOR_TOOL_USE)
970
+ result.tool_results = tool_results
971
+ result.should_stop = True
802
972
  return
803
973
 
804
- if permission_denied:
805
- return
974
+ result.tool_results = tool_results
975
+ # should_stop remains False, indicating the loop should continue
806
976
 
807
- new_messages = messages + [assistant_message] + tool_results
808
- logger.debug(
809
- f"[query] Recursing with {len(new_messages)} messages after tools; "
810
- 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
+ },
811
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()
812
1018
 
813
- async for msg in query(new_messages, system_prompt, context, query_context, can_use_tool_fn):
814
- 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
+ )
@@ -66,16 +66,43 @@ def anthropic_usage_tokens(usage: Optional[Mapping[str, Any] | object]) -> Dict[
66
66
  def openai_usage_tokens(usage: Optional[Mapping[str, Any] | object]) -> Dict[str, int]:
67
67
  """Extract token counts from an OpenAI-compatible response usage payload."""
68
68
  prompt_details = None
69
+ input_details = None
70
+ output_details = None
69
71
  if isinstance(usage, dict):
70
72
  prompt_details = usage.get("prompt_tokens_details")
73
+ input_details = usage.get("input_tokens_details")
74
+ output_details = usage.get("output_tokens_details")
71
75
  else:
72
76
  prompt_details = getattr(usage, "prompt_tokens_details", None)
73
-
74
- cache_read_tokens = _get_usage_field(prompt_details, "cached_tokens") if prompt_details else 0
77
+ input_details = getattr(usage, "input_tokens_details", None)
78
+ output_details = getattr(usage, "output_tokens_details", None)
79
+
80
+ cache_read_tokens = 0
81
+ if prompt_details:
82
+ cache_read_tokens = _get_usage_field(prompt_details, "cached_tokens")
83
+ if not cache_read_tokens and input_details:
84
+ cache_read_tokens = _get_usage_field(input_details, "cached_tokens")
85
+
86
+ input_tokens = _get_usage_field(usage, "prompt_tokens")
87
+ if not input_tokens:
88
+ input_tokens = _get_usage_field(usage, "input_tokens")
89
+
90
+ output_tokens = _get_usage_field(usage, "completion_tokens")
91
+ if not output_tokens:
92
+ output_tokens = _get_usage_field(usage, "output_tokens")
93
+
94
+ reasoning_tokens = _get_usage_field(output_details, "reasoning_tokens") if output_details else 0
95
+ if reasoning_tokens:
96
+ if output_tokens <= 0:
97
+ output_tokens = reasoning_tokens
98
+ elif output_tokens < reasoning_tokens:
99
+ output_tokens = output_tokens + reasoning_tokens
100
+ else:
101
+ output_tokens = max(output_tokens, reasoning_tokens)
75
102
 
76
103
  return {
77
- "input_tokens": _get_usage_field(usage, "prompt_tokens"),
78
- "output_tokens": _get_usage_field(usage, "completion_tokens"),
104
+ "input_tokens": input_tokens,
105
+ "output_tokens": output_tokens,
79
106
  "cache_read_input_tokens": cache_read_tokens,
80
107
  "cache_creation_input_tokens": 0,
81
108
  }
@@ -219,10 +246,10 @@ def _tool_prompt_for_text_mode(tools: List[Tool[Any, Any]]) -> str:
219
246
  if hasattr(finfo, "is_required"):
220
247
  try:
221
248
  is_req = bool(finfo.is_required())
222
- except Exception:
249
+ except (TypeError, AttributeError):
223
250
  is_req = False
224
251
  required_fields.append(f"{fname}{' (required)' if is_req else ''}")
225
- except Exception:
252
+ except (AttributeError, TypeError):
226
253
  required_fields = []
227
254
 
228
255
  required_str = ", ".join(required_fields) if required_fields else "see input schema"
@@ -487,6 +514,23 @@ def content_blocks_from_anthropic_response(response: Any, tool_mode: str) -> Lis
487
514
  btype = getattr(block, "type", None)
488
515
  if btype == "text":
489
516
  blocks.append({"type": "text", "text": getattr(block, "text", "")})
517
+ elif btype == "thinking":
518
+ blocks.append(
519
+ {
520
+ "type": "thinking",
521
+ "thinking": getattr(block, "thinking", None) or "",
522
+ "signature": getattr(block, "signature", None),
523
+ }
524
+ )
525
+ elif btype == "redacted_thinking":
526
+ # Preserve encrypted payload for replay even if we don't display it.
527
+ blocks.append(
528
+ {
529
+ "type": "redacted_thinking",
530
+ "data": getattr(block, "data", None),
531
+ "signature": getattr(block, "signature", None),
532
+ }
533
+ )
490
534
  elif btype == "tool_use":
491
535
  raw_input = getattr(block, "input", {}) or {}
492
536
  blocks.append(