ripperdoc 0.2.4__py3-none-any.whl → 0.2.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ripperdoc/__init__.py +1 -1
- ripperdoc/__main__.py +0 -5
- ripperdoc/cli/cli.py +37 -16
- ripperdoc/cli/commands/__init__.py +2 -0
- ripperdoc/cli/commands/agents_cmd.py +12 -9
- ripperdoc/cli/commands/compact_cmd.py +7 -3
- ripperdoc/cli/commands/context_cmd.py +33 -13
- ripperdoc/cli/commands/doctor_cmd.py +27 -14
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/mcp_cmd.py +13 -8
- ripperdoc/cli/commands/memory_cmd.py +5 -5
- ripperdoc/cli/commands/models_cmd.py +47 -16
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +1 -2
- ripperdoc/cli/commands/tasks_cmd.py +24 -13
- ripperdoc/cli/ui/rich_ui.py +500 -406
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/agents.py +17 -9
- ripperdoc/core/config.py +130 -6
- ripperdoc/core/default_tools.py +7 -2
- ripperdoc/core/permissions.py +20 -14
- ripperdoc/core/providers/anthropic.py +107 -4
- ripperdoc/core/providers/base.py +33 -4
- ripperdoc/core/providers/gemini.py +169 -50
- ripperdoc/core/providers/openai.py +257 -23
- ripperdoc/core/query.py +294 -61
- ripperdoc/core/query_utils.py +50 -6
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +13 -7
- ripperdoc/core/tool.py +8 -6
- ripperdoc/sdk/client.py +14 -1
- ripperdoc/tools/ask_user_question_tool.py +20 -22
- ripperdoc/tools/background_shell.py +19 -13
- ripperdoc/tools/bash_tool.py +356 -209
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +5 -2
- ripperdoc/tools/exit_plan_mode_tool.py +6 -3
- ripperdoc/tools/file_edit_tool.py +53 -10
- ripperdoc/tools/file_read_tool.py +17 -7
- ripperdoc/tools/file_write_tool.py +49 -13
- ripperdoc/tools/glob_tool.py +10 -9
- ripperdoc/tools/grep_tool.py +182 -51
- ripperdoc/tools/ls_tool.py +6 -6
- ripperdoc/tools/mcp_tools.py +106 -456
- ripperdoc/tools/multi_edit_tool.py +49 -9
- ripperdoc/tools/notebook_edit_tool.py +57 -13
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +7 -8
- ripperdoc/tools/todo_tool.py +12 -12
- ripperdoc/tools/tool_search_tool.py +5 -6
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/file_watch.py +5 -4
- ripperdoc/utils/json_utils.py +4 -4
- ripperdoc/utils/log.py +3 -3
- ripperdoc/utils/mcp.py +36 -15
- ripperdoc/utils/memory.py +9 -6
- ripperdoc/utils/message_compaction.py +16 -11
- ripperdoc/utils/messages.py +73 -8
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/permissions/__init__.py +7 -1
- ripperdoc/utils/permissions/path_validation_utils.py +5 -3
- ripperdoc/utils/permissions/shell_command_validation.py +496 -18
- ripperdoc/utils/prompt.py +1 -1
- ripperdoc/utils/safe_get_cwd.py +5 -2
- ripperdoc/utils/session_history.py +38 -19
- ripperdoc/utils/todo.py +6 -2
- ripperdoc/utils/token_estimation.py +4 -3
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
- ripperdoc-0.2.5.dist-info/RECORD +107 -0
- ripperdoc-0.2.4.dist-info/RECORD +0 -99
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
ripperdoc/core/query.py
CHANGED
|
@@ -9,6 +9,7 @@ import inspect
|
|
|
9
9
|
import os
|
|
10
10
|
import time
|
|
11
11
|
from asyncio import CancelledError
|
|
12
|
+
from dataclasses import dataclass, field
|
|
12
13
|
from typing import (
|
|
13
14
|
Any,
|
|
14
15
|
AsyncGenerator,
|
|
@@ -39,6 +40,8 @@ from ripperdoc.core.query_utils import (
|
|
|
39
40
|
tool_result_message,
|
|
40
41
|
)
|
|
41
42
|
from ripperdoc.core.tool import Tool, ToolProgress, ToolResult, ToolUseContext
|
|
43
|
+
from ripperdoc.utils.coerce import parse_optional_int
|
|
44
|
+
from ripperdoc.utils.context_length_errors import detect_context_length_error
|
|
42
45
|
from ripperdoc.utils.file_watch import ChangedFileNotice, FileSnapshot, detect_changed_files
|
|
43
46
|
from ripperdoc.utils.log import get_logger
|
|
44
47
|
from ripperdoc.utils.messages import (
|
|
@@ -118,10 +121,10 @@ async def _check_tool_permissions(
|
|
|
118
121
|
return response.strip().lower() in ("y", "yes"), None
|
|
119
122
|
|
|
120
123
|
return True, None
|
|
121
|
-
except
|
|
122
|
-
logger.
|
|
123
|
-
f"Error checking permissions for tool '{tool.name}'",
|
|
124
|
-
extra={"tool": getattr(tool, "name", None)},
|
|
124
|
+
except (TypeError, AttributeError, ValueError) as exc:
|
|
125
|
+
logger.warning(
|
|
126
|
+
f"Error checking permissions for tool '{tool.name}': {type(exc).__name__}: {exc}",
|
|
127
|
+
extra={"tool": getattr(tool, "name", None), "error_type": type(exc).__name__},
|
|
125
128
|
)
|
|
126
129
|
return False, None
|
|
127
130
|
|
|
@@ -170,9 +173,12 @@ async def _run_tool_use_generator(
|
|
|
170
173
|
f"[query] Tool completed tool_use_id={tool_use_id} name={tool_name} "
|
|
171
174
|
f"result_len={len(result_content)}"
|
|
172
175
|
)
|
|
173
|
-
except
|
|
174
|
-
|
|
175
|
-
|
|
176
|
+
except CancelledError:
|
|
177
|
+
raise # Don't suppress task cancellation
|
|
178
|
+
except (RuntimeError, ValueError, TypeError, OSError, IOError, AttributeError, KeyError) as exc:
|
|
179
|
+
logger.warning(
|
|
180
|
+
"Error executing tool '%s': %s: %s",
|
|
181
|
+
tool_name, type(exc).__name__, exc,
|
|
176
182
|
extra={"tool": tool_name, "tool_use_id": tool_use_id},
|
|
177
183
|
)
|
|
178
184
|
yield tool_result_message(tool_use_id, f"Error executing tool: {str(exc)}", is_error=True)
|
|
@@ -254,8 +260,15 @@ async def _run_concurrent_tool_uses(
|
|
|
254
260
|
try:
|
|
255
261
|
async for message in gen:
|
|
256
262
|
await queue.put(message)
|
|
257
|
-
except
|
|
258
|
-
|
|
263
|
+
except asyncio.CancelledError:
|
|
264
|
+
raise # Don't suppress cancellation
|
|
265
|
+
except (StopAsyncIteration, GeneratorExit):
|
|
266
|
+
pass # Normal generator termination
|
|
267
|
+
except (RuntimeError, ValueError, TypeError) as exc:
|
|
268
|
+
logger.warning(
|
|
269
|
+
"[query] Error while consuming tool generator: %s: %s",
|
|
270
|
+
type(exc).__name__, exc,
|
|
271
|
+
)
|
|
259
272
|
finally:
|
|
260
273
|
await queue.put(None)
|
|
261
274
|
|
|
@@ -304,9 +317,10 @@ class ToolRegistry:
|
|
|
304
317
|
self._order.append(name)
|
|
305
318
|
try:
|
|
306
319
|
deferred = tool.defer_loading()
|
|
307
|
-
except
|
|
308
|
-
logger.
|
|
309
|
-
"[tool_registry] Tool.defer_loading failed",
|
|
320
|
+
except (TypeError, AttributeError) as exc:
|
|
321
|
+
logger.warning(
|
|
322
|
+
"[tool_registry] Tool.defer_loading failed: %s: %s",
|
|
323
|
+
type(exc).__name__, exc,
|
|
310
324
|
extra={"tool": getattr(tool, "name", None)},
|
|
311
325
|
)
|
|
312
326
|
deferred = False
|
|
@@ -367,6 +381,55 @@ class ToolRegistry:
|
|
|
367
381
|
yield name, tool
|
|
368
382
|
|
|
369
383
|
|
|
384
|
+
def _apply_skill_context_updates(
|
|
385
|
+
tool_results: List[UserMessage], query_context: "QueryContext"
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Update query context based on Skill tool outputs."""
|
|
388
|
+
for message in tool_results:
|
|
389
|
+
data = getattr(message, "tool_use_result", None)
|
|
390
|
+
if not isinstance(data, dict):
|
|
391
|
+
continue
|
|
392
|
+
skill_name = (
|
|
393
|
+
data.get("skill")
|
|
394
|
+
or data.get("command_name")
|
|
395
|
+
or data.get("commandName")
|
|
396
|
+
or data.get("command")
|
|
397
|
+
)
|
|
398
|
+
if not skill_name:
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
allowed_tools = data.get("allowed_tools") or data.get("allowedTools") or []
|
|
402
|
+
if allowed_tools and getattr(query_context, "tool_registry", None):
|
|
403
|
+
try:
|
|
404
|
+
query_context.tool_registry.activate_tools(
|
|
405
|
+
[tool for tool in allowed_tools if isinstance(tool, str) and tool.strip()]
|
|
406
|
+
)
|
|
407
|
+
except (KeyError, ValueError, TypeError) as exc:
|
|
408
|
+
logger.warning(
|
|
409
|
+
"[query] Failed to activate tools listed in skill output: %s: %s",
|
|
410
|
+
type(exc).__name__, exc,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
model_hint = data.get("model")
|
|
414
|
+
if isinstance(model_hint, str) and model_hint.strip():
|
|
415
|
+
logger.debug(
|
|
416
|
+
"[query] Applying model hint from skill",
|
|
417
|
+
extra={"skill": skill_name, "model": model_hint},
|
|
418
|
+
)
|
|
419
|
+
query_context.model = model_hint.strip()
|
|
420
|
+
|
|
421
|
+
max_tokens = data.get("max_thinking_tokens")
|
|
422
|
+
if max_tokens is None:
|
|
423
|
+
max_tokens = data.get("maxThinkingTokens")
|
|
424
|
+
parsed_max = parse_optional_int(max_tokens)
|
|
425
|
+
if parsed_max is not None:
|
|
426
|
+
logger.debug(
|
|
427
|
+
"[query] Applying max thinking tokens from skill",
|
|
428
|
+
extra={"skill": skill_name, "max_thinking_tokens": parsed_max},
|
|
429
|
+
)
|
|
430
|
+
query_context.max_thinking_tokens = parsed_max
|
|
431
|
+
|
|
432
|
+
|
|
370
433
|
class QueryContext:
|
|
371
434
|
"""Context for a query session."""
|
|
372
435
|
|
|
@@ -415,7 +478,7 @@ async def query_llm(
|
|
|
415
478
|
tools: List[Tool[Any, Any]],
|
|
416
479
|
max_thinking_tokens: int = 0,
|
|
417
480
|
model: str = "main",
|
|
418
|
-
|
|
481
|
+
_abort_signal: Optional[asyncio.Event] = None,
|
|
419
482
|
*,
|
|
420
483
|
progress_callback: Optional[Callable[[str], Awaitable[None]]] = None,
|
|
421
484
|
request_timeout: Optional[float] = None,
|
|
@@ -430,7 +493,7 @@ async def query_llm(
|
|
|
430
493
|
tools: Available tools
|
|
431
494
|
max_thinking_tokens: Maximum tokens for thinking (0 = disabled)
|
|
432
495
|
model: Model pointer to use
|
|
433
|
-
|
|
496
|
+
_abort_signal: Event to signal abortion (currently unused, reserved for future)
|
|
434
497
|
progress_callback: Optional async callback invoked with streamed text chunks
|
|
435
498
|
request_timeout: Max seconds to wait for a provider response before retrying
|
|
436
499
|
max_retries: Number of retries on timeout/errors (total attempts = retries + 1)
|
|
@@ -507,18 +570,50 @@ async def query_llm(
|
|
|
507
570
|
progress_callback=progress_callback,
|
|
508
571
|
request_timeout=request_timeout,
|
|
509
572
|
max_retries=max_retries,
|
|
573
|
+
max_thinking_tokens=max_thinking_tokens,
|
|
510
574
|
)
|
|
511
575
|
|
|
576
|
+
# Check if provider returned an error response
|
|
577
|
+
if provider_response.is_error:
|
|
578
|
+
logger.warning(
|
|
579
|
+
"[query_llm] Provider returned error response",
|
|
580
|
+
extra={
|
|
581
|
+
"model": model_profile.model,
|
|
582
|
+
"error_code": provider_response.error_code,
|
|
583
|
+
"error_message": provider_response.error_message,
|
|
584
|
+
},
|
|
585
|
+
)
|
|
586
|
+
metadata: Dict[str, Any] = {
|
|
587
|
+
"api_error": True,
|
|
588
|
+
"error_code": provider_response.error_code,
|
|
589
|
+
"error_message": provider_response.error_message,
|
|
590
|
+
}
|
|
591
|
+
# Add context length info if applicable
|
|
592
|
+
if provider_response.error_code == "context_length_exceeded":
|
|
593
|
+
metadata["context_length_exceeded"] = True
|
|
594
|
+
|
|
595
|
+
error_msg = create_assistant_message(
|
|
596
|
+
content=provider_response.content_blocks,
|
|
597
|
+
duration_ms=provider_response.duration_ms,
|
|
598
|
+
metadata=metadata,
|
|
599
|
+
)
|
|
600
|
+
error_msg.is_api_error_message = True
|
|
601
|
+
return error_msg
|
|
602
|
+
|
|
512
603
|
return create_assistant_message(
|
|
513
604
|
content=provider_response.content_blocks,
|
|
514
605
|
cost_usd=provider_response.cost_usd,
|
|
515
606
|
duration_ms=provider_response.duration_ms,
|
|
607
|
+
metadata=provider_response.metadata,
|
|
516
608
|
)
|
|
517
609
|
|
|
518
|
-
except
|
|
610
|
+
except CancelledError:
|
|
611
|
+
raise # Don't suppress task cancellation
|
|
612
|
+
except (RuntimeError, ValueError, TypeError, OSError, ConnectionError, TimeoutError) as e:
|
|
519
613
|
# Return error message
|
|
520
|
-
logger.
|
|
521
|
-
"Error querying AI model",
|
|
614
|
+
logger.warning(
|
|
615
|
+
"Error querying AI model: %s: %s",
|
|
616
|
+
type(e).__name__, e,
|
|
522
617
|
extra={
|
|
523
618
|
"model": getattr(model_profile, "model", None),
|
|
524
619
|
"model_pointer": model,
|
|
@@ -528,56 +623,90 @@ async def query_llm(
|
|
|
528
623
|
},
|
|
529
624
|
)
|
|
530
625
|
duration_ms = (time.time() - start_time) * 1000
|
|
626
|
+
context_error = detect_context_length_error(e)
|
|
627
|
+
metadata = None
|
|
628
|
+
content = f"Error querying AI model: {str(e)}"
|
|
629
|
+
|
|
630
|
+
if context_error:
|
|
631
|
+
content = f"The request exceeded the model's context window. {context_error.message}"
|
|
632
|
+
metadata = {
|
|
633
|
+
"context_length_exceeded": True,
|
|
634
|
+
"context_length_provider": context_error.provider,
|
|
635
|
+
"context_length_error_code": context_error.error_code,
|
|
636
|
+
"context_length_status_code": context_error.status_code,
|
|
637
|
+
}
|
|
638
|
+
logger.info(
|
|
639
|
+
"[query_llm] Detected context-length error; consider compacting history",
|
|
640
|
+
extra={
|
|
641
|
+
"provider": context_error.provider,
|
|
642
|
+
"error_code": context_error.error_code,
|
|
643
|
+
"status_code": context_error.status_code,
|
|
644
|
+
},
|
|
645
|
+
)
|
|
646
|
+
|
|
531
647
|
error_msg = create_assistant_message(
|
|
532
|
-
content=
|
|
648
|
+
content=content, duration_ms=duration_ms, metadata=metadata
|
|
533
649
|
)
|
|
534
650
|
error_msg.is_api_error_message = True
|
|
535
651
|
return error_msg
|
|
536
652
|
|
|
537
653
|
|
|
538
|
-
|
|
654
|
+
MAX_QUERY_ITERATIONS = int(os.getenv("RIPPERDOC_MAX_QUERY_ITERATIONS", "1024"))
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@dataclass
|
|
658
|
+
class IterationResult:
|
|
659
|
+
"""Result of a single query iteration.
|
|
660
|
+
|
|
661
|
+
This is used as an "out parameter" to communicate results from
|
|
662
|
+
_run_query_iteration back to the main query loop.
|
|
663
|
+
"""
|
|
664
|
+
|
|
665
|
+
assistant_message: Optional[AssistantMessage] = None
|
|
666
|
+
tool_results: List[UserMessage] = field(default_factory=list)
|
|
667
|
+
should_stop: bool = False # True means exit the query loop entirely
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
async def _run_query_iteration(
|
|
539
671
|
messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
|
|
540
672
|
system_prompt: str,
|
|
541
673
|
context: Dict[str, str],
|
|
542
674
|
query_context: QueryContext,
|
|
543
|
-
can_use_tool_fn: Optional[ToolPermissionCallable]
|
|
675
|
+
can_use_tool_fn: Optional[ToolPermissionCallable],
|
|
676
|
+
iteration: int,
|
|
677
|
+
result: IterationResult,
|
|
544
678
|
) -> AsyncGenerator[Union[UserMessage, AssistantMessage, ProgressMessage], None]:
|
|
545
|
-
"""
|
|
679
|
+
"""Run a single iteration of the query loop.
|
|
546
680
|
|
|
547
|
-
This
|
|
548
|
-
1.
|
|
549
|
-
2.
|
|
550
|
-
3.
|
|
551
|
-
4. Recursively continues the conversation
|
|
681
|
+
This function handles one round of:
|
|
682
|
+
1. Calling the LLM
|
|
683
|
+
2. Streaming progress
|
|
684
|
+
3. Processing tool calls (if any)
|
|
552
685
|
|
|
553
686
|
Args:
|
|
554
|
-
messages:
|
|
687
|
+
messages: Current conversation history
|
|
555
688
|
system_prompt: Base system prompt
|
|
556
689
|
context: Additional context dictionary
|
|
557
690
|
query_context: Query configuration
|
|
558
691
|
can_use_tool_fn: Optional function to check tool permissions
|
|
692
|
+
iteration: Current iteration number (for logging)
|
|
693
|
+
result: IterationResult object to store results
|
|
559
694
|
|
|
560
695
|
Yields:
|
|
561
|
-
Messages (
|
|
696
|
+
Messages (progress, assistant, tool results) as they are generated
|
|
562
697
|
"""
|
|
563
|
-
logger.
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
"message_count": len(messages),
|
|
567
|
-
"tool_count": len(query_context.tools),
|
|
568
|
-
"safe_mode": query_context.safe_mode,
|
|
569
|
-
"model_pointer": query_context.model,
|
|
570
|
-
},
|
|
571
|
-
)
|
|
572
|
-
# Work on a copy so external mutations (e.g., UI appending messages while consuming)
|
|
573
|
-
# do not interfere with recursion or normalization.
|
|
574
|
-
messages = list(messages)
|
|
698
|
+
logger.debug(f"[query] Iteration {iteration}/{MAX_QUERY_ITERATIONS}")
|
|
699
|
+
|
|
700
|
+
# Check for file changes at the start of each iteration
|
|
575
701
|
change_notices = detect_changed_files(query_context.file_state_cache)
|
|
576
702
|
if change_notices:
|
|
577
703
|
messages.append(create_user_message(_format_changed_file_notice(change_notices)))
|
|
704
|
+
|
|
578
705
|
model_profile = resolve_model_profile(query_context.model)
|
|
579
706
|
tool_mode = determine_tool_mode(model_profile)
|
|
580
|
-
tools_for_model: List[Tool[Any, Any]] =
|
|
707
|
+
tools_for_model: List[Tool[Any, Any]] = (
|
|
708
|
+
[] if tool_mode == "text" else query_context.all_tools()
|
|
709
|
+
)
|
|
581
710
|
|
|
582
711
|
full_system_prompt = build_full_system_prompt(
|
|
583
712
|
system_prompt, context, tool_mode, query_context.all_tools()
|
|
@@ -591,6 +720,7 @@ async def query(
|
|
|
591
720
|
},
|
|
592
721
|
)
|
|
593
722
|
|
|
723
|
+
# Stream LLM response
|
|
594
724
|
progress_queue: asyncio.Queue[Optional[ProgressMessage]] = asyncio.Queue()
|
|
595
725
|
|
|
596
726
|
async def _stream_progress(chunk: str) -> None:
|
|
@@ -604,8 +734,10 @@ async def query(
|
|
|
604
734
|
content=chunk,
|
|
605
735
|
)
|
|
606
736
|
)
|
|
607
|
-
except
|
|
608
|
-
logger.
|
|
737
|
+
except asyncio.QueueFull:
|
|
738
|
+
logger.warning("[query] Progress queue full, dropping chunk")
|
|
739
|
+
except (RuntimeError, ValueError) as exc:
|
|
740
|
+
logger.warning("[query] Failed to enqueue stream progress chunk: %s", exc)
|
|
609
741
|
|
|
610
742
|
assistant_task = asyncio.create_task(
|
|
611
743
|
query_llm(
|
|
@@ -624,6 +756,7 @@ async def query(
|
|
|
624
756
|
|
|
625
757
|
assistant_message: Optional[AssistantMessage] = None
|
|
626
758
|
|
|
759
|
+
# Wait for LLM response while yielding progress
|
|
627
760
|
while True:
|
|
628
761
|
if query_context.abort_controller.is_set():
|
|
629
762
|
assistant_task.cancel()
|
|
@@ -632,6 +765,7 @@ async def query(
|
|
|
632
765
|
except CancelledError:
|
|
633
766
|
pass
|
|
634
767
|
yield create_assistant_message(INTERRUPT_MESSAGE)
|
|
768
|
+
result.should_stop = True
|
|
635
769
|
return
|
|
636
770
|
if assistant_task.done():
|
|
637
771
|
assistant_message = await assistant_task
|
|
@@ -652,20 +786,24 @@ async def query(
|
|
|
652
786
|
if progress:
|
|
653
787
|
yield progress
|
|
654
788
|
|
|
789
|
+
# Drain remaining progress messages
|
|
655
790
|
while not progress_queue.empty():
|
|
656
791
|
residual = progress_queue.get_nowait()
|
|
657
792
|
if residual:
|
|
658
793
|
yield residual
|
|
659
794
|
|
|
660
795
|
assert assistant_message is not None
|
|
796
|
+
result.assistant_message = assistant_message
|
|
661
797
|
|
|
662
798
|
# Check for abort
|
|
663
799
|
if query_context.abort_controller.is_set():
|
|
664
800
|
yield create_assistant_message(INTERRUPT_MESSAGE)
|
|
801
|
+
result.should_stop = True
|
|
665
802
|
return
|
|
666
803
|
|
|
667
804
|
yield assistant_message
|
|
668
805
|
|
|
806
|
+
# Extract and process tool calls
|
|
669
807
|
tool_use_blocks: List[MessageContent] = extract_tool_use_blocks(assistant_message)
|
|
670
808
|
text_blocks = (
|
|
671
809
|
len(assistant_message.message.content)
|
|
@@ -679,13 +817,16 @@ async def query(
|
|
|
679
817
|
|
|
680
818
|
if not tool_use_blocks:
|
|
681
819
|
logger.debug("[query] No tool_use blocks; returning response to user.")
|
|
820
|
+
result.should_stop = True
|
|
682
821
|
return
|
|
683
822
|
|
|
823
|
+
# Process tool calls
|
|
684
824
|
logger.debug(f"[query] Executing {len(tool_use_blocks)} tool_use block(s).")
|
|
685
825
|
tool_results: List[UserMessage] = []
|
|
686
826
|
permission_denied = False
|
|
687
827
|
sibling_ids = set(
|
|
688
|
-
getattr(t, "tool_use_id", None) or getattr(t, "id", None) or ""
|
|
828
|
+
getattr(t, "tool_use_id", None) or getattr(t, "id", None) or ""
|
|
829
|
+
for t in tool_use_blocks
|
|
689
830
|
)
|
|
690
831
|
prepared_calls: List[Dict[str, Any]] = []
|
|
691
832
|
|
|
@@ -693,12 +834,18 @@ async def query(
|
|
|
693
834
|
tool_name = tool_use.name
|
|
694
835
|
if not tool_name:
|
|
695
836
|
continue
|
|
696
|
-
tool_use_id =
|
|
837
|
+
tool_use_id = (
|
|
838
|
+
getattr(tool_use, "tool_use_id", None) or getattr(tool_use, "id", None) or ""
|
|
839
|
+
)
|
|
697
840
|
tool_input = getattr(tool_use, "input", {}) or {}
|
|
698
841
|
|
|
699
|
-
tool, missing_msg = _resolve_tool(
|
|
842
|
+
tool, missing_msg = _resolve_tool(
|
|
843
|
+
query_context.tool_registry, tool_name, tool_use_id
|
|
844
|
+
)
|
|
700
845
|
if missing_msg:
|
|
701
|
-
logger.warning(
|
|
846
|
+
logger.warning(
|
|
847
|
+
f"[query] Tool '{tool_name}' not found for tool_use_id={tool_use_id}"
|
|
848
|
+
)
|
|
702
849
|
tool_results.append(missing_msg)
|
|
703
850
|
yield missing_msg
|
|
704
851
|
continue
|
|
@@ -725,7 +872,8 @@ async def query(
|
|
|
725
872
|
validation = await tool.validate_input(parsed_input, tool_context)
|
|
726
873
|
if not validation.result:
|
|
727
874
|
logger.debug(
|
|
728
|
-
f"[query] Validation failed for tool_use_id={tool_use_id}:
|
|
875
|
+
f"[query] Validation failed for tool_use_id={tool_use_id}: "
|
|
876
|
+
f"{validation.message}"
|
|
729
877
|
)
|
|
730
878
|
result_msg = tool_result_message(
|
|
731
879
|
tool_use_id,
|
|
@@ -742,9 +890,12 @@ async def query(
|
|
|
742
890
|
)
|
|
743
891
|
if not allowed:
|
|
744
892
|
logger.debug(
|
|
745
|
-
f"[query] Permission denied for tool_use_id={tool_use_id}:
|
|
893
|
+
f"[query] Permission denied for tool_use_id={tool_use_id}: "
|
|
894
|
+
f"{denial_message}"
|
|
895
|
+
)
|
|
896
|
+
denial_text = (
|
|
897
|
+
denial_message or f"User aborted the tool invocation: {tool_name}"
|
|
746
898
|
)
|
|
747
|
-
denial_text = denial_message or f"User aborted the tool invocation: {tool_name}"
|
|
748
899
|
denial_msg = tool_result_message(tool_use_id, denial_text, is_error=True)
|
|
749
900
|
tool_results.append(denial_msg)
|
|
750
901
|
yield denial_msg
|
|
@@ -775,9 +926,22 @@ async def query(
|
|
|
775
926
|
tool_results.append(error_msg)
|
|
776
927
|
yield error_msg
|
|
777
928
|
continue
|
|
778
|
-
except
|
|
779
|
-
|
|
780
|
-
|
|
929
|
+
except CancelledError:
|
|
930
|
+
raise # Don't suppress task cancellation
|
|
931
|
+
except (
|
|
932
|
+
RuntimeError,
|
|
933
|
+
ValueError,
|
|
934
|
+
TypeError,
|
|
935
|
+
OSError,
|
|
936
|
+
IOError,
|
|
937
|
+
AttributeError,
|
|
938
|
+
KeyError,
|
|
939
|
+
) as e:
|
|
940
|
+
logger.warning(
|
|
941
|
+
"Error executing tool '%s': %s: %s",
|
|
942
|
+
tool_name,
|
|
943
|
+
type(e).__name__,
|
|
944
|
+
e,
|
|
781
945
|
extra={"tool": tool_name, "tool_use_id": tool_use_id},
|
|
782
946
|
)
|
|
783
947
|
error_msg = tool_result_message(
|
|
@@ -790,25 +954,94 @@ async def query(
|
|
|
790
954
|
break
|
|
791
955
|
|
|
792
956
|
if permission_denied:
|
|
957
|
+
result.tool_results = tool_results
|
|
958
|
+
result.should_stop = True
|
|
793
959
|
return
|
|
794
960
|
|
|
795
961
|
if prepared_calls:
|
|
796
962
|
async for message in _run_tools_concurrently(prepared_calls, tool_results):
|
|
797
963
|
yield message
|
|
798
964
|
|
|
965
|
+
_apply_skill_context_updates(tool_results, query_context)
|
|
966
|
+
|
|
799
967
|
# Check for abort after tools
|
|
800
968
|
if query_context.abort_controller.is_set():
|
|
801
969
|
yield create_assistant_message(INTERRUPT_MESSAGE_FOR_TOOL_USE)
|
|
970
|
+
result.tool_results = tool_results
|
|
971
|
+
result.should_stop = True
|
|
802
972
|
return
|
|
803
973
|
|
|
804
|
-
|
|
805
|
-
|
|
974
|
+
result.tool_results = tool_results
|
|
975
|
+
# should_stop remains False, indicating the loop should continue
|
|
806
976
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
977
|
+
|
|
978
|
+
async def query(
|
|
979
|
+
messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
|
|
980
|
+
system_prompt: str,
|
|
981
|
+
context: Dict[str, str],
|
|
982
|
+
query_context: QueryContext,
|
|
983
|
+
can_use_tool_fn: Optional[ToolPermissionCallable] = None,
|
|
984
|
+
) -> AsyncGenerator[Union[UserMessage, AssistantMessage, ProgressMessage], None]:
|
|
985
|
+
"""Execute a query with tool support.
|
|
986
|
+
|
|
987
|
+
This is the main query loop that:
|
|
988
|
+
1. Sends messages to the AI
|
|
989
|
+
2. Handles tool use responses
|
|
990
|
+
3. Executes tools
|
|
991
|
+
4. Continues the conversation in a loop until no more tool calls
|
|
992
|
+
|
|
993
|
+
Args:
|
|
994
|
+
messages: Conversation history
|
|
995
|
+
system_prompt: Base system prompt
|
|
996
|
+
context: Additional context dictionary
|
|
997
|
+
query_context: Query configuration
|
|
998
|
+
can_use_tool_fn: Optional function to check tool permissions
|
|
999
|
+
|
|
1000
|
+
Yields:
|
|
1001
|
+
Messages (user, assistant, progress) as they are generated
|
|
1002
|
+
"""
|
|
1003
|
+
logger.info(
|
|
1004
|
+
"[query] Starting query loop",
|
|
1005
|
+
extra={
|
|
1006
|
+
"message_count": len(messages),
|
|
1007
|
+
"tool_count": len(query_context.tools),
|
|
1008
|
+
"safe_mode": query_context.safe_mode,
|
|
1009
|
+
"model_pointer": query_context.model,
|
|
1010
|
+
},
|
|
811
1011
|
)
|
|
1012
|
+
# Work on a copy so external mutations (e.g., UI appending messages while consuming)
|
|
1013
|
+
# do not interfere with the loop or normalization.
|
|
1014
|
+
messages = list(messages)
|
|
1015
|
+
|
|
1016
|
+
for iteration in range(1, MAX_QUERY_ITERATIONS + 1):
|
|
1017
|
+
result = IterationResult()
|
|
812
1018
|
|
|
813
|
-
|
|
814
|
-
|
|
1019
|
+
async for msg in _run_query_iteration(
|
|
1020
|
+
messages,
|
|
1021
|
+
system_prompt,
|
|
1022
|
+
context,
|
|
1023
|
+
query_context,
|
|
1024
|
+
can_use_tool_fn,
|
|
1025
|
+
iteration,
|
|
1026
|
+
result,
|
|
1027
|
+
):
|
|
1028
|
+
yield msg
|
|
1029
|
+
|
|
1030
|
+
if result.should_stop:
|
|
1031
|
+
return
|
|
1032
|
+
|
|
1033
|
+
# Update messages for next iteration
|
|
1034
|
+
messages = messages + [result.assistant_message] + result.tool_results
|
|
1035
|
+
logger.debug(
|
|
1036
|
+
f"[query] Continuing loop with {len(messages)} messages after tools; "
|
|
1037
|
+
f"tool_results_count={len(result.tool_results)}"
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
# Reached max iterations
|
|
1041
|
+
logger.warning(
|
|
1042
|
+
f"[query] Reached maximum iterations ({MAX_QUERY_ITERATIONS}), stopping query loop"
|
|
1043
|
+
)
|
|
1044
|
+
yield create_assistant_message(
|
|
1045
|
+
f"Reached maximum query iterations ({MAX_QUERY_ITERATIONS}). "
|
|
1046
|
+
"Please continue the conversation to proceed."
|
|
1047
|
+
)
|
ripperdoc/core/query_utils.py
CHANGED
|
@@ -66,16 +66,43 @@ def anthropic_usage_tokens(usage: Optional[Mapping[str, Any] | object]) -> Dict[
|
|
|
66
66
|
def openai_usage_tokens(usage: Optional[Mapping[str, Any] | object]) -> Dict[str, int]:
|
|
67
67
|
"""Extract token counts from an OpenAI-compatible response usage payload."""
|
|
68
68
|
prompt_details = None
|
|
69
|
+
input_details = None
|
|
70
|
+
output_details = None
|
|
69
71
|
if isinstance(usage, dict):
|
|
70
72
|
prompt_details = usage.get("prompt_tokens_details")
|
|
73
|
+
input_details = usage.get("input_tokens_details")
|
|
74
|
+
output_details = usage.get("output_tokens_details")
|
|
71
75
|
else:
|
|
72
76
|
prompt_details = getattr(usage, "prompt_tokens_details", None)
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
input_details = getattr(usage, "input_tokens_details", None)
|
|
78
|
+
output_details = getattr(usage, "output_tokens_details", None)
|
|
79
|
+
|
|
80
|
+
cache_read_tokens = 0
|
|
81
|
+
if prompt_details:
|
|
82
|
+
cache_read_tokens = _get_usage_field(prompt_details, "cached_tokens")
|
|
83
|
+
if not cache_read_tokens and input_details:
|
|
84
|
+
cache_read_tokens = _get_usage_field(input_details, "cached_tokens")
|
|
85
|
+
|
|
86
|
+
input_tokens = _get_usage_field(usage, "prompt_tokens")
|
|
87
|
+
if not input_tokens:
|
|
88
|
+
input_tokens = _get_usage_field(usage, "input_tokens")
|
|
89
|
+
|
|
90
|
+
output_tokens = _get_usage_field(usage, "completion_tokens")
|
|
91
|
+
if not output_tokens:
|
|
92
|
+
output_tokens = _get_usage_field(usage, "output_tokens")
|
|
93
|
+
|
|
94
|
+
reasoning_tokens = _get_usage_field(output_details, "reasoning_tokens") if output_details else 0
|
|
95
|
+
if reasoning_tokens:
|
|
96
|
+
if output_tokens <= 0:
|
|
97
|
+
output_tokens = reasoning_tokens
|
|
98
|
+
elif output_tokens < reasoning_tokens:
|
|
99
|
+
output_tokens = output_tokens + reasoning_tokens
|
|
100
|
+
else:
|
|
101
|
+
output_tokens = max(output_tokens, reasoning_tokens)
|
|
75
102
|
|
|
76
103
|
return {
|
|
77
|
-
"input_tokens":
|
|
78
|
-
"output_tokens":
|
|
104
|
+
"input_tokens": input_tokens,
|
|
105
|
+
"output_tokens": output_tokens,
|
|
79
106
|
"cache_read_input_tokens": cache_read_tokens,
|
|
80
107
|
"cache_creation_input_tokens": 0,
|
|
81
108
|
}
|
|
@@ -219,10 +246,10 @@ def _tool_prompt_for_text_mode(tools: List[Tool[Any, Any]]) -> str:
|
|
|
219
246
|
if hasattr(finfo, "is_required"):
|
|
220
247
|
try:
|
|
221
248
|
is_req = bool(finfo.is_required())
|
|
222
|
-
except
|
|
249
|
+
except (TypeError, AttributeError):
|
|
223
250
|
is_req = False
|
|
224
251
|
required_fields.append(f"{fname}{' (required)' if is_req else ''}")
|
|
225
|
-
except
|
|
252
|
+
except (AttributeError, TypeError):
|
|
226
253
|
required_fields = []
|
|
227
254
|
|
|
228
255
|
required_str = ", ".join(required_fields) if required_fields else "see input schema"
|
|
@@ -487,6 +514,23 @@ def content_blocks_from_anthropic_response(response: Any, tool_mode: str) -> Lis
|
|
|
487
514
|
btype = getattr(block, "type", None)
|
|
488
515
|
if btype == "text":
|
|
489
516
|
blocks.append({"type": "text", "text": getattr(block, "text", "")})
|
|
517
|
+
elif btype == "thinking":
|
|
518
|
+
blocks.append(
|
|
519
|
+
{
|
|
520
|
+
"type": "thinking",
|
|
521
|
+
"thinking": getattr(block, "thinking", None) or "",
|
|
522
|
+
"signature": getattr(block, "signature", None),
|
|
523
|
+
}
|
|
524
|
+
)
|
|
525
|
+
elif btype == "redacted_thinking":
|
|
526
|
+
# Preserve encrypted payload for replay even if we don't display it.
|
|
527
|
+
blocks.append(
|
|
528
|
+
{
|
|
529
|
+
"type": "redacted_thinking",
|
|
530
|
+
"data": getattr(block, "data", None),
|
|
531
|
+
"signature": getattr(block, "signature", None),
|
|
532
|
+
}
|
|
533
|
+
)
|
|
490
534
|
elif btype == "tool_use":
|
|
491
535
|
raw_input = getattr(block, "input", {}) or {}
|
|
492
536
|
blocks.append(
|