gemini-computer-use 0.1.2 → 0.2.3
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.
- package/README.md +113 -2
- package/core/agent.py +69 -20
- package/core/mcp_tools.py +1658 -0
- package/core/policies.py +5 -2
- package/core/server.py +862 -5
- package/gateway.py +1491 -0
- package/install.ps1 +116 -36
- package/install.sh +116 -28
- package/package.json +4 -3
|
@@ -0,0 +1,1658 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""core/mcp_tools.py - shared Antigravity Mesh tool implementation.
|
|
4
|
+
|
|
5
|
+
This module is the single place that contains tool *logic*. Both consumers
|
|
6
|
+
(the asyncio tunnel node ``core/agent.py`` and the standalone HTTP server
|
|
7
|
+
``core/server.py``) are thin transports that only forward to
|
|
8
|
+
:func:`call_tool`.
|
|
9
|
+
|
|
10
|
+
Design rules (see the project contract):
|
|
11
|
+
|
|
12
|
+
* standard library only, Python 3.8+;
|
|
13
|
+
* :func:`call_tool` never raises - every failure is returned as
|
|
14
|
+
``{"error": "<human readable>"}``;
|
|
15
|
+
* output pagination never throws the middle away: callers advance a ``cursor``;
|
|
16
|
+
* large outputs are spooled to a file instead of being dropped;
|
|
17
|
+
* every mutating operation is checked against ``read_only`` / ``write_roots``.
|
|
18
|
+
|
|
19
|
+
Public interface::
|
|
20
|
+
|
|
21
|
+
TOOLS: list[dict]
|
|
22
|
+
configure(**kwargs) -> None
|
|
23
|
+
call_tool(name: str, args: dict) -> dict
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import fnmatch
|
|
29
|
+
import glob as _glob
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import signal as _signal
|
|
36
|
+
import socket
|
|
37
|
+
import subprocess
|
|
38
|
+
import tempfile
|
|
39
|
+
import threading
|
|
40
|
+
import time
|
|
41
|
+
import uuid
|
|
42
|
+
from datetime import datetime, timezone
|
|
43
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Optional dependency: the host vitals collector. Kept optional so the module
|
|
47
|
+
# stays importable under a bare interpreter.
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
try: # pragma: no cover - exercised implicitly by the environment
|
|
50
|
+
from core.vitals import get_host_vitals as _core_get_host_vitals # type: ignore
|
|
51
|
+
except Exception: # pragma: no cover
|
|
52
|
+
_core_get_host_vitals = None # type: ignore
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Configuration / module state
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
_DEFAULT_MAX_OUTPUT_CHARS = 50000
|
|
60
|
+
_DEFAULT_JOBS_DIR = os.path.join("~", ".cache", "antigravity-mesh", "jobs")
|
|
61
|
+
|
|
62
|
+
#: bash_exec never blocks a transport thread for longer than this.
|
|
63
|
+
MAX_TIMEOUT_SEC = 120
|
|
64
|
+
#: job_output caps its wait so it stays well inside the 28s gateway limit.
|
|
65
|
+
MAX_WAIT_MS = 20000
|
|
66
|
+
#: run_job refuses to start more than this many concurrent jobs.
|
|
67
|
+
MAX_RUNNING_JOBS = 32
|
|
68
|
+
#: Per-job output written to disk before a marker is appended and growth stops.
|
|
69
|
+
JOB_OUTPUT_CAP = 64 * 1024 * 1024
|
|
70
|
+
#: bash_exec spools its complete output to disk above this size.
|
|
71
|
+
SPOOL_THRESHOLD = 2 * 1024 * 1024
|
|
72
|
+
#: Hard ceiling for glob_find result sets.
|
|
73
|
+
GLOB_MAX_RESULTS = 10000
|
|
74
|
+
|
|
75
|
+
_SKIP_DIRS = frozenset((".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv"))
|
|
76
|
+
|
|
77
|
+
_WORKSPACE = os.path.realpath(os.getcwd())
|
|
78
|
+
_READ_ONLY = False
|
|
79
|
+
_WRITE_ROOTS: Optional[List[str]] = None
|
|
80
|
+
_JOBS_DIR = os.path.realpath(os.path.expanduser(_DEFAULT_JOBS_DIR))
|
|
81
|
+
_MAX_OUTPUT_CHARS = _DEFAULT_MAX_OUTPUT_CHARS
|
|
82
|
+
|
|
83
|
+
_META_LOCK = threading.RLock()
|
|
84
|
+
_JOBS: Dict[str, Dict[str, Any]] = {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _split_roots(value: Any) -> Optional[List[str]]:
|
|
88
|
+
"""Normalise ``write_roots`` (list/tuple/pathsep string) into real paths."""
|
|
89
|
+
if value is None:
|
|
90
|
+
return None
|
|
91
|
+
if isinstance(value, str):
|
|
92
|
+
parts = [p for p in value.split(os.pathsep) if p.strip()]
|
|
93
|
+
if len(parts) == 1 and os.pathsep not in value:
|
|
94
|
+
# A single root with no separator: still accept it verbatim.
|
|
95
|
+
parts = [value]
|
|
96
|
+
elif isinstance(value, (list, tuple, set)):
|
|
97
|
+
parts = [str(p) for p in value if str(p).strip()]
|
|
98
|
+
else:
|
|
99
|
+
parts = [str(value)]
|
|
100
|
+
roots: List[str] = []
|
|
101
|
+
for part in parts:
|
|
102
|
+
if not str(part).strip():
|
|
103
|
+
continue
|
|
104
|
+
roots.append(os.path.realpath(os.path.expanduser(str(part).strip())))
|
|
105
|
+
return roots or None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_bool(value: Any) -> bool:
|
|
109
|
+
if isinstance(value, bool):
|
|
110
|
+
return value
|
|
111
|
+
if value is None:
|
|
112
|
+
return False
|
|
113
|
+
return str(value).strip().lower() in ("1", "true", "yes", "on", "y", "t")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def configure(**kwargs: Any) -> None:
|
|
117
|
+
"""(Re)configure the module. All keys are optional.
|
|
118
|
+
|
|
119
|
+
Recognised keys: ``workspace``, ``read_only``, ``allow_write``,
|
|
120
|
+
``write_roots``, ``jobs_dir``, ``max_output_chars``.
|
|
121
|
+
"""
|
|
122
|
+
global _WORKSPACE, _READ_ONLY, _WRITE_ROOTS, _JOBS_DIR, _MAX_OUTPUT_CHARS
|
|
123
|
+
|
|
124
|
+
if kwargs.get("workspace"):
|
|
125
|
+
_WORKSPACE = os.path.realpath(os.path.expanduser(str(kwargs["workspace"])))
|
|
126
|
+
if "read_only" in kwargs and kwargs["read_only"] is not None:
|
|
127
|
+
_READ_ONLY = _parse_bool(kwargs["read_only"])
|
|
128
|
+
elif "allow_write" in kwargs and kwargs["allow_write"] is not None:
|
|
129
|
+
_READ_ONLY = not _parse_bool(kwargs["allow_write"])
|
|
130
|
+
if "write_roots" in kwargs:
|
|
131
|
+
_WRITE_ROOTS = _split_roots(kwargs["write_roots"])
|
|
132
|
+
if kwargs.get("jobs_dir"):
|
|
133
|
+
_JOBS_DIR = os.path.realpath(os.path.expanduser(str(kwargs["jobs_dir"])))
|
|
134
|
+
if kwargs.get("max_output_chars") is not None:
|
|
135
|
+
try:
|
|
136
|
+
value = int(kwargs["max_output_chars"])
|
|
137
|
+
if value > 0:
|
|
138
|
+
_MAX_OUTPUT_CHARS = value
|
|
139
|
+
except (TypeError, ValueError):
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Small helpers
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def _now_iso() -> str:
|
|
148
|
+
return datetime.now(timezone.utc).isoformat()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _sha256_hex(data: bytes) -> str:
|
|
152
|
+
return hashlib.sha256(data).hexdigest()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _resolve(path: Any) -> str:
|
|
156
|
+
"""expanduser -> relative to workspace -> realpath."""
|
|
157
|
+
raw = "" if path is None else str(path)
|
|
158
|
+
raw = raw.strip()
|
|
159
|
+
if not raw:
|
|
160
|
+
raw = _WORKSPACE
|
|
161
|
+
raw = os.path.expanduser(raw)
|
|
162
|
+
if not os.path.isabs(raw):
|
|
163
|
+
raw = os.path.join(_WORKSPACE, raw)
|
|
164
|
+
return os.path.realpath(raw)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _within_write_roots(target: str) -> bool:
|
|
168
|
+
if _WRITE_ROOTS is None:
|
|
169
|
+
return True
|
|
170
|
+
candidate = os.path.realpath(target)
|
|
171
|
+
for root in _WRITE_ROOTS:
|
|
172
|
+
root = os.path.realpath(root)
|
|
173
|
+
if candidate == root:
|
|
174
|
+
return True
|
|
175
|
+
if candidate.startswith(root.rstrip(os.sep) + os.sep):
|
|
176
|
+
return True
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _write_guard(target: str) -> Optional[str]:
|
|
181
|
+
"""Return an error string when writing to *target* is not permitted."""
|
|
182
|
+
if _READ_ONLY:
|
|
183
|
+
return "server is in read-only mode"
|
|
184
|
+
if not _within_write_roots(target):
|
|
185
|
+
return "path is outside allowed write roots: %s" % target
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _clamp_int(value: Any, low: int, high: int, default: int) -> int:
|
|
190
|
+
try:
|
|
191
|
+
result = int(value)
|
|
192
|
+
except (TypeError, ValueError):
|
|
193
|
+
return default
|
|
194
|
+
if result < low:
|
|
195
|
+
return low
|
|
196
|
+
if result > high:
|
|
197
|
+
return high
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _max_chars(value: Any) -> int:
|
|
202
|
+
if value is None:
|
|
203
|
+
return _MAX_OUTPUT_CHARS
|
|
204
|
+
try:
|
|
205
|
+
result = int(value)
|
|
206
|
+
except (TypeError, ValueError):
|
|
207
|
+
return _MAX_OUTPUT_CHARS
|
|
208
|
+
if result < 1:
|
|
209
|
+
return 1
|
|
210
|
+
if result > 50 * 1024 * 1024:
|
|
211
|
+
return 50 * 1024 * 1024
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _safe_int(value: Any, default: int = 0) -> int:
|
|
216
|
+
try:
|
|
217
|
+
return int(value)
|
|
218
|
+
except (TypeError, ValueError):
|
|
219
|
+
return default
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _sanitize_output(text: str) -> str:
|
|
223
|
+
"""Strip ANSI escapes and non-printable control characters."""
|
|
224
|
+
if not text:
|
|
225
|
+
return ""
|
|
226
|
+
text = re.sub(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", "", text)
|
|
227
|
+
return "".join(
|
|
228
|
+
ch for ch in text if ch in ("\n", "\r", "\t") or (ord(ch) >= 32 and ord(ch) != 127)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _paginate(full: str, cursor: Any, max_chars: int) -> Tuple[str, bool, Optional[int]]:
|
|
233
|
+
"""Return ``(chunk, truncated, next_cursor)`` for a character offset cursor."""
|
|
234
|
+
offset = max(0, _safe_int(cursor, 0))
|
|
235
|
+
total = len(full)
|
|
236
|
+
if offset >= total:
|
|
237
|
+
return "", False, None
|
|
238
|
+
end = offset + max_chars
|
|
239
|
+
chunk = full[offset:end]
|
|
240
|
+
if end < total:
|
|
241
|
+
return chunk, True, end
|
|
242
|
+
return chunk, False, None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _atomic_write_bytes(target: str, data: bytes, mode: Optional[int] = None) -> None:
|
|
246
|
+
"""Write *data* to *target* atomically (tmp file in the same dir + replace).
|
|
247
|
+
|
|
248
|
+
``mkstemp`` creates the temporary file with mode 0600, so the mode has to be
|
|
249
|
+
set explicitly or an overwrite would silently strip a file's permissions
|
|
250
|
+
(e.g. make a launcher script non-executable). Precedence:
|
|
251
|
+
explicit ``mode`` > the mode of the file being replaced > 0644 for new files.
|
|
252
|
+
"""
|
|
253
|
+
parent = os.path.dirname(target) or "."
|
|
254
|
+
existing_mode = None
|
|
255
|
+
if mode is None:
|
|
256
|
+
try:
|
|
257
|
+
existing_mode = os.stat(target).st_mode & 0o7777
|
|
258
|
+
except OSError:
|
|
259
|
+
existing_mode = None
|
|
260
|
+
fd, tmp = tempfile.mkstemp(prefix=".mcp-tmp-", dir=parent)
|
|
261
|
+
try:
|
|
262
|
+
with os.fdopen(fd, "wb") as handle:
|
|
263
|
+
handle.write(data)
|
|
264
|
+
handle.flush()
|
|
265
|
+
os.fsync(handle.fileno())
|
|
266
|
+
if mode is not None:
|
|
267
|
+
os.chmod(tmp, mode)
|
|
268
|
+
elif existing_mode is not None:
|
|
269
|
+
os.chmod(tmp, existing_mode)
|
|
270
|
+
else:
|
|
271
|
+
os.chmod(tmp, 0o644)
|
|
272
|
+
os.replace(tmp, target)
|
|
273
|
+
except Exception:
|
|
274
|
+
try:
|
|
275
|
+
os.unlink(tmp)
|
|
276
|
+
except OSError:
|
|
277
|
+
pass
|
|
278
|
+
raise
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _parse_mode(mode: Any) -> Optional[int]:
|
|
282
|
+
if mode is None:
|
|
283
|
+
return None
|
|
284
|
+
if isinstance(mode, bool):
|
|
285
|
+
raise ValueError("invalid mode")
|
|
286
|
+
if isinstance(mode, int):
|
|
287
|
+
return mode
|
|
288
|
+
text = str(mode).strip()
|
|
289
|
+
if not text:
|
|
290
|
+
return None
|
|
291
|
+
return int(text, 8)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
|
|
295
|
+
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
|
296
|
+
_atomic_write_bytes(path, data)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _read_json(path: str) -> Optional[Dict[str, Any]]:
|
|
300
|
+
try:
|
|
301
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
302
|
+
value = json.load(handle)
|
|
303
|
+
return value if isinstance(value, dict) else None
|
|
304
|
+
except Exception:
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _fallback_host_vitals() -> Dict[str, Any]:
|
|
309
|
+
"""Stdlib-only vitals collector used when core.vitals is unavailable."""
|
|
310
|
+
import platform
|
|
311
|
+
|
|
312
|
+
system = platform.system()
|
|
313
|
+
load = {"1m": 0.0, "5m": 0.0, "15m": 0.0}
|
|
314
|
+
try:
|
|
315
|
+
if hasattr(os, "getloadavg"):
|
|
316
|
+
la = os.getloadavg()
|
|
317
|
+
load = {"1m": round(la[0], 2), "5m": round(la[1], 2), "15m": round(la[2], 2)}
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
total_mb = 0.0
|
|
322
|
+
free_mb = 0.0
|
|
323
|
+
try:
|
|
324
|
+
with open("/proc/meminfo", "r") as handle:
|
|
325
|
+
for line in handle:
|
|
326
|
+
if line.startswith("MemTotal:"):
|
|
327
|
+
total_mb = round(int(line.split()[1]) / 1024.0, 1)
|
|
328
|
+
elif line.startswith("MemAvailable:"):
|
|
329
|
+
free_mb = round(int(line.split()[1]) / 1024.0, 1)
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
used_mb = max(0.0, round(total_mb - free_mb, 1))
|
|
334
|
+
used_pct = round((used_mb / total_mb) * 100.0, 1) if total_mb > 0 else 0.0
|
|
335
|
+
try:
|
|
336
|
+
du = shutil.disk_usage("C:\\" if system == "Windows" else "/")
|
|
337
|
+
disk = {
|
|
338
|
+
"total_gb": round(du.total / (1024 ** 3), 2),
|
|
339
|
+
"used_gb": round(du.used / (1024 ** 3), 2),
|
|
340
|
+
"free_gb": round(du.free / (1024 ** 3), 2),
|
|
341
|
+
"used_pct": round((du.used / du.total) * 100.0, 1) if du.total > 0 else 0.0,
|
|
342
|
+
}
|
|
343
|
+
except Exception:
|
|
344
|
+
disk = {"total_gb": 0.0, "used_gb": 0.0, "free_gb": 0.0, "used_pct": 0.0}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
"hostname": socket.gethostname(),
|
|
348
|
+
"os": system,
|
|
349
|
+
"cpu_load": load,
|
|
350
|
+
"ram": {"total_mb": total_mb, "used_mb": used_mb, "free_mb": free_mb, "used_pct": used_pct},
|
|
351
|
+
"disk": disk,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _skill_path() -> str:
|
|
356
|
+
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "skills", "orchestrator.md")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _load_orchestration_skill() -> str:
|
|
360
|
+
try:
|
|
361
|
+
with open(_skill_path(), "r", encoding="utf-8") as handle:
|
|
362
|
+
content = handle.read()
|
|
363
|
+
if content.strip():
|
|
364
|
+
return content
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
return (
|
|
368
|
+
"# Antigravity Mesh Orchestrator Skill (Standalone)\n"
|
|
369
|
+
"Mode: Local Standalone Server (antigravity_mesh).\n"
|
|
370
|
+
"All commands execute directly on this host through the Antigravity Mesh MCP tools.\n"
|
|
371
|
+
"Rules:\n"
|
|
372
|
+
"1. Never ask the user to run commands manually; use bash_exec.\n"
|
|
373
|
+
"2. Verify exit codes and stdout/stderr before continuing.\n"
|
|
374
|
+
"3. On non-zero exit code, halt and report the error details.\n"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Output spooling
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
def _outputs_dir() -> str:
|
|
383
|
+
path = os.path.join(_JOBS_DIR, "outputs")
|
|
384
|
+
os.makedirs(path, exist_ok=True)
|
|
385
|
+
return path
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _spool_output(text: str) -> Optional[str]:
|
|
389
|
+
"""Persist a large text blob and return its absolute path (best effort)."""
|
|
390
|
+
try:
|
|
391
|
+
path = os.path.join(_outputs_dir(), "%s.out" % uuid.uuid4().hex)
|
|
392
|
+
_atomic_write_bytes(path, text.encode("utf-8", "replace"))
|
|
393
|
+
return path
|
|
394
|
+
except Exception:
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
# Tool 1-3: legacy tools (kept as-is for compatibility)
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
def _tool_mesh_status(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
403
|
+
"""Report that this node is reachable, with hard evidence.
|
|
404
|
+
|
|
405
|
+
The answer exists because a model may otherwise claim the host is offline or
|
|
406
|
+
invent a reason. Every field here was just measured on the node: if this text
|
|
407
|
+
arrives at all, the agent and the tunnel are working by definition.
|
|
408
|
+
"""
|
|
409
|
+
import datetime
|
|
410
|
+
import socket as _socket
|
|
411
|
+
|
|
412
|
+
now = datetime.datetime.now().astimezone()
|
|
413
|
+
uptime_s = None
|
|
414
|
+
try:
|
|
415
|
+
with open("/proc/uptime", "r") as handle:
|
|
416
|
+
uptime_s = float(handle.read().split()[0])
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
try:
|
|
420
|
+
hostname = _socket.gethostname()
|
|
421
|
+
except Exception:
|
|
422
|
+
hostname = "unknown"
|
|
423
|
+
|
|
424
|
+
# The pid and the process start prove the agent is the one answering.
|
|
425
|
+
agent_pid = os.getpid()
|
|
426
|
+
payload = {
|
|
427
|
+
"status": "online",
|
|
428
|
+
"reachable": True,
|
|
429
|
+
"message": ("This node IS reachable: the request that produced this answer was executed "
|
|
430
|
+
"on the host by the mesh agent. If a previous attempt reported the node as "
|
|
431
|
+
"offline, that report was wrong - retry the operation instead of concluding "
|
|
432
|
+
"the service is down."),
|
|
433
|
+
"hostname": hostname,
|
|
434
|
+
"agent_pid": agent_pid,
|
|
435
|
+
"checked_at": now.isoformat(timespec="seconds"),
|
|
436
|
+
"node_utc_offset": now.strftime("%z"),
|
|
437
|
+
"workspace": _WORKSPACE,
|
|
438
|
+
"pid_namespace": os.uname().nodename if hasattr(os, "uname") else hostname,
|
|
439
|
+
}
|
|
440
|
+
if uptime_s is not None:
|
|
441
|
+
payload["host_uptime_seconds"] = int(uptime_s)
|
|
442
|
+
payload["host_uptime_human"] = "%dh %dm" % (int(uptime_s // 3600), int((uptime_s % 3600) // 60))
|
|
443
|
+
return payload
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _tool_system_info(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
447
|
+
"""A one-call summary of the host.
|
|
448
|
+
|
|
449
|
+
Exploratory agents spend several tool calls discovering the same basics (OS,
|
|
450
|
+
desktop, home, disks). Gemini's client allows only a handful of calls per
|
|
451
|
+
turn, so this answers them in one round trip.
|
|
452
|
+
"""
|
|
453
|
+
import platform
|
|
454
|
+
import socket as _socket
|
|
455
|
+
import subprocess as _sp
|
|
456
|
+
|
|
457
|
+
def run(cmd: str) -> str:
|
|
458
|
+
try:
|
|
459
|
+
out = _sp.run(cmd, shell=True, capture_output=True, text=True, timeout=8)
|
|
460
|
+
return (out.stdout or out.stderr).strip()
|
|
461
|
+
except Exception:
|
|
462
|
+
return ""
|
|
463
|
+
|
|
464
|
+
info: Dict[str, Any] = {
|
|
465
|
+
"hostname": _socket.gethostname(),
|
|
466
|
+
"os": platform.platform(),
|
|
467
|
+
"kernel": platform.release(),
|
|
468
|
+
"python": platform.python_version(),
|
|
469
|
+
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
|
|
470
|
+
"home": os.path.expanduser("~"),
|
|
471
|
+
"cwd": os.getcwd(),
|
|
472
|
+
"desktop": os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION") or "",
|
|
473
|
+
"session_type": os.environ.get("XDG_SESSION_TYPE", ""),
|
|
474
|
+
"shell": os.environ.get("SHELL", ""),
|
|
475
|
+
"disks": run("df -h --output=target,size,used,avail,pcent 2>/dev/null | head -8"),
|
|
476
|
+
"memory": run("free -h 2>/dev/null | head -3"),
|
|
477
|
+
"load": run("uptime"),
|
|
478
|
+
"top_processes": run("ps -eo comm,%mem --sort=-%mem 2>/dev/null | head -6"),
|
|
479
|
+
}
|
|
480
|
+
# Current wallpaper, the usual first question for a desktop node.
|
|
481
|
+
# Current wallpaper. KDE keeps the last applied value in the appletsrc as
|
|
482
|
+
# `Image=<path-or-dir>` (sometimes with a file:// prefix); GNOME uses
|
|
483
|
+
# gsettings. A directory means a wallpaper package, so resolve the image
|
|
484
|
+
# inside it the same way Plasma does.
|
|
485
|
+
wallpaper = ""
|
|
486
|
+
kde_cfg = os.path.expanduser("~/.config/plasma-org.kde.plasma.desktop-appletsrc")
|
|
487
|
+
if os.path.isfile(kde_cfg):
|
|
488
|
+
try:
|
|
489
|
+
with open(kde_cfg, "r", encoding="utf-8", errors="replace") as handle:
|
|
490
|
+
for line in handle:
|
|
491
|
+
if line.startswith("Image="):
|
|
492
|
+
wallpaper = line.strip()[len("Image="):]
|
|
493
|
+
except Exception:
|
|
494
|
+
wallpaper = ""
|
|
495
|
+
wallpaper = wallpaper.replace("file://", "")
|
|
496
|
+
if not wallpaper:
|
|
497
|
+
gnome = run("gsettings get org.gnome.desktop.background picture-uri 2>/dev/null")
|
|
498
|
+
if gnome and "file://" in gnome:
|
|
499
|
+
wallpaper = gnome.split("file://", 1)[1].strip().strip("'\"")
|
|
500
|
+
if wallpaper:
|
|
501
|
+
if os.path.isdir(wallpaper):
|
|
502
|
+
# A wallpaper package holds many resolutions and formats (png, jpg,
|
|
503
|
+
# webp, avif); pick the largest image inside it.
|
|
504
|
+
best, best_size = "", 0
|
|
505
|
+
for root, _dirs, files in os.walk(wallpaper):
|
|
506
|
+
for name in files:
|
|
507
|
+
if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".avif")):
|
|
508
|
+
path = os.path.join(root, name)
|
|
509
|
+
try:
|
|
510
|
+
size = os.path.getsize(path)
|
|
511
|
+
except OSError:
|
|
512
|
+
continue
|
|
513
|
+
if size > best_size:
|
|
514
|
+
best, best_size = path, size
|
|
515
|
+
wallpaper = best or wallpaper
|
|
516
|
+
info["wallpaper"] = wallpaper
|
|
517
|
+
info["wallpaper_exists"] = os.path.isfile(wallpaper)
|
|
518
|
+
return info
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _tool_system_vitals(args: Dict[str, Any]) -> Any:
|
|
522
|
+
try:
|
|
523
|
+
if _core_get_host_vitals is not None:
|
|
524
|
+
return _core_get_host_vitals()
|
|
525
|
+
except Exception:
|
|
526
|
+
pass
|
|
527
|
+
return _fallback_host_vitals()
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _tool_get_orchestration_skill(args: Dict[str, Any]) -> Any:
|
|
531
|
+
return _load_orchestration_skill()
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _tool_list_dir(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
535
|
+
try:
|
|
536
|
+
raw = args.get("path", "") or ""
|
|
537
|
+
target = _resolve(raw)
|
|
538
|
+
if not os.path.exists(target):
|
|
539
|
+
return {
|
|
540
|
+
"exit_code": 1,
|
|
541
|
+
"stdout": "",
|
|
542
|
+
"stderr": ("[TOOL ERROR] Path not found: %s -- this path does NOT exist. "
|
|
543
|
+
"Never report it as real; check the parent directory first." % target),
|
|
544
|
+
}
|
|
545
|
+
if os.path.isfile(target):
|
|
546
|
+
return _tool_read_file({"path": target})
|
|
547
|
+
|
|
548
|
+
entries = []
|
|
549
|
+
for entry in sorted(os.scandir(target), key=lambda x: (not x.is_dir(), x.name.lower())):
|
|
550
|
+
suffix = "/" if entry.is_dir() else ""
|
|
551
|
+
size = entry.stat().st_size if entry.is_file() else 0
|
|
552
|
+
entries.append(
|
|
553
|
+
"%s%s" % (entry.name, suffix)
|
|
554
|
+
+ (" (%s bytes)" % size if entry.is_file() else "")
|
|
555
|
+
)
|
|
556
|
+
listing = "\n".join(entries) if entries else "(empty directory)"
|
|
557
|
+
return {
|
|
558
|
+
"exit_code": 0,
|
|
559
|
+
# [VERIFIED BY TOOL] tells the model this listing came from the real
|
|
560
|
+
# filesystem in this call: only such data may be reported as fact.
|
|
561
|
+
"stdout": "[VERIFIED BY TOOL] Directory: %s (%d items):\n%s" % (target, len(entries), listing),
|
|
562
|
+
"stderr": "",
|
|
563
|
+
}
|
|
564
|
+
except Exception as exc:
|
|
565
|
+
return {"error": "list_dir failed: %s" % exc}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ---------------------------------------------------------------------------
|
|
569
|
+
# Tool 4: bash_exec
|
|
570
|
+
# ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
def _run_bash(command: str, timeout: int) -> Tuple[str, str, int, float]:
|
|
573
|
+
started = time.time()
|
|
574
|
+
try:
|
|
575
|
+
proc = subprocess.run(
|
|
576
|
+
["bash", "-c", command],
|
|
577
|
+
cwd=_WORKSPACE if os.path.isdir(_WORKSPACE) else None,
|
|
578
|
+
capture_output=True,
|
|
579
|
+
text=True,
|
|
580
|
+
timeout=timeout,
|
|
581
|
+
errors="replace",
|
|
582
|
+
)
|
|
583
|
+
return proc.stdout or "", proc.stderr or "", proc.returncode, round(time.time() - started, 3)
|
|
584
|
+
except subprocess.TimeoutExpired as exc:
|
|
585
|
+
out = exc.stdout or ""
|
|
586
|
+
err = exc.stderr or ""
|
|
587
|
+
if isinstance(out, bytes):
|
|
588
|
+
out = out.decode("utf-8", "replace")
|
|
589
|
+
if isinstance(err, bytes):
|
|
590
|
+
err = err.decode("utf-8", "replace")
|
|
591
|
+
err = (err + "\n" if err else "") + (
|
|
592
|
+
"Command timed out after %d seconds. Tip: for long background tasks use "
|
|
593
|
+
"'nohup ... > output.log 2>&1 &' or run_job." % timeout
|
|
594
|
+
)
|
|
595
|
+
return out, err, 124, round(time.time() - started, 3)
|
|
596
|
+
except Exception as exc:
|
|
597
|
+
return "", str(exc), 1, round(time.time() - started, 3)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _tool_bash_exec(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
601
|
+
command = args.get("command")
|
|
602
|
+
if not isinstance(command, str) or not command.strip():
|
|
603
|
+
return {"error": "command is required and must be a non-empty string"}
|
|
604
|
+
timeout = _clamp_int(args.get("timeout_sec"), 1, MAX_TIMEOUT_SEC, 25)
|
|
605
|
+
max_chars = _max_chars(args.get("max_chars"))
|
|
606
|
+
cursor = max(0, _safe_int(args.get("cursor"), 0))
|
|
607
|
+
|
|
608
|
+
stdout_raw, stderr_raw, exit_code, duration = _run_bash(command, timeout)
|
|
609
|
+
out = _sanitize_output(stdout_raw)
|
|
610
|
+
err = _sanitize_output(stderr_raw)
|
|
611
|
+
|
|
612
|
+
full = out + err
|
|
613
|
+
saved_to: Optional[str] = None
|
|
614
|
+
if len(full.encode("utf-8", "replace")) > SPOOL_THRESHOLD:
|
|
615
|
+
saved_to = _spool_output(full)
|
|
616
|
+
|
|
617
|
+
out_len = len(out)
|
|
618
|
+
offset = min(cursor, len(full))
|
|
619
|
+
end = offset + max_chars
|
|
620
|
+
if offset < out_len:
|
|
621
|
+
stdout_part = full[offset:min(end, out_len)]
|
|
622
|
+
else:
|
|
623
|
+
stdout_part = ""
|
|
624
|
+
if end > out_len:
|
|
625
|
+
stderr_part = full[max(offset, out_len):end]
|
|
626
|
+
else:
|
|
627
|
+
stderr_part = ""
|
|
628
|
+
next_cursor = end if end < len(full) else None
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
"stdout": stdout_part,
|
|
632
|
+
"stderr": stderr_part,
|
|
633
|
+
"exit_code": exit_code,
|
|
634
|
+
"duration": duration,
|
|
635
|
+
"truncated": next_cursor is not None,
|
|
636
|
+
"next_cursor": next_cursor,
|
|
637
|
+
"saved_to": saved_to,
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# ---------------------------------------------------------------------------
|
|
642
|
+
# Tool 5: read_file
|
|
643
|
+
# ---------------------------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
def _tool_read_file(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
646
|
+
try:
|
|
647
|
+
path = args.get("path")
|
|
648
|
+
if not isinstance(path, str) or not path.strip():
|
|
649
|
+
return {"error": "path is required"}
|
|
650
|
+
target = _resolve(path)
|
|
651
|
+
if not os.path.exists(target):
|
|
652
|
+
return {"error": "[TOOL ERROR] File not found: %s -- does not exist, do not report it as real." % target}
|
|
653
|
+
if os.path.isdir(target):
|
|
654
|
+
return _tool_list_dir({"path": target})
|
|
655
|
+
|
|
656
|
+
with open(target, "r", encoding="utf-8", errors="replace") as handle:
|
|
657
|
+
lines = handle.readlines()
|
|
658
|
+
|
|
659
|
+
start_line = max(1, _safe_int(args.get("start_line"), 1) or 1)
|
|
660
|
+
end_line = args.get("end_line")
|
|
661
|
+
end = min(len(lines), _safe_int(end_line, len(lines))) if end_line else len(lines)
|
|
662
|
+
selected = lines[start_line - 1:end]
|
|
663
|
+
width = max(4, len(str(end))) # keep the column stable for files >= 10000 lines
|
|
664
|
+
content = "".join(
|
|
665
|
+
"%*d | %s" % (width, idx + start_line, line) for idx, line in enumerate(selected)
|
|
666
|
+
)
|
|
667
|
+
header = "[VERIFIED BY TOOL] File: %s (lines %d-%d of %d total)\n%s\n" % (
|
|
668
|
+
target,
|
|
669
|
+
start_line,
|
|
670
|
+
end,
|
|
671
|
+
len(lines),
|
|
672
|
+
"-" * 60,
|
|
673
|
+
)
|
|
674
|
+
full = _sanitize_output(header + content)
|
|
675
|
+
|
|
676
|
+
max_chars = _max_chars(args.get("max_chars"))
|
|
677
|
+
cursor = max(0, _safe_int(args.get("cursor"), 0))
|
|
678
|
+
chunk, truncated, next_cursor = _paginate(full, cursor, max_chars)
|
|
679
|
+
# No marker is appended inside the page: the text must stay byte-exact so
|
|
680
|
+
# a client can stitch pages using next_cursor. Callers surface that value.
|
|
681
|
+
|
|
682
|
+
return {
|
|
683
|
+
"stdout": chunk,
|
|
684
|
+
"stderr": "",
|
|
685
|
+
"exit_code": 0,
|
|
686
|
+
"truncated": truncated,
|
|
687
|
+
"next_cursor": next_cursor,
|
|
688
|
+
}
|
|
689
|
+
except Exception as exc:
|
|
690
|
+
return {"error": "read_file failed: %s" % exc}
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
# ---------------------------------------------------------------------------
|
|
694
|
+
# Tool 6: write_file
|
|
695
|
+
# ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
def _tool_write_file(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
698
|
+
try:
|
|
699
|
+
path = args.get("path")
|
|
700
|
+
if not isinstance(path, str) or not path.strip():
|
|
701
|
+
return {"error": "path is required"}
|
|
702
|
+
if "content" not in args or args.get("content") is None:
|
|
703
|
+
return {"error": "content is required"}
|
|
704
|
+
content = args.get("content")
|
|
705
|
+
if not isinstance(content, str):
|
|
706
|
+
content = str(content)
|
|
707
|
+
|
|
708
|
+
target = _resolve(path)
|
|
709
|
+
guard = _write_guard(target)
|
|
710
|
+
if guard:
|
|
711
|
+
return {"error": guard}
|
|
712
|
+
|
|
713
|
+
create_dirs = args.get("create_dirs", True)
|
|
714
|
+
parent = os.path.dirname(target) or "."
|
|
715
|
+
if create_dirs and parent and not os.path.isdir(parent):
|
|
716
|
+
os.makedirs(parent, exist_ok=True)
|
|
717
|
+
if not os.path.isdir(parent):
|
|
718
|
+
return {"error": "parent directory does not exist: %s" % parent}
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
mode = _parse_mode(args.get("mode"))
|
|
722
|
+
except Exception:
|
|
723
|
+
return {"error": "invalid mode: %r" % (args.get("mode"),)}
|
|
724
|
+
|
|
725
|
+
data = content.encode("utf-8", "replace")
|
|
726
|
+
_atomic_write_bytes(target, data, mode=mode)
|
|
727
|
+
return {"ok": True, "path": target, "bytes": len(data), "sha256": _sha256_hex(data)}
|
|
728
|
+
except Exception as exc:
|
|
729
|
+
return {"error": "write_file failed: %s" % exc}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
# ---------------------------------------------------------------------------
|
|
733
|
+
# Tool 7: edit_file
|
|
734
|
+
# ---------------------------------------------------------------------------
|
|
735
|
+
|
|
736
|
+
def _tool_edit_file(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
737
|
+
try:
|
|
738
|
+
path = args.get("path")
|
|
739
|
+
if not isinstance(path, str) or not path.strip():
|
|
740
|
+
return {"error": "path is required"}
|
|
741
|
+
old_string = args.get("old_string")
|
|
742
|
+
if not isinstance(old_string, str) or old_string == "":
|
|
743
|
+
return {"error": "old_string is required and must be a non-empty string"}
|
|
744
|
+
new_string = args.get("new_string")
|
|
745
|
+
if not isinstance(new_string, str):
|
|
746
|
+
return {"error": "new_string is required and must be a string"}
|
|
747
|
+
|
|
748
|
+
target = _resolve(path)
|
|
749
|
+
guard = _write_guard(target)
|
|
750
|
+
if guard:
|
|
751
|
+
return {"error": guard}
|
|
752
|
+
if not os.path.isfile(target):
|
|
753
|
+
return {"error": "[TOOL ERROR] File not found: %s -- does not exist, do not report it as real." % target}
|
|
754
|
+
|
|
755
|
+
with open(target, "rb") as handle:
|
|
756
|
+
raw = handle.read()
|
|
757
|
+
current_sha = _sha256_hex(raw)
|
|
758
|
+
|
|
759
|
+
expected = args.get("expected_sha256")
|
|
760
|
+
if expected:
|
|
761
|
+
if str(expected).strip().lower() != current_sha:
|
|
762
|
+
return {"error": "file changed since read (sha256 mismatch)"}
|
|
763
|
+
|
|
764
|
+
# Byte-level replacement: decoding with errors="replace" would silently
|
|
765
|
+
# corrupt non-UTF-8 files (0xff -> U+FFFD). Matching the raw bytes keeps
|
|
766
|
+
# the edit surgical for any encoding while old/new stay valid UTF-8.
|
|
767
|
+
try:
|
|
768
|
+
old_bytes = old_string.encode("utf-8")
|
|
769
|
+
new_bytes = new_string.encode("utf-8")
|
|
770
|
+
except UnicodeEncodeError as exc:
|
|
771
|
+
return {"error": "old_string/new_string must be valid UTF-8 text: %s" % exc}
|
|
772
|
+
count = raw.count(old_bytes)
|
|
773
|
+
if count == 0:
|
|
774
|
+
return {"error": "old_string not found"}
|
|
775
|
+
replace_all = _parse_bool(args.get("replace_all"))
|
|
776
|
+
if count > 1 and not replace_all:
|
|
777
|
+
return {"error": "old_string is not unique (%d matches)" % count}
|
|
778
|
+
|
|
779
|
+
replacements = count if replace_all else 1
|
|
780
|
+
if replace_all:
|
|
781
|
+
data = raw.replace(old_bytes, new_bytes)
|
|
782
|
+
else:
|
|
783
|
+
data = raw.replace(old_bytes, new_bytes, 1)
|
|
784
|
+
|
|
785
|
+
_atomic_write_bytes(target, data)
|
|
786
|
+
return {
|
|
787
|
+
"ok": True,
|
|
788
|
+
"path": target,
|
|
789
|
+
"replacements": replacements,
|
|
790
|
+
"sha256": _sha256_hex(data),
|
|
791
|
+
}
|
|
792
|
+
except Exception as exc:
|
|
793
|
+
return {"error": "edit_file failed: %s" % exc}
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
# ---------------------------------------------------------------------------
|
|
797
|
+
# Tool 8: grep_search
|
|
798
|
+
# ---------------------------------------------------------------------------
|
|
799
|
+
|
|
800
|
+
def _is_probably_binary(path: str) -> bool:
|
|
801
|
+
try:
|
|
802
|
+
with open(path, "rb") as handle:
|
|
803
|
+
return b"\x00" in handle.read(4096)
|
|
804
|
+
except Exception:
|
|
805
|
+
return True
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _iter_files(base: str):
|
|
809
|
+
if os.path.isfile(base):
|
|
810
|
+
yield base
|
|
811
|
+
return
|
|
812
|
+
for root, dirs, files in os.walk(base):
|
|
813
|
+
dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
|
|
814
|
+
for name in files:
|
|
815
|
+
yield os.path.join(root, name)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _tool_grep_search(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
819
|
+
try:
|
|
820
|
+
pattern = args.get("pattern")
|
|
821
|
+
if not isinstance(pattern, str) or pattern == "":
|
|
822
|
+
return {"error": "pattern is required and must be a non-empty string"}
|
|
823
|
+
base = _resolve(args.get("path") or ".")
|
|
824
|
+
if not os.path.exists(base):
|
|
825
|
+
return {"error": "Path not found: %s" % base}
|
|
826
|
+
|
|
827
|
+
limit = _clamp_int(args.get("limit"), 1, 1000, 200)
|
|
828
|
+
ignore_case = _parse_bool(args.get("ignore_case"))
|
|
829
|
+
fixed = _parse_bool(args.get("fixed"))
|
|
830
|
+
try:
|
|
831
|
+
context = max(0, min(20, _safe_int(args.get("context"), 0)))
|
|
832
|
+
except Exception:
|
|
833
|
+
context = 0
|
|
834
|
+
glob_filter = args.get("glob")
|
|
835
|
+
if glob_filter is not None and not isinstance(glob_filter, str):
|
|
836
|
+
return {"error": "glob must be a string"}
|
|
837
|
+
|
|
838
|
+
flags = re.IGNORECASE if ignore_case else 0
|
|
839
|
+
if fixed:
|
|
840
|
+
needle = pattern.lower() if ignore_case else pattern
|
|
841
|
+
else:
|
|
842
|
+
try:
|
|
843
|
+
regex = re.compile(pattern, flags)
|
|
844
|
+
except re.error as exc:
|
|
845
|
+
return {"error": "invalid regular expression: %s" % exc}
|
|
846
|
+
|
|
847
|
+
matches: List[Dict[str, Any]] = []
|
|
848
|
+
truncated = False
|
|
849
|
+
for file_path in _iter_files(base):
|
|
850
|
+
if glob_filter and not fnmatch.fnmatch(os.path.basename(file_path), glob_filter):
|
|
851
|
+
continue
|
|
852
|
+
if _is_probably_binary(file_path):
|
|
853
|
+
continue
|
|
854
|
+
try:
|
|
855
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as handle:
|
|
856
|
+
lines = handle.read().splitlines()
|
|
857
|
+
except Exception:
|
|
858
|
+
continue
|
|
859
|
+
|
|
860
|
+
hit_lines = []
|
|
861
|
+
for idx, line in enumerate(lines):
|
|
862
|
+
found = (needle in (line.lower() if ignore_case else line)) if fixed else bool(regex.search(line))
|
|
863
|
+
if found:
|
|
864
|
+
hit_lines.append(idx)
|
|
865
|
+
if not hit_lines:
|
|
866
|
+
continue
|
|
867
|
+
|
|
868
|
+
emitted = set()
|
|
869
|
+
for idx in hit_lines:
|
|
870
|
+
first = max(0, idx - context)
|
|
871
|
+
last = min(len(lines) - 1, idx + context)
|
|
872
|
+
for lineno in range(first, last + 1):
|
|
873
|
+
if lineno in emitted:
|
|
874
|
+
continue
|
|
875
|
+
if len(matches) >= limit:
|
|
876
|
+
truncated = True
|
|
877
|
+
break
|
|
878
|
+
emitted.add(lineno)
|
|
879
|
+
matches.append(
|
|
880
|
+
{"path": os.path.realpath(file_path), "line": lineno + 1, "text": lines[lineno]}
|
|
881
|
+
)
|
|
882
|
+
if truncated:
|
|
883
|
+
break
|
|
884
|
+
if truncated:
|
|
885
|
+
break
|
|
886
|
+
|
|
887
|
+
return {"matches": matches, "count": len(matches), "truncated": truncated,
|
|
888
|
+
"note": "[VERIFIED BY TOOL] matches come from the real filesystem"}
|
|
889
|
+
except Exception as exc:
|
|
890
|
+
return {"error": "grep_search failed: %s" % exc}
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
# ---------------------------------------------------------------------------
|
|
894
|
+
# Tool 9: glob_find
|
|
895
|
+
# ---------------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
def _skip_path(path: str) -> bool:
|
|
898
|
+
parts = path.split(os.sep)
|
|
899
|
+
return any(part in _SKIP_DIRS for part in parts)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _tool_glob_find(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
903
|
+
try:
|
|
904
|
+
pattern = args.get("pattern")
|
|
905
|
+
if not isinstance(pattern, str) or not pattern.strip():
|
|
906
|
+
return {"error": "pattern is required and must be a non-empty string"}
|
|
907
|
+
base = _resolve(args.get("path") or ".")
|
|
908
|
+
if not os.path.exists(base):
|
|
909
|
+
return {"error": "Path not found: %s" % base}
|
|
910
|
+
|
|
911
|
+
found: List[str] = []
|
|
912
|
+
if "/" in pattern or "**" in pattern:
|
|
913
|
+
# Patterns containing a path separator must be matched against the
|
|
914
|
+
# path relative to `base` (sub/*.py, */b.py, sub/b.py, **/b.py);
|
|
915
|
+
# matching a bare basename against them always returned nothing.
|
|
916
|
+
try:
|
|
917
|
+
found = _glob.glob(os.path.join(_glob.escape(base), pattern), recursive=True)
|
|
918
|
+
except Exception as exc:
|
|
919
|
+
return {"error": "glob_find failed: %s" % exc}
|
|
920
|
+
found = [p for p in found if os.path.isfile(p) and not _skip_path(p)]
|
|
921
|
+
else:
|
|
922
|
+
for root, dirs, files in os.walk(base):
|
|
923
|
+
dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
|
|
924
|
+
for name in files:
|
|
925
|
+
if fnmatch.fnmatch(name, pattern):
|
|
926
|
+
found.append(os.path.join(root, name))
|
|
927
|
+
|
|
928
|
+
# Deduplicate: glob.glob itself returns duplicates for patterns such as
|
|
929
|
+
# "**/**/*.py", which would otherwise be reported several times.
|
|
930
|
+
files = sorted({os.path.realpath(p) for p in found})
|
|
931
|
+
truncated = len(files) > GLOB_MAX_RESULTS
|
|
932
|
+
if truncated:
|
|
933
|
+
files = files[:GLOB_MAX_RESULTS]
|
|
934
|
+
return {"files": files, "count": len(files), "truncated": truncated,
|
|
935
|
+
"note": "[VERIFIED BY TOOL] paths come from the real filesystem"}
|
|
936
|
+
except Exception as exc:
|
|
937
|
+
return {"error": "glob_find failed: %s" % exc}
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
# ---------------------------------------------------------------------------
|
|
941
|
+
# Tools 10-13: background jobs
|
|
942
|
+
# ---------------------------------------------------------------------------
|
|
943
|
+
|
|
944
|
+
class _OutputBudget(object):
|
|
945
|
+
"""Shared 64 MB write budget for one job's stdout+stderr streams."""
|
|
946
|
+
|
|
947
|
+
def __init__(self, cap: int = JOB_OUTPUT_CAP) -> None:
|
|
948
|
+
self.cap = cap
|
|
949
|
+
self.used = 0
|
|
950
|
+
self.notified = False
|
|
951
|
+
self.lock = threading.Lock()
|
|
952
|
+
|
|
953
|
+
def write(self, handle, chunk: bytes) -> None:
|
|
954
|
+
with self.lock:
|
|
955
|
+
remaining = self.cap - self.used
|
|
956
|
+
if remaining <= 0:
|
|
957
|
+
return
|
|
958
|
+
take = chunk[:remaining]
|
|
959
|
+
handle.write(take)
|
|
960
|
+
self.used += len(take)
|
|
961
|
+
if len(take) < len(chunk) and not self.notified:
|
|
962
|
+
handle.write(b"\n... [job output truncated at 64 MB] ...\n")
|
|
963
|
+
self.notified = True
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _jobs_root() -> str:
|
|
967
|
+
os.makedirs(_JOBS_DIR, exist_ok=True)
|
|
968
|
+
return _JOBS_DIR
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _job_dir(job_id: str) -> str:
|
|
972
|
+
return os.path.join(_JOBS_DIR, job_id)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _meta_path(job_id: str) -> str:
|
|
976
|
+
return os.path.join(_job_dir(job_id), "meta.json")
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _read_meta(job_id: str) -> Optional[Dict[str, Any]]:
|
|
980
|
+
if not job_id or not re.match(r"^[A-Za-z0-9_.-]{1,128}$", job_id):
|
|
981
|
+
return None
|
|
982
|
+
return _read_json(_meta_path(job_id))
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _update_meta(job_id: str, **fields: Any) -> Optional[Dict[str, Any]]:
|
|
986
|
+
with _META_LOCK:
|
|
987
|
+
meta = _read_json(_meta_path(job_id))
|
|
988
|
+
if meta is None:
|
|
989
|
+
return None
|
|
990
|
+
meta.update(fields)
|
|
991
|
+
try:
|
|
992
|
+
_write_json_atomic(_meta_path(job_id), meta)
|
|
993
|
+
except Exception:
|
|
994
|
+
return meta
|
|
995
|
+
return meta
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _proc_start_time(pid: Any) -> Optional[int]:
|
|
999
|
+
"""Start time of a process (field 22 of /proc/<pid>/stat), used to detect PID reuse."""
|
|
1000
|
+
try:
|
|
1001
|
+
with open("/proc/%d/stat" % int(pid), "rb") as handle:
|
|
1002
|
+
data = handle.read()
|
|
1003
|
+
rest = data.rsplit(b")", 1)[1].split()
|
|
1004
|
+
return int(rest[19])
|
|
1005
|
+
except Exception:
|
|
1006
|
+
return None
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _pid_alive(pid: Any, start_time: Any = None) -> bool:
|
|
1010
|
+
try:
|
|
1011
|
+
pid_int = int(pid)
|
|
1012
|
+
except (TypeError, ValueError):
|
|
1013
|
+
return False
|
|
1014
|
+
if pid_int <= 0:
|
|
1015
|
+
return False
|
|
1016
|
+
# After a restart the in-memory entry is gone; without the start time a
|
|
1017
|
+
# recycled PID would keep a finished job looking "running" forever.
|
|
1018
|
+
if start_time is not None:
|
|
1019
|
+
current = _proc_start_time(pid_int)
|
|
1020
|
+
if current is None:
|
|
1021
|
+
return False
|
|
1022
|
+
try:
|
|
1023
|
+
if int(start_time) != current:
|
|
1024
|
+
return False
|
|
1025
|
+
except (TypeError, ValueError):
|
|
1026
|
+
pass
|
|
1027
|
+
try:
|
|
1028
|
+
os.kill(pid_int, 0)
|
|
1029
|
+
except ProcessLookupError:
|
|
1030
|
+
return False
|
|
1031
|
+
except PermissionError:
|
|
1032
|
+
return True
|
|
1033
|
+
except Exception:
|
|
1034
|
+
return False
|
|
1035
|
+
return True
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _duration_from_meta(meta: Dict[str, Any]) -> Optional[float]:
|
|
1039
|
+
started = meta.get("started_at")
|
|
1040
|
+
finished = meta.get("finished_at")
|
|
1041
|
+
if not started:
|
|
1042
|
+
return meta.get("duration")
|
|
1043
|
+
try:
|
|
1044
|
+
start_ts = datetime.fromisoformat(str(started))
|
|
1045
|
+
except Exception:
|
|
1046
|
+
return meta.get("duration")
|
|
1047
|
+
if finished:
|
|
1048
|
+
try:
|
|
1049
|
+
end_ts = datetime.fromisoformat(str(finished))
|
|
1050
|
+
except Exception:
|
|
1051
|
+
end_ts = datetime.now(timezone.utc)
|
|
1052
|
+
else:
|
|
1053
|
+
end_ts = datetime.now(timezone.utc)
|
|
1054
|
+
try:
|
|
1055
|
+
return round((end_ts - start_ts).total_seconds(), 3)
|
|
1056
|
+
except Exception:
|
|
1057
|
+
return meta.get("duration")
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _refresh_meta(job_id: str, meta: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
|
1061
|
+
"""Bring a job's persisted status up to date. Never raises."""
|
|
1062
|
+
try:
|
|
1063
|
+
if meta is None:
|
|
1064
|
+
meta = _read_json(_meta_path(job_id))
|
|
1065
|
+
if meta is None:
|
|
1066
|
+
return None
|
|
1067
|
+
if meta.get("status") != "running":
|
|
1068
|
+
return meta
|
|
1069
|
+
|
|
1070
|
+
entry = _JOBS.get(job_id) or {}
|
|
1071
|
+
proc = entry.get("proc")
|
|
1072
|
+
if proc is not None:
|
|
1073
|
+
returncode = proc.poll()
|
|
1074
|
+
if returncode is None:
|
|
1075
|
+
return meta
|
|
1076
|
+
# The process is gone: let the output pumps drain their pipes so a
|
|
1077
|
+
# subsequent file read cannot miss the tail of the output.
|
|
1078
|
+
for thread in entry.get("readers", []):
|
|
1079
|
+
try:
|
|
1080
|
+
thread.join(timeout=0.5)
|
|
1081
|
+
except Exception:
|
|
1082
|
+
pass
|
|
1083
|
+
status = "killed" if meta.get("status") == "killed" else "done"
|
|
1084
|
+
return _update_meta(
|
|
1085
|
+
job_id,
|
|
1086
|
+
status=status,
|
|
1087
|
+
exit_code=returncode,
|
|
1088
|
+
duration=_duration_from_meta(meta),
|
|
1089
|
+
finished_at=meta.get("finished_at") or _now_iso(),
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
if not _pid_alive(meta.get("pid"), meta.get("pid_start")):
|
|
1093
|
+
return _update_meta(
|
|
1094
|
+
job_id,
|
|
1095
|
+
status="done",
|
|
1096
|
+
duration=_duration_from_meta(meta),
|
|
1097
|
+
finished_at=meta.get("finished_at") or _now_iso(),
|
|
1098
|
+
)
|
|
1099
|
+
return meta
|
|
1100
|
+
except Exception:
|
|
1101
|
+
return meta
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _all_metas() -> List[Dict[str, Any]]:
|
|
1105
|
+
metas: List[Dict[str, Any]] = []
|
|
1106
|
+
try:
|
|
1107
|
+
if not os.path.isdir(_JOBS_DIR):
|
|
1108
|
+
return metas
|
|
1109
|
+
for name in os.listdir(_JOBS_DIR):
|
|
1110
|
+
path = os.path.join(_JOBS_DIR, name, "meta.json")
|
|
1111
|
+
if not os.path.isfile(path):
|
|
1112
|
+
continue
|
|
1113
|
+
meta = _read_json(path)
|
|
1114
|
+
if meta is not None:
|
|
1115
|
+
meta.setdefault("job_id", name)
|
|
1116
|
+
metas.append(meta)
|
|
1117
|
+
except Exception:
|
|
1118
|
+
pass
|
|
1119
|
+
return metas
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _running_jobs() -> List[Dict[str, Any]]:
|
|
1123
|
+
running = []
|
|
1124
|
+
for meta in _all_metas():
|
|
1125
|
+
refreshed = _refresh_meta(meta.get("job_id", ""), meta)
|
|
1126
|
+
if refreshed and refreshed.get("status") == "running":
|
|
1127
|
+
running.append(refreshed)
|
|
1128
|
+
return running
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _pump(stream, path: str, budget: _OutputBudget) -> None:
|
|
1132
|
+
try:
|
|
1133
|
+
with open(path, "ab", buffering=0) as handle:
|
|
1134
|
+
while True:
|
|
1135
|
+
chunk = stream.read(65536)
|
|
1136
|
+
if not chunk:
|
|
1137
|
+
break
|
|
1138
|
+
budget.write(handle, chunk)
|
|
1139
|
+
except Exception:
|
|
1140
|
+
pass
|
|
1141
|
+
finally:
|
|
1142
|
+
try:
|
|
1143
|
+
stream.close()
|
|
1144
|
+
except Exception:
|
|
1145
|
+
pass
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _reap(job_id: str, proc: "subprocess.Popen", started_mono: float) -> None:
|
|
1149
|
+
"""Background thread: collect a finished job and persist its result."""
|
|
1150
|
+
try:
|
|
1151
|
+
returncode = proc.wait()
|
|
1152
|
+
except Exception:
|
|
1153
|
+
returncode = None
|
|
1154
|
+
|
|
1155
|
+
entry = _JOBS.get(job_id) or {}
|
|
1156
|
+
for thread in entry.get("readers", []):
|
|
1157
|
+
try:
|
|
1158
|
+
thread.join(timeout=5)
|
|
1159
|
+
except Exception:
|
|
1160
|
+
pass
|
|
1161
|
+
|
|
1162
|
+
with _META_LOCK:
|
|
1163
|
+
meta = _read_json(_meta_path(job_id))
|
|
1164
|
+
if meta is None:
|
|
1165
|
+
return
|
|
1166
|
+
if meta.get("status") == "killed":
|
|
1167
|
+
status = "killed"
|
|
1168
|
+
else:
|
|
1169
|
+
status = "done"
|
|
1170
|
+
meta.update(
|
|
1171
|
+
{
|
|
1172
|
+
"status": status,
|
|
1173
|
+
"exit_code": returncode,
|
|
1174
|
+
"duration": round(time.time() - started_mono, 3),
|
|
1175
|
+
"finished_at": _now_iso(),
|
|
1176
|
+
}
|
|
1177
|
+
)
|
|
1178
|
+
try:
|
|
1179
|
+
_write_json_atomic(_meta_path(job_id), meta)
|
|
1180
|
+
except Exception:
|
|
1181
|
+
pass
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _tool_run_job(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
1185
|
+
try:
|
|
1186
|
+
command = args.get("command")
|
|
1187
|
+
if not isinstance(command, str) or not command.strip():
|
|
1188
|
+
return {"error": "command is required and must be a non-empty string"}
|
|
1189
|
+
|
|
1190
|
+
if len(_running_jobs()) >= MAX_RUNNING_JOBS:
|
|
1191
|
+
return {"error": "too many running jobs (limit %d)" % MAX_RUNNING_JOBS}
|
|
1192
|
+
|
|
1193
|
+
cwd = None
|
|
1194
|
+
if args.get("cwd"):
|
|
1195
|
+
cwd = _resolve(args.get("cwd"))
|
|
1196
|
+
if not os.path.isdir(cwd):
|
|
1197
|
+
return {"error": "cwd is not a directory: %s" % cwd}
|
|
1198
|
+
if cwd is None:
|
|
1199
|
+
cwd = _WORKSPACE if os.path.isdir(_WORKSPACE) else None
|
|
1200
|
+
|
|
1201
|
+
job_id = uuid.uuid4().hex
|
|
1202
|
+
job_dir = _job_dir(job_id)
|
|
1203
|
+
os.makedirs(job_dir, exist_ok=True)
|
|
1204
|
+
stdout_path = os.path.join(job_dir, "stdout.log")
|
|
1205
|
+
stderr_path = os.path.join(job_dir, "stderr.log")
|
|
1206
|
+
for path in (stdout_path, stderr_path):
|
|
1207
|
+
with open(path, "ab"):
|
|
1208
|
+
pass
|
|
1209
|
+
|
|
1210
|
+
try:
|
|
1211
|
+
proc = subprocess.Popen(
|
|
1212
|
+
["bash", "-c", command],
|
|
1213
|
+
cwd=cwd,
|
|
1214
|
+
stdin=subprocess.DEVNULL,
|
|
1215
|
+
stdout=subprocess.PIPE,
|
|
1216
|
+
stderr=subprocess.PIPE,
|
|
1217
|
+
bufsize=0,
|
|
1218
|
+
start_new_session=True,
|
|
1219
|
+
)
|
|
1220
|
+
except Exception as exc:
|
|
1221
|
+
return {"error": "failed to start job: %s" % exc}
|
|
1222
|
+
|
|
1223
|
+
started_at = _now_iso()
|
|
1224
|
+
budget = _OutputBudget()
|
|
1225
|
+
readers = [
|
|
1226
|
+
threading.Thread(target=_pump, args=(proc.stdout, stdout_path, budget), daemon=True),
|
|
1227
|
+
threading.Thread(target=_pump, args=(proc.stderr, stderr_path, budget), daemon=True),
|
|
1228
|
+
]
|
|
1229
|
+
for thread in readers:
|
|
1230
|
+
thread.start()
|
|
1231
|
+
started_mono = time.time()
|
|
1232
|
+
_JOBS[job_id] = {
|
|
1233
|
+
"proc": proc,
|
|
1234
|
+
"readers": readers,
|
|
1235
|
+
"started_mono": started_mono,
|
|
1236
|
+
"pid_start": _proc_start_time(proc.pid),
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
meta = {
|
|
1240
|
+
"job_id": job_id,
|
|
1241
|
+
"command": command,
|
|
1242
|
+
"pid": proc.pid,
|
|
1243
|
+
"pid_start": _proc_start_time(proc.pid),
|
|
1244
|
+
"cwd": cwd,
|
|
1245
|
+
"started_at": started_at,
|
|
1246
|
+
"status": "running",
|
|
1247
|
+
"exit_code": None,
|
|
1248
|
+
"duration": None,
|
|
1249
|
+
"finished_at": None,
|
|
1250
|
+
"stdout_path": stdout_path,
|
|
1251
|
+
"stderr_path": stderr_path,
|
|
1252
|
+
}
|
|
1253
|
+
try:
|
|
1254
|
+
_write_json_atomic(_meta_path(job_id), meta)
|
|
1255
|
+
except Exception as exc:
|
|
1256
|
+
_JOBS.pop(job_id, None)
|
|
1257
|
+
return {"error": "failed to persist job metadata: %s" % exc}
|
|
1258
|
+
|
|
1259
|
+
reaper = threading.Thread(target=_reap, args=(job_id, proc, started_mono), daemon=True)
|
|
1260
|
+
reaper.start()
|
|
1261
|
+
_JOBS[job_id]["reaper"] = reaper
|
|
1262
|
+
|
|
1263
|
+
return {
|
|
1264
|
+
"job_id": job_id,
|
|
1265
|
+
"pid": proc.pid,
|
|
1266
|
+
"pid_start": meta.get("pid_start"),
|
|
1267
|
+
"command": command,
|
|
1268
|
+
"started_at": started_at,
|
|
1269
|
+
}
|
|
1270
|
+
except Exception as exc:
|
|
1271
|
+
return {"error": "run_job failed: %s" % exc}
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _wait_for_job(job_id: str, wait_ms: int) -> None:
|
|
1275
|
+
deadline = time.time() + (wait_ms / 1000.0)
|
|
1276
|
+
while True:
|
|
1277
|
+
meta = _refresh_meta(job_id)
|
|
1278
|
+
if meta is None or meta.get("status") != "running":
|
|
1279
|
+
return
|
|
1280
|
+
if time.time() >= deadline:
|
|
1281
|
+
return
|
|
1282
|
+
entry = _JOBS.get(job_id) or {}
|
|
1283
|
+
proc = entry.get("proc")
|
|
1284
|
+
remaining = max(0.0, deadline - time.time())
|
|
1285
|
+
if proc is not None:
|
|
1286
|
+
try:
|
|
1287
|
+
proc.wait(timeout=min(remaining, 0.2) or 0.01)
|
|
1288
|
+
except Exception:
|
|
1289
|
+
pass
|
|
1290
|
+
else:
|
|
1291
|
+
time.sleep(min(0.1, remaining or 0.01))
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _tool_job_output(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
1295
|
+
try:
|
|
1296
|
+
job_id = args.get("job_id")
|
|
1297
|
+
if not isinstance(job_id, str) or not job_id.strip():
|
|
1298
|
+
return {"error": "job_id is required"}
|
|
1299
|
+
job_id = job_id.strip()
|
|
1300
|
+
meta = _read_meta(job_id)
|
|
1301
|
+
if meta is None:
|
|
1302
|
+
return {"error": "job not found: %s" % job_id}
|
|
1303
|
+
|
|
1304
|
+
wait_ms = _clamp_int(args.get("wait_ms"), 0, MAX_WAIT_MS, 0)
|
|
1305
|
+
if wait_ms:
|
|
1306
|
+
_wait_for_job(job_id, wait_ms)
|
|
1307
|
+
meta = _refresh_meta(job_id) or meta
|
|
1308
|
+
|
|
1309
|
+
stdout_path = meta.get("stdout_path") or os.path.join(_job_dir(job_id), "stdout.log")
|
|
1310
|
+
stderr_path = meta.get("stderr_path") or os.path.join(_job_dir(job_id), "stderr.log")
|
|
1311
|
+
|
|
1312
|
+
def _read_text(path: str) -> str:
|
|
1313
|
+
try:
|
|
1314
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
1315
|
+
return _sanitize_output(handle.read())
|
|
1316
|
+
except Exception:
|
|
1317
|
+
return ""
|
|
1318
|
+
|
|
1319
|
+
stdout_full = _read_text(stdout_path)
|
|
1320
|
+
stderr_full = _read_text(stderr_path)
|
|
1321
|
+
|
|
1322
|
+
max_chars = _max_chars(args.get("max_chars"))
|
|
1323
|
+
cursor = max(0, _safe_int(args.get("cursor"), 0))
|
|
1324
|
+
stdout_part, truncated, next_cursor = _paginate(stdout_full, cursor, max_chars)
|
|
1325
|
+
stderr_part = stderr_full[:max_chars]
|
|
1326
|
+
stderr_truncated = len(stderr_full) > max_chars
|
|
1327
|
+
|
|
1328
|
+
return {
|
|
1329
|
+
"status": meta.get("status", "running"),
|
|
1330
|
+
"exit_code": meta.get("exit_code"),
|
|
1331
|
+
"stdout": stdout_part,
|
|
1332
|
+
"stderr": stderr_part,
|
|
1333
|
+
"next_cursor": next_cursor,
|
|
1334
|
+
"duration": meta.get("duration") if meta.get("duration") is not None else _duration_from_meta(meta),
|
|
1335
|
+
"truncated": bool(truncated or stderr_truncated),
|
|
1336
|
+
}
|
|
1337
|
+
except Exception as exc:
|
|
1338
|
+
return {"error": "job_output failed: %s" % exc}
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
_SIGNALS = {
|
|
1342
|
+
"TERM": _signal.SIGTERM,
|
|
1343
|
+
"KILL": _signal.SIGKILL,
|
|
1344
|
+
"INT": _signal.SIGINT,
|
|
1345
|
+
"HUP": _signal.SIGHUP,
|
|
1346
|
+
"QUIT": _signal.SIGQUIT,
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _tool_job_kill(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
1351
|
+
try:
|
|
1352
|
+
if _READ_ONLY:
|
|
1353
|
+
return {"error": "server is in read-only mode"}
|
|
1354
|
+
job_id = args.get("job_id")
|
|
1355
|
+
if not isinstance(job_id, str) or not job_id.strip():
|
|
1356
|
+
return {"error": "job_id is required"}
|
|
1357
|
+
job_id = job_id.strip()
|
|
1358
|
+
meta = _read_meta(job_id)
|
|
1359
|
+
if meta is None:
|
|
1360
|
+
return {"error": "job not found: %s" % job_id}
|
|
1361
|
+
meta = _refresh_meta(job_id) or meta
|
|
1362
|
+
|
|
1363
|
+
raw_signal = args.get("signal", "TERM")
|
|
1364
|
+
if isinstance(raw_signal, int) and not isinstance(raw_signal, bool):
|
|
1365
|
+
sig = raw_signal
|
|
1366
|
+
else:
|
|
1367
|
+
sig = _SIGNALS.get(str(raw_signal).strip().upper())
|
|
1368
|
+
if sig is None:
|
|
1369
|
+
return {"error": "unsupported signal: %r" % (raw_signal,)}
|
|
1370
|
+
|
|
1371
|
+
if meta.get("status") != "running":
|
|
1372
|
+
return {"ok": True, "status": meta.get("status", "done")}
|
|
1373
|
+
|
|
1374
|
+
pid = meta.get("pid")
|
|
1375
|
+
delivered = False
|
|
1376
|
+
try:
|
|
1377
|
+
os.killpg(os.getpgid(int(pid)), sig)
|
|
1378
|
+
delivered = True
|
|
1379
|
+
except Exception:
|
|
1380
|
+
try:
|
|
1381
|
+
os.kill(int(pid), sig)
|
|
1382
|
+
delivered = True
|
|
1383
|
+
except Exception as exc:
|
|
1384
|
+
return {"error": "failed to signal job %s: %s" % (job_id, exc)}
|
|
1385
|
+
|
|
1386
|
+
if delivered:
|
|
1387
|
+
_update_meta(job_id, status="killed", finished_at=_now_iso(), duration=_duration_from_meta(meta))
|
|
1388
|
+
return {"ok": True, "status": "killed"}
|
|
1389
|
+
except Exception as exc:
|
|
1390
|
+
return {"error": "job_kill failed: %s" % exc}
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _tool_job_list(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
1394
|
+
try:
|
|
1395
|
+
metas = []
|
|
1396
|
+
for meta in _all_metas():
|
|
1397
|
+
refreshed = _refresh_meta(meta.get("job_id", ""), meta) or meta
|
|
1398
|
+
metas.append(refreshed)
|
|
1399
|
+
metas.sort(key=lambda item: str(item.get("started_at") or ""), reverse=True)
|
|
1400
|
+
# The full history is unbounded (one entry per job ever started), and every
|
|
1401
|
+
# byte here lands in the model's context. Show the most recent few by
|
|
1402
|
+
# default and let the caller ask for more explicitly.
|
|
1403
|
+
try:
|
|
1404
|
+
limit = int(args.get("limit") or 20)
|
|
1405
|
+
except (TypeError, ValueError):
|
|
1406
|
+
limit = 20
|
|
1407
|
+
limit = max(1, min(limit, 50))
|
|
1408
|
+
jobs = []
|
|
1409
|
+
for meta in metas[:limit]:
|
|
1410
|
+
command = str(meta.get("command") or "")
|
|
1411
|
+
if len(command) > 80:
|
|
1412
|
+
command = command[:77] + "..."
|
|
1413
|
+
jobs.append({
|
|
1414
|
+
"job_id": meta.get("job_id"),
|
|
1415
|
+
"command": command,
|
|
1416
|
+
"status": meta.get("status", "running"),
|
|
1417
|
+
"exit_code": meta.get("exit_code"),
|
|
1418
|
+
"duration": meta.get("duration"),
|
|
1419
|
+
"started_at": meta.get("started_at"),
|
|
1420
|
+
})
|
|
1421
|
+
return {"jobs": jobs, "count": len(jobs), "total": len(metas),
|
|
1422
|
+
"note": ("showing the %d most recent of %d; pass limit=<n> for more"
|
|
1423
|
+
% (len(jobs), len(metas))) if len(metas) > len(jobs) else None}
|
|
1424
|
+
except Exception as exc:
|
|
1425
|
+
return {"error": "job_list failed: %s" % exc}
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
# ---------------------------------------------------------------------------
|
|
1429
|
+
# Tool definitions (MCP schemas)
|
|
1430
|
+
# ---------------------------------------------------------------------------
|
|
1431
|
+
|
|
1432
|
+
def _schema(properties: Dict[str, Any], required: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
1433
|
+
schema: Dict[str, Any] = {"type": "object", "properties": properties}
|
|
1434
|
+
if required:
|
|
1435
|
+
schema["required"] = required
|
|
1436
|
+
schema["additionalProperties"] = False
|
|
1437
|
+
return schema
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
TOOLS: List[Dict[str, Any]] = [
|
|
1441
|
+
{
|
|
1442
|
+
"name": "mesh_status",
|
|
1443
|
+
"title": "Mesh Status",
|
|
1444
|
+
"description": ("Confirm this node is reachable. Call this first if you think the host is "
|
|
1445
|
+
"offline; it reports live evidence from the host itself."),
|
|
1446
|
+
"inputSchema": _schema({}),
|
|
1447
|
+
},
|
|
1448
|
+
{
|
|
1449
|
+
"name": "system_info",
|
|
1450
|
+
"title": "System Info",
|
|
1451
|
+
"description": ("One-call host summary: OS, desktop, user, home, disks, memory, load, "
|
|
1452
|
+
"top processes and the current wallpaper."),
|
|
1453
|
+
"inputSchema": _schema({}),
|
|
1454
|
+
},
|
|
1455
|
+
{
|
|
1456
|
+
"name": "system_vitals",
|
|
1457
|
+
"title": "System Vitals",
|
|
1458
|
+
"description": "Retrieve CPU, RAM and disk metrics of the local host.",
|
|
1459
|
+
"inputSchema": _schema({}),
|
|
1460
|
+
},
|
|
1461
|
+
{
|
|
1462
|
+
"name": "get_orchestration_skill",
|
|
1463
|
+
"title": "Get Orchestration Skill",
|
|
1464
|
+
"description": "Load the current Antigravity orchestration skill and rules.",
|
|
1465
|
+
"inputSchema": _schema({}),
|
|
1466
|
+
},
|
|
1467
|
+
{
|
|
1468
|
+
"name": "list_dir",
|
|
1469
|
+
"title": "List Directory",
|
|
1470
|
+
"description": "List files and directories at the given path (workspace by default).",
|
|
1471
|
+
"inputSchema": _schema({"path": {"type": "string", "description": "Directory or file path."}}),
|
|
1472
|
+
},
|
|
1473
|
+
{
|
|
1474
|
+
"name": "bash_exec",
|
|
1475
|
+
"title": "Execute Bash Command",
|
|
1476
|
+
"description": (
|
|
1477
|
+
"Execute a shell command on the local host. Output is paginated: when the result is "
|
|
1478
|
+
"cut, call again with cursor=next_cursor to continue (nothing is dropped). Large "
|
|
1479
|
+
"outputs (>256 KB) are spooled to a file returned in saved_to."
|
|
1480
|
+
),
|
|
1481
|
+
"inputSchema": _schema(
|
|
1482
|
+
{
|
|
1483
|
+
"command": {"type": "string", "description": "Shell command to execute."},
|
|
1484
|
+
"timeout_sec": {"type": "integer", "description": "Timeout in seconds (1-120, default 25)."},
|
|
1485
|
+
"max_chars": {"type": "integer", "description": "Maximum characters per response."},
|
|
1486
|
+
"cursor": {"type": "integer", "description": "Character offset to continue from."},
|
|
1487
|
+
},
|
|
1488
|
+
["command"],
|
|
1489
|
+
),
|
|
1490
|
+
},
|
|
1491
|
+
{
|
|
1492
|
+
"name": "read_file",
|
|
1493
|
+
"title": "Read File",
|
|
1494
|
+
"description": (
|
|
1495
|
+
"Read a text file with %4d | line numbers, optionally restricted to a line range. "
|
|
1496
|
+
"Supports max_chars/cursor pagination."
|
|
1497
|
+
),
|
|
1498
|
+
"inputSchema": _schema(
|
|
1499
|
+
{
|
|
1500
|
+
"path": {"type": "string", "description": "File path to read."},
|
|
1501
|
+
"start_line": {"type": "integer", "description": "First line (1-based)."},
|
|
1502
|
+
"end_line": {"type": "integer", "description": "Last line (inclusive)."},
|
|
1503
|
+
"max_chars": {"type": "integer", "description": "Maximum characters per response."},
|
|
1504
|
+
"cursor": {"type": "integer", "description": "Character offset to continue from."},
|
|
1505
|
+
},
|
|
1506
|
+
["path"],
|
|
1507
|
+
),
|
|
1508
|
+
},
|
|
1509
|
+
{
|
|
1510
|
+
"name": "write_file",
|
|
1511
|
+
"title": "Write File",
|
|
1512
|
+
"description": "Atomically create or overwrite a file (tmp file + os.replace).",
|
|
1513
|
+
"inputSchema": _schema(
|
|
1514
|
+
{
|
|
1515
|
+
"path": {"type": "string", "description": "File path to write."},
|
|
1516
|
+
"content": {"type": "string", "description": "Full file content."},
|
|
1517
|
+
"create_dirs": {"type": "boolean", "description": "Create parent directories (default true)."},
|
|
1518
|
+
"mode": {"type": "string", "description": "Octal file mode, e.g. \"0644\"."},
|
|
1519
|
+
},
|
|
1520
|
+
["path", "content"],
|
|
1521
|
+
),
|
|
1522
|
+
},
|
|
1523
|
+
{
|
|
1524
|
+
"name": "edit_file",
|
|
1525
|
+
"title": "Edit File",
|
|
1526
|
+
"description": (
|
|
1527
|
+
"Replace an exact substring in a file. old_string must match exactly once unless "
|
|
1528
|
+
"replace_all is set. Optional expected_sha256 guards against overwriting a file "
|
|
1529
|
+
"that changed since it was read."
|
|
1530
|
+
),
|
|
1531
|
+
"inputSchema": _schema(
|
|
1532
|
+
{
|
|
1533
|
+
"path": {"type": "string", "description": "File path to edit."},
|
|
1534
|
+
"old_string": {"type": "string", "description": "Exact text to replace."},
|
|
1535
|
+
"new_string": {"type": "string", "description": "Replacement text."},
|
|
1536
|
+
"expected_sha256": {"type": "string", "description": "Expected current sha256 of the file."},
|
|
1537
|
+
"replace_all": {"type": "boolean", "description": "Replace every occurrence."},
|
|
1538
|
+
},
|
|
1539
|
+
["path", "old_string", "new_string"],
|
|
1540
|
+
),
|
|
1541
|
+
},
|
|
1542
|
+
{
|
|
1543
|
+
"name": "grep_search",
|
|
1544
|
+
"title": "Grep Search",
|
|
1545
|
+
"description": "Recursively search file contents; skips binary files and heavy directories.",
|
|
1546
|
+
"inputSchema": _schema(
|
|
1547
|
+
{
|
|
1548
|
+
"pattern": {"type": "string", "description": "Regular expression (or literal text with fixed)."},
|
|
1549
|
+
"path": {"type": "string", "description": "Root directory or file (default workspace)."},
|
|
1550
|
+
"glob": {"type": "string", "description": "File name filter, e.g. *.py."},
|
|
1551
|
+
"limit": {"type": "integer", "description": "Maximum matches (1-1000, default 200)."},
|
|
1552
|
+
"ignore_case": {"type": "boolean", "description": "Case-insensitive search."},
|
|
1553
|
+
"fixed": {"type": "boolean", "description": "Treat pattern as literal text."},
|
|
1554
|
+
"context": {"type": "integer", "description": "Context lines around each match."},
|
|
1555
|
+
},
|
|
1556
|
+
["pattern"],
|
|
1557
|
+
),
|
|
1558
|
+
},
|
|
1559
|
+
{
|
|
1560
|
+
"name": "glob_find",
|
|
1561
|
+
"title": "Glob Find",
|
|
1562
|
+
"description": "Find files by glob pattern (* and **).",
|
|
1563
|
+
"inputSchema": _schema(
|
|
1564
|
+
{
|
|
1565
|
+
"pattern": {"type": "string", "description": "Glob pattern, e.g. **/*.py."},
|
|
1566
|
+
"path": {"type": "string", "description": "Root directory (default workspace)."},
|
|
1567
|
+
},
|
|
1568
|
+
["pattern"],
|
|
1569
|
+
),
|
|
1570
|
+
},
|
|
1571
|
+
{
|
|
1572
|
+
"name": "run_job",
|
|
1573
|
+
"title": "Run Background Job",
|
|
1574
|
+
"description": "Start a command in the background; returns a job_id for job_output/job_kill.",
|
|
1575
|
+
"inputSchema": _schema(
|
|
1576
|
+
{
|
|
1577
|
+
"command": {"type": "string", "description": "Shell command to run in the background."},
|
|
1578
|
+
"cwd": {"type": "string", "description": "Working directory (default workspace)."},
|
|
1579
|
+
},
|
|
1580
|
+
["command"],
|
|
1581
|
+
),
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
"name": "job_output",
|
|
1585
|
+
"title": "Job Output",
|
|
1586
|
+
"description": "Read a background job's output, optionally waiting up to 20 s for completion.",
|
|
1587
|
+
"inputSchema": _schema(
|
|
1588
|
+
{
|
|
1589
|
+
"job_id": {"type": "string", "description": "Job id returned by run_job."},
|
|
1590
|
+
"wait_ms": {"type": "integer", "description": "Milliseconds to wait (0-20000)."},
|
|
1591
|
+
"max_chars": {"type": "integer", "description": "Maximum stdout characters per response."},
|
|
1592
|
+
"cursor": {"type": "integer", "description": "stdout character offset to continue from."},
|
|
1593
|
+
},
|
|
1594
|
+
["job_id"],
|
|
1595
|
+
),
|
|
1596
|
+
},
|
|
1597
|
+
{
|
|
1598
|
+
"name": "job_kill",
|
|
1599
|
+
"title": "Job Kill",
|
|
1600
|
+
"description": "Terminate a running background job.",
|
|
1601
|
+
"inputSchema": _schema(
|
|
1602
|
+
{
|
|
1603
|
+
"job_id": {"type": "string", "description": "Job id returned by run_job."},
|
|
1604
|
+
"signal": {"type": "string", "description": "TERM (default), KILL, INT, HUP or QUIT."},
|
|
1605
|
+
},
|
|
1606
|
+
["job_id"],
|
|
1607
|
+
),
|
|
1608
|
+
},
|
|
1609
|
+
{
|
|
1610
|
+
"name": "job_list",
|
|
1611
|
+
"title": "Job List",
|
|
1612
|
+
"description": "List recent background jobs, newest first (20 by default, max 50).",
|
|
1613
|
+
"inputSchema": _schema({
|
|
1614
|
+
"limit": {"type": "integer", "description": "How many jobs to return (1-50, default 20)."},
|
|
1615
|
+
}),
|
|
1616
|
+
},
|
|
1617
|
+
]
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
# ---------------------------------------------------------------------------
|
|
1621
|
+
# Dispatch
|
|
1622
|
+
# ---------------------------------------------------------------------------
|
|
1623
|
+
|
|
1624
|
+
_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Any]] = {
|
|
1625
|
+
"mesh_status": _tool_mesh_status,
|
|
1626
|
+
"system_info": _tool_system_info,
|
|
1627
|
+
"system_vitals": _tool_system_vitals,
|
|
1628
|
+
"get_orchestration_skill": _tool_get_orchestration_skill,
|
|
1629
|
+
"list_dir": _tool_list_dir,
|
|
1630
|
+
"bash_exec": _tool_bash_exec,
|
|
1631
|
+
"read_file": _tool_read_file,
|
|
1632
|
+
"write_file": _tool_write_file,
|
|
1633
|
+
"edit_file": _tool_edit_file,
|
|
1634
|
+
"grep_search": _tool_grep_search,
|
|
1635
|
+
"glob_find": _tool_glob_find,
|
|
1636
|
+
"run_job": _tool_run_job,
|
|
1637
|
+
"job_output": _tool_job_output,
|
|
1638
|
+
"job_kill": _tool_job_kill,
|
|
1639
|
+
"job_list": _tool_job_list,
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def call_tool(name: str, args: Any = None) -> Any:
|
|
1644
|
+
"""Dispatch a tool call. Never raises: any failure becomes ``{"error": ...}``."""
|
|
1645
|
+
try:
|
|
1646
|
+
handler = _HANDLERS.get(name) if isinstance(name, str) else None
|
|
1647
|
+
if handler is None:
|
|
1648
|
+
return {"error": "Unknown tool: %s" % (name,)}
|
|
1649
|
+
if args is None:
|
|
1650
|
+
args = {}
|
|
1651
|
+
if not isinstance(args, dict):
|
|
1652
|
+
return {"error": "arguments must be an object"}
|
|
1653
|
+
result = handler(args)
|
|
1654
|
+
if result is None:
|
|
1655
|
+
return {"error": "tool %s returned no result" % name}
|
|
1656
|
+
return result
|
|
1657
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
1658
|
+
return {"error": "%s: %s" % (type(exc).__name__, exc)}
|