power-loop 0.2.0__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.
- llm_client/__init__.py +0 -0
- llm_client/capabilities.py +162 -0
- llm_client/interface.py +470 -0
- llm_client/llm_factory.py +981 -0
- llm_client/llm_tooling.py +645 -0
- llm_client/llm_utils.py +205 -0
- llm_client/multimodal.py +237 -0
- llm_client/qwen_image.py +576 -0
- llm_client/web_search.py +149 -0
- power_loop/__init__.py +326 -0
- power_loop/agent/__init__.py +6 -0
- power_loop/agent/sink.py +247 -0
- power_loop/agent/stateful_loop.py +363 -0
- power_loop/agent/system_prompt.py +396 -0
- power_loop/agent/types.py +41 -0
- power_loop/contracts/__init__.py +132 -0
- power_loop/contracts/errors.py +140 -0
- power_loop/contracts/event_payloads.py +278 -0
- power_loop/contracts/events.py +86 -0
- power_loop/contracts/handlers.py +45 -0
- power_loop/contracts/hook_contexts.py +265 -0
- power_loop/contracts/hooks.py +64 -0
- power_loop/contracts/messages.py +90 -0
- power_loop/contracts/protocols.py +48 -0
- power_loop/contracts/tools.py +56 -0
- power_loop/core/agent_context.py +94 -0
- power_loop/core/events.py +124 -0
- power_loop/core/hooks.py +122 -0
- power_loop/core/phase.py +217 -0
- power_loop/core/pipeline.py +880 -0
- power_loop/core/runner.py +60 -0
- power_loop/core/state.py +208 -0
- power_loop/runtime/budget.py +179 -0
- power_loop/runtime/cancellation.py +127 -0
- power_loop/runtime/compact.py +300 -0
- power_loop/runtime/env.py +103 -0
- power_loop/runtime/memory.py +107 -0
- power_loop/runtime/provider.py +176 -0
- power_loop/runtime/retry.py +182 -0
- power_loop/runtime/session_store.py +636 -0
- power_loop/runtime/skills.py +201 -0
- power_loop/runtime/spec.py +233 -0
- power_loop/runtime/structured.py +225 -0
- power_loop/tools/__init__.py +51 -0
- power_loop/tools/default_manifest.py +244 -0
- power_loop/tools/default_tools.py +766 -0
- power_loop/tools/registry.py +162 -0
- power_loop/tools/spawn_agent.py +173 -0
- power_loop-0.2.0.dist-info/METADATA +632 -0
- power_loop-0.2.0.dist-info/RECORD +53 -0
- power_loop-0.2.0.dist-info/WHEEL +5 -0
- power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
- power_loop-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""SQLite-backed session store for power-loop.
|
|
2
|
+
|
|
3
|
+
Owns the persistence layer for stateful agent loops:
|
|
4
|
+
- sessions : metadata + parent linking + spawn depth + lifecycle
|
|
5
|
+
- messages : ordered (session_id, seq) message log with state flag
|
|
6
|
+
- compactions : audit of every compaction (which seq range → which note)
|
|
7
|
+
- usage_rounds : per-round token usage
|
|
8
|
+
- session_state : single-row per-session mutable state (next_seq, pending, …)
|
|
9
|
+
|
|
10
|
+
The store is the **only** place that writes to disk. Callers (StatefulAgentLoop,
|
|
11
|
+
the Sink that the pipeline drives, the subagent runtime) all go through here.
|
|
12
|
+
|
|
13
|
+
Threading
|
|
14
|
+
---------
|
|
15
|
+
A single :class:`sqlite3.Connection` is opened with ``check_same_thread=False``
|
|
16
|
+
and guarded by a re-entrant lock. SQLite is set to WAL so concurrent readers
|
|
17
|
+
from other processes still work. For asyncio callers, wrap calls in
|
|
18
|
+
``asyncio.to_thread``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import secrets
|
|
25
|
+
import sqlite3
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from enum import Enum
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
DEFAULT_DB_PATH = "./power_loop_sessions.db"
|
|
34
|
+
MAX_SPAWN_DEPTH = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SessionKind(str, Enum):
|
|
38
|
+
ROOT = "root"
|
|
39
|
+
SUBAGENT = "subagent"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SessionStatus(str, Enum):
|
|
43
|
+
ACTIVE = "active"
|
|
44
|
+
ARCHIVED = "archived"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SubagentLifecycle(str, Enum):
|
|
48
|
+
"""How long a subagent's session persists relative to its parent."""
|
|
49
|
+
|
|
50
|
+
EPHEMERAL = "ephemeral" # delete child immediately after it returns
|
|
51
|
+
LINKED = "linked" # keep, cascade-delete when parent is closed
|
|
52
|
+
DETACHED = "detached" # keep, independent of parent's lifecycle
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MessageState(str, Enum):
|
|
56
|
+
ACTIVE = "active"
|
|
57
|
+
COMPACTED_OUT = "compacted_out"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
SCHEMA_SQL = """
|
|
61
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
62
|
+
session_id TEXT PRIMARY KEY,
|
|
63
|
+
created_at INTEGER NOT NULL,
|
|
64
|
+
updated_at INTEGER NOT NULL,
|
|
65
|
+
system_prompt TEXT,
|
|
66
|
+
model TEXT,
|
|
67
|
+
config_json TEXT,
|
|
68
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
69
|
+
kind TEXT NOT NULL DEFAULT 'root',
|
|
70
|
+
parent_session_id TEXT,
|
|
71
|
+
spawn_tool_call_id TEXT,
|
|
72
|
+
spawn_depth INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
lifecycle TEXT NOT NULL DEFAULT 'ephemeral',
|
|
74
|
+
metadata_json TEXT
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_parent
|
|
77
|
+
ON sessions(parent_session_id);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
80
|
+
session_id TEXT NOT NULL,
|
|
81
|
+
seq INTEGER NOT NULL,
|
|
82
|
+
role TEXT NOT NULL,
|
|
83
|
+
name TEXT,
|
|
84
|
+
content TEXT,
|
|
85
|
+
tool_calls_json TEXT,
|
|
86
|
+
tool_call_id TEXT,
|
|
87
|
+
round_index INTEGER,
|
|
88
|
+
state TEXT NOT NULL DEFAULT 'active',
|
|
89
|
+
meta_json TEXT,
|
|
90
|
+
created_at INTEGER NOT NULL,
|
|
91
|
+
PRIMARY KEY (session_id, seq)
|
|
92
|
+
);
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session_state
|
|
94
|
+
ON messages(session_id, state, seq);
|
|
95
|
+
|
|
96
|
+
CREATE TABLE IF NOT EXISTS compactions (
|
|
97
|
+
session_id TEXT NOT NULL,
|
|
98
|
+
compact_seq INTEGER NOT NULL,
|
|
99
|
+
note_seq INTEGER NOT NULL,
|
|
100
|
+
from_seq INTEGER NOT NULL,
|
|
101
|
+
to_seq INTEGER NOT NULL,
|
|
102
|
+
before_tokens INTEGER,
|
|
103
|
+
after_tokens INTEGER,
|
|
104
|
+
round_index INTEGER,
|
|
105
|
+
created_at INTEGER NOT NULL,
|
|
106
|
+
PRIMARY KEY (session_id, compact_seq)
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
CREATE TABLE IF NOT EXISTS usage_rounds (
|
|
110
|
+
session_id TEXT NOT NULL,
|
|
111
|
+
round_index INTEGER NOT NULL,
|
|
112
|
+
prompt_tokens INTEGER,
|
|
113
|
+
completion_tokens INTEGER,
|
|
114
|
+
total_tokens INTEGER,
|
|
115
|
+
model TEXT,
|
|
116
|
+
created_at INTEGER NOT NULL,
|
|
117
|
+
PRIMARY KEY (session_id, round_index)
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
CREATE TABLE IF NOT EXISTS session_state (
|
|
121
|
+
session_id TEXT PRIMARY KEY,
|
|
122
|
+
next_seq INTEGER NOT NULL DEFAULT 1,
|
|
123
|
+
round_index INTEGER NOT NULL DEFAULT 0,
|
|
124
|
+
last_compact_seq INTEGER NOT NULL DEFAULT 0,
|
|
125
|
+
pending_json TEXT
|
|
126
|
+
);
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class SessionRow:
|
|
132
|
+
session_id: str
|
|
133
|
+
created_at: int
|
|
134
|
+
updated_at: int
|
|
135
|
+
system_prompt: str | None
|
|
136
|
+
model: str | None
|
|
137
|
+
config: dict[str, Any]
|
|
138
|
+
status: SessionStatus
|
|
139
|
+
kind: SessionKind
|
|
140
|
+
parent_session_id: str | None
|
|
141
|
+
spawn_tool_call_id: str | None
|
|
142
|
+
spawn_depth: int
|
|
143
|
+
lifecycle: SubagentLifecycle
|
|
144
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class MessageRow:
|
|
149
|
+
session_id: str
|
|
150
|
+
seq: int
|
|
151
|
+
role: str
|
|
152
|
+
name: str | None
|
|
153
|
+
content: str | None
|
|
154
|
+
tool_calls: list[dict[str, Any]] | None
|
|
155
|
+
tool_call_id: str | None
|
|
156
|
+
round_index: int | None
|
|
157
|
+
state: MessageState
|
|
158
|
+
meta: dict[str, Any]
|
|
159
|
+
created_at: int
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class SessionStateRow:
|
|
164
|
+
session_id: str
|
|
165
|
+
next_seq: int
|
|
166
|
+
round_index: int
|
|
167
|
+
last_compact_seq: int
|
|
168
|
+
pending: dict[str, Any] | None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class CompactionRow:
|
|
173
|
+
session_id: str
|
|
174
|
+
compact_seq: int
|
|
175
|
+
note_seq: int
|
|
176
|
+
from_seq: int
|
|
177
|
+
to_seq: int
|
|
178
|
+
before_tokens: int | None
|
|
179
|
+
after_tokens: int | None
|
|
180
|
+
round_index: int | None
|
|
181
|
+
created_at: int
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _now_ms() -> int:
|
|
185
|
+
return time.time_ns() // 1_000_000
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _new_session_id() -> str:
|
|
189
|
+
return "sess_" + secrets.token_hex(12)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _dumps(obj: Any) -> str | None:
|
|
193
|
+
if obj is None:
|
|
194
|
+
return None
|
|
195
|
+
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _loads(s: str | None) -> Any:
|
|
199
|
+
if s is None or s == "":
|
|
200
|
+
return None
|
|
201
|
+
return json.loads(s)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class SessionStore:
|
|
205
|
+
"""Owns the SQLite connection and exposes typed CRUD over the schema."""
|
|
206
|
+
|
|
207
|
+
def __init__(self, conn: sqlite3.Connection, path: str) -> None:
|
|
208
|
+
self._conn = conn
|
|
209
|
+
self._lock = threading.RLock()
|
|
210
|
+
self.path = path
|
|
211
|
+
|
|
212
|
+
# ── lifecycle ─────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def open(cls, path: str | Path = DEFAULT_DB_PATH) -> SessionStore:
|
|
216
|
+
path_str = str(path)
|
|
217
|
+
# ":memory:" stays as-is; file paths get expanded.
|
|
218
|
+
if path_str != ":memory:":
|
|
219
|
+
p = Path(path_str).expanduser()
|
|
220
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
path_str = str(p)
|
|
222
|
+
conn = sqlite3.connect(path_str, check_same_thread=False, isolation_level=None)
|
|
223
|
+
conn.row_factory = sqlite3.Row
|
|
224
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
225
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
226
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
227
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
228
|
+
conn.executescript(SCHEMA_SQL)
|
|
229
|
+
return cls(conn, path_str)
|
|
230
|
+
|
|
231
|
+
def close(self) -> None:
|
|
232
|
+
with self._lock:
|
|
233
|
+
self._conn.close()
|
|
234
|
+
|
|
235
|
+
def __enter__(self) -> SessionStore:
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
def __exit__(self, *exc: Any) -> None:
|
|
239
|
+
self.close()
|
|
240
|
+
|
|
241
|
+
# ── session CRUD ──────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def create_session(
|
|
244
|
+
self,
|
|
245
|
+
*,
|
|
246
|
+
system_prompt: str | None = None,
|
|
247
|
+
model: str | None = None,
|
|
248
|
+
config: dict[str, Any] | None = None,
|
|
249
|
+
parent_session_id: str | None = None,
|
|
250
|
+
spawn_tool_call_id: str | None = None,
|
|
251
|
+
kind: SessionKind = SessionKind.ROOT,
|
|
252
|
+
lifecycle: SubagentLifecycle = SubagentLifecycle.EPHEMERAL,
|
|
253
|
+
metadata: dict[str, Any] | None = None,
|
|
254
|
+
session_id: str | None = None,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Insert a new session row and its empty session_state row.
|
|
257
|
+
|
|
258
|
+
For subagents, supply ``parent_session_id``; ``spawn_depth`` is computed
|
|
259
|
+
from the parent and enforced against :data:`MAX_SPAWN_DEPTH`.
|
|
260
|
+
"""
|
|
261
|
+
spawn_depth = 0
|
|
262
|
+
if parent_session_id is not None:
|
|
263
|
+
parent = self.get_session(parent_session_id)
|
|
264
|
+
if parent is None:
|
|
265
|
+
raise ValueError(f"parent session not found: {parent_session_id}")
|
|
266
|
+
spawn_depth = parent.spawn_depth + 1
|
|
267
|
+
if spawn_depth > MAX_SPAWN_DEPTH:
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"spawn depth {spawn_depth} exceeds max {MAX_SPAWN_DEPTH}"
|
|
270
|
+
)
|
|
271
|
+
kind = SessionKind.SUBAGENT
|
|
272
|
+
|
|
273
|
+
sid = session_id or _new_session_id()
|
|
274
|
+
now = _now_ms()
|
|
275
|
+
with self._lock, self._conn:
|
|
276
|
+
self._conn.execute(
|
|
277
|
+
"""
|
|
278
|
+
INSERT INTO sessions (
|
|
279
|
+
session_id, created_at, updated_at,
|
|
280
|
+
system_prompt, model, config_json,
|
|
281
|
+
status, kind, parent_session_id, spawn_tool_call_id,
|
|
282
|
+
spawn_depth, lifecycle, metadata_json
|
|
283
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
284
|
+
""",
|
|
285
|
+
(
|
|
286
|
+
sid, now, now,
|
|
287
|
+
system_prompt, model, _dumps(config or {}),
|
|
288
|
+
SessionStatus.ACTIVE.value, kind.value,
|
|
289
|
+
parent_session_id, spawn_tool_call_id,
|
|
290
|
+
spawn_depth, lifecycle.value,
|
|
291
|
+
_dumps(metadata or {}),
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
self._conn.execute(
|
|
295
|
+
"INSERT INTO session_state (session_id) VALUES (?)",
|
|
296
|
+
(sid,),
|
|
297
|
+
)
|
|
298
|
+
return sid
|
|
299
|
+
|
|
300
|
+
def get_session(self, session_id: str) -> SessionRow | None:
|
|
301
|
+
with self._lock:
|
|
302
|
+
row = self._conn.execute(
|
|
303
|
+
"SELECT * FROM sessions WHERE session_id=?", (session_id,)
|
|
304
|
+
).fetchone()
|
|
305
|
+
return _row_to_session(row) if row else None
|
|
306
|
+
|
|
307
|
+
def list_children(self, parent_session_id: str) -> list[SessionRow]:
|
|
308
|
+
with self._lock:
|
|
309
|
+
rows = self._conn.execute(
|
|
310
|
+
"SELECT * FROM sessions WHERE parent_session_id=? ORDER BY created_at",
|
|
311
|
+
(parent_session_id,),
|
|
312
|
+
).fetchall()
|
|
313
|
+
return [_row_to_session(r) for r in rows]
|
|
314
|
+
|
|
315
|
+
def archive_session(self, session_id: str) -> None:
|
|
316
|
+
with self._lock, self._conn:
|
|
317
|
+
self._conn.execute(
|
|
318
|
+
"UPDATE sessions SET status=?, updated_at=? WHERE session_id=?",
|
|
319
|
+
(SessionStatus.ARCHIVED.value, _now_ms(), session_id),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def close_session(self, session_id: str, *, cascade: bool = True) -> int:
|
|
323
|
+
"""Physically delete the session's rows across all tables.
|
|
324
|
+
|
|
325
|
+
With ``cascade=True`` (default), also deletes every descendant whose
|
|
326
|
+
lifecycle is ``LINKED``. ``DETACHED`` descendants are preserved and
|
|
327
|
+
re-parented to ``NULL``. Returns the number of sessions removed.
|
|
328
|
+
"""
|
|
329
|
+
with self._lock, self._conn:
|
|
330
|
+
return self._delete_session_tree(session_id, cascade=cascade)
|
|
331
|
+
|
|
332
|
+
def _delete_session_tree(self, session_id: str, *, cascade: bool) -> int:
|
|
333
|
+
deleted = 0
|
|
334
|
+
if cascade:
|
|
335
|
+
children = self._conn.execute(
|
|
336
|
+
"SELECT session_id, lifecycle FROM sessions WHERE parent_session_id=?",
|
|
337
|
+
(session_id,),
|
|
338
|
+
).fetchall()
|
|
339
|
+
for child in children:
|
|
340
|
+
if child["lifecycle"] == SubagentLifecycle.DETACHED.value:
|
|
341
|
+
self._conn.execute(
|
|
342
|
+
"UPDATE sessions SET parent_session_id=NULL WHERE session_id=?",
|
|
343
|
+
(child["session_id"],),
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
deleted += self._delete_session_tree(child["session_id"], cascade=True)
|
|
347
|
+
self._conn.execute("DELETE FROM messages WHERE session_id=?", (session_id,))
|
|
348
|
+
self._conn.execute("DELETE FROM compactions WHERE session_id=?", (session_id,))
|
|
349
|
+
self._conn.execute("DELETE FROM usage_rounds WHERE session_id=?", (session_id,))
|
|
350
|
+
self._conn.execute("DELETE FROM session_state WHERE session_id=?", (session_id,))
|
|
351
|
+
cur = self._conn.execute("DELETE FROM sessions WHERE session_id=?", (session_id,))
|
|
352
|
+
deleted += cur.rowcount
|
|
353
|
+
return deleted
|
|
354
|
+
|
|
355
|
+
def update_session_prompt(self, session_id: str, system_prompt: str | None) -> None:
|
|
356
|
+
with self._lock, self._conn:
|
|
357
|
+
self._conn.execute(
|
|
358
|
+
"UPDATE sessions SET system_prompt=?, updated_at=? WHERE session_id=?",
|
|
359
|
+
(system_prompt, _now_ms(), session_id),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# ── messages ──────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
def append_message(
|
|
365
|
+
self,
|
|
366
|
+
session_id: str,
|
|
367
|
+
*,
|
|
368
|
+
role: str,
|
|
369
|
+
content: str | None = None,
|
|
370
|
+
tool_calls: list[dict[str, Any]] | None = None,
|
|
371
|
+
tool_call_id: str | None = None,
|
|
372
|
+
name: str | None = None,
|
|
373
|
+
round_index: int | None = None,
|
|
374
|
+
meta: dict[str, Any] | None = None,
|
|
375
|
+
) -> int:
|
|
376
|
+
"""Append one message and return its allocated ``seq``.
|
|
377
|
+
|
|
378
|
+
Allocation is atomic: ``session_state.next_seq`` is read+incremented
|
|
379
|
+
under the same transaction as the INSERT.
|
|
380
|
+
"""
|
|
381
|
+
now = _now_ms()
|
|
382
|
+
with self._lock, self._conn:
|
|
383
|
+
row = self._conn.execute(
|
|
384
|
+
"SELECT next_seq FROM session_state WHERE session_id=?",
|
|
385
|
+
(session_id,),
|
|
386
|
+
).fetchone()
|
|
387
|
+
if row is None:
|
|
388
|
+
raise ValueError(f"unknown session: {session_id}")
|
|
389
|
+
seq = int(row["next_seq"])
|
|
390
|
+
self._conn.execute(
|
|
391
|
+
"""
|
|
392
|
+
INSERT INTO messages (
|
|
393
|
+
session_id, seq, role, name, content, tool_calls_json,
|
|
394
|
+
tool_call_id, round_index, state, meta_json, created_at
|
|
395
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
396
|
+
""",
|
|
397
|
+
(
|
|
398
|
+
session_id, seq, role, name, content,
|
|
399
|
+
_dumps(tool_calls) if tool_calls else None,
|
|
400
|
+
tool_call_id, round_index,
|
|
401
|
+
MessageState.ACTIVE.value,
|
|
402
|
+
_dumps(meta or {}), now,
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
self._conn.execute(
|
|
406
|
+
"UPDATE session_state SET next_seq=? WHERE session_id=?",
|
|
407
|
+
(seq + 1, session_id),
|
|
408
|
+
)
|
|
409
|
+
self._conn.execute(
|
|
410
|
+
"UPDATE sessions SET updated_at=? WHERE session_id=?",
|
|
411
|
+
(now, session_id),
|
|
412
|
+
)
|
|
413
|
+
return seq
|
|
414
|
+
|
|
415
|
+
def load_active_messages(self, session_id: str) -> list[MessageRow]:
|
|
416
|
+
"""Return all messages in ``state='active'`` order by seq."""
|
|
417
|
+
with self._lock:
|
|
418
|
+
rows = self._conn.execute(
|
|
419
|
+
"""
|
|
420
|
+
SELECT * FROM messages
|
|
421
|
+
WHERE session_id=? AND state=?
|
|
422
|
+
ORDER BY seq ASC
|
|
423
|
+
""",
|
|
424
|
+
(session_id, MessageState.ACTIVE.value),
|
|
425
|
+
).fetchall()
|
|
426
|
+
return [_row_to_message(r) for r in rows]
|
|
427
|
+
|
|
428
|
+
def load_all_messages(self, session_id: str) -> list[MessageRow]:
|
|
429
|
+
with self._lock:
|
|
430
|
+
rows = self._conn.execute(
|
|
431
|
+
"SELECT * FROM messages WHERE session_id=? ORDER BY seq ASC",
|
|
432
|
+
(session_id,),
|
|
433
|
+
).fetchall()
|
|
434
|
+
return [_row_to_message(r) for r in rows]
|
|
435
|
+
|
|
436
|
+
def get_message(self, session_id: str, seq: int) -> MessageRow | None:
|
|
437
|
+
with self._lock:
|
|
438
|
+
row = self._conn.execute(
|
|
439
|
+
"SELECT * FROM messages WHERE session_id=? AND seq=?",
|
|
440
|
+
(session_id, seq),
|
|
441
|
+
).fetchone()
|
|
442
|
+
return _row_to_message(row) if row else None
|
|
443
|
+
|
|
444
|
+
# ── compaction ────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
def record_compaction(
|
|
447
|
+
self,
|
|
448
|
+
session_id: str,
|
|
449
|
+
*,
|
|
450
|
+
from_seq: int,
|
|
451
|
+
to_seq: int,
|
|
452
|
+
note_content: str,
|
|
453
|
+
before_tokens: int | None,
|
|
454
|
+
after_tokens: int | None,
|
|
455
|
+
round_index: int | None,
|
|
456
|
+
note_meta: dict[str, Any] | None = None,
|
|
457
|
+
) -> tuple[int, int]:
|
|
458
|
+
"""Mark ``[from_seq, to_seq]`` as ``compacted_out`` and append a
|
|
459
|
+
``compact_note`` message in one transaction.
|
|
460
|
+
|
|
461
|
+
Returns ``(compact_seq, note_seq)``.
|
|
462
|
+
"""
|
|
463
|
+
now = _now_ms()
|
|
464
|
+
with self._lock, self._conn:
|
|
465
|
+
state_row = self._conn.execute(
|
|
466
|
+
"SELECT next_seq, last_compact_seq FROM session_state WHERE session_id=?",
|
|
467
|
+
(session_id,),
|
|
468
|
+
).fetchone()
|
|
469
|
+
if state_row is None:
|
|
470
|
+
raise ValueError(f"unknown session: {session_id}")
|
|
471
|
+
note_seq = int(state_row["next_seq"])
|
|
472
|
+
compact_seq = int(state_row["last_compact_seq"]) + 1
|
|
473
|
+
|
|
474
|
+
self._conn.execute(
|
|
475
|
+
"UPDATE messages SET state=? WHERE session_id=? AND seq BETWEEN ? AND ?",
|
|
476
|
+
(MessageState.COMPACTED_OUT.value, session_id, from_seq, to_seq),
|
|
477
|
+
)
|
|
478
|
+
meta = dict(note_meta or {})
|
|
479
|
+
meta.update({
|
|
480
|
+
"compacted_at_round": round_index,
|
|
481
|
+
"from_seq": from_seq,
|
|
482
|
+
"to_seq": to_seq,
|
|
483
|
+
"original_tokens": before_tokens,
|
|
484
|
+
"summary_tokens": after_tokens,
|
|
485
|
+
})
|
|
486
|
+
self._conn.execute(
|
|
487
|
+
"""
|
|
488
|
+
INSERT INTO messages (
|
|
489
|
+
session_id, seq, role, name, content, tool_calls_json,
|
|
490
|
+
tool_call_id, round_index, state, meta_json, created_at
|
|
491
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
492
|
+
""",
|
|
493
|
+
(
|
|
494
|
+
session_id, note_seq, "system", "compact_note", note_content,
|
|
495
|
+
None, None, round_index,
|
|
496
|
+
MessageState.ACTIVE.value, _dumps(meta), now,
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
self._conn.execute(
|
|
500
|
+
"""
|
|
501
|
+
INSERT INTO compactions (
|
|
502
|
+
session_id, compact_seq, note_seq, from_seq, to_seq,
|
|
503
|
+
before_tokens, after_tokens, round_index, created_at
|
|
504
|
+
) VALUES (?,?,?,?,?,?,?,?,?)
|
|
505
|
+
""",
|
|
506
|
+
(
|
|
507
|
+
session_id, compact_seq, note_seq, from_seq, to_seq,
|
|
508
|
+
before_tokens, after_tokens, round_index, now,
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
self._conn.execute(
|
|
512
|
+
"""
|
|
513
|
+
UPDATE session_state
|
|
514
|
+
SET next_seq=?, last_compact_seq=?
|
|
515
|
+
WHERE session_id=?
|
|
516
|
+
""",
|
|
517
|
+
(note_seq + 1, compact_seq, session_id),
|
|
518
|
+
)
|
|
519
|
+
self._conn.execute(
|
|
520
|
+
"UPDATE sessions SET updated_at=? WHERE session_id=?",
|
|
521
|
+
(now, session_id),
|
|
522
|
+
)
|
|
523
|
+
return compact_seq, note_seq
|
|
524
|
+
|
|
525
|
+
def list_compactions(self, session_id: str) -> list[CompactionRow]:
|
|
526
|
+
with self._lock:
|
|
527
|
+
rows = self._conn.execute(
|
|
528
|
+
"SELECT * FROM compactions WHERE session_id=? ORDER BY compact_seq",
|
|
529
|
+
(session_id,),
|
|
530
|
+
).fetchall()
|
|
531
|
+
return [
|
|
532
|
+
CompactionRow(
|
|
533
|
+
session_id=r["session_id"],
|
|
534
|
+
compact_seq=r["compact_seq"],
|
|
535
|
+
note_seq=r["note_seq"],
|
|
536
|
+
from_seq=r["from_seq"],
|
|
537
|
+
to_seq=r["to_seq"],
|
|
538
|
+
before_tokens=r["before_tokens"],
|
|
539
|
+
after_tokens=r["after_tokens"],
|
|
540
|
+
round_index=r["round_index"],
|
|
541
|
+
created_at=r["created_at"],
|
|
542
|
+
)
|
|
543
|
+
for r in rows
|
|
544
|
+
]
|
|
545
|
+
|
|
546
|
+
# ── state ─────────────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
def get_state(self, session_id: str) -> SessionStateRow | None:
|
|
549
|
+
with self._lock:
|
|
550
|
+
row = self._conn.execute(
|
|
551
|
+
"SELECT * FROM session_state WHERE session_id=?", (session_id,)
|
|
552
|
+
).fetchone()
|
|
553
|
+
if row is None:
|
|
554
|
+
return None
|
|
555
|
+
return SessionStateRow(
|
|
556
|
+
session_id=row["session_id"],
|
|
557
|
+
next_seq=row["next_seq"],
|
|
558
|
+
round_index=row["round_index"],
|
|
559
|
+
last_compact_seq=row["last_compact_seq"],
|
|
560
|
+
pending=_loads(row["pending_json"]),
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
def set_round_index(self, session_id: str, round_index: int) -> None:
|
|
564
|
+
with self._lock, self._conn:
|
|
565
|
+
self._conn.execute(
|
|
566
|
+
"UPDATE session_state SET round_index=? WHERE session_id=?",
|
|
567
|
+
(round_index, session_id),
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
def set_pending(self, session_id: str, pending: dict[str, Any] | None) -> None:
|
|
571
|
+
"""Mark a session as having unresolved tool_calls (or clear)."""
|
|
572
|
+
with self._lock, self._conn:
|
|
573
|
+
self._conn.execute(
|
|
574
|
+
"UPDATE session_state SET pending_json=? WHERE session_id=?",
|
|
575
|
+
(_dumps(pending) if pending else None, session_id),
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# ── usage ─────────────────────────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
def record_usage(
|
|
581
|
+
self,
|
|
582
|
+
session_id: str,
|
|
583
|
+
*,
|
|
584
|
+
round_index: int,
|
|
585
|
+
prompt_tokens: int | None,
|
|
586
|
+
completion_tokens: int | None,
|
|
587
|
+
total_tokens: int | None,
|
|
588
|
+
model: str | None = None,
|
|
589
|
+
) -> None:
|
|
590
|
+
with self._lock, self._conn:
|
|
591
|
+
self._conn.execute(
|
|
592
|
+
"""
|
|
593
|
+
INSERT OR REPLACE INTO usage_rounds (
|
|
594
|
+
session_id, round_index, prompt_tokens, completion_tokens,
|
|
595
|
+
total_tokens, model, created_at
|
|
596
|
+
) VALUES (?,?,?,?,?,?,?)
|
|
597
|
+
""",
|
|
598
|
+
(
|
|
599
|
+
session_id, round_index, prompt_tokens, completion_tokens,
|
|
600
|
+
total_tokens, model, _now_ms(),
|
|
601
|
+
),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _row_to_session(row: sqlite3.Row) -> SessionRow:
|
|
606
|
+
return SessionRow(
|
|
607
|
+
session_id=row["session_id"],
|
|
608
|
+
created_at=row["created_at"],
|
|
609
|
+
updated_at=row["updated_at"],
|
|
610
|
+
system_prompt=row["system_prompt"],
|
|
611
|
+
model=row["model"],
|
|
612
|
+
config=_loads(row["config_json"]) or {},
|
|
613
|
+
status=SessionStatus(row["status"]),
|
|
614
|
+
kind=SessionKind(row["kind"]),
|
|
615
|
+
parent_session_id=row["parent_session_id"],
|
|
616
|
+
spawn_tool_call_id=row["spawn_tool_call_id"],
|
|
617
|
+
spawn_depth=row["spawn_depth"],
|
|
618
|
+
lifecycle=SubagentLifecycle(row["lifecycle"]),
|
|
619
|
+
metadata=_loads(row["metadata_json"]) or {},
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _row_to_message(row: sqlite3.Row) -> MessageRow:
|
|
624
|
+
return MessageRow(
|
|
625
|
+
session_id=row["session_id"],
|
|
626
|
+
seq=row["seq"],
|
|
627
|
+
role=row["role"],
|
|
628
|
+
name=row["name"],
|
|
629
|
+
content=row["content"],
|
|
630
|
+
tool_calls=_loads(row["tool_calls_json"]),
|
|
631
|
+
tool_call_id=row["tool_call_id"],
|
|
632
|
+
round_index=row["round_index"],
|
|
633
|
+
state=MessageState(row["state"]),
|
|
634
|
+
meta=_loads(row["meta_json"]) or {},
|
|
635
|
+
created_at=row["created_at"],
|
|
636
|
+
)
|