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.
- bma_executor_sdk/__init__.py +77 -0
- bma_executor_sdk/_rpc.py +192 -0
- bma_executor_sdk/agent.py +558 -0
- bma_executor_sdk/client.py +675 -0
- bma_executor_sdk/generated.py +1155 -0
- bma_executor_sdk/harness.py +769 -0
- bma_executor_sdk/transport.py +189 -0
- bma_executor_sdk-0.4.0.dist-info/METADATA +28 -0
- bma_executor_sdk-0.4.0.dist-info/RECORD +10 -0
- bma_executor_sdk-0.4.0.dist-info/WHEEL +4 -0
|
@@ -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
|
+
]
|
bma_executor_sdk/_rpc.py
ADDED
|
@@ -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()
|