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