bma-executor-sdk 0.4.0__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.
@@ -0,0 +1,77 @@
1
+ """BMA executor protocol (bma.executor.v1.1): executor, harness and agent ends.
2
+
3
+ Install with ``pip install bma-executor-sdk``.
4
+ Protocol types are generated from the BMA contract into
5
+ :mod:`bma_executor_sdk.generated` (TypedDicts: plain JSON dicts on the wire).
6
+ """
7
+
8
+ from . import generated
9
+ from ._rpc import ClientStoppedError, ExecutorError, NotConnectedError, RPCError
10
+ from .agent import AgentEventRecord, AgentHandler, AgentPlatform, AgentSessionClosedError, AgentTurn
11
+ from .client import (
12
+ DEFAULT_BASE_URL,
13
+ PROTOCOL_V1,
14
+ PROTOCOL_V11,
15
+ ExecutorClient,
16
+ ExecutorTokenError,
17
+ ExecutorTokenScopeError,
18
+ LogEvent,
19
+ MCPChannel,
20
+ static_token,
21
+ )
22
+ from .harness import (
23
+ AssignRejectedError,
24
+ Executor,
25
+ Harness,
26
+ HarnessMCPChannel,
27
+ Principal,
28
+ Session,
29
+ SessionEndedError,
30
+ ToolOutcomeUnknownError,
31
+ Unauthorized,
32
+ )
33
+ from .transport import (
34
+ TERMINAL_CLOSE_CODES,
35
+ ChannelClosed,
36
+ ConnectRequest,
37
+ Connection,
38
+ Dialer,
39
+ dial,
40
+ )
41
+
42
+ __all__ = [
43
+ "DEFAULT_BASE_URL",
44
+ "PROTOCOL_V1",
45
+ "PROTOCOL_V11",
46
+ "TERMINAL_CLOSE_CODES",
47
+ "AgentEventRecord",
48
+ "AgentHandler",
49
+ "AgentPlatform",
50
+ "AgentSessionClosedError",
51
+ "AgentTurn",
52
+ "AssignRejectedError",
53
+ "ChannelClosed",
54
+ "ClientStoppedError",
55
+ "ConnectRequest",
56
+ "Connection",
57
+ "Dialer",
58
+ "Executor",
59
+ "ExecutorClient",
60
+ "ExecutorError",
61
+ "ExecutorTokenError",
62
+ "ExecutorTokenScopeError",
63
+ "Harness",
64
+ "HarnessMCPChannel",
65
+ "LogEvent",
66
+ "MCPChannel",
67
+ "NotConnectedError",
68
+ "Principal",
69
+ "RPCError",
70
+ "Session",
71
+ "SessionEndedError",
72
+ "ToolOutcomeUnknownError",
73
+ "Unauthorized",
74
+ "dial",
75
+ "generated",
76
+ "static_token",
77
+ ]
@@ -0,0 +1,192 @@
1
+ """JSON-RPC 2.0 over a Connection, shared by the executor and harness ends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import Any
9
+
10
+ from .generated import JsonRpcErrorCode
11
+ from .transport import ChannelClosed, Connection
12
+
13
+
14
+ class ExecutorError(Exception):
15
+ """Base class of bma_executor_sdk errors."""
16
+
17
+
18
+ class NotConnectedError(ExecutorError):
19
+ """The channel is not connected (yet, or any more)."""
20
+
21
+ def __init__(self, message: str = "executor channel is not connected") -> None:
22
+ super().__init__(message)
23
+
24
+
25
+ class ClientStoppedError(ExecutorError):
26
+ """The client was stopped, or closed with a terminal close code."""
27
+
28
+ def __init__(self, message: str = "client stopped") -> None:
29
+ super().__init__(message)
30
+
31
+
32
+ class RPCError(ExecutorError):
33
+ """A JSON-RPC error. A handler that raises it keeps its code on the wire."""
34
+
35
+ def __init__(self, code: int, message: str, data: dict[str, Any] | None = None) -> None:
36
+ super().__init__(f"json-rpc error {code}: {message}")
37
+ self.code = code
38
+ self.message = message
39
+ self.data = data
40
+
41
+ @property
42
+ def retryable(self) -> bool | None:
43
+ return None if self.data is None else self.data.get("retryable")
44
+
45
+ def to_wire(self) -> dict[str, Any]:
46
+ error: dict[str, Any] = {"code": int(self.code), "message": self.message}
47
+ if self.data is not None:
48
+ error["data"] = self.data
49
+ return error
50
+
51
+
52
+ def rpc_error(code: JsonRpcErrorCode, message: str, retryable: bool) -> RPCError:
53
+ return RPCError(code, message, {"retryable": retryable})
54
+
55
+
56
+ RequestHandler = Callable[[str, str, Any], Awaitable[Any]]
57
+ NotificationHandler = Callable[[str, Any], None]
58
+
59
+
60
+ class Peer:
61
+ """One JSON-RPC endpoint on a Connection.
62
+
63
+ Inbound requests run as tasks (except methods in ``inline``, which are
64
+ handled in read order); notifications are handled in read order.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ conn: Connection,
70
+ *,
71
+ id_prefix: str,
72
+ on_request: RequestHandler,
73
+ on_notification: NotificationHandler,
74
+ on_log: Callable[[str, str, BaseException | None], None],
75
+ inline: frozenset[str] = frozenset(),
76
+ ) -> None:
77
+ self.conn = conn
78
+ self._id_prefix = id_prefix
79
+ self._on_request = on_request
80
+ self._on_notification = on_notification
81
+ self._log = on_log
82
+ self._inline = inline
83
+ self._pending: dict[str, asyncio.Future[Any]] = {}
84
+ self._next_id = 0
85
+ self._tasks: set[asyncio.Task[Any]] = set()
86
+ self.closed: ChannelClosed | None = None
87
+
88
+ async def call(self, method: str, params: Any, timeout: float) -> Any:
89
+ if self.closed is not None:
90
+ raise NotConnectedError()
91
+ self._next_id += 1
92
+ request_id = f"{self._id_prefix}_{self._next_id}"
93
+ future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
94
+ self._pending[request_id] = future
95
+ try:
96
+ await self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
97
+ return await asyncio.wait_for(future, timeout)
98
+ except asyncio.TimeoutError as error:
99
+ raise TimeoutError(f"{method}: no response within {timeout}s") from error
100
+ finally:
101
+ self._pending.pop(request_id, None)
102
+
103
+ async def notify(self, method: str, params: Any) -> None:
104
+ if self.closed is not None:
105
+ raise NotConnectedError()
106
+ await self._send({"jsonrpc": "2.0", "method": method, "params": params})
107
+
108
+ async def _send(self, message: dict[str, Any]) -> None:
109
+ try:
110
+ await self.conn.send(json.dumps(message, separators=(",", ":")))
111
+ except ChannelClosed as error:
112
+ raise NotConnectedError(f"write {message.get('method', 'response')}: {error}") from error
113
+
114
+ async def run(self) -> ChannelClosed:
115
+ """Read until the channel closes; then fail pending calls."""
116
+ try:
117
+ while True:
118
+ raw = await self.conn.recv()
119
+ try:
120
+ message = json.loads(raw)
121
+ except ValueError as error:
122
+ self._log("warn", "invalid JSON-RPC frame", error)
123
+ continue
124
+ if isinstance(message, dict) and message.get("method") in self._inline and message.get("id") is not None:
125
+ await self._handle_request(str(message["id"]), message["method"], message.get("params"))
126
+ continue
127
+ self._dispatch(message)
128
+ except ChannelClosed as closed:
129
+ self.closed = closed
130
+ except asyncio.CancelledError:
131
+ self.closed = ChannelClosed(1006, "reader canceled")
132
+ raise
133
+ finally:
134
+ if self.closed is None:
135
+ self.closed = ChannelClosed(1006, "reader failed")
136
+ self.fail_pending(NotConnectedError())
137
+ return self.closed
138
+
139
+ def fail_pending(self, error: Exception) -> None:
140
+ pending, self._pending = self._pending, {}
141
+ for future in pending.values():
142
+ if not future.done():
143
+ future.set_exception(error)
144
+
145
+ def _dispatch(self, message: Any) -> None:
146
+ if not isinstance(message, dict):
147
+ self._log("warn", "JSON-RPC frame is not an object", None)
148
+ return
149
+ method, request_id = message.get("method"), message.get("id")
150
+ if method and request_id is not None:
151
+ self._spawn(self._handle_request(str(request_id), method, message.get("params")))
152
+ elif method:
153
+ try:
154
+ self._on_notification(method, message.get("params"))
155
+ except Exception as error: # a handler bug must not stop the channel
156
+ self._log("error", f"{method} notification handler failed", error)
157
+ elif request_id is not None:
158
+ future = self._pending.pop(str(request_id), None)
159
+ if future is None or future.done():
160
+ return
161
+ error = message.get("error")
162
+ if error is not None:
163
+ future.set_exception(RPCError(error.get("code", 0), error.get("message", ""), error.get("data")))
164
+ else:
165
+ future.set_result(message.get("result"))
166
+ else:
167
+ self._log("warn", "JSON-RPC frame is neither request nor response", None)
168
+
169
+ def _spawn(self, coro: Awaitable[None]) -> None:
170
+ task = asyncio.ensure_future(coro)
171
+ self._tasks.add(task)
172
+ task.add_done_callback(self._tasks.discard)
173
+
174
+ async def _handle_request(self, request_id: str, method: str, params: Any) -> None:
175
+ try:
176
+ result = await self._on_request(request_id, method, params)
177
+ response: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "result": result}
178
+ except RPCError as error:
179
+ response = {"jsonrpc": "2.0", "id": request_id, "error": error.to_wire()}
180
+ except asyncio.CancelledError:
181
+ raise
182
+ except Exception as error:
183
+ self._log("warn", f"{method} handler failed", error)
184
+ response = {"jsonrpc": "2.0", "id": request_id, "error": {"code": int(JsonRpcErrorCode.InternalError), "message": str(error)}}
185
+ try:
186
+ await self._send(response)
187
+ except NotConnectedError as error:
188
+ self._log("debug", f"write {method} response", error)
189
+
190
+ def cancel_tasks(self) -> None:
191
+ for task in list(self._tasks):
192
+ task.cancel()