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,309 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from typing import TYPE_CHECKING, Any, List, Optional
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from mistletoe.block_token import (
|
|
9
|
+
Document,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from teddy_executor.core.domain.models import ActionData, Plan, ActionType
|
|
13
|
+
from teddy_executor.core.ports.inbound.plan_parser import IPlanParser, InvalidPlanError
|
|
14
|
+
from teddy_executor.core.services.parser_infrastructure import (
|
|
15
|
+
H1_LEVEL,
|
|
16
|
+
H2_LEVEL,
|
|
17
|
+
_FencePreProcessor,
|
|
18
|
+
_PeekableStream,
|
|
19
|
+
get_child_text,
|
|
20
|
+
get_action_heading,
|
|
21
|
+
consume_content_until_next_action,
|
|
22
|
+
normalize_headings,
|
|
23
|
+
print_ast,
|
|
24
|
+
)
|
|
25
|
+
from teddy_executor.core.services.parser_reporting import (
|
|
26
|
+
format_structural_mismatch_msg,
|
|
27
|
+
validate_plan_structure,
|
|
28
|
+
)
|
|
29
|
+
from teddy_executor.core.services.parser_metadata import parse_plan_metadata
|
|
30
|
+
from teddy_executor.core.services.action_parser_strategies import (
|
|
31
|
+
parse_create_action,
|
|
32
|
+
parse_read_action,
|
|
33
|
+
)
|
|
34
|
+
from teddy_executor.core.services.action_parser_complex import (
|
|
35
|
+
parse_edit_action,
|
|
36
|
+
parse_execute_action,
|
|
37
|
+
parse_research_action,
|
|
38
|
+
parse_message_action,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MarkdownPlanParser(IPlanParser):
|
|
43
|
+
"""
|
|
44
|
+
A service that parses a Markdown plan string into a `Plan` domain object using a
|
|
45
|
+
single-pass AST stream.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self):
|
|
49
|
+
self._preprocessor = _FencePreProcessor()
|
|
50
|
+
self._valid_actions = {action.value for action in ActionType}
|
|
51
|
+
self._dispatch_map = {
|
|
52
|
+
"CREATE": parse_create_action,
|
|
53
|
+
"READ": parse_read_action,
|
|
54
|
+
"EDIT": lambda s, node=None: parse_edit_action(
|
|
55
|
+
s, self._valid_actions, node=node
|
|
56
|
+
),
|
|
57
|
+
"EXECUTE": parse_execute_action,
|
|
58
|
+
"RESEARCH": lambda s, node=None: parse_research_action(
|
|
59
|
+
s, self._valid_actions, node=node
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
def parse(self, plan_content: str, plan_path: Optional[str] = None) -> Plan:
|
|
64
|
+
"""
|
|
65
|
+
Parses the specified Markdown plan string into a structured Plan object.
|
|
66
|
+
"""
|
|
67
|
+
from mistletoe.block_token import (
|
|
68
|
+
Document,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Trim trailing whitespace to prevent mistletoe from
|
|
72
|
+
# interpreting trailing indentation as an unexpected code block.
|
|
73
|
+
# We keep leading whitespace for potential Markdown significance (though rare at top-level).
|
|
74
|
+
clean_content = plan_content.rstrip()
|
|
75
|
+
|
|
76
|
+
if not clean_content:
|
|
77
|
+
raise InvalidPlanError("Plan content cannot be empty.")
|
|
78
|
+
|
|
79
|
+
# Strip preamble (text before the first # heading at start of a line)
|
|
80
|
+
# Use MULTILINE so ^ matches start of any line. Allow optional leading whitespace
|
|
81
|
+
# before # because Markdown permits up to 3 spaces before heading markers.
|
|
82
|
+
h1_match = re.search(r"^[ \t]*#", clean_content, re.MULTILINE)
|
|
83
|
+
if h1_match and h1_match.start() > 0:
|
|
84
|
+
clean_content = clean_content[h1_match.start() :]
|
|
85
|
+
|
|
86
|
+
# Normalize H1 heading on the first line (e.g., #Title -> # Title)
|
|
87
|
+
# This runs after preamble stripping so it always targets the heading line
|
|
88
|
+
clean_content = normalize_headings(clean_content)
|
|
89
|
+
|
|
90
|
+
processed_content = self._preprocessor.process(clean_content)
|
|
91
|
+
doc = Document(processed_content)
|
|
92
|
+
|
|
93
|
+
if os.environ.get("TEDDY_DEBUG"):
|
|
94
|
+
print_ast(doc)
|
|
95
|
+
|
|
96
|
+
stream = _PeekableStream(iter(doc.children or []))
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
title, rationale, metadata, section_heading = self._parse_strict_top_level(
|
|
100
|
+
stream, doc
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
self._validate_mutual_exclusivity(doc)
|
|
104
|
+
|
|
105
|
+
actions = self._parse_section_content(
|
|
106
|
+
stream, clean_content, section_heading, doc
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
is_session = False
|
|
110
|
+
if plan_path:
|
|
111
|
+
normalized_path = plan_path.replace("\\", "/").lower()
|
|
112
|
+
is_session = ".teddy/sessions/" in normalized_path
|
|
113
|
+
|
|
114
|
+
plan = Plan(
|
|
115
|
+
title=title,
|
|
116
|
+
rationale=rationale,
|
|
117
|
+
actions=actions,
|
|
118
|
+
metadata=metadata,
|
|
119
|
+
source_doc=doc,
|
|
120
|
+
is_session=is_session,
|
|
121
|
+
plan_path=plan_path,
|
|
122
|
+
raw_content=clean_content,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Write corrected content back to source file if it came from a session file path
|
|
126
|
+
if plan_path and is_session:
|
|
127
|
+
from pathlib import Path
|
|
128
|
+
|
|
129
|
+
path_obj = Path(plan_path)
|
|
130
|
+
try:
|
|
131
|
+
current_disk = path_obj.read_text(encoding="utf-8")
|
|
132
|
+
except Exception:
|
|
133
|
+
current_disk = None
|
|
134
|
+
if current_disk is not None and current_disk.rstrip() != clean_content:
|
|
135
|
+
path_obj.write_text(clean_content, encoding="utf-8")
|
|
136
|
+
|
|
137
|
+
return plan
|
|
138
|
+
except InvalidPlanError as e:
|
|
139
|
+
if "### Expected Response Structure (MRP) " in str(e):
|
|
140
|
+
raise e
|
|
141
|
+
|
|
142
|
+
# Re-format the error using the shared infrastructure to always include AST
|
|
143
|
+
e_nodes = getattr(e, "offending_nodes", [])
|
|
144
|
+
rich_msg = format_structural_mismatch_msg(
|
|
145
|
+
doc, str(e).splitlines()[0], -1, e_nodes
|
|
146
|
+
)
|
|
147
|
+
raise InvalidPlanError(rich_msg, offending_nodes=e_nodes) from e
|
|
148
|
+
|
|
149
|
+
def _raise_structural_error(
|
|
150
|
+
self, doc: Document, expected_name: str, mismatch_idx: int, actual_node: Any
|
|
151
|
+
):
|
|
152
|
+
"""Constructs and raises a detailed structural validation error."""
|
|
153
|
+
offending_nodes = [actual_node] if actual_node else []
|
|
154
|
+
raise InvalidPlanError(
|
|
155
|
+
self._format_structural_mismatch_msg(
|
|
156
|
+
doc, expected_name, mismatch_idx, actual_node
|
|
157
|
+
),
|
|
158
|
+
offending_nodes=offending_nodes,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _format_structural_mismatch_msg(
|
|
162
|
+
self, doc: Document, expected: str, mismatch_idx: int, actual_node: Any
|
|
163
|
+
) -> str:
|
|
164
|
+
"""Wrapper for infrastructure helper to maintain internal API for tests."""
|
|
165
|
+
offending_nodes = (
|
|
166
|
+
actual_node if isinstance(actual_node, list) else [actual_node]
|
|
167
|
+
)
|
|
168
|
+
return format_structural_mismatch_msg(
|
|
169
|
+
doc, expected, mismatch_idx, offending_nodes
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _consume_mandatory_node(
|
|
173
|
+
self, stream: _PeekableStream, doc: Document, idx: int, expected: str, predicate
|
|
174
|
+
) -> Any:
|
|
175
|
+
node = stream.peek()
|
|
176
|
+
if not node or not predicate(node):
|
|
177
|
+
self._raise_structural_error(doc, expected, idx, node)
|
|
178
|
+
return stream.next()
|
|
179
|
+
|
|
180
|
+
def _parse_strict_top_level(
|
|
181
|
+
self, stream: _PeekableStream, doc: Document
|
|
182
|
+
) -> tuple[str, str, dict[str, str], Any]:
|
|
183
|
+
from mistletoe.block_token import Heading
|
|
184
|
+
|
|
185
|
+
# 0: Find H1 Title. Must be at index 0 per Rule 3.1.
|
|
186
|
+
node = stream.peek()
|
|
187
|
+
start_idx = 0
|
|
188
|
+
|
|
189
|
+
if not node or not (isinstance(node, Heading) and node.level == H1_LEVEL):
|
|
190
|
+
offending_nodes = [node] if node else []
|
|
191
|
+
rich_msg = format_structural_mismatch_msg(
|
|
192
|
+
doc, "a Level 1 Heading (Title)", 0, offending_nodes
|
|
193
|
+
)
|
|
194
|
+
raise InvalidPlanError(rich_msg, offending_nodes=offending_nodes)
|
|
195
|
+
|
|
196
|
+
title = get_child_text(node).strip()
|
|
197
|
+
|
|
198
|
+
validate_plan_structure(doc, start_idx)
|
|
199
|
+
|
|
200
|
+
# If we got here, the structure is correct. Consume nodes and extract data.
|
|
201
|
+
stream.next() # Title (already used)
|
|
202
|
+
metadata_list_node = stream.next()
|
|
203
|
+
if not metadata_list_node:
|
|
204
|
+
raise InvalidPlanError(
|
|
205
|
+
"Plan parsing failed: Expected metadata list missing."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
metadata = parse_plan_metadata(metadata_list_node)
|
|
209
|
+
|
|
210
|
+
stream.next() # H2 Rationale
|
|
211
|
+
rationale_node = stream.next()
|
|
212
|
+
rationale = get_child_text(rationale_node).strip()
|
|
213
|
+
section_heading = stream.next() # H2 Action Plan or Message
|
|
214
|
+
|
|
215
|
+
return title, rationale, metadata, section_heading
|
|
216
|
+
|
|
217
|
+
def _validate_mutual_exclusivity(self, doc: "Document") -> None:
|
|
218
|
+
"""Validates that the document does not contain both ## Action Plan and ## Message."""
|
|
219
|
+
from mistletoe.block_token import Heading
|
|
220
|
+
|
|
221
|
+
doc_children = doc.children or []
|
|
222
|
+
h2_headings = [
|
|
223
|
+
n for n in doc_children if isinstance(n, Heading) and n.level == H2_LEVEL
|
|
224
|
+
]
|
|
225
|
+
h2_texts = [get_child_text(h) for h in h2_headings]
|
|
226
|
+
if "Action Plan" in h2_texts and "Message" in h2_texts:
|
|
227
|
+
raise InvalidPlanError(
|
|
228
|
+
"Plan cannot contain both '## Action Plan' and '## Message'. Mutual exclusivity is required."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def _parse_section_content(
|
|
232
|
+
self,
|
|
233
|
+
stream: _PeekableStream,
|
|
234
|
+
clean_content: str,
|
|
235
|
+
section_heading: Any,
|
|
236
|
+
doc: Document,
|
|
237
|
+
) -> List[ActionData]:
|
|
238
|
+
"""Parses the content of either a ## Message or ## Action Plan section."""
|
|
239
|
+
section_name = get_child_text(section_heading).strip()
|
|
240
|
+
if "Message" in section_name:
|
|
241
|
+
raw_content = None
|
|
242
|
+
start_line = getattr(section_heading, "line_number", None)
|
|
243
|
+
if start_line is not None and start_line > 0:
|
|
244
|
+
lines = clean_content.splitlines(keepends=True)
|
|
245
|
+
if start_line < len(lines):
|
|
246
|
+
raw_content = "".join(lines[start_line:]).lstrip("\n")
|
|
247
|
+
actions = [
|
|
248
|
+
parse_message_action(
|
|
249
|
+
stream, node=section_heading, raw_content=raw_content
|
|
250
|
+
)
|
|
251
|
+
]
|
|
252
|
+
else:
|
|
253
|
+
actions = self._parse_actions(stream, doc)
|
|
254
|
+
return actions
|
|
255
|
+
|
|
256
|
+
def _parse_actions(
|
|
257
|
+
self, stream: _PeekableStream, doc: Document
|
|
258
|
+
) -> List[ActionData]:
|
|
259
|
+
from mistletoe.block_token import BlockCode, CodeFence, ThematicBreak
|
|
260
|
+
|
|
261
|
+
actions: List[ActionData] = []
|
|
262
|
+
# 'Action Plan' heading is already consumed by _parse_strict_top_level.
|
|
263
|
+
|
|
264
|
+
# Parse all subsequent actions
|
|
265
|
+
while stream.has_next():
|
|
266
|
+
node = stream.peek()
|
|
267
|
+
action_heading = get_action_heading(node, self._valid_actions)
|
|
268
|
+
|
|
269
|
+
if not action_heading:
|
|
270
|
+
# Skip code blocks and thematic breaks that can appear between
|
|
271
|
+
# action blocks due to formatting or trailing content.
|
|
272
|
+
if isinstance(node, (BlockCode, CodeFence, ThematicBreak)):
|
|
273
|
+
stream.next()
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
# Accumulate offending node and raise structural error
|
|
277
|
+
offending_nodes = consume_content_until_next_action(
|
|
278
|
+
stream, self._valid_actions
|
|
279
|
+
)
|
|
280
|
+
raise InvalidPlanError(
|
|
281
|
+
format_structural_mismatch_msg(
|
|
282
|
+
doc, "a Level 3 Action Heading", -1, offending_nodes
|
|
283
|
+
),
|
|
284
|
+
offending_nodes=offending_nodes,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
stream.next() # Consume action heading
|
|
288
|
+
action_type_str = get_child_text(action_heading).strip().replace("`", "")
|
|
289
|
+
|
|
290
|
+
# Guard: MESSAGE under ## Action Plan must produce a clear mutual exclusivity error
|
|
291
|
+
if action_type_str == "MESSAGE":
|
|
292
|
+
raise InvalidPlanError(
|
|
293
|
+
"MESSAGE action is not allowed under '## Action Plan'. "
|
|
294
|
+
"Use '## Message' section instead. Mutual exclusivity is required.",
|
|
295
|
+
offending_nodes=[action_heading],
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if action_type_str not in self._dispatch_map:
|
|
299
|
+
raise InvalidPlanError(
|
|
300
|
+
f"Unknown action type: {action_type_str}",
|
|
301
|
+
offending_nodes=[action_heading],
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
parse_method = self._dispatch_map[action_type_str]
|
|
305
|
+
actions.append(parse_method(stream, node=action_heading))
|
|
306
|
+
|
|
307
|
+
return actions
|
|
308
|
+
|
|
309
|
+
# Structural formatting logic moved to parser_infrastructure.py
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import timezone
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from teddy_executor.core.domain.models import ExecutionReport
|
|
6
|
+
from teddy_executor.core.ports.outbound.markdown_report_formatter import (
|
|
7
|
+
IMarkdownReportFormatter,
|
|
8
|
+
)
|
|
9
|
+
from teddy_executor.core.utils.markdown import (
|
|
10
|
+
get_fence_for_content,
|
|
11
|
+
get_language_from_path,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MarkdownReportFormatter(IMarkdownReportFormatter):
|
|
16
|
+
"""
|
|
17
|
+
Implements IMarkdownReportFormatter using the Jinja2 template engine.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_cached_env = None
|
|
21
|
+
_cached_template = None
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def _reset_singleton(cls):
|
|
25
|
+
"""Internal helper for test isolation."""
|
|
26
|
+
cls._cached_env = None
|
|
27
|
+
cls._cached_template = None
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
from jinja2 import Environment, PackageLoader
|
|
31
|
+
|
|
32
|
+
if MarkdownReportFormatter._cached_env is None:
|
|
33
|
+
env = Environment(
|
|
34
|
+
loader=PackageLoader("teddy_executor.core.services", "templates"),
|
|
35
|
+
trim_blocks=True,
|
|
36
|
+
lstrip_blocks=True,
|
|
37
|
+
autoescape=False, # nosec B701
|
|
38
|
+
)
|
|
39
|
+
env.filters["basename"] = os.path.basename
|
|
40
|
+
env.filters["fence"] = get_fence_for_content
|
|
41
|
+
env.filters["language_from_path"] = get_language_from_path
|
|
42
|
+
|
|
43
|
+
MarkdownReportFormatter._cached_env = env
|
|
44
|
+
MarkdownReportFormatter._cached_template = env.get_template(
|
|
45
|
+
"execution_report.md.j2"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
self.env = MarkdownReportFormatter._cached_env
|
|
49
|
+
self.template = MarkdownReportFormatter._cached_template
|
|
50
|
+
|
|
51
|
+
def _prepare_context(self, report: ExecutionReport) -> dict[str, Any]:
|
|
52
|
+
"""Prepares the report data for rendering."""
|
|
53
|
+
|
|
54
|
+
def format_datetime(dt):
|
|
55
|
+
if not dt:
|
|
56
|
+
return ""
|
|
57
|
+
if dt.tzinfo is None:
|
|
58
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
59
|
+
return dt.isoformat()
|
|
60
|
+
|
|
61
|
+
plan_title: str = "Untitled Plan"
|
|
62
|
+
if hasattr(report, "plan_title"):
|
|
63
|
+
val = getattr(report, "plan_title")
|
|
64
|
+
plan_title = str(val) if val is not None else "Untitled Plan"
|
|
65
|
+
elif isinstance(report, dict):
|
|
66
|
+
plan_title = str(report.get("plan_title", "Untitled Plan"))
|
|
67
|
+
|
|
68
|
+
is_session = False
|
|
69
|
+
if hasattr(report, "is_session"):
|
|
70
|
+
is_session = bool(getattr(report, "is_session"))
|
|
71
|
+
elif isinstance(report, dict):
|
|
72
|
+
is_session = bool(report.get("is_session", False))
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"report": report,
|
|
76
|
+
"is_session": is_session,
|
|
77
|
+
"plan_title": plan_title,
|
|
78
|
+
"format_datetime": format_datetime,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def format(self, report: ExecutionReport) -> str:
|
|
82
|
+
"""Renders the execution report to a Markdown string."""
|
|
83
|
+
from teddy_executor.core.utils.serialization import (
|
|
84
|
+
scrub_dict_for_serialization,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# 1. Prepare context with real objects to support attribute access in Python
|
|
88
|
+
context = self._prepare_context(report)
|
|
89
|
+
|
|
90
|
+
# 2. Scrub the report data specifically to neutralize mocks for Jinja2
|
|
91
|
+
report_data = (
|
|
92
|
+
report.__dict__
|
|
93
|
+
if hasattr(report, "__dict__")
|
|
94
|
+
else (report if isinstance(report, dict) else {})
|
|
95
|
+
)
|
|
96
|
+
context["report"] = scrub_dict_for_serialization(report_data)
|
|
97
|
+
|
|
98
|
+
# 3. Render with scrubbed data but real functions
|
|
99
|
+
rendered = self.template.render(context)
|
|
100
|
+
|
|
101
|
+
# Post-process for whitespace sanitization
|
|
102
|
+
lines = [line.rstrip() for line in rendered.splitlines()]
|
|
103
|
+
|
|
104
|
+
sanitized_lines = []
|
|
105
|
+
in_fence = False
|
|
106
|
+
consecutive_blanks = 0
|
|
107
|
+
|
|
108
|
+
for line in lines:
|
|
109
|
+
# Track code block state
|
|
110
|
+
if line.strip().startswith("```"):
|
|
111
|
+
in_fence = not in_fence
|
|
112
|
+
|
|
113
|
+
if in_fence:
|
|
114
|
+
# Inside code block: preserve all whitespace and newlines
|
|
115
|
+
sanitized_lines.append(line)
|
|
116
|
+
consecutive_blanks = 0
|
|
117
|
+
# Outside code block: apply sanitization rules
|
|
118
|
+
elif not line:
|
|
119
|
+
consecutive_blanks += 1
|
|
120
|
+
# Only allow one consecutive blank line (max 2 newlines)
|
|
121
|
+
if consecutive_blanks <= 1:
|
|
122
|
+
sanitized_lines.append(line)
|
|
123
|
+
else:
|
|
124
|
+
# If the line starts with a bullet point, prevent a blank line before it
|
|
125
|
+
# unless it's the very first bullet in a list after a header.
|
|
126
|
+
# This ensures density for list items.
|
|
127
|
+
if (
|
|
128
|
+
line.strip().startswith("- ")
|
|
129
|
+
and sanitized_lines
|
|
130
|
+
and not sanitized_lines[-1].strip()
|
|
131
|
+
):
|
|
132
|
+
# If the previous line was blank and we are starting a bullet,
|
|
133
|
+
# check if the line before THAT was also a bullet.
|
|
134
|
+
if len(sanitized_lines) > 1 and sanitized_lines[
|
|
135
|
+
-2
|
|
136
|
+
].strip().startswith("- "):
|
|
137
|
+
sanitized_lines.pop() # Remove the blank line between bullets
|
|
138
|
+
|
|
139
|
+
consecutive_blanks = 0
|
|
140
|
+
sanitized_lines.append(line)
|
|
141
|
+
|
|
142
|
+
sanitized = "\n".join(sanitized_lines).strip()
|
|
143
|
+
return sanitized
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, List, Optional, Iterator, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Insert a space after `#` on the first line if missing (e.g., `#Title` -> `# Title`)
|
|
7
|
+
# Only normalizes the first line to avoid corrupting code fences or shebangs.
|
|
8
|
+
def normalize_headings(content: str) -> str:
|
|
9
|
+
"""Insert a space after `#` if missing on the first line (the H1 title)."""
|
|
10
|
+
first_newline = content.find("\n")
|
|
11
|
+
if first_newline == -1:
|
|
12
|
+
first_line = content
|
|
13
|
+
rest = ""
|
|
14
|
+
else:
|
|
15
|
+
first_line = content[:first_newline]
|
|
16
|
+
rest = content[first_newline:]
|
|
17
|
+
if re.match(r"^#[^ #\t\n]", first_line):
|
|
18
|
+
first_line = "# " + first_line[1:]
|
|
19
|
+
return first_line + rest
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from mistletoe.block_token import Heading
|
|
24
|
+
|
|
25
|
+
# Constants for Markdown structure
|
|
26
|
+
H1_LEVEL = 1
|
|
27
|
+
H2_LEVEL = 2
|
|
28
|
+
H3_LEVEL = 3
|
|
29
|
+
|
|
30
|
+
# Constant for parsing key-value pairs
|
|
31
|
+
EXPECTED_KV_PARTS = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _FencePreProcessor:
|
|
35
|
+
"""
|
|
36
|
+
A utility to pre-process raw LLM Markdown output to ensure all code fences are valid
|
|
37
|
+
before parsing. This is a crucial safety net.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def process(self, content: str) -> str:
|
|
41
|
+
"""
|
|
42
|
+
Pre-process raw Markdown content to normalize code fences.
|
|
43
|
+
|
|
44
|
+
Currently handles:
|
|
45
|
+
- Stripping trailing non-whitespace content on fence lines with 6+
|
|
46
|
+
consecutive backticks or tildes (e.g., ``~~~~~~ trailing text`` → ``~~~~~~``).
|
|
47
|
+
"""
|
|
48
|
+
lines = content.split("\n")
|
|
49
|
+
result = []
|
|
50
|
+
# Pattern: optional leading whitespace, then 6+ consecutive pure tildes
|
|
51
|
+
# OR 6+ consecutive pure backticks, then any trailing content.
|
|
52
|
+
pattern = re.compile(r"^(\s*)(\~{6,}|\`{6,})(.*)$")
|
|
53
|
+
|
|
54
|
+
for line in lines:
|
|
55
|
+
match = pattern.match(line)
|
|
56
|
+
if match:
|
|
57
|
+
trailing = match.group(3)
|
|
58
|
+
# Only strip trailing content if it does NOT contain any backtick
|
|
59
|
+
# or tilde. This prevents corrupting lines like
|
|
60
|
+
# "~~~~~~` trailing" where fence characters appear in content.
|
|
61
|
+
if trailing is not None and trailing.strip():
|
|
62
|
+
if not any(c in trailing for c in ("`", "~")):
|
|
63
|
+
line = match.group(1) + match.group(2)
|
|
64
|
+
# If trailing is empty/whitespace or contains fence chars,
|
|
65
|
+
# keep original line unchanged.
|
|
66
|
+
result.append(line)
|
|
67
|
+
|
|
68
|
+
return "\n".join(result)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _PeekableStream:
|
|
72
|
+
"""A wrapper for an iterator to allow peeking at the next item."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, iterator: Iterator[Any]):
|
|
75
|
+
self._iterator = iterator
|
|
76
|
+
self._next_item: Optional[Any] = None
|
|
77
|
+
self._fetch_next()
|
|
78
|
+
|
|
79
|
+
def _fetch_next(self):
|
|
80
|
+
try:
|
|
81
|
+
self._next_item = next(self._iterator)
|
|
82
|
+
except StopIteration:
|
|
83
|
+
self._next_item = None
|
|
84
|
+
|
|
85
|
+
def has_next(self) -> bool:
|
|
86
|
+
return self._next_item is not None
|
|
87
|
+
|
|
88
|
+
def peek(self) -> Optional[Any]:
|
|
89
|
+
return self._next_item
|
|
90
|
+
|
|
91
|
+
def next(self) -> Optional[Any]:
|
|
92
|
+
current_item = self._next_item
|
|
93
|
+
if current_item is not None:
|
|
94
|
+
self._fetch_next()
|
|
95
|
+
return current_item
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def normalize_path(path: str) -> str:
|
|
99
|
+
return path.replace("\\", "/")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def normalize_link_target(target: str) -> str:
|
|
103
|
+
if target.startswith(("http://", "https://")):
|
|
104
|
+
return target
|
|
105
|
+
is_abs = os.path.isabs(target)
|
|
106
|
+
is_likely_true_absolute = False
|
|
107
|
+
if os.name == "nt":
|
|
108
|
+
has_drive, _ = os.path.splitdrive(target)
|
|
109
|
+
if is_abs and has_drive:
|
|
110
|
+
is_likely_true_absolute = True
|
|
111
|
+
elif os.name == "posix" and is_abs:
|
|
112
|
+
common_roots = ("/tmp", "/etc", "/home", "/var", "/usr", "/root") # nosec B108
|
|
113
|
+
if target.startswith(common_roots):
|
|
114
|
+
is_likely_true_absolute = True
|
|
115
|
+
if target.startswith("/") and not is_likely_true_absolute:
|
|
116
|
+
return target.lstrip("/")
|
|
117
|
+
return target
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def find_node_in_tree(node: Any, node_type: type) -> Optional[Any]:
|
|
121
|
+
if isinstance(node, node_type):
|
|
122
|
+
return node
|
|
123
|
+
if hasattr(node, "children") and node.children is not None:
|
|
124
|
+
for child in node.children:
|
|
125
|
+
found = find_node_in_tree(child, node_type)
|
|
126
|
+
if found:
|
|
127
|
+
return found
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_child_text(node: Any) -> str:
|
|
132
|
+
if hasattr(node, "children") and node.children is not None:
|
|
133
|
+
return "".join([get_child_text(child) for child in node.children])
|
|
134
|
+
return getattr(node, "content", "")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_action_heading(node: Any, valid_actions: set[str]) -> "Optional[Heading]":
|
|
138
|
+
"""Checks if a node is a valid H3 action heading."""
|
|
139
|
+
from mistletoe.block_token import Heading
|
|
140
|
+
from mistletoe.span_token import InlineCode
|
|
141
|
+
|
|
142
|
+
if isinstance(node, Heading) and node.level == H3_LEVEL:
|
|
143
|
+
text = get_child_text(node).strip()
|
|
144
|
+
potential_type = text.split(":")[0].strip().replace("`", "")
|
|
145
|
+
if potential_type in valid_actions:
|
|
146
|
+
return node
|
|
147
|
+
# Allow unknown actions if they are formatted like `ACTION` to fail later
|
|
148
|
+
children = list(node.children) if node.children else []
|
|
149
|
+
if children and isinstance(children[0], InlineCode):
|
|
150
|
+
return node
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def consume_content_until_next_action(
|
|
155
|
+
stream: _PeekableStream, valid_actions: set[str]
|
|
156
|
+
) -> List[Any]:
|
|
157
|
+
"""Consumes nodes from the stream until the next H3 action heading or H1/H2."""
|
|
158
|
+
from mistletoe.block_token import Heading
|
|
159
|
+
|
|
160
|
+
content_nodes = []
|
|
161
|
+
while stream.has_next():
|
|
162
|
+
node = stream.peek()
|
|
163
|
+
if isinstance(node, Heading):
|
|
164
|
+
if node.level <= H2_LEVEL:
|
|
165
|
+
break
|
|
166
|
+
if get_action_heading(node, valid_actions):
|
|
167
|
+
break
|
|
168
|
+
content_nodes.append(stream.next())
|
|
169
|
+
return content_nodes
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def print_ast(token: Any, indent: int = 0):
|
|
173
|
+
"""Recursively prints the AST in a readable format for debugging."""
|
|
174
|
+
prefix = " " * indent
|
|
175
|
+
print(f"{prefix}- {type(token).__name__}")
|
|
176
|
+
|
|
177
|
+
content_attr = getattr(token, "content", None)
|
|
178
|
+
if content_attr is not None:
|
|
179
|
+
first_line = (
|
|
180
|
+
str(content_attr).splitlines()[0]
|
|
181
|
+
if "\n" in str(content_attr)
|
|
182
|
+
else str(content_attr)
|
|
183
|
+
)
|
|
184
|
+
print(f'{prefix} Content: "{first_line[:80]}"')
|
|
185
|
+
|
|
186
|
+
children_attr = getattr(token, "children", None)
|
|
187
|
+
if children_attr is not None:
|
|
188
|
+
for child in children_attr:
|
|
189
|
+
print_ast(child, indent + 1)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def translate_setup_commands(
|
|
193
|
+
setup_str: str,
|
|
194
|
+
initial_cwd: Optional[str] = None,
|
|
195
|
+
initial_env: Optional[dict[str, str]] = None,
|
|
196
|
+
) -> tuple[Optional[str], Optional[dict[str, str]]]:
|
|
197
|
+
"""
|
|
198
|
+
Translates a chained setup string (e.g. 'cd dir && export FOO=bar')
|
|
199
|
+
into cwd and env parameters.
|
|
200
|
+
"""
|
|
201
|
+
cwd = initial_cwd
|
|
202
|
+
env = initial_env
|
|
203
|
+
|
|
204
|
+
parts = [p.strip() for p in setup_str.split("&&")]
|
|
205
|
+
for part in parts:
|
|
206
|
+
if part.startswith("cd "):
|
|
207
|
+
cwd = part[3:].strip()
|
|
208
|
+
elif part.startswith("export "):
|
|
209
|
+
if env is None:
|
|
210
|
+
env = {}
|
|
211
|
+
kv_part = part[7:].strip()
|
|
212
|
+
if "=" in kv_part:
|
|
213
|
+
key, value = kv_part.split("=", 1)
|
|
214
|
+
key = key.strip()
|
|
215
|
+
value = value.strip()
|
|
216
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
217
|
+
value.startswith("'") and value.endswith("'")
|
|
218
|
+
):
|
|
219
|
+
value = value[1:-1]
|
|
220
|
+
env[key] = value
|
|
221
|
+
|
|
222
|
+
return cwd, env
|