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.
- bazaar_compute_node/__init__.py +3 -0
- bazaar_compute_node/app/__init__.py +1 -0
- bazaar_compute_node/app/application.py +398 -0
- bazaar_compute_node/app/attachments.py +154 -0
- bazaar_compute_node/app/command.py +342 -0
- bazaar_compute_node/app/config.py +121 -0
- bazaar_compute_node/app/registry.py +120 -0
- bazaar_compute_node/app/transport.py +264 -0
- bazaar_compute_node/app/windows_pipe.py +463 -0
- bazaar_compute_node/app/wrapper.py +63 -0
- bazaar_compute_node/bcc.py +524 -0
- bazaar_compute_node/cli.py +382 -0
- bazaar_compute_node/contrib/__init__.py +1 -0
- bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
- bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
- bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
- bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
- bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
- bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
- bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
- bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
- bazaar_compute_node/contrib/logging/__init__.py +5 -0
- bazaar_compute_node/contrib/logging/audit.py +61 -0
- bazaar_compute_node/contrib/logging/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
- bazaar_compute_node/contrib/sqlite/codec.py +768 -0
- bazaar_compute_node/contrib/sqlite/database.py +282 -0
- bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
- bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
- bazaar_compute_node/contrib/wecom/__init__.py +1 -0
- bazaar_compute_node/contrib/wecom/channel.py +960 -0
- bazaar_compute_node/contrib/wecom/markdown.py +146 -0
- bazaar_compute_node/contrib/wecom/plugin.py +29 -0
- bazaar_compute_node/core/__init__.py +5 -0
- bazaar_compute_node/core/approval.py +51 -0
- bazaar_compute_node/core/audit.py +101 -0
- bazaar_compute_node/core/channel.py +121 -0
- bazaar_compute_node/core/client.py +30 -0
- bazaar_compute_node/core/command.py +85 -0
- bazaar_compute_node/core/concurrency.py +29 -0
- bazaar_compute_node/core/correlation.py +48 -0
- bazaar_compute_node/core/instruction.py +224 -0
- bazaar_compute_node/core/lifecycle.py +48 -0
- bazaar_compute_node/core/models/__init__.py +63 -0
- bazaar_compute_node/core/models/entities.py +514 -0
- bazaar_compute_node/core/models/states.py +369 -0
- bazaar_compute_node/core/observability.py +47 -0
- bazaar_compute_node/core/orchestration/__init__.py +5 -0
- bazaar_compute_node/core/orchestration/command.py +614 -0
- bazaar_compute_node/core/orchestration/services.py +135 -0
- bazaar_compute_node/core/orchestration/session.py +891 -0
- bazaar_compute_node/core/orchestration/turn.py +451 -0
- bazaar_compute_node/core/outcomes.py +51 -0
- bazaar_compute_node/core/paths.py +19 -0
- bazaar_compute_node/core/runtime.py +118 -0
- bazaar_compute_node/core/storage.py +167 -0
- bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
- bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
- bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
- bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
- 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
|
+
...
|