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