power-loop 0.2.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.
- llm_client/__init__.py +0 -0
- llm_client/capabilities.py +162 -0
- llm_client/interface.py +470 -0
- llm_client/llm_factory.py +981 -0
- llm_client/llm_tooling.py +645 -0
- llm_client/llm_utils.py +205 -0
- llm_client/multimodal.py +237 -0
- llm_client/qwen_image.py +576 -0
- llm_client/web_search.py +149 -0
- power_loop/__init__.py +326 -0
- power_loop/agent/__init__.py +6 -0
- power_loop/agent/sink.py +247 -0
- power_loop/agent/stateful_loop.py +363 -0
- power_loop/agent/system_prompt.py +396 -0
- power_loop/agent/types.py +41 -0
- power_loop/contracts/__init__.py +132 -0
- power_loop/contracts/errors.py +140 -0
- power_loop/contracts/event_payloads.py +278 -0
- power_loop/contracts/events.py +86 -0
- power_loop/contracts/handlers.py +45 -0
- power_loop/contracts/hook_contexts.py +265 -0
- power_loop/contracts/hooks.py +64 -0
- power_loop/contracts/messages.py +90 -0
- power_loop/contracts/protocols.py +48 -0
- power_loop/contracts/tools.py +56 -0
- power_loop/core/agent_context.py +94 -0
- power_loop/core/events.py +124 -0
- power_loop/core/hooks.py +122 -0
- power_loop/core/phase.py +217 -0
- power_loop/core/pipeline.py +880 -0
- power_loop/core/runner.py +60 -0
- power_loop/core/state.py +208 -0
- power_loop/runtime/budget.py +179 -0
- power_loop/runtime/cancellation.py +127 -0
- power_loop/runtime/compact.py +300 -0
- power_loop/runtime/env.py +103 -0
- power_loop/runtime/memory.py +107 -0
- power_loop/runtime/provider.py +176 -0
- power_loop/runtime/retry.py +182 -0
- power_loop/runtime/session_store.py +636 -0
- power_loop/runtime/skills.py +201 -0
- power_loop/runtime/spec.py +233 -0
- power_loop/runtime/structured.py +225 -0
- power_loop/tools/__init__.py +51 -0
- power_loop/tools/default_manifest.py +244 -0
- power_loop/tools/default_tools.py +766 -0
- power_loop/tools/registry.py +162 -0
- power_loop/tools/spawn_agent.py +173 -0
- power_loop-0.2.0.dist-info/METADATA +632 -0
- power_loop-0.2.0.dist-info/RECORD +53 -0
- power_loop-0.2.0.dist-info/WHEEL +5 -0
- power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
- power_loop-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# NOTE: This module intentionally copies tool implementations from zero-code/core/tools.py
|
|
4
|
+
# with only import-path adjustments for power-loop package layout.
|
|
5
|
+
import difflib
|
|
6
|
+
import os
|
|
7
|
+
import queue
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from power_loop.core.agent_context import get_ctx
|
|
17
|
+
from power_loop.runtime.env import AGENT_DIR, AGENT_RW_ALLOWLIST, WORKSPACE_DIR, safe_path
|
|
18
|
+
from power_loop.runtime.skills import SKILL_LOADER
|
|
19
|
+
|
|
20
|
+
RESULT_MAX_CHARS = 50000
|
|
21
|
+
_HEAD_LINES = 30
|
|
22
|
+
_TAIL_LINES = 170
|
|
23
|
+
SENTINEL = "___ZERO_CODE_CMD_DONE___"
|
|
24
|
+
|
|
25
|
+
FILE_READ_STATE: dict[str, float] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _truncate_output(lines: list[str], head: int = _HEAD_LINES, tail: int = _TAIL_LINES) -> str:
|
|
29
|
+
limit = head + tail
|
|
30
|
+
if len(lines) <= limit:
|
|
31
|
+
return "\n".join(lines)
|
|
32
|
+
omitted = len(lines) - limit
|
|
33
|
+
return (
|
|
34
|
+
"\n".join(lines[:head])
|
|
35
|
+
+ f"\n\n... ({omitted} lines omitted) ...\n\n"
|
|
36
|
+
+ "\n".join(lines[-tail:])
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BashSession:
|
|
41
|
+
"""Persistent bash process with merged stdout/stderr via pty."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, cwd: Path):
|
|
44
|
+
self._cwd = cwd
|
|
45
|
+
self._proc: subprocess.Popen | None = None
|
|
46
|
+
self._q: queue.Queue[str] = queue.Queue()
|
|
47
|
+
self._master_fd: int | None = None
|
|
48
|
+
self._start()
|
|
49
|
+
|
|
50
|
+
def _start(self) -> None:
|
|
51
|
+
import pty
|
|
52
|
+
|
|
53
|
+
master_fd, slave_fd = pty.openpty()
|
|
54
|
+
try:
|
|
55
|
+
import termios
|
|
56
|
+
|
|
57
|
+
attrs = termios.tcgetattr(master_fd)
|
|
58
|
+
attrs[3] &= ~termios.ECHO
|
|
59
|
+
termios.tcsetattr(master_fd, termios.TCSANOW, attrs)
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
env = os.environ.copy()
|
|
64
|
+
env["TERM"] = "dumb"
|
|
65
|
+
|
|
66
|
+
self._proc = subprocess.Popen(
|
|
67
|
+
["/bin/bash", "--norc", "--noprofile"],
|
|
68
|
+
stdin=subprocess.PIPE,
|
|
69
|
+
stdout=slave_fd,
|
|
70
|
+
stderr=slave_fd,
|
|
71
|
+
text=True,
|
|
72
|
+
bufsize=0,
|
|
73
|
+
cwd=str(self._cwd),
|
|
74
|
+
env=env,
|
|
75
|
+
)
|
|
76
|
+
os.close(slave_fd)
|
|
77
|
+
self._master_fd = master_fd
|
|
78
|
+
self._q = queue.Queue()
|
|
79
|
+
threading.Thread(target=self._reader, daemon=True).start()
|
|
80
|
+
|
|
81
|
+
def _reader(self) -> None:
|
|
82
|
+
buf = ""
|
|
83
|
+
fd = self._master_fd
|
|
84
|
+
if fd is None:
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
while True:
|
|
88
|
+
try:
|
|
89
|
+
data = os.read(fd, 4096)
|
|
90
|
+
except OSError:
|
|
91
|
+
break
|
|
92
|
+
if not data:
|
|
93
|
+
break
|
|
94
|
+
buf += data.decode("utf-8", errors="replace")
|
|
95
|
+
while True:
|
|
96
|
+
idx_n = buf.find("\n")
|
|
97
|
+
idx_r = buf.find("\r")
|
|
98
|
+
if idx_n == -1 and idx_r == -1:
|
|
99
|
+
break
|
|
100
|
+
candidates = [i for i in (idx_n, idx_r) if i != -1]
|
|
101
|
+
cut = min(candidates)
|
|
102
|
+
line, buf = buf[:cut], buf[cut + 1 :]
|
|
103
|
+
if line:
|
|
104
|
+
self._q.put(line)
|
|
105
|
+
if buf:
|
|
106
|
+
self._q.put(buf)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def _drain(self, timeout: float, idle_timeout: float = 5.0) -> tuple[list[str], str | None]:
|
|
111
|
+
lines: list[str] = []
|
|
112
|
+
exit_code: str | None = None
|
|
113
|
+
deadline = time.monotonic() + timeout
|
|
114
|
+
|
|
115
|
+
while True:
|
|
116
|
+
remaining = deadline - time.monotonic()
|
|
117
|
+
if remaining <= 0:
|
|
118
|
+
break
|
|
119
|
+
wait = min(remaining, idle_timeout)
|
|
120
|
+
try:
|
|
121
|
+
line = self._q.get(timeout=wait)
|
|
122
|
+
except queue.Empty:
|
|
123
|
+
if self._proc is not None and self._proc.poll() is not None:
|
|
124
|
+
while not self._q.empty():
|
|
125
|
+
try:
|
|
126
|
+
line = self._q.get_nowait()
|
|
127
|
+
cleaned = re.sub(r"\\x1b\\[[0-9;]*[A-Za-z]", "", line).rstrip("\r")
|
|
128
|
+
if SENTINEL in cleaned:
|
|
129
|
+
parts = cleaned.strip().split()
|
|
130
|
+
if len(parts) >= 2 and parts[-1].lstrip("-").isdigit():
|
|
131
|
+
exit_code = parts[-1]
|
|
132
|
+
break
|
|
133
|
+
lines.append(cleaned)
|
|
134
|
+
except queue.Empty:
|
|
135
|
+
break
|
|
136
|
+
break
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
cleaned = re.sub(r"\\x1b\\[[0-9;]*[A-Za-z]", "", line).rstrip("\r")
|
|
140
|
+
if SENTINEL in cleaned:
|
|
141
|
+
parts = cleaned.strip().split()
|
|
142
|
+
if len(parts) >= 2 and parts[-1].lstrip("-").isdigit():
|
|
143
|
+
exit_code = parts[-1]
|
|
144
|
+
break
|
|
145
|
+
lines.append(cleaned)
|
|
146
|
+
|
|
147
|
+
return lines, exit_code
|
|
148
|
+
|
|
149
|
+
def execute(self, command: str, timeout: int = 120) -> str:
|
|
150
|
+
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
|
151
|
+
if any(d in command for d in dangerous):
|
|
152
|
+
return (
|
|
153
|
+
"Error: Dangerous command blocked.\n"
|
|
154
|
+
"For safety, interactive or privileged commands (like sudo / shutdown) "
|
|
155
|
+
"must be run manually in your own terminal, not via the agent bash tool."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if self._proc is None or self._proc.poll() is not None:
|
|
159
|
+
self._start()
|
|
160
|
+
|
|
161
|
+
assert self._proc is not None and self._proc.stdin is not None
|
|
162
|
+
full_cmd = f"{command}\\necho {SENTINEL} $?\\n"
|
|
163
|
+
try:
|
|
164
|
+
self._proc.stdin.write(full_cmd)
|
|
165
|
+
self._proc.stdin.flush()
|
|
166
|
+
except (BrokenPipeError, OSError):
|
|
167
|
+
self._start()
|
|
168
|
+
return "Error: Bash session crashed, restarted. Please retry."
|
|
169
|
+
|
|
170
|
+
lines, exit_code = self._drain(timeout, idle_timeout=5.0)
|
|
171
|
+
timed_out = exit_code is None
|
|
172
|
+
|
|
173
|
+
if timed_out and self._proc is not None and self._proc.poll() is not None:
|
|
174
|
+
exit_code = str(self._proc.returncode)
|
|
175
|
+
timed_out = False
|
|
176
|
+
|
|
177
|
+
header = f"exit_code={exit_code or '?'}"
|
|
178
|
+
if timed_out:
|
|
179
|
+
header += f" (timed out after {timeout}s — command may still be running)"
|
|
180
|
+
|
|
181
|
+
body = _truncate_output(lines) if lines else "(no output)"
|
|
182
|
+
return f"{header}\\n{body}"[:RESULT_MAX_CHARS]
|
|
183
|
+
|
|
184
|
+
def restart(self) -> str:
|
|
185
|
+
if self._master_fd is not None:
|
|
186
|
+
try:
|
|
187
|
+
os.close(self._master_fd)
|
|
188
|
+
except OSError:
|
|
189
|
+
pass
|
|
190
|
+
self._master_fd = None
|
|
191
|
+
if self._proc and self._proc.poll() is None:
|
|
192
|
+
self._proc.terminate()
|
|
193
|
+
try:
|
|
194
|
+
self._proc.wait(timeout=5)
|
|
195
|
+
except Exception:
|
|
196
|
+
self._proc.kill()
|
|
197
|
+
self._start()
|
|
198
|
+
return "Bash session restarted."
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
BASH = BashSession(WORKSPACE_DIR)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _display_path(path: Path) -> str:
|
|
205
|
+
resolved = path.resolve()
|
|
206
|
+
if resolved.is_relative_to(WORKSPACE_DIR):
|
|
207
|
+
return str(resolved.relative_to(WORKSPACE_DIR))
|
|
208
|
+
if resolved.is_relative_to(AGENT_DIR):
|
|
209
|
+
return f"@agent/{resolved.relative_to(AGENT_DIR)}"
|
|
210
|
+
return str(resolved)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_BASH_WRITE_HINTS = (
|
|
214
|
+
" >",
|
|
215
|
+
">>",
|
|
216
|
+
" tee ",
|
|
217
|
+
" sed -i",
|
|
218
|
+
" rm ",
|
|
219
|
+
" mv ",
|
|
220
|
+
" cp ",
|
|
221
|
+
" touch ",
|
|
222
|
+
" mkdir ",
|
|
223
|
+
)
|
|
224
|
+
_BASH_READ_HINTS = (
|
|
225
|
+
" cat ",
|
|
226
|
+
" less ",
|
|
227
|
+
" head ",
|
|
228
|
+
" tail ",
|
|
229
|
+
" grep ",
|
|
230
|
+
" rg ",
|
|
231
|
+
" find ",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _is_agent_path_allowed_for_bash(command: str) -> bool:
|
|
236
|
+
lowered = command.lower()
|
|
237
|
+
if str(AGENT_DIR).lower() not in lowered:
|
|
238
|
+
return True
|
|
239
|
+
return any(str(path).lower() in lowered for path in AGENT_RW_ALLOWLIST)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _validate_bash_command_scope(command: str) -> str | None:
|
|
243
|
+
lowered = f" {command.lower()} "
|
|
244
|
+
if str(AGENT_DIR).lower() not in lowered:
|
|
245
|
+
return None
|
|
246
|
+
if _is_agent_path_allowed_for_bash(command):
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
if any(hint in lowered for hint in _BASH_WRITE_HINTS):
|
|
250
|
+
return (
|
|
251
|
+
"Error: Writing under agent home is blocked outside allowlisted paths (.cache/logs). "
|
|
252
|
+
"Use workspace files or allowlisted agent paths only."
|
|
253
|
+
)
|
|
254
|
+
if any(hint in lowered for hint in _BASH_READ_HINTS):
|
|
255
|
+
return (
|
|
256
|
+
"Error: Reading agent-home internals is blocked outside allowlisted paths (.cache/logs). "
|
|
257
|
+
"Use load_skill(name) for skill content instead of direct file reads."
|
|
258
|
+
)
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def run_bash(command: str | None = None, restart: bool = False, timeout: int = 120) -> str:
|
|
263
|
+
if restart:
|
|
264
|
+
return BASH.restart()
|
|
265
|
+
if not command:
|
|
266
|
+
return "Error: command is required (or set restart=true)"
|
|
267
|
+
timeout = max(5, min(int(timeout), 600))
|
|
268
|
+
scope_err = _validate_bash_command_scope(command)
|
|
269
|
+
if scope_err:
|
|
270
|
+
return scope_err
|
|
271
|
+
return BASH.execute(command, timeout=timeout)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _list_directory(dp: Path) -> str:
|
|
275
|
+
entries = sorted(dp.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
276
|
+
lines = [f"Directory: {_display_path(dp)}/"]
|
|
277
|
+
for entry in entries[:100]:
|
|
278
|
+
prefix = "d " if entry.is_dir() else "f "
|
|
279
|
+
size = ""
|
|
280
|
+
if entry.is_file():
|
|
281
|
+
size = f" ({entry.stat().st_size} bytes)"
|
|
282
|
+
lines.append(f" {prefix}{entry.name}{size}")
|
|
283
|
+
if len(entries) > 100:
|
|
284
|
+
lines.append(f" ... and {len(entries) - 100} more entries")
|
|
285
|
+
return "\\n".join(lines)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def run_read(path: str, offset: int | None = None, limit: int | None = None) -> str:
|
|
289
|
+
try:
|
|
290
|
+
fp = safe_path(path)
|
|
291
|
+
if fp.is_dir():
|
|
292
|
+
return _list_directory(fp)
|
|
293
|
+
text = fp.read_text()
|
|
294
|
+
all_lines = text.splitlines()
|
|
295
|
+
total = len(all_lines)
|
|
296
|
+
start = max(0, (offset or 1) - 1)
|
|
297
|
+
end = min(total, start + limit) if limit else total
|
|
298
|
+
selected = all_lines[start:end]
|
|
299
|
+
numbered = [f"{start + i + 1:>6}|{line}" for i, line in enumerate(selected)]
|
|
300
|
+
header = f"({total} lines total)"
|
|
301
|
+
if start > 0 or end < total:
|
|
302
|
+
header = f"(showing lines {start+1}-{end} of {total})"
|
|
303
|
+
FILE_READ_STATE[str(fp)] = fp.stat().st_mtime
|
|
304
|
+
return header + "\\n" + "\\n".join(numbered)
|
|
305
|
+
except Exception as e:
|
|
306
|
+
return f"Error: {e}"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def run_write(path: str, content: str) -> str:
|
|
310
|
+
try:
|
|
311
|
+
fp = safe_path(path)
|
|
312
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
existed = fp.exists()
|
|
314
|
+
old_size = fp.stat().st_size if existed else 0
|
|
315
|
+
fp.write_text(content)
|
|
316
|
+
line_count = content.count("\\n") + (1 if content and not content.endswith("\\n") else 0)
|
|
317
|
+
display = _display_path(fp)
|
|
318
|
+
if existed:
|
|
319
|
+
return f"Wrote {len(content)} bytes ({line_count} lines) to {display} (overwritten, was {old_size} bytes) [workspace: {WORKSPACE_DIR}]"
|
|
320
|
+
return f"Wrote {len(content)} bytes ({line_count} lines) to {display} (new file) [workspace: {WORKSPACE_DIR}]"
|
|
321
|
+
except Exception as e:
|
|
322
|
+
return f"Error: {e}"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _check_read_state(fp: Path) -> str | None:
|
|
326
|
+
key = str(fp)
|
|
327
|
+
if key not in FILE_READ_STATE:
|
|
328
|
+
return f"Error: File has not been read yet. Use read_file first before editing: {fp.name}"
|
|
329
|
+
if fp.exists():
|
|
330
|
+
current_mtime = fp.stat().st_mtime
|
|
331
|
+
if current_mtime > FILE_READ_STATE[key]:
|
|
332
|
+
return f"Error: File was modified since last read. Re-read it first: {fp.name}"
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _detect_line_ending(content: str) -> str:
|
|
337
|
+
crlf_idx = content.find("\\r\\n")
|
|
338
|
+
lf_idx = content.find("\\n")
|
|
339
|
+
if lf_idx == -1:
|
|
340
|
+
return "\\n"
|
|
341
|
+
if crlf_idx == -1:
|
|
342
|
+
return "\\n"
|
|
343
|
+
return "\\r\\n" if crlf_idx < lf_idx else "\\n"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _normalize_to_lf(text: str) -> str:
|
|
347
|
+
return text.replace("\\r\\n", "\\n").replace("\\r", "\\n")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _restore_line_endings(text: str, ending: str) -> str:
|
|
351
|
+
return text.replace("\\n", "\\r\\n") if ending == "\\r\\n" else text
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _strip_bom(content: str) -> tuple[str, str]:
|
|
355
|
+
if content.startswith("\ufeff"):
|
|
356
|
+
return "\ufeff", content[1:]
|
|
357
|
+
return "", content
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _normalize_unicode(text: str) -> str:
|
|
361
|
+
lines = text.split("\\n")
|
|
362
|
+
stripped = "\\n".join(line.rstrip() for line in lines)
|
|
363
|
+
result = re.sub(r"[\u2018\u2019\u201a\u201b]", "'", stripped)
|
|
364
|
+
result = re.sub(r"[\u201c\u201d\u201e\u201f]", '"', result)
|
|
365
|
+
result = re.sub(r"[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]", "-", result)
|
|
366
|
+
result = re.sub(r"[\u00a0\u2002-\u200a\u202f\u205f\u3000]", " ", result)
|
|
367
|
+
return result
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _fuzzy_find(content: str, old_text: str) -> tuple[int, int, str] | None:
|
|
371
|
+
idx = content.find(old_text)
|
|
372
|
+
if idx != -1:
|
|
373
|
+
return idx, idx + len(old_text), content
|
|
374
|
+
|
|
375
|
+
lf_content = _normalize_to_lf(content)
|
|
376
|
+
lf_old = _normalize_to_lf(old_text)
|
|
377
|
+
idx = lf_content.find(lf_old)
|
|
378
|
+
if idx != -1:
|
|
379
|
+
return idx, idx + len(lf_old), lf_content
|
|
380
|
+
|
|
381
|
+
uni_content = _normalize_unicode(lf_content)
|
|
382
|
+
uni_old = _normalize_unicode(lf_old)
|
|
383
|
+
idx = uni_content.find(uni_old)
|
|
384
|
+
if idx != -1:
|
|
385
|
+
return idx, idx + len(uni_old), uni_content
|
|
386
|
+
|
|
387
|
+
trim_content = "\\n".join(line.strip() for line in lf_content.split("\\n"))
|
|
388
|
+
trim_old = "\\n".join(line.strip() for line in lf_old.split("\\n"))
|
|
389
|
+
idx = trim_content.find(trim_old)
|
|
390
|
+
if idx != -1:
|
|
391
|
+
return idx, idx + len(trim_old), trim_content
|
|
392
|
+
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _generate_diff(old_content: str, new_content: str, context: int = 3) -> str:
|
|
397
|
+
old_lines = old_content.split("\\n")
|
|
398
|
+
new_lines = new_content.split("\\n")
|
|
399
|
+
diff = difflib.unified_diff(old_lines, new_lines, lineterm="", n=context)
|
|
400
|
+
return "\\n".join(diff)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def run_edit(path: str, old_text: str, new_text: str, replace_all: bool = False) -> str:
|
|
404
|
+
try:
|
|
405
|
+
fp = safe_path(path)
|
|
406
|
+
|
|
407
|
+
read_err = _check_read_state(fp)
|
|
408
|
+
if read_err:
|
|
409
|
+
return read_err
|
|
410
|
+
|
|
411
|
+
raw_content = fp.read_text()
|
|
412
|
+
bom, content = _strip_bom(raw_content)
|
|
413
|
+
original_ending = _detect_line_ending(content)
|
|
414
|
+
normalized = _normalize_to_lf(content)
|
|
415
|
+
norm_old = _normalize_to_lf(old_text)
|
|
416
|
+
norm_new = _normalize_to_lf(new_text)
|
|
417
|
+
|
|
418
|
+
if norm_old == norm_new:
|
|
419
|
+
return "Error: old_text and new_text are identical."
|
|
420
|
+
|
|
421
|
+
if replace_all:
|
|
422
|
+
match = _fuzzy_find(normalized, norm_old)
|
|
423
|
+
if match is None:
|
|
424
|
+
return f"Error: Text not found in {path}. Provide a larger unique snippet."
|
|
425
|
+
_, _, base = match
|
|
426
|
+
if base == normalized:
|
|
427
|
+
count = base.count(norm_old)
|
|
428
|
+
updated = base.replace(norm_old, norm_new)
|
|
429
|
+
else:
|
|
430
|
+
search_key = _normalize_unicode(norm_old)
|
|
431
|
+
count = base.count(search_key)
|
|
432
|
+
updated = base.replace(search_key, norm_new)
|
|
433
|
+
else:
|
|
434
|
+
match = _fuzzy_find(normalized, norm_old)
|
|
435
|
+
if match is None:
|
|
436
|
+
return f"Error: Text not found in {path}. Provide a larger unique snippet."
|
|
437
|
+
start, end, base = match
|
|
438
|
+
|
|
439
|
+
fuzzy_base = _normalize_unicode(_normalize_to_lf(base))
|
|
440
|
+
fuzzy_old = _normalize_unicode(norm_old)
|
|
441
|
+
occurrence_count = fuzzy_base.split(fuzzy_old)
|
|
442
|
+
count = len(occurrence_count) - 1
|
|
443
|
+
if count > 1:
|
|
444
|
+
positions = []
|
|
445
|
+
search_start = 0
|
|
446
|
+
for _ in range(min(count, 5)):
|
|
447
|
+
idx = fuzzy_base.find(fuzzy_old, search_start)
|
|
448
|
+
if idx == -1:
|
|
449
|
+
break
|
|
450
|
+
line_no = fuzzy_base[:idx].count("\\n") + 1
|
|
451
|
+
positions.append(str(line_no))
|
|
452
|
+
search_start = idx + 1
|
|
453
|
+
return (
|
|
454
|
+
f"Error: old_text matches {count} locations in {path} (lines: {', '.join(positions)}). "
|
|
455
|
+
"Provide more surrounding context to make it unique, or use replace_all=true."
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
updated = base[:start] + norm_new + base[end:]
|
|
459
|
+
count = 1
|
|
460
|
+
|
|
461
|
+
diff_output = _generate_diff(base, updated)
|
|
462
|
+
final = bom + _restore_line_endings(updated, original_ending)
|
|
463
|
+
fp.write_text(final)
|
|
464
|
+
FILE_READ_STATE[str(fp)] = fp.stat().st_mtime
|
|
465
|
+
|
|
466
|
+
label = "replace_all" if replace_all else ("fuzzy match" if base != normalized else "exact")
|
|
467
|
+
return f"Edited {path} ({label}, {count} replacement{'s' if count != 1 else ''})\\n{diff_output}"
|
|
468
|
+
except Exception as e:
|
|
469
|
+
return f"Error: {e}"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _seek_context(lines: list[str], context_lines: list[str], start_from: int = 0) -> int | None:
|
|
473
|
+
def _match_line(file_line: str, ctx_line: str) -> bool:
|
|
474
|
+
if file_line == ctx_line:
|
|
475
|
+
return True
|
|
476
|
+
if _normalize_unicode(file_line) == _normalize_unicode(ctx_line):
|
|
477
|
+
return True
|
|
478
|
+
if file_line.rstrip() == ctx_line.rstrip():
|
|
479
|
+
return True
|
|
480
|
+
if file_line.strip() == ctx_line.strip():
|
|
481
|
+
return True
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
if not context_lines:
|
|
485
|
+
return start_from
|
|
486
|
+
|
|
487
|
+
for i in range(start_from, len(lines)):
|
|
488
|
+
if _match_line(lines[i], context_lines[0]):
|
|
489
|
+
if len(context_lines) == 1:
|
|
490
|
+
return i
|
|
491
|
+
all_match = True
|
|
492
|
+
for j, ctx in enumerate(context_lines[1:], 1):
|
|
493
|
+
if i + j >= len(lines) or not _match_line(lines[i + j], ctx):
|
|
494
|
+
all_match = False
|
|
495
|
+
break
|
|
496
|
+
if all_match:
|
|
497
|
+
return i
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _parse_patch(patch_text: str) -> list[dict[str, Any]]:
|
|
502
|
+
hunks: list[dict[str, Any]] = []
|
|
503
|
+
current_context: list[str] = []
|
|
504
|
+
current_changes: list[tuple[str, str]] = []
|
|
505
|
+
|
|
506
|
+
for raw_line in patch_text.split("\\n"):
|
|
507
|
+
if raw_line.startswith("@@"):
|
|
508
|
+
if current_changes:
|
|
509
|
+
hunks.append({"context": current_context, "changes": current_changes})
|
|
510
|
+
current_context = []
|
|
511
|
+
current_changes = []
|
|
512
|
+
ctx_text = raw_line[2:].strip() if len(raw_line) > 2 else ""
|
|
513
|
+
if ctx_text:
|
|
514
|
+
current_context.append(ctx_text)
|
|
515
|
+
elif raw_line.startswith("-"):
|
|
516
|
+
current_changes.append(("-", raw_line[1:]))
|
|
517
|
+
elif raw_line.startswith("+"):
|
|
518
|
+
current_changes.append(("+", raw_line[1:]))
|
|
519
|
+
elif raw_line.startswith(" "):
|
|
520
|
+
current_changes.append((" ", raw_line[1:]))
|
|
521
|
+
|
|
522
|
+
if current_changes:
|
|
523
|
+
hunks.append({"context": current_context, "changes": current_changes})
|
|
524
|
+
|
|
525
|
+
return hunks
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def run_apply_patch(path: str, patch: str) -> str:
|
|
529
|
+
try:
|
|
530
|
+
fp = safe_path(path)
|
|
531
|
+
|
|
532
|
+
read_err = _check_read_state(fp)
|
|
533
|
+
if read_err:
|
|
534
|
+
return read_err
|
|
535
|
+
|
|
536
|
+
raw_content = fp.read_text()
|
|
537
|
+
bom, content = _strip_bom(raw_content)
|
|
538
|
+
original_ending = _detect_line_ending(content)
|
|
539
|
+
normalized = _normalize_to_lf(content)
|
|
540
|
+
lines = normalized.split("\\n")
|
|
541
|
+
|
|
542
|
+
hunks = _parse_patch(patch)
|
|
543
|
+
if not hunks:
|
|
544
|
+
return "Error: No valid hunks found in patch. Use @@ for context and +/- for changes."
|
|
545
|
+
|
|
546
|
+
cursor = 0
|
|
547
|
+
for hi, hunk in enumerate(hunks):
|
|
548
|
+
ctx = hunk["context"]
|
|
549
|
+
changes = hunk["changes"]
|
|
550
|
+
|
|
551
|
+
if ctx:
|
|
552
|
+
pos = _seek_context(lines, ctx, cursor)
|
|
553
|
+
if pos is None:
|
|
554
|
+
return (
|
|
555
|
+
f"Error: Could not locate context for hunk {hi + 1} in {path}. "
|
|
556
|
+
f"Context: {ctx!r}"
|
|
557
|
+
)
|
|
558
|
+
cursor = pos + len(ctx)
|
|
559
|
+
else:
|
|
560
|
+
if hi == 0:
|
|
561
|
+
cursor = 0
|
|
562
|
+
|
|
563
|
+
apply_at = cursor
|
|
564
|
+
i = apply_at
|
|
565
|
+
result_insert = []
|
|
566
|
+
|
|
567
|
+
for op, text in changes:
|
|
568
|
+
if op == "-":
|
|
569
|
+
if i >= len(lines):
|
|
570
|
+
return (
|
|
571
|
+
f"Error: Hunk {hi + 1} tries to delete beyond end of file. "
|
|
572
|
+
f"Expected: {text!r}"
|
|
573
|
+
)
|
|
574
|
+
file_line = lines[i]
|
|
575
|
+
if not (
|
|
576
|
+
file_line == text
|
|
577
|
+
or file_line.strip() == text.strip()
|
|
578
|
+
or _normalize_unicode(file_line) == _normalize_unicode(text)
|
|
579
|
+
):
|
|
580
|
+
return (
|
|
581
|
+
f"Error: Hunk {hi + 1} delete mismatch at line {i + 1}. "
|
|
582
|
+
f"Expected: {text!r}, Found: {file_line!r}"
|
|
583
|
+
)
|
|
584
|
+
i += 1
|
|
585
|
+
elif op == "+":
|
|
586
|
+
result_insert.append(text)
|
|
587
|
+
elif op == " ":
|
|
588
|
+
if i >= len(lines):
|
|
589
|
+
return (
|
|
590
|
+
f"Error: Hunk {hi + 1} context line beyond end of file. "
|
|
591
|
+
f"Expected: {text!r}"
|
|
592
|
+
)
|
|
593
|
+
i += 1
|
|
594
|
+
result_insert.append(lines[i - 1])
|
|
595
|
+
|
|
596
|
+
lines[apply_at:i] = result_insert
|
|
597
|
+
cursor = apply_at + len(result_insert)
|
|
598
|
+
|
|
599
|
+
new_content = "\\n".join(lines)
|
|
600
|
+
diff_output = _generate_diff(normalized, new_content)
|
|
601
|
+
final = bom + _restore_line_endings(new_content, original_ending)
|
|
602
|
+
fp.write_text(final)
|
|
603
|
+
FILE_READ_STATE[str(fp)] = fp.stat().st_mtime
|
|
604
|
+
|
|
605
|
+
return f"Patched {path} ({len(hunks)} hunk{'s' if len(hunks) != 1 else ''})\\n{diff_output}"
|
|
606
|
+
except Exception as e:
|
|
607
|
+
return f"Error: {e}"
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def run_glob(pattern: str, path: str = ".") -> str:
|
|
611
|
+
try:
|
|
612
|
+
base = safe_path(path)
|
|
613
|
+
if not base.is_dir():
|
|
614
|
+
return f"Error: {path} is not a directory"
|
|
615
|
+
if not pattern.startswith("**/") and "/" not in pattern:
|
|
616
|
+
pattern = "**/" + pattern
|
|
617
|
+
matches = sorted(base.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
618
|
+
if not matches:
|
|
619
|
+
return f"No files matching '{pattern}' in {_display_path(base)} [workspace: {WORKSPACE_DIR}]"
|
|
620
|
+
header = f"[searched in: {_display_path(base)}, workspace: {WORKSPACE_DIR}]"
|
|
621
|
+
lines = [header] + [_display_path(m) for m in matches[:50]]
|
|
622
|
+
result = "\\n".join(lines)
|
|
623
|
+
if len(matches) > 50:
|
|
624
|
+
result += f"\\n... and {len(matches) - 50} more"
|
|
625
|
+
return result
|
|
626
|
+
except Exception as e:
|
|
627
|
+
return f"Error: {e}"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def run_grep(pattern: str, path: str = ".", include: str | None = None, max_results: int = 50) -> str:
|
|
631
|
+
try:
|
|
632
|
+
base = safe_path(path)
|
|
633
|
+
cmd = ["rg", "--no-heading", "--line-number", "--max-count", str(max_results), pattern, str(base)]
|
|
634
|
+
if include:
|
|
635
|
+
cmd.extend(["--glob", include])
|
|
636
|
+
try:
|
|
637
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
638
|
+
if r.returncode > 1:
|
|
639
|
+
err = r.stderr.strip() or "rg failed"
|
|
640
|
+
return f"Error: {err}"
|
|
641
|
+
out = r.stdout.strip()
|
|
642
|
+
if not out:
|
|
643
|
+
return f"No matches for '{pattern}'"
|
|
644
|
+
lines = out.splitlines()[:max_results]
|
|
645
|
+
return "\\n".join(lines)
|
|
646
|
+
except FileNotFoundError:
|
|
647
|
+
compiled = re.compile(pattern)
|
|
648
|
+
results = []
|
|
649
|
+
search_dir = base if base.is_dir() else base.parent
|
|
650
|
+
glob_pat = include or "**/*"
|
|
651
|
+
for fp in search_dir.glob(glob_pat):
|
|
652
|
+
if not fp.is_file():
|
|
653
|
+
continue
|
|
654
|
+
try:
|
|
655
|
+
for i, line in enumerate(fp.read_text().splitlines(), 1):
|
|
656
|
+
if compiled.search(line):
|
|
657
|
+
results.append(f"{_display_path(fp)}:{i}:{line.rstrip()}")
|
|
658
|
+
if len(results) >= max_results:
|
|
659
|
+
break
|
|
660
|
+
except (UnicodeDecodeError, PermissionError):
|
|
661
|
+
continue
|
|
662
|
+
if len(results) >= max_results:
|
|
663
|
+
break
|
|
664
|
+
return "\\n".join(results) if results else f"No matches for '{pattern}'"
|
|
665
|
+
except Exception as e:
|
|
666
|
+
return f"Error: {e}"
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def run_load_skill(name: str) -> str:
|
|
670
|
+
return SKILL_LOADER.get_content(name)
|
|
671
|
+
|
|
672
|
+
class BackgroundManager:
|
|
673
|
+
"""Background command runner with task tracking."""
|
|
674
|
+
|
|
675
|
+
def __init__(self) -> None:
|
|
676
|
+
self.tasks: dict[str, dict[str, Any]] = {}
|
|
677
|
+
self._lock = threading.Lock()
|
|
678
|
+
|
|
679
|
+
def run(self, command: str) -> str:
|
|
680
|
+
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
|
681
|
+
if any(d in command for d in dangerous):
|
|
682
|
+
return (
|
|
683
|
+
"Error: Dangerous command blocked.\n"
|
|
684
|
+
"Background tasks do not support interactive or privileged commands "
|
|
685
|
+
"(like sudo / shutdown). Please run these manually in your own shell."
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
scope_err = _validate_bash_command_scope(command)
|
|
689
|
+
if scope_err:
|
|
690
|
+
return scope_err
|
|
691
|
+
|
|
692
|
+
task_id = str(uuid.uuid4())[:8]
|
|
693
|
+
with self._lock:
|
|
694
|
+
self.tasks[task_id] = {"status": "running", "result": None, "command": command}
|
|
695
|
+
|
|
696
|
+
threading.Thread(target=self._execute, args=(task_id, command), daemon=True).start()
|
|
697
|
+
return f"Background task {task_id} started: {command[:80]}"
|
|
698
|
+
|
|
699
|
+
def _execute(self, task_id: str, command: str) -> None:
|
|
700
|
+
try:
|
|
701
|
+
r = subprocess.run(
|
|
702
|
+
command,
|
|
703
|
+
shell=True,
|
|
704
|
+
cwd=str(WORKSPACE_DIR),
|
|
705
|
+
capture_output=True,
|
|
706
|
+
text=True,
|
|
707
|
+
timeout=300,
|
|
708
|
+
)
|
|
709
|
+
output = (r.stdout + r.stderr).strip()[:50000]
|
|
710
|
+
status = "completed"
|
|
711
|
+
except subprocess.TimeoutExpired:
|
|
712
|
+
output = "Error: Timeout (300s)"
|
|
713
|
+
status = "timeout"
|
|
714
|
+
except Exception as e: # pragma: no cover
|
|
715
|
+
output = f"Error: {e}"
|
|
716
|
+
status = "error"
|
|
717
|
+
|
|
718
|
+
with self._lock:
|
|
719
|
+
task = self.tasks.get(task_id)
|
|
720
|
+
if task is not None:
|
|
721
|
+
task["status"] = status
|
|
722
|
+
task["result"] = output or "(no output)"
|
|
723
|
+
|
|
724
|
+
def check(self, task_id: str | None = None) -> str:
|
|
725
|
+
with self._lock:
|
|
726
|
+
if task_id:
|
|
727
|
+
task = self.tasks.get(task_id)
|
|
728
|
+
if not task:
|
|
729
|
+
return f"Error: Unknown task {task_id}"
|
|
730
|
+
return f"[{task['status']}] {task['command'][:60]}\n{task.get('result') or '(running)'}"
|
|
731
|
+
|
|
732
|
+
lines: list[str] = []
|
|
733
|
+
for tid, task in self.tasks.items():
|
|
734
|
+
lines.append(f"{tid}: [{task['status']}] {task['command'][:60]}")
|
|
735
|
+
return "\n".join(lines) if lines else "No background tasks."
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
BG = BackgroundManager()
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def run_background(command: str) -> str:
|
|
742
|
+
return BG.run(command)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def check_background(task_id: str | None = None) -> str:
|
|
746
|
+
return BG.check(task_id)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def run_todo(items: list[dict[str, Any]]) -> str:
|
|
750
|
+
return get_ctx().todo.update(items)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
# Default tool handlers copied from zero-code mapping style.
|
|
754
|
+
DEFAULT_TOOL_HANDLERS: dict[str, Any] = {
|
|
755
|
+
"bash": lambda **kw: run_bash(kw.get("command"), kw.get("restart", False), kw.get("timeout", 120)),
|
|
756
|
+
"read_file": lambda **kw: run_read(kw["path"], kw.get("offset"), kw.get("limit")),
|
|
757
|
+
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
|
|
758
|
+
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"], kw.get("replace_all", False)),
|
|
759
|
+
"apply_patch": lambda **kw: run_apply_patch(kw["path"], kw["patch"]),
|
|
760
|
+
"glob": lambda **kw: run_glob(kw["pattern"], kw.get("path", ".")),
|
|
761
|
+
"grep": lambda **kw: run_grep(kw["pattern"], kw.get("path", "."), kw.get("include"), kw.get("max_results", 50)),
|
|
762
|
+
"load_skill": lambda **kw: run_load_skill(kw["name"]),
|
|
763
|
+
"todo": lambda **kw: run_todo(kw["items"]),
|
|
764
|
+
"background_run": lambda **kw: run_background(kw["command"]),
|
|
765
|
+
"check_background": lambda **kw: check_background(kw.get("task_id")),
|
|
766
|
+
}
|