ripperdoc 0.2.3__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 +35 -15
- 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 +523 -396
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/agents.py +172 -4
- ripperdoc/core/config.py +130 -6
- ripperdoc/core/default_tools.py +13 -2
- ripperdoc/core/permissions.py +20 -14
- ripperdoc/core/providers/__init__.py +31 -15
- ripperdoc/core/providers/anthropic.py +122 -8
- ripperdoc/core/providers/base.py +93 -15
- ripperdoc/core/providers/gemini.py +539 -96
- ripperdoc/core/providers/openai.py +371 -26
- ripperdoc/core/query.py +301 -62
- ripperdoc/core/query_utils.py +51 -7
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +79 -67
- ripperdoc/core/tool.py +15 -6
- ripperdoc/sdk/client.py +14 -1
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +82 -26
- ripperdoc/tools/bash_tool.py +356 -209
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- 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 +172 -413
- 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 +91 -9
- 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 +82 -22
- ripperdoc/utils/memory.py +9 -6
- ripperdoc/utils/message_compaction.py +19 -16
- 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 +34 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +14 -1
- ripperdoc-0.2.5.dist-info/RECORD +107 -0
- ripperdoc-0.2.3.dist-info/RECORD +0 -95
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.3.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 (
|
|
@@ -58,7 +61,7 @@ from ripperdoc.utils.messages import (
|
|
|
58
61
|
logger = get_logger()
|
|
59
62
|
|
|
60
63
|
DEFAULT_REQUEST_TIMEOUT_SEC = float(os.getenv("RIPPERDOC_API_TIMEOUT", "120"))
|
|
61
|
-
MAX_LLM_RETRIES =
|
|
64
|
+
MAX_LLM_RETRIES = int(os.getenv("RIPPERDOC_MAX_RETRIES", "10"))
|
|
62
65
|
|
|
63
66
|
|
|
64
67
|
def _resolve_tool(
|
|
@@ -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
|
|
|
@@ -377,6 +440,8 @@ class QueryContext:
|
|
|
377
440
|
safe_mode: bool = False,
|
|
378
441
|
model: str = "main",
|
|
379
442
|
verbose: bool = False,
|
|
443
|
+
pause_ui: Optional[Callable[[], None]] = None,
|
|
444
|
+
resume_ui: Optional[Callable[[], None]] = None,
|
|
380
445
|
) -> None:
|
|
381
446
|
self.tool_registry = ToolRegistry(tools)
|
|
382
447
|
self.max_thinking_tokens = max_thinking_tokens
|
|
@@ -385,6 +450,8 @@ class QueryContext:
|
|
|
385
450
|
self.verbose = verbose
|
|
386
451
|
self.abort_controller = asyncio.Event()
|
|
387
452
|
self.file_state_cache: Dict[str, FileSnapshot] = {}
|
|
453
|
+
self.pause_ui = pause_ui
|
|
454
|
+
self.resume_ui = resume_ui
|
|
388
455
|
|
|
389
456
|
@property
|
|
390
457
|
def tools(self) -> List[Tool[Any, Any]]:
|
|
@@ -411,7 +478,7 @@ async def query_llm(
|
|
|
411
478
|
tools: List[Tool[Any, Any]],
|
|
412
479
|
max_thinking_tokens: int = 0,
|
|
413
480
|
model: str = "main",
|
|
414
|
-
|
|
481
|
+
_abort_signal: Optional[asyncio.Event] = None,
|
|
415
482
|
*,
|
|
416
483
|
progress_callback: Optional[Callable[[str], Awaitable[None]]] = None,
|
|
417
484
|
request_timeout: Optional[float] = None,
|
|
@@ -426,7 +493,7 @@ async def query_llm(
|
|
|
426
493
|
tools: Available tools
|
|
427
494
|
max_thinking_tokens: Maximum tokens for thinking (0 = disabled)
|
|
428
495
|
model: Model pointer to use
|
|
429
|
-
|
|
496
|
+
_abort_signal: Event to signal abortion (currently unused, reserved for future)
|
|
430
497
|
progress_callback: Optional async callback invoked with streamed text chunks
|
|
431
498
|
request_timeout: Max seconds to wait for a provider response before retrying
|
|
432
499
|
max_retries: Number of retries on timeout/errors (total attempts = retries + 1)
|
|
@@ -503,18 +570,50 @@ async def query_llm(
|
|
|
503
570
|
progress_callback=progress_callback,
|
|
504
571
|
request_timeout=request_timeout,
|
|
505
572
|
max_retries=max_retries,
|
|
573
|
+
max_thinking_tokens=max_thinking_tokens,
|
|
506
574
|
)
|
|
507
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
|
+
|
|
508
603
|
return create_assistant_message(
|
|
509
604
|
content=provider_response.content_blocks,
|
|
510
605
|
cost_usd=provider_response.cost_usd,
|
|
511
606
|
duration_ms=provider_response.duration_ms,
|
|
607
|
+
metadata=provider_response.metadata,
|
|
512
608
|
)
|
|
513
609
|
|
|
514
|
-
except
|
|
610
|
+
except CancelledError:
|
|
611
|
+
raise # Don't suppress task cancellation
|
|
612
|
+
except (RuntimeError, ValueError, TypeError, OSError, ConnectionError, TimeoutError) as e:
|
|
515
613
|
# Return error message
|
|
516
|
-
logger.
|
|
517
|
-
"Error querying AI model",
|
|
614
|
+
logger.warning(
|
|
615
|
+
"Error querying AI model: %s: %s",
|
|
616
|
+
type(e).__name__, e,
|
|
518
617
|
extra={
|
|
519
618
|
"model": getattr(model_profile, "model", None),
|
|
520
619
|
"model_pointer": model,
|
|
@@ -524,56 +623,90 @@ async def query_llm(
|
|
|
524
623
|
},
|
|
525
624
|
)
|
|
526
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
|
+
|
|
527
647
|
error_msg = create_assistant_message(
|
|
528
|
-
content=
|
|
648
|
+
content=content, duration_ms=duration_ms, metadata=metadata
|
|
529
649
|
)
|
|
530
650
|
error_msg.is_api_error_message = True
|
|
531
651
|
return error_msg
|
|
532
652
|
|
|
533
653
|
|
|
534
|
-
|
|
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(
|
|
535
671
|
messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
|
|
536
672
|
system_prompt: str,
|
|
537
673
|
context: Dict[str, str],
|
|
538
674
|
query_context: QueryContext,
|
|
539
|
-
can_use_tool_fn: Optional[ToolPermissionCallable]
|
|
675
|
+
can_use_tool_fn: Optional[ToolPermissionCallable],
|
|
676
|
+
iteration: int,
|
|
677
|
+
result: IterationResult,
|
|
540
678
|
) -> AsyncGenerator[Union[UserMessage, AssistantMessage, ProgressMessage], None]:
|
|
541
|
-
"""
|
|
679
|
+
"""Run a single iteration of the query loop.
|
|
542
680
|
|
|
543
|
-
This
|
|
544
|
-
1.
|
|
545
|
-
2.
|
|
546
|
-
3.
|
|
547
|
-
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)
|
|
548
685
|
|
|
549
686
|
Args:
|
|
550
|
-
messages:
|
|
687
|
+
messages: Current conversation history
|
|
551
688
|
system_prompt: Base system prompt
|
|
552
689
|
context: Additional context dictionary
|
|
553
690
|
query_context: Query configuration
|
|
554
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
|
|
555
694
|
|
|
556
695
|
Yields:
|
|
557
|
-
Messages (
|
|
696
|
+
Messages (progress, assistant, tool results) as they are generated
|
|
558
697
|
"""
|
|
559
|
-
logger.
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
"message_count": len(messages),
|
|
563
|
-
"tool_count": len(query_context.tools),
|
|
564
|
-
"safe_mode": query_context.safe_mode,
|
|
565
|
-
"model_pointer": query_context.model,
|
|
566
|
-
},
|
|
567
|
-
)
|
|
568
|
-
# Work on a copy so external mutations (e.g., UI appending messages while consuming)
|
|
569
|
-
# do not interfere with recursion or normalization.
|
|
570
|
-
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
|
|
571
701
|
change_notices = detect_changed_files(query_context.file_state_cache)
|
|
572
702
|
if change_notices:
|
|
573
703
|
messages.append(create_user_message(_format_changed_file_notice(change_notices)))
|
|
704
|
+
|
|
574
705
|
model_profile = resolve_model_profile(query_context.model)
|
|
575
706
|
tool_mode = determine_tool_mode(model_profile)
|
|
576
|
-
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
|
+
)
|
|
577
710
|
|
|
578
711
|
full_system_prompt = build_full_system_prompt(
|
|
579
712
|
system_prompt, context, tool_mode, query_context.all_tools()
|
|
@@ -587,6 +720,7 @@ async def query(
|
|
|
587
720
|
},
|
|
588
721
|
)
|
|
589
722
|
|
|
723
|
+
# Stream LLM response
|
|
590
724
|
progress_queue: asyncio.Queue[Optional[ProgressMessage]] = asyncio.Queue()
|
|
591
725
|
|
|
592
726
|
async def _stream_progress(chunk: str) -> None:
|
|
@@ -600,8 +734,10 @@ async def query(
|
|
|
600
734
|
content=chunk,
|
|
601
735
|
)
|
|
602
736
|
)
|
|
603
|
-
except
|
|
604
|
-
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)
|
|
605
741
|
|
|
606
742
|
assistant_task = asyncio.create_task(
|
|
607
743
|
query_llm(
|
|
@@ -620,6 +756,7 @@ async def query(
|
|
|
620
756
|
|
|
621
757
|
assistant_message: Optional[AssistantMessage] = None
|
|
622
758
|
|
|
759
|
+
# Wait for LLM response while yielding progress
|
|
623
760
|
while True:
|
|
624
761
|
if query_context.abort_controller.is_set():
|
|
625
762
|
assistant_task.cancel()
|
|
@@ -628,6 +765,7 @@ async def query(
|
|
|
628
765
|
except CancelledError:
|
|
629
766
|
pass
|
|
630
767
|
yield create_assistant_message(INTERRUPT_MESSAGE)
|
|
768
|
+
result.should_stop = True
|
|
631
769
|
return
|
|
632
770
|
if assistant_task.done():
|
|
633
771
|
assistant_message = await assistant_task
|
|
@@ -648,20 +786,24 @@ async def query(
|
|
|
648
786
|
if progress:
|
|
649
787
|
yield progress
|
|
650
788
|
|
|
789
|
+
# Drain remaining progress messages
|
|
651
790
|
while not progress_queue.empty():
|
|
652
791
|
residual = progress_queue.get_nowait()
|
|
653
792
|
if residual:
|
|
654
793
|
yield residual
|
|
655
794
|
|
|
656
795
|
assert assistant_message is not None
|
|
796
|
+
result.assistant_message = assistant_message
|
|
657
797
|
|
|
658
798
|
# Check for abort
|
|
659
799
|
if query_context.abort_controller.is_set():
|
|
660
800
|
yield create_assistant_message(INTERRUPT_MESSAGE)
|
|
801
|
+
result.should_stop = True
|
|
661
802
|
return
|
|
662
803
|
|
|
663
804
|
yield assistant_message
|
|
664
805
|
|
|
806
|
+
# Extract and process tool calls
|
|
665
807
|
tool_use_blocks: List[MessageContent] = extract_tool_use_blocks(assistant_message)
|
|
666
808
|
text_blocks = (
|
|
667
809
|
len(assistant_message.message.content)
|
|
@@ -675,13 +817,16 @@ async def query(
|
|
|
675
817
|
|
|
676
818
|
if not tool_use_blocks:
|
|
677
819
|
logger.debug("[query] No tool_use blocks; returning response to user.")
|
|
820
|
+
result.should_stop = True
|
|
678
821
|
return
|
|
679
822
|
|
|
823
|
+
# Process tool calls
|
|
680
824
|
logger.debug(f"[query] Executing {len(tool_use_blocks)} tool_use block(s).")
|
|
681
825
|
tool_results: List[UserMessage] = []
|
|
682
826
|
permission_denied = False
|
|
683
827
|
sibling_ids = set(
|
|
684
|
-
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
|
|
685
830
|
)
|
|
686
831
|
prepared_calls: List[Dict[str, Any]] = []
|
|
687
832
|
|
|
@@ -689,12 +834,18 @@ async def query(
|
|
|
689
834
|
tool_name = tool_use.name
|
|
690
835
|
if not tool_name:
|
|
691
836
|
continue
|
|
692
|
-
tool_use_id =
|
|
837
|
+
tool_use_id = (
|
|
838
|
+
getattr(tool_use, "tool_use_id", None) or getattr(tool_use, "id", None) or ""
|
|
839
|
+
)
|
|
693
840
|
tool_input = getattr(tool_use, "input", {}) or {}
|
|
694
841
|
|
|
695
|
-
tool, missing_msg = _resolve_tool(
|
|
842
|
+
tool, missing_msg = _resolve_tool(
|
|
843
|
+
query_context.tool_registry, tool_name, tool_use_id
|
|
844
|
+
)
|
|
696
845
|
if missing_msg:
|
|
697
|
-
logger.warning(
|
|
846
|
+
logger.warning(
|
|
847
|
+
f"[query] Tool '{tool_name}' not found for tool_use_id={tool_use_id}"
|
|
848
|
+
)
|
|
698
849
|
tool_results.append(missing_msg)
|
|
699
850
|
yield missing_msg
|
|
700
851
|
continue
|
|
@@ -714,12 +865,15 @@ async def query(
|
|
|
714
865
|
tool_registry=query_context.tool_registry,
|
|
715
866
|
file_state_cache=query_context.file_state_cache,
|
|
716
867
|
abort_signal=query_context.abort_controller,
|
|
868
|
+
pause_ui=query_context.pause_ui,
|
|
869
|
+
resume_ui=query_context.resume_ui,
|
|
717
870
|
)
|
|
718
871
|
|
|
719
872
|
validation = await tool.validate_input(parsed_input, tool_context)
|
|
720
873
|
if not validation.result:
|
|
721
874
|
logger.debug(
|
|
722
|
-
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}"
|
|
723
877
|
)
|
|
724
878
|
result_msg = tool_result_message(
|
|
725
879
|
tool_use_id,
|
|
@@ -736,9 +890,12 @@ async def query(
|
|
|
736
890
|
)
|
|
737
891
|
if not allowed:
|
|
738
892
|
logger.debug(
|
|
739
|
-
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}"
|
|
740
898
|
)
|
|
741
|
-
denial_text = denial_message or f"User aborted the tool invocation: {tool_name}"
|
|
742
899
|
denial_msg = tool_result_message(tool_use_id, denial_text, is_error=True)
|
|
743
900
|
tool_results.append(denial_msg)
|
|
744
901
|
yield denial_msg
|
|
@@ -769,9 +926,22 @@ async def query(
|
|
|
769
926
|
tool_results.append(error_msg)
|
|
770
927
|
yield error_msg
|
|
771
928
|
continue
|
|
772
|
-
except
|
|
773
|
-
|
|
774
|
-
|
|
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,
|
|
775
945
|
extra={"tool": tool_name, "tool_use_id": tool_use_id},
|
|
776
946
|
)
|
|
777
947
|
error_msg = tool_result_message(
|
|
@@ -784,25 +954,94 @@ async def query(
|
|
|
784
954
|
break
|
|
785
955
|
|
|
786
956
|
if permission_denied:
|
|
957
|
+
result.tool_results = tool_results
|
|
958
|
+
result.should_stop = True
|
|
787
959
|
return
|
|
788
960
|
|
|
789
961
|
if prepared_calls:
|
|
790
962
|
async for message in _run_tools_concurrently(prepared_calls, tool_results):
|
|
791
963
|
yield message
|
|
792
964
|
|
|
965
|
+
_apply_skill_context_updates(tool_results, query_context)
|
|
966
|
+
|
|
793
967
|
# Check for abort after tools
|
|
794
968
|
if query_context.abort_controller.is_set():
|
|
795
969
|
yield create_assistant_message(INTERRUPT_MESSAGE_FOR_TOOL_USE)
|
|
970
|
+
result.tool_results = tool_results
|
|
971
|
+
result.should_stop = True
|
|
796
972
|
return
|
|
797
973
|
|
|
798
|
-
|
|
799
|
-
|
|
974
|
+
result.tool_results = tool_results
|
|
975
|
+
# should_stop remains False, indicating the loop should continue
|
|
800
976
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
+
},
|
|
805
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()
|
|
806
1018
|
|
|
807
|
-
|
|
808
|
-
|
|
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
|
+
)
|