teddy-cli 0.1.0__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.
- teddy_cli-0.1.0.dist-info/LICENSE +677 -0
- teddy_cli-0.1.0.dist-info/METADATA +33 -0
- teddy_cli-0.1.0.dist-info/RECORD +143 -0
- teddy_cli-0.1.0.dist-info/WHEEL +4 -0
- teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
- teddy_executor/__init__.py +1 -0
- teddy_executor/__main__.py +335 -0
- teddy_executor/adapters/__init__.py +0 -0
- teddy_executor/adapters/inbound/__init__.py +0 -0
- teddy_executor/adapters/inbound/cli_formatter.py +107 -0
- teddy_executor/adapters/inbound/cli_helpers.py +249 -0
- teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
- teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
- teddy_executor/adapters/outbound/__init__.py +7 -0
- teddy_executor/adapters/outbound/console_interactor.py +212 -0
- teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
- teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
- teddy_executor/adapters/outbound/console_tooling.py +62 -0
- teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
- teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
- teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
- teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
- teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
- teddy_executor/adapters/outbound/shell_adapter.py +344 -0
- teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
- teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
- teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
- teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
- teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
- teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
- teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
- teddy_executor/container.py +333 -0
- teddy_executor/core/__init__.py +0 -0
- teddy_executor/core/domain/__init__.py +0 -0
- teddy_executor/core/domain/models/__init__.py +44 -0
- teddy_executor/core/domain/models/action_ports.py +28 -0
- teddy_executor/core/domain/models/change_set.py +10 -0
- teddy_executor/core/domain/models/exceptions.py +40 -0
- teddy_executor/core/domain/models/execution_report.py +65 -0
- teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
- teddy_executor/core/domain/models/plan.py +85 -0
- teddy_executor/core/domain/models/planning_ports.py +43 -0
- teddy_executor/core/domain/models/project_context.py +56 -0
- teddy_executor/core/domain/models/report_assembly_data.py +18 -0
- teddy_executor/core/domain/models/session.py +17 -0
- teddy_executor/core/domain/models/shell_output.py +12 -0
- teddy_executor/core/domain/models/web_search_results.py +26 -0
- teddy_executor/core/ports/__init__.py +0 -0
- teddy_executor/core/ports/inbound/__init__.py +0 -0
- teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
- teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
- teddy_executor/core/ports/inbound/init.py +15 -0
- teddy_executor/core/ports/inbound/plan_parser.py +52 -0
- teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
- teddy_executor/core/ports/inbound/plan_validator.py +26 -0
- teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
- teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
- teddy_executor/core/ports/outbound/__init__.py +34 -0
- teddy_executor/core/ports/outbound/config_service.py +29 -0
- teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
- teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
- teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
- teddy_executor/core/ports/outbound/llm_client.py +90 -0
- teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
- teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
- teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
- teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
- teddy_executor/core/ports/outbound/session_manager.py +97 -0
- teddy_executor/core/ports/outbound/session_repository.py +65 -0
- teddy_executor/core/ports/outbound/shell_executor.py +24 -0
- teddy_executor/core/ports/outbound/system_environment.py +25 -0
- teddy_executor/core/ports/outbound/time_service.py +28 -0
- teddy_executor/core/ports/outbound/user_interactor.py +126 -0
- teddy_executor/core/ports/outbound/web_scraper.py +24 -0
- teddy_executor/core/ports/outbound/web_searcher.py +25 -0
- teddy_executor/core/services/__init__.py +0 -0
- teddy_executor/core/services/action_changeset_builder.py +90 -0
- teddy_executor/core/services/action_diff_manager.py +110 -0
- teddy_executor/core/services/action_dispatcher.py +142 -0
- teddy_executor/core/services/action_executor.py +209 -0
- teddy_executor/core/services/action_factory.py +197 -0
- teddy_executor/core/services/action_parser_complex.py +216 -0
- teddy_executor/core/services/action_parser_strategies.py +84 -0
- teddy_executor/core/services/context_service.py +437 -0
- teddy_executor/core/services/edit_simulator.py +128 -0
- teddy_executor/core/services/execution_orchestrator.py +295 -0
- teddy_executor/core/services/execution_report_assembler.py +62 -0
- teddy_executor/core/services/init_service.py +80 -0
- teddy_executor/core/services/markdown_plan_parser.py +309 -0
- teddy_executor/core/services/markdown_report_formatter.py +143 -0
- teddy_executor/core/services/parser_infrastructure.py +222 -0
- teddy_executor/core/services/parser_metadata.py +153 -0
- teddy_executor/core/services/parser_reporting.py +267 -0
- teddy_executor/core/services/plan_validator.py +82 -0
- teddy_executor/core/services/planning_service.py +242 -0
- teddy_executor/core/services/prompt_manager.py +146 -0
- teddy_executor/core/services/session_lifecycle_manager.py +228 -0
- teddy_executor/core/services/session_loop_guard.py +46 -0
- teddy_executor/core/services/session_orchestrator.py +538 -0
- teddy_executor/core/services/session_planner.py +43 -0
- teddy_executor/core/services/session_pruning_service.py +438 -0
- teddy_executor/core/services/session_replanner.py +105 -0
- teddy_executor/core/services/session_repository.py +194 -0
- teddy_executor/core/services/session_service.py +529 -0
- teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
- teddy_executor/core/services/validation_rules/__init__.py +4 -0
- teddy_executor/core/services/validation_rules/edit.py +207 -0
- teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
- teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
- teddy_executor/core/services/validation_rules/execute.py +37 -0
- teddy_executor/core/services/validation_rules/filesystem.py +73 -0
- teddy_executor/core/services/validation_rules/helpers.py +178 -0
- teddy_executor/core/services/validation_rules/message.py +29 -0
- teddy_executor/core/utils/__init__.py +1 -0
- teddy_executor/core/utils/diff.py +57 -0
- teddy_executor/core/utils/io.py +75 -0
- teddy_executor/core/utils/markdown.py +131 -0
- teddy_executor/core/utils/serialization.py +39 -0
- teddy_executor/core/utils/string.py +351 -0
- teddy_executor/prompts.py +45 -0
- teddy_executor/registries/__init__.py +1 -0
- teddy_executor/registries/infrastructure.py +147 -0
- teddy_executor/registries/reviewer.py +57 -0
- teddy_executor/registries/validators.py +47 -0
- teddy_executor/resources/__init__.py +1 -0
- teddy_executor/resources/config/.gitignore +2 -0
- teddy_executor/resources/config/__init__.py +1 -0
- teddy_executor/resources/config/config.yaml +49 -0
- teddy_executor/resources/config/init.context +5 -0
- teddy_executor/resources/config/prompts/architect.xml +462 -0
- teddy_executor/resources/config/prompts/assistant.xml +336 -0
- teddy_executor/resources/config/prompts/debugger.xml +456 -0
- teddy_executor/resources/config/prompts/developer.xml +481 -0
- teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
- teddy_executor/resources/config/prompts/prototyper.xml +425 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Heuristic matching logic for the EDIT action validator.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import List, Set
|
|
9
|
+
|
|
10
|
+
from teddy_executor.core.domain.models.plan import DEFAULT_SIMILARITY_THRESHOLD
|
|
11
|
+
from teddy_executor.core.services.validation_rules.edit_matcher_heuristics import (
|
|
12
|
+
gather_candidate_starts,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Performance Heuristic Constants
|
|
16
|
+
LARGE_BLOCK_LINE_LIMIT = 20
|
|
17
|
+
SUB_SAMPLE_RATIO_THRESHOLD = 0.7
|
|
18
|
+
SUB_SAMPLE_PASS_THRESHOLD = 0.7
|
|
19
|
+
CANDIDATE_EVALUATION_CAP = 5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def find_best_match(
|
|
23
|
+
file_content: str,
|
|
24
|
+
find_block: str,
|
|
25
|
+
threshold: float = DEFAULT_SIMILARITY_THRESHOLD,
|
|
26
|
+
) -> tuple[str, float, bool, int]:
|
|
27
|
+
"""
|
|
28
|
+
Finds the most similar block of text in the file content.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
tuple[str, float, bool, int]: (best_match_string, best_score, is_ambiguous, offset)
|
|
32
|
+
"""
|
|
33
|
+
file_lines = file_content.splitlines(keepends=True)
|
|
34
|
+
find_lines = find_block.splitlines(keepends=True)
|
|
35
|
+
num_find_lines = len(find_lines)
|
|
36
|
+
|
|
37
|
+
if not file_lines or not find_lines:
|
|
38
|
+
return "", 0.0, False, 0
|
|
39
|
+
|
|
40
|
+
# If the file is smaller than the find block, just compare against the whole file
|
|
41
|
+
if len(file_lines) < num_find_lines:
|
|
42
|
+
matcher = difflib.SequenceMatcher(None, find_lines, file_lines)
|
|
43
|
+
score = matcher.ratio()
|
|
44
|
+
return "".join(file_lines), round(score, 2), False, 0
|
|
45
|
+
|
|
46
|
+
candidate_starts = gather_candidate_starts(file_lines, find_lines, threshold)
|
|
47
|
+
best_match_lines, score, is_ambiguous, offset = _evaluate_candidates(
|
|
48
|
+
file_lines, find_lines, candidate_starts, find_block
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return "".join(best_match_lines), round(score, 2), is_ambiguous, offset
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def find_best_match_and_diff(
|
|
55
|
+
file_content: str,
|
|
56
|
+
find_block: str,
|
|
57
|
+
threshold: float = DEFAULT_SIMILARITY_THRESHOLD,
|
|
58
|
+
) -> tuple[str, float, bool, int]:
|
|
59
|
+
"""
|
|
60
|
+
Finds the most similar block of text in the file content and generates a diff.
|
|
61
|
+
Uses character-level ndiff (with ? markers) for fuzzy matches.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
tuple[str, float, bool, int]: (diff_text, best_score, is_ambiguous, offset)
|
|
65
|
+
"""
|
|
66
|
+
debug = os.environ.get("TEDDY_DEBUG")
|
|
67
|
+
start_total = time.perf_counter()
|
|
68
|
+
|
|
69
|
+
best_match_str, score, is_ambiguous, offset = find_best_match(
|
|
70
|
+
file_content, find_block, threshold
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
res = ""
|
|
74
|
+
if best_match_str and not is_ambiguous:
|
|
75
|
+
# Generate character-level diff only for non-perfect matches
|
|
76
|
+
# Note: score is already rounded to 2 decimal places by find_best_match
|
|
77
|
+
if 0 < score < 1.0:
|
|
78
|
+
find_lines = find_block.splitlines(keepends=True)
|
|
79
|
+
match_lines = best_match_str.splitlines(keepends=True)
|
|
80
|
+
# ndiff provides the '?' lines for intra-line changes
|
|
81
|
+
diff = difflib.ndiff(find_lines, match_lines)
|
|
82
|
+
res = "\n".join(line.rstrip("\n\r") for line in diff)
|
|
83
|
+
|
|
84
|
+
if debug:
|
|
85
|
+
print("--- MATCHER PROFILING ---")
|
|
86
|
+
print(f"Total: {time.perf_counter() - start_total:.4f}s")
|
|
87
|
+
print("-------------------------")
|
|
88
|
+
|
|
89
|
+
return res, score, is_ambiguous, offset
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _evaluate_candidates(
|
|
93
|
+
file_lines: List[str],
|
|
94
|
+
find_lines: List[str],
|
|
95
|
+
candidate_starts: Set[int],
|
|
96
|
+
find_block: str,
|
|
97
|
+
) -> tuple[List[str], float, bool, int]:
|
|
98
|
+
"""Evaluates candidates using difflib ratio, with sub-sampling and priority capping."""
|
|
99
|
+
num_find_lines = len(find_lines)
|
|
100
|
+
scored_candidates = []
|
|
101
|
+
|
|
102
|
+
for start in candidate_starts:
|
|
103
|
+
window = file_lines[start : start + num_find_lines]
|
|
104
|
+
if num_find_lines > LARGE_BLOCK_LINE_LIMIT:
|
|
105
|
+
score = _calculate_sub_sample_score(window, find_lines)
|
|
106
|
+
if score >= SUB_SAMPLE_PASS_THRESHOLD:
|
|
107
|
+
scored_candidates.append((score, window))
|
|
108
|
+
else:
|
|
109
|
+
window_str = "".join(window)
|
|
110
|
+
matcher = difflib.SequenceMatcher(None, window_str, find_block)
|
|
111
|
+
score = matcher.ratio()
|
|
112
|
+
scored_candidates.append((score, window))
|
|
113
|
+
|
|
114
|
+
# Sort by score descending and cap evaluation
|
|
115
|
+
scored_candidates.sort(key=lambda x: x[0], reverse=True)
|
|
116
|
+
candidates_to_refine = scored_candidates[:CANDIDATE_EVALUATION_CAP]
|
|
117
|
+
|
|
118
|
+
# FALLBACK: pick first block if no candidates found
|
|
119
|
+
if not candidates_to_refine and file_lines:
|
|
120
|
+
candidates_to_refine = [(0.0, file_lines[:num_find_lines])]
|
|
121
|
+
|
|
122
|
+
return _refine_and_select_best(
|
|
123
|
+
candidates_to_refine, find_lines, num_find_lines, find_block
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _refine_and_select_best(
|
|
128
|
+
candidates: List[tuple[float, List[str]]],
|
|
129
|
+
find_lines: List[str],
|
|
130
|
+
num_find_lines: int,
|
|
131
|
+
find_block: str,
|
|
132
|
+
) -> tuple[List[str], float, bool, int]:
|
|
133
|
+
"""Refines top candidates and returns the best match with ambiguity info."""
|
|
134
|
+
best_ratio = -1.0
|
|
135
|
+
best_match_lines: List[str] = []
|
|
136
|
+
is_ambiguous = False
|
|
137
|
+
best_offset = 0
|
|
138
|
+
ratio_calls = 0
|
|
139
|
+
|
|
140
|
+
for score, window in candidates:
|
|
141
|
+
if num_find_lines > LARGE_BLOCK_LINE_LIMIT:
|
|
142
|
+
matcher = difflib.SequenceMatcher(None, window, find_lines)
|
|
143
|
+
ratio = matcher.ratio()
|
|
144
|
+
else:
|
|
145
|
+
ratio = score
|
|
146
|
+
|
|
147
|
+
# Whitespace Indifference Bonus & Indentation Offset
|
|
148
|
+
ratio, current_offset = _apply_indentation_bonus(window, find_block, ratio)
|
|
149
|
+
|
|
150
|
+
current_match_lines, ratio, current_is_ambiguous = _apply_substring_boost(
|
|
151
|
+
window, find_lines, ratio
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
ratio_calls += 1
|
|
155
|
+
if ratio > best_ratio:
|
|
156
|
+
best_ratio = ratio
|
|
157
|
+
best_match_lines = current_match_lines
|
|
158
|
+
is_ambiguous = current_is_ambiguous
|
|
159
|
+
best_offset = current_offset
|
|
160
|
+
elif ratio == best_ratio and ratio > 0:
|
|
161
|
+
is_ambiguous = True
|
|
162
|
+
|
|
163
|
+
if os.environ.get("TEDDY_DEBUG"):
|
|
164
|
+
print(f"Top Candidates Refined: {len(candidates)}")
|
|
165
|
+
print(f"Ratio Calls: {ratio_calls}")
|
|
166
|
+
|
|
167
|
+
return best_match_lines, best_ratio, is_ambiguous, best_offset
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _apply_indentation_bonus(
|
|
171
|
+
window: List[str], find_block: str, current_ratio: float
|
|
172
|
+
) -> tuple[float, int]:
|
|
173
|
+
"""
|
|
174
|
+
Applies 1.0 score boost if window matches find_block logic exactly,
|
|
175
|
+
ignoring trailing whitespace and constant relative indentation.
|
|
176
|
+
"""
|
|
177
|
+
ratio = current_ratio
|
|
178
|
+
offset = 0
|
|
179
|
+
if ratio >= 1.0:
|
|
180
|
+
return ratio, offset
|
|
181
|
+
|
|
182
|
+
w_lines = [line.rstrip() for line in window]
|
|
183
|
+
f_lines = [line.rstrip() for line in find_block.splitlines(keepends=True)]
|
|
184
|
+
|
|
185
|
+
if len(w_lines) != len(f_lines):
|
|
186
|
+
return ratio, offset
|
|
187
|
+
|
|
188
|
+
offsets = []
|
|
189
|
+
for w_line, f_line in zip(w_lines, f_lines):
|
|
190
|
+
w_stripped = w_line.lstrip()
|
|
191
|
+
f_stripped = f_line.lstrip()
|
|
192
|
+
if w_stripped != f_stripped:
|
|
193
|
+
return ratio, offset
|
|
194
|
+
if w_stripped: # Only calculate offset for non-empty lines
|
|
195
|
+
offsets.append(
|
|
196
|
+
len(w_line) - len(w_stripped) - (len(f_line) - len(f_stripped))
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if offsets and len(set(offsets)) == 1:
|
|
200
|
+
ratio = 1.0
|
|
201
|
+
offset = offsets[0]
|
|
202
|
+
|
|
203
|
+
return ratio, offset
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _apply_substring_boost(
|
|
207
|
+
window: List[str], find_lines: List[str], current_ratio: float
|
|
208
|
+
) -> tuple[List[str], float, bool]:
|
|
209
|
+
"""
|
|
210
|
+
Applies Substring Boost: If a single-line block matches a substring exactly,
|
|
211
|
+
ratio is 1.0. This handles surgical intra-line replacements.
|
|
212
|
+
"""
|
|
213
|
+
ratio = current_ratio
|
|
214
|
+
match_lines = window
|
|
215
|
+
is_ambiguous = False
|
|
216
|
+
|
|
217
|
+
if ratio < 1.0 and len(find_lines) == 1:
|
|
218
|
+
find_text = find_lines[0].rstrip("\n\r")
|
|
219
|
+
if find_text and find_text in window[0]:
|
|
220
|
+
match_count = window[0].count(find_text)
|
|
221
|
+
ratio = 1.0
|
|
222
|
+
match_lines = [find_text]
|
|
223
|
+
if match_count > 1:
|
|
224
|
+
# Intra-line ambiguity detected
|
|
225
|
+
is_ambiguous = True
|
|
226
|
+
|
|
227
|
+
return match_lines, ratio, is_ambiguous
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _calculate_sub_sample_score(window: List[str], find_lines: List[str]) -> float:
|
|
231
|
+
"""
|
|
232
|
+
Calculates a lightning-fast similarity score for large blocks.
|
|
233
|
+
Uses simple string equality on a sub-sample of lines to avoid the
|
|
234
|
+
overhead of difflib.SequenceMatcher.quick_ratio() during the filter phase.
|
|
235
|
+
"""
|
|
236
|
+
num_find_lines = len(find_lines)
|
|
237
|
+
sub_sample_matches = 0
|
|
238
|
+
total_checks = 0
|
|
239
|
+
# Check up to 10 representative lines distributed across the block.
|
|
240
|
+
step = max(1, num_find_lines // 10)
|
|
241
|
+
for k in range(0, num_find_lines, step):
|
|
242
|
+
total_checks += 1
|
|
243
|
+
# Simple equality is O(N) where N is line length, much faster than quick_ratio
|
|
244
|
+
if window[k].strip() == find_lines[k].strip():
|
|
245
|
+
sub_sample_matches += 1
|
|
246
|
+
|
|
247
|
+
return sub_sample_matches / total_checks
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tiered heuristic search for candidate window start positions in EditMatcher.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import List, Set
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def gather_candidate_starts(
|
|
11
|
+
file_lines: List[str], find_lines: List[str], threshold: float
|
|
12
|
+
) -> Set[int]:
|
|
13
|
+
"""Orchestrates tiered heuristic search for candidate window start positions."""
|
|
14
|
+
num_find_lines = len(find_lines)
|
|
15
|
+
|
|
16
|
+
# Tier 1: Exact Priority Anchors
|
|
17
|
+
candidate_starts = _find_starts_by_anchors(file_lines, find_lines)
|
|
18
|
+
|
|
19
|
+
# Tier 2: Incremental Fuzzy Cascade (Fallback)
|
|
20
|
+
if not candidate_starts:
|
|
21
|
+
candidate_starts = _find_starts_by_fuzzy_cascade(
|
|
22
|
+
file_lines, num_find_lines, find_lines[0], threshold
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Tier 3: Substring Fallback (For single-word or intra-line matches)
|
|
26
|
+
if not candidate_starts and num_find_lines == 1:
|
|
27
|
+
find_text = find_lines[0].strip()
|
|
28
|
+
for i, line in enumerate(file_lines):
|
|
29
|
+
if find_text in line:
|
|
30
|
+
candidate_starts.add(i)
|
|
31
|
+
|
|
32
|
+
# Tier 4: Exhaustive Fallback
|
|
33
|
+
if not candidate_starts:
|
|
34
|
+
candidate_starts = set(range(len(file_lines) - num_find_lines + 1))
|
|
35
|
+
|
|
36
|
+
return candidate_starts
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find_starts_by_anchors(file_lines: List[str], find_lines: List[str]) -> Set[int]:
|
|
40
|
+
"""Tier 1: Find candidate windows by matching the longest unique 'anchor' lines."""
|
|
41
|
+
num_find_lines = len(find_lines)
|
|
42
|
+
priority_lines = sorted(
|
|
43
|
+
[(line.strip(), i) for i, line in enumerate(find_lines) if line.strip()],
|
|
44
|
+
key=lambda x: len(x[0]),
|
|
45
|
+
reverse=True,
|
|
46
|
+
)[:5]
|
|
47
|
+
|
|
48
|
+
file_line_map = defaultdict(list)
|
|
49
|
+
for i, line in enumerate(file_lines):
|
|
50
|
+
trimmed = line.strip()
|
|
51
|
+
if trimmed:
|
|
52
|
+
file_line_map[trimmed].append(i)
|
|
53
|
+
|
|
54
|
+
candidate_starts: Set[int] = set()
|
|
55
|
+
for trimmed, find_idx in priority_lines:
|
|
56
|
+
if trimmed in file_line_map:
|
|
57
|
+
for file_idx in file_line_map[trimmed]:
|
|
58
|
+
start = file_idx - find_idx
|
|
59
|
+
if 0 <= start <= len(file_lines) - num_find_lines:
|
|
60
|
+
candidate_starts.add(start)
|
|
61
|
+
return candidate_starts
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _find_starts_by_fuzzy_cascade(
|
|
65
|
+
file_lines: List[str],
|
|
66
|
+
num_find_lines: int,
|
|
67
|
+
first_find_line_raw: str,
|
|
68
|
+
threshold: float,
|
|
69
|
+
) -> Set[int]:
|
|
70
|
+
"""
|
|
71
|
+
Tier 2: Find candidate windows by fuzzy matching the first line of the block.
|
|
72
|
+
Uses real_quick_ratio as a fast pre-filter before checking quick_ratio.
|
|
73
|
+
"""
|
|
74
|
+
candidate_starts: Set[int] = set()
|
|
75
|
+
first_find_line = first_find_line_raw.strip()
|
|
76
|
+
for i, f_line in enumerate(file_lines):
|
|
77
|
+
f_line_stripped = f_line.strip()
|
|
78
|
+
# Pre-filter with real_quick_ratio which is O(N+M) and very fast
|
|
79
|
+
matcher = difflib.SequenceMatcher(None, f_line_stripped, first_find_line)
|
|
80
|
+
if matcher.real_quick_ratio() >= threshold:
|
|
81
|
+
if matcher.quick_ratio() >= threshold:
|
|
82
|
+
if 0 <= i <= len(file_lines) - num_find_lines:
|
|
83
|
+
candidate_starts.add(i)
|
|
84
|
+
return candidate_starts
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validation rules for the 'EXECUTE' action.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from teddy_executor.core.domain.models.plan import ActionData, ValidationError
|
|
8
|
+
from teddy_executor.core.services.validation_rules.helpers import (
|
|
9
|
+
BaseActionValidator,
|
|
10
|
+
ContextPaths,
|
|
11
|
+
ValidationResult,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExecuteActionValidator(BaseActionValidator):
|
|
16
|
+
"""Checks for command content and valid working directory."""
|
|
17
|
+
|
|
18
|
+
def validate(
|
|
19
|
+
self,
|
|
20
|
+
action: ActionData,
|
|
21
|
+
context_paths: Optional[ContextPaths] = None,
|
|
22
|
+
) -> ValidationResult:
|
|
23
|
+
cmd_text = action.params.get("command", "").strip()
|
|
24
|
+
errors: ValidationResult = []
|
|
25
|
+
|
|
26
|
+
_, path_errs = self._get_validated_path(action, ["cwd"], "EXECUTE")
|
|
27
|
+
errors.extend(path_errs)
|
|
28
|
+
|
|
29
|
+
if not cmd_text:
|
|
30
|
+
errors.append(
|
|
31
|
+
ValidationError(message="EXECUTE action must contain a command")
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return errors
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Removed legacy functional validation rule in favor of ExecuteActionValidator class.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Consolidated validation rules for filesystem-related actions (CREATE, READ, PRUNE).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from teddy_executor.core.domain.models.plan import ActionData, ValidationError
|
|
8
|
+
from teddy_executor.core.services.validation_rules.helpers import (
|
|
9
|
+
BaseActionValidator,
|
|
10
|
+
ContextPaths,
|
|
11
|
+
ValidationResult,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CreateActionValidator(BaseActionValidator):
|
|
16
|
+
"""Handles validation for the 'CREATE' action."""
|
|
17
|
+
|
|
18
|
+
def validate(
|
|
19
|
+
self,
|
|
20
|
+
action: ActionData,
|
|
21
|
+
context_paths: Optional[ContextPaths] = None,
|
|
22
|
+
) -> ValidationResult:
|
|
23
|
+
"""Performs pre-flight checks for CREATE."""
|
|
24
|
+
target_path, validation_errs = self._get_validated_path(
|
|
25
|
+
action, ["path"], "CREATE"
|
|
26
|
+
)
|
|
27
|
+
if validation_errs:
|
|
28
|
+
return validation_errs
|
|
29
|
+
|
|
30
|
+
if target_path and self._file_system_manager.path_exists(target_path):
|
|
31
|
+
if not action.params.get("overwrite"):
|
|
32
|
+
return [
|
|
33
|
+
ValidationError(
|
|
34
|
+
message=(
|
|
35
|
+
f"File already exists: {target_path}. **Hint:** The 'Overwrite: true' "
|
|
36
|
+
"parameter can be used with caution to bypass this."
|
|
37
|
+
),
|
|
38
|
+
file_path=target_path,
|
|
39
|
+
offending_node=action.node,
|
|
40
|
+
)
|
|
41
|
+
]
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReadActionValidator(BaseActionValidator):
|
|
46
|
+
"""Handles validation for the 'READ' action."""
|
|
47
|
+
|
|
48
|
+
def validate(
|
|
49
|
+
self,
|
|
50
|
+
action: ActionData,
|
|
51
|
+
context_paths: Optional[ContextPaths] = None,
|
|
52
|
+
) -> ValidationResult:
|
|
53
|
+
"""Performs pre-flight checks for READ."""
|
|
54
|
+
src_resource, read_errors = self._get_validated_path(
|
|
55
|
+
action, ["resource"], "READ"
|
|
56
|
+
)
|
|
57
|
+
if read_errors:
|
|
58
|
+
return read_errors
|
|
59
|
+
|
|
60
|
+
if (
|
|
61
|
+
isinstance(src_resource, str)
|
|
62
|
+
and not src_resource.startswith("http://")
|
|
63
|
+
and not src_resource.startswith("https://")
|
|
64
|
+
):
|
|
65
|
+
if not self._file_system_manager.path_exists(src_resource):
|
|
66
|
+
return [
|
|
67
|
+
ValidationError(
|
|
68
|
+
message=f"File to read does not exist: {src_resource}",
|
|
69
|
+
file_path=src_resource,
|
|
70
|
+
offending_node=action.node,
|
|
71
|
+
)
|
|
72
|
+
]
|
|
73
|
+
return read_errors
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared helper classes and functions for validation rules.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from abc import ABC
|
|
8
|
+
from typing import Dict, List, Optional, Protocol, Sequence
|
|
9
|
+
|
|
10
|
+
from teddy_executor.core.domain.models.plan import (
|
|
11
|
+
DEFAULT_SIMILARITY_THRESHOLD,
|
|
12
|
+
ActionData,
|
|
13
|
+
ValidationError,
|
|
14
|
+
)
|
|
15
|
+
from teddy_executor.core.ports.outbound import IConfigService, IFileSystemManager
|
|
16
|
+
|
|
17
|
+
ContextPaths = Dict[str, Sequence[str]]
|
|
18
|
+
ValidationResult = List[ValidationError]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IActionValidator(Protocol):
|
|
22
|
+
"""
|
|
23
|
+
Protocol for an action-specific validation rule.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def can_validate(self, action_type: str) -> bool:
|
|
27
|
+
"""Returns True if this validator can handle the given action type."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def validate(
|
|
31
|
+
self,
|
|
32
|
+
action: ActionData,
|
|
33
|
+
context_paths: Optional[ContextPaths] = None,
|
|
34
|
+
) -> ValidationResult:
|
|
35
|
+
"""Validates the given action."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BaseActionValidator(ABC, IActionValidator):
|
|
40
|
+
"""Base class for action validators to reduce boilerplate."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, file_system_manager: IFileSystemManager):
|
|
43
|
+
self._file_system_manager = file_system_manager
|
|
44
|
+
|
|
45
|
+
def can_validate(self, action_type: str) -> bool:
|
|
46
|
+
# This assumes the class name follows the pattern [ActionType]ActionValidator
|
|
47
|
+
expected_prefix = self.__class__.__name__.replace("ActionValidator", "").lower()
|
|
48
|
+
return action_type.lower() == expected_prefix
|
|
49
|
+
|
|
50
|
+
def _validate_alias_url_restriction(
|
|
51
|
+
self, action: ActionData, param_name: str = "resource"
|
|
52
|
+
) -> ValidationResult:
|
|
53
|
+
"""Ensures 'File Path' alias doesn't point to a URL."""
|
|
54
|
+
path_str = action.params.get(param_name)
|
|
55
|
+
used_alias = action.params.get("metadata_used_file_path_alias", False)
|
|
56
|
+
|
|
57
|
+
if (
|
|
58
|
+
used_alias
|
|
59
|
+
and isinstance(path_str, str)
|
|
60
|
+
and (path_str.startswith("http://") or path_str.startswith("https://"))
|
|
61
|
+
):
|
|
62
|
+
return [
|
|
63
|
+
ValidationError(
|
|
64
|
+
message="Strict Local Only: 'File Path' alias does not support URLs",
|
|
65
|
+
file_path=path_str,
|
|
66
|
+
offending_node=action.node,
|
|
67
|
+
)
|
|
68
|
+
]
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
def _get_validated_path(
|
|
72
|
+
self, action: ActionData, param_names: List[str], action_type: str
|
|
73
|
+
) -> tuple[Optional[str], ValidationResult]:
|
|
74
|
+
"""Extracts and validates a path from action parameters."""
|
|
75
|
+
path_str = None
|
|
76
|
+
primary_param = param_names[0] if param_names else "resource"
|
|
77
|
+
for name in param_names:
|
|
78
|
+
if val := action.params.get(name):
|
|
79
|
+
path_str = val
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
if not isinstance(path_str, str):
|
|
83
|
+
return None, []
|
|
84
|
+
|
|
85
|
+
errors = self._validate_alias_url_restriction(action, param_name=primary_param)
|
|
86
|
+
if not errors:
|
|
87
|
+
try:
|
|
88
|
+
validate_path_is_safe(path_str, action_type)
|
|
89
|
+
except PlanValidationError as e:
|
|
90
|
+
errors.append(
|
|
91
|
+
ValidationError(
|
|
92
|
+
message=e.message,
|
|
93
|
+
file_path=e.file_path,
|
|
94
|
+
offending_node=action.node,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return path_str, errors
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PlanValidationError(Exception):
|
|
102
|
+
"""Custom exception for plan validation errors."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, message: str, file_path: Optional[str] = None):
|
|
105
|
+
super().__init__(message)
|
|
106
|
+
self.message = message
|
|
107
|
+
self.file_path = file_path
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def validate_path_is_safe(path_str: str, action_type: str):
|
|
111
|
+
"""
|
|
112
|
+
Ensures a file path is safe by checking for absolute paths and
|
|
113
|
+
directory traversal attempts.
|
|
114
|
+
"""
|
|
115
|
+
if os.path.isabs(path_str):
|
|
116
|
+
raise PlanValidationError(
|
|
117
|
+
f"Action `{action_type}` contains an absolute path, which is not allowed: {path_str}"
|
|
118
|
+
)
|
|
119
|
+
if ".." in Path(path_str).parts:
|
|
120
|
+
raise PlanValidationError(
|
|
121
|
+
f"Action `{action_type}` contains a directory traversal attempt ('..'), which is not allowed: {path_str}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_similarity_threshold(
|
|
126
|
+
config_service: IConfigService, action_params: dict
|
|
127
|
+
) -> float:
|
|
128
|
+
"""
|
|
129
|
+
Centralized resolution logic for the similarity threshold.
|
|
130
|
+
Resolves in order: Action Params -> Global Config -> Domain Default.
|
|
131
|
+
"""
|
|
132
|
+
global_threshold_raw = config_service.get_setting(
|
|
133
|
+
"execution.similarity_threshold", DEFAULT_SIMILARITY_THRESHOLD
|
|
134
|
+
)
|
|
135
|
+
global_threshold = (
|
|
136
|
+
float(global_threshold_raw)
|
|
137
|
+
if global_threshold_raw is not None
|
|
138
|
+
else DEFAULT_SIMILARITY_THRESHOLD
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
threshold_raw = action_params.get(
|
|
142
|
+
"execution.similarity_threshold", global_threshold
|
|
143
|
+
)
|
|
144
|
+
return float(threshold_raw) if threshold_raw is not None else global_threshold
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_path_in_context(
|
|
148
|
+
path_str: str,
|
|
149
|
+
context_paths: Optional[Dict[str, Sequence[str]]],
|
|
150
|
+
check_session: bool = True,
|
|
151
|
+
check_turn: bool = True,
|
|
152
|
+
) -> bool:
|
|
153
|
+
"""
|
|
154
|
+
Checks if a path (normalized) is present in the specified context scopes.
|
|
155
|
+
Normalizes both target and context paths to use forward slashes.
|
|
156
|
+
"""
|
|
157
|
+
if not context_paths or not path_str:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
scopes = []
|
|
161
|
+
if check_session:
|
|
162
|
+
scopes.append("Session")
|
|
163
|
+
if check_turn:
|
|
164
|
+
scopes.append("Turn")
|
|
165
|
+
|
|
166
|
+
# Normalize target: convert backslashes BEFORE removing leading slash/dot-slash
|
|
167
|
+
normalized_target = path_str.replace("\\", "/").removeprefix("./").lstrip("/")
|
|
168
|
+
|
|
169
|
+
for scope in scopes:
|
|
170
|
+
context_files = context_paths.get(scope, [])
|
|
171
|
+
# Normalize context: convert backslashes BEFORE removing leading slash/dot-slash
|
|
172
|
+
normalized_context = [
|
|
173
|
+
p.replace("\\", "/").removeprefix("./").lstrip("/") for p in context_files
|
|
174
|
+
]
|
|
175
|
+
if normalized_target in normalized_context:
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
return False
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from teddy_executor.core.domain.models.plan import ActionData, ValidationError
|
|
4
|
+
from teddy_executor.core.services.validation_rules.helpers import (
|
|
5
|
+
BaseActionValidator,
|
|
6
|
+
ContextPaths,
|
|
7
|
+
ValidationResult,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MessageActionValidator(BaseActionValidator):
|
|
12
|
+
"""
|
|
13
|
+
Validates MESSAGE actions.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def validate(
|
|
17
|
+
self,
|
|
18
|
+
action: ActionData,
|
|
19
|
+
context_paths: Optional[ContextPaths] = None,
|
|
20
|
+
) -> ValidationResult:
|
|
21
|
+
content = action.params.get("content")
|
|
22
|
+
if not content or not isinstance(content, str) or not content.strip():
|
|
23
|
+
return [
|
|
24
|
+
ValidationError(
|
|
25
|
+
message="MESSAGE action must have non-empty content.",
|
|
26
|
+
offending_node=action.node,
|
|
27
|
+
)
|
|
28
|
+
]
|
|
29
|
+
return []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Init for core utils
|