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,675 @@
|
|
|
1
|
+
"""The executor ("hand") end of the executor protocol: ExecutorClient.
|
|
2
|
+
|
|
3
|
+
Mirrors go/executor/client.go: token bootstrap, outbound channel, JSON-RPC,
|
|
4
|
+
registration with resume, heartbeat, reconnect with backoff, dispatch, session
|
|
5
|
+
lifecycle, tool cancellation, retried tool results, and the MCP channel.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import random as _random
|
|
12
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ._rpc import ClientStoppedError, NotConnectedError, Peer, RPCError
|
|
19
|
+
from .generated import (
|
|
20
|
+
METHOD_EXECUTOR_CLOSE,
|
|
21
|
+
METHOD_EXECUTOR_DRAIN,
|
|
22
|
+
METHOD_EXECUTOR_HEARTBEAT,
|
|
23
|
+
METHOD_EXECUTOR_REGISTER,
|
|
24
|
+
METHOD_SESSION_ASSIGN,
|
|
25
|
+
METHOD_SESSION_ENDED,
|
|
26
|
+
METHOD_SESSION_READY,
|
|
27
|
+
METHOD_SESSION_STOP,
|
|
28
|
+
METHOD_TOOL_CALL,
|
|
29
|
+
METHOD_TOOL_CANCEL,
|
|
30
|
+
METHOD_TOOL_RESULT,
|
|
31
|
+
Ack,
|
|
32
|
+
Capabilities,
|
|
33
|
+
ChannelScope,
|
|
34
|
+
ConnectionState,
|
|
35
|
+
ExecutorDrainParams,
|
|
36
|
+
ExecutorDrainResult,
|
|
37
|
+
ExecutorInfo,
|
|
38
|
+
ExecutorLoad,
|
|
39
|
+
ExecutorRegisterResult,
|
|
40
|
+
ExecutorTokenResponse,
|
|
41
|
+
JsonRpcErrorCode,
|
|
42
|
+
ProtocolVersion,
|
|
43
|
+
SessionAssignParams,
|
|
44
|
+
SessionAssignResult,
|
|
45
|
+
SessionEndedParams,
|
|
46
|
+
SessionReadyParams,
|
|
47
|
+
SessionStopParams,
|
|
48
|
+
SessionStopResult,
|
|
49
|
+
ToolCallParams,
|
|
50
|
+
ToolOutcome,
|
|
51
|
+
ToolResultParams,
|
|
52
|
+
)
|
|
53
|
+
from .transport import CLOSE_NORMAL, MAX_FRAME_BYTES, TERMINAL_CLOSE_CODES, ChannelClosed, Connection, Dialer, channel_url, dial
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from .agent import AgentHandler, _AgentRuntime
|
|
57
|
+
from .generated import AgentCapability
|
|
58
|
+
|
|
59
|
+
DEFAULT_BASE_URL = "https://api.bma.beeos.ai"
|
|
60
|
+
DEFAULT_REQUEST_TIMEOUT = 30.0
|
|
61
|
+
DEFAULT_MAX_RECONNECT_ATTEMPTS = 10
|
|
62
|
+
DEFAULT_RECONNECT_BASE_DELAY = 0.5
|
|
63
|
+
DEFAULT_TOOL_RESULT_RETRY_ATTEMPTS = 5
|
|
64
|
+
DEFAULT_TOOL_RESULT_RETRY_BASE_DELAY = 0.25
|
|
65
|
+
DEFAULT_HEARTBEAT_INTERVAL = 15.0
|
|
66
|
+
PROTOCOL_V1 = ProtocolVersion.BmaExecutorV1.value
|
|
67
|
+
PROTOCOL_V11 = ProtocolVersion.BmaExecutorV11.value
|
|
68
|
+
_MAX_TRACKED_TOOL_RESULTS = 8192
|
|
69
|
+
_MAX_TOOL_RESULT_RETRY_DELAY = 10.0
|
|
70
|
+
_MAX_RECONNECT_DELAY = 30.0
|
|
71
|
+
|
|
72
|
+
TokenSource = Callable[[], Awaitable[ExecutorTokenResponse]]
|
|
73
|
+
ToolCallHandler = Callable[[ToolCallParams], Awaitable[ToolResultParams]]
|
|
74
|
+
SessionAssignHandler = Callable[[SessionAssignParams], Awaitable[SessionAssignResult]]
|
|
75
|
+
SessionStopHandler = Callable[[SessionStopParams], Awaitable[SessionStopResult]]
|
|
76
|
+
DrainHandler = Callable[[ExecutorDrainParams], Awaitable[ExecutorDrainResult]]
|
|
77
|
+
RawRequestHandler = Callable[[Any], Awaitable[Any]]
|
|
78
|
+
RawNotificationHandler = Callable[[Any], None]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class LogEvent:
|
|
83
|
+
level: str # debug | info | warn | error
|
|
84
|
+
message: str
|
|
85
|
+
error: BaseException | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def static_token(ws_url: str, token: str = "") -> TokenSource:
|
|
89
|
+
"""TokenSource for a harness reached directly (a local harness on
|
|
90
|
+
``ws://127.0.0.1``, ``unix:///path.sock`` or in-process). token may be empty
|
|
91
|
+
when the harness has no authenticate hook."""
|
|
92
|
+
|
|
93
|
+
async def source() -> ExecutorTokenResponse:
|
|
94
|
+
return {"executor_id": "", "token": token, "expires_in": 0, "ws_url": ws_url, "channel_scope": []}
|
|
95
|
+
|
|
96
|
+
return source
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _RunningToolCall:
|
|
100
|
+
__slots__ = ("task", "handler", "canceled", "reason")
|
|
101
|
+
|
|
102
|
+
def __init__(self) -> None:
|
|
103
|
+
self.task: asyncio.Task[Any] | None = None
|
|
104
|
+
self.handler: asyncio.Task[Any] | None = None
|
|
105
|
+
self.canceled = False
|
|
106
|
+
self.reason = ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ExecutorClient:
|
|
110
|
+
"""Outbound executor client (Go: ``Client``)."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
environment_id: str,
|
|
116
|
+
executor: ExecutorInfo,
|
|
117
|
+
capabilities: Capabilities | None = None,
|
|
118
|
+
environment_key: str = "",
|
|
119
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
120
|
+
ws_url: str = "",
|
|
121
|
+
token_source: TokenSource | None = None,
|
|
122
|
+
dialer: Dialer | None = None,
|
|
123
|
+
http_client: httpx.AsyncClient | None = None,
|
|
124
|
+
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
125
|
+
heartbeat_interval: float = DEFAULT_HEARTBEAT_INTERVAL,
|
|
126
|
+
heartbeat_load: Callable[[], ExecutorLoad | None] | None = None,
|
|
127
|
+
max_reconnect_attempts: int = DEFAULT_MAX_RECONNECT_ATTEMPTS,
|
|
128
|
+
reconnect_base_delay: float = DEFAULT_RECONNECT_BASE_DELAY,
|
|
129
|
+
random: Callable[[], float] = _random.random,
|
|
130
|
+
tool_result_retry_attempts: int = DEFAULT_TOOL_RESULT_RETRY_ATTEMPTS,
|
|
131
|
+
tool_result_retry_base_delay: float = DEFAULT_TOOL_RESULT_RETRY_BASE_DELAY,
|
|
132
|
+
on_tool_result_lost: Callable[[ToolResultParams, BaseException], None] | None = None,
|
|
133
|
+
on_log: Callable[[LogEvent], None] | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
# environment_id may be empty: a machine-scoped executor registers
|
|
136
|
+
# without one (executor.register in the v1.1 contract).
|
|
137
|
+
if token_source is None and not environment_key.strip():
|
|
138
|
+
raise ValueError("bma_executor_sdk: environment_key is required without token_source")
|
|
139
|
+
if not executor.get("name"):
|
|
140
|
+
raise ValueError("bma_executor_sdk: executor['name'] is required")
|
|
141
|
+
self._environment_id = environment_id
|
|
142
|
+
self._executor = executor
|
|
143
|
+
self._capabilities: Capabilities = capabilities or {}
|
|
144
|
+
self._environment_key = environment_key
|
|
145
|
+
self._base_url = base_url.rstrip("/")
|
|
146
|
+
self._ws_url = ws_url
|
|
147
|
+
self._token_source = token_source
|
|
148
|
+
self._dialer: Dialer = dialer or dial
|
|
149
|
+
self._http_client = http_client
|
|
150
|
+
self._request_timeout = request_timeout
|
|
151
|
+
self._default_heartbeat = heartbeat_interval
|
|
152
|
+
self._heartbeat_load = heartbeat_load
|
|
153
|
+
self._max_reconnect_attempts = max_reconnect_attempts
|
|
154
|
+
self._reconnect_base_delay = reconnect_base_delay
|
|
155
|
+
self._random = random
|
|
156
|
+
self._tool_result_retry_attempts = max(1, tool_result_retry_attempts)
|
|
157
|
+
self._tool_result_retry_base_delay = tool_result_retry_base_delay
|
|
158
|
+
self._on_tool_result_lost = on_tool_result_lost
|
|
159
|
+
self._on_log = on_log
|
|
160
|
+
|
|
161
|
+
self._peer: Peer | None = None
|
|
162
|
+
self._state = ConnectionState.Disconnected
|
|
163
|
+
self._stopped = False
|
|
164
|
+
self._register_result: ExecutorRegisterResult | None = None
|
|
165
|
+
self._connection_id = ""
|
|
166
|
+
self._executor_id = ""
|
|
167
|
+
self._heartbeat_interval = heartbeat_interval
|
|
168
|
+
self._sessions: set[str] = set()
|
|
169
|
+
self._reconnect_attempts = 0
|
|
170
|
+
self._completed_tool_results: dict[str, None] = {}
|
|
171
|
+
self._seen_tool_calls: dict[str, None] = {}
|
|
172
|
+
self._running_tool_calls: dict[str, _RunningToolCall] = {}
|
|
173
|
+
self._tool_call_handler: ToolCallHandler | None = None
|
|
174
|
+
self._session_assign_handler: SessionAssignHandler | None = None
|
|
175
|
+
self._session_stop_handler: SessionStopHandler | None = None
|
|
176
|
+
self._drain_handler: DrainHandler | None = None
|
|
177
|
+
self._request_handlers: dict[str, RawRequestHandler] = {}
|
|
178
|
+
self._notification_handlers: dict[str, RawNotificationHandler] = {}
|
|
179
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
180
|
+
# Set after every successful executor.register, then replaced, so agent
|
|
181
|
+
# outboxes resend after a reconnect.
|
|
182
|
+
self._registered = asyncio.Event()
|
|
183
|
+
self._agent: _AgentRuntime | None = None
|
|
184
|
+
|
|
185
|
+
# --- lifecycle ---
|
|
186
|
+
|
|
187
|
+
async def start(self) -> ExecutorRegisterResult:
|
|
188
|
+
"""Fetch a token, connect, register, and start heartbeats."""
|
|
189
|
+
if self._stopped:
|
|
190
|
+
raise ClientStoppedError()
|
|
191
|
+
if self._peer is not None:
|
|
192
|
+
raise RuntimeError("bma_executor_sdk: client already started")
|
|
193
|
+
self._state = ConnectionState.Connecting
|
|
194
|
+
try:
|
|
195
|
+
token = await self._fetch_token()
|
|
196
|
+
await self._connect(token)
|
|
197
|
+
except BaseException:
|
|
198
|
+
if not self._stopped:
|
|
199
|
+
self._state = ConnectionState.Disconnected
|
|
200
|
+
raise
|
|
201
|
+
self._spawn(self._heartbeat_loop())
|
|
202
|
+
assert self._register_result is not None
|
|
203
|
+
return self._register_result
|
|
204
|
+
|
|
205
|
+
async def stop(self, reason: str = "") -> None:
|
|
206
|
+
"""Send executor.close, close the channel and fail pending calls."""
|
|
207
|
+
if self._stopped:
|
|
208
|
+
return
|
|
209
|
+
self._stopped = True
|
|
210
|
+
peer, self._peer = self._peer, None
|
|
211
|
+
self._state = ConnectionState.Closed
|
|
212
|
+
if peer is not None:
|
|
213
|
+
try:
|
|
214
|
+
await asyncio.wait_for(peer.notify(METHOD_EXECUTOR_CLOSE, {"reason": reason}), 2)
|
|
215
|
+
except Exception as error: # best effort: the close below still ends the channel
|
|
216
|
+
self._log("debug", "executor.close not sent", error)
|
|
217
|
+
await peer.conn.close(CLOSE_NORMAL, reason)
|
|
218
|
+
peer.fail_pending(ClientStoppedError())
|
|
219
|
+
peer.cancel_tasks()
|
|
220
|
+
for running in self._running_tool_calls.values():
|
|
221
|
+
if running.task is not None:
|
|
222
|
+
running.task.cancel()
|
|
223
|
+
for task in list(self._tasks):
|
|
224
|
+
task.cancel()
|
|
225
|
+
if self._tasks:
|
|
226
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def state(self) -> ConnectionState:
|
|
230
|
+
return self._state
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def executor_id(self) -> str:
|
|
234
|
+
return self._executor_id
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def connection_id(self) -> str:
|
|
238
|
+
return self._connection_id
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def active_sessions(self) -> list[str]:
|
|
242
|
+
return list(self._sessions)
|
|
243
|
+
|
|
244
|
+
# --- handlers ---
|
|
245
|
+
|
|
246
|
+
def on_tool_call(self, handler: ToolCallHandler) -> None:
|
|
247
|
+
"""Register the tool.call handler. tool.cancel cancels the handler's
|
|
248
|
+
task; the client then reports outcome=canceled."""
|
|
249
|
+
self._tool_call_handler = handler
|
|
250
|
+
|
|
251
|
+
def on_session_assign(self, handler: SessionAssignHandler) -> None:
|
|
252
|
+
self._session_assign_handler = handler
|
|
253
|
+
|
|
254
|
+
def on_session_stop(self, handler: SessionStopHandler) -> None:
|
|
255
|
+
self._session_stop_handler = handler
|
|
256
|
+
|
|
257
|
+
def on_drain(self, handler: DrainHandler) -> None:
|
|
258
|
+
self._drain_handler = handler
|
|
259
|
+
|
|
260
|
+
def on_request(self, method: str, handler: RawRequestHandler) -> None:
|
|
261
|
+
"""Register a handler for another harness-to-executor request method."""
|
|
262
|
+
self._request_handlers[method] = handler
|
|
263
|
+
|
|
264
|
+
def on_notification(self, method: str, handler: RawNotificationHandler) -> None:
|
|
265
|
+
self._notification_handlers[method] = handler
|
|
266
|
+
|
|
267
|
+
def serve_agent(self, handler: AgentHandler, capability: AgentCapability | None = None) -> None:
|
|
268
|
+
"""Advertise the agent capability and route the agent methods to
|
|
269
|
+
handler. Call it before start()."""
|
|
270
|
+
from .agent import _AgentRuntime
|
|
271
|
+
|
|
272
|
+
self._agent = _AgentRuntime(self, handler, dict(capability or {}, enabled=True)) # type: ignore[arg-type]
|
|
273
|
+
|
|
274
|
+
# --- RPC ---
|
|
275
|
+
|
|
276
|
+
async def call(self, method: str, params: Any) -> Any:
|
|
277
|
+
"""Send a JSON-RPC request and return its result."""
|
|
278
|
+
peer = self._peer
|
|
279
|
+
if peer is None:
|
|
280
|
+
raise NotConnectedError()
|
|
281
|
+
return await peer.call(method, params, self._request_timeout)
|
|
282
|
+
|
|
283
|
+
async def notify(self, method: str, params: Any) -> None:
|
|
284
|
+
peer = self._peer
|
|
285
|
+
if peer is None:
|
|
286
|
+
raise NotConnectedError()
|
|
287
|
+
await peer.notify(method, params)
|
|
288
|
+
|
|
289
|
+
async def report_tool_result(self, params: ToolResultParams) -> Ack:
|
|
290
|
+
"""Send tool.result once per tool_call_id, retrying retryable failures
|
|
291
|
+
with exponential backoff. A duplicate for an accepted tool_call_id
|
|
292
|
+
returns accepted without sending another frame."""
|
|
293
|
+
call_id = params["tool_call_id"]
|
|
294
|
+
if call_id in self._completed_tool_results:
|
|
295
|
+
return {"accepted": True}
|
|
296
|
+
_remember(self._completed_tool_results, call_id)
|
|
297
|
+
last_error: BaseException | None = None
|
|
298
|
+
for attempt in range(1, self._tool_result_retry_attempts + 1):
|
|
299
|
+
try:
|
|
300
|
+
return await self.call(METHOD_TOOL_RESULT, params)
|
|
301
|
+
except (RPCError, NotConnectedError, TimeoutError) as error:
|
|
302
|
+
last_error = error
|
|
303
|
+
if attempt == self._tool_result_retry_attempts or not _retryable_tool_result_error(error):
|
|
304
|
+
break
|
|
305
|
+
self._log("warn", f"tool.result for {call_id} failed on attempt {attempt}, retrying", error)
|
|
306
|
+
if self._stopped:
|
|
307
|
+
last_error = ClientStoppedError()
|
|
308
|
+
break
|
|
309
|
+
await asyncio.sleep(self._tool_result_retry_delay(attempt))
|
|
310
|
+
self._completed_tool_results.pop(call_id, None)
|
|
311
|
+
assert last_error is not None
|
|
312
|
+
raise last_error
|
|
313
|
+
|
|
314
|
+
async def session_ready(self, params: SessionReadyParams) -> Ack:
|
|
315
|
+
"""Report that the session is prepared (after workspace and MCP setup)."""
|
|
316
|
+
return await self.call(METHOD_SESSION_READY, params)
|
|
317
|
+
|
|
318
|
+
async def session_ended(self, params: SessionEndedParams) -> Ack:
|
|
319
|
+
"""Report that every process and MCP server of the session stopped."""
|
|
320
|
+
result: Ack = await self.call(METHOD_SESSION_ENDED, params)
|
|
321
|
+
self._sessions.discard(params["session_id"])
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
async def open_mcp_channel(self, session_id: str, server_key: str) -> MCPChannel:
|
|
325
|
+
"""Open the MCP channel for one session.assign mcp_servers entry with a
|
|
326
|
+
fresh executor token; frames are raw MCP JSON-RPC."""
|
|
327
|
+
if not session_id.strip() or not server_key.strip():
|
|
328
|
+
raise ValueError("bma_executor_sdk: session_id and server_key are required")
|
|
329
|
+
if self._stopped:
|
|
330
|
+
raise ClientStoppedError()
|
|
331
|
+
token = await self._fetch_token()
|
|
332
|
+
scopes = token.get("channel_scope") or []
|
|
333
|
+
if scopes and ChannelScope.Mcp.value not in scopes:
|
|
334
|
+
raise ExecutorTokenScopeError("executor token does not allow the mcp channel")
|
|
335
|
+
url = channel_url(self._ws_url or token["ws_url"], channel="mcp", session_id=session_id, server_key=server_key)
|
|
336
|
+
conn = await self._dialer(url, token["token"], [PROTOCOL_V11])
|
|
337
|
+
return MCPChannel(conn)
|
|
338
|
+
|
|
339
|
+
# --- internals ---
|
|
340
|
+
|
|
341
|
+
def _spawn(self, coro: Awaitable[Any]) -> asyncio.Task[Any]:
|
|
342
|
+
task = asyncio.ensure_future(coro)
|
|
343
|
+
self._tasks.add(task)
|
|
344
|
+
task.add_done_callback(self._tasks.discard)
|
|
345
|
+
return task
|
|
346
|
+
|
|
347
|
+
def _log(self, level: str, message: str, error: BaseException | None = None) -> None:
|
|
348
|
+
if self._on_log is not None:
|
|
349
|
+
self._on_log(LogEvent(level, message, error))
|
|
350
|
+
|
|
351
|
+
async def _fetch_token(self) -> ExecutorTokenResponse:
|
|
352
|
+
if self._token_source is not None:
|
|
353
|
+
token = await self._token_source()
|
|
354
|
+
if not token.get("ws_url"):
|
|
355
|
+
raise ValueError("bma_executor_sdk: token source returned no ws_url")
|
|
356
|
+
return token
|
|
357
|
+
client = self._http_client or httpx.AsyncClient()
|
|
358
|
+
try:
|
|
359
|
+
response = await client.post(
|
|
360
|
+
f"{self._base_url}/v1/executors/token",
|
|
361
|
+
json={"environment_id": self._environment_id, "executor": self._executor},
|
|
362
|
+
headers={"Authorization": f"Bearer {self._environment_key}"},
|
|
363
|
+
timeout=self._request_timeout,
|
|
364
|
+
)
|
|
365
|
+
finally:
|
|
366
|
+
if self._http_client is None:
|
|
367
|
+
await client.aclose()
|
|
368
|
+
if response.status_code != 200:
|
|
369
|
+
raise ExecutorTokenError(f"token request failed: {response.status_code}: {response.text[:4096].strip()}")
|
|
370
|
+
token = response.json()
|
|
371
|
+
if not token.get("token") or not token.get("ws_url"):
|
|
372
|
+
raise ExecutorTokenError("token response is missing token or ws_url")
|
|
373
|
+
return token
|
|
374
|
+
|
|
375
|
+
async def _connect(self, token: ExecutorTokenResponse) -> None:
|
|
376
|
+
self._state = ConnectionState.Connecting
|
|
377
|
+
conn = await self._dialer(self._ws_url or token["ws_url"], token["token"], [PROTOCOL_V11])
|
|
378
|
+
if self._stopped:
|
|
379
|
+
await conn.close(CLOSE_NORMAL, "stopped")
|
|
380
|
+
raise ClientStoppedError()
|
|
381
|
+
peer = Peer(conn, id_prefix="req", on_request=self._dispatch_request, on_notification=self._dispatch_notification, on_log=self._log)
|
|
382
|
+
self._peer = peer
|
|
383
|
+
self._spawn(self._read(peer))
|
|
384
|
+
capabilities: dict[str, Any] = dict(self._capabilities)
|
|
385
|
+
if self._agent is not None:
|
|
386
|
+
capabilities["agent"] = self._agent.capability
|
|
387
|
+
params: dict[str, Any] = {
|
|
388
|
+
"protocol": PROTOCOL_V11,
|
|
389
|
+
"environment_id": self._environment_id,
|
|
390
|
+
"executor": self._executor,
|
|
391
|
+
"capabilities": capabilities,
|
|
392
|
+
}
|
|
393
|
+
resume = self._resume_state()
|
|
394
|
+
if resume is not None:
|
|
395
|
+
params["resume"] = resume
|
|
396
|
+
try:
|
|
397
|
+
result: ExecutorRegisterResult = await peer.call(METHOD_EXECUTOR_REGISTER, params, self._request_timeout)
|
|
398
|
+
except BaseException:
|
|
399
|
+
if self._peer is peer:
|
|
400
|
+
self._peer = None
|
|
401
|
+
await conn.close(1011, "registration failed")
|
|
402
|
+
raise
|
|
403
|
+
interval_ms = result.get("heartbeat_interval_ms") or 0
|
|
404
|
+
self._heartbeat_interval = interval_ms / 1000 if interval_ms > 0 else self._default_heartbeat
|
|
405
|
+
self._register_result = result
|
|
406
|
+
self._connection_id = result["connection_id"]
|
|
407
|
+
self._executor_id = result["executor_id"]
|
|
408
|
+
self._state = ConnectionState.Ready
|
|
409
|
+
self._reconnect_attempts = 0
|
|
410
|
+
registered, self._registered = self._registered, asyncio.Event()
|
|
411
|
+
registered.set()
|
|
412
|
+
self._log("info", f"executor registered as {result['executor_id']}")
|
|
413
|
+
|
|
414
|
+
def _resume_state(self) -> dict[str, Any] | None:
|
|
415
|
+
if not self._executor_id and not self._connection_id and not self._sessions:
|
|
416
|
+
return None
|
|
417
|
+
resume: dict[str, Any] = {"active_sessions": sorted(self._sessions)}
|
|
418
|
+
if self._executor_id:
|
|
419
|
+
resume["executor_id"] = self._executor_id
|
|
420
|
+
if self._connection_id:
|
|
421
|
+
resume["last_connection_id"] = self._connection_id
|
|
422
|
+
return resume
|
|
423
|
+
|
|
424
|
+
async def _read(self, peer: Peer) -> None:
|
|
425
|
+
closed = await peer.run()
|
|
426
|
+
await self._disconnected(peer, closed)
|
|
427
|
+
|
|
428
|
+
async def _disconnected(self, peer: Peer, closed: ChannelClosed) -> None:
|
|
429
|
+
if self._peer is not peer:
|
|
430
|
+
return
|
|
431
|
+
self._peer = None
|
|
432
|
+
if self._stopped:
|
|
433
|
+
self._state = ConnectionState.Closed
|
|
434
|
+
return
|
|
435
|
+
if closed.code in TERMINAL_CLOSE_CODES:
|
|
436
|
+
self._state = ConnectionState.Closed
|
|
437
|
+
self._log("error", f"executor channel closed with terminal code {closed.code}: {closed.reason}")
|
|
438
|
+
return
|
|
439
|
+
self._state = ConnectionState.Disconnected
|
|
440
|
+
self._spawn(self._reconnect_loop())
|
|
441
|
+
|
|
442
|
+
async def _reconnect_loop(self) -> None:
|
|
443
|
+
while not self._stopped:
|
|
444
|
+
if self._reconnect_attempts >= self._max_reconnect_attempts:
|
|
445
|
+
self._state = ConnectionState.Closed
|
|
446
|
+
self._log("error", "executor reconnect attempts exhausted")
|
|
447
|
+
return
|
|
448
|
+
attempt = self._reconnect_attempts
|
|
449
|
+
self._reconnect_attempts += 1
|
|
450
|
+
delay = min(self._reconnect_base_delay * (1 << attempt), _MAX_RECONNECT_DELAY)
|
|
451
|
+
delay *= 0.5 + self._random() * 0.5
|
|
452
|
+
self._log("info", f"reconnecting executor channel in {delay:.3f}s")
|
|
453
|
+
await asyncio.sleep(delay)
|
|
454
|
+
if self._stopped:
|
|
455
|
+
return
|
|
456
|
+
try:
|
|
457
|
+
await self._connect(await self._fetch_token())
|
|
458
|
+
return
|
|
459
|
+
except asyncio.CancelledError:
|
|
460
|
+
raise
|
|
461
|
+
except Exception as error:
|
|
462
|
+
self._state = ConnectionState.Disconnected
|
|
463
|
+
self._log("warn", "executor reconnect failed", error)
|
|
464
|
+
|
|
465
|
+
async def _heartbeat_loop(self) -> None:
|
|
466
|
+
while True:
|
|
467
|
+
await asyncio.sleep(self._heartbeat_interval)
|
|
468
|
+
if self._state not in (ConnectionState.Ready, ConnectionState.Draining):
|
|
469
|
+
continue
|
|
470
|
+
params: dict[str, Any] = {"connection_id": self._connection_id, "active_sessions": sorted(self._sessions)}
|
|
471
|
+
if self._heartbeat_load is not None:
|
|
472
|
+
load = self._heartbeat_load()
|
|
473
|
+
if load is not None:
|
|
474
|
+
params["load"] = load
|
|
475
|
+
try:
|
|
476
|
+
result = await self.call(METHOD_EXECUTOR_HEARTBEAT, params)
|
|
477
|
+
except (RPCError, NotConnectedError, TimeoutError) as error:
|
|
478
|
+
self._log("warn", "executor heartbeat failed", error)
|
|
479
|
+
continue
|
|
480
|
+
if result.get("drain") and self._state == ConnectionState.Ready:
|
|
481
|
+
self._state = ConnectionState.Draining
|
|
482
|
+
|
|
483
|
+
async def _dispatch_request(self, request_id: str, method: str, params: Any) -> Any:
|
|
484
|
+
if not isinstance(params, dict):
|
|
485
|
+
raise RPCError(JsonRpcErrorCode.InvalidParams, f"invalid {method} params")
|
|
486
|
+
if method == METHOD_TOOL_CALL:
|
|
487
|
+
if not params.get("tool_call_id") or not params.get("session_id"):
|
|
488
|
+
raise RPCError(JsonRpcErrorCode.InvalidParams, "invalid tool.call params")
|
|
489
|
+
if self._begin_tool_call(params): # type: ignore[arg-type]
|
|
490
|
+
return {"accepted": True}
|
|
491
|
+
self._log("debug", f"duplicate tool.call for {params['tool_call_id']} ignored")
|
|
492
|
+
return {"accepted": True}
|
|
493
|
+
if method == METHOD_TOOL_CANCEL:
|
|
494
|
+
return self._cancel_tool_call(params.get("tool_call_id", ""), params.get("reason", ""))
|
|
495
|
+
if method == METHOD_SESSION_ASSIGN:
|
|
496
|
+
return await self._assign_session(params) # type: ignore[arg-type]
|
|
497
|
+
if method == METHOD_SESSION_STOP:
|
|
498
|
+
result: SessionStopResult = {"stopped": True}
|
|
499
|
+
if self._session_stop_handler is not None:
|
|
500
|
+
result = await self._session_stop_handler(params) # type: ignore[arg-type]
|
|
501
|
+
self._sessions.discard(params.get("session_id", ""))
|
|
502
|
+
return result
|
|
503
|
+
if method == METHOD_EXECUTOR_DRAIN:
|
|
504
|
+
self._state = ConnectionState.Draining
|
|
505
|
+
if self._drain_handler is not None:
|
|
506
|
+
return await self._drain_handler(params) # type: ignore[arg-type]
|
|
507
|
+
return {"accepted": True, "active_sessions": sorted(self._sessions)}
|
|
508
|
+
if self._agent is not None and method in self._agent.methods:
|
|
509
|
+
return await self._agent.methods[method](params)
|
|
510
|
+
handler = self._request_handlers.get(method)
|
|
511
|
+
if handler is None:
|
|
512
|
+
raise RPCError(JsonRpcErrorCode.MethodNotFound, f"unknown method {method}")
|
|
513
|
+
return await handler(params)
|
|
514
|
+
|
|
515
|
+
def _dispatch_notification(self, method: str, params: Any) -> None:
|
|
516
|
+
if method == METHOD_EXECUTOR_CLOSE:
|
|
517
|
+
# The harness closed the executor deliberately: no reconnect.
|
|
518
|
+
self._stopped = True
|
|
519
|
+
self._state = ConnectionState.Closed
|
|
520
|
+
peer, self._peer = self._peer, None
|
|
521
|
+
if peer is not None:
|
|
522
|
+
peer.fail_pending(ClientStoppedError())
|
|
523
|
+
self._spawn(peer.conn.close(CLOSE_NORMAL, "executor.close"))
|
|
524
|
+
return
|
|
525
|
+
handler = self._notification_handlers.get(method)
|
|
526
|
+
if handler is not None:
|
|
527
|
+
handler(params)
|
|
528
|
+
|
|
529
|
+
async def _assign_session(self, params: SessionAssignParams) -> SessionAssignResult:
|
|
530
|
+
if self._state == ConnectionState.Draining:
|
|
531
|
+
return {"accepted": False, "session_id": params["session_id"]}
|
|
532
|
+
result: SessionAssignResult = {"accepted": True, "session_id": params["session_id"]}
|
|
533
|
+
if self._session_assign_handler is not None:
|
|
534
|
+
result = await self._session_assign_handler(params)
|
|
535
|
+
if result.get("accepted"):
|
|
536
|
+
self._sessions.add(params["session_id"])
|
|
537
|
+
return result
|
|
538
|
+
|
|
539
|
+
def _begin_tool_call(self, params: ToolCallParams) -> bool:
|
|
540
|
+
call_id = params["tool_call_id"]
|
|
541
|
+
if call_id in self._seen_tool_calls or call_id in self._running_tool_calls:
|
|
542
|
+
return False
|
|
543
|
+
_remember(self._seen_tool_calls, call_id)
|
|
544
|
+
running = _RunningToolCall()
|
|
545
|
+
self._running_tool_calls[call_id] = running
|
|
546
|
+
running.task = self._spawn(self._run_tool_call(params, running))
|
|
547
|
+
return True
|
|
548
|
+
|
|
549
|
+
def _cancel_tool_call(self, call_id: str, reason: str) -> dict[str, bool]:
|
|
550
|
+
running = self._running_tool_calls.get(call_id)
|
|
551
|
+
if running is None or running.task is None:
|
|
552
|
+
return {"canceled": False}
|
|
553
|
+
running.canceled = True
|
|
554
|
+
running.reason = reason
|
|
555
|
+
if running.handler is not None: # otherwise the call sees canceled before it starts
|
|
556
|
+
running.handler.cancel()
|
|
557
|
+
self._log("info", f"canceled tool.call {call_id}")
|
|
558
|
+
return {"canceled": True}
|
|
559
|
+
|
|
560
|
+
async def _run_tool_call(self, params: ToolCallParams, running: _RunningToolCall) -> None:
|
|
561
|
+
call_id = params["tool_call_id"]
|
|
562
|
+
handler = self._tool_call_handler
|
|
563
|
+
result: ToolResultParams
|
|
564
|
+
if running.canceled:
|
|
565
|
+
result = _canceled_tool_result(params, running.reason)
|
|
566
|
+
elif handler is None:
|
|
567
|
+
result = _failed_tool_result(params, "no tool handler registered")
|
|
568
|
+
else:
|
|
569
|
+
inner = running.handler = asyncio.ensure_future(handler(params))
|
|
570
|
+
deadline_ms = params.get("deadline_ms") or 0
|
|
571
|
+
done, _ = await asyncio.wait({inner}, timeout=deadline_ms / 1000 if deadline_ms > 0 else None)
|
|
572
|
+
if not done:
|
|
573
|
+
inner.cancel()
|
|
574
|
+
await asyncio.gather(inner, return_exceptions=True)
|
|
575
|
+
result = _failed_tool_result(params, "tool.call deadline_ms exceeded")
|
|
576
|
+
elif inner.cancelled():
|
|
577
|
+
result = _failed_tool_result(params, "tool handler canceled")
|
|
578
|
+
elif inner.exception() is not None:
|
|
579
|
+
result = _failed_tool_result(params, str(inner.exception()))
|
|
580
|
+
else:
|
|
581
|
+
result = dict(inner.result()) # type: ignore[assignment]
|
|
582
|
+
self._running_tool_calls.pop(call_id, None)
|
|
583
|
+
if running.canceled:
|
|
584
|
+
result = _canceled_tool_result(params, running.reason)
|
|
585
|
+
# The result identifies the call the harness dispatched, whatever the
|
|
586
|
+
# handler put in it.
|
|
587
|
+
result["tool_call_id"] = call_id
|
|
588
|
+
result["session_id"] = params["session_id"]
|
|
589
|
+
try:
|
|
590
|
+
await self.report_tool_result(result)
|
|
591
|
+
except Exception as error:
|
|
592
|
+
self._log("error", f"failed to report tool result for {call_id}", error)
|
|
593
|
+
if self._on_tool_result_lost is not None:
|
|
594
|
+
self._on_tool_result_lost(result, error)
|
|
595
|
+
|
|
596
|
+
def _tool_result_retry_delay(self, attempt: int) -> float:
|
|
597
|
+
delay = min(self._tool_result_retry_base_delay * (2 ** (attempt - 1)), _MAX_TOOL_RESULT_RETRY_DELAY)
|
|
598
|
+
return delay * (0.5 + self._random() * 0.5)
|
|
599
|
+
|
|
600
|
+
async def _wait_registered(self) -> None:
|
|
601
|
+
await self._registered.wait()
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
class ExecutorTokenError(Exception):
|
|
605
|
+
"""POST /v1/executors/token failed."""
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class ExecutorTokenScopeError(Exception):
|
|
609
|
+
"""The executor token does not allow the requested channel."""
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class MCPChannel:
|
|
613
|
+
"""Executor end of one MCP channel: raw MCP JSON-RPC frames. MCP process
|
|
614
|
+
supervision and reconnection stay with the adapter."""
|
|
615
|
+
|
|
616
|
+
def __init__(self, conn: Connection) -> None:
|
|
617
|
+
self._conn = conn
|
|
618
|
+
|
|
619
|
+
async def read(self) -> str:
|
|
620
|
+
return await self._conn.recv()
|
|
621
|
+
|
|
622
|
+
async def write(self, frame: str) -> None:
|
|
623
|
+
await self._conn.send(frame)
|
|
624
|
+
|
|
625
|
+
async def close(self) -> None:
|
|
626
|
+
await self._conn.close(CLOSE_NORMAL, "mcp channel closed")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _remember(tracked: dict[str, None], key: str) -> None:
|
|
630
|
+
if len(tracked) >= _MAX_TRACKED_TOOL_RESULTS:
|
|
631
|
+
tracked.pop(next(iter(tracked)))
|
|
632
|
+
tracked[key] = None
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _retryable_tool_result_error(error: BaseException) -> bool:
|
|
636
|
+
if isinstance(error, RPCError):
|
|
637
|
+
if error.retryable is not None:
|
|
638
|
+
return bool(error.retryable)
|
|
639
|
+
return error.code in (JsonRpcErrorCode.ExecutorBusy, JsonRpcErrorCode.Timeout)
|
|
640
|
+
return isinstance(error, (NotConnectedError, TimeoutError))
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _failed_tool_result(params: ToolCallParams, message: str) -> ToolResultParams:
|
|
644
|
+
return {
|
|
645
|
+
"session_id": params["session_id"],
|
|
646
|
+
"tool_call_id": params["tool_call_id"],
|
|
647
|
+
"outcome": ToolOutcome.Failed.value, # type: ignore[typeddict-item]
|
|
648
|
+
"is_error": True,
|
|
649
|
+
"content": [{"type": "text", "text": message}], # type: ignore[list-item]
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _canceled_tool_result(params: ToolCallParams, reason: str) -> ToolResultParams:
|
|
654
|
+
text = f"canceled: {reason}" if reason else "canceled"
|
|
655
|
+
return {
|
|
656
|
+
"session_id": params["session_id"],
|
|
657
|
+
"tool_call_id": params["tool_call_id"],
|
|
658
|
+
"outcome": ToolOutcome.Canceled.value, # type: ignore[typeddict-item]
|
|
659
|
+
"is_error": False,
|
|
660
|
+
"content": [{"type": "text", "text": text}], # type: ignore[list-item]
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
__all__ = [
|
|
665
|
+
"DEFAULT_BASE_URL",
|
|
666
|
+
"PROTOCOL_V1",
|
|
667
|
+
"PROTOCOL_V11",
|
|
668
|
+
"ExecutorClient",
|
|
669
|
+
"ExecutorTokenError",
|
|
670
|
+
"ExecutorTokenScopeError",
|
|
671
|
+
"LogEvent",
|
|
672
|
+
"MCPChannel",
|
|
673
|
+
"MAX_FRAME_BYTES",
|
|
674
|
+
"static_token",
|
|
675
|
+
]
|