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,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