ripperdoc 0.2.8__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 (84) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +28 -115
  3. ripperdoc/cli/commands/__init__.py +0 -1
  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/hooks_cmd.py +27 -53
  10. ripperdoc/cli/commands/models_cmd.py +26 -9
  11. ripperdoc/cli/commands/permissions_cmd.py +27 -9
  12. ripperdoc/cli/commands/resume_cmd.py +5 -3
  13. ripperdoc/cli/commands/status_cmd.py +4 -4
  14. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  15. ripperdoc/cli/ui/file_mention_completer.py +2 -1
  16. ripperdoc/cli/ui/interrupt_handler.py +2 -3
  17. ripperdoc/cli/ui/message_display.py +4 -2
  18. ripperdoc/cli/ui/provider_options.py +247 -0
  19. ripperdoc/cli/ui/rich_ui.py +110 -59
  20. ripperdoc/cli/ui/spinner.py +25 -1
  21. ripperdoc/cli/ui/tool_renderers.py +8 -2
  22. ripperdoc/cli/ui/wizard.py +215 -0
  23. ripperdoc/core/agents.py +9 -3
  24. ripperdoc/core/config.py +49 -12
  25. ripperdoc/core/custom_commands.py +7 -6
  26. ripperdoc/core/default_tools.py +11 -2
  27. ripperdoc/core/hooks/config.py +1 -3
  28. ripperdoc/core/hooks/events.py +23 -28
  29. ripperdoc/core/hooks/executor.py +4 -6
  30. ripperdoc/core/hooks/integration.py +12 -21
  31. ripperdoc/core/hooks/manager.py +40 -15
  32. ripperdoc/core/permissions.py +40 -8
  33. ripperdoc/core/providers/anthropic.py +109 -36
  34. ripperdoc/core/providers/gemini.py +70 -5
  35. ripperdoc/core/providers/openai.py +60 -5
  36. ripperdoc/core/query.py +82 -38
  37. ripperdoc/core/query_utils.py +2 -0
  38. ripperdoc/core/skills.py +9 -3
  39. ripperdoc/core/system_prompt.py +4 -2
  40. ripperdoc/core/tool.py +9 -5
  41. ripperdoc/sdk/client.py +2 -2
  42. ripperdoc/tools/ask_user_question_tool.py +5 -3
  43. ripperdoc/tools/background_shell.py +2 -1
  44. ripperdoc/tools/bash_output_tool.py +1 -1
  45. ripperdoc/tools/bash_tool.py +26 -16
  46. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  47. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  48. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  49. ripperdoc/tools/file_edit_tool.py +8 -4
  50. ripperdoc/tools/file_read_tool.py +8 -4
  51. ripperdoc/tools/file_write_tool.py +9 -5
  52. ripperdoc/tools/glob_tool.py +3 -2
  53. ripperdoc/tools/grep_tool.py +3 -2
  54. ripperdoc/tools/kill_bash_tool.py +1 -1
  55. ripperdoc/tools/ls_tool.py +1 -1
  56. ripperdoc/tools/mcp_tools.py +13 -10
  57. ripperdoc/tools/multi_edit_tool.py +8 -7
  58. ripperdoc/tools/notebook_edit_tool.py +7 -4
  59. ripperdoc/tools/skill_tool.py +1 -1
  60. ripperdoc/tools/task_tool.py +5 -4
  61. ripperdoc/tools/todo_tool.py +2 -2
  62. ripperdoc/tools/tool_search_tool.py +3 -2
  63. ripperdoc/utils/conversation_compaction.py +8 -4
  64. ripperdoc/utils/file_watch.py +8 -2
  65. ripperdoc/utils/json_utils.py +2 -1
  66. ripperdoc/utils/mcp.py +11 -3
  67. ripperdoc/utils/memory.py +4 -2
  68. ripperdoc/utils/message_compaction.py +21 -7
  69. ripperdoc/utils/message_formatting.py +11 -7
  70. ripperdoc/utils/messages.py +105 -66
  71. ripperdoc/utils/path_ignore.py +35 -8
  72. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  73. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  74. ripperdoc/utils/safe_get_cwd.py +2 -1
  75. ripperdoc/utils/session_history.py +13 -6
  76. ripperdoc/utils/todo.py +2 -1
  77. ripperdoc/utils/token_estimation.py +6 -1
  78. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.9.dist-info}/METADATA +1 -1
  79. ripperdoc-0.2.9.dist-info/RECORD +123 -0
  80. ripperdoc-0.2.8.dist-info/RECORD +0 -121
  81. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.9.dist-info}/WHEEL +0 -0
  82. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.9.dist-info}/entry_points.txt +0 -0
  83. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.9.dist-info}/licenses/LICENSE +0 -0
  84. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.9.dist-info}/top_level.txt +0 -0
@@ -6,7 +6,7 @@ throughout the application lifecycle.
6
6
 
7
7
  import os
8
8
  from pathlib import Path
9
- from typing import Any, Awaitable, Callable, Dict, List, Optional
9
+ from typing import Any, Dict, List, Optional
10
10
 
11
11
  from ripperdoc.core.hooks.config import (
12
12
  HooksConfig,
@@ -47,10 +47,7 @@ class HookResult:
47
47
  @property
48
48
  def should_block(self) -> bool:
49
49
  """Check if any hook returned a blocking decision."""
50
- return any(
51
- o.decision in (HookDecision.DENY, HookDecision.BLOCK)
52
- for o in self.outputs
53
- )
50
+ return any(o.decision in (HookDecision.DENY, HookDecision.BLOCK) for o in self.outputs)
54
51
 
55
52
  @property
56
53
  def should_allow(self) -> bool:
@@ -431,9 +428,7 @@ class HookManager:
431
428
 
432
429
  # --- Notification ---
433
430
 
434
- def run_notification(
435
- self, message: str, notification_type: str = "info"
436
- ) -> HookResult:
431
+ def run_notification(self, message: str, notification_type: str = "info") -> HookResult:
437
432
  """Run Notification hooks synchronously.
438
433
 
439
434
  Args:
@@ -478,11 +473,18 @@ class HookManager:
478
473
 
479
474
  # --- Stop ---
480
475
 
481
- def run_stop(self, stop_hook_active: bool = False) -> HookResult:
476
+ def run_stop(
477
+ self,
478
+ stop_hook_active: bool = False,
479
+ reason: Optional[str] = None,
480
+ stop_sequence: Optional[str] = None,
481
+ ) -> HookResult:
482
482
  """Run Stop hooks synchronously.
483
483
 
484
484
  Args:
485
485
  stop_hook_active: True if already continuing from a stop hook
486
+ reason: Reason for stopping
487
+ stop_sequence: Stop sequence that triggered the stop
486
488
  """
487
489
  hooks = self._get_hooks(HookEvent.STOP)
488
490
  if not hooks:
@@ -490,6 +492,8 @@ class HookManager:
490
492
 
491
493
  input_data = StopInput(
492
494
  stop_hook_active=stop_hook_active,
495
+ reason=reason,
496
+ stop_sequence=stop_sequence,
493
497
  session_id=self.session_id,
494
498
  transcript_path=self.transcript_path,
495
499
  cwd=self._get_cwd(),
@@ -499,7 +503,12 @@ class HookManager:
499
503
  outputs = self.executor.execute_hooks_sync(hooks, input_data)
500
504
  return HookResult(outputs)
501
505
 
502
- async def run_stop_async(self, stop_hook_active: bool = False) -> HookResult:
506
+ async def run_stop_async(
507
+ self,
508
+ stop_hook_active: bool = False,
509
+ reason: Optional[str] = None,
510
+ stop_sequence: Optional[str] = None,
511
+ ) -> HookResult:
503
512
  """Run Stop hooks asynchronously."""
504
513
  hooks = self._get_hooks(HookEvent.STOP)
505
514
  if not hooks:
@@ -507,6 +516,8 @@ class HookManager:
507
516
 
508
517
  input_data = StopInput(
509
518
  stop_hook_active=stop_hook_active,
519
+ reason=reason,
520
+ stop_sequence=stop_sequence,
510
521
  session_id=self.session_id,
511
522
  transcript_path=self.transcript_path,
512
523
  cwd=self._get_cwd(),
@@ -558,9 +569,7 @@ class HookManager:
558
569
 
559
570
  # --- Pre Compact ---
560
571
 
561
- def run_pre_compact(
562
- self, trigger: str, custom_instructions: str = ""
563
- ) -> HookResult:
572
+ def run_pre_compact(self, trigger: str, custom_instructions: str = "") -> HookResult:
564
573
  """Run PreCompact hooks synchronously.
565
574
 
566
575
  Args:
@@ -645,11 +654,18 @@ class HookManager:
645
654
 
646
655
  # --- Session End ---
647
656
 
648
- def run_session_end(self, reason: str) -> HookResult:
657
+ def run_session_end(
658
+ self,
659
+ reason: str,
660
+ duration_seconds: Optional[float] = None,
661
+ message_count: Optional[int] = None,
662
+ ) -> HookResult:
649
663
  """Run SessionEnd hooks synchronously.
650
664
 
651
665
  Args:
652
666
  reason: "clear", "logout", "prompt_input_exit", or "other"
667
+ duration_seconds: How long the session lasted
668
+ message_count: Number of messages in the session
653
669
  """
654
670
  hooks = self._get_hooks(HookEvent.SESSION_END)
655
671
  if not hooks:
@@ -657,6 +673,8 @@ class HookManager:
657
673
 
658
674
  input_data = SessionEndInput(
659
675
  reason=reason,
676
+ duration_seconds=duration_seconds,
677
+ message_count=message_count,
660
678
  session_id=self.session_id,
661
679
  transcript_path=self.transcript_path,
662
680
  cwd=self._get_cwd(),
@@ -666,7 +684,12 @@ class HookManager:
666
684
  outputs = self.executor.execute_hooks_sync(hooks, input_data)
667
685
  return HookResult(outputs)
668
686
 
669
- async def run_session_end_async(self, reason: str) -> HookResult:
687
+ async def run_session_end_async(
688
+ self,
689
+ reason: str,
690
+ duration_seconds: Optional[float] = None,
691
+ message_count: Optional[int] = None,
692
+ ) -> HookResult:
670
693
  """Run SessionEnd hooks asynchronously."""
671
694
  hooks = self._get_hooks(HookEvent.SESSION_END)
672
695
  if not hooks:
@@ -674,6 +697,8 @@ class HookManager:
674
697
 
675
698
  input_data = SessionEndInput(
676
699
  reason=reason,
700
+ duration_seconds=duration_seconds,
701
+ message_count=message_count,
677
702
  session_id=self.session_id,
678
703
  transcript_path=self.transcript_path,
679
704
  cwd=self._get_cwd(),
@@ -26,8 +26,29 @@ class PermissionResult:
26
26
  decision: Optional[PermissionDecision] = None
27
27
 
28
28
 
29
- def _format_input_preview(parsed_input: Any) -> str:
30
- """Create a short, human-friendly preview for prompts."""
29
+ def _format_input_preview(parsed_input: Any, tool_name: Optional[str] = None) -> str:
30
+ """Create a human-friendly preview for prompts.
31
+
32
+ For Bash commands, shows full details for security review.
33
+ For other tools, shows a concise preview.
34
+ """
35
+ # For Bash tool, show full command details for security review
36
+ if tool_name == "Bash" and hasattr(parsed_input, "command"):
37
+ lines = [f"Command: {getattr(parsed_input, 'command')}"]
38
+
39
+ # Add other relevant parameters
40
+ if hasattr(parsed_input, "timeout") and parsed_input.timeout:
41
+ lines.append(f"Timeout: {parsed_input.timeout}ms")
42
+ if hasattr(parsed_input, "sandbox"):
43
+ lines.append(f"Sandbox: {parsed_input.sandbox}")
44
+ if hasattr(parsed_input, "run_in_background"):
45
+ lines.append(f"Background: {parsed_input.run_in_background}")
46
+ if hasattr(parsed_input, "shell_executable") and parsed_input.shell_executable:
47
+ lines.append(f"Shell: {parsed_input.shell_executable}")
48
+
49
+ return "\n ".join(lines)
50
+
51
+ # For other tools with commands, show concise preview
31
52
  if hasattr(parsed_input, "command"):
32
53
  return f"command='{getattr(parsed_input, 'command')}'"
33
54
  if hasattr(parsed_input, "file_path"):
@@ -94,10 +115,13 @@ def _rule_strings(rule_suggestions: Optional[Any]) -> list[str]:
94
115
 
95
116
  def make_permission_checker(
96
117
  project_path: Path,
97
- safe_mode: bool,
118
+ yolo_mode: bool,
98
119
  prompt_fn: Optional[Callable[[str], str]] = None,
99
120
  ) -> Callable[[Tool[Any, Any], Any], Awaitable[PermissionResult]]:
100
- """Create a permission checking function for the current project."""
121
+ """Create a permission checking function for the current project.
122
+
123
+ In yolo mode, all tool calls are allowed without prompting.
124
+ """
101
125
 
102
126
  project_path = project_path.resolve()
103
127
  config_manager.get_project_config(project_path)
@@ -120,7 +144,7 @@ def make_permission_checker(
120
144
  """Check and optionally persist permission for a tool invocation."""
121
145
  config = config_manager.get_project_config(project_path)
122
146
 
123
- if not safe_mode:
147
+ if yolo_mode:
124
148
  return PermissionResult(result=True)
125
149
 
126
150
  try:
@@ -130,7 +154,11 @@ def make_permission_checker(
130
154
  # Tool implementation error - log and deny for safety
131
155
  logger.warning(
132
156
  "[permissions] Tool needs_permissions check failed",
133
- extra={"tool": getattr(tool, "name", None), "error": str(exc), "error_type": type(exc).__name__},
157
+ extra={
158
+ "tool": getattr(tool, "name", None),
159
+ "error": str(exc),
160
+ "error_type": type(exc).__name__,
161
+ },
134
162
  )
135
163
  return PermissionResult(
136
164
  result=False,
@@ -172,7 +200,11 @@ def make_permission_checker(
172
200
  # Tool implementation error - fall back to asking user
173
201
  logger.warning(
174
202
  "[permissions] Tool check_permissions failed",
175
- extra={"tool": getattr(tool, "name", None), "error": str(exc), "error_type": type(exc).__name__},
203
+ extra={
204
+ "tool": getattr(tool, "name", None),
205
+ "error": str(exc),
206
+ "error_type": type(exc).__name__,
207
+ },
176
208
  )
177
209
  decision = PermissionDecision(
178
210
  behavior="ask",
@@ -203,7 +235,7 @@ def make_permission_checker(
203
235
  )
204
236
 
205
237
  # Ask/passthrough flows prompt the user.
206
- input_preview = _format_input_preview(parsed_input)
238
+ input_preview = _format_input_preview(parsed_input, tool_name=tool.name)
207
239
  prompt_lines = [
208
240
  f"{tool.name}",
209
241
  "",
@@ -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, Awaitable, Callable, Dict, List, Optional
8
9
  from uuid import uuid4
@@ -73,17 +74,21 @@ def _content_blocks_from_stream_state(
73
74
 
74
75
  # Add thinking block if present
75
76
  if collected_thinking:
76
- blocks.append({
77
- "type": "thinking",
78
- "thinking": "".join(collected_thinking),
79
- })
77
+ blocks.append(
78
+ {
79
+ "type": "thinking",
80
+ "thinking": "".join(collected_thinking),
81
+ }
82
+ )
80
83
 
81
84
  # Add text block if present
82
85
  if collected_text:
83
- blocks.append({
84
- "type": "text",
85
- "text": "".join(collected_text),
86
- })
86
+ blocks.append(
87
+ {
88
+ "type": "text",
89
+ "text": "".join(collected_text),
90
+ }
91
+ )
87
92
 
88
93
  # Add tool_use blocks
89
94
  for idx in sorted(collected_tool_calls.keys()):
@@ -92,12 +97,14 @@ def _content_blocks_from_stream_state(
92
97
  if not name:
93
98
  continue
94
99
  tool_use_id = call.get("id") or str(uuid4())
95
- blocks.append({
96
- "type": "tool_use",
97
- "tool_use_id": tool_use_id,
98
- "name": name,
99
- "input": call.get("input", {}),
100
- })
100
+ blocks.append(
101
+ {
102
+ "type": "tool_use",
103
+ "tool_use_id": tool_use_id,
104
+ "name": name,
105
+ "input": call.get("input", {}),
106
+ }
107
+ )
101
108
 
102
109
  return blocks
103
110
 
@@ -110,25 +117,31 @@ def _content_blocks_from_response(response: Any) -> List[Dict[str, Any]]:
110
117
  if btype == "text":
111
118
  blocks.append({"type": "text", "text": getattr(block, "text", "")})
112
119
  elif btype == "thinking":
113
- blocks.append({
114
- "type": "thinking",
115
- "thinking": getattr(block, "thinking", None) or "",
116
- "signature": getattr(block, "signature", None),
117
- })
120
+ blocks.append(
121
+ {
122
+ "type": "thinking",
123
+ "thinking": getattr(block, "thinking", None) or "",
124
+ "signature": getattr(block, "signature", None),
125
+ }
126
+ )
118
127
  elif btype == "redacted_thinking":
119
- blocks.append({
120
- "type": "redacted_thinking",
121
- "data": getattr(block, "data", None),
122
- "signature": getattr(block, "signature", None),
123
- })
128
+ blocks.append(
129
+ {
130
+ "type": "redacted_thinking",
131
+ "data": getattr(block, "data", None),
132
+ "signature": getattr(block, "signature", None),
133
+ }
134
+ )
124
135
  elif btype == "tool_use":
125
136
  raw_input = getattr(block, "input", {}) or {}
126
- blocks.append({
127
- "type": "tool_use",
128
- "tool_use_id": getattr(block, "id", None) or str(uuid4()),
129
- "name": getattr(block, "name", None),
130
- "input": raw_input if isinstance(raw_input, dict) else {},
131
- })
137
+ blocks.append(
138
+ {
139
+ "type": "tool_use",
140
+ "tool_use_id": getattr(block, "id", None) or str(uuid4()),
141
+ "name": getattr(block, "name", None),
142
+ "input": raw_input if isinstance(raw_input, dict) else {},
143
+ }
144
+ )
132
145
  return blocks
133
146
 
134
147
 
@@ -188,6 +201,15 @@ class AnthropicClient(ProviderClient):
188
201
  except Exception as exc:
189
202
  duration_ms = (time.time() - start_time) * 1000
190
203
  error_code, error_message = _classify_anthropic_error(exc)
204
+ logger.debug(
205
+ "[anthropic_client] Exception details",
206
+ extra={
207
+ "model": model_profile.model,
208
+ "exception_type": type(exc).__name__,
209
+ "exception_str": str(exc),
210
+ "error_code": error_code,
211
+ },
212
+ )
191
213
  logger.error(
192
214
  "[anthropic_client] API call failed",
193
215
  extra={
@@ -222,6 +244,17 @@ class AnthropicClient(ProviderClient):
222
244
  tool_schemas = await build_anthropic_tool_schemas(tools)
223
245
  response_metadata: Dict[str, Any] = {}
224
246
 
247
+ logger.debug(
248
+ "[anthropic_client] Preparing request",
249
+ extra={
250
+ "model": model_profile.model,
251
+ "tool_mode": tool_mode,
252
+ "stream": stream,
253
+ "max_thinking_tokens": max_thinking_tokens,
254
+ "num_tools": len(tool_schemas),
255
+ },
256
+ )
257
+
225
258
  anthropic_kwargs: Dict[str, Any] = {}
226
259
  if model_profile.api_base:
227
260
  anthropic_kwargs["base_url"] = model_profile.api_base
@@ -239,9 +272,9 @@ class AnthropicClient(ProviderClient):
239
272
  # The read timeout applies to waiting for each chunk from the server
240
273
  timeout_config = httpx.Timeout(
241
274
  connect=60.0, # 60 seconds to establish connection
242
- read=600.0, # 10 minutes to wait for each chunk (model may be thinking)
243
- write=60.0, # 60 seconds to send request
244
- pool=60.0, # 60 seconds to get connection from pool
275
+ read=600.0, # 10 minutes to wait for each chunk (model may be thinking)
276
+ write=60.0, # 60 seconds to send request
277
+ pool=60.0, # 60 seconds to get connection from pool
245
278
  )
246
279
  anthropic_kwargs["timeout"] = timeout_config
247
280
  elif request_timeout and request_timeout > 0:
@@ -267,6 +300,21 @@ class AnthropicClient(ProviderClient):
267
300
  if thinking_payload:
268
301
  request_kwargs["thinking"] = thinking_payload
269
302
 
303
+ logger.debug(
304
+ "[anthropic_client] Request parameters",
305
+ extra={
306
+ "model": model_profile.model,
307
+ "request_kwargs": json.dumps(
308
+ {k: v for k, v in request_kwargs.items() if k != "messages"},
309
+ ensure_ascii=False,
310
+ default=str,
311
+ )[:1000],
312
+ "thinking_payload": json.dumps(thinking_payload, ensure_ascii=False)
313
+ if thinking_payload
314
+ else None,
315
+ },
316
+ )
317
+
270
318
  async with await self._client(anthropic_kwargs) as client:
271
319
  if stream:
272
320
  # Streaming mode: use event-based streaming with per-token timeout
@@ -294,6 +342,16 @@ class AnthropicClient(ProviderClient):
294
342
  model_profile.model, duration_ms=duration_ms, cost_usd=cost_usd, **usage_tokens
295
343
  )
296
344
 
345
+ logger.debug(
346
+ "[anthropic_client] Response content blocks",
347
+ extra={
348
+ "model": model_profile.model,
349
+ "content_blocks": json.dumps(content_blocks, ensure_ascii=False)[:1000],
350
+ "usage_tokens": json.dumps(usage_tokens, ensure_ascii=False),
351
+ "metadata": json.dumps(response_metadata, ensure_ascii=False)[:500],
352
+ },
353
+ )
354
+
297
355
  logger.info(
298
356
  "[anthropic_client] Response received",
299
357
  extra={
@@ -354,6 +412,13 @@ class AnthropicClient(ProviderClient):
354
412
  event_count = 0
355
413
  message_stop_received = False
356
414
 
415
+ logger.debug(
416
+ "[anthropic_client] Initiating stream request",
417
+ extra={
418
+ "model": request_kwargs.get("model"),
419
+ },
420
+ )
421
+
357
422
  # Create the stream - this initiates the connection
358
423
  stream = client.messages.stream(**request_kwargs)
359
424
 
@@ -448,7 +513,12 @@ class AnthropicClient(ProviderClient):
448
513
  else:
449
514
  raise
450
515
 
451
- if last_error and not collected_text and not collected_thinking and not collected_tool_calls:
516
+ if (
517
+ last_error
518
+ and not collected_text
519
+ and not collected_thinking
520
+ and not collected_tool_calls
521
+ ):
452
522
  raise RuntimeError(f"Stream failed after {attempts} attempts") from last_error
453
523
 
454
524
  # Store reasoning content in metadata
@@ -542,7 +612,8 @@ class AnthropicClient(ProviderClient):
542
612
  except (RuntimeError, ValueError, TypeError, OSError) as cb_exc:
543
613
  logger.warning(
544
614
  "[anthropic_client] Progress callback failed: %s: %s",
545
- type(cb_exc).__name__, cb_exc,
615
+ type(cb_exc).__name__,
616
+ cb_exc,
546
617
  )
547
618
 
548
619
  elif delta_type == "text_delta":
@@ -556,7 +627,8 @@ class AnthropicClient(ProviderClient):
556
627
  except (RuntimeError, ValueError, TypeError, OSError) as cb_exc:
557
628
  logger.warning(
558
629
  "[anthropic_client] Progress callback failed: %s: %s",
559
- type(cb_exc).__name__, cb_exc,
630
+ type(cb_exc).__name__,
631
+ cb_exc,
560
632
  )
561
633
 
562
634
  elif delta_type == "input_json_delta":
@@ -599,6 +671,7 @@ class AnthropicClient(ProviderClient):
599
671
  # Parse accumulated JSON for tool calls
600
672
  if index in collected_tool_calls:
601
673
  import json
674
+
602
675
  json_str = collected_tool_calls[index].get("input_json", "")
603
676
  if json_str:
604
677
  try:
@@ -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={