nextloop 0.1.0rc1__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.
- nextloop/__init__.py +96 -0
- nextloop/adapters/__init__.py +6 -0
- nextloop/adapters/_sse.py +25 -0
- nextloop/adapters/anthropic.py +211 -0
- nextloop/adapters/memory.py +97 -0
- nextloop/adapters/openai.py +188 -0
- nextloop/adapters/sqlite.py +250 -0
- nextloop/contracts.py +350 -0
- nextloop/driver.py +446 -0
- nextloop/events/__init__.py +17 -0
- nextloop/events/outcome.py +183 -0
- nextloop/events/project.py +186 -0
- nextloop/events/repair.py +143 -0
- nextloop/events/response.py +215 -0
- nextloop/execute/__init__.py +15 -0
- nextloop/execute/batch.py +165 -0
- nextloop/execute/model.py +134 -0
- nextloop/execute/tools.py +151 -0
- nextloop/loop.py +169 -0
- nextloop/py.typed +0 -0
- nextloop-0.1.0rc1.dist-info/METADATA +148 -0
- nextloop-0.1.0rc1.dist-info/RECORD +25 -0
- nextloop-0.1.0rc1.dist-info/WHEEL +5 -0
- nextloop-0.1.0rc1.dist-info/licenses/LICENSE +21 -0
- nextloop-0.1.0rc1.dist-info/top_level.txt +1 -0
nextloop/driver.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .contracts import (
|
|
8
|
+
EventType,
|
|
9
|
+
FinalMessage,
|
|
10
|
+
InvariantViolation,
|
|
11
|
+
Message,
|
|
12
|
+
NewEvent,
|
|
13
|
+
SurfaceOp,
|
|
14
|
+
TurnContext,
|
|
15
|
+
TurnOutcome,
|
|
16
|
+
TurnStatus,
|
|
17
|
+
new_id,
|
|
18
|
+
)
|
|
19
|
+
from .events.outcome import BatchAction, settle_outcomes
|
|
20
|
+
from .events.project import (
|
|
21
|
+
compaction_events,
|
|
22
|
+
latest_working_context,
|
|
23
|
+
make_compaction_request,
|
|
24
|
+
message_to_data,
|
|
25
|
+
project_events,
|
|
26
|
+
valid_compaction,
|
|
27
|
+
)
|
|
28
|
+
from .events.response import ResponseAction, decide_response, reject_batch
|
|
29
|
+
from .execute.batch import BatchExecutor
|
|
30
|
+
from .execute.model import ModelCaller
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class SessionActor:
|
|
35
|
+
run_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
36
|
+
commit_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
37
|
+
inbox: list[tuple[str, str]] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SessionExecution:
|
|
42
|
+
context: TurnContext
|
|
43
|
+
actor: SessionActor
|
|
44
|
+
writer_epoch: int
|
|
45
|
+
seq: int
|
|
46
|
+
turn: int
|
|
47
|
+
step: int = 0
|
|
48
|
+
corrections: int = 0
|
|
49
|
+
open_step: bool = False
|
|
50
|
+
final_committed: bool = False
|
|
51
|
+
resource_locks: set[str] = field(default_factory=set)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def session_id(self) -> str:
|
|
55
|
+
return self.context.session_id
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def cancel(self) -> asyncio.Event:
|
|
59
|
+
if not hasattr(self, "_cancel"):
|
|
60
|
+
self._cancel = asyncio.Event()
|
|
61
|
+
return self._cancel
|
|
62
|
+
|
|
63
|
+
async def append(self, events: Sequence[NewEvent]) -> None:
|
|
64
|
+
async with self.actor.commit_lock:
|
|
65
|
+
await self._append_locked(events)
|
|
66
|
+
|
|
67
|
+
async def _append_locked(self, events: Sequence[NewEvent]) -> None:
|
|
68
|
+
if not events:
|
|
69
|
+
return
|
|
70
|
+
committed = await self.context.store.append(
|
|
71
|
+
self.session_id,
|
|
72
|
+
self.writer_epoch,
|
|
73
|
+
self.seq + 1,
|
|
74
|
+
events,
|
|
75
|
+
)
|
|
76
|
+
self.seq = committed[-1].seq
|
|
77
|
+
|
|
78
|
+
async def has_steer(self) -> bool:
|
|
79
|
+
async with self.actor.commit_lock:
|
|
80
|
+
return bool(self.actor.inbox)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _CancelFinalization(Exception):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TurnDriver:
|
|
88
|
+
def __init__(self, execution: SessionExecution) -> None:
|
|
89
|
+
self.execution = execution
|
|
90
|
+
context = execution.context
|
|
91
|
+
self.model = ModelCaller(context.provider, context.config, execution.has_steer)
|
|
92
|
+
self.tools = BatchExecutor(context.tools, context.config.max_parallel_tools)
|
|
93
|
+
|
|
94
|
+
async def run(self) -> TurnOutcome:
|
|
95
|
+
execution = self.execution
|
|
96
|
+
try:
|
|
97
|
+
while True:
|
|
98
|
+
if execution.cancel.is_set():
|
|
99
|
+
return await self._finalize(TurnStatus.CANCELLED, "Cancelled")
|
|
100
|
+
await self._begin_step()
|
|
101
|
+
surface = await execution.context.store.load_surface(execution.session_id)
|
|
102
|
+
projection = project_events(
|
|
103
|
+
surface,
|
|
104
|
+
session_id=execution.session_id,
|
|
105
|
+
turn=execution.turn,
|
|
106
|
+
step=execution.step,
|
|
107
|
+
config=execution.context.config,
|
|
108
|
+
tools=execution.context.tools.catalog(),
|
|
109
|
+
)
|
|
110
|
+
if projection.compaction_reason is not None:
|
|
111
|
+
compaction = await self.model.call(
|
|
112
|
+
make_compaction_request(projection, execution.context.config),
|
|
113
|
+
execution.cancel,
|
|
114
|
+
)
|
|
115
|
+
await execution.append(compaction.events)
|
|
116
|
+
if compaction.rebuild_for_steer:
|
|
117
|
+
await self._end_step("steer")
|
|
118
|
+
continue
|
|
119
|
+
if compaction.cancelled or execution.cancel.is_set():
|
|
120
|
+
return await self._finalize(
|
|
121
|
+
TurnStatus.CANCELLED, "Cancelled during compaction"
|
|
122
|
+
)
|
|
123
|
+
summary = compaction.response.content if compaction.response else ""
|
|
124
|
+
if compaction.error or not valid_compaction(summary, projection):
|
|
125
|
+
error = str(
|
|
126
|
+
compaction.error
|
|
127
|
+
or "Compaction returned an empty or non-shrinking summary"
|
|
128
|
+
)
|
|
129
|
+
return await self._finalize(TurnStatus.FAILED, error)
|
|
130
|
+
await execution.append(
|
|
131
|
+
compaction_events(
|
|
132
|
+
summary,
|
|
133
|
+
surface,
|
|
134
|
+
turn=execution.turn,
|
|
135
|
+
step=execution.step,
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
surface = await execution.context.store.load_surface(execution.session_id)
|
|
139
|
+
projection = project_events(
|
|
140
|
+
surface,
|
|
141
|
+
session_id=execution.session_id,
|
|
142
|
+
turn=execution.turn,
|
|
143
|
+
step=execution.step,
|
|
144
|
+
config=execution.context.config,
|
|
145
|
+
tools=execution.context.tools.catalog(),
|
|
146
|
+
)
|
|
147
|
+
if projection.compaction_reason is not None:
|
|
148
|
+
return await self._finalize(
|
|
149
|
+
TurnStatus.FAILED,
|
|
150
|
+
"Request still exceeds capacity after one compaction",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
result = await self.model.call(projection.request, execution.cancel)
|
|
154
|
+
await execution.append(result.events)
|
|
155
|
+
if result.rebuild_for_steer:
|
|
156
|
+
await self._end_step("steer")
|
|
157
|
+
continue
|
|
158
|
+
if result.cancelled or execution.cancel.is_set():
|
|
159
|
+
return await self._finalize(TurnStatus.CANCELLED, "Cancelled")
|
|
160
|
+
if result.error or result.response is None:
|
|
161
|
+
return await self._finalize(
|
|
162
|
+
TurnStatus.FAILED,
|
|
163
|
+
str(result.error or "Provider returned no response"),
|
|
164
|
+
)
|
|
165
|
+
attempt = result.events[-1].attempt if result.events else 1
|
|
166
|
+
decision = decide_response(
|
|
167
|
+
result.response,
|
|
168
|
+
turn=execution.turn,
|
|
169
|
+
step=execution.step,
|
|
170
|
+
attempt=attempt,
|
|
171
|
+
corrections=execution.corrections,
|
|
172
|
+
max_corrections=execution.context.config.max_protocol_corrections,
|
|
173
|
+
)
|
|
174
|
+
await execution.append(decision.events)
|
|
175
|
+
if decision.action == ResponseAction.NEXT_STEP:
|
|
176
|
+
execution.corrections += 1
|
|
177
|
+
await self._end_step("protocol_correction")
|
|
178
|
+
continue
|
|
179
|
+
if decision.action == ResponseAction.CANCELLED:
|
|
180
|
+
return await self._finalize(TurnStatus.CANCELLED, "Provider cancelled")
|
|
181
|
+
if decision.action == ResponseAction.FAILED:
|
|
182
|
+
return await self._finalize(
|
|
183
|
+
TurnStatus.FAILED,
|
|
184
|
+
decision.error or "Provider terminal failure",
|
|
185
|
+
)
|
|
186
|
+
if decision.action == ResponseAction.FINAL_CANDIDATE:
|
|
187
|
+
outcome = await self._finish_success(decision.final_content)
|
|
188
|
+
if outcome is not None:
|
|
189
|
+
return outcome
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
batch = await self.tools.execute(
|
|
193
|
+
decision.calls,
|
|
194
|
+
session_id=execution.session_id,
|
|
195
|
+
turn=execution.turn,
|
|
196
|
+
step=execution.step,
|
|
197
|
+
cancel=execution.cancel,
|
|
198
|
+
active_resources=frozenset(execution.resource_locks),
|
|
199
|
+
commit=execution.append,
|
|
200
|
+
)
|
|
201
|
+
if batch.issues:
|
|
202
|
+
rejection = reject_batch(
|
|
203
|
+
[issue.to_dict() for issue in batch.issues],
|
|
204
|
+
turn=execution.turn,
|
|
205
|
+
step=execution.step,
|
|
206
|
+
corrections=execution.corrections,
|
|
207
|
+
max_corrections=execution.context.config.max_protocol_corrections,
|
|
208
|
+
)
|
|
209
|
+
await execution.append(rejection.events)
|
|
210
|
+
if rejection.action == ResponseAction.NEXT_STEP:
|
|
211
|
+
execution.corrections += 1
|
|
212
|
+
await self._end_step("tool_batch_rejected")
|
|
213
|
+
continue
|
|
214
|
+
return await self._finalize(
|
|
215
|
+
TurnStatus.FAILED,
|
|
216
|
+
rejection.error or "Tool batch rejected",
|
|
217
|
+
)
|
|
218
|
+
settled = settle_outcomes(
|
|
219
|
+
batch.outcomes,
|
|
220
|
+
turn=execution.turn,
|
|
221
|
+
step=execution.step,
|
|
222
|
+
pacing_step_interval=execution.context.config.pacing_step_interval,
|
|
223
|
+
blocked_resources=batch.blocked_resources,
|
|
224
|
+
)
|
|
225
|
+
await execution.append(settled.events)
|
|
226
|
+
execution.resource_locks.update(settled.acquired_resources)
|
|
227
|
+
execution.resource_locks.difference_update(settled.released_resources)
|
|
228
|
+
if settled.action == BatchAction.BLOCKED:
|
|
229
|
+
return await self._finalize(
|
|
230
|
+
TurnStatus.BLOCKED,
|
|
231
|
+
settled.error or "Blocked by unresolved resources",
|
|
232
|
+
)
|
|
233
|
+
if settled.action == BatchAction.CANCELLED or execution.cancel.is_set():
|
|
234
|
+
return await self._finalize(TurnStatus.CANCELLED, "Cancelled during Tool batch")
|
|
235
|
+
if settled.action == BatchAction.FINALIZE:
|
|
236
|
+
outcome = await self._finish_success(settled.final_content)
|
|
237
|
+
if outcome is not None:
|
|
238
|
+
return outcome
|
|
239
|
+
continue
|
|
240
|
+
await self._end_step("tool_batch_completed")
|
|
241
|
+
except InvariantViolation:
|
|
242
|
+
raise
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
return await self._finalize(TurnStatus.FAILED, f"{type(exc).__name__}: {exc}")
|
|
245
|
+
|
|
246
|
+
async def _begin_step(self) -> None:
|
|
247
|
+
execution = self.execution
|
|
248
|
+
async with execution.actor.commit_lock:
|
|
249
|
+
execution.step += 1
|
|
250
|
+
execution.open_step = True
|
|
251
|
+
events = [
|
|
252
|
+
NewEvent(event_type=EventType.STEP_START, turn=execution.turn, step=execution.step)
|
|
253
|
+
]
|
|
254
|
+
inputs = []
|
|
255
|
+
if execution.step == 1:
|
|
256
|
+
inputs.append((execution.context.input_id, execution.context.initial_input))
|
|
257
|
+
inputs.extend(execution.actor.inbox)
|
|
258
|
+
execution.actor.inbox.clear()
|
|
259
|
+
for input_id, content in inputs:
|
|
260
|
+
events.append(
|
|
261
|
+
NewEvent(
|
|
262
|
+
event_type=EventType.USER_MESSAGE,
|
|
263
|
+
turn=execution.turn,
|
|
264
|
+
step=execution.step,
|
|
265
|
+
surface_op=SurfaceOp.APPEND,
|
|
266
|
+
payload={
|
|
267
|
+
"input_id": input_id,
|
|
268
|
+
"message": message_to_data(Message(role="user", content=content)),
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
await execution._append_locked(events)
|
|
273
|
+
|
|
274
|
+
async def _end_step(self, status: str) -> None:
|
|
275
|
+
execution = self.execution
|
|
276
|
+
if not execution.open_step:
|
|
277
|
+
return
|
|
278
|
+
await execution.append(
|
|
279
|
+
[
|
|
280
|
+
NewEvent(
|
|
281
|
+
event_type=EventType.STEP_END,
|
|
282
|
+
turn=execution.turn,
|
|
283
|
+
step=execution.step,
|
|
284
|
+
payload={"status": status},
|
|
285
|
+
)
|
|
286
|
+
]
|
|
287
|
+
)
|
|
288
|
+
execution.open_step = False
|
|
289
|
+
|
|
290
|
+
async def _end_step_locked(self, status: str) -> None:
|
|
291
|
+
execution = self.execution
|
|
292
|
+
if not execution.open_step:
|
|
293
|
+
return
|
|
294
|
+
await execution._append_locked(
|
|
295
|
+
[
|
|
296
|
+
NewEvent(
|
|
297
|
+
event_type=EventType.STEP_END,
|
|
298
|
+
turn=execution.turn,
|
|
299
|
+
step=execution.step,
|
|
300
|
+
payload={"status": status},
|
|
301
|
+
)
|
|
302
|
+
]
|
|
303
|
+
)
|
|
304
|
+
execution.open_step = False
|
|
305
|
+
|
|
306
|
+
async def _working_context_expiry(self) -> NewEvent | None:
|
|
307
|
+
execution = self.execution
|
|
308
|
+
surface = await execution.context.store.load_surface(execution.session_id)
|
|
309
|
+
active = latest_working_context(surface)
|
|
310
|
+
if active is None:
|
|
311
|
+
return None
|
|
312
|
+
marker = active.provider_data.get("working_context", {})
|
|
313
|
+
if marker.get("scope") != "turn":
|
|
314
|
+
return None
|
|
315
|
+
message = Message(role="user", content="[WorkingContext expired · turn]")
|
|
316
|
+
return NewEvent(
|
|
317
|
+
event_type=EventType.USER_MESSAGE,
|
|
318
|
+
turn=execution.turn,
|
|
319
|
+
step=execution.step,
|
|
320
|
+
surface_op=SurfaceOp.APPEND,
|
|
321
|
+
payload={
|
|
322
|
+
"message": message_to_data(message),
|
|
323
|
+
"working_context": {"action": "expired", "scope": "turn"},
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
async def _finish_success(self, content: str) -> TurnOutcome | None:
|
|
328
|
+
execution = self.execution
|
|
329
|
+
async with execution.actor.commit_lock:
|
|
330
|
+
if execution.actor.inbox:
|
|
331
|
+
await self._end_step_locked("steer")
|
|
332
|
+
return None
|
|
333
|
+
if execution.cancel.is_set():
|
|
334
|
+
raise _CancelFinalization()
|
|
335
|
+
final_message_id = new_id("final")
|
|
336
|
+
final = FinalMessage(
|
|
337
|
+
final_message_id=final_message_id,
|
|
338
|
+
session_id=execution.session_id,
|
|
339
|
+
turn=execution.turn,
|
|
340
|
+
step=execution.step,
|
|
341
|
+
content=content,
|
|
342
|
+
)
|
|
343
|
+
events = [
|
|
344
|
+
NewEvent(
|
|
345
|
+
event_type=EventType.FINAL_MESSAGE,
|
|
346
|
+
turn=execution.turn,
|
|
347
|
+
step=execution.step,
|
|
348
|
+
payload={"final_message_id": final_message_id, "content": content},
|
|
349
|
+
)
|
|
350
|
+
]
|
|
351
|
+
expiry = await self._working_context_expiry()
|
|
352
|
+
if expiry is not None:
|
|
353
|
+
events.append(expiry)
|
|
354
|
+
if execution.open_step:
|
|
355
|
+
events.append(
|
|
356
|
+
NewEvent(
|
|
357
|
+
event_type=EventType.STEP_END,
|
|
358
|
+
turn=execution.turn,
|
|
359
|
+
step=execution.step,
|
|
360
|
+
payload={"status": "final"},
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
execution.open_step = False
|
|
364
|
+
events.append(
|
|
365
|
+
NewEvent(
|
|
366
|
+
event_type=EventType.TURN_END,
|
|
367
|
+
turn=execution.turn,
|
|
368
|
+
step=execution.step,
|
|
369
|
+
payload={
|
|
370
|
+
"status": TurnStatus.SUCCEEDED.value,
|
|
371
|
+
"final_message_id": final_message_id,
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
await execution._append_locked(events)
|
|
376
|
+
execution.final_committed = True
|
|
377
|
+
try:
|
|
378
|
+
await execution.context.delivery.deliver(final)
|
|
379
|
+
await execution.append(
|
|
380
|
+
[
|
|
381
|
+
NewEvent(
|
|
382
|
+
event_type=EventType.DELIVERY_COMMITTED,
|
|
383
|
+
turn=execution.turn,
|
|
384
|
+
step=execution.step,
|
|
385
|
+
payload={"final_message_id": final_message_id},
|
|
386
|
+
)
|
|
387
|
+
]
|
|
388
|
+
)
|
|
389
|
+
status = TurnStatus.SUCCEEDED
|
|
390
|
+
error = None
|
|
391
|
+
except _CancelFinalization:
|
|
392
|
+
return await self._finalize(TurnStatus.CANCELLED, "Cancelled before final commitment")
|
|
393
|
+
except Exception as exc:
|
|
394
|
+
error = str(exc)
|
|
395
|
+
await execution.append(
|
|
396
|
+
[
|
|
397
|
+
NewEvent(
|
|
398
|
+
event_type=EventType.DELIVERY_UNKNOWN,
|
|
399
|
+
turn=execution.turn,
|
|
400
|
+
step=execution.step,
|
|
401
|
+
payload={"final_message_id": final_message_id, "error": error},
|
|
402
|
+
)
|
|
403
|
+
]
|
|
404
|
+
)
|
|
405
|
+
status = TurnStatus.DELIVERY_UNKNOWN
|
|
406
|
+
return TurnOutcome(
|
|
407
|
+
status=status,
|
|
408
|
+
session_id=execution.session_id,
|
|
409
|
+
turn=execution.turn,
|
|
410
|
+
step=execution.step,
|
|
411
|
+
final_message_id=final_message_id,
|
|
412
|
+
error=error,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
async def _finalize(self, status: TurnStatus, error: str) -> TurnOutcome:
|
|
416
|
+
execution = self.execution
|
|
417
|
+
events = []
|
|
418
|
+
expiry = await self._working_context_expiry()
|
|
419
|
+
if expiry is not None:
|
|
420
|
+
events.append(expiry)
|
|
421
|
+
if execution.open_step:
|
|
422
|
+
events.append(
|
|
423
|
+
NewEvent(
|
|
424
|
+
event_type=EventType.STEP_END,
|
|
425
|
+
turn=execution.turn,
|
|
426
|
+
step=execution.step,
|
|
427
|
+
payload={"status": status.value},
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
execution.open_step = False
|
|
431
|
+
events.append(
|
|
432
|
+
NewEvent(
|
|
433
|
+
event_type=EventType.TURN_END,
|
|
434
|
+
turn=execution.turn,
|
|
435
|
+
step=execution.step,
|
|
436
|
+
payload={"status": status.value, "error": error},
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
await execution.append(events)
|
|
440
|
+
return TurnOutcome(
|
|
441
|
+
status=status,
|
|
442
|
+
session_id=execution.session_id,
|
|
443
|
+
turn=execution.turn,
|
|
444
|
+
step=execution.step,
|
|
445
|
+
error=error,
|
|
446
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .outcome import BatchAction, BatchDecision, settle_outcomes
|
|
2
|
+
from .project import Projection, project_events
|
|
3
|
+
from .repair import RepairDecision, repair_events
|
|
4
|
+
from .response import ResponseAction, ResponseDecision, decide_response
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"BatchAction",
|
|
8
|
+
"BatchDecision",
|
|
9
|
+
"Projection",
|
|
10
|
+
"RepairDecision",
|
|
11
|
+
"ResponseAction",
|
|
12
|
+
"ResponseDecision",
|
|
13
|
+
"decide_response",
|
|
14
|
+
"project_events",
|
|
15
|
+
"repair_events",
|
|
16
|
+
"settle_outcomes",
|
|
17
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..contracts import (
|
|
9
|
+
Cancelled,
|
|
10
|
+
Completed,
|
|
11
|
+
EventType,
|
|
12
|
+
Message,
|
|
13
|
+
NewEvent,
|
|
14
|
+
Skipped,
|
|
15
|
+
SurfaceOp,
|
|
16
|
+
ToolCall,
|
|
17
|
+
ToolOutcome,
|
|
18
|
+
ToolSpec,
|
|
19
|
+
UncertainHanging,
|
|
20
|
+
UncertainSettled,
|
|
21
|
+
)
|
|
22
|
+
from .project import message_to_data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BatchAction(StrEnum):
|
|
26
|
+
NEXT_STEP = "next_step"
|
|
27
|
+
FINALIZE = "finalize"
|
|
28
|
+
CANCELLED = "cancelled"
|
|
29
|
+
BLOCKED = "blocked"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class BatchDecision:
|
|
34
|
+
action: BatchAction
|
|
35
|
+
events: tuple[NewEvent, ...]
|
|
36
|
+
final_content: str = ""
|
|
37
|
+
error: str | None = None
|
|
38
|
+
acquired_resources: frozenset[str] = frozenset()
|
|
39
|
+
released_resources: frozenset[str] = frozenset()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def outcome_to_data(outcome: ToolOutcome) -> dict[str, Any]:
|
|
43
|
+
if isinstance(outcome, Completed):
|
|
44
|
+
return {
|
|
45
|
+
"kind": "completed",
|
|
46
|
+
"content": outcome.content,
|
|
47
|
+
"is_error": outcome.is_error,
|
|
48
|
+
"concludes_turn": outcome.concludes_turn,
|
|
49
|
+
"structured": outcome.structured,
|
|
50
|
+
"assets": list(outcome.assets),
|
|
51
|
+
"release_resources": list(outcome.release_resources),
|
|
52
|
+
}
|
|
53
|
+
if isinstance(outcome, Skipped):
|
|
54
|
+
return {"kind": "skipped", "reason": outcome.reason}
|
|
55
|
+
if isinstance(outcome, Cancelled):
|
|
56
|
+
return {
|
|
57
|
+
"kind": "cancelled",
|
|
58
|
+
"settled": outcome.settled,
|
|
59
|
+
"partial_content": outcome.partial_content,
|
|
60
|
+
"resource_locks": list(outcome.resource_locks),
|
|
61
|
+
}
|
|
62
|
+
if isinstance(outcome, UncertainSettled):
|
|
63
|
+
return {
|
|
64
|
+
"kind": "uncertain_settled",
|
|
65
|
+
"content": outcome.content,
|
|
66
|
+
"assets": list(outcome.assets),
|
|
67
|
+
}
|
|
68
|
+
if isinstance(outcome, UncertainHanging):
|
|
69
|
+
return {
|
|
70
|
+
"kind": "uncertain_hanging",
|
|
71
|
+
"content": outcome.content,
|
|
72
|
+
"resource_locks": list(outcome.resource_locks),
|
|
73
|
+
}
|
|
74
|
+
raise TypeError(f"Unsupported ToolOutcome: {type(outcome).__name__}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def outcome_text(call: ToolCall, outcome: ToolOutcome) -> str:
|
|
78
|
+
if isinstance(outcome, Completed):
|
|
79
|
+
prefix = "[tool error]\n" if outcome.is_error else ""
|
|
80
|
+
return prefix + outcome.content
|
|
81
|
+
if isinstance(outcome, Skipped):
|
|
82
|
+
return "[skipped]\n" + outcome.reason
|
|
83
|
+
if isinstance(outcome, Cancelled):
|
|
84
|
+
return f"[cancelled settled={str(outcome.settled).lower()}]\n" + outcome.partial_content
|
|
85
|
+
if isinstance(outcome, UncertainSettled):
|
|
86
|
+
return "[UNCERTAIN_EFFECT_SETTLED]\n" + outcome.content
|
|
87
|
+
return "[UNCERTAIN_EFFECT_HANGING]\n" + outcome.content
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def outcome_event(
|
|
91
|
+
call: ToolCall, outcome: ToolOutcome, *, turn: int, step: int, suffix: str = ""
|
|
92
|
+
) -> NewEvent:
|
|
93
|
+
message = Message(
|
|
94
|
+
role="tool",
|
|
95
|
+
content=outcome_text(call, outcome) + suffix,
|
|
96
|
+
tool_call_id=call.call_id,
|
|
97
|
+
name=call.name,
|
|
98
|
+
)
|
|
99
|
+
return NewEvent(
|
|
100
|
+
event_type=EventType.TOOL_RESULT,
|
|
101
|
+
turn=turn,
|
|
102
|
+
step=step,
|
|
103
|
+
surface_op=SurfaceOp.APPEND,
|
|
104
|
+
payload={
|
|
105
|
+
"call_id": call.call_id,
|
|
106
|
+
"message": message_to_data(message),
|
|
107
|
+
"outcome": outcome_to_data(outcome),
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def settle_outcomes(
|
|
113
|
+
outcomes: Sequence[tuple[ToolCall, ToolSpec, ToolOutcome]],
|
|
114
|
+
*,
|
|
115
|
+
turn: int,
|
|
116
|
+
step: int,
|
|
117
|
+
pacing_step_interval: int,
|
|
118
|
+
blocked_resources: frozenset[str] = frozenset(),
|
|
119
|
+
) -> BatchDecision:
|
|
120
|
+
events: list[NewEvent] = []
|
|
121
|
+
acquired: set[str] = set()
|
|
122
|
+
released: set[str] = set()
|
|
123
|
+
final_content = ""
|
|
124
|
+
cancelled = False
|
|
125
|
+
for index, (call, _spec, outcome) in enumerate(outcomes):
|
|
126
|
+
suffix = ""
|
|
127
|
+
if (
|
|
128
|
+
index == len(outcomes) - 1
|
|
129
|
+
and pacing_step_interval > 0
|
|
130
|
+
and step % pacing_step_interval == 0
|
|
131
|
+
):
|
|
132
|
+
suffix = (
|
|
133
|
+
f"\n[Runtime: Step {step} reached; verify progress, converge, "
|
|
134
|
+
"or ask the user if blocked]"
|
|
135
|
+
)
|
|
136
|
+
events.append(outcome_event(call, outcome, turn=turn, step=step, suffix=suffix))
|
|
137
|
+
if isinstance(outcome, Completed):
|
|
138
|
+
released.update(outcome.release_resources)
|
|
139
|
+
for message in outcome.additional_messages:
|
|
140
|
+
marker = message.provider_data.get("working_context")
|
|
141
|
+
payload: dict[str, Any] = {"message": message_to_data(message)}
|
|
142
|
+
if isinstance(marker, Mapping):
|
|
143
|
+
payload["working_context"] = dict(marker)
|
|
144
|
+
events.append(
|
|
145
|
+
NewEvent(
|
|
146
|
+
event_type=EventType.USER_MESSAGE,
|
|
147
|
+
turn=turn,
|
|
148
|
+
step=step,
|
|
149
|
+
surface_op=SurfaceOp.APPEND,
|
|
150
|
+
payload=payload,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
if outcome.concludes_turn and not final_content:
|
|
154
|
+
final_content = outcome.content
|
|
155
|
+
elif isinstance(outcome, Cancelled):
|
|
156
|
+
cancelled = True
|
|
157
|
+
if not outcome.settled:
|
|
158
|
+
acquired.update(outcome.resource_locks)
|
|
159
|
+
elif isinstance(outcome, UncertainHanging):
|
|
160
|
+
acquired.update(outcome.resource_locks)
|
|
161
|
+
acquired.difference_update(released)
|
|
162
|
+
has_hanging = any(isinstance(outcome, UncertainHanging) for _, _, outcome in outcomes)
|
|
163
|
+
if blocked_resources or has_hanging:
|
|
164
|
+
action = BatchAction.BLOCKED
|
|
165
|
+
elif cancelled:
|
|
166
|
+
action = BatchAction.CANCELLED
|
|
167
|
+
elif final_content:
|
|
168
|
+
action = BatchAction.FINALIZE
|
|
169
|
+
else:
|
|
170
|
+
action = BatchAction.NEXT_STEP
|
|
171
|
+
error = None
|
|
172
|
+
if action == BatchAction.BLOCKED:
|
|
173
|
+
error = "Execution is blocked by unresolved resources: " + ", ".join(
|
|
174
|
+
sorted(blocked_resources | acquired)
|
|
175
|
+
)
|
|
176
|
+
return BatchDecision(
|
|
177
|
+
action=action,
|
|
178
|
+
events=tuple(events),
|
|
179
|
+
final_content=final_content,
|
|
180
|
+
error=error,
|
|
181
|
+
acquired_resources=frozenset(acquired),
|
|
182
|
+
released_resources=frozenset(released),
|
|
183
|
+
)
|