agi-diy 0.1.0__tar.gz

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,5 @@
1
+ .venv/
2
+ dist/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
agi_diy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: agi-diy
3
+ Version: 0.1.0
4
+ Summary: api.agi.diy client: lease a VLA policy, run it over the LeRobot remote-inference protocol.
5
+ Project-URL: Homepage, https://api.agi.diy
6
+ Project-URL: Source, https://github.com/cagataycali/api-agi-diy
7
+ Author: agi.diy
8
+ License-Expression: Apache-2.0
9
+ Requires-Python: >=3.10
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest; extra == 'dev'
12
+ Provides-Extra: lerobot
13
+ Requires-Dist: lerobot[remote]>=0.6; extra == 'lerobot'
14
+ Requires-Dist: numpy; extra == 'lerobot'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # agi-diy
18
+
19
+ Python client for [api.agi.diy](https://api.agi.diy). Lease a VLA policy, run it over the LeRobot
20
+ remote-inference protocol (zenoh). One call = one token per action in the chunk.
21
+
22
+ ```
23
+ pip install "agi-diy[lerobot]"
24
+ agi login
25
+ agi run act/so101 --probe
26
+ ```
27
+
28
+ ```python
29
+ import agi
30
+
31
+ with agi.Policy("act/so101") as session:
32
+ actions = session.predict(
33
+ {"observation.state": state, "observation.images.front": frame},
34
+ task="pick up the cube",
35
+ ) # np.ndarray [n_action_steps, dof]
36
+ ```
37
+
38
+ The key comes from `AGI_API_KEY` or `~/.agi/credentials` (`AGI_API_KEY=agi_sk_live_…`).
39
+
40
+ ## Surface
41
+
42
+ ```python
43
+ agi.Policy("act/so101") # POST /v1/sessions → lease; raises agi.NoSlots / agi.InsufficientCredits / agi.AuthError
44
+ session.endpoint # "ws/zenoh.api.agi.diy:80"
45
+ session.deployment # "d_…"
46
+ session.url # "zenoh+ws://zenoh.api.agi.diy:80/d_…"
47
+ session.connect() # admitted lerobot.remote_inference.RemoteClient
48
+ session.predict(obs, task="…") # one chunk, np.ndarray
49
+ session.policy() # strands-robots policy: arm.run(session.policy(), seconds=30)
50
+ session.rollout_args() # flags for lerobot-rollout
51
+ session.close() # DELETE /v1/sessions/:id (also a context manager)
52
+ agi.models(); agi.me(); agi.usage(); agi.sessions(); agi.health()
53
+ ```
54
+
55
+ CLI: `agi login` · `agi models` · `agi session act/so101` · `agi run act/so101 --probe` ·
56
+ `agi rollout act/so101 -- --robot.type=so101_follower …` · `agi usage`.
57
+
58
+ `connect()`, `predict()` and `rollout` need `pip install "agi-diy[lerobot]"`. The REST part has no dependencies.
59
+
60
+ Errors subclass `agi.Error` with `.error`, `.status`, `.retry_after_s`.
@@ -0,0 +1,44 @@
1
+ # agi-diy
2
+
3
+ Python client for [api.agi.diy](https://api.agi.diy). Lease a VLA policy, run it over the LeRobot
4
+ remote-inference protocol (zenoh). One call = one token per action in the chunk.
5
+
6
+ ```
7
+ pip install "agi-diy[lerobot]"
8
+ agi login
9
+ agi run act/so101 --probe
10
+ ```
11
+
12
+ ```python
13
+ import agi
14
+
15
+ with agi.Policy("act/so101") as session:
16
+ actions = session.predict(
17
+ {"observation.state": state, "observation.images.front": frame},
18
+ task="pick up the cube",
19
+ ) # np.ndarray [n_action_steps, dof]
20
+ ```
21
+
22
+ The key comes from `AGI_API_KEY` or `~/.agi/credentials` (`AGI_API_KEY=agi_sk_live_…`).
23
+
24
+ ## Surface
25
+
26
+ ```python
27
+ agi.Policy("act/so101") # POST /v1/sessions → lease; raises agi.NoSlots / agi.InsufficientCredits / agi.AuthError
28
+ session.endpoint # "ws/zenoh.api.agi.diy:80"
29
+ session.deployment # "d_…"
30
+ session.url # "zenoh+ws://zenoh.api.agi.diy:80/d_…"
31
+ session.connect() # admitted lerobot.remote_inference.RemoteClient
32
+ session.predict(obs, task="…") # one chunk, np.ndarray
33
+ session.policy() # strands-robots policy: arm.run(session.policy(), seconds=30)
34
+ session.rollout_args() # flags for lerobot-rollout
35
+ session.close() # DELETE /v1/sessions/:id (also a context manager)
36
+ agi.models(); agi.me(); agi.usage(); agi.sessions(); agi.health()
37
+ ```
38
+
39
+ CLI: `agi login` · `agi models` · `agi session act/so101` · `agi run act/so101 --probe` ·
40
+ `agi rollout act/so101 -- --robot.type=so101_follower …` · `agi usage`.
41
+
42
+ `connect()`, `predict()` and `rollout` need `pip install "agi-diy[lerobot]"`. The REST part has no dependencies.
43
+
44
+ Errors subclass `agi.Error` with `.error`, `.status`, `.retry_after_s`.
@@ -0,0 +1,43 @@
1
+ """api.agi.diy — lease a VLA policy, run it over zenoh.
2
+
3
+ import agi
4
+ with agi.Policy("act/so101") as session:
5
+ actions = session.predict(observation, task="pick up the cube")
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from . import _http
11
+ from .errors import AuthError, Error, InsufficientCredits, MissingDependency, NoSlots, UnknownModel
12
+ from .session import Policy
13
+
14
+ __version__ = "0.1.0"
15
+ __all__ = [
16
+ "Policy", "models", "me", "usage", "health", "sessions",
17
+ "Error", "AuthError", "NoSlots", "InsufficientCredits", "UnknownModel", "MissingDependency",
18
+ ]
19
+
20
+
21
+ def models() -> list[dict]:
22
+ """GET /v1/models."""
23
+ return _http.request("GET", "/v1/models").get("models", [])
24
+
25
+
26
+ def me() -> dict:
27
+ """GET /v1/me."""
28
+ return _http.request("GET", "/v1/me")
29
+
30
+
31
+ def usage(since: str | None = None) -> dict:
32
+ """GET /v1/usage[?since=<iso>]."""
33
+ return _http.request("GET", "/v1/usage" + (f"?since={since}" if since else ""))
34
+
35
+
36
+ def sessions() -> list[dict]:
37
+ """GET /v1/sessions."""
38
+ return _http.request("GET", "/v1/sessions").get("sessions", [])
39
+
40
+
41
+ def health() -> dict:
42
+ """GET /v1/health (no key needed)."""
43
+ return _http.request("GET", "/v1/health", auth=False)
@@ -0,0 +1,78 @@
1
+ """Credentials + one JSON request helper. Standard library only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.error
8
+ import urllib.request
9
+ from pathlib import Path
10
+
11
+ from .errors import AuthError, from_response
12
+
13
+ DEFAULT_URL = "https://api.agi.diy"
14
+ CREDENTIALS = Path(os.environ.get("AGI_CREDENTIALS", "~/.agi/credentials")).expanduser()
15
+ USER_AGENT = "agi-diy-python/0.1.0"
16
+
17
+
18
+ def _read_credentials() -> dict[str, str]:
19
+ out: dict[str, str] = {}
20
+ try:
21
+ for line in CREDENTIALS.read_text().splitlines():
22
+ line = line.strip()
23
+ if not line or line.startswith("#") or "=" not in line:
24
+ continue
25
+ k, v = line.split("=", 1)
26
+ out[k.strip()] = v.strip().strip('"').strip("'")
27
+ except FileNotFoundError:
28
+ pass
29
+ return out
30
+
31
+
32
+ def api_url() -> str:
33
+ return (os.environ.get("AGI_API_URL") or _read_credentials().get("AGI_API_URL") or DEFAULT_URL).rstrip("/")
34
+
35
+
36
+ def api_key(required: bool = True) -> str | None:
37
+ key = os.environ.get("AGI_API_KEY") or _read_credentials().get("AGI_API_KEY")
38
+ if not key and required:
39
+ raise AuthError(
40
+ f"Set AGI_API_KEY or run `agi login` (writes {CREDENTIALS}); no API key found.",
41
+ error="unauthorized",
42
+ status=0,
43
+ )
44
+ return key
45
+
46
+
47
+ def write_credentials(key: str, url: str | None = None) -> Path:
48
+ CREDENTIALS.parent.mkdir(parents=True, exist_ok=True)
49
+ lines = [f"AGI_API_KEY={key}"]
50
+ if url and url.rstrip("/") != DEFAULT_URL:
51
+ lines.append(f"AGI_API_URL={url.rstrip('/')}")
52
+ CREDENTIALS.write_text("\n".join(lines) + "\n")
53
+ CREDENTIALS.chmod(0o600)
54
+ return CREDENTIALS
55
+
56
+
57
+ def request(method: str, path: str, body: dict | None = None, *, key: str | None = None, auth: bool = True, timeout: float = 15.0) -> dict:
58
+ url = api_url() + path
59
+ data = json.dumps(body).encode() if body is not None else None
60
+ headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
61
+ if data is not None:
62
+ headers["Content-Type"] = "application/json"
63
+ if auth:
64
+ headers["Authorization"] = f"Bearer {key or api_key()}"
65
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
66
+ try:
67
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
68
+ raw = resp.read()
69
+ return json.loads(raw) if raw else {}
70
+ except urllib.error.HTTPError as exc:
71
+ raw = exc.read()
72
+ try:
73
+ parsed = json.loads(raw) if raw else None
74
+ except ValueError:
75
+ parsed = None
76
+ raise from_response(exc.code, parsed) from None
77
+ except urllib.error.URLError as exc:
78
+ raise from_response(0, {"error": "unreachable", "message": f"Cannot reach {url}: {exc.reason}."}) from None
@@ -0,0 +1,178 @@
1
+ """`agi` — login, models, run --probe, rollout. Output is plain text or JSON (--json)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import json
8
+ import os
9
+ import shutil
10
+ import sys
11
+ import time
12
+
13
+ from . import __version__, _http
14
+ from .errors import Error
15
+
16
+
17
+ def _die(exc: Exception, code: int = 1) -> int:
18
+ print(f"error: {exc}", file=sys.stderr)
19
+ return code
20
+
21
+
22
+ def cmd_login(a: argparse.Namespace) -> int:
23
+ key = a.key or os.environ.get("AGI_API_KEY") or getpass.getpass("API key (agi_sk_live_…): ").strip()
24
+ if not key.startswith("agi_sk_"):
25
+ print("error: keys start with agi_sk_live_; create one at https://api.agi.diy/dashboard", file=sys.stderr)
26
+ return 2
27
+ if a.url:
28
+ os.environ["AGI_API_URL"] = a.url
29
+ try:
30
+ me = _http.request("GET", "/v1/me", key=key)
31
+ except Error as exc:
32
+ return _die(exc)
33
+ path = _http.write_credentials(key, a.url)
34
+ print(f"logged in as {me.get('login')} · credits {me.get('credits_tokens')} tokens · saved to {path}")
35
+ return 0
36
+
37
+
38
+ def cmd_models(a: argparse.Namespace) -> int:
39
+ try:
40
+ models = _http.request("GET", "/v1/models").get("models", [])
41
+ except Error as exc:
42
+ return _die(exc)
43
+ if a.json:
44
+ print(json.dumps(models, indent=2))
45
+ return 0
46
+ if not models:
47
+ print("no models")
48
+ return 0
49
+ w = max(len(m["id"]) for m in models)
50
+ for m in models:
51
+ print(f"{m['id']:<{w}} {m.get('state','?'):<8} slots {m.get('slots_free','?')}/{m.get('slots_total','?')} "
52
+ f"horizon {m.get('action_horizon','?')} fps {m.get('fps','?')} {m.get('semantics','')}")
53
+ return 0
54
+
55
+
56
+ def cmd_me(a: argparse.Namespace) -> int:
57
+ try:
58
+ print(json.dumps(_http.request("GET", "/v1/me"), indent=2))
59
+ except Error as exc:
60
+ return _die(exc)
61
+ return 0
62
+
63
+
64
+ def cmd_usage(a: argparse.Namespace) -> int:
65
+ try:
66
+ u = _http.request("GET", "/v1/usage")
67
+ except Error as exc:
68
+ return _die(exc)
69
+ if a.json:
70
+ print(json.dumps(u, indent=2))
71
+ return 0
72
+ print(f"credits {u.get('credits_tokens')} tokens · used {u.get('tokens_used')} tokens in {u.get('calls')} calls")
73
+ for row in u.get("by_model", []):
74
+ print(f" {row['model']:<16} {row['calls']:>6} calls {row['tokens']:>9} tokens")
75
+ return 0
76
+
77
+
78
+ def cmd_session(a: argparse.Namespace) -> int:
79
+ from .session import Policy
80
+
81
+ try:
82
+ s = Policy(a.model, ttl_s=a.ttl)
83
+ except Error as exc:
84
+ return _die(exc)
85
+ print(json.dumps(s.lease, indent=2) if a.json else
86
+ f"session {s.id}\nendpoint {s.endpoint}\ndeployment {s.deployment}\nsemantics {s.semantics}\nurl {s.url}\nexpires {s.expires_at}")
87
+ if not a.keep:
88
+ s.close()
89
+ return 0
90
+
91
+
92
+ def cmd_run(a: argparse.Namespace) -> int:
93
+ from .session import Policy
94
+
95
+ try:
96
+ session = Policy(a.model, ttl_s=a.ttl)
97
+ except Error as exc:
98
+ return _die(exc)
99
+ try:
100
+ print(f"session {session.id} · {session.endpoint} · {session.deployment}")
101
+ t0 = time.perf_counter()
102
+ client = session.connect(chunk_merge="append")
103
+ caps = client.capabilities
104
+ print(f"connected in {(time.perf_counter()-t0)*1000:.0f} ms · features "
105
+ + ", ".join(f"{f.name}{tuple(f.shape)}" for f in caps.features)
106
+ + f" · action {caps.action_feature.shape[0]}×{caps.execution_steps} @ {1/caps.action_interval:g} Hz")
107
+ if a.probe:
108
+ obs = session.synthetic_observation()
109
+ for i in range(a.n):
110
+ t1 = time.perf_counter()
111
+ actions = session.predict(obs, task=a.task)
112
+ dt = (time.perf_counter() - t1) * 1000
113
+ print(f"chunk {i+1}/{a.n}: shape {actions.shape} dtype {actions.dtype} · {dt:.0f} ms · "
114
+ f"first {actions[0][:3].round(2).tolist()}…")
115
+ return 0
116
+ except Error as exc:
117
+ return _die(exc)
118
+ except Exception as exc: # zenoh/lerobot errors already name the fix
119
+ return _die(exc)
120
+ finally:
121
+ session.close()
122
+
123
+
124
+ def cmd_rollout(a: argparse.Namespace) -> int:
125
+ from .session import Policy
126
+
127
+ exe = shutil.which("lerobot-rollout")
128
+ if not exe:
129
+ print("error: install LeRobot with `pip install 'lerobot[remote]'`; lerobot-rollout is not on PATH.", file=sys.stderr)
130
+ return 2
131
+ try:
132
+ session = Policy(a.model, ttl_s=a.ttl)
133
+ except Error as exc:
134
+ return _die(exc)
135
+ argv = [exe, *session.rollout_args(), *a.args]
136
+ print("+ " + " ".join(argv), file=sys.stderr)
137
+ import subprocess
138
+
139
+ try:
140
+ return subprocess.call(argv)
141
+ finally:
142
+ session.close()
143
+
144
+
145
+ def main(argv: list[str] | None = None) -> int:
146
+ p = argparse.ArgumentParser(prog="agi", description="api.agi.diy — the brain over cloud.")
147
+ p.add_argument("--version", action="version", version=__version__)
148
+ sub = p.add_subparsers(dest="cmd", required=True)
149
+
150
+ s = sub.add_parser("login", help="save an API key to ~/.agi/credentials")
151
+ s.add_argument("--key"); s.add_argument("--url", help="API base URL (default https://api.agi.diy)")
152
+ s.set_defaults(fn=cmd_login)
153
+
154
+ s = sub.add_parser("models", help="list models"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_models)
155
+ s = sub.add_parser("me", help="who am I"); s.set_defaults(fn=cmd_me)
156
+ s = sub.add_parser("usage", help="credits and usage"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_usage)
157
+
158
+ s = sub.add_parser("session", help="lease a slot and print endpoint + deployment")
159
+ s.add_argument("model"); s.add_argument("--ttl", type=int); s.add_argument("--keep", action="store_true", help="do not end it")
160
+ s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_session)
161
+
162
+ s = sub.add_parser("run", help="lease, connect over zenoh, --probe sends a synthetic observation")
163
+ s.add_argument("model"); s.add_argument("--probe", action="store_true"); s.add_argument("-n", type=int, default=3)
164
+ s.add_argument("--task", default="probe"); s.add_argument("--ttl", type=int); s.set_defaults(fn=cmd_run)
165
+
166
+ s = sub.add_parser("rollout", help="lease, then exec lerobot-rollout with the remote flags filled in")
167
+ s.add_argument("model"); s.add_argument("--ttl", type=int)
168
+ s.add_argument("args", nargs=argparse.REMAINDER, help="-- <lerobot-rollout args>")
169
+ s.set_defaults(fn=cmd_rollout)
170
+
171
+ a = p.parse_args(argv)
172
+ if getattr(a, "args", None) and a.args[:1] == ["--"]:
173
+ a.args = a.args[1:]
174
+ return a.fn(a)
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main())
@@ -0,0 +1,56 @@
1
+ """Every error names the fix in one sentence."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class Error(Exception):
7
+ """An api.agi.diy refusal. `error` is the snake_case code from the API."""
8
+
9
+ def __init__(self, message: str, *, error: str = "error", status: int = 0, retry_after_s: float | None = None):
10
+ super().__init__(message)
11
+ self.error = error
12
+ self.status = status
13
+ self.retry_after_s = retry_after_s
14
+
15
+ @property
16
+ def retryable(self) -> bool:
17
+ return self.retry_after_s is not None
18
+
19
+
20
+ class AuthError(Error):
21
+ """401: no key, a revoked key, or a wrong key."""
22
+
23
+
24
+ class NoSlots(Error):
25
+ """409 no_slots: every slot for the model is taken; wait `retry_after_s` and call again."""
26
+
27
+
28
+ class InsufficientCredits(Error):
29
+ """402: credits are at zero."""
30
+
31
+
32
+ class UnknownModel(Error):
33
+ """404 unknown_model: the id is not in `agi models`."""
34
+
35
+
36
+ class MissingDependency(Error):
37
+ """A zenoh call needs the lerobot extra."""
38
+
39
+
40
+ _BY_CODE = {
41
+ "unauthorized": AuthError,
42
+ "no_slots": NoSlots,
43
+ "insufficient_credits": InsufficientCredits,
44
+ "unknown_model": UnknownModel,
45
+ }
46
+
47
+
48
+ def from_response(status: int, body: dict | None) -> Error:
49
+ body = body or {}
50
+ code = str(body.get("error") or f"http_{status}")
51
+ message = str(body.get("message") or f"api.agi.diy answered {status}.")
52
+ retry = body.get("retry_after_s")
53
+ if code == "unauthorized":
54
+ message = "The API key is missing or invalid; set AGI_API_KEY or run `agi login`."
55
+ cls = _BY_CODE.get(code, Error)
56
+ return cls(message, error=code, status=status, retry_after_s=float(retry) if retry is not None else None)
@@ -0,0 +1,239 @@
1
+ """agi.Policy: one leased slot on api.agi.diy, spoken over zenoh with LeRobot's own client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from . import _http
10
+ from .errors import MissingDependency
11
+
12
+
13
+ def _lerobot():
14
+ try:
15
+ from lerobot.inference import RemoteInferenceConfig # noqa: F401
16
+ from lerobot.remote_inference import RemoteClient # noqa: F401
17
+ except ImportError:
18
+ raise MissingDependency(
19
+ "Run `pip install 'agi-diy[lerobot]'`; connect() and predict() need lerobot[remote].",
20
+ error="missing_dependency",
21
+ ) from None
22
+
23
+
24
+ class Policy:
25
+ """`agi.Policy("act/so101")` leases one slot and holds it until `close()`.
26
+
27
+ Attributes come straight from the lease: `id`, `model`, `endpoint` ("ws/zenoh.api.agi.diy:80"),
28
+ `deployment` ("d_…"), `semantics`, `action_horizon`, `fps`, `expires_at`, `zenoh_mode`.
29
+ """
30
+
31
+ def __init__(self, model: str, *, ttl_s: int | None = None, api_key: str | None = None):
32
+ body: dict[str, Any] = {"model": model}
33
+ if ttl_s is not None:
34
+ body["ttl_s"] = int(ttl_s)
35
+ self._key = api_key
36
+ self.lease = _http.request("POST", "/v1/sessions", body, key=api_key)
37
+ self._client: Any = None
38
+ self._closed = False
39
+
40
+ # --- lease fields -------------------------------------------------------
41
+ @property
42
+ def id(self) -> str:
43
+ return self.lease["id"]
44
+
45
+ @property
46
+ def model(self) -> str:
47
+ return self.lease["model"]
48
+
49
+ @property
50
+ def deployment(self) -> str:
51
+ return self.lease["deployment"]
52
+
53
+ @property
54
+ def endpoint(self) -> str:
55
+ z = self.lease.get("zenoh") or {}
56
+ return z.get("endpoint") or (z.get("endpoints") or [""])[0]
57
+
58
+ @property
59
+ def endpoints(self) -> list[str]:
60
+ z = self.lease.get("zenoh") or {}
61
+ return list(z.get("endpoints") or ([z["endpoint"]] if z.get("endpoint") else []))
62
+
63
+ @property
64
+ def zenoh_mode(self) -> str:
65
+ return (self.lease.get("zenoh") or {}).get("mode") or "client"
66
+
67
+ @property
68
+ def semantics(self) -> str:
69
+ return self.lease.get("semantics") or ""
70
+
71
+ @property
72
+ def action_horizon(self) -> int | None:
73
+ return self.lease.get("action_horizon")
74
+
75
+ @property
76
+ def fps(self) -> int | None:
77
+ return self.lease.get("fps")
78
+
79
+ @property
80
+ def expires_at(self) -> str | None:
81
+ return self.lease.get("expires_at")
82
+
83
+ @property
84
+ def url(self) -> str:
85
+ """`zenoh+<link>://host:port/<deployment>` — e.g. `zenoh+ws://zenoh.api.agi.diy:80/d_…`.
86
+
87
+ A tcp endpoint gives `zenoh://host:port/<deployment>`, which strands-robots `Policy(...)` parses today.
88
+ For ws use `session.policy()` (works now) or the one-line parser change in docs/UPSTREAM.md.
89
+ """
90
+ link, _, hostport = self.endpoint.partition("/")
91
+ scheme = "zenoh" if link == "tcp" else f"zenoh+{link}"
92
+ return f"{scheme}://{hostport}/{self.deployment}"
93
+
94
+ def rollout_args(self) -> list[str]:
95
+ """The `lerobot-rollout` flags for this lease."""
96
+ args = [
97
+ "--inference.type=remote",
98
+ f"--inference.endpoint={self.endpoint}",
99
+ f"--inference.deployment={self.deployment}",
100
+ f"--inference.zenoh_mode={self.zenoh_mode}",
101
+ ]
102
+ if self.semantics:
103
+ args.append(f"--inference.semantics={self.semantics}")
104
+ return args
105
+
106
+ # --- zenoh --------------------------------------------------------------
107
+ def config(self, **overrides: Any):
108
+ """A `lerobot.inference.RemoteInferenceConfig` built from the lease."""
109
+ _lerobot()
110
+ from lerobot.inference import RemoteInferenceConfig
111
+
112
+ kw: dict[str, Any] = dict(
113
+ endpoint=self.endpoint,
114
+ deployment=self.deployment,
115
+ semantics=self.semantics or "unknown",
116
+ zenoh_mode=self.zenoh_mode,
117
+ )
118
+ kw.update(overrides)
119
+ return RemoteInferenceConfig(**kw)
120
+
121
+ def connect(self, **overrides: Any):
122
+ """A connected, admitted `lerobot.remote_inference.RemoteClient` for this lease."""
123
+ _lerobot()
124
+ from lerobot.remote_inference import RemoteClient
125
+
126
+ config = self.config(**overrides)
127
+ client = RemoteClient.connect(config)
128
+ try:
129
+ caps = client.capabilities
130
+ semantics = client.descriptor.get("semantics") or config.semantics
131
+ client.admit(
132
+ features=caps.features,
133
+ action_feature=caps.action_feature,
134
+ semantics=semantics,
135
+ action_interval=caps.action_interval,
136
+ mode=config.mode,
137
+ )
138
+ except Exception:
139
+ client.close()
140
+ raise
141
+ self._client = client
142
+ return client
143
+
144
+ def policy(self, **settings: Any):
145
+ """A strands-robots policy for this lease: `arm.run(session.policy(), seconds=30)`."""
146
+ try:
147
+ from strands_robots.policy.lerobot import ZenohPolicy
148
+ except ImportError:
149
+ raise MissingDependency(
150
+ "Run `pip install strands-robots`; session.policy() returns a strands-robots ZenohPolicy.",
151
+ error="missing_dependency",
152
+ ) from None
153
+ settings.setdefault("zenoh_mode", self.zenoh_mode)
154
+ return ZenohPolicy(endpoint=self.endpoint, deployment=self.deployment, **settings)
155
+
156
+ def features(self) -> tuple:
157
+ """The deployment's observation features (name, shape, dtype, kind), from the server descriptor."""
158
+ client = self._client or self.connect(chunk_merge="append")
159
+ return client.capabilities.features
160
+
161
+ def synthetic_observation(self) -> dict:
162
+ """Zeros for every feature the deployment expects — what `agi run --probe` sends."""
163
+ import numpy as np
164
+
165
+ client = self._client or self.connect(chunk_merge="append")
166
+ obs = {}
167
+ for f in client.capabilities.features:
168
+ obs[f.name] = np.zeros(tuple(f.shape), dtype=f.dtype)
169
+ return obs
170
+
171
+ def predict(self, observation: dict, task: str = "", *, timeout_s: float | None = None):
172
+ """One observation in, one action chunk out: `np.ndarray [n_action_steps, dof]`.
173
+
174
+ `observation` is keyed by the deployment's feature names, e.g.
175
+ `{"observation.state": float32[6], "observation.images.front": uint8[H, W, 3]}`.
176
+ """
177
+ _lerobot()
178
+ import numpy as np
179
+ from lerobot.inference import ChunkRequest, ExecutionMode, ObservationSnapshot
180
+ from lerobot.policies.rtc.action_queue import QueueSnapshot
181
+
182
+ client = self._client or self.connect(chunk_merge="append")
183
+ caps = client.capabilities
184
+ features = {}
185
+ for spec in caps.features:
186
+ if spec.name not in observation:
187
+ raise KeyError(f"Observation is missing {spec.name!r}; the deployment expects {[f.name for f in caps.features]}.")
188
+ features[spec.name] = np.ascontiguousarray(np.asarray(observation[spec.name]), dtype=spec.dtype)
189
+ aligned = client.chunk_settings.get("chunk_merge") == "aligned"
190
+ snapshot = ObservationSnapshot(
191
+ features=features,
192
+ capture_time=time.monotonic(),
193
+ task=task,
194
+ observation_id=uuid4().hex,
195
+ action_cursor=0 if aligned else None,
196
+ )
197
+ continuation = QueueSnapshot(generation=client.generation, cursor=0, index=0, model_actions=None, canonical_actions=None, provenance=())
198
+ request = ChunkRequest(
199
+ observation=snapshot,
200
+ continuation=continuation,
201
+ request_id=uuid4().hex,
202
+ generation=client.generation,
203
+ mode=ExecutionMode.CHUNK,
204
+ delay=0,
205
+ submitted_at=time.monotonic(),
206
+ )
207
+ if timeout_s is not None:
208
+ client.config.action_timeout_s = float(timeout_s)
209
+ chunk = client.infer(request)
210
+ return chunk.canonical_actions.numpy()
211
+
212
+ # --- lifecycle ----------------------------------------------------------
213
+ def refresh(self) -> dict:
214
+ self.lease = _http.request("GET", f"/v1/sessions/{self.id}", key=self._key)
215
+ return self.lease
216
+
217
+ def close(self) -> dict | None:
218
+ """End the lease (DELETE /v1/sessions/:id) and the zenoh session, if any. Idempotent."""
219
+ if self._closed:
220
+ return None
221
+ self._closed = True
222
+ if self._client is not None:
223
+ try:
224
+ self._client.close()
225
+ finally:
226
+ self._client = None
227
+ try:
228
+ return _http.request("DELETE", f"/v1/sessions/{self.id}", key=self._key)
229
+ except Exception:
230
+ return None
231
+
232
+ def __enter__(self) -> "Policy":
233
+ return self
234
+
235
+ def __exit__(self, *exc: Any) -> None:
236
+ self.close()
237
+
238
+ def __repr__(self) -> str:
239
+ return f"agi.Policy({self.model!r}, deployment={self.deployment!r}, endpoint={self.endpoint!r})"
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agi-diy"
7
+ version = "0.1.0"
8
+ description = "api.agi.diy client: lease a VLA policy, run it over the LeRobot remote-inference protocol."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "agi.diy" }]
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ lerobot = ["lerobot[remote]>=0.6", "numpy"]
17
+ dev = ["pytest"]
18
+
19
+ [project.scripts]
20
+ agi = "agi.cli:main"
21
+
22
+ [project.urls]
23
+ Homepage = "https://api.agi.diy"
24
+ Source = "https://github.com/cagataycali/api-agi-diy"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["agi"]
@@ -0,0 +1,10 @@
1
+ # tests
2
+
3
+ ```
4
+ uv venv .venv && source .venv/bin/activate
5
+ uv pip install -e ".[dev]" # REST tests only (fake Worker on http.server)
6
+ uv pip install -e "/path/to/lerobot[remote]" # + zenoh loopback tests (real lerobot PolicyServer over ws/)
7
+ PYTHONPATH=tests pytest -q
8
+ ```
9
+
10
+ `test_zenoh_loopback.py` skips without `lerobot[remote]`.
@@ -0,0 +1,16 @@
1
+ import os
2
+ import pytest
3
+
4
+ from fake_worker import KEY, serve
5
+
6
+
7
+ @pytest.fixture
8
+ def worker(monkeypatch, tmp_path):
9
+ url, state, stop = serve()
10
+ monkeypatch.setenv("AGI_API_URL", url)
11
+ monkeypatch.setenv("AGI_API_KEY", KEY)
12
+ monkeypatch.setenv("AGI_CREDENTIALS", str(tmp_path / "credentials"))
13
+ import importlib, agi._http
14
+ importlib.reload(agi._http)
15
+ yield state
16
+ stop()
@@ -0,0 +1,111 @@
1
+ """A tiny api.agi.diy stand-in (docs/CONTRACT.md v0) on http.server, for REST tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+
9
+ KEY = "agi_sk_live_" + "a" * 32
10
+ MODEL = {
11
+ "id": "act/so101", "family": "act", "robot": "so101", "semantics": "so101-degrees-gripper-percent-v1",
12
+ "action_horizon": 50, "fps": 30, "cameras": ["front"], "slots_free": 1, "slots_total": 1, "state": "ready",
13
+ }
14
+
15
+
16
+ class State:
17
+ def __init__(self):
18
+ self.sessions: dict[str, dict] = {}
19
+ self.free = 1
20
+ self.credits = 200_000
21
+ self.requests: list[tuple[str, str, dict | None]] = []
22
+
23
+
24
+ def make_handler(state: State):
25
+ class H(BaseHTTPRequestHandler):
26
+ def log_message(self, *a): # quiet
27
+ pass
28
+
29
+ def _json(self, code, body):
30
+ raw = json.dumps(body).encode()
31
+ self.send_response(code)
32
+ self.send_header("Content-Type", "application/json")
33
+ self.send_header("Content-Length", str(len(raw)))
34
+ self.end_headers()
35
+ self.wfile.write(raw)
36
+
37
+ def _body(self):
38
+ n = int(self.headers.get("Content-Length") or 0)
39
+ return json.loads(self.rfile.read(n)) if n else None
40
+
41
+ def _auth(self):
42
+ return self.headers.get("Authorization") == f"Bearer {KEY}"
43
+
44
+ def do_GET(self):
45
+ path = self.path.split("?")[0]
46
+ state.requests.append(("GET", path, None))
47
+ if path == "/v1/health":
48
+ return self._json(200, {"ok": True, "runners": 1, "time": "2026-10-24T00:00:00Z"})
49
+ if not self._auth():
50
+ return self._json(401, {"error": "unauthorized", "message": "Missing or invalid API key."})
51
+ if path == "/v1/models":
52
+ return self._json(200, {"models": [MODEL]})
53
+ if path == "/v1/me":
54
+ return self._json(200, {"id": "u_1", "login": "tester", "credits_tokens": state.credits})
55
+ if path == "/v1/usage":
56
+ return self._json(200, {"credits_tokens": state.credits, "tokens_used": 0, "calls": 0, "by_model": [], "events": []})
57
+ if path.startswith("/v1/sessions/"):
58
+ s = state.sessions.get(path.rsplit("/", 1)[1])
59
+ return self._json(200, s) if s else self._json(404, {"error": "not_found", "message": "No such session."})
60
+ if path == "/v1/sessions":
61
+ return self._json(200, {"sessions": list(state.sessions.values())})
62
+ self._json(404, {"error": "not_found", "message": "No such route."})
63
+
64
+ def do_POST(self):
65
+ body = self._body()
66
+ state.requests.append(("POST", self.path, body))
67
+ if not self._auth():
68
+ return self._json(401, {"error": "unauthorized", "message": "Missing or invalid API key."})
69
+ if self.path == "/v1/sessions":
70
+ if body.get("model") != MODEL["id"]:
71
+ return self._json(404, {"error": "unknown_model", "message": "Model not found; see GET /v1/models."})
72
+ if state.credits <= 0:
73
+ return self._json(402, {"error": "insufficient_credits", "message": "Credits are at zero; top up at https://api.agi.diy/dashboard."})
74
+ if state.free <= 0:
75
+ return self._json(409, {"error": "no_slots", "message": "All slots are busy; retry in 5 s.", "retry_after_s": 5})
76
+ state.free -= 1
77
+ sid = f"ses_{len(state.sessions)+1:022d}"
78
+ s = {
79
+ "id": sid, "model": MODEL["id"], "state": "leased", "deployment": "d_test0000000000000000ab", "runner": "thor",
80
+ "zenoh": {"endpoint": "ws/zenoh.api.agi.diy:80", "endpoints": ["ws/zenoh.api.agi.diy:80", "tcp/1.2.3.4:7447"], "mode": "client"},
81
+ "semantics": MODEL["semantics"], "features": {}, "action_horizon": 50, "fps": 30,
82
+ "created_at": "2026-10-24T00:00:00Z", "expires_at": "2026-10-24T00:10:00Z", "ended_at": None,
83
+ "end_reason": None, "tokens_used": 0,
84
+ }
85
+ state.sessions[sid] = s
86
+ return self._json(201, s)
87
+ self._json(404, {"error": "not_found", "message": "No such route."})
88
+
89
+ def do_DELETE(self):
90
+ state.requests.append(("DELETE", self.path, None))
91
+ if not self._auth():
92
+ return self._json(401, {"error": "unauthorized", "message": "Missing or invalid API key."})
93
+ sid = self.path.rsplit("/", 1)[1]
94
+ s = state.sessions.get(sid)
95
+ if not s:
96
+ return self._json(404, {"error": "not_found", "message": "No such session."})
97
+ if s["state"] != "ended":
98
+ s["state"], s["end_reason"] = "ended", "client_done"
99
+ state.free += 1
100
+ self._json(200, s)
101
+
102
+ return H
103
+
104
+
105
+ def serve():
106
+ """Start on a free port; returns (base_url, state, shutdown)."""
107
+ state = State()
108
+ srv = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(state))
109
+ t = threading.Thread(target=srv.serve_forever, daemon=True)
110
+ t.start()
111
+ return f"http://127.0.0.1:{srv.server_port}", state, srv.shutdown
@@ -0,0 +1,110 @@
1
+ """A real lerobot PolicyServer on a local zenoh ws listener, serving a tiny deterministic policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ from collections import deque
7
+ from threading import Event, Thread
8
+
9
+ import torch
10
+ from lerobot.configs.types import FeatureType, NormalizationMode, PolicyFeature
11
+ from lerobot.inference import FeatureSpec, PolicyRunner
12
+ from lerobot.policies.act.configuration_act import ACTConfig
13
+ from lerobot.policies.act.processor_act import make_act_pre_post_processors
14
+ from lerobot.policies.pretrained import PreTrainedPolicy
15
+ from lerobot.remote_inference.server import PolicyServer, SessionWorker
16
+ from lerobot.transport.zenoh import ZenohConfig, ZenohTransport
17
+ from lerobot.utils.constants import ACTION, OBS_ENV_STATE, OBS_STATE
18
+
19
+ SEMANTICS = "so101-degrees-gripper-percent-v1"
20
+ NAMES = ("shoulder.pos", "elbow.pos", "gripper.pos")
21
+
22
+
23
+ class TinyPolicy(PreTrainedPolicy):
24
+ config_class = ACTConfig
25
+ name = "tiny"
26
+
27
+ def __init__(self, config):
28
+ super().__init__(config)
29
+ self.reset()
30
+
31
+ def get_optim_params(self):
32
+ return {}
33
+
34
+ def reset(self):
35
+ self._action_queue = deque()
36
+
37
+ def forward(self, batch):
38
+ raise NotImplementedError
39
+
40
+ def select_action(self, batch, **kwargs):
41
+ raise AssertionError("the runner must not call select_action")
42
+
43
+ def predict_action_chunk(self, batch, **kwargs):
44
+ return torch.arange(24, dtype=torch.float32).reshape(1, 8, 3)
45
+
46
+
47
+ def free_port() -> int:
48
+ with socket.socket() as s:
49
+ s.bind(("127.0.0.1", 0))
50
+ return s.getsockname()[1]
51
+
52
+
53
+ def serve(deployment: str = "d_loopback", link: str = "ws"):
54
+ """Start a server thread; returns (endpoint, stop)."""
55
+ config = ACTConfig(
56
+ input_features={
57
+ OBS_STATE: PolicyFeature(type=FeatureType.STATE, shape=(3,)),
58
+ OBS_ENV_STATE: PolicyFeature(type=FeatureType.ENV, shape=(3,)),
59
+ },
60
+ output_features={ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(3,))},
61
+ normalization_mapping={
62
+ FeatureType.STATE: NormalizationMode.MEAN_STD,
63
+ FeatureType.ENV: NormalizationMode.IDENTITY,
64
+ FeatureType.ACTION: NormalizationMode.MEAN_STD,
65
+ },
66
+ chunk_size=8,
67
+ n_action_steps=8,
68
+ dim_model=16,
69
+ n_heads=2,
70
+ dim_feedforward=32,
71
+ n_encoder_layers=1,
72
+ n_decoder_layers=1,
73
+ vision_backbone="resnet18",
74
+ pretrained_backbone_weights=None,
75
+ device="cpu",
76
+ )
77
+ stats = {
78
+ OBS_STATE: {"mean": torch.zeros(3), "std": torch.ones(3)},
79
+ ACTION: {"mean": torch.tensor([1.0, 2.0, 3.0]), "std": torch.ones(3) * 2},
80
+ }
81
+ pre, post = make_act_pre_post_processors(config, stats)
82
+ runner = PolicyRunner(
83
+ TinyPolicy(config), pre, post, action_interval=1 / 30,
84
+ features=tuple(FeatureSpec(k, (3,), "float32", names=NAMES, semantics=SEMANTICS) for k in (OBS_STATE, OBS_ENV_STATE)),
85
+ action_feature=FeatureSpec(ACTION, (3,), "float32", names=NAMES, semantics=SEMANTICS),
86
+ )
87
+ worker = SessionWorker(runner, deployment=deployment, artifact_identity="tiny-loopback", semantics=SEMANTICS)
88
+ endpoint = f"{link}/127.0.0.1:{free_port()}"
89
+ transport = ZenohTransport(ZenohConfig(listen_endpoints=[endpoint]))
90
+ server = PolicyServer(worker, transport)
91
+ ready = Event()
92
+ original = transport.declare_token
93
+
94
+ def declare_token(key):
95
+ token = original(key)
96
+ ready.set()
97
+ return token
98
+
99
+ transport.declare_token = declare_token
100
+ thread = Thread(target=server.serve, daemon=True)
101
+ thread.start()
102
+ assert ready.wait(30), "loopback server did not advertise readiness"
103
+
104
+ def stop():
105
+ server.stop()
106
+ thread.join(10)
107
+ transport.close()
108
+ worker.close()
109
+
110
+ return endpoint, stop
@@ -0,0 +1,100 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+
5
+ import pytest
6
+
7
+ import agi
8
+ from agi import _http
9
+ from fake_worker import KEY
10
+
11
+
12
+ def test_models_me_usage_health(worker):
13
+ assert agi.models()[0]["id"] == "act/so101"
14
+ assert agi.me()["login"] == "tester"
15
+ assert agi.usage()["credits_tokens"] == 200_000
16
+ assert agi.health()["ok"] is True
17
+ assert ("GET", "/v1/health", None) in worker.requests
18
+
19
+
20
+ def test_policy_lease_and_close(worker):
21
+ with agi.Policy("act/so101", ttl_s=120) as s:
22
+ assert s.endpoint == "ws/zenoh.api.agi.diy:80"
23
+ assert s.endpoints[1] == "tcp/1.2.3.4:7447"
24
+ assert s.deployment.startswith("d_")
25
+ assert s.zenoh_mode == "client"
26
+ assert s.url == "zenoh+ws://zenoh.api.agi.diy:80/d_test0000000000000000ab"
27
+ assert "--inference.endpoint=ws/zenoh.api.agi.diy:80" in s.rollout_args()
28
+ assert f"--inference.deployment={s.deployment}" in s.rollout_args()
29
+ assert "--inference.semantics=so101-degrees-gripper-percent-v1" in s.rollout_args()
30
+ assert worker.requests[-1] == ("POST", "/v1/sessions", {"model": "act/so101", "ttl_s": 120})
31
+ assert worker.requests[-1] == ("DELETE", f"/v1/sessions/{s.id}", None)
32
+ assert worker.free == 1
33
+ assert s.close() is None # idempotent
34
+
35
+
36
+ def test_errors(worker, monkeypatch):
37
+ with pytest.raises(agi.UnknownModel) as e:
38
+ agi.Policy("nope/so101")
39
+ assert e.value.error == "unknown_model" and e.value.status == 404
40
+
41
+ worker.free = 0
42
+ with pytest.raises(agi.NoSlots) as e:
43
+ agi.Policy("act/so101")
44
+ assert e.value.retry_after_s == 5 and e.value.retryable
45
+
46
+ worker.free, worker.credits = 1, 0
47
+ with pytest.raises(agi.InsufficientCredits):
48
+ agi.Policy("act/so101")
49
+
50
+ monkeypatch.setenv("AGI_API_KEY", "agi_sk_live_wrong")
51
+ with pytest.raises(agi.AuthError) as e:
52
+ agi.models()
53
+ assert "agi login" in str(e.value)
54
+
55
+ monkeypatch.delenv("AGI_API_KEY")
56
+ with pytest.raises(agi.AuthError):
57
+ agi.models()
58
+
59
+
60
+ def test_credentials_file(worker, monkeypatch, tmp_path):
61
+ monkeypatch.delenv("AGI_API_KEY")
62
+ path = _http.write_credentials(KEY, os.environ["AGI_API_URL"])
63
+ assert path == tmp_path / "credentials"
64
+ assert oct(path.stat().st_mode & 0o777) == "0o600"
65
+ assert path.read_text().splitlines()[0] == f"AGI_API_KEY={KEY}"
66
+ assert agi.me()["login"] == "tester"
67
+
68
+
69
+ def test_missing_lerobot_names_fix(worker, monkeypatch):
70
+ import builtins
71
+ real = builtins.__import__
72
+
73
+ def fake(name, *a, **k):
74
+ if name.startswith("lerobot"):
75
+ raise ImportError(name)
76
+ return real(name, *a, **k)
77
+
78
+ monkeypatch.setattr(builtins, "__import__", fake)
79
+ s = agi.Policy("act/so101")
80
+ with pytest.raises(agi.MissingDependency) as e:
81
+ s.connect()
82
+ assert "agi-diy[lerobot]" in str(e.value)
83
+ s.close()
84
+
85
+
86
+ def _cli(*args):
87
+ return subprocess.run([sys.executable, "-m", "agi.cli", *args], capture_output=True, text=True, env=os.environ)
88
+
89
+
90
+ def test_cli(worker):
91
+ r = _cli("models")
92
+ assert r.returncode == 0 and "act/so101" in r.stdout and "ready" in r.stdout
93
+ r = _cli("session", "act/so101")
94
+ assert r.returncode == 0 and "ws/zenoh.api.agi.diy:80" in r.stdout and worker.free == 1
95
+ r = _cli("login", "--key", KEY, "--url", os.environ["AGI_API_URL"])
96
+ assert r.returncode == 0 and "tester" in r.stdout, r.stderr
97
+ r = _cli("login", "--key", "agi_sk_live_bad")
98
+ assert r.returncode == 1 and "agi login" in r.stderr
99
+ r = _cli("usage")
100
+ assert r.returncode == 0 and "credits 200000" in r.stdout
@@ -0,0 +1,66 @@
1
+ """End-to-end against fakes: fake Worker leases a slot whose endpoint is a REAL lerobot PolicyServer over zenoh ws."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ pytest.importorskip("zenoh")
7
+ pytest.importorskip("lerobot.remote_inference")
8
+
9
+ import agi
10
+ from loopback_server import SEMANTICS, serve
11
+
12
+
13
+ @pytest.fixture(scope="module")
14
+ def server():
15
+ endpoint, stop = serve(deployment="d_loopback", link="ws")
16
+ yield endpoint
17
+ stop()
18
+
19
+
20
+ def test_predict_one_shot(worker, server, monkeypatch):
21
+ # Patch the fake's lease to point at the loopback server (ws link, zenoh client mode).
22
+ import fake_worker
23
+ monkeypatch.setitem(fake_worker.MODEL, "semantics", SEMANTICS)
24
+ session = agi.Policy("act/so101")
25
+ session.lease["zenoh"] = {"endpoint": server, "endpoints": [server], "mode": "client"}
26
+ session.lease["deployment"] = "d_loopback"
27
+ assert session.endpoint.startswith("ws/127.0.0.1:")
28
+ with session:
29
+ obs = session.synthetic_observation()
30
+ assert set(obs) == {"observation.state", "observation.environment_state"}
31
+ actions = session.predict(obs, task="probe")
32
+ assert isinstance(actions, np.ndarray)
33
+ assert actions.shape == (8, 3) and actions.dtype == np.float32
34
+ # tiny policy: arange(24) de-normalized with mean [1,2,3], std 2 -> [1,4,7], [7,10,13], ...
35
+ np.testing.assert_allclose(actions[0], [1.0, 4.0, 7.0])
36
+ np.testing.assert_allclose(actions[1], [7.0, 10.0, 13.0])
37
+ again = session.predict(obs, task="probe")
38
+ assert again.shape == (8, 3)
39
+ assert worker.free == 1
40
+
41
+
42
+ def test_connect_returns_admitted_client(worker, server, monkeypatch):
43
+ import fake_worker
44
+ monkeypatch.setitem(fake_worker.MODEL, "semantics", SEMANTICS)
45
+ session = agi.Policy("act/so101")
46
+ session.lease["zenoh"] = {"endpoint": server, "endpoints": [server], "mode": "client"}
47
+ session.lease["deployment"] = "d_loopback"
48
+ client = session.connect()
49
+ try:
50
+ assert client.session_id # admitted
51
+ assert client.descriptor["deployment"] == "d_loopback"
52
+ assert [f.name for f in client.capabilities.features] == ["observation.state", "observation.environment_state"]
53
+ assert session.config().zenoh_mode == "client"
54
+ finally:
55
+ session.close()
56
+
57
+
58
+ def test_wrong_deployment_names_fix(worker, server, monkeypatch):
59
+ import fake_worker
60
+ monkeypatch.setitem(fake_worker.MODEL, "semantics", SEMANTICS)
61
+ session = agi.Policy("act/so101")
62
+ session.lease["zenoh"] = {"endpoint": server, "endpoints": [server], "mode": "client"}
63
+ session.lease["deployment"] = "d_nope"
64
+ with pytest.raises(TimeoutError, match="did not answer discovery"):
65
+ session.connect(handshake_timeout_s=1.5)
66
+ session.close()