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.
@@ -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
@@ -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
File without changes