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,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional, TYPE_CHECKING
|
|
4
|
+
from teddy_executor.core.domain.models.change_set import ChangeSet
|
|
5
|
+
from teddy_executor.core.services.validation_rules.helpers import (
|
|
6
|
+
resolve_similarity_threshold,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from teddy_executor.core.domain.models.plan import ActionData
|
|
11
|
+
from teddy_executor.core.ports.outbound import (
|
|
12
|
+
IFileSystemManager,
|
|
13
|
+
IConfigService,
|
|
14
|
+
)
|
|
15
|
+
from teddy_executor.core.ports.inbound.edit_simulator import IEditSimulator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ActionChangeSetBuilder:
|
|
19
|
+
"""
|
|
20
|
+
Shared service to build ChangeSet objects for CREATE and EDIT actions.
|
|
21
|
+
Ensures DRY logic between executors and reviewers.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def format_action_prompt(action: "ActionData") -> str:
|
|
26
|
+
"""Generates a detailed prompt string for an action."""
|
|
27
|
+
prompt_parts = [
|
|
28
|
+
"---",
|
|
29
|
+
f"Action: {action.type}",
|
|
30
|
+
f"Description: {action.description}" if action.description else "",
|
|
31
|
+
]
|
|
32
|
+
display_map = {"handoff_resources": "Reference Files"}
|
|
33
|
+
param_str = "\n".join(
|
|
34
|
+
f" - {display_map.get(k, k)}: {v}"
|
|
35
|
+
for k, v in action.params.items()
|
|
36
|
+
if k.lower() not in ("edits", "content")
|
|
37
|
+
)
|
|
38
|
+
if param_str:
|
|
39
|
+
prompt_parts.extend(["Parameters:", param_str])
|
|
40
|
+
prompt_parts.append("---")
|
|
41
|
+
return "\n".join(filter(None, prompt_parts))
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
file_system_manager: IFileSystemManager,
|
|
46
|
+
config_service: IConfigService,
|
|
47
|
+
edit_simulator: IEditSimulator,
|
|
48
|
+
):
|
|
49
|
+
self._file_system_manager = file_system_manager
|
|
50
|
+
self._config_service = config_service
|
|
51
|
+
self._edit_simulator = edit_simulator
|
|
52
|
+
|
|
53
|
+
def create_change_set(self, action: "ActionData") -> Optional[ChangeSet]:
|
|
54
|
+
"""Creates a ChangeSet for file operations."""
|
|
55
|
+
action_type = action.type.upper()
|
|
56
|
+
if action_type not in ("CREATE", "EDIT"):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
path_str = action.params.get("path") or action.params.get("File Path")
|
|
60
|
+
if not path_str:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
before_content = (
|
|
64
|
+
self._file_system_manager.read_raw_file(path_str)
|
|
65
|
+
if self._file_system_manager.path_exists(path_str)
|
|
66
|
+
else ""
|
|
67
|
+
)
|
|
68
|
+
path = Path(path_str)
|
|
69
|
+
|
|
70
|
+
if action_type == "EDIT":
|
|
71
|
+
threshold = resolve_similarity_threshold(
|
|
72
|
+
self._config_service, action.params
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
match_all = action.params.get("match_all", False)
|
|
76
|
+
after_content, _ = self._edit_simulator.simulate_edits(
|
|
77
|
+
before_content,
|
|
78
|
+
action.params.get("edits", []),
|
|
79
|
+
threshold=threshold,
|
|
80
|
+
match_all=match_all,
|
|
81
|
+
)
|
|
82
|
+
else: # CREATE
|
|
83
|
+
after_content = action.params.get("content", "")
|
|
84
|
+
|
|
85
|
+
return ChangeSet(
|
|
86
|
+
path=path,
|
|
87
|
+
before_content=before_content,
|
|
88
|
+
after_content=after_content,
|
|
89
|
+
action_type=action_type,
|
|
90
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from teddy_executor.core.domain.models import (
|
|
3
|
+
ActionLog,
|
|
4
|
+
ActionStatus,
|
|
5
|
+
ChangeSet,
|
|
6
|
+
)
|
|
7
|
+
from teddy_executor.core.utils.diff import generate_unified_diff
|
|
8
|
+
|
|
9
|
+
# Constant for perfect match detection to avoid floating point noise
|
|
10
|
+
PERFECT_MATCH_THRESHOLD = 0.99999
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ActionDiffManager:
|
|
14
|
+
"""Helper class to manage diff injection and suppression logic."""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def inject_diff(
|
|
18
|
+
action, action_log: ActionLog, change_set: Optional[ChangeSet]
|
|
19
|
+
) -> ActionLog:
|
|
20
|
+
"""Injects or suppresses diffs in the ActionLog based on outcome."""
|
|
21
|
+
if ActionDiffManager._should_suppress(action, action_log, change_set):
|
|
22
|
+
return ActionDiffManager._clean_log(action_log)
|
|
23
|
+
|
|
24
|
+
diff = ActionDiffManager._generate_diff(action, change_set)
|
|
25
|
+
if not diff:
|
|
26
|
+
return action_log
|
|
27
|
+
|
|
28
|
+
details = action_log.details
|
|
29
|
+
new_details = {"diff": diff}
|
|
30
|
+
if isinstance(details, dict):
|
|
31
|
+
new_details.update(details)
|
|
32
|
+
|
|
33
|
+
return ActionLog(
|
|
34
|
+
status=action_log.status,
|
|
35
|
+
action_type=action_log.action_type,
|
|
36
|
+
params=action_log.params,
|
|
37
|
+
details=new_details,
|
|
38
|
+
modified=action_log.modified,
|
|
39
|
+
modified_fields=action_log.modified_fields,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _should_suppress(
|
|
44
|
+
action, action_log: ActionLog, change_set: Optional[ChangeSet]
|
|
45
|
+
) -> bool:
|
|
46
|
+
"""Determines if a diff should be suppressed."""
|
|
47
|
+
if action_log.status != ActionStatus.SUCCESS:
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
is_create_ovr = (
|
|
51
|
+
action.type.upper() == "CREATE"
|
|
52
|
+
and (action.params.get("overwrite") or action.params.get("Overwrite"))
|
|
53
|
+
and change_set
|
|
54
|
+
and change_set.before_content
|
|
55
|
+
)
|
|
56
|
+
is_edit = action.type.upper() == "EDIT"
|
|
57
|
+
|
|
58
|
+
if not (is_create_ovr or is_edit):
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
if is_edit:
|
|
62
|
+
details = action_log.details
|
|
63
|
+
if not isinstance(details, dict):
|
|
64
|
+
return True
|
|
65
|
+
scores = details.get("similarity_scores") or [
|
|
66
|
+
details.get("similarity_score", 1.0)
|
|
67
|
+
]
|
|
68
|
+
if all(s >= PERFECT_MATCH_THRESHOLD for s in scores):
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
return not (
|
|
72
|
+
change_set
|
|
73
|
+
and isinstance(change_set.before_content, str)
|
|
74
|
+
and isinstance(change_set.after_content, str)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _clean_log(action_log: ActionLog) -> ActionLog:
|
|
79
|
+
"""Removes pre-injected diffs from the log."""
|
|
80
|
+
if isinstance(action_log.details, dict) and "diff" in action_log.details:
|
|
81
|
+
new_details = action_log.details.copy()
|
|
82
|
+
new_details.pop("diff")
|
|
83
|
+
return ActionLog(
|
|
84
|
+
status=action_log.status,
|
|
85
|
+
action_type=action_log.action_type,
|
|
86
|
+
params=action_log.params,
|
|
87
|
+
details=new_details,
|
|
88
|
+
modified=action_log.modified,
|
|
89
|
+
modified_fields=action_log.modified_fields,
|
|
90
|
+
)
|
|
91
|
+
return action_log
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _generate_diff(action, change_set: Optional[ChangeSet]) -> Optional[str]:
|
|
95
|
+
"""Generates the actual diff string."""
|
|
96
|
+
if not change_set:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
from teddy_executor.core.utils.diff import generate_character_diff
|
|
100
|
+
|
|
101
|
+
if action.type.upper() == "EDIT":
|
|
102
|
+
return generate_character_diff(
|
|
103
|
+
change_set.before_content, change_set.after_content
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return generate_unified_diff(
|
|
107
|
+
change_set.before_content,
|
|
108
|
+
change_set.after_content,
|
|
109
|
+
change_set.path.name,
|
|
110
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import is_dataclass, asdict
|
|
3
|
+
from typing import Protocol, Any, Optional
|
|
4
|
+
|
|
5
|
+
from teddy_executor.core.domain.models import (
|
|
6
|
+
ActionData,
|
|
7
|
+
ActionLog,
|
|
8
|
+
ActionStatus,
|
|
9
|
+
)
|
|
10
|
+
from teddy_executor.core.domain.models.shell_output import ShellOutput
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# --- Protocols for Dependencies ---
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IAction(Protocol):
|
|
17
|
+
"""Defines the interface for any action handler."""
|
|
18
|
+
|
|
19
|
+
def execute(self, **_kwargs) -> Any: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IActionFactory(Protocol):
|
|
23
|
+
"""Defines the interface for the factory that creates actions."""
|
|
24
|
+
|
|
25
|
+
def create_action(
|
|
26
|
+
self, action_type: str, params: Optional[dict] = None
|
|
27
|
+
) -> IAction: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# --- Service Implementation ---
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ActionDispatcher:
|
|
36
|
+
"""
|
|
37
|
+
A service that dispatches a single action to its handler and logs the result.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, action_factory: IActionFactory):
|
|
41
|
+
self._action_factory = action_factory
|
|
42
|
+
|
|
43
|
+
def _prepare_execution_params(self, action_data: ActionData) -> dict[str, Any]:
|
|
44
|
+
"""Handles parameter validation, translation, and cleaning."""
|
|
45
|
+
params = action_data.params.copy()
|
|
46
|
+
if not isinstance(params, dict):
|
|
47
|
+
if action_data.type == "execute":
|
|
48
|
+
return {"command": params}
|
|
49
|
+
raise TypeError(
|
|
50
|
+
f"Action type '{action_data.type}' requires dictionary parameters, but received type '{type(params).__name__}'."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
param_map = {
|
|
54
|
+
"create_file": {"file_path": "path"},
|
|
55
|
+
"edit": {"file_path": "path"},
|
|
56
|
+
"read": {"source": "path", "resource": "path"},
|
|
57
|
+
}
|
|
58
|
+
type_key = action_data.type.lower()
|
|
59
|
+
if type_key == "create":
|
|
60
|
+
type_key = "create_file"
|
|
61
|
+
|
|
62
|
+
mapping = param_map.get(type_key, {})
|
|
63
|
+
for old_key, new_key in mapping.items():
|
|
64
|
+
if old_key in params:
|
|
65
|
+
params[new_key] = params.pop(old_key)
|
|
66
|
+
|
|
67
|
+
params.pop("expected_outcome", None)
|
|
68
|
+
params.pop("Description", None)
|
|
69
|
+
|
|
70
|
+
# Filter out internal metadata keys (convention: starts with 'metadata_')
|
|
71
|
+
clean_params = {
|
|
72
|
+
k: v for k, v in params.items() if not k.startswith("metadata_")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return clean_params
|
|
76
|
+
|
|
77
|
+
def _execute_and_process_result(
|
|
78
|
+
self, action_type: str, execution_params: dict[str, Any]
|
|
79
|
+
) -> tuple[Any, ActionStatus]:
|
|
80
|
+
"""Executes the action, normalizes the result, and determines status."""
|
|
81
|
+
action_handler = self._action_factory.create_action(
|
|
82
|
+
action_type, execution_params
|
|
83
|
+
)
|
|
84
|
+
result = action_handler.execute(**execution_params)
|
|
85
|
+
|
|
86
|
+
if is_dataclass(result) and not isinstance(result, type):
|
|
87
|
+
result = asdict(result)
|
|
88
|
+
|
|
89
|
+
if isinstance(result, str):
|
|
90
|
+
if action_type.lower() == "read":
|
|
91
|
+
result = {"content": result}
|
|
92
|
+
elif action_type.lower() == "edit" and isinstance(result, list):
|
|
93
|
+
# edit_file now returns a list of scores
|
|
94
|
+
result = {"similarity_scores": result}
|
|
95
|
+
|
|
96
|
+
status = ActionStatus.SUCCESS
|
|
97
|
+
if isinstance(result, dict) and "return_code" in result:
|
|
98
|
+
shell_output: ShellOutput = result # type: ignore
|
|
99
|
+
if shell_output["return_code"] != 0:
|
|
100
|
+
status = ActionStatus.FAILURE
|
|
101
|
+
return result, status
|
|
102
|
+
|
|
103
|
+
def dispatch_and_execute(
|
|
104
|
+
self, action_data: ActionData, agent_name: Optional[str] = None
|
|
105
|
+
) -> ActionLog:
|
|
106
|
+
"""
|
|
107
|
+
Takes an ActionData object, finds the corresponding action handler
|
|
108
|
+
via the factory, executes it, and returns the result as an ActionLog.
|
|
109
|
+
"""
|
|
110
|
+
action_name = action_data.type.upper()
|
|
111
|
+
log_desc = f" - {action_data.description}" if action_data.description else ""
|
|
112
|
+
is_message_action = action_data.type.upper() == "MESSAGE"
|
|
113
|
+
if not is_message_action:
|
|
114
|
+
logger.info(f"{action_name}{log_desc}")
|
|
115
|
+
|
|
116
|
+
log_params = action_data.params.copy()
|
|
117
|
+
if action_data.description and "Description" not in log_params:
|
|
118
|
+
log_params["Description"] = action_data.description
|
|
119
|
+
|
|
120
|
+
log_data: dict = {
|
|
121
|
+
"action_type": action_data.type,
|
|
122
|
+
"params": log_params,
|
|
123
|
+
"modified": action_data.modified,
|
|
124
|
+
"modified_fields": action_data.modified_fields,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
execution_params = self._prepare_execution_params(action_data)
|
|
129
|
+
details, status = self._execute_and_process_result(
|
|
130
|
+
action_data.type, execution_params
|
|
131
|
+
)
|
|
132
|
+
log_data["details"] = details
|
|
133
|
+
log_data["status"] = status
|
|
134
|
+
if not is_message_action:
|
|
135
|
+
logger.info(status.value.upper())
|
|
136
|
+
except Exception as e:
|
|
137
|
+
log_data["status"] = ActionStatus.FAILURE
|
|
138
|
+
log_data["details"] = str(e)
|
|
139
|
+
if not is_message_action:
|
|
140
|
+
logger.info("FAILURE")
|
|
141
|
+
|
|
142
|
+
return ActionLog(**log_data)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from teddy_executor.core.domain.models import (
|
|
6
|
+
ActionLog,
|
|
7
|
+
ActionStatus,
|
|
8
|
+
ChangeSet,
|
|
9
|
+
)
|
|
10
|
+
from teddy_executor.core.ports.inbound.edit_simulator import IEditSimulator
|
|
11
|
+
from teddy_executor.core.services.action_diff_manager import ActionDiffManager
|
|
12
|
+
from teddy_executor.core.services.action_changeset_builder import ActionChangeSetBuilder
|
|
13
|
+
from teddy_executor.core.ports.outbound import (
|
|
14
|
+
IConfigService,
|
|
15
|
+
IFileSystemManager,
|
|
16
|
+
IUserInteractor,
|
|
17
|
+
)
|
|
18
|
+
from teddy_executor.core.services.action_dispatcher import ActionDispatcher
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Constant for perfect match detection to avoid floating point noise
|
|
23
|
+
PERFECT_MATCH_THRESHOLD = 0.99999
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ActionExecutor:
|
|
27
|
+
"""
|
|
28
|
+
Handles the execution logic for a single action, including isolation,
|
|
29
|
+
interception, and user confirmation.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
action_dispatcher: ActionDispatcher,
|
|
35
|
+
user_interactor: IUserInteractor,
|
|
36
|
+
file_system_manager: IFileSystemManager,
|
|
37
|
+
edit_simulator: IEditSimulator,
|
|
38
|
+
config_service: IConfigService,
|
|
39
|
+
):
|
|
40
|
+
self._action_dispatcher = action_dispatcher
|
|
41
|
+
self._user_interactor = user_interactor
|
|
42
|
+
self._file_system_manager = file_system_manager
|
|
43
|
+
self._changeset_builder = ActionChangeSetBuilder(
|
|
44
|
+
file_system_manager, config_service, edit_simulator
|
|
45
|
+
)
|
|
46
|
+
self._file_hashes: dict[str, str] = {}
|
|
47
|
+
|
|
48
|
+
def _create_intercepted_log(
|
|
49
|
+
self, action, status: ActionStatus, details: str
|
|
50
|
+
) -> ActionLog:
|
|
51
|
+
"""Creates an ActionLog for an intercepted action (skip or handoff)."""
|
|
52
|
+
log_params = action.params.copy()
|
|
53
|
+
if action.description:
|
|
54
|
+
log_params["Description"] = action.description
|
|
55
|
+
return ActionLog(
|
|
56
|
+
status=status,
|
|
57
|
+
action_type=action.type,
|
|
58
|
+
params=log_params,
|
|
59
|
+
details=details,
|
|
60
|
+
modified=action.modified,
|
|
61
|
+
modified_fields=action.modified_fields,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _compute_file_hash(self, path: str) -> str:
|
|
65
|
+
"""Computes SHA256 hash of file content for mid-execution consistency checks."""
|
|
66
|
+
content = self._file_system_manager.read_file(path)
|
|
67
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
68
|
+
|
|
69
|
+
def _handle_skipped_action(self, action, reason: str) -> ActionLog:
|
|
70
|
+
"""Creates an ActionLog for a skipped action."""
|
|
71
|
+
return self._create_intercepted_log(action, ActionStatus.SKIPPED, reason)
|
|
72
|
+
|
|
73
|
+
def _enrich_failed_log(self, action, action_log: ActionLog) -> ActionLog:
|
|
74
|
+
"""If a CREATE or EDIT action failed, enrich the log with file content."""
|
|
75
|
+
if action.type not in ("CREATE", "EDIT"):
|
|
76
|
+
return action_log
|
|
77
|
+
|
|
78
|
+
path = action.params.get("path") or action.params.get("File Path")
|
|
79
|
+
if not path:
|
|
80
|
+
return action_log
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
content = self._file_system_manager.read_file(path)
|
|
84
|
+
new_details = (
|
|
85
|
+
action_log.details
|
|
86
|
+
if isinstance(action_log.details, dict)
|
|
87
|
+
else {"original_details": action_log.details}
|
|
88
|
+
)
|
|
89
|
+
new_details["content"] = content
|
|
90
|
+
return ActionLog(
|
|
91
|
+
status=action_log.status,
|
|
92
|
+
action_type=action_log.action_type,
|
|
93
|
+
params=action_log.params,
|
|
94
|
+
details=new_details,
|
|
95
|
+
modified=action_log.modified,
|
|
96
|
+
modified_fields=action_log.modified_fields,
|
|
97
|
+
)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.debug(
|
|
100
|
+
"Failed to enrich failed log with file content for %s: %s", path, e
|
|
101
|
+
)
|
|
102
|
+
return action_log
|
|
103
|
+
|
|
104
|
+
def _create_change_set(self, action) -> ChangeSet | None:
|
|
105
|
+
"""Creates a ChangeSet for file operations."""
|
|
106
|
+
return self._changeset_builder.create_change_set(action)
|
|
107
|
+
|
|
108
|
+
def _get_interactive_confirmation(self, action) -> tuple[bool, str]:
|
|
109
|
+
"""Prompts the user for confirmation of an action."""
|
|
110
|
+
prompt = ActionChangeSetBuilder.format_action_prompt(action)
|
|
111
|
+
|
|
112
|
+
change_set = self._create_change_set(action)
|
|
113
|
+
|
|
114
|
+
return self._user_interactor.confirm_action(
|
|
115
|
+
action=action, action_prompt=prompt, change_set=change_set
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def confirm_and_dispatch( # noqa: PLR0913, C901
|
|
119
|
+
self,
|
|
120
|
+
action,
|
|
121
|
+
interactive: bool,
|
|
122
|
+
total_actions: int,
|
|
123
|
+
agent_name: Optional[str] = None,
|
|
124
|
+
is_session: bool = False,
|
|
125
|
+
skip_isolation: bool = False,
|
|
126
|
+
) -> tuple[ActionLog, str]:
|
|
127
|
+
"""Handles user confirmation and dispatches a single action."""
|
|
128
|
+
# Capture the change set BEFORE execution for diff reporting
|
|
129
|
+
change_set = self._create_change_set(action)
|
|
130
|
+
|
|
131
|
+
should_dispatch, reason = True, ""
|
|
132
|
+
# Communication actions (MESSAGE) bypass the interactive confirmation
|
|
133
|
+
# to ensure a fluid conversational flow.
|
|
134
|
+
is_communication = action.type.upper() == "MESSAGE"
|
|
135
|
+
|
|
136
|
+
if interactive and not is_communication:
|
|
137
|
+
should_dispatch, reason = self._get_interactive_confirmation(action)
|
|
138
|
+
|
|
139
|
+
if not should_dispatch:
|
|
140
|
+
return (
|
|
141
|
+
self._handle_skipped_action(
|
|
142
|
+
action, f"User skipped this action. Reason: {reason}"
|
|
143
|
+
),
|
|
144
|
+
"",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Mid-execution consistency: pre-check hash for EDIT actions
|
|
148
|
+
path = action.params.get("path")
|
|
149
|
+
if action.type.upper() == "EDIT" and path and path in self._file_hashes:
|
|
150
|
+
try:
|
|
151
|
+
current_hash = self._compute_file_hash(path)
|
|
152
|
+
if current_hash != self._file_hashes[path]:
|
|
153
|
+
logger.error(
|
|
154
|
+
"EDIT pre-check failed: file content modified during "
|
|
155
|
+
"execution for %s",
|
|
156
|
+
path,
|
|
157
|
+
)
|
|
158
|
+
return (
|
|
159
|
+
ActionLog(
|
|
160
|
+
status=ActionStatus.FAILURE,
|
|
161
|
+
action_type="EDIT",
|
|
162
|
+
params=action.params.copy(),
|
|
163
|
+
details="File content modified during execution",
|
|
164
|
+
),
|
|
165
|
+
reason,
|
|
166
|
+
)
|
|
167
|
+
except OSError: # safe to ignore
|
|
168
|
+
# If we can't read the file, proceed with normal dispatch
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
action_log = self._action_dispatcher.dispatch_and_execute(
|
|
172
|
+
action, agent_name=agent_name
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if action_log.status == ActionStatus.FAILURE:
|
|
176
|
+
return self._enrich_failed_log(action, action_log), reason
|
|
177
|
+
|
|
178
|
+
# Post-dispatch: update hash for EDIT, clear for EXECUTE
|
|
179
|
+
if action.type.upper() == "EDIT" and path:
|
|
180
|
+
try:
|
|
181
|
+
self._file_hashes[path] = self._compute_file_hash(path)
|
|
182
|
+
except OSError: # safe to ignore
|
|
183
|
+
pass
|
|
184
|
+
elif action.type.upper() == "EXECUTE":
|
|
185
|
+
self._file_hashes.clear()
|
|
186
|
+
|
|
187
|
+
# For success, we still want to return the message captured via 'm'
|
|
188
|
+
# For MESSAGE actions, return the user's typed message from action_log.details
|
|
189
|
+
# instead of the empty reason string.
|
|
190
|
+
captured_message = action_log.details if is_communication else reason
|
|
191
|
+
return (
|
|
192
|
+
ActionDiffManager.inject_diff(action, action_log, change_set),
|
|
193
|
+
captured_message,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def handle_skipped_action(self, action, reason: str) -> ActionLog:
|
|
197
|
+
"""Public method for skipping actions."""
|
|
198
|
+
return self._handle_skipped_action(action, reason)
|
|
199
|
+
|
|
200
|
+
def handle_failed_action(self, action, details: str) -> ActionLog:
|
|
201
|
+
"""Creates an ActionLog for an action that failed during preparation."""
|
|
202
|
+
return self._create_intercepted_log(action, ActionStatus.FAILURE, details)
|
|
203
|
+
|
|
204
|
+
def reset_file_hashes(self) -> None:
|
|
205
|
+
"""
|
|
206
|
+
Clears all stored file hashes. Called at the start of each plan execution
|
|
207
|
+
to prevent stale hashes from previous turns from causing false pre-check failures.
|
|
208
|
+
"""
|
|
209
|
+
self._file_hashes.clear()
|