bazaar-compute-node 0.1.3__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.
Files changed (62) hide show
  1. bazaar_compute_node/__init__.py +3 -0
  2. bazaar_compute_node/app/__init__.py +1 -0
  3. bazaar_compute_node/app/application.py +398 -0
  4. bazaar_compute_node/app/attachments.py +154 -0
  5. bazaar_compute_node/app/command.py +342 -0
  6. bazaar_compute_node/app/config.py +121 -0
  7. bazaar_compute_node/app/registry.py +120 -0
  8. bazaar_compute_node/app/transport.py +264 -0
  9. bazaar_compute_node/app/windows_pipe.py +463 -0
  10. bazaar_compute_node/app/wrapper.py +63 -0
  11. bazaar_compute_node/bcc.py +524 -0
  12. bazaar_compute_node/cli.py +382 -0
  13. bazaar_compute_node/contrib/__init__.py +1 -0
  14. bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
  15. bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
  16. bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
  17. bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
  18. bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
  19. bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
  20. bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
  21. bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
  22. bazaar_compute_node/contrib/logging/__init__.py +5 -0
  23. bazaar_compute_node/contrib/logging/audit.py +61 -0
  24. bazaar_compute_node/contrib/logging/plugin.py +11 -0
  25. bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
  26. bazaar_compute_node/contrib/sqlite/codec.py +768 -0
  27. bazaar_compute_node/contrib/sqlite/database.py +282 -0
  28. bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
  29. bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
  30. bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
  31. bazaar_compute_node/contrib/wecom/__init__.py +1 -0
  32. bazaar_compute_node/contrib/wecom/channel.py +960 -0
  33. bazaar_compute_node/contrib/wecom/markdown.py +146 -0
  34. bazaar_compute_node/contrib/wecom/plugin.py +29 -0
  35. bazaar_compute_node/core/__init__.py +5 -0
  36. bazaar_compute_node/core/approval.py +51 -0
  37. bazaar_compute_node/core/audit.py +101 -0
  38. bazaar_compute_node/core/channel.py +121 -0
  39. bazaar_compute_node/core/client.py +30 -0
  40. bazaar_compute_node/core/command.py +85 -0
  41. bazaar_compute_node/core/concurrency.py +29 -0
  42. bazaar_compute_node/core/correlation.py +48 -0
  43. bazaar_compute_node/core/instruction.py +224 -0
  44. bazaar_compute_node/core/lifecycle.py +48 -0
  45. bazaar_compute_node/core/models/__init__.py +63 -0
  46. bazaar_compute_node/core/models/entities.py +514 -0
  47. bazaar_compute_node/core/models/states.py +369 -0
  48. bazaar_compute_node/core/observability.py +47 -0
  49. bazaar_compute_node/core/orchestration/__init__.py +5 -0
  50. bazaar_compute_node/core/orchestration/command.py +614 -0
  51. bazaar_compute_node/core/orchestration/services.py +135 -0
  52. bazaar_compute_node/core/orchestration/session.py +891 -0
  53. bazaar_compute_node/core/orchestration/turn.py +451 -0
  54. bazaar_compute_node/core/outcomes.py +51 -0
  55. bazaar_compute_node/core/paths.py +19 -0
  56. bazaar_compute_node/core/runtime.py +118 -0
  57. bazaar_compute_node/core/storage.py +167 -0
  58. bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
  59. bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
  60. bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
  61. bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
  62. bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
@@ -0,0 +1,451 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass, replace
7
+
8
+ from ..approval import ApprovalBinding
9
+ from ..audit import ErrorKind
10
+ from ..channel import IChannel
11
+ from ..concurrency import ISessionConcurrency
12
+ from ..correlation import CorrelationContext
13
+ from ..lifecycle import TimeoutBudget
14
+ from ..models import (
15
+ AgentSignal,
16
+ AgentTick,
17
+ AgentTickSource,
18
+ ApprovalRequest,
19
+ ApprovalResult,
20
+ BcnSession,
21
+ ChannelSession,
22
+ InboundMessage,
23
+ RuntimeEvent,
24
+ RuntimeEventState,
25
+ RuntimeSession,
26
+ RuntimeTurn,
27
+ RuntimeTurnState,
28
+ StreamEvent,
29
+ )
30
+ from ..runtime import IRuntime, IRuntimeTurnStream, RuntimeSessionUnavailable
31
+ from ..storage import IStorage
32
+ from .services import SessionAuditRecorder, SessionStateWriter
33
+
34
+
35
+ def _is_compaction_event(event_name: str) -> bool:
36
+ return "compaction" in event_name.casefold()
37
+
38
+
39
+ def _is_turn_event(event_name: str) -> bool:
40
+ return "turn" in event_name.casefold()
41
+
42
+
43
+ def _runtime_event_agent_signal(event: RuntimeEvent) -> AgentSignal:
44
+ if _is_compaction_event(event.event_name):
45
+ normalized = event.event_name.casefold()
46
+ if any(token in normalized for token in ("start", "begin")):
47
+ return AgentSignal.COMPACTION_STARTED
48
+ if any(token in normalized for token in ("complete", "finish", "end")):
49
+ return AgentSignal.COMPACTION_COMPLETED
50
+ if event.state is RuntimeEventState.COMPLETED:
51
+ return AgentSignal.COMPACTION_COMPLETED
52
+ return AgentSignal.COMPACTION_IN_PROGRESS
53
+
54
+ if not _is_turn_event(event.event_name):
55
+ if event.state is RuntimeEventState.UNKNOWN:
56
+ return AgentSignal.UNKNOWN
57
+ if (
58
+ event.state is RuntimeEventState.FAILED
59
+ or "error" in event.event_name.casefold()
60
+ ):
61
+ return AgentSignal.FAILED
62
+ return AgentSignal.WORKING_OBSERVED
63
+
64
+ if event.state is RuntimeEventState.STARTED:
65
+ return AgentSignal.TURN_STARTED
66
+ if event.state is RuntimeEventState.COMPLETED:
67
+ return AgentSignal.TURN_COMPLETED
68
+ if event.state is RuntimeEventState.FAILED:
69
+ return AgentSignal.TURN_FAILED
70
+ if event.state is RuntimeEventState.CANCELLED:
71
+ return AgentSignal.TURN_CANCELLED
72
+ return AgentSignal.UNKNOWN
73
+
74
+
75
+ def _is_terminal_turn_event(event: RuntimeEvent) -> bool:
76
+ return _is_turn_event(event.event_name) and event.state in {
77
+ RuntimeEventState.COMPLETED,
78
+ RuntimeEventState.FAILED,
79
+ RuntimeEventState.CANCELLED,
80
+ RuntimeEventState.UNKNOWN,
81
+ }
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class SessionContext:
86
+ channel_session: ChannelSession
87
+ bcn_session: BcnSession
88
+ runtime_session: RuntimeSession
89
+
90
+
91
+ class _ApprovalHandler:
92
+ def __init__(
93
+ self,
94
+ callback: Callable[[ApprovalRequest, float], Awaitable[ApprovalResult]],
95
+ ) -> None:
96
+ self._callback = callback
97
+
98
+ async def request_approval(
99
+ self, request: ApprovalRequest, *, timeout: float
100
+ ) -> ApprovalResult:
101
+ return await self._callback(request, timeout)
102
+
103
+
104
+ class SessionTurnCoordinator:
105
+ """Drive runtime turns and persist their event/state transitions."""
106
+
107
+ def __init__(
108
+ self,
109
+ *,
110
+ channel: IChannel,
111
+ runtime: IRuntime,
112
+ storage: IStorage,
113
+ audit: SessionAuditRecorder,
114
+ state_writer: SessionStateWriter,
115
+ timeout_budget: TimeoutBudget,
116
+ concurrency: ISessionConcurrency,
117
+ turns: dict[str, RuntimeTurn],
118
+ node_id: Callable[[], str],
119
+ clock: Callable[[], int],
120
+ ) -> None:
121
+ self._channel = channel
122
+ self._runtime = runtime
123
+ self._storage = storage
124
+ self._audit = audit
125
+ self._state_writer = state_writer
126
+ self._timeout_budget = timeout_budget
127
+ self._concurrency = concurrency
128
+ self._turns = turns
129
+ self._node_id = node_id
130
+ self._clock = clock
131
+ self._logger = logging.getLogger("bazaar_compute_node.orchestration.turn")
132
+
133
+ async def run_turn(
134
+ self,
135
+ message: InboundMessage,
136
+ context: SessionContext,
137
+ turn: RuntimeTurn,
138
+ *,
139
+ unread_count: int,
140
+ ) -> RuntimeTurn:
141
+ binding = ApprovalBinding(
142
+ request_id="pending",
143
+ bcn_session_id=context.bcn_session.id,
144
+ channel_session_id=context.channel_session.id,
145
+ runtime_session_id=context.runtime_session.id,
146
+ turn_id=turn.turn_id,
147
+ )
148
+ turn_correlation = self.turn_correlation(message, context, turn)
149
+
150
+ async def request_approval(
151
+ request: ApprovalRequest, *, timeout: float
152
+ ) -> ApprovalResult:
153
+ request_id = request.request_id
154
+ current_binding = replace(binding, request_id=request_id)
155
+ if not current_binding.matches(request):
156
+ raise ValueError("runtime approval request correlation mismatch")
157
+ approval_correlation = CorrelationContext(
158
+ node_id=self._node_id(),
159
+ channel=context.channel_session.channel,
160
+ channel_session_id=context.channel_session.id,
161
+ bcn_session_id=context.bcn_session.id,
162
+ runtime_session_id=context.runtime_session.id,
163
+ turn_id=turn.turn_id,
164
+ request_id=request_id,
165
+ inbound_seq=message.seq,
166
+ )
167
+ await self._audit.append(
168
+ event_name="approval.requested",
169
+ state=RuntimeEventState.STARTED,
170
+ correlation=approval_correlation,
171
+ metadata={"action": request.action},
172
+ )
173
+ try:
174
+ result = await self._channel.request_approval(request, timeout=timeout)
175
+ if result.request_id != request_id:
176
+ raise ValueError("channel approval result correlation mismatch")
177
+ except Exception as error:
178
+ await self._audit.append(
179
+ event_name="approval.failed",
180
+ state=RuntimeEventState.FAILED,
181
+ correlation=approval_correlation,
182
+ error_kind=ErrorKind.PROVIDER_FAILED,
183
+ error_message=f"approval failed: {type(error).__name__}",
184
+ metadata={"action": request.action},
185
+ )
186
+ raise
187
+ await self._audit.append(
188
+ event_name="approval.decided",
189
+ state=RuntimeEventState.COMPLETED,
190
+ correlation=approval_correlation,
191
+ metadata={
192
+ "action": request.action,
193
+ "decision": result.decision.value,
194
+ },
195
+ )
196
+ return result
197
+
198
+ stream: IRuntimeTurnStream | None = None
199
+ observed_terminal = False
200
+ try:
201
+ approval_handler = _ApprovalHandler(
202
+ lambda request, timeout: request_approval(request, timeout=timeout)
203
+ )
204
+ await self._audit.append(
205
+ event_name="runtime.request.turn.started",
206
+ state=RuntimeEventState.STARTED,
207
+ correlation=turn_correlation,
208
+ metadata={"provider_method": "turn/start"},
209
+ )
210
+ try:
211
+ stream = await self._runtime.start_turn(
212
+ context.runtime_session,
213
+ 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.",
217
+ approval_handler,
218
+ timeout=self._timeout_budget.provider_call_seconds,
219
+ )
220
+ except Exception as error:
221
+ await self._audit.append(
222
+ event_name="runtime.request.turn.failed",
223
+ state=RuntimeEventState.FAILED,
224
+ correlation=turn_correlation,
225
+ error_kind=ErrorKind.PROVIDER_FAILED,
226
+ error_message=f"turn request failed: {type(error).__name__}",
227
+ metadata={"provider_method": "turn/start"},
228
+ )
229
+ raise
230
+ async for event in stream:
231
+ if isinstance(event, StreamEvent):
232
+ if event.session_id != context.bcn_session.id:
233
+ self._logger.error(
234
+ "runtime emitted stream event for another session",
235
+ extra={
236
+ "expected_session_id": context.bcn_session.id,
237
+ "actual_session_id": event.session_id,
238
+ },
239
+ )
240
+ continue
241
+ try:
242
+ self._channel.offer_stream_event(event)
243
+ except Exception:
244
+ self._logger.exception("channel rejected stream event")
245
+ continue
246
+ turn = await self._apply_runtime_event(message, context, turn, event)
247
+ if _is_terminal_turn_event(event):
248
+ observed_terminal = True
249
+ break
250
+ if not observed_terminal:
251
+ return await self.finish_turn(
252
+ turn,
253
+ RuntimeTurnState.UNKNOWN,
254
+ error_kind=ErrorKind.PROVIDER_UNKNOWN,
255
+ error_message="runtime stream ended without a terminal event",
256
+ correlation=turn_correlation,
257
+ session_id=context.bcn_session.id,
258
+ )
259
+ return turn
260
+ except asyncio.CancelledError:
261
+ await self._close_stream(stream)
262
+ await self.finish_turn(
263
+ turn,
264
+ RuntimeTurnState.CANCELLED,
265
+ error_kind=ErrorKind.CANCELLED,
266
+ error_message="runtime turn cancelled",
267
+ correlation=turn_correlation,
268
+ session_id=context.bcn_session.id,
269
+ )
270
+ raise
271
+ except RuntimeSessionUnavailable:
272
+ await self._close_stream(stream)
273
+ raise
274
+ except Exception as error: # noqa: BLE001
275
+ await self._close_stream(stream)
276
+ return await self.finish_turn(
277
+ turn,
278
+ RuntimeTurnState.FAILED,
279
+ error_kind=ErrorKind.PROVIDER_FAILED,
280
+ error_message=f"runtime turn failed: {type(error).__name__}",
281
+ correlation=turn_correlation,
282
+ session_id=context.bcn_session.id,
283
+ )
284
+ finally:
285
+ await self._close_stream(stream)
286
+
287
+ async def finish_turn(
288
+ self,
289
+ turn: RuntimeTurn,
290
+ state: RuntimeTurnState,
291
+ *,
292
+ error_kind: ErrorKind | None,
293
+ error_message: str | None,
294
+ correlation: CorrelationContext | None,
295
+ session_id: str,
296
+ ) -> RuntimeTurn:
297
+ async with self._concurrency.for_session(session_id):
298
+ if turn.state in {
299
+ RuntimeTurnState.COMPLETED,
300
+ RuntimeTurnState.FAILED,
301
+ RuntimeTurnState.CANCELLED,
302
+ RuntimeTurnState.UNKNOWN,
303
+ }:
304
+ return turn
305
+ current_turn = turn.transition_to(
306
+ state,
307
+ at_ms=self._clock(),
308
+ error_kind=error_kind.value if error_kind else None,
309
+ error_message=error_message,
310
+ )
311
+ self._turns.pop(turn.turn_id, None)
312
+ if state is RuntimeTurnState.COMPLETED:
313
+ agent_signal = AgentSignal.TURN_COMPLETED
314
+ elif state is RuntimeTurnState.FAILED:
315
+ agent_signal = AgentSignal.TURN_FAILED
316
+ elif state is RuntimeTurnState.CANCELLED:
317
+ agent_signal = AgentSignal.TURN_CANCELLED
318
+ else:
319
+ agent_signal = AgentSignal.UNKNOWN
320
+ self._state_writer.apply_observation(
321
+ session_id,
322
+ AgentTick(
323
+ source=AgentTickSource.RUNTIME,
324
+ signal=agent_signal,
325
+ observed_at_ms=self._clock(),
326
+ error_kind=error_kind.value if error_kind else None,
327
+ error_message=error_message,
328
+ ),
329
+ )
330
+ await self._audit.append(
331
+ event_name=f"runtime.turn.{state.value}",
332
+ state=(
333
+ RuntimeEventState.COMPLETED
334
+ if state is RuntimeTurnState.COMPLETED
335
+ else RuntimeEventState.CANCELLED
336
+ if state is RuntimeTurnState.CANCELLED
337
+ else RuntimeEventState.FAILED
338
+ if state is not RuntimeTurnState.UNKNOWN
339
+ else RuntimeEventState.UNKNOWN
340
+ ),
341
+ correlation=correlation or CorrelationContext(turn_id=turn.turn_id),
342
+ error_kind=error_kind,
343
+ error_message=error_message,
344
+ )
345
+ return current_turn
346
+
347
+ async def _apply_runtime_event(
348
+ self,
349
+ message,
350
+ context: SessionContext,
351
+ turn: RuntimeTurn,
352
+ event: RuntimeEvent,
353
+ ) -> RuntimeTurn:
354
+ if event.turn_id is not None and event.turn_id != turn.turn_id:
355
+ raise ValueError("runtime event turn correlation mismatch")
356
+ 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,
408
+ )
409
+ if _is_terminal_turn_event(event):
410
+ self._turns.pop(turn.turn_id, None)
411
+ else:
412
+ self._turns[turn.turn_id] = updated_turn
413
+ self._state_writer.apply_observation(
414
+ context.bcn_session.id,
415
+ agent_tick,
416
+ )
417
+ try:
418
+ audit_kind = ErrorKind(event.error_kind) if event.error_kind else None
419
+ except ValueError:
420
+ audit_kind = ErrorKind.INTERNAL
421
+ audit_error_message = event.error_message if audit_kind else None
422
+ await self._audit.append(
423
+ event_name=event.event_name,
424
+ state=event.state,
425
+ correlation=self.turn_correlation(message, context, updated_turn),
426
+ error_kind=audit_kind,
427
+ error_message=audit_error_message,
428
+ )
429
+ return updated_turn
430
+
431
+ async def _close_stream(self, stream: IRuntimeTurnStream | None) -> None:
432
+ if stream is not None:
433
+ await stream.aclose()
434
+
435
+ def turn_correlation(
436
+ self,
437
+ message: InboundMessage,
438
+ context: SessionContext,
439
+ turn: RuntimeTurn,
440
+ ) -> CorrelationContext:
441
+ return CorrelationContext(
442
+ node_id=self._node_id(),
443
+ channel=context.channel_session.channel,
444
+ channel_session_id=context.channel_session.id,
445
+ bcn_session_id=context.bcn_session.id,
446
+ runtime_session_id=context.runtime_session.id,
447
+ turn_id=turn.turn_id,
448
+ inbound_seq=message.seq,
449
+ provider_thread_id=context.runtime_session.provider_thread_id,
450
+ provider_turn_id=turn.provider_turn_id,
451
+ )
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+
7
+
8
+ class ProviderCallStatus(StrEnum):
9
+ CONFIRMED = "confirmed"
10
+ QUEUED = "queued"
11
+ PARTIAL = "partial"
12
+ FAILED = "failed"
13
+ UNKNOWN = "unknown"
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ProviderCallResult[ResultT]:
18
+ """Provider outcome that never treats an unconfirmed call as a success."""
19
+
20
+ status: ProviderCallStatus
21
+ value: ResultT | None = None
22
+ error_kind: str | None = None
23
+ error_message: str | None = None
24
+ receipt: Mapping[str, object] = field(default_factory=dict)
25
+
26
+ def __post_init__(self) -> None:
27
+ if self.status in {
28
+ ProviderCallStatus.CONFIRMED,
29
+ ProviderCallStatus.QUEUED,
30
+ }:
31
+ if self.value is None:
32
+ raise ValueError(
33
+ f"a {self.status.value} provider call requires a value"
34
+ )
35
+ if self.error_kind is not None or self.error_message is not None:
36
+ raise ValueError(
37
+ f"a {self.status.value} provider call cannot contain an error"
38
+ )
39
+ return
40
+
41
+ if self.status is ProviderCallStatus.PARTIAL:
42
+ if self.value is None:
43
+ raise ValueError("a partial provider call requires a value")
44
+ if not self.error_kind:
45
+ raise ValueError("a partial provider call requires an error_kind")
46
+ return
47
+
48
+ if self.value is not None:
49
+ raise ValueError("an unconfirmed provider call cannot contain a value")
50
+ if not self.error_kind:
51
+ raise ValueError("an unconfirmed provider call requires an error_kind")
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def resolve_data_dir() -> Path:
7
+ """Resolve the persistent node data directory under the user's home."""
8
+
9
+ return (Path.home() / ".bcn").resolve(strict=False)
10
+
11
+
12
+ def resolve_workspace_dir(
13
+ workspace_id: str,
14
+ ) -> Path:
15
+ """Resolve the persistent shared workspace for one node identity."""
16
+
17
+ if not isinstance(workspace_id, str) or not workspace_id:
18
+ raise ValueError("workspace_id must be a non-empty string")
19
+ return resolve_data_dir() / "workspaces" / workspace_id
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+ from typing import Protocol
7
+
8
+ from .approval import IApprovalHandler
9
+ from .client import CLIENT_INFO, ClientInfo
10
+ from .lifecycle import IAsyncLifecycle
11
+ from .models import RuntimeEvent, RuntimeSession, RuntimeTurn, StreamEvent
12
+ from .outcomes import ProviderCallResult
13
+
14
+
15
+ class RuntimeSessionUnavailable(RuntimeError):
16
+ """The runtime session failed before a provider turn request was written."""
17
+
18
+
19
+ class RuntimeSandboxMode(StrEnum):
20
+ """Provider-neutral filesystem sandbox modes for runtime turns."""
21
+
22
+ WORKSPACE_WRITE = "workspace-write"
23
+ DANGER_FULL_ACCESS = "danger-full-access"
24
+
25
+
26
+ type RuntimeStreamItem = RuntimeEvent | StreamEvent
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class RuntimeCommandContext:
31
+ """Generic command capability made available to a runtime adapter."""
32
+
33
+ run_command: Callable[[str, Sequence[str], str | None], Awaitable[None]]
34
+ environment_for_session: Callable[[RuntimeSession], Mapping[str, str]]
35
+ node_id: str = "bcn-node"
36
+ runtime_options: Mapping[str, str] = field(default_factory=dict)
37
+ sandbox_mode: RuntimeSandboxMode = RuntimeSandboxMode.WORKSPACE_WRITE
38
+ network_access: bool = True
39
+ startup_timeout_seconds: float = 60
40
+ client_info: ClientInfo = CLIENT_INFO
41
+
42
+
43
+ class IRuntimeTurnStream(Protocol):
44
+ """Cancellable stream of provider-neutral runtime events."""
45
+
46
+ def __aiter__(self) -> AsyncIterator[RuntimeStreamItem]:
47
+ """Iterate stream items without exposing provider wire types."""
48
+ ...
49
+
50
+ async def __anext__(self) -> RuntimeStreamItem:
51
+ """Return the next stream item or raise StopAsyncIteration."""
52
+ ...
53
+
54
+ async def aclose(self) -> None:
55
+ """Stop the stream and release its provider resources."""
56
+ ...
57
+
58
+
59
+ class IRuntime(IAsyncLifecycle, Protocol):
60
+ """Async agent-runtime contract isolated from provider SDK types.
61
+
62
+ A stream ending before a terminal runtime event is observed is an unknown
63
+ provider outcome. Caller cancellation propagates to the provider and is
64
+ not converted into a confirmed failure.
65
+ """
66
+
67
+ @property
68
+ def name(self) -> str:
69
+ """Return the stable entry-point identity of this adapter."""
70
+ ...
71
+
72
+ def environment_variable_names(self) -> Sequence[str]:
73
+ """Return optional daemon environment names required by this runtime."""
74
+ ...
75
+
76
+ async def start_session(
77
+ self, session: RuntimeSession, *, timeout: float
78
+ ) -> ProviderCallResult[RuntimeSession]:
79
+ """Start a new runtime process/session."""
80
+ ...
81
+
82
+ async def resume_session(
83
+ self, session: RuntimeSession, *, timeout: float
84
+ ) -> ProviderCallResult[RuntimeSession]:
85
+ """Reconcile or resume a persisted runtime session."""
86
+ ...
87
+
88
+ async def start_turn(
89
+ self,
90
+ session: RuntimeSession,
91
+ turn: RuntimeTurn,
92
+ input_text: str,
93
+ approval_handler: IApprovalHandler,
94
+ *,
95
+ timeout: float,
96
+ ) -> IRuntimeTurnStream:
97
+ """Start one turn and return its cancellable event stream.
98
+
99
+ Raise RuntimeSessionUnavailable only before writing the turn request to
100
+ the provider so orchestration can safely recover the session and retry.
101
+ """
102
+ ...
103
+
104
+ async def interrupt_turn(
105
+ self,
106
+ session: RuntimeSession,
107
+ turn: RuntimeTurn,
108
+ *,
109
+ timeout: float,
110
+ ) -> ProviderCallResult[RuntimeTurn]:
111
+ """Request interruption without claiming provider completion."""
112
+ ...
113
+
114
+ async def stop_session(
115
+ self, session: RuntimeSession, *, timeout: float
116
+ ) -> ProviderCallResult[RuntimeSession]:
117
+ """Stop one runtime process within the bounded shutdown budget."""
118
+ ...