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.
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/PKG-INFO +3 -3
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/README.md +2 -2
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/pyproject.toml +1 -1
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/client.py +45 -1
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/generated.py +11 -0
- bma_executor_sdk-0.4.5/tests/test_executor_token_refresh.py +88 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/.gitignore +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/examples/agent_minimal.py +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/examples/executor_minimal.py +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/examples/harness_loopback.py +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/__init__.py +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/_rpc.py +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/agent.py +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/harness.py +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/src/bma_executor_sdk/transport.py +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/tests/executor_helpers.py +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/tests/test_executor_conformance.py +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/tests/test_executor_contract.py +0 -0
- {bma_executor_sdk-0.4.4 → bma_executor_sdk-0.4.5}/tests/test_executor_interop.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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),
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|