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.
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import threading
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from ..contracts import (
10
+ EVENT_SCHEMA_VERSION,
11
+ Event,
12
+ EventType,
13
+ InvariantViolation,
14
+ NewEvent,
15
+ SurfaceOp,
16
+ utc_now,
17
+ )
18
+
19
+
20
+ class SQLiteEventStore:
21
+ def __init__(self, path: str) -> None:
22
+ if path != ":memory:":
23
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
24
+ self._connection = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
25
+ self._connection.row_factory = sqlite3.Row
26
+ self._lock = threading.RLock()
27
+ with self._lock:
28
+ self._connection.execute("PRAGMA journal_mode=WAL")
29
+ self._connection.execute("PRAGMA synchronous=FULL")
30
+ self._connection.execute("PRAGMA busy_timeout=5000")
31
+ self._connection.execute(
32
+ """
33
+ CREATE TABLE IF NOT EXISTS session_writers (
34
+ session_id TEXT PRIMARY KEY,
35
+ writer_epoch INTEGER NOT NULL
36
+ )
37
+ """
38
+ )
39
+ self._connection.execute(
40
+ """
41
+ CREATE TABLE IF NOT EXISTS session_events (
42
+ session_id TEXT NOT NULL,
43
+ seq INTEGER NOT NULL,
44
+ turn INTEGER NOT NULL,
45
+ step INTEGER NOT NULL,
46
+ attempt INTEGER NOT NULL,
47
+ writer_epoch INTEGER NOT NULL,
48
+ event_id TEXT NOT NULL UNIQUE,
49
+ event_type TEXT NOT NULL,
50
+ surface_op TEXT NOT NULL,
51
+ payload_json TEXT NOT NULL,
52
+ occurred_at TEXT NOT NULL,
53
+ schema_version TEXT NOT NULL,
54
+ PRIMARY KEY (session_id, seq)
55
+ )
56
+ """
57
+ )
58
+ self._connection.execute(
59
+ "CREATE INDEX IF NOT EXISTS session_events_surface_idx "
60
+ "ON session_events(session_id, surface_op, seq)"
61
+ )
62
+ self._connection.execute(
63
+ "CREATE INDEX IF NOT EXISTS session_events_turn_idx "
64
+ "ON session_events(session_id, turn, step, seq)"
65
+ )
66
+
67
+ def close(self) -> None:
68
+ with self._lock:
69
+ self._connection.close()
70
+
71
+ async def attach_writer(self, session_id: str) -> tuple[int, int, int]:
72
+ with self._lock:
73
+ self._connection.execute("BEGIN IMMEDIATE")
74
+ try:
75
+ row = self._connection.execute(
76
+ "SELECT writer_epoch FROM session_writers WHERE session_id = ?",
77
+ (session_id,),
78
+ ).fetchone()
79
+ epoch = (int(row["writer_epoch"]) if row else 0) + 1
80
+ self._connection.execute(
81
+ "INSERT INTO session_writers(session_id, writer_epoch) VALUES (?, ?) "
82
+ "ON CONFLICT(session_id) DO UPDATE SET writer_epoch = excluded.writer_epoch",
83
+ (session_id, epoch),
84
+ )
85
+ tail = self._connection.execute(
86
+ "SELECT COALESCE(MAX(seq), 0) AS last_seq, COALESCE(MAX(turn), 0) AS last_turn "
87
+ "FROM session_events WHERE session_id = ?",
88
+ (session_id,),
89
+ ).fetchone()
90
+ self._connection.execute("COMMIT")
91
+ return epoch, int(tail["last_seq"]), int(tail["last_turn"])
92
+ except Exception:
93
+ self._connection.execute("ROLLBACK")
94
+ raise
95
+
96
+ async def append(
97
+ self,
98
+ session_id: str,
99
+ writer_epoch: int,
100
+ expected_seq: int,
101
+ events: Sequence[NewEvent],
102
+ ) -> Sequence[Event]:
103
+ if not events:
104
+ return ()
105
+ with self._lock:
106
+ self._connection.execute("BEGIN IMMEDIATE")
107
+ try:
108
+ row = self._connection.execute(
109
+ "SELECT writer_epoch FROM session_writers WHERE session_id = ?",
110
+ (session_id,),
111
+ ).fetchone()
112
+ if row is None or int(row["writer_epoch"]) != writer_epoch:
113
+ raise InvariantViolation("Stale writer_epoch")
114
+ last_seq = int(
115
+ self._connection.execute(
116
+ "SELECT COALESCE(MAX(seq), 0) AS last_seq "
117
+ "FROM session_events WHERE session_id = ?",
118
+ (session_id,),
119
+ ).fetchone()["last_seq"]
120
+ )
121
+ if expected_seq != last_seq + 1:
122
+ committed = self._idempotent(session_id, events)
123
+ self._connection.execute("COMMIT")
124
+ return committed
125
+ committed = []
126
+ for offset, draft in enumerate(events):
127
+ occurred_at = utc_now()
128
+ self._connection.execute(
129
+ """
130
+ INSERT INTO session_events (
131
+ session_id, seq, turn, step, attempt, writer_epoch, event_id,
132
+ event_type, surface_op, payload_json, occurred_at, schema_version
133
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
134
+ """,
135
+ (
136
+ session_id,
137
+ expected_seq + offset,
138
+ draft.turn,
139
+ draft.step,
140
+ draft.attempt,
141
+ writer_epoch,
142
+ draft.event_id,
143
+ draft.event_type.value,
144
+ draft.surface_op.value,
145
+ json.dumps(
146
+ draft.payload,
147
+ ensure_ascii=False,
148
+ sort_keys=True,
149
+ separators=(",", ":"),
150
+ ),
151
+ occurred_at,
152
+ EVENT_SCHEMA_VERSION,
153
+ ),
154
+ )
155
+ committed.append(
156
+ self._event_from_draft(
157
+ session_id, expected_seq + offset, writer_epoch, draft, occurred_at
158
+ )
159
+ )
160
+ self._connection.execute("COMMIT")
161
+ return committed
162
+ except Exception:
163
+ self._connection.execute("ROLLBACK")
164
+ raise
165
+
166
+ async def load_surface(self, session_id: str) -> Sequence[Event]:
167
+ with self._lock:
168
+ rows = self._connection.execute(
169
+ """
170
+ SELECT * FROM session_events
171
+ WHERE session_id = ?
172
+ AND seq >= COALESCE((
173
+ SELECT MAX(seq) FROM session_events
174
+ WHERE session_id = ? AND surface_op = 'epoch'
175
+ ), 1)
176
+ AND surface_op != 'none'
177
+ ORDER BY seq
178
+ """,
179
+ (session_id, session_id),
180
+ ).fetchall()
181
+ return tuple(self._event_from_row(row) for row in rows)
182
+
183
+ async def load_recovery(self, session_id: str) -> Sequence[Event]:
184
+ with self._lock:
185
+ rows = self._connection.execute(
186
+ "SELECT * FROM session_events WHERE session_id = ? ORDER BY seq",
187
+ (session_id,),
188
+ ).fetchall()
189
+ return tuple(self._event_from_row(row) for row in rows)
190
+
191
+ def _idempotent(self, session_id: str, drafts: Sequence[NewEvent]) -> Sequence[Event]:
192
+ committed = []
193
+ for draft in drafts:
194
+ row = self._connection.execute(
195
+ "SELECT * FROM session_events WHERE session_id = ? AND event_id = ?",
196
+ (session_id, draft.event_id),
197
+ ).fetchone()
198
+ if row is None:
199
+ raise InvariantViolation("Expected sequence does not match Session tail")
200
+ event = self._event_from_row(row)
201
+ if (
202
+ event.event_type != draft.event_type
203
+ or event.turn != draft.turn
204
+ or event.step != draft.step
205
+ or event.attempt != draft.attempt
206
+ or event.surface_op != draft.surface_op
207
+ or event.payload != dict(draft.payload)
208
+ ):
209
+ raise InvariantViolation("Event ID reused with different content")
210
+ committed.append(event)
211
+ return committed
212
+
213
+ @staticmethod
214
+ def _event_from_draft(
215
+ session_id: str,
216
+ seq: int,
217
+ writer_epoch: int,
218
+ draft: NewEvent,
219
+ occurred_at: str,
220
+ ) -> Event:
221
+ return Event(
222
+ session_id=session_id,
223
+ seq=seq,
224
+ turn=draft.turn,
225
+ step=draft.step,
226
+ attempt=draft.attempt,
227
+ writer_epoch=writer_epoch,
228
+ event_id=draft.event_id,
229
+ event_type=draft.event_type,
230
+ surface_op=draft.surface_op,
231
+ payload=dict(draft.payload),
232
+ occurred_at=occurred_at,
233
+ )
234
+
235
+ @staticmethod
236
+ def _event_from_row(row: sqlite3.Row) -> Event:
237
+ return Event(
238
+ session_id=row["session_id"],
239
+ seq=row["seq"],
240
+ turn=row["turn"],
241
+ step=row["step"],
242
+ attempt=row["attempt"],
243
+ writer_epoch=row["writer_epoch"],
244
+ event_id=row["event_id"],
245
+ event_type=EventType(row["event_type"]),
246
+ surface_op=SurfaceOp(row["surface_op"]),
247
+ payload=json.loads(row["payload_json"]),
248
+ occurred_at=row["occurred_at"],
249
+ schema_version=row["schema_version"],
250
+ )
nextloop/contracts.py ADDED
@@ -0,0 +1,350 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import uuid
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+ from datetime import UTC, datetime
9
+ from enum import StrEnum
10
+ from typing import Any, Protocol
11
+
12
+ API_VERSION = "0.1.0"
13
+ EVENT_SCHEMA_VERSION = "1.0"
14
+
15
+
16
+ def new_id(prefix: str) -> str:
17
+ return f"{prefix}_{uuid.uuid4().hex}"
18
+
19
+
20
+ def utc_now() -> str:
21
+ return datetime.now(UTC).isoformat()
22
+
23
+
24
+ class NextloopError(Exception):
25
+ """Base error for nextloop."""
26
+
27
+
28
+ class InvariantViolation(NextloopError):
29
+ """Raised when seq, idempotency, or writer_epoch invariants fail."""
30
+
31
+
32
+ class CapacityError(NextloopError):
33
+ """Raised when request exceeds context capacity even after compaction."""
34
+
35
+
36
+ class ProtocolExhausted(NextloopError):
37
+ """Raised when model exceeds max_protocol_corrections in one Turn."""
38
+
39
+
40
+ class ProviderError(NextloopError):
41
+ def __init__(
42
+ self,
43
+ message: str,
44
+ *,
45
+ retryable: bool = False,
46
+ partial_output: bool = False,
47
+ code: str | None = None,
48
+ ) -> None:
49
+ super().__init__(message)
50
+ self.retryable = retryable
51
+ self.partial_output = partial_output
52
+ self.code = code
53
+
54
+
55
+ class SurfaceOp(StrEnum):
56
+ NONE = "none" # Log-only audit/lifecycle event; skipped by projection
57
+ APPEND = "append" # Model-visible message/observation appended to surface
58
+ EPOCH = "epoch" # Compaction watermark establishing a new surface baseline
59
+
60
+
61
+ class EventType(StrEnum):
62
+ INPUT_ACCEPTED = "input/accepted"
63
+ TURN_START = "turn/start"
64
+ STEP_START = "step/start"
65
+ USER_MESSAGE = "user/message"
66
+ ASSISTANT_ATTEMPT = "assistant/attempt"
67
+ ASSISTANT_MESSAGE = "assistant/message"
68
+ TOOL_CALL = "tool/call"
69
+ TOOL_RESULT = "tool/result"
70
+ TOOL_BATCH_REJECTED = "tool/batch-rejected"
71
+ COMPACTION = "compaction"
72
+ CANCELLATION_REQUESTED = "cancellation/requested"
73
+ FINAL_MESSAGE = "final/message"
74
+ STEP_END = "step/end"
75
+ TURN_END = "turn/end"
76
+ DELIVERY_COMMITTED = "delivery/committed"
77
+ DELIVERY_UNKNOWN = "delivery/unknown"
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class NewEvent:
82
+ event_type: EventType
83
+ turn: int
84
+ step: int
85
+ attempt: int = 0
86
+ surface_op: SurfaceOp = SurfaceOp.NONE
87
+ payload: Mapping[str, Any] = field(default_factory=dict)
88
+ event_id: str = field(default_factory=lambda: new_id("evt"))
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class Event:
93
+ session_id: str
94
+ seq: int
95
+ turn: int
96
+ step: int
97
+ attempt: int
98
+ writer_epoch: int
99
+ event_id: str
100
+ event_type: EventType
101
+ surface_op: SurfaceOp
102
+ payload: Mapping[str, Any]
103
+ occurred_at: str
104
+ schema_version: str = EVENT_SCHEMA_VERSION
105
+
106
+
107
+ class FinishReason(StrEnum):
108
+ STOP = "stop"
109
+ TOOL_CALLS = "tool_calls"
110
+ LENGTH = "length"
111
+ CONTENT_FILTER = "content_filter"
112
+ ERROR = "error"
113
+ CANCELLED = "cancelled"
114
+
115
+
116
+ class ToolExecutionMode(StrEnum):
117
+ PARALLEL = "parallel" # Side-effect-free read; runs in bounded pool
118
+ EXCLUSIVE = "exclusive" # Mutating barrier; failure truncates remaining batch
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class ToolCall:
123
+ call_id: str
124
+ name: str
125
+ arguments: str
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class ToolSpec:
130
+ name: str
131
+ description: str
132
+ parameters: Mapping[str, Any]
133
+ mode: ToolExecutionMode = ToolExecutionMode.EXCLUSIVE
134
+ resources: tuple[str, ...] = ()
135
+ timeout_seconds: float | None = None
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class ValidationIssue:
140
+ call_id: str
141
+ name: str
142
+ message: str
143
+
144
+ def to_dict(self) -> dict[str, str]:
145
+ return {"call_id": self.call_id, "name": self.name, "message": self.message}
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class ValidatedCall:
150
+ call: ToolCall
151
+ spec: ToolSpec
152
+ arguments: Mapping[str, Any]
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class Message:
157
+ role: str # "system" | "user" | "assistant" | "tool"
158
+ content: str = ""
159
+ tool_calls: tuple[ToolCall, ...] = ()
160
+ tool_call_id: str | None = None
161
+ name: str | None = None
162
+ provider_data: Mapping[str, Any] = field(default_factory=dict)
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class ModelUsage:
167
+ input_tokens: int | None = None
168
+ output_tokens: int | None = None
169
+ cached_input_tokens: int | None = None
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class ModelResponse:
174
+ content: str = ""
175
+ tool_calls: tuple[ToolCall, ...] = ()
176
+ finish_reason: FinishReason = FinishReason.STOP
177
+ refusal: str | None = None
178
+ usage: ModelUsage | None = None
179
+ provider_data: Mapping[str, Any] = field(default_factory=dict)
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class CandidateRequest:
184
+ session_id: str
185
+ turn: int
186
+ step: int
187
+ messages: tuple[Message, ...]
188
+ tools: tuple[ToolSpec, ...]
189
+
190
+ def canonical_bytes(self) -> bytes:
191
+ data = {
192
+ "tools": [
193
+ {"name": t.name, "description": t.description, "parameters": dict(t.parameters)}
194
+ for t in self.tools
195
+ ],
196
+ "messages": [
197
+ {
198
+ "role": m.role,
199
+ "content": m.content,
200
+ "tool_calls": [
201
+ {"call_id": c.call_id, "name": c.name, "arguments": c.arguments}
202
+ for c in m.tool_calls
203
+ ],
204
+ "tool_call_id": m.tool_call_id,
205
+ "name": m.name,
206
+ "provider_data": dict(m.provider_data),
207
+ }
208
+ for m in self.messages
209
+ ],
210
+ }
211
+ return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode(
212
+ "utf-8"
213
+ )
214
+
215
+
216
+ @dataclass(frozen=True)
217
+ class Completed:
218
+ content: str
219
+ is_error: bool = False
220
+ concludes_turn: bool = False
221
+ structured: Any | None = None
222
+ assets: tuple[str, ...] = ()
223
+ additional_messages: tuple[Message, ...] = ()
224
+ release_resources: tuple[str, ...] = ()
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class Skipped:
229
+ reason: str
230
+
231
+
232
+ @dataclass(frozen=True)
233
+ class Cancelled:
234
+ settled: bool
235
+ partial_content: str = ""
236
+ resource_locks: tuple[str, ...] = ()
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class UncertainSettled:
241
+ content: str
242
+ assets: tuple[str, ...] = ()
243
+
244
+
245
+ @dataclass(frozen=True)
246
+ class UncertainHanging:
247
+ content: str
248
+ resource_locks: tuple[str, ...] = ()
249
+
250
+
251
+ ToolOutcome = Completed | Skipped | Cancelled | UncertainSettled | UncertainHanging
252
+
253
+
254
+ class TurnStatus(StrEnum):
255
+ SUCCEEDED = "succeeded"
256
+ FAILED = "failed"
257
+ CANCELLED = "cancelled"
258
+ BLOCKED = "blocked"
259
+ DELIVERY_UNKNOWN = "delivery_unknown"
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class LoopConfig:
264
+ system_prompt: str
265
+ compaction_prompt: str = ""
266
+ bootstrap_messages: tuple[Message, ...] = ()
267
+ context_window: int = 128_000
268
+ output_reserve: int = 4_096
269
+ max_parallel_tools: int = 4
270
+ max_protocol_corrections: int = 2
271
+ max_provider_attempts: int = 3
272
+ retry_backoff_seconds: float = 0.1
273
+ pacing_step_interval: int = 15
274
+
275
+
276
+ @dataclass(frozen=True)
277
+ class FinalMessage:
278
+ final_message_id: str
279
+ session_id: str
280
+ turn: int
281
+ step: int
282
+ content: str
283
+
284
+
285
+ @dataclass(frozen=True)
286
+ class TurnOutcome:
287
+ status: TurnStatus
288
+ session_id: str
289
+ turn: int
290
+ step: int
291
+ final_message_id: str | None = None
292
+ error: str | None = None
293
+
294
+
295
+ @dataclass(frozen=True)
296
+ class RecoveryOutcome:
297
+ session_id: str
298
+ writer_epoch: int
299
+ interrupted_turns: tuple[int, ...] = ()
300
+ delivered_final_ids: tuple[str, ...] = ()
301
+ delivery_unknown_final_ids: tuple[str, ...] = ()
302
+ active_resource_locks: frozenset[str] = field(default_factory=frozenset)
303
+
304
+
305
+ class EventStore(Protocol):
306
+ async def attach_writer(self, session_id: str) -> tuple[int, int, int]: ...
307
+ async def append(
308
+ self, session_id: str, writer_epoch: int, expected_seq: int, events: Sequence[NewEvent]
309
+ ) -> Sequence[Event]: ...
310
+ async def load_surface(self, session_id: str) -> Sequence[Event]: ...
311
+ async def load_recovery(self, session_id: str) -> Sequence[Event]: ...
312
+
313
+
314
+ class ModelProvider(Protocol):
315
+ async def complete(
316
+ self, request: CandidateRequest, *, attempt: int, cancel: asyncio.Event
317
+ ) -> ModelResponse: ...
318
+
319
+
320
+ class ToolRuntime(Protocol):
321
+ def catalog(self) -> Sequence[ToolSpec]: ...
322
+ def validate(
323
+ self, calls: Sequence[ToolCall]
324
+ ) -> tuple[tuple[ValidatedCall, ...], tuple[ValidationIssue, ...]]: ...
325
+ async def execute(
326
+ self,
327
+ call: ToolCall,
328
+ arguments: Mapping[str, Any],
329
+ *,
330
+ session_id: str,
331
+ turn: int,
332
+ step: int,
333
+ cancel: asyncio.Event,
334
+ ) -> ToolOutcome: ...
335
+
336
+
337
+ class DeliveryOwner(Protocol):
338
+ async def deliver(self, message: FinalMessage) -> None: ...
339
+
340
+
341
+ @dataclass(frozen=True)
342
+ class TurnContext:
343
+ session_id: str
344
+ initial_input: str
345
+ config: LoopConfig
346
+ store: EventStore
347
+ provider: ModelProvider
348
+ tools: ToolRuntime
349
+ delivery: DeliveryOwner
350
+ input_id: str = field(default_factory=lambda: new_id("in"))