bazaar-compute-node 0.1.3__tar.gz → 0.1.5__tar.gz

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.
Files changed (62) hide show
  1. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/PKG-INFO +1 -1
  2. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/pyproject.toml +1 -1
  3. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/pyproject.toml.orig +1 -1
  4. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/codex_app_server/__init__.py +4 -0
  5. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/codex_app_server/client.py +34 -0
  6. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/codex_app_server/events.py +8 -33
  7. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/codex_app_server/runtime.py +51 -2
  8. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/sqlite/codec.py +0 -67
  9. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/sqlite/database.py +34 -0
  10. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/sqlite/migrations.py +15 -0
  11. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/sqlite/repository.py +0 -210
  12. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/models/entities.py +6 -22
  13. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/orchestration/session.py +107 -4
  14. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/orchestration/turn.py +104 -54
  15. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/runtime.py +16 -0
  16. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/storage.py +0 -5
  17. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/LICENSE +0 -0
  18. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/README.md +0 -0
  19. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/__init__.py +0 -0
  20. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/app/__init__.py +0 -0
  21. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/app/application.py +0 -0
  22. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/app/attachments.py +0 -0
  23. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/app/command.py +0 -0
  24. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/app/config.py +0 -0
  25. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/app/registry.py +0 -0
  26. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/app/transport.py +0 -0
  27. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/app/windows_pipe.py +0 -0
  28. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/app/wrapper.py +0 -0
  29. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/bcc.py +0 -0
  30. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/cli.py +0 -0
  31. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/__init__.py +0 -0
  32. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/codex_app_server/approval.py +0 -0
  33. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/codex_app_server/plugin.py +0 -0
  34. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/codex_app_server/process.py +0 -0
  35. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/codex_app_server/protocol.py +0 -0
  36. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/logging/__init__.py +0 -0
  37. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/logging/audit.py +0 -0
  38. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/logging/plugin.py +0 -0
  39. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/sqlite/__init__.py +0 -0
  40. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/sqlite/plugin.py +0 -0
  41. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/wecom/__init__.py +0 -0
  42. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/wecom/channel.py +0 -0
  43. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/wecom/markdown.py +0 -0
  44. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/contrib/wecom/plugin.py +0 -0
  45. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/__init__.py +0 -0
  46. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/approval.py +0 -0
  47. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/audit.py +0 -0
  48. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/channel.py +0 -0
  49. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/client.py +0 -0
  50. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/command.py +0 -0
  51. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/concurrency.py +0 -0
  52. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/correlation.py +0 -0
  53. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/instruction.py +0 -0
  54. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/lifecycle.py +0 -0
  55. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/models/__init__.py +0 -0
  56. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/models/states.py +0 -0
  57. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/observability.py +0 -0
  58. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/orchestration/__init__.py +0 -0
  59. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/orchestration/command.py +0 -0
  60. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/orchestration/services.py +0 -0
  61. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/outcomes.py +0 -0
  62. {bazaar_compute_node-0.1.3 → bazaar_compute_node-0.1.5}/src/bazaar_compute_node/core/paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazaar-compute-node
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Runtime-agnostic computer node daemon for agents and channels.
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -11,10 +11,12 @@ from .client import (
11
11
  build_thread_start_params,
12
12
  build_turn_interrupt_params,
13
13
  build_turn_start_params,
14
+ build_turn_steer_params,
14
15
  parse_error_notification,
15
16
  parse_thread_response,
16
17
  parse_turn_notification,
17
18
  parse_turn_response,
19
+ parse_turn_steer_response,
18
20
  )
19
21
  from .events import CodexTurnEventStream
20
22
  from .process import (
@@ -56,8 +58,10 @@ __all__ = [
56
58
  "build_thread_start_params",
57
59
  "build_turn_interrupt_params",
58
60
  "build_turn_start_params",
61
+ "build_turn_steer_params",
59
62
  "parse_error_notification",
60
63
  "parse_thread_response",
61
64
  "parse_turn_notification",
62
65
  "parse_turn_response",
66
+ "parse_turn_steer_response",
63
67
  ]
@@ -153,6 +153,21 @@ def build_turn_interrupt_params(thread_id: str, turn_id: str) -> dict[str, objec
153
153
  return {"threadId": thread_id, "turnId": turn_id}
154
154
 
155
155
 
156
+ def build_turn_steer_params(
157
+ thread_id: str,
158
+ turn_id: str,
159
+ input_text: str,
160
+ ) -> dict[str, object]:
161
+ _validate_non_empty_string("thread_id", thread_id)
162
+ _validate_non_empty_string("turn_id", turn_id)
163
+ _validate_non_empty_string("input_text", input_text)
164
+ return {
165
+ "threadId": thread_id,
166
+ "expectedTurnId": turn_id,
167
+ "input": [{"type": "text", "text": input_text}],
168
+ }
169
+
170
+
156
171
  class CodexAppServerClient:
157
172
  """Typed facade over the adapter-local Codex JSONL supervisor."""
158
173
 
@@ -270,6 +285,20 @@ class CodexAppServerClient:
270
285
  timeout=timeout,
271
286
  )
272
287
 
288
+ async def steer_turn(
289
+ self,
290
+ thread_id: str,
291
+ turn_id: str,
292
+ input_text: str,
293
+ *,
294
+ timeout: float,
295
+ ) -> JsonlMessage:
296
+ return await self.supervisor.request(
297
+ "turn/steer",
298
+ build_turn_steer_params(thread_id, turn_id, input_text),
299
+ timeout=timeout,
300
+ )
301
+
273
302
  async def receive(self, *, timeout: float | None = None) -> JsonlMessage:
274
303
  return await self.supervisor.receive(timeout=timeout)
275
304
 
@@ -290,6 +319,11 @@ def parse_turn_response(response: Mapping[str, object]) -> CodexTurnInfo:
290
319
  return _parse_turn(_require_mapping(result, "turn"))
291
320
 
292
321
 
322
+ def parse_turn_steer_response(response: Mapping[str, object]) -> str:
323
+ result = _require_mapping(response, "result")
324
+ return _require_text(result, "turnId", "result.turnId")
325
+
326
+
293
327
  def parse_turn_notification(message: Mapping[str, object]) -> tuple[str, CodexTurnInfo]:
294
328
  params = _require_mapping(message, "params")
295
329
  thread_id = _require_text(params, "threadId", "params.threadId")
@@ -4,7 +4,6 @@ import asyncio
4
4
  from collections.abc import Callable, Mapping
5
5
  from time import time_ns
6
6
  from typing import Self, cast
7
- from uuid import uuid7
8
7
 
9
8
  from ...core.approval import IApprovalHandler
10
9
  from ...core.models import (
@@ -21,12 +20,6 @@ from .approval import (
21
20
  parse_approval_request,
22
21
  )
23
22
 
24
- _DURABLE_ITEM_METHODS = {
25
- "item/started",
26
- "item/completed",
27
- "item/autoApprovalReview/started",
28
- "item/autoApprovalReview/completed",
29
- }
30
23
  _STREAM_EVENT_KINDS = {
31
24
  "item/agentMessage/delta": StreamEventKind.AGENT_MESSAGE_DELTA,
32
25
  "item/plan/delta": StreamEventKind.PLAN_DELTA,
@@ -56,8 +49,6 @@ class CodexTurnEventStream(IRuntimeTurnStream):
56
49
  self,
57
50
  supervisor: JsonlProcessSupervisor,
58
51
  *,
59
- node_id: str,
60
- runtime: str,
61
52
  session_id: str,
62
53
  runtime_session_id: str,
63
54
  turn_id: str,
@@ -71,8 +62,6 @@ class CodexTurnEventStream(IRuntimeTurnStream):
71
62
  on_closed: Callable[[], None] | None = None,
72
63
  ) -> None:
73
64
  self._supervisor = supervisor
74
- self._node_id = node_id
75
- self._runtime = runtime
76
65
  self._session_id = session_id
77
66
  self._runtime_session_id = runtime_session_id
78
67
  self._turn_id = turn_id
@@ -217,7 +206,6 @@ class CodexTurnEventStream(IRuntimeTurnStream):
217
206
  return self._event(
218
207
  event_name="codex.turn.error",
219
208
  state=RuntimeEventState.STARTED,
220
- error_type=error.error_type,
221
209
  error_message=error.message,
222
210
  metadata=metadata,
223
211
  )
@@ -231,9 +219,14 @@ class CodexTurnEventStream(IRuntimeTurnStream):
231
219
  return None
232
220
  if method == "item/reasoning/summaryPartAdded":
233
221
  return None
234
- if method == "turn/progress" or (
235
- method.startswith("item/") and method not in _DURABLE_ITEM_METHODS
236
- ):
222
+ if method in {
223
+ "item/started",
224
+ "item/completed",
225
+ "item/autoApprovalReview/started",
226
+ "item/autoApprovalReview/completed",
227
+ }:
228
+ return None
229
+ if method == "turn/progress" or method.startswith("item/"):
237
230
  stream_id = params.get("itemId")
238
231
  if not isinstance(stream_id, str) or not stream_id:
239
232
  stream_id = None
@@ -255,15 +248,6 @@ class CodexTurnEventStream(IRuntimeTurnStream):
255
248
  stream_id=stream_id,
256
249
  content=content,
257
250
  )
258
- if method == "turn/started" or method in _DURABLE_ITEM_METHODS:
259
- metadata = self._provider_metadata(method, params)
260
- if provider_turn_id is not None:
261
- metadata["provider_turn_id"] = provider_turn_id
262
- return self._event(
263
- event_name="codex.turn.progress",
264
- state=RuntimeEventState.STARTED,
265
- metadata=metadata,
266
- )
267
251
  return None
268
252
 
269
253
  def _event(
@@ -272,25 +256,16 @@ class CodexTurnEventStream(IRuntimeTurnStream):
272
256
  event_name: str,
273
257
  state: RuntimeEventState,
274
258
  error_kind: str | None = None,
275
- error_type: str | None = None,
276
259
  error_message: str | None = None,
277
260
  metadata: Mapping[str, object] | None = None,
278
261
  ) -> RuntimeEvent:
279
262
  return RuntimeEvent(
280
- event_seq=0,
281
- event_id=str(uuid7()),
282
263
  created_at_ms=time_ns() // 1_000_000,
283
- level="error" if error_kind else "info",
284
264
  event_name=event_name,
285
265
  state=state,
286
- node_id=self._node_id,
287
- bcn_session_id=self._session_id,
288
- runtime_session_id=self._runtime_session_id,
289
266
  turn_id=self._turn_id,
290
267
  error_kind=error_kind,
291
- error_type=error_type,
292
268
  error_message=error_message,
293
- runtime=self._runtime,
294
269
  metadata=dict(metadata or {}),
295
270
  )
296
271
 
@@ -30,6 +30,7 @@ from .client import (
30
30
  CodexAppServerClient,
31
31
  parse_thread_response,
32
32
  parse_turn_response,
33
+ parse_turn_steer_response,
33
34
  )
34
35
  from .events import CodexTurnEventStream
35
36
  from .process import JsonlProcessSpec, JsonlProcessSupervisor
@@ -327,8 +328,6 @@ class CodexAppServerRuntime(IRuntime, IAsyncLifecycle):
327
328
  connection.active_turn_id = turn.turn_id
328
329
  return CodexTurnEventStream(
329
330
  connection.supervisor,
330
- node_id=self._context.node_id,
331
- runtime=session.runtime,
332
331
  session_id=session.bcn_session_id,
333
332
  runtime_session_id=session.id,
334
333
  turn_id=turn.turn_id,
@@ -382,6 +381,56 @@ class CodexAppServerRuntime(IRuntime, IAsyncLifecycle):
382
381
  },
383
382
  )
384
383
 
384
+ async def steer_turn(
385
+ self,
386
+ session: RuntimeSession,
387
+ turn: RuntimeTurn,
388
+ input_text: str,
389
+ *,
390
+ timeout: float,
391
+ ) -> bool:
392
+ self._ensure_started()
393
+ connection = self._connections.get(session.id)
394
+ provider_turn_id = turn.provider_turn_id
395
+ if (
396
+ connection is None
397
+ or not connection.supervisor.is_running
398
+ or connection.active_turn_id != turn.turn_id
399
+ or provider_turn_id is None
400
+ ):
401
+ return False
402
+ try:
403
+ response = await connection.client.steer_turn(
404
+ connection.provider_thread_id,
405
+ provider_turn_id,
406
+ input_text,
407
+ timeout=timeout,
408
+ )
409
+ accepted_turn_id = parse_turn_steer_response(response)
410
+ if accepted_turn_id != provider_turn_id:
411
+ raise CodexAppServerProtocolError(
412
+ "turn/steer returned a different provider turn"
413
+ )
414
+ except asyncio.CancelledError:
415
+ raise
416
+ except Exception as error: # noqa: BLE001
417
+ self._logger.warning(
418
+ "%s",
419
+ json.dumps(
420
+ {
421
+ "event_name": "runtime.turn.steer.not_accepted",
422
+ "metadata": {
423
+ "error_type": type(error).__name__,
424
+ "session_id": session.bcn_session_id,
425
+ },
426
+ },
427
+ separators=(",", ":"),
428
+ sort_keys=True,
429
+ ),
430
+ )
431
+ return False
432
+ return True
433
+
385
434
  async def stop_session(
386
435
  self, session: RuntimeSession, *, timeout: float
387
436
  ) -> ProviderCallResult[RuntimeSession]:
@@ -17,8 +17,6 @@ from ...core.models import (
17
17
  OutboundDeliveryState,
18
18
  OutboundMessage,
19
19
  RuntimeAttempt,
20
- RuntimeEvent,
21
- RuntimeEventState,
22
20
  RuntimeSession,
23
21
  )
24
22
 
@@ -214,40 +212,6 @@ def _outbound_message_from_row(row: aiosqlite.Row) -> OutboundMessage:
214
212
  )
215
213
 
216
214
 
217
- def _runtime_event_from_row(row: aiosqlite.Row) -> RuntimeEvent:
218
- return RuntimeEvent(
219
- event_seq=_required_non_negative_int(row["event_seq"], "event_seq"),
220
- event_id=_required_text(row["event_id"], "event_id"),
221
- created_at_ms=_required_non_negative_int(row["created_at_ms"], "created_at_ms"),
222
- level=_required_text(row["level"], "level"),
223
- event_name=_required_text(row["event_name"], "event_name"),
224
- state=RuntimeEventState(_required_text(row["state"], "runtime_event.state")),
225
- duration_ms=_optional_non_negative_int(row["duration_ms"], "duration_ms"),
226
- node_id=_optional_text(row["node_id"], "node_id"),
227
- channel=_optional_text(row["channel"], "channel"),
228
- runtime=_optional_text(row["runtime"], "runtime"),
229
- channel_session_id=_optional_text(
230
- row["channel_session_id"], "channel_session_id"
231
- ),
232
- bcn_session_id=_optional_text(row["bcn_session_id"], "bcn_session_id"),
233
- runtime_session_id=_optional_text(
234
- row["runtime_session_id"], "runtime_session_id"
235
- ),
236
- turn_id=_optional_text(row["turn_id"], "turn_id"),
237
- request_id=_optional_text(row["request_id"], "request_id"),
238
- command_id=_optional_text(row["command_id"], "command_id"),
239
- inbound_seq=_optional_non_negative_int(row["inbound_seq"], "inbound_seq"),
240
- outbound_message_id=_optional_text(
241
- row["outbound_message_id"], "outbound_message_id"
242
- ),
243
- error_kind=_optional_text(row["error_kind"], "error_kind"),
244
- error_type=_optional_text(row["error_type"], "error_type"),
245
- error_message=_optional_text(row["error_message"], "error_message"),
246
- traceback_ref=_optional_text(row["traceback_ref"], "traceback_ref"),
247
- metadata=_decode_metadata(row["metadata_json"], "metadata_json"),
248
- )
249
-
250
-
251
215
  def _consumer_cursor_from_row(row: aiosqlite.Row) -> ConsumerCursor:
252
216
  return ConsumerCursor(
253
217
  session_id=_required_text(row["session_id"], "session_id"),
@@ -508,37 +472,6 @@ def _merge_timestamp(
508
472
  return incoming if incoming is not None else existing
509
473
 
510
474
 
511
- def _validate_runtime_event_input(event: RuntimeEvent) -> None:
512
- if not isinstance(event, RuntimeEvent):
513
- raise TypeError("event must be a RuntimeEvent")
514
- if not isinstance(event.state, RuntimeEventState):
515
- raise TypeError("runtime event state is invalid")
516
- for value, field_name in (
517
- (event.node_id, "node_id"),
518
- (event.channel, "channel"),
519
- (event.runtime, "runtime"),
520
- (event.channel_session_id, "channel_session_id"),
521
- (event.bcn_session_id, "bcn_session_id"),
522
- (event.runtime_session_id, "runtime_session_id"),
523
- (event.turn_id, "turn_id"),
524
- (event.request_id, "request_id"),
525
- (event.command_id, "command_id"),
526
- (event.outbound_message_id, "outbound_message_id"),
527
- (event.error_kind, "error_kind"),
528
- (event.error_type, "error_type"),
529
- (event.error_message, "error_message"),
530
- (event.traceback_ref, "traceback_ref"),
531
- ):
532
- _validate_optional_input_text(value, field_name)
533
-
534
-
535
- def _same_runtime_event_payload(
536
- existing: RuntimeEvent,
537
- incoming: RuntimeEvent,
538
- ) -> bool:
539
- return replace(existing, event_seq=incoming.event_seq) == incoming
540
-
541
-
542
475
  def _validate_optional_input_text(value: object, field_name: str) -> None:
543
476
  if value is not None and (not isinstance(value, str) or not value):
544
477
  raise ValueError(f"{field_name} must be a non-empty string when present")
@@ -13,6 +13,7 @@ import aiosqlite
13
13
  from ...core.paths import resolve_data_dir
14
14
  from ...core.storage import NodeIdentity
15
15
  from .migrations import (
16
+ RUNTIME_EVENTS_REMOVAL_MIGRATION,
16
17
  MigrationChecksumError,
17
18
  MigrationError,
18
19
  apply_migrations,
@@ -117,6 +118,39 @@ class SqliteDatabase:
117
118
  transaction,
118
119
  clock=_current_time_ms,
119
120
  )
121
+ compaction_row: aiosqlite.Row | None = None
122
+ async with SqliteTransaction(self) as transaction:
123
+ compaction_row = await transaction.fetchone(
124
+ "SELECT compaction_completed_at_ms "
125
+ "FROM schema_migrations WHERE version = ?",
126
+ (RUNTIME_EVENTS_REMOVAL_MIGRATION.version,),
127
+ )
128
+ if compaction_row is None:
129
+ raise MigrationError(
130
+ "runtime event removal migration is missing from ledger"
131
+ )
132
+ if compaction_row["compaction_completed_at_ms"] is None:
133
+ async with self._transaction_lock:
134
+ await connection.execute("VACUUM")
135
+ async with SqliteTransaction(self) as transaction:
136
+ await transaction.execute(
137
+ "UPDATE schema_migrations "
138
+ "SET compaction_completed_at_ms = ? WHERE version = ?",
139
+ (
140
+ _current_time_ms(),
141
+ RUNTIME_EVENTS_REMOVAL_MIGRATION.version,
142
+ ),
143
+ )
144
+ async with self._transaction_lock:
145
+ checkpoint_cursor = await connection.execute(
146
+ "PRAGMA wal_checkpoint(TRUNCATE)"
147
+ )
148
+ try:
149
+ checkpoint_row = await checkpoint_cursor.fetchone()
150
+ finally:
151
+ await checkpoint_cursor.close()
152
+ if checkpoint_row is None or checkpoint_row[0] != 0:
153
+ raise MigrationError("SQLite WAL checkpoint could not complete")
120
154
  except BaseException:
121
155
  self._connection = None
122
156
  self._node_state = None
@@ -551,6 +551,20 @@ TRANSIENT_STREAM_EVENT_MIGRATION = Migration(
551
551
  ),
552
552
  )
553
553
 
554
+ RUNTIME_EVENTS_REMOVAL_MIGRATION = Migration(
555
+ version=9,
556
+ name="remove_runtime_events",
557
+ statements=(
558
+ """
559
+ ALTER TABLE schema_migrations
560
+ ADD COLUMN compaction_completed_at_ms INTEGER
561
+ """,
562
+ """
563
+ DROP TABLE runtime_events
564
+ """,
565
+ ),
566
+ )
567
+
554
568
  MIGRATIONS: tuple[Migration, ...] = (
555
569
  SCHEMA_MIGRATION,
556
570
  SESSION_MAPPING_INDEX_MIGRATION,
@@ -560,6 +574,7 @@ MIGRATIONS: tuple[Migration, ...] = (
560
574
  INBOUND_MESSAGE_REFERENCE_INTEGRITY_MIGRATION,
561
575
  INBOUND_PROVIDER_IDENTITY_MIGRATION,
562
576
  TRANSIENT_STREAM_EVENT_MIGRATION,
577
+ RUNTIME_EVENTS_REMOVAL_MIGRATION,
563
578
  )
564
579
 
565
580
 
@@ -17,7 +17,6 @@ from ...core.models import (
17
17
  InboundMessage,
18
18
  OutboundMessage,
19
19
  RuntimeAttempt,
20
- RuntimeEvent,
21
20
  RuntimeSession,
22
21
  )
23
22
  from .codec import (
@@ -31,9 +30,7 @@ from .codec import (
31
30
  _required_non_negative_int,
32
31
  _required_positive_int,
33
32
  _runtime_attempt_from_row,
34
- _runtime_event_from_row,
35
33
  _runtime_session_from_row,
36
- _same_runtime_event_payload,
37
34
  _validate_bcn_session_update,
38
35
  _validate_channel_session_input,
39
36
  _validate_channel_session_update,
@@ -47,7 +44,6 @@ from .codec import (
47
44
  _validate_outbound_message_input,
48
45
  _validate_outbound_update,
49
46
  _validate_positive_int,
50
- _validate_runtime_event_input,
51
47
  _validate_runtime_session_update,
52
48
  )
53
49
 
@@ -664,212 +660,6 @@ class SqliteTransaction(AbstractAsyncContextManager["SqliteTransaction"]):
664
660
  )
665
661
  return canonical
666
662
 
667
- async def append_runtime_event(self, event: RuntimeEvent) -> RuntimeEvent:
668
- _validate_runtime_event_input(event)
669
- existing_row = await self._fetch_one_or_conflict(
670
- "SELECT event_seq, event_id, created_at_ms, level, event_name, state, "
671
- "duration_ms, node_id, channel, runtime, "
672
- "channel_session_id, bcn_session_id, runtime_session_id, "
673
- "turn_id, request_id, command_id, inbound_seq, outbound_message_id, "
674
- "error_kind, error_type, error_message, traceback_ref, metadata_json "
675
- "FROM runtime_events WHERE event_id = ? ORDER BY event_seq",
676
- (event.event_id,),
677
- "runtime event identity",
678
- )
679
- if existing_row is not None:
680
- existing = _runtime_event_from_row(existing_row)
681
- if _same_runtime_event_payload(existing, event):
682
- return existing
683
- raise ValueError(
684
- "runtime event id is already bound to different event content"
685
- )
686
-
687
- await self._validate_runtime_event_references(event)
688
- sequence_row = await self.fetchone(
689
- "SELECT COALESCE(MAX(event_seq), 0) + 1 AS next_event_seq "
690
- "FROM runtime_events"
691
- )
692
- if sequence_row is None:
693
- raise RuntimeError("SQLite runtime event sequence query returned no row")
694
- next_event_seq = _required_positive_int(
695
- sequence_row["next_event_seq"], "next_event_seq"
696
- )
697
- canonical = replace(event, event_seq=next_event_seq)
698
- await self.execute(
699
- "INSERT INTO runtime_events ("
700
- "event_seq, event_id, created_at_ms, level, event_name, state, "
701
- "duration_ms, node_id, channel, runtime, "
702
- "channel_session_id, bcn_session_id, runtime_session_id, "
703
- "turn_id, request_id, command_id, inbound_seq, outbound_message_id, "
704
- "error_kind, error_type, error_message, traceback_ref, metadata_json"
705
- ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
706
- (
707
- canonical.event_seq,
708
- canonical.event_id,
709
- canonical.created_at_ms,
710
- canonical.level,
711
- canonical.event_name,
712
- canonical.state.value,
713
- canonical.duration_ms,
714
- canonical.node_id,
715
- canonical.channel,
716
- canonical.runtime,
717
- canonical.channel_session_id,
718
- canonical.bcn_session_id,
719
- canonical.runtime_session_id,
720
- canonical.turn_id,
721
- canonical.request_id,
722
- canonical.command_id,
723
- canonical.inbound_seq,
724
- canonical.outbound_message_id,
725
- canonical.error_kind,
726
- canonical.error_type,
727
- canonical.error_message,
728
- canonical.traceback_ref,
729
- _encode_metadata(canonical.metadata),
730
- ),
731
- )
732
- return canonical
733
-
734
- async def _validate_runtime_event_references(self, event: RuntimeEvent) -> None:
735
- channel_session = None
736
- if event.channel_session_id is not None:
737
- channel_session = await self.get_channel_session(event.channel_session_id)
738
- if channel_session is None:
739
- raise ValueError(f"unknown channel session: {event.channel_session_id}")
740
- if event.channel is not None and event.channel != channel_session.channel:
741
- raise ValueError("runtime event channel binding does not match")
742
-
743
- bcn_session = None
744
- if event.bcn_session_id is not None:
745
- bcn_session = await self.get_bcn_session(event.bcn_session_id)
746
- if bcn_session is None:
747
- raise ValueError(f"unknown bcn session: {event.bcn_session_id}")
748
- if (
749
- event.channel_session_id is not None
750
- and bcn_session.channel_session_id != event.channel_session_id
751
- ):
752
- raise ValueError("runtime event bcn/channel binding does not match")
753
- if channel_session is None:
754
- channel_session = await self.get_channel_session(
755
- bcn_session.channel_session_id
756
- )
757
- if (
758
- event.channel is not None
759
- and channel_session is not None
760
- and event.channel != channel_session.channel
761
- ):
762
- raise ValueError("runtime event channel binding does not match")
763
-
764
- runtime_session = None
765
- if event.runtime_session_id is not None:
766
- runtime_session = await self.get_runtime_session(event.runtime_session_id)
767
- if runtime_session is None:
768
- raise ValueError(f"unknown runtime session: {event.runtime_session_id}")
769
- if (
770
- event.bcn_session_id is not None
771
- and runtime_session.bcn_session_id != event.bcn_session_id
772
- ):
773
- raise ValueError("runtime event runtime/bcn binding does not match")
774
- if (
775
- event.channel_session_id is not None
776
- and runtime_session.channel_session_id != event.channel_session_id
777
- ):
778
- raise ValueError("runtime event runtime/channel binding does not match")
779
- if event.runtime is not None and runtime_session.runtime != event.runtime:
780
- raise ValueError("runtime event runtime name does not match")
781
- if event.channel is not None:
782
- runtime_channel = await self.get_channel_session(
783
- runtime_session.channel_session_id
784
- )
785
- if (
786
- runtime_channel is not None
787
- and runtime_channel.channel != event.channel
788
- ):
789
- raise ValueError("runtime event channel binding does not match")
790
-
791
- if event.turn_id is not None:
792
- attempt = await self.get_runtime_attempt(event.turn_id)
793
- if attempt is None:
794
- raise ValueError(f"unknown runtime attempt: {event.turn_id}")
795
- if (
796
- event.runtime_session_id is not None
797
- and attempt.session_id != event.runtime_session_id
798
- ):
799
- raise ValueError("runtime event attempt/runtime binding does not match")
800
- if runtime_session is None:
801
- runtime_session = await self.get_runtime_session(attempt.session_id)
802
- if (
803
- event.bcn_session_id is not None
804
- and runtime_session is not None
805
- and runtime_session.bcn_session_id != event.bcn_session_id
806
- ):
807
- raise ValueError("runtime event turn/bcn binding does not match")
808
- if runtime_session is not None:
809
- if (
810
- event.channel_session_id is not None
811
- and runtime_session.channel_session_id != event.channel_session_id
812
- ):
813
- raise ValueError(
814
- "runtime event turn/channel binding does not match"
815
- )
816
- if (
817
- event.runtime is not None
818
- and runtime_session.runtime != event.runtime
819
- ):
820
- raise ValueError("runtime event turn/runtime name does not match")
821
- if event.channel is not None:
822
- turn_channel = await self.get_channel_session(
823
- runtime_session.channel_session_id
824
- )
825
- if (
826
- turn_channel is not None
827
- and turn_channel.channel != event.channel
828
- ):
829
- raise ValueError(
830
- "runtime event turn/channel binding does not match"
831
- )
832
-
833
- if event.outbound_message_id is not None:
834
- outbound = await self.get_outbound_message(event.outbound_message_id)
835
- if outbound is None:
836
- raise ValueError(
837
- f"unknown outbound message: {event.outbound_message_id}"
838
- )
839
- if (
840
- event.bcn_session_id is not None
841
- and outbound.session_id != event.bcn_session_id
842
- ):
843
- raise ValueError("runtime event outbound/bcn binding does not match")
844
- if (
845
- event.channel_session_id is not None
846
- and outbound.channel_session_id != event.channel_session_id
847
- ):
848
- raise ValueError(
849
- "runtime event outbound/channel binding does not match"
850
- )
851
- if event.channel is not None:
852
- outbound_channel = await self.get_channel_session(
853
- outbound.channel_session_id
854
- )
855
- if (
856
- outbound_channel is not None
857
- and outbound_channel.channel != event.channel
858
- ):
859
- raise ValueError(
860
- "runtime event outbound/channel binding does not match"
861
- )
862
-
863
- if event.inbound_seq is not None and event.bcn_session_id is not None:
864
- row = await self.fetchone(
865
- "SELECT 1 FROM inbound_messages WHERE session_id = ? AND seq = ?",
866
- (event.bcn_session_id, event.inbound_seq),
867
- )
868
- if row is None:
869
- raise ValueError(
870
- f"unknown inbound sequence for bcn session: {event.inbound_seq}"
871
- )
872
-
873
663
  async def save_channel_session(self, session: ChannelSession) -> None:
874
664
  _validate_channel_session_input(session)
875
665
  existing = await self.get_channel_session(session.id)
@@ -476,39 +476,23 @@ class StreamEvent:
476
476
 
477
477
  @dataclass(frozen=True, slots=True)
478
478
  class RuntimeEvent:
479
- event_seq: int
480
- event_id: str
481
479
  created_at_ms: int
482
- level: str
483
480
  event_name: str
484
481
  state: RuntimeEventState
485
- duration_ms: int | None = None
486
- node_id: str | None = None
487
- channel: str | None = None
488
- channel_session_id: str | None = None
489
- bcn_session_id: str | None = None
490
- runtime_session_id: str | None = None
491
482
  turn_id: str | None = None
492
- request_id: str | None = None
493
- command_id: str | None = None
494
- inbound_seq: int | None = None
495
- outbound_message_id: str | None = None
496
483
  error_kind: str | None = None
497
- error_type: str | None = None
498
484
  error_message: str | None = None
499
- traceback_ref: str | None = None
500
- runtime: str | None = None
501
485
  metadata: Metadata = field(default_factory=dict)
502
486
 
503
487
  def __post_init__(self) -> None:
504
- _validate_non_negative(self.event_seq, "event_seq")
505
- _validate_text(self.event_id, "event_id")
506
488
  _validate_non_negative(self.created_at_ms, "created_at_ms")
507
- _validate_text(self.level, "level")
508
489
  _validate_text(self.event_name, "event_name")
490
+ if not isinstance(self.state, RuntimeEventState):
491
+ raise TypeError("state must be a RuntimeEventState")
509
492
  for value, field_name in (
510
- (self.duration_ms, "duration_ms"),
511
- (self.inbound_seq, "inbound_seq"),
493
+ (self.turn_id, "turn_id"),
494
+ (self.error_kind, "error_kind"),
495
+ (self.error_message, "error_message"),
512
496
  ):
513
497
  if value is not None:
514
- _validate_non_negative(value, field_name)
498
+ _validate_text(value, field_name)
@@ -332,29 +332,132 @@ class SessionOrchestrator(IAsyncLifecycle):
332
332
  self,
333
333
  queue: asyncio.Queue[_RuntimeNotification],
334
334
  ) -> None:
335
+ pending: list[_RuntimeNotification] = []
335
336
  while True:
336
- batch = [await queue.get()]
337
+ if pending:
338
+ batch = pending
339
+ pending = []
340
+ else:
341
+ batch = [await queue.get()]
337
342
  while True:
338
343
  try:
339
344
  batch.append(queue.get_nowait())
340
345
  except asyncio.QueueEmpty:
341
346
  break
347
+ turn_task = asyncio.create_task(
348
+ self._run_notification(batch[0]),
349
+ name=f"bcn-turn-{batch[0].context.bcn_session.id}",
350
+ )
351
+ queue_task = asyncio.create_task(queue.get())
352
+ queue_notification_consumed = False
342
353
  try:
343
- result = await self._run_notification(batch[0])
354
+ while True:
355
+ notification: _RuntimeNotification | None = None
356
+ done, _ = await asyncio.wait(
357
+ (turn_task, queue_task),
358
+ return_when=asyncio.FIRST_COMPLETED,
359
+ )
360
+ if queue_task in done:
361
+ notification = queue_task.result()
362
+ pending.append(notification)
363
+ queue_notification_consumed = True
364
+ while True:
365
+ try:
366
+ pending.append(queue.get_nowait())
367
+ except asyncio.QueueEmpty:
368
+ break
369
+ if turn_task in done:
370
+ break
371
+
372
+ async with self._storage.transaction() as transaction:
373
+ runtime_session = await transaction.find_runtime_session(
374
+ batch[0].context.bcn_session.id
375
+ )
376
+ cursor = await transaction.get_consumer_cursor(
377
+ batch[0].context.bcn_session.id
378
+ )
379
+ delivered_through_seq = (
380
+ cursor.delivered_through_seq if cursor is not None else 0
381
+ )
382
+ unread = await transaction.list_inbound_messages(
383
+ batch[0].context.bcn_session.id,
384
+ after_seq=delivered_through_seq,
385
+ notifying_only=True,
386
+ )
387
+
388
+ active_turn = next(
389
+ (
390
+ turn
391
+ for turn in self._runtime_turns.values()
392
+ if turn.session_id == batch[0].context.runtime_session.id
393
+ and turn.state is RuntimeTurnState.RUNNING
394
+ and turn.provider_turn_id is not None
395
+ ),
396
+ None,
397
+ )
398
+ if (
399
+ runtime_session is not None
400
+ and unread
401
+ and active_turn is not None
402
+ and notification is not None
403
+ ):
404
+ context = SessionContext(
405
+ batch[0].context.channel_session,
406
+ batch[0].context.bcn_session,
407
+ runtime_session,
408
+ )
409
+ await self._turns.steer_turn(
410
+ notification.message,
411
+ context,
412
+ active_turn,
413
+ unread_count=len(unread),
414
+ )
415
+ queue_task = asyncio.create_task(queue.get())
416
+ queue_notification_consumed = False
417
+
418
+ result = turn_task.result()
344
419
  for notification in batch:
345
420
  if not notification.completion.done():
346
421
  notification.completion.set_result(result)
347
422
  except asyncio.CancelledError:
348
- for notification in batch:
423
+ turn_task.cancel()
424
+ queue_task.cancel()
425
+ await asyncio.gather(
426
+ turn_task,
427
+ queue_task,
428
+ return_exceptions=True,
429
+ )
430
+ if (
431
+ not queue_notification_consumed
432
+ and not queue_task.cancelled()
433
+ and queue_task.exception() is None
434
+ ):
435
+ pending.append(queue_task.result())
436
+ queue_notification_consumed = True
437
+ for notification in (*batch, *pending):
349
438
  if not notification.completion.done():
350
439
  notification.completion.cancel()
440
+ for _ in range(len(pending)):
441
+ queue.task_done()
442
+ pending.clear()
351
443
  raise
352
444
  except Exception as error: # noqa: BLE001
445
+ turn_task.cancel()
446
+ await asyncio.gather(turn_task, return_exceptions=True)
353
447
  for notification in batch:
354
448
  if not notification.completion.done():
355
449
  notification.completion.set_exception(error)
356
450
  finally:
357
- for _notification in batch:
451
+ if not queue_task.done():
452
+ queue_task.cancel()
453
+ try:
454
+ notification = await queue_task
455
+ except asyncio.CancelledError:
456
+ pass
457
+ else:
458
+ if not queue_notification_consumed:
459
+ pending.append(notification)
460
+ for _ in range(len(batch)):
358
461
  queue.task_done()
359
462
 
360
463
  async def _receive_loop(self) -> None:
@@ -81,6 +81,14 @@ def _is_terminal_turn_event(event: RuntimeEvent) -> bool:
81
81
  }
82
82
 
83
83
 
84
+ def inbox_notice(session_id: str, unread_count: int) -> str:
85
+ return (
86
+ f"[inbox notice session={session_id}]\n"
87
+ f"Inbox update: {unread_count} unread message(s). "
88
+ "Use the message command to read them."
89
+ )
90
+
91
+
84
92
  @dataclass(frozen=True, slots=True)
85
93
  class SessionContext:
86
94
  channel_session: ChannelSession
@@ -211,9 +219,7 @@ class SessionTurnCoordinator:
211
219
  stream = await self._runtime.start_turn(
212
220
  context.runtime_session,
213
221
  turn,
214
- f"[inbox notice session={context.bcn_session.id}]\n"
215
- f"Inbox update: {unread_count} unread message(s). "
216
- "Use the message command to read them.",
222
+ inbox_notice(context.bcn_session.id, unread_count),
217
223
  approval_handler,
218
224
  timeout=self._timeout_budget.provider_call_seconds,
219
225
  )
@@ -284,6 +290,52 @@ class SessionTurnCoordinator:
284
290
  finally:
285
291
  await self._close_stream(stream)
286
292
 
293
+ async def steer_turn(
294
+ self,
295
+ message: InboundMessage,
296
+ context: SessionContext,
297
+ turn: RuntimeTurn,
298
+ *,
299
+ unread_count: int,
300
+ ) -> None:
301
+ try:
302
+ accepted = await self._runtime.steer_turn(
303
+ context.runtime_session,
304
+ turn,
305
+ inbox_notice(context.bcn_session.id, unread_count),
306
+ timeout=self._timeout_budget.provider_call_seconds,
307
+ )
308
+ except asyncio.CancelledError:
309
+ raise
310
+ except Exception as error: # noqa: BLE001
311
+ self._logger.warning(
312
+ "runtime turn steer failed",
313
+ extra={
314
+ "error_type": type(error).__name__,
315
+ "session_id": context.bcn_session.id,
316
+ "turn_id": turn.turn_id,
317
+ },
318
+ )
319
+ accepted = False
320
+ try:
321
+ await self._audit.append(
322
+ event_name=(
323
+ "runtime.request.turn.steer.accepted"
324
+ if accepted
325
+ else "runtime.request.turn.steer.not_accepted"
326
+ ),
327
+ state=RuntimeEventState.COMPLETED,
328
+ correlation=self.turn_correlation(message, context, turn),
329
+ metadata={
330
+ "provider_method": "turn/steer",
331
+ "unread_count": unread_count,
332
+ },
333
+ )
334
+ except asyncio.CancelledError:
335
+ raise
336
+ except Exception:
337
+ self._logger.exception("runtime turn steer audit failed")
338
+
287
339
  async def finish_turn(
288
340
  self,
289
341
  turn: RuntimeTurn,
@@ -354,58 +406,56 @@ class SessionTurnCoordinator:
354
406
  if event.turn_id is not None and event.turn_id != turn.turn_id:
355
407
  raise ValueError("runtime event turn correlation mismatch")
356
408
  async with self._concurrency.for_session(message.session_id):
357
- async with self._storage.transaction() as transaction:
358
- event = await transaction.append_runtime_event(event)
359
- agent_signal = _runtime_event_agent_signal(event)
360
- if not _is_turn_event(event.event_name):
361
- target_state = turn.state
362
- elif event.state is RuntimeEventState.STARTED:
363
- target_state = RuntimeTurnState.RUNNING
364
- elif event.state is RuntimeEventState.COMPLETED:
365
- target_state = RuntimeTurnState.COMPLETED
366
- elif event.state is RuntimeEventState.FAILED:
367
- target_state = RuntimeTurnState.FAILED
368
- elif event.state is RuntimeEventState.CANCELLED:
369
- target_state = RuntimeTurnState.CANCELLED
370
- else:
371
- target_state = RuntimeTurnState.UNKNOWN
372
- provider_turn_id = event.metadata.get("provider_turn_id")
373
- if provider_turn_id is not None and (
374
- not isinstance(provider_turn_id, str) or not provider_turn_id
375
- ):
376
- raise ValueError("runtime event provider_turn_id is invalid")
377
- if (
378
- provider_turn_id is not None
379
- and turn.provider_turn_id is not None
380
- and turn.provider_turn_id != provider_turn_id
381
- ):
382
- raise ValueError("runtime event provider turn correlation mismatch")
383
- error_kind = event.error_kind
384
- if event.state is RuntimeEventState.FAILED and error_kind is None:
385
- error_kind = ErrorKind.PROVIDER_FAILED.value
386
- if event.state is RuntimeEventState.UNKNOWN and error_kind is None:
387
- error_kind = ErrorKind.PROVIDER_UNKNOWN.value
388
- if event.state is RuntimeEventState.CANCELLED and error_kind is None:
389
- error_kind = ErrorKind.CANCELLED.value
390
- updated_turn = turn.transition_to(
391
- target_state,
392
- at_ms=event.created_at_ms,
393
- error_kind=error_kind,
394
- error_message=event.error_message,
395
- latest_event_name=event.event_name,
396
- )
397
- if provider_turn_id is not None:
398
- updated_turn = replace(
399
- updated_turn,
400
- provider_turn_id=provider_turn_id,
401
- )
402
- agent_tick = AgentTick(
403
- source=AgentTickSource.RUNTIME,
404
- signal=agent_signal,
405
- observed_at_ms=self._clock(),
406
- error_kind=error_kind,
407
- error_message=event.error_message,
409
+ agent_signal = _runtime_event_agent_signal(event)
410
+ if not _is_turn_event(event.event_name):
411
+ target_state = turn.state
412
+ elif event.state is RuntimeEventState.STARTED:
413
+ target_state = RuntimeTurnState.RUNNING
414
+ elif event.state is RuntimeEventState.COMPLETED:
415
+ target_state = RuntimeTurnState.COMPLETED
416
+ elif event.state is RuntimeEventState.FAILED:
417
+ target_state = RuntimeTurnState.FAILED
418
+ elif event.state is RuntimeEventState.CANCELLED:
419
+ target_state = RuntimeTurnState.CANCELLED
420
+ else:
421
+ target_state = RuntimeTurnState.UNKNOWN
422
+ provider_turn_id = event.metadata.get("provider_turn_id")
423
+ if provider_turn_id is not None and (
424
+ not isinstance(provider_turn_id, str) or not provider_turn_id
425
+ ):
426
+ raise ValueError("runtime event provider_turn_id is invalid")
427
+ if (
428
+ provider_turn_id is not None
429
+ and turn.provider_turn_id is not None
430
+ and turn.provider_turn_id != provider_turn_id
431
+ ):
432
+ raise ValueError("runtime event provider turn correlation mismatch")
433
+ error_kind = event.error_kind
434
+ if event.state is RuntimeEventState.FAILED and error_kind is None:
435
+ error_kind = ErrorKind.PROVIDER_FAILED.value
436
+ if event.state is RuntimeEventState.UNKNOWN and error_kind is None:
437
+ error_kind = ErrorKind.PROVIDER_UNKNOWN.value
438
+ if event.state is RuntimeEventState.CANCELLED and error_kind is None:
439
+ error_kind = ErrorKind.CANCELLED.value
440
+ updated_turn = turn.transition_to(
441
+ target_state,
442
+ at_ms=event.created_at_ms,
443
+ error_kind=error_kind,
444
+ error_message=event.error_message,
445
+ latest_event_name=event.event_name,
446
+ )
447
+ if provider_turn_id is not None:
448
+ updated_turn = replace(
449
+ updated_turn,
450
+ provider_turn_id=provider_turn_id,
408
451
  )
452
+ agent_tick = AgentTick(
453
+ source=AgentTickSource.RUNTIME,
454
+ signal=agent_signal,
455
+ observed_at_ms=self._clock(),
456
+ error_kind=error_kind,
457
+ error_message=event.error_message,
458
+ )
409
459
  if _is_terminal_turn_event(event):
410
460
  self._turns.pop(turn.turn_id, None)
411
461
  else:
@@ -101,6 +101,22 @@ class IRuntime(IAsyncLifecycle, Protocol):
101
101
  """
102
102
  ...
103
103
 
104
+ async def steer_turn(
105
+ self,
106
+ session: RuntimeSession,
107
+ turn: RuntimeTurn,
108
+ input_text: str,
109
+ *,
110
+ timeout: float,
111
+ ) -> bool:
112
+ """Append input to the active turn when the runtime supports steering.
113
+
114
+ Return true only when the runtime confirms that the active turn accepted
115
+ the input. Unsupported or unconfirmed steering returns false without
116
+ changing the turn lifecycle.
117
+ """
118
+ ...
119
+
104
120
  async def interrupt_turn(
105
121
  self,
106
122
  session: RuntimeSession,
@@ -12,7 +12,6 @@ from .models import (
12
12
  InboundMessage,
13
13
  OutboundMessage,
14
14
  RuntimeAttempt,
15
- RuntimeEvent,
16
15
  RuntimeSession,
17
16
  )
18
17
 
@@ -140,10 +139,6 @@ class IStorageTransaction(Protocol):
140
139
  """Persist a draft or delivery transition and return its canonical row."""
141
140
  ...
142
141
 
143
- async def append_runtime_event(self, event: RuntimeEvent) -> RuntimeEvent:
144
- """Append an operational event and return its canonical local sequence."""
145
- ...
146
-
147
142
 
148
143
  class IStorage(IAsyncLifecycle, Protocol):
149
144
  """Provider-neutral storage lifecycle and explicit transaction factory."""