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