tsugite-cli 0.3.3__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.
- tsugite/__init__.py +6 -0
- tsugite/agent_composition.py +163 -0
- tsugite/agent_inheritance.py +479 -0
- tsugite/agent_preparation.py +236 -0
- tsugite/agent_runner/__init__.py +45 -0
- tsugite/agent_runner/helpers.py +106 -0
- tsugite/agent_runner/history_integration.py +248 -0
- tsugite/agent_runner/metrics.py +100 -0
- tsugite/agent_runner/runner.py +1879 -0
- tsugite/agent_runner/validation.py +70 -0
- tsugite/agent_utils.py +167 -0
- tsugite/attachments/__init__.py +65 -0
- tsugite/attachments/auto_context.py +199 -0
- tsugite/attachments/base.py +34 -0
- tsugite/attachments/file.py +51 -0
- tsugite/attachments/inline.py +31 -0
- tsugite/attachments/storage.py +178 -0
- tsugite/attachments/url.py +59 -0
- tsugite/attachments/youtube.py +101 -0
- tsugite/benchmark/__init__.py +62 -0
- tsugite/benchmark/config.py +183 -0
- tsugite/benchmark/core.py +292 -0
- tsugite/benchmark/discovery.py +377 -0
- tsugite/benchmark/evaluators.py +671 -0
- tsugite/benchmark/execution.py +657 -0
- tsugite/benchmark/metrics.py +204 -0
- tsugite/benchmark/reports.py +420 -0
- tsugite/benchmark/utils.py +288 -0
- tsugite/builtin_agents/chat-assistant.md +53 -0
- tsugite/builtin_agents/default.md +140 -0
- tsugite/builtin_agents.py +5 -0
- tsugite/cache.py +195 -0
- tsugite/cli/__init__.py +1042 -0
- tsugite/cli/agents.py +148 -0
- tsugite/cli/attachments.py +193 -0
- tsugite/cli/benchmark.py +663 -0
- tsugite/cli/cache.py +113 -0
- tsugite/cli/config.py +272 -0
- tsugite/cli/helpers.py +534 -0
- tsugite/cli/history.py +193 -0
- tsugite/cli/init.py +387 -0
- tsugite/cli/mcp.py +193 -0
- tsugite/cli/tools.py +419 -0
- tsugite/config.py +204 -0
- tsugite/console.py +48 -0
- tsugite/constants.py +21 -0
- tsugite/core/__init__.py +19 -0
- tsugite/core/agent.py +774 -0
- tsugite/core/executor.py +300 -0
- tsugite/core/memory.py +67 -0
- tsugite/core/tools.py +271 -0
- tsugite/docker_cli.py +270 -0
- tsugite/events/__init__.py +55 -0
- tsugite/events/base.py +46 -0
- tsugite/events/bus.py +62 -0
- tsugite/events/events.py +224 -0
- tsugite/exceptions.py +40 -0
- tsugite/history/__init__.py +29 -0
- tsugite/history/index.py +210 -0
- tsugite/history/models.py +106 -0
- tsugite/history/storage.py +157 -0
- tsugite/mcp_client.py +219 -0
- tsugite/mcp_config.py +174 -0
- tsugite/md_agents.py +751 -0
- tsugite/models.py +257 -0
- tsugite/renderer.py +151 -0
- tsugite/shell_tool_config.py +265 -0
- tsugite/templates/assistant.md +14 -0
- tsugite/tools/__init__.py +265 -0
- tsugite/tools/agents.py +312 -0
- tsugite/tools/edit_strategies.py +393 -0
- tsugite/tools/fs.py +329 -0
- tsugite/tools/http.py +239 -0
- tsugite/tools/interactive.py +430 -0
- tsugite/tools/shell.py +129 -0
- tsugite/tools/shell_tools.py +214 -0
- tsugite/tools/tasks.py +339 -0
- tsugite/tsugite.py +7 -0
- tsugite/ui/__init__.py +46 -0
- tsugite/ui/base.py +638 -0
- tsugite/ui/chat.py +265 -0
- tsugite/ui/chat.tcss +92 -0
- tsugite/ui/chat_history.py +286 -0
- tsugite/ui/helpers.py +102 -0
- tsugite/ui/jsonl.py +125 -0
- tsugite/ui/live_template.py +529 -0
- tsugite/ui/plain.py +419 -0
- tsugite/ui/textual_chat.py +642 -0
- tsugite/ui/textual_handler.py +225 -0
- tsugite/ui/widgets/__init__.py +6 -0
- tsugite/ui/widgets/base_scroll_log.py +27 -0
- tsugite/ui/widgets/message_list.py +121 -0
- tsugite/ui/widgets/thought_log.py +80 -0
- tsugite/ui_context.py +90 -0
- tsugite/utils.py +367 -0
- tsugite/xdg.py +104 -0
- tsugite_cli-0.3.3.dist-info/METADATA +325 -0
- tsugite_cli-0.3.3.dist-info/RECORD +101 -0
- tsugite_cli-0.3.3.dist-info/WHEEL +4 -0
- tsugite_cli-0.3.3.dist-info/entry_points.txt +5 -0
- tsugite_cli-0.3.3.dist-info/licenses/LICENSE +235 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Interactive user input tools for Tsugite agents."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
import termios
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
import nest_asyncio
|
|
11
|
+
import questionary
|
|
12
|
+
from questionary import Style
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.prompt import Confirm, Prompt
|
|
15
|
+
|
|
16
|
+
from ..tools import tool
|
|
17
|
+
from ..ui_context import paused_progress
|
|
18
|
+
from ..utils import is_interactive, validation_error
|
|
19
|
+
|
|
20
|
+
# Allow nested event loops (needed for questionary in async contexts)
|
|
21
|
+
nest_asyncio.apply()
|
|
22
|
+
|
|
23
|
+
# Custom style for questionary to match Rich theme
|
|
24
|
+
QUESTIONARY_STYLE = Style(
|
|
25
|
+
[
|
|
26
|
+
("qmark", "fg:cyan bold"), # Question mark
|
|
27
|
+
("question", "fg:cyan bold"), # Question text
|
|
28
|
+
("answer", "fg:yellow bold"), # Selected answer
|
|
29
|
+
("pointer", "fg:yellow bold"), # Selection pointer
|
|
30
|
+
("highlighted", "fg:yellow bold"), # Highlighted option
|
|
31
|
+
("selected", "fg:green"), # Already selected (for checkbox)
|
|
32
|
+
("instruction", "fg:white"), # Instructions - white/default for better readability
|
|
33
|
+
]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _flush_input_buffer() -> None:
|
|
38
|
+
"""Flush any pending input from stdin to prevent accidental key presses.
|
|
39
|
+
|
|
40
|
+
This prevents issues where a user accidentally hits Enter twice and
|
|
41
|
+
unintentionally confirms a pre-selected option.
|
|
42
|
+
"""
|
|
43
|
+
if not is_interactive():
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
# Save current terminal settings
|
|
48
|
+
fd = sys.stdin.fileno()
|
|
49
|
+
old_settings = termios.tcgetattr(fd)
|
|
50
|
+
|
|
51
|
+
# Flush input buffer
|
|
52
|
+
termios.tcflush(fd, termios.TCIFLUSH)
|
|
53
|
+
|
|
54
|
+
# Restore settings
|
|
55
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
56
|
+
except (termios.error, OSError):
|
|
57
|
+
# If we can't flush (e.g., not a real TTY), just continue
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _generate_id_from_question(question: str) -> str:
|
|
62
|
+
"""Generate a valid ID from question text.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
question: The question text
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A lowercase, underscored ID based on the question
|
|
69
|
+
"""
|
|
70
|
+
# Remove punctuation and convert to lowercase
|
|
71
|
+
clean = re.sub(r"[^\w\s]", "", question.lower())
|
|
72
|
+
# Replace spaces with underscores
|
|
73
|
+
id_str = re.sub(r"\s+", "_", clean.strip())
|
|
74
|
+
# Truncate if too long
|
|
75
|
+
if len(id_str) > 50:
|
|
76
|
+
id_str = id_str[:50]
|
|
77
|
+
# Remove trailing underscores
|
|
78
|
+
id_str = id_str.rstrip("_")
|
|
79
|
+
return id_str or "question"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@tool
|
|
83
|
+
def ask_user(question: str, question_type: str = "text", options: Optional[List[str]] = None) -> str:
|
|
84
|
+
"""Ask the user a question interactively.
|
|
85
|
+
|
|
86
|
+
This tool allows the LLM to ask the user for input during agent execution.
|
|
87
|
+
It supports three types of questions:
|
|
88
|
+
- text: Freeform text input
|
|
89
|
+
- yes_no: Binary yes/no question (returns "yes" or "no")
|
|
90
|
+
- choice: Multiple choice from a list of options
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
question: The question to ask the user
|
|
94
|
+
question_type: Type of question - "text", "yes_no", or "choice"
|
|
95
|
+
options: List of options for "choice" type questions (required for choice type)
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
User's response as a string
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ValueError: If not in interactive mode or invalid parameters
|
|
102
|
+
RuntimeError: If user interaction fails
|
|
103
|
+
"""
|
|
104
|
+
if not is_interactive():
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
"Cannot use ask_user tool in non-interactive mode. "
|
|
107
|
+
"This tool requires a terminal with user input capability."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Validate question type
|
|
111
|
+
valid_types = ["text", "yes_no", "choice"]
|
|
112
|
+
if question_type not in valid_types:
|
|
113
|
+
raise validation_error(
|
|
114
|
+
"question_type",
|
|
115
|
+
question_type,
|
|
116
|
+
f"must be one of {', '.join(valid_types)}",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Validate options for choice type
|
|
120
|
+
if question_type == "choice":
|
|
121
|
+
if not options or len(options) < 2:
|
|
122
|
+
raise validation_error(
|
|
123
|
+
"options",
|
|
124
|
+
str(options),
|
|
125
|
+
"must provide at least 2 options for choice type questions",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
with terminal_context() as console:
|
|
130
|
+
return handle_question_by_type(question_type, question, options, console, _flush_input_buffer)
|
|
131
|
+
except KeyboardInterrupt:
|
|
132
|
+
raise RuntimeError("User input interrupted by keyboard interrupt")
|
|
133
|
+
except Exception as e:
|
|
134
|
+
raise RuntimeError(f"Failed to get user input: {e}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@tool
|
|
138
|
+
def ask_user_batch(questions: List[dict]) -> dict:
|
|
139
|
+
"""Ask the user multiple questions at once and collect all responses.
|
|
140
|
+
|
|
141
|
+
This tool allows the LLM to ask multiple questions in a batch, showing all questions
|
|
142
|
+
upfront and collecting all answers before returning to the agent. This provides a
|
|
143
|
+
better user experience for multi-field forms or related questions.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
questions: List of question dictionaries. REQUIRED fields per question:
|
|
147
|
+
- question (str, REQUIRED): The question text to display
|
|
148
|
+
- type (str, REQUIRED): Question type - "text", "yes_no", or "choice"
|
|
149
|
+
(can also use "question_type" for consistency with ask_user)
|
|
150
|
+
- options (List[str]): Options for choice type (REQUIRED when type="choice")
|
|
151
|
+
- id (str, OPTIONAL): Unique identifier for response dict key.
|
|
152
|
+
If not provided, auto-generated from question text.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Dictionary mapping question IDs to user responses
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
ValueError: If not in interactive mode or invalid question structure
|
|
159
|
+
RuntimeError: If user interaction fails
|
|
160
|
+
|
|
161
|
+
Example:
|
|
162
|
+
# With explicit IDs
|
|
163
|
+
responses = ask_user_batch(questions=[
|
|
164
|
+
{"id": "email", "question": "What is your email?", "type": "text"},
|
|
165
|
+
{"id": "save", "question": "Save to file?", "type": "yes_no"},
|
|
166
|
+
{"id": "format", "question": "Choose format:", "type": "choice", "options": ["json", "txt"]}
|
|
167
|
+
])
|
|
168
|
+
# Returns: {"email": "user@example.com", "save": "yes", "format": "json"}
|
|
169
|
+
|
|
170
|
+
# Without IDs (auto-generated from questions)
|
|
171
|
+
responses = ask_user_batch(questions=[
|
|
172
|
+
{"question": "What is your name?", "type": "text"},
|
|
173
|
+
{"question": "Save to file?", "type": "yes_no"},
|
|
174
|
+
{"question": "Choose format:", "type": "choice", "options": ["json", "txt", "md"]}
|
|
175
|
+
])
|
|
176
|
+
# Returns: {"what_is_your_name": "Alice", "save_to_file": "yes", "choose_format": "json"}
|
|
177
|
+
"""
|
|
178
|
+
if not is_interactive():
|
|
179
|
+
raise RuntimeError(
|
|
180
|
+
"Cannot use ask_user_batch tool in non-interactive mode. "
|
|
181
|
+
"This tool requires a terminal with user input capability."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Validate questions list
|
|
185
|
+
if not questions or not isinstance(questions, list):
|
|
186
|
+
raise validation_error(
|
|
187
|
+
"questions",
|
|
188
|
+
str(questions),
|
|
189
|
+
"must be a non-empty list of question dictionaries",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Validate each question structure and auto-generate IDs if needed
|
|
193
|
+
valid_types = ["text", "yes_no", "choice"]
|
|
194
|
+
validate_batch_questions(questions, valid_types)
|
|
195
|
+
|
|
196
|
+
# Collect responses
|
|
197
|
+
responses = {}
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
with terminal_context() as console:
|
|
201
|
+
console.print("\n[bold cyan]Please answer the following questions:[/bold cyan]\n")
|
|
202
|
+
|
|
203
|
+
for i, q in enumerate(questions, 1):
|
|
204
|
+
q_id = q["id"]
|
|
205
|
+
q_text = q["question"]
|
|
206
|
+
q_type = q["type"]
|
|
207
|
+
options = q.get("options")
|
|
208
|
+
|
|
209
|
+
# Show question number
|
|
210
|
+
console.print(f"[dim]Question {i}/{len(questions)}[/dim]")
|
|
211
|
+
|
|
212
|
+
# Handle question based on type
|
|
213
|
+
answer = handle_question_by_type(q_type, q_text, options, console, _flush_input_buffer)
|
|
214
|
+
responses[q_id] = answer
|
|
215
|
+
|
|
216
|
+
# Add spacing between questions (except after last one)
|
|
217
|
+
if i < len(questions):
|
|
218
|
+
console.print()
|
|
219
|
+
|
|
220
|
+
console.print("\n[green]✓ All questions answered[/green]\n")
|
|
221
|
+
|
|
222
|
+
# Write summary of answers to captured stdout so it appears in observation for LLM
|
|
223
|
+
print("\nUser responses:")
|
|
224
|
+
for q_id, answer in responses.items():
|
|
225
|
+
print(f" {q_id}: {answer}")
|
|
226
|
+
|
|
227
|
+
except KeyboardInterrupt:
|
|
228
|
+
raise RuntimeError("User input interrupted by keyboard interrupt")
|
|
229
|
+
except Exception as e:
|
|
230
|
+
raise RuntimeError(f"Failed to get user input: {e}")
|
|
231
|
+
|
|
232
|
+
return responses
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@contextmanager
|
|
236
|
+
def terminal_context():
|
|
237
|
+
"""Context manager for terminal I/O with proper stdin/stdout handling.
|
|
238
|
+
|
|
239
|
+
Yields:
|
|
240
|
+
Console: A terminal console that writes directly to real terminal
|
|
241
|
+
"""
|
|
242
|
+
terminal_console = Console(file=sys.__stdout__, force_terminal=True)
|
|
243
|
+
old_stdin = sys.stdin
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
# Restore real terminal stdin for user input
|
|
247
|
+
sys.stdin = sys.__stdin__
|
|
248
|
+
|
|
249
|
+
# Pause progress spinner while showing prompts
|
|
250
|
+
with paused_progress():
|
|
251
|
+
yield terminal_console
|
|
252
|
+
finally:
|
|
253
|
+
# Restore stdin for executor
|
|
254
|
+
sys.stdin = old_stdin
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def ask_text_question(question: str, console: Console, flush_fn) -> str:
|
|
258
|
+
"""Ask a freeform text question.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
question: Question text
|
|
262
|
+
console: Rich console for output
|
|
263
|
+
flush_fn: Function to flush input buffer
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
User's text response
|
|
267
|
+
"""
|
|
268
|
+
console.print(f"\n[cyan]Question:[/cyan] {question}")
|
|
269
|
+
flush_fn()
|
|
270
|
+
response = Prompt.ask("[yellow]Your answer[/yellow]", console=console)
|
|
271
|
+
print(f"User answered: {response}")
|
|
272
|
+
return response
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def ask_yes_no_question(question: str, console: Console, flush_fn) -> str:
|
|
276
|
+
"""Ask a yes/no question.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
question: Question text
|
|
280
|
+
console: Rich console for output
|
|
281
|
+
flush_fn: Function to flush input buffer
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
"yes" or "no"
|
|
285
|
+
"""
|
|
286
|
+
console.print(f"\n[cyan]Question:[/cyan] {question}")
|
|
287
|
+
flush_fn()
|
|
288
|
+
result = Confirm.ask("[yellow]Your answer[/yellow]", console=console)
|
|
289
|
+
answer = "yes" if result else "no"
|
|
290
|
+
print(f"User answered: {answer}")
|
|
291
|
+
return answer
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def ask_choice_question(question: str, options: List[str], console: Console, flush_fn) -> str:
|
|
295
|
+
"""Ask a multiple choice question with arrow key navigation.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
question: Question text
|
|
299
|
+
options: List of choice options
|
|
300
|
+
console: Rich console for output
|
|
301
|
+
flush_fn: Function to flush input buffer
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Selected option
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
KeyboardInterrupt: If user cancels with Ctrl+C
|
|
308
|
+
"""
|
|
309
|
+
console.print() # Add blank line for spacing
|
|
310
|
+
flush_fn()
|
|
311
|
+
|
|
312
|
+
# Small delay to prevent rapid double-press issues
|
|
313
|
+
time.sleep(0.15)
|
|
314
|
+
|
|
315
|
+
# Questionary needs real stdout for its TUI - temporarily restore it
|
|
316
|
+
old_stdout = sys.stdout
|
|
317
|
+
sys.stdout = sys.__stdout__
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
answer = questionary.select(
|
|
321
|
+
question,
|
|
322
|
+
choices=options,
|
|
323
|
+
style=QUESTIONARY_STYLE,
|
|
324
|
+
use_arrow_keys=True,
|
|
325
|
+
use_shortcuts=True,
|
|
326
|
+
use_jk_keys=True,
|
|
327
|
+
instruction="(Use arrow keys or j/k, Enter to select)",
|
|
328
|
+
).ask()
|
|
329
|
+
|
|
330
|
+
# questionary returns None on Ctrl+C
|
|
331
|
+
if answer is None:
|
|
332
|
+
raise KeyboardInterrupt()
|
|
333
|
+
|
|
334
|
+
print(f"User answered: {answer}")
|
|
335
|
+
return answer
|
|
336
|
+
finally:
|
|
337
|
+
# Restore captured stdout for executor
|
|
338
|
+
sys.stdout = old_stdout
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def validate_batch_questions(questions: List[dict], valid_types: List[str]) -> None:
|
|
342
|
+
"""Validate batch questions structure and auto-generate IDs.
|
|
343
|
+
|
|
344
|
+
Modifies questions in-place to add auto-generated IDs where missing.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
questions: List of question dictionaries to validate
|
|
348
|
+
valid_types: List of valid question types
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
ValueError: If validation fails
|
|
352
|
+
"""
|
|
353
|
+
seen_ids = set()
|
|
354
|
+
|
|
355
|
+
for i, q in enumerate(questions):
|
|
356
|
+
if not isinstance(q, dict):
|
|
357
|
+
raise validation_error(f"questions[{i}]", str(q), "must be a dictionary")
|
|
358
|
+
|
|
359
|
+
# Check required fields
|
|
360
|
+
if "question" not in q:
|
|
361
|
+
raise validation_error(f"questions[{i}]", str(q), "missing required field 'question'")
|
|
362
|
+
|
|
363
|
+
# Accept both 'type' and 'question_type' for compatibility
|
|
364
|
+
if "type" not in q and "question_type" not in q:
|
|
365
|
+
raise validation_error(f"questions[{i}]", str(q), "missing required field 'type' or 'question_type'")
|
|
366
|
+
|
|
367
|
+
# Normalize to 'type' if 'question_type' was provided
|
|
368
|
+
if "question_type" in q and "type" not in q:
|
|
369
|
+
q["type"] = q["question_type"]
|
|
370
|
+
|
|
371
|
+
# Auto-generate ID if not provided
|
|
372
|
+
if "id" not in q:
|
|
373
|
+
base_id = _generate_id_from_question(q["question"])
|
|
374
|
+
q_id = base_id
|
|
375
|
+
counter = 1
|
|
376
|
+
while q_id in seen_ids:
|
|
377
|
+
q_id = f"{base_id}_{counter}"
|
|
378
|
+
counter += 1
|
|
379
|
+
q["id"] = q_id
|
|
380
|
+
else:
|
|
381
|
+
q_id = q["id"]
|
|
382
|
+
# Check for duplicate explicit IDs
|
|
383
|
+
if q_id in seen_ids:
|
|
384
|
+
raise validation_error(
|
|
385
|
+
f"questions[{i}].id", q_id, "duplicate question ID - all question IDs must be unique"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
seen_ids.add(q_id)
|
|
389
|
+
|
|
390
|
+
# Validate type
|
|
391
|
+
q_type = q["type"]
|
|
392
|
+
if q_type not in valid_types:
|
|
393
|
+
raise validation_error(f"questions[{i}].type", q_type, f"must be one of {', '.join(valid_types)}")
|
|
394
|
+
|
|
395
|
+
# Validate options for choice type
|
|
396
|
+
if q_type == "choice":
|
|
397
|
+
if "options" not in q or not q["options"] or len(q["options"]) < 2:
|
|
398
|
+
raise validation_error(
|
|
399
|
+
f"questions[{i}].options",
|
|
400
|
+
str(q.get("options")),
|
|
401
|
+
"must provide at least 2 options for choice type questions",
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def handle_question_by_type(q_type: str, q_text: str, options: Optional[List[str]], console: Console, flush_fn) -> str:
|
|
406
|
+
"""Handle a question based on its type.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
q_type: Question type ("text", "yes_no", or "choice")
|
|
410
|
+
q_text: Question text
|
|
411
|
+
options: Options for choice questions (required if q_type == "choice")
|
|
412
|
+
console: Rich console for output
|
|
413
|
+
flush_fn: Function to flush input buffer
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
User's response
|
|
417
|
+
|
|
418
|
+
Raises:
|
|
419
|
+
ValueError: If invalid question type or missing options
|
|
420
|
+
"""
|
|
421
|
+
if q_type == "text":
|
|
422
|
+
return ask_text_question(q_text, console, flush_fn)
|
|
423
|
+
elif q_type == "yes_no":
|
|
424
|
+
return ask_yes_no_question(q_text, console, flush_fn)
|
|
425
|
+
elif q_type == "choice":
|
|
426
|
+
if not options:
|
|
427
|
+
raise ValueError("Options required for choice type questions")
|
|
428
|
+
return ask_choice_question(q_text, options, console, flush_fn)
|
|
429
|
+
else:
|
|
430
|
+
raise ValueError(f"Invalid question type: {q_type}")
|
tsugite/tools/shell.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Shell command execution tools for Tsugite agents."""
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from tsugite.tools import tool
|
|
7
|
+
from tsugite.utils import execute_shell_command
|
|
8
|
+
|
|
9
|
+
DANGEROUS_SHELL_SUBSTRINGS = (
|
|
10
|
+
"rm -rf /",
|
|
11
|
+
"sudo rm",
|
|
12
|
+
"dd if=",
|
|
13
|
+
"mkfs",
|
|
14
|
+
"format",
|
|
15
|
+
"> /dev/",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
BLOCKED_SAFE_MODE_COMMANDS = {
|
|
20
|
+
"rm",
|
|
21
|
+
"rmdir",
|
|
22
|
+
"del",
|
|
23
|
+
"format",
|
|
24
|
+
"fdisk",
|
|
25
|
+
"mkfs",
|
|
26
|
+
"sudo",
|
|
27
|
+
"su",
|
|
28
|
+
"chmod",
|
|
29
|
+
"chown",
|
|
30
|
+
"passwd",
|
|
31
|
+
"wget",
|
|
32
|
+
"curl",
|
|
33
|
+
"nc",
|
|
34
|
+
"netcat",
|
|
35
|
+
"ssh",
|
|
36
|
+
"scp",
|
|
37
|
+
"python",
|
|
38
|
+
"perl",
|
|
39
|
+
"ruby",
|
|
40
|
+
"node",
|
|
41
|
+
"bash",
|
|
42
|
+
"sh",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@tool
|
|
47
|
+
def run(command: str, timeout: int = 30, shell: bool = True) -> str:
|
|
48
|
+
"""Execute a shell command and return its output.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
command: Shell command to execute
|
|
52
|
+
timeout: Maximum execution time in seconds (default: 30)
|
|
53
|
+
shell: Whether to use shell execution (default: True)
|
|
54
|
+
"""
|
|
55
|
+
# Basic safety check for dangerous patterns
|
|
56
|
+
for pattern in DANGEROUS_SHELL_SUBSTRINGS:
|
|
57
|
+
if pattern in command.lower():
|
|
58
|
+
raise ValueError(f"Dangerous command pattern detected: {pattern}")
|
|
59
|
+
|
|
60
|
+
return execute_shell_command(command, timeout=timeout, shell=shell)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@tool
|
|
64
|
+
def run_safe(command: str, timeout: int = 30) -> str:
|
|
65
|
+
"""Execute a shell command with additional safety checks.
|
|
66
|
+
|
|
67
|
+
This version disables shell=True for safer execution and has stricter
|
|
68
|
+
command validation.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
command: Command to execute (will be parsed safely)
|
|
72
|
+
timeout: Maximum execution time in seconds (default: 30)
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Command output
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
RuntimeError: If command execution fails or is deemed unsafe
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
cmd_parts = shlex.split(command)
|
|
82
|
+
if not cmd_parts:
|
|
83
|
+
raise ValueError("Empty command")
|
|
84
|
+
|
|
85
|
+
command_name = cmd_parts[0].lower()
|
|
86
|
+
|
|
87
|
+
if command_name in BLOCKED_SAFE_MODE_COMMANDS:
|
|
88
|
+
raise ValueError(f"Command '{command_name}' is not allowed in safe mode")
|
|
89
|
+
|
|
90
|
+
return run(command, timeout=timeout, shell=False)
|
|
91
|
+
|
|
92
|
+
except Exception as e:
|
|
93
|
+
raise RuntimeError(f"Safe command execution failed: {e}") from e
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@tool
|
|
97
|
+
def get_system_info() -> str:
|
|
98
|
+
"""Get basic system information.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
System information including OS, hostname, and current directory
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
info_commands = [
|
|
105
|
+
("OS", "uname -s"),
|
|
106
|
+
("Hostname", "hostname"),
|
|
107
|
+
("Current Directory", "pwd"),
|
|
108
|
+
("Date", "date"),
|
|
109
|
+
("Uptime", "uptime"),
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
results = []
|
|
113
|
+
for label, cmd in info_commands:
|
|
114
|
+
try:
|
|
115
|
+
result = subprocess.run(
|
|
116
|
+
cmd.split(),
|
|
117
|
+
capture_output=True,
|
|
118
|
+
text=True,
|
|
119
|
+
timeout=5,
|
|
120
|
+
check=True,
|
|
121
|
+
)
|
|
122
|
+
results.append(f"{label}: {result.stdout.strip()}")
|
|
123
|
+
except Exception:
|
|
124
|
+
results.append(f"{label}: [unavailable]")
|
|
125
|
+
|
|
126
|
+
return "\n".join(results)
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
return f"Failed to get system info: {e}"
|