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/README.md +271 -90
- package/core/agent.py +258 -33
- package/core/mcp_tools.py +1769 -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
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
|
|
37
|
+
import random
|
|
38
|
+
import sys
|
|
39
|
+
import time
|
|
40
|
+
|
|
6
41
|
import websockets
|
|
7
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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=
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
282
|
+
main()
|