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.
package/core/agent.py CHANGED
@@ -1,57 +1,282 @@
1
+ """core/agent.py - Antigravity Mesh tunnel node (thin transport).
2
+
3
+ All tool logic lives in :mod:`core.mcp_tools`; this module only maintains the
4
+ websocket tunnel to the gateway and forwards ``tools/call`` requests. The
5
+ response shape is unchanged (``stdout``/``stderr``/``exit_code`` and friends),
6
+ so an unmodified gateway keeps working.
7
+
8
+ Configuration (read once at import/startup)::
9
+
10
+ MESH_GATEWAY shared gateway domain
11
+ MESH_USER node name
12
+ MESH_TOKEN node token
13
+ MESH_CONFIG_FILE KEY=VALUE file used for any of the three above that is not
14
+ set in the environment (default:
15
+ ~/.config/antigravity-mesh/agent.env). This keeps the token
16
+ out of launchers such as the Windows Startup script.
17
+ MESH_WORKSPACE base directory for relative paths (default: cwd)
18
+ MESH_READ_ONLY "1"/"true" -> mutating tools refuse to run
19
+ MESH_WRITE_ROOTS pathsep-separated list of roots allowed for writes
20
+
21
+ Resilience contract:
22
+
23
+ * every link failure (DNS, TLS, reset, half-open socket after sleep) is retried
24
+ forever with capped exponential backoff plus jitter;
25
+ * tool calls run concurrently, so a slow command never delays another call or
26
+ the keepalive pings;
27
+ * only one agent per node name runs on a machine. The gateway keeps exactly one
28
+ tunnel per node and a new tunnel supersedes the old one, so two agents would
29
+ knock each other off in an endless loop and fail every in-flight call. A
30
+ second instance therefore waits for the lock instead of connecting.
31
+ """
32
+
1
33
  import asyncio
2
34
  import json
3
35
  import logging
4
36
  import os
5
- import subprocess
37
+ import random
38
+ import sys
39
+ import time
40
+
6
41
  import websockets
7
- from core.vitals import get_host_vitals
42
+
43
+ from core import mcp_tools
8
44
 
9
45
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
10
46
  logger = logging.getLogger("agy-agent")
11
47
 
12
- GATEWAY_HOST = os.environ.get("MESH_GATEWAY", "smart-server.online")
13
- USER = os.environ.get("MESH_USER", "levra7")
14
- TOKEN = os.environ.get("MESH_TOKEN", "")
48
+ CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "antigravity-mesh")
49
+ DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "agent.env")
15
50
 
16
- async def handle_tool_call(name: str, args: dict) -> dict:
17
- if name == "system_vitals":
18
- return get_host_vitals()
19
- elif name == "bash_exec":
20
- cmd = args.get("command", "")
21
- logger.info(f"Executing bash_exec: {cmd}")
51
+ #: Backoff cap for ordinary link failures.
52
+ MAX_BACKOFF = 15.0
53
+ #: Wait after the gateway rejects the token: retrying fast cannot help, but the
54
+ #: node must recover by itself once it is re-registered.
55
+ UNAUTHORIZED_BACKOFF = 60.0
56
+
57
+
58
+ def read_env_file(path: str) -> dict:
59
+ """Parse a KEY=VALUE file; tolerates a UTF-8 BOM (Windows PowerShell 5 writes one)."""
60
+ values = {}
61
+ try:
62
+ with open(path, "r", encoding="utf-8-sig") as handle:
63
+ for line in handle:
64
+ line = line.strip()
65
+ if not line or line.startswith("#") or "=" not in line:
66
+ continue
67
+ key, _, value = line.partition("=")
68
+ values[key.strip()] = value.strip().strip('"').strip("'")
69
+ except OSError:
70
+ pass
71
+ return values
72
+
73
+
74
+ def _load_settings() -> dict:
75
+ file_values = read_env_file(os.environ.get("MESH_CONFIG_FILE") or DEFAULT_CONFIG_FILE)
76
+
77
+ def pick(key: str, default: str) -> str:
78
+ return os.environ.get(key) or file_values.get(key) or default
79
+
80
+ return {
81
+ "gateway": pick("MESH_GATEWAY", "smart-server.online"),
82
+ "user": pick("MESH_USER", "levra7"),
83
+ "token": pick("MESH_TOKEN", ""),
84
+ }
85
+
86
+
87
+ _SETTINGS = _load_settings()
88
+ GATEWAY_HOST = _SETTINGS["gateway"]
89
+ USER = _SETTINGS["user"]
90
+ TOKEN = _SETTINGS["token"]
91
+
92
+
93
+ def _env_flag(name: str) -> bool:
94
+ return str(os.environ.get(name, "")).strip().lower() in ("1", "true", "yes", "on")
95
+
96
+
97
+ def configure_from_env() -> None:
98
+ """Apply MESH_* environment configuration to the shared tool module."""
99
+ write_roots = os.environ.get("MESH_WRITE_ROOTS") or None
100
+ jobs_dir = os.environ.get("MESH_JOBS_DIR") or None
101
+ max_chars = os.environ.get("MESH_MAX_OUTPUT_CHARS")
102
+ mcp_tools.configure(
103
+ workspace=os.environ.get("MESH_WORKSPACE") or os.getcwd(),
104
+ read_only=_env_flag("MESH_READ_ONLY"),
105
+ write_roots=write_roots,
106
+ jobs_dir=jobs_dir,
107
+ max_output_chars=max_chars,
108
+ )
109
+
110
+
111
+ # Configure as soon as the node module is imported so external callers (tests,
112
+ # harnesses) observe the environment-driven settings.
113
+ configure_from_env()
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Single instance per node name
118
+ # ---------------------------------------------------------------------------
119
+
120
+ class InstanceLock:
121
+ """Non-blocking, OS-released file lock (fcntl on POSIX, msvcrt on Windows).
122
+
123
+ The OS drops the lock when the process dies, so a crash never leaves a stale
124
+ lock behind.
125
+ """
126
+
127
+ def __init__(self, path: str):
128
+ self.path = path
129
+ self._fh = None
130
+
131
+ def try_acquire(self) -> bool:
132
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
133
+ fh = open(self.path, "a+")
22
134
  try:
23
- proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60)
24
- return {"exit_code": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr}
25
- except subprocess.TimeoutExpired:
26
- return {"exit_code": 124, "stdout": "", "stderr": "Command timed out after 60 seconds"}
27
- except Exception as e:
28
- return {"exit_code": 1, "stdout": "", "stderr": str(e)}
29
- return {"error": f"Unknown tool {name}"}
135
+ if os.name == "nt":
136
+ import msvcrt
137
+ fh.seek(0)
138
+ msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
139
+ else:
140
+ import fcntl
141
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
142
+ except OSError:
143
+ fh.close()
144
+ return False
145
+ fh.seek(0)
146
+ fh.truncate()
147
+ fh.write(str(os.getpid()))
148
+ fh.flush()
149
+ self._fh = fh
150
+ return True
151
+
152
+
153
+ def wait_for_instance_lock(user: str) -> InstanceLock:
154
+ """Block until this process is the only agent for ``user`` on the machine."""
155
+ safe = "".join(c for c in user if c.isalnum() or c in "-_") or "node"
156
+ lock = InstanceLock(os.path.join(CONFIG_DIR, "agent-%s.lock" % safe))
157
+ warned = False
158
+ while not lock.try_acquire():
159
+ if not warned:
160
+ logger.warning("Another agent for node '%s' is already running on this machine; "
161
+ "waiting for it to exit instead of fighting over the tunnel.", user)
162
+ warned = True
163
+ time.sleep(10)
164
+ return lock
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Tunnel
169
+ # ---------------------------------------------------------------------------
170
+
171
+ async def handle_tool_call(name: str, args: dict) -> dict:
172
+ """Forward one tool call to the shared implementation in a worker thread."""
173
+ logger.info("Executing %s", name)
174
+ return await asyncio.to_thread(mcp_tools.call_tool, name, args or {})
175
+
176
+
177
+ async def _serve_call(ws, req_id, params: dict) -> None:
178
+ tool_name = params.get("name")
179
+ tool_args = params.get("arguments", {})
180
+ try:
181
+ res = await handle_tool_call(tool_name, tool_args)
182
+ except Exception as tool_exc:
183
+ # A tool must never take the tunnel down with it.
184
+ logger.warning(f"Tool {tool_name} failed: {tool_exc}")
185
+ res = {"error": f"{type(tool_exc).__name__}: {tool_exc}"}
186
+ try:
187
+ await ws.send(json.dumps({"id": req_id, "result": res}, ensure_ascii=False))
188
+ except Exception as send_exc:
189
+ # The link dropped while the tool ran; the gateway already failed the
190
+ # call, and the reconnect loop restores the tunnel.
191
+ logger.warning(f"Could not deliver result of {tool_name}: {send_exc}")
192
+
193
+
194
+ def _is_unauthorized(exc: BaseException) -> bool:
195
+ """True when the gateway rejected the node's credentials.
196
+
197
+ The gateway closes with 4001 before accepting the websocket; Starlette turns
198
+ that into an HTTP 403 handshake response, so depending on timing the client
199
+ sees either the close code or the HTTP status. Handles websockets' legacy
200
+ and new exception shapes.
201
+ """
202
+ for attr in ("rcvd", "received"):
203
+ frame = getattr(exc, attr, None)
204
+ if frame is not None and getattr(frame, "code", None) == 4001:
205
+ return True
206
+ if getattr(exc, "code", None) == 4001:
207
+ return True
208
+ status = getattr(exc, "status_code", None) # legacy InvalidStatusCode
209
+ response = getattr(exc, "response", None) # websockets >= 14 InvalidStatus
210
+ if status is None and response is not None:
211
+ status = getattr(response, "status_code", None)
212
+ return status in (401, 403)
213
+
214
+
215
+ def tunnel_uri() -> str:
216
+ """Canonical tunnel URL: one shared domain, node selected by ?user=."""
217
+ base = GATEWAY_HOST.rstrip("/")
218
+ if not base.startswith(("ws://", "wss://")):
219
+ base = "wss://" + base
220
+ return f"{base}/ws/tunnel?user={USER}&token={TOKEN}"
221
+
30
222
 
31
223
  async def run_agent():
32
- uri = f"wss://{GATEWAY_HOST}/ws/tunnel?user={USER}&token={TOKEN}"
33
- logger.info(f"Connecting to Gateway {uri}...")
224
+ """Stay connected to the gateway, healing every kind of link failure.
225
+
226
+ A dropped websocket is retried with exponential backoff plus jitter: a short
227
+ first retry restores a flapping link quickly, while the growing cap stops a
228
+ hammering loop when the network is genuinely down. The backoff resets after a
229
+ healthy connection, so a long-lived session is not penalised.
230
+ """
231
+ if not TOKEN:
232
+ logger.error("MESH_TOKEN is empty (env and %s): the gateway will reject this node. "
233
+ "Re-run the installer.", os.environ.get("MESH_CONFIG_FILE") or DEFAULT_CONFIG_FILE)
234
+ uri = tunnel_uri()
235
+ logger.info(f"Connecting to Gateway {uri.split('&token=')[0]} ...")
236
+ backoff = 1.0
237
+ in_flight = set()
34
238
  while True:
35
239
  try:
36
- async with websockets.connect(uri, ping_interval=20, ping_timeout=20) as ws:
240
+ async with websockets.connect(uri, ping_interval=10, ping_timeout=10,
241
+ close_timeout=5, open_timeout=15,
242
+ max_size=None) as ws:
37
243
  logger.info(f"Connected to Mesh Gateway as '{USER}'!")
244
+ backoff = 1.0 # healthy again
38
245
  async for raw_msg in ws:
39
246
  try:
40
247
  data = json.loads(raw_msg)
41
248
  except Exception:
42
249
  continue
43
- req_id = data.get("id")
44
- method = data.get("method")
45
- params = data.get("params", {})
46
- if method == "tools/call":
47
- tool_name = params.get("name")
48
- tool_args = params.get("arguments", {})
49
- res = await handle_tool_call(tool_name, tool_args)
50
- resp = {"id": req_id, "result": res}
51
- await ws.send(json.dumps(resp))
250
+ if data.get("method") == "tools/call":
251
+ # Each call runs on its own: Gemini issues parallel calls,
252
+ # and serialising them pushed later ones past the
253
+ # gateway's 28 s limit.
254
+ task = asyncio.create_task(_serve_call(ws, data.get("id"), data.get("params", {})))
255
+ in_flight.add(task)
256
+ task.add_done_callback(in_flight.discard)
257
+ # A clean close (e.g. gateway restart) still needs a reconnect.
258
+ raise ConnectionError("tunnel closed by gateway")
259
+ except asyncio.CancelledError:
260
+ raise
52
261
  except Exception as e:
53
- logger.warning(f"Connection lost: {e}. Reconnecting in 5s...")
54
- await asyncio.sleep(5)
262
+ if _is_unauthorized(e):
263
+ delay = UNAUTHORIZED_BACKOFF
264
+ logger.error("Gateway rejected node '%s' (4001 Unauthorized): the token is wrong or the "
265
+ "node was removed. Re-run the installer. Retrying in %.0fs.", USER, delay)
266
+ else:
267
+ delay = min(backoff, MAX_BACKOFF) + random.uniform(0, 0.5)
268
+ logger.warning(f"Connection lost: {e}. Reconnecting in {delay:.1f}s...")
269
+ backoff = min(backoff * 2, MAX_BACKOFF)
270
+ await asyncio.sleep(delay)
271
+
272
+
273
+ def main() -> None:
274
+ _lock = wait_for_instance_lock(USER) # held for the life of the process
275
+ try:
276
+ asyncio.run(run_agent())
277
+ except KeyboardInterrupt:
278
+ sys.exit(0)
279
+
55
280
 
56
281
  if __name__ == "__main__":
57
- asyncio.run(run_agent())
282
+ main()