gemini-computer-use 0.1.2 → 0.2.4

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