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/tool.py
CHANGED
|
@@ -44,6 +44,13 @@ class ToolUseContext(BaseModel):
|
|
|
44
44
|
file_state_cache: Dict[str, "FileSnapshot"] = Field(default_factory=dict)
|
|
45
45
|
tool_registry: Optional[Any] = None
|
|
46
46
|
abort_signal: Optional[Any] = None
|
|
47
|
+
# UI control callbacks for tools that need user interaction
|
|
48
|
+
pause_ui: Optional[Any] = Field(default=None, description="Callback to pause UI spinner")
|
|
49
|
+
resume_ui: Optional[Any] = Field(default=None, description="Callback to resume UI spinner")
|
|
50
|
+
# Plan mode control callback
|
|
51
|
+
on_exit_plan_mode: Optional[Any] = Field(
|
|
52
|
+
default=None, description="Callback invoked when exiting plan mode"
|
|
53
|
+
)
|
|
47
54
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
48
55
|
|
|
49
56
|
|
|
@@ -201,9 +208,10 @@ async def build_tool_description(
|
|
|
201
208
|
|
|
202
209
|
if parts:
|
|
203
210
|
return f"{description_text}\n\nInput examples:\n" + "\n\n".join(parts)
|
|
204
|
-
except
|
|
205
|
-
logger.
|
|
206
|
-
"[tool] Failed to build input example section",
|
|
211
|
+
except (TypeError, ValueError, AttributeError, KeyError) as exc:
|
|
212
|
+
logger.warning(
|
|
213
|
+
"[tool] Failed to build input example section: %s: %s",
|
|
214
|
+
type(exc).__name__, exc,
|
|
207
215
|
extra={"tool": getattr(tool, "name", None)},
|
|
208
216
|
)
|
|
209
217
|
return description_text
|
|
@@ -220,9 +228,10 @@ def tool_input_examples(tool: Tool[Any, Any], limit: int = 5) -> List[Dict[str,
|
|
|
220
228
|
for example in examples[:limit]:
|
|
221
229
|
try:
|
|
222
230
|
results.append(example.example)
|
|
223
|
-
except
|
|
224
|
-
logger.
|
|
225
|
-
"[tool] Failed to format tool input example",
|
|
231
|
+
except (TypeError, ValueError, AttributeError) as exc:
|
|
232
|
+
logger.warning(
|
|
233
|
+
"[tool] Failed to format tool input example: %s: %s",
|
|
234
|
+
type(exc).__name__, exc,
|
|
226
235
|
extra={"tool": getattr(tool, "name", None)},
|
|
227
236
|
)
|
|
228
237
|
continue
|
ripperdoc/sdk/client.py
CHANGED
|
@@ -27,6 +27,7 @@ from ripperdoc.core.default_tools import get_default_tools
|
|
|
27
27
|
from ripperdoc.core.query import QueryContext, query as _core_query
|
|
28
28
|
from ripperdoc.core.permissions import PermissionResult
|
|
29
29
|
from ripperdoc.core.system_prompt import build_system_prompt
|
|
30
|
+
from ripperdoc.core.skills import build_skill_summary, load_all_skills
|
|
30
31
|
from ripperdoc.core.tool import Tool
|
|
31
32
|
from ripperdoc.tools.task_tool import TaskTool
|
|
32
33
|
from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
|
|
@@ -42,6 +43,7 @@ from ripperdoc.utils.mcp import (
|
|
|
42
43
|
load_mcp_servers_async,
|
|
43
44
|
shutdown_mcp_runtime,
|
|
44
45
|
)
|
|
46
|
+
from ripperdoc.utils.log import get_logger
|
|
45
47
|
|
|
46
48
|
MessageType = Union[UserMessage, AssistantMessage, ProgressMessage]
|
|
47
49
|
PermissionChecker = Callable[
|
|
@@ -67,6 +69,8 @@ QueryRunner = Callable[
|
|
|
67
69
|
|
|
68
70
|
_END_OF_STREAM = object()
|
|
69
71
|
|
|
72
|
+
logger = get_logger()
|
|
73
|
+
|
|
70
74
|
|
|
71
75
|
def _coerce_to_path(path: Union[str, Path]) -> Path:
|
|
72
76
|
return path if isinstance(path, Path) else Path(path)
|
|
@@ -281,12 +285,21 @@ class RipperdocClient:
|
|
|
281
285
|
return self.options.system_prompt
|
|
282
286
|
|
|
283
287
|
instructions: List[str] = []
|
|
288
|
+
project_path = _coerce_to_path(self.options.cwd or Path.cwd())
|
|
289
|
+
skill_result = load_all_skills(project_path)
|
|
290
|
+
for err in skill_result.errors:
|
|
291
|
+
logger.warning(
|
|
292
|
+
"[skills] Failed to load skill",
|
|
293
|
+
extra={"path": str(err.path), "reason": err.reason},
|
|
294
|
+
)
|
|
295
|
+
skill_instructions = build_skill_summary(skill_result.skills)
|
|
296
|
+
if skill_instructions:
|
|
297
|
+
instructions.append(skill_instructions)
|
|
284
298
|
instructions.extend(self.options.extra_instructions())
|
|
285
299
|
memory = build_memory_instructions()
|
|
286
300
|
if memory:
|
|
287
301
|
instructions.append(memory)
|
|
288
302
|
|
|
289
|
-
project_path = _coerce_to_path(self.options.cwd or Path.cwd())
|
|
290
303
|
dynamic_tools = await load_dynamic_mcp_tools_async(project_path)
|
|
291
304
|
if dynamic_tools:
|
|
292
305
|
self._tools = merge_tools_with_dynamic(self._tools, dynamic_tools)
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""Ask user question tool for interactive clarification.
|
|
2
|
+
|
|
3
|
+
This tool allows the AI to ask the user questions during execution,
|
|
4
|
+
enabling clarification of ambiguous instructions, gathering preferences,
|
|
5
|
+
and making decisions on implementation choices.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from textwrap import dedent
|
|
12
|
+
from typing import AsyncGenerator, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from ripperdoc.core.tool import (
|
|
17
|
+
Tool,
|
|
18
|
+
ToolOutput,
|
|
19
|
+
ToolResult,
|
|
20
|
+
ToolUseContext,
|
|
21
|
+
ValidationResult,
|
|
22
|
+
)
|
|
23
|
+
from ripperdoc.utils.log import get_logger
|
|
24
|
+
|
|
25
|
+
logger = get_logger()
|
|
26
|
+
|
|
27
|
+
TOOL_NAME = "AskUserQuestion"
|
|
28
|
+
OTHER_VALUE = "__other__"
|
|
29
|
+
HEADER_MAX_CHARS = 12
|
|
30
|
+
|
|
31
|
+
ASK_USER_QUESTION_PROMPT = dedent(
|
|
32
|
+
"""\
|
|
33
|
+
Use this tool when you need to ask the user questions during execution. This allows you to:
|
|
34
|
+
1. Gather user preferences or requirements
|
|
35
|
+
2. Clarify ambiguous instructions
|
|
36
|
+
3. Get decisions on implementation choices as you work
|
|
37
|
+
4. Offer choices to the user about what direction to take.
|
|
38
|
+
|
|
39
|
+
Usage notes:
|
|
40
|
+
- Users will always be able to select "Other" to provide custom text input
|
|
41
|
+
- Use multiSelect: true to allow multiple answers to be selected for a question
|
|
42
|
+
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
|
|
43
|
+
"""
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OptionInput(BaseModel):
|
|
48
|
+
"""Single option for a question."""
|
|
49
|
+
|
|
50
|
+
label: str = Field(
|
|
51
|
+
description="The display text for this option that the user will see and select. "
|
|
52
|
+
"Should be concise (1-5 words) and clearly describe the choice."
|
|
53
|
+
)
|
|
54
|
+
description: str = Field(
|
|
55
|
+
description="Explanation of what this option means or what will happen if chosen. "
|
|
56
|
+
"Useful for providing context about trade-offs or implications."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class QuestionInput(BaseModel):
|
|
61
|
+
"""Single question to ask the user."""
|
|
62
|
+
|
|
63
|
+
question: str = Field(
|
|
64
|
+
description="The complete question to ask the user. Should be clear, specific, and end "
|
|
65
|
+
'with a question mark. Example: "Which library should we use for date formatting?" '
|
|
66
|
+
'If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"'
|
|
67
|
+
)
|
|
68
|
+
header: str = Field(
|
|
69
|
+
description=f"Very short label displayed as a chip/tag (max {HEADER_MAX_CHARS} chars). "
|
|
70
|
+
'Examples: "Auth method", "Library", "Approach".'
|
|
71
|
+
)
|
|
72
|
+
options: List[OptionInput] = Field(
|
|
73
|
+
min_length=2,
|
|
74
|
+
max_length=4,
|
|
75
|
+
description="The available choices for this question. Must have 2-4 options. "
|
|
76
|
+
"Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). "
|
|
77
|
+
"There should be no 'Other' option, that will be provided automatically.",
|
|
78
|
+
)
|
|
79
|
+
multiSelect: bool = Field(
|
|
80
|
+
description="Set to true to allow the user to select multiple options instead of just one. "
|
|
81
|
+
"Use when choices are not mutually exclusive."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AskUserQuestionToolInput(BaseModel):
|
|
86
|
+
"""Input for the AskUserQuestion tool."""
|
|
87
|
+
|
|
88
|
+
questions: List[QuestionInput] = Field(
|
|
89
|
+
min_length=1,
|
|
90
|
+
max_length=4,
|
|
91
|
+
description="Questions to ask the user (1-4 questions)",
|
|
92
|
+
)
|
|
93
|
+
answers: Optional[Dict[str, str]] = Field(
|
|
94
|
+
default=None,
|
|
95
|
+
description="User answers collected by the permission component",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AskUserQuestionToolOutput(BaseModel):
|
|
100
|
+
"""Output from the AskUserQuestion tool."""
|
|
101
|
+
|
|
102
|
+
questions: List[QuestionInput]
|
|
103
|
+
answers: Dict[str, str]
|
|
104
|
+
cancelled: bool = False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def truncate_header(header: str) -> str:
|
|
108
|
+
"""Truncate header to maximum characters."""
|
|
109
|
+
if len(header) <= HEADER_MAX_CHARS:
|
|
110
|
+
return header
|
|
111
|
+
return f"{header[: HEADER_MAX_CHARS - 1]}..."
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def format_option_display(option: OptionInput, index: int) -> str:
|
|
115
|
+
"""Format a single option for display."""
|
|
116
|
+
desc = f" - {option.description}" if option.description.strip() else ""
|
|
117
|
+
return f" {index}. {option.label}{desc}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def format_question_prompt(question: QuestionInput, question_num: int, total: int) -> str:
|
|
121
|
+
"""Format a question for terminal display."""
|
|
122
|
+
header = truncate_header(question.header)
|
|
123
|
+
lines = [
|
|
124
|
+
"",
|
|
125
|
+
f"[{header}] Question {question_num}/{total}",
|
|
126
|
+
f" {question.question}",
|
|
127
|
+
"",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
for idx, opt in enumerate(question.options, start=1):
|
|
131
|
+
lines.append(format_option_display(opt, idx))
|
|
132
|
+
|
|
133
|
+
# Add "Other" option
|
|
134
|
+
lines.append(f" {len(question.options) + 1}. Other (type your own answer)")
|
|
135
|
+
|
|
136
|
+
if question.multiSelect:
|
|
137
|
+
lines.append("")
|
|
138
|
+
lines.append(" Enter numbers separated by commas (e.g., 1,3), or 'o' for other: ")
|
|
139
|
+
else:
|
|
140
|
+
lines.append("")
|
|
141
|
+
lines.append(" Enter choice (1-{}) or 'o' for other: ".format(len(question.options) + 1))
|
|
142
|
+
|
|
143
|
+
return "\n".join(lines)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def prompt_user_for_answer(
|
|
147
|
+
question: QuestionInput, question_num: int, total: int
|
|
148
|
+
) -> Optional[str]:
|
|
149
|
+
"""Prompt user for an answer to a single question.
|
|
150
|
+
|
|
151
|
+
Returns the answer string, or None if cancelled.
|
|
152
|
+
"""
|
|
153
|
+
loop = asyncio.get_running_loop()
|
|
154
|
+
|
|
155
|
+
def _prompt() -> Optional[str]:
|
|
156
|
+
try:
|
|
157
|
+
from prompt_toolkit import prompt as pt_prompt
|
|
158
|
+
|
|
159
|
+
prompt_text = format_question_prompt(question, question_num, total)
|
|
160
|
+
print(prompt_text, end="")
|
|
161
|
+
|
|
162
|
+
while True:
|
|
163
|
+
response = pt_prompt("").strip()
|
|
164
|
+
|
|
165
|
+
if not response:
|
|
166
|
+
print(" Please enter a valid choice.")
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
if response.lower() in ("q", "quit", "cancel", "exit"):
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
if response.lower() == "o" or response == str(len(question.options) + 1):
|
|
173
|
+
# Other option selected
|
|
174
|
+
print(" Enter your custom answer: ", end="")
|
|
175
|
+
custom = pt_prompt("")
|
|
176
|
+
if custom.strip():
|
|
177
|
+
return custom.strip()
|
|
178
|
+
print(" Custom answer cannot be empty.")
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
if question.multiSelect:
|
|
182
|
+
# Parse comma-separated numbers
|
|
183
|
+
try:
|
|
184
|
+
indices = [int(x.strip()) for x in response.split(",")]
|
|
185
|
+
valid_range = range(1, len(question.options) + 2)
|
|
186
|
+
if all(i in valid_range for i in indices):
|
|
187
|
+
selected = []
|
|
188
|
+
for i in indices:
|
|
189
|
+
if i == len(question.options) + 1:
|
|
190
|
+
# Other option
|
|
191
|
+
print(" Enter your custom answer: ", end="")
|
|
192
|
+
custom = pt_prompt("")
|
|
193
|
+
if custom.strip():
|
|
194
|
+
selected.append(custom.strip())
|
|
195
|
+
else:
|
|
196
|
+
selected.append(question.options[i - 1].label)
|
|
197
|
+
if selected:
|
|
198
|
+
return ", ".join(selected)
|
|
199
|
+
print(
|
|
200
|
+
f" Invalid selection. Enter numbers from 1 to {len(question.options) + 1}."
|
|
201
|
+
)
|
|
202
|
+
except ValueError:
|
|
203
|
+
print(" Invalid input. Enter numbers separated by commas.")
|
|
204
|
+
else:
|
|
205
|
+
# Single selection
|
|
206
|
+
try:
|
|
207
|
+
choice = int(response)
|
|
208
|
+
if 1 <= choice <= len(question.options):
|
|
209
|
+
return question.options[choice - 1].label
|
|
210
|
+
elif choice == len(question.options) + 1:
|
|
211
|
+
# Other option
|
|
212
|
+
print(" Enter your custom answer: ", end="")
|
|
213
|
+
custom = pt_prompt("")
|
|
214
|
+
if custom.strip():
|
|
215
|
+
return custom.strip()
|
|
216
|
+
print(" Custom answer cannot be empty.")
|
|
217
|
+
else:
|
|
218
|
+
print(
|
|
219
|
+
f" Invalid choice. Enter a number from 1 to {len(question.options) + 1}."
|
|
220
|
+
)
|
|
221
|
+
except ValueError:
|
|
222
|
+
print(" Invalid input. Enter a number.")
|
|
223
|
+
|
|
224
|
+
except KeyboardInterrupt:
|
|
225
|
+
return None
|
|
226
|
+
except EOFError:
|
|
227
|
+
return None
|
|
228
|
+
except (OSError, RuntimeError, ValueError) as e:
|
|
229
|
+
logger.warning(
|
|
230
|
+
"[ask_user_question_tool] Error during prompt: %s: %s",
|
|
231
|
+
type(e).__name__, e,
|
|
232
|
+
)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
return await loop.run_in_executor(None, _prompt)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def collect_answers(
|
|
239
|
+
questions: List[QuestionInput], initial_answers: Dict[str, str]
|
|
240
|
+
) -> tuple[Dict[str, str], bool]:
|
|
241
|
+
"""Collect answers for all questions.
|
|
242
|
+
|
|
243
|
+
Returns (answers_dict, cancelled_flag).
|
|
244
|
+
"""
|
|
245
|
+
answers = dict(initial_answers)
|
|
246
|
+
total = len(questions)
|
|
247
|
+
|
|
248
|
+
for idx, question in enumerate(questions, start=1):
|
|
249
|
+
# Skip if already answered
|
|
250
|
+
if question.question in answers and answers[question.question]:
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
answer = await prompt_user_for_answer(question, idx, total)
|
|
254
|
+
if answer is None:
|
|
255
|
+
return answers, True # Cancelled
|
|
256
|
+
answers[question.question] = answer
|
|
257
|
+
|
|
258
|
+
return answers, False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutput]):
|
|
262
|
+
"""Tool for asking the user questions interactively."""
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def name(self) -> str:
|
|
266
|
+
return TOOL_NAME
|
|
267
|
+
|
|
268
|
+
async def description(self) -> str:
|
|
269
|
+
return (
|
|
270
|
+
"Asks the user multiple choice questions to gather information, "
|
|
271
|
+
"clarify ambiguity, understand preferences, make decisions or offer them choices."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def input_schema(self) -> type[AskUserQuestionToolInput]:
|
|
276
|
+
return AskUserQuestionToolInput
|
|
277
|
+
|
|
278
|
+
async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
|
|
279
|
+
return ASK_USER_QUESTION_PROMPT
|
|
280
|
+
|
|
281
|
+
def user_facing_name(self) -> str:
|
|
282
|
+
return ""
|
|
283
|
+
|
|
284
|
+
def is_read_only(self) -> bool:
|
|
285
|
+
return True
|
|
286
|
+
|
|
287
|
+
def is_concurrency_safe(self) -> bool:
|
|
288
|
+
return True
|
|
289
|
+
|
|
290
|
+
def needs_permissions(
|
|
291
|
+
self,
|
|
292
|
+
input_data: Optional[AskUserQuestionToolInput] = None, # noqa: ARG002
|
|
293
|
+
) -> bool:
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
async def validate_input(
|
|
297
|
+
self,
|
|
298
|
+
input_data: AskUserQuestionToolInput,
|
|
299
|
+
context: Optional[ToolUseContext] = None, # noqa: ARG002
|
|
300
|
+
) -> ValidationResult:
|
|
301
|
+
"""Validate that question texts and option labels are unique."""
|
|
302
|
+
seen_questions: set[str] = set()
|
|
303
|
+
|
|
304
|
+
for question in input_data.questions:
|
|
305
|
+
if question.question in seen_questions:
|
|
306
|
+
return ValidationResult(result=False, message="Question texts must be unique")
|
|
307
|
+
seen_questions.add(question.question)
|
|
308
|
+
|
|
309
|
+
option_labels: set[str] = set()
|
|
310
|
+
for option in question.options:
|
|
311
|
+
if option.label in option_labels:
|
|
312
|
+
return ValidationResult(
|
|
313
|
+
result=False,
|
|
314
|
+
message=f'Option labels for "{question.question}" must be unique',
|
|
315
|
+
)
|
|
316
|
+
option_labels.add(option.label)
|
|
317
|
+
|
|
318
|
+
return ValidationResult(result=True)
|
|
319
|
+
|
|
320
|
+
def render_result_for_assistant(self, output: AskUserQuestionToolOutput) -> str:
|
|
321
|
+
"""Render the tool output for the AI assistant."""
|
|
322
|
+
if output.cancelled:
|
|
323
|
+
return "User declined to answer your questions."
|
|
324
|
+
|
|
325
|
+
if not output.answers:
|
|
326
|
+
return "User did not provide any answers."
|
|
327
|
+
|
|
328
|
+
serialized = ", ".join(
|
|
329
|
+
f'"{question}"="{answer}"' for question, answer in output.answers.items()
|
|
330
|
+
)
|
|
331
|
+
return (
|
|
332
|
+
f"User has answered your questions: {serialized}. "
|
|
333
|
+
"You can now continue with the user's answers in mind."
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def render_tool_use_message(
|
|
337
|
+
self,
|
|
338
|
+
input_data: AskUserQuestionToolInput,
|
|
339
|
+
verbose: bool = False, # noqa: ARG002
|
|
340
|
+
) -> str:
|
|
341
|
+
"""Render the tool use message for display."""
|
|
342
|
+
question_count = len(input_data.questions)
|
|
343
|
+
if question_count == 1:
|
|
344
|
+
return f"Asking user: {input_data.questions[0].question}"
|
|
345
|
+
return f"Asking user {question_count} questions"
|
|
346
|
+
|
|
347
|
+
async def call(
|
|
348
|
+
self,
|
|
349
|
+
input_data: AskUserQuestionToolInput,
|
|
350
|
+
context: ToolUseContext,
|
|
351
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
352
|
+
"""Execute the tool to ask user questions."""
|
|
353
|
+
questions = input_data.questions
|
|
354
|
+
initial_answers = input_data.answers or {}
|
|
355
|
+
|
|
356
|
+
# Pause UI spinner before user interaction
|
|
357
|
+
if context.pause_ui:
|
|
358
|
+
try:
|
|
359
|
+
context.pause_ui()
|
|
360
|
+
except (RuntimeError, ValueError, OSError):
|
|
361
|
+
logger.debug("[ask_user_question_tool] Failed to pause UI")
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
# Display introduction
|
|
365
|
+
loop = asyncio.get_running_loop()
|
|
366
|
+
|
|
367
|
+
def _print_intro() -> None:
|
|
368
|
+
print("\n" + "=" * 60)
|
|
369
|
+
print("I need a few answers to proceed:")
|
|
370
|
+
print("=" * 60)
|
|
371
|
+
|
|
372
|
+
await loop.run_in_executor(None, _print_intro)
|
|
373
|
+
|
|
374
|
+
# Collect answers
|
|
375
|
+
answers, cancelled = await collect_answers(questions, initial_answers)
|
|
376
|
+
|
|
377
|
+
if cancelled:
|
|
378
|
+
output = AskUserQuestionToolOutput(
|
|
379
|
+
questions=questions,
|
|
380
|
+
answers=answers,
|
|
381
|
+
cancelled=True,
|
|
382
|
+
)
|
|
383
|
+
yield ToolResult(
|
|
384
|
+
data=output,
|
|
385
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
386
|
+
)
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
# Display summary
|
|
390
|
+
def _print_summary() -> None:
|
|
391
|
+
print("\n" + "-" * 40)
|
|
392
|
+
print("Your answers:")
|
|
393
|
+
for q, a in answers.items():
|
|
394
|
+
print(f" - {q}")
|
|
395
|
+
print(f" -> {a}")
|
|
396
|
+
print("-" * 40 + "\n")
|
|
397
|
+
|
|
398
|
+
await loop.run_in_executor(None, _print_summary)
|
|
399
|
+
|
|
400
|
+
output = AskUserQuestionToolOutput(
|
|
401
|
+
questions=questions,
|
|
402
|
+
answers=answers,
|
|
403
|
+
cancelled=False,
|
|
404
|
+
)
|
|
405
|
+
yield ToolResult(
|
|
406
|
+
data=output,
|
|
407
|
+
result_for_assistant=self.render_result_for_assistant(output),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
except (OSError, RuntimeError, ValueError, KeyError) as exc:
|
|
411
|
+
logger.warning(
|
|
412
|
+
"[ask_user_question_tool] Error collecting answers: %s: %s",
|
|
413
|
+
type(exc).__name__, exc,
|
|
414
|
+
)
|
|
415
|
+
output = AskUserQuestionToolOutput(
|
|
416
|
+
questions=questions,
|
|
417
|
+
answers={},
|
|
418
|
+
cancelled=True,
|
|
419
|
+
)
|
|
420
|
+
yield ToolResult(
|
|
421
|
+
data=output,
|
|
422
|
+
result_for_assistant="Error while collecting user answers: " + str(exc),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
finally:
|
|
426
|
+
# Resume UI spinner after user interaction
|
|
427
|
+
if context.resume_ui:
|
|
428
|
+
try:
|
|
429
|
+
context.resume_ui()
|
|
430
|
+
except (RuntimeError, ValueError, OSError):
|
|
431
|
+
logger.debug("[ask_user_question_tool] Failed to resume UI")
|
|
@@ -49,6 +49,14 @@ _loop_lock = threading.Lock()
|
|
|
49
49
|
_shutdown_registered = False
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
def _safe_log_exception(message: str, **extra: Any) -> None:
|
|
53
|
+
"""Log an exception but never let logging failures bubble up."""
|
|
54
|
+
try:
|
|
55
|
+
logger.exception(message, extra=extra)
|
|
56
|
+
except (OSError, RuntimeError, ValueError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
52
60
|
def _ensure_background_loop() -> asyncio.AbstractEventLoop:
|
|
53
61
|
"""Create (or return) a dedicated loop for background processes."""
|
|
54
62
|
global _background_loop, _background_thread
|
|
@@ -106,7 +114,9 @@ async def _pump_stream(stream: asyncio.StreamReader, sink: List[str]) -> None:
|
|
|
106
114
|
text = chunk.decode("utf-8", errors="replace")
|
|
107
115
|
with _tasks_lock:
|
|
108
116
|
sink.append(text)
|
|
109
|
-
except
|
|
117
|
+
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
118
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
119
|
+
return # Normal cancellation
|
|
110
120
|
# Best effort; ignore stream read errors to avoid leaking tasks.
|
|
111
121
|
logger.debug(
|
|
112
122
|
f"Stream pump error for background task: {exc}",
|
|
@@ -149,9 +159,10 @@ async def _monitor_task(task: BackgroundTask) -> None:
|
|
|
149
159
|
task.exit_code = -1
|
|
150
160
|
except asyncio.CancelledError:
|
|
151
161
|
return
|
|
152
|
-
except
|
|
153
|
-
logger.
|
|
154
|
-
"Error monitoring background task",
|
|
162
|
+
except (OSError, RuntimeError, ProcessLookupError) as exc:
|
|
163
|
+
logger.warning(
|
|
164
|
+
"Error monitoring background task: %s: %s",
|
|
165
|
+
type(exc).__name__, exc,
|
|
155
166
|
extra={"task_id": task.id, "command": task.command},
|
|
156
167
|
)
|
|
157
168
|
with _tasks_lock:
|
|
@@ -301,11 +312,8 @@ def list_background_tasks() -> List[str]:
|
|
|
301
312
|
return list(_tasks.keys())
|
|
302
313
|
|
|
303
314
|
|
|
304
|
-
def
|
|
305
|
-
"""
|
|
306
|
-
global _background_loop, _background_thread
|
|
307
|
-
|
|
308
|
-
loop = _background_loop
|
|
315
|
+
async def _shutdown_loop(loop: asyncio.AbstractEventLoop) -> None:
|
|
316
|
+
"""Drain running background processes before stopping the loop."""
|
|
309
317
|
with _tasks_lock:
|
|
310
318
|
tasks = list(_tasks.values())
|
|
311
319
|
_tasks.clear()
|
|
@@ -313,21 +321,69 @@ def shutdown_background_shell() -> None:
|
|
|
313
321
|
for task in tasks:
|
|
314
322
|
try:
|
|
315
323
|
task.killed = True
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
324
|
+
with contextlib.suppress(ProcessLookupError):
|
|
325
|
+
task.process.kill()
|
|
326
|
+
try:
|
|
327
|
+
with contextlib.suppress(ProcessLookupError):
|
|
328
|
+
await asyncio.wait_for(task.process.wait(), timeout=1.5)
|
|
329
|
+
except asyncio.TimeoutError:
|
|
330
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
331
|
+
task.process.kill()
|
|
332
|
+
with contextlib.suppress(asyncio.TimeoutError, ProcessLookupError):
|
|
333
|
+
await asyncio.wait_for(task.process.wait(), timeout=0.5)
|
|
334
|
+
task.exit_code = task.process.returncode or -1
|
|
335
|
+
except (OSError, RuntimeError, asyncio.CancelledError) as exc:
|
|
336
|
+
if not isinstance(exc, asyncio.CancelledError):
|
|
337
|
+
_safe_log_exception(
|
|
338
|
+
"Error shutting down background task",
|
|
339
|
+
task_id=task.id,
|
|
340
|
+
command=task.command,
|
|
341
|
+
)
|
|
342
|
+
finally:
|
|
343
|
+
await _finalize_reader_tasks(task.reader_tasks)
|
|
344
|
+
task.done_event.set()
|
|
323
345
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
346
|
+
current = asyncio.current_task()
|
|
347
|
+
pending = [t for t in asyncio.all_tasks(loop) if t is not current]
|
|
348
|
+
for pending_task in pending:
|
|
349
|
+
pending_task.cancel()
|
|
350
|
+
if pending:
|
|
351
|
+
with contextlib.suppress(Exception):
|
|
352
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
353
|
+
|
|
354
|
+
with contextlib.suppress(Exception):
|
|
355
|
+
await loop.shutdown_asyncgens()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def shutdown_background_shell() -> None:
|
|
359
|
+
"""Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings."""
|
|
360
|
+
global _background_loop, _background_thread
|
|
361
|
+
|
|
362
|
+
loop = _background_loop
|
|
363
|
+
thread = _background_thread
|
|
364
|
+
|
|
365
|
+
if not loop or loop.is_closed():
|
|
366
|
+
_background_loop = None
|
|
367
|
+
_background_thread = None
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
if loop.is_running():
|
|
372
|
+
try:
|
|
373
|
+
fut = asyncio.run_coroutine_threadsafe(_shutdown_loop(loop), loop)
|
|
374
|
+
fut.result(timeout=3)
|
|
375
|
+
except (RuntimeError, TimeoutError, concurrent.futures.TimeoutError):
|
|
376
|
+
logger.debug("Failed to cleanly shutdown background loop", exc_info=True)
|
|
377
|
+
try:
|
|
378
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
379
|
+
except (RuntimeError, OSError):
|
|
380
|
+
logger.debug("Failed to stop background loop", exc_info=True)
|
|
381
|
+
else:
|
|
382
|
+
loop.run_until_complete(_shutdown_loop(loop))
|
|
383
|
+
finally:
|
|
384
|
+
if thread and thread.is_alive():
|
|
385
|
+
thread.join(timeout=2)
|
|
386
|
+
with contextlib.suppress(Exception):
|
|
387
|
+
loop.close()
|
|
388
|
+
_background_loop = None
|
|
389
|
+
_background_thread = None
|