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