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,242 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, Optional, Sequence
3
+ from teddy_executor.core.ports.inbound.planning_use_case import IPlanningUseCase
4
+
5
+
6
+ class PlanningService(IPlanningUseCase):
7
+ """
8
+ Orchestrates context gathering and LLM interaction to generate plans.
9
+ """
10
+
11
+ from teddy_executor.core.domain.models.planning_ports import PlanningPorts
12
+
13
+ def __init__(self, ports: PlanningPorts):
14
+ self._context_service = ports.context
15
+ self._llm_client = ports.llm
16
+ self._file_system_manager = ports.fs
17
+ self._config_service = ports.config
18
+ self._prompt_manager = ports.prompts
19
+ self._user_interactor = ports.ui
20
+ self._session_manager = ports.session_manager
21
+
22
+ def generate_plan(
23
+ self,
24
+ user_message: Optional[str],
25
+ turn_dir: str,
26
+ context_files: Optional[Dict[str, Sequence[str]]] = None,
27
+ ) -> tuple[str, float]:
28
+ """Generates a new plan.md file."""
29
+ import re
30
+
31
+ turn_path = Path(turn_dir)
32
+ agent_name, meta, meta_file_path = self._prompt_manager.resolve_agent_metadata(
33
+ turn_path
34
+ )
35
+
36
+ if self._user_interactor:
37
+ session_folder = turn_path.parent.name
38
+ natural_name = re.sub(r"^\d{8}_\d{6}-", "", session_folder)
39
+ msg = f"\n[cyan][{turn_path.name}] {natural_name} | Waiting for {agent_name} to respond...[/cyan]"
40
+ self._user_interactor.display_message(msg)
41
+
42
+ self._run_preflight_check()
43
+
44
+ # Defensive resolution of context manifests from turn_dir
45
+ resolved_context_files = context_files
46
+ if resolved_context_files is None and self._session_manager:
47
+ plan_path = (turn_path / "plan.md").as_posix()
48
+ # Mypy: dict is invariant, so dict[str, list] != dict[str, Sequence]
49
+ resolved_context_files = self._session_manager.resolve_context_paths( # type: ignore[assignment]
50
+ plan_path
51
+ )
52
+ # Resolve message to capture user intent in meta.yaml
53
+ resolved_message = self._prompt_manager.resolve_message(user_message, turn_path)
54
+
55
+ if resolved_message is not None:
56
+ if not meta.get("is_replan"):
57
+ meta["user_request"] = resolved_message
58
+
59
+ system_prompt = self._prompt_manager.fetch_system_prompt(agent_name, turn_path)
60
+
61
+ # Compute system prompt token count BEFORE context construction so the
62
+ # ProjectContext DTO is born with correct data (no post-hoc patching needed).
63
+ model = str(
64
+ meta.get("model") or self._config_service.get_setting("llm.model") or ""
65
+ )
66
+ try:
67
+ system_token_count = self._llm_client.get_text_token_count(
68
+ system_prompt, model=model
69
+ )
70
+ except Exception:
71
+ system_token_count = 0
72
+
73
+ context = self._context_service.get_context(
74
+ context_files=resolved_context_files,
75
+ agent_name=agent_name,
76
+ current_turn=Path(turn_dir).name,
77
+ system_prompt_tokens=system_token_count,
78
+ )
79
+
80
+ # Context is purely project state (including initial_request.md via session.context).
81
+ full_context = f"{context.header}\n{context.content}"
82
+ messages = [
83
+ {"role": "system", "content": system_prompt},
84
+ {
85
+ "role": "user",
86
+ "content": full_context,
87
+ },
88
+ ]
89
+
90
+ self._file_system_manager.write_file(
91
+ (turn_path / "input.md").as_posix(), full_context
92
+ )
93
+
94
+ model = str(
95
+ meta.get("model") or self._config_service.get_setting("llm.model") or ""
96
+ )
97
+
98
+ # Pre-emptive Hydration: Trigger hydration via get_context_window BEFORE counting tokens.
99
+ # This ensures the model is known to the registry so token counting and telemetry work.
100
+ if self._user_interactor:
101
+ # We call this for the side-effect of triggering hydration in Turn 1
102
+ self._llm_client.get_context_window(model=model)
103
+
104
+ token_count = int(
105
+ self._safe_float(self._llm_client.get_token_count(messages=messages))
106
+ )
107
+
108
+ if self._user_interactor:
109
+ self._display_telemetry(meta, token_count)
110
+
111
+ response, plan_content, turn_cost = self._perform_generation_with_retry(
112
+ messages,
113
+ model=model,
114
+ provider=meta.get("provider"),
115
+ api_key=meta.get("api_key"),
116
+ )
117
+
118
+ cost_val = self._prompt_manager.log_telemetry(token_count, turn_cost)
119
+ plan_path = (turn_path / "plan.md").as_posix()
120
+ self._file_system_manager.write_file(plan_path, plan_content)
121
+ # Pre-populate meta["model"] before update_meta to ensure the user-configured
122
+ # model (with routing prefix like openrouter/) is preserved.
123
+ # This prevents the bug where meta["model"] was missing on first turn (no --model flag)
124
+ # and update_meta overwrote it with the bare actual model.
125
+ meta.setdefault("model", model)
126
+ self._prompt_manager.update_meta(
127
+ meta, response, token_count, turn_cost, meta_file_path
128
+ )
129
+
130
+ return plan_path, cost_val
131
+
132
+ def _perform_generation_with_retry(
133
+ self,
134
+ messages: list[Dict[str, str]],
135
+ model: str,
136
+ provider: Optional[str] = None,
137
+ api_key: Optional[str] = None,
138
+ ) -> tuple[Any, str, float]:
139
+ """Implements retry loop for empty LLM content."""
140
+ max_retries_val = self._config_service.get_setting("llm.max_retries")
141
+ max_retries = int(max_retries_val) if max_retries_val is not None else 3
142
+ response = None
143
+ plan_content = ""
144
+ turn_cost = 0.0
145
+
146
+ # Construct overrides dict for kwargs
147
+ overrides = {}
148
+ if provider:
149
+ overrides["provider"] = provider
150
+ if api_key:
151
+ overrides["api_key"] = api_key
152
+
153
+ for attempt in range(max_retries):
154
+ response = self._llm_client.get_completion(
155
+ messages=messages, model=model, **overrides
156
+ )
157
+ plan_content = self._extract_plan_content(response)
158
+ turn_cost = self._llm_client.get_completion_cost(
159
+ response, model_override=model
160
+ )
161
+
162
+ if plan_content and plan_content.strip():
163
+ break
164
+
165
+ if attempt < max_retries - 1 and self._user_interactor:
166
+ self._user_interactor.display_message(
167
+ f"[yellow]Empty response received (Attempt {attempt + 1}/{max_retries}). Retrying...[/yellow]"
168
+ )
169
+
170
+ return response, plan_content, turn_cost
171
+
172
+ # Class-level cache to ensure remote preflight is performed exactly once per process.
173
+ # This eliminates the 10s timeout lag on subsequent turns in a session.
174
+ _PREFLIGHT_DONE = False
175
+
176
+ @classmethod
177
+ def reset_preflight(cls) -> None:
178
+ """Resets the preflight cache (used primarily for testing)."""
179
+ cls._PREFLIGHT_DONE = False
180
+
181
+ def _run_preflight_check(self) -> None:
182
+ """Ensures system is configured before attempting generation."""
183
+ from teddy_executor.core.domain.models.exceptions import ConfigurationError
184
+
185
+ # Perform local validation only. Remote connectivity is checked lazily
186
+ # by the LLM client during actual generation. This ensures fast CLI startup
187
+ # and eliminates redundant remote lag for sessions.
188
+ errors = self._llm_client.validate_config(include_remote=False)
189
+
190
+ if not errors:
191
+ PlanningService._PREFLIGHT_DONE = True
192
+ return
193
+
194
+ error_msg = f"Configuration Error: {', '.join(errors)}"
195
+ raise ConfigurationError(error_msg)
196
+
197
+ def _extract_plan_content(self, response: Any) -> str:
198
+ """Robustly extracts content from the LLM response object."""
199
+ if hasattr(response, "choices") and len(response.choices) > 0:
200
+ return getattr(response.choices[0].message, "content", "") or ""
201
+ return ""
202
+
203
+ def _display_telemetry(self, meta: Dict[str, Any], token_count: int) -> None:
204
+ """Displays real-time telemetry about the upcoming LLM call."""
205
+ model = str(
206
+ meta.get("actual_model")
207
+ or meta.get("model")
208
+ or self._config_service.get_setting("llm.model")
209
+ or "unknown"
210
+ )
211
+ context_window = self._safe_float(
212
+ self._llm_client.get_context_window(model=model)
213
+ )
214
+ cumulative_cost = self._safe_float(meta.get("cumulative_cost"))
215
+
216
+ self._user_interactor.display_message(
217
+ f"[blue]• Model:[/blue] [magenta]{model}[/magenta]"
218
+ )
219
+
220
+ window_str = f"{context_window / 1000:.1f}k" if context_window > 0 else "???"
221
+ self._user_interactor.display_message(
222
+ f"[blue]• Context:[/blue] [magenta]{token_count / 1000:.1f}k / {window_str} tokens[/magenta]"
223
+ )
224
+
225
+ pricing_supported = self._llm_client.supports_pricing(model=model)
226
+ cost_str = (
227
+ f"${cumulative_cost:.4f}"
228
+ if context_window > 0 and pricing_supported
229
+ else "$???"
230
+ )
231
+ self._user_interactor.display_message(
232
+ f"[blue]• Session Cost:[/blue] [magenta]{cost_str}[/magenta]\n"
233
+ )
234
+
235
+ def _safe_float(self, v: Any, default: float = 0.0) -> float:
236
+ """Robust conversion to float, handling mocks and strings."""
237
+ try:
238
+ if hasattr(v, "__float__"):
239
+ return float(v)
240
+ return float(str(v))
241
+ except (TypeError, ValueError):
242
+ return default
@@ -0,0 +1,146 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, Optional
3
+ import yaml
4
+ from teddy_executor.core.ports.outbound.file_system_manager import IFileSystemManager
5
+ from teddy_executor.core.ports.outbound.user_interactor import IUserInteractor
6
+ from teddy_executor.core.ports.outbound.prompt_manager import IPromptManager
7
+ from teddy_executor.core.utils.serialization import scrub_dict_for_serialization
8
+
9
+
10
+ class PromptManager(IPromptManager):
11
+ """
12
+ Service for resolving agent configuration, system prompts, and user messages.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ file_system_manager: IFileSystemManager,
18
+ user_interactor: IUserInteractor = None, # type: ignore
19
+ ):
20
+ self._file_system_manager = file_system_manager
21
+ self._user_interactor = user_interactor
22
+
23
+ def get_prompt_content(self, agent_name: str) -> Optional[str]:
24
+ """Synchronously retrieves the raw content of an agent prompt."""
25
+ from teddy_executor.prompts import find_prompt_content
26
+
27
+ return find_prompt_content(agent_name)
28
+
29
+ def get_available_agents(self) -> list[str]:
30
+ """Returns the list of available agent names from .teddy/prompts/."""
31
+ prompts_dir = ".teddy/prompts/"
32
+ if not self._file_system_manager.path_exists(prompts_dir):
33
+ return []
34
+ files = self._file_system_manager.list_directory(prompts_dir)
35
+ return sorted((Path(f).stem for f in files), key=str.casefold)
36
+
37
+ def resolve_agent_metadata(
38
+ self, turn_path: Path
39
+ ) -> tuple[str, Dict[str, Any], str]:
40
+ meta_file_path = (turn_path / "meta.yaml").as_posix()
41
+ meta_content = ""
42
+ if self._file_system_manager.path_exists(meta_file_path):
43
+ meta_content = self._file_system_manager.read_file(meta_file_path)
44
+
45
+ meta = yaml.safe_load(str(meta_content))
46
+ if not isinstance(meta, dict):
47
+ meta = {}
48
+ return meta.get("agent_name", "pathfinder"), meta, meta_file_path
49
+
50
+ def resolve_message(
51
+ self, user_message: Optional[str], turn_path: Path
52
+ ) -> Optional[str]:
53
+ # If message is an empty string, it's a continuation signal.
54
+ if user_message == "":
55
+ return ""
56
+
57
+ resolved = user_message
58
+ if resolved is None and turn_path.name == "01":
59
+ # Check for initial_request.md at session root (turn_path.parent)
60
+ # This only acts as a fallback for the very first turn.
61
+ request_path = (turn_path.parent / "initial_request.md").as_posix()
62
+ if self._file_system_manager.path_exists(request_path):
63
+ resolved = self._file_system_manager.read_file(request_path)
64
+
65
+ if resolved is not None and not resolved.strip():
66
+ # User provided empty input at the prompt (just hit enter) -> Exit
67
+ return None
68
+
69
+ return resolved
70
+
71
+ def _find_prompt_file(self, directory: str, agent_name: str) -> Optional[str]:
72
+ """Searches a directory for a file with the given agent name (any extension)."""
73
+ if not self._file_system_manager.path_exists(directory):
74
+ return None
75
+ for f in self._file_system_manager.list_directory(directory):
76
+ if Path(f).stem.casefold() == agent_name.casefold():
77
+ return f"{directory}/{f}"
78
+ return None
79
+
80
+ def fetch_system_prompt(self, agent_name: str, turn_path: Path) -> str:
81
+ # 1. Try Session-Root override (Current standard)
82
+ session_root_prompt = self._find_prompt_file(
83
+ turn_path.parent.as_posix(), agent_name
84
+ )
85
+ if session_root_prompt:
86
+ return self._file_system_manager.read_file(session_root_prompt)
87
+
88
+ # 2. Try .teddy/prompts/ (canonical source, user-editable)
89
+ teddy_prompt_dir = (
90
+ turn_path.parent.parent.parent.parent / ".teddy" / "prompts"
91
+ ).as_posix()
92
+ teddy_prompt_path = self._find_prompt_file(teddy_prompt_dir, agent_name)
93
+ if teddy_prompt_path:
94
+ return self._file_system_manager.read_file(teddy_prompt_path)
95
+
96
+ import logging
97
+
98
+ logging.getLogger(__name__).warning(
99
+ "PromptManager: Failed to resolve system prompt for agent '%s' (searched %s and %s)",
100
+ agent_name,
101
+ session_root_prompt,
102
+ teddy_prompt_path,
103
+ )
104
+ return ""
105
+
106
+ def log_telemetry(self, token_count: Any, turn_cost: Any) -> float:
107
+ def safe_float(v: Any, default: float = 0.0) -> float:
108
+ try:
109
+ return float(v)
110
+ except (TypeError, ValueError):
111
+ return default
112
+
113
+ return safe_float(turn_cost)
114
+
115
+ def update_meta(
116
+ self,
117
+ meta: Dict[str, Any],
118
+ response: Any,
119
+ token_count: int,
120
+ turn_cost: float,
121
+ meta_file_path: str,
122
+ ) -> None:
123
+ try:
124
+ meta["turn_cost"] = float(turn_cost)
125
+ meta["token_count"] = int(token_count)
126
+ except (TypeError, ValueError):
127
+ meta["turn_cost"] = meta.get("turn_cost", 0.0)
128
+ meta["token_count"] = meta.get("token_count", 0)
129
+
130
+ # Preserve the user-configured model (with routing prefix) for routing.
131
+ # Store the actual serving model separately for telemetry.
132
+ actual_model = str(getattr(response, "model", "unknown"))
133
+ if "model" not in meta or meta.get("model") == actual_model:
134
+ meta["model"] = actual_model
135
+ meta["actual_model"] = actual_model
136
+
137
+ # Capture finish_reason; scrub_dict_for_serialization will neutralize mocks
138
+ if hasattr(response, "choices") and len(response.choices) > 0:
139
+ meta["finish_reason"] = getattr(
140
+ response.choices[0], "finish_reason", "unknown"
141
+ )
142
+
143
+ serializable_meta = scrub_dict_for_serialization(meta)
144
+ self._file_system_manager.write_file(
145
+ meta_file_path, yaml.dump(serializable_meta)
146
+ )
@@ -0,0 +1,228 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Any, Optional, TYPE_CHECKING
4
+
5
+ import yaml
6
+ from teddy_executor.core.domain.models.execution_report import ExecutionReport
7
+
8
+ from typing import Sequence
9
+
10
+ from teddy_executor.core.ports.inbound.run_plan_use_case import IRunPlanUseCase
11
+ from teddy_executor.core.ports.outbound.session_manager import SessionState
12
+ from teddy_executor.core.utils.io import Tee as _Tee
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ if TYPE_CHECKING:
17
+ from teddy_executor.core.domain.models.planning_ports import (
18
+ SessionPorts,
19
+ )
20
+ from teddy_executor.core.domain.models.plan import Plan
21
+
22
+ _ = SessionPorts
23
+
24
+
25
+ class SessionLifecycleManager:
26
+ """
27
+ Manages the lifecycle of session turns, including finalization,
28
+ resume state machine, and automated re-plan coordination.
29
+ """
30
+
31
+ tee_active = False # Class-level default for mock spec compatibility
32
+
33
+ def __init__(self, ports: "SessionPorts"):
34
+ self._session_service = ports.session_service
35
+ self._file_system_manager = ports.file_system_manager
36
+ self._report_formatter = ports.report_formatter
37
+ self._user_interactor = ports.user_interactor
38
+ self._session_planner = ports.session_planner
39
+ self._replanner = ports.replanner
40
+ self.tee_active = False
41
+
42
+ def resume(
43
+ self,
44
+ session_name: str,
45
+ orchestrator: IRunPlanUseCase,
46
+ interactive: bool = True,
47
+ project_context: Optional[Any] = None,
48
+ ) -> tuple[str, Optional[ExecutionReport]]:
49
+ """Implements the 'resume' state machine.
50
+
51
+ Returns:
52
+ A tuple (actual_session_name, report). The actual_session_name
53
+ may differ from the input session_name after a centennial
54
+ migration (when the session transitions to a continuation name
55
+ like 'my-session-2').
56
+ """
57
+ state, turn_path = self._session_service.get_session_state(session_name)
58
+
59
+ if state == SessionState.PENDING_PLAN:
60
+ plan_path = f"{turn_path}/plan.md"
61
+ report = orchestrator.execute(
62
+ plan_path=plan_path,
63
+ interactive=interactive,
64
+ project_context=project_context,
65
+ )
66
+ return (session_name, report)
67
+
68
+ if state == SessionState.EMPTY:
69
+ return self._handle_planning_and_execution(
70
+ turn_path,
71
+ orchestrator,
72
+ interactive,
73
+ project_context=project_context,
74
+ )
75
+
76
+ if state == SessionState.COMPLETE_TURN:
77
+ next_turn_dir = self._session_service.transition_to_next_turn(
78
+ plan_path=f"{turn_path}/plan.md"
79
+ )
80
+ return self._handle_planning_and_execution(
81
+ next_turn_dir,
82
+ orchestrator,
83
+ interactive,
84
+ project_context=project_context,
85
+ )
86
+
87
+ return (session_name, None)
88
+
89
+ def _handle_planning_and_execution(
90
+ self,
91
+ turn_dir: str,
92
+ orchestrator: IRunPlanUseCase,
93
+ interactive: bool,
94
+ project_context: Optional[Any] = None,
95
+ ) -> tuple[str, Optional[ExecutionReport]]:
96
+ """Triggers planning for a turn and then executes the resulting plan.
97
+
98
+ Tee is installed before planning to capture all output (turn headers,
99
+ metadata, planning logs) into history.log. The installation is guarded
100
+ by tee_active to prevent double installation.
101
+
102
+ Returns:
103
+ A tuple (actual_session_name, report). The actual_session_name
104
+ is the session name returned by trigger_new_plan, which may
105
+ differ from the original session name after a centennial
106
+ migration.
107
+ """
108
+ # Install Tee to capture planning output before trigger_new_plan
109
+ tee = None
110
+ if not self.tee_active:
111
+ try:
112
+ log_path = str(Path(turn_dir).parent / "history.log")
113
+ # Defensive guard: never write history.log to project root
114
+ resolved = str(Path(log_path).resolve())
115
+ project_root = str(Path.cwd().resolve())
116
+ if resolved.rstrip("/") == project_root.rstrip("/"):
117
+ safe_dir = str(Path(turn_dir).parent.parent / ".tmp")
118
+ self._file_system_manager.create_directory(safe_dir)
119
+ log_path = str(Path(safe_dir) / "history.log")
120
+ log_file = self._file_system_manager.open_file_for_append(log_path)
121
+ tee = _Tee(log_file)
122
+ tee.__enter__()
123
+ self.tee_active = True
124
+ except Exception:
125
+ logger.warning("Failed to install Tee in lifecycle manager")
126
+ tee = None
127
+
128
+ try:
129
+ # Print initial request before turn header (before planning)
130
+ from teddy_executor.core.services.session_orchestrator import (
131
+ _print_initial_request,
132
+ )
133
+
134
+ if Path(turn_dir).name == "01":
135
+ _print_initial_request(None, True, plan_path=Path(turn_dir).as_posix())
136
+ new_name = self._session_planner.trigger_new_plan(turn_dir)
137
+ if not new_name or new_name == "CANCELLED":
138
+ return (turn_dir, None)
139
+ _, actual_turn_path = self._session_service.get_session_state(new_name)
140
+ report = orchestrator.execute(
141
+ plan_path=f"{actual_turn_path}/plan.md",
142
+ interactive=interactive,
143
+ project_context=project_context,
144
+ )
145
+ return (new_name, report)
146
+ finally:
147
+ self.tee_active = False
148
+ if tee is not None:
149
+ try:
150
+ tee.__exit__(None, None, None)
151
+ except Exception:
152
+ logger.exception("Failed to clean up Tee in lifecycle manager")
153
+
154
+ def trigger_replan( # noqa: PLR0913
155
+ self,
156
+ plan_path: str,
157
+ errors: list[str],
158
+ original_plan_content: str,
159
+ title: str = "Unknown Plan",
160
+ rationale: str = "Structural Error",
161
+ failed_resources: Optional[dict[str, str]] = None,
162
+ is_session: bool = False,
163
+ validation_ast: Optional[str] = None,
164
+ original_actions: Optional[Sequence[Any]] = None,
165
+ plan: Optional["Plan"] = None,
166
+ ) -> ExecutionReport:
167
+ """Triggers the Automated Re-plan Loop."""
168
+ self._user_interactor.display_message(
169
+ "[yellow]Validation failed... replanning[/yellow]"
170
+ )
171
+ report = self._replanner.build_failure_report(
172
+ errors,
173
+ title,
174
+ rationale,
175
+ failed_resources or {},
176
+ validation_ast=validation_ast,
177
+ original_actions=original_actions,
178
+ is_session=is_session,
179
+ )
180
+ next_turn_dir = self.finalize_turn(
181
+ plan_path, report, is_validation_failure=True, plan=plan
182
+ )
183
+
184
+ self._replanner.trigger_replan_turn(
185
+ next_turn_dir, errors, original_plan_content, validation_ast=validation_ast
186
+ )
187
+ return report
188
+
189
+ def finalize_turn(
190
+ self,
191
+ plan_path: str,
192
+ report: ExecutionReport,
193
+ is_validation_failure: bool = False,
194
+ plan: Optional[Any] = None,
195
+ ) -> str:
196
+ """Persists the report and transitions to the next turn."""
197
+ turn_dir = Path(plan_path).parent
198
+ meta_path = turn_dir / "meta.yaml"
199
+
200
+ # Read current cost from meta.yaml
201
+ turn_cost = 0.0
202
+ if self._file_system_manager.path_exists(str(meta_path)):
203
+ meta_content = self._file_system_manager.read_file(str(meta_path))
204
+ meta_loaded = yaml.safe_load(str(meta_content))
205
+ meta = meta_loaded if isinstance(meta_loaded, dict) else {}
206
+ turn_cost = meta.get("turn_cost", 0.0)
207
+
208
+ # 1. Persist the report to the current turn directory
209
+ formatted_report = self._report_formatter.format(report)
210
+ # Use root-relative path for report persistence to ensure context discovery
211
+ report_file_path = self._session_service.to_root_relative(turn_dir, "report.md")
212
+ self._file_system_manager.write_file(report_file_path, formatted_report)
213
+
214
+ # Extract manual pruning paths from plan metadata
215
+ pruned_paths = []
216
+ if plan:
217
+ raw_pruned = plan.metadata.get("pruned_context", "")
218
+ if raw_pruned:
219
+ pruned_paths = [p.strip() for p in raw_pruned.split(",") if p.strip()]
220
+
221
+ # 2. Transition to next turn
222
+ return self._session_service.transition_to_next_turn(
223
+ plan_path=plan_path,
224
+ execution_report=report,
225
+ turn_cost=turn_cost,
226
+ is_validation_failure=is_validation_failure,
227
+ pruned_paths=pruned_paths,
228
+ )
@@ -0,0 +1,46 @@
1
+ from teddy_executor.core.ports.outbound import IConfigService
2
+ from teddy_executor.core.ports.outbound.session_loop_guard import ISessionLoopGuard
3
+
4
+
5
+ class ProductionSessionLoopGuard(ISessionLoopGuard):
6
+ """
7
+ Production implementation: always continues unless manually interrupted.
8
+ """
9
+
10
+ def __init__(
11
+ self,
12
+ config_service: IConfigService,
13
+ initial_turn: int,
14
+ initial_cost: float,
15
+ ) -> None:
16
+ self._config_service = config_service
17
+ self._initial_turn = initial_turn
18
+ self._initial_cost = initial_cost
19
+
20
+ def should_continue(
21
+ self, turn_count: int, cumulative_cost: float, interactive: bool
22
+ ) -> bool:
23
+ if interactive:
24
+ return True
25
+
26
+ if not self._config_service.get_setting("yolo_guardrails.enabled", True):
27
+ return True
28
+
29
+ max_turns = int(
30
+ self._config_service.get_setting("yolo_guardrails.max_turns", 99) or 99
31
+ )
32
+ max_cost = float(
33
+ self._config_service.get_setting("yolo_guardrails.max_session_cost", 5.0)
34
+ or 5.0
35
+ )
36
+
37
+ turn_delta = turn_count - self._initial_turn
38
+ cost_delta = cumulative_cost - self._initial_cost
39
+
40
+ if turn_delta >= max_turns:
41
+ return False
42
+
43
+ if cost_delta >= max_cost:
44
+ return False
45
+
46
+ return True