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,769 @@
1
+ """The harness ("brain") end of the executor protocol.
2
+
3
+ Mirrors go/executor/harness.go and harness_mcp.go: it accepts executor control
4
+ and MCP channels (WebSocket on TCP, WebSocket on a Unix domain socket, or
5
+ in-process), and drives them with session.assign / tool.call / tool.cancel /
6
+ session.stop / executor.drain. It speaks the same protocol as the BMA service,
7
+ so an executor cannot tell a local harness from the cloud.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import inspect
14
+ import secrets
15
+ from collections.abc import Awaitable, Callable, Sequence
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from http import HTTPStatus
19
+ from typing import TYPE_CHECKING, Any
20
+ from urllib.parse import parse_qsl, urlsplit
21
+
22
+ from ._rpc import ExecutorError, NotConnectedError, Peer, RPCError, rpc_error
23
+ from .client import DEFAULT_REQUEST_TIMEOUT, PROTOCOL_V1, PROTOCOL_V11, LogEvent
24
+ from .generated import (
25
+ METHOD_EXECUTOR_CAPABILITIES_UPDATE,
26
+ METHOD_EXECUTOR_CLOSE,
27
+ METHOD_EXECUTOR_DRAIN,
28
+ METHOD_EXECUTOR_HEARTBEAT,
29
+ METHOD_EXECUTOR_REGISTER,
30
+ METHOD_SESSION_ASSIGN,
31
+ METHOD_SESSION_ENDED,
32
+ METHOD_SESSION_EVENT,
33
+ METHOD_SESSION_READY,
34
+ METHOD_SESSION_STATUS,
35
+ METHOD_SESSION_STOP,
36
+ METHOD_TOOL_CALL,
37
+ METHOD_TOOL_CANCEL,
38
+ METHOD_TOOL_PROGRESS,
39
+ METHOD_TOOL_RESULT,
40
+ METHOD_INTERACTION_RESPONSE,
41
+ METHOD_SESSION_CLOSE,
42
+ METHOD_TURN_CANCEL,
43
+ METHOD_TURN_INPUT,
44
+ METHOD_TURN_START,
45
+ Ack,
46
+ AgentCapability,
47
+ ExecutorDrainResult,
48
+ InteractionResponseParams,
49
+ InteractionResponseResult,
50
+ SessionCloseParams,
51
+ SessionCloseResult,
52
+ TurnCancelParams,
53
+ TurnCancelResult,
54
+ TurnInputParams,
55
+ TurnStartParams,
56
+ TurnStartResult,
57
+ ExecutorInfo,
58
+ JsonRpcErrorCode,
59
+ SessionAssignParams,
60
+ SessionEventParams,
61
+ SessionEventResult,
62
+ SessionStatusParams,
63
+ ToolCallParams,
64
+ ToolProgressParams,
65
+ ToolResultParams,
66
+ )
67
+ from .transport import (
68
+ CLOSE_NORMAL,
69
+ CLOSE_POLICY_VIOLATION,
70
+ CLOSE_REPLACED,
71
+ MAX_FRAME_BYTES,
72
+ ChannelClosed,
73
+ ConnectRequest,
74
+ Connection,
75
+ Dialer,
76
+ WebSocketConnection,
77
+ pipe,
78
+ )
79
+
80
+ if TYPE_CHECKING:
81
+ from .agent import AgentPlatform
82
+
83
+ DEFAULT_HARNESS_HEARTBEAT_INTERVAL = 15.0
84
+ DEFAULT_HARNESS_LEASE_TTL = 60.0
85
+ _MAX_REMEMBERED_EVENT_IDS = 1024
86
+
87
+
88
+ class SessionEndedError(ExecutorError):
89
+ """A request on a session that has ended."""
90
+
91
+ def __init__(self) -> None:
92
+ super().__init__("session ended")
93
+
94
+
95
+ class ToolOutcomeUnknownError(ExecutorError):
96
+ """The executor was lost (lease expiry, executor.close, session ended, or a
97
+ restarted executor that no longer reports the session) before tool.result
98
+ arrived. The tool may or may not have run."""
99
+
100
+ def __init__(self) -> None:
101
+ super().__init__("tool outcome unknown: executor lost before tool.result")
102
+
103
+
104
+ class AssignRejectedError(ExecutorError):
105
+ """session.assign answered accepted=false."""
106
+
107
+ def __init__(self) -> None:
108
+ super().__init__("executor rejected session.assign")
109
+
110
+
111
+ class Unauthorized(Exception):
112
+ """Raised by an authenticate hook to refuse a channel."""
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Principal:
117
+ """What authenticate learned. A non-empty executor_id pins the executor
118
+ identity: register and MCP channels must belong to it."""
119
+
120
+ executor_id: str = ""
121
+
122
+
123
+ Authenticate = Callable[[ConnectRequest], "Principal | Awaitable[Principal]"]
124
+
125
+
126
+ def _now() -> str:
127
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
128
+
129
+
130
+ def _random_id(n: int) -> str:
131
+ return secrets.token_hex(n)
132
+
133
+
134
+ class Harness:
135
+ """Harness end of the executor protocol (Go: ``Harness``)."""
136
+
137
+ def __init__(
138
+ self,
139
+ *,
140
+ authenticate: Authenticate | None = None,
141
+ heartbeat_interval: float = DEFAULT_HARNESS_HEARTBEAT_INTERVAL,
142
+ lease_ttl: float = DEFAULT_HARNESS_LEASE_TTL,
143
+ request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
144
+ protocols: Sequence[str] = (PROTOCOL_V11, PROTOCOL_V1),
145
+ on_executor: Callable[[Executor], Any] | None = None,
146
+ on_session_event: Callable[[Executor, SessionEventParams], Awaitable[SessionEventResult]] | None = None,
147
+ on_session_status: Callable[[Executor, SessionStatusParams], None] | None = None,
148
+ on_tool_progress: Callable[[Executor, ToolProgressParams], None] | None = None,
149
+ on_log: Callable[[LogEvent], None] | None = None,
150
+ agent: AgentPlatform | None = None,
151
+ ) -> None:
152
+ """authenticate verifies every channel (control and MCP); None accepts
153
+ every connection: use that only on a loopback listener or in-process."""
154
+ self._authenticate = authenticate
155
+ self.heartbeat_interval = heartbeat_interval
156
+ self.lease_ttl = lease_ttl
157
+ self.request_timeout = request_timeout
158
+ self.protocols = tuple(protocols)
159
+ self._on_executor = on_executor
160
+ self._on_session_event = on_session_event
161
+ self._on_session_status = on_session_status
162
+ self._on_tool_progress = on_tool_progress
163
+ self._on_log = on_log
164
+ self.agent = agent
165
+ self._executors: dict[str, Executor] = {}
166
+ self._sessions: dict[str, Session] = {}
167
+ self._changed = asyncio.Event()
168
+ self._next_conn = 0
169
+ self._tasks: set[asyncio.Task[Any]] = set()
170
+ from .agent import _AgentLedger
171
+
172
+ self._agent_ledger = _AgentLedger()
173
+
174
+ # --- registry ---
175
+
176
+ def executors(self) -> list[Executor]:
177
+ """Executors currently registered (connected or within their lease)."""
178
+ return list(self._executors.values())
179
+
180
+ async def wait_executor(self) -> Executor:
181
+ """Return a connected executor, waiting for one to register."""
182
+ while True:
183
+ for executor in self._executors.values():
184
+ if executor.connected:
185
+ return executor
186
+ await self._changed.wait()
187
+
188
+ def _signal(self) -> None:
189
+ changed, self._changed = self._changed, asyncio.Event()
190
+ changed.set()
191
+
192
+ def _log(self, level: str, message: str, error: BaseException | None = None) -> None:
193
+ if self._on_log is not None:
194
+ self._on_log(LogEvent(level, message, error))
195
+
196
+ def _spawn(self, coro: Awaitable[Any]) -> asyncio.Task[Any]:
197
+ task = asyncio.ensure_future(coro)
198
+ self._tasks.add(task)
199
+ task.add_done_callback(self._tasks.discard)
200
+ return task
201
+
202
+ # --- listeners ---
203
+
204
+ async def serve(self, host: str = "127.0.0.1", port: int = 0) -> Any:
205
+ """Listen for WebSocket channels on TCP; returns the websockets Server
206
+ (``server.sockets[0].getsockname()`` has the bound port)."""
207
+ from websockets.asyncio.server import serve
208
+
209
+ return await serve(self._ws_handler, host, port, **self._ws_options())
210
+
211
+ async def serve_unix(self, path: str) -> Any:
212
+ """Listen for WebSocket channels on a Unix domain socket; executors use
213
+ ``unix://<path>``."""
214
+ from websockets.asyncio.server import unix_serve
215
+
216
+ return await unix_serve(self._ws_handler, path, **self._ws_options())
217
+
218
+ def inprocess_dialer(self) -> Dialer:
219
+ """A Dialer that connects an ExecutorClient in the same event loop to
220
+ this harness without a socket (use with static_token("inprocess://"))."""
221
+
222
+ async def dialer(url: str, token: str, subprotocols: Sequence[str]) -> Connection:
223
+ parts = urlsplit(url)
224
+ request = ConnectRequest(
225
+ path=parts.path or "/", query=dict(parse_qsl(parts.query)),
226
+ headers={"Authorization": f"Bearer {token}"} if token else {},
227
+ )
228
+ protocol = next((p for p in self.protocols if p in subprotocols), None)
229
+ if protocol is None:
230
+ raise ConnectionRefusedError("unsupported executor protocol")
231
+ principal, refusal = await self._admit(request)
232
+ if refusal is not None:
233
+ raise ConnectionRefusedError(refusal[1])
234
+ ours, theirs = pipe(protocol)
235
+ self._spawn(self._serve_channel(ours, request, principal))
236
+ return theirs
237
+
238
+ return dialer
239
+
240
+ def _ws_options(self) -> dict[str, Any]:
241
+ return {
242
+ "subprotocols": list(self.protocols),
243
+ "process_request": self._process_request,
244
+ "max_size": MAX_FRAME_BYTES,
245
+ }
246
+
247
+ async def _process_request(self, connection: Any, request: Any) -> Any:
248
+ offered = ",".join(request.headers.get_all("Sec-WebSocket-Protocol"))
249
+ if not any(value.strip() in self.protocols for value in offered.split(",")):
250
+ return connection.respond(HTTPStatus.BAD_REQUEST, "unsupported executor protocol\n")
251
+ parts = urlsplit(request.path)
252
+ connect = ConnectRequest(path=parts.path, query=dict(parse_qsl(parts.query)), headers=dict(request.headers.raw_items()))
253
+ principal, refusal = await self._admit(connect)
254
+ if refusal is not None:
255
+ return connection.respond(refusal[0], refusal[1] + "\n")
256
+ connection.bma_connect = (connect, principal)
257
+ return None
258
+
259
+ async def _ws_handler(self, ws: Any) -> None:
260
+ connect, principal = ws.bma_connect
261
+ await self._serve_channel(WebSocketConnection(ws), connect, principal)
262
+
263
+ async def _admit(self, request: ConnectRequest) -> tuple[Principal, tuple[HTTPStatus, str] | None]:
264
+ principal = Principal()
265
+ if self._authenticate is not None:
266
+ try:
267
+ outcome = self._authenticate(request)
268
+ principal = await outcome if inspect.isawaitable(outcome) else outcome # type: ignore[assignment]
269
+ except Exception:
270
+ return principal, (HTTPStatus.UNAUTHORIZED, "unauthorized")
271
+ channel = request.query.get("channel", "")
272
+ if channel in ("", "control"):
273
+ return principal, None
274
+ if channel != "mcp":
275
+ return principal, (HTTPStatus.BAD_REQUEST, "unknown channel")
276
+ session_id, server_key = request.query.get("session_id", ""), request.query.get("server_key", "")
277
+ if not session_id or not server_key:
278
+ return principal, (HTTPStatus.BAD_REQUEST, "session_id and server_key are required")
279
+ session = self._sessions.get(session_id)
280
+ # Only the executor holding the session may serve its MCP servers.
281
+ if session is None or (principal.executor_id and principal.executor_id != session.executor.id):
282
+ return principal, (HTTPStatus.FORBIDDEN, "session is not active for this executor")
283
+ return principal, None
284
+
285
+ async def _serve_channel(self, conn: Connection, request: ConnectRequest, principal: Principal) -> None:
286
+ if request.query.get("channel") == "mcp":
287
+ await self._serve_mcp(conn, request)
288
+ return
289
+ self._next_conn += 1
290
+ await _HarnessConn(self, conn, principal, f"conn_{self._next_conn}_{_random_id(4)}").run()
291
+
292
+ async def _serve_mcp(self, conn: Connection, request: ConnectRequest) -> None:
293
+ session = self._sessions.get(request.query["session_id"])
294
+ if session is None:
295
+ await conn.close(CLOSE_POLICY_VIOLATION, "session ended")
296
+ return
297
+ channel = HarnessMCPChannel(conn)
298
+ if not session._attach_mcp(request.query["server_key"], channel): # noqa: SLF001
299
+ await conn.close(CLOSE_POLICY_VIOLATION, "session ended")
300
+ return
301
+ # The handler owns the connection until the channel is closed.
302
+ await channel._closed.wait() # noqa: SLF001
303
+
304
+ # --- sessions ---
305
+
306
+ def _bind(self, executor: Executor, session: Session) -> None:
307
+ if session.id in self._sessions:
308
+ raise ExecutorError(f"session {session.id} is already bound")
309
+ if executor._gone: # noqa: SLF001
310
+ raise NotConnectedError()
311
+ self._sessions[session.id] = session
312
+ executor._sessions[session.id] = session # noqa: SLF001
313
+
314
+ def _unbind(self, session: Session) -> None:
315
+ if self._sessions.get(session.id) is session:
316
+ del self._sessions[session.id]
317
+ if session.executor._sessions.get(session.id) is session: # noqa: SLF001
318
+ del session.executor._sessions[session.id] # noqa: SLF001
319
+
320
+
321
+ class Executor:
322
+ """One registered executor. It outlives a single connection: a reconnect
323
+ with resume.executor_id within the lease keeps its sessions and pending
324
+ tool calls."""
325
+
326
+ def __init__(self, harness: Harness, executor_id: str) -> None:
327
+ self._h = harness
328
+ self.id = executor_id
329
+ self._conn: _HarnessConn | None = None
330
+ self.info: ExecutorInfo = {} # type: ignore[typeddict-item]
331
+ self.capabilities: dict[str, Any] = {}
332
+ self.environment_id = ""
333
+ self.protocol = ""
334
+ self._sessions: dict[str, Session] = {}
335
+ self._lease_timer: asyncio.TimerHandle | None = None
336
+ self._draining = False
337
+ self._gone = False
338
+ self._event_ids: dict[str, SessionEventResult] = {}
339
+
340
+ @property
341
+ def connected(self) -> bool:
342
+ return self._conn is not None
343
+
344
+ def sessions(self) -> list[Session]:
345
+ return list(self._sessions.values())
346
+
347
+ async def _call(self, method: str, params: Any) -> Any:
348
+ conn = self._conn
349
+ if conn is None:
350
+ raise NotConnectedError()
351
+ return await conn.peer.call(method, params, self._h.request_timeout)
352
+
353
+ async def drain(self, reason: str = "") -> ExecutorDrainResult:
354
+ """Ask the executor to stop accepting sessions; heartbeats report
355
+ drain=true from now on."""
356
+ self._draining = True
357
+ return await self._call(METHOD_EXECUTOR_DRAIN, {"reason": reason})
358
+
359
+ async def assign_session(self, params: SessionAssignParams) -> Session:
360
+ """Bind a session and send session.assign. work_id and the lease token
361
+ are generated when empty. The session is bound before the request is
362
+ sent, so session.ready and MCP channels racing the answer are accepted."""
363
+ params = dict(params) # type: ignore[assignment]
364
+ if not str(params.get("session_id", "")).strip():
365
+ raise ValueError("bma_executor_sdk: session_id is required")
366
+ if not params.get("work_id"):
367
+ params["work_id"] = f"work_{_random_id(8)}"
368
+ lease = dict(params.get("lease") or {})
369
+ if not lease.get("token"):
370
+ lease["token"] = f"lease_{_random_id(16)}"
371
+ lease.setdefault("fencing_token", 0)
372
+ params["lease"] = lease # type: ignore[typeddict-item]
373
+ if not params.get("environment_id"):
374
+ params["environment_id"] = self.environment_id
375
+ session = Session(self, params["session_id"], params["work_id"], lease["token"])
376
+ self._h._bind(self, session) # noqa: SLF001
377
+ try:
378
+ result = await self._call(METHOD_SESSION_ASSIGN, params)
379
+ if not result.get("accepted"):
380
+ raise AssignRejectedError()
381
+ except BaseException:
382
+ session._end("assign_failed", SessionEndedError) # noqa: SLF001
383
+ raise
384
+ return session
385
+
386
+ def _session(self, session_id: str) -> Session | None:
387
+ return self._sessions.get(session_id)
388
+
389
+ def _forget(self, reason: str) -> None:
390
+ self._gone = True
391
+ for session in self.sessions():
392
+ session._end(reason, ToolOutcomeUnknownError) # noqa: SLF001
393
+ if self._h._executors.get(self.id) is self: # noqa: SLF001
394
+ del self._h._executors[self.id] # noqa: SLF001
395
+ self._h._signal() # noqa: SLF001
396
+
397
+ # --- agent capability (Go: agent_harness.go) ---
398
+
399
+ def agent_capability(self) -> AgentCapability | None:
400
+ """The advertised agent capability, None when absent."""
401
+ return self.capabilities.get("agent")
402
+
403
+ def _require_agent(self) -> None:
404
+ capability = self.agent_capability()
405
+ if not capability or not capability.get("enabled"):
406
+ raise RPCError(JsonRpcErrorCode.CapabilityUnavailable, "executor does not advertise the agent capability")
407
+
408
+ async def start_turn(self, params: TurnStartParams) -> TurnStartResult:
409
+ """Send turn.start; safe to repeat with the same turn_id."""
410
+ self._require_agent()
411
+ return await self._call(METHOD_TURN_START, params)
412
+
413
+ async def send_turn_input(self, params: TurnInputParams) -> Ack:
414
+ """Send turn.input; a conflict means the turn is over and the input
415
+ belongs in a new turn.start."""
416
+ return await self._call(METHOD_TURN_INPUT, params)
417
+
418
+ async def cancel_turn(self, params: TurnCancelParams) -> TurnCancelResult:
419
+ """Send turn.cancel; the turn's end still arrives as session.status_idle."""
420
+ return await self._call(METHOD_TURN_CANCEL, params)
421
+
422
+ async def close_agent_session(self, params: SessionCloseParams) -> SessionCloseResult:
423
+ return await self._call(METHOD_SESSION_CLOSE, params)
424
+
425
+ async def respond_interaction(self, params: InteractionResponseParams) -> InteractionResponseResult:
426
+ """Deliver the platform's answer; accepted=false means the executor no
427
+ longer waits for it."""
428
+ return await self._call(METHOD_INTERACTION_RESPONSE, params)
429
+
430
+
431
+ class Session:
432
+ """One session bound to an executor."""
433
+
434
+ def __init__(self, executor: Executor, session_id: str, work_id: str, lease_token: str) -> None:
435
+ self.executor = executor
436
+ self.id = session_id
437
+ self.work_id = work_id
438
+ self._lease_token = lease_token
439
+ self._ready = asyncio.Event()
440
+ self._done = asyncio.Event()
441
+ self.end_reason = ""
442
+ self._calls: dict[str, asyncio.Future[ToolResultParams]] = {}
443
+ self._mcp: dict[str, HarnessMCPChannel] = {}
444
+ self._mcp_changed = asyncio.Event()
445
+
446
+ @property
447
+ def ended(self) -> bool:
448
+ return self._done.is_set()
449
+
450
+ async def wait_ended(self) -> str:
451
+ await self._done.wait()
452
+ return self.end_reason
453
+
454
+ async def ready(self) -> None:
455
+ """Wait for session.ready."""
456
+ ready = asyncio.ensure_future(self._ready.wait())
457
+ done = asyncio.ensure_future(self._done.wait())
458
+ try:
459
+ await asyncio.wait({ready, done}, return_when=asyncio.FIRST_COMPLETED)
460
+ finally:
461
+ ready.cancel()
462
+ done.cancel()
463
+ if not self._ready.is_set():
464
+ raise SessionEndedError()
465
+
466
+ async def call_tool(self, params: ToolCallParams) -> ToolResultParams:
467
+ """Send tool.call and wait for its tool.result. Idempotent by
468
+ tool_call_id (generated when empty): repeating an id joins the in-flight
469
+ call or returns the recorded result. Canceling the awaiting task stops
470
+ waiting only; use cancel_tool to abort the tool."""
471
+ params = dict(params, session_id=self.id) # type: ignore[assignment]
472
+ if not params.get("tool_call_id"):
473
+ params["tool_call_id"] = f"call_{_random_id(8)}"
474
+ call_id = params["tool_call_id"]
475
+ future = self._calls.get(call_id)
476
+ if future is None:
477
+ if self.end_reason:
478
+ raise SessionEndedError()
479
+ future = asyncio.get_running_loop().create_future()
480
+ self._calls[call_id] = future
481
+ try:
482
+ ack = await self.executor._call(METHOD_TOOL_CALL, params) # noqa: SLF001
483
+ if not ack.get("accepted"):
484
+ raise ExecutorError("executor did not accept tool.call")
485
+ except BaseException:
486
+ # Not dispatched: forget the call so the same id can be retried.
487
+ if self._calls.get(call_id) is future:
488
+ del self._calls[call_id]
489
+ if not future.done():
490
+ future.cancel()
491
+ raise
492
+ return await asyncio.shield(future)
493
+
494
+ async def cancel_tool(self, tool_call_id: str, reason: str = "") -> bool:
495
+ """Send tool.cancel. The waiting call_tool still receives the
496
+ executor's tool.result (outcome canceled when the abort won)."""
497
+ result = await self.executor._call(METHOD_TOOL_CANCEL, {"session_id": self.id, "tool_call_id": tool_call_id, "reason": reason}) # noqa: SLF001
498
+ return bool(result.get("canceled"))
499
+
500
+ async def stop(self, reason: str = "") -> None:
501
+ """Send session.stop; the session ends locally on success."""
502
+ await self.executor._call(METHOD_SESSION_STOP, {"session_id": self.id, "reason": reason}) # noqa: SLF001
503
+ self._end(reason, ToolOutcomeUnknownError)
504
+
505
+ async def mcp(self, server_key: str) -> HarnessMCPChannel:
506
+ """Wait for the executor to open the MCP channel for server_key. A newer
507
+ channel replaces the old one; the session's end closes all of them."""
508
+ while True:
509
+ if self.end_reason:
510
+ raise SessionEndedError()
511
+ channel = self._mcp.get(server_key)
512
+ if channel is not None:
513
+ return channel
514
+ await self._mcp_changed.wait()
515
+
516
+ def _mark_ready(self) -> None:
517
+ self._ready.set()
518
+
519
+ def _complete(self, result: ToolResultParams) -> bool:
520
+ future = self._calls.get(result["tool_call_id"])
521
+ if future is None:
522
+ return False
523
+ if not future.done():
524
+ future.set_result(result)
525
+ return True
526
+
527
+ def _end(self, reason: str, pending_error: type[Exception]) -> None:
528
+ if self.end_reason:
529
+ return
530
+ self.end_reason = reason or "ended"
531
+ for future in self._calls.values():
532
+ if not future.done():
533
+ future.set_exception(pending_error())
534
+ future.exception() # retrieved: nobody may be waiting
535
+ channels, self._mcp = self._mcp, {}
536
+ self._done.set()
537
+ self._signal_mcp()
538
+ for channel in channels.values():
539
+ channel._spawn_close() # noqa: SLF001
540
+ self.executor._h._unbind(self) # noqa: SLF001
541
+
542
+ def _signal_mcp(self) -> None:
543
+ changed, self._mcp_changed = self._mcp_changed, asyncio.Event()
544
+ changed.set()
545
+
546
+ def _attach_mcp(self, server_key: str, channel: HarnessMCPChannel) -> bool:
547
+ if self.end_reason:
548
+ return False
549
+ previous = self._mcp.get(server_key)
550
+ self._mcp[server_key] = channel
551
+
552
+ def detach() -> None:
553
+ if self._mcp.get(server_key) is channel:
554
+ del self._mcp[server_key]
555
+
556
+ channel._detach = detach # noqa: SLF001
557
+ self._signal_mcp()
558
+ if previous is not None:
559
+ previous._spawn_close() # noqa: SLF001
560
+ return True
561
+
562
+
563
+ class HarnessMCPChannel:
564
+ """Harness end of one MCP channel, isolated by (session_id, server_key).
565
+ Frames are raw MCP JSON-RPC; the harness is the MCP client."""
566
+
567
+ def __init__(self, conn: Connection) -> None:
568
+ self._conn = conn
569
+ self._closed = asyncio.Event()
570
+ self._detach: Callable[[], None] | None = None
571
+
572
+ async def read(self) -> str:
573
+ """Next raw MCP frame. A transport error closes the channel; the
574
+ executor reopens a fresh one, so call Session.mcp again."""
575
+ try:
576
+ return await self._conn.recv()
577
+ except ChannelClosed:
578
+ await self.close()
579
+ raise
580
+
581
+ async def write(self, frame: str) -> None:
582
+ await self._conn.send(frame)
583
+
584
+ async def close(self) -> None:
585
+ if self._closed.is_set():
586
+ return
587
+ self._closed.set()
588
+ if self._detach is not None:
589
+ self._detach()
590
+ await self._conn.close(CLOSE_NORMAL, "mcp channel closed")
591
+
592
+ def _spawn_close(self) -> None:
593
+ asyncio.ensure_future(self.close())
594
+
595
+
596
+ class _HarnessConn:
597
+ def __init__(self, harness: Harness, conn: Connection, principal: Principal, conn_id: str) -> None:
598
+ self.h = harness
599
+ self.conn = conn
600
+ self.principal = principal
601
+ self.id = conn_id
602
+ self.executor: Executor | None = None
603
+ self.peer = Peer(
604
+ conn, id_prefix="h", on_request=self._handle_request, on_notification=self._handle_notification,
605
+ on_log=harness._log, inline=frozenset({METHOD_EXECUTOR_REGISTER}), # noqa: SLF001
606
+ )
607
+
608
+ async def run(self) -> None:
609
+ await self.peer.run()
610
+ self.peer.cancel_tasks()
611
+ self._disconnected()
612
+
613
+ def _disconnected(self) -> None:
614
+ executor = self.executor
615
+ if executor is None or executor._conn is not self or executor._gone: # noqa: SLF001
616
+ return
617
+ executor._conn = None # noqa: SLF001
618
+
619
+ def expire() -> None:
620
+ if executor._conn is None and not executor._gone: # noqa: SLF001
621
+ executor._forget("lease_expired") # noqa: SLF001
622
+
623
+ executor._lease_timer = asyncio.get_running_loop().call_later(self.h.lease_ttl, expire) # noqa: SLF001
624
+
625
+ async def _handle_request(self, request_id: str, method: str, params: Any) -> Any:
626
+ if method == METHOD_EXECUTOR_REGISTER:
627
+ return self._register(params)
628
+ executor = self.executor
629
+ if executor is None:
630
+ raise rpc_error(JsonRpcErrorCode.InvalidRequest, "executor.register must come first", False)
631
+ if not isinstance(params, dict):
632
+ raise rpc_error(JsonRpcErrorCode.InvalidParams, f"invalid {method} params", False)
633
+ from .agent import _dispatch_agent
634
+
635
+ handled, result = await _dispatch_agent(self.h, executor, method, params)
636
+ if handled:
637
+ return result
638
+ return await self._dispatch(executor, request_id, method, params)
639
+
640
+ def _register(self, params: Any) -> dict[str, Any]:
641
+ if self.executor is not None:
642
+ raise rpc_error(JsonRpcErrorCode.InvalidRequest, "already registered", False)
643
+ if not isinstance(params, dict) or not isinstance(params.get("executor"), dict):
644
+ raise rpc_error(JsonRpcErrorCode.InvalidParams, "invalid executor.register params", False)
645
+ if params.get("protocol") != self.conn.subprotocol:
646
+ raise rpc_error(JsonRpcErrorCode.UnsupportedProtocol, "protocol must match the negotiated subprotocol", False)
647
+ resume = params.get("resume") or {}
648
+ resume_id = resume.get("executor_id") or ""
649
+ executor_id = self.principal.executor_id
650
+ if executor_id and resume_id and resume_id != executor_id:
651
+ raise rpc_error(JsonRpcErrorCode.Forbidden, "resume.executor_id does not match the authenticated executor", False)
652
+ if not executor_id:
653
+ executor_id = resume_id or f"exe_{_random_id(8)}"
654
+
655
+ h = self.h
656
+ executor = h._executors.get(executor_id) # noqa: SLF001
657
+ if executor is None:
658
+ executor = Executor(h, executor_id)
659
+ h._executors[executor_id] = executor # noqa: SLF001
660
+ previous = executor._conn # noqa: SLF001
661
+ executor._conn = self # noqa: SLF001
662
+ executor.info = params["executor"]
663
+ executor.capabilities = params.get("capabilities") or {}
664
+ executor.environment_id = params.get("environment_id", "")
665
+ executor.protocol = params["protocol"]
666
+ if executor._lease_timer is not None: # noqa: SLF001
667
+ executor._lease_timer.cancel() # noqa: SLF001
668
+ executor._lease_timer = None # noqa: SLF001
669
+ self.executor = executor
670
+ if previous is not None:
671
+ # The new connection is authoritative: 4004 replaced.
672
+ h._spawn(previous.conn.close(CLOSE_REPLACED, "replaced by a newer connection")) # noqa: SLF001
673
+ # Sessions the executor no longer reports were lost with its old process.
674
+ keep = set(resume.get("active_sessions") or [])
675
+ for session in executor.sessions():
676
+ if session.id not in keep:
677
+ session._end("executor_lost", ToolOutcomeUnknownError) # noqa: SLF001
678
+ h._signal() # noqa: SLF001
679
+ if h._on_executor is not None: # noqa: SLF001
680
+ outcome = h._on_executor(executor) # noqa: SLF001
681
+ if inspect.isawaitable(outcome):
682
+ h._spawn(outcome) # noqa: SLF001
683
+ return {
684
+ "executor_id": executor_id,
685
+ "connection_id": self.id,
686
+ "server_time": _now(),
687
+ "heartbeat_interval_ms": int(h.heartbeat_interval * 1000),
688
+ "lease_ttl_ms": int(h.lease_ttl * 1000),
689
+ "max_control_frame_bytes": MAX_FRAME_BYTES,
690
+ "max_mcp_frame_bytes": MAX_FRAME_BYTES,
691
+ "drain": executor._draining, # noqa: SLF001
692
+ "protocol": params["protocol"],
693
+ }
694
+
695
+ async def _dispatch(self, executor: Executor, request_id: str, method: str, params: dict[str, Any]) -> Any:
696
+ if method == METHOD_EXECUTOR_HEARTBEAT:
697
+ return {"server_time": _now(), "lease_ttl_ms": int(self.h.lease_ttl * 1000), "drain": executor._draining} # noqa: SLF001
698
+ if method == METHOD_SESSION_READY:
699
+ session = executor._session(params.get("session_id", "")) # noqa: SLF001
700
+ if session is None:
701
+ raise rpc_error(JsonRpcErrorCode.SessionNotActive, "session is not active on this executor", False)
702
+ if params.get("lease_token") and params["lease_token"] != session._lease_token: # noqa: SLF001
703
+ raise rpc_error(JsonRpcErrorCode.LeaseExpired, "lease token does not match", False)
704
+ session._mark_ready() # noqa: SLF001
705
+ return {"accepted": True}
706
+ if method == METHOD_SESSION_ENDED:
707
+ session = executor._session(params.get("session_id", "")) # noqa: SLF001
708
+ if session is not None:
709
+ session._end(params.get("reason", ""), ToolOutcomeUnknownError) # noqa: SLF001
710
+ return {"accepted": True}
711
+ if method == METHOD_TOOL_RESULT:
712
+ if not params.get("tool_call_id"):
713
+ raise rpc_error(JsonRpcErrorCode.InvalidParams, "invalid tool.result params", False)
714
+ session = executor._session(params.get("session_id", "")) # noqa: SLF001
715
+ if session is None:
716
+ raise rpc_error(JsonRpcErrorCode.SessionNotActive, "session is not active on this executor", False)
717
+ if not session._complete(params): # type: ignore[arg-type] # noqa: SLF001
718
+ raise rpc_error(JsonRpcErrorCode.NotFound, "tool_call_id was not dispatched by this harness", False)
719
+ return {"accepted": True}
720
+ if method == METHOD_SESSION_EVENT:
721
+ if executor._session(params.get("session_id", "")) is None: # noqa: SLF001
722
+ raise rpc_error(JsonRpcErrorCode.SessionNotActive, "session is not active on this executor", False)
723
+ previous = executor._event_ids.get(request_id) # noqa: SLF001
724
+ if previous is not None:
725
+ return previous
726
+ result: SessionEventResult = {"accepted": True, "event_id": f"evt_{_random_id(8)}"}
727
+ if self.h._on_session_event is not None: # noqa: SLF001
728
+ try:
729
+ result = await self.h._on_session_event(executor, params) # type: ignore[arg-type] # noqa: SLF001
730
+ except Exception as error:
731
+ raise rpc_error(JsonRpcErrorCode.InternalError, str(error), True) from error
732
+ if len(executor._event_ids) >= _MAX_REMEMBERED_EVENT_IDS: # noqa: SLF001
733
+ executor._event_ids.clear() # noqa: SLF001
734
+ executor._event_ids[request_id] = result # noqa: SLF001
735
+ return result
736
+ raise rpc_error(JsonRpcErrorCode.MethodNotFound, f"unknown method {method}", False)
737
+
738
+ def _handle_notification(self, method: str, params: Any) -> None:
739
+ executor = self.executor
740
+ if executor is None or not isinstance(params, dict):
741
+ return
742
+ from .agent import _handle_agent_notification
743
+
744
+ if _handle_agent_notification(self.h, executor, method, params):
745
+ return
746
+ h = self.h
747
+ if method == METHOD_SESSION_STATUS and h._on_session_status is not None: # noqa: SLF001
748
+ h._on_session_status(executor, params) # type: ignore[arg-type] # noqa: SLF001
749
+ elif method == METHOD_TOOL_PROGRESS and h._on_tool_progress is not None: # noqa: SLF001
750
+ h._on_tool_progress(executor, params) # type: ignore[arg-type] # noqa: SLF001
751
+ elif method == METHOD_EXECUTOR_CAPABILITIES_UPDATE and isinstance(params.get("capabilities"), dict):
752
+ executor.capabilities = params["capabilities"]
753
+ elif method == METHOD_EXECUTOR_CLOSE:
754
+ # A deliberate shutdown: no reconnect grace.
755
+ executor._forget("executor_closed") # noqa: SLF001
756
+ h._spawn(self.conn.close(CLOSE_NORMAL, "executor.close")) # noqa: SLF001
757
+
758
+
759
+ __all__ = [
760
+ "AssignRejectedError",
761
+ "Executor",
762
+ "Harness",
763
+ "HarnessMCPChannel",
764
+ "Principal",
765
+ "Session",
766
+ "SessionEndedError",
767
+ "ToolOutcomeUnknownError",
768
+ "Unauthorized",
769
+ ]