drums-sdk 0.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
drums/__init__.py ADDED
@@ -0,0 +1,744 @@
1
+ """Drums SDK: isolated desktop environments for computer-use RL (standard library only).
2
+
3
+ import drums
4
+ with drums.connect("http://127.0.0.1:7070", token) as client: # closes the envs it made on exit
5
+ with client.create(image="osworld-ubuntu", task="035f41ba") as env: # OSWorld task id, path or JSON dict
6
+ env.step("pyautogui.click(100, 200)") # pyautogui code, or an action dict
7
+ obs = env.observe() # PNG + metadata
8
+ with env.fork(8, keep_state=False) as kids: # 8 independent copies of this state
9
+ print(kids.state) # the saved state the fork made
10
+ r = kids[0].verify() # task checker -> Reward (raises if invalid)
11
+ state = env.save() # a saved state; client.create(from_state=state)
12
+ print(r.score, r.valid, env.metrics()["usage"])
13
+
14
+ Names: the SDK says env, saved state, fork and save; the HTTP API and raw records say slot (`/v1/slots`,
15
+ `"slot": "s104"`), checkpoint (`/v1/checkpoints`), branch (`/branch`) and capture (`/capture`). They are the same things.
16
+
17
+ Validity: every create/reset/fork child starts an episode with a gate report (isolation, mount table, network,
18
+ cgroup limits, state hash, processes alive after resume, ...). An episode is valid only when every gate passed and
19
+ the env was never quarantined (`Reward.valid`; the reasons are in `Reward.invalid_reasons`). `verify()` always
20
+ carries the verdict; by default it raises InvalidEpisode instead of returning a reward from an invalid episode.
21
+ Retries: drumsd itself retries only failures that happened before any restored process ran; the SDK retries only GET
22
+ requests while drumsd is unreachable. Everything else is raised with the gate report.
23
+
24
+ Cleanup: envs and saved states have leases on the server (default: reaped after 30 min unused; `idle_timeout=` and
25
+ `ttl=` on create/fork/save change that, 0 = never), so a crashed trainer does not hold the host forever. The client
26
+ also closes the envs it created when the process exits (atexit, and SIGTERM/SIGHUP when their handlers were the
27
+ default); `connect(..., close_on_exit=False)` turns that off.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import base64
32
+ import concurrent.futures as cf
33
+ import json
34
+ import os
35
+ import struct
36
+ import time
37
+ import urllib.error
38
+ import urllib.parse
39
+ import urllib.request
40
+
41
+ import atexit
42
+ import signal
43
+ import threading
44
+ import warnings
45
+
46
+ from .errors import (ActionFailed, AuthError, BadRequest, BatchError, CheckerError, CheckpointStale, Conflict, DaemonUnavailable, DrumsError, EnvFailed, # noqa: F401
47
+ EnvGone, ForkError, GateFailed, GuestError, InvalidEpisode, NotFound, NotQuiescent, Overloaded, RestoreFailed, TaskSetupFailed,
48
+ TaskUnsupported)
49
+
50
+ __version__ = "0.2.5"
51
+ DEFAULT_URL = os.environ.get("DRUMS_URL") or os.environ.get("DRUMSD_URL") or "http://127.0.0.1:7070"
52
+ DEFAULT_IMAGE = "osworld-ubuntu"
53
+
54
+ __all__ = ["ActionFailed", "connect", "Client", "Env", "Reward", "Observation", "StepResult", "State", "ForkResult", "env", "__version__",
55
+ "DrumsError", "AuthError", "BadRequest", "NotFound", "EnvGone", "Conflict", "DaemonUnavailable", "GuestError", "EnvFailed",
56
+ "RestoreFailed", "GateFailed", "TaskSetupFailed", "ForkError", "BatchError", "InvalidEpisode", "CheckerError", "NotQuiescent", "Overloaded",
57
+ "CheckpointStale", "TaskUnsupported"]
58
+
59
+ _STATUS = {400: BadRequest, 401: AuthError, 404: NotFound, 409: Conflict, 410: EnvGone, 422: BadRequest, 429: Overloaded, 502: GuestError,
60
+ 503: DaemonUnavailable}
61
+
62
+
63
+ def connect(url: str | None = None, token: str | None = None, *, timeout: float = 900.0, retry_s: float = 60.0, check: bool = True,
64
+ close_on_exit: bool = True, handle_signals: bool = True) -> "Client":
65
+ """A client for one drumsd. `url`/`token` default to $DRUMS_URL / $DRUMS_TOKEN. With check=True the token is tried
66
+ at once (AuthError / DaemonUnavailable here rather than on the first create).
67
+ close_on_exit=True: the envs this client created and did not close are closed when the process exits (atexit) or
68
+ when the client is used as a context manager and the block ends. handle_signals=True: SIGTERM and SIGHUP exit
69
+ through that cleanup too, when their handler is still the default one (never replaces a handler you installed;
70
+ main thread only). A `kill -9` cannot be caught: the server-side leases reap those envs."""
71
+ c = Client(url or DEFAULT_URL, token if token is not None else os.environ.get("DRUMS_TOKEN"), timeout=timeout, retry_s=retry_s,
72
+ close_on_exit=close_on_exit, handle_signals=handle_signals)
73
+ if check:
74
+ c.health()
75
+ return c
76
+
77
+
78
+ def _exit_on_signal(signum, frame): # noqa: ARG001
79
+ raise SystemExit(128 + signum)
80
+
81
+
82
+ def _install_signal_exit():
83
+ """SIGTERM/SIGHUP -> SystemExit, so `finally`, context managers and atexit run (only over default handlers)."""
84
+ if threading.current_thread() is not threading.main_thread():
85
+ return
86
+ for name in ("SIGTERM", "SIGHUP"):
87
+ sig = getattr(signal, name, None)
88
+ if sig is not None and signal.getsignal(sig) == signal.SIG_DFL:
89
+ try:
90
+ signal.signal(sig, _exit_on_signal)
91
+ except (ValueError, OSError):
92
+ pass
93
+
94
+
95
+ class Client:
96
+ """A connection to one drumsd (use drums.connect). Creates envs, lists them, and reads health and metrics."""
97
+
98
+ def __init__(self, url: str = DEFAULT_URL, token: str | None = None, timeout: float = 900.0, retry_s: float = 60.0,
99
+ close_on_exit: bool = False, handle_signals: bool = False):
100
+ self.url, self.timeout, self.retry_s = url.rstrip("/"), timeout, retry_s
101
+ self.token = token if token is not None else os.environ.get("DRUMS_TOKEN")
102
+ self._owned: dict[str, None] = {} # ids of envs this client created and has not closed (insertion order)
103
+ self._owned_lock = threading.Lock()
104
+ self.close_on_exit = close_on_exit
105
+ if close_on_exit:
106
+ atexit.register(self._close_at_exit)
107
+ if handle_signals:
108
+ _install_signal_exit()
109
+
110
+ # cleanup ----------------------------------------------------------------------------------------------------------
111
+ def _own(self, env_id):
112
+ if env_id:
113
+ with self._owned_lock:
114
+ self._owned[env_id] = None
115
+
116
+ def _disown(self, env_id):
117
+ with self._owned_lock:
118
+ self._owned.pop(env_id, None)
119
+
120
+ def owned_envs(self) -> list:
121
+ """Ids of the envs this client created and has not closed."""
122
+ with self._owned_lock:
123
+ return list(self._owned)
124
+
125
+ def close(self, timeout: float = 30.0) -> list:
126
+ """Close every env this client created and has not closed (in parallel). Envs that are already gone are
127
+ skipped. Returns [(env_id, error or None)]. Saved states are not deleted here (their leases, or
128
+ fork(keep_state=False), or client.delete_state, take care of them)."""
129
+ ids = self.owned_envs()
130
+ if not ids:
131
+ return []
132
+
133
+ def one(i):
134
+ try:
135
+ self.call("DELETE", f"/v1/slots/{urllib.parse.quote(i)}", timeout=timeout)
136
+ except (EnvGone, NotFound):
137
+ pass
138
+ except Exception as e: # noqa: BLE001
139
+ return i, e
140
+ finally:
141
+ self._disown(i)
142
+ return i, None
143
+ try:
144
+ with cf.ThreadPoolExecutor(min(16, len(ids))) as ex:
145
+ return list(ex.map(one, ids))
146
+ except RuntimeError: # at interpreter shutdown no new thread can start: one by one, within a budget
147
+ deadline = time.monotonic() + max(timeout, 1.0)
148
+ out = []
149
+ for i in self.owned_envs():
150
+ if time.monotonic() > deadline:
151
+ out.append((i, DrumsError(0, "not closed: the close budget ran out (its lease will reap it)")))
152
+ continue
153
+ out.append(one(i))
154
+ return out
155
+
156
+ def _close_at_exit(self):
157
+ # bounded: an unreachable drumsd must not hang the exit (the envs' leases reap whatever is left)
158
+ if self.close_on_exit:
159
+ try:
160
+ self.close(timeout=10.0)
161
+ except Exception: # noqa: BLE001
162
+ pass
163
+
164
+ def __enter__(self):
165
+ return self
166
+
167
+ def __exit__(self, *exc):
168
+ self.close()
169
+
170
+ # transport --------------------------------------------------------------------------------------------------------
171
+ def call(self, method: str, path: str, body=None, raw: bool = False, timeout: float | None = None):
172
+ """One request. GETs are retried while drumsd is unreachable (up to retry_s: a restarting drumsd comes back and
173
+ reconciles); other methods are never retried, since their outcome is unknown after a dropped connection."""
174
+ data = None if body is None else json.dumps(body).encode()
175
+ headers = {"Content-Type": "application/json"}
176
+ if self.token:
177
+ headers["Authorization"] = f"Bearer {self.token}"
178
+ deadline = time.monotonic() + (self.retry_s if method == "GET" else 0.0)
179
+ delay = 0.25
180
+ while True:
181
+ req = urllib.request.Request(self.url + path, data=data, method=method, headers=headers)
182
+ try:
183
+ with urllib.request.urlopen(req, timeout=timeout or self.timeout) as r:
184
+ b = r.read()
185
+ return b if raw else json.loads(b or b"null")
186
+ except urllib.error.HTTPError as e:
187
+ text = e.read().decode(errors="replace")
188
+ try:
189
+ body_j = json.loads(text)
190
+ msg = body_j.get("error", text) if isinstance(body_j, dict) else text
191
+ except ValueError:
192
+ body_j, msg = None, text
193
+ if e.code == 503 and time.monotonic() < deadline:
194
+ pass
195
+ else:
196
+ cls = _STATUS.get(e.code, DrumsError)
197
+ if e.code == 409 and isinstance(body_j, dict) and body_j.get("code") == "not_quiescent":
198
+ cls = NotQuiescent
199
+ if e.code == 409 and isinstance(body_j, dict) and body_j.get("code") == "checkpoint_stale":
200
+ cls = CheckpointStale
201
+ if e.code == 422 and isinstance(body_j, dict) and body_j.get("code") == "task_unsupported":
202
+ cls = TaskUnsupported
203
+ if e.code == 409 and "slot is destroyed" in str(msg): # drumsd < 0.4.2
204
+ cls = EnvGone
205
+ raise cls(e.code, msg, body_j) from None
206
+ except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as e:
207
+ if time.monotonic() >= deadline:
208
+ what = "the outcome is unknown; call env.refresh()" if method != "GET" else f"retried for {self.retry_s:.0f} s"
209
+ raise DaemonUnavailable(0, f"drumsd at {self.url} unreachable on {method} {path} ({what}): {getattr(e, 'reason', e)}") from None
210
+ time.sleep(delay)
211
+ delay = min(delay * 2, 2.0)
212
+
213
+ # daemon -----------------------------------------------------------------------------------------------------------
214
+ def health(self) -> dict:
215
+ return self.call("GET", "/v1/health")
216
+
217
+ def metrics(self) -> dict:
218
+ """Aggregate metrics of the current window: op latency (create/reset/fork/step/verify/...), episodes by verdict,
219
+ failures and retries by cause, rewards, and cost (host price x wall time, per valid episode / verified reward).
220
+ The cost counts the whole window's wall time, idle time included: the window starts when drumsd started or at
221
+ the last reset_metrics(), so call reset_metrics() at the start of a run to measure that run."""
222
+ return self.call("GET", "/v1/metrics")
223
+
224
+ def reset_metrics(self) -> dict:
225
+ """Close the current metrics window (its ledger goes to drumsd's event log) and start a new one."""
226
+ return self.call("POST", "/v1/metrics/reset", {})
227
+
228
+ def images(self) -> list:
229
+ """What envs can be created from: installed images and saved states (API: GET /v1/checkpoints)."""
230
+ return self.call("GET", "/v1/checkpoints")
231
+
232
+ def delete_state(self, state) -> dict:
233
+ """Delete a saved state (from save() or fork(); API: checkpoint): its template server stops and its images are
234
+ freed. Refused (Conflict) while an env made from it still exists. drumsd also deletes a state by itself when
235
+ its lease runs out (default: 30 min with no env made from it) or, for fork(keep_state=False), when its last
236
+ env closes."""
237
+ return self.call("DELETE", f"/v1/checkpoints/{urllib.parse.quote(str(state))}")
238
+
239
+ def tasks(self, domain: str | None = None) -> list:
240
+ q = "?" + urllib.parse.urlencode({"domain": domain}) if domain else ""
241
+ return self.call("GET", "/v1/tasks" + q)
242
+
243
+ def task(self, task_id: str) -> dict:
244
+ """An OSWorld task JSON from the host's pinned checkout, by id or unique prefix (>= 8 characters)."""
245
+ return self.call("GET", f"/v1/tasks/{urllib.parse.quote(task_id)}")
246
+
247
+ def envs(self) -> list:
248
+ """The live envs on the host (drumsd >= 0.4 lists only those; older ones list every env, so destroyed ones are
249
+ dropped here). These Env objects are not owned by this client: close_on_exit does not close them."""
250
+ return [Env(self, r) for r in self.call("GET", "/v1/slots") if r.get("state") != "destroyed"]
251
+
252
+ def env_records(self, include_destroyed: bool = False, page_size: int = 500):
253
+ """Every env record, live ones only or with the destroyed ones (their records are kept), as a generator over
254
+ pages of `page_size` (GET /v1/slots?limit=&after=&include_destroyed=). An older drumsd answers with one list."""
255
+ after = None
256
+ while True:
257
+ q = {"include_destroyed": "true" if include_destroyed else "false", "limit": str(page_size)}
258
+ if after:
259
+ q["after"] = after
260
+ r = self.call("GET", "/v1/slots?" + urllib.parse.urlencode(q))
261
+ if isinstance(r, list): # drumsd < 0.4: no paging, every env
262
+ yield from (x for x in r if include_destroyed or x.get("state") != "destroyed")
263
+ return
264
+ yield from r.get("items") or []
265
+ after = r.get("next_after")
266
+ if not after:
267
+ return
268
+
269
+ def _task(self, task):
270
+ if task is None or isinstance(task, dict):
271
+ return task
272
+ if isinstance(task, str) and os.path.exists(task):
273
+ with open(task) as f:
274
+ return json.load(f)
275
+ if isinstance(task, str):
276
+ return self.task(task)
277
+ raise BadRequest(400, f"task must be an OSWorld JSON dict, a path, or a task id; got {type(task).__name__}")
278
+
279
+ # envs -------------------------------------------------------------------------------------------------------------
280
+ def create(self, image: str = DEFAULT_IMAGE, task=None, *, from_state=None, setup: bool | None = None,
281
+ idle_timeout: float | None = None, ttl: float | None = None) -> "Env":
282
+ """A new env from an image (or from a saved state), with an optional OSWorld task whose setup runs now and after
283
+ every reset. Raises RestoreFailed / GateFailed / TaskSetupFailed if it did not come up ready (the env still
284
+ exists on the server, down or quarantined: close it, or let its lease reap it).
285
+ idle_timeout / ttl (seconds): the env's lease. drumsd destroys it after `idle_timeout` s with no request naming
286
+ it, or `ttl` s after it was created. None = the server's default (30 min idle, no TTL); 0 = no limit."""
287
+ ck = str(from_state) if from_state is not None else image
288
+ body = {"checkpoint": ck, "task": self._task(task)}
289
+ if setup is not None:
290
+ body["setup"] = setup
291
+ if idle_timeout is not None:
292
+ body["idle_timeout_s"] = idle_timeout
293
+ if ttl is not None:
294
+ body["ttl_s"] = ttl
295
+ e = Env(self, self.call("POST", "/v1/slots", body))
296
+ self._own(e.record.get("id"))
297
+ e._check_up()
298
+ return e
299
+
300
+ def create_many(self, n: int, image: str = DEFAULT_IMAGE, task=None, *, from_state=None, workers: int = 8,
301
+ idle_timeout: float | None = None, ttl: float | None = None) -> list:
302
+ """n envs created concurrently. All ready -> the list; otherwise BatchError(ok=[ready envs], failed=[(i, error)])."""
303
+ t = self._task(task)
304
+ res = self.map(lambda i: self.create(image, t, from_state=from_state, idle_timeout=idle_timeout, ttl=ttl), range(n), workers=workers)
305
+ ok = [r for r in res if isinstance(r, Env)]
306
+ failed = [(i, r) for i, r in enumerate(res) if not isinstance(r, Env)]
307
+ if failed:
308
+ raise BatchError(ok, failed)
309
+ return ok
310
+
311
+ @staticmethod
312
+ def map(fn, items, workers: int = 8) -> list:
313
+ """fn over items in threads; each result is the return value or the exception it raised (never dropped)."""
314
+ items = list(items)
315
+
316
+ def run(x):
317
+ try:
318
+ return fn(x)
319
+ except Exception as e: # noqa: BLE001
320
+ return e
321
+ with cf.ThreadPoolExecutor(max(1, min(workers, len(items) or 1))) as ex:
322
+ return list(ex.map(run, items))
323
+
324
+
325
+ def _action(a):
326
+ if isinstance(a, str):
327
+ return {"type": "pyautogui", "code": a}
328
+ return a
329
+
330
+
331
+ def _png_size(png: bytes):
332
+ if len(png) >= 24 and png[12:16] == b"IHDR":
333
+ return struct.unpack(">II", png[16:24])
334
+ return None, None
335
+
336
+
337
+ class State(str):
338
+ """A saved state (API: checkpoint id). Pass to client.create(from_state=...); free it with client.delete_state()."""
339
+
340
+
341
+ class ForkResult(list):
342
+ """What fork() returns: the ready children, as the plain list fork() always returned, plus the fork's saved state.
343
+
344
+ `state` is the saved state the fork made (a State). With fork(..., keep_state=False) drumsd deletes it once its
345
+ last env is closed; otherwise it stays until client.delete_state(state) or its lease (default 30 min with no env
346
+ made from it). `checkpoint` is the server's full record of it, `failed` the children that did not come up
347
+ (strict=False only), `seconds` the fork's time. As a context manager it closes the children at the end."""
348
+
349
+ def __init__(self, children, state=None, checkpoint=None, failed=None, seconds=None, client=None):
350
+ super().__init__(children)
351
+ self.state = State(state) if state else None
352
+ self.checkpoint = checkpoint or {}
353
+ self.failed = failed or []
354
+ self.seconds = seconds
355
+ self._client = client
356
+
357
+ def close(self) -> None:
358
+ """Close every child (already closed ones are skipped)."""
359
+ for k in self:
360
+ try:
361
+ getattr(k, "sync", k).close() # an aio AsyncEnv is closed through its sync Env
362
+ except DrumsError:
363
+ pass
364
+
365
+ def delete_state(self) -> dict | None:
366
+ """Close the children, then delete the saved state now (NotFound/EnvGone-safe)."""
367
+ self.close()
368
+ if self.state and self._client is not None:
369
+ try:
370
+ return self._client.delete_state(self.state)
371
+ except NotFound:
372
+ return None
373
+ return None
374
+
375
+ def __enter__(self):
376
+ return self
377
+
378
+ def __exit__(self, *exc):
379
+ self.close()
380
+
381
+
382
+ class StepResult(dict):
383
+ """The guest's reply to an action ('result'), the episode's validity at that moment, and the step's seconds.
384
+ `observation` is set when the step asked for a screenshot."""
385
+
386
+ @property
387
+ def result(self):
388
+ return self.get("result")
389
+
390
+ @property
391
+ def seconds(self):
392
+ return self.get("seconds")
393
+
394
+ @property
395
+ def observation(self):
396
+ return self.get("observation")
397
+
398
+
399
+ class Observation:
400
+ def __init__(self, png: bytes, meta: dict):
401
+ self.png, self.meta = png, meta
402
+ self.width, self.height = _png_size(png)
403
+
404
+ def __repr__(self):
405
+ return f"<drums.Observation {self.width}x{self.height} env={self.meta.get('env')} episode={self.meta.get('episode')} {self.meta.get('verdict')}>"
406
+
407
+
408
+ class Reward:
409
+ """A checker result with the episode's gate verdict. Use `score` for training only when `valid` is True."""
410
+
411
+ def __init__(self, d: dict):
412
+ self.raw = d
413
+ self.env_id = d.get("slot")
414
+ self.episode = d.get("episode")
415
+ self.task_id = d.get("task_id")
416
+ self.score = d.get("score")
417
+ self.valid = bool(d.get("valid"))
418
+ self.validity = d.get("validity") or {}
419
+ self.verdict = self.validity.get("verdict")
420
+ self.bad_gates = self.validity.get("gates", [])
421
+ self.checker_error = d.get("checker_error")
422
+ self.checker_skipped = d.get("checker_skipped") # e.g. "quarantined: client": the checker did not run
423
+ self.gates = d.get("gates")
424
+ self.seconds = d.get("seconds")
425
+ # the reward audit (drumsd 0.4.2 and later): recheck, redundant checker, scope (reward-hacking signal), checker
426
+ # timeout/crash. `trusted` is True only for a valid episode with a score and no audit flag; an older drumsd
427
+ # sends no audit, so `trusted` is None (unknown), never True.
428
+ self.audit = d.get("audit") or {}
429
+ self.audit_flags = list(self.audit.get("flags") or [])
430
+ self.trusted = self.audit.get("trusted") if self.audit else None
431
+ # why the episode is not valid, in words (empty when valid). drumsd >= 0.4.2 sends them; for an older one
432
+ # they are derived, and a quarantined env's "valid" verdict (the old inconsistency) is reported as invalid.
433
+ self.invalid_reasons = list(d.get("invalid_reasons") or [])
434
+ if not self.valid and not self.invalid_reasons:
435
+ if d.get("state") not in (None, "ready"):
436
+ self.invalid_reasons.append(f"env is {d.get('state')}")
437
+ self.invalid_reasons += [f"gate {g} {'pending' if self.verdict == 'pending' else 'failed'}" for g in self.bad_gates]
438
+ if not self.valid and self.verdict == "valid":
439
+ self.verdict = "invalid"
440
+
441
+ def __repr__(self):
442
+ return f"<drums.Reward env={self.env_id} ep={self.episode} score={self.score} valid={self.valid}{' ERR' if self.checker_error else ''}>"
443
+
444
+
445
+ class Env:
446
+ """One isolated desktop environment (API: a slot, `/v1/slots/{id}`)."""
447
+
448
+ def __init__(self, client: Client, record: dict):
449
+ self.client, self.record = client, record
450
+ self.steps = 0
451
+ self._last_fork = None
452
+ self.last_state = None # the saved state the last fork() of this env made (a State), or None
453
+
454
+ @property
455
+ def last_fork(self):
456
+ """Deprecated (0.2.4): the last fork()'s raw server reply. Use the ForkResult fork() returns (`.state`,
457
+ `.checkpoint`) or `env.last_state`."""
458
+ warnings.warn("Env.last_fork is deprecated; use the ForkResult fork() returns (.state, .checkpoint) or env.last_state",
459
+ DeprecationWarning, stacklevel=2)
460
+ return self._last_fork
461
+
462
+ @last_fork.setter
463
+ def last_fork(self, v):
464
+ self._last_fork = v
465
+
466
+ # identity ---------------------------------------------------------------------------------------------------------
467
+ id = property(lambda self: self.record["id"])
468
+ episode = property(lambda self: self.record.get("episode"))
469
+ state = property(lambda self: self.record.get("state"))
470
+ validity = property(lambda self: self.record.get("validity") or {})
471
+ task = property(lambda self: self.record.get("task"))
472
+ from_state = property(lambda self: State(self.record.get("checkpoint"))) # the checkpoint this env restores to
473
+
474
+ def refresh(self) -> dict:
475
+ self.record = self.client.call("GET", f"/v1/slots/{self.id}")
476
+ return self.record
477
+
478
+ def _check_up(self):
479
+ if self.record.get("state") == "ready":
480
+ return
481
+ st, err = self.record.get("state"), (self.record.get("last_error") or self.record.get("reason") or "")
482
+ if st == "quarantined":
483
+ raise GateFailed(self.record)
484
+ if "task setup" in err:
485
+ raise TaskSetupFailed(self.record)
486
+ raise RestoreFailed(self.record)
487
+
488
+ # episode ----------------------------------------------------------------------------------------------------------
489
+ def reset(self) -> "Env":
490
+ """New episode from the image or saved state this env was made from (and the task's setup, for an env created
491
+ with a task)."""
492
+ self.record = self.client.call("POST", f"/v1/slots/{self.id}/reset")
493
+ self.steps = 0
494
+ self._last_fork = None
495
+ self._check_up()
496
+ return self
497
+
498
+ def step(self, action, screenshot: bool = False, check: bool = True) -> StepResult:
499
+ """pyautogui code (str) or an action dict: {"type": "pyautogui", "code"}, {"type": "execute", "command", "shell"},
500
+ {"type": "launch", "command", "shell"}, {"type": "sleep", "seconds"}. Screen 1920x1080, origin top left, pixels.
501
+ pyautogui and execute run inside the env as its desktop user `user` (not root), with OSWorld's prefix
502
+ (`import pyautogui, time`). See https://console.drums.run/docs/concepts/computers-and-templates#actions.
503
+ check=True (default): an action that ran and exited non-zero raises ActionFailed with its exit status, output
504
+ and error; check=False returns the result as is. A guest that does not answer or times out (120 s) raises
505
+ GuestError either way."""
506
+ a = _action(action)
507
+ r = self.client.call("POST", f"/v1/slots/{self.id}/step", {"action": a, "screenshot": screenshot})
508
+ self.steps += 1
509
+ res = r.get("result")
510
+ if check and isinstance(res, dict) and res.get("returncode") not in (None, 0):
511
+ raise ActionFailed(res, a)
512
+ if r.get("screenshot_png_b64"):
513
+ png = base64.b64decode(r.pop("screenshot_png_b64"))
514
+ r["screenshot_png"] = png
515
+ r["observation"] = Observation(png, {"env": self.id, "episode": r.get("episode"), "verdict": (r.get("validity") or {}).get("verdict")})
516
+ return StepResult(r)
517
+
518
+ def observe(self) -> Observation:
519
+ png = self.screenshot()
520
+ rec = self.refresh()
521
+ t = rec.get("task") or {}
522
+ return Observation(png, {"env": self.id, "episode": rec.get("episode"), "state": rec.get("state"),
523
+ "verdict": (rec.get("validity") or {}).get("verdict"), "task_id": t.get("id"),
524
+ "instruction": t.get("instruction"), "steps": self.steps, "t": time.time()})
525
+
526
+ def screenshot(self) -> bytes:
527
+ return self.client.call("GET", f"/v1/slots/{self.id}/screenshot", raw=True)
528
+
529
+ def gates(self) -> dict:
530
+ return self.client.call("GET", f"/v1/slots/{self.id}/gates")
531
+
532
+ def valid(self, timeout: float = 30.0) -> bool:
533
+ """Wait until the episode's verdict is no longer pending; True only when every gate passed."""
534
+ t0 = time.monotonic()
535
+ while True:
536
+ v = self.gates()["validity"]["verdict"]
537
+ if v != "pending":
538
+ return v == "valid"
539
+ if time.monotonic() - t0 > timeout:
540
+ return False
541
+ time.sleep(0.25)
542
+
543
+ def wait_valid(self, timeout: float = 60.0) -> "Env":
544
+ """Block until this episode's last gate (processes alive 5 s after resume) has resolved. Raises GateFailed if the
545
+ episode is invalid. Act after this when your first actions may end processes: a process that exits in the first
546
+ seconds after a restore cannot be told apart from a bad restore, so the episode would be flagged invalid."""
547
+ t0 = time.monotonic()
548
+ while True:
549
+ g = self.gates()
550
+ v = g["validity"]["verdict"]
551
+ if v == "valid":
552
+ return self
553
+ if v == "invalid":
554
+ raise GateFailed(self.refresh())
555
+ if time.monotonic() - t0 > timeout:
556
+ raise DrumsError(0, f"env {self.id}: verdict still pending after {timeout} s")
557
+ time.sleep(0.25)
558
+
559
+ def verify(self, strict: bool = True) -> Reward:
560
+ """Run the task's checker on the current state. The server waits for the episode's last gate first.
561
+ strict=True: raises InvalidEpisode (reward attached, flagged, with `.invalid_reasons`) if the episode is not
562
+ valid, CheckerError if the checker failed. strict=False: returns the Reward; use `.score` only when `.valid`.
563
+ On a quarantined env the checker is not run: score None, valid False, reason "quarantined: <why>".
564
+ Note: OSWorld checkers may run a postconfig (save, close a window) that changes the desktop."""
565
+ r = Reward(self.client.call("POST", f"/v1/slots/{self.id}/verify", {}))
566
+ if strict and r.checker_error is not None:
567
+ raise CheckerError(r)
568
+ if strict and not r.valid:
569
+ raise InvalidEpisode(r)
570
+ return r
571
+
572
+ def load_task(self, task) -> "Env":
573
+ """Load an OSWorld task into this ready env (setup runs now and after every reset)."""
574
+ self.record = self.client.call("POST", f"/v1/slots/{self.id}/task", {"task": self.client._task(task)})
575
+ return self
576
+
577
+ def metrics(self) -> dict:
578
+ """This env's metrics: op latency (reset/step/fork/verify/...), episodes by verdict, failures and retries by
579
+ cause, rewards, lifetime, and its share of the host cost by env-seconds (API field: slot_seconds)."""
580
+ return self.client.call("GET", f"/v1/slots/{self.id}/metrics")
581
+
582
+ # branching --------------------------------------------------------------------------------------------------------
583
+ def wait_quiescent(self, timeout: float | None = None, window: float | None = None) -> dict:
584
+ """Block until this env's process tree is safe to capture: no process or thread starting or exiting for
585
+ `window` seconds (server default 1 s), no task exiting, no unreaped zombie. fork() and save() already do this
586
+ themselves, so calling it is optional (useful to time it, or to wait without capturing). Returns the wait
587
+ record; raises NotQuiescent after `timeout` seconds (server default 30 s)."""
588
+ body = {k: v for k, v in (("timeout_s", timeout), ("window_s", window)) if v is not None}
589
+ return self.client.call("POST", f"/v1/slots/{self.id}/quiescent", body)
590
+
591
+ def save(self, name: str | None = None, quiesce_timeout: float | None = None, *, idle_timeout: float | None = None,
592
+ ttl: float | None = None) -> State:
593
+ """Save the current state (API: capture; this env keeps running). client.create(from_state=...) starts from it.
594
+ Safe right after an action: drumsd waits until the tree is quiescent and checks that the dump is that tree
595
+ (NotQuiescent if it cannot within `quiesce_timeout`, server default 30 s; nothing is captured then).
596
+ idle_timeout / ttl (seconds): the state's lease. drumsd deletes it after `idle_timeout` s with no env made from
597
+ it (counted from its creation or its last env's close), or `ttl` s after it was made once no env uses it.
598
+ None = the server's default (30 min idle, no TTL); 0 = no limit."""
599
+ body = {"id": name} if name else {}
600
+ if quiesce_timeout is not None:
601
+ body["quiesce_timeout_s"] = quiesce_timeout
602
+ if idle_timeout is not None:
603
+ body["idle_timeout_s"] = idle_timeout
604
+ if ttl is not None:
605
+ body["ttl_s"] = ttl
606
+ return State(self.client.call("POST", f"/v1/slots/{self.id}/capture", body)["id"])
607
+
608
+ def prefork(self, n: int, quiesce_timeout: float | None = None, *, keep_state: bool = True,
609
+ idle_timeout: float | None = None, ttl: float | None = None, state_idle_timeout: float | None = None,
610
+ state_ttl: float | None = None) -> dict:
611
+ """Prepare fork(n) now, in the background (API: prefork), and return at once. Call it right after an action;
612
+ the next fork(n) with the same keep_state then gets these children if the env took no input since, no
613
+ process started or exited in it, and its desktop state hash is unchanged (else it is discarded and the fork
614
+ runs as usual). Any input (step, reset) discards it; so does a prefork nobody claims within the daemon's
615
+ prefork_max_age_s (default 120 s). The children are the env as it was when it went quiet after the action."""
616
+ body = {"n": n}
617
+ if quiesce_timeout is not None:
618
+ body["quiesce_timeout_s"] = quiesce_timeout
619
+ if not keep_state:
620
+ body["delete_state_with_children"] = True
621
+ for k, v in (("idle_timeout_s", idle_timeout), ("ttl_s", ttl), ("state_idle_timeout_s", state_idle_timeout), ("state_ttl_s", state_ttl)):
622
+ if v is not None:
623
+ body[k] = v
624
+ return self.client.call("POST", f"/v1/slots/{self.id}/prefork", body)
625
+
626
+ def fork(self, n: int, strict: bool = True, quiesce_timeout: float | None = None, *, keep_state: bool = True,
627
+ idle_timeout: float | None = None, ttl: float | None = None, state_idle_timeout: float | None = None,
628
+ state_ttl: float | None = None) -> ForkResult:
629
+ """Save this state and start n independent children from it, concurrently (API: branch); this env keeps
630
+ running. Safe right after an action (no sleep needed): the capture waits for a quiescent tree, see save().
631
+ Returns a ForkResult: the ready children (a list, as before) with `.state`, the saved state the fork made
632
+ (also `env.last_state`). keep_state=False: drumsd deletes that state as soon as its last env is closed, so a
633
+ loop does not leak one state (~600 MB of RAM) per fork; keep_state=True (default): it stays until
634
+ client.delete_state(state) or its lease. idle_timeout/ttl: the children's leases (see create);
635
+ state_idle_timeout/state_ttl: the state's (see save).
636
+ strict=True: if any child failed, raises ForkError(ok=[ready children], failed=[EnvFailed...], state)."""
637
+ body = {"n": n}
638
+ if quiesce_timeout is not None:
639
+ body["quiesce_timeout_s"] = quiesce_timeout
640
+ if not keep_state:
641
+ body["delete_state_with_children"] = True
642
+ for k, v in (("idle_timeout_s", idle_timeout), ("ttl_s", ttl), ("state_idle_timeout_s", state_idle_timeout), ("state_ttl_s", state_ttl)):
643
+ if v is not None:
644
+ body[k] = v
645
+ r = self.client.call("POST", f"/v1/slots/{self.id}/branch", body)
646
+ self._last_fork = r
647
+ ck = r.get("checkpoint") or {}
648
+ self.last_state = State(ck["id"]) if ck.get("id") else None
649
+ ok, failed = [], []
650
+ for c in r["children"]:
651
+ if "error" in c:
652
+ failed.append(DrumsError(0, c["error"]))
653
+ continue
654
+ self.client._own(c.get("id"))
655
+ e = Env(self.client, c)
656
+ try:
657
+ e._check_up()
658
+ ok.append(e)
659
+ except EnvFailed as x:
660
+ failed.append(x)
661
+ if not strict:
662
+ ok.append(e)
663
+ if failed and strict:
664
+ raise ForkError(ok, failed, ck.get("id"))
665
+ return ForkResult(ok, ck.get("id"), ck, failed, r.get("seconds"), self.client)
666
+
667
+ branch = fork # 0.1 name
668
+
669
+ # lifecycle --------------------------------------------------------------------------------------------------------
670
+ def quarantine(self, reason: str = "client") -> dict:
671
+ self.record = self.client.call("POST", f"/v1/slots/{self.id}/quarantine", {"reason": reason})
672
+ return self.record
673
+
674
+ def release(self) -> dict:
675
+ """Kill a quarantined env's processes; it becomes down and a reset restores it."""
676
+ self.record = self.client.call("POST", f"/v1/slots/{self.id}/release", {})
677
+ return self.record
678
+
679
+ def close(self) -> dict:
680
+ """Destroy this env (its records are kept on the host). A no-op on an env that is already gone (closed here or
681
+ elsewhere, or reaped by its lease)."""
682
+ if self.record.get("state") in ("destroyed",):
683
+ self.client._disown(self.id)
684
+ return self.record
685
+ try:
686
+ self.record = self.client.call("DELETE", f"/v1/slots/{self.id}")
687
+ except (EnvGone, NotFound) as e:
688
+ self.record = {**self.record, "state": "destroyed", "closed_reason": getattr(e, "closed_reason", None) or str(e)}
689
+ self.client._disown(self.id)
690
+ return self.record
691
+
692
+ def guest(self) -> "GuestClient":
693
+ """An OSWorld guest-server-like transport (execute, launch, screenshot, screen_size, platform) over the API."""
694
+ return GuestClient(self)
695
+
696
+ def __enter__(self):
697
+ return self
698
+
699
+ def __exit__(self, *exc):
700
+ try:
701
+ self.close()
702
+ except DrumsError:
703
+ pass
704
+
705
+ def __repr__(self):
706
+ return f"<drums.Env {self.id} {self.state} episode={self.episode} {self.validity.get('verdict')}>"
707
+
708
+
709
+ class GuestClient:
710
+ """The GuestClient protocol (execute/launch/screenshot/screen_size/platform) that RL backends use, through drumsd."""
711
+
712
+ def __init__(self, env: Env):
713
+ self.env, self.base, self.timeout = env, f"{env.client.url}/v1/slots/{env.id}", env.client.timeout
714
+
715
+ def execute(self, command, shell: bool = False, allow_rc=(0,), timeout=None) -> dict:
716
+ r = self.env.step({"type": "execute", "command": command, "shell": shell}, check=False)["result"]
717
+ if allow_rc is not None and r.get("returncode") not in allow_rc:
718
+ raise GuestError(0, f"guest command rc={r.get('returncode')} err={str(r.get('error'))[:300]!r}")
719
+ return r
720
+
721
+ def launch(self, command, shell: bool = False) -> None:
722
+ self.env.step({"type": "launch", "command": command, "shell": shell})
723
+
724
+ def screenshot(self) -> bytes:
725
+ return self.env.screenshot()
726
+
727
+ def screen_size(self):
728
+ w, h = _png_size(self.screenshot())
729
+ return w, h
730
+
731
+ def platform(self, timeout=10) -> str:
732
+ return "Linux" if self.env.refresh().get("state") == "ready" else "unavailable"
733
+
734
+
735
+ # ---- 0.1 compatibility ----------------------------------------------------------------------------------------------
736
+ def env(task: dict | None = None, *, from_state: str | None = None, checkpoint: str = DEFAULT_IMAGE, url: str = DEFAULT_URL,
737
+ token: str | None = None) -> Env:
738
+ """0.1 entry point: a new env (`from_state` wins over `checkpoint`, default the installed image "osworld-ubuntu";
739
+ before 0.2.4 the default was "clean", which no install has). Prefer drums.connect(...).create(...)."""
740
+ c = Client(url, token if token is not None else os.environ.get("DRUMS_TOKEN"))
741
+ rec = c.call("POST", "/v1/slots", {"checkpoint": from_state or checkpoint, "task": task})
742
+ e = Env(c, rec)
743
+ e._check_up()
744
+ return e