tunacode-cli 0.0.66__py3-none-any.whl → 0.0.68__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.
Potentially problematic release.
This version of tunacode-cli might be problematic. Click here for more details.
- tunacode/cli/commands/__init__.py +2 -0
- tunacode/cli/commands/implementations/__init__.py +2 -0
- tunacode/cli/commands/implementations/command_reload.py +48 -0
- tunacode/cli/commands/implementations/quickstart.py +43 -0
- tunacode/cli/commands/implementations/system.py +27 -3
- tunacode/cli/commands/registry.py +131 -1
- tunacode/cli/commands/slash/__init__.py +32 -0
- tunacode/cli/commands/slash/command.py +157 -0
- tunacode/cli/commands/slash/loader.py +134 -0
- tunacode/cli/commands/slash/processor.py +294 -0
- tunacode/cli/commands/slash/types.py +93 -0
- tunacode/cli/commands/slash/validator.py +399 -0
- tunacode/cli/main.py +4 -1
- tunacode/cli/repl.py +25 -0
- tunacode/configuration/defaults.py +1 -0
- tunacode/constants.py +1 -1
- tunacode/core/agents/agent_components/agent_helpers.py +14 -13
- tunacode/core/agents/main.py +1 -1
- tunacode/core/agents/utils.py +4 -3
- tunacode/core/setup/config_setup.py +231 -6
- tunacode/core/setup/coordinator.py +13 -5
- tunacode/core/setup/git_safety_setup.py +5 -1
- tunacode/exceptions.py +119 -5
- tunacode/setup.py +5 -2
- tunacode/tools/glob.py +9 -46
- tunacode/tools/grep.py +9 -51
- tunacode/tools/xml_helper.py +83 -0
- tunacode/tutorial/__init__.py +9 -0
- tunacode/tutorial/content.py +98 -0
- tunacode/tutorial/manager.py +182 -0
- tunacode/tutorial/steps.py +124 -0
- tunacode/ui/output.py +1 -1
- tunacode/utils/user_configuration.py +45 -0
- tunacode_cli-0.0.68.dist-info/METADATA +192 -0
- {tunacode_cli-0.0.66.dist-info → tunacode_cli-0.0.68.dist-info}/RECORD +38 -25
- tunacode_cli-0.0.66.dist-info/METADATA +0 -327
- {tunacode_cli-0.0.66.dist-info → tunacode_cli-0.0.68.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.66.dist-info → tunacode_cli-0.0.68.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.66.dist-info → tunacode_cli-0.0.68.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Security validation for slash command execution."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from .types import SecurityLevel, SecurityViolation, ValidationResult
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommandValidator:
|
|
14
|
+
"""Comprehensive security validation system for slash commands."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, security_level: SecurityLevel = SecurityLevel.MODERATE):
|
|
17
|
+
self.security_level = security_level
|
|
18
|
+
self._init_security_rules()
|
|
19
|
+
|
|
20
|
+
def _init_security_rules(self) -> None:
|
|
21
|
+
"""Initialize security rules based on security level."""
|
|
22
|
+
|
|
23
|
+
# Always blocked commands (all security levels)
|
|
24
|
+
self.ALWAYS_BLOCKED = {
|
|
25
|
+
"rm",
|
|
26
|
+
"rmdir",
|
|
27
|
+
"del",
|
|
28
|
+
"format",
|
|
29
|
+
"fdisk",
|
|
30
|
+
"mkfs",
|
|
31
|
+
"sudo",
|
|
32
|
+
"su",
|
|
33
|
+
"passwd",
|
|
34
|
+
"useradd",
|
|
35
|
+
"userdel",
|
|
36
|
+
"chmod",
|
|
37
|
+
"chown",
|
|
38
|
+
"chgrp",
|
|
39
|
+
"setfacl",
|
|
40
|
+
"iptables",
|
|
41
|
+
"firewall-cmd",
|
|
42
|
+
"ufw",
|
|
43
|
+
"systemctl",
|
|
44
|
+
"service",
|
|
45
|
+
"initctl",
|
|
46
|
+
"wget",
|
|
47
|
+
"curl",
|
|
48
|
+
"nc",
|
|
49
|
+
"netcat",
|
|
50
|
+
"telnet",
|
|
51
|
+
"dd",
|
|
52
|
+
"mount",
|
|
53
|
+
"umount",
|
|
54
|
+
"fsck",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Dangerous patterns (all security levels)
|
|
58
|
+
self.DANGEROUS_PATTERNS = [
|
|
59
|
+
r"rm\s+.*-[rf]", # Recursive/force delete
|
|
60
|
+
r">\s*/dev/", # Device access
|
|
61
|
+
r"\|\s*sh\b", # Pipe to shell
|
|
62
|
+
r"\|\s*bash\b", # Pipe to bash
|
|
63
|
+
r"`[^`]*`", # Command substitution
|
|
64
|
+
r"\$\([^)]*\)", # Command substitution
|
|
65
|
+
r"(?:;|\|\||&&)", # Command chaining (;, ||, &&)
|
|
66
|
+
r"[|&]+\s*$", # Trailing pipes
|
|
67
|
+
r"eval\s+", # Dynamic evaluation
|
|
68
|
+
r"exec\s+", # Process replacement
|
|
69
|
+
r"source\s+", # Script sourcing
|
|
70
|
+
r"\.\s+/", # Script sourcing (dot)
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# Security level specific rules
|
|
74
|
+
if self.security_level == SecurityLevel.STRICT:
|
|
75
|
+
self.ALLOWED_COMMANDS = {
|
|
76
|
+
"git": ["status", "branch", "log", "show"],
|
|
77
|
+
"ls": ["-la", "-l", "-a"],
|
|
78
|
+
"cat": [],
|
|
79
|
+
"echo": [],
|
|
80
|
+
"date": [],
|
|
81
|
+
"pwd": [],
|
|
82
|
+
}
|
|
83
|
+
elif self.security_level == SecurityLevel.MODERATE:
|
|
84
|
+
self.ALLOWED_COMMANDS = {
|
|
85
|
+
"git": ["status", "branch", "log", "diff", "show", "remote", "config"],
|
|
86
|
+
"npm": ["list", "info", "view", "outdated", "audit", "install"],
|
|
87
|
+
"python": ["-c", "-m", "--version", "-V"],
|
|
88
|
+
"node": ["--version", "-v", "-e"],
|
|
89
|
+
"ls": ["-la", "-l", "-a", "-h", "-R"],
|
|
90
|
+
"cat": [],
|
|
91
|
+
"head": [],
|
|
92
|
+
"tail": [],
|
|
93
|
+
"echo": [],
|
|
94
|
+
"date": [],
|
|
95
|
+
"pwd": [],
|
|
96
|
+
"whoami": [],
|
|
97
|
+
"grep": ["-r", "-n", "-i", "-v"],
|
|
98
|
+
"find": ["-name", "-type", "-size"],
|
|
99
|
+
"wc": ["-l", "-w", "-c"],
|
|
100
|
+
"sort": [],
|
|
101
|
+
"uniq": [],
|
|
102
|
+
"cut": [],
|
|
103
|
+
}
|
|
104
|
+
else: # PERMISSIVE
|
|
105
|
+
self.ALLOWED_COMMANDS = {
|
|
106
|
+
# All moderate commands plus more
|
|
107
|
+
"git": ["status", "branch", "log", "diff", "show", "remote", "config"],
|
|
108
|
+
"npm": ["list", "info", "view", "outdated", "audit"],
|
|
109
|
+
"python": ["-c", "-m", "--version", "-V"],
|
|
110
|
+
"node": ["--version", "-v", "-e"],
|
|
111
|
+
"ls": ["-la", "-l", "-a", "-h", "-R"],
|
|
112
|
+
"cat": [],
|
|
113
|
+
"head": [],
|
|
114
|
+
"tail": [],
|
|
115
|
+
"echo": [],
|
|
116
|
+
"date": [],
|
|
117
|
+
"pwd": [],
|
|
118
|
+
"whoami": [],
|
|
119
|
+
"grep": ["-r", "-n", "-i", "-v"],
|
|
120
|
+
"find": ["-name", "-type", "-size"],
|
|
121
|
+
"wc": ["-l", "-w", "-c"],
|
|
122
|
+
"sort": [],
|
|
123
|
+
"uniq": [],
|
|
124
|
+
"cut": [],
|
|
125
|
+
"make": ["--version", "-n"], # Dry run only
|
|
126
|
+
"docker": ["ps", "images", "info", "version"],
|
|
127
|
+
"kubectl": ["get", "describe", "logs", "version"],
|
|
128
|
+
"terraform": ["version", "validate", "plan"],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
def validate_shell_command(self, command: str) -> ValidationResult:
|
|
132
|
+
"""Comprehensive command validation with detailed results."""
|
|
133
|
+
command = command.strip()
|
|
134
|
+
violations = []
|
|
135
|
+
|
|
136
|
+
if not command:
|
|
137
|
+
violations.append(
|
|
138
|
+
SecurityViolation(
|
|
139
|
+
type="empty_command",
|
|
140
|
+
message="Empty command not allowed",
|
|
141
|
+
command=command,
|
|
142
|
+
severity="error",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return ValidationResult(False, violations)
|
|
146
|
+
|
|
147
|
+
# Parse command
|
|
148
|
+
parts = command.split()
|
|
149
|
+
base_command = parts[0].lower()
|
|
150
|
+
|
|
151
|
+
# Check for always blocked commands
|
|
152
|
+
if base_command in self.ALWAYS_BLOCKED:
|
|
153
|
+
violations.append(
|
|
154
|
+
SecurityViolation(
|
|
155
|
+
type="blocked_command",
|
|
156
|
+
message=f"Command '{base_command}' is always blocked",
|
|
157
|
+
command=command,
|
|
158
|
+
severity="error",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
return ValidationResult(False, violations)
|
|
162
|
+
|
|
163
|
+
# Check dangerous patterns
|
|
164
|
+
for pattern in self.DANGEROUS_PATTERNS:
|
|
165
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
166
|
+
violations.append(
|
|
167
|
+
SecurityViolation(
|
|
168
|
+
type="dangerous_pattern",
|
|
169
|
+
message=f"Command matches dangerous pattern: {pattern}",
|
|
170
|
+
command=command,
|
|
171
|
+
severity="error",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
return ValidationResult(False, violations)
|
|
175
|
+
|
|
176
|
+
# Check against whitelist
|
|
177
|
+
if base_command not in self.ALLOWED_COMMANDS:
|
|
178
|
+
violations.append(
|
|
179
|
+
SecurityViolation(
|
|
180
|
+
type="unknown_command",
|
|
181
|
+
message=f"Command '{base_command}' not in whitelist",
|
|
182
|
+
command=command,
|
|
183
|
+
severity="warning"
|
|
184
|
+
if self.security_level == SecurityLevel.PERMISSIVE
|
|
185
|
+
else "error",
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if self.security_level != SecurityLevel.PERMISSIVE:
|
|
190
|
+
return ValidationResult(False, violations)
|
|
191
|
+
else:
|
|
192
|
+
# Validate subcommands if specified
|
|
193
|
+
allowed_subcommands = self.ALLOWED_COMMANDS[base_command]
|
|
194
|
+
if allowed_subcommands and len(parts) > 1:
|
|
195
|
+
subcommand = parts[1]
|
|
196
|
+
if subcommand not in allowed_subcommands:
|
|
197
|
+
# Allow python scripts
|
|
198
|
+
if base_command == "python" and subcommand.endswith(".py"):
|
|
199
|
+
pass
|
|
200
|
+
elif base_command == "grep":
|
|
201
|
+
pass
|
|
202
|
+
else:
|
|
203
|
+
violations.append(
|
|
204
|
+
SecurityViolation(
|
|
205
|
+
type="invalid_subcommand",
|
|
206
|
+
message=f"Subcommand '{subcommand}' not allowed for '{base_command}'",
|
|
207
|
+
command=command,
|
|
208
|
+
severity="error",
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return ValidationResult(False, violations)
|
|
212
|
+
|
|
213
|
+
# Additional validation checks
|
|
214
|
+
violations.extend(self._check_file_access_patterns(command))
|
|
215
|
+
violations.extend(self._check_network_patterns(command))
|
|
216
|
+
violations.extend(self._check_privilege_escalation(command))
|
|
217
|
+
|
|
218
|
+
# Determine if command is allowed
|
|
219
|
+
error_violations = [v for v in violations if v.severity == "error"]
|
|
220
|
+
allowed = len(error_violations) == 0
|
|
221
|
+
|
|
222
|
+
return ValidationResult(allowed, violations)
|
|
223
|
+
|
|
224
|
+
def _check_file_access_patterns(self, command: str) -> List[SecurityViolation]:
|
|
225
|
+
"""Check for suspicious file access patterns."""
|
|
226
|
+
violations = []
|
|
227
|
+
|
|
228
|
+
# Sensitive file patterns
|
|
229
|
+
sensitive_patterns = [
|
|
230
|
+
r"/etc/passwd",
|
|
231
|
+
r"/etc/shadow",
|
|
232
|
+
r"/etc/hosts",
|
|
233
|
+
r"/home/[^/]+/\.ssh",
|
|
234
|
+
r"~/.ssh",
|
|
235
|
+
r"/var/log/",
|
|
236
|
+
r"/proc/",
|
|
237
|
+
r"/sys/",
|
|
238
|
+
r"\.env",
|
|
239
|
+
r"\.secret",
|
|
240
|
+
r"\.key",
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
for pattern in sensitive_patterns:
|
|
244
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
245
|
+
violations.append(
|
|
246
|
+
SecurityViolation(
|
|
247
|
+
type="sensitive_file_access",
|
|
248
|
+
message=f"Potential access to sensitive file: {pattern}",
|
|
249
|
+
command=command,
|
|
250
|
+
severity="warning",
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return violations
|
|
255
|
+
|
|
256
|
+
def _check_network_patterns(self, command: str) -> List[SecurityViolation]:
|
|
257
|
+
"""Check for network access patterns."""
|
|
258
|
+
violations = []
|
|
259
|
+
|
|
260
|
+
network_patterns = [
|
|
261
|
+
r"curl\s+",
|
|
262
|
+
r"wget\s+",
|
|
263
|
+
r"nc\s+",
|
|
264
|
+
r"netcat\s+",
|
|
265
|
+
r"ssh\s+",
|
|
266
|
+
r"scp\s+",
|
|
267
|
+
r"rsync\s+.*:",
|
|
268
|
+
r"ftp\s+",
|
|
269
|
+
r"telnet\s+",
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
for pattern in network_patterns:
|
|
273
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
274
|
+
violations.append(
|
|
275
|
+
SecurityViolation(
|
|
276
|
+
type="network_access",
|
|
277
|
+
message=f"Network access detected: {pattern}",
|
|
278
|
+
command=command,
|
|
279
|
+
severity="error",
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return violations
|
|
284
|
+
|
|
285
|
+
def _check_privilege_escalation(self, command: str) -> List[SecurityViolation]:
|
|
286
|
+
"""Check for privilege escalation attempts."""
|
|
287
|
+
violations = []
|
|
288
|
+
|
|
289
|
+
privilege_patterns = [
|
|
290
|
+
r"sudo\s+",
|
|
291
|
+
r"su\s+",
|
|
292
|
+
r"doas\s+",
|
|
293
|
+
r"pkexec\s+",
|
|
294
|
+
r"runuser\s+",
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
for pattern in privilege_patterns:
|
|
298
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
299
|
+
violations.append(
|
|
300
|
+
SecurityViolation(
|
|
301
|
+
type="privilege_escalation",
|
|
302
|
+
message=f"Privilege escalation attempt: {pattern}",
|
|
303
|
+
command=command,
|
|
304
|
+
severity="error",
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return violations
|
|
309
|
+
|
|
310
|
+
def validate_file_path(self, file_path: str, base_path: Path) -> ValidationResult:
|
|
311
|
+
"""Enhanced file path validation."""
|
|
312
|
+
violations = []
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
# Basic path traversal check
|
|
316
|
+
resolved = (base_path / file_path).resolve()
|
|
317
|
+
base_resolved = base_path.resolve()
|
|
318
|
+
|
|
319
|
+
if not str(resolved).startswith(str(base_resolved)):
|
|
320
|
+
violations.append(
|
|
321
|
+
SecurityViolation(
|
|
322
|
+
type="path_traversal",
|
|
323
|
+
message=f"Path traversal detected: {file_path}",
|
|
324
|
+
command=file_path,
|
|
325
|
+
severity="error",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
return ValidationResult(False, violations)
|
|
329
|
+
|
|
330
|
+
# Check for sensitive file access
|
|
331
|
+
sensitive_dirs = [".ssh", ".git", ".env", "node_modules"]
|
|
332
|
+
path_parts = Path(file_path).parts
|
|
333
|
+
|
|
334
|
+
for sensitive in sensitive_dirs:
|
|
335
|
+
if sensitive in path_parts:
|
|
336
|
+
violations.append(
|
|
337
|
+
SecurityViolation(
|
|
338
|
+
type="sensitive_directory",
|
|
339
|
+
message=f"Access to sensitive directory: {sensitive}",
|
|
340
|
+
command=file_path,
|
|
341
|
+
severity="warning",
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return ValidationResult(True, violations)
|
|
346
|
+
|
|
347
|
+
except (OSError, ValueError) as e:
|
|
348
|
+
violations.append(
|
|
349
|
+
SecurityViolation(
|
|
350
|
+
type="invalid_path",
|
|
351
|
+
message=f"Invalid file path: {str(e)}",
|
|
352
|
+
command=file_path,
|
|
353
|
+
severity="error",
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
return ValidationResult(False, violations)
|
|
357
|
+
|
|
358
|
+
def validate_glob_pattern(self, pattern: str) -> ValidationResult:
|
|
359
|
+
"""Enhanced glob pattern validation."""
|
|
360
|
+
violations = []
|
|
361
|
+
|
|
362
|
+
# Dangerous glob patterns
|
|
363
|
+
dangerous_globs = [
|
|
364
|
+
r"\.\./.*", # Parent directory traversal
|
|
365
|
+
r"/etc/.*",
|
|
366
|
+
r"/home/.*",
|
|
367
|
+
r"/root/.*", # System directories
|
|
368
|
+
r"/var/.*",
|
|
369
|
+
r"/usr/bin/.*",
|
|
370
|
+
r"/bin/.*",
|
|
371
|
+
r".*\.key$",
|
|
372
|
+
r".*\.secret$",
|
|
373
|
+
r".*\.pem$", # Sensitive files
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
for dangerous in dangerous_globs:
|
|
377
|
+
if re.match(dangerous, pattern, re.IGNORECASE):
|
|
378
|
+
violations.append(
|
|
379
|
+
SecurityViolation(
|
|
380
|
+
type="dangerous_glob",
|
|
381
|
+
message=f"Dangerous glob pattern: {dangerous}",
|
|
382
|
+
command=pattern,
|
|
383
|
+
severity="error",
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
return ValidationResult(False, violations)
|
|
387
|
+
|
|
388
|
+
# Check for overly broad patterns
|
|
389
|
+
if pattern in ["**/*", "*", "**"]:
|
|
390
|
+
violations.append(
|
|
391
|
+
SecurityViolation(
|
|
392
|
+
type="broad_glob",
|
|
393
|
+
message="Overly broad glob pattern",
|
|
394
|
+
command=pattern,
|
|
395
|
+
severity="warning",
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
return ValidationResult(True, violations)
|
tunacode/cli/main.py
CHANGED
|
@@ -27,6 +27,9 @@ state_manager = StateManager()
|
|
|
27
27
|
def main(
|
|
28
28
|
version: bool = typer.Option(False, "--version", "-v", help="Show version and exit."),
|
|
29
29
|
run_setup: bool = typer.Option(False, "--setup", help="Run setup process."),
|
|
30
|
+
wizard: bool = typer.Option(
|
|
31
|
+
False, "--wizard", help="Run interactive setup wizard for guided configuration."
|
|
32
|
+
),
|
|
30
33
|
baseurl: str = typer.Option(
|
|
31
34
|
None, "--baseurl", help="API base URL (e.g., https://openrouter.ai/api/v1)"
|
|
32
35
|
),
|
|
@@ -60,7 +63,7 @@ def main(
|
|
|
60
63
|
cli_config = {k: v for k, v in cli_config.items() if v is not None}
|
|
61
64
|
|
|
62
65
|
try:
|
|
63
|
-
await setup(run_setup, state_manager, cli_config)
|
|
66
|
+
await setup(run_setup or wizard, state_manager, cli_config, wizard_mode=wizard)
|
|
64
67
|
|
|
65
68
|
# Initialize ToolHandler after setup
|
|
66
69
|
tool_handler = ToolHandler(state_manager)
|
tunacode/cli/repl.py
CHANGED
|
@@ -446,6 +446,9 @@ async def repl(state_manager: StateManager):
|
|
|
446
446
|
await ui.success("Ready to assist")
|
|
447
447
|
state_manager.session._startup_shown = True
|
|
448
448
|
|
|
449
|
+
# Offer tutorial to first-time users
|
|
450
|
+
await _offer_tutorial_if_appropriate(state_manager)
|
|
451
|
+
|
|
449
452
|
instance = agent.get_or_create_agent(state_manager.session.current_model, state_manager)
|
|
450
453
|
|
|
451
454
|
async with instance.run_mcp_servers():
|
|
@@ -539,3 +542,25 @@ async def repl(state_manager: StateManager):
|
|
|
539
542
|
except (TypeError, ValueError):
|
|
540
543
|
pass
|
|
541
544
|
await ui.info(MSG_SESSION_ENDED)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
async def _offer_tutorial_if_appropriate(state_manager: StateManager) -> None:
|
|
548
|
+
"""Offer tutorial to first-time users if appropriate."""
|
|
549
|
+
try:
|
|
550
|
+
from tunacode.tutorial import TutorialManager
|
|
551
|
+
|
|
552
|
+
tutorial_manager = TutorialManager(state_manager)
|
|
553
|
+
|
|
554
|
+
# Check if we should offer tutorial
|
|
555
|
+
if await tutorial_manager.should_offer_tutorial():
|
|
556
|
+
# Offer tutorial to user
|
|
557
|
+
accepted = await tutorial_manager.offer_tutorial()
|
|
558
|
+
if accepted:
|
|
559
|
+
# Run tutorial
|
|
560
|
+
await tutorial_manager.run_tutorial()
|
|
561
|
+
except ImportError:
|
|
562
|
+
# Tutorial system not available, silently continue
|
|
563
|
+
pass
|
|
564
|
+
except Exception as e:
|
|
565
|
+
# Don't let tutorial errors crash the REPL
|
|
566
|
+
logger.warning(f"Tutorial offer failed: {e}")
|
|
@@ -24,6 +24,7 @@ DEFAULT_USER_CONFIG: UserConfig = {
|
|
|
24
24
|
"fallback_response": True,
|
|
25
25
|
"fallback_verbosity": "normal", # Options: minimal, normal, detailed
|
|
26
26
|
"context_window_size": 200000,
|
|
27
|
+
"enable_streaming": True, # Always enable streaming
|
|
27
28
|
"ripgrep": {
|
|
28
29
|
"use_bundled": False, # Use system ripgrep binary
|
|
29
30
|
"timeout": 10, # Search timeout in seconds
|
tunacode/constants.py
CHANGED
|
@@ -94,27 +94,28 @@ def create_empty_response_message(
|
|
|
94
94
|
iteration: int,
|
|
95
95
|
state_manager: StateManager,
|
|
96
96
|
) -> str:
|
|
97
|
-
"""Create
|
|
97
|
+
"""Create a constructive message for handling empty responses."""
|
|
98
98
|
tools_context = get_recent_tools_context(tool_calls)
|
|
99
99
|
|
|
100
|
-
content = f"""
|
|
101
|
-
|
|
102
|
-
This is UNACCEPTABLE. You FAILED to produce output.
|
|
100
|
+
content = f"""Response appears {empty_reason if empty_reason != "empty" else "empty"} or incomplete. Let's troubleshoot and try again.
|
|
103
101
|
|
|
104
102
|
Task: {message[:200]}...
|
|
105
103
|
{tools_context}
|
|
106
|
-
|
|
104
|
+
Attempt: {iteration}
|
|
105
|
+
|
|
106
|
+
Please take one of these specific actions:
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
1. **Search yielded no results?** → Try alternative search terms or broader patterns
|
|
109
|
+
2. **Found what you need?** → Use TUNACODE_TASK_COMPLETE to finalize
|
|
110
|
+
3. **Encountering a blocker?** → Explain the specific issue preventing progress
|
|
111
|
+
4. **Need more context?** → Use list_dir or expand your search scope
|
|
109
112
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
**Expected in your response:**
|
|
114
|
+
- Execute at least one tool OR provide substantial analysis
|
|
115
|
+
- If stuck, clearly describe what you've tried and what's blocking you
|
|
116
|
+
- Avoid empty responses - the system needs actionable output to proceed
|
|
114
117
|
|
|
115
|
-
|
|
116
|
-
EXECUTE A TOOL OR PROVIDE SUBSTANTIAL CONTENT.
|
|
117
|
-
DO NOT RETURN ANOTHER EMPTY RESPONSE."""
|
|
118
|
+
Ready to continue with a complete response."""
|
|
118
119
|
|
|
119
120
|
return content
|
|
120
121
|
|
tunacode/core/agents/main.py
CHANGED
|
@@ -214,7 +214,7 @@ async def process_request(
|
|
|
214
214
|
await ui.muted(
|
|
215
215
|
f" Recent tools: {get_recent_tools_context(state_manager.session.tool_calls)}"
|
|
216
216
|
)
|
|
217
|
-
await ui.muted(" Injecting
|
|
217
|
+
await ui.muted(" Injecting retry guidance prompt")
|
|
218
218
|
|
|
219
219
|
state_manager.session.consecutive_empty_responses = 0
|
|
220
220
|
else:
|
tunacode/core/agents/utils.py
CHANGED
|
@@ -278,12 +278,10 @@ async def extract_and_execute_tool_calls(
|
|
|
278
278
|
if not tool_callback:
|
|
279
279
|
return
|
|
280
280
|
|
|
281
|
-
# Format 1: {"tool": "name", "args": {...}}
|
|
282
|
-
await parse_json_tool_calls(text, tool_callback, state_manager)
|
|
283
|
-
|
|
284
281
|
# Format 2: Tool calls in code blocks
|
|
285
282
|
code_block_pattern = r'```json\s*(\{(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*"tool"(?:[^{}]|"[^"]*"|(?:\{[^}]*\}))*\})\s*```'
|
|
286
283
|
code_matches = re.findall(code_block_pattern, text, re.MULTILINE | re.DOTALL)
|
|
284
|
+
remaining_text = re.sub(code_block_pattern, "", text)
|
|
287
285
|
|
|
288
286
|
for match in code_matches:
|
|
289
287
|
try:
|
|
@@ -332,6 +330,9 @@ async def extract_and_execute_tool_calls(
|
|
|
332
330
|
if state_manager.session.show_thoughts:
|
|
333
331
|
await ui.error(f"Error parsing code block tool call: {e!s}")
|
|
334
332
|
|
|
333
|
+
# Format 1: {"tool": "name", "args": {...}}
|
|
334
|
+
await parse_json_tool_calls(remaining_text, tool_callback, state_manager)
|
|
335
|
+
|
|
335
336
|
|
|
336
337
|
def patch_tool_messages(
|
|
337
338
|
error_message: ErrorMessage = "Tool operation failed",
|