bma-executor-sdk 0.4.4__tar.gz → 0.4.5__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.
Files changed (20) hide show
  1. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/PKG-INFO +3 -3
  2. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/README.md +2 -2
  3. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/pyproject.toml +1 -1
  4. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/client.py +45 -1
  5. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/generated.py +11 -0
  6. bma_executor_sdk-0.4.5/tests/test_executor_token_refresh.py +88 -0
  7. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/.gitignore +0 -0
  8. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/examples/agent_minimal.py +0 -0
  9. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/examples/executor_minimal.py +0 -0
  10. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/examples/harness_loopback.py +0 -0
  11. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/__init__.py +0 -0
  12. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/_rpc.py +0 -0
  13. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/agent.py +0 -0
  14. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/harness.py +0 -0
  15. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/transport.py +0 -0
  16. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/tests/executor_helpers.py +0 -0
  17. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/tests/test_executor_conformance.py +0 -0
  18. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/tests/test_executor_contract.py +0 -0
  19. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/tests/test_executor_interop.py +0 -0
  20. {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/tests/test_executor_runtime.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bma-executor-sdk
3
- Version: 0.4.4
3
+ Version: 0.4.5
4
4
  Summary: BMA executor protocol (bma.executor.v1.1): executor, harness and agent ends on asyncio with WebSocket, Unix socket and in-process transports.
5
5
  Project-URL: Repository, https://github.com/beeos-ai/bma-sdks
6
6
  License-Expression: Apache-2.0
@@ -17,13 +17,13 @@ asyncio executor (`ExecutorClient`), harness (`Harness`) and agent ends of the B
17
17
 
18
18
  | | |
19
19
  | --- | --- |
20
- | Version | 0.4.4 (tag `executor-python/v0.4.4`), see [CHANGELOG](../../CHANGELOG.md) |
20
+ | Version | 0.4.5 (tag `executor-python/v0.4.5`), see [CHANGELOG](../../CHANGELOG.md) |
21
21
  | Protocol | `bma.executor.v1.1` (contract 1.1.0, [`lock.json`](../../contracts/bma/executor/lock.json)) |
22
22
  | Python | 3.10 or newer; depends on `httpx` and `websockets` |
23
23
  | Integration guide | [docs/executor](../../docs/executor/README.md) ([中文](../../docs/executor/README.zh-CN.md)) |
24
24
 
25
25
  ```sh
26
- pip install bma-executor-sdk==0.4.4
26
+ pip install bma-executor-sdk==0.4.5
27
27
  ```
28
28
 
29
29
  The REST / façade client is the separate package [`bma-sdk`](../../python).
@@ -6,13 +6,13 @@ asyncio executor (`ExecutorClient`), harness (`Harness`) and agent ends of the B
6
6
 
7
7
  | | |
8
8
  | --- | --- |
9
- | Version | 0.4.4 (tag `executor-python/v0.4.4`), see [CHANGELOG](../../CHANGELOG.md) |
9
+ | Version | 0.4.5 (tag `executor-python/v0.4.5`), see [CHANGELOG](../../CHANGELOG.md) |
10
10
  | Protocol | `bma.executor.v1.1` (contract 1.1.0, [`lock.json`](../../contracts/bma/executor/lock.json)) |
11
11
  | Python | 3.10 or newer; depends on `httpx` and `websockets` |
12
12
  | Integration guide | [docs/executor](../../docs/executor/README.md) ([中文](../../docs/executor/README.zh-CN.md)) |
13
13
 
14
14
  ```sh
15
- pip install bma-executor-sdk==0.4.4
15
+ pip install bma-executor-sdk==0.4.5
16
16
  ```
17
17
 
18
18
  The REST / façade client is the separate package [`bma-sdk`](../../python).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bma-executor-sdk"
3
- version = "0.4.4"
3
+ version = "0.4.5"
4
4
  description = "BMA executor protocol (bma.executor.v1.1): executor, harness and agent ends on asyncio with WebSocket, Unix socket and in-process transports."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -21,6 +21,7 @@ from .generated import (
21
21
  METHOD_EXECUTOR_DRAIN,
22
22
  METHOD_EXECUTOR_HEARTBEAT,
23
23
  METHOD_EXECUTOR_REGISTER,
24
+ METHOD_EXECUTOR_TOKEN_REFRESH,
24
25
  METHOD_SESSION_ASSIGN,
25
26
  METHOD_SESSION_ENDED,
26
27
  METHOD_SESSION_READY,
@@ -69,6 +70,8 @@ PROTOCOL_V11 = ProtocolVersion.BmaExecutorV11.value
69
70
  _MAX_TRACKED_TOOL_RESULTS = 8192
70
71
  _MAX_TOOL_RESULT_RETRY_DELAY = 10.0
71
72
  _MAX_RECONNECT_DELAY = 30.0
73
+ # Fraction of a token's lifetime after which executor.token.refresh is sent.
74
+ _TOKEN_REFRESH_AT = 0.7
72
75
 
73
76
  TokenSource = Callable[[], Awaitable[ExecutorTokenResponse]]
74
77
  ToolCallHandler = Callable[[ToolCallParams], Awaitable[ToolResultParams]]
@@ -127,6 +130,7 @@ class ExecutorClient:
127
130
  heartbeat_load: Callable[[], ExecutorLoad | None] | None = None,
128
131
  max_reconnect_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS,
129
132
  reconnect_base_delay: float = DEFAULT_RECONNECT_BASE_DELAY,
133
+ reconnect_max_delay: float = _MAX_RECONNECT_DELAY,
130
134
  random: Callable[[], float] = _random.random,
131
135
  tool_result_retry_attempts: int = DEFAULT_TOOL_RESULT_RETRY_ATTEMPTS,
132
136
  tool_result_retry_base_delay: float = DEFAULT_TOOL_RESULT_RETRY_BASE_DELAY,
@@ -153,6 +157,7 @@ class ExecutorClient:
153
157
  self._heartbeat_load = heartbeat_load
154
158
  self._max_reconnect_attempts = max_reconnect_attempts
155
159
  self._reconnect_base_delay = reconnect_base_delay
160
+ self._reconnect_max_delay = reconnect_max_delay
156
161
  self._random = random
157
162
  self._tool_result_retry_attempts = max(1, tool_result_retry_attempts)
158
163
  self._tool_result_retry_base_delay = tool_result_retry_base_delay
@@ -411,6 +416,45 @@ class ExecutorClient:
411
416
  registered, self._registered = self._registered, asyncio.Event()
412
417
  registered.set()
413
418
  self._log("info", f"executor registered as {result['executor_id']}")
419
+ if token.get("expires_in"):
420
+ self._spawn(self._refresh_token_loop(peer, float(token["expires_in"])))
421
+
422
+ async def _refresh_token_loop(self, peer: Peer, lifetime: float) -> None:
423
+ """Keep peer open across token lifetimes: at 70% of the token's
424
+ lifetime fetch a new token and send executor.token.refresh. A harness
425
+ without the method (-32601) closes with 4001 at expiry and the client
426
+ reconnects as before."""
427
+ loop = asyncio.get_running_loop()
428
+ expires_at = loop.time() + lifetime
429
+ wait = lifetime * _TOKEN_REFRESH_AT
430
+ while True:
431
+ await asyncio.sleep(wait)
432
+ if self._stopped or self._peer is not peer:
433
+ return
434
+ try:
435
+ fresh = await self._fetch_token()
436
+ await self.call(METHOD_EXECUTOR_TOKEN_REFRESH, {"token": fresh["token"]})
437
+ except RPCError as error:
438
+ if error.code in (-32601, -32012):
439
+ self._log("info", "harness does not refresh executor tokens; reconnecting at expiry")
440
+ return
441
+ failure: BaseException = error
442
+ except asyncio.CancelledError:
443
+ raise
444
+ except Exception as error:
445
+ failure = error
446
+ else:
447
+ lifetime = float(fresh.get("expires_in") or 0)
448
+ if lifetime <= 0:
449
+ return
450
+ expires_at = loop.time() + lifetime
451
+ wait = lifetime * _TOKEN_REFRESH_AT
452
+ continue
453
+ self._log("warn", "executor token refresh failed", failure)
454
+ remaining = expires_at - loop.time()
455
+ if remaining < 2:
456
+ return
457
+ wait = remaining / 2
414
458
 
415
459
  def _resume_state(self) -> dict[str, Any] | None:
416
460
  if not self._executor_id and not self._connection_id and not self._sessions:
@@ -448,7 +492,7 @@ class ExecutorClient:
448
492
  return
449
493
  attempt = self._reconnect_attempts
450
494
  self._reconnect_attempts += 1
451
- delay = min(self._reconnect_base_delay * (1 << attempt), _MAX_RECONNECT_DELAY)
495
+ delay = min(self._reconnect_base_delay * (1 << attempt), self._reconnect_max_delay)
452
496
  delay *= 0.5 + self._random() * 0.5
453
497
  self._log("info", f"reconnecting executor channel in {delay:.3f}s")
454
498
  await asyncio.sleep(delay)
@@ -184,6 +184,7 @@ class Cursors(TypedDict):
184
184
  class DisplayCapability(TypedDict):
185
185
  """v1.1 display object. v1 sent a boolean; the server accepts both."""
186
186
  rfb: NotRequired[bool]
187
+ desktop: NotRequired[bool]
187
188
 
188
189
  class EvaluatedPermission(TypedDict):
189
190
  """Permission decision the executor applied to a tool use. ask means the executor opened an interaction.request for it."""
@@ -236,6 +237,10 @@ class ExecutorResume(TypedDict):
236
237
  last_connection_id: NotRequired[str]
237
238
  active_sessions: NotRequired[list[str]]
238
239
 
240
+ class ExecutorTokenRefreshResult(TypedDict):
241
+ """executor.token.refresh result: the connection now closes with 4001 at expires_at."""
242
+ expires_at: str
243
+
239
244
  class ExecutorTokenRequest(TypedDict):
240
245
  """POST /v1/executors/token body. The bearer is an environment key, or a machine daemon token. An empty environment_id with a machine token requests a machine-scoped executor: its executor_id is exec_<machine_id>, stable across refreshes, and the JWT carries machine_id and daemon_token_id."""
241
246
  environment_id: str
@@ -816,6 +821,10 @@ class ExecutorHeartbeatParams(TypedDict):
816
821
  active_sessions: list[str]
817
822
  load: NotRequired[ExecutorLoad]
818
823
 
824
+ class ExecutorTokenRefreshParams(TypedDict):
825
+ "Params object of executor.token.refresh."
826
+ token: str
827
+
819
828
  class ExecutorCapabilitiesUpdateParams(TypedDict):
820
829
  "Params object of executor.capabilities.update."
821
830
  capabilities: Capabilities
@@ -1082,6 +1091,7 @@ class MethodSpec:
1082
1091
 
1083
1092
  METHOD_EXECUTOR_REGISTER = "executor.register"
1084
1093
  METHOD_EXECUTOR_HEARTBEAT = "executor.heartbeat"
1094
+ METHOD_EXECUTOR_TOKEN_REFRESH = "executor.token.refresh"
1085
1095
  METHOD_EXECUTOR_CAPABILITIES_UPDATE = "executor.capabilities.update"
1086
1096
  METHOD_EXECUTOR_DRAIN = "executor.drain"
1087
1097
  METHOD_EXECUTOR_CLOSE = "executor.close"
@@ -1119,6 +1129,7 @@ METHOD_INTERACTION_CANCEL = "interaction.cancel"
1119
1129
  METHODS: dict[str, MethodSpec] = {
1120
1130
  "executor.register": MethodSpec(name="executor.register", direction="executor-to-harness", kind="request", since="bma.executor.v1", params_type="ExecutorRegisterParams", result_type="ExecutorRegisterResult", capability=None, idempotent_by=None),
1121
1131
  "executor.heartbeat": MethodSpec(name="executor.heartbeat", direction="executor-to-harness", kind="request", since="bma.executor.v1", params_type="ExecutorHeartbeatParams", result_type="ExecutorHeartbeatResult", capability=None, idempotent_by=None),
1132
+ "executor.token.refresh": MethodSpec(name="executor.token.refresh", direction="executor-to-harness", kind="request", since="bma.executor.v1.1", params_type="ExecutorTokenRefreshParams", result_type="ExecutorTokenRefreshResult", capability=None, idempotent_by=None),
1122
1133
  "executor.capabilities.update": MethodSpec(name="executor.capabilities.update", direction="executor-to-harness", kind="notification", since="bma.executor.v1", params_type="ExecutorCapabilitiesUpdateParams", result_type=None, capability=None, idempotent_by=None),
1123
1134
  "executor.drain": MethodSpec(name="executor.drain", direction="harness-to-executor", kind="request", since="bma.executor.v1", params_type="ExecutorDrainParams", result_type="ExecutorDrainResult", capability=None, idempotent_by=None),
1124
1135
  "executor.close": MethodSpec(name="executor.close", direction="bidirectional", kind="notification", since="bma.executor.v1", params_type="ExecutorCloseParams", result_type=None, capability=None, idempotent_by=None),
@@ -0,0 +1,88 @@
1
+ """executor.token.refresh: one control connection across token lifetimes,
2
+ and the 4001 reconnect on a harness without the method."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ from websockets.asyncio.server import ServerConnection, serve
12
+
13
+ import bma_executor_sdk as bx
14
+
15
+ EXECUTOR = {"name": "py-test", "os": "linux", "arch": "arm64", "version": "0.1.0"}
16
+
17
+
18
+ async def fake_harness(answer_refresh: Callable[[dict[str, Any]], dict[str, Any]]):
19
+ refreshes: list[str] = []
20
+ dials: list[int] = []
21
+
22
+ async def handler(ws: ServerConnection) -> None:
23
+ dials.append(1)
24
+ async for raw in ws:
25
+ frame = json.loads(raw)
26
+ if "id" not in frame or "method" not in frame:
27
+ continue
28
+ if frame["method"] == "executor.register":
29
+ reply: dict[str, Any] = {
30
+ "result": {
31
+ "protocol": "bma.executor.v1.1", "executor_id": "exec_test", "connection_id": "conn_test",
32
+ "server_time": "2026-11-01T00:00:00Z", "heartbeat_interval_ms": 60000, "lease_ttl_ms": 60000,
33
+ "max_control_frame_bytes": 65536, "max_mcp_frame_bytes": 1 << 20,
34
+ }
35
+ }
36
+ elif frame["method"] == "executor.token.refresh":
37
+ refreshes.append(frame["params"]["token"])
38
+ reply = answer_refresh(frame)
39
+ else:
40
+ reply = {"result": {"accepted": True}}
41
+ await ws.send(json.dumps({"jsonrpc": "2.0", "id": frame["id"], **reply}))
42
+
43
+ server = await serve(handler, "127.0.0.1", 0, subprotocols=["bma.executor.v1.1"])
44
+ port = server.sockets[0].getsockname()[1]
45
+ return server, f"ws://127.0.0.1:{port}", refreshes, dials
46
+
47
+
48
+ def client(url: str) -> bx.ExecutorClient:
49
+ minted = 0
50
+
51
+ async def source() -> Any:
52
+ nonlocal minted
53
+ minted += 1
54
+ return {"executor_id": "exec_test", "token": f"tok-{minted}", "expires_in": 1, "ws_url": url, "channel_scope": []}
55
+
56
+ return bx.ExecutorClient(environment_id="", executor=EXECUTOR, token_source=source) # type: ignore[arg-type]
57
+
58
+
59
+ async def test_refreshes_inside_the_connection() -> None:
60
+ server, url, refreshes, dials = await fake_harness(lambda _: {"result": {"expires_at": "2026-11-01T00:00:01Z"}})
61
+ executor = client(url)
62
+ try:
63
+ await executor.start()
64
+ for _ in range(400):
65
+ if len(refreshes) >= 3:
66
+ break
67
+ await asyncio.sleep(0.01)
68
+ assert refreshes[:3] == ["tok-2", "tok-3", "tok-4"]
69
+ assert len(dials) == 1
70
+ finally:
71
+ await executor.stop()
72
+ server.close()
73
+ await server.wait_closed()
74
+
75
+
76
+ async def test_stops_refreshing_on_method_not_found() -> None:
77
+ server, url, refreshes, _ = await fake_harness(
78
+ lambda _: {"error": {"code": -32601, "message": "unknown method executor.token.refresh"}}
79
+ )
80
+ executor = client(url)
81
+ try:
82
+ await executor.start()
83
+ await asyncio.sleep(2.5)
84
+ assert refreshes == ["tok-2"]
85
+ finally:
86
+ await executor.stop()
87
+ server.close()
88
+ await server.wait_closed()