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,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
+ ]