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,558 @@
|
|
|
1
|
+
"""The agent capability: a complete local agent served over the executor protocol.
|
|
2
|
+
|
|
3
|
+
Executor side (Go: agent.go): ``ExecutorClient.serve_agent(handler)`` with an
|
|
4
|
+
:class:`AgentHandler`; the SDK owns turn.start/input/cancel, session.close,
|
|
5
|
+
event ids and sequences, ACK tracking and resend after reconnect,
|
|
6
|
+
interactions, and the session.status_running / session.status_idle events that
|
|
7
|
+
frame every turn.
|
|
8
|
+
|
|
9
|
+
Harness side (Go: agent_harness.go): ``Harness(agent=platform)`` with an
|
|
10
|
+
:class:`AgentPlatform`, plus ``Executor.start_turn`` and friends.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from collections import deque
|
|
17
|
+
from collections.abc import Awaitable, Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
21
|
+
|
|
22
|
+
from ._rpc import ClientStoppedError, ExecutorError, NotConnectedError, RPCError, rpc_error
|
|
23
|
+
from .generated import (
|
|
24
|
+
METHOD_INTERACTION_CANCEL,
|
|
25
|
+
METHOD_INTERACTION_REQUEST,
|
|
26
|
+
METHOD_INTERACTION_RESPONSE,
|
|
27
|
+
METHOD_SESSION_CLOSE,
|
|
28
|
+
METHOD_SESSION_ENDED,
|
|
29
|
+
METHOD_SESSION_EVENT,
|
|
30
|
+
METHOD_SESSION_EVENT_DELTA,
|
|
31
|
+
METHOD_TURN_CANCEL,
|
|
32
|
+
METHOD_TURN_INPUT,
|
|
33
|
+
METHOD_TURN_START,
|
|
34
|
+
AgentCapability,
|
|
35
|
+
AgentEventType,
|
|
36
|
+
ConnectionState,
|
|
37
|
+
InteractionCancelParams,
|
|
38
|
+
InteractionCancelReason,
|
|
39
|
+
InteractionKind,
|
|
40
|
+
InteractionOutcome,
|
|
41
|
+
InteractionRequestParams,
|
|
42
|
+
InteractionResponseParams,
|
|
43
|
+
JsonRpcErrorCode,
|
|
44
|
+
SessionEventDeltaParams,
|
|
45
|
+
TurnInputParams,
|
|
46
|
+
TurnOutcome,
|
|
47
|
+
TurnStartParams,
|
|
48
|
+
TurnStartResult,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from .client import ExecutorClient
|
|
53
|
+
from .harness import Executor, Harness
|
|
54
|
+
|
|
55
|
+
_AGENT_INPUT_BUFFER = 16
|
|
56
|
+
_AGENT_RESEND_DELAY = 0.2
|
|
57
|
+
_AGENT_CLOSE_FLUSH_WAIT = 30.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AgentSessionClosedError(ExecutorError):
|
|
61
|
+
"""An AgentTurn method was used after session.close."""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
super().__init__("agent session closed")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- executor side ---
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AgentHandler(Protocol):
|
|
71
|
+
async def open_turn(self, params: TurnStartParams) -> TurnStartResult:
|
|
72
|
+
"""Decide whether to accept turn.start and report the configuration the
|
|
73
|
+
agent will apply. It runs before turn.start is answered, so it must not
|
|
74
|
+
do the turn's work. A raised RPCError keeps its code."""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
async def run_turn(self, turn: AgentTurn) -> None:
|
|
78
|
+
"""Do the turn's work. turn.cancel and session.close cancel this task.
|
|
79
|
+
Returning ends the turn as completed, cancellation as canceled, any
|
|
80
|
+
exception as failed (the SDK emits session.error first)."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
async def close_session(self, session_id: str, reason: str) -> None:
|
|
84
|
+
"""Release the local conversation after session.close."""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AgentTurn:
|
|
89
|
+
"""The handle run_turn uses to talk to the platform."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, session: _AgentSession, params: TurnStartParams) -> None:
|
|
92
|
+
self._session = session
|
|
93
|
+
self.params = params
|
|
94
|
+
self.inputs: asyncio.Queue[TurnInputParams] = asyncio.Queue(_AGENT_INPUT_BUFFER)
|
|
95
|
+
"""turn.input messages in arrival order."""
|
|
96
|
+
self._seen = {params.get("input_id", "")}
|
|
97
|
+
self._task: asyncio.Task[Any] | None = None
|
|
98
|
+
self._canceled = False
|
|
99
|
+
self._done = asyncio.Event()
|
|
100
|
+
self.ended = False
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def session_id(self) -> str:
|
|
104
|
+
return self._session.id
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def turn_id(self) -> str:
|
|
108
|
+
return self.params["turn_id"]
|
|
109
|
+
|
|
110
|
+
def new_event_id(self) -> str:
|
|
111
|
+
"""A fresh event id, for a message streamed with delta() before it is
|
|
112
|
+
emitted with emit_with_id()."""
|
|
113
|
+
return self._session.new_event_id()
|
|
114
|
+
|
|
115
|
+
def emit(self, event: Any) -> str:
|
|
116
|
+
"""Queue one durable event (an Agent*Event / Session*Event dict) and
|
|
117
|
+
return its event id. Delivery is asynchronous, ordered and
|
|
118
|
+
at-least-once; the platform deduplicates by event id."""
|
|
119
|
+
return self.emit_with_id(self.new_event_id(), event)
|
|
120
|
+
|
|
121
|
+
def emit_with_id(self, event_id: str, event: Any) -> str:
|
|
122
|
+
if self.ended:
|
|
123
|
+
raise ExecutorError("turn already ended")
|
|
124
|
+
self._session.enqueue(self.turn_id, event_id, event)
|
|
125
|
+
return event_id
|
|
126
|
+
|
|
127
|
+
async def delta(self, event_id: str, event_type: str, index: int, text: str) -> None:
|
|
128
|
+
"""Stream a transient preview of the durable event event_id; dropped
|
|
129
|
+
while disconnected and never resent."""
|
|
130
|
+
client = self._session.runtime.client
|
|
131
|
+
params: SessionEventDeltaParams = {
|
|
132
|
+
"session_id": self.session_id, "turn_id": self.turn_id, "event_id": event_id,
|
|
133
|
+
"event_type": event_type, "index": index, "text": text,
|
|
134
|
+
}
|
|
135
|
+
try:
|
|
136
|
+
await client.notify(METHOD_SESSION_EVENT_DELTA, params)
|
|
137
|
+
except (NotConnectedError, ClientStoppedError) as error:
|
|
138
|
+
client._log("debug", "session.event_delta dropped", error) # noqa: SLF001
|
|
139
|
+
|
|
140
|
+
async def flush(self) -> None:
|
|
141
|
+
"""Wait until every event emitted so far is acknowledged."""
|
|
142
|
+
await self._session.drained.wait()
|
|
143
|
+
|
|
144
|
+
async def request_interaction(self, params: InteractionRequestParams) -> InteractionResponseParams:
|
|
145
|
+
"""Ask the platform for an approval or an answer and wait for
|
|
146
|
+
interaction.response. When the task is canceled or expires_at passes,
|
|
147
|
+
the interaction is withdrawn; expiry returns outcome expired, which the
|
|
148
|
+
agent must treat as a deny."""
|
|
149
|
+
s = self._session
|
|
150
|
+
params = dict(params, session_id=s.id, turn_id=self.turn_id) # type: ignore[assignment]
|
|
151
|
+
if not params.get("interaction_id"):
|
|
152
|
+
params["interaction_id"] = "int_" + self.new_event_id()[4:]
|
|
153
|
+
interaction_id = params["interaction_id"]
|
|
154
|
+
answer: asyncio.Future[InteractionResponseParams] = asyncio.get_running_loop().create_future()
|
|
155
|
+
s.pending[interaction_id] = answer
|
|
156
|
+
try:
|
|
157
|
+
await self.flush()
|
|
158
|
+
await s.call_until_connected(lambda: s.runtime.client.call(METHOD_INTERACTION_REQUEST, params))
|
|
159
|
+
timeout = None
|
|
160
|
+
if params.get("expires_at"):
|
|
161
|
+
expires = datetime.fromisoformat(params["expires_at"].replace("Z", "+00:00"))
|
|
162
|
+
timeout = max(0.0, (expires - datetime.now(timezone.utc)).total_seconds())
|
|
163
|
+
try:
|
|
164
|
+
return await asyncio.wait_for(asyncio.shield(answer), timeout)
|
|
165
|
+
except asyncio.TimeoutError:
|
|
166
|
+
await s.withdraw(interaction_id, InteractionCancelReason.Expired.value)
|
|
167
|
+
return {"session_id": s.id, "interaction_id": interaction_id, "outcome": InteractionOutcome.Expired.value} # type: ignore[typeddict-item]
|
|
168
|
+
except asyncio.CancelledError:
|
|
169
|
+
s.runtime.client._spawn(s.withdraw(interaction_id, InteractionCancelReason.TurnCanceled.value)) # noqa: SLF001
|
|
170
|
+
raise
|
|
171
|
+
finally:
|
|
172
|
+
s.pending.pop(interaction_id, None)
|
|
173
|
+
|
|
174
|
+
async def cancel_interaction(self, interaction_id: str, reason: str) -> None:
|
|
175
|
+
"""Withdraw an open interaction, for example after the user answered it
|
|
176
|
+
locally (reason resolved_locally)."""
|
|
177
|
+
await self._session.withdraw(interaction_id, reason)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _AgentRuntime:
|
|
181
|
+
def __init__(self, client: ExecutorClient, handler: AgentHandler, capability: AgentCapability) -> None:
|
|
182
|
+
self.client = client
|
|
183
|
+
self.handler = handler
|
|
184
|
+
self.capability = capability
|
|
185
|
+
self.sessions: dict[str, _AgentSession] = {}
|
|
186
|
+
self.methods: dict[str, Callable[[Any], Awaitable[Any]]] = {
|
|
187
|
+
METHOD_TURN_START: self.turn_start,
|
|
188
|
+
METHOD_TURN_INPUT: self.turn_input,
|
|
189
|
+
METHOD_TURN_CANCEL: self.turn_cancel,
|
|
190
|
+
METHOD_SESSION_CLOSE: self.session_close,
|
|
191
|
+
METHOD_INTERACTION_RESPONSE: self.interaction_response,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
def session(self, session_id: str, create: bool) -> _AgentSession | None:
|
|
195
|
+
session = self.sessions.get(session_id)
|
|
196
|
+
if session is not None or not create:
|
|
197
|
+
return session
|
|
198
|
+
limit = self.capability.get("max_sessions") or 0
|
|
199
|
+
if limit > 0 and len(self.sessions) >= limit:
|
|
200
|
+
raise RPCError(JsonRpcErrorCode.ExecutorBusy, "executor_busy")
|
|
201
|
+
session = _AgentSession(self, session_id)
|
|
202
|
+
self.sessions[session_id] = session
|
|
203
|
+
self.client._sessions.add(session_id) # noqa: SLF001
|
|
204
|
+
session.sender = self.client._spawn(session.send_loop()) # noqa: SLF001
|
|
205
|
+
return session
|
|
206
|
+
|
|
207
|
+
async def turn_start(self, params: TurnStartParams) -> TurnStartResult:
|
|
208
|
+
if not params.get("session_id") or not params.get("turn_id") or not params.get("input"):
|
|
209
|
+
raise RPCError(JsonRpcErrorCode.InvalidParams, "session_id, turn_id and input are required")
|
|
210
|
+
s = self.session(params["session_id"], True)
|
|
211
|
+
assert s is not None
|
|
212
|
+
if params["turn_id"] in s.results:
|
|
213
|
+
return s.results[params["turn_id"]]
|
|
214
|
+
if s.turn is not None:
|
|
215
|
+
raise RPCError(JsonRpcErrorCode.Conflict, "a turn is already running; use turn.input")
|
|
216
|
+
turn = AgentTurn(s, params)
|
|
217
|
+
s.turn = turn # reserve the session before open_turn
|
|
218
|
+
try:
|
|
219
|
+
result = dict(await self.handler.open_turn(params))
|
|
220
|
+
if not result.get("accepted"):
|
|
221
|
+
raise RPCError(JsonRpcErrorCode.ExecutorBusy, "turn not accepted")
|
|
222
|
+
except BaseException:
|
|
223
|
+
s.turn = None
|
|
224
|
+
raise
|
|
225
|
+
result["turn_id"] = params["turn_id"]
|
|
226
|
+
s.results[params["turn_id"]] = result # type: ignore[assignment]
|
|
227
|
+
self.client._spawn(self.run_turn(turn)) # noqa: SLF001
|
|
228
|
+
return result # type: ignore[return-value]
|
|
229
|
+
|
|
230
|
+
async def run_turn(self, turn: AgentTurn) -> None:
|
|
231
|
+
s = turn._session # noqa: SLF001
|
|
232
|
+
turn_id = turn.turn_id
|
|
233
|
+
s.enqueue(turn_id, turn.new_event_id(), {"type": AgentEventType.SessionStatusRunning.value})
|
|
234
|
+
inner = asyncio.ensure_future(self.handler.run_turn(turn))
|
|
235
|
+
turn._task = inner # noqa: SLF001
|
|
236
|
+
if turn._canceled: # noqa: SLF001 canceled before the work started
|
|
237
|
+
inner.cancel()
|
|
238
|
+
deadline_ms = turn.params.get("deadline_ms") or 0
|
|
239
|
+
done, _ = await asyncio.wait({inner}, timeout=deadline_ms / 1000 if deadline_ms > 0 else None)
|
|
240
|
+
turn.ended = True
|
|
241
|
+
outcome = TurnOutcome.Completed
|
|
242
|
+
if not done:
|
|
243
|
+
inner.cancel()
|
|
244
|
+
await asyncio.gather(inner, return_exceptions=True)
|
|
245
|
+
outcome = TurnOutcome.Failed
|
|
246
|
+
s.enqueue(turn_id, turn.new_event_id(), _session_error("timeout", "turn deadline_ms exceeded"))
|
|
247
|
+
elif turn._canceled or inner.cancelled(): # noqa: SLF001
|
|
248
|
+
outcome = TurnOutcome.Canceled
|
|
249
|
+
elif inner.exception() is not None:
|
|
250
|
+
outcome = TurnOutcome.Failed
|
|
251
|
+
s.enqueue(turn_id, turn.new_event_id(), _session_error("agent_error", str(inner.exception())))
|
|
252
|
+
s.enqueue(turn_id, turn.new_event_id(), {
|
|
253
|
+
"type": AgentEventType.SessionStatusIdle.value, "stop_reason": {"type": "end_turn"}, "turn_outcome": outcome.value,
|
|
254
|
+
})
|
|
255
|
+
if s.turn is turn:
|
|
256
|
+
s.turn = None
|
|
257
|
+
turn._done.set() # noqa: SLF001
|
|
258
|
+
|
|
259
|
+
async def turn_input(self, params: TurnInputParams) -> dict[str, bool]:
|
|
260
|
+
s = self.session(params.get("session_id", ""), False)
|
|
261
|
+
if s is None:
|
|
262
|
+
raise RPCError(JsonRpcErrorCode.SessionNotActive, "session_not_active")
|
|
263
|
+
turn = s.turn
|
|
264
|
+
if turn is None or turn.turn_id != params.get("turn_id") or turn.ended:
|
|
265
|
+
raise RPCError(JsonRpcErrorCode.Conflict, "turn is not running; start a new turn")
|
|
266
|
+
if params.get("input_id") in turn._seen: # noqa: SLF001
|
|
267
|
+
return {"accepted": True}
|
|
268
|
+
turn._seen.add(params.get("input_id", "")) # noqa: SLF001
|
|
269
|
+
put = asyncio.ensure_future(turn.inputs.put(params))
|
|
270
|
+
ended = asyncio.ensure_future(turn._done.wait()) # noqa: SLF001
|
|
271
|
+
await asyncio.wait({put, ended}, return_when=asyncio.FIRST_COMPLETED)
|
|
272
|
+
ended.cancel()
|
|
273
|
+
if not put.done():
|
|
274
|
+
put.cancel()
|
|
275
|
+
raise RPCError(JsonRpcErrorCode.Conflict, "turn ended; start a new turn")
|
|
276
|
+
return {"accepted": True}
|
|
277
|
+
|
|
278
|
+
async def turn_cancel(self, params: dict[str, Any]) -> dict[str, bool]:
|
|
279
|
+
s = self.session(params.get("session_id", ""), False)
|
|
280
|
+
turn = s.turn if s is not None else None
|
|
281
|
+
if turn is None or turn.turn_id != params.get("turn_id") or turn.ended:
|
|
282
|
+
return {"canceled": False}
|
|
283
|
+
_cancel_turn(turn)
|
|
284
|
+
return {"canceled": True}
|
|
285
|
+
|
|
286
|
+
async def session_close(self, params: dict[str, Any]) -> dict[str, bool]:
|
|
287
|
+
s = self.session(params.get("session_id", ""), False)
|
|
288
|
+
if s is None:
|
|
289
|
+
return {"closed": False}
|
|
290
|
+
turn = s.turn
|
|
291
|
+
if turn is not None:
|
|
292
|
+
_cancel_turn(turn)
|
|
293
|
+
await turn._done.wait() # noqa: SLF001
|
|
294
|
+
try:
|
|
295
|
+
await asyncio.wait_for(s.drained.wait(), _AGENT_CLOSE_FLUSH_WAIT)
|
|
296
|
+
except asyncio.TimeoutError as error:
|
|
297
|
+
self.client._log("warn", f"session.close: events of {s.id} still unacknowledged", error) # noqa: SLF001
|
|
298
|
+
await self.handler.close_session(s.id, params.get("reason", ""))
|
|
299
|
+
s.closed = True
|
|
300
|
+
s.wake.set()
|
|
301
|
+
self.sessions.pop(s.id, None)
|
|
302
|
+
self.client._sessions.discard(s.id) # noqa: SLF001
|
|
303
|
+
|
|
304
|
+
async def ended() -> None:
|
|
305
|
+
try:
|
|
306
|
+
await asyncio.wait_for(
|
|
307
|
+
s.call_until_connected(lambda: self.client.call(METHOD_SESSION_ENDED, {"session_id": s.id, "reason": params.get("reason", "")})),
|
|
308
|
+
_AGENT_CLOSE_FLUSH_WAIT,
|
|
309
|
+
)
|
|
310
|
+
except Exception as error:
|
|
311
|
+
self.client._log("warn", f"session.ended for {s.id} failed", error) # noqa: SLF001
|
|
312
|
+
|
|
313
|
+
self.client._spawn(ended()) # noqa: SLF001
|
|
314
|
+
return {"closed": True}
|
|
315
|
+
|
|
316
|
+
async def interaction_response(self, params: InteractionResponseParams) -> dict[str, bool]:
|
|
317
|
+
s = self.session(params.get("session_id", ""), False)
|
|
318
|
+
if s is None:
|
|
319
|
+
return {"accepted": False}
|
|
320
|
+
interaction_id = params.get("interaction_id", "")
|
|
321
|
+
waiter = s.pending.get(interaction_id)
|
|
322
|
+
if waiter is None or interaction_id in s.answered or waiter.done():
|
|
323
|
+
return {"accepted": False}
|
|
324
|
+
s.answered.add(interaction_id)
|
|
325
|
+
waiter.set_result(params)
|
|
326
|
+
return {"accepted": True}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _cancel_turn(turn: AgentTurn) -> None:
|
|
330
|
+
turn._canceled = True # noqa: SLF001
|
|
331
|
+
if turn._task is not None: # noqa: SLF001
|
|
332
|
+
turn._task.cancel() # noqa: SLF001
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _session_error(kind: str, message: str) -> dict[str, Any]:
|
|
336
|
+
return {"type": AgentEventType.SessionError.value, "error": {"type": kind, "message": message}}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class _AgentSession:
|
|
340
|
+
"""One platform session on this executor: its outbox of durable events and
|
|
341
|
+
at most one running turn."""
|
|
342
|
+
|
|
343
|
+
def __init__(self, runtime: _AgentRuntime, session_id: str) -> None:
|
|
344
|
+
self.runtime = runtime
|
|
345
|
+
self.id = session_id
|
|
346
|
+
self.next_seq = 1
|
|
347
|
+
self.next_id = 0
|
|
348
|
+
self.outbox: deque[dict[str, Any]] = deque()
|
|
349
|
+
self.wake = asyncio.Event()
|
|
350
|
+
self.drained = asyncio.Event()
|
|
351
|
+
self.drained.set()
|
|
352
|
+
self.closed = False
|
|
353
|
+
self.turn: AgentTurn | None = None
|
|
354
|
+
self.results: dict[str, TurnStartResult] = {}
|
|
355
|
+
self.pending: dict[str, asyncio.Future[InteractionResponseParams]] = {}
|
|
356
|
+
self.answered: set[str] = set()
|
|
357
|
+
self.sender: asyncio.Task[Any] | None = None
|
|
358
|
+
|
|
359
|
+
def new_event_id(self) -> str:
|
|
360
|
+
self.next_id += 1
|
|
361
|
+
return f"evt_{self.id}_{self.next_id}"
|
|
362
|
+
|
|
363
|
+
def enqueue(self, turn_id: str, event_id: str, event: Any) -> None:
|
|
364
|
+
if self.closed:
|
|
365
|
+
raise AgentSessionClosedError()
|
|
366
|
+
self.outbox.append({"session_id": self.id, "turn_id": turn_id, "event_id": event_id, "sequence": self.next_seq, "event": event})
|
|
367
|
+
self.next_seq += 1
|
|
368
|
+
self.drained.clear()
|
|
369
|
+
self.wake.set()
|
|
370
|
+
|
|
371
|
+
async def withdraw(self, interaction_id: str, reason: str) -> None:
|
|
372
|
+
self.answered.add(interaction_id)
|
|
373
|
+
client = self.runtime.client
|
|
374
|
+
params: InteractionCancelParams = {"session_id": self.id, "interaction_id": interaction_id, "reason": reason} # type: ignore[typeddict-item]
|
|
375
|
+
try:
|
|
376
|
+
await asyncio.wait_for(self.call_until_connected(lambda: client.call(METHOD_INTERACTION_CANCEL, params)), _AGENT_CLOSE_FLUSH_WAIT)
|
|
377
|
+
except Exception as error:
|
|
378
|
+
client._log("warn", f"interaction.cancel for {interaction_id} failed", error) # noqa: SLF001
|
|
379
|
+
|
|
380
|
+
async def send_loop(self) -> None:
|
|
381
|
+
"""Deliver the outbox head first, one event at a time, so the platform
|
|
382
|
+
sees every session in sequence order. A transport failure keeps the
|
|
383
|
+
event and waits for the next registration: the resend after reconnect."""
|
|
384
|
+
client = self.runtime.client
|
|
385
|
+
while True:
|
|
386
|
+
if not self.outbox:
|
|
387
|
+
if self.closed:
|
|
388
|
+
return
|
|
389
|
+
self.wake.clear()
|
|
390
|
+
await self.wake.wait()
|
|
391
|
+
continue
|
|
392
|
+
head = self.outbox[0]
|
|
393
|
+
try:
|
|
394
|
+
await self.call_until_connected(lambda: client.call(METHOD_SESSION_EVENT, head))
|
|
395
|
+
except ClientStoppedError:
|
|
396
|
+
return
|
|
397
|
+
except RPCError as error:
|
|
398
|
+
# A permanent rejection cannot be fixed by resending; surface it
|
|
399
|
+
# instead of blocking the session.
|
|
400
|
+
client._log("error", f"session.event {head['event_id']} rejected", error) # noqa: SLF001
|
|
401
|
+
self.outbox.popleft()
|
|
402
|
+
if not self.outbox:
|
|
403
|
+
self.drained.set()
|
|
404
|
+
|
|
405
|
+
async def call_until_connected(self, call: Callable[[], Awaitable[Any]]) -> Any:
|
|
406
|
+
"""Retry call across disconnects. Only transport failures,
|
|
407
|
+
executor_busy and retryable JSON-RPC errors are retried."""
|
|
408
|
+
client = self.runtime.client
|
|
409
|
+
while True:
|
|
410
|
+
registered = client._registered # noqa: SLF001
|
|
411
|
+
try:
|
|
412
|
+
return await call()
|
|
413
|
+
except RPCError as error:
|
|
414
|
+
if not error.retryable and error.code != JsonRpcErrorCode.ExecutorBusy:
|
|
415
|
+
raise
|
|
416
|
+
except (NotConnectedError, TimeoutError):
|
|
417
|
+
pass
|
|
418
|
+
if client._stopped: # noqa: SLF001
|
|
419
|
+
raise ClientStoppedError()
|
|
420
|
+
connected = client.state in (ConnectionState.Ready, ConnectionState.Draining)
|
|
421
|
+
try:
|
|
422
|
+
await asyncio.wait_for(registered.wait(), _AGENT_RESEND_DELAY if connected else None)
|
|
423
|
+
except asyncio.TimeoutError:
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# --- harness side ---
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@dataclass(frozen=True)
|
|
431
|
+
class AgentEventRecord:
|
|
432
|
+
"""One validated durable agent event."""
|
|
433
|
+
|
|
434
|
+
session_id: str
|
|
435
|
+
turn_id: str
|
|
436
|
+
event_id: str
|
|
437
|
+
sequence: int
|
|
438
|
+
type: str
|
|
439
|
+
event: dict[str, Any]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class AgentPlatform(Protocol):
|
|
443
|
+
"""Where durable agent events, previews and interactions go."""
|
|
444
|
+
|
|
445
|
+
async def acked_sequence(self, session_id: str) -> int:
|
|
446
|
+
"""Highest contiguous sequence already persisted (0 when none); asked
|
|
447
|
+
once per session per process so a restarted platform keeps
|
|
448
|
+
deduplicating resent events."""
|
|
449
|
+
...
|
|
450
|
+
|
|
451
|
+
async def agent_event(self, executor: Executor | None, record: AgentEventRecord) -> None:
|
|
452
|
+
"""Persist one durable event; called once per event, in sequence order
|
|
453
|
+
per session. Only after it returns is the event acknowledged."""
|
|
454
|
+
...
|
|
455
|
+
|
|
456
|
+
def agent_delta(self, executor: Executor, delta: SessionEventDeltaParams) -> None:
|
|
457
|
+
"""Relay a transient preview. Never persisted."""
|
|
458
|
+
...
|
|
459
|
+
|
|
460
|
+
async def interaction_requested(self, executor: Executor, request: InteractionRequestParams) -> str:
|
|
461
|
+
"""Open (or, for a repeated interaction_id, return) the platform
|
|
462
|
+
interaction; answer it later with Executor.respond_interaction."""
|
|
463
|
+
...
|
|
464
|
+
|
|
465
|
+
async def interaction_canceled(self, executor: Executor, cancel: InteractionCancelParams) -> None:
|
|
466
|
+
...
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
_AGENT_EVENT_TYPES = frozenset(member.value for member in AgentEventType)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class _AgentLedger:
|
|
473
|
+
def __init__(self) -> None:
|
|
474
|
+
self.acked: dict[str, int] = {}
|
|
475
|
+
self.locks: dict[str, asyncio.Lock] = {}
|
|
476
|
+
|
|
477
|
+
def lock(self, session_id: str) -> asyncio.Lock:
|
|
478
|
+
return self.locks.setdefault(session_id, asyncio.Lock())
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
async def _dispatch_agent(h: Harness, executor: Executor, method: str, params: dict[str, Any]) -> tuple[bool, Any]:
|
|
482
|
+
platform = h.agent
|
|
483
|
+
if platform is None:
|
|
484
|
+
return False, None
|
|
485
|
+
if method == METHOD_SESSION_EVENT:
|
|
486
|
+
if not params.get("event_id"):
|
|
487
|
+
return False, None # a tool executor's event
|
|
488
|
+
return True, await agent_event(h, executor, params)
|
|
489
|
+
if method == METHOD_INTERACTION_REQUEST:
|
|
490
|
+
kind = params.get("kind")
|
|
491
|
+
if (
|
|
492
|
+
not params.get("session_id") or not params.get("interaction_id")
|
|
493
|
+
or kind not in (InteractionKind.Permission.value, InteractionKind.Question.value)
|
|
494
|
+
or (kind == InteractionKind.Permission.value and not params.get("tool_use_id"))
|
|
495
|
+
or (kind == InteractionKind.Question.value and params.get("question") is None)
|
|
496
|
+
):
|
|
497
|
+
raise rpc_error(JsonRpcErrorCode.InvalidParams, "invalid interaction.request params", False)
|
|
498
|
+
try:
|
|
499
|
+
platform_id = await platform.interaction_requested(executor, params) # type: ignore[arg-type]
|
|
500
|
+
except Exception as error:
|
|
501
|
+
raise rpc_error(JsonRpcErrorCode.InternalError, str(error), True) from error
|
|
502
|
+
return True, {"accepted": True, "platform_interaction_id": platform_id}
|
|
503
|
+
if method == METHOD_INTERACTION_CANCEL:
|
|
504
|
+
if not params.get("session_id") or not params.get("interaction_id"):
|
|
505
|
+
raise rpc_error(JsonRpcErrorCode.InvalidParams, "invalid interaction.cancel params", False)
|
|
506
|
+
try:
|
|
507
|
+
await platform.interaction_canceled(executor, params) # type: ignore[arg-type]
|
|
508
|
+
except Exception as error:
|
|
509
|
+
raise rpc_error(JsonRpcErrorCode.InternalError, str(error), True) from error
|
|
510
|
+
return True, {"accepted": True}
|
|
511
|
+
return False, None
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
async def agent_event(h: Harness, executor: Executor | None, params: dict[str, Any]) -> dict[str, Any]:
|
|
515
|
+
"""Validate and persist one agent session.event (sequence-checked)."""
|
|
516
|
+
platform = h.agent
|
|
517
|
+
assert platform is not None
|
|
518
|
+
event = params.get("event")
|
|
519
|
+
sequence = params.get("sequence")
|
|
520
|
+
if (
|
|
521
|
+
not params.get("session_id") or not params.get("turn_id") or not isinstance(sequence, int) or sequence < 1
|
|
522
|
+
or not isinstance(event, dict) or event.get("type") not in _AGENT_EVENT_TYPES
|
|
523
|
+
):
|
|
524
|
+
raise rpc_error(JsonRpcErrorCode.InvalidParams, "invalid agent session.event", False)
|
|
525
|
+
session_id = params["session_id"]
|
|
526
|
+
ledger = h._agent_ledger # noqa: SLF001
|
|
527
|
+
async with ledger.lock(session_id):
|
|
528
|
+
acked = ledger.acked.get(session_id)
|
|
529
|
+
if acked is None:
|
|
530
|
+
try:
|
|
531
|
+
acked = await platform.acked_sequence(session_id)
|
|
532
|
+
except Exception as error:
|
|
533
|
+
raise rpc_error(JsonRpcErrorCode.InternalError, str(error), True) from error
|
|
534
|
+
result = {"accepted": True, "event_id": params["event_id"], "acked_sequence": acked}
|
|
535
|
+
if sequence <= acked:
|
|
536
|
+
return result
|
|
537
|
+
if sequence != acked + 1:
|
|
538
|
+
h._log("warn", f"agent session.event sequence gap in {session_id}") # noqa: SLF001
|
|
539
|
+
raise RPCError(JsonRpcErrorCode.Conflict, "conflict", {"retryable": False, "expected_sequence": acked + 1, "detail": f"expected_sequence={acked + 1}"})
|
|
540
|
+
record = AgentEventRecord(session_id, params["turn_id"], params["event_id"], sequence, event["type"], event)
|
|
541
|
+
try:
|
|
542
|
+
await platform.agent_event(executor, record)
|
|
543
|
+
except Exception as error:
|
|
544
|
+
raise rpc_error(JsonRpcErrorCode.InternalError, str(error), True) from error
|
|
545
|
+
ledger.acked[session_id] = sequence
|
|
546
|
+
result["acked_sequence"] = sequence
|
|
547
|
+
return result
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _handle_agent_notification(h: Harness, executor: Executor, method: str, params: dict[str, Any]) -> bool:
|
|
551
|
+
if h.agent is None or method != METHOD_SESSION_EVENT_DELTA:
|
|
552
|
+
return False
|
|
553
|
+
if params.get("session_id") and params.get("event_id"):
|
|
554
|
+
h.agent.agent_delta(executor, params) # type: ignore[arg-type]
|
|
555
|
+
return True
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
__all__ = ["AgentEventRecord", "AgentHandler", "AgentPlatform", "AgentSessionClosedError", "AgentTurn"]
|