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.
Files changed (143) hide show
  1. teddy_cli-0.1.0.dist-info/LICENSE +677 -0
  2. teddy_cli-0.1.0.dist-info/METADATA +33 -0
  3. teddy_cli-0.1.0.dist-info/RECORD +143 -0
  4. teddy_cli-0.1.0.dist-info/WHEEL +4 -0
  5. teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
  6. teddy_executor/__init__.py +1 -0
  7. teddy_executor/__main__.py +335 -0
  8. teddy_executor/adapters/__init__.py +0 -0
  9. teddy_executor/adapters/inbound/__init__.py +0 -0
  10. teddy_executor/adapters/inbound/cli_formatter.py +107 -0
  11. teddy_executor/adapters/inbound/cli_helpers.py +249 -0
  12. teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
  13. teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
  14. teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
  15. teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
  16. teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
  17. teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
  18. teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
  19. teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
  20. teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
  21. teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
  22. teddy_executor/adapters/outbound/__init__.py +7 -0
  23. teddy_executor/adapters/outbound/console_interactor.py +212 -0
  24. teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
  25. teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
  26. teddy_executor/adapters/outbound/console_tooling.py +62 -0
  27. teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
  28. teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
  29. teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
  30. teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
  31. teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
  32. teddy_executor/adapters/outbound/shell_adapter.py +344 -0
  33. teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
  34. teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
  35. teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
  36. teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
  37. teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
  38. teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
  39. teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
  40. teddy_executor/container.py +333 -0
  41. teddy_executor/core/__init__.py +0 -0
  42. teddy_executor/core/domain/__init__.py +0 -0
  43. teddy_executor/core/domain/models/__init__.py +44 -0
  44. teddy_executor/core/domain/models/action_ports.py +28 -0
  45. teddy_executor/core/domain/models/change_set.py +10 -0
  46. teddy_executor/core/domain/models/exceptions.py +40 -0
  47. teddy_executor/core/domain/models/execution_report.py +65 -0
  48. teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
  49. teddy_executor/core/domain/models/plan.py +85 -0
  50. teddy_executor/core/domain/models/planning_ports.py +43 -0
  51. teddy_executor/core/domain/models/project_context.py +56 -0
  52. teddy_executor/core/domain/models/report_assembly_data.py +18 -0
  53. teddy_executor/core/domain/models/session.py +17 -0
  54. teddy_executor/core/domain/models/shell_output.py +12 -0
  55. teddy_executor/core/domain/models/web_search_results.py +26 -0
  56. teddy_executor/core/ports/__init__.py +0 -0
  57. teddy_executor/core/ports/inbound/__init__.py +0 -0
  58. teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
  59. teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
  60. teddy_executor/core/ports/inbound/init.py +15 -0
  61. teddy_executor/core/ports/inbound/plan_parser.py +52 -0
  62. teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
  63. teddy_executor/core/ports/inbound/plan_validator.py +26 -0
  64. teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
  65. teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
  66. teddy_executor/core/ports/outbound/__init__.py +34 -0
  67. teddy_executor/core/ports/outbound/config_service.py +29 -0
  68. teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
  69. teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
  70. teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
  71. teddy_executor/core/ports/outbound/llm_client.py +90 -0
  72. teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
  73. teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
  74. teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
  75. teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
  76. teddy_executor/core/ports/outbound/session_manager.py +97 -0
  77. teddy_executor/core/ports/outbound/session_repository.py +65 -0
  78. teddy_executor/core/ports/outbound/shell_executor.py +24 -0
  79. teddy_executor/core/ports/outbound/system_environment.py +25 -0
  80. teddy_executor/core/ports/outbound/time_service.py +28 -0
  81. teddy_executor/core/ports/outbound/user_interactor.py +126 -0
  82. teddy_executor/core/ports/outbound/web_scraper.py +24 -0
  83. teddy_executor/core/ports/outbound/web_searcher.py +25 -0
  84. teddy_executor/core/services/__init__.py +0 -0
  85. teddy_executor/core/services/action_changeset_builder.py +90 -0
  86. teddy_executor/core/services/action_diff_manager.py +110 -0
  87. teddy_executor/core/services/action_dispatcher.py +142 -0
  88. teddy_executor/core/services/action_executor.py +209 -0
  89. teddy_executor/core/services/action_factory.py +197 -0
  90. teddy_executor/core/services/action_parser_complex.py +216 -0
  91. teddy_executor/core/services/action_parser_strategies.py +84 -0
  92. teddy_executor/core/services/context_service.py +437 -0
  93. teddy_executor/core/services/edit_simulator.py +128 -0
  94. teddy_executor/core/services/execution_orchestrator.py +295 -0
  95. teddy_executor/core/services/execution_report_assembler.py +62 -0
  96. teddy_executor/core/services/init_service.py +80 -0
  97. teddy_executor/core/services/markdown_plan_parser.py +309 -0
  98. teddy_executor/core/services/markdown_report_formatter.py +143 -0
  99. teddy_executor/core/services/parser_infrastructure.py +222 -0
  100. teddy_executor/core/services/parser_metadata.py +153 -0
  101. teddy_executor/core/services/parser_reporting.py +267 -0
  102. teddy_executor/core/services/plan_validator.py +82 -0
  103. teddy_executor/core/services/planning_service.py +242 -0
  104. teddy_executor/core/services/prompt_manager.py +146 -0
  105. teddy_executor/core/services/session_lifecycle_manager.py +228 -0
  106. teddy_executor/core/services/session_loop_guard.py +46 -0
  107. teddy_executor/core/services/session_orchestrator.py +538 -0
  108. teddy_executor/core/services/session_planner.py +43 -0
  109. teddy_executor/core/services/session_pruning_service.py +438 -0
  110. teddy_executor/core/services/session_replanner.py +105 -0
  111. teddy_executor/core/services/session_repository.py +194 -0
  112. teddy_executor/core/services/session_service.py +529 -0
  113. teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
  114. teddy_executor/core/services/validation_rules/__init__.py +4 -0
  115. teddy_executor/core/services/validation_rules/edit.py +207 -0
  116. teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
  117. teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
  118. teddy_executor/core/services/validation_rules/execute.py +37 -0
  119. teddy_executor/core/services/validation_rules/filesystem.py +73 -0
  120. teddy_executor/core/services/validation_rules/helpers.py +178 -0
  121. teddy_executor/core/services/validation_rules/message.py +29 -0
  122. teddy_executor/core/utils/__init__.py +1 -0
  123. teddy_executor/core/utils/diff.py +57 -0
  124. teddy_executor/core/utils/io.py +75 -0
  125. teddy_executor/core/utils/markdown.py +131 -0
  126. teddy_executor/core/utils/serialization.py +39 -0
  127. teddy_executor/core/utils/string.py +351 -0
  128. teddy_executor/prompts.py +45 -0
  129. teddy_executor/registries/__init__.py +1 -0
  130. teddy_executor/registries/infrastructure.py +147 -0
  131. teddy_executor/registries/reviewer.py +57 -0
  132. teddy_executor/registries/validators.py +47 -0
  133. teddy_executor/resources/__init__.py +1 -0
  134. teddy_executor/resources/config/.gitignore +2 -0
  135. teddy_executor/resources/config/__init__.py +1 -0
  136. teddy_executor/resources/config/config.yaml +49 -0
  137. teddy_executor/resources/config/init.context +5 -0
  138. teddy_executor/resources/config/prompts/architect.xml +462 -0
  139. teddy_executor/resources/config/prompts/assistant.xml +336 -0
  140. teddy_executor/resources/config/prompts/debugger.xml +456 -0
  141. teddy_executor/resources/config/prompts/developer.xml +481 -0
  142. teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
  143. 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