opencommand 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. opencommand/__init__.py +4 -0
  2. opencommand/cli.py +375 -0
  3. opencommand/core/agent.py +91 -0
  4. opencommand/core/config.py +65 -0
  5. opencommand/core/errors.py +23 -0
  6. opencommand/core/logging.py +65 -0
  7. opencommand/core/supervisor.py +81 -0
  8. opencommand/core/tools.py +136 -0
  9. opencommand/engine/__init__.py +66 -0
  10. opencommand/engine/runner.py +275 -0
  11. opencommand/modules/knowledge.py +85 -0
  12. opencommand/modules/memory/MEMORY.md +14 -0
  13. opencommand/modules/skills/bash.md +14 -0
  14. opencommand/modules/skills/powershell.md +15 -0
  15. opencommand/modules/skills/python.md +20 -0
  16. opencommand/modules/skills/system.md +38 -0
  17. opencommand/modules/system/automatic.py +151 -0
  18. opencommand/modules/system/cron.py +193 -0
  19. opencommand/modules/system/notify.py +85 -0
  20. opencommand/modules/system/platform.py +197 -0
  21. opencommand/modules/templates.py +227 -0
  22. opencommand/modules/tools/desktop.py +127 -0
  23. opencommand/modules/tools/files.py +82 -0
  24. opencommand/modules/tools/instructions.py +163 -0
  25. opencommand/modules/tools/memory.py +78 -0
  26. opencommand/modules/tools/playwright.py +61 -0
  27. opencommand/modules/tools/research.py +46 -0
  28. opencommand/modules/tools/system.py +163 -0
  29. opencommand/modules/tools/terminal.py +53 -0
  30. opencommand/providers/provider.py +157 -0
  31. opencommand/swarm/agents/commander.py +530 -0
  32. opencommand/swarm/agents/worker.py +26 -0
  33. opencommand/tui/dashboard.py +41 -0
  34. opencommand-0.1.0.dist-info/METADATA +76 -0
  35. opencommand-0.1.0.dist-info/RECORD +37 -0
  36. opencommand-0.1.0.dist-info/WHEEL +4 -0
  37. opencommand-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,197 @@
1
+ """Platform / host autodetection for OpenCommand setup and per-host config.
2
+
3
+ On first run (or `opencommand setup`) this probes the host and derives a
4
+ recommended configuration: shell, Python build (free-threaded vs GIL), CPU
5
+ topology (core count -> worker pool size), instruction-set support (AVX2 /
6
+ AVX-512 -> llama.cpp build flags), GPU backend (CUDA / ROCm / Metal / Vulkan /
7
+ none -> n_gpu_layers), available system tools (git, yara, ffmpeg, playwright
8
+ browsers), and total RAM. The result feeds `recommend_config()` which returns an
9
+ `OpenCommandConfig` plus engine hints, so the swarm is tuned per machine without
10
+ hand-editing anything.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import platform
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+
21
+
22
+ def detect() -> dict:
23
+ """Return a full host descriptor dict (everything OpenCommand cares about)."""
24
+ return {
25
+ "system": platform.system(),
26
+ "release": platform.release(),
27
+ "machine": platform.machine(),
28
+ "python": sys.version.split()[0],
29
+ "free_threaded": _free_threaded(),
30
+ "shell": shell_kind(),
31
+ "cores": os.cpu_count() or 1,
32
+ "isa": _isa(),
33
+ "gpu": _gpu(),
34
+ "ram_gb": _ram_gb(),
35
+ "tools": _tools(),
36
+ }
37
+
38
+
39
+ def shell_kind() -> str:
40
+ return "cmd" if sys.platform.startswith("win") else "sh"
41
+
42
+
43
+ def _free_threaded() -> bool:
44
+ fn = getattr(sys, "_is_gil_enabled", None)
45
+ if fn is None:
46
+ return False
47
+ try:
48
+ return not fn()
49
+ except Exception: # noqa: BLE001
50
+ return False
51
+
52
+
53
+ def _isa() -> dict:
54
+ """Detect CPU instruction-set extensions (drives llama.cpp build flags)."""
55
+ caps: set[str] = set()
56
+ try:
57
+ import cpuid # type: ignore
58
+ except Exception:
59
+ cpuid = None
60
+ if cpuid is not None and hasattr(cpuid, "get_extended_feature_bits"):
61
+ bits = cpuid.get_extended_feature_bits(0)
62
+ if bits & (1 << 28):
63
+ caps.add("avx")
64
+ if bits & (1 << 5):
65
+ caps.add("avx2")
66
+ if bits & (1 << 16):
67
+ caps.add("avx512f")
68
+ else:
69
+ if sys.platform.startswith("linux"):
70
+ try:
71
+ from pathlib import Path
72
+ info = Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="ignore")
73
+ for line in info.splitlines():
74
+ if line.lower().startswith("flags"):
75
+ for f in line.split()[2:]:
76
+ if f in ("avx", "avx2", "avx512f"):
77
+ caps.add(f)
78
+ break
79
+ except Exception: # noqa: BLE001
80
+ pass
81
+ if not caps:
82
+ mach = platform.machine().lower()
83
+ if mach in ("x86_64", "amd64"):
84
+ caps.add("avx2") # assume baseline AVX2 on modern x86_64
85
+ elif mach in ("arm64", "aarch64"):
86
+ caps.add("neon")
87
+ return {"extensions": sorted(caps), "avx2": "avx2" in caps, "avx512": "avx512f" in caps}
88
+
89
+
90
+ def _gpu() -> dict:
91
+ """Detect the best available GPU backend for llama.cpp offload."""
92
+ backend = "none"
93
+ detail = ""
94
+ if shutil.which("nvidia-smi"):
95
+ try:
96
+ out = subprocess.run(["nvidia-smi", "--query-gpu=name,memory.total",
97
+ "--format=csv,noheader"], capture_output=True,
98
+ text=True, timeout=10).stdout.strip()
99
+ backend, detail = "cuda", out.replace("\n", " | ")
100
+ except Exception: # noqa: BLE001
101
+ backend = "cuda"
102
+ elif shutil.which("rocm-smi") or os.path.exists("/opt/rocm"):
103
+ backend = "rocm"
104
+ elif platform.system() == "Darwin" and platform.machine() == "arm64":
105
+ backend, detail = "metal", "Apple Silicon"
106
+ elif shutil.which("vulkaninfo") or os.environ.get("VK_ICD_FILENAMES"):
107
+ backend = "vulkan"
108
+ return {"backend": backend, "detail": detail}
109
+
110
+
111
+ def _ram_gb() -> float:
112
+ """Total system RAM in GB. Uses ctypes (no extra deps); Linux falls back to /proc."""
113
+ if sys.platform.startswith("win"):
114
+ try:
115
+ import ctypes
116
+
117
+ class _MEMORYSTATUSEX(ctypes.Structure):
118
+ _fields_ = [
119
+ ("dwLength", ctypes.c_ulong),
120
+ ("dwMemoryLoad", ctypes.c_ulong),
121
+ ("ullTotalPhys", ctypes.c_ulonglong),
122
+ ("ullAvailPhys", ctypes.c_ulonglong),
123
+ ("ullTotalPageFile", ctypes.c_ulonglong),
124
+ ("ullAvailPageFile", ctypes.c_ulonglong),
125
+ ("ullTotalVirtual", ctypes.c_ulonglong),
126
+ ("ullAvailVirtual", ctypes.c_ulonglong),
127
+ ("ullAvailExtendedVirtual", ctypes.c_ulonglong),
128
+ ]
129
+
130
+ stat = _MEMORYSTATUSEX()
131
+ stat.dwLength = ctypes.sizeof(stat)
132
+ if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
133
+ return round(stat.ullTotalPhys / (1024 ** 3), 1)
134
+ except Exception: # noqa: BLE001
135
+ pass
136
+ try:
137
+ if sys.platform.startswith("linux"):
138
+ from pathlib import Path
139
+ for line in Path("/proc/meminfo").read_text(encoding="utf-8",
140
+ errors="ignore").splitlines():
141
+ if line.startswith("MemTotal:"):
142
+ return round(int(line.split()[1]) / 1024 / 1024, 1)
143
+ except Exception: # noqa: BLE001
144
+ pass
145
+ return 0.0
146
+
147
+
148
+ def _tools() -> dict:
149
+ """Detect optional external tools the swarm can use."""
150
+ found: dict[str, bool] = {}
151
+ for name in ("git", "yara", "ffmpeg", "cmake", "cl", "gcc", "cc"):
152
+ found[name] = shutil.which(name) is not None
153
+ pw = False
154
+ try:
155
+ from playwright.sync_api import sync_playwright
156
+ with sync_playwright() as p:
157
+ pw = p.chromium.executable_path is not None
158
+ except Exception: # noqa: BLE001
159
+ pw = False
160
+ found["playwright_chromium"] = pw
161
+ return found
162
+
163
+
164
+ def describe() -> str:
165
+ d = detect()
166
+ ft = "free-threaded" if d["free_threaded"] else "GIL"
167
+ gpu = d["gpu"]["backend"]
168
+ isa = ",".join(d["isa"]["extensions"]) or "base"
169
+ return (f"{d['system']} {d['release']} ({d['machine']}), Python {d['python']} "
170
+ f"[{ft}], {d['cores']} cores, ISA={isa}, GPU={gpu}")
171
+
172
+
173
+ def recommend_config(d: dict | None = None) -> dict:
174
+ """Map a host descriptor to OpenCommand + engine configuration hints.
175
+
176
+ Returns a dict with `config` (kwargs for OpenCommandConfig) and `engine`
177
+ (kwargs for Engine), ready to apply on setup.
178
+ """
179
+ d = d or detect()
180
+ cores = max(1, d["cores"])
181
+ pool = max(1, min(cores - 1, 8)) # leave cores for OS; cap to avoid RAM blowup
182
+ n_gpu_layers = -1 if d["gpu"]["backend"] != "none" else 0
183
+ build_flags = []
184
+ if d["isa"]["avx512"]:
185
+ build_flags.append("-DGGML_AVX512=ON")
186
+ elif d["isa"]["avx2"]:
187
+ build_flags.append("-DGGML_AVX2=ON")
188
+ return {
189
+ "config": {
190
+ "max_workers": pool,
191
+ "vision": d["gpu"]["backend"] != "none" or cores >= 4,
192
+ },
193
+ "engine": {"pool_size": pool},
194
+ "runner": {"n_gpu_layers": n_gpu_layers},
195
+ "build": {"cmake_args": " ".join(build_flags) if build_flags else ""},
196
+ }
197
+
@@ -0,0 +1,227 @@
1
+ """Required markdown templates for every artifact OpenCommand can create.
2
+
3
+ Each template declares the mandatory front-matter / header fields and the body
4
+ sections an artifact must contain. The instructions tool and commander use these
5
+ to ensure every skill, task, doc, memory, scaffold, research, plan, and report
6
+ note is complete and consistent, so the knowledge base and memory store stay
7
+ structured and machine-readable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+
15
+ @dataclass
16
+ class Template:
17
+ """A required-shape template for one markdown artifact type."""
18
+
19
+ kind: str
20
+ title: str
21
+ # YAML-ish front-matter keys that MUST be present (value may be filled later).
22
+ required_fields: list[str] = field(default_factory=list)
23
+ # Markdown section headings (## ...) that MUST appear in the body.
24
+ required_sections: list[str] = field(default_factory=list)
25
+ # A concrete skeleton the writer fills in.
26
+ skeleton: str = ""
27
+
28
+ def render(self, **values: str) -> str:
29
+ """Render the skeleton, substituting {field} placeholders from ``values``."""
30
+ try:
31
+ return self.skeleton.format(**values)
32
+ except (KeyError, IndexError):
33
+ return self.skeleton
34
+
35
+
36
+ TEMPLATES: dict[str, Template] = {
37
+ "skill": Template(
38
+ kind="skill",
39
+ title="Skill",
40
+ required_fields=["name", "category", "updated", "tags"],
41
+ required_sections=["Summary", "Conventions", "When to use", "Examples", "Gotchas"],
42
+ skeleton=(
43
+ "---\n"
44
+ "name: {name}\n"
45
+ "category: {category}\n"
46
+ "updated: {updated}\n"
47
+ "tags: [{tags}]\n"
48
+ "---\n\n"
49
+ "# {name}\n\n"
50
+ "## Summary\n{description}\n\n"
51
+ "## Conventions\n- \n\n"
52
+ "## When to use\n- \n\n"
53
+ "## Examples\n```\n\n```\n\n"
54
+ "## Gotchas\n- \n"
55
+ ),
56
+ ),
57
+ "task": Template(
58
+ kind="task",
59
+ title="Task Playbook",
60
+ required_fields=["name", "goal", "updated", "tags"],
61
+ required_sections=["Objective", "Preconditions", "Steps", "Verification", "Rollback"],
62
+ skeleton=(
63
+ "---\n"
64
+ "name: {name}\n"
65
+ "goal: {goal}\n"
66
+ "updated: {updated}\n"
67
+ "tags: [{tags}]\n"
68
+ "---\n\n"
69
+ "# {name}\n\n"
70
+ "## Objective\n\n"
71
+ "## Preconditions\n- \n\n"
72
+ "## Steps\n1. \n\n"
73
+ "## Verification\n- \n\n"
74
+ "## Rollback\n- \n"
75
+ ),
76
+ ),
77
+ "doc": Template(
78
+ kind="doc",
79
+ title="Reference Doc",
80
+ required_fields=["name", "topic", "updated", "tags"],
81
+ required_sections=["Overview", "Details", "References"],
82
+ skeleton=(
83
+ "---\n"
84
+ "name: {name}\n"
85
+ "topic: {topic}\n"
86
+ "updated: {updated}\n"
87
+ "tags: [{tags}]\n"
88
+ "---\n\n"
89
+ "# {name}\n\n"
90
+ "## Overview\n\n"
91
+ "## Details\n\n"
92
+ "## References\n- \n"
93
+ ),
94
+ ),
95
+ "memory": Template(
96
+ kind="memory",
97
+ title="Memory Note",
98
+ required_fields=["topic", "tags", "created"],
99
+ required_sections=["Body"],
100
+ skeleton=(
101
+ "---\n"
102
+ "topic: {topic}\n"
103
+ "tags: [{tags}]\n"
104
+ "created: {created}\n"
105
+ "---\n\n"
106
+ "# {topic}\n\n"
107
+ "## Body\n\n"
108
+ ),
109
+ ),
110
+ "scaffold": Template(
111
+ kind="scaffold",
112
+ title="Scaffold Manifest",
113
+ required_fields=["phase", "index", "created"],
114
+ required_sections=["Purpose", "Layout", "Conventions"],
115
+ skeleton=(
116
+ "---\n"
117
+ "phase: {phase}\n"
118
+ "index: {index}\n"
119
+ "created: {created}\n"
120
+ "---\n\n"
121
+ "# Scaffold: {phase}\n\n"
122
+ "## Purpose\n\n"
123
+ "## Layout\n- \n\n"
124
+ "## Conventions\n- Write implementation under this directory.\n"
125
+ "- Do not modify files outside this phase's scope.\n"
126
+ ),
127
+ ),
128
+ "research": Template(
129
+ kind="research",
130
+ title="Research Findings",
131
+ required_fields=["phase", "iteration", "created"],
132
+ required_sections=["Question", "Findings", "Sources", "Confidence", "Gaps"],
133
+ skeleton=(
134
+ "---\n"
135
+ "phase: {phase}\n"
136
+ "iteration: {iteration}\n"
137
+ "created: {created}\n"
138
+ "---\n\n"
139
+ "# Research: {phase} (iter {iteration})\n\n"
140
+ "## Question\n\n"
141
+ "## Findings\n- \n\n"
142
+ "## Sources\n- \n\n"
143
+ "## Confidence\n- high | medium | low\n\n"
144
+ "## Gaps\n- \n"
145
+ ),
146
+ ),
147
+ "plan": Template(
148
+ kind="plan",
149
+ title="Goal Plan",
150
+ required_fields=["goal", "created", "phases"],
151
+ required_sections=["Goal", "Phases", "Assumptions"],
152
+ skeleton=(
153
+ "---\n"
154
+ "goal: {goal}\n"
155
+ "created: {created}\n"
156
+ "phases: {phases}\n"
157
+ "---\n\n"
158
+ "# Plan\n\n"
159
+ "## Goal\n{goal}\n\n"
160
+ "## Phases\n\n"
161
+ "## Assumptions\n- \n"
162
+ ),
163
+ ),
164
+ "report": Template(
165
+ kind="report",
166
+ title="Goal Report",
167
+ required_fields=["goal", "created", "status"],
168
+ required_sections=["Summary", "Phases", "Review", "Self-Improvement"],
169
+ skeleton=(
170
+ "---\n"
171
+ "goal: {goal}\n"
172
+ "created: {created}\n"
173
+ "status: {status}\n"
174
+ "---\n\n"
175
+ "# Report\n\n"
176
+ "## Summary\n\n"
177
+ "## Phases\n\n"
178
+ "## Review\n\n"
179
+ "## Self-Improvement\n\n"
180
+ ),
181
+ ),
182
+ }
183
+
184
+
185
+ def get_template(kind: str) -> Template | None:
186
+ return TEMPLATES.get(kind)
187
+
188
+
189
+ def template_for(kind: str) -> Template:
190
+ """Return the template for ``kind`` or raise ``KeyError``."""
191
+ t = TEMPLATES.get(kind)
192
+ if t is None:
193
+ raise KeyError(f"no template for kind {kind!r}")
194
+ return t
195
+
196
+
197
+ def validate_markdown(kind: str, text: str) -> list[str]:
198
+ """Return a list of missing required front-matter fields/sections for ``kind``."""
199
+ t = TEMPLATES.get(kind)
200
+ if t is None:
201
+ return [f"unknown kind: {kind}"]
202
+ missing: list[str] = []
203
+ fm = _parse_front_matter(text)
204
+ for field_name in t.required_fields:
205
+ if field_name not in fm:
206
+ missing.append(f"field:{field_name}")
207
+ for section in t.required_sections:
208
+ if f"## {section}" not in text:
209
+ missing.append(f"section:{section}")
210
+ return missing
211
+
212
+
213
+ def _parse_front_matter(text: str) -> dict[str, str]:
214
+ """Extract a simple YAML front-matter block (key: value) into a dict."""
215
+ if not text.startswith("---"):
216
+ return {}
217
+ end = text.find("\n---", 3)
218
+ if end == -1:
219
+ return {}
220
+ block = text[3:end]
221
+ fm: dict[str, str] = {}
222
+ for line in block.splitlines():
223
+ if ":" not in line:
224
+ continue
225
+ key, _, val = line.partition(":")
226
+ fm[key.strip()] = val.strip()
227
+ return fm
@@ -0,0 +1,127 @@
1
+ """Desktop control tool: realtime vision + mouse/keyboard action loop.
2
+
3
+ Captures the screen, asks a small embedded vision-language model what is
4
+ happening and what to do next, then executes the chosen action via
5
+ pyautogui/pynput. Used for fully interactive testing (e.g. clicking UI,
6
+ verifying behavior). The vision model is the built-in Qwen3-VL (or MiniCPM-V)
7
+ the Engine (no external server).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import io
14
+ from dataclasses import dataclass
15
+
16
+ from ...core.tools import BaseTool
17
+ from ...engine.runner import LocalRunner
18
+
19
+ try:
20
+ import pyautogui
21
+ import mss
22
+ _HAVE_DESKTOP = True
23
+ except Exception: # pragma: no cover
24
+ _HAVE_DESKTOP = False
25
+
26
+
27
+ @dataclass
28
+ class DesktopStep:
29
+ observation: str
30
+ action: str
31
+ ok: bool
32
+
33
+
34
+ def capture_screen() -> bytes:
35
+ """Return a PNG screenshot of the primary monitor as bytes."""
36
+ if not _HAVE_DESKTOP:
37
+ raise RuntimeError("pyautogui/mss not available")
38
+ with mss.mss() as sct:
39
+ img = sct.grab(sct.monitors[1])
40
+ import PIL.Image
41
+ buf = io.BytesIO()
42
+ PIL.Image.frombytes("RGB", img.size, img.rgb).save(buf, format="PNG")
43
+ return buf.getvalue()
44
+
45
+
46
+ def act(action: str) -> None:
47
+ """Execute a high-level action string: click(x,y), type(text), key(name), wait."""
48
+ if not _HAVE_DESKTOP:
49
+ raise RuntimeError("pyautogui not available")
50
+ action = action.strip()
51
+ if action.startswith("click("):
52
+ import re
53
+ m = re.match(r"click\((\d+),\s*(\d+)\)", action)
54
+ if m:
55
+ pyautogui.click(int(m.group(1)), int(m.group(2)))
56
+ elif action.startswith("type("):
57
+ pyautogui.write(action[len("type("):-1], interval=0.02)
58
+ elif action.startswith("key("):
59
+ pyautogui.press(action[len("key("):-1])
60
+ elif action.startswith("wait("):
61
+ import time
62
+ time.sleep(float(action[len("wait("):-1]))
63
+ else:
64
+ raise ValueError(f"unknown action: {action}")
65
+
66
+
67
+ class DesktopTool(BaseTool):
68
+ name = "desktop"
69
+ description = (
70
+ "Realtime computer control: capture screen, ask a small vision model what "
71
+ "to do, and execute mouse/keyboard actions. For interactive testing."
72
+ )
73
+ parameters = {
74
+ "type": "object",
75
+ "properties": {
76
+ "goal": {"type": "string", "description": "What to accomplish on screen."},
77
+ "steps": {"type": "integer", "description": "Max interaction steps (default 10)."},
78
+ },
79
+ "required": ["goal"],
80
+ }
81
+
82
+ def __init__(self, vision_runner: LocalRunner | None = None) -> None:
83
+ self.vision = vision_runner
84
+
85
+ def run(self, goal: str, steps: int = 10) -> str:
86
+ if not _HAVE_DESKTOP:
87
+ return "ERROR: desktop control libs (pyautogui/mss) not installed"
88
+ if self.vision is None:
89
+ return "ERROR: no vision model configured for desktop tool"
90
+ history: list[DesktopStep] = []
91
+ for i in range(steps):
92
+ b64 = base64.b64encode(capture_screen()).decode()
93
+ content = [
94
+ {"type": "text", "text": (
95
+ f"Goal: {goal}\nStep {i+1}. Describe the screen and decide the "
96
+ "next single action as: click(x,y) | type(text) | key(name) | "
97
+ "wait(secs) | done. Reply with ACTION: <action> and a short WHY.")},
98
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
99
+ ]
100
+ resp = self.vision.chat([{"role": "user", "content": content}],
101
+ temperature=0.1, max_tokens=256)
102
+ text = resp["choices"][0]["message"].get("content") or ""
103
+ action = self._extract_action(text)
104
+ if action == "done" or action == "":
105
+ history.append(DesktopStep(text, "done", True))
106
+ break
107
+ try:
108
+ act(action)
109
+ history.append(DesktopStep(text, action, True))
110
+ except Exception as e: # noqa: BLE001
111
+ history.append(DesktopStep(text, action, False))
112
+ return f"ERROR on step {i+1}: {e}\n{self._summarize(history)}"
113
+ return self._summarize(history)
114
+
115
+ @staticmethod
116
+ def _extract_action(text: str) -> str:
117
+ for line in text.splitlines():
118
+ if line.strip().upper().startswith("ACTION:"):
119
+ return line.split(":", 1)[1].strip()
120
+ return ""
121
+
122
+ @staticmethod
123
+ def _summarize(history: list[DesktopStep]) -> str:
124
+ lines = [f"desktop loop: {len(history)} steps"]
125
+ for i, s in enumerate(history, 1):
126
+ lines.append(f" {i}. {s.action} -> {'ok' if s.ok else 'FAIL'}")
127
+ return "\n".join(lines)
@@ -0,0 +1,82 @@
1
+ """Filesystem tool: deterministic file authoring for agents (write/read/edit/list).
2
+
3
+ On Windows the shell tool cannot reliably emit multi-line files (no heredoc /
4
+ cat >), so agents need a real file API to write full codebases. All paths are
5
+ resolved under the workspace root; traversal outside it is rejected.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from ...core.tools import BaseTool
13
+
14
+
15
+ class FilesTool(BaseTool):
16
+ name = "files"
17
+ description = (
18
+ "Author and inspect files in the workspace: write/create, read, edit "
19
+ "(old->new string replace), list a directory, and mkdir. Paths are "
20
+ "relative to or inside the workspace root; traversal outside is rejected."
21
+ )
22
+ parameters = {
23
+ "type": "object",
24
+ "properties": {
25
+ "action": {
26
+ "type": "string",
27
+ "enum": ["write", "read", "edit", "list", "mkdir"],
28
+ "description": "Which filesystem operation to perform.",
29
+ },
30
+ "path": {"type": "string", "description": "Target path (relative to workspace)."},
31
+ "content": {"type": "string", "description": "File content for write."},
32
+ "old": {"type": "string", "description": "Substring to replace for edit."},
33
+ "new": {"type": "string", "description": "Replacement substring for edit."},
34
+ },
35
+ "required": ["action", "path"],
36
+ }
37
+
38
+ def __init__(self, workspace: Path) -> None:
39
+ self.root = Path(workspace).resolve()
40
+
41
+ def _resolve(self, path: str) -> Path:
42
+ p = (self.root / path).resolve()
43
+ if self.root not in p.parents and p != self.root:
44
+ raise ValueError(f"path escapes workspace: {path}")
45
+ return p
46
+
47
+ def run(self, action: str, path: str, content: str | None = None,
48
+ old: str | None = None, new: str | None = None) -> str:
49
+ try:
50
+ p = self._resolve(path)
51
+ except ValueError as e:
52
+ return f"ERROR: {e}"
53
+ if action == "write":
54
+ if content is None:
55
+ return "ERROR: write requires 'content'"
56
+ p.parent.mkdir(parents=True, exist_ok=True)
57
+ p.write_text(content, encoding="utf-8")
58
+ return f"Wrote {p} ({len(content)} bytes)"
59
+ if action == "read":
60
+ if not p.is_file():
61
+ return f"ERROR: not a file: {path}"
62
+ return p.read_text(encoding="utf-8")
63
+ if action == "edit":
64
+ if old is None or new is None:
65
+ return "ERROR: edit requires 'old' and 'new'"
66
+ if not p.is_file():
67
+ return f"ERROR: not a file: {path}"
68
+ text = p.read_text(encoding="utf-8")
69
+ if old not in text:
70
+ return "ERROR: 'old' substring not found"
71
+ p.write_text(text.replace(old, new, 1), encoding="utf-8")
72
+ return f"Edited {p}"
73
+ if action == "list":
74
+ if not p.is_dir():
75
+ return f"ERROR: not a directory: {path}"
76
+ items = sorted(
77
+ (f"{'d' if c.is_dir() else 'f'} {c.name}" for c in p.iterdir()))
78
+ return "\n".join(items) if items else "(empty)"
79
+ if action == "mkdir":
80
+ p.mkdir(parents=True, exist_ok=True)
81
+ return f"Mkdir {p}"
82
+ return f"ERROR: unknown action '{action}'"