krnl-code 1.0.4__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.
- krnl_agent/__init__.py +9 -0
- krnl_agent/__main__.py +7 -0
- krnl_agent/agent_registry.py +95 -0
- krnl_agent/agent_selector.py +69 -0
- krnl_agent/audit_log.py +155 -0
- krnl_agent/background.py +94 -0
- krnl_agent/checkpoints.py +67 -0
- krnl_agent/ci.py +73 -0
- krnl_agent/cli.py +1458 -0
- krnl_agent/commands.py +42 -0
- krnl_agent/config.py +425 -0
- krnl_agent/context.py +352 -0
- krnl_agent/depaudit.py +63 -0
- krnl_agent/deploy.py +245 -0
- krnl_agent/doctor.py +106 -0
- krnl_agent/events.py +141 -0
- krnl_agent/gitignore.py +47 -0
- krnl_agent/graph.py +928 -0
- krnl_agent/guardrails.py +70 -0
- krnl_agent/headless.py +60 -0
- krnl_agent/history.py +49 -0
- krnl_agent/hooks.py +72 -0
- krnl_agent/ingest.py +129 -0
- krnl_agent/llm.py +456 -0
- krnl_agent/loop.py +779 -0
- krnl_agent/mcp_client.py +128 -0
- krnl_agent/memory.py +61 -0
- krnl_agent/modelrouter.py +151 -0
- krnl_agent/monitor.py +112 -0
- krnl_agent/notify.py +119 -0
- krnl_agent/parallel_executor.py +139 -0
- krnl_agent/permissions.py +128 -0
- krnl_agent/plugins.py +105 -0
- krnl_agent/pricing.py +85 -0
- krnl_agent/prompts.py +60 -0
- krnl_agent/repomap.py +133 -0
- krnl_agent/sandbox.py +69 -0
- krnl_agent/scaffold.py +167 -0
- krnl_agent/schedules.py +137 -0
- krnl_agent/secrets.py +100 -0
- krnl_agent/selfheal.py +87 -0
- krnl_agent/server.py +302 -0
- krnl_agent/sessions.py +258 -0
- krnl_agent/settings.py +59 -0
- krnl_agent/skills.py +73 -0
- krnl_agent/teams.py +38 -0
- krnl_agent/tool_schemas.py +431 -0
- krnl_agent/tools.py +694 -0
- krnl_agent/webtools.py +139 -0
- krnl_code-1.0.4.dist-info/METADATA +214 -0
- krnl_code-1.0.4.dist-info/RECORD +56 -0
- krnl_code-1.0.4.dist-info/WHEEL +5 -0
- krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
- krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
- krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
- krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/tools.py
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
"""Tool implementations. Every tool is workspace-sandboxed.
|
|
2
|
+
|
|
3
|
+
A tool returns a `ToolOutcome`:
|
|
4
|
+
* output — text fed back to the model
|
|
5
|
+
* ok — whether it succeeded
|
|
6
|
+
* diff — unified diff (for file-mutating tools, used for the approval UI)
|
|
7
|
+
* is_new — whether a file is being created (affects diff rendering)
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
import fnmatch
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import threading
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable, Optional
|
|
20
|
+
|
|
21
|
+
from .config import AgentConfig
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ToolOutcome:
|
|
26
|
+
output: str
|
|
27
|
+
ok: bool = True
|
|
28
|
+
diff: Optional[str] = None
|
|
29
|
+
is_new: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToolContext:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
workspace: str,
|
|
36
|
+
agent_cfg: AgentConfig,
|
|
37
|
+
ignore: list[str],
|
|
38
|
+
web: Optional[dict] = None,
|
|
39
|
+
deploy_cfg: Optional[dict] = None,
|
|
40
|
+
):
|
|
41
|
+
self.root = Path(workspace).resolve()
|
|
42
|
+
self.cfg = agent_cfg
|
|
43
|
+
self.ignore = ignore
|
|
44
|
+
# web search backend, e.g. {"provider": "duckduckgo", "api_key": ""}
|
|
45
|
+
self.web = web or {}
|
|
46
|
+
# auto-deploy policy (free_tier_only / allow_billable / cost_ceiling_usd)
|
|
47
|
+
self.deploy_cfg = deploy_cfg or {}
|
|
48
|
+
|
|
49
|
+
# --- sandbox helpers --------------------------------------------------- #
|
|
50
|
+
def resolve(self, rel: str) -> Path:
|
|
51
|
+
full = (self.root / rel).resolve()
|
|
52
|
+
try:
|
|
53
|
+
full.relative_to(self.root)
|
|
54
|
+
except ValueError:
|
|
55
|
+
raise PermissionError(f"Path '{rel}' is outside the workspace.")
|
|
56
|
+
return full
|
|
57
|
+
|
|
58
|
+
def rel(self, full: Path) -> str:
|
|
59
|
+
return str(full.relative_to(self.root)).replace(os.sep, "/")
|
|
60
|
+
|
|
61
|
+
def is_ignored(self, rel_path: str) -> bool:
|
|
62
|
+
rel_path = rel_path.replace(os.sep, "/")
|
|
63
|
+
for pat in self.ignore:
|
|
64
|
+
if fnmatch.fnmatch(rel_path, pat) or fnmatch.fnmatch(
|
|
65
|
+
rel_path, pat.rstrip("/*") + "/*"
|
|
66
|
+
):
|
|
67
|
+
return True
|
|
68
|
+
# match a directory prefix like ".git/**"
|
|
69
|
+
top = pat.split("/", 1)[0]
|
|
70
|
+
if rel_path == top or rel_path.startswith(top + "/"):
|
|
71
|
+
if "**" in pat or "/" in pat:
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_binary(path: Path) -> bool:
|
|
77
|
+
"""Heuristic: a NUL byte in the first 4 KB means it's not text."""
|
|
78
|
+
try:
|
|
79
|
+
with open(path, "rb") as f:
|
|
80
|
+
return b"\x00" in f.read(4096)
|
|
81
|
+
except Exception:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- #
|
|
86
|
+
# Read-only tools
|
|
87
|
+
# --------------------------------------------------------------------------- #
|
|
88
|
+
def list_files(ctx: ToolContext, path: str = ".") -> ToolOutcome:
|
|
89
|
+
base = ctx.resolve(path)
|
|
90
|
+
if not base.exists():
|
|
91
|
+
return ToolOutcome(f"Path not found: {path}", ok=False)
|
|
92
|
+
rows: list[str] = []
|
|
93
|
+
for root, dirs, files in os.walk(base):
|
|
94
|
+
# prune ignored dirs in-place for speed
|
|
95
|
+
rroot = ctx.rel(Path(root))
|
|
96
|
+
dirs[:] = [
|
|
97
|
+
d for d in sorted(dirs) if not ctx.is_ignored(f"{rroot}/{d}".lstrip("./"))
|
|
98
|
+
]
|
|
99
|
+
for f in sorted(files):
|
|
100
|
+
rp = ctx.rel(Path(root) / f)
|
|
101
|
+
if not ctx.is_ignored(rp):
|
|
102
|
+
rows.append(rp)
|
|
103
|
+
if len(rows) > 800:
|
|
104
|
+
rows.append("... (truncated)")
|
|
105
|
+
break
|
|
106
|
+
return ToolOutcome("\n".join(rows) if rows else "(empty)")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_file(
|
|
110
|
+
ctx: ToolContext,
|
|
111
|
+
path: str,
|
|
112
|
+
start_line: Optional[int] = None,
|
|
113
|
+
end_line: Optional[int] = None,
|
|
114
|
+
) -> ToolOutcome:
|
|
115
|
+
if ctx.is_ignored(path):
|
|
116
|
+
return ToolOutcome(f"Refusing to read ignored/secret file: {path}", ok=False)
|
|
117
|
+
full = ctx.resolve(path)
|
|
118
|
+
if not full.is_file():
|
|
119
|
+
return ToolOutcome(f"File not found: {path}", ok=False)
|
|
120
|
+
if full.stat().st_size > ctx.cfg.max_file_bytes:
|
|
121
|
+
return ToolOutcome(
|
|
122
|
+
f"File too large ({full.stat().st_size} bytes > "
|
|
123
|
+
f"{ctx.cfg.max_file_bytes}). Read a line range instead.",
|
|
124
|
+
ok=False,
|
|
125
|
+
)
|
|
126
|
+
if _is_binary(full):
|
|
127
|
+
return ToolOutcome(
|
|
128
|
+
f"{path} appears to be a binary/non-text file; cannot read as text.",
|
|
129
|
+
ok=False,
|
|
130
|
+
)
|
|
131
|
+
text = full.read_text(encoding="utf-8", errors="replace")
|
|
132
|
+
lines = text.splitlines()
|
|
133
|
+
if start_line or end_line:
|
|
134
|
+
s = max(1, start_line or 1)
|
|
135
|
+
e = min(len(lines), end_line or len(lines))
|
|
136
|
+
snippet = lines[s - 1 : e]
|
|
137
|
+
numbered = "\n".join(f"{s + i}\t{ln}" for i, ln in enumerate(snippet))
|
|
138
|
+
return ToolOutcome(numbered or "(empty range)")
|
|
139
|
+
numbered = "\n".join(f"{i + 1}\t{ln}" for i, ln in enumerate(lines))
|
|
140
|
+
return ToolOutcome(numbered or "(empty file)")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def search_text(ctx: ToolContext, query: str, glob: Optional[str] = None) -> ToolOutcome:
|
|
144
|
+
rg = shutil.which("rg")
|
|
145
|
+
if rg:
|
|
146
|
+
cmd = [rg, "--line-number", "--no-heading", "--color", "never", "-S"]
|
|
147
|
+
if glob:
|
|
148
|
+
cmd += ["--glob", glob]
|
|
149
|
+
cmd += [query, "."]
|
|
150
|
+
try:
|
|
151
|
+
res = subprocess.run(
|
|
152
|
+
cmd, cwd=ctx.root, capture_output=True, text=True, timeout=20
|
|
153
|
+
)
|
|
154
|
+
out = res.stdout.strip()
|
|
155
|
+
return ToolOutcome(out[:8000] if out else "(no matches)")
|
|
156
|
+
except Exception:
|
|
157
|
+
pass # fall through to python implementation
|
|
158
|
+
# pure-python fallback
|
|
159
|
+
hits: list[str] = []
|
|
160
|
+
for root, dirs, files in os.walk(ctx.root):
|
|
161
|
+
dirs[:] = [
|
|
162
|
+
d
|
|
163
|
+
for d in dirs
|
|
164
|
+
if not ctx.is_ignored(f"{ctx.rel(Path(root))}/{d}".lstrip("./"))
|
|
165
|
+
]
|
|
166
|
+
for f in files:
|
|
167
|
+
rp = ctx.rel(Path(root) / f)
|
|
168
|
+
if ctx.is_ignored(rp) or (glob and not fnmatch.fnmatch(f, glob)):
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
with open(Path(root) / f, encoding="utf-8", errors="ignore") as fh:
|
|
172
|
+
for i, line in enumerate(fh, 1):
|
|
173
|
+
if query in line:
|
|
174
|
+
hits.append(f"{rp}:{i}:{line.rstrip()}")
|
|
175
|
+
if len(hits) >= 200:
|
|
176
|
+
return ToolOutcome("\n".join(hits) + "\n... (truncated)")
|
|
177
|
+
except Exception:
|
|
178
|
+
continue
|
|
179
|
+
return ToolOutcome("\n".join(hits) if hits else "(no matches)")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def glob_files(ctx: ToolContext, pattern: str, path: str = ".") -> ToolOutcome:
|
|
183
|
+
"""Find files by glob pattern (supports ** for recursion)."""
|
|
184
|
+
base = ctx.resolve(path)
|
|
185
|
+
if not base.exists():
|
|
186
|
+
return ToolOutcome(f"Path not found: {path}", ok=False)
|
|
187
|
+
matches: list[str] = []
|
|
188
|
+
try:
|
|
189
|
+
for p in sorted(base.glob(pattern)):
|
|
190
|
+
if p.is_file():
|
|
191
|
+
rel = ctx.rel(p)
|
|
192
|
+
if not ctx.is_ignored(rel):
|
|
193
|
+
matches.append(rel)
|
|
194
|
+
if len(matches) >= 500:
|
|
195
|
+
matches.append("... (truncated)")
|
|
196
|
+
break
|
|
197
|
+
except Exception as e: # noqa: BLE001
|
|
198
|
+
return ToolOutcome(f"glob error: {e}", ok=False)
|
|
199
|
+
return ToolOutcome("\n".join(matches) if matches else "(no matches)")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# --------------------------------------------------------------------------- #
|
|
203
|
+
# Code intelligence / security (read-only)
|
|
204
|
+
# --------------------------------------------------------------------------- #
|
|
205
|
+
def repo_map(ctx: ToolContext, path: str = ".", lang: str = "") -> ToolOutcome:
|
|
206
|
+
"""Compact symbol/outline map of the repo — read this before reading files."""
|
|
207
|
+
from . import repomap
|
|
208
|
+
|
|
209
|
+
return ToolOutcome(repomap.build_map(ctx, path, lang or None))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def secret_scan(ctx: ToolContext, path: str = ".") -> ToolOutcome:
|
|
213
|
+
"""Scan the workspace for hard-coded secrets/credentials."""
|
|
214
|
+
from . import secrets
|
|
215
|
+
|
|
216
|
+
count, report = secrets.scan(ctx, path)
|
|
217
|
+
return ToolOutcome(report, ok=(count == 0))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def dependency_audit(ctx: ToolContext) -> ToolOutcome:
|
|
221
|
+
"""Audit project dependencies for known vulnerabilities."""
|
|
222
|
+
from . import depaudit
|
|
223
|
+
|
|
224
|
+
clean, report = depaudit.audit(ctx)
|
|
225
|
+
return ToolOutcome(report, ok=clean)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# --------------------------------------------------------------------------- #
|
|
229
|
+
# Deploy / monitor / self-heal
|
|
230
|
+
# --------------------------------------------------------------------------- #
|
|
231
|
+
def deploy_check(ctx: ToolContext) -> ToolOutcome:
|
|
232
|
+
"""List deploy targets and whether each is ready (CLI installed + token set)."""
|
|
233
|
+
from . import deploy as _dep
|
|
234
|
+
|
|
235
|
+
rows = _dep.readiness()
|
|
236
|
+
ready = [r for r in rows if r["ready"]]
|
|
237
|
+
lines = [f"Deploy targets ({len(ready)} ready):"]
|
|
238
|
+
for r in rows:
|
|
239
|
+
mark = "✔" if r["ready"] else ("·" if r["cli_ok"] else "✗")
|
|
240
|
+
tier = "free" if r["free_tier"] and not r["billable"] else "billable"
|
|
241
|
+
lines.append(f" {mark} {r['name']:<14} [{r['kind']}/{tier}] cli={r['cli']} "
|
|
242
|
+
f"token={r['token_env']} {'READY' if r['ready'] else ''}")
|
|
243
|
+
lines.append("\nSuggested default: " + (_dep.suggest() or "(none)"))
|
|
244
|
+
return ToolOutcome("\n".join(lines))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def deploy(ctx: ToolContext, target: str, params: Optional[dict] = None) -> ToolOutcome:
|
|
248
|
+
"""Deploy the current project to a target (gated by the spend policy)."""
|
|
249
|
+
from . import deploy as _dep
|
|
250
|
+
from . import guardrails
|
|
251
|
+
|
|
252
|
+
decision, reason = guardrails.spend_gate(ctx.deploy_cfg, target)
|
|
253
|
+
if decision == "deny":
|
|
254
|
+
return ToolOutcome(f"Deploy to {target} blocked: {reason}", ok=False)
|
|
255
|
+
ok, cmd = _dep.deploy_command(target, params)
|
|
256
|
+
if not ok:
|
|
257
|
+
return ToolOutcome(cmd, ok=False)
|
|
258
|
+
if decision == "ask":
|
|
259
|
+
# Surface to the agent; the run_command approval gate is the human checkpoint.
|
|
260
|
+
cmd = cmd # the agent will run this via run_command after confirming with the user
|
|
261
|
+
out = run_command(ctx, cmd)
|
|
262
|
+
note = f"[spend-gate: {reason}]\n"
|
|
263
|
+
return ToolOutcome(note + out.output, ok=out.ok)
|
|
264
|
+
out = run_command(ctx, cmd)
|
|
265
|
+
return ToolOutcome(out.output, ok=out.ok)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def rollback(ctx: ToolContext, target: str, params: Optional[dict] = None) -> ToolOutcome:
|
|
269
|
+
"""Roll a target back to its previous known-good release (safe to automate)."""
|
|
270
|
+
from . import deploy as _dep
|
|
271
|
+
|
|
272
|
+
ok, cmd = _dep.rollback_command(target, params)
|
|
273
|
+
if not ok:
|
|
274
|
+
return ToolOutcome(cmd, ok=False)
|
|
275
|
+
return run_command(ctx, cmd)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def provision_db(ctx: ToolContext, provider: str, params: Optional[dict] = None) -> ToolOutcome:
|
|
279
|
+
"""Provision a managed database (neon / supabase)."""
|
|
280
|
+
from . import deploy as _dep
|
|
281
|
+
|
|
282
|
+
ok, cmd = _dep.deploy_command(provider, params)
|
|
283
|
+
if not ok:
|
|
284
|
+
return ToolOutcome(cmd, ok=False)
|
|
285
|
+
return run_command(ctx, cmd)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def monitor_status(ctx: ToolContext) -> ToolOutcome:
|
|
289
|
+
"""Report configured monitoring providers and current error/uptime status."""
|
|
290
|
+
from . import monitor
|
|
291
|
+
|
|
292
|
+
return ToolOutcome(monitor.status())
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def health_check(ctx: ToolContext, url: str, path: str = "/health") -> ToolOutcome:
|
|
296
|
+
"""Check a deployed app's health endpoint."""
|
|
297
|
+
from . import selfheal
|
|
298
|
+
|
|
299
|
+
healthy, detail = selfheal.check_health(url, path)
|
|
300
|
+
return ToolOutcome(detail, ok=healthy)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# --------------------------------------------------------------------------- #
|
|
304
|
+
# Mutating tools (gated by approval in the loop)
|
|
305
|
+
# --------------------------------------------------------------------------- #
|
|
306
|
+
def _unified(path: str, before: str, after: str) -> str:
|
|
307
|
+
return "".join(
|
|
308
|
+
difflib.unified_diff(
|
|
309
|
+
before.splitlines(keepends=True),
|
|
310
|
+
after.splitlines(keepends=True),
|
|
311
|
+
fromfile=f"a/{path}",
|
|
312
|
+
tofile=f"b/{path}",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _prepare_write(ctx: ToolContext, path: str, content: str) -> ToolOutcome:
|
|
318
|
+
"""Build the diff/preview WITHOUT writing (used to ask for approval)."""
|
|
319
|
+
if ctx.is_ignored(path):
|
|
320
|
+
return ToolOutcome(f"Refusing to write ignored/secret path: {path}", ok=False)
|
|
321
|
+
full = ctx.resolve(path)
|
|
322
|
+
is_new = not full.exists()
|
|
323
|
+
before = "" if is_new else full.read_text(encoding="utf-8", errors="replace")
|
|
324
|
+
patch = _unified(path, before, content)
|
|
325
|
+
if not patch and not is_new:
|
|
326
|
+
return ToolOutcome(f"No change: {path} already has that content.", ok=True)
|
|
327
|
+
return ToolOutcome(output="", ok=True, diff=patch, is_new=is_new)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def write_file(ctx: ToolContext, path: str, content: str) -> ToolOutcome:
|
|
331
|
+
pre = _prepare_write(ctx, path, content)
|
|
332
|
+
if not pre.ok:
|
|
333
|
+
return pre
|
|
334
|
+
full = ctx.resolve(path)
|
|
335
|
+
full.parent.mkdir(parents=True, exist_ok=True)
|
|
336
|
+
full.write_text(content, encoding="utf-8")
|
|
337
|
+
return ToolOutcome(f"Wrote {path} ({len(content)} bytes).", diff=pre.diff, is_new=pre.is_new)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def edit_file(
|
|
341
|
+
ctx: ToolContext,
|
|
342
|
+
path: str,
|
|
343
|
+
old_string: str,
|
|
344
|
+
new_string: str,
|
|
345
|
+
replace_all: bool = False,
|
|
346
|
+
) -> ToolOutcome:
|
|
347
|
+
if ctx.is_ignored(path):
|
|
348
|
+
return ToolOutcome(f"Refusing to edit ignored/secret path: {path}", ok=False)
|
|
349
|
+
full = ctx.resolve(path)
|
|
350
|
+
if not full.is_file():
|
|
351
|
+
return ToolOutcome(f"File not found: {path}", ok=False)
|
|
352
|
+
before = full.read_text(encoding="utf-8", errors="replace")
|
|
353
|
+
count = before.count(old_string)
|
|
354
|
+
if count == 0:
|
|
355
|
+
return ToolOutcome(
|
|
356
|
+
f"`old_string` not found in {path}. Re-read the file; it must match "
|
|
357
|
+
f"exactly (whitespace included).",
|
|
358
|
+
ok=False,
|
|
359
|
+
)
|
|
360
|
+
if count > 1 and not replace_all:
|
|
361
|
+
return ToolOutcome(
|
|
362
|
+
f"`old_string` is not unique in {path} ({count} matches). Add more "
|
|
363
|
+
f"surrounding context or set replace_all=true.",
|
|
364
|
+
ok=False,
|
|
365
|
+
)
|
|
366
|
+
after = before.replace(old_string, new_string)
|
|
367
|
+
patch = _unified(path, before, after)
|
|
368
|
+
full.write_text(after, encoding="utf-8")
|
|
369
|
+
n = count if replace_all else 1
|
|
370
|
+
return ToolOutcome(f"Edited {path} ({n} replacement(s)).", diff=patch)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _apply_edits(before: str, edits: list, path: str):
|
|
374
|
+
"""Apply a list of {old_string,new_string,replace_all} edits. Returns
|
|
375
|
+
(after, error). `error` is None on success."""
|
|
376
|
+
text = before
|
|
377
|
+
for i, e in enumerate(edits):
|
|
378
|
+
old = e.get("old_string", "")
|
|
379
|
+
new = e.get("new_string", "")
|
|
380
|
+
if not old:
|
|
381
|
+
return text, f"edit {i}: empty old_string"
|
|
382
|
+
cnt = text.count(old)
|
|
383
|
+
if cnt == 0:
|
|
384
|
+
return text, f"edit {i}: old_string not found in {path}"
|
|
385
|
+
if cnt > 1 and not e.get("replace_all"):
|
|
386
|
+
return text, f"edit {i}: old_string not unique ({cnt}); add context or replace_all"
|
|
387
|
+
text = text.replace(old, new) if e.get("replace_all") else text.replace(old, new, 1)
|
|
388
|
+
return text, None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def multi_edit(ctx: ToolContext, path: str, edits: list) -> ToolOutcome:
|
|
392
|
+
"""Apply several edits to one file atomically (all-or-nothing)."""
|
|
393
|
+
if ctx.is_ignored(path):
|
|
394
|
+
return ToolOutcome(f"Refusing to edit ignored/secret path: {path}", ok=False)
|
|
395
|
+
full = ctx.resolve(path)
|
|
396
|
+
if not full.is_file():
|
|
397
|
+
return ToolOutcome(f"File not found: {path}", ok=False)
|
|
398
|
+
before = full.read_text(encoding="utf-8", errors="replace")
|
|
399
|
+
after, err = _apply_edits(before, edits or [], path)
|
|
400
|
+
if err:
|
|
401
|
+
return ToolOutcome(err, ok=False)
|
|
402
|
+
patch = _unified(path, before, after)
|
|
403
|
+
full.write_text(after, encoding="utf-8")
|
|
404
|
+
return ToolOutcome(f"Applied {len(edits)} edit(s) to {path}.", diff=patch)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def create_file(ctx: ToolContext, path: str, content: str) -> ToolOutcome:
|
|
408
|
+
full = ctx.resolve(path)
|
|
409
|
+
if full.exists():
|
|
410
|
+
return ToolOutcome(f"File already exists: {path}. Use write_file/edit_file.", ok=False)
|
|
411
|
+
return write_file(ctx, path, content)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def delete_file(ctx: ToolContext, path: str) -> ToolOutcome:
|
|
415
|
+
if ctx.is_ignored(path):
|
|
416
|
+
return ToolOutcome(f"Refusing to delete ignored/secret path: {path}", ok=False)
|
|
417
|
+
full = ctx.resolve(path)
|
|
418
|
+
if not full.is_file():
|
|
419
|
+
return ToolOutcome(f"File not found: {path}", ok=False)
|
|
420
|
+
before = full.read_text(encoding="utf-8", errors="replace")
|
|
421
|
+
full.unlink()
|
|
422
|
+
return ToolOutcome(f"Deleted {path}.", diff=_unified(path, before, ""))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def run_command(
|
|
426
|
+
ctx: ToolContext,
|
|
427
|
+
command: str,
|
|
428
|
+
on_output: Optional[Callable[[str], None]] = None,
|
|
429
|
+
) -> ToolOutcome:
|
|
430
|
+
"""Run a shell command, streaming each output line to `on_output` if given.
|
|
431
|
+
|
|
432
|
+
Output is merged (stdout+stderr) and bounded by a watchdog timer so a hung
|
|
433
|
+
process is always killed after `max_command_seconds`.
|
|
434
|
+
"""
|
|
435
|
+
try:
|
|
436
|
+
proc = subprocess.Popen(
|
|
437
|
+
command,
|
|
438
|
+
cwd=ctx.root,
|
|
439
|
+
shell=True,
|
|
440
|
+
stdout=subprocess.PIPE,
|
|
441
|
+
stderr=subprocess.STDOUT,
|
|
442
|
+
text=True,
|
|
443
|
+
bufsize=1,
|
|
444
|
+
)
|
|
445
|
+
except Exception as e: # noqa: BLE001
|
|
446
|
+
return ToolOutcome(f"Failed to start command: {e}", ok=False)
|
|
447
|
+
|
|
448
|
+
killed = {"v": False}
|
|
449
|
+
|
|
450
|
+
def _kill():
|
|
451
|
+
killed["v"] = True
|
|
452
|
+
try:
|
|
453
|
+
proc.kill()
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
timer = threading.Timer(ctx.cfg.max_command_seconds, _kill)
|
|
458
|
+
timer.start()
|
|
459
|
+
captured: list[str] = []
|
|
460
|
+
try:
|
|
461
|
+
if proc.stdout:
|
|
462
|
+
for line in proc.stdout:
|
|
463
|
+
captured.append(line)
|
|
464
|
+
if on_output:
|
|
465
|
+
on_output(line)
|
|
466
|
+
proc.wait()
|
|
467
|
+
finally:
|
|
468
|
+
timer.cancel()
|
|
469
|
+
|
|
470
|
+
rc = proc.returncode
|
|
471
|
+
out = "".join(captured)
|
|
472
|
+
if len(out) > 8000:
|
|
473
|
+
out = "… (truncated)\n" + out[-8000:]
|
|
474
|
+
parts = [f"$ {command}", f"(exit {rc})"]
|
|
475
|
+
if killed["v"]:
|
|
476
|
+
parts.append(f"[timed out after {ctx.cfg.max_command_seconds}s — killed]")
|
|
477
|
+
if out.strip():
|
|
478
|
+
parts.append("--- output ---\n" + out)
|
|
479
|
+
return ToolOutcome("\n".join(parts), ok=(rc == 0 and not killed["v"]))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# --------------------------------------------------------------------------- #
|
|
483
|
+
# Web tools
|
|
484
|
+
# --------------------------------------------------------------------------- #
|
|
485
|
+
def web_fetch(ctx: ToolContext, url: str) -> ToolOutcome:
|
|
486
|
+
from . import webtools
|
|
487
|
+
|
|
488
|
+
ok, text = webtools.web_fetch(url, max_chars=ctx.cfg.max_file_bytes)
|
|
489
|
+
return ToolOutcome(text, ok=ok)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def web_search(ctx: ToolContext, query: str) -> ToolOutcome:
|
|
493
|
+
from . import webtools
|
|
494
|
+
|
|
495
|
+
provider = ctx.web.get("provider", "duckduckgo")
|
|
496
|
+
ok, text = webtools.web_search(query, provider=provider, api_key=ctx.web.get("api_key", ""))
|
|
497
|
+
return ToolOutcome(text, ok=ok)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# --------------------------------------------------------------------------- #
|
|
501
|
+
# Background processes
|
|
502
|
+
# --------------------------------------------------------------------------- #
|
|
503
|
+
def bash_background(ctx: ToolContext, command: str) -> ToolOutcome:
|
|
504
|
+
from .background import manager
|
|
505
|
+
|
|
506
|
+
pid = manager.start(str(ctx.root), command)
|
|
507
|
+
return ToolOutcome(f"Started background process {pid}: {command}")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def process_output(ctx: ToolContext, id: str, tail: int = 100) -> ToolOutcome:
|
|
511
|
+
from .background import manager
|
|
512
|
+
|
|
513
|
+
return ToolOutcome(manager.output(id, tail))
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def process_kill(ctx: ToolContext, id: str) -> ToolOutcome:
|
|
517
|
+
from .background import manager
|
|
518
|
+
|
|
519
|
+
return ToolOutcome(manager.kill(id))
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def process_list(ctx: ToolContext) -> ToolOutcome:
|
|
523
|
+
from .background import manager
|
|
524
|
+
|
|
525
|
+
return ToolOutcome(manager.list_())
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# --------------------------------------------------------------------------- #
|
|
529
|
+
# Git helpers (thin, safe wrappers)
|
|
530
|
+
# --------------------------------------------------------------------------- #
|
|
531
|
+
def _git(ctx: ToolContext, args: str) -> ToolOutcome:
|
|
532
|
+
return run_command(ctx, f"git {args}")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def git_status(ctx: ToolContext) -> ToolOutcome:
|
|
536
|
+
return _git(ctx, "status --short --branch")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def git_diff(ctx: ToolContext, path: str = "") -> ToolOutcome:
|
|
540
|
+
return _git(ctx, f"diff -- {path}" if path else "diff")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def git_commit(ctx: ToolContext, message: str, all: bool = True) -> ToolOutcome:
|
|
544
|
+
safe = message.replace('"', '\\"')
|
|
545
|
+
add = "git add -A && " if all else ""
|
|
546
|
+
return run_command(ctx, f'{add}git commit -m "{safe}"')
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# --------------------------------------------------------------------------- #
|
|
550
|
+
# Git worktrees — isolated copies to work safely in parallel
|
|
551
|
+
# --------------------------------------------------------------------------- #
|
|
552
|
+
def worktree_add(ctx: ToolContext, name: str, branch: str = "") -> ToolOutcome:
|
|
553
|
+
rel = f".krnl/worktrees/{name}"
|
|
554
|
+
flag = f"-b {branch} " if branch else ""
|
|
555
|
+
out = run_command(ctx, f"git worktree add {flag}{rel}")
|
|
556
|
+
if out.ok:
|
|
557
|
+
out.output += f"\n(isolated worktree at {rel}; run commands there with: cd {rel} && …)"
|
|
558
|
+
return out
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def worktree_list(ctx: ToolContext) -> ToolOutcome:
|
|
562
|
+
return run_command(ctx, "git worktree list")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def worktree_remove(ctx: ToolContext, name: str) -> ToolOutcome:
|
|
566
|
+
return run_command(ctx, f"git worktree remove .krnl/worktrees/{name} --force")
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# --------------------------------------------------------------------------- #
|
|
570
|
+
# CI / PR helpers
|
|
571
|
+
# --------------------------------------------------------------------------- #
|
|
572
|
+
def git_branch(ctx: ToolContext, name: str) -> ToolOutcome:
|
|
573
|
+
return run_command(ctx, f"git checkout -b {name}")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def git_push(ctx: ToolContext, branch: str = "") -> ToolOutcome:
|
|
577
|
+
target = branch or "HEAD"
|
|
578
|
+
return run_command(ctx, f"git push -u origin {target}")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def open_pr(ctx: ToolContext, title: str, body: str = "") -> ToolOutcome:
|
|
582
|
+
if not shutil.which("gh"):
|
|
583
|
+
return ToolOutcome("GitHub CLI (gh) not found — install it to open PRs.", ok=False)
|
|
584
|
+
safe_t = title.replace('"', '\\"')
|
|
585
|
+
safe_b = body.replace('"', '\\"')
|
|
586
|
+
return run_command(ctx, f'gh pr create --title "{safe_t}" --body "{safe_b}"')
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# --------------------------------------------------------------------------- #
|
|
590
|
+
# Dispatch table
|
|
591
|
+
# --------------------------------------------------------------------------- #
|
|
592
|
+
TOOL_FUNCS = {
|
|
593
|
+
"list_files": list_files,
|
|
594
|
+
"read_file": read_file,
|
|
595
|
+
"search_text": search_text,
|
|
596
|
+
"glob": glob_files,
|
|
597
|
+
"repo_map": repo_map,
|
|
598
|
+
"secret_scan": secret_scan,
|
|
599
|
+
"dependency_audit": dependency_audit,
|
|
600
|
+
"deploy_check": deploy_check,
|
|
601
|
+
"deploy": deploy,
|
|
602
|
+
"rollback": rollback,
|
|
603
|
+
"provision_db": provision_db,
|
|
604
|
+
"monitor_status": monitor_status,
|
|
605
|
+
"health_check": health_check,
|
|
606
|
+
"write_file": write_file,
|
|
607
|
+
"edit_file": edit_file,
|
|
608
|
+
"multi_edit": multi_edit,
|
|
609
|
+
"create_file": create_file,
|
|
610
|
+
"delete_file": delete_file,
|
|
611
|
+
"run_command": run_command,
|
|
612
|
+
"web_fetch": web_fetch,
|
|
613
|
+
"web_search": web_search,
|
|
614
|
+
"bash_background": bash_background,
|
|
615
|
+
"process_output": process_output,
|
|
616
|
+
"process_kill": process_kill,
|
|
617
|
+
"process_list": process_list,
|
|
618
|
+
"git_status": git_status,
|
|
619
|
+
"git_diff": git_diff,
|
|
620
|
+
"git_commit": git_commit,
|
|
621
|
+
"worktree_add": worktree_add,
|
|
622
|
+
"worktree_list": worktree_list,
|
|
623
|
+
"worktree_remove": worktree_remove,
|
|
624
|
+
"git_branch": git_branch,
|
|
625
|
+
"git_push": git_push,
|
|
626
|
+
"open_pr": open_pr,
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def preview_for(ctx: ToolContext, name: str, args: dict) -> Optional[str]:
|
|
631
|
+
"""Build a human-readable preview shown in the approval prompt."""
|
|
632
|
+
if "__raw__" in args:
|
|
633
|
+
return f"⚠ Failed to parse tool arguments as valid JSON. Raw content: {args['__raw__'][:300]}..."
|
|
634
|
+
try:
|
|
635
|
+
if name in ("write_file", "create_file"):
|
|
636
|
+
pre = _prepare_write(ctx, args.get("path", ""), args.get("content", ""))
|
|
637
|
+
return pre.diff or pre.output
|
|
638
|
+
if name == "edit_file":
|
|
639
|
+
full = ctx.resolve(args["path"])
|
|
640
|
+
if not full.is_file():
|
|
641
|
+
return f"(new edit target missing: {args['path']})"
|
|
642
|
+
before = full.read_text(encoding="utf-8", errors="replace")
|
|
643
|
+
old, new = args.get("old_string", ""), args.get("new_string", "")
|
|
644
|
+
if old not in before:
|
|
645
|
+
return f"⚠ old_string not found in {args['path']}"
|
|
646
|
+
after = before.replace(old, new, -1 if args.get("replace_all") else 1)
|
|
647
|
+
return _unified(args["path"], before, after)
|
|
648
|
+
if name == "multi_edit":
|
|
649
|
+
full = ctx.resolve(args["path"])
|
|
650
|
+
if not full.is_file():
|
|
651
|
+
return f"(edit target missing: {args['path']})"
|
|
652
|
+
before = full.read_text(encoding="utf-8", errors="replace")
|
|
653
|
+
after, err = _apply_edits(before, args.get("edits", []), args["path"])
|
|
654
|
+
return _unified(args["path"], before, after) if not err else f"⚠ {err}"
|
|
655
|
+
if name == "delete_file":
|
|
656
|
+
return f"DELETE {args.get('path')}"
|
|
657
|
+
if name == "run_command":
|
|
658
|
+
return f"$ {args.get('command')}"
|
|
659
|
+
if name == "bash_background":
|
|
660
|
+
return f"$ {args.get('command')} (background)"
|
|
661
|
+
if name == "git_commit":
|
|
662
|
+
return f"git commit -m \"{args.get('message', '')}\""
|
|
663
|
+
if name in ("deploy", "provision_db"):
|
|
664
|
+
from . import deploy as _dep
|
|
665
|
+
tgt = args.get("target") or args.get("provider", "")
|
|
666
|
+
ok, cmd = _dep.deploy_command(tgt, args.get("params"))
|
|
667
|
+
return f"DEPLOY {tgt}: $ {cmd}" if ok else f"⚠ {cmd}"
|
|
668
|
+
if name == "rollback":
|
|
669
|
+
from . import deploy as _dep
|
|
670
|
+
ok, cmd = _dep.rollback_command(args.get("target", ""), args.get("params"))
|
|
671
|
+
return f"ROLLBACK: $ {cmd}" if ok else f"⚠ {cmd}"
|
|
672
|
+
except Exception as e: # noqa: BLE001
|
|
673
|
+
return f"(could not build preview: {e})"
|
|
674
|
+
return None
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def execute(ctx: ToolContext, name: str, args: dict) -> ToolOutcome:
|
|
678
|
+
if "__raw__" in args:
|
|
679
|
+
return ToolOutcome(
|
|
680
|
+
"Failed to parse tool arguments as valid JSON. "
|
|
681
|
+
"Please ensure your tool arguments are valid JSON and all special characters/quotes are properly escaped.",
|
|
682
|
+
ok=False
|
|
683
|
+
)
|
|
684
|
+
fn = TOOL_FUNCS.get(name)
|
|
685
|
+
if not fn:
|
|
686
|
+
return ToolOutcome(f"Unknown tool: {name}", ok=False)
|
|
687
|
+
try:
|
|
688
|
+
return fn(ctx, **args)
|
|
689
|
+
except TypeError as e:
|
|
690
|
+
return ToolOutcome(f"Bad arguments for {name}: {e}", ok=False)
|
|
691
|
+
except PermissionError as e:
|
|
692
|
+
return ToolOutcome(str(e), ok=False)
|
|
693
|
+
except Exception as e: # noqa: BLE001
|
|
694
|
+
return ToolOutcome(f"Tool {name} failed: {type(e).__name__}: {e}", ok=False)
|