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/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
+ )