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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +257 -123
- ripperdoc/cli/commands/__init__.py +2 -1
- ripperdoc/cli/commands/agents_cmd.py +138 -8
- ripperdoc/cli/commands/clear_cmd.py +9 -4
- ripperdoc/cli/commands/config_cmd.py +1 -1
- ripperdoc/cli/commands/context_cmd.py +3 -2
- ripperdoc/cli/commands/doctor_cmd.py +18 -4
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/hooks_cmd.py +27 -53
- ripperdoc/cli/commands/models_cmd.py +27 -10
- ripperdoc/cli/commands/permissions_cmd.py +27 -9
- ripperdoc/cli/commands/resume_cmd.py +9 -3
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- ripperdoc/cli/commands/status_cmd.py +4 -4
- ripperdoc/cli/commands/tasks_cmd.py +8 -4
- ripperdoc/cli/ui/file_mention_completer.py +2 -1
- ripperdoc/cli/ui/interrupt_handler.py +2 -3
- ripperdoc/cli/ui/message_display.py +4 -2
- ripperdoc/cli/ui/panels.py +1 -0
- ripperdoc/cli/ui/provider_options.py +247 -0
- ripperdoc/cli/ui/rich_ui.py +403 -81
- ripperdoc/cli/ui/spinner.py +54 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/tool_renderers.py +8 -2
- ripperdoc/cli/ui/wizard.py +213 -0
- ripperdoc/core/agents.py +19 -6
- ripperdoc/core/config.py +51 -17
- ripperdoc/core/custom_commands.py +7 -6
- ripperdoc/core/default_tools.py +101 -12
- ripperdoc/core/hooks/config.py +1 -3
- ripperdoc/core/hooks/events.py +27 -28
- ripperdoc/core/hooks/executor.py +4 -6
- ripperdoc/core/hooks/integration.py +12 -21
- ripperdoc/core/hooks/llm_callback.py +59 -0
- ripperdoc/core/hooks/manager.py +40 -15
- ripperdoc/core/permissions.py +118 -12
- ripperdoc/core/providers/anthropic.py +109 -36
- ripperdoc/core/providers/gemini.py +70 -5
- ripperdoc/core/providers/openai.py +89 -24
- ripperdoc/core/query.py +273 -68
- ripperdoc/core/query_utils.py +2 -0
- ripperdoc/core/skills.py +9 -3
- ripperdoc/core/system_prompt.py +4 -2
- ripperdoc/core/tool.py +17 -8
- ripperdoc/sdk/client.py +79 -4
- ripperdoc/tools/ask_user_question_tool.py +5 -3
- ripperdoc/tools/background_shell.py +307 -135
- ripperdoc/tools/bash_output_tool.py +1 -1
- ripperdoc/tools/bash_tool.py +63 -24
- ripperdoc/tools/dynamic_mcp_tool.py +29 -8
- ripperdoc/tools/enter_plan_mode_tool.py +1 -1
- ripperdoc/tools/exit_plan_mode_tool.py +1 -1
- ripperdoc/tools/file_edit_tool.py +167 -54
- ripperdoc/tools/file_read_tool.py +28 -4
- ripperdoc/tools/file_write_tool.py +13 -10
- ripperdoc/tools/glob_tool.py +3 -2
- ripperdoc/tools/grep_tool.py +3 -2
- ripperdoc/tools/kill_bash_tool.py +1 -1
- ripperdoc/tools/ls_tool.py +1 -1
- ripperdoc/tools/lsp_tool.py +615 -0
- ripperdoc/tools/mcp_tools.py +13 -10
- ripperdoc/tools/multi_edit_tool.py +8 -7
- ripperdoc/tools/notebook_edit_tool.py +7 -4
- ripperdoc/tools/skill_tool.py +1 -1
- ripperdoc/tools/task_tool.py +519 -69
- ripperdoc/tools/todo_tool.py +2 -2
- ripperdoc/tools/tool_search_tool.py +3 -2
- ripperdoc/utils/conversation_compaction.py +9 -5
- ripperdoc/utils/file_watch.py +214 -5
- ripperdoc/utils/json_utils.py +2 -1
- ripperdoc/utils/lsp.py +806 -0
- ripperdoc/utils/mcp.py +11 -3
- ripperdoc/utils/memory.py +4 -2
- ripperdoc/utils/message_compaction.py +21 -7
- ripperdoc/utils/message_formatting.py +14 -7
- ripperdoc/utils/messages.py +126 -67
- ripperdoc/utils/path_ignore.py +35 -8
- ripperdoc/utils/permissions/path_validation_utils.py +2 -1
- ripperdoc/utils/permissions/shell_command_validation.py +427 -91
- ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
- ripperdoc/utils/safe_get_cwd.py +2 -1
- ripperdoc/utils/session_heatmap.py +244 -0
- ripperdoc/utils/session_history.py +13 -6
- ripperdoc/utils/session_stats.py +293 -0
- ripperdoc/utils/todo.py +2 -1
- ripperdoc/utils/token_estimation.py +6 -1
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
- ripperdoc-0.2.10.dist-info/RECORD +129 -0
- ripperdoc-0.2.8.dist-info/RECORD +0 -121
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
ripperdoc/core/query.py
CHANGED
|
@@ -26,7 +26,7 @@ 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
32
|
from ripperdoc.core.hooks.manager import hook_manager
|
|
@@ -43,7 +43,11 @@ from ripperdoc.core.query_utils import (
|
|
|
43
43
|
from ripperdoc.core.tool import Tool, ToolProgress, ToolResult, ToolUseContext
|
|
44
44
|
from ripperdoc.utils.coerce import parse_optional_int
|
|
45
45
|
from ripperdoc.utils.context_length_errors import detect_context_length_error
|
|
46
|
-
from ripperdoc.utils.file_watch import
|
|
46
|
+
from ripperdoc.utils.file_watch import (
|
|
47
|
+
BoundedFileCache,
|
|
48
|
+
ChangedFileNotice,
|
|
49
|
+
detect_changed_files,
|
|
50
|
+
)
|
|
47
51
|
from ripperdoc.utils.log import get_logger
|
|
48
52
|
from ripperdoc.utils.messages import (
|
|
49
53
|
AssistantMessage,
|
|
@@ -65,6 +69,42 @@ DEFAULT_REQUEST_TIMEOUT_SEC = float(os.getenv("RIPPERDOC_API_TIMEOUT", "120"))
|
|
|
65
69
|
MAX_LLM_RETRIES = int(os.getenv("RIPPERDOC_MAX_RETRIES", "10"))
|
|
66
70
|
|
|
67
71
|
|
|
72
|
+
def infer_thinking_mode(model_profile: ModelProfile) -> Optional[str]:
|
|
73
|
+
"""Infer thinking mode from ModelProfile if not explicitly configured.
|
|
74
|
+
|
|
75
|
+
This function checks the model_profile.thinking_mode first. If it's set,
|
|
76
|
+
returns that value. Otherwise, auto-detects based on api_base and model name.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
model_profile: The model profile to analyze
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Thinking mode string ("deepseek", "qwen", "openrouter", "gemini_openai")
|
|
83
|
+
or None if no thinking mode should be applied.
|
|
84
|
+
"""
|
|
85
|
+
# Use explicit config if set
|
|
86
|
+
explicit_mode = model_profile.thinking_mode
|
|
87
|
+
if explicit_mode:
|
|
88
|
+
return explicit_mode
|
|
89
|
+
|
|
90
|
+
# Auto-detect based on API base and model name
|
|
91
|
+
base = (model_profile.api_base or "").lower()
|
|
92
|
+
name = (model_profile.model or "").lower()
|
|
93
|
+
|
|
94
|
+
if "deepseek" in base or name.startswith("deepseek"):
|
|
95
|
+
return "deepseek"
|
|
96
|
+
if "dashscope" in base or "qwen" in name:
|
|
97
|
+
return "qwen"
|
|
98
|
+
if "openrouter.ai" in base:
|
|
99
|
+
return "openrouter"
|
|
100
|
+
if "generativelanguage.googleapis.com" in base or name.startswith("gemini"):
|
|
101
|
+
return "gemini_openai"
|
|
102
|
+
if "openai" in base:
|
|
103
|
+
return "openai"
|
|
104
|
+
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
68
108
|
def _resolve_tool(
|
|
69
109
|
tool_registry: "ToolRegistry", tool_name: str, tool_use_id: str
|
|
70
110
|
) -> tuple[Optional[Tool[Any, Any]], Optional[UserMessage]]:
|
|
@@ -95,7 +135,7 @@ async def _check_tool_permissions(
|
|
|
95
135
|
parsed_input: Any,
|
|
96
136
|
query_context: "QueryContext",
|
|
97
137
|
can_use_tool_fn: Optional[ToolPermissionCallable],
|
|
98
|
-
) -> tuple[bool, Optional[str]]:
|
|
138
|
+
) -> tuple[bool, Optional[str], Optional[Any]]:
|
|
99
139
|
"""Evaluate whether a tool call is allowed."""
|
|
100
140
|
try:
|
|
101
141
|
if can_use_tool_fn is not None:
|
|
@@ -103,14 +143,16 @@ async def _check_tool_permissions(
|
|
|
103
143
|
if inspect.isawaitable(decision):
|
|
104
144
|
decision = await decision
|
|
105
145
|
if isinstance(decision, PermissionResult):
|
|
106
|
-
return decision.result, decision.message
|
|
146
|
+
return decision.result, decision.message, decision.updated_input
|
|
107
147
|
if isinstance(decision, dict) and "result" in decision:
|
|
108
|
-
return bool(decision.get("result")), decision.get("message")
|
|
148
|
+
return bool(decision.get("result")), decision.get("message"), decision.get(
|
|
149
|
+
"updated_input"
|
|
150
|
+
)
|
|
109
151
|
if isinstance(decision, tuple) and len(decision) == 2:
|
|
110
|
-
return bool(decision[0]), decision[1]
|
|
111
|
-
return bool(decision), None
|
|
152
|
+
return bool(decision[0]), decision[1], None
|
|
153
|
+
return bool(decision), None, None
|
|
112
154
|
|
|
113
|
-
if query_context.
|
|
155
|
+
if not query_context.yolo_mode and tool.needs_permissions(parsed_input):
|
|
114
156
|
loop = asyncio.get_running_loop()
|
|
115
157
|
input_preview = (
|
|
116
158
|
parsed_input.model_dump()
|
|
@@ -119,15 +161,15 @@ async def _check_tool_permissions(
|
|
|
119
161
|
)
|
|
120
162
|
prompt = f"Allow tool '{tool.name}' with input {input_preview}? [y/N]: "
|
|
121
163
|
response = await loop.run_in_executor(None, lambda: input(prompt))
|
|
122
|
-
return response.strip().lower() in ("y", "yes"), None
|
|
164
|
+
return response.strip().lower() in ("y", "yes"), None, None
|
|
123
165
|
|
|
124
|
-
return True, None
|
|
166
|
+
return True, None, None
|
|
125
167
|
except (TypeError, AttributeError, ValueError) as exc:
|
|
126
168
|
logger.warning(
|
|
127
169
|
f"Error checking permissions for tool '{tool.name}': {type(exc).__name__}: {exc}",
|
|
128
170
|
extra={"tool": getattr(tool, "name", None), "error_type": type(exc).__name__},
|
|
129
171
|
)
|
|
130
|
-
return False, None
|
|
172
|
+
return False, None, None
|
|
131
173
|
|
|
132
174
|
|
|
133
175
|
def _format_changed_file_notice(notices: List[ChangedFileNotice]) -> str:
|
|
@@ -146,6 +188,18 @@ def _format_changed_file_notice(notices: List[ChangedFileNotice]) -> str:
|
|
|
146
188
|
return "\n".join(lines)
|
|
147
189
|
|
|
148
190
|
|
|
191
|
+
def _append_hook_context(context: Dict[str, str], label: str, payload: Optional[str]) -> None:
|
|
192
|
+
"""Append hook-supplied context to the shared context dict."""
|
|
193
|
+
if not payload:
|
|
194
|
+
return
|
|
195
|
+
key = f"Hook:{label}"
|
|
196
|
+
existing = context.get(key)
|
|
197
|
+
if existing:
|
|
198
|
+
context[key] = f"{existing}\n{payload}"
|
|
199
|
+
else:
|
|
200
|
+
context[key] = payload
|
|
201
|
+
|
|
202
|
+
|
|
149
203
|
async def _run_tool_use_generator(
|
|
150
204
|
tool: Tool[Any, Any],
|
|
151
205
|
tool_use_id: str,
|
|
@@ -153,13 +207,16 @@ async def _run_tool_use_generator(
|
|
|
153
207
|
parsed_input: Any,
|
|
154
208
|
sibling_ids: set[str],
|
|
155
209
|
tool_context: ToolUseContext,
|
|
210
|
+
context: Dict[str, str],
|
|
156
211
|
) -> AsyncGenerator[Union[UserMessage, ProgressMessage], None]:
|
|
157
212
|
"""Execute a single tool_use and yield progress/results."""
|
|
158
213
|
# Get tool input as dict for hooks
|
|
159
214
|
tool_input_dict = (
|
|
160
215
|
parsed_input.model_dump()
|
|
161
216
|
if hasattr(parsed_input, "model_dump")
|
|
162
|
-
else dict(parsed_input)
|
|
217
|
+
else dict(parsed_input)
|
|
218
|
+
if isinstance(parsed_input, dict)
|
|
219
|
+
else {}
|
|
163
220
|
)
|
|
164
221
|
|
|
165
222
|
# Run PreToolUse hooks
|
|
@@ -197,9 +254,11 @@ async def _run_tool_use_generator(
|
|
|
197
254
|
f"[query] PreToolUse hook added context for {tool_name}",
|
|
198
255
|
extra={"context": pre_result.additional_context[:100]},
|
|
199
256
|
)
|
|
257
|
+
_append_hook_context(context, f"PreToolUse:{tool_name}", pre_result.additional_context)
|
|
258
|
+
if pre_result.system_message:
|
|
259
|
+
_append_hook_context(context, f"PreToolUse:{tool_name}:system", pre_result.system_message)
|
|
200
260
|
|
|
201
261
|
tool_output = None
|
|
202
|
-
tool_error = None
|
|
203
262
|
|
|
204
263
|
try:
|
|
205
264
|
async for output in tool.call(parsed_input, tool_context):
|
|
@@ -224,18 +283,28 @@ async def _run_tool_use_generator(
|
|
|
224
283
|
except CancelledError:
|
|
225
284
|
raise # Don't suppress task cancellation
|
|
226
285
|
except (RuntimeError, ValueError, TypeError, OSError, IOError, AttributeError, KeyError) as exc:
|
|
227
|
-
tool_error = str(exc)
|
|
228
286
|
logger.warning(
|
|
229
287
|
"Error executing tool '%s': %s: %s",
|
|
230
|
-
tool_name,
|
|
288
|
+
tool_name,
|
|
289
|
+
type(exc).__name__,
|
|
290
|
+
exc,
|
|
231
291
|
extra={"tool": tool_name, "tool_use_id": tool_use_id},
|
|
232
292
|
)
|
|
233
293
|
yield tool_result_message(tool_use_id, f"Error executing tool: {str(exc)}", is_error=True)
|
|
234
294
|
|
|
235
295
|
# Run PostToolUse hooks
|
|
236
|
-
await hook_manager.run_post_tool_use_async(
|
|
296
|
+
post_result = await hook_manager.run_post_tool_use_async(
|
|
237
297
|
tool_name, tool_input_dict, tool_response=tool_output, tool_use_id=tool_use_id
|
|
238
298
|
)
|
|
299
|
+
if post_result.additional_context:
|
|
300
|
+
_append_hook_context(context, f"PostToolUse:{tool_name}", post_result.additional_context)
|
|
301
|
+
if post_result.system_message:
|
|
302
|
+
_append_hook_context(
|
|
303
|
+
context, f"PostToolUse:{tool_name}:system", post_result.system_message
|
|
304
|
+
)
|
|
305
|
+
if post_result.should_block:
|
|
306
|
+
reason = post_result.block_reason or post_result.stop_reason or "Blocked by hook."
|
|
307
|
+
yield create_user_message(f"PostToolUse hook blocked: {reason}")
|
|
239
308
|
|
|
240
309
|
|
|
241
310
|
def _group_tool_calls_by_concurrency(prepared_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
@@ -307,6 +376,7 @@ async def _run_concurrent_tool_uses(
|
|
|
307
376
|
"""Drain multiple tool generators concurrently and stream outputs."""
|
|
308
377
|
if not generators:
|
|
309
378
|
return
|
|
379
|
+
yield # Make this a proper async generator that yields nothing
|
|
310
380
|
|
|
311
381
|
queue: asyncio.Queue[Optional[Union[UserMessage, ProgressMessage]]] = asyncio.Queue()
|
|
312
382
|
|
|
@@ -321,7 +391,8 @@ async def _run_concurrent_tool_uses(
|
|
|
321
391
|
except (RuntimeError, ValueError, TypeError) as exc:
|
|
322
392
|
logger.warning(
|
|
323
393
|
"[query] Error while consuming tool generator: %s: %s",
|
|
324
|
-
type(exc).__name__,
|
|
394
|
+
type(exc).__name__,
|
|
395
|
+
exc,
|
|
325
396
|
)
|
|
326
397
|
finally:
|
|
327
398
|
await queue.put(None)
|
|
@@ -374,7 +445,8 @@ class ToolRegistry:
|
|
|
374
445
|
except (TypeError, AttributeError) as exc:
|
|
375
446
|
logger.warning(
|
|
376
447
|
"[tool_registry] Tool.defer_loading failed: %s: %s",
|
|
377
|
-
type(exc).__name__,
|
|
448
|
+
type(exc).__name__,
|
|
449
|
+
exc,
|
|
378
450
|
extra={"tool": getattr(tool, "name", None)},
|
|
379
451
|
)
|
|
380
452
|
deferred = False
|
|
@@ -461,7 +533,8 @@ def _apply_skill_context_updates(
|
|
|
461
533
|
except (KeyError, ValueError, TypeError) as exc:
|
|
462
534
|
logger.warning(
|
|
463
535
|
"[query] Failed to activate tools listed in skill output: %s: %s",
|
|
464
|
-
type(exc).__name__,
|
|
536
|
+
type(exc).__name__,
|
|
537
|
+
exc,
|
|
465
538
|
)
|
|
466
539
|
|
|
467
540
|
model_hint = data.get("model")
|
|
@@ -487,25 +560,43 @@ def _apply_skill_context_updates(
|
|
|
487
560
|
class QueryContext:
|
|
488
561
|
"""Context for a query session."""
|
|
489
562
|
|
|
563
|
+
# Thresholds for memory warnings
|
|
564
|
+
MESSAGE_COUNT_WARNING_THRESHOLD = int(
|
|
565
|
+
os.getenv("RIPPERDOC_MESSAGE_WARNING_THRESHOLD", "500")
|
|
566
|
+
)
|
|
567
|
+
MESSAGE_COUNT_CRITICAL_THRESHOLD = int(
|
|
568
|
+
os.getenv("RIPPERDOC_MESSAGE_CRITICAL_THRESHOLD", "1000")
|
|
569
|
+
)
|
|
570
|
+
|
|
490
571
|
def __init__(
|
|
491
572
|
self,
|
|
492
573
|
tools: List[Tool[Any, Any]],
|
|
493
574
|
max_thinking_tokens: int = 0,
|
|
494
|
-
|
|
575
|
+
yolo_mode: bool = False,
|
|
495
576
|
model: str = "main",
|
|
496
577
|
verbose: bool = False,
|
|
497
578
|
pause_ui: Optional[Callable[[], None]] = None,
|
|
498
579
|
resume_ui: Optional[Callable[[], None]] = None,
|
|
580
|
+
stop_hook: str = "stop",
|
|
581
|
+
file_cache_max_entries: int = 500,
|
|
582
|
+
file_cache_max_memory_mb: float = 50.0,
|
|
499
583
|
) -> None:
|
|
500
584
|
self.tool_registry = ToolRegistry(tools)
|
|
501
585
|
self.max_thinking_tokens = max_thinking_tokens
|
|
502
|
-
self.
|
|
586
|
+
self.yolo_mode = yolo_mode
|
|
503
587
|
self.model = model
|
|
504
588
|
self.verbose = verbose
|
|
505
589
|
self.abort_controller = asyncio.Event()
|
|
506
|
-
|
|
590
|
+
# Use BoundedFileCache instead of plain Dict to prevent unbounded growth
|
|
591
|
+
self.file_state_cache: BoundedFileCache = BoundedFileCache(
|
|
592
|
+
max_entries=file_cache_max_entries,
|
|
593
|
+
max_memory_mb=file_cache_max_memory_mb,
|
|
594
|
+
)
|
|
507
595
|
self.pause_ui = pause_ui
|
|
508
596
|
self.resume_ui = resume_ui
|
|
597
|
+
self.stop_hook = stop_hook
|
|
598
|
+
self.stop_hook_active = False
|
|
599
|
+
self._last_message_warning_count = 0
|
|
509
600
|
|
|
510
601
|
@property
|
|
511
602
|
def tools(self) -> List[Tool[Any, Any]]:
|
|
@@ -525,6 +616,44 @@ class QueryContext:
|
|
|
525
616
|
"""Return all known tools (active + deferred)."""
|
|
526
617
|
return self.tool_registry.all_tools
|
|
527
618
|
|
|
619
|
+
def check_message_count(self, message_count: int) -> None:
|
|
620
|
+
"""Check message count and log warnings if thresholds are exceeded.
|
|
621
|
+
|
|
622
|
+
This helps detect potential memory issues in long sessions.
|
|
623
|
+
"""
|
|
624
|
+
if message_count >= self.MESSAGE_COUNT_CRITICAL_THRESHOLD:
|
|
625
|
+
if self._last_message_warning_count < self.MESSAGE_COUNT_CRITICAL_THRESHOLD:
|
|
626
|
+
logger.warning(
|
|
627
|
+
"[query] Critical: Message history is very large. "
|
|
628
|
+
"Consider compacting or starting a new session.",
|
|
629
|
+
extra={
|
|
630
|
+
"message_count": message_count,
|
|
631
|
+
"threshold": self.MESSAGE_COUNT_CRITICAL_THRESHOLD,
|
|
632
|
+
"file_cache_stats": self.file_state_cache.stats(),
|
|
633
|
+
},
|
|
634
|
+
)
|
|
635
|
+
self._last_message_warning_count = message_count
|
|
636
|
+
elif message_count >= self.MESSAGE_COUNT_WARNING_THRESHOLD:
|
|
637
|
+
# Only warn once per threshold crossing
|
|
638
|
+
if self._last_message_warning_count < self.MESSAGE_COUNT_WARNING_THRESHOLD:
|
|
639
|
+
logger.info(
|
|
640
|
+
"[query] Message history growing large; automatic compaction may trigger soon",
|
|
641
|
+
extra={
|
|
642
|
+
"message_count": message_count,
|
|
643
|
+
"threshold": self.MESSAGE_COUNT_WARNING_THRESHOLD,
|
|
644
|
+
"file_cache_stats": self.file_state_cache.stats(),
|
|
645
|
+
},
|
|
646
|
+
)
|
|
647
|
+
self._last_message_warning_count = message_count
|
|
648
|
+
|
|
649
|
+
def get_memory_stats(self) -> Dict[str, Any]:
|
|
650
|
+
"""Return memory usage statistics for monitoring."""
|
|
651
|
+
return {
|
|
652
|
+
"file_cache": self.file_state_cache.stats(),
|
|
653
|
+
"tool_count": len(self.tool_registry.all_tools),
|
|
654
|
+
"active_tool_count": len(self.tool_registry.active_tools),
|
|
655
|
+
}
|
|
656
|
+
|
|
528
657
|
|
|
529
658
|
async def query_llm(
|
|
530
659
|
messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
|
|
@@ -557,7 +686,6 @@ async def query_llm(
|
|
|
557
686
|
AssistantMessage with the model's response
|
|
558
687
|
"""
|
|
559
688
|
request_timeout = request_timeout or DEFAULT_REQUEST_TIMEOUT_SEC
|
|
560
|
-
request_timeout = request_timeout or DEFAULT_REQUEST_TIMEOUT_SEC
|
|
561
689
|
model_profile = resolve_model_profile(model)
|
|
562
690
|
|
|
563
691
|
# Normalize messages based on protocol family (Anthropic allows tool blocks; OpenAI-style prefers text-only)
|
|
@@ -572,8 +700,22 @@ async def query_llm(
|
|
|
572
700
|
else:
|
|
573
701
|
messages_for_model = messages
|
|
574
702
|
|
|
703
|
+
# Get thinking_mode for provider-specific handling
|
|
704
|
+
# Apply when thinking is enabled (max_thinking_tokens > 0) OR when using a
|
|
705
|
+
# reasoning model like deepseek-reasoner which has thinking enabled by default
|
|
706
|
+
thinking_mode: Optional[str] = None
|
|
707
|
+
if protocol == "openai":
|
|
708
|
+
model_name = (model_profile.model or "").lower()
|
|
709
|
+
# DeepSeek Reasoner models have thinking enabled by default
|
|
710
|
+
is_reasoning_model = "reasoner" in model_name or "r1" in model_name
|
|
711
|
+
if max_thinking_tokens > 0 or is_reasoning_model:
|
|
712
|
+
thinking_mode = infer_thinking_mode(model_profile)
|
|
713
|
+
|
|
575
714
|
normalized_messages: List[Dict[str, Any]] = normalize_messages_for_api(
|
|
576
|
-
messages_for_model,
|
|
715
|
+
messages_for_model,
|
|
716
|
+
protocol=protocol,
|
|
717
|
+
tool_mode=tool_mode,
|
|
718
|
+
thinking_mode=thinking_mode,
|
|
577
719
|
)
|
|
578
720
|
logger.info(
|
|
579
721
|
"[query_llm] Preparing model request",
|
|
@@ -584,6 +726,7 @@ async def query_llm(
|
|
|
584
726
|
"normalized_messages": len(normalized_messages),
|
|
585
727
|
"tool_count": len(tools),
|
|
586
728
|
"max_thinking_tokens": max_thinking_tokens,
|
|
729
|
+
"thinking_mode": thinking_mode,
|
|
587
730
|
"tool_mode": tool_mode,
|
|
588
731
|
},
|
|
589
732
|
)
|
|
@@ -601,13 +744,25 @@ async def query_llm(
|
|
|
601
744
|
start_time = time.time()
|
|
602
745
|
|
|
603
746
|
try:
|
|
604
|
-
|
|
747
|
+
try:
|
|
748
|
+
client: Optional[ProviderClient] = get_provider_client(model_profile.provider)
|
|
749
|
+
except RuntimeError as exc:
|
|
750
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
751
|
+
error_msg = create_assistant_message(
|
|
752
|
+
content=str(exc),
|
|
753
|
+
duration_ms=duration_ms,
|
|
754
|
+
)
|
|
755
|
+
error_msg.is_api_error_message = True
|
|
756
|
+
return error_msg
|
|
605
757
|
if client is None:
|
|
606
758
|
duration_ms = (time.time() - start_time) * 1000
|
|
759
|
+
provider_label = getattr(model_profile.provider, "value", None) or str(
|
|
760
|
+
model_profile.provider
|
|
761
|
+
)
|
|
607
762
|
error_msg = create_assistant_message(
|
|
608
763
|
content=(
|
|
609
|
-
"
|
|
610
|
-
"
|
|
764
|
+
f"No provider client available for '{provider_label}'. "
|
|
765
|
+
"Check your model configuration and provider dependencies."
|
|
611
766
|
),
|
|
612
767
|
duration_ms=duration_ms,
|
|
613
768
|
)
|
|
@@ -659,6 +814,13 @@ async def query_llm(
|
|
|
659
814
|
cost_usd=provider_response.cost_usd,
|
|
660
815
|
duration_ms=provider_response.duration_ms,
|
|
661
816
|
metadata=provider_response.metadata,
|
|
817
|
+
model=model_profile.model,
|
|
818
|
+
input_tokens=provider_response.usage_tokens.get("input_tokens", 0),
|
|
819
|
+
output_tokens=provider_response.usage_tokens.get("output_tokens", 0),
|
|
820
|
+
cache_read_tokens=provider_response.usage_tokens.get("cache_read_input_tokens", 0),
|
|
821
|
+
cache_creation_tokens=provider_response.usage_tokens.get(
|
|
822
|
+
"cache_creation_input_tokens", 0
|
|
823
|
+
),
|
|
662
824
|
)
|
|
663
825
|
|
|
664
826
|
except CancelledError:
|
|
@@ -667,7 +829,8 @@ async def query_llm(
|
|
|
667
829
|
# Return error message
|
|
668
830
|
logger.warning(
|
|
669
831
|
"Error querying AI model: %s: %s",
|
|
670
|
-
type(e).__name__,
|
|
832
|
+
type(e).__name__,
|
|
833
|
+
e,
|
|
671
834
|
extra={
|
|
672
835
|
"model": getattr(model_profile, "model", None),
|
|
673
836
|
"model_pointer": model,
|
|
@@ -758,9 +921,7 @@ async def _run_query_iteration(
|
|
|
758
921
|
|
|
759
922
|
model_profile = resolve_model_profile(query_context.model)
|
|
760
923
|
tool_mode = determine_tool_mode(model_profile)
|
|
761
|
-
tools_for_model: List[Tool[Any, Any]] = (
|
|
762
|
-
[] if tool_mode == "text" else query_context.all_tools()
|
|
763
|
-
)
|
|
924
|
+
tools_for_model: List[Tool[Any, Any]] = [] if tool_mode == "text" else query_context.all_tools()
|
|
764
925
|
|
|
765
926
|
full_system_prompt = build_full_system_prompt(
|
|
766
927
|
system_prompt, context, tool_mode, query_context.all_tools()
|
|
@@ -775,7 +936,7 @@ async def _run_query_iteration(
|
|
|
775
936
|
)
|
|
776
937
|
|
|
777
938
|
# Stream LLM response
|
|
778
|
-
progress_queue: asyncio.Queue[Optional[ProgressMessage]] = asyncio.Queue()
|
|
939
|
+
progress_queue: asyncio.Queue[Optional[ProgressMessage]] = asyncio.Queue(maxsize=1000)
|
|
779
940
|
|
|
780
941
|
async def _stream_progress(chunk: str) -> None:
|
|
781
942
|
if not chunk:
|
|
@@ -828,23 +989,23 @@ async def _run_query_iteration(
|
|
|
828
989
|
progress = progress_queue.get_nowait()
|
|
829
990
|
except asyncio.QueueEmpty:
|
|
830
991
|
waiter = asyncio.create_task(progress_queue.get())
|
|
831
|
-
|
|
992
|
+
abort_waiter = asyncio.create_task(query_context.abort_controller.wait())
|
|
832
993
|
done, pending = await asyncio.wait(
|
|
833
|
-
{assistant_task, waiter},
|
|
994
|
+
{assistant_task, waiter, abort_waiter},
|
|
834
995
|
return_when=asyncio.FIRST_COMPLETED,
|
|
835
|
-
timeout=0.1 # Check abort_controller every 100ms
|
|
836
996
|
)
|
|
837
|
-
|
|
838
|
-
#
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
997
|
+
for task in pending:
|
|
998
|
+
# Don't cancel assistant_task here - it should only be cancelled
|
|
999
|
+
# through abort_controller in the main loop
|
|
1000
|
+
if task is not assistant_task:
|
|
1001
|
+
task.cancel()
|
|
1002
|
+
try:
|
|
1003
|
+
await task
|
|
1004
|
+
except asyncio.CancelledError:
|
|
1005
|
+
pass
|
|
1006
|
+
if abort_waiter in done:
|
|
844
1007
|
continue
|
|
845
1008
|
if assistant_task in done:
|
|
846
|
-
for task in pending:
|
|
847
|
-
task.cancel()
|
|
848
1009
|
assistant_message = await assistant_task
|
|
849
1010
|
break
|
|
850
1011
|
progress = waiter.result()
|
|
@@ -857,7 +1018,8 @@ async def _run_query_iteration(
|
|
|
857
1018
|
if residual:
|
|
858
1019
|
yield residual
|
|
859
1020
|
|
|
860
|
-
|
|
1021
|
+
if assistant_message is None:
|
|
1022
|
+
raise RuntimeError("assistant_message was unexpectedly None after LLM query")
|
|
861
1023
|
result.assistant_message = assistant_message
|
|
862
1024
|
|
|
863
1025
|
# Check for abort
|
|
@@ -882,6 +1044,27 @@ async def _run_query_iteration(
|
|
|
882
1044
|
|
|
883
1045
|
if not tool_use_blocks:
|
|
884
1046
|
logger.debug("[query] No tool_use blocks; returning response to user.")
|
|
1047
|
+
stop_hook = query_context.stop_hook
|
|
1048
|
+
stop_result = (
|
|
1049
|
+
await hook_manager.run_subagent_stop_async(
|
|
1050
|
+
stop_hook_active=query_context.stop_hook_active
|
|
1051
|
+
)
|
|
1052
|
+
if stop_hook == "subagent"
|
|
1053
|
+
else await hook_manager.run_stop_async(stop_hook_active=query_context.stop_hook_active)
|
|
1054
|
+
)
|
|
1055
|
+
if stop_result.additional_context:
|
|
1056
|
+
_append_hook_context(context, f"{stop_hook}:context", stop_result.additional_context)
|
|
1057
|
+
if stop_result.system_message:
|
|
1058
|
+
_append_hook_context(context, f"{stop_hook}:system", stop_result.system_message)
|
|
1059
|
+
if stop_result.should_block:
|
|
1060
|
+
reason = stop_result.block_reason or stop_result.stop_reason or "Blocked by hook."
|
|
1061
|
+
result.tool_results = [create_user_message(f"{stop_hook} hook blocked: {reason}")]
|
|
1062
|
+
for msg in result.tool_results:
|
|
1063
|
+
yield msg
|
|
1064
|
+
query_context.stop_hook_active = True
|
|
1065
|
+
result.should_stop = False
|
|
1066
|
+
return
|
|
1067
|
+
query_context.stop_hook_active = False
|
|
885
1068
|
result.should_stop = True
|
|
886
1069
|
return
|
|
887
1070
|
|
|
@@ -890,8 +1073,7 @@ async def _run_query_iteration(
|
|
|
890
1073
|
tool_results: List[UserMessage] = []
|
|
891
1074
|
permission_denied = False
|
|
892
1075
|
sibling_ids = set(
|
|
893
|
-
getattr(t, "tool_use_id", None) or getattr(t, "id", None) or ""
|
|
894
|
-
for t in tool_use_blocks
|
|
1076
|
+
getattr(t, "tool_use_id", None) or getattr(t, "id", None) or "" for t in tool_use_blocks
|
|
895
1077
|
)
|
|
896
1078
|
prepared_calls: List[Dict[str, Any]] = []
|
|
897
1079
|
|
|
@@ -899,22 +1081,17 @@ async def _run_query_iteration(
|
|
|
899
1081
|
tool_name = tool_use.name
|
|
900
1082
|
if not tool_name:
|
|
901
1083
|
continue
|
|
902
|
-
tool_use_id = (
|
|
903
|
-
getattr(tool_use, "tool_use_id", None) or getattr(tool_use, "id", None) or ""
|
|
904
|
-
)
|
|
1084
|
+
tool_use_id = getattr(tool_use, "tool_use_id", None) or getattr(tool_use, "id", None) or ""
|
|
905
1085
|
tool_input = getattr(tool_use, "input", {}) or {}
|
|
906
1086
|
|
|
907
|
-
tool, missing_msg = _resolve_tool(
|
|
908
|
-
query_context.tool_registry, tool_name, tool_use_id
|
|
909
|
-
)
|
|
1087
|
+
tool, missing_msg = _resolve_tool(query_context.tool_registry, tool_name, tool_use_id)
|
|
910
1088
|
if missing_msg:
|
|
911
|
-
logger.warning(
|
|
912
|
-
f"[query] Tool '{tool_name}' not found for tool_use_id={tool_use_id}"
|
|
913
|
-
)
|
|
1089
|
+
logger.warning(f"[query] Tool '{tool_name}' not found for tool_use_id={tool_use_id}")
|
|
914
1090
|
tool_results.append(missing_msg)
|
|
915
1091
|
yield missing_msg
|
|
916
1092
|
continue
|
|
917
|
-
|
|
1093
|
+
if tool is None:
|
|
1094
|
+
raise RuntimeError(f"Tool '{tool_name}' resolved to None unexpectedly")
|
|
918
1095
|
|
|
919
1096
|
try:
|
|
920
1097
|
parsed_input = tool.input_schema(**tool_input)
|
|
@@ -924,11 +1101,12 @@ async def _run_query_iteration(
|
|
|
924
1101
|
)
|
|
925
1102
|
|
|
926
1103
|
tool_context = ToolUseContext(
|
|
927
|
-
|
|
1104
|
+
yolo_mode=query_context.yolo_mode,
|
|
928
1105
|
verbose=query_context.verbose,
|
|
929
1106
|
permission_checker=can_use_tool_fn,
|
|
930
1107
|
tool_registry=query_context.tool_registry,
|
|
931
1108
|
file_state_cache=query_context.file_state_cache,
|
|
1109
|
+
conversation_messages=messages,
|
|
932
1110
|
abort_signal=query_context.abort_controller,
|
|
933
1111
|
pause_ui=query_context.pause_ui,
|
|
934
1112
|
resume_ui=query_context.resume_ui,
|
|
@@ -937,8 +1115,7 @@ async def _run_query_iteration(
|
|
|
937
1115
|
validation = await tool.validate_input(parsed_input, tool_context)
|
|
938
1116
|
if not validation.result:
|
|
939
1117
|
logger.debug(
|
|
940
|
-
f"[query] Validation failed for tool_use_id={tool_use_id}: "
|
|
941
|
-
f"{validation.message}"
|
|
1118
|
+
f"[query] Validation failed for tool_use_id={tool_use_id}: {validation.message}"
|
|
942
1119
|
)
|
|
943
1120
|
result_msg = tool_result_message(
|
|
944
1121
|
tool_use_id,
|
|
@@ -949,23 +1126,43 @@ async def _run_query_iteration(
|
|
|
949
1126
|
yield result_msg
|
|
950
1127
|
continue
|
|
951
1128
|
|
|
952
|
-
if query_context.
|
|
953
|
-
allowed, denial_message = await _check_tool_permissions(
|
|
1129
|
+
if not query_context.yolo_mode or can_use_tool_fn is not None:
|
|
1130
|
+
allowed, denial_message, updated_input = await _check_tool_permissions(
|
|
954
1131
|
tool, parsed_input, query_context, can_use_tool_fn
|
|
955
1132
|
)
|
|
956
1133
|
if not allowed:
|
|
957
1134
|
logger.debug(
|
|
958
|
-
f"[query] Permission denied for tool_use_id={tool_use_id}: "
|
|
959
|
-
f"{denial_message}"
|
|
960
|
-
)
|
|
961
|
-
denial_text = (
|
|
962
|
-
denial_message or f"User aborted the tool invocation: {tool_name}"
|
|
1135
|
+
f"[query] Permission denied for tool_use_id={tool_use_id}: {denial_message}"
|
|
963
1136
|
)
|
|
1137
|
+
denial_text = denial_message or f"User aborted the tool invocation: {tool_name}"
|
|
964
1138
|
denial_msg = tool_result_message(tool_use_id, denial_text, is_error=True)
|
|
965
1139
|
tool_results.append(denial_msg)
|
|
966
1140
|
yield denial_msg
|
|
967
1141
|
permission_denied = True
|
|
968
1142
|
break
|
|
1143
|
+
if updated_input:
|
|
1144
|
+
try:
|
|
1145
|
+
parsed_input = tool.input_schema(**updated_input)
|
|
1146
|
+
except ValidationError as ve:
|
|
1147
|
+
detail_text = format_pydantic_errors(ve)
|
|
1148
|
+
error_msg = tool_result_message(
|
|
1149
|
+
tool_use_id,
|
|
1150
|
+
f"Invalid permission-updated input for tool '{tool_name}': {detail_text}",
|
|
1151
|
+
is_error=True,
|
|
1152
|
+
)
|
|
1153
|
+
tool_results.append(error_msg)
|
|
1154
|
+
yield error_msg
|
|
1155
|
+
continue
|
|
1156
|
+
validation = await tool.validate_input(parsed_input, tool_context)
|
|
1157
|
+
if not validation.result:
|
|
1158
|
+
error_msg = tool_result_message(
|
|
1159
|
+
tool_use_id,
|
|
1160
|
+
validation.message or "Tool input validation failed.",
|
|
1161
|
+
is_error=True,
|
|
1162
|
+
)
|
|
1163
|
+
tool_results.append(error_msg)
|
|
1164
|
+
yield error_msg
|
|
1165
|
+
continue
|
|
969
1166
|
|
|
970
1167
|
prepared_calls.append(
|
|
971
1168
|
{
|
|
@@ -977,6 +1174,7 @@ async def _run_query_iteration(
|
|
|
977
1174
|
parsed_input,
|
|
978
1175
|
sibling_ids,
|
|
979
1176
|
tool_context,
|
|
1177
|
+
context,
|
|
980
1178
|
),
|
|
981
1179
|
}
|
|
982
1180
|
)
|
|
@@ -1070,7 +1268,7 @@ async def query(
|
|
|
1070
1268
|
extra={
|
|
1071
1269
|
"message_count": len(messages),
|
|
1072
1270
|
"tool_count": len(query_context.tools),
|
|
1073
|
-
"
|
|
1271
|
+
"yolo_mode": query_context.yolo_mode,
|
|
1074
1272
|
"model_pointer": query_context.model,
|
|
1075
1273
|
},
|
|
1076
1274
|
)
|
|
@@ -1078,6 +1276,9 @@ async def query(
|
|
|
1078
1276
|
# do not interfere with the loop or normalization.
|
|
1079
1277
|
messages = list(messages)
|
|
1080
1278
|
|
|
1279
|
+
# Check initial message count for memory warnings
|
|
1280
|
+
query_context.check_message_count(len(messages))
|
|
1281
|
+
|
|
1081
1282
|
for iteration in range(1, MAX_QUERY_ITERATIONS + 1):
|
|
1082
1283
|
result = IterationResult()
|
|
1083
1284
|
|
|
@@ -1100,6 +1301,10 @@ async def query(
|
|
|
1100
1301
|
messages = messages + [result.assistant_message] + result.tool_results # type: ignore[operator]
|
|
1101
1302
|
else:
|
|
1102
1303
|
messages = messages + result.tool_results # type: ignore[operator]
|
|
1304
|
+
|
|
1305
|
+
# Check message count after each iteration for memory warnings
|
|
1306
|
+
query_context.check_message_count(len(messages))
|
|
1307
|
+
|
|
1103
1308
|
logger.debug(
|
|
1104
1309
|
f"[query] Continuing loop with {len(messages)} messages after tools; "
|
|
1105
1310
|
f"tool_results_count={len(result.tool_results)}"
|
ripperdoc/core/query_utils.py
CHANGED
|
@@ -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
|
|