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,40 @@
|
|
|
1
|
+
"""`agentforge-chat` — Chat-agent runtime for AgentForge (feat-020).
|
|
2
|
+
|
|
3
|
+
Public surface: `ChatSession` (chunk 3) + history drivers
|
|
4
|
+
(`InMemoryChatHistory`, `SqliteChatHistory`) + four truncation
|
|
5
|
+
strategies (`SlidingWindow`, `TokenBudget`, `SummariseOldest`,
|
|
6
|
+
`Hybrid`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from agentforge_chat.build import build_chat_session_from_config
|
|
12
|
+
from agentforge_chat.history import InMemoryChatHistory
|
|
13
|
+
from agentforge_chat.session import ChatSession, SafetyMode
|
|
14
|
+
from agentforge_chat.sqlite import SqliteChatHistory
|
|
15
|
+
from agentforge_chat.tokenisers import (
|
|
16
|
+
Tokeniser,
|
|
17
|
+
anthropic_tokeniser,
|
|
18
|
+
tiktoken_tokeniser,
|
|
19
|
+
)
|
|
20
|
+
from agentforge_chat.truncation import (
|
|
21
|
+
Hybrid,
|
|
22
|
+
SlidingWindow,
|
|
23
|
+
SummariseOldest,
|
|
24
|
+
TokenBudget,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ChatSession",
|
|
29
|
+
"Hybrid",
|
|
30
|
+
"InMemoryChatHistory",
|
|
31
|
+
"SafetyMode",
|
|
32
|
+
"SlidingWindow",
|
|
33
|
+
"SqliteChatHistory",
|
|
34
|
+
"SummariseOldest",
|
|
35
|
+
"TokenBudget",
|
|
36
|
+
"Tokeniser",
|
|
37
|
+
"anthropic_tokeniser",
|
|
38
|
+
"build_chat_session_from_config",
|
|
39
|
+
"tiktoken_tokeniser",
|
|
40
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Tiny LRU+TTL cache for per-session idempotency keys (feat-020).
|
|
2
|
+
|
|
3
|
+
Keyed by ``(session_id, key)``; values are the previous
|
|
4
|
+
`ChatResponse`. Entries past TTL are evicted on lookup; entries
|
|
5
|
+
past `max_entries` are evicted oldest-first.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IdempotencyCache[V]:
|
|
15
|
+
def __init__(self, *, ttl_s: float, max_entries: int = 256) -> None:
|
|
16
|
+
self._ttl = ttl_s
|
|
17
|
+
self._max = max_entries
|
|
18
|
+
self._store: OrderedDict[tuple[str, str], tuple[float, V]] = OrderedDict()
|
|
19
|
+
|
|
20
|
+
def get(self, session_id: str, key: str) -> V | None:
|
|
21
|
+
k = (session_id, key)
|
|
22
|
+
entry = self._store.get(k)
|
|
23
|
+
if entry is None:
|
|
24
|
+
return None
|
|
25
|
+
ts, value = entry
|
|
26
|
+
if (time.monotonic() - ts) > self._ttl:
|
|
27
|
+
self._store.pop(k, None)
|
|
28
|
+
return None
|
|
29
|
+
# Mark as recently used.
|
|
30
|
+
self._store.move_to_end(k)
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
def put(self, session_id: str, key: str, value: V) -> None:
|
|
34
|
+
k = (session_id, key)
|
|
35
|
+
self._store[k] = (time.monotonic(), value)
|
|
36
|
+
self._store.move_to_end(k)
|
|
37
|
+
while len(self._store) > self._max:
|
|
38
|
+
self._store.popitem(last=False)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Per-session lock registry (feat-020).
|
|
2
|
+
|
|
3
|
+
`ChatSession.send` / `stream` acquires a session-scoped lock so
|
|
4
|
+
concurrent calls against the same `session_id` queue. v0.1 shipped
|
|
5
|
+
an in-process `asyncio.Lock` via `WeakValueDictionary`. v0.2 extends
|
|
6
|
+
the surface to support cross-process locks (Redis-backed) via a
|
|
7
|
+
`SessionLock` Protocol that both shapes satisfy.
|
|
8
|
+
|
|
9
|
+
Default factory keeps the in-process behaviour. Multi-worker
|
|
10
|
+
deployments inject `redis_session_lock_factory(...)` from
|
|
11
|
+
`agentforge-chat-history-redis`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import weakref
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from types import TracebackType
|
|
20
|
+
from typing import Protocol
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SessionLock(Protocol): # pragma: no cover — Protocol method stubs
|
|
24
|
+
"""Async-context-manager lock keyed by `session_id`.
|
|
25
|
+
|
|
26
|
+
`ChatSession` calls `async with lock:` once per turn.
|
|
27
|
+
Implementations:
|
|
28
|
+
|
|
29
|
+
- :class:`InMemorySessionLock` — wraps a per-session
|
|
30
|
+
``asyncio.Lock``. Default; single-process only.
|
|
31
|
+
- ``RedisSessionLock`` (in `agentforge-chat-history-redis`) —
|
|
32
|
+
cross-process; uses Redis ``SET NX PX`` + UUID fencing.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
async def __aenter__(self) -> SessionLock: ...
|
|
36
|
+
|
|
37
|
+
async def __aexit__(
|
|
38
|
+
self,
|
|
39
|
+
exc_type: type[BaseException] | None,
|
|
40
|
+
exc: BaseException | None,
|
|
41
|
+
tb: TracebackType | None,
|
|
42
|
+
) -> None: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
SessionLockFactory = Callable[[str], SessionLock]
|
|
46
|
+
"""Build a `SessionLock` for one ``session_id``. v0.2 lets callers
|
|
47
|
+
inject this on `ChatSession` / `ChatServer` construction."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InMemorySessionLock:
|
|
51
|
+
"""Wraps a per-session `asyncio.Lock` so multiple chat turns on
|
|
52
|
+
the same session_id queue inside one process.
|
|
53
|
+
|
|
54
|
+
Conforms structurally to `SessionLock`.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, lock: asyncio.Lock) -> None:
|
|
58
|
+
self._lock = lock
|
|
59
|
+
|
|
60
|
+
async def __aenter__(self) -> InMemorySessionLock:
|
|
61
|
+
await self._lock.acquire()
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
async def __aexit__(
|
|
65
|
+
self,
|
|
66
|
+
exc_type: type[BaseException] | None,
|
|
67
|
+
exc: BaseException | None,
|
|
68
|
+
tb: TracebackType | None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._lock.release()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _LockRegistry:
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
|
|
76
|
+
|
|
77
|
+
def get(self, session_id: str) -> asyncio.Lock:
|
|
78
|
+
lock = self._locks.get(session_id)
|
|
79
|
+
if lock is None:
|
|
80
|
+
lock = asyncio.Lock()
|
|
81
|
+
self._locks[session_id] = lock
|
|
82
|
+
return lock
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_REGISTRY = _LockRegistry()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def lock_for(session_id: str) -> asyncio.Lock:
|
|
89
|
+
"""Return the (shared, weak-referenced) raw `asyncio.Lock`.
|
|
90
|
+
|
|
91
|
+
Retained for backward-compatibility with v0.1 callers that read
|
|
92
|
+
`ChatSession._lock` directly. New code should use
|
|
93
|
+
:func:`default_session_lock_factory` or inject a custom
|
|
94
|
+
`SessionLockFactory`.
|
|
95
|
+
"""
|
|
96
|
+
return _REGISTRY.get(session_id)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def default_session_lock_factory(session_id: str) -> SessionLock:
|
|
100
|
+
"""Build the default in-process `SessionLock` for ``session_id``.
|
|
101
|
+
|
|
102
|
+
Wraps the shared `asyncio.Lock` from the weak-ref registry so
|
|
103
|
+
multiple `ChatSession` instances bound to the same session_id
|
|
104
|
+
still queue correctly.
|
|
105
|
+
"""
|
|
106
|
+
return InMemorySessionLock(_REGISTRY.get(session_id))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
"InMemorySessionLock",
|
|
111
|
+
"SessionLock",
|
|
112
|
+
"SessionLockFactory",
|
|
113
|
+
"default_session_lock_factory",
|
|
114
|
+
"lock_for",
|
|
115
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Sentence segmenter for the buffer-then-stream path (feat-020).
|
|
2
|
+
|
|
3
|
+
v0.2 ships `ChatSession.stream()` in the spec's
|
|
4
|
+
`safety_mode: "buffer-then-stream"` semantics: the agent runs to
|
|
5
|
+
completion, then the assistant turn is sliced into sentence-ish
|
|
6
|
+
chunks for the wire format. Real per-token streaming follows in a
|
|
7
|
+
later release without changing this surface.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
_SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+")
|
|
15
|
+
_MAX_CHUNK_CHARS = 200
|
|
16
|
+
"""Soft cap so a single uninterrupted paragraph still emits as
|
|
17
|
+
multiple chunks."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def segment_for_stream(text: str) -> list[str]:
|
|
21
|
+
"""Split ``text`` into wire-format-friendly chunks.
|
|
22
|
+
|
|
23
|
+
Prefers sentence boundaries (``.!?`` followed by whitespace);
|
|
24
|
+
falls back to paragraph boundaries; falls back to a hard
|
|
25
|
+
`_MAX_CHUNK_CHARS` cap.
|
|
26
|
+
"""
|
|
27
|
+
if not text:
|
|
28
|
+
return []
|
|
29
|
+
parts = [p for p in _SENTENCE_BOUNDARY.split(text) if p]
|
|
30
|
+
out: list[str] = []
|
|
31
|
+
for part in parts:
|
|
32
|
+
out.extend(_split_long(part))
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _split_long(text: str) -> list[str]:
|
|
37
|
+
if len(text) <= _MAX_CHUNK_CHARS:
|
|
38
|
+
return [text]
|
|
39
|
+
pieces: list[str] = []
|
|
40
|
+
cursor = 0
|
|
41
|
+
while cursor < len(text):
|
|
42
|
+
end = min(cursor + _MAX_CHUNK_CHARS, len(text))
|
|
43
|
+
pieces.append(text[cursor:end])
|
|
44
|
+
cursor = end
|
|
45
|
+
return pieces
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Sentence-window buffer for the streaming output-guardrail path
|
|
2
|
+
(feat-020 v0.3 polish).
|
|
3
|
+
|
|
4
|
+
When `ChatSession.safety_mode == "sentence-window"`, streamed text
|
|
5
|
+
tokens accumulate in a `_SentenceWindowBuffer`. Each `push(text)` call
|
|
6
|
+
returns the completed sentences ready to validate (terminator
|
|
7
|
+
followed by whitespace, OR newline, OR 200-char hard cap so a
|
|
8
|
+
paragraph without punctuation still flushes). The buffer's
|
|
9
|
+
`flush()` returns whatever residual remains so callers can pipe
|
|
10
|
+
the partial through the guardrail one last time at end-of-stream.
|
|
11
|
+
|
|
12
|
+
Boundary heuristic mirrors :mod:`agentforge_chat._segment` so the
|
|
13
|
+
streaming and buffer-then-stream paths produce comparable chunk
|
|
14
|
+
shapes. Multi-language sentence segmentation is out of scope for
|
|
15
|
+
v0.3; the regex is English-centric.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
_SOFT_MAX_CHARS = 200
|
|
23
|
+
"""Soft cap so an unpunctuated paragraph still emits as chunks."""
|
|
24
|
+
|
|
25
|
+
_BOUNDARY_RE = re.compile(r"[.!?]\s+|\n+")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _SentenceWindowBuffer:
|
|
29
|
+
"""Accumulates streamed tokens; releases completed sentences.
|
|
30
|
+
|
|
31
|
+
Not thread-safe — `ChatSession._stream_per_token` already
|
|
32
|
+
serialises per-session via a lock, so each buffer instance is
|
|
33
|
+
single-writer / single-reader.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._buf = ""
|
|
38
|
+
|
|
39
|
+
def push(self, text: str) -> list[str]:
|
|
40
|
+
"""Append `text` to the buffer; return any completed sentences.
|
|
41
|
+
|
|
42
|
+
A sentence is "completed" when:
|
|
43
|
+
- a `.!?` is followed by whitespace, OR
|
|
44
|
+
- a newline appears, OR
|
|
45
|
+
- the buffer length exceeds `_SOFT_MAX_CHARS`.
|
|
46
|
+
|
|
47
|
+
Remaining partial text stays buffered until the next push
|
|
48
|
+
or a `flush()`.
|
|
49
|
+
"""
|
|
50
|
+
if not text:
|
|
51
|
+
return []
|
|
52
|
+
self._buf += text
|
|
53
|
+
completed: list[str] = []
|
|
54
|
+
while True:
|
|
55
|
+
cut = self._find_cut()
|
|
56
|
+
if cut is None:
|
|
57
|
+
break
|
|
58
|
+
sentence = self._buf[:cut].rstrip()
|
|
59
|
+
if sentence:
|
|
60
|
+
completed.append(sentence)
|
|
61
|
+
self._buf = self._buf[cut:].lstrip()
|
|
62
|
+
return completed
|
|
63
|
+
|
|
64
|
+
def flush(self) -> str:
|
|
65
|
+
"""Return the residual buffer contents + reset internal state.
|
|
66
|
+
|
|
67
|
+
Callers run this through their per-sentence pipeline one
|
|
68
|
+
last time so end-of-stream text isn't dropped on the floor.
|
|
69
|
+
Returns an empty string when the buffer is already empty.
|
|
70
|
+
"""
|
|
71
|
+
residual, self._buf = self._buf, ""
|
|
72
|
+
return residual
|
|
73
|
+
|
|
74
|
+
def _find_cut(self) -> int | None:
|
|
75
|
+
"""Return the byte index at which to slice off a completed
|
|
76
|
+
sentence, or `None` if nothing is ready yet.
|
|
77
|
+
|
|
78
|
+
Priority: punctuation/newline boundary first; hard-cap
|
|
79
|
+
fallback at `_SOFT_MAX_CHARS`.
|
|
80
|
+
"""
|
|
81
|
+
match = _BOUNDARY_RE.search(self._buf)
|
|
82
|
+
if match is not None:
|
|
83
|
+
return match.end()
|
|
84
|
+
if len(self._buf) >= _SOFT_MAX_CHARS:
|
|
85
|
+
return _SOFT_MAX_CHARS
|
|
86
|
+
return None
|
agentforge_chat/build.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Config-driven `ChatSession` construction (feat-020).
|
|
2
|
+
|
|
3
|
+
`build_chat_session_from_config(config, agent)` reads
|
|
4
|
+
`modules.chat:` and assembles:
|
|
5
|
+
|
|
6
|
+
- the history-store driver (resolver category `chat.history`),
|
|
7
|
+
- the truncation strategy (resolver category `chat.truncation`),
|
|
8
|
+
- per-turn / per-session budget + idempotency knobs.
|
|
9
|
+
|
|
10
|
+
Drivers that expose `from_config(cfg)` are preferred; otherwise
|
|
11
|
+
the class is constructed with `**cfg`. Async-factory drivers
|
|
12
|
+
(e.g. `SqliteChatHistory.from_path`) are recognised by the
|
|
13
|
+
`from_path` classmethod returning an awaitable.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from agentforge.agent import Agent
|
|
21
|
+
from agentforge_core.config.schema import AgentForgeConfig
|
|
22
|
+
from agentforge_core.contracts.chat import ChatHistoryStore, HistoryTruncationStrategy
|
|
23
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
24
|
+
from agentforge_core.resolver import Resolver
|
|
25
|
+
|
|
26
|
+
from agentforge_chat.history import InMemoryChatHistory
|
|
27
|
+
from agentforge_chat.session import ChatSession, SafetyMode
|
|
28
|
+
from agentforge_chat.truncation import SlidingWindow
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def build_chat_session_from_config(
|
|
32
|
+
config: AgentForgeConfig,
|
|
33
|
+
agent: Agent,
|
|
34
|
+
*,
|
|
35
|
+
session_id: str | None = None,
|
|
36
|
+
owner: str | None = None,
|
|
37
|
+
system_prompt: str | None = None,
|
|
38
|
+
) -> ChatSession:
|
|
39
|
+
"""Instantiate a `ChatSession` driven by `modules.chat:`.
|
|
40
|
+
|
|
41
|
+
Caller still owns the `Agent`; the chat session merely wraps it.
|
|
42
|
+
`session_id` defaults to a fresh ULID-ish hex when omitted.
|
|
43
|
+
"""
|
|
44
|
+
chat_cfg = config.modules.chat
|
|
45
|
+
history: ChatHistoryStore = InMemoryChatHistory()
|
|
46
|
+
truncation: HistoryTruncationStrategy = SlidingWindow(50)
|
|
47
|
+
per_turn = None
|
|
48
|
+
per_session = None
|
|
49
|
+
idem_window = 60.0
|
|
50
|
+
safety_mode: SafetyMode = "buffer-then-stream"
|
|
51
|
+
if chat_cfg is not None:
|
|
52
|
+
if chat_cfg.history is not None:
|
|
53
|
+
history = await _build_history(chat_cfg.history.driver, chat_cfg.history.config)
|
|
54
|
+
if chat_cfg.truncation is not None:
|
|
55
|
+
truncation = _build_truncation(chat_cfg.truncation.strategy, chat_cfg.truncation.config)
|
|
56
|
+
per_turn = chat_cfg.session.per_turn_budget_usd
|
|
57
|
+
per_session = chat_cfg.session.per_session_budget_usd
|
|
58
|
+
idem_window = chat_cfg.session.idempotency_window_s
|
|
59
|
+
safety_mode = chat_cfg.session.safety_mode
|
|
60
|
+
return ChatSession(
|
|
61
|
+
agent=agent,
|
|
62
|
+
session_id=session_id,
|
|
63
|
+
history_store=history,
|
|
64
|
+
system_prompt=system_prompt,
|
|
65
|
+
truncation=truncation,
|
|
66
|
+
owner=owner,
|
|
67
|
+
per_turn_budget_usd=per_turn,
|
|
68
|
+
per_session_budget_usd=per_session,
|
|
69
|
+
idempotency_window_s=idem_window,
|
|
70
|
+
safety_mode=safety_mode,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _build_history(driver: str, cfg: dict[str, Any]) -> ChatHistoryStore:
|
|
75
|
+
cls = Resolver.global_().resolve("chat.history", driver)
|
|
76
|
+
instance = await _maybe_async(_instantiate(cls, cfg))
|
|
77
|
+
if not isinstance(instance, ChatHistoryStore):
|
|
78
|
+
raise ModuleError(
|
|
79
|
+
f"Resolved chat.history driver {driver!r} ({cls.__name__}) does not "
|
|
80
|
+
f"implement ChatHistoryStore."
|
|
81
|
+
)
|
|
82
|
+
return instance
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _build_truncation(name: str, cfg: dict[str, Any]) -> HistoryTruncationStrategy:
|
|
86
|
+
cls = Resolver.global_().resolve("chat.truncation", name)
|
|
87
|
+
instance = _instantiate(cls, cfg)
|
|
88
|
+
if not isinstance(instance, HistoryTruncationStrategy):
|
|
89
|
+
raise ModuleError(
|
|
90
|
+
f"Resolved chat.truncation {name!r} ({cls.__name__}) does not "
|
|
91
|
+
f"implement HistoryTruncationStrategy."
|
|
92
|
+
)
|
|
93
|
+
return instance
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _instantiate(cls: type, cfg: dict[str, Any]) -> Any:
|
|
97
|
+
from_config = getattr(cls, "from_config", None)
|
|
98
|
+
if callable(from_config):
|
|
99
|
+
return from_config(cfg)
|
|
100
|
+
from_path = getattr(cls, "from_path", None)
|
|
101
|
+
if callable(from_path) and "path" in cfg:
|
|
102
|
+
return from_path(cfg["path"])
|
|
103
|
+
return cls(**cfg)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _maybe_async(value: Any) -> Any:
|
|
107
|
+
if hasattr(value, "__await__"):
|
|
108
|
+
return await value
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = ["build_chat_session_from_config"]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""`InMemoryChatHistory` — process-local default `ChatHistoryStore`.
|
|
2
|
+
|
|
3
|
+
Backs `ChatSession` when no driver is configured. Useful for tests
|
|
4
|
+
and tiny demos; not persistent across process restarts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentforge_core.contracts.chat import ChatHistoryStore
|
|
15
|
+
from agentforge_core.values.chat import ChatTurn, SessionInfo
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InMemoryChatHistory(ChatHistoryStore):
|
|
19
|
+
"""Thread-safe in-memory implementation of `ChatHistoryStore`."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._turns: dict[str, list[ChatTurn]] = {}
|
|
23
|
+
self._meta: dict[str, dict[str, Any]] = {}
|
|
24
|
+
self._owners: dict[str, str | None] = {}
|
|
25
|
+
self._created_at: dict[str, datetime] = {}
|
|
26
|
+
self._last_active: dict[str, datetime] = {}
|
|
27
|
+
self._lock = asyncio.Lock()
|
|
28
|
+
|
|
29
|
+
async def append(self, turn: ChatTurn) -> None:
|
|
30
|
+
async with self._lock:
|
|
31
|
+
self._turns.setdefault(turn.session_id, []).append(turn)
|
|
32
|
+
now = datetime.now(UTC)
|
|
33
|
+
self._created_at.setdefault(turn.session_id, now)
|
|
34
|
+
self._last_active[turn.session_id] = now
|
|
35
|
+
|
|
36
|
+
async def load(
|
|
37
|
+
self,
|
|
38
|
+
session_id: str,
|
|
39
|
+
*,
|
|
40
|
+
limit: int | None = None,
|
|
41
|
+
before: datetime | None = None,
|
|
42
|
+
after: datetime | None = None,
|
|
43
|
+
roles: list[str] | None = None,
|
|
44
|
+
) -> list[ChatTurn]:
|
|
45
|
+
async with self._lock:
|
|
46
|
+
turns = list(self._turns.get(session_id, []))
|
|
47
|
+
if before is not None:
|
|
48
|
+
turns = [t for t in turns if t.timestamp < before]
|
|
49
|
+
if after is not None:
|
|
50
|
+
turns = [t for t in turns if t.timestamp > after]
|
|
51
|
+
if roles is not None:
|
|
52
|
+
allowed = set(roles)
|
|
53
|
+
turns = [t for t in turns if t.role in allowed]
|
|
54
|
+
turns.sort(key=lambda t: t.timestamp)
|
|
55
|
+
if limit is not None:
|
|
56
|
+
turns = turns[:limit]
|
|
57
|
+
return turns
|
|
58
|
+
|
|
59
|
+
async def count(self, session_id: str) -> int:
|
|
60
|
+
async with self._lock:
|
|
61
|
+
return len(self._turns.get(session_id, []))
|
|
62
|
+
|
|
63
|
+
async def delete_session(self, session_id: str) -> int:
|
|
64
|
+
async with self._lock:
|
|
65
|
+
removed = len(self._turns.pop(session_id, []))
|
|
66
|
+
self._meta.pop(session_id, None)
|
|
67
|
+
self._owners.pop(session_id, None)
|
|
68
|
+
self._created_at.pop(session_id, None)
|
|
69
|
+
self._last_active.pop(session_id, None)
|
|
70
|
+
return removed
|
|
71
|
+
|
|
72
|
+
async def list_sessions(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
owner: str | None = None,
|
|
76
|
+
limit: int = 100,
|
|
77
|
+
before: datetime | None = None,
|
|
78
|
+
) -> list[SessionInfo]:
|
|
79
|
+
async with self._lock:
|
|
80
|
+
out = [self._build_info(sid) for sid in self._turns]
|
|
81
|
+
if owner is not None:
|
|
82
|
+
out = [s for s in out if s.owner == owner]
|
|
83
|
+
if before is not None:
|
|
84
|
+
out = [s for s in out if s.last_active_at < before]
|
|
85
|
+
out.sort(key=lambda s: s.last_active_at, reverse=True)
|
|
86
|
+
return out[:limit]
|
|
87
|
+
|
|
88
|
+
async def update_session_metadata(self, session_id: str, metadata: Mapping[str, Any]) -> None:
|
|
89
|
+
async with self._lock:
|
|
90
|
+
bag = self._meta.setdefault(session_id, {})
|
|
91
|
+
for k, v in metadata.items():
|
|
92
|
+
bag[k] = v
|
|
93
|
+
if "owner" in metadata:
|
|
94
|
+
self._owners[session_id] = metadata["owner"]
|
|
95
|
+
|
|
96
|
+
async def expire_before(self, cutoff: datetime) -> int:
|
|
97
|
+
async with self._lock:
|
|
98
|
+
doomed = [sid for sid, last in self._last_active.items() if last < cutoff]
|
|
99
|
+
for sid in doomed:
|
|
100
|
+
self._turns.pop(sid, None)
|
|
101
|
+
self._meta.pop(sid, None)
|
|
102
|
+
self._owners.pop(sid, None)
|
|
103
|
+
self._created_at.pop(sid, None)
|
|
104
|
+
self._last_active.pop(sid, None)
|
|
105
|
+
return len(doomed)
|
|
106
|
+
|
|
107
|
+
async def close(self) -> None:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def capabilities(self) -> set[str]:
|
|
111
|
+
return {"ttl"}
|
|
112
|
+
|
|
113
|
+
def _build_info(self, sid: str) -> SessionInfo:
|
|
114
|
+
turns = self._turns.get(sid, [])
|
|
115
|
+
return SessionInfo(
|
|
116
|
+
id=sid,
|
|
117
|
+
owner=self._owners.get(sid),
|
|
118
|
+
created_at=self._created_at.get(sid, datetime.now(UTC)),
|
|
119
|
+
last_active_at=self._last_active.get(sid, datetime.now(UTC)),
|
|
120
|
+
turn_count=len(turns),
|
|
121
|
+
total_cost_usd=sum(t.cost_usd for t in turns),
|
|
122
|
+
metadata=dict(self._meta.get(sid, {})),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = ["InMemoryChatHistory"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Module manifest for `agentforge add module chat` (feat-010).
|
|
2
|
+
name: chat
|
|
3
|
+
description: |
|
|
4
|
+
Chat-agent runtime (feat-020). Adds the `agentforge_chat` package
|
|
5
|
+
with `ChatSession`, in-memory + SQLite history drivers, and four
|
|
6
|
+
truncation strategies.
|
|
7
|
+
|
|
8
|
+
distribution:
|
|
9
|
+
pip_name: agentforge-chat
|
|
10
|
+
|
|
11
|
+
config_block:
|
|
12
|
+
modules.chat:
|
|
13
|
+
history:
|
|
14
|
+
driver: memory # memory | sqlite (sqlite needs `agentforge-chat[sqlite]`)
|
|
15
|
+
config: {}
|
|
16
|
+
truncation:
|
|
17
|
+
strategy: sliding_window
|
|
18
|
+
max_turns: 50
|
|
19
|
+
session:
|
|
20
|
+
per_turn_budget_usd: null
|
|
21
|
+
per_session_budget_usd: null
|
|
22
|
+
idempotency_window_s: 60
|
|
23
|
+
|
|
24
|
+
entry_points:
|
|
25
|
+
agentforge.chat.history:
|
|
26
|
+
memory: agentforge_chat.history:InMemoryChatHistory
|
|
27
|
+
sqlite: agentforge_chat.sqlite:SqliteChatHistory
|
|
28
|
+
agentforge.chat.truncation:
|
|
29
|
+
sliding_window: agentforge_chat.truncation:SlidingWindow
|
|
30
|
+
token_budget: agentforge_chat.truncation:TokenBudget
|
|
31
|
+
summarise_oldest: agentforge_chat.truncation:SummariseOldest
|
|
32
|
+
hybrid: agentforge_chat.truncation:Hybrid
|
agentforge_chat/py.typed
ADDED
|
File without changes
|