ata-coder 2.4.2__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.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- ata_coder-2.4.2.dist-info/top_level.txt +1 -0
ata_coder/agent_tools.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Tool execution, parallel dispatch, filtering, and result storage — mixin for CoderAgent."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .tools import ToolResult
|
|
9
|
+
from .core import ToolCallEvent, ToolResultEvent, ToolStreamEvent, ErrorEvent
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToolExecutionMixin:
|
|
15
|
+
"""Tool execution with fool-proof checks, parallel dispatch, and filtering."""
|
|
16
|
+
|
|
17
|
+
# ── Tool execution ────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
async def _execute_tool(self, tool_name: str, arguments: dict[str, Any]) -> ToolResult:
|
|
20
|
+
"""Execute a tool with fool-proof checks, dispatching to built-in or MCP.
|
|
21
|
+
|
|
22
|
+
Wraps synchronous tool execution in asyncio.to_thread() as a bridge
|
|
23
|
+
until tools.py is fully async (Phase 1.7).
|
|
24
|
+
"""
|
|
25
|
+
source = "mcp" if (self.mcp and self.mcp.is_mcp_tool(tool_name)) else "builtin"
|
|
26
|
+
|
|
27
|
+
# ── Fool-proof evaluation ──────────────────────────────────────
|
|
28
|
+
if self.fool_proof:
|
|
29
|
+
from .fool_proof import ActionRequired
|
|
30
|
+
check = self.fool_proof.evaluate(tool_name, arguments)
|
|
31
|
+
|
|
32
|
+
if check.action == ActionRequired.BLOCKED:
|
|
33
|
+
msg = f"BLOCKED: {check.confirm_message}"
|
|
34
|
+
self._emit(ErrorEvent(msg))
|
|
35
|
+
return ToolResult(success=False, output="", error=msg)
|
|
36
|
+
|
|
37
|
+
if check.action in (ActionRequired.CONFIRM, ActionRequired.WARN_CONFIRM):
|
|
38
|
+
# Already handled by permission prompt flow
|
|
39
|
+
if self.permissions:
|
|
40
|
+
from .permissions import tool_category
|
|
41
|
+
category = tool_category(tool_name)
|
|
42
|
+
if category != "read":
|
|
43
|
+
allowed = self.permissions.check(tool_name, arguments)
|
|
44
|
+
if not allowed:
|
|
45
|
+
return ToolResult(
|
|
46
|
+
success=False, output="",
|
|
47
|
+
error=f"User denied permission for {tool_name}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# ── Permission check (fallback) ────────────────────────────────
|
|
51
|
+
elif self.permissions:
|
|
52
|
+
from .permissions import tool_category
|
|
53
|
+
category = tool_category(tool_name)
|
|
54
|
+
if category != "read":
|
|
55
|
+
allowed = self.permissions.check(tool_name, arguments)
|
|
56
|
+
if not allowed:
|
|
57
|
+
msg = f"User denied permission for {tool_name}"
|
|
58
|
+
self._emit(ErrorEvent(msg))
|
|
59
|
+
return ToolResult(success=False, output="", error=msg)
|
|
60
|
+
|
|
61
|
+
# ── Privilege check (needs elevation?) ────────────────────────
|
|
62
|
+
if tool_name == "run_shell" and self.privilege_mgr:
|
|
63
|
+
command = arguments.get("command", "")
|
|
64
|
+
allowed, reason = self.privilege_mgr.check_dangerous_command(command)
|
|
65
|
+
if not allowed:
|
|
66
|
+
self._emit(ErrorEvent(reason))
|
|
67
|
+
return ToolResult(success=False, output="", error=reason)
|
|
68
|
+
|
|
69
|
+
# Audit privileged operations
|
|
70
|
+
if self.privilege_mgr.is_dangerous:
|
|
71
|
+
self.privilege_mgr.audit_operation(tool_name, arguments)
|
|
72
|
+
# Wrap command with elevation if needed
|
|
73
|
+
if self.privilege_mgr.needs_elevation(command):
|
|
74
|
+
arguments = dict(arguments)
|
|
75
|
+
original = arguments["command"]
|
|
76
|
+
arguments["command"] = self.privilege_mgr.wrap_command(original, force_elevation=True)
|
|
77
|
+
logger.info("Elevated command: %s", arguments["command"][:100])
|
|
78
|
+
|
|
79
|
+
# Trigger extension point: on_tool_execute
|
|
80
|
+
self._ep_on_tool_execute.trigger(tool_name=tool_name, arguments=arguments)
|
|
81
|
+
|
|
82
|
+
self._emit(ToolCallEvent(tool_name, arguments, source=source))
|
|
83
|
+
|
|
84
|
+
# Execute MCP tool
|
|
85
|
+
if source == "mcp":
|
|
86
|
+
try:
|
|
87
|
+
mcp_result = await self.mcp.call_tool(tool_name, arguments)
|
|
88
|
+
output = self._format_mcp_result(mcp_result)
|
|
89
|
+
result = ToolResult(success=True, output=output)
|
|
90
|
+
self._emit(ToolResultEvent(tool_name, result, source="mcp", arguments=arguments))
|
|
91
|
+
return result
|
|
92
|
+
except Exception as e:
|
|
93
|
+
result = ToolResult(success=False, output="", error=str(e))
|
|
94
|
+
self._emit(ToolResultEvent(tool_name, result, source="mcp", arguments=arguments))
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
# Capture old content before write/edit (for change tracker + diff)
|
|
98
|
+
if tool_name == "edit_file":
|
|
99
|
+
old_file_content = self._read_old_content(arguments.get("file_path", ""))
|
|
100
|
+
else:
|
|
101
|
+
old_file_content = ""
|
|
102
|
+
|
|
103
|
+
# Execute built-in tool with self-correction
|
|
104
|
+
if self.change_tracker and self.change_tracker.dry_run and tool_name in ("write_file", "edit_file"):
|
|
105
|
+
# Dry-run: skip actual file write, only track in change_tracker via capture below
|
|
106
|
+
result = ToolResult(success=True, output=f"[DRY-RUN] Would {tool_name}: {arguments.get('file_path', '')}")
|
|
107
|
+
else:
|
|
108
|
+
# Set up real-time streaming for long-running tools
|
|
109
|
+
if tool_name in ("run_shell", "web_search", "web_fetch"):
|
|
110
|
+
def _on_stream(tool_name: str, chunk: str):
|
|
111
|
+
self._emit(ToolStreamEvent(tool_name, chunk))
|
|
112
|
+
self.tools.set_stream_callback(_on_stream)
|
|
113
|
+
try:
|
|
114
|
+
result = await self.tools.execute(tool_name, arguments)
|
|
115
|
+
finally:
|
|
116
|
+
if tool_name in ("run_shell", "web_search", "web_fetch"):
|
|
117
|
+
self.tools.set_stream_callback(None)
|
|
118
|
+
|
|
119
|
+
# Record successful file changes in the change tracker
|
|
120
|
+
if result.success and self.fool_proof and tool_name in ("write_file", "edit_file"):
|
|
121
|
+
self.fool_proof.capture(tool_name, arguments, result, old_content=old_file_content)
|
|
122
|
+
|
|
123
|
+
# Self-correction: if failed, try to diagnose and fix
|
|
124
|
+
if not result.success and self.self_correct and self.self_correct.should_retry(tool_name, arguments):
|
|
125
|
+
diagnosis = self.self_correct.diagnose(result.error, tool_name, arguments)
|
|
126
|
+
if diagnosis and diagnosis.retry_strategy == "auto_fix":
|
|
127
|
+
fixed_args = self.self_correct.suggest_fix(tool_name, arguments, diagnosis, error_message=result.error)
|
|
128
|
+
if fixed_args and fixed_args != arguments:
|
|
129
|
+
self._emit(ToolResultEvent(tool_name, result, source="builtin", arguments=arguments))
|
|
130
|
+
logger.info("Auto-correcting: %s (was: %s)", diagnosis.fix_suggestion[:80], result.error[:80])
|
|
131
|
+
# Retry with fixed args
|
|
132
|
+
corrected_result = await self.tools.execute(tool_name, fixed_args)
|
|
133
|
+
self.self_correct.record_attempt(
|
|
134
|
+
tool_name, arguments, result.error,
|
|
135
|
+
diagnosis, fixed_args,
|
|
136
|
+
corrected_result.success,
|
|
137
|
+
)
|
|
138
|
+
if corrected_result.success:
|
|
139
|
+
self._emit(ToolResultEvent(tool_name, corrected_result, source="builtin"))
|
|
140
|
+
# Record in change tracker after auto-fixed edit
|
|
141
|
+
if self.fool_proof and tool_name in ("write_file", "edit_file"):
|
|
142
|
+
self.fool_proof.capture(tool_name, fixed_args, corrected_result,
|
|
143
|
+
old_content=old_file_content if tool_name == "edit_file" else "")
|
|
144
|
+
return corrected_result
|
|
145
|
+
|
|
146
|
+
self._emit(ToolResultEvent(tool_name, result, source="builtin", arguments=arguments))
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
# ── Parallel execution ──────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _can_parallelize(tool_calls: list[dict], pre_parsed: dict[int, dict] | None = None) -> bool:
|
|
153
|
+
"""Check if tool calls can run in parallel (no shared write targets).
|
|
154
|
+
|
|
155
|
+
*pre_parsed* maps index → pre-parsed arguments dict, avoiding
|
|
156
|
+
redundant JSON parsing when the caller has already decoded them.
|
|
157
|
+
"""
|
|
158
|
+
write_targets = set()
|
|
159
|
+
for i, tc in enumerate(tool_calls):
|
|
160
|
+
name = tc["function"]["name"]
|
|
161
|
+
if name == "run_shell":
|
|
162
|
+
return False # Shell commands have side effects, serialize
|
|
163
|
+
if name in ("write_file", "edit_file"):
|
|
164
|
+
if pre_parsed and i in pre_parsed:
|
|
165
|
+
fp = pre_parsed[i].get("file_path", "")
|
|
166
|
+
else:
|
|
167
|
+
try:
|
|
168
|
+
fp = json.loads(tc["function"]["arguments"]).get("file_path", "")
|
|
169
|
+
except json.JSONDecodeError:
|
|
170
|
+
return False
|
|
171
|
+
if fp in write_targets:
|
|
172
|
+
return False
|
|
173
|
+
write_targets.add(fp)
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
async def _execute_parallel(self, tool_calls: list[dict], text: str = "") -> list[ToolResult]:
|
|
177
|
+
"""Execute multiple tool calls concurrently via asyncio.gather."""
|
|
178
|
+
async def run_one(idx: int, tc: dict) -> tuple[int, ToolResult]:
|
|
179
|
+
tool_name = tc["function"]["name"]
|
|
180
|
+
try:
|
|
181
|
+
arguments = json.loads(tc["function"]["arguments"])
|
|
182
|
+
except json.JSONDecodeError:
|
|
183
|
+
arguments = {}
|
|
184
|
+
result = await self._execute_tool(tool_name, arguments)
|
|
185
|
+
return idx, result
|
|
186
|
+
|
|
187
|
+
tasks = [run_one(i, tc) for i, tc in enumerate(tool_calls)]
|
|
188
|
+
gathered = await asyncio.gather(*tasks, return_exceptions=True)
|
|
189
|
+
|
|
190
|
+
results: list[ToolResult] = []
|
|
191
|
+
for item in gathered:
|
|
192
|
+
if isinstance(item, Exception):
|
|
193
|
+
logger.error("Parallel tool execution failed: %s", item)
|
|
194
|
+
results.append(ToolResult(success=False, output="", error=str(item)))
|
|
195
|
+
else:
|
|
196
|
+
results.append(item[1]) # (idx, result) tuple
|
|
197
|
+
return results
|
|
198
|
+
|
|
199
|
+
# ── Tool filtering (multi-skill) ────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
def _compute_allowed_tools(self) -> set[str] | None:
|
|
202
|
+
"""Compute effective tool restrictions from all active skill extensions.
|
|
203
|
+
|
|
204
|
+
Cached per run — invalidated on skill change (new call to run()).
|
|
205
|
+
|
|
206
|
+
Rule: intersection of non-empty tool restrictions across all active
|
|
207
|
+
skill extensions. Empty list = no restriction (all tools allowed).
|
|
208
|
+
Returns None if no restrictions exist.
|
|
209
|
+
"""
|
|
210
|
+
if self._cached_allowed_tools is not None:
|
|
211
|
+
return self._cached_allowed_tools
|
|
212
|
+
restrictions: list[set[str]] = []
|
|
213
|
+
for ext in self.ext_mgr.list_active():
|
|
214
|
+
if "skill" not in ext.meta.tags:
|
|
215
|
+
continue
|
|
216
|
+
tools = ext.get_tools()
|
|
217
|
+
if tools: # non-empty = this extension restricts tools
|
|
218
|
+
restrictions.append(set(tools))
|
|
219
|
+
|
|
220
|
+
if not restrictions:
|
|
221
|
+
self._cached_allowed_tools = None
|
|
222
|
+
return None # no restrictions → all tools allowed
|
|
223
|
+
|
|
224
|
+
# Intersection of all non-empty restrictions
|
|
225
|
+
allowed = restrictions[0]
|
|
226
|
+
for r in restrictions[1:]:
|
|
227
|
+
allowed &= r
|
|
228
|
+
result = allowed if allowed else None
|
|
229
|
+
self._cached_allowed_tools = result
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
# ── Result formatting & storage ─────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
def _format_mcp_result(self, mcp_result: Any) -> str:
|
|
235
|
+
"""Format MCP tool result into a string."""
|
|
236
|
+
if isinstance(mcp_result, dict):
|
|
237
|
+
content = mcp_result.get("content", [])
|
|
238
|
+
if isinstance(content, list):
|
|
239
|
+
parts = []
|
|
240
|
+
for item in content:
|
|
241
|
+
if isinstance(item, dict):
|
|
242
|
+
if item.get("type") == "text":
|
|
243
|
+
parts.append(item.get("text", ""))
|
|
244
|
+
elif item.get("type") == "resource":
|
|
245
|
+
parts.append(json.dumps(item.get("resource", {})))
|
|
246
|
+
else:
|
|
247
|
+
parts.append(json.dumps(item))
|
|
248
|
+
else:
|
|
249
|
+
parts.append(str(item))
|
|
250
|
+
return "\n".join(parts)
|
|
251
|
+
return json.dumps(mcp_result)
|
|
252
|
+
return str(mcp_result)
|
|
253
|
+
|
|
254
|
+
def _store_tool_result(self, result: ToolResult, tool_call_id: str) -> None:
|
|
255
|
+
"""Truncate tool output and append to message history.
|
|
256
|
+
|
|
257
|
+
Full output is available during execution, but only a capped version
|
|
258
|
+
is stored for future LLM turns to prevent context bloat.
|
|
259
|
+
"""
|
|
260
|
+
cap = self.config.agent.max_message_output_chars
|
|
261
|
+
content = result.to_message()
|
|
262
|
+
if len(content) > cap:
|
|
263
|
+
content = (
|
|
264
|
+
content[:cap]
|
|
265
|
+
+ f"\n\n... [truncated {len(content) - cap:,} chars "
|
|
266
|
+
+ f"from {result.output.count(chr(10)) + 1} lines]"
|
|
267
|
+
)
|
|
268
|
+
self._state.messages.append({
|
|
269
|
+
"role": "tool",
|
|
270
|
+
"tool_call_id": tool_call_id,
|
|
271
|
+
"content": content,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _warn_if_large_result(result: ToolResult, tool_name: str) -> None:
|
|
276
|
+
"""Log a warning when a tool result is unusually large."""
|
|
277
|
+
size = len(result.output)
|
|
278
|
+
if size > 30_000:
|
|
279
|
+
logger.warning(
|
|
280
|
+
"Large tool result: %s → %d chars (~%d tokens)",
|
|
281
|
+
tool_name, size, size // 4,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# ── Change tracking helpers ─────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
def _read_old_content(self, file_path: str) -> str:
|
|
287
|
+
"""Read the current content of a file before editing (for change tracking).
|
|
288
|
+
|
|
289
|
+
Uses the tool executor's file cache when possible to avoid a redundant
|
|
290
|
+
disk read if the file was recently read by _tool_read_file.
|
|
291
|
+
"""
|
|
292
|
+
if not file_path:
|
|
293
|
+
return ""
|
|
294
|
+
p = Path(file_path)
|
|
295
|
+
if not p.is_absolute():
|
|
296
|
+
p = self.tools.workspace / p
|
|
297
|
+
if not p.exists():
|
|
298
|
+
return ""
|
|
299
|
+
|
|
300
|
+
# Check file cache first (populated by _tool_read_file)
|
|
301
|
+
# Cache format: (mtime, cached_at, content) — 3-tuple with LRU timestamp
|
|
302
|
+
cache_key = str(p.resolve())
|
|
303
|
+
if cache_key in self.tools._file_cache:
|
|
304
|
+
cached_mtime, _, cached_content = self.tools._file_cache[cache_key]
|
|
305
|
+
if cached_mtime == p.stat().st_mtime:
|
|
306
|
+
return cached_content
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
# Safety: skip files > 50MB to avoid OOM
|
|
310
|
+
if p.stat().st_size > 50_000_000:
|
|
311
|
+
logger.warning("Skipping large file for change tracking: %s", p)
|
|
312
|
+
return f"[file too large: {p.stat().st_size / 1_000_000:.0f}MB]"
|
|
313
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
314
|
+
except FileNotFoundError:
|
|
315
|
+
return ""
|
|
316
|
+
except Exception:
|
|
317
|
+
logger.debug("Failed to read %s for change tracking", file_path, exc_info=True)
|
|
318
|
+
return ""
|
ata_coder/agent_undo.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Undo, redo, change tracking, and dry-run — mixin for CoderAgent."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UndoMixin:
|
|
5
|
+
"""Change-tracking undo/redo and dry-run commands."""
|
|
6
|
+
|
|
7
|
+
# ── Undo / Changes / Dry-run ───────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
def undo(self, count: int = 1) -> str:
|
|
10
|
+
"""Undo the last N changes."""
|
|
11
|
+
if not self.change_tracker:
|
|
12
|
+
return "Change tracker not available."
|
|
13
|
+
reverted = self.change_tracker.undo(count)
|
|
14
|
+
if not reverted:
|
|
15
|
+
return "Nothing to undo."
|
|
16
|
+
lines = [f"Undid {len(reverted)} change(s):"]
|
|
17
|
+
for c in reverted:
|
|
18
|
+
lines.append(f" {c.summary}")
|
|
19
|
+
return "\n".join(lines)
|
|
20
|
+
|
|
21
|
+
def undo_all(self) -> str:
|
|
22
|
+
"""Undo all changes in this session."""
|
|
23
|
+
if not self.change_tracker:
|
|
24
|
+
return "Change tracker not available."
|
|
25
|
+
reverted = self.change_tracker.undo_all()
|
|
26
|
+
if not reverted:
|
|
27
|
+
return "Nothing to undo."
|
|
28
|
+
return f"Undid all {len(reverted)} changes."
|
|
29
|
+
|
|
30
|
+
def restore_change(self, change_id: int) -> str:
|
|
31
|
+
"""Re-apply a reverted change."""
|
|
32
|
+
if not self.change_tracker:
|
|
33
|
+
return "Change tracker not available."
|
|
34
|
+
restored = self.change_tracker.restore(change_id)
|
|
35
|
+
if restored:
|
|
36
|
+
return f"Restored: {restored.summary}"
|
|
37
|
+
return f"Change #{change_id} not found or not reverted."
|
|
38
|
+
|
|
39
|
+
def list_changes(self) -> str:
|
|
40
|
+
"""List all changes in this session."""
|
|
41
|
+
if not self.change_tracker:
|
|
42
|
+
return "Change tracker not available."
|
|
43
|
+
return self.change_tracker.summary()
|
|
44
|
+
|
|
45
|
+
def show_change_diff(self, last_n: int = 3) -> str:
|
|
46
|
+
"""Show diffs for recent changes."""
|
|
47
|
+
if not self.change_tracker:
|
|
48
|
+
return "Change tracker not available."
|
|
49
|
+
return self.change_tracker.diff_summary(last_n)
|
|
50
|
+
|
|
51
|
+
def toggle_dry_run(self, enabled: bool | None = None) -> str:
|
|
52
|
+
"""Enable or disable dry-run mode."""
|
|
53
|
+
if not self.change_tracker:
|
|
54
|
+
return "Change tracker not available."
|
|
55
|
+
|
|
56
|
+
if enabled is None:
|
|
57
|
+
enabled = not self.change_tracker.dry_run
|
|
58
|
+
|
|
59
|
+
self.change_tracker.dry_run = enabled
|
|
60
|
+
if enabled:
|
|
61
|
+
return "DRY-RUN MODE ON — changes will be PREVIEWED only, not applied."
|
|
62
|
+
else:
|
|
63
|
+
return "DRY-RUN MODE OFF — changes will be applied normally."
|