lcode-cli 0.1.1__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.
lcode/tools.py ADDED
@@ -0,0 +1,533 @@
1
+ """The tools the model can call: file reading/editing, search, shell and a todo list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import fnmatch
7
+ import json
8
+ import os
9
+ import queue
10
+ import re
11
+ import shlex
12
+ import shutil
13
+ import signal
14
+ import subprocess
15
+ import tempfile
16
+ import threading
17
+ import time
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ from rich.panel import Panel
22
+ from rich.syntax import Syntax
23
+ from rich.text import Text
24
+
25
+ from lcode.permissions import bash_key, is_read_only
26
+
27
+ if TYPE_CHECKING:
28
+ from lcode.agent import Agent
29
+
30
+ MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call
31
+ IGNORE_DIRS = {
32
+ ".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache",
33
+ ".ruff_cache", ".tox", ".idea", ".vscode", "dist", "build", ".next", "target", ".cache", ".gradle",
34
+ "site-packages", ".eggs", ".DS_Store",
35
+ } # fmt: skip
36
+
37
+
38
+ def _fn(name: str, description: str, properties: dict, required: list[str]) -> dict:
39
+ return {
40
+ "type": "function",
41
+ "function": {
42
+ "name": name,
43
+ "description": description,
44
+ "parameters": {"type": "object", "properties": properties, "required": required},
45
+ },
46
+ }
47
+
48
+
49
+ SCHEMAS = [
50
+ _fn(
51
+ "read_file",
52
+ "Read a text file. Returns lines prefixed with line numbers (cat -n style). Reads up to `limit` lines "
53
+ "starting at `offset` (1-based). Read a file before editing it.",
54
+ {
55
+ "path": {"type": "string", "description": "File path, relative to the working directory or absolute"},
56
+ "offset": {"type": "integer", "description": "1-based line to start from (default 1)"},
57
+ "limit": {"type": "integer", "description": "Maximum lines to read (default 2000)"},
58
+ },
59
+ ["path"],
60
+ ),
61
+ _fn(
62
+ "write_file",
63
+ "Create a new file or completely overwrite an existing one with `content`. Parent directories are "
64
+ "created. For small changes to existing files prefer edit_file.",
65
+ {"path": {"type": "string"}, "content": {"type": "string", "description": "Full file content"}},
66
+ ["path", "content"],
67
+ ),
68
+ _fn(
69
+ "edit_file",
70
+ "Replace `old_string` with `new_string` in a file. `old_string` must match the file exactly (including "
71
+ "indentation) and be unique unless replace_all is true: include enough surrounding lines to make it "
72
+ "unique. Do NOT include the line-number prefixes from read_file output.",
73
+ {
74
+ "path": {"type": "string"},
75
+ "old_string": {"type": "string", "description": "Exact text to replace"},
76
+ "new_string": {"type": "string", "description": "Replacement text"},
77
+ "replace_all": {"type": "boolean", "description": "Replace every occurrence (default false)"},
78
+ },
79
+ ["path", "old_string", "new_string"],
80
+ ),
81
+ _fn(
82
+ "list_dir",
83
+ "Show a directory tree (skips .git, node_modules, virtualenvs and caches).",
84
+ {
85
+ "path": {"type": "string", "description": "Directory (default: working directory)"},
86
+ "depth": {"type": "integer", "description": "Maximum depth (default 2)"},
87
+ },
88
+ [],
89
+ ),
90
+ _fn(
91
+ "glob",
92
+ "Find files by glob pattern, e.g. '**/*.py' or 'src/**/test_*.ts'. Newest first.",
93
+ {"pattern": {"type": "string"}, "path": {"type": "string", "description": "Base directory"}},
94
+ ["pattern"],
95
+ ),
96
+ _fn(
97
+ "grep",
98
+ "Search file contents with a regular expression. Returns path:line:text matches.",
99
+ {
100
+ "pattern": {"type": "string", "description": "Regular expression"},
101
+ "path": {"type": "string", "description": "File or directory to search (default: working directory)"},
102
+ "glob": {"type": "string", "description": "Only search files matching this glob, e.g. '*.py'"},
103
+ "ignore_case": {"type": "boolean"},
104
+ "context": {"type": "integer", "description": "Lines of context around each match"},
105
+ },
106
+ ["pattern"],
107
+ ),
108
+ _fn(
109
+ "bash",
110
+ "Run a shell command with bash in the working directory and return its output and exit code. The "
111
+ "working directory persists between calls (cd works). Use it to run scripts and tests, use git, install "
112
+ "packages, etc. Avoid interactive commands.",
113
+ {
114
+ "command": {"type": "string"},
115
+ "timeout": {"type": "integer", "description": "Seconds before the command is killed (default 180)"},
116
+ },
117
+ ["command"],
118
+ ),
119
+ _fn(
120
+ "todo_write",
121
+ "Create or update your task list for multi-step work. Pass the full list every time. Keep exactly one "
122
+ "item in_progress while working.",
123
+ {
124
+ "todos": {
125
+ "type": "array",
126
+ "items": {
127
+ "type": "object",
128
+ "properties": {
129
+ "content": {"type": "string"},
130
+ "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
131
+ },
132
+ "required": ["content", "status"],
133
+ },
134
+ }
135
+ },
136
+ ["todos"],
137
+ ),
138
+ ]
139
+ TOOL_NAMES = {s["function"]["name"] for s in SCHEMAS}
140
+
141
+
142
+ # ----------------------------------------------------------------------------- helpers
143
+
144
+
145
+ def truncate(text: str, limit: int = MAX_TOOL_OUTPUT) -> str:
146
+ if len(text) <= limit:
147
+ return text
148
+ head, tail = text[: limit * 2 // 3], text[-limit // 3 :]
149
+ return f"{head}\n\n... [{len(text) - len(head) - len(tail)} characters truncated] ...\n\n{tail}"
150
+
151
+
152
+ def is_binary(path: Path) -> bool:
153
+ try:
154
+ with open(path, "rb") as f:
155
+ return b"\0" in f.read(8192)
156
+ except OSError:
157
+ return False
158
+
159
+
160
+ def walk_files(base: Path):
161
+ for root, dirs, files in os.walk(base):
162
+ dirs[:] = sorted(d for d in dirs if d not in IGNORE_DIRS and not d.endswith(".egg-info"))
163
+ for name in sorted(files):
164
+ if name not in IGNORE_DIRS:
165
+ yield Path(root) / name
166
+
167
+
168
+ def tree(base: Path, depth: int = 2, limit: int = 400) -> str:
169
+ lines, count = [f"{base}/"], 0
170
+
171
+ def walk(directory: Path, prefix: str, level: int) -> None:
172
+ nonlocal count
173
+ try:
174
+ entries = sorted(directory.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
175
+ except OSError:
176
+ return
177
+ entries = [e for e in entries if e.name not in IGNORE_DIRS]
178
+ for i, entry in enumerate(entries):
179
+ if count >= limit:
180
+ lines.append(f"{prefix}... (truncated)")
181
+ return
182
+ count += 1
183
+ last = i == len(entries) - 1
184
+ lines.append(f"{prefix}{'└── ' if last else '├── '}{entry.name}{'/' if entry.is_dir() else ''}")
185
+ if entry.is_dir() and level < depth:
186
+ walk(entry, prefix + (" " if last else "│ "), level + 1)
187
+
188
+ walk(base, "", 1)
189
+ return "\n".join(lines)
190
+
191
+
192
+ def fuzzy_replace(text: str, old: str, new: str) -> str | None:
193
+ """Replace `old` with `new` matching lines while ignoring trailing whitespace. None unless exactly one match."""
194
+ t_lines = text.splitlines(keepends=True)
195
+ o_lines = [line.rstrip() for line in old.strip("\n").splitlines()]
196
+ if not o_lines:
197
+ return None
198
+ n = len(o_lines)
199
+ matches = [i for i in range(len(t_lines) - n + 1) if [ln.rstrip() for ln in t_lines[i : i + n]] == o_lines]
200
+ if len(matches) != 1:
201
+ return None
202
+ i = matches[0]
203
+ tail = "\n" if t_lines[i + n - 1].endswith("\n") else ""
204
+ replacement = new.strip("\n") + tail if new.strip("\n") else ""
205
+ return "".join(t_lines[:i]) + replacement + "".join(t_lines[i + n :])
206
+
207
+
208
+ def parse_text_tool_calls(content: str) -> list[dict]:
209
+ """Recover tool calls that a model printed as text instead of emitting structured calls."""
210
+ calls = []
211
+ for block in re.findall(r"<tool_call>(.*?)</tool_call>", content, re.S):
212
+ block = block.strip()
213
+ fn = re.search(r"<function=([\w.-]+)>(.*?)</function>", block, re.S)
214
+ if fn:
215
+ params = re.findall(r"<parameter=([\w.-]+)>(.*?)</parameter>", fn.group(2), re.S)
216
+ calls.append({"function": {"name": fn.group(1), "arguments": {k: v.strip("\n") for k, v in params}}})
217
+ continue
218
+ try:
219
+ obj = json.loads(block)
220
+ calls.append({"function": {"name": obj["name"], "arguments": obj.get("arguments", {})}})
221
+ except (json.JSONDecodeError, KeyError, TypeError):
222
+ pass
223
+ if calls:
224
+ return calls
225
+ # Bare JSON objects such as {"name": "grep", "arguments": {...}}, possibly inside ``` fences.
226
+ decoder = json.JSONDecoder()
227
+ for m in re.finditer(r"\{", content):
228
+ try:
229
+ obj, _ = decoder.raw_decode(content, m.start())
230
+ except json.JSONDecodeError:
231
+ continue
232
+ if isinstance(obj, dict) and obj.get("name") in TOOL_NAMES and isinstance(obj.get("arguments", {}), dict):
233
+ calls.append({"function": {"name": obj["name"], "arguments": obj.get("arguments", {})}})
234
+ return calls
235
+
236
+
237
+ class ToolError(Exception):
238
+ pass
239
+
240
+
241
+ # ----------------------------------------------------------------------------- toolbox
242
+
243
+
244
+ class Toolbox:
245
+ def __init__(self, agent: Agent):
246
+ self.agent = agent
247
+ self.read_mtimes: dict[str, float] = {}
248
+ self.todos: list[dict] = []
249
+
250
+ @property
251
+ def console(self):
252
+ return self.agent.console
253
+
254
+ def resolve(self, path: str | None) -> Path:
255
+ p = Path(os.path.expanduser(path or "."))
256
+ return (p if p.is_absolute() else self.agent.cwd / p).resolve()
257
+
258
+ def rel(self, p: Path) -> str:
259
+ try:
260
+ return str(p.relative_to(self.agent.cwd)) or "."
261
+ except ValueError:
262
+ return str(p)
263
+
264
+ def run(self, name: str, args: dict) -> str:
265
+ fn = getattr(self, f"t_{name}", None)
266
+ if fn is None:
267
+ return f"Error: unknown tool '{name}'. Available: {', '.join(sorted(TOOL_NAMES))}"
268
+ try:
269
+ return fn(**args)
270
+ except ToolError as e:
271
+ return f"Error: {e}"
272
+ except TypeError as e:
273
+ return f"Error: bad arguments for {name}: {e}"
274
+ except Exception as e: # surface every failure to the model instead of crashing the session
275
+ return f"Error: {type(e).__name__}: {e}"
276
+
277
+ # -- read-only
278
+ def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str:
279
+ p = self.resolve(path)
280
+ if not p.exists():
281
+ raise ToolError(f"{path} does not exist")
282
+ if p.is_dir():
283
+ raise ToolError(f"{path} is a directory; use list_dir")
284
+ if is_binary(p):
285
+ raise ToolError(f"{path} is a binary file ({p.stat().st_size} bytes)")
286
+ lines = p.read_text(errors="replace").splitlines()
287
+ offset, limit = max(1, int(offset or 1)), max(1, int(limit or 2000))
288
+ chunk = lines[offset - 1 : offset - 1 + limit]
289
+ self.read_mtimes[str(p)] = p.stat().st_mtime
290
+ if not lines:
291
+ return "(empty file)"
292
+ out = "\n".join(f"{i:6}\t{line[:2000]}" for i, line in enumerate(chunk, start=offset))
293
+ end = offset - 1 + len(chunk)
294
+ if end < len(lines) or offset > 1:
295
+ out += f"\n\n[Showing lines {offset}-{end} of {len(lines)}. Use offset/limit to read more.]"
296
+ return truncate(out, 120_000)
297
+
298
+ def t_list_dir(self, path: str = ".", depth: int = 2) -> str:
299
+ p = self.resolve(path)
300
+ if not p.is_dir():
301
+ raise ToolError(f"{path} is not a directory")
302
+ return tree(p, depth=max(1, min(int(depth or 2), 6)))
303
+
304
+ def t_glob(self, pattern: str, path: str = ".") -> str:
305
+ base = self.resolve(path)
306
+ pattern = pattern.removeprefix("./")
307
+ patterns = [pattern] + ([pattern[3:]] if pattern.startswith("**/") else [])
308
+ hits = [
309
+ p for p in walk_files(base) if any(fnmatch.fnmatch(p.relative_to(base).as_posix(), x) for x in patterns)
310
+ ]
311
+ hits.sort(key=lambda p: p.stat().st_mtime, reverse=True)
312
+ if not hits:
313
+ return "No files found."
314
+ more = f"\n... and {len(hits) - 200} more" if len(hits) > 200 else ""
315
+ return "\n".join(self.rel(p) for p in hits[:200]) + more
316
+
317
+ def t_grep(
318
+ self, pattern: str, path: str = ".", glob: str | None = None, ignore_case: bool = False, context: int = 0
319
+ ) -> str:
320
+ p = self.resolve(path)
321
+ if shutil.which("rg"):
322
+ cmd = ["rg", "--line-number", "--no-heading", "--with-filename", "--hidden", "--color", "never"]
323
+ cmd += ["--max-columns", "400"]
324
+ if ignore_case:
325
+ cmd.append("-i")
326
+ if context:
327
+ cmd += ["-C", str(int(context))]
328
+ if glob:
329
+ cmd += ["-g", glob]
330
+ for d in IGNORE_DIRS:
331
+ cmd += ["-g", f"!{d}/"]
332
+ cmd += ["-e", pattern, str(p)]
333
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
334
+ if r.returncode == 2:
335
+ raise ToolError(r.stderr.strip())
336
+ out = r.stdout
337
+ else:
338
+ try:
339
+ rx = re.compile(pattern, re.I if ignore_case else 0)
340
+ except re.error as e:
341
+ raise ToolError(f"invalid regex: {e}") from e
342
+ results = []
343
+ for f in [p] if p.is_file() else walk_files(p):
344
+ if (glob and not fnmatch.fnmatch(f.name, glob)) or is_binary(f):
345
+ continue
346
+ try:
347
+ for n, line in enumerate(f.read_text(errors="replace").splitlines(), 1):
348
+ if rx.search(line):
349
+ results.append(f"{f}:{n}:{line[:400]}")
350
+ except OSError:
351
+ continue
352
+ out = "\n".join(results)
353
+ out = out.replace(str(self.agent.cwd) + os.sep, "")
354
+ lines = out.splitlines()
355
+ if not lines:
356
+ return "No matches."
357
+ if len(lines) > 400:
358
+ return "\n".join(lines[:400]) + f"\n... [{len(lines) - 400} more lines; narrow the search]"
359
+ return out
360
+
361
+ # -- file changes
362
+ def _check_fresh(self, p: Path) -> None:
363
+ key = str(p)
364
+ if key not in self.read_mtimes:
365
+ raise ToolError(f"You must read_file {self.rel(p)} before modifying it.")
366
+ if p.stat().st_mtime > self.read_mtimes[key] + 1e-6:
367
+ raise ToolError(f"{self.rel(p)} changed on disk since you read it. Read it again first.")
368
+
369
+ def _diff(self, p: Path, old: str, new: str) -> Syntax:
370
+ diff = "".join(
371
+ difflib.unified_diff(
372
+ old.splitlines(True), new.splitlines(True), f"a/{self.rel(p)}", f"b/{self.rel(p)}", n=3
373
+ )
374
+ )
375
+ if len(diff) > 12_000:
376
+ diff = diff[:12_000] + "\n... (diff truncated for display)\n"
377
+ return Syntax(diff or "(no changes)", "diff", theme="monokai", word_wrap=True)
378
+
379
+ def _write(self, p: Path, content: str) -> None:
380
+ p.parent.mkdir(parents=True, exist_ok=True)
381
+ p.write_text(content)
382
+ self.read_mtimes[str(p)] = p.stat().st_mtime
383
+
384
+ def t_write_file(self, path: str, content: str) -> str:
385
+ p = self.resolve(path)
386
+ exists = p.exists()
387
+ if exists:
388
+ if p.is_dir():
389
+ raise ToolError(f"{path} is a directory")
390
+ self._check_fresh(p)
391
+ body = self._diff(p, p.read_text(errors="replace"), content)
392
+ else:
393
+ lines = content.splitlines()
394
+ preview = "\n".join(lines[:60]) + (f"\n... ({len(lines) - 60} more lines)" if len(lines) > 60 else "")
395
+ body = Syntax(preview, Syntax.guess_lexer(str(p), content), theme="monokai", line_numbers=True)
396
+ verb = "Overwrite" if exists else "Create"
397
+ ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body)
398
+ if not ok:
399
+ return feedback
400
+ self._write(p, content)
401
+ n = len(content.splitlines())
402
+ self.console.print(f" [green]✓[/] {'Updated' if exists else 'Created'} {self.rel(p)} ({n} lines)")
403
+ return f"{'Overwrote' if exists else 'Created'} {self.rel(p)} ({n} lines)."
404
+
405
+ def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
406
+ p = self.resolve(path)
407
+ if not p.exists():
408
+ raise ToolError(f"{path} does not exist (use write_file to create it)")
409
+ self._check_fresh(p)
410
+ if old_string == new_string:
411
+ raise ToolError("old_string and new_string are identical")
412
+ if not old_string:
413
+ raise ToolError("old_string is empty; use write_file to create or overwrite files")
414
+ text = p.read_text(errors="replace")
415
+ count = text.count(old_string)
416
+ if count == 0:
417
+ new_text = fuzzy_replace(text, old_string, new_string)
418
+ if new_text is None:
419
+ raise ToolError(
420
+ f"old_string not found in {self.rel(p)}. It must match exactly, including indentation. "
421
+ "Re-read the file and copy the text precisely (without line-number prefixes)."
422
+ )
423
+ count = 1
424
+ elif count > 1 and not replace_all:
425
+ raise ToolError(
426
+ f"old_string occurs {count} times in {self.rel(p)}. Add surrounding lines to make it unique, "
427
+ "or set replace_all=true."
428
+ )
429
+ else:
430
+ new_text = text.replace(old_string, new_string) if replace_all else text.replace(old_string, new_string, 1)
431
+ ok, feedback = self.agent.perms.request("edit", "edit", f"Edit {self.rel(p)}", self._diff(p, text, new_text))
432
+ if not ok:
433
+ return feedback
434
+ self._write(p, new_text)
435
+ self.console.print(f" [green]✓[/] Edited {self.rel(p)}")
436
+ idx = new_text.find(new_string) if new_string else -1
437
+ if idx < 0:
438
+ return f"Edited {self.rel(p)} ({count} replacement(s))."
439
+ # Show the model the edited region so it can verify the result.
440
+ start = new_text.count("\n", 0, idx) + 1
441
+ lines = new_text.splitlines()
442
+ lo, hi = max(1, start - 3), min(len(lines), start + new_string.count("\n") + 3)
443
+ snippet = "\n".join(f"{i:6}\t{lines[i - 1]}" for i in range(lo, hi + 1))
444
+ return f"Edited {self.rel(p)} ({count} replacement(s)). Result:\n{snippet}"
445
+
446
+ # -- shell
447
+ def t_bash(self, command: str, timeout: int = 180) -> str:
448
+ if not is_read_only(command):
449
+ body = Syntax(command, "bash", theme="monokai", word_wrap=True)
450
+ ok, feedback = self.agent.perms.request(
451
+ bash_key(command), "bash", f"Run command (in {self.agent.cwd})", body
452
+ )
453
+ if not ok:
454
+ return feedback
455
+ self.console.print(Text(f" $ {command}", style="bold cyan"))
456
+ fd, cwd_file = tempfile.mkstemp(prefix="lcode_cwd_")
457
+ os.close(fd)
458
+ script = f"{command}\n__lcode_ec=$?\npwd -P > {shlex.quote(cwd_file)}\nexit $__lcode_ec\n"
459
+ proc = subprocess.Popen(
460
+ ["bash", "-c", script],
461
+ cwd=self.agent.cwd,
462
+ stdout=subprocess.PIPE,
463
+ stderr=subprocess.STDOUT,
464
+ stdin=subprocess.DEVNULL,
465
+ text=True,
466
+ errors="replace",
467
+ start_new_session=True,
468
+ bufsize=1,
469
+ )
470
+ lines: queue.Queue[str | None] = queue.Queue()
471
+
472
+ def pump() -> None:
473
+ assert proc.stdout is not None
474
+ for line in proc.stdout:
475
+ lines.put(line)
476
+ lines.put(None)
477
+
478
+ threading.Thread(target=pump, daemon=True).start()
479
+ out: list[str] = []
480
+ shown, status = 0, ""
481
+ deadline = time.time() + int(timeout or 180)
482
+ try:
483
+ while True:
484
+ try:
485
+ line = lines.get(timeout=0.2)
486
+ except queue.Empty:
487
+ if time.time() > deadline:
488
+ os.killpg(proc.pid, signal.SIGKILL)
489
+ status = f"\n[Command timed out after {timeout}s and was killed]"
490
+ break
491
+ continue
492
+ if line is None:
493
+ break
494
+ out.append(line)
495
+ if shown <= 40:
496
+ msg = " ... (more output hidden)" if shown == 40 else " " + line.rstrip("\n")[:300]
497
+ self.console.print(Text(msg, style="dim"))
498
+ shown += 1
499
+ except KeyboardInterrupt:
500
+ os.killpg(proc.pid, signal.SIGKILL)
501
+ raise
502
+ code = proc.wait()
503
+ try:
504
+ recorded = Path(cwd_file).read_text().strip()
505
+ if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
506
+ self.agent.cwd = Path(recorded).resolve()
507
+ status += f"\n[Working directory is now {self.agent.cwd}]"
508
+ except OSError:
509
+ pass
510
+ finally:
511
+ Path(cwd_file).unlink(missing_ok=True)
512
+ self.console.print(Text(f" exit code {code}", style="green" if code == 0 else "red"))
513
+ return truncate("".join(out)) + status + f"\n[exit code: {code}]"
514
+
515
+ # -- planning
516
+ def t_todo_write(self, todos: list | str) -> str:
517
+ if isinstance(todos, str):
518
+ todos = json.loads(todos)
519
+ self.todos = list(todos)
520
+ self.show_todos()
521
+ return "Todo list updated."
522
+
523
+ def show_todos(self) -> None:
524
+ icons = {"completed": "[green]✔[/]", "in_progress": "[yellow]◐[/]", "pending": "[dim]○[/]"}
525
+ rows = []
526
+ for todo in self.todos:
527
+ status, text = todo.get("status", "pending"), todo.get("content", "")
528
+ if status == "completed":
529
+ text = f"[strike dim]{text}[/]"
530
+ elif status == "in_progress":
531
+ text = f"[bold]{text}[/]"
532
+ rows.append(f"{icons.get(status, '○')} {text}")
533
+ self.console.print(Panel("\n".join(rows) or "(empty)", title="Todos", title_align="left", border_style="blue"))