agentforge-chat 0.2.1__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.
- agentforge_chat/__init__.py +40 -0
- agentforge_chat/_idempotency.py +38 -0
- agentforge_chat/_locks.py +115 -0
- agentforge_chat/_segment.py +45 -0
- agentforge_chat/_window.py +86 -0
- agentforge_chat/build.py +112 -0
- agentforge_chat/history.py +126 -0
- agentforge_chat/manifest.yaml +32 -0
- agentforge_chat/py.typed +0 -0
- agentforge_chat/session.py +496 -0
- agentforge_chat/sqlite.py +276 -0
- agentforge_chat/tokenisers.py +91 -0
- agentforge_chat/truncation.py +206 -0
- agentforge_chat-0.2.1.dist-info/METADATA +59 -0
- agentforge_chat-0.2.1.dist-info/RECORD +18 -0
- agentforge_chat-0.2.1.dist-info/WHEEL +4 -0
- agentforge_chat-0.2.1.dist-info/entry_points.txt +9 -0
- agentforge_chat-0.2.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""`SqliteChatHistory` — `ChatHistoryStore` over SQLite via aiosqlite.
|
|
2
|
+
|
|
3
|
+
Two tables: ``chat_turns`` (one row per turn) and ``chat_sessions``
|
|
4
|
+
(one row per session). Indexed on ``(session_id, created_at)`` so
|
|
5
|
+
``load()`` is sub-linear w.r.t. total turn count.
|
|
6
|
+
|
|
7
|
+
Schema is created via ``init_schema()`` / ``from_path()``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import aiosqlite
|
|
20
|
+
from agentforge_core.contracts.chat import ChatHistoryStore
|
|
21
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
22
|
+
from agentforge_core.values.chat import ChatTurn, SessionInfo
|
|
23
|
+
from agentforge_core.values.messages import ToolCall
|
|
24
|
+
|
|
25
|
+
_SCHEMA_SQL = """
|
|
26
|
+
CREATE TABLE IF NOT EXISTS chat_sessions (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
owner TEXT,
|
|
29
|
+
created_at TEXT NOT NULL,
|
|
30
|
+
last_active_at TEXT NOT NULL,
|
|
31
|
+
metadata TEXT NOT NULL
|
|
32
|
+
);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_owner
|
|
34
|
+
ON chat_sessions(owner);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_last_active
|
|
36
|
+
ON chat_sessions(last_active_at);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS chat_turns (
|
|
38
|
+
id TEXT PRIMARY KEY,
|
|
39
|
+
session_id TEXT NOT NULL,
|
|
40
|
+
role TEXT NOT NULL,
|
|
41
|
+
content TEXT NOT NULL,
|
|
42
|
+
timestamp TEXT NOT NULL,
|
|
43
|
+
run_id TEXT,
|
|
44
|
+
tool_calls TEXT NOT NULL,
|
|
45
|
+
tool_call_id TEXT,
|
|
46
|
+
tokens_in INTEGER NOT NULL,
|
|
47
|
+
tokens_out INTEGER NOT NULL,
|
|
48
|
+
cost_usd REAL NOT NULL,
|
|
49
|
+
metadata TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_turns_session_ts
|
|
52
|
+
ON chat_turns(session_id, timestamp);
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SqliteChatHistory(ChatHistoryStore):
|
|
57
|
+
"""`ChatHistoryStore` backed by a single SQLite file."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, *, connection: aiosqlite.Connection) -> None:
|
|
60
|
+
self._db = connection
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
async def from_path(cls, path: str | Path) -> SqliteChatHistory:
|
|
64
|
+
"""Open or create a SQLite database at ``path``.
|
|
65
|
+
|
|
66
|
+
``":memory:"`` is allowed for tests; on disk the parent
|
|
67
|
+
directory must already exist.
|
|
68
|
+
"""
|
|
69
|
+
connection = await aiosqlite.connect(str(path))
|
|
70
|
+
connection.row_factory = aiosqlite.Row
|
|
71
|
+
await connection.executescript(_SCHEMA_SQL)
|
|
72
|
+
await connection.commit()
|
|
73
|
+
return cls(connection=connection)
|
|
74
|
+
|
|
75
|
+
async def __aenter__(self) -> SqliteChatHistory:
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
async def __aexit__(
|
|
79
|
+
self,
|
|
80
|
+
exc_type: type[BaseException] | None,
|
|
81
|
+
exc: BaseException | None,
|
|
82
|
+
tb: TracebackType | None,
|
|
83
|
+
) -> None:
|
|
84
|
+
await self.close()
|
|
85
|
+
|
|
86
|
+
async def close(self) -> None:
|
|
87
|
+
await self._db.close()
|
|
88
|
+
|
|
89
|
+
async def append(self, turn: ChatTurn) -> None:
|
|
90
|
+
ts_iso = turn.timestamp.isoformat()
|
|
91
|
+
await self._db.execute(
|
|
92
|
+
"""INSERT OR REPLACE INTO chat_turns
|
|
93
|
+
(id, session_id, role, content, timestamp, run_id,
|
|
94
|
+
tool_calls, tool_call_id, tokens_in, tokens_out,
|
|
95
|
+
cost_usd, metadata)
|
|
96
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
97
|
+
(
|
|
98
|
+
turn.id,
|
|
99
|
+
turn.session_id,
|
|
100
|
+
turn.role,
|
|
101
|
+
turn.content,
|
|
102
|
+
ts_iso,
|
|
103
|
+
turn.run_id,
|
|
104
|
+
json.dumps([tc.model_dump(mode="json") for tc in turn.tool_calls]),
|
|
105
|
+
turn.tool_call_id,
|
|
106
|
+
turn.tokens_in,
|
|
107
|
+
turn.tokens_out,
|
|
108
|
+
turn.cost_usd,
|
|
109
|
+
json.dumps(turn.metadata),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
await self._upsert_session(turn.session_id, ts_iso)
|
|
113
|
+
await self._db.commit()
|
|
114
|
+
|
|
115
|
+
async def _upsert_session(self, session_id: str, now_iso: str) -> None:
|
|
116
|
+
await self._db.execute(
|
|
117
|
+
"""INSERT INTO chat_sessions
|
|
118
|
+
(id, owner, created_at, last_active_at, metadata)
|
|
119
|
+
VALUES (?, NULL, ?, ?, ?)
|
|
120
|
+
ON CONFLICT(id) DO UPDATE SET last_active_at=excluded.last_active_at""",
|
|
121
|
+
(session_id, now_iso, now_iso, "{}"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def load(
|
|
125
|
+
self,
|
|
126
|
+
session_id: str,
|
|
127
|
+
*,
|
|
128
|
+
limit: int | None = None,
|
|
129
|
+
before: datetime | None = None,
|
|
130
|
+
after: datetime | None = None,
|
|
131
|
+
roles: list[str] | None = None,
|
|
132
|
+
) -> list[ChatTurn]:
|
|
133
|
+
where = ["session_id = ?"]
|
|
134
|
+
params: list[Any] = [session_id]
|
|
135
|
+
if before is not None:
|
|
136
|
+
where.append("timestamp < ?")
|
|
137
|
+
params.append(before.isoformat())
|
|
138
|
+
if after is not None:
|
|
139
|
+
where.append("timestamp > ?")
|
|
140
|
+
params.append(after.isoformat())
|
|
141
|
+
if roles is not None:
|
|
142
|
+
placeholders = ", ".join("?" * len(roles))
|
|
143
|
+
where.append(f"role IN ({placeholders})") # nosec B608 — `?` placeholders only
|
|
144
|
+
params.extend(roles)
|
|
145
|
+
sql = (
|
|
146
|
+
"SELECT * FROM chat_turns WHERE " # noqa: S608 # nosec B608
|
|
147
|
+
+ " AND ".join(where)
|
|
148
|
+
+ " ORDER BY timestamp"
|
|
149
|
+
)
|
|
150
|
+
if limit is not None:
|
|
151
|
+
sql += " LIMIT ?"
|
|
152
|
+
params.append(limit)
|
|
153
|
+
async with self._db.execute(sql, params) as cur:
|
|
154
|
+
rows = await cur.fetchall()
|
|
155
|
+
return [_row_to_turn(row) for row in rows]
|
|
156
|
+
|
|
157
|
+
async def count(self, session_id: str) -> int:
|
|
158
|
+
async with self._db.execute(
|
|
159
|
+
"SELECT COUNT(*) FROM chat_turns WHERE session_id = ?",
|
|
160
|
+
(session_id,),
|
|
161
|
+
) as cur:
|
|
162
|
+
row = await cur.fetchone()
|
|
163
|
+
return int(row[0]) if row else 0
|
|
164
|
+
|
|
165
|
+
async def delete_session(self, session_id: str) -> int:
|
|
166
|
+
cur = await self._db.execute(
|
|
167
|
+
"DELETE FROM chat_turns WHERE session_id = ?",
|
|
168
|
+
(session_id,),
|
|
169
|
+
)
|
|
170
|
+
removed = cur.rowcount or 0
|
|
171
|
+
await self._db.execute(
|
|
172
|
+
"DELETE FROM chat_sessions WHERE id = ?",
|
|
173
|
+
(session_id,),
|
|
174
|
+
)
|
|
175
|
+
await self._db.commit()
|
|
176
|
+
return removed
|
|
177
|
+
|
|
178
|
+
async def list_sessions(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
owner: str | None = None,
|
|
182
|
+
limit: int = 100,
|
|
183
|
+
before: datetime | None = None,
|
|
184
|
+
) -> list[SessionInfo]:
|
|
185
|
+
where: list[str] = []
|
|
186
|
+
params: list[Any] = []
|
|
187
|
+
if owner is not None:
|
|
188
|
+
where.append("owner = ?")
|
|
189
|
+
params.append(owner)
|
|
190
|
+
if before is not None:
|
|
191
|
+
where.append("last_active_at < ?")
|
|
192
|
+
params.append(before.isoformat())
|
|
193
|
+
sql = "SELECT * FROM chat_sessions"
|
|
194
|
+
if where:
|
|
195
|
+
sql += " WHERE " + " AND ".join(where)
|
|
196
|
+
sql += " ORDER BY last_active_at DESC LIMIT ?" # nosec B608
|
|
197
|
+
params.append(limit)
|
|
198
|
+
async with self._db.execute(sql, params) as cur:
|
|
199
|
+
rows = await cur.fetchall()
|
|
200
|
+
return [await self._row_to_info(row) for row in rows]
|
|
201
|
+
|
|
202
|
+
async def update_session_metadata(self, session_id: str, metadata: Mapping[str, Any]) -> None:
|
|
203
|
+
async with self._db.execute(
|
|
204
|
+
"SELECT metadata, owner FROM chat_sessions WHERE id = ?",
|
|
205
|
+
(session_id,),
|
|
206
|
+
) as cur:
|
|
207
|
+
row = await cur.fetchone()
|
|
208
|
+
if row is None:
|
|
209
|
+
raise ModuleError(f"Cannot update metadata for unknown session {session_id!r}")
|
|
210
|
+
existing = json.loads(row["metadata"])
|
|
211
|
+
existing.update(dict(metadata))
|
|
212
|
+
owner = metadata.get("owner", row["owner"])
|
|
213
|
+
await self._db.execute(
|
|
214
|
+
"UPDATE chat_sessions SET metadata = ?, owner = ? WHERE id = ?",
|
|
215
|
+
(json.dumps(existing), owner, session_id),
|
|
216
|
+
)
|
|
217
|
+
await self._db.commit()
|
|
218
|
+
|
|
219
|
+
async def expire_before(self, cutoff: datetime) -> int:
|
|
220
|
+
cutoff_iso = cutoff.isoformat()
|
|
221
|
+
await self._db.execute(
|
|
222
|
+
"""DELETE FROM chat_turns WHERE session_id IN (
|
|
223
|
+
SELECT id FROM chat_sessions WHERE last_active_at < ?
|
|
224
|
+
)""",
|
|
225
|
+
(cutoff_iso,),
|
|
226
|
+
)
|
|
227
|
+
cur = await self._db.execute(
|
|
228
|
+
"DELETE FROM chat_sessions WHERE last_active_at < ?",
|
|
229
|
+
(cutoff_iso,),
|
|
230
|
+
)
|
|
231
|
+
removed = cur.rowcount or 0
|
|
232
|
+
await self._db.commit()
|
|
233
|
+
return removed
|
|
234
|
+
|
|
235
|
+
def capabilities(self) -> set[str]:
|
|
236
|
+
return {"ttl"}
|
|
237
|
+
|
|
238
|
+
async def _row_to_info(self, row: Any) -> SessionInfo:
|
|
239
|
+
async with self._db.execute(
|
|
240
|
+
"SELECT COUNT(*), COALESCE(SUM(cost_usd), 0.0) FROM chat_turns WHERE session_id = ?",
|
|
241
|
+
(row["id"],),
|
|
242
|
+
) as cur:
|
|
243
|
+
agg = await cur.fetchone()
|
|
244
|
+
count = int(agg[0]) if agg else 0
|
|
245
|
+
cost = float(agg[1]) if agg else 0.0
|
|
246
|
+
return SessionInfo(
|
|
247
|
+
id=row["id"],
|
|
248
|
+
owner=row["owner"],
|
|
249
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
250
|
+
last_active_at=datetime.fromisoformat(row["last_active_at"]),
|
|
251
|
+
turn_count=count,
|
|
252
|
+
total_cost_usd=cost,
|
|
253
|
+
metadata=json.loads(row["metadata"]),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _row_to_turn(row: Any) -> ChatTurn:
|
|
258
|
+
raw_calls = json.loads(row["tool_calls"])
|
|
259
|
+
tool_calls = tuple(ToolCall.model_validate(tc) for tc in raw_calls)
|
|
260
|
+
return ChatTurn(
|
|
261
|
+
id=row["id"],
|
|
262
|
+
session_id=row["session_id"],
|
|
263
|
+
role=row["role"],
|
|
264
|
+
content=row["content"],
|
|
265
|
+
timestamp=datetime.fromisoformat(row["timestamp"]),
|
|
266
|
+
run_id=row["run_id"],
|
|
267
|
+
tool_calls=tool_calls,
|
|
268
|
+
tool_call_id=row["tool_call_id"],
|
|
269
|
+
tokens_in=int(row["tokens_in"]),
|
|
270
|
+
tokens_out=int(row["tokens_out"]),
|
|
271
|
+
cost_usd=float(row["cost_usd"]),
|
|
272
|
+
metadata=json.loads(row["metadata"]),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
__all__ = ["SqliteChatHistory"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Provider-aware tokenisers for `TokenBudget` (feat-020 v0.2).
|
|
2
|
+
|
|
3
|
+
The default `TokenBudget` ships a 4-chars-per-token heuristic
|
|
4
|
+
that works for English-ish prose but drifts widely on code +
|
|
5
|
+
non-ASCII content. For accurate budget enforcement, supply a
|
|
6
|
+
`Tokeniser`: any callable that maps a string to a token count.
|
|
7
|
+
|
|
8
|
+
Two built-ins:
|
|
9
|
+
|
|
10
|
+
- :func:`tiktoken_tokeniser` — counts via the
|
|
11
|
+
`tiktoken` library used by OpenAI-compatible models.
|
|
12
|
+
- :func:`anthropic_tokeniser` — counts via the Anthropic
|
|
13
|
+
SDK's `count_tokens` API.
|
|
14
|
+
|
|
15
|
+
Both lazy-import their backing SDKs and raise :class:`ModuleError`
|
|
16
|
+
with pip remediation when the SDK isn't installed. Users
|
|
17
|
+
provide their own callable when they want a different
|
|
18
|
+
tokeniser.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from functools import lru_cache
|
|
25
|
+
from typing import Any, cast
|
|
26
|
+
|
|
27
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
28
|
+
|
|
29
|
+
Tokeniser = Callable[[str], int]
|
|
30
|
+
"""Map an input string to a non-negative integer token count."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@lru_cache(maxsize=8)
|
|
34
|
+
def _load_tiktoken_encoding(model: str) -> Any: # pragma: no cover — exercised via build
|
|
35
|
+
try:
|
|
36
|
+
import tiktoken # noqa: PLC0415
|
|
37
|
+
except ImportError as exc:
|
|
38
|
+
msg = (
|
|
39
|
+
"tiktoken is not installed. Install via "
|
|
40
|
+
"`pip install tiktoken` to use tiktoken_tokeniser."
|
|
41
|
+
)
|
|
42
|
+
raise ModuleError(msg) from exc
|
|
43
|
+
try:
|
|
44
|
+
return tiktoken.encoding_for_model(model)
|
|
45
|
+
except KeyError:
|
|
46
|
+
return tiktoken.get_encoding("cl100k_base")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def tiktoken_tokeniser(model: str = "gpt-4o-mini") -> Tokeniser:
|
|
50
|
+
"""Build a tiktoken-backed `Tokeniser` for ``model``.
|
|
51
|
+
|
|
52
|
+
Falls back to the ``cl100k_base`` encoding when the model
|
|
53
|
+
name isn't in tiktoken's registry.
|
|
54
|
+
"""
|
|
55
|
+
enc = _load_tiktoken_encoding(model)
|
|
56
|
+
|
|
57
|
+
def _count(text: str) -> int:
|
|
58
|
+
return len(enc.encode(text))
|
|
59
|
+
|
|
60
|
+
return _count
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def anthropic_tokeniser() -> Tokeniser: # pragma: no cover — exercised via build
|
|
64
|
+
"""Build an Anthropic-SDK-backed `Tokeniser`.
|
|
65
|
+
|
|
66
|
+
Uses the synchronous `count_tokens(text)` method on a
|
|
67
|
+
process-wide Anthropic client. Suitable for offline token
|
|
68
|
+
counting where no API key is needed.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
import anthropic # noqa: PLC0415
|
|
72
|
+
except ImportError as exc:
|
|
73
|
+
msg = (
|
|
74
|
+
"anthropic SDK is not installed. Install via "
|
|
75
|
+
"`pip install anthropic` to use anthropic_tokeniser."
|
|
76
|
+
)
|
|
77
|
+
raise ModuleError(msg) from exc
|
|
78
|
+
|
|
79
|
+
client = anthropic.Anthropic()
|
|
80
|
+
|
|
81
|
+
def _count(text: str) -> int:
|
|
82
|
+
return int(cast("Any", client).count_tokens(text))
|
|
83
|
+
|
|
84
|
+
return _count
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"Tokeniser",
|
|
89
|
+
"anthropic_tokeniser",
|
|
90
|
+
"tiktoken_tokeniser",
|
|
91
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Truncation strategies (feat-020).
|
|
2
|
+
|
|
3
|
+
Four built-ins ship in v0.2:
|
|
4
|
+
|
|
5
|
+
- `SlidingWindow(max_turns=N)` — keep the last N turns.
|
|
6
|
+
- `TokenBudget(max_tokens=N)` — keep as many recent turns as fit
|
|
7
|
+
under N tokens (approximate, using a 4-chars-per-token heuristic
|
|
8
|
+
in v0.2; provider-aware tokenisation is a follow-up).
|
|
9
|
+
- `SummariseOldest(threshold_turns=N, summariser=cb)` — keep the
|
|
10
|
+
last N turns verbatim; everything older condenses to a single
|
|
11
|
+
``system`` turn via the supplied summariser callback.
|
|
12
|
+
- `Hybrid(*strategies)` — pipe input through each strategy in
|
|
13
|
+
order; later strategies see the previous strategy's output.
|
|
14
|
+
|
|
15
|
+
Each respects the conformance invariants documented in
|
|
16
|
+
`agentforge_core.contracts.chat.HistoryTruncationStrategy`:
|
|
17
|
+
order-preserving + tool-call/tool-result pair atomicity.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from agentforge_core.contracts.chat import HistoryTruncationStrategy
|
|
26
|
+
from agentforge_core.values.chat import ChatTurn
|
|
27
|
+
|
|
28
|
+
from agentforge_chat.tokenisers import Tokeniser
|
|
29
|
+
|
|
30
|
+
SummariserCallback = Callable[[list[ChatTurn]], Awaitable[str]]
|
|
31
|
+
"""Async callback that turns a batch of turns into a single
|
|
32
|
+
summary string. The default implementation in `SummariseOldest`
|
|
33
|
+
concatenates contents — production users supply an LLM-backed
|
|
34
|
+
summariser."""
|
|
35
|
+
|
|
36
|
+
_CHARS_PER_TOKEN = 4
|
|
37
|
+
"""Approximate chars-per-token heuristic for `TokenBudget`."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _approx_tokens(turn: ChatTurn) -> int:
|
|
41
|
+
return max(1, len(turn.content) // _CHARS_PER_TOKEN)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _keep_pair_atomic(turns: list[ChatTurn]) -> list[ChatTurn]:
|
|
45
|
+
"""Drop a leading orphan tool turn that lost its act partner.
|
|
46
|
+
|
|
47
|
+
The truncation contract says tool-call/tool-result pairs must
|
|
48
|
+
not be split. We enforce by dropping any tool turn whose
|
|
49
|
+
preceding assistant turn isn't in the selection.
|
|
50
|
+
"""
|
|
51
|
+
if not turns:
|
|
52
|
+
return turns
|
|
53
|
+
out: list[ChatTurn] = []
|
|
54
|
+
last_role: str | None = None
|
|
55
|
+
for t in turns:
|
|
56
|
+
if t.role == "tool" and last_role != "assistant":
|
|
57
|
+
continue
|
|
58
|
+
out.append(t)
|
|
59
|
+
last_role = t.role
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SlidingWindow(HistoryTruncationStrategy):
|
|
64
|
+
"""Keep the most recent ``max_turns`` turns."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, max_turns: int = 50) -> None:
|
|
67
|
+
if max_turns < 1:
|
|
68
|
+
raise ValueError(f"max_turns must be >= 1, got {max_turns}")
|
|
69
|
+
self.max_turns = max_turns
|
|
70
|
+
|
|
71
|
+
async def select(
|
|
72
|
+
self,
|
|
73
|
+
all_turns: list[ChatTurn],
|
|
74
|
+
next_user_message: str,
|
|
75
|
+
context: Mapping[str, Any],
|
|
76
|
+
) -> list[ChatTurn]:
|
|
77
|
+
del next_user_message, context
|
|
78
|
+
kept = all_turns[-self.max_turns :]
|
|
79
|
+
return _keep_pair_atomic(kept)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TokenBudget(HistoryTruncationStrategy):
|
|
83
|
+
"""Keep recent turns until ``max_tokens`` is exhausted.
|
|
84
|
+
|
|
85
|
+
Token counting defaults to the 4-chars-per-token heuristic.
|
|
86
|
+
Pass a ``tokeniser`` callable to use a provider-aware
|
|
87
|
+
encoder — e.g. :func:`agentforge_chat.tokenisers.tiktoken_tokeniser`
|
|
88
|
+
for OpenAI-compatible models or
|
|
89
|
+
:func:`agentforge_chat.tokenisers.anthropic_tokeniser` for
|
|
90
|
+
Anthropic. The callable maps a string to its token count.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
max_tokens: int = 64_000,
|
|
96
|
+
*,
|
|
97
|
+
tokeniser: Tokeniser | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
if max_tokens < 1:
|
|
100
|
+
raise ValueError(f"max_tokens must be >= 1, got {max_tokens}")
|
|
101
|
+
self.max_tokens = max_tokens
|
|
102
|
+
self._tokeniser = tokeniser
|
|
103
|
+
|
|
104
|
+
def _tokens_for_text(self, text: str) -> int:
|
|
105
|
+
if self._tokeniser is None:
|
|
106
|
+
return max(1, len(text) // _CHARS_PER_TOKEN)
|
|
107
|
+
return max(0, int(self._tokeniser(text)))
|
|
108
|
+
|
|
109
|
+
def _tokens_for_turn(self, turn: ChatTurn) -> int:
|
|
110
|
+
if self._tokeniser is None:
|
|
111
|
+
return _approx_tokens(turn)
|
|
112
|
+
return max(1, int(self._tokeniser(turn.content)))
|
|
113
|
+
|
|
114
|
+
async def select(
|
|
115
|
+
self,
|
|
116
|
+
all_turns: list[ChatTurn],
|
|
117
|
+
next_user_message: str,
|
|
118
|
+
context: Mapping[str, Any],
|
|
119
|
+
) -> list[ChatTurn]:
|
|
120
|
+
del context
|
|
121
|
+
# Reserve budget for the next user message itself.
|
|
122
|
+
reserved = self._tokens_for_text(next_user_message)
|
|
123
|
+
remaining = self.max_tokens - reserved
|
|
124
|
+
chosen: list[ChatTurn] = []
|
|
125
|
+
for turn in reversed(all_turns):
|
|
126
|
+
cost = self._tokens_for_turn(turn)
|
|
127
|
+
if cost > remaining:
|
|
128
|
+
break
|
|
129
|
+
chosen.append(turn)
|
|
130
|
+
remaining -= cost
|
|
131
|
+
chosen.reverse()
|
|
132
|
+
return _keep_pair_atomic(chosen)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class SummariseOldest(HistoryTruncationStrategy):
|
|
136
|
+
"""Keep the last ``threshold_turns``; condense older turns
|
|
137
|
+
into a single ``system`` summary turn."""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
threshold_turns: int = 30,
|
|
143
|
+
summariser: SummariserCallback | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
if threshold_turns < 1:
|
|
146
|
+
raise ValueError(f"threshold_turns must be >= 1, got {threshold_turns}")
|
|
147
|
+
self.threshold_turns = threshold_turns
|
|
148
|
+
self.summariser: SummariserCallback = (
|
|
149
|
+
summariser if summariser is not None else _default_summariser
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def select(
|
|
153
|
+
self,
|
|
154
|
+
all_turns: list[ChatTurn],
|
|
155
|
+
next_user_message: str,
|
|
156
|
+
context: Mapping[str, Any],
|
|
157
|
+
) -> list[ChatTurn]:
|
|
158
|
+
del next_user_message, context
|
|
159
|
+
if len(all_turns) <= self.threshold_turns:
|
|
160
|
+
return _keep_pair_atomic(list(all_turns))
|
|
161
|
+
older = all_turns[: -self.threshold_turns]
|
|
162
|
+
recent = all_turns[-self.threshold_turns :]
|
|
163
|
+
summary_text = await self.summariser(older)
|
|
164
|
+
summary = ChatTurn(
|
|
165
|
+
id=f"summary-{older[0].id}-{older[-1].id}",
|
|
166
|
+
session_id=older[0].session_id,
|
|
167
|
+
role="system",
|
|
168
|
+
content=f"[Summary of {len(older)} older turns] {summary_text}",
|
|
169
|
+
timestamp=older[0].timestamp,
|
|
170
|
+
metadata={"agentforge_chat.summary": True},
|
|
171
|
+
)
|
|
172
|
+
return _keep_pair_atomic([summary, *recent])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class Hybrid(HistoryTruncationStrategy):
|
|
176
|
+
"""Compose strategies in series: each runs against the
|
|
177
|
+
previous strategy's output."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, *strategies: HistoryTruncationStrategy) -> None:
|
|
180
|
+
if not strategies:
|
|
181
|
+
raise ValueError("Hybrid requires at least one strategy")
|
|
182
|
+
self.strategies = strategies
|
|
183
|
+
|
|
184
|
+
async def select(
|
|
185
|
+
self,
|
|
186
|
+
all_turns: list[ChatTurn],
|
|
187
|
+
next_user_message: str,
|
|
188
|
+
context: Mapping[str, Any],
|
|
189
|
+
) -> list[ChatTurn]:
|
|
190
|
+
current = list(all_turns)
|
|
191
|
+
for s in self.strategies:
|
|
192
|
+
current = await s.select(current, next_user_message, context)
|
|
193
|
+
return current
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def _default_summariser(turns: list[ChatTurn]) -> str:
|
|
197
|
+
pieces = [f"{t.role}: {t.content}" for t in turns]
|
|
198
|
+
return " | ".join(pieces)[:2000]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = [
|
|
202
|
+
"Hybrid",
|
|
203
|
+
"SlidingWindow",
|
|
204
|
+
"SummariseOldest",
|
|
205
|
+
"TokenBudget",
|
|
206
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentforge-chat
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Chat-agent runtime (ChatSession + history drivers + truncation) for AgentForge
|
|
5
|
+
Project-URL: Homepage, https://github.com/Scaffoldic/agentforge-py
|
|
6
|
+
Project-URL: Repository, https://github.com/Scaffoldic/agentforge-py
|
|
7
|
+
Project-URL: Documentation, https://github.com/Scaffoldic/agentforge-py
|
|
8
|
+
Project-URL: Changelog, https://github.com/Scaffoldic/agentforge-py/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/Scaffoldic/agentforge-py/issues
|
|
10
|
+
Author: The AgentForge Authors
|
|
11
|
+
License-Expression: Apache-2.0
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agent,ai,chat,chatbot,conversation
|
|
14
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Requires-Dist: agentforge-core~=0.2.1
|
|
23
|
+
Requires-Dist: agentforge-py~=0.2.1
|
|
24
|
+
Provides-Extra: sqlite
|
|
25
|
+
Requires-Dist: aiosqlite>=0.20; extra == 'sqlite'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# agentforge-chat
|
|
29
|
+
|
|
30
|
+
Chat-agent runtime for AgentForge: `ChatSession`,
|
|
31
|
+
`InMemoryChatHistory` / `SqliteChatHistory` drivers, and four
|
|
32
|
+
truncation strategies (sliding-window, token-budget,
|
|
33
|
+
summarise-oldest, hybrid).
|
|
34
|
+
|
|
35
|
+
See [`docs/features/feat-020-chat-agents.md`](https://github.com/Scaffoldic/agentforge-py/blob/main/docs/features/feat-020-chat-agents.md)
|
|
36
|
+
for the design and runbook.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install agentforge-chat
|
|
42
|
+
# or, with the SQLite driver pre-pulled:
|
|
43
|
+
pip install "agentforge-chat[sqlite]"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Three-line chat from a one-shot agent
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from agentforge import Agent
|
|
50
|
+
from agentforge_chat import ChatSession, SqliteChatHistory
|
|
51
|
+
|
|
52
|
+
agent = Agent(model="anthropic:claude-sonnet-4-6", strategy="react")
|
|
53
|
+
session = ChatSession(
|
|
54
|
+
agent=agent,
|
|
55
|
+
history_store=await SqliteChatHistory.from_path("./chat.db"),
|
|
56
|
+
)
|
|
57
|
+
print((await session.send("Hi")).content)
|
|
58
|
+
print((await session.send("What did I just say?")).content)
|
|
59
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
agentforge_chat/__init__.py,sha256=av4ETJyvjnKJ1uCub6QlpgA82Mu0kEpA03yb3vrmzbA,1050
|
|
2
|
+
agentforge_chat/_idempotency.py,sha256=1ekiRDJC6SxVYklxsKCfW0A80BawNMzD6FHkcpHZS5Q,1232
|
|
3
|
+
agentforge_chat/_locks.py,sha256=stV_5isaEmyk4T1CW5Bs2ExiSZ7F2ayoltJN_PFIwg4,3411
|
|
4
|
+
agentforge_chat/_segment.py,sha256=5EPSLgukC5sroU5lhIr0TK4Tjh52VzU1eMlEg7d4lAg,1339
|
|
5
|
+
agentforge_chat/_window.py,sha256=HOT6vmMkX7Xxm1UPPyoTtBjy_Esm8YZNEsUqV_pjGLc,2944
|
|
6
|
+
agentforge_chat/build.py,sha256=9UXGAWhO8elY98k-_AOmnwc4FxtHBsXQJaPMPnniaL4,4039
|
|
7
|
+
agentforge_chat/history.py,sha256=Uv7rzlOrSHMegXKDJgxTIPoCSgNzguMYVmidSO56DqQ,4510
|
|
8
|
+
agentforge_chat/manifest.yaml,sha256=i9rASO_QPu_74ZxRweKmeiIG9gu8pXR_A_s6VgB0QTc,1023
|
|
9
|
+
agentforge_chat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
agentforge_chat/session.py,sha256=7KA8hZ1efeWUlraZ33WVgrFkObwXnCCcIX93oD6Trhc,19448
|
|
11
|
+
agentforge_chat/sqlite.py,sha256=-p2w8MAiTqLLbCSIiRypNvVwreONCWyhtfKsX-6owks,9591
|
|
12
|
+
agentforge_chat/tokenisers.py,sha256=JgU8ELf6rjuexC4Ha1LLQZyJBT8LZFyfCUs7Y-3WX9Y,2704
|
|
13
|
+
agentforge_chat/truncation.py,sha256=7FX2Mu4RiJ5KmTV4aPOOFeqXyz5TmXmJ0OdHR8jg7aM,6922
|
|
14
|
+
agentforge_chat-0.2.1.dist-info/METADATA,sha256=W4KH6lEmzqpK12K9sYAM6BuhD1psJoeaaADQA4Sg8Ec,2150
|
|
15
|
+
agentforge_chat-0.2.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
16
|
+
agentforge_chat-0.2.1.dist-info/entry_points.txt,sha256=hZaTWW0rtnD_FSFT21Cou6kkmcYa7jSSwyr1U2fzGmA,376
|
|
17
|
+
agentforge_chat-0.2.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
18
|
+
agentforge_chat-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
[agentforge.chat.history]
|
|
2
|
+
memory = agentforge_chat.history:InMemoryChatHistory
|
|
3
|
+
sqlite = agentforge_chat.sqlite:SqliteChatHistory
|
|
4
|
+
|
|
5
|
+
[agentforge.chat.truncation]
|
|
6
|
+
hybrid = agentforge_chat.truncation:Hybrid
|
|
7
|
+
sliding_window = agentforge_chat.truncation:SlidingWindow
|
|
8
|
+
summarise_oldest = agentforge_chat.truncation:SummariseOldest
|
|
9
|
+
token_budget = agentforge_chat.truncation:TokenBudget
|