ripperdoc 0.2.7__py3-none-any.whl → 0.2.9__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 (87) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +33 -115
  3. ripperdoc/cli/commands/__init__.py +70 -6
  4. ripperdoc/cli/commands/agents_cmd.py +6 -3
  5. ripperdoc/cli/commands/clear_cmd.py +1 -4
  6. ripperdoc/cli/commands/config_cmd.py +1 -1
  7. ripperdoc/cli/commands/context_cmd.py +3 -2
  8. ripperdoc/cli/commands/doctor_cmd.py +18 -4
  9. ripperdoc/cli/commands/help_cmd.py +11 -1
  10. ripperdoc/cli/commands/hooks_cmd.py +610 -0
  11. ripperdoc/cli/commands/models_cmd.py +26 -9
  12. ripperdoc/cli/commands/permissions_cmd.py +57 -37
  13. ripperdoc/cli/commands/resume_cmd.py +6 -4
  14. ripperdoc/cli/commands/status_cmd.py +4 -4
  15. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  16. ripperdoc/cli/ui/file_mention_completer.py +64 -8
  17. ripperdoc/cli/ui/interrupt_handler.py +3 -4
  18. ripperdoc/cli/ui/message_display.py +5 -3
  19. ripperdoc/cli/ui/panels.py +13 -10
  20. ripperdoc/cli/ui/provider_options.py +247 -0
  21. ripperdoc/cli/ui/rich_ui.py +196 -77
  22. ripperdoc/cli/ui/spinner.py +25 -1
  23. ripperdoc/cli/ui/tool_renderers.py +8 -2
  24. ripperdoc/cli/ui/wizard.py +215 -0
  25. ripperdoc/core/agents.py +9 -3
  26. ripperdoc/core/config.py +49 -12
  27. ripperdoc/core/custom_commands.py +412 -0
  28. ripperdoc/core/default_tools.py +11 -2
  29. ripperdoc/core/hooks/__init__.py +99 -0
  30. ripperdoc/core/hooks/config.py +301 -0
  31. ripperdoc/core/hooks/events.py +535 -0
  32. ripperdoc/core/hooks/executor.py +496 -0
  33. ripperdoc/core/hooks/integration.py +344 -0
  34. ripperdoc/core/hooks/manager.py +745 -0
  35. ripperdoc/core/permissions.py +40 -8
  36. ripperdoc/core/providers/anthropic.py +548 -68
  37. ripperdoc/core/providers/gemini.py +70 -5
  38. ripperdoc/core/providers/openai.py +60 -5
  39. ripperdoc/core/query.py +140 -39
  40. ripperdoc/core/query_utils.py +2 -0
  41. ripperdoc/core/skills.py +9 -3
  42. ripperdoc/core/system_prompt.py +4 -2
  43. ripperdoc/core/tool.py +9 -5
  44. ripperdoc/sdk/client.py +2 -2
  45. ripperdoc/tools/ask_user_question_tool.py +5 -3
  46. ripperdoc/tools/background_shell.py +2 -1
  47. ripperdoc/tools/bash_output_tool.py +1 -1
  48. ripperdoc/tools/bash_tool.py +30 -20
  49. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  50. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  51. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  52. ripperdoc/tools/file_edit_tool.py +8 -4
  53. ripperdoc/tools/file_read_tool.py +9 -5
  54. ripperdoc/tools/file_write_tool.py +9 -5
  55. ripperdoc/tools/glob_tool.py +3 -2
  56. ripperdoc/tools/grep_tool.py +3 -2
  57. ripperdoc/tools/kill_bash_tool.py +1 -1
  58. ripperdoc/tools/ls_tool.py +1 -1
  59. ripperdoc/tools/mcp_tools.py +13 -10
  60. ripperdoc/tools/multi_edit_tool.py +8 -7
  61. ripperdoc/tools/notebook_edit_tool.py +7 -4
  62. ripperdoc/tools/skill_tool.py +1 -1
  63. ripperdoc/tools/task_tool.py +5 -4
  64. ripperdoc/tools/todo_tool.py +2 -2
  65. ripperdoc/tools/tool_search_tool.py +3 -2
  66. ripperdoc/utils/conversation_compaction.py +11 -7
  67. ripperdoc/utils/file_watch.py +8 -2
  68. ripperdoc/utils/json_utils.py +2 -1
  69. ripperdoc/utils/mcp.py +11 -3
  70. ripperdoc/utils/memory.py +4 -2
  71. ripperdoc/utils/message_compaction.py +21 -7
  72. ripperdoc/utils/message_formatting.py +11 -7
  73. ripperdoc/utils/messages.py +105 -66
  74. ripperdoc/utils/path_ignore.py +38 -12
  75. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  76. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  77. ripperdoc/utils/safe_get_cwd.py +2 -1
  78. ripperdoc/utils/session_history.py +13 -6
  79. ripperdoc/utils/todo.py +2 -1
  80. ripperdoc/utils/token_estimation.py +6 -1
  81. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/METADATA +24 -3
  82. ripperdoc-0.2.9.dist-info/RECORD +123 -0
  83. ripperdoc-0.2.7.dist-info/RECORD +0 -113
  84. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/WHEEL +0 -0
  85. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/entry_points.txt +0 -0
  86. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/licenses/LICENSE +0 -0
  87. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/top_level.txt +0 -0
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import copy
7
7
  import inspect
8
+ import json
8
9
  import os
9
10
  import time
10
11
  from typing import Any, AsyncIterator, Dict, List, Optional, Tuple, cast
@@ -240,9 +241,7 @@ async def _async_build_tool_declarations(tools: List[Tool[Any, Any]]) -> List[Di
240
241
  description=description,
241
242
  parameters_json_schema=parameters_schema,
242
243
  )
243
- declarations.append(
244
- func_decl.model_dump(mode="json", exclude_none=True)
245
- )
244
+ declarations.append(func_decl.model_dump(mode="json", exclude_none=True))
246
245
  else:
247
246
  declarations.append(
248
247
  {
@@ -385,6 +384,17 @@ class GeminiClient(ProviderClient):
385
384
  ) -> ProviderResponse:
386
385
  start_time = time.time()
387
386
 
387
+ logger.debug(
388
+ "[gemini_client] Preparing request",
389
+ extra={
390
+ "model": model_profile.model,
391
+ "tool_mode": tool_mode,
392
+ "stream": stream,
393
+ "max_thinking_tokens": max_thinking_tokens,
394
+ "num_tools": len(tools),
395
+ },
396
+ )
397
+
388
398
  try:
389
399
  client = await self._client(model_profile)
390
400
  except asyncio.CancelledError:
@@ -392,6 +402,15 @@ class GeminiClient(ProviderClient):
392
402
  except Exception as exc:
393
403
  duration_ms = (time.time() - start_time) * 1000
394
404
  error_code, error_message = _classify_gemini_error(exc)
405
+ logger.debug(
406
+ "[gemini_client] Exception details during init",
407
+ extra={
408
+ "model": model_profile.model,
409
+ "exception_type": type(exc).__name__,
410
+ "exception_str": str(exc),
411
+ "error_code": error_code,
412
+ },
413
+ )
395
414
  logger.error(
396
415
  "[gemini_client] Initialization failed",
397
416
  extra={
@@ -422,7 +441,12 @@ class GeminiClient(ProviderClient):
422
441
  from google.genai import types as genai_types # type: ignore
423
442
 
424
443
  config["thinking_config"] = genai_types.ThinkingConfig(**thinking_config)
425
- except (ImportError, ModuleNotFoundError, TypeError, ValueError): # pragma: no cover - fallback when SDK not installed
444
+ except (
445
+ ImportError,
446
+ ModuleNotFoundError,
447
+ TypeError,
448
+ ValueError,
449
+ ): # pragma: no cover - fallback when SDK not installed
426
450
  config["thinking_config"] = thinking_config
427
451
  if declarations:
428
452
  config["tools"] = [{"function_declarations": declarations}]
@@ -432,6 +456,23 @@ class GeminiClient(ProviderClient):
432
456
  "contents": contents,
433
457
  "config": config,
434
458
  }
459
+
460
+ logger.debug(
461
+ "[gemini_client] Request parameters",
462
+ extra={
463
+ "model": model_profile.model,
464
+ "config": json.dumps(
465
+ {k: v for k, v in config.items() if k != "system_instruction"},
466
+ ensure_ascii=False,
467
+ default=str,
468
+ )[:1000],
469
+ "num_declarations": len(declarations),
470
+ "thinking_config": json.dumps(thinking_config, ensure_ascii=False)
471
+ if thinking_config
472
+ else None,
473
+ },
474
+ )
475
+
435
476
  usage_tokens: Dict[str, int] = {}
436
477
  collected_text: List[str] = []
437
478
  function_calls: List[Dict[str, Any]] = []
@@ -483,6 +524,10 @@ class GeminiClient(ProviderClient):
483
524
 
484
525
  try:
485
526
  if stream:
527
+ logger.debug(
528
+ "[gemini_client] Initiating stream request",
529
+ extra={"model": model_profile.model},
530
+ )
486
531
  stream_resp = await _call_generate(streaming=True)
487
532
 
488
533
  # Normalize streams into an async iterator to avoid StopIteration surfacing through
@@ -523,7 +568,8 @@ class GeminiClient(ProviderClient):
523
568
  except (RuntimeError, ValueError, TypeError, OSError) as cb_exc:
524
569
  logger.warning(
525
570
  "[gemini_client] Stream callback failed: %s: %s",
526
- type(cb_exc).__name__, cb_exc,
571
+ type(cb_exc).__name__,
572
+ cb_exc,
527
573
  )
528
574
  if text_chunk:
529
575
  collected_text.append(text_chunk)
@@ -552,6 +598,15 @@ class GeminiClient(ProviderClient):
552
598
  except Exception as exc:
553
599
  duration_ms = (time.time() - start_time) * 1000
554
600
  error_code, error_message = _classify_gemini_error(exc)
601
+ logger.debug(
602
+ "[gemini_client] Exception details",
603
+ extra={
604
+ "model": model_profile.model,
605
+ "exception_type": type(exc).__name__,
606
+ "exception_str": str(exc),
607
+ "error_code": error_code,
608
+ },
609
+ )
555
610
  logger.error(
556
611
  "[gemini_client] API call failed",
557
612
  extra={
@@ -595,6 +650,16 @@ class GeminiClient(ProviderClient):
595
650
  **(usage_tokens or {}),
596
651
  )
597
652
 
653
+ logger.debug(
654
+ "[gemini_client] Response content blocks",
655
+ extra={
656
+ "model": model_profile.model,
657
+ "content_blocks": json.dumps(content_blocks, ensure_ascii=False)[:1000],
658
+ "usage_tokens": json.dumps(usage_tokens, ensure_ascii=False),
659
+ "metadata": json.dumps(response_metadata, ensure_ascii=False)[:500],
660
+ },
661
+ )
662
+
598
663
  logger.info(
599
664
  "[gemini_client] Response received",
600
665
  extra={
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import json
6
7
  import time
7
8
  from typing import Any, Dict, List, Optional, cast
8
9
  from uuid import uuid4
@@ -94,7 +95,7 @@ def _detect_openai_vendor(model_profile: ModelProfile) -> str:
94
95
  if "generativelanguage.googleapis.com" in base or name.startswith("gemini"):
95
96
  return "gemini_openai"
96
97
  if "gpt-5" in name:
97
- return "openai_reasoning"
98
+ return "openai"
98
99
  return "openai"
99
100
 
100
101
 
@@ -130,7 +131,7 @@ def _build_thinking_kwargs(
130
131
  if effort:
131
132
  top_level["reasoning_effort"] = effort
132
133
  extra_body.setdefault("reasoning", {"effort": effort})
133
- elif vendor == "openai_reasoning":
134
+ elif vendor == "openai":
134
135
  if effort:
135
136
  extra_body["reasoning"] = {"effort": effort}
136
137
  else:
@@ -178,6 +179,15 @@ class OpenAIClient(ProviderClient):
178
179
  except Exception as exc:
179
180
  duration_ms = (time.time() - start_time) * 1000
180
181
  error_code, error_message = _classify_openai_error(exc)
182
+ logger.debug(
183
+ "[openai_client] Exception details",
184
+ extra={
185
+ "model": model_profile.model,
186
+ "exception_type": type(exc).__name__,
187
+ "exception_str": str(exc),
188
+ "error_code": error_code,
189
+ },
190
+ )
181
191
  logger.error(
182
192
  "[openai_client] API call failed",
183
193
  extra={
@@ -213,6 +223,18 @@ class OpenAIClient(ProviderClient):
213
223
  openai_messages: List[Dict[str, object]] = [
214
224
  {"role": "system", "content": system_prompt}
215
225
  ] + sanitize_tool_history(list(normalized_messages))
226
+
227
+ logger.debug(
228
+ "[openai_client] Preparing request",
229
+ extra={
230
+ "model": model_profile.model,
231
+ "tool_mode": tool_mode,
232
+ "stream": stream,
233
+ "max_thinking_tokens": max_thinking_tokens,
234
+ "num_tools": len(openai_tools),
235
+ "num_messages": len(openai_messages),
236
+ },
237
+ )
216
238
  collected_text: List[str] = []
217
239
  streamed_tool_calls: Dict[int, Dict[str, Optional[str]]] = {}
218
240
  streamed_tool_text: List[str] = []
@@ -228,6 +250,16 @@ class OpenAIClient(ProviderClient):
228
250
  model_profile, max_thinking_tokens
229
251
  )
230
252
 
253
+ logger.debug(
254
+ "[openai_client] Request parameters",
255
+ extra={
256
+ "model": model_profile.model,
257
+ "thinking_extra_body": json.dumps(thinking_extra_body, ensure_ascii=False),
258
+ "thinking_top_level": json.dumps(thinking_top_level, ensure_ascii=False),
259
+ "messages_preview": json.dumps(openai_messages[:2], ensure_ascii=False)[:500],
260
+ },
261
+ )
262
+
231
263
  async with AsyncOpenAI(
232
264
  api_key=model_profile.api_key, base_url=model_profile.api_base
233
265
  ) as client:
@@ -246,6 +278,16 @@ class OpenAIClient(ProviderClient):
246
278
  }
247
279
  if thinking_extra_body:
248
280
  stream_kwargs["extra_body"] = thinking_extra_body
281
+ logger.debug(
282
+ "[openai_client] Initiating stream request",
283
+ extra={
284
+ "model": model_profile.model,
285
+ "stream_kwargs": json.dumps(
286
+ {k: v for k, v in stream_kwargs.items() if k != "messages"},
287
+ ensure_ascii=False,
288
+ ),
289
+ },
290
+ )
249
291
  stream_coro = client.chat.completions.create( # type: ignore[call-overload]
250
292
  **stream_kwargs
251
293
  )
@@ -303,7 +345,8 @@ class OpenAIClient(ProviderClient):
303
345
  except (RuntimeError, ValueError, TypeError, OSError) as cb_exc:
304
346
  logger.warning(
305
347
  "[openai_client] Stream callback failed: %s: %s",
306
- type(cb_exc).__name__, cb_exc,
348
+ type(cb_exc).__name__,
349
+ cb_exc,
307
350
  )
308
351
 
309
352
  # Tool call deltas for native tool mode
@@ -333,7 +376,8 @@ class OpenAIClient(ProviderClient):
333
376
  except (RuntimeError, ValueError, TypeError, OSError) as cb_exc:
334
377
  logger.warning(
335
378
  "[openai_client] Stream callback failed: %s: %s",
336
- type(cb_exc).__name__, cb_exc,
379
+ type(cb_exc).__name__,
380
+ cb_exc,
337
381
  )
338
382
 
339
383
  if idx not in announced_tool_indexes and state.get("name"):
@@ -344,7 +388,8 @@ class OpenAIClient(ProviderClient):
344
388
  except (RuntimeError, ValueError, TypeError, OSError) as cb_exc:
345
389
  logger.warning(
346
390
  "[openai_client] Stream callback failed: %s: %s",
347
- type(cb_exc).__name__, cb_exc,
391
+ type(cb_exc).__name__,
392
+ cb_exc,
348
393
  )
349
394
 
350
395
  streamed_tool_calls[idx] = state
@@ -467,6 +512,16 @@ class OpenAIClient(ProviderClient):
467
512
  if stream_reasoning_details:
468
513
  response_metadata["reasoning_details"] = stream_reasoning_details
469
514
 
515
+ logger.debug(
516
+ "[openai_client] Response content blocks",
517
+ extra={
518
+ "model": model_profile.model,
519
+ "content_blocks": json.dumps(content_blocks, ensure_ascii=False)[:1000],
520
+ "usage_tokens": json.dumps(usage_tokens, ensure_ascii=False),
521
+ "metadata": json.dumps(response_metadata, ensure_ascii=False)[:500],
522
+ },
523
+ )
524
+
470
525
  logger.info(
471
526
  "[openai_client] Response received",
472
527
  extra={
ripperdoc/core/query.py CHANGED
@@ -26,9 +26,10 @@ from typing import (
26
26
 
27
27
  from pydantic import ValidationError
28
28
 
29
- from ripperdoc.core.config import provider_protocol
29
+ from ripperdoc.core.config import ModelProfile, provider_protocol
30
30
  from ripperdoc.core.providers import ProviderClient, get_provider_client
31
31
  from ripperdoc.core.permissions import PermissionResult
32
+ from ripperdoc.core.hooks.manager import hook_manager
32
33
  from ripperdoc.core.query_utils import (
33
34
  build_full_system_prompt,
34
35
  determine_tool_mode,
@@ -64,6 +65,42 @@ DEFAULT_REQUEST_TIMEOUT_SEC = float(os.getenv("RIPPERDOC_API_TIMEOUT", "120"))
64
65
  MAX_LLM_RETRIES = int(os.getenv("RIPPERDOC_MAX_RETRIES", "10"))
65
66
 
66
67
 
68
+ def infer_thinking_mode(model_profile: ModelProfile) -> Optional[str]:
69
+ """Infer thinking mode from ModelProfile if not explicitly configured.
70
+
71
+ This function checks the model_profile.thinking_mode first. If it's set,
72
+ returns that value. Otherwise, auto-detects based on api_base and model name.
73
+
74
+ Args:
75
+ model_profile: The model profile to analyze
76
+
77
+ Returns:
78
+ Thinking mode string ("deepseek", "qwen", "openrouter", "gemini_openai")
79
+ or None if no thinking mode should be applied.
80
+ """
81
+ # Use explicit config if set
82
+ explicit_mode = model_profile.thinking_mode
83
+ if explicit_mode:
84
+ return explicit_mode
85
+
86
+ # Auto-detect based on API base and model name
87
+ base = (model_profile.api_base or "").lower()
88
+ name = (model_profile.model or "").lower()
89
+
90
+ if "deepseek" in base or name.startswith("deepseek"):
91
+ return "deepseek"
92
+ if "dashscope" in base or "qwen" in name:
93
+ return "qwen"
94
+ if "openrouter.ai" in base:
95
+ return "openrouter"
96
+ if "generativelanguage.googleapis.com" in base or name.startswith("gemini"):
97
+ return "gemini_openai"
98
+ if "openai" in base:
99
+ return "openai"
100
+
101
+ return None
102
+
103
+
67
104
  def _resolve_tool(
68
105
  tool_registry: "ToolRegistry", tool_name: str, tool_use_id: str
69
106
  ) -> tuple[Optional[Tool[Any, Any]], Optional[UserMessage]]:
@@ -109,7 +146,7 @@ async def _check_tool_permissions(
109
146
  return bool(decision[0]), decision[1]
110
147
  return bool(decision), None
111
148
 
112
- if query_context.safe_mode and tool.needs_permissions(parsed_input):
149
+ if not query_context.yolo_mode and tool.needs_permissions(parsed_input):
113
150
  loop = asyncio.get_running_loop()
114
151
  input_preview = (
115
152
  parsed_input.model_dump()
@@ -154,6 +191,53 @@ async def _run_tool_use_generator(
154
191
  tool_context: ToolUseContext,
155
192
  ) -> AsyncGenerator[Union[UserMessage, ProgressMessage], None]:
156
193
  """Execute a single tool_use and yield progress/results."""
194
+ # Get tool input as dict for hooks
195
+ tool_input_dict = (
196
+ parsed_input.model_dump()
197
+ if hasattr(parsed_input, "model_dump")
198
+ else dict(parsed_input)
199
+ if isinstance(parsed_input, dict)
200
+ else {}
201
+ )
202
+
203
+ # Run PreToolUse hooks
204
+ pre_result = await hook_manager.run_pre_tool_use_async(
205
+ tool_name, tool_input_dict, tool_use_id=tool_use_id
206
+ )
207
+ if pre_result.should_block:
208
+ block_reason = pre_result.block_reason or f"Blocked by hook: {tool_name}"
209
+ logger.info(
210
+ f"[query] Tool {tool_name} blocked by PreToolUse hook",
211
+ extra={"tool_use_id": tool_use_id, "reason": block_reason},
212
+ )
213
+ yield tool_result_message(tool_use_id, f"Hook blocked: {block_reason}", is_error=True)
214
+ return
215
+
216
+ # Handle updated input from hooks
217
+ if pre_result.updated_input:
218
+ logger.debug(
219
+ f"[query] PreToolUse hook modified input for {tool_name}",
220
+ extra={"tool_use_id": tool_use_id},
221
+ )
222
+ # Re-parse the input with the updated values
223
+ try:
224
+ parsed_input = tool.input_schema(**pre_result.updated_input)
225
+ tool_input_dict = pre_result.updated_input
226
+ except (ValueError, TypeError) as exc:
227
+ logger.warning(
228
+ f"[query] Failed to apply updated input from hook: {exc}",
229
+ extra={"tool_use_id": tool_use_id},
230
+ )
231
+
232
+ # Add hook context if provided
233
+ if pre_result.additional_context:
234
+ logger.debug(
235
+ f"[query] PreToolUse hook added context for {tool_name}",
236
+ extra={"context": pre_result.additional_context[:100]},
237
+ )
238
+
239
+ tool_output = None
240
+
157
241
  try:
158
242
  async for output in tool.call(parsed_input, tool_context):
159
243
  if isinstance(output, ToolProgress):
@@ -164,6 +248,7 @@ async def _run_tool_use_generator(
164
248
  )
165
249
  logger.debug(f"[query] Progress from tool_use_id={tool_use_id}: {output.content}")
166
250
  elif isinstance(output, ToolResult):
251
+ tool_output = output.data
167
252
  result_content = output.result_for_assistant or str(output.data)
168
253
  result_msg = tool_result_message(
169
254
  tool_use_id, result_content, tool_use_result=output.data
@@ -178,11 +263,18 @@ async def _run_tool_use_generator(
178
263
  except (RuntimeError, ValueError, TypeError, OSError, IOError, AttributeError, KeyError) as exc:
179
264
  logger.warning(
180
265
  "Error executing tool '%s': %s: %s",
181
- tool_name, type(exc).__name__, exc,
266
+ tool_name,
267
+ type(exc).__name__,
268
+ exc,
182
269
  extra={"tool": tool_name, "tool_use_id": tool_use_id},
183
270
  )
184
271
  yield tool_result_message(tool_use_id, f"Error executing tool: {str(exc)}", is_error=True)
185
272
 
273
+ # Run PostToolUse hooks
274
+ await hook_manager.run_post_tool_use_async(
275
+ tool_name, tool_input_dict, tool_response=tool_output, tool_use_id=tool_use_id
276
+ )
277
+
186
278
 
187
279
  def _group_tool_calls_by_concurrency(prepared_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
188
280
  """Group consecutive tool calls by their concurrency safety."""
@@ -267,7 +359,8 @@ async def _run_concurrent_tool_uses(
267
359
  except (RuntimeError, ValueError, TypeError) as exc:
268
360
  logger.warning(
269
361
  "[query] Error while consuming tool generator: %s: %s",
270
- type(exc).__name__, exc,
362
+ type(exc).__name__,
363
+ exc,
271
364
  )
272
365
  finally:
273
366
  await queue.put(None)
@@ -320,7 +413,8 @@ class ToolRegistry:
320
413
  except (TypeError, AttributeError) as exc:
321
414
  logger.warning(
322
415
  "[tool_registry] Tool.defer_loading failed: %s: %s",
323
- type(exc).__name__, exc,
416
+ type(exc).__name__,
417
+ exc,
324
418
  extra={"tool": getattr(tool, "name", None)},
325
419
  )
326
420
  deferred = False
@@ -407,7 +501,8 @@ def _apply_skill_context_updates(
407
501
  except (KeyError, ValueError, TypeError) as exc:
408
502
  logger.warning(
409
503
  "[query] Failed to activate tools listed in skill output: %s: %s",
410
- type(exc).__name__, exc,
504
+ type(exc).__name__,
505
+ exc,
411
506
  )
412
507
 
413
508
  model_hint = data.get("model")
@@ -437,7 +532,7 @@ class QueryContext:
437
532
  self,
438
533
  tools: List[Tool[Any, Any]],
439
534
  max_thinking_tokens: int = 0,
440
- safe_mode: bool = False,
535
+ yolo_mode: bool = False,
441
536
  model: str = "main",
442
537
  verbose: bool = False,
443
538
  pause_ui: Optional[Callable[[], None]] = None,
@@ -445,7 +540,7 @@ class QueryContext:
445
540
  ) -> None:
446
541
  self.tool_registry = ToolRegistry(tools)
447
542
  self.max_thinking_tokens = max_thinking_tokens
448
- self.safe_mode = safe_mode
543
+ self.yolo_mode = yolo_mode
449
544
  self.model = model
450
545
  self.verbose = verbose
451
546
  self.abort_controller = asyncio.Event()
@@ -518,8 +613,22 @@ async def query_llm(
518
613
  else:
519
614
  messages_for_model = messages
520
615
 
616
+ # Get thinking_mode for provider-specific handling
617
+ # Apply when thinking is enabled (max_thinking_tokens > 0) OR when using a
618
+ # reasoning model like deepseek-reasoner which has thinking enabled by default
619
+ thinking_mode: Optional[str] = None
620
+ if protocol == "openai":
621
+ model_name = (model_profile.model or "").lower()
622
+ # DeepSeek Reasoner models have thinking enabled by default
623
+ is_reasoning_model = "reasoner" in model_name or "r1" in model_name
624
+ if max_thinking_tokens > 0 or is_reasoning_model:
625
+ thinking_mode = infer_thinking_mode(model_profile)
626
+
521
627
  normalized_messages: List[Dict[str, Any]] = normalize_messages_for_api(
522
- messages_for_model, protocol=protocol, tool_mode=tool_mode
628
+ messages_for_model,
629
+ protocol=protocol,
630
+ tool_mode=tool_mode,
631
+ thinking_mode=thinking_mode,
523
632
  )
524
633
  logger.info(
525
634
  "[query_llm] Preparing model request",
@@ -530,6 +639,7 @@ async def query_llm(
530
639
  "normalized_messages": len(normalized_messages),
531
640
  "tool_count": len(tools),
532
641
  "max_thinking_tokens": max_thinking_tokens,
642
+ "thinking_mode": thinking_mode,
533
643
  "tool_mode": tool_mode,
534
644
  },
535
645
  )
@@ -613,7 +723,8 @@ async def query_llm(
613
723
  # Return error message
614
724
  logger.warning(
615
725
  "Error querying AI model: %s: %s",
616
- type(e).__name__, e,
726
+ type(e).__name__,
727
+ e,
617
728
  extra={
618
729
  "model": getattr(model_profile, "model", None),
619
730
  "model_pointer": model,
@@ -624,12 +735,12 @@ async def query_llm(
624
735
  )
625
736
  duration_ms = (time.time() - start_time) * 1000
626
737
  context_error = detect_context_length_error(e)
627
- metadata = None
738
+ error_metadata: Optional[Dict[str, Any]] = None
628
739
  content = f"Error querying AI model: {str(e)}"
629
740
 
630
741
  if context_error:
631
742
  content = f"The request exceeded the model's context window. {context_error.message}"
632
- metadata = {
743
+ error_metadata = {
633
744
  "context_length_exceeded": True,
634
745
  "context_length_provider": context_error.provider,
635
746
  "context_length_error_code": context_error.error_code,
@@ -645,7 +756,7 @@ async def query_llm(
645
756
  )
646
757
 
647
758
  error_msg = create_assistant_message(
648
- content=content, duration_ms=duration_ms, metadata=metadata
759
+ content=content, duration_ms=duration_ms, metadata=error_metadata
649
760
  )
650
761
  error_msg.is_api_error_message = True
651
762
  return error_msg
@@ -704,9 +815,7 @@ async def _run_query_iteration(
704
815
 
705
816
  model_profile = resolve_model_profile(query_context.model)
706
817
  tool_mode = determine_tool_mode(model_profile)
707
- tools_for_model: List[Tool[Any, Any]] = (
708
- [] if tool_mode == "text" else query_context.all_tools()
709
- )
818
+ tools_for_model: List[Tool[Any, Any]] = [] if tool_mode == "text" else query_context.all_tools()
710
819
 
711
820
  full_system_prompt = build_full_system_prompt(
712
821
  system_prompt, context, tool_mode, query_context.all_tools()
@@ -778,7 +887,7 @@ async def _run_query_iteration(
778
887
  done, pending = await asyncio.wait(
779
888
  {assistant_task, waiter},
780
889
  return_when=asyncio.FIRST_COMPLETED,
781
- timeout=0.1 # Check abort_controller every 100ms
890
+ timeout=0.1, # Check abort_controller every 100ms
782
891
  )
783
892
  if not done:
784
893
  # Timeout - cancel waiter and continue loop to check abort_controller
@@ -836,8 +945,7 @@ async def _run_query_iteration(
836
945
  tool_results: List[UserMessage] = []
837
946
  permission_denied = False
838
947
  sibling_ids = set(
839
- getattr(t, "tool_use_id", None) or getattr(t, "id", None) or ""
840
- for t in tool_use_blocks
948
+ getattr(t, "tool_use_id", None) or getattr(t, "id", None) or "" for t in tool_use_blocks
841
949
  )
842
950
  prepared_calls: List[Dict[str, Any]] = []
843
951
 
@@ -845,18 +953,12 @@ async def _run_query_iteration(
845
953
  tool_name = tool_use.name
846
954
  if not tool_name:
847
955
  continue
848
- tool_use_id = (
849
- getattr(tool_use, "tool_use_id", None) or getattr(tool_use, "id", None) or ""
850
- )
956
+ tool_use_id = getattr(tool_use, "tool_use_id", None) or getattr(tool_use, "id", None) or ""
851
957
  tool_input = getattr(tool_use, "input", {}) or {}
852
958
 
853
- tool, missing_msg = _resolve_tool(
854
- query_context.tool_registry, tool_name, tool_use_id
855
- )
959
+ tool, missing_msg = _resolve_tool(query_context.tool_registry, tool_name, tool_use_id)
856
960
  if missing_msg:
857
- logger.warning(
858
- f"[query] Tool '{tool_name}' not found for tool_use_id={tool_use_id}"
859
- )
961
+ logger.warning(f"[query] Tool '{tool_name}' not found for tool_use_id={tool_use_id}")
860
962
  tool_results.append(missing_msg)
861
963
  yield missing_msg
862
964
  continue
@@ -870,7 +972,7 @@ async def _run_query_iteration(
870
972
  )
871
973
 
872
974
  tool_context = ToolUseContext(
873
- safe_mode=query_context.safe_mode,
975
+ yolo_mode=query_context.yolo_mode,
874
976
  verbose=query_context.verbose,
875
977
  permission_checker=can_use_tool_fn,
876
978
  tool_registry=query_context.tool_registry,
@@ -883,8 +985,7 @@ async def _run_query_iteration(
883
985
  validation = await tool.validate_input(parsed_input, tool_context)
884
986
  if not validation.result:
885
987
  logger.debug(
886
- f"[query] Validation failed for tool_use_id={tool_use_id}: "
887
- f"{validation.message}"
988
+ f"[query] Validation failed for tool_use_id={tool_use_id}: {validation.message}"
888
989
  )
889
990
  result_msg = tool_result_message(
890
991
  tool_use_id,
@@ -895,18 +996,15 @@ async def _run_query_iteration(
895
996
  yield result_msg
896
997
  continue
897
998
 
898
- if query_context.safe_mode or can_use_tool_fn is not None:
999
+ if not query_context.yolo_mode or can_use_tool_fn is not None:
899
1000
  allowed, denial_message = await _check_tool_permissions(
900
1001
  tool, parsed_input, query_context, can_use_tool_fn
901
1002
  )
902
1003
  if not allowed:
903
1004
  logger.debug(
904
- f"[query] Permission denied for tool_use_id={tool_use_id}: "
905
- f"{denial_message}"
906
- )
907
- denial_text = (
908
- denial_message or f"User aborted the tool invocation: {tool_name}"
1005
+ f"[query] Permission denied for tool_use_id={tool_use_id}: {denial_message}"
909
1006
  )
1007
+ denial_text = denial_message or f"User aborted the tool invocation: {tool_name}"
910
1008
  denial_msg = tool_result_message(tool_use_id, denial_text, is_error=True)
911
1009
  tool_results.append(denial_msg)
912
1010
  yield denial_msg
@@ -1016,7 +1114,7 @@ async def query(
1016
1114
  extra={
1017
1115
  "message_count": len(messages),
1018
1116
  "tool_count": len(query_context.tools),
1019
- "safe_mode": query_context.safe_mode,
1117
+ "yolo_mode": query_context.yolo_mode,
1020
1118
  "model_pointer": query_context.model,
1021
1119
  },
1022
1120
  )
@@ -1042,7 +1140,10 @@ async def query(
1042
1140
  return
1043
1141
 
1044
1142
  # Update messages for next iteration
1045
- messages = messages + [result.assistant_message] + result.tool_results
1143
+ if result.assistant_message is not None:
1144
+ messages = messages + [result.assistant_message] + result.tool_results # type: ignore[operator]
1145
+ else:
1146
+ messages = messages + result.tool_results # type: ignore[operator]
1046
1147
  logger.debug(
1047
1148
  f"[query] Continuing loop with {len(messages)} messages after tools; "
1048
1149
  f"tool_results_count={len(result.tool_results)}"
@@ -462,11 +462,13 @@ def log_openai_messages(normalized_messages: List[Dict[str, Any]]) -> None:
462
462
  role = message.get("role")
463
463
  tool_calls = message.get("tool_calls")
464
464
  tool_call_id = message.get("tool_call_id")
465
+ has_reasoning = "reasoning_content" in message and message.get("reasoning_content")
465
466
  ids = [tc.get("id") for tc in tool_calls] if tool_calls else []
466
467
  summary_parts.append(
467
468
  f"{idx}:{role}"
468
469
  + (f" tool_calls={ids}" if ids else "")
469
470
  + (f" tool_call_id={tool_call_id}" if tool_call_id else "")
471
+ + (" +reasoning" if has_reasoning else "")
470
472
  )
471
473
  logger.debug(f"[query_llm] OpenAI normalized messages: {' | '.join(summary_parts)}")
472
474