teddy-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. teddy_cli-0.1.0.dist-info/LICENSE +677 -0
  2. teddy_cli-0.1.0.dist-info/METADATA +33 -0
  3. teddy_cli-0.1.0.dist-info/RECORD +143 -0
  4. teddy_cli-0.1.0.dist-info/WHEEL +4 -0
  5. teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
  6. teddy_executor/__init__.py +1 -0
  7. teddy_executor/__main__.py +335 -0
  8. teddy_executor/adapters/__init__.py +0 -0
  9. teddy_executor/adapters/inbound/__init__.py +0 -0
  10. teddy_executor/adapters/inbound/cli_formatter.py +107 -0
  11. teddy_executor/adapters/inbound/cli_helpers.py +249 -0
  12. teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
  13. teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
  14. teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
  15. teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
  16. teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
  17. teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
  18. teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
  19. teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
  20. teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
  21. teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
  22. teddy_executor/adapters/outbound/__init__.py +7 -0
  23. teddy_executor/adapters/outbound/console_interactor.py +212 -0
  24. teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
  25. teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
  26. teddy_executor/adapters/outbound/console_tooling.py +62 -0
  27. teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
  28. teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
  29. teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
  30. teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
  31. teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
  32. teddy_executor/adapters/outbound/shell_adapter.py +344 -0
  33. teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
  34. teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
  35. teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
  36. teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
  37. teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
  38. teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
  39. teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
  40. teddy_executor/container.py +333 -0
  41. teddy_executor/core/__init__.py +0 -0
  42. teddy_executor/core/domain/__init__.py +0 -0
  43. teddy_executor/core/domain/models/__init__.py +44 -0
  44. teddy_executor/core/domain/models/action_ports.py +28 -0
  45. teddy_executor/core/domain/models/change_set.py +10 -0
  46. teddy_executor/core/domain/models/exceptions.py +40 -0
  47. teddy_executor/core/domain/models/execution_report.py +65 -0
  48. teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
  49. teddy_executor/core/domain/models/plan.py +85 -0
  50. teddy_executor/core/domain/models/planning_ports.py +43 -0
  51. teddy_executor/core/domain/models/project_context.py +56 -0
  52. teddy_executor/core/domain/models/report_assembly_data.py +18 -0
  53. teddy_executor/core/domain/models/session.py +17 -0
  54. teddy_executor/core/domain/models/shell_output.py +12 -0
  55. teddy_executor/core/domain/models/web_search_results.py +26 -0
  56. teddy_executor/core/ports/__init__.py +0 -0
  57. teddy_executor/core/ports/inbound/__init__.py +0 -0
  58. teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
  59. teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
  60. teddy_executor/core/ports/inbound/init.py +15 -0
  61. teddy_executor/core/ports/inbound/plan_parser.py +52 -0
  62. teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
  63. teddy_executor/core/ports/inbound/plan_validator.py +26 -0
  64. teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
  65. teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
  66. teddy_executor/core/ports/outbound/__init__.py +34 -0
  67. teddy_executor/core/ports/outbound/config_service.py +29 -0
  68. teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
  69. teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
  70. teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
  71. teddy_executor/core/ports/outbound/llm_client.py +90 -0
  72. teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
  73. teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
  74. teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
  75. teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
  76. teddy_executor/core/ports/outbound/session_manager.py +97 -0
  77. teddy_executor/core/ports/outbound/session_repository.py +65 -0
  78. teddy_executor/core/ports/outbound/shell_executor.py +24 -0
  79. teddy_executor/core/ports/outbound/system_environment.py +25 -0
  80. teddy_executor/core/ports/outbound/time_service.py +28 -0
  81. teddy_executor/core/ports/outbound/user_interactor.py +126 -0
  82. teddy_executor/core/ports/outbound/web_scraper.py +24 -0
  83. teddy_executor/core/ports/outbound/web_searcher.py +25 -0
  84. teddy_executor/core/services/__init__.py +0 -0
  85. teddy_executor/core/services/action_changeset_builder.py +90 -0
  86. teddy_executor/core/services/action_diff_manager.py +110 -0
  87. teddy_executor/core/services/action_dispatcher.py +142 -0
  88. teddy_executor/core/services/action_executor.py +209 -0
  89. teddy_executor/core/services/action_factory.py +197 -0
  90. teddy_executor/core/services/action_parser_complex.py +216 -0
  91. teddy_executor/core/services/action_parser_strategies.py +84 -0
  92. teddy_executor/core/services/context_service.py +437 -0
  93. teddy_executor/core/services/edit_simulator.py +128 -0
  94. teddy_executor/core/services/execution_orchestrator.py +295 -0
  95. teddy_executor/core/services/execution_report_assembler.py +62 -0
  96. teddy_executor/core/services/init_service.py +80 -0
  97. teddy_executor/core/services/markdown_plan_parser.py +309 -0
  98. teddy_executor/core/services/markdown_report_formatter.py +143 -0
  99. teddy_executor/core/services/parser_infrastructure.py +222 -0
  100. teddy_executor/core/services/parser_metadata.py +153 -0
  101. teddy_executor/core/services/parser_reporting.py +267 -0
  102. teddy_executor/core/services/plan_validator.py +82 -0
  103. teddy_executor/core/services/planning_service.py +242 -0
  104. teddy_executor/core/services/prompt_manager.py +146 -0
  105. teddy_executor/core/services/session_lifecycle_manager.py +228 -0
  106. teddy_executor/core/services/session_loop_guard.py +46 -0
  107. teddy_executor/core/services/session_orchestrator.py +538 -0
  108. teddy_executor/core/services/session_planner.py +43 -0
  109. teddy_executor/core/services/session_pruning_service.py +438 -0
  110. teddy_executor/core/services/session_replanner.py +105 -0
  111. teddy_executor/core/services/session_repository.py +194 -0
  112. teddy_executor/core/services/session_service.py +529 -0
  113. teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
  114. teddy_executor/core/services/validation_rules/__init__.py +4 -0
  115. teddy_executor/core/services/validation_rules/edit.py +207 -0
  116. teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
  117. teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
  118. teddy_executor/core/services/validation_rules/execute.py +37 -0
  119. teddy_executor/core/services/validation_rules/filesystem.py +73 -0
  120. teddy_executor/core/services/validation_rules/helpers.py +178 -0
  121. teddy_executor/core/services/validation_rules/message.py +29 -0
  122. teddy_executor/core/utils/__init__.py +1 -0
  123. teddy_executor/core/utils/diff.py +57 -0
  124. teddy_executor/core/utils/io.py +75 -0
  125. teddy_executor/core/utils/markdown.py +131 -0
  126. teddy_executor/core/utils/serialization.py +39 -0
  127. teddy_executor/core/utils/string.py +351 -0
  128. teddy_executor/prompts.py +45 -0
  129. teddy_executor/registries/__init__.py +1 -0
  130. teddy_executor/registries/infrastructure.py +147 -0
  131. teddy_executor/registries/reviewer.py +57 -0
  132. teddy_executor/registries/validators.py +47 -0
  133. teddy_executor/resources/__init__.py +1 -0
  134. teddy_executor/resources/config/.gitignore +2 -0
  135. teddy_executor/resources/config/__init__.py +1 -0
  136. teddy_executor/resources/config/config.yaml +49 -0
  137. teddy_executor/resources/config/init.context +5 -0
  138. teddy_executor/resources/config/prompts/architect.xml +462 -0
  139. teddy_executor/resources/config/prompts/assistant.xml +336 -0
  140. teddy_executor/resources/config/prompts/debugger.xml +456 -0
  141. teddy_executor/resources/config/prompts/developer.xml +481 -0
  142. teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
  143. teddy_executor/resources/config/prompts/prototyper.xml +425 -0
@@ -0,0 +1,153 @@
1
+ from typing import Any, List, Optional
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from mistletoe.block_token import (
6
+ List as MdList,
7
+ ListItem as MdListItem,
8
+ )
9
+ from mistletoe.span_token import Link
10
+
11
+ from teddy_executor.core.services.parser_infrastructure import (
12
+ EXPECTED_KV_PARTS,
13
+ get_child_text,
14
+ normalize_path,
15
+ normalize_link_target,
16
+ find_node_in_tree,
17
+ )
18
+
19
+
20
+ def _process_link_key(
21
+ item: "MdListItem", text: str, key_map: dict[str, str]
22
+ ) -> Optional[tuple[str, str]]:
23
+ """Helper to process a single link-based metadata key."""
24
+ from mistletoe.span_token import Link
25
+
26
+ for key_text, param_key in key_map.items():
27
+ if f"{key_text}:" in text:
28
+ link_node = find_node_in_tree(item, Link)
29
+ if link_node:
30
+ target = normalize_link_target(link_node.target)
31
+ return param_key, normalize_path(target)
32
+ parts = text.split(f"{key_text}:", 1)
33
+ if len(parts) == EXPECTED_KV_PARTS and parts[1].strip():
34
+ return param_key, normalize_path(parts[1].strip())
35
+ return None
36
+
37
+
38
+ def _process_text_key(text: str, key_map: dict[str, str]) -> Optional[tuple[str, str]]:
39
+ """Helper to process a single text-based metadata key."""
40
+ # Strip markdown bolding for resilient key matching
41
+ clean_text = text.replace("**", "")
42
+ for key_text, param_key in key_map.items():
43
+ if f"{key_text}:" in clean_text:
44
+ # Use clean text for finding the colon and splitting
45
+ parts = clean_text.split(":", 1)
46
+ return param_key, parts[1].strip()
47
+ return None
48
+
49
+
50
+ def parse_plan_metadata(metadata_list_node: "MdList") -> dict[str, str]:
51
+ """Parses top-level plan metadata list."""
52
+ metadata = {}
53
+ list_children = getattr(metadata_list_node, "children", [])
54
+ for item in list_children if list_children is not None else []:
55
+ text = get_child_text(item).strip()
56
+ if ":" in text:
57
+ key, value = text.split(":", 1)
58
+ metadata[key.strip("* ")] = value.strip()
59
+ return metadata
60
+
61
+
62
+ def parse_action_metadata(
63
+ metadata_list: "MdList",
64
+ link_key_map: Optional[dict[str, str]] = None,
65
+ text_key_map: Optional[dict[str, str]] = None,
66
+ ) -> tuple[Optional[str], dict[str, Any]]:
67
+ """Parses metadata from a Markdown list."""
68
+ from mistletoe.block_token import ListItem as MdListItem
69
+
70
+ params: dict[str, Any] = {}
71
+ description: Optional[str] = None
72
+ if not metadata_list.children:
73
+ return description, params
74
+
75
+ _link_key_map = link_key_map or {}
76
+ _text_key_map = text_key_map or {}
77
+
78
+ for item in metadata_list.children:
79
+ if not isinstance(item, MdListItem):
80
+ continue
81
+ text = get_child_text(item)
82
+
83
+ if "Description:" in text:
84
+ description = text.split(":", 1)[1].strip()
85
+ continue
86
+
87
+ link_result = _process_link_key(item, text, _link_key_map)
88
+ if link_result:
89
+ params[link_result[0]] = link_result[1]
90
+ continue
91
+
92
+ text_result = _process_text_key(text, _text_key_map)
93
+ if text_result:
94
+ params[text_result[0]] = text_result[1]
95
+
96
+ return description, params
97
+
98
+
99
+ def parse_env_from_metadata(metadata_list: "MdList") -> Optional[dict[str, str]]:
100
+ """Parses environment variables from a nested metadata list."""
101
+ from mistletoe.block_token import List as MdList
102
+
103
+ if not metadata_list.children:
104
+ return None
105
+
106
+ env_dict: dict[str, str] = {}
107
+ for item in metadata_list.children:
108
+ if "env:" in get_child_text(item).strip():
109
+ env_list = find_node_in_tree(item, MdList)
110
+ if env_list and env_list.children:
111
+ for env_item in env_list.children:
112
+ env_text = get_child_text(env_item).strip()
113
+ if ":" in env_text:
114
+ key, value = [p.strip() for p in env_text.split(":", 1)]
115
+ env_dict[key] = value.strip('"')
116
+ return env_dict if env_dict else None
117
+
118
+
119
+ def _find_all_links(node) -> "List[Link]":
120
+ """Recursively finds all Link tokens in a node tree."""
121
+ from mistletoe.span_token import Link
122
+
123
+ links = []
124
+ if isinstance(node, Link):
125
+ links.append(node)
126
+ if hasattr(node, "children") and node.children:
127
+ for child in node.children:
128
+ links.extend(_find_all_links(child))
129
+ return links
130
+
131
+
132
+ def parse_handoff_resources_from_list(metadata_list: "MdList") -> "List[str] | None":
133
+ """
134
+ Parses handoff resources from a metadata list item.
135
+ Supports both nested lists and simple multi-line links within the item.
136
+ Recognizes both legacy "Handoff Resources:" and new "Reference Files:".
137
+ """
138
+ resources = []
139
+ if not (metadata_list and metadata_list.children):
140
+ return None
141
+
142
+ for item in metadata_list.children:
143
+ item_text = get_child_text(item).strip()
144
+ if item_text.startswith("Handoff Resources:") or item_text.startswith(
145
+ "Reference Files:"
146
+ ):
147
+ # Find all links within this list item's entire sub-tree
148
+ links = _find_all_links(item)
149
+ for link in links:
150
+ target = normalize_link_target(link.target)
151
+ resources.append(normalize_path(target))
152
+
153
+ return resources if resources else None
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+ from typing import Any, List, TYPE_CHECKING
3
+ from teddy_executor.core.domain.models.plan import Plan
4
+ from teddy_executor.core.ports.inbound.plan_parser import InvalidPlanError
5
+ from teddy_executor.core.services.parser_infrastructure import (
6
+ get_child_text,
7
+ H2_LEVEL,
8
+ H3_LEVEL,
9
+ )
10
+
11
+ if TYPE_CHECKING:
12
+ from mistletoe.block_token import Document
13
+ from teddy_executor.core.utils.markdown import get_fence_for_content
14
+
15
+ # Maximum length for AST node previews in error reports
16
+ MAX_PREVIEW_LENGTH = 60
17
+
18
+
19
+ def _get_node_preview(node: Any) -> str:
20
+ """Extracts a truncated first-line preview of a node's content."""
21
+ content = get_child_text(node).strip() if node else ""
22
+ first_line = content.splitlines()[0] if content else ""
23
+ if len(first_line) > MAX_PREVIEW_LENGTH:
24
+ return first_line[:MAX_PREVIEW_LENGTH].strip() + "..."
25
+ return first_line
26
+
27
+
28
+ def _get_failure_cutoff_idx(
29
+ children: List[Any], mismatch_idx: int, offending_ids: set[int]
30
+ ) -> float:
31
+ """Determines the index after which nodes should be marked as unvalidated."""
32
+ failure_cutoff_idx = mismatch_idx if mismatch_idx != -1 else float("inf")
33
+ if offending_ids:
34
+ for i, node in enumerate(children):
35
+ if id(node) in offending_ids:
36
+ failure_cutoff_idx = min(failure_cutoff_idx, i)
37
+ break
38
+ return failure_cutoff_idx
39
+
40
+
41
+ def _format_expected_structure() -> str:
42
+ """Returns the formatted 'Expected Document Structure' section."""
43
+ lines = [
44
+ "[000] Heading (Level 1)",
45
+ "[001] List (Metadata)",
46
+ "[002] Heading (Level 2: Rationale)",
47
+ "[003] Code Block (Rationale Content)",
48
+ "[004] Heading (Level 2: Action Plan) —or— Heading (Level 2: Message)",
49
+ "[005...] (If Action Plan) Heading (Level 3: Action Type) and (Action-specific AST nodes)",
50
+ ]
51
+ content = "\n".join(lines) + "\n"
52
+ fence = get_fence_for_content(content)
53
+ return f"### Expected Response Structure (MRP) \n{fence}text\n{content}{fence}\n"
54
+
55
+
56
+ def format_node_name(node: Any) -> str:
57
+ """Formats the type name of a node with relevant metadata and content preview."""
58
+ from mistletoe.block_token import Heading, CodeFence, BlockCode
59
+
60
+ if node is None:
61
+ return "EOF"
62
+ name = type(node).__name__
63
+ if name in ("CodeFence", "BlockCode"):
64
+ name = "Code Block"
65
+
66
+ if isinstance(node, Heading):
67
+ name += f" (Level {node.level})"
68
+ elif isinstance(node, CodeFence):
69
+ delimiter = getattr(node, "delimiter", "```")
70
+ count = len(delimiter)
71
+ label = "tildes" if delimiter.startswith("~") else "backticks"
72
+ name += f" ({count} {label})"
73
+ elif isinstance(node, BlockCode):
74
+ name += " (indented)"
75
+
76
+ preview = _get_node_preview(node)
77
+ if preview:
78
+ name += f': "{preview}"'
79
+ return name
80
+
81
+
82
+ def _render_ast_view(
83
+ doc: Document,
84
+ error_ids: set[int],
85
+ error_map: dict[int, str],
86
+ cutoff_idx: float = float("inf"),
87
+ ) -> str:
88
+ """
89
+ Core AST rendering logic with logical indentation. Returns raw text.
90
+ """
91
+ from mistletoe.block_token import Heading
92
+
93
+ indent_level = 0
94
+ lines = []
95
+
96
+ for i, node in enumerate(doc.children or []):
97
+ node_id = id(node)
98
+
99
+ # Logical children (indented siblings) of an action (H3)
100
+ if isinstance(node, Heading):
101
+ if node.level <= H2_LEVEL:
102
+ indent_level = 0
103
+ elif node.level == H3_LEVEL:
104
+ indent_level = 1
105
+
106
+ is_error = node_id in error_ids
107
+ is_unvalidated = i > cutoff_idx and not is_error
108
+
109
+ if is_error:
110
+ status = f"[✗] [{i:03d}]"
111
+ elif is_unvalidated:
112
+ status = f"[ ] [{i:03d}]"
113
+ else:
114
+ status = f"[✓] [{i:03d}]"
115
+
116
+ # Truncate error message for AST trace to keep it clean
117
+ raw_reason = error_map.get(node_id, "")
118
+ concise_reason = raw_reason.splitlines()[0] if raw_reason else ""
119
+ reason = f" (Error: {concise_reason})" if is_error else ""
120
+
121
+ # Heading 1-3 are never indented. Their contents/sub-headings are.
122
+ is_top_heading = isinstance(node, Heading) and node.level <= H3_LEVEL
123
+ display_indent = " " * (0 if is_top_heading else indent_level)
124
+ n_name = format_node_name(node)
125
+ lines.append(f"{display_indent}{status} {n_name}{reason}")
126
+
127
+ return "\n".join(lines) + "\n"
128
+
129
+
130
+ def format_hybrid_ast_view(
131
+ doc: Document,
132
+ errors: List[Any], # List[ValidationError]
133
+ ) -> str:
134
+ """
135
+ Generates a hybrid AST visualization: surgical highlighting and logical indentation.
136
+ """
137
+ error_ids = {id(e.offending_node) for e in errors if e.offending_node}
138
+ error_map = {id(e.offending_node): e.message for e in errors if e.offending_node}
139
+ ast_text = _render_ast_view(doc, error_ids, error_map)
140
+
141
+ fence = get_fence_for_content(ast_text)
142
+ return f"### Plan AST with Highlighted Failures\n{fence}text\n{ast_text}{fence}\n"
143
+
144
+
145
+ def get_action_type_from_node(plan: Plan, offending_node: Any) -> str:
146
+ """Walks back from a node to find its parent action type."""
147
+ from mistletoe.block_token import Heading
148
+
149
+ if not offending_node:
150
+ return "Unknown"
151
+
152
+ if not plan.source_doc:
153
+ return get_child_text(offending_node).strip().replace("`", "")
154
+
155
+ nodes = list(plan.source_doc.children or [])
156
+ target_idx = -1
157
+ for i, node in enumerate(nodes):
158
+ if id(node) == id(offending_node):
159
+ target_idx = i
160
+ break
161
+
162
+ if target_idx != -1:
163
+ for i in range(target_idx, -1, -1):
164
+ if isinstance(nodes[i], Heading) and nodes[i].level == H3_LEVEL:
165
+ return get_child_text(nodes[i]).strip().replace("`", "")
166
+
167
+ return get_child_text(offending_node).strip().replace("`", "")
168
+
169
+
170
+ def validate_plan_structure(doc: Document, start_idx: int):
171
+ """Validates the structural schema of the top-level nodes."""
172
+ from mistletoe.block_token import (
173
+ BlockCode,
174
+ CodeFence,
175
+ Heading,
176
+ List as MdList,
177
+ )
178
+
179
+ doc_children = doc.children if doc.children is not None else []
180
+ children = list(doc_children)
181
+ expected_schema = [
182
+ (
183
+ "a List (Metadata) immediately following the title",
184
+ lambda n: isinstance(n, MdList),
185
+ ),
186
+ (
187
+ "a Level 2 Heading containing 'Rationale'",
188
+ lambda n: (
189
+ isinstance(n, Heading)
190
+ and n.level == H2_LEVEL
191
+ and "Rationale" in get_child_text(n)
192
+ ),
193
+ ),
194
+ (
195
+ "a CodeFence or BlockCode containing the rationale content",
196
+ lambda n: isinstance(n, (CodeFence, BlockCode)),
197
+ ),
198
+ (
199
+ "a Level 2 Heading containing 'Action Plan' or 'Message'",
200
+ lambda n: (
201
+ isinstance(n, Heading)
202
+ and n.level == H2_LEVEL
203
+ and any(
204
+ term in get_child_text(n) for term in ["Action Plan", "Message"]
205
+ )
206
+ ),
207
+ ),
208
+ ]
209
+
210
+ offending_nodes = []
211
+ primary_mismatch = None
212
+
213
+ for i, (expected_desc, predicate) in enumerate(expected_schema):
214
+ target_idx = start_idx + 1 + i
215
+ actual_node = children[target_idx] if target_idx < len(children) else None
216
+
217
+ if not actual_node or not predicate(actual_node):
218
+ offending_nodes.append(actual_node)
219
+ if primary_mismatch is None:
220
+ primary_mismatch = (expected_desc, target_idx, actual_node)
221
+
222
+ if offending_nodes and primary_mismatch is not None:
223
+ expected_desc, target_idx, actual_node = primary_mismatch
224
+ error_msg = format_structural_mismatch_msg(
225
+ doc, expected_desc, target_idx, offending_nodes
226
+ )
227
+ raise InvalidPlanError(error_msg, offending_nodes=offending_nodes)
228
+
229
+
230
+ def format_structural_mismatch_msg(
231
+ doc: Document,
232
+ expected: str,
233
+ mismatch_idx: int,
234
+ offending_nodes: List[Any],
235
+ ) -> str:
236
+ """Constructs a detailed structural validation error message."""
237
+ primary_node = offending_nodes[0] if offending_nodes else None
238
+ actual_name = format_node_name(primary_node)
239
+
240
+ is_direct = expected.startswith(("a ", "an ", "## ", "### ", "Heading", "List"))
241
+ error_header = f"Expected {expected}" if is_direct else expected
242
+
243
+ # If mismatch_idx is -1, this is a content error, not a structural schema mismatch
244
+ if mismatch_idx == -1:
245
+ msg = f"Plan content is invalid: {expected}.\n\n"
246
+ else:
247
+ msg = f"Plan structure is invalid. {error_header}, but found {actual_name}.\n\n"
248
+
249
+ msg += _format_expected_structure()
250
+ msg += "\n### Actual Response Structure\n"
251
+
252
+ children = list(doc.children) if doc.children else []
253
+ offending_ids = {id(node) for node in offending_nodes if node is not None}
254
+ error_map = {id_node: error_header for id_node in offending_ids}
255
+ if mismatch_idx != -1 and mismatch_idx < len(children):
256
+ error_ids_set = offending_ids | {id(children[mismatch_idx])}
257
+ error_map[id(children[mismatch_idx])] = error_header
258
+ else:
259
+ error_ids_set = offending_ids
260
+
261
+ cutoff = _get_failure_cutoff_idx(children, mismatch_idx, offending_ids)
262
+ ast_text = _render_ast_view(doc, error_ids_set, error_map, cutoff)
263
+ fence = get_fence_for_content(ast_text)
264
+ msg += f"{fence}text\n{ast_text}{fence}\n"
265
+
266
+ msg += "\n**Hint:** Parsing often fails due to improper Code Block Formatting.\n"
267
+ return msg
@@ -0,0 +1,82 @@
1
+ """
2
+ This module contains the implementation of the PlanValidator service.
3
+ """
4
+
5
+ from typing import Dict, List, Optional, Sequence
6
+
7
+ from teddy_executor.core.domain.models.plan import Plan
8
+ from teddy_executor.core.ports.inbound.plan_validator import IPlanValidator
9
+ from teddy_executor.core.services.validation_rules.helpers import (
10
+ IActionValidator,
11
+ ValidationError,
12
+ )
13
+
14
+
15
+ from teddy_executor.core.ports.outbound import IFileSystemManager
16
+
17
+
18
+ class PlanValidator(IPlanValidator):
19
+ """
20
+ Implements IPlanValidator using a strategy pattern to run pre-flight checks.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ file_system_manager: IFileSystemManager,
26
+ validators: Optional[List[IActionValidator]] = None,
27
+ ):
28
+ self._file_system_manager = file_system_manager
29
+ self._validators = validators or []
30
+
31
+ def validate(
32
+ self, plan: Plan, context_paths: Optional[Dict[str, Sequence[str]]] = None
33
+ ) -> List[ValidationError]:
34
+ """
35
+ Validates a plan by dispatching each action to a specific validation method.
36
+
37
+ Returns:
38
+ A list of validation error objects. An empty list signifies success.
39
+ """
40
+ errors: List[ValidationError] = []
41
+ for action in plan.actions:
42
+ action_type_lower = action.type.lower()
43
+ action_errors: Optional[List[ValidationError]] = None
44
+
45
+ # Dispatch to injected action-specific validators
46
+ handled_by_injected = False
47
+ for validator in self._validators:
48
+ if validator.can_validate(action_type_lower):
49
+ action_errors = validator.validate(
50
+ action, context_paths=context_paths
51
+ )
52
+ handled_by_injected = True
53
+ break
54
+
55
+ if handled_by_injected:
56
+ if action_errors:
57
+ errors.extend(action_errors)
58
+ elif action_type_lower in [
59
+ "research",
60
+ "prompt",
61
+ "invoke",
62
+ "return",
63
+ ]:
64
+ # These actions have no validation rules currently
65
+ pass
66
+ elif action_type_lower == "message":
67
+ # MESSAGE under ## Action Plan is invalid; mutual exclusivity required
68
+ errors.append(
69
+ ValidationError(
70
+ message="MESSAGE action is not allowed under '## Action Plan'. "
71
+ "Use '## Message' section instead. Mutual exclusivity is required.",
72
+ file_path=None,
73
+ )
74
+ )
75
+ else:
76
+ errors.append(
77
+ ValidationError(
78
+ message=f"Unknown action type: {action.type}",
79
+ file_path=action.params.get("path"),
80
+ )
81
+ )
82
+ return errors