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,438 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import is_dataclass, replace
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from teddy_executor.core.domain.models import ProjectContext
|
|
6
|
+
from teddy_executor.core.ports.outbound.config_service import IConfigService
|
|
7
|
+
from teddy_executor.core.ports.outbound.file_system_manager import IFileSystemManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionPruningService:
|
|
11
|
+
"""
|
|
12
|
+
Encapsulates auto-pruning heuristics for session context.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
config_service: IConfigService,
|
|
18
|
+
file_system_manager: IFileSystemManager,
|
|
19
|
+
):
|
|
20
|
+
self._config_service = config_service
|
|
21
|
+
self._file_system_manager = file_system_manager
|
|
22
|
+
self._read_cache: Dict[str, str] = {}
|
|
23
|
+
|
|
24
|
+
def prune(
|
|
25
|
+
self, context: ProjectContext, current_status: Optional[str] = None
|
|
26
|
+
) -> ProjectContext:
|
|
27
|
+
"""Applies configured auto-pruning heuristics to the project context."""
|
|
28
|
+
self._read_cache.clear()
|
|
29
|
+
try:
|
|
30
|
+
if not self._config_service.get_setting("auto_pruning.enabled", True):
|
|
31
|
+
return context
|
|
32
|
+
|
|
33
|
+
# Handle MagicMocks in unit tests
|
|
34
|
+
if not is_dataclass(context):
|
|
35
|
+
return context
|
|
36
|
+
|
|
37
|
+
items = list(context.items)
|
|
38
|
+
|
|
39
|
+
# 1. Prune by status/validation failure (Heuristics 3 & 4)
|
|
40
|
+
turns_to_prune = self._identify_turns_to_prune(items, current_status)
|
|
41
|
+
|
|
42
|
+
for i, item in enumerate(items):
|
|
43
|
+
new_item = self._process_context_item(item, turns_to_prune)
|
|
44
|
+
if new_item is not item:
|
|
45
|
+
items[i] = new_item
|
|
46
|
+
|
|
47
|
+
# 2. Heuristic 6: Retention Limit
|
|
48
|
+
items = self._apply_retention_limit(items)
|
|
49
|
+
|
|
50
|
+
# 3. Heuristic 2: Global Budget
|
|
51
|
+
items = self._apply_global_budget(
|
|
52
|
+
items,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return replace(context, items=items)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
# Failure Transparency: Log and re-raise or return original context
|
|
58
|
+
import sys
|
|
59
|
+
|
|
60
|
+
print(f"[ERROR] PruningService failure: {e}", file=sys.stderr)
|
|
61
|
+
return context
|
|
62
|
+
|
|
63
|
+
def _process_context_item(self, item: Any, turns_to_prune: Dict[str, str]) -> Any:
|
|
64
|
+
"""Processes an individual context item for pruning."""
|
|
65
|
+
if item.scope != "Turn":
|
|
66
|
+
return item
|
|
67
|
+
|
|
68
|
+
if item.git_status == "D":
|
|
69
|
+
return replace(
|
|
70
|
+
item, selected=False, auto_prune_reason="File deleted from disk"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Normalize path for consistent string matching
|
|
74
|
+
posix_path = item.path.replace("\\", "/").removeprefix("./").lstrip("/")
|
|
75
|
+
|
|
76
|
+
# Match numeric turn directories (e.g. '01', '02')
|
|
77
|
+
turn_id = self._extract_turn_id(posix_path)
|
|
78
|
+
if turn_id:
|
|
79
|
+
# Check both raw string and integer-normalized version
|
|
80
|
+
reason = turns_to_prune.get(turn_id) or turns_to_prune.get(
|
|
81
|
+
str(int(turn_id))
|
|
82
|
+
)
|
|
83
|
+
if reason:
|
|
84
|
+
return replace(
|
|
85
|
+
item,
|
|
86
|
+
selected=False,
|
|
87
|
+
auto_prune_reason=reason,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return item
|
|
91
|
+
|
|
92
|
+
def _extract_turn_id(self, path: str) -> Optional[str]:
|
|
93
|
+
"""Extracts the last numeric directory segment from the path."""
|
|
94
|
+
if not isinstance(path, str):
|
|
95
|
+
return None
|
|
96
|
+
# Normalize to forward slashes and strip prefixes for consistent matching
|
|
97
|
+
normalized = path.replace("\\", "/").removeprefix("./").lstrip("/")
|
|
98
|
+
# Turn IDs are typically 1-3 digits. 4+ digits usually represent years or other data.
|
|
99
|
+
matches = re.findall(r"(?:^|/)(\d{1,3})(?=/|$)", normalized)
|
|
100
|
+
return matches[-1] if matches else None
|
|
101
|
+
|
|
102
|
+
def _identify_turns_to_prune(
|
|
103
|
+
self, items, current_status: Optional[str] = None
|
|
104
|
+
) -> Dict[str, str]:
|
|
105
|
+
"""Identifies turns that should be pruned based on failure status."""
|
|
106
|
+
prune_non_green = bool(
|
|
107
|
+
self._config_service.get_setting("auto_pruning.prune_failure_history", True)
|
|
108
|
+
)
|
|
109
|
+
prune_validation = bool(
|
|
110
|
+
self._config_service.get_setting(
|
|
111
|
+
"auto_pruning.prune_validation_failures", True
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
turn_statuses, validation_failures, non_vf_reports = (
|
|
116
|
+
self._collect_turn_metadata(items, prune_non_green, prune_validation)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
turns_to_prune = self._apply_pruning_heuristics(
|
|
120
|
+
turn_statuses,
|
|
121
|
+
validation_failures,
|
|
122
|
+
prune_non_green,
|
|
123
|
+
current_status,
|
|
124
|
+
non_vf_reports,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return turns_to_prune
|
|
128
|
+
|
|
129
|
+
def _collect_turn_metadata(
|
|
130
|
+
self,
|
|
131
|
+
items,
|
|
132
|
+
prune_non_green: bool,
|
|
133
|
+
prune_validation: bool,
|
|
134
|
+
) -> tuple[Dict[int, bool], set[int], set[int]]:
|
|
135
|
+
"""Scans items to determine turn statuses, validation failures, and non-VF reports."""
|
|
136
|
+
turn_statuses: Dict[int, bool] = {}
|
|
137
|
+
validation_failures: set[int] = set()
|
|
138
|
+
non_vf_reports: set[int] = set()
|
|
139
|
+
checked_paths = set()
|
|
140
|
+
|
|
141
|
+
for item in items:
|
|
142
|
+
if item.scope != "Turn" or item.path in checked_paths:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
posix_path = item.path.replace("\\", "/")
|
|
146
|
+
turn_id_str = self._extract_turn_id(posix_path)
|
|
147
|
+
if not turn_id_str:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
turn_id = int(turn_id_str)
|
|
151
|
+
checked_paths.add(item.path)
|
|
152
|
+
|
|
153
|
+
self._update_turn_metadata_from_item(
|
|
154
|
+
item,
|
|
155
|
+
posix_path,
|
|
156
|
+
turn_id,
|
|
157
|
+
{
|
|
158
|
+
"statuses": turn_statuses,
|
|
159
|
+
"validation_fails": validation_failures,
|
|
160
|
+
"non_vf_reports": non_vf_reports,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"non_green": prune_non_green,
|
|
164
|
+
"validation": prune_validation,
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return turn_statuses, validation_failures, non_vf_reports
|
|
169
|
+
|
|
170
|
+
def _update_turn_metadata_from_item(
|
|
171
|
+
self,
|
|
172
|
+
item: Any,
|
|
173
|
+
posix_path: str,
|
|
174
|
+
turn_id: int,
|
|
175
|
+
state: Dict[str, Any],
|
|
176
|
+
config: Dict[str, bool],
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Processes a single item to update turn-level metadata."""
|
|
179
|
+
self._update_metadata_from_report(item, posix_path, turn_id, state, config)
|
|
180
|
+
self._update_metadata_from_plan(item, posix_path, turn_id, state, config)
|
|
181
|
+
|
|
182
|
+
def _update_metadata_from_report(
|
|
183
|
+
self,
|
|
184
|
+
item: Any,
|
|
185
|
+
posix_path: str,
|
|
186
|
+
turn_id: int,
|
|
187
|
+
state: Dict[str, Any],
|
|
188
|
+
config: Dict[str, bool],
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Processes a report.md item for validation failure and sparing metadata."""
|
|
191
|
+
if not posix_path.endswith("report.md"):
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
# Heuristic 4: Validation failure
|
|
195
|
+
if config.get("validation") and self._check_report_failed_validation(item.path):
|
|
196
|
+
state["validation_fails"].add(turn_id)
|
|
197
|
+
|
|
198
|
+
# Non-VF reports for Heuristic 4 guard
|
|
199
|
+
if self._check_report_is_non_vf_report(item.path):
|
|
200
|
+
state["non_vf_reports"].add(turn_id)
|
|
201
|
+
|
|
202
|
+
def _update_metadata_from_plan(
|
|
203
|
+
self,
|
|
204
|
+
item: Any,
|
|
205
|
+
posix_path: str,
|
|
206
|
+
turn_id: int,
|
|
207
|
+
state: Dict[str, Any],
|
|
208
|
+
config: Dict[str, bool],
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Processes a plan.md item for status and message sparing metadata."""
|
|
211
|
+
if not posix_path.endswith("plan.md"):
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
# Heuristic 3: Non-green state
|
|
215
|
+
is_failed = self._check_plan_failed(item.path)
|
|
216
|
+
if config["non_green"]:
|
|
217
|
+
is_green = not is_failed
|
|
218
|
+
# If any file in turn is non-green, the whole turn is non-green
|
|
219
|
+
state["statuses"][turn_id] = (
|
|
220
|
+
state["statuses"].get(turn_id, True) and is_green
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _check_plan_failed(self, path: str) -> bool:
|
|
224
|
+
"""Checks if a plan file contains a failure status emoji on the status line."""
|
|
225
|
+
content = self._safe_read(path)
|
|
226
|
+
if content:
|
|
227
|
+
# Anchored to start of line to avoid matches in rationales or code blocks
|
|
228
|
+
return bool(re.search(r"^- \*\*Status:\*\*.*[🔴🟡]", content, re.MULTILINE))
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
def _check_report_failed_validation(self, path: str) -> bool:
|
|
232
|
+
"""Checks if a report file contains the official validation failure status."""
|
|
233
|
+
content = self._safe_read(path)
|
|
234
|
+
if content:
|
|
235
|
+
# Anchored to target the standardized overall status line
|
|
236
|
+
return bool(
|
|
237
|
+
re.search(
|
|
238
|
+
r"^- \*\*Overall Status:\*\* Validation Failed",
|
|
239
|
+
content,
|
|
240
|
+
re.MULTILINE,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
def _check_report_is_non_vf_report(self, path: str) -> bool:
|
|
246
|
+
"""Checks if a report file exists and does NOT have validation failure status."""
|
|
247
|
+
content = self._safe_read(path)
|
|
248
|
+
if content:
|
|
249
|
+
return not bool(
|
|
250
|
+
re.search(
|
|
251
|
+
r"^- \*\*Overall Status:\*\* Validation Failed",
|
|
252
|
+
content,
|
|
253
|
+
re.MULTILINE,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
def _check_report_is_success(self, path: str) -> bool:
|
|
259
|
+
"""Checks if a report file contains the official success status."""
|
|
260
|
+
content = self._safe_read(path)
|
|
261
|
+
if content:
|
|
262
|
+
# Anchored to target the standardized overall status line
|
|
263
|
+
return bool(
|
|
264
|
+
re.search(
|
|
265
|
+
r"^- \*\*Overall Status:\*\* SUCCESS",
|
|
266
|
+
content,
|
|
267
|
+
re.MULTILINE,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
def _safe_read(self, path: str) -> Optional[str]:
|
|
273
|
+
"""Reads a file with caching and error handling."""
|
|
274
|
+
if path in self._read_cache:
|
|
275
|
+
return self._read_cache[path]
|
|
276
|
+
try:
|
|
277
|
+
if self._file_system_manager.path_exists(path):
|
|
278
|
+
content = self._file_system_manager.read_file(path)
|
|
279
|
+
self._read_cache[path] = content
|
|
280
|
+
return content
|
|
281
|
+
except (FileNotFoundError, OSError):
|
|
282
|
+
pass
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
def _check_file_contains(self, path: str, patterns: str | tuple[str, ...]) -> bool:
|
|
286
|
+
"""Safely checks if a file exists and contains specific patterns."""
|
|
287
|
+
try:
|
|
288
|
+
if self._file_system_manager.path_exists(path):
|
|
289
|
+
content = self._file_system_manager.read_file(path)
|
|
290
|
+
if isinstance(patterns, str):
|
|
291
|
+
return patterns in content
|
|
292
|
+
return any(p in content for p in patterns)
|
|
293
|
+
except (FileNotFoundError, OSError):
|
|
294
|
+
pass
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
def _apply_pruning_heuristics(
|
|
298
|
+
self,
|
|
299
|
+
turn_statuses: Dict[int, bool],
|
|
300
|
+
validation_failures: set[int],
|
|
301
|
+
prune_non_green: bool,
|
|
302
|
+
current_status: Optional[str] = None,
|
|
303
|
+
non_vf_reports: Optional[set[int]] = None,
|
|
304
|
+
) -> Dict[str, str]:
|
|
305
|
+
"""Applies heuristics to the collected metadata."""
|
|
306
|
+
turns_to_prune: Dict[str, str] = {}
|
|
307
|
+
|
|
308
|
+
# Heuristic 4: Validation Failure (guarded by non-VF report)
|
|
309
|
+
is_currently_non_vf = (
|
|
310
|
+
current_status is not None and "Validation Failed" not in current_status
|
|
311
|
+
)
|
|
312
|
+
latest_non_vf_turn = max(non_vf_reports) if non_vf_reports else -1
|
|
313
|
+
|
|
314
|
+
for tid in sorted(validation_failures):
|
|
315
|
+
prune_vf = tid < latest_non_vf_turn
|
|
316
|
+
if not prune_vf and is_currently_non_vf:
|
|
317
|
+
prune_vf = True
|
|
318
|
+
if prune_vf:
|
|
319
|
+
turns_to_prune[str(tid)] = "Plan failed validation"
|
|
320
|
+
|
|
321
|
+
# Heuristic 3: Recovery Cleanup
|
|
322
|
+
# If current_status is Green, OR if the latest turn on disk is Green, prune failures.
|
|
323
|
+
is_currently_green = current_status is not None and "🟢" in current_status
|
|
324
|
+
|
|
325
|
+
if prune_non_green and turn_statuses:
|
|
326
|
+
latest_on_disk = max(turn_statuses.keys())
|
|
327
|
+
is_latest_green = turn_statuses[latest_on_disk]
|
|
328
|
+
|
|
329
|
+
if is_currently_green or is_latest_green:
|
|
330
|
+
for tid, is_green in turn_statuses.items():
|
|
331
|
+
if not is_green:
|
|
332
|
+
turns_to_prune.setdefault(
|
|
333
|
+
str(tid), "Pruned failure history after successful recovery"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return turns_to_prune
|
|
337
|
+
|
|
338
|
+
def _apply_retention_limit(self, items):
|
|
339
|
+
"""Prunes turn context items that exceed the turn retention limit."""
|
|
340
|
+
try:
|
|
341
|
+
setting = self._config_service.get_setting(
|
|
342
|
+
"auto_pruning.max_turns_retention", 0
|
|
343
|
+
)
|
|
344
|
+
limit = int(setting) if setting is not None else 0
|
|
345
|
+
except (TypeError, ValueError):
|
|
346
|
+
limit = 0
|
|
347
|
+
|
|
348
|
+
if limit <= 0:
|
|
349
|
+
return items
|
|
350
|
+
|
|
351
|
+
turn_id_map, max_id = self._map_turn_ids(items)
|
|
352
|
+
if max_id == -1:
|
|
353
|
+
return items
|
|
354
|
+
|
|
355
|
+
# Calculate threshold and prune
|
|
356
|
+
threshold = max_id - limit
|
|
357
|
+
reason = f"Turn exceeds retention limit of {limit}"
|
|
358
|
+
|
|
359
|
+
for idx, tid in turn_id_map.items():
|
|
360
|
+
if tid <= threshold:
|
|
361
|
+
items[idx] = replace(
|
|
362
|
+
items[idx],
|
|
363
|
+
selected=False,
|
|
364
|
+
auto_prune_reason=reason,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return items
|
|
368
|
+
|
|
369
|
+
def _map_turn_ids(self, items) -> tuple[Dict[int, int], int]:
|
|
370
|
+
"""Identifies turn IDs and the maximum ID in the context items."""
|
|
371
|
+
max_id = -1
|
|
372
|
+
turn_id_map = {} # idx -> int_id
|
|
373
|
+
|
|
374
|
+
for i, item in enumerate(items):
|
|
375
|
+
if item.scope != "Turn":
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
tid_str = self._extract_turn_id(item.path)
|
|
379
|
+
if tid_str:
|
|
380
|
+
try:
|
|
381
|
+
tid = int(tid_str)
|
|
382
|
+
turn_id_map[i] = tid
|
|
383
|
+
max_id = max(max_id, tid)
|
|
384
|
+
except (ValueError, TypeError):
|
|
385
|
+
continue
|
|
386
|
+
return turn_id_map, max_id
|
|
387
|
+
|
|
388
|
+
def _get_turn_context_threshold(self) -> int:
|
|
389
|
+
"""Reads the turn context threshold from config.
|
|
390
|
+
|
|
391
|
+
Reads ``auto_pruning.turn_context_threshold`` from config.
|
|
392
|
+
Returns 0 if not set or on parse errors (skips budget heuristic).
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
threshold = self._config_service.get_setting(
|
|
396
|
+
"auto_pruning.turn_context_threshold"
|
|
397
|
+
)
|
|
398
|
+
return int(threshold) if threshold is not None else 0
|
|
399
|
+
except (TypeError, ValueError):
|
|
400
|
+
return 0
|
|
401
|
+
|
|
402
|
+
def _apply_global_budget(self, items):
|
|
403
|
+
"""Prunes turn and history context items to fit within a global token budget."""
|
|
404
|
+
threshold = self._get_turn_context_threshold()
|
|
405
|
+
|
|
406
|
+
if threshold > 0:
|
|
407
|
+
# Sum selected items to reflect Turn-scope (system_prompt_tokens excluded per spec)
|
|
408
|
+
total_tokens = sum(
|
|
409
|
+
item.token_count
|
|
410
|
+
for item in items
|
|
411
|
+
if item.selected
|
|
412
|
+
and item.scope == "Turn"
|
|
413
|
+
and isinstance(item.token_count, (int, float))
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
if total_tokens > threshold:
|
|
417
|
+
# Gather eligible pruning candidates: standard Turn files (which includes history files in turn.context)
|
|
418
|
+
prune_candidates = [
|
|
419
|
+
(i, item)
|
|
420
|
+
for i, item in enumerate(items)
|
|
421
|
+
if item.scope == "Turn"
|
|
422
|
+
and item.selected
|
|
423
|
+
and isinstance(item.token_count, (int, float))
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
# Sort by token count descending to prune largest files first
|
|
427
|
+
prune_candidates.sort(key=lambda x: x[1].token_count, reverse=True)
|
|
428
|
+
|
|
429
|
+
for idx, item in prune_candidates:
|
|
430
|
+
if total_tokens <= threshold:
|
|
431
|
+
break
|
|
432
|
+
items[idx] = replace(
|
|
433
|
+
item,
|
|
434
|
+
selected=False,
|
|
435
|
+
auto_prune_reason="Pruned to fit context budget",
|
|
436
|
+
)
|
|
437
|
+
total_tokens -= item.token_count
|
|
438
|
+
return items
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Any, Sequence
|
|
3
|
+
|
|
4
|
+
from teddy_executor.core.domain.models.execution_report import (
|
|
5
|
+
ExecutionReport,
|
|
6
|
+
RunStatus,
|
|
7
|
+
RunSummary,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionReplanner:
|
|
12
|
+
"""
|
|
13
|
+
Orchestrates the feedback and planning logic for the Automated Re-plan Loop.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, file_system_manager, planning_service):
|
|
17
|
+
self._file_system_manager = file_system_manager
|
|
18
|
+
self._planning_service = planning_service
|
|
19
|
+
|
|
20
|
+
def build_failure_report( # noqa: PLR0913
|
|
21
|
+
self,
|
|
22
|
+
errors: list[str],
|
|
23
|
+
title: str,
|
|
24
|
+
rationale: str,
|
|
25
|
+
failed_resources: dict[str, str],
|
|
26
|
+
validation_ast: str | None = None,
|
|
27
|
+
original_actions: Sequence[Any] | None = None,
|
|
28
|
+
is_session: bool = False,
|
|
29
|
+
) -> ExecutionReport:
|
|
30
|
+
"""Creates a validation failure report."""
|
|
31
|
+
now = datetime.now(timezone.utc)
|
|
32
|
+
summary = RunSummary(
|
|
33
|
+
status=RunStatus.VALIDATION_FAILED,
|
|
34
|
+
start_time=now,
|
|
35
|
+
end_time=now,
|
|
36
|
+
error="Plan validation failed.",
|
|
37
|
+
)
|
|
38
|
+
return ExecutionReport(
|
|
39
|
+
run_summary=summary,
|
|
40
|
+
plan_title=title,
|
|
41
|
+
rationale=rationale,
|
|
42
|
+
original_actions=original_actions or [],
|
|
43
|
+
action_logs=[],
|
|
44
|
+
validation_result=errors,
|
|
45
|
+
validation_ast=validation_ast,
|
|
46
|
+
failed_resources=failed_resources,
|
|
47
|
+
is_session=is_session,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def trigger_replan_turn(
|
|
51
|
+
self,
|
|
52
|
+
next_turn_dir: str,
|
|
53
|
+
errors: list[str],
|
|
54
|
+
original_content: str,
|
|
55
|
+
validation_ast: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Generates the feedback message and triggers the planning phase."""
|
|
58
|
+
feedback = self._build_feedback_message(
|
|
59
|
+
errors, original_content, validation_ast
|
|
60
|
+
)
|
|
61
|
+
self._planning_service.generate_plan(
|
|
62
|
+
user_message=feedback, turn_dir=next_turn_dir
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _build_feedback_message(
|
|
66
|
+
self, errors: list[str], original_content: str, validation_ast: str | None
|
|
67
|
+
) -> str:
|
|
68
|
+
"""Constructs the feedback message for the AI."""
|
|
69
|
+
error_msgs = [e.strip() for e in errors]
|
|
70
|
+
ast_section = f"\n{validation_ast}\n" if validation_ast else ""
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
"The previous plan failed validation. Please review the errors and "
|
|
74
|
+
"the original plan, then generate a corrected version.\n\n"
|
|
75
|
+
"## Validation Errors:\n"
|
|
76
|
+
+ "\n\n---\n\n".join(error_msgs)
|
|
77
|
+
+ "\n"
|
|
78
|
+
+ ast_section
|
|
79
|
+
+ "\n"
|
|
80
|
+
f"## Original Faulty Plan:\n"
|
|
81
|
+
f"````````````markdown\n{original_content}\n````````````"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def gather_failed_resources(
|
|
85
|
+
self, errors: list, is_session: bool = False
|
|
86
|
+
) -> dict[str, str]:
|
|
87
|
+
"""Collects the contents of files that caused validation errors.
|
|
88
|
+
If is_session is True, skip I/O since Resource Contents are already in input.md.
|
|
89
|
+
"""
|
|
90
|
+
if is_session:
|
|
91
|
+
return {}
|
|
92
|
+
resources = {}
|
|
93
|
+
for error in errors:
|
|
94
|
+
path = getattr(error, "file_path", None)
|
|
95
|
+
if path:
|
|
96
|
+
try:
|
|
97
|
+
clean_path = path.lstrip("/")
|
|
98
|
+
if self._file_system_manager.path_exists(clean_path):
|
|
99
|
+
resources[path] = self._file_system_manager.read_file(
|
|
100
|
+
clean_path
|
|
101
|
+
)
|
|
102
|
+
except Exception: # nosec B112
|
|
103
|
+
# Best effort resource gathering; skip if file is unreadable
|
|
104
|
+
continue
|
|
105
|
+
return resources
|