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
|
@@ -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"))
|