tunacode-cli 0.0.55__py3-none-any.whl → 0.0.78.6__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 -2
- tunacode/cli/commands/implementations/__init__.py +2 -3
- tunacode/cli/commands/implementations/command_reload.py +48 -0
- tunacode/cli/commands/implementations/debug.py +2 -2
- tunacode/cli/commands/implementations/development.py +10 -8
- tunacode/cli/commands/implementations/model.py +357 -29
- tunacode/cli/commands/implementations/quickstart.py +43 -0
- tunacode/cli/commands/implementations/system.py +96 -3
- tunacode/cli/commands/implementations/template.py +0 -2
- tunacode/cli/commands/registry.py +139 -5
- tunacode/cli/commands/slash/__init__.py +32 -0
- tunacode/cli/commands/slash/command.py +157 -0
- tunacode/cli/commands/slash/loader.py +135 -0
- tunacode/cli/commands/slash/processor.py +294 -0
- tunacode/cli/commands/slash/types.py +93 -0
- tunacode/cli/commands/slash/validator.py +400 -0
- tunacode/cli/main.py +23 -2
- tunacode/cli/repl.py +217 -190
- tunacode/cli/repl_components/command_parser.py +38 -4
- tunacode/cli/repl_components/error_recovery.py +85 -4
- tunacode/cli/repl_components/output_display.py +12 -1
- tunacode/cli/repl_components/tool_executor.py +1 -1
- tunacode/configuration/defaults.py +12 -3
- tunacode/configuration/key_descriptions.py +284 -0
- tunacode/configuration/settings.py +0 -1
- tunacode/constants.py +12 -40
- tunacode/core/agents/__init__.py +43 -2
- tunacode/core/agents/agent_components/__init__.py +7 -0
- tunacode/core/agents/agent_components/agent_config.py +249 -55
- tunacode/core/agents/agent_components/agent_helpers.py +43 -13
- tunacode/core/agents/agent_components/node_processor.py +179 -139
- tunacode/core/agents/agent_components/response_state.py +123 -6
- tunacode/core/agents/agent_components/state_transition.py +116 -0
- tunacode/core/agents/agent_components/streaming.py +296 -0
- tunacode/core/agents/agent_components/task_completion.py +19 -6
- tunacode/core/agents/agent_components/tool_buffer.py +21 -1
- tunacode/core/agents/agent_components/tool_executor.py +10 -0
- tunacode/core/agents/main.py +522 -370
- tunacode/core/agents/main_legact.py +538 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/utils.py +29 -121
- tunacode/core/code_index.py +83 -29
- tunacode/core/setup/__init__.py +0 -2
- tunacode/core/setup/config_setup.py +110 -20
- tunacode/core/setup/config_wizard.py +230 -0
- tunacode/core/setup/coordinator.py +14 -5
- tunacode/core/state.py +16 -20
- tunacode/core/token_usage/usage_tracker.py +5 -3
- tunacode/core/tool_authorization.py +352 -0
- tunacode/core/tool_handler.py +67 -40
- tunacode/exceptions.py +119 -5
- tunacode/prompts/system.xml +751 -0
- tunacode/services/mcp.py +125 -7
- tunacode/setup.py +5 -25
- tunacode/tools/base.py +163 -0
- tunacode/tools/bash.py +110 -1
- tunacode/tools/glob.py +332 -34
- tunacode/tools/grep.py +179 -82
- tunacode/tools/grep_components/result_formatter.py +98 -4
- tunacode/tools/list_dir.py +132 -2
- tunacode/tools/prompts/bash_prompt.xml +72 -0
- tunacode/tools/prompts/glob_prompt.xml +45 -0
- tunacode/tools/prompts/grep_prompt.xml +98 -0
- tunacode/tools/prompts/list_dir_prompt.xml +31 -0
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/prompts/read_file_prompt.xml +54 -0
- tunacode/tools/prompts/run_command_prompt.xml +64 -0
- tunacode/tools/prompts/update_file_prompt.xml +53 -0
- tunacode/tools/prompts/write_file_prompt.xml +37 -0
- tunacode/tools/react.py +153 -0
- tunacode/tools/read_file.py +91 -0
- tunacode/tools/run_command.py +114 -0
- tunacode/tools/schema_assembler.py +167 -0
- tunacode/tools/update_file.py +94 -0
- tunacode/tools/write_file.py +86 -0
- 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/types.py +20 -27
- tunacode/ui/completers.py +434 -50
- tunacode/ui/config_dashboard.py +585 -0
- tunacode/ui/console.py +63 -11
- tunacode/ui/input.py +20 -3
- tunacode/ui/keybindings.py +7 -4
- tunacode/ui/model_selector.py +395 -0
- tunacode/ui/output.py +40 -19
- tunacode/ui/panels.py +212 -43
- tunacode/ui/path_heuristics.py +91 -0
- tunacode/ui/prompt_manager.py +5 -1
- tunacode/ui/tool_ui.py +33 -10
- tunacode/utils/api_key_validation.py +93 -0
- tunacode/utils/config_comparator.py +340 -0
- tunacode/utils/json_utils.py +206 -0
- tunacode/utils/message_utils.py +14 -4
- tunacode/utils/models_registry.py +593 -0
- tunacode/utils/ripgrep.py +332 -9
- tunacode/utils/text_utils.py +18 -1
- tunacode/utils/user_configuration.py +45 -0
- tunacode_cli-0.0.78.6.dist-info/METADATA +260 -0
- tunacode_cli-0.0.78.6.dist-info/RECORD +158 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +1 -2
- tunacode/cli/commands/implementations/todo.py +0 -217
- tunacode/context.py +0 -71
- tunacode/core/setup/git_safety_setup.py +0 -182
- tunacode/prompts/system.md +0 -731
- tunacode/tools/read_file_async_poc.py +0 -196
- tunacode/tools/todo.py +0 -349
- tunacode_cli-0.0.55.dist-info/METADATA +0 -322
- tunacode_cli-0.0.55.dist-info/RECORD +0 -126
- tunacode_cli-0.0.55.dist-info/top_level.txt +0 -1
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: tunacode.utils.config_comparator
|
|
3
|
+
|
|
4
|
+
Configuration comparison utility for analyzing user configurations against defaults.
|
|
5
|
+
Provides detailed analysis of customizations, defaults, and configuration state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional, Set, Union
|
|
12
|
+
|
|
13
|
+
from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
|
|
14
|
+
from tunacode.types import UserConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ConfigDifference:
|
|
19
|
+
"""Represents a difference between user config and defaults."""
|
|
20
|
+
|
|
21
|
+
key_path: str
|
|
22
|
+
user_value: Any
|
|
23
|
+
default_value: Any
|
|
24
|
+
difference_type: str # "custom", "missing", "extra", "type_mismatch"
|
|
25
|
+
section: str
|
|
26
|
+
description: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ConfigAnalysis:
|
|
31
|
+
"""Complete analysis of configuration state."""
|
|
32
|
+
|
|
33
|
+
user_config: UserConfig
|
|
34
|
+
default_config: UserConfig
|
|
35
|
+
differences: List[ConfigDifference]
|
|
36
|
+
custom_keys: Set[str]
|
|
37
|
+
missing_keys: Set[str]
|
|
38
|
+
extra_keys: Set[str]
|
|
39
|
+
type_mismatches: Set[str]
|
|
40
|
+
sections_analyzed: Set[str]
|
|
41
|
+
total_keys: int
|
|
42
|
+
custom_percentage: float
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ConfigComparator:
|
|
46
|
+
"""Compares user configuration against defaults to identify customizations."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, default_config: Optional[UserConfig] = None):
|
|
49
|
+
"""Initialize comparator with default configuration."""
|
|
50
|
+
self.default_config = default_config or DEFAULT_USER_CONFIG
|
|
51
|
+
|
|
52
|
+
def _get_key_description(self, key_path: str, difference_type: str) -> str:
|
|
53
|
+
"""Get a descriptive explanation for a configuration key difference."""
|
|
54
|
+
try:
|
|
55
|
+
from tunacode.configuration.key_descriptions import get_key_description
|
|
56
|
+
|
|
57
|
+
desc = get_key_description(key_path)
|
|
58
|
+
|
|
59
|
+
if desc:
|
|
60
|
+
if difference_type == "custom":
|
|
61
|
+
return f"Custom: {desc.description}"
|
|
62
|
+
elif difference_type == "missing":
|
|
63
|
+
return f"Missing: {desc.description}"
|
|
64
|
+
elif difference_type == "extra":
|
|
65
|
+
return f"Extra: {desc.description}"
|
|
66
|
+
elif difference_type == "type_mismatch":
|
|
67
|
+
return f"Type mismatch: {desc.description}"
|
|
68
|
+
|
|
69
|
+
except ImportError:
|
|
70
|
+
pass # Fall back to basic descriptions
|
|
71
|
+
|
|
72
|
+
# Fallback descriptions
|
|
73
|
+
if difference_type == "custom":
|
|
74
|
+
return f"Custom value: {key_path}"
|
|
75
|
+
elif difference_type == "missing":
|
|
76
|
+
return f"Missing configuration key: {key_path}"
|
|
77
|
+
elif difference_type == "extra":
|
|
78
|
+
return f"Extra configuration key: {key_path}"
|
|
79
|
+
elif difference_type == "type_mismatch":
|
|
80
|
+
return f"Type mismatch for: {key_path}"
|
|
81
|
+
|
|
82
|
+
return f"Configuration difference: {key_path}"
|
|
83
|
+
|
|
84
|
+
def analyze_config(self, user_config: UserConfig) -> ConfigAnalysis:
|
|
85
|
+
"""Perform complete analysis of user configuration."""
|
|
86
|
+
differences: list[ConfigDifference] = []
|
|
87
|
+
custom_keys: set[str] = set()
|
|
88
|
+
missing_keys: set[str] = set()
|
|
89
|
+
extra_keys: set[str] = set()
|
|
90
|
+
type_mismatches: set[str] = set()
|
|
91
|
+
|
|
92
|
+
# Analyze each section recursively
|
|
93
|
+
self._analyze_recursive(
|
|
94
|
+
user_config=user_config,
|
|
95
|
+
default_config=self.default_config,
|
|
96
|
+
current_path="",
|
|
97
|
+
differences=differences,
|
|
98
|
+
custom_keys=custom_keys,
|
|
99
|
+
missing_keys=missing_keys,
|
|
100
|
+
extra_keys=extra_keys,
|
|
101
|
+
type_mismatches=type_mismatches,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Calculate statistics
|
|
105
|
+
sections_analyzed: set[str] = set()
|
|
106
|
+
self._collect_sections(user_config, sections_analyzed)
|
|
107
|
+
self._collect_sections(self.default_config, sections_analyzed)
|
|
108
|
+
|
|
109
|
+
total_keys = len(custom_keys) + len(missing_keys) + len(extra_keys) + len(type_mismatches)
|
|
110
|
+
custom_percentage = (len(custom_keys) / total_keys * 100) if total_keys > 0 else 0
|
|
111
|
+
|
|
112
|
+
return ConfigAnalysis(
|
|
113
|
+
user_config=user_config,
|
|
114
|
+
default_config=self.default_config,
|
|
115
|
+
differences=differences,
|
|
116
|
+
custom_keys=custom_keys,
|
|
117
|
+
missing_keys=missing_keys,
|
|
118
|
+
extra_keys=extra_keys,
|
|
119
|
+
type_mismatches=type_mismatches,
|
|
120
|
+
sections_analyzed=sections_analyzed,
|
|
121
|
+
total_keys=total_keys,
|
|
122
|
+
custom_percentage=custom_percentage,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _analyze_recursive(
|
|
126
|
+
self,
|
|
127
|
+
user_config: Dict[str, Any],
|
|
128
|
+
default_config: Dict[str, Any],
|
|
129
|
+
current_path: str,
|
|
130
|
+
differences: List[ConfigDifference],
|
|
131
|
+
custom_keys: Set[str],
|
|
132
|
+
missing_keys: Set[str],
|
|
133
|
+
extra_keys: Set[str],
|
|
134
|
+
type_mismatches: Set[str],
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Recursively analyze configuration differences."""
|
|
137
|
+
|
|
138
|
+
# Check for missing keys (present in default but not in user)
|
|
139
|
+
for key, default_value in default_config.items():
|
|
140
|
+
full_key = f"{current_path}.{key}" if current_path else key
|
|
141
|
+
|
|
142
|
+
if key not in user_config:
|
|
143
|
+
missing_keys.add(full_key)
|
|
144
|
+
differences.append(
|
|
145
|
+
ConfigDifference(
|
|
146
|
+
key_path=full_key,
|
|
147
|
+
user_value=None,
|
|
148
|
+
default_value=default_value,
|
|
149
|
+
difference_type="missing",
|
|
150
|
+
section=current_path or "root",
|
|
151
|
+
description=self._get_key_description(full_key, "missing"),
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
user_value = user_config[key]
|
|
157
|
+
|
|
158
|
+
# Recursively analyze nested dictionaries
|
|
159
|
+
if isinstance(default_value, dict) and isinstance(user_value, dict):
|
|
160
|
+
self._analyze_recursive(
|
|
161
|
+
user_config=user_value,
|
|
162
|
+
default_config=default_value,
|
|
163
|
+
current_path=full_key,
|
|
164
|
+
differences=differences,
|
|
165
|
+
custom_keys=custom_keys,
|
|
166
|
+
missing_keys=missing_keys,
|
|
167
|
+
extra_keys=extra_keys,
|
|
168
|
+
type_mismatches=type_mismatches,
|
|
169
|
+
)
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# Check for type mismatches
|
|
173
|
+
if not isinstance(user_value, type(default_value)):
|
|
174
|
+
type_mismatches.add(full_key)
|
|
175
|
+
differences.append(
|
|
176
|
+
ConfigDifference(
|
|
177
|
+
key_path=full_key,
|
|
178
|
+
user_value=user_value,
|
|
179
|
+
default_value=default_value,
|
|
180
|
+
difference_type="type_mismatch",
|
|
181
|
+
section=current_path or "root",
|
|
182
|
+
description=self._get_key_description(full_key, "type_mismatch"),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
# Check for custom values
|
|
188
|
+
if user_value != default_value:
|
|
189
|
+
custom_keys.add(full_key)
|
|
190
|
+
differences.append(
|
|
191
|
+
ConfigDifference(
|
|
192
|
+
key_path=full_key,
|
|
193
|
+
user_value=user_value,
|
|
194
|
+
default_value=default_value,
|
|
195
|
+
difference_type="custom",
|
|
196
|
+
section=current_path or "root",
|
|
197
|
+
description=self._get_key_description(full_key, "custom"),
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Check for extra keys (present in user but not in default)
|
|
202
|
+
for key, user_value in user_config.items():
|
|
203
|
+
if key not in default_config:
|
|
204
|
+
full_key = f"{current_path}.{key}" if current_path else key
|
|
205
|
+
extra_keys.add(full_key)
|
|
206
|
+
differences.append(
|
|
207
|
+
ConfigDifference(
|
|
208
|
+
key_path=full_key,
|
|
209
|
+
user_value=user_value,
|
|
210
|
+
default_value=None,
|
|
211
|
+
difference_type="extra",
|
|
212
|
+
section=current_path or "root",
|
|
213
|
+
description=self._get_key_description(full_key, "extra"),
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _collect_sections(self, config: Dict[str, Any], sections: Set[str]) -> None:
|
|
218
|
+
"""Collect all section names from configuration."""
|
|
219
|
+
for key, value in config.items():
|
|
220
|
+
if isinstance(value, dict):
|
|
221
|
+
sections.add(key)
|
|
222
|
+
self._collect_sections(value, sections)
|
|
223
|
+
|
|
224
|
+
def get_summary_stats(self, analysis: ConfigAnalysis) -> Dict[str, Any]:
|
|
225
|
+
"""Get summary statistics for the configuration analysis."""
|
|
226
|
+
return {
|
|
227
|
+
"total_keys_analyzed": analysis.total_keys,
|
|
228
|
+
"custom_keys_count": len(analysis.custom_keys),
|
|
229
|
+
"missing_keys_count": len(analysis.missing_keys),
|
|
230
|
+
"extra_keys_count": len(analysis.extra_keys),
|
|
231
|
+
"type_mismatches_count": len(analysis.type_mismatches),
|
|
232
|
+
"custom_percentage": analysis.custom_percentage,
|
|
233
|
+
"sections_analyzed": len(analysis.sections_analyzed),
|
|
234
|
+
"has_issues": bool(analysis.missing_keys or analysis.type_mismatches),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def get_section_analysis(
|
|
238
|
+
self, analysis: ConfigAnalysis, section: str
|
|
239
|
+
) -> List[ConfigDifference]:
|
|
240
|
+
"""Get differences for a specific section."""
|
|
241
|
+
return [diff for diff in analysis.differences if diff.section == section]
|
|
242
|
+
|
|
243
|
+
def is_config_healthy(self, analysis: ConfigAnalysis) -> bool:
|
|
244
|
+
"""Check if configuration is healthy (no critical issues)."""
|
|
245
|
+
# Type mismatches are considered critical
|
|
246
|
+
if analysis.type_mismatches:
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
# Missing keys might be acceptable depending on the context
|
|
250
|
+
# For now, we'll consider missing keys as warnings, not errors
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
def get_recommendations(self, analysis: ConfigAnalysis) -> List[str]:
|
|
254
|
+
"""Get recommendations based on configuration analysis."""
|
|
255
|
+
recommendations = []
|
|
256
|
+
|
|
257
|
+
if analysis.type_mismatches:
|
|
258
|
+
recommendations.append(
|
|
259
|
+
f"Fix {len(analysis.type_mismatches)} type mismatch(es) in configuration"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if analysis.missing_keys:
|
|
263
|
+
recommendations.append(
|
|
264
|
+
f"Consider adding {len(analysis.missing_keys)} missing configuration key(s)"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if analysis.custom_percentage > 80:
|
|
268
|
+
recommendations.append(
|
|
269
|
+
"High customization detected - consider documenting your configuration"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if analysis.extra_keys:
|
|
273
|
+
recommendations.append(
|
|
274
|
+
f"Found {len(analysis.extra_keys)} unrecognized configuration key(s)"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return recommendations
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def load_and_analyze_config(config_path: Optional[Union[str, Path]] = None) -> ConfigAnalysis:
|
|
281
|
+
"""Load configuration from file and analyze it."""
|
|
282
|
+
from tunacode.utils.user_configuration import load_config
|
|
283
|
+
|
|
284
|
+
if config_path:
|
|
285
|
+
try:
|
|
286
|
+
with open(config_path, "r") as f:
|
|
287
|
+
user_config = json.load(f)
|
|
288
|
+
except (FileNotFoundError, json.JSONDecodeError) as e:
|
|
289
|
+
raise ValueError(f"Failed to load config from {config_path}: {e}")
|
|
290
|
+
else:
|
|
291
|
+
user_config = load_config()
|
|
292
|
+
if user_config is None:
|
|
293
|
+
raise ValueError("No user configuration found")
|
|
294
|
+
|
|
295
|
+
comparator = ConfigComparator()
|
|
296
|
+
return comparator.analyze_config(user_config)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def create_config_report(analysis: ConfigAnalysis) -> str:
|
|
300
|
+
"""Create a human-readable report of configuration analysis."""
|
|
301
|
+
stats = ConfigComparator().get_summary_stats(analysis)
|
|
302
|
+
|
|
303
|
+
report = [
|
|
304
|
+
"Configuration Analysis Report",
|
|
305
|
+
"=" * 50,
|
|
306
|
+
f"Total keys analyzed: {stats['total_keys_analyzed']}",
|
|
307
|
+
f"Custom keys: {stats['custom_keys_count']} ({stats['custom_percentage']:.1f}%)",
|
|
308
|
+
f"Missing keys: {stats['missing_keys_count']}",
|
|
309
|
+
f"Extra keys: {stats['extra_keys_count']}",
|
|
310
|
+
f"Type mismatches: {stats['type_mismatches_count']}",
|
|
311
|
+
f"Sections analyzed: {stats['sections_analyzed']}",
|
|
312
|
+
f"Configuration healthy: {'Yes' if stats['has_issues'] else 'No'}",
|
|
313
|
+
"",
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
if analysis.custom_keys:
|
|
317
|
+
report.append("Custom Values:")
|
|
318
|
+
for key in sorted(analysis.custom_keys):
|
|
319
|
+
report.append(f" ✓ {key}")
|
|
320
|
+
report.append("")
|
|
321
|
+
|
|
322
|
+
if analysis.missing_keys:
|
|
323
|
+
report.append("Missing Keys:")
|
|
324
|
+
for key in sorted(analysis.missing_keys):
|
|
325
|
+
report.append(f" ⚠ {key}")
|
|
326
|
+
report.append("")
|
|
327
|
+
|
|
328
|
+
if analysis.type_mismatches:
|
|
329
|
+
report.append("Type Mismatches:")
|
|
330
|
+
for key in sorted(analysis.type_mismatches):
|
|
331
|
+
report.append(f" ✗ {key}")
|
|
332
|
+
report.append("")
|
|
333
|
+
|
|
334
|
+
recommendations = ConfigComparator().get_recommendations(analysis)
|
|
335
|
+
if recommendations:
|
|
336
|
+
report.append("Recommendations:")
|
|
337
|
+
for rec in recommendations:
|
|
338
|
+
report.append(f" • {rec}")
|
|
339
|
+
|
|
340
|
+
return "\n".join(report)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: tunacode.utils.json_utils
|
|
3
|
+
|
|
4
|
+
JSON parsing utilities with enhanced error handling and concatenated object support.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any, Dict, List, Optional, Union
|
|
10
|
+
|
|
11
|
+
from tunacode.constants import READ_ONLY_TOOLS
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConcatenatedJSONError(Exception):
|
|
17
|
+
"""Raised when concatenated JSON objects are detected but cannot be safely handled."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, objects_found: int, tool_name: Optional[str] = None):
|
|
20
|
+
self.message = message
|
|
21
|
+
self.objects_found = objects_found
|
|
22
|
+
self.tool_name = tool_name
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def split_concatenated_json(json_string: str, strict_mode: bool = True) -> List[Dict[str, Any]]:
|
|
27
|
+
"""
|
|
28
|
+
Split concatenated JSON objects like {"a": 1}{"b": 2} into separate objects.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
json_string: String containing potentially concatenated JSON objects
|
|
32
|
+
strict_mode: If True, only returns valid JSON objects. If False, attempts
|
|
33
|
+
to recover partial objects.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
List of parsed JSON objects
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
json.JSONDecodeError: If no valid JSON objects can be extracted
|
|
40
|
+
ConcatenatedJSONError: If multiple objects found but not safe to process
|
|
41
|
+
"""
|
|
42
|
+
objects = []
|
|
43
|
+
brace_count = 0
|
|
44
|
+
start_pos = 0
|
|
45
|
+
in_string = False
|
|
46
|
+
escape_next = False
|
|
47
|
+
|
|
48
|
+
for i, char in enumerate(json_string):
|
|
49
|
+
if escape_next:
|
|
50
|
+
escape_next = False
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
if char == "\\":
|
|
54
|
+
escape_next = True
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
if char == '"' and not escape_next:
|
|
58
|
+
in_string = not in_string
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if in_string:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
if char == "{":
|
|
65
|
+
if brace_count == 0:
|
|
66
|
+
start_pos = i
|
|
67
|
+
brace_count += 1
|
|
68
|
+
elif char == "}":
|
|
69
|
+
brace_count -= 1
|
|
70
|
+
if brace_count == 0:
|
|
71
|
+
potential_json = json_string[start_pos : i + 1].strip()
|
|
72
|
+
try:
|
|
73
|
+
parsed = json.loads(potential_json)
|
|
74
|
+
if isinstance(parsed, dict):
|
|
75
|
+
objects.append(parsed)
|
|
76
|
+
else:
|
|
77
|
+
logger.warning(f"Non-dict JSON object ignored: {type(parsed)}")
|
|
78
|
+
except json.JSONDecodeError as e:
|
|
79
|
+
if strict_mode:
|
|
80
|
+
logger.debug(f"Invalid JSON fragment skipped: {potential_json[:100]}...")
|
|
81
|
+
else:
|
|
82
|
+
logger.warning(f"JSON parse error: {e}")
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if not objects:
|
|
86
|
+
raise json.JSONDecodeError("No valid JSON objects found", json_string, 0)
|
|
87
|
+
|
|
88
|
+
return objects
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_tool_args_safety(
|
|
92
|
+
objects: List[Dict[str, Any]], tool_name: Optional[str] = None
|
|
93
|
+
) -> bool:
|
|
94
|
+
"""
|
|
95
|
+
Validate whether it's safe to execute multiple JSON objects for a given tool.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
objects: List of JSON objects to validate
|
|
99
|
+
tool_name: Name of the tool (if known)
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
bool: True if safe to execute, False otherwise
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ConcatenatedJSONError: If multiple objects detected for unsafe tool
|
|
106
|
+
"""
|
|
107
|
+
if len(objects) <= 1:
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
# Check if tool is read-only (safer to execute multiple times)
|
|
111
|
+
if tool_name and tool_name in READ_ONLY_TOOLS:
|
|
112
|
+
logger.info(f"Multiple JSON objects for read-only tool {tool_name} - allowing execution")
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
# For write/execute tools, multiple objects are potentially dangerous
|
|
116
|
+
if tool_name:
|
|
117
|
+
logger.warning(
|
|
118
|
+
f"Multiple JSON objects detected for tool {tool_name} "
|
|
119
|
+
f"({len(objects)} objects). This may indicate a model error."
|
|
120
|
+
)
|
|
121
|
+
raise ConcatenatedJSONError(
|
|
122
|
+
f"Multiple JSON objects not safe for tool {tool_name}",
|
|
123
|
+
objects_found=len(objects),
|
|
124
|
+
tool_name=tool_name,
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
logger.warning(f"Multiple JSON objects detected ({len(objects)}) with unknown tool")
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def safe_json_parse(
|
|
132
|
+
json_string: str, tool_name: Optional[str] = None, allow_concatenated: bool = False
|
|
133
|
+
) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
|
|
134
|
+
"""
|
|
135
|
+
Safely parse JSON with optional concatenated object support.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
json_string: JSON string to parse
|
|
139
|
+
tool_name: Name of the tool (for safety validation)
|
|
140
|
+
allow_concatenated: Whether to attempt splitting concatenated objects
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Single dict if one object, or list of dicts if multiple objects
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
json.JSONDecodeError: If parsing fails
|
|
147
|
+
ConcatenatedJSONError: If concatenated objects are unsafe
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
# First, try normal JSON parsing
|
|
151
|
+
result = json.loads(json_string)
|
|
152
|
+
if isinstance(result, dict):
|
|
153
|
+
return result
|
|
154
|
+
else:
|
|
155
|
+
raise json.JSONDecodeError(f"Expected dict, got {type(result)}", json_string, 0)
|
|
156
|
+
|
|
157
|
+
except json.JSONDecodeError as e:
|
|
158
|
+
if not allow_concatenated or "Extra data" not in str(e):
|
|
159
|
+
raise
|
|
160
|
+
|
|
161
|
+
logger.info("Attempting to split concatenated JSON objects")
|
|
162
|
+
|
|
163
|
+
# Try to split concatenated objects
|
|
164
|
+
objects = split_concatenated_json(json_string)
|
|
165
|
+
|
|
166
|
+
# Validate safety
|
|
167
|
+
if validate_tool_args_safety(objects, tool_name):
|
|
168
|
+
if len(objects) == 1:
|
|
169
|
+
return objects[0]
|
|
170
|
+
else:
|
|
171
|
+
return objects
|
|
172
|
+
else:
|
|
173
|
+
# Not safe - return first object with warning
|
|
174
|
+
logger.warning(f"Using first of {len(objects)} JSON objects only")
|
|
175
|
+
return objects[0]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def merge_json_objects(objects: List[Dict[str, Any]], strategy: str = "first") -> Dict[str, Any]:
|
|
179
|
+
"""
|
|
180
|
+
Merge multiple JSON objects using different strategies.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
objects: List of JSON objects to merge
|
|
184
|
+
strategy: Merge strategy ("first", "last", "combine")
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Single merged JSON object
|
|
188
|
+
"""
|
|
189
|
+
if not objects:
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
if len(objects) == 1:
|
|
193
|
+
return objects[0]
|
|
194
|
+
|
|
195
|
+
if strategy == "first":
|
|
196
|
+
return objects[0]
|
|
197
|
+
elif strategy == "last":
|
|
198
|
+
return objects[-1]
|
|
199
|
+
elif strategy == "combine":
|
|
200
|
+
# Combine all objects, later values override earlier ones
|
|
201
|
+
result = {}
|
|
202
|
+
for obj in objects:
|
|
203
|
+
result.update(obj)
|
|
204
|
+
return result
|
|
205
|
+
else:
|
|
206
|
+
raise ValueError(f"Unknown merge strategy: {strategy}")
|
tunacode/utils/message_utils.py
CHANGED
|
@@ -9,11 +9,21 @@ def get_message_content(message: Any) -> str:
|
|
|
9
9
|
return message
|
|
10
10
|
if isinstance(message, dict):
|
|
11
11
|
if "content" in message:
|
|
12
|
-
|
|
12
|
+
content = message["content"]
|
|
13
|
+
# Handle nested content structures
|
|
14
|
+
if isinstance(content, list):
|
|
15
|
+
return " ".join(get_message_content(item) for item in content)
|
|
16
|
+
return str(content)
|
|
13
17
|
if "thought" in message:
|
|
14
|
-
return message["thought"]
|
|
18
|
+
return str(message["thought"])
|
|
15
19
|
if hasattr(message, "content"):
|
|
16
|
-
|
|
20
|
+
content = message.content
|
|
21
|
+
if isinstance(content, list):
|
|
22
|
+
return " ".join(get_message_content(item) for item in content)
|
|
23
|
+
return str(content)
|
|
17
24
|
if hasattr(message, "parts"):
|
|
18
|
-
|
|
25
|
+
parts = message.parts
|
|
26
|
+
if isinstance(parts, list):
|
|
27
|
+
return " ".join(get_message_content(part) for part in parts)
|
|
28
|
+
return str(parts)
|
|
19
29
|
return ""
|