axio-context-sqlite 0.6.1__tar.gz → 0.7.0__tar.gz
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.
- {axio_context_sqlite-0.6.1 → axio_context_sqlite-0.7.0}/.gitignore +2 -0
- {axio_context_sqlite-0.6.1 → axio_context_sqlite-0.7.0}/PKG-INFO +56 -4
- {axio_context_sqlite-0.6.1 → axio_context_sqlite-0.7.0}/README.md +53 -1
- {axio_context_sqlite-0.6.1 → axio_context_sqlite-0.7.0}/pyproject.toml +3 -3
- axio_context_sqlite-0.7.0/tests/test_autocompact_sqlite.py +158 -0
- {axio_context_sqlite-0.6.1 → axio_context_sqlite-0.7.0}/src/axio_context_sqlite/__init__.py +0 -0
- {axio_context_sqlite-0.6.1 → axio_context_sqlite-0.7.0}/src/axio_context_sqlite/py.typed +0 -0
- {axio_context_sqlite-0.6.1 → axio_context_sqlite-0.7.0}/src/axio_context_sqlite/store.py +0 -0
- {axio_context_sqlite-0.6.1 → axio_context_sqlite-0.7.0}/tests/test_store.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: axio-context-sqlite
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: SQLite-backed context store for Axio
|
|
5
|
-
Project-URL: Homepage, https://github.com/axio-agent
|
|
6
|
-
Project-URL: Repository, https://github.com/axio-agent
|
|
5
|
+
Project-URL: Homepage, https://github.com/mosquito/axio-agent
|
|
6
|
+
Project-URL: Repository, https://github.com/mosquito/axio-agent
|
|
7
7
|
License: MIT
|
|
8
8
|
Keywords: agent,ai,context,llm,sqlite,storage
|
|
9
9
|
Requires-Python: >=3.12
|
|
@@ -17,7 +17,7 @@ Description-Content-Type: text/markdown
|
|
|
17
17
|
[](https://pypi.org/project/axio-context-sqlite/)
|
|
18
18
|
[](LICENSE)
|
|
19
19
|
|
|
20
|
-
SQLite-backed persistent context store for [axio](https://github.com/axio-agent
|
|
20
|
+
SQLite-backed persistent context store for [axio](https://github.com/mosquito/axio-agent).
|
|
21
21
|
|
|
22
22
|
## Installation
|
|
23
23
|
|
|
@@ -27,6 +27,32 @@ pip install axio-context-sqlite
|
|
|
27
27
|
|
|
28
28
|
## Usage
|
|
29
29
|
|
|
30
|
+
### `connect(db_path)`
|
|
31
|
+
|
|
32
|
+
Open (or create) a SQLite database at `db_path` and initialise the schema.
|
|
33
|
+
Returns an `aiosqlite.Connection`. The caller is responsible for closing it.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
async def main():
|
|
37
|
+
conn = await connect("~/.axio/chat.db")
|
|
38
|
+
# ... use the connection ...
|
|
39
|
+
await conn.close()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The helper enables WAL journal mode and a 5-second busy timeout so concurrent
|
|
43
|
+
readers and a single writer can safely share the same database file.
|
|
44
|
+
|
|
45
|
+
### `SQLiteContextStore(conn, session_id, project=None, db_name="axio_context")`
|
|
46
|
+
|
|
47
|
+
Create a context store bound to one session.
|
|
48
|
+
|
|
49
|
+
| Parameter | Type | Default | Description |
|
|
50
|
+
|-----------|------|---------|-------------|
|
|
51
|
+
| `conn` | `aiosqlite.Connection` | — | Open database connection (from `connect()`) |
|
|
52
|
+
| `session_id` | `str` | — | Unique identifier for this conversation session |
|
|
53
|
+
| `project` | `str \| None` | `str(Path.cwd().resolve())` | Logical project scope used to group and list sessions. Defaults to the current working directory. |
|
|
54
|
+
| `db_name` | `str` | `"axio_context"` | Prefix used for the database table names (`axio_context_messages`, `axio_context_tokens`). |
|
|
55
|
+
|
|
30
56
|
Open a connection with `connect()`, then create a `SQLiteContextStore` bound to a
|
|
31
57
|
session. The caller owns the connection and is responsible for closing it.
|
|
32
58
|
|
|
@@ -56,6 +82,22 @@ asyncio.run(main())
|
|
|
56
82
|
conversation history across process restarts. Multiple sessions can coexist in
|
|
57
83
|
the same database file, isolated by `session_id` and `project`.
|
|
58
84
|
|
|
85
|
+
#### Storage and compression
|
|
86
|
+
|
|
87
|
+
Message content is stored as serialized JSON. Payloads larger than 512 bytes
|
|
88
|
+
are automatically compressed with gzip (compresslevel 6) and stored
|
|
89
|
+
base64-encoded with a `gzip:` prefix. Smaller payloads are stored as-is with a
|
|
90
|
+
`plain:` prefix. Decompression happens transparently on read — callers never
|
|
91
|
+
see the encoded form.
|
|
92
|
+
|
|
93
|
+
#### SQLite performance settings
|
|
94
|
+
|
|
95
|
+
Every connection opened by `connect()` is configured with:
|
|
96
|
+
|
|
97
|
+
- `PRAGMA journal_mode=WAL` — enables concurrent readers alongside one writer
|
|
98
|
+
- `PRAGMA busy_timeout=5000` — waits up to 5 seconds before raising a lock error
|
|
99
|
+
- `PRAGMA synchronous=NORMAL` — balances durability and write throughput
|
|
100
|
+
|
|
59
101
|
### Agent integration
|
|
60
102
|
|
|
61
103
|
<!--
|
|
@@ -112,6 +154,16 @@ async def main() -> None:
|
|
|
112
154
|
asyncio.run(main())
|
|
113
155
|
```
|
|
114
156
|
|
|
157
|
+
### Token accounting
|
|
158
|
+
|
|
159
|
+
`add_context_tokens(input_tokens, output_tokens)` atomically increments the
|
|
160
|
+
stored token counts for the current session and project using a SQL UPSERT
|
|
161
|
+
(`INSERT ... ON CONFLICT DO UPDATE SET ... = ... + excluded....`). This is safe
|
|
162
|
+
to call concurrently from multiple coroutines without an application-level lock.
|
|
163
|
+
|
|
164
|
+
`set_context_tokens()` replaces the counts unconditionally, and
|
|
165
|
+
`get_context_tokens()` returns a `(input_tokens, output_tokens)` tuple.
|
|
166
|
+
|
|
115
167
|
### Forking
|
|
116
168
|
|
|
117
169
|
`fork()` copies the current session's messages into a new session — useful for
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://pypi.org/project/axio-context-sqlite/)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
SQLite-backed persistent context store for [axio](https://github.com/axio-agent
|
|
7
|
+
SQLite-backed persistent context store for [axio](https://github.com/mosquito/axio-agent).
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -14,6 +14,32 @@ pip install axio-context-sqlite
|
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
|
+
### `connect(db_path)`
|
|
18
|
+
|
|
19
|
+
Open (or create) a SQLite database at `db_path` and initialise the schema.
|
|
20
|
+
Returns an `aiosqlite.Connection`. The caller is responsible for closing it.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
async def main():
|
|
24
|
+
conn = await connect("~/.axio/chat.db")
|
|
25
|
+
# ... use the connection ...
|
|
26
|
+
await conn.close()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The helper enables WAL journal mode and a 5-second busy timeout so concurrent
|
|
30
|
+
readers and a single writer can safely share the same database file.
|
|
31
|
+
|
|
32
|
+
### `SQLiteContextStore(conn, session_id, project=None, db_name="axio_context")`
|
|
33
|
+
|
|
34
|
+
Create a context store bound to one session.
|
|
35
|
+
|
|
36
|
+
| Parameter | Type | Default | Description |
|
|
37
|
+
|-----------|------|---------|-------------|
|
|
38
|
+
| `conn` | `aiosqlite.Connection` | — | Open database connection (from `connect()`) |
|
|
39
|
+
| `session_id` | `str` | — | Unique identifier for this conversation session |
|
|
40
|
+
| `project` | `str \| None` | `str(Path.cwd().resolve())` | Logical project scope used to group and list sessions. Defaults to the current working directory. |
|
|
41
|
+
| `db_name` | `str` | `"axio_context"` | Prefix used for the database table names (`axio_context_messages`, `axio_context_tokens`). |
|
|
42
|
+
|
|
17
43
|
Open a connection with `connect()`, then create a `SQLiteContextStore` bound to a
|
|
18
44
|
session. The caller owns the connection and is responsible for closing it.
|
|
19
45
|
|
|
@@ -43,6 +69,22 @@ asyncio.run(main())
|
|
|
43
69
|
conversation history across process restarts. Multiple sessions can coexist in
|
|
44
70
|
the same database file, isolated by `session_id` and `project`.
|
|
45
71
|
|
|
72
|
+
#### Storage and compression
|
|
73
|
+
|
|
74
|
+
Message content is stored as serialized JSON. Payloads larger than 512 bytes
|
|
75
|
+
are automatically compressed with gzip (compresslevel 6) and stored
|
|
76
|
+
base64-encoded with a `gzip:` prefix. Smaller payloads are stored as-is with a
|
|
77
|
+
`plain:` prefix. Decompression happens transparently on read — callers never
|
|
78
|
+
see the encoded form.
|
|
79
|
+
|
|
80
|
+
#### SQLite performance settings
|
|
81
|
+
|
|
82
|
+
Every connection opened by `connect()` is configured with:
|
|
83
|
+
|
|
84
|
+
- `PRAGMA journal_mode=WAL` — enables concurrent readers alongside one writer
|
|
85
|
+
- `PRAGMA busy_timeout=5000` — waits up to 5 seconds before raising a lock error
|
|
86
|
+
- `PRAGMA synchronous=NORMAL` — balances durability and write throughput
|
|
87
|
+
|
|
46
88
|
### Agent integration
|
|
47
89
|
|
|
48
90
|
<!--
|
|
@@ -99,6 +141,16 @@ async def main() -> None:
|
|
|
99
141
|
asyncio.run(main())
|
|
100
142
|
```
|
|
101
143
|
|
|
144
|
+
### Token accounting
|
|
145
|
+
|
|
146
|
+
`add_context_tokens(input_tokens, output_tokens)` atomically increments the
|
|
147
|
+
stored token counts for the current session and project using a SQL UPSERT
|
|
148
|
+
(`INSERT ... ON CONFLICT DO UPDATE SET ... = ... + excluded....`). This is safe
|
|
149
|
+
to call concurrently from multiple coroutines without an application-level lock.
|
|
150
|
+
|
|
151
|
+
`set_context_tokens()` replaces the counts unconditionally, and
|
|
152
|
+
`get_context_tokens()` returns a `(input_tokens, output_tokens)` tuple.
|
|
153
|
+
|
|
102
154
|
### Forking
|
|
103
155
|
|
|
104
156
|
`fork()` copies the current session's messages into a new session — useful for
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "axio-context-sqlite"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.0"
|
|
4
4
|
description = "SQLite-backed context store for Axio"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -9,8 +9,8 @@ keywords = ["llm", "agent", "ai", "sqlite", "context", "storage"]
|
|
|
9
9
|
dependencies = ["axio", "aiosqlite>=0.20"]
|
|
10
10
|
|
|
11
11
|
[project.urls]
|
|
12
|
-
Homepage = "https://github.com/axio-agent
|
|
13
|
-
Repository = "https://github.com/axio-agent
|
|
12
|
+
Homepage = "https://github.com/mosquito/axio-agent"
|
|
13
|
+
Repository = "https://github.com/mosquito/axio-agent"
|
|
14
14
|
|
|
15
15
|
[build-system]
|
|
16
16
|
requires = ["hatchling"]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Tests for AutoCompactStore wrapping SQLiteContextStore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator, AsyncIterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
import aiosqlite
|
|
11
|
+
import pytest
|
|
12
|
+
from axio.blocks import TextBlock
|
|
13
|
+
from axio.compaction import AutoCompactStore
|
|
14
|
+
from axio.events import StreamEvent
|
|
15
|
+
from axio.messages import Message
|
|
16
|
+
from axio.testing import StubTransport, make_text_response
|
|
17
|
+
from axio.tool import Tool
|
|
18
|
+
|
|
19
|
+
from axio_context_sqlite import SQLiteContextStore, connect
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Helpers
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _msg(role: str = "user", text: str = "x") -> Message:
|
|
27
|
+
return Message(role=role, content=[TextBlock(text=text)]) # type: ignore[arg-type]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FailTransport:
|
|
31
|
+
def stream(self, messages: list[Message], tools: list[Tool[Any]], system: str) -> AsyncIterator[StreamEvent]:
|
|
32
|
+
raise RuntimeError("boom")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Fixtures
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def db_path(tmp_path: Path) -> Path:
|
|
42
|
+
return tmp_path / "autocompact_test.db"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
async def conn(db_path: Path) -> AsyncGenerator[aiosqlite.Connection, None]:
|
|
47
|
+
c = await connect(db_path)
|
|
48
|
+
yield c
|
|
49
|
+
await c.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
async def sqlite_store(conn: aiosqlite.Connection) -> SQLiteContextStore:
|
|
54
|
+
return SQLiteContextStore(conn, uuid4().hex, "test-project")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Tests
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestAutoCompactWithSQLite:
|
|
63
|
+
async def test_history_written_back_to_sqlite(self, sqlite_store: SQLiteContextStore) -> None:
|
|
64
|
+
"""After compaction, messages in the DB are the compacted ones."""
|
|
65
|
+
for i in range(14):
|
|
66
|
+
role = "user" if i % 2 == 0 else "assistant"
|
|
67
|
+
await sqlite_store.append(_msg(role, f"msg-{i}"))
|
|
68
|
+
|
|
69
|
+
transport = StubTransport([make_text_response("Summary of earlier work")])
|
|
70
|
+
store = AutoCompactStore(sqlite_store, transport, max_tokens=1, keep_recent=4)
|
|
71
|
+
|
|
72
|
+
await store.add_context_tokens(2, 0)
|
|
73
|
+
|
|
74
|
+
history = await sqlite_store.get_history()
|
|
75
|
+
# 2 (summary pair) + 4 recent
|
|
76
|
+
assert len(history) == 6
|
|
77
|
+
assert history[0].role == "user"
|
|
78
|
+
block0 = history[0].content[0]
|
|
79
|
+
assert isinstance(block0, TextBlock)
|
|
80
|
+
assert "Summary of earlier work" in block0.text
|
|
81
|
+
assert history[1].role == "assistant"
|
|
82
|
+
|
|
83
|
+
async def test_cumulative_tokens_persisted_in_sqlite(self, sqlite_store: SQLiteContextStore) -> None:
|
|
84
|
+
"""Cumulative token counts survive compaction in the SQLite table."""
|
|
85
|
+
for i in range(14):
|
|
86
|
+
role = "user" if i % 2 == 0 else "assistant"
|
|
87
|
+
await sqlite_store.append(_msg(role, f"msg-{i}"))
|
|
88
|
+
await sqlite_store.set_context_tokens(400_000, 15_000)
|
|
89
|
+
|
|
90
|
+
transport = StubTransport([make_text_response("summary")])
|
|
91
|
+
store = AutoCompactStore(sqlite_store, transport, max_tokens=1, keep_recent=4)
|
|
92
|
+
|
|
93
|
+
await store.add_context_tokens(2, 0)
|
|
94
|
+
|
|
95
|
+
in_tok, out_tok = await sqlite_store.get_context_tokens()
|
|
96
|
+
assert in_tok == 400_002
|
|
97
|
+
assert out_tok == 15_000
|
|
98
|
+
|
|
99
|
+
async def test_sqlite_session_id_preserved(self, sqlite_store: SQLiteContextStore) -> None:
|
|
100
|
+
"""session_id of wrapper matches the inner SQLiteContextStore."""
|
|
101
|
+
store = AutoCompactStore(sqlite_store, StubTransport([]), max_tokens=999_999)
|
|
102
|
+
assert store.session_id == sqlite_store.session_id
|
|
103
|
+
|
|
104
|
+
async def test_compact_does_not_corrupt_on_failure(self, sqlite_store: SQLiteContextStore) -> None:
|
|
105
|
+
"""If compaction fails, the original history in the DB is untouched."""
|
|
106
|
+
msgs = [_msg("user" if i % 2 == 0 else "assistant", f"msg-{i}") for i in range(14)]
|
|
107
|
+
for m in msgs:
|
|
108
|
+
await sqlite_store.append(m)
|
|
109
|
+
|
|
110
|
+
store = AutoCompactStore(sqlite_store, FailTransport(), max_tokens=1, keep_recent=4)
|
|
111
|
+
await store.add_context_tokens(2, 0)
|
|
112
|
+
|
|
113
|
+
history = await sqlite_store.get_history()
|
|
114
|
+
assert len(history) == 14
|
|
115
|
+
|
|
116
|
+
async def test_fork_creates_independent_sqlite_session(self, sqlite_store: SQLiteContextStore) -> None:
|
|
117
|
+
"""fork() returns an AutoCompactStore with a new session_id; mutations are independent."""
|
|
118
|
+
for i in range(6):
|
|
119
|
+
role = "user" if i % 2 == 0 else "assistant"
|
|
120
|
+
await sqlite_store.append(_msg(role, f"msg-{i}"))
|
|
121
|
+
|
|
122
|
+
store = AutoCompactStore(sqlite_store, StubTransport([]), max_tokens=999_999)
|
|
123
|
+
forked = await store.fork()
|
|
124
|
+
|
|
125
|
+
assert forked.session_id != store.session_id
|
|
126
|
+
|
|
127
|
+
# append to fork — parent unaffected
|
|
128
|
+
await forked.append(_msg(text="extra"))
|
|
129
|
+
assert len(await store.get_history()) == 6
|
|
130
|
+
assert len(await forked.get_history()) == 7
|
|
131
|
+
|
|
132
|
+
async def test_compact_uses_fork_as_snapshot(self, sqlite_store: SQLiteContextStore) -> None:
|
|
133
|
+
"""compact_context receives a fork of the store, not the live store itself."""
|
|
134
|
+
for i in range(14):
|
|
135
|
+
role = "user" if i % 2 == 0 else "assistant"
|
|
136
|
+
await sqlite_store.append(_msg(role, f"msg-{i}"))
|
|
137
|
+
|
|
138
|
+
seen_session_ids: list[str] = []
|
|
139
|
+
|
|
140
|
+
import axio.compaction as _mod
|
|
141
|
+
|
|
142
|
+
original = _mod.compact_context
|
|
143
|
+
|
|
144
|
+
async def _capturing(ctx, transport, **kw): # type: ignore[no-untyped-def]
|
|
145
|
+
seen_session_ids.append(ctx.session_id)
|
|
146
|
+
return await original(ctx, StubTransport([make_text_response("summary")]), **kw)
|
|
147
|
+
|
|
148
|
+
_mod.compact_context = _capturing # type: ignore[assignment]
|
|
149
|
+
try:
|
|
150
|
+
store = AutoCompactStore(
|
|
151
|
+
sqlite_store, StubTransport([make_text_response("summary")]), max_tokens=1, keep_recent=4
|
|
152
|
+
)
|
|
153
|
+
await store.add_context_tokens(2, 0)
|
|
154
|
+
finally:
|
|
155
|
+
_mod.compact_context = original
|
|
156
|
+
|
|
157
|
+
assert len(seen_session_ids) == 1
|
|
158
|
+
assert seen_session_ids[0] != sqlite_store.session_id
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|