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.
Files changed (53) hide show
  1. llm_client/__init__.py +0 -0
  2. llm_client/capabilities.py +162 -0
  3. llm_client/interface.py +470 -0
  4. llm_client/llm_factory.py +981 -0
  5. llm_client/llm_tooling.py +645 -0
  6. llm_client/llm_utils.py +205 -0
  7. llm_client/multimodal.py +237 -0
  8. llm_client/qwen_image.py +576 -0
  9. llm_client/web_search.py +149 -0
  10. power_loop/__init__.py +326 -0
  11. power_loop/agent/__init__.py +6 -0
  12. power_loop/agent/sink.py +247 -0
  13. power_loop/agent/stateful_loop.py +363 -0
  14. power_loop/agent/system_prompt.py +396 -0
  15. power_loop/agent/types.py +41 -0
  16. power_loop/contracts/__init__.py +132 -0
  17. power_loop/contracts/errors.py +140 -0
  18. power_loop/contracts/event_payloads.py +278 -0
  19. power_loop/contracts/events.py +86 -0
  20. power_loop/contracts/handlers.py +45 -0
  21. power_loop/contracts/hook_contexts.py +265 -0
  22. power_loop/contracts/hooks.py +64 -0
  23. power_loop/contracts/messages.py +90 -0
  24. power_loop/contracts/protocols.py +48 -0
  25. power_loop/contracts/tools.py +56 -0
  26. power_loop/core/agent_context.py +94 -0
  27. power_loop/core/events.py +124 -0
  28. power_loop/core/hooks.py +122 -0
  29. power_loop/core/phase.py +217 -0
  30. power_loop/core/pipeline.py +880 -0
  31. power_loop/core/runner.py +60 -0
  32. power_loop/core/state.py +208 -0
  33. power_loop/runtime/budget.py +179 -0
  34. power_loop/runtime/cancellation.py +127 -0
  35. power_loop/runtime/compact.py +300 -0
  36. power_loop/runtime/env.py +103 -0
  37. power_loop/runtime/memory.py +107 -0
  38. power_loop/runtime/provider.py +176 -0
  39. power_loop/runtime/retry.py +182 -0
  40. power_loop/runtime/session_store.py +636 -0
  41. power_loop/runtime/skills.py +201 -0
  42. power_loop/runtime/spec.py +233 -0
  43. power_loop/runtime/structured.py +225 -0
  44. power_loop/tools/__init__.py +51 -0
  45. power_loop/tools/default_manifest.py +244 -0
  46. power_loop/tools/default_tools.py +766 -0
  47. power_loop/tools/registry.py +162 -0
  48. power_loop/tools/spawn_agent.py +173 -0
  49. power_loop-0.2.0.dist-info/METADATA +632 -0
  50. power_loop-0.2.0.dist-info/RECORD +53 -0
  51. power_loop-0.2.0.dist-info/WHEEL +5 -0
  52. power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
  53. 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
+ )