ripperdoc 0.2.8__py3-none-any.whl → 0.2.10__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 (94) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +257 -123
  3. ripperdoc/cli/commands/__init__.py +2 -1
  4. ripperdoc/cli/commands/agents_cmd.py +138 -8
  5. ripperdoc/cli/commands/clear_cmd.py +9 -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/exit_cmd.py +1 -0
  10. ripperdoc/cli/commands/hooks_cmd.py +27 -53
  11. ripperdoc/cli/commands/models_cmd.py +27 -10
  12. ripperdoc/cli/commands/permissions_cmd.py +27 -9
  13. ripperdoc/cli/commands/resume_cmd.py +9 -3
  14. ripperdoc/cli/commands/stats_cmd.py +244 -0
  15. ripperdoc/cli/commands/status_cmd.py +4 -4
  16. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  17. ripperdoc/cli/ui/file_mention_completer.py +2 -1
  18. ripperdoc/cli/ui/interrupt_handler.py +2 -3
  19. ripperdoc/cli/ui/message_display.py +4 -2
  20. ripperdoc/cli/ui/panels.py +1 -0
  21. ripperdoc/cli/ui/provider_options.py +247 -0
  22. ripperdoc/cli/ui/rich_ui.py +403 -81
  23. ripperdoc/cli/ui/spinner.py +54 -18
  24. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  25. ripperdoc/cli/ui/tool_renderers.py +8 -2
  26. ripperdoc/cli/ui/wizard.py +213 -0
  27. ripperdoc/core/agents.py +19 -6
  28. ripperdoc/core/config.py +51 -17
  29. ripperdoc/core/custom_commands.py +7 -6
  30. ripperdoc/core/default_tools.py +101 -12
  31. ripperdoc/core/hooks/config.py +1 -3
  32. ripperdoc/core/hooks/events.py +27 -28
  33. ripperdoc/core/hooks/executor.py +4 -6
  34. ripperdoc/core/hooks/integration.py +12 -21
  35. ripperdoc/core/hooks/llm_callback.py +59 -0
  36. ripperdoc/core/hooks/manager.py +40 -15
  37. ripperdoc/core/permissions.py +118 -12
  38. ripperdoc/core/providers/anthropic.py +109 -36
  39. ripperdoc/core/providers/gemini.py +70 -5
  40. ripperdoc/core/providers/openai.py +89 -24
  41. ripperdoc/core/query.py +273 -68
  42. ripperdoc/core/query_utils.py +2 -0
  43. ripperdoc/core/skills.py +9 -3
  44. ripperdoc/core/system_prompt.py +4 -2
  45. ripperdoc/core/tool.py +17 -8
  46. ripperdoc/sdk/client.py +79 -4
  47. ripperdoc/tools/ask_user_question_tool.py +5 -3
  48. ripperdoc/tools/background_shell.py +307 -135
  49. ripperdoc/tools/bash_output_tool.py +1 -1
  50. ripperdoc/tools/bash_tool.py +63 -24
  51. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  52. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  53. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  54. ripperdoc/tools/file_edit_tool.py +167 -54
  55. ripperdoc/tools/file_read_tool.py +28 -4
  56. ripperdoc/tools/file_write_tool.py +13 -10
  57. ripperdoc/tools/glob_tool.py +3 -2
  58. ripperdoc/tools/grep_tool.py +3 -2
  59. ripperdoc/tools/kill_bash_tool.py +1 -1
  60. ripperdoc/tools/ls_tool.py +1 -1
  61. ripperdoc/tools/lsp_tool.py +615 -0
  62. ripperdoc/tools/mcp_tools.py +13 -10
  63. ripperdoc/tools/multi_edit_tool.py +8 -7
  64. ripperdoc/tools/notebook_edit_tool.py +7 -4
  65. ripperdoc/tools/skill_tool.py +1 -1
  66. ripperdoc/tools/task_tool.py +519 -69
  67. ripperdoc/tools/todo_tool.py +2 -2
  68. ripperdoc/tools/tool_search_tool.py +3 -2
  69. ripperdoc/utils/conversation_compaction.py +9 -5
  70. ripperdoc/utils/file_watch.py +214 -5
  71. ripperdoc/utils/json_utils.py +2 -1
  72. ripperdoc/utils/lsp.py +806 -0
  73. ripperdoc/utils/mcp.py +11 -3
  74. ripperdoc/utils/memory.py +4 -2
  75. ripperdoc/utils/message_compaction.py +21 -7
  76. ripperdoc/utils/message_formatting.py +14 -7
  77. ripperdoc/utils/messages.py +126 -67
  78. ripperdoc/utils/path_ignore.py +35 -8
  79. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  80. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  81. ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
  82. ripperdoc/utils/safe_get_cwd.py +2 -1
  83. ripperdoc/utils/session_heatmap.py +244 -0
  84. ripperdoc/utils/session_history.py +13 -6
  85. ripperdoc/utils/session_stats.py +293 -0
  86. ripperdoc/utils/todo.py +2 -1
  87. ripperdoc/utils/token_estimation.py +6 -1
  88. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
  89. ripperdoc-0.2.10.dist-info/RECORD +129 -0
  90. ripperdoc-0.2.8.dist-info/RECORD +0 -121
  91. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
  92. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
  93. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
  94. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, List
5
+ from typing import Any, List, Optional
6
6
 
7
7
  from ripperdoc.core.tool import Tool
8
8
 
@@ -17,6 +17,7 @@ from ripperdoc.tools.file_write_tool import FileWriteTool
17
17
  from ripperdoc.tools.glob_tool import GlobTool
18
18
  from ripperdoc.tools.ls_tool import LSTool
19
19
  from ripperdoc.tools.grep_tool import GrepTool
20
+ from ripperdoc.tools.lsp_tool import LspTool
20
21
  from ripperdoc.tools.skill_tool import SkillTool
21
22
  from ripperdoc.tools.todo_tool import TodoReadTool, TodoWriteTool
22
23
  from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
@@ -34,8 +35,73 @@ from ripperdoc.utils.log import get_logger
34
35
 
35
36
  logger = get_logger()
36
37
 
38
+ # Canonical tool names for --tools filtering
39
+ BUILTIN_TOOL_NAMES = [
40
+ "Bash",
41
+ "BashOutput",
42
+ "KillBash",
43
+ "Read",
44
+ "Edit",
45
+ "MultiEdit",
46
+ "NotebookEdit",
47
+ "Write",
48
+ "Glob",
49
+ "LS",
50
+ "Grep",
51
+ "LSP",
52
+ "Skill",
53
+ "TodoRead",
54
+ "TodoWrite",
55
+ "AskUserQuestion",
56
+ "EnterPlanMode",
57
+ "ExitPlanMode",
58
+ "ToolSearch",
59
+ "ListMcpServers",
60
+ "ListMcpResources",
61
+ "ReadMcpResource",
62
+ "Task",
63
+ ]
37
64
 
38
- def get_default_tools() -> List[Tool[Any, Any]]:
65
+
66
+ def filter_tools_by_names(
67
+ tools: List[Tool[Any, Any]], tool_names: List[str]
68
+ ) -> List[Tool[Any, Any]]:
69
+ """Filter a tool list to only include tools with matching names.
70
+
71
+ Args:
72
+ tools: The full list of tools to filter.
73
+ tool_names: List of tool names to include.
74
+
75
+ Returns:
76
+ Filtered list of tools. If Task is included, it's recreated with
77
+ the filtered base tools.
78
+ """
79
+ if not tool_names:
80
+ return []
81
+
82
+ name_set = set(tool_names)
83
+ filtered: List[Tool[Any, Any]] = []
84
+ has_task = False
85
+
86
+ for tool in tools:
87
+ tool_name = getattr(tool, "name", tool.__class__.__name__)
88
+ if tool_name in name_set:
89
+ if tool_name == "Task":
90
+ has_task = True
91
+ else:
92
+ filtered.append(tool)
93
+
94
+ # If Task is requested, recreate it with the filtered base tools
95
+ if has_task:
96
+ def _filtered_base_provider() -> List[Tool[Any, Any]]:
97
+ return [t for t in filtered if getattr(t, "name", None) != "Task"]
98
+
99
+ filtered.append(TaskTool(_filtered_base_provider))
100
+
101
+ return filtered
102
+
103
+
104
+ def get_default_tools(allowed_tools: Optional[List[str]] = None) -> List[Tool[Any, Any]]:
39
105
  """Construct the default tool set (base tools + Task subagent launcher)."""
40
106
  base_tools: List[Tool[Any, Any]] = [
41
107
  BashTool(),
@@ -49,6 +115,7 @@ def get_default_tools() -> List[Tool[Any, Any]]:
49
115
  GlobTool(),
50
116
  LSTool(),
51
117
  GrepTool(),
118
+ LspTool(),
52
119
  SkillTool(),
53
120
  TodoReadTool(),
54
121
  TodoWriteTool(),
@@ -68,21 +135,43 @@ def get_default_tools() -> List[Tool[Any, Any]]:
68
135
  if isinstance(tool, Tool):
69
136
  base_tools.append(tool)
70
137
  dynamic_tools.append(tool)
71
- except (ImportError, ModuleNotFoundError, OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
138
+ except (
139
+ ImportError,
140
+ ModuleNotFoundError,
141
+ OSError,
142
+ RuntimeError,
143
+ ConnectionError,
144
+ ValueError,
145
+ TypeError,
146
+ ) as exc:
72
147
  # If MCP runtime is not available, continue with base tools only.
73
148
  logger.warning(
74
149
  "[default_tools] Failed to load dynamic MCP tools: %s: %s",
75
- type(exc).__name__, exc,
150
+ type(exc).__name__,
151
+ exc,
76
152
  )
77
153
 
78
154
  task_tool = TaskTool(lambda: base_tools)
79
155
  all_tools = base_tools + [task_tool]
80
- logger.debug(
81
- "[default_tools] Built tool inventory",
82
- extra={
83
- "base_tools": len(base_tools),
84
- "dynamic_mcp_tools": len(dynamic_tools),
85
- "total_tools": len(all_tools),
86
- },
87
- )
156
+
157
+ # Apply allowed_tools filter if specified
158
+ if allowed_tools is not None:
159
+ all_tools = filter_tools_by_names(all_tools, allowed_tools)
160
+ logger.debug(
161
+ "[default_tools] Filtered tool inventory",
162
+ extra={
163
+ "allowed_tools": allowed_tools,
164
+ "filtered_tools": len(all_tools),
165
+ },
166
+ )
167
+ else:
168
+ logger.debug(
169
+ "[default_tools] Built tool inventory",
170
+ extra={
171
+ "base_tools": len(base_tools),
172
+ "dynamic_mcp_tools": len(dynamic_tools),
173
+ "total_tools": len(all_tools),
174
+ },
175
+ )
176
+
88
177
  return all_tools
@@ -234,9 +234,7 @@ def _parse_hooks_file(data: Dict[str, Any]) -> HooksConfig:
234
234
  hook_definitions.append(hook_def)
235
235
 
236
236
  if hook_definitions:
237
- parsed_matchers.append(
238
- HookMatcher(matcher=matcher_pattern, hooks=hook_definitions)
239
- )
237
+ parsed_matchers.append(HookMatcher(matcher=matcher_pattern, hooks=hook_definitions))
240
238
 
241
239
  if parsed_matchers:
242
240
  parsed_hooks[event_name] = parsed_matchers
@@ -5,10 +5,9 @@ as well as the input/output data structures for each event type.
5
5
  """
6
6
 
7
7
  import json
8
- import os
9
8
  from enum import Enum
10
- from typing import Any, Dict, List, Literal, Optional, Union
11
- from pydantic import BaseModel, Field
9
+ from typing import Any, Dict, Literal, Optional, Union
10
+ from pydantic import BaseModel, ConfigDict, Field
12
11
 
13
12
 
14
13
  class HookEvent(str, Enum):
@@ -73,8 +72,7 @@ class HookInput(BaseModel):
73
72
  permission_mode: str = "default" # "default", "plan", "acceptEdits", "bypassPermissions"
74
73
  hook_event_name: str = ""
75
74
 
76
- class Config:
77
- populate_by_name = True
75
+ model_config = ConfigDict(populate_by_name=True)
78
76
 
79
77
 
80
78
  class PreToolUseInput(HookInput):
@@ -150,6 +148,8 @@ class StopInput(HookInput):
150
148
 
151
149
  hook_event_name: str = "Stop"
152
150
  stop_hook_active: bool = False # True if already continuing from a stop hook
151
+ reason: Optional[str] = None
152
+ stop_sequence: Optional[str] = None
153
153
 
154
154
 
155
155
  class SubagentStopInput(HookInput):
@@ -210,6 +210,8 @@ class SessionEndInput(HookInput):
210
210
 
211
211
  hook_event_name: str = "SessionEnd"
212
212
  reason: str = "" # "clear", "logout", "prompt_input_exit", "other"
213
+ duration_seconds: Optional[float] = None
214
+ message_count: Optional[int] = None
213
215
 
214
216
 
215
217
  # ─────────────────────────────────────────────────────────────────────────────
@@ -232,8 +234,7 @@ class PreToolUseHookOutput(BaseModel):
232
234
  ) # Modified tool input
233
235
  additional_context: Optional[str] = Field(default=None, alias="additionalContext")
234
236
 
235
- class Config:
236
- populate_by_name = True
237
+ model_config = ConfigDict(populate_by_name=True)
237
238
 
238
239
 
239
240
  class PermissionRequestDecision(BaseModel):
@@ -244,8 +245,7 @@ class PermissionRequestDecision(BaseModel):
244
245
  message: Optional[str] = None
245
246
  interrupt: bool = False
246
247
 
247
- class Config:
248
- populate_by_name = True
248
+ model_config = ConfigDict(populate_by_name=True)
249
249
 
250
250
 
251
251
  class PermissionRequestHookOutput(BaseModel):
@@ -254,8 +254,7 @@ class PermissionRequestHookOutput(BaseModel):
254
254
  hook_event_name: Literal["PermissionRequest"] = "PermissionRequest"
255
255
  decision: Optional[PermissionRequestDecision] = None
256
256
 
257
- class Config:
258
- populate_by_name = True
257
+ model_config = ConfigDict(populate_by_name=True)
259
258
 
260
259
 
261
260
  class PostToolUseHookOutput(BaseModel):
@@ -264,8 +263,7 @@ class PostToolUseHookOutput(BaseModel):
264
263
  hook_event_name: Literal["PostToolUse"] = "PostToolUse"
265
264
  additional_context: Optional[str] = Field(default=None, alias="additionalContext")
266
265
 
267
- class Config:
268
- populate_by_name = True
266
+ model_config = ConfigDict(populate_by_name=True)
269
267
 
270
268
 
271
269
  class UserPromptSubmitHookOutput(BaseModel):
@@ -274,8 +272,7 @@ class UserPromptSubmitHookOutput(BaseModel):
274
272
  hook_event_name: Literal["UserPromptSubmit"] = "UserPromptSubmit"
275
273
  additional_context: Optional[str] = Field(default=None, alias="additionalContext")
276
274
 
277
- class Config:
278
- populate_by_name = True
275
+ model_config = ConfigDict(populate_by_name=True)
279
276
 
280
277
 
281
278
  class SessionStartHookOutput(BaseModel):
@@ -284,8 +281,7 @@ class SessionStartHookOutput(BaseModel):
284
281
  hook_event_name: Literal["SessionStart"] = "SessionStart"
285
282
  additional_context: Optional[str] = Field(default=None, alias="additionalContext")
286
283
 
287
- class Config:
288
- populate_by_name = True
284
+ model_config = ConfigDict(populate_by_name=True)
289
285
 
290
286
 
291
287
  HookSpecificOutput = Union[
@@ -325,7 +321,7 @@ class HookOutput(BaseModel):
325
321
  )
326
322
 
327
323
  # Additional context to inject
328
- additional_context: Optional[str] = None
324
+ additional_context: Optional[str] = Field(default=None, alias="additionalContext")
329
325
 
330
326
  # Raw output (for non-JSON responses)
331
327
  raw_output: Optional[str] = None
@@ -336,8 +332,7 @@ class HookOutput(BaseModel):
336
332
  exit_code: int = 0
337
333
  timed_out: bool = False
338
334
 
339
- class Config:
340
- populate_by_name = True
335
+ model_config = ConfigDict(populate_by_name=True)
341
336
 
342
337
  @classmethod
343
338
  def from_raw(
@@ -420,10 +415,10 @@ class HookOutput(BaseModel):
420
415
  # Handle PreToolUse specific fields
421
416
  if event_name == "PreToolUse":
422
417
  output.hook_specific_output = PreToolUseHookOutput(
423
- permission_decision=hso.get("permissionDecision"),
424
- permission_decision_reason=hso.get("permissionDecisionReason"),
425
- updated_input=hso.get("updatedInput"),
426
- additional_context=hso.get("additionalContext"),
418
+ permissionDecision=hso.get("permissionDecision"),
419
+ permissionDecisionReason=hso.get("permissionDecisionReason"),
420
+ updatedInput=hso.get("updatedInput"),
421
+ additionalContext=hso.get("additionalContext"),
427
422
  )
428
423
  # Map permissionDecision to decision
429
424
  perm_decision = hso.get("permissionDecision")
@@ -444,7 +439,7 @@ class HookOutput(BaseModel):
444
439
  if isinstance(decision_obj, dict):
445
440
  decision_data = PermissionRequestDecision(
446
441
  behavior=decision_obj.get("behavior", ""),
447
- updated_input=decision_obj.get("updatedInput"),
442
+ updatedInput=decision_obj.get("updatedInput"),
448
443
  message=decision_obj.get("message"),
449
444
  interrupt=decision_obj.get("interrupt", False),
450
445
  )
@@ -462,7 +457,7 @@ class HookOutput(BaseModel):
462
457
  # Handle PostToolUse specific fields
463
458
  elif event_name == "PostToolUse":
464
459
  output.hook_specific_output = PostToolUseHookOutput(
465
- additional_context=hso.get("additionalContext"),
460
+ additionalContext=hso.get("additionalContext"),
466
461
  )
467
462
  if hso.get("additionalContext"):
468
463
  output.additional_context = hso["additionalContext"]
@@ -470,7 +465,7 @@ class HookOutput(BaseModel):
470
465
  # Handle UserPromptSubmit specific fields
471
466
  elif event_name == "UserPromptSubmit":
472
467
  output.hook_specific_output = UserPromptSubmitHookOutput(
473
- additional_context=hso.get("additionalContext"),
468
+ additionalContext=hso.get("additionalContext"),
474
469
  )
475
470
  if hso.get("additionalContext"):
476
471
  output.additional_context = hso["additionalContext"]
@@ -478,7 +473,7 @@ class HookOutput(BaseModel):
478
473
  # Handle SessionStart specific fields
479
474
  elif event_name == "SessionStart":
480
475
  output.hook_specific_output = SessionStartHookOutput(
481
- additional_context=hso.get("additionalContext"),
476
+ additionalContext=hso.get("additionalContext"),
482
477
  )
483
478
  if hso.get("additionalContext"):
484
479
  output.additional_context = hso["additionalContext"]
@@ -520,6 +515,10 @@ class HookOutput(BaseModel):
520
515
  """Get updated input from PreToolUse hook."""
521
516
  if isinstance(self.hook_specific_output, PreToolUseHookOutput):
522
517
  return self.hook_specific_output.updated_input
518
+ if isinstance(self.hook_specific_output, PermissionRequestHookOutput):
519
+ decision = self.hook_specific_output.decision
520
+ if decision and decision.updated_input:
521
+ return decision.updated_input
523
522
  if isinstance(self.hook_specific_output, dict):
524
523
  return self.hook_specific_output.get("updatedInput")
525
524
  return None
@@ -14,7 +14,7 @@ import os
14
14
  import subprocess
15
15
  import tempfile
16
16
  from pathlib import Path
17
- from typing import Any, Callable, Dict, Optional, Awaitable
17
+ from typing import Callable, Dict, Optional, Awaitable
18
18
 
19
19
  from ripperdoc.core.hooks.config import HookDefinition
20
20
  from ripperdoc.core.hooks.events import AnyHookInput, HookOutput, HookDecision, SessionStartInput
@@ -191,7 +191,7 @@ class HookExecutor:
191
191
  pass
192
192
 
193
193
  # Not JSON, treat as additional context
194
- return HookOutput(raw_output=response, additional_context=response)
194
+ return HookOutput(raw_output=response, additionalContext=response)
195
195
 
196
196
  async def execute_prompt_async(
197
197
  self,
@@ -224,7 +224,7 @@ class HookExecutor:
224
224
  prompt = self._expand_prompt(hook.prompt, input_data)
225
225
 
226
226
  logger.debug(
227
- f"Executing prompt hook",
227
+ "Executing prompt hook",
228
228
  extra={
229
229
  "event": input_data.hook_event_name,
230
230
  "timeout": hook.timeout,
@@ -277,9 +277,7 @@ class HookExecutor:
277
277
  """
278
278
  # Prompt hooks require async - skip in sync mode
279
279
  if hook.is_prompt_hook():
280
- logger.warning(
281
- "Prompt hook skipped in sync mode. Use execute_async for prompt hooks."
282
- )
280
+ logger.warning("Prompt hook skipped in sync mode. Use execute_async for prompt hooks.")
283
281
  return HookOutput()
284
282
 
285
283
  return self._execute_command_sync(hook, input_data)
@@ -4,11 +4,9 @@ This module provides convenient integration points for running hooks
4
4
  as part of tool execution flows.
5
5
  """
6
6
 
7
- from pathlib import Path
8
7
  from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, Union
9
8
 
10
- from ripperdoc.core.hooks.events import HookDecision
11
- from ripperdoc.core.hooks.manager import HookManager, HookResult, hook_manager
9
+ from ripperdoc.core.hooks.manager import HookManager, hook_manager
12
10
  from ripperdoc.utils.log import get_logger
13
11
 
14
12
  logger = get_logger()
@@ -98,9 +96,7 @@ class HookInterceptor:
98
96
  Returns:
99
97
  Tuple of (should_continue, block_reason, additional_context)
100
98
  """
101
- result = self.manager.run_post_tool_use(
102
- tool_name, tool_input, tool_output, tool_error
103
- )
99
+ result = self.manager.run_post_tool_use(tool_name, tool_input, tool_output, tool_error)
104
100
 
105
101
  if result.should_block:
106
102
  return False, result.block_reason, result.additional_context
@@ -129,7 +125,7 @@ class HookInterceptor:
129
125
  tool_name: str,
130
126
  tool_input: Dict[str, Any],
131
127
  execute_fn: Callable[[], T],
132
- ) -> Tuple[bool, Union[T, str], Optional[str]]:
128
+ ) -> Tuple[bool, Union[T, str, None], Optional[str]]:
133
129
  """Wrap synchronous tool execution with pre/post hooks.
134
130
 
135
131
  Args:
@@ -141,9 +137,7 @@ class HookInterceptor:
141
137
  Tuple of (success, result_or_error, additional_context)
142
138
  """
143
139
  # Run pre-tool hooks
144
- should_proceed, block_reason, pre_context = self.check_pre_tool_use(
145
- tool_name, tool_input
146
- )
140
+ should_proceed, block_reason, pre_context = self.check_pre_tool_use(tool_name, tool_input)
147
141
 
148
142
  if not should_proceed:
149
143
  return False, block_reason or "Blocked by hook", pre_context
@@ -157,9 +151,7 @@ class HookInterceptor:
157
151
  tool_error = str(e)
158
152
 
159
153
  # Run post-tool hooks
160
- _, _, post_context = self.run_post_tool_use(
161
- tool_name, tool_input, result, tool_error
162
- )
154
+ _, _, post_context = self.run_post_tool_use(tool_name, tool_input, result, tool_error)
163
155
 
164
156
  # Combine contexts
165
157
  combined_context = None
@@ -168,7 +160,7 @@ class HookInterceptor:
168
160
  combined_context = "\n".join(parts) if parts else None
169
161
 
170
162
  if tool_error:
171
- return False, tool_error, combined_context
163
+ return False, tool_error or "", combined_context
172
164
 
173
165
  return True, result, combined_context
174
166
 
@@ -177,7 +169,7 @@ class HookInterceptor:
177
169
  tool_name: str,
178
170
  tool_input: Dict[str, Any],
179
171
  execute_fn: Callable[[], T],
180
- ) -> Tuple[bool, Union[T, str], Optional[str]]:
172
+ ) -> Tuple[bool, Union[T, str, None], Optional[str]]:
181
173
  """Wrap async tool execution with pre/post hooks."""
182
174
  # Run pre-tool hooks
183
175
  should_proceed, block_reason, pre_context = await self.check_pre_tool_use_async(
@@ -190,6 +182,7 @@ class HookInterceptor:
190
182
  # Execute the tool
191
183
  try:
192
184
  import asyncio
185
+
193
186
  if asyncio.iscoroutinefunction(execute_fn):
194
187
  result = await execute_fn()
195
188
  else:
@@ -211,7 +204,7 @@ class HookInterceptor:
211
204
  combined_context = "\n".join(parts) if parts else None
212
205
 
213
206
  if tool_error:
214
- return False, tool_error, combined_context
207
+ return False, tool_error or "", combined_context
215
208
 
216
209
  return True, result, combined_context
217
210
 
@@ -241,9 +234,7 @@ def run_post_tool_use(
241
234
  tool_error: Optional[str] = None,
242
235
  ) -> Tuple[bool, Optional[str], Optional[str]]:
243
236
  """Convenience function to run post-tool hooks using global interceptor."""
244
- return hook_interceptor.run_post_tool_use(
245
- tool_name, tool_input, tool_output, tool_error
246
- )
237
+ return hook_interceptor.run_post_tool_use(tool_name, tool_input, tool_output, tool_error)
247
238
 
248
239
 
249
240
  async def run_post_tool_use_async(
@@ -333,7 +324,7 @@ def check_stop(
333
324
  - should_stop: True if agent should stop
334
325
  - continue_reason: Reason to continue if blocked
335
326
  """
336
- result = hook_manager.run_stop(reason, stop_sequence)
327
+ result = hook_manager.run_stop(False, reason, stop_sequence)
337
328
 
338
329
  if result.should_block:
339
330
  return False, result.block_reason
@@ -345,7 +336,7 @@ async def check_stop_async(
345
336
  reason: Optional[str] = None, stop_sequence: Optional[str] = None
346
337
  ) -> Tuple[bool, Optional[str]]:
347
338
  """Async version of check_stop."""
348
- result = await hook_manager.run_stop_async(reason, stop_sequence)
339
+ result = await hook_manager.run_stop_async(False, reason, stop_sequence)
349
340
 
350
341
  if result.should_block:
351
342
  return False, result.block_reason
@@ -0,0 +1,59 @@
1
+ """LLM callback helper for prompt-based hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from ripperdoc.core.hooks.executor import LLMCallback
8
+ from ripperdoc.core.query import query_llm
9
+ from ripperdoc.utils.log import get_logger
10
+ from ripperdoc.utils.messages import AssistantMessage, create_user_message
11
+
12
+ logger = get_logger()
13
+
14
+
15
+ def _extract_text(message: AssistantMessage) -> str:
16
+ content = message.message.content
17
+ if isinstance(content, str):
18
+ return content
19
+ if isinstance(content, list):
20
+ parts = []
21
+ for block in content:
22
+ text = getattr(block, "text", None) or (
23
+ block.get("text") if isinstance(block, dict) else None
24
+ )
25
+ if text:
26
+ parts.append(str(text))
27
+ return "\n".join(parts)
28
+ return ""
29
+
30
+
31
+ def build_hook_llm_callback(
32
+ *,
33
+ model: str = "quick",
34
+ max_thinking_tokens: int = 0,
35
+ system_prompt: Optional[str] = None,
36
+ ) -> LLMCallback:
37
+ """Build an async callback for prompt hooks using the configured model."""
38
+
39
+ async def _callback(prompt: str) -> str:
40
+ try:
41
+ assistant = await query_llm(
42
+ [create_user_message(prompt)],
43
+ system_prompt or "",
44
+ [],
45
+ max_thinking_tokens=max_thinking_tokens,
46
+ model=model,
47
+ stream=False,
48
+ )
49
+ return _extract_text(assistant).strip()
50
+ except Exception as exc:
51
+ logger.warning(
52
+ "[hooks] Prompt hook LLM callback failed: %s: %s",
53
+ type(exc).__name__,
54
+ exc,
55
+ )
56
+ return f"Prompt hook evaluation failed: {exc}"
57
+
58
+ return _callback
59
+