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,538 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Optional, cast
|
|
4
|
+
|
|
5
|
+
from teddy_executor.core.domain.models.execution_report import (
|
|
6
|
+
ExecutionReport,
|
|
7
|
+
)
|
|
8
|
+
from teddy_executor.core.services.parser_reporting import (
|
|
9
|
+
format_hybrid_ast_view,
|
|
10
|
+
)
|
|
11
|
+
from teddy_executor.core.domain.models.plan import Plan
|
|
12
|
+
from teddy_executor.core.ports.inbound.run_plan_use_case import IRunPlanUseCase
|
|
13
|
+
from teddy_executor.core.ports.outbound.file_system_manager import IFileSystemManager
|
|
14
|
+
from teddy_executor.core.services.session_replanner import SessionReplanner
|
|
15
|
+
from teddy_executor.core.utils.io import Tee as _Tee
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_status_emoji(raw_status: str) -> str:
|
|
22
|
+
"""Extract the first status emoji (🟢, 🟡, 🔴) from a status string."""
|
|
23
|
+
for emoji in ("🟢", "🟡", "🔴"):
|
|
24
|
+
if emoji in raw_status:
|
|
25
|
+
return emoji
|
|
26
|
+
return ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _print_initial_request(
|
|
30
|
+
message: Optional[str], is_session: bool, plan_path: Optional[str] = None
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Print the initial user request before the turn header.
|
|
33
|
+
|
|
34
|
+
If message is not provided (None) and plan_path is set, falls back to
|
|
35
|
+
reading '<session_root>/initial_request.md' from the filesystem.
|
|
36
|
+
|
|
37
|
+
Only prints when is_session=True and message is non-empty.
|
|
38
|
+
Output::
|
|
39
|
+
Initial Request:
|
|
40
|
+
{content}
|
|
41
|
+
(blank line separator)
|
|
42
|
+
"""
|
|
43
|
+
if not is_session:
|
|
44
|
+
return
|
|
45
|
+
# Resolve message from file if not provided
|
|
46
|
+
if not message or not message.strip():
|
|
47
|
+
if plan_path:
|
|
48
|
+
try:
|
|
49
|
+
import_path = Path(plan_path).parent / "initial_request.md"
|
|
50
|
+
if import_path.exists():
|
|
51
|
+
content = import_path.read_text(encoding="utf-8").strip()
|
|
52
|
+
if content:
|
|
53
|
+
message = content
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
if not message or not message.strip():
|
|
57
|
+
return
|
|
58
|
+
typer.secho("")
|
|
59
|
+
typer.secho("Initial Request:")
|
|
60
|
+
typer.secho(message.strip())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _print_header_bar(plan: Any, is_session: bool) -> None:
|
|
64
|
+
"""Print the plan status emoji and title after telemetry, before actions.
|
|
65
|
+
|
|
66
|
+
Only prints when is_session=True.
|
|
67
|
+
Output: {emoji} {title} (no blank lines around it)
|
|
68
|
+
"""
|
|
69
|
+
if not is_session:
|
|
70
|
+
return
|
|
71
|
+
# Guard against mock objects or non-standard plan-like objects
|
|
72
|
+
metadata = getattr(plan, "metadata", {})
|
|
73
|
+
if not isinstance(metadata, dict):
|
|
74
|
+
metadata = {}
|
|
75
|
+
raw_status = metadata.get("Status") or metadata.get("status") or ""
|
|
76
|
+
emoji = _extract_status_emoji(raw_status)
|
|
77
|
+
title = getattr(plan, "title", None)
|
|
78
|
+
if not isinstance(title, str):
|
|
79
|
+
title = ""
|
|
80
|
+
parts = [p for p in [emoji, title] if p]
|
|
81
|
+
if parts:
|
|
82
|
+
typer.secho(" ".join(parts))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _print_user_message(
|
|
86
|
+
message: Optional[str], is_session: bool, plan: Optional[Any] = None
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Print the user message after all actions execute.
|
|
89
|
+
|
|
90
|
+
If message is not provided (None) and plan is set, falls back to
|
|
91
|
+
plan.metadata.get("user_request").
|
|
92
|
+
|
|
93
|
+
Only prints when is_session=True and message is non-empty.
|
|
94
|
+
Output::
|
|
95
|
+
(blank line)
|
|
96
|
+
User Message:
|
|
97
|
+
{content}
|
|
98
|
+
(trailing newline)
|
|
99
|
+
"""
|
|
100
|
+
if not is_session:
|
|
101
|
+
return
|
|
102
|
+
# Resolve message from plan metadata if not provided
|
|
103
|
+
if not message or not message.strip():
|
|
104
|
+
if plan:
|
|
105
|
+
meta_msg = getattr(plan, "metadata", {}).get("user_request") or ""
|
|
106
|
+
if meta_msg.strip():
|
|
107
|
+
message = meta_msg.strip()
|
|
108
|
+
if not message or not message.strip():
|
|
109
|
+
return
|
|
110
|
+
typer.secho("")
|
|
111
|
+
typer.secho("User Message:")
|
|
112
|
+
typer.secho(message.strip())
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class SessionOrchestrator(IRunPlanUseCase):
|
|
116
|
+
"""
|
|
117
|
+
A wrapper service implementing the 'Turn Transition Algorithm'
|
|
118
|
+
around the base execution logic.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__( # noqa: PLR0913
|
|
122
|
+
self,
|
|
123
|
+
execution_orchestrator,
|
|
124
|
+
session_service,
|
|
125
|
+
file_system_manager: IFileSystemManager,
|
|
126
|
+
plan_validator,
|
|
127
|
+
plan_parser,
|
|
128
|
+
user_interactor,
|
|
129
|
+
lifecycle_manager,
|
|
130
|
+
replanner: SessionReplanner,
|
|
131
|
+
context_service,
|
|
132
|
+
config_service,
|
|
133
|
+
llm_client,
|
|
134
|
+
prompt_manager,
|
|
135
|
+
pruning_service=None,
|
|
136
|
+
):
|
|
137
|
+
self._execution_orchestrator = execution_orchestrator
|
|
138
|
+
self._session_service = session_service
|
|
139
|
+
self._file_system_manager = file_system_manager
|
|
140
|
+
self._plan_validator = plan_validator
|
|
141
|
+
self._plan_parser = plan_parser
|
|
142
|
+
self._user_interactor = user_interactor
|
|
143
|
+
self._lifecycle_manager = lifecycle_manager
|
|
144
|
+
self._replanner = replanner
|
|
145
|
+
self._context_service = context_service
|
|
146
|
+
self._config_service = config_service
|
|
147
|
+
self._llm_client = llm_client
|
|
148
|
+
self._prompt_manager = prompt_manager
|
|
149
|
+
self._pruning_service = pruning_service
|
|
150
|
+
|
|
151
|
+
def resume(
|
|
152
|
+
self,
|
|
153
|
+
session_name: str,
|
|
154
|
+
interactive: bool = True,
|
|
155
|
+
project_context: Optional[Any] = None,
|
|
156
|
+
):
|
|
157
|
+
"""
|
|
158
|
+
Implements the 'resume' state machine.
|
|
159
|
+
"""
|
|
160
|
+
return self._lifecycle_manager.resume(
|
|
161
|
+
session_name,
|
|
162
|
+
self,
|
|
163
|
+
interactive,
|
|
164
|
+
project_context=project_context,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def execute( # noqa: PLR0913, C901
|
|
168
|
+
self,
|
|
169
|
+
plan: Optional[Plan] = None,
|
|
170
|
+
plan_content: Optional[str] = None,
|
|
171
|
+
plan_path: Optional[str] = None,
|
|
172
|
+
interactive: bool = True,
|
|
173
|
+
message: Optional[str] = None,
|
|
174
|
+
project_context: Optional[Any] = None,
|
|
175
|
+
) -> ExecutionReport:
|
|
176
|
+
# Empty message signals session termination (no report.md created).
|
|
177
|
+
if message is not None and not message.strip():
|
|
178
|
+
return None # type: ignore
|
|
179
|
+
|
|
180
|
+
# 0. Detect Session Mode (requires plan_path and meta.yaml)
|
|
181
|
+
is_session = self._is_session_mode(plan_path)
|
|
182
|
+
|
|
183
|
+
# Install Tee for history.log capture (guarded: skip if lifecycle manager already installed)
|
|
184
|
+
_tee = None
|
|
185
|
+
if is_session and plan_path and not self._lifecycle_manager.tee_active:
|
|
186
|
+
try:
|
|
187
|
+
_log_path = str(Path(plan_path).parent.parent / "history.log")
|
|
188
|
+
# Defensive guard: ensure history.log is never written to project root.
|
|
189
|
+
# If resolved path equals project root or CWD, redirect to .tmp/.
|
|
190
|
+
_resolved = str(Path(_log_path).resolve())
|
|
191
|
+
_project_root = str(Path.cwd().resolve())
|
|
192
|
+
if _resolved.rstrip("/") == _project_root.rstrip("/"):
|
|
193
|
+
_safe_dir = str(Path(plan_path).parent.parent / ".tmp")
|
|
194
|
+
self._file_system_manager.create_directory(_safe_dir)
|
|
195
|
+
_log_path = str(Path(_safe_dir) / "history.log")
|
|
196
|
+
_log_file = self._file_system_manager.open_file_for_append(_log_path)
|
|
197
|
+
_tee = _Tee(_log_file)
|
|
198
|
+
_tee.__enter__()
|
|
199
|
+
except Exception:
|
|
200
|
+
_tee = None
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
# 1. Resolve Plan (Parse only)
|
|
204
|
+
result = self._prepare_plan_parsing(
|
|
205
|
+
plan, plan_content, plan_path, is_session
|
|
206
|
+
)
|
|
207
|
+
if isinstance(result, ExecutionReport):
|
|
208
|
+
return result
|
|
209
|
+
plan = result
|
|
210
|
+
|
|
211
|
+
# 2. Context Preparation (Gather, Prune, Harvest)
|
|
212
|
+
# We must harvest context BEFORE validation so that pruned paths persist across replans
|
|
213
|
+
if is_session and plan_path and not project_context:
|
|
214
|
+
context_files = self._session_service.resolve_context_paths(plan_path)
|
|
215
|
+
agent_name = (
|
|
216
|
+
plan.metadata.get("Agent")
|
|
217
|
+
or plan.metadata.get("agent")
|
|
218
|
+
or (
|
|
219
|
+
self._lifecycle_manager.get_agent_name(plan_path)
|
|
220
|
+
if hasattr(self._lifecycle_manager, "get_agent_name")
|
|
221
|
+
else "Unknown"
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
total_window = self._llm_client.get_context_window()
|
|
225
|
+
|
|
226
|
+
cache_dir = str(Path(plan_path).parent.parent)
|
|
227
|
+
# Compute system prompt token count BEFORE context construction so the
|
|
228
|
+
# ProjectContext DTO is born with correct data (no post-hoc patching needed).
|
|
229
|
+
system_prompt = self._prompt_manager.fetch_system_prompt(
|
|
230
|
+
agent_name, Path(plan_path).parent
|
|
231
|
+
)
|
|
232
|
+
model = str(self._config_service.get_setting("llm.model") or "")
|
|
233
|
+
try:
|
|
234
|
+
system_token_count = self._llm_client.get_text_token_count(
|
|
235
|
+
system_prompt, model=model
|
|
236
|
+
)
|
|
237
|
+
except Exception:
|
|
238
|
+
system_token_count = 0
|
|
239
|
+
|
|
240
|
+
project_context = self._context_service.get_context(
|
|
241
|
+
context_files=context_files,
|
|
242
|
+
agent_name=agent_name,
|
|
243
|
+
total_window=total_window,
|
|
244
|
+
cache_dir=cache_dir,
|
|
245
|
+
system_prompt_tokens=system_token_count,
|
|
246
|
+
)
|
|
247
|
+
from dataclasses import is_dataclass
|
|
248
|
+
|
|
249
|
+
if (
|
|
250
|
+
is_dataclass(project_context)
|
|
251
|
+
and agent_name != project_context.agent_name
|
|
252
|
+
):
|
|
253
|
+
from dataclasses import replace
|
|
254
|
+
|
|
255
|
+
project_context = replace(
|
|
256
|
+
cast(Any, project_context),
|
|
257
|
+
agent_name=agent_name,
|
|
258
|
+
)
|
|
259
|
+
if self._pruning_service:
|
|
260
|
+
status = plan.metadata.get("Status") if plan else None
|
|
261
|
+
project_context = self._pruning_service.prune(
|
|
262
|
+
project_context, current_status=status
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
self._harvest_context(
|
|
266
|
+
is_session=is_session,
|
|
267
|
+
project_context=project_context,
|
|
268
|
+
plan=plan,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# 3. Validation (passing refined context)
|
|
272
|
+
result = self._validate_plan_with_context(
|
|
273
|
+
plan, plan_path, is_session, project_context
|
|
274
|
+
)
|
|
275
|
+
if isinstance(result, ExecutionReport):
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
# Print header bar (emoji + title) before execution logs (only for session mode)
|
|
279
|
+
if is_session:
|
|
280
|
+
_print_header_bar(plan, is_session)
|
|
281
|
+
|
|
282
|
+
# 4. Execution
|
|
283
|
+
report = self._execution_orchestrator.execute(
|
|
284
|
+
plan=plan,
|
|
285
|
+
plan_content=plan_content,
|
|
286
|
+
plan_path=plan_path,
|
|
287
|
+
interactive=interactive,
|
|
288
|
+
message=message,
|
|
289
|
+
project_context=project_context,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# 4a. Empty user reply after communication turn → terminate session immediately (no report.md)
|
|
293
|
+
if report and plan.is_communication_turn():
|
|
294
|
+
user_reply = next(
|
|
295
|
+
(
|
|
296
|
+
log.details
|
|
297
|
+
for log in report.action_logs
|
|
298
|
+
if log.action_type == "MESSAGE"
|
|
299
|
+
),
|
|
300
|
+
None,
|
|
301
|
+
)
|
|
302
|
+
if user_reply is not None and not user_reply.strip():
|
|
303
|
+
import typer
|
|
304
|
+
|
|
305
|
+
typer.secho("\nSession terminated.", fg=typer.colors.RED, err=True)
|
|
306
|
+
typer.secho(
|
|
307
|
+
"To continue the session, use `teddy resume [session_path]`.",
|
|
308
|
+
err=True,
|
|
309
|
+
)
|
|
310
|
+
return None # type: ignore
|
|
311
|
+
|
|
312
|
+
# Print user message after all actions executed (only for session mode)
|
|
313
|
+
# Uses plan.metadata["user_request"] which is populated by _dispatch_single_action
|
|
314
|
+
# when the user provides a reply during execution.
|
|
315
|
+
if is_session:
|
|
316
|
+
user_reply = plan.metadata.get("user_request", "") if plan else ""
|
|
317
|
+
_print_user_message(user_reply, is_session, plan=plan)
|
|
318
|
+
|
|
319
|
+
# 4. Turn Transition
|
|
320
|
+
if is_session and plan_path:
|
|
321
|
+
report = self._handle_aborted_session(report, plan)
|
|
322
|
+
if report is None:
|
|
323
|
+
import typer
|
|
324
|
+
|
|
325
|
+
typer.secho("\nSession terminated.", fg=typer.colors.RED, err=True)
|
|
326
|
+
typer.secho(
|
|
327
|
+
"To continue the session, use `teddy resume [session_path]`.",
|
|
328
|
+
err=True,
|
|
329
|
+
)
|
|
330
|
+
return None # type: ignore
|
|
331
|
+
|
|
332
|
+
self._lifecycle_manager.finalize_turn(plan_path, report, plan=plan)
|
|
333
|
+
|
|
334
|
+
return report
|
|
335
|
+
|
|
336
|
+
finally:
|
|
337
|
+
if _tee is not None:
|
|
338
|
+
try:
|
|
339
|
+
_tee.__exit__(None, None, None)
|
|
340
|
+
except Exception:
|
|
341
|
+
logger.exception("Failed to clean up Tee during session execute")
|
|
342
|
+
|
|
343
|
+
def _harvest_context(
|
|
344
|
+
self,
|
|
345
|
+
is_session: bool,
|
|
346
|
+
project_context: Optional[Any],
|
|
347
|
+
plan: Plan,
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Harvests unselected context paths."""
|
|
350
|
+
if is_session and project_context:
|
|
351
|
+
if hasattr(project_context, "items") and project_context.items:
|
|
352
|
+
pruned_paths = [
|
|
353
|
+
item.path for item in project_context.items if not item.selected
|
|
354
|
+
]
|
|
355
|
+
if pruned_paths:
|
|
356
|
+
plan.metadata["pruned_context"] = ",".join(pruned_paths)
|
|
357
|
+
else:
|
|
358
|
+
plan.metadata.pop("pruned_context", None)
|
|
359
|
+
|
|
360
|
+
def _handle_aborted_session(
|
|
361
|
+
self, report: ExecutionReport, plan: Optional[Plan]
|
|
362
|
+
) -> ExecutionReport:
|
|
363
|
+
"""Handles user interaction and metadata updates when a session is aborted."""
|
|
364
|
+
from dataclasses import replace
|
|
365
|
+
from teddy_executor.core.domain.models import RunStatus
|
|
366
|
+
|
|
367
|
+
if report.run_summary.status != RunStatus.ABORTED:
|
|
368
|
+
return report
|
|
369
|
+
|
|
370
|
+
import typer
|
|
371
|
+
|
|
372
|
+
typer.secho("Plan aborted by user.", fg=typer.colors.YELLOW, err=True)
|
|
373
|
+
|
|
374
|
+
# We always prompt for a NEW message when a plan is aborted,
|
|
375
|
+
# unless one was explicitly captured during the abort process itself
|
|
376
|
+
# (e.g. via the 'm' key in TUI). Pre-existing turn messages are ignored.
|
|
377
|
+
new_message = self._user_interactor.ask_question(
|
|
378
|
+
"Plan aborted. How do you want to proceed?"
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# If still empty after potential prompt, return None to signal session termination.
|
|
382
|
+
if not new_message:
|
|
383
|
+
return None # type: ignore
|
|
384
|
+
|
|
385
|
+
# Update metadata (dict is mutable even in frozen dataclass)
|
|
386
|
+
report.metadata["user_request"] = new_message
|
|
387
|
+
# Replace report to include user_request so it shows in report.md header
|
|
388
|
+
updated_report = replace(report, user_request=new_message)
|
|
389
|
+
# Update plan metadata as well
|
|
390
|
+
if plan:
|
|
391
|
+
plan.metadata["user_request"] = new_message
|
|
392
|
+
|
|
393
|
+
return updated_report
|
|
394
|
+
|
|
395
|
+
def _prepare_plan_parsing(
|
|
396
|
+
self,
|
|
397
|
+
plan: Optional[Plan],
|
|
398
|
+
plan_content: Optional[str],
|
|
399
|
+
plan_path: Optional[str],
|
|
400
|
+
is_session: bool,
|
|
401
|
+
) -> Plan | ExecutionReport:
|
|
402
|
+
"""Handles parsing only, potentially triggering a replan on structural error."""
|
|
403
|
+
if plan:
|
|
404
|
+
plan.is_session = is_session
|
|
405
|
+
|
|
406
|
+
# Defensive check for missing plan file
|
|
407
|
+
if plan_path and not plan_content:
|
|
408
|
+
if not self._file_system_manager.path_exists(plan_path):
|
|
409
|
+
error_msg = f"Plan file not found: {plan_path}"
|
|
410
|
+
if is_session:
|
|
411
|
+
return self._lifecycle_manager.trigger_replan(
|
|
412
|
+
plan_path=plan_path,
|
|
413
|
+
errors=[error_msg],
|
|
414
|
+
original_plan_content="",
|
|
415
|
+
)
|
|
416
|
+
return self._replanner.build_failure_report(
|
|
417
|
+
errors=[error_msg],
|
|
418
|
+
title="Missing Plan",
|
|
419
|
+
rationale="The plan file could not be found on disk.",
|
|
420
|
+
failed_resources={},
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
content = plan_content or (
|
|
424
|
+
self._file_system_manager.read_file(plan_path) if plan_path else ""
|
|
425
|
+
)
|
|
426
|
+
if not plan:
|
|
427
|
+
try:
|
|
428
|
+
plan = self._plan_parser.parse(content, plan_path=plan_path)
|
|
429
|
+
except Exception as e:
|
|
430
|
+
if is_session and plan_path:
|
|
431
|
+
return self._lifecycle_manager.trigger_replan(
|
|
432
|
+
plan_path=plan_path,
|
|
433
|
+
errors=[f"Structural error: {str(e)}"],
|
|
434
|
+
original_plan_content=content,
|
|
435
|
+
)
|
|
436
|
+
raise
|
|
437
|
+
return plan
|
|
438
|
+
|
|
439
|
+
def _validate_plan_with_context(
|
|
440
|
+
self,
|
|
441
|
+
plan: Plan,
|
|
442
|
+
plan_path: Optional[str],
|
|
443
|
+
is_session: bool,
|
|
444
|
+
project_context: Optional[Any],
|
|
445
|
+
) -> Plan | ExecutionReport:
|
|
446
|
+
"""Handles logical validation with optional refined context."""
|
|
447
|
+
context_paths = None
|
|
448
|
+
if is_session and plan_path:
|
|
449
|
+
# Prefer active project context (respecting pruning) if available
|
|
450
|
+
if project_context and hasattr(project_context, "items"):
|
|
451
|
+
context_paths = {
|
|
452
|
+
"Session": [], # Pruning logic already applied
|
|
453
|
+
"Turn": [
|
|
454
|
+
item.path for item in project_context.items if item.selected
|
|
455
|
+
],
|
|
456
|
+
}
|
|
457
|
+
else:
|
|
458
|
+
context_paths = self._session_service.resolve_context_paths(plan_path)
|
|
459
|
+
|
|
460
|
+
errors = self._plan_validator.validate(plan, context_paths=context_paths)
|
|
461
|
+
if errors:
|
|
462
|
+
content = (
|
|
463
|
+
self._file_system_manager.read_file(plan_path) if plan_path else ""
|
|
464
|
+
)
|
|
465
|
+
return self._handle_logical_validation_errors(
|
|
466
|
+
plan, errors, content, plan_path, is_session
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
return plan
|
|
470
|
+
|
|
471
|
+
def _is_session_mode(self, plan_path: Optional[str]) -> bool:
|
|
472
|
+
"""Determines if the orchestrator should operate in Session Mode."""
|
|
473
|
+
if not plan_path:
|
|
474
|
+
return False
|
|
475
|
+
meta_path = Path(plan_path).parent / "meta.yaml"
|
|
476
|
+
return self._file_system_manager.path_exists(str(meta_path))
|
|
477
|
+
|
|
478
|
+
def _parse_and_handle_structural_errors(
|
|
479
|
+
self, content: str, plan_path: Optional[str], is_session: bool
|
|
480
|
+
) -> Plan:
|
|
481
|
+
"""Parses the plan and triggers a replan on structural failure."""
|
|
482
|
+
try:
|
|
483
|
+
return self._plan_parser.parse(content, plan_path=plan_path)
|
|
484
|
+
except Exception as e:
|
|
485
|
+
if is_session and plan_path:
|
|
486
|
+
# Ensure the rich diagnostic is visible to the user
|
|
487
|
+
self._user_interactor.display_message(str(e))
|
|
488
|
+
self._lifecycle_manager.trigger_replan(
|
|
489
|
+
plan_path=plan_path,
|
|
490
|
+
errors=[f"Structural error: {str(e)}"],
|
|
491
|
+
original_plan_content=content,
|
|
492
|
+
)
|
|
493
|
+
# Re-planning is already handled by trigger_replan
|
|
494
|
+
raise RuntimeError(
|
|
495
|
+
"Structural validation failed. Re-plan triggered."
|
|
496
|
+
) from e
|
|
497
|
+
raise
|
|
498
|
+
|
|
499
|
+
def _handle_logical_validation_errors( # noqa: PLR0913
|
|
500
|
+
self,
|
|
501
|
+
plan: Plan,
|
|
502
|
+
errors: list[Any],
|
|
503
|
+
content: str,
|
|
504
|
+
plan_path: Optional[str],
|
|
505
|
+
is_session: bool,
|
|
506
|
+
) -> ExecutionReport:
|
|
507
|
+
"""Formats logical errors and handles the failure report/replan."""
|
|
508
|
+
rich_ast = (
|
|
509
|
+
format_hybrid_ast_view(plan.source_doc, errors) if plan.source_doc else ""
|
|
510
|
+
)
|
|
511
|
+
error_messages = [e.message for e in errors]
|
|
512
|
+
|
|
513
|
+
failed_resources = self._replanner.gather_failed_resources(
|
|
514
|
+
errors, is_session=is_session
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
if is_session and plan_path:
|
|
518
|
+
return self._lifecycle_manager.trigger_replan(
|
|
519
|
+
plan_path=plan_path,
|
|
520
|
+
errors=error_messages,
|
|
521
|
+
original_plan_content=content,
|
|
522
|
+
title=plan.title,
|
|
523
|
+
rationale=plan.rationale,
|
|
524
|
+
failed_resources=failed_resources,
|
|
525
|
+
validation_ast=rich_ast,
|
|
526
|
+
original_actions=plan.actions,
|
|
527
|
+
plan=plan,
|
|
528
|
+
is_session=is_session,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
return self._replanner.build_failure_report(
|
|
532
|
+
errors=error_messages,
|
|
533
|
+
title=plan.title,
|
|
534
|
+
rationale=plan.rationale,
|
|
535
|
+
failed_resources=failed_resources,
|
|
536
|
+
validation_ast=rich_ast,
|
|
537
|
+
original_actions=plan.actions,
|
|
538
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from teddy_executor.core.ports.outbound.file_system_manager import IFileSystemManager
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionPlanner:
|
|
11
|
+
"""Handles interactive turn planning and dynamic session renaming."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
file_system_manager: IFileSystemManager,
|
|
16
|
+
planning_service,
|
|
17
|
+
user_interactor,
|
|
18
|
+
session_service,
|
|
19
|
+
):
|
|
20
|
+
self._file_system_manager = file_system_manager
|
|
21
|
+
self._planning_service = planning_service
|
|
22
|
+
self._user_interactor = user_interactor
|
|
23
|
+
self._session_service = session_service
|
|
24
|
+
|
|
25
|
+
def trigger_new_plan(
|
|
26
|
+
self, turn_dir: str, message: Optional[str] = None
|
|
27
|
+
) -> Optional[str]:
|
|
28
|
+
"""Prompts user and triggers planning. Returns session name on success."""
|
|
29
|
+
# Note: PlanningService.generate_plan handles tiered message resolution
|
|
30
|
+
# via PromptManager (CLI -> initial_request.md -> Prompt).
|
|
31
|
+
resolved_message = message
|
|
32
|
+
# We pass it to generate_plan which handles the resolution and hint.
|
|
33
|
+
|
|
34
|
+
plan_path, turn_cost = self._planning_service.generate_plan(
|
|
35
|
+
user_message=resolved_message,
|
|
36
|
+
turn_dir=turn_dir,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Handle planning cancellation/empty input
|
|
40
|
+
if plan_path is None:
|
|
41
|
+
return "CANCELLED"
|
|
42
|
+
|
|
43
|
+
return Path(turn_dir).parent.name
|