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