zettcode-engine 0.3.4__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.
- zettcode/_compat.py +35 -0
- zettcode/budget.py +24 -0
- zettcode/config.py +1088 -0
- zettcode/config_edit.py +457 -0
- zettcode/engine/__init__.py +39 -0
- zettcode/engine/agent/__init__.py +81 -0
- zettcode/engine/agent/base.py +60 -0
- zettcode/engine/agent/context.py +166 -0
- zettcode/engine/agent/exports.py +73 -0
- zettcode/engine/agent/interaction.py +54 -0
- zettcode/engine/agent/sessions.py +218 -0
- zettcode/engine/agent/settings.py +269 -0
- zettcode/engine/agent/turns.py +308 -0
- zettcode/engine/core.py +819 -0
- zettcode/engine/dialogue/__init__.py +6 -0
- zettcode/engine/dialogue/ask.py +113 -0
- zettcode/engine/dialogue/side.py +197 -0
- zettcode/engine/dialogue/title.py +80 -0
- zettcode/engine/export/__init__.py +421 -0
- zettcode/engine/export/trace/script.js +18 -0
- zettcode/engine/export/trace/style.css +136 -0
- zettcode/engine/export/trace/template.html +31 -0
- zettcode/engine/extensions/__init__.py +8 -0
- zettcode/engine/extensions/capabilities.py +68 -0
- zettcode/engine/extensions/compaction.py +259 -0
- zettcode/engine/extensions/context.py +521 -0
- zettcode/engine/extensions/files.py +101 -0
- zettcode/engine/extensions/guidance.py +17 -0
- zettcode/engine/extensions/harness.py +208 -0
- zettcode/engine/extensions/harness_switch.py +18 -0
- zettcode/engine/extensions/mcp_notice.py +49 -0
- zettcode/engine/extensions/relay.py +93 -0
- zettcode/engine/extensions/subagents.py +391 -0
- zettcode/engine/extensions/tool_output.py +153 -0
- zettcode/engine/extensions/truncation.py +156 -0
- zettcode/engine/extensions/usage.py +304 -0
- zettcode/engine/harness_paths.py +74 -0
- zettcode/engine/permission/__init__.py +197 -0
- zettcode/engine/permission/approval.py +66 -0
- zettcode/engine/permission/readonly.py +435 -0
- zettcode/engine/permission/sandbox.py +140 -0
- zettcode/engine/pipeline.py +191 -0
- zettcode/engine/plugins/__init__.py +72 -0
- zettcode/engine/plugins/commands.py +152 -0
- zettcode/engine/plugins/context.py +251 -0
- zettcode/engine/plugins/extension.py +183 -0
- zettcode/engine/plugins/groups.py +74 -0
- zettcode/engine/plugins/loader.py +367 -0
- zettcode/engine/plugins/mixins.py +156 -0
- zettcode/engine/plugins/plugin.py +190 -0
- zettcode/engine/plugins/services.py +68 -0
- zettcode/engine/plugins/settings.py +117 -0
- zettcode/engine/plugins/state.py +64 -0
- zettcode/engine/plugins/switches.py +553 -0
- zettcode/engine/py.typed +0 -0
- zettcode/engine/server_tools.py +179 -0
- zettcode/engine/storage/__init__.py +51 -0
- zettcode/engine/storage/layout.py +161 -0
- zettcode/engine/storage/ledger.py +659 -0
- zettcode/engine/storage/locks.py +182 -0
- zettcode/engine/storage/metadata.py +278 -0
- zettcode/engine/storage/records.py +373 -0
- zettcode/engine/storage/store.py +1056 -0
- zettcode/engine/tools/__init__.py +7 -0
- zettcode/engine/tools/boundary.py +56 -0
- zettcode/engine/tools/files.py +262 -0
- zettcode/engine/tools/images.py +32 -0
- zettcode/engine/tools/output.py +81 -0
- zettcode/engine/tools/shell.py +181 -0
- zettcode/engine/tools/workspace.py +271 -0
- zettcode/error_text.py +217 -0
- zettcode/filelock.py +125 -0
- zettcode/logging_setup.py +196 -0
- zettcode/mention.py +124 -0
- zettcode/modes.py +18 -0
- zettcode/paths.py +113 -0
- zettcode/plugin_tui.py +133 -0
- zettcode/processes.py +126 -0
- zettcode/prompt.py +94 -0
- zettcode/providers.py +215 -0
- zettcode/py.typed +0 -0
- zettcode/registry.py +63 -0
- zettcode/relay.py +267 -0
- zettcode/statusline_parts.py +84 -0
- zettcode/version.py +11 -0
- zettcode/wire.py +30 -0
- zettcode/wording.py +11 -0
- zettcode_engine-0.3.4.dist-info/METADATA +23 -0
- zettcode_engine-0.3.4.dist-info/RECORD +90 -0
- zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
"""Where sessions live: the workspace layout, the JSONL writer, and the plugin.
|
|
2
|
+
|
|
3
|
+
One store root is shared by every workspace the user opens, so it is grouped by
|
|
4
|
+
workspace first::
|
|
5
|
+
|
|
6
|
+
~/.zettcode/sessions/<workspace path, levels joined with ->/
|
|
7
|
+
├── metadata.jsonl <- the index: title and activity times
|
|
8
|
+
└── <session-id>/
|
|
9
|
+
└── data.jsonl <- the conversation tree
|
|
10
|
+
|
|
11
|
+
The folder name is the resolved workspace path with ``-`` between its levels
|
|
12
|
+
(``-Users-me-api``), so a path containing separators never turns into extra
|
|
13
|
+
folders; :mod:`layout` decides the name, and moves a folder an older version
|
|
14
|
+
named with base64 when nothing has it open.
|
|
15
|
+
|
|
16
|
+
File operations are plain blocking reads and writes: an append is one small
|
|
17
|
+
line, and the caller is the single process owning the session. The last line of
|
|
18
|
+
a file may be truncated by a crash, so an unparsable *final* line is ignored; a
|
|
19
|
+
corrupt line anywhere else raises, because that means real damage.
|
|
20
|
+
|
|
21
|
+
:class:`SessionPersistenceMixin` owns all of that — the layout, parsing and the
|
|
22
|
+
torn-last-line policy, the tree walk, ``read``, ``append``, ``checkpoint``, the
|
|
23
|
+
metadata log, and ``list_sessions``. :class:`SessionStore` adds the framework
|
|
24
|
+
hooks on top, so saving stays a mixin concern and the agent lifecycle stays a
|
|
25
|
+
plugin concern.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import json
|
|
32
|
+
import threading
|
|
33
|
+
from collections import Counter
|
|
34
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
35
|
+
from dataclasses import dataclass, field, replace
|
|
36
|
+
from datetime import datetime
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import cast
|
|
39
|
+
from weakref import WeakKeyDictionary
|
|
40
|
+
|
|
41
|
+
from pydantic import ValidationError
|
|
42
|
+
from zett_agent.agent import AgentRunContext
|
|
43
|
+
from zett_agent.extensions.base import AgentExtension
|
|
44
|
+
from zett_agent.extensions.compaction import CompactedMessage
|
|
45
|
+
from zett_agent.extensions.events import (
|
|
46
|
+
CompactionEvent,
|
|
47
|
+
ExtensionEvent,
|
|
48
|
+
MessageAppendedEvent,
|
|
49
|
+
MessageTiming,
|
|
50
|
+
RunCancelledEvent,
|
|
51
|
+
)
|
|
52
|
+
from zett_agent.ids import new_uuid7
|
|
53
|
+
from zett_agent.messages import AnyMessage, AssistantMessage, SystemMessage, ToolMessage
|
|
54
|
+
from zett_agent.model import ModelUsage
|
|
55
|
+
|
|
56
|
+
from ..dialogue.side import ORIGIN, READER, SIDE, WATCHED, is_side, is_watched_side
|
|
57
|
+
from .layout import claim_workspace, open_workspace
|
|
58
|
+
from .ledger import LedgerReader, SessionStats
|
|
59
|
+
from .locks import prune_empty
|
|
60
|
+
from .metadata import (
|
|
61
|
+
MAX_MODEL,
|
|
62
|
+
MAX_TITLE,
|
|
63
|
+
METADATA_FILE,
|
|
64
|
+
SESSION_METADATA_FILE,
|
|
65
|
+
SessionInfo,
|
|
66
|
+
SessionMetadata,
|
|
67
|
+
info_of,
|
|
68
|
+
read_metadata,
|
|
69
|
+
read_session_metadata,
|
|
70
|
+
write_session_metadata,
|
|
71
|
+
)
|
|
72
|
+
from .records import (
|
|
73
|
+
FORK_SUFFIX,
|
|
74
|
+
LINES,
|
|
75
|
+
MAX_AGENT_NAME,
|
|
76
|
+
SAFE_SESSION_ID,
|
|
77
|
+
STORE_FILE,
|
|
78
|
+
STORE_VERSION,
|
|
79
|
+
CompactionLine,
|
|
80
|
+
JsonValue,
|
|
81
|
+
Line,
|
|
82
|
+
MessageLine,
|
|
83
|
+
Session,
|
|
84
|
+
SessionLine,
|
|
85
|
+
now,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def message_context(
|
|
90
|
+
value: Mapping[str, object] | None, *, field: str, nonempty_keys: bool = False
|
|
91
|
+
) -> dict[str, JsonValue]:
|
|
92
|
+
"""Validate one metadata or tags object before it reaches the log."""
|
|
93
|
+
if value is None:
|
|
94
|
+
return {}
|
|
95
|
+
encoded: dict[str, object] = {}
|
|
96
|
+
for key, item in value.items():
|
|
97
|
+
if not isinstance(key, str):
|
|
98
|
+
raise ValueError(f"{field} keys must be strings")
|
|
99
|
+
if nonempty_keys and not key.strip():
|
|
100
|
+
raise ValueError(f"{field} keys cannot be empty")
|
|
101
|
+
encoded[key] = item
|
|
102
|
+
# Pydantic validates the value when the line is built; this only tells the
|
|
103
|
+
# checker what the runtime already guarantees.
|
|
104
|
+
return cast("dict[str, JsonValue]", encoded)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(slots=True)
|
|
108
|
+
class _Request:
|
|
109
|
+
"""Persistence bookkeeping for one request-scoped context."""
|
|
110
|
+
|
|
111
|
+
request_id: str
|
|
112
|
+
checkpoint_version: int = 0
|
|
113
|
+
#: Whether this request is a side question (see ``engine.dialogue.side``): its
|
|
114
|
+
#: messages are written for the record but never replayed into a context.
|
|
115
|
+
side: bool = False
|
|
116
|
+
#: Whether the reader watched that side question on screen, which is what
|
|
117
|
+
#: a resumed transcript restores: a plugin's own question is never drawn.
|
|
118
|
+
#: Both are set together, from the run's own question (:meth:`on_event`), and
|
|
119
|
+
#: read only for a request already known to be a side one, so the defaults are
|
|
120
|
+
#: what the dataclass requires and nothing more: a run whose question was never
|
|
121
|
+
#: seen is not a side run at all, and its lines go untagged.
|
|
122
|
+
watched: bool = False
|
|
123
|
+
#: Who asked it — ``"reader"``, or the plugin asking for itself — kept as a
|
|
124
|
+
#: tag so a stored exchange says what it was without reading its text.
|
|
125
|
+
origin: str = READER
|
|
126
|
+
#: One stored message id for each replayable non-system context position.
|
|
127
|
+
#: ``None`` marks a context-only message the store was told to skip. The
|
|
128
|
+
#: checkpoint's own position is represented by its boundary message id, so
|
|
129
|
+
#: a later compaction can name the same boundary again.
|
|
130
|
+
context_ids: list[str | None] = field(default_factory=list)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SessionPersistenceMixin:
|
|
134
|
+
"""Standalone mixin: read and write the conversation log and its metadata.
|
|
135
|
+
|
|
136
|
+
It inherits nothing, so it can be combined with the framework's
|
|
137
|
+
``AgentExtension`` (see :class:`SessionStore`). It owns the storage: the
|
|
138
|
+
workspace layout, parsing and the torn-last-line policy, the tree walk,
|
|
139
|
+
``read``, ``append``, ``checkpoint``, the metadata log with
|
|
140
|
+
``set_title``/``session_title`` and the per-session flags beside them, and
|
|
141
|
+
``list_sessions``. It holds no framework behavior.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
root: Directory holding one folder per workspace, and inside it one
|
|
145
|
+
``<session_id>/`` folder per session with its own ``data.jsonl``.
|
|
146
|
+
``None`` uses ``~/.zettcode/sessions``.
|
|
147
|
+
workspace: Project the sessions belong to; its resolved absolute path,
|
|
148
|
+
joined with ``-``, names the workspace folder. ``None`` uses the
|
|
149
|
+
current directory, which is only right for standalone use — the
|
|
150
|
+
runtime always passes the configured workspace.
|
|
151
|
+
|
|
152
|
+
Folders are created when something is first written to them, not when the store is built: a project
|
|
153
|
+
that is opened and never used leaves nothing in the store.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(self, root: str | Path | None = None, workspace: str | Path | None = None) -> None:
|
|
157
|
+
self.root = Path(root) if root is not None else Path.home() / ".zettcode" / "sessions"
|
|
158
|
+
self.workspace = Path(workspace).expanduser().resolve() if workspace is not None else Path.cwd()
|
|
159
|
+
#: Opening it moves a folder an older version named with base64 to the readable name,
|
|
160
|
+
#: or leaves it where it is when something has it open (see :mod:`.layout`). The folder is
|
|
161
|
+
#: made by the first write, not here.
|
|
162
|
+
self.workspace_dir = open_workspace(self.root, self.workspace)
|
|
163
|
+
#: The log older versions kept for the whole workspace. Read, never written: see
|
|
164
|
+
#: :mod:`zettcode.engine.storage.metadata`.
|
|
165
|
+
self.legacy_metadata_path = self.workspace_dir / METADATA_FILE
|
|
166
|
+
# Parsed sessions, keyed by the file stamp that produced them. Appending
|
|
167
|
+
# is the hot path — one call per message — so it must not re-parse the
|
|
168
|
+
# whole log; the cache makes a read O(1) and a write a tuple copy.
|
|
169
|
+
self._sessions: dict[str, tuple[tuple[int, int] | None, Session]] = {}
|
|
170
|
+
#: A read now runs on a worker thread for the framework's restore hook,
|
|
171
|
+
#: while appends still fold into the same cache on the loop. The lock
|
|
172
|
+
#: orders only the cache lookups and the publish; the parse itself runs
|
|
173
|
+
#: outside it. It does not make the file write atomic.
|
|
174
|
+
self._cache_lock = threading.RLock()
|
|
175
|
+
#: The old log folded into rows, with the stamp of the file it came from, so a
|
|
176
|
+
#: workspace with several old sessions folds it once instead of once per session.
|
|
177
|
+
self._legacy: tuple[tuple[int, int] | None, dict[str, SessionInfo]] | None = None
|
|
178
|
+
#: Whether the workspace folder has been made and marked as ours; the first write does it, once.
|
|
179
|
+
self._claimed = False
|
|
180
|
+
#: Reads the totals the sessions panel shows; made on the first ask, since most runs never list.
|
|
181
|
+
self._ledger: LedgerReader | None = None
|
|
182
|
+
super().__init__()
|
|
183
|
+
|
|
184
|
+
# -- files --------------------------------------------------------------
|
|
185
|
+
def session_dir(self, session_id: str) -> Path:
|
|
186
|
+
"""Return the directory of one session, refusing ids that leave the store."""
|
|
187
|
+
if not SAFE_SESSION_ID.match(session_id) or ".." in session_id:
|
|
188
|
+
raise ValueError(f"Unsafe session id: {session_id!r}")
|
|
189
|
+
return self.workspace_dir / session_id
|
|
190
|
+
|
|
191
|
+
def session_path(self, session_id: str) -> Path:
|
|
192
|
+
"""Return the conversation file of one session."""
|
|
193
|
+
return self.session_dir(session_id) / STORE_FILE
|
|
194
|
+
|
|
195
|
+
def session_ids(self) -> Iterator[str]:
|
|
196
|
+
"""Yield the id of every session folder this workspace owns."""
|
|
197
|
+
for path in sorted(self.workspace_dir.glob(f"*/{STORE_FILE}")):
|
|
198
|
+
session_id = path.parent.name
|
|
199
|
+
if SAFE_SESSION_ID.match(session_id) and ".." not in session_id:
|
|
200
|
+
yield session_id
|
|
201
|
+
|
|
202
|
+
def exists(self, session_id: str) -> bool:
|
|
203
|
+
"""Say whether a session was started, by reading the one line that says so.
|
|
204
|
+
|
|
205
|
+
A session file opens with its header, so that line answers the question.
|
|
206
|
+
:meth:`read` is the call that checks the whole file, and it is the wrong one
|
|
207
|
+
to ask a yes-or-no question with: on a long session it parses every message
|
|
208
|
+
to report what the first line already says, and a command line that did so
|
|
209
|
+
before resuming paid for the parse twice — once to be told the session was
|
|
210
|
+
there, and again, in a store that had not kept the first, to open it.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
session_id: The session to look for.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
``True`` when the file exists and its first record is a session header.
|
|
217
|
+
"""
|
|
218
|
+
return self._header(session_id) is not None
|
|
219
|
+
|
|
220
|
+
def _header(self, session_id: str) -> SessionLine | None:
|
|
221
|
+
"""Return a session's header, read from the first line of its file and nothing more."""
|
|
222
|
+
try:
|
|
223
|
+
with self.session_path(session_id).open("rb") as handle:
|
|
224
|
+
for line in handle:
|
|
225
|
+
if line.strip():
|
|
226
|
+
record = LINES.validate_json(line)
|
|
227
|
+
return record if isinstance(record, SessionLine) else None
|
|
228
|
+
except (OSError, ValidationError):
|
|
229
|
+
return None
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def read(self, session_id: str) -> Session:
|
|
233
|
+
"""Parse one session file, tolerating a half-written final line.
|
|
234
|
+
|
|
235
|
+
The file is parsed outside the cache lock: a worker restoring a long
|
|
236
|
+
session must not make the loop's next append wait for the whole parse.
|
|
237
|
+
The lock is taken only to look up a cached parse and to publish a new
|
|
238
|
+
one, and a parse that started before a concurrent append is discarded
|
|
239
|
+
for the newer cache entry instead of overwriting it.
|
|
240
|
+
"""
|
|
241
|
+
path = self.session_path(session_id)
|
|
242
|
+
stamp = self._stamp(path) if path.is_file() else None
|
|
243
|
+
with self._cache_lock:
|
|
244
|
+
cached = self._sessions.get(session_id)
|
|
245
|
+
if cached is not None and cached[0] == stamp:
|
|
246
|
+
return cached[1]
|
|
247
|
+
if stamp is None:
|
|
248
|
+
session = Session(header=None, messages=(), compactions=(), head_id=None)
|
|
249
|
+
else:
|
|
250
|
+
session = self._parse(path)
|
|
251
|
+
with self._cache_lock:
|
|
252
|
+
cached = self._sessions.get(session_id)
|
|
253
|
+
if stamp is not None and cached is not None and cached[0] is not None and cached[0] >= stamp:
|
|
254
|
+
return cached[1]
|
|
255
|
+
self._sessions[session_id] = (stamp, session)
|
|
256
|
+
return session
|
|
257
|
+
|
|
258
|
+
def _parse(self, path: Path) -> Session:
|
|
259
|
+
"""Parse one session file without touching the cache."""
|
|
260
|
+
# Bytes, not text: the parser takes them as they are, so the file is not
|
|
261
|
+
# decoded to a string only to be encoded again, and a line is only ever split
|
|
262
|
+
# where the file's own newline is. ``str.splitlines`` also breaks at U+2028,
|
|
263
|
+
# U+2029, and U+0085, which JSON writes into a string as they are — a
|
|
264
|
+
# message that held one was cut in half and the file called corrupt.
|
|
265
|
+
lines = path.read_bytes().splitlines()
|
|
266
|
+
header: SessionLine | None = None
|
|
267
|
+
messages: list[MessageLine] = []
|
|
268
|
+
compactions: list[CompactionLine] = []
|
|
269
|
+
head_id: str | None = None
|
|
270
|
+
for index, line in enumerate(lines):
|
|
271
|
+
if not line.strip():
|
|
272
|
+
continue
|
|
273
|
+
try:
|
|
274
|
+
parsed = LINES.validate_json(line)
|
|
275
|
+
except ValidationError as error:
|
|
276
|
+
if index == len(lines) - 1:
|
|
277
|
+
break # A crash can tear the line being appended; the rest is intact.
|
|
278
|
+
raise ValueError(f"Corrupt session file {path.name} at line {index + 1}") from error
|
|
279
|
+
match parsed:
|
|
280
|
+
case SessionLine():
|
|
281
|
+
header = parsed
|
|
282
|
+
case MessageLine():
|
|
283
|
+
messages.append(parsed)
|
|
284
|
+
head_id = parsed.id
|
|
285
|
+
case CompactionLine():
|
|
286
|
+
compactions.append(parsed)
|
|
287
|
+
case other:
|
|
288
|
+
raise ValueError(f"Unknown session record: {type(other).__name__}")
|
|
289
|
+
self._check_links(path, messages, compactions)
|
|
290
|
+
return Session(header=header, messages=tuple(messages), compactions=tuple(compactions), head_id=head_id)
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def _stamp(path: Path) -> tuple[int, int]:
|
|
294
|
+
"""Return the file's identity as mtime and size, the cache's validity key.
|
|
295
|
+
|
|
296
|
+
The log is append-only, so a larger stamp is a newer file. ``read`` uses
|
|
297
|
+
that order to discard a parse that raced an append; replacing a session
|
|
298
|
+
file with one carrying an older stamp is not a supported operation.
|
|
299
|
+
"""
|
|
300
|
+
stat = path.stat()
|
|
301
|
+
return (stat.st_mtime_ns, stat.st_size)
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def _check_links(path: Path, messages: list[MessageLine], compactions: list[CompactionLine]) -> None:
|
|
305
|
+
"""Reject a file whose parent links do not form the tree they claim to.
|
|
306
|
+
|
|
307
|
+
Appends only reference already-written nodes, so an unknown parent means
|
|
308
|
+
the file was edited or truncated in the middle; failing here beats
|
|
309
|
+
silently reading a broken history.
|
|
310
|
+
"""
|
|
311
|
+
lines: list[MessageLine | CompactionLine] = [*messages, *compactions]
|
|
312
|
+
known = {line.id for line in lines}
|
|
313
|
+
for line in messages:
|
|
314
|
+
if line.parent is not None and line.parent not in known:
|
|
315
|
+
raise ValueError(f"Unknown parent {line.parent!r} in {path.name}")
|
|
316
|
+
for boundary in compactions:
|
|
317
|
+
if boundary.parent not in known:
|
|
318
|
+
raise ValueError(f"Unknown boundary {boundary.parent!r} in {path.name}")
|
|
319
|
+
|
|
320
|
+
def _append_line(self, session_id: str, line: Line) -> None:
|
|
321
|
+
"""Append one validated line, flushing so a reader sees whole records."""
|
|
322
|
+
text = json.dumps(line.model_dump(mode="json"), ensure_ascii=False, allow_nan=False, sort_keys=True)
|
|
323
|
+
if not self._claimed:
|
|
324
|
+
claim_workspace(self.workspace_dir, self.workspace)
|
|
325
|
+
self._claimed = True
|
|
326
|
+
self.session_dir(session_id).mkdir(parents=True, exist_ok=True)
|
|
327
|
+
path = self.session_path(session_id)
|
|
328
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
329
|
+
handle.write(text + "\n")
|
|
330
|
+
handle.flush()
|
|
331
|
+
self._remember(session_id, line)
|
|
332
|
+
|
|
333
|
+
def _remember(self, session_id: str, line: Line) -> None:
|
|
334
|
+
"""Fold a line this process just wrote into the cache, without re-reading.
|
|
335
|
+
|
|
336
|
+
A session that was never read has no entry to update, and the next read
|
|
337
|
+
parses the file once; when there is one, the new line is spliced into the
|
|
338
|
+
parsed model and the stamp refreshed, so the append that just happened
|
|
339
|
+
never pays for the parse the cache exists to avoid.
|
|
340
|
+
|
|
341
|
+
A cache entry that does not know the line's parent is dropped rather
|
|
342
|
+
than extended: a worker may have published an empty session between the
|
|
343
|
+
append's leading read and this call, and a one-line cache would then
|
|
344
|
+
claim to be the whole history.
|
|
345
|
+
"""
|
|
346
|
+
with self._cache_lock:
|
|
347
|
+
cached = self._sessions.get(session_id)
|
|
348
|
+
if cached is None:
|
|
349
|
+
return
|
|
350
|
+
_, session = cached
|
|
351
|
+
match line:
|
|
352
|
+
case SessionLine():
|
|
353
|
+
session = replace(session, header=line)
|
|
354
|
+
case MessageLine():
|
|
355
|
+
if (
|
|
356
|
+
line.parent is not None
|
|
357
|
+
and line.parent != session.head_id
|
|
358
|
+
and session.message(line.parent) is None
|
|
359
|
+
):
|
|
360
|
+
self._sessions.pop(session_id, None)
|
|
361
|
+
return
|
|
362
|
+
# A read on a worker may have parsed the file after this
|
|
363
|
+
# append wrote it but before it reached this cache update.
|
|
364
|
+
# The line is then already the tail; splicing it again
|
|
365
|
+
# would duplicate the message. The stamp is still refreshed
|
|
366
|
+
# below, or the next append would re-parse the whole file.
|
|
367
|
+
if not (session.messages and session.messages[-1].id == line.id):
|
|
368
|
+
session = replace(session, messages=(*session.messages, line), head_id=line.id)
|
|
369
|
+
case CompactionLine():
|
|
370
|
+
if session.message(line.parent) is None:
|
|
371
|
+
self._sessions.pop(session_id, None)
|
|
372
|
+
return
|
|
373
|
+
if not any(item.id == line.id for item in session.compactions):
|
|
374
|
+
session = replace(session, compactions=(*session.compactions, line))
|
|
375
|
+
self._sessions[session_id] = (self._stamp(self.session_path(session_id)), session)
|
|
376
|
+
|
|
377
|
+
# -- the store API ------------------------------------------------------
|
|
378
|
+
async def append(
|
|
379
|
+
self,
|
|
380
|
+
session_id: str,
|
|
381
|
+
request_id: str,
|
|
382
|
+
message: AnyMessage,
|
|
383
|
+
timing: MessageTiming | None = None,
|
|
384
|
+
parent_session_id: str | None = None,
|
|
385
|
+
agent_name: str | None = None,
|
|
386
|
+
metadata: Mapping[str, object] | None = None,
|
|
387
|
+
tags: Mapping[str, object] | None = None,
|
|
388
|
+
usage: ModelUsage | None = None,
|
|
389
|
+
*,
|
|
390
|
+
parent: str | None = None,
|
|
391
|
+
) -> str:
|
|
392
|
+
"""Append one raw message and return its id, linking it into the tree.
|
|
393
|
+
|
|
394
|
+
The new message continues from ``parent`` when given, otherwise from the
|
|
395
|
+
head. Passing an older node's id forks the session: the abandoned branch
|
|
396
|
+
stays in the file and drops out of the active context, and later appends
|
|
397
|
+
continue from the fork.
|
|
398
|
+
"""
|
|
399
|
+
if agent_name is not None and (not agent_name.strip() or len(agent_name) > MAX_AGENT_NAME):
|
|
400
|
+
raise ValueError(f"Agent name must contain between 1 and {MAX_AGENT_NAME} characters")
|
|
401
|
+
if usage is not None and not isinstance(message, AssistantMessage):
|
|
402
|
+
raise ValueError("Model usage belongs only to assistant messages")
|
|
403
|
+
session = self.read(session_id)
|
|
404
|
+
created_at = self._write_header(session, session_id, parent_session_id, agent_name)
|
|
405
|
+
if created_at is not None:
|
|
406
|
+
self._begin_metadata(session_id, created_at)
|
|
407
|
+
if parent is None:
|
|
408
|
+
parent = session.head_id
|
|
409
|
+
elif session.message(parent) is None:
|
|
410
|
+
raise ValueError(f"Unknown parent message: {parent!r}")
|
|
411
|
+
node_id = new_uuid7()
|
|
412
|
+
stamp = now()
|
|
413
|
+
self._append_line(
|
|
414
|
+
session_id,
|
|
415
|
+
MessageLine(
|
|
416
|
+
id=node_id,
|
|
417
|
+
parent=parent,
|
|
418
|
+
session_id=session_id,
|
|
419
|
+
request_id=request_id,
|
|
420
|
+
message=(message,),
|
|
421
|
+
timing=timing or MessageTiming.instant(),
|
|
422
|
+
usage=usage,
|
|
423
|
+
metadata=message_context(metadata, field="Message metadata"),
|
|
424
|
+
tags=message_context(tags, field="Message tags", nonempty_keys=True),
|
|
425
|
+
created_at=stamp,
|
|
426
|
+
),
|
|
427
|
+
)
|
|
428
|
+
# Nothing is written about the session itself: the conversation file's own
|
|
429
|
+
# modification time moved with the line above, and that is the activity a list shows.
|
|
430
|
+
return node_id
|
|
431
|
+
|
|
432
|
+
async def checkpoint(
|
|
433
|
+
self,
|
|
434
|
+
session_id: str,
|
|
435
|
+
summary: CompactedMessage,
|
|
436
|
+
boundary_id: str,
|
|
437
|
+
expected_version: int,
|
|
438
|
+
) -> CompactionLine:
|
|
439
|
+
"""Append a checkpoint summarizing the branch through one message."""
|
|
440
|
+
session = self.read(session_id)
|
|
441
|
+
latest = session.latest_compaction
|
|
442
|
+
version = latest.version if latest is not None else 0
|
|
443
|
+
if version != expected_version:
|
|
444
|
+
raise ValueError("Checkpoint conflict; reload the session")
|
|
445
|
+
if session.message(boundary_id) is None:
|
|
446
|
+
raise ValueError("Checkpoint boundary must identify an existing message")
|
|
447
|
+
if latest is not None:
|
|
448
|
+
previous = session.index(latest.parent)
|
|
449
|
+
position = session.index(boundary_id)
|
|
450
|
+
if previous is not None and position is not None and position <= previous:
|
|
451
|
+
raise ValueError("Checkpoint boundary must advance beyond the previous one")
|
|
452
|
+
line = CompactionLine(
|
|
453
|
+
id=new_uuid7(),
|
|
454
|
+
parent=boundary_id,
|
|
455
|
+
version=version + 1,
|
|
456
|
+
message=(summary,),
|
|
457
|
+
created_at=now(),
|
|
458
|
+
)
|
|
459
|
+
self._append_line(session_id, line)
|
|
460
|
+
return line
|
|
461
|
+
|
|
462
|
+
async def set_title(self, session_id: str, title: str) -> None:
|
|
463
|
+
"""Write one session's display title, replacing any earlier one.
|
|
464
|
+
|
|
465
|
+
The title is metadata, not conversation, so renaming rewrites the session's small
|
|
466
|
+
metadata file and never shows up in the message history. Naming a session that has
|
|
467
|
+
not been used yet creates it: the header that marks a session is written first, so a
|
|
468
|
+
name the user typed is never dropped for want of a conversation.
|
|
469
|
+
"""
|
|
470
|
+
cleaned = title.strip()
|
|
471
|
+
if not cleaned or len(cleaned) > MAX_TITLE:
|
|
472
|
+
raise ValueError(f"Session title must contain between 1 and {MAX_TITLE} characters")
|
|
473
|
+
metadata = self._metadata(session_id)
|
|
474
|
+
if metadata is None:
|
|
475
|
+
created_at = self._write_header(self.read(session_id), session_id, None, None)
|
|
476
|
+
metadata = (
|
|
477
|
+
self._begin_metadata(session_id, created_at) if created_at is not None else self._metadata(session_id)
|
|
478
|
+
)
|
|
479
|
+
if metadata is not None:
|
|
480
|
+
write_session_metadata(self._metadata_file(session_id), metadata.model_copy(update={"title": cleaned}))
|
|
481
|
+
|
|
482
|
+
def fork(self, session_id: str) -> str:
|
|
483
|
+
"""Copy the context one session carries into a new session, and return its id.
|
|
484
|
+
|
|
485
|
+
What is copied is the context, not the file: the newest checkpoint's summary
|
|
486
|
+
and the messages after it — the part a request would carry. A session that has
|
|
487
|
+
been compacted therefore forks into the part of it that still matters, and the
|
|
488
|
+
messages the summary replaced stay behind with the session they belong to. That
|
|
489
|
+
is what makes a fork worth having rather than a copy of the file: a long
|
|
490
|
+
conversation continues from what the model is actually working with.
|
|
491
|
+
|
|
492
|
+
The new session records where it came from and takes the source's title with
|
|
493
|
+
``(fork)`` after it, so a picker shows the two apart. A session holding nothing
|
|
494
|
+
is refused rather than forked empty.
|
|
495
|
+
|
|
496
|
+
This is file work, like :meth:`read`, and not part of the persistence
|
|
497
|
+
extension's async contract: the shell runs it on a thread, so copying a long
|
|
498
|
+
conversation does not hold the event loop.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
session_id: The session whose context is copied.
|
|
502
|
+
|
|
503
|
+
Raises:
|
|
504
|
+
ValueError: There is nothing to copy — the session holds no messages, or
|
|
505
|
+
never reached the store at all, which for a fork of the session the
|
|
506
|
+
reader is already in are the same answer to the same question.
|
|
507
|
+
"""
|
|
508
|
+
source = self.read(session_id)
|
|
509
|
+
checkpoint, tail = source.active() if source.header is not None else (None, ())
|
|
510
|
+
if checkpoint is None and not tail:
|
|
511
|
+
raise ValueError("Nothing to fork yet: this session has no messages")
|
|
512
|
+
new_id = new_uuid7()
|
|
513
|
+
created_at = self._write_header(self.read(new_id), new_id, session_id, None)
|
|
514
|
+
if created_at is not None:
|
|
515
|
+
title = self.session_title(session_id)
|
|
516
|
+
carried = (
|
|
517
|
+
f"{title}{FORK_SUFFIX}" if title is not None and len(f"{title}{FORK_SUFFIX}") <= MAX_TITLE else None
|
|
518
|
+
)
|
|
519
|
+
# A fork carries on from where the session left off, so it uses the model that did,
|
|
520
|
+
# and keeps the choice about compaction that was made for that conversation.
|
|
521
|
+
self._begin_metadata(
|
|
522
|
+
new_id,
|
|
523
|
+
created_at,
|
|
524
|
+
title=carried,
|
|
525
|
+
model=self.session_model(session_id),
|
|
526
|
+
self_compact=self.session_self_compact(session_id),
|
|
527
|
+
permission=self.session_permission(session_id),
|
|
528
|
+
)
|
|
529
|
+
parent: str | None = None
|
|
530
|
+
if checkpoint is not None:
|
|
531
|
+
# The summary is copied as a message of its own. As a checkpoint it would
|
|
532
|
+
# need a boundary message to sit on, and the boundary it was taken at
|
|
533
|
+
# belongs to the conversation being left behind; as a message it reaches
|
|
534
|
+
# the next request exactly as the checkpoint body did.
|
|
535
|
+
parent = self._copy_message(new_id, None, cast("AnyMessage", checkpoint.message[0]), request_id="fork")
|
|
536
|
+
for line in tail:
|
|
537
|
+
parent = self._copy_message(
|
|
538
|
+
new_id,
|
|
539
|
+
parent,
|
|
540
|
+
line.message[0],
|
|
541
|
+
request_id=line.request_id,
|
|
542
|
+
timing=line.timing,
|
|
543
|
+
usage=line.usage,
|
|
544
|
+
metadata=line.metadata,
|
|
545
|
+
tags=line.tags,
|
|
546
|
+
created_at=line.created_at,
|
|
547
|
+
)
|
|
548
|
+
return new_id
|
|
549
|
+
|
|
550
|
+
def _copy_message(
|
|
551
|
+
self,
|
|
552
|
+
session_id: str,
|
|
553
|
+
parent: str | None,
|
|
554
|
+
message: AnyMessage,
|
|
555
|
+
*,
|
|
556
|
+
request_id: str,
|
|
557
|
+
timing: MessageTiming | None = None,
|
|
558
|
+
usage: ModelUsage | None = None,
|
|
559
|
+
metadata: Mapping[str, object] | None = None,
|
|
560
|
+
tags: Mapping[str, object] | None = None,
|
|
561
|
+
created_at: datetime | None = None,
|
|
562
|
+
) -> str:
|
|
563
|
+
"""Append one message copied from another session, and return its id.
|
|
564
|
+
|
|
565
|
+
Everything the line carried travels with it — its request, its timings, the
|
|
566
|
+
usage its reply reported, and the tags that mark a side question — so a forked
|
|
567
|
+
conversation reads, and counts, the way the one it came from did.
|
|
568
|
+
"""
|
|
569
|
+
node_id = new_uuid7()
|
|
570
|
+
self._append_line(
|
|
571
|
+
session_id,
|
|
572
|
+
MessageLine(
|
|
573
|
+
id=node_id,
|
|
574
|
+
parent=parent,
|
|
575
|
+
session_id=session_id,
|
|
576
|
+
request_id=request_id,
|
|
577
|
+
message=(message,),
|
|
578
|
+
timing=timing or MessageTiming.instant(),
|
|
579
|
+
usage=usage,
|
|
580
|
+
metadata=dict(metadata or {}),
|
|
581
|
+
tags=dict(tags or {}),
|
|
582
|
+
created_at=created_at or now(),
|
|
583
|
+
),
|
|
584
|
+
)
|
|
585
|
+
return node_id
|
|
586
|
+
|
|
587
|
+
def session_title(self, session_id: str) -> str | None:
|
|
588
|
+
"""Return one session's stored title, or ``None`` when it is unnamed."""
|
|
589
|
+
metadata = self._metadata(session_id)
|
|
590
|
+
return metadata.title if metadata is not None else None
|
|
591
|
+
|
|
592
|
+
def session_model(self, session_id: str) -> str | None:
|
|
593
|
+
"""Return the model one session last used, or ``None`` when none was recorded."""
|
|
594
|
+
metadata = self._metadata(session_id)
|
|
595
|
+
return metadata.model if metadata is not None else None
|
|
596
|
+
|
|
597
|
+
def set_model(self, session_id: str, model: str) -> bool:
|
|
598
|
+
"""Record the model a session used, writing only when it is not the one already recorded.
|
|
599
|
+
|
|
600
|
+
A session is asked about once per turn and its model changes once in a long while, so
|
|
601
|
+
asking is one small read and the write is the exception. Nothing is written for a session
|
|
602
|
+
with no conversation yet — there is no folder to put the file in, and the first turn
|
|
603
|
+
records it when it ends — and a name that is empty or absurdly long is not a model.
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
Whether the file was written.
|
|
607
|
+
"""
|
|
608
|
+
cleaned = model.strip()
|
|
609
|
+
if not cleaned or len(cleaned) > MAX_MODEL:
|
|
610
|
+
return False
|
|
611
|
+
metadata = self._metadata(session_id)
|
|
612
|
+
if metadata is None or metadata.model == cleaned:
|
|
613
|
+
return False
|
|
614
|
+
return write_session_metadata(self._metadata_file(session_id), metadata.model_copy(update={"model": cleaned}))
|
|
615
|
+
|
|
616
|
+
def session_self_compact(self, session_id: str) -> bool | None:
|
|
617
|
+
"""Return the choice one session made about letting the model request compaction.
|
|
618
|
+
|
|
619
|
+
``None`` is the answer of a session that never chose — every session whose file predates the
|
|
620
|
+
setting, and every session nobody has touched it in — and means it follows the default the
|
|
621
|
+
configuration gives, which the runtime applies; the store knows nothing of the configuration.
|
|
622
|
+
"""
|
|
623
|
+
metadata = self._metadata(session_id)
|
|
624
|
+
return metadata.self_compact if metadata is not None else None
|
|
625
|
+
|
|
626
|
+
def set_self_compact(self, session_id: str, choice: bool | None, *, default: bool = False) -> None:
|
|
627
|
+
"""Record the choice one session made about letting the model request compaction.
|
|
628
|
+
|
|
629
|
+
The choice is metadata, not conversation: it rewrites the session's small metadata file and
|
|
630
|
+
leaves everything else in it — the title, the model, the creation time — as it was. ``None``
|
|
631
|
+
takes the choice back, so the session follows the default again. A choice made before the
|
|
632
|
+
first message is a choice about a session that does not exist yet, so a choice that *differs*
|
|
633
|
+
from the default creates it, the header going in first as it does when one is named; one that
|
|
634
|
+
matches the default, or none, records nothing, because a session that holds no choice already
|
|
635
|
+
behaves that way. A file that holds the value asked for is not rewritten.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
session_id: The session the choice belongs to.
|
|
639
|
+
choice: ``True`` or ``False`` for the session's own choice, ``None`` to follow the default.
|
|
640
|
+
default: What a session with no choice does, to tell a choice that changes something from
|
|
641
|
+
one that does not.
|
|
642
|
+
|
|
643
|
+
Raises:
|
|
644
|
+
OSError: The metadata file could not be replaced. Nothing was saved, so a caller that
|
|
645
|
+
reported success would be reporting a setting that is not on disk.
|
|
646
|
+
"""
|
|
647
|
+
metadata = self._metadata(session_id)
|
|
648
|
+
if metadata is None:
|
|
649
|
+
if choice is None or choice == default:
|
|
650
|
+
return
|
|
651
|
+
created_at = self._write_header(self.read(session_id), session_id, None, None)
|
|
652
|
+
metadata = (
|
|
653
|
+
SessionMetadata(session_id=session_id, created_at=created_at, updated_at=created_at)
|
|
654
|
+
if created_at is not None
|
|
655
|
+
else self._metadata(session_id)
|
|
656
|
+
)
|
|
657
|
+
if metadata is None or metadata.self_compact == choice:
|
|
658
|
+
return
|
|
659
|
+
updated = metadata.model_copy(update={"self_compact": choice})
|
|
660
|
+
if not write_session_metadata(self._metadata_file(session_id), updated):
|
|
661
|
+
raise OSError(f"Could not save the self-compaction setting for session {session_id!r}")
|
|
662
|
+
|
|
663
|
+
def session_permission(self, session_id: str) -> str | None:
|
|
664
|
+
"""Return the permission level one session last ran at, or ``None`` when it never chose.
|
|
665
|
+
|
|
666
|
+
``None`` is the answer of every session whose file predates the setting, and of every session nobody
|
|
667
|
+
changed the level in; a run of one of those starts at the level it was built with.
|
|
668
|
+
"""
|
|
669
|
+
metadata = self._metadata(session_id)
|
|
670
|
+
return metadata.permission if metadata is not None else None
|
|
671
|
+
|
|
672
|
+
def set_permission(self, session_id: str, level: str | None, *, default: str) -> None:
|
|
673
|
+
"""Record the permission level one session last ran at.
|
|
674
|
+
|
|
675
|
+
The level is metadata, not conversation: this rewrites the session's small metadata file and leaves the
|
|
676
|
+
title, the model and the times as they were. A level equal to the default records nothing — and takes back
|
|
677
|
+
a level recorded before, so a session set back to the default really does follow it — because a session
|
|
678
|
+
that holds no level behaves that way. A level recorded before the first message is about a session that
|
|
679
|
+
does not exist yet, so one that differs from the default creates it, the header going in first.
|
|
680
|
+
|
|
681
|
+
Args:
|
|
682
|
+
session_id: The session the level belongs to.
|
|
683
|
+
level: The level's name, or ``None`` to forget the choice.
|
|
684
|
+
default: What a session with no level runs at, to tell a choice that changes something from one
|
|
685
|
+
that does not.
|
|
686
|
+
|
|
687
|
+
Raises:
|
|
688
|
+
OSError: The metadata file could not be replaced, so nothing was saved.
|
|
689
|
+
"""
|
|
690
|
+
stored = level if level != default else None
|
|
691
|
+
metadata = self._metadata(session_id)
|
|
692
|
+
if metadata is None:
|
|
693
|
+
if stored is None:
|
|
694
|
+
return
|
|
695
|
+
created_at = self._write_header(self.read(session_id), session_id, None, None)
|
|
696
|
+
metadata = (
|
|
697
|
+
SessionMetadata(session_id=session_id, created_at=created_at, updated_at=created_at)
|
|
698
|
+
if created_at is not None
|
|
699
|
+
else self._metadata(session_id)
|
|
700
|
+
)
|
|
701
|
+
if metadata is None or metadata.permission == stored:
|
|
702
|
+
return
|
|
703
|
+
updated = metadata.model_copy(update={"permission": stored})
|
|
704
|
+
if not write_session_metadata(self._metadata_file(session_id), updated):
|
|
705
|
+
raise OSError(f"Could not save the permission level for session {session_id!r}")
|
|
706
|
+
|
|
707
|
+
async def list_sessions(self, *, limit: int = 100, offset: int = 0) -> list[SessionInfo]:
|
|
708
|
+
"""Return session metadata by latest activity, newest first.
|
|
709
|
+
|
|
710
|
+
Each row is read from the session's own metadata file, so no conversation is parsed
|
|
711
|
+
and the cost is one small file per session. A session with no file of its own is one
|
|
712
|
+
an older version made: its row is carried over from the old log, written into its
|
|
713
|
+
folder, and read from there from then on. A folder deleted by hand has no row.
|
|
714
|
+
"""
|
|
715
|
+
if limit < 1:
|
|
716
|
+
raise ValueError("limit must be positive")
|
|
717
|
+
if offset < 0:
|
|
718
|
+
raise ValueError("offset cannot be negative")
|
|
719
|
+
# On a thread: the cost is a file per session, which is a few milliseconds for the
|
|
720
|
+
# hundred a person has and a fifth of a second for five thousand, and this is awaited
|
|
721
|
+
# from the interface. The first listing after an upgrade also writes a file per old
|
|
722
|
+
# session, once.
|
|
723
|
+
return await asyncio.to_thread(self._listing, limit, offset)
|
|
724
|
+
|
|
725
|
+
async def session_stats(self, session_ids: Sequence[str]) -> dict[str, SessionStats]:
|
|
726
|
+
"""Return the entry count and the token totals of the given sessions.
|
|
727
|
+
|
|
728
|
+
Answered from ``sessions.json`` beside the session folders while a conversation file is as the
|
|
729
|
+
summary last saw it, so the cost of a listing does not grow with the length of the conversations
|
|
730
|
+
(see :mod:`.ledger`); a changed one is read from where it was last read. A session with no totals
|
|
731
|
+
(a subagent's, or one that cannot be read) is missing from the answer.
|
|
732
|
+
"""
|
|
733
|
+
if self._ledger is None:
|
|
734
|
+
self._ledger = LedgerReader(self.root, self.workspace)
|
|
735
|
+
return await asyncio.to_thread(self._ledger.stats, tuple(session_ids))
|
|
736
|
+
|
|
737
|
+
def _listing(self, limit: int, offset: int) -> list[SessionInfo]:
|
|
738
|
+
"""Read every session's row and return one page of them, newest activity first."""
|
|
739
|
+
rows: list[SessionInfo] = []
|
|
740
|
+
for session_id in self.session_ids():
|
|
741
|
+
metadata = self._metadata(session_id)
|
|
742
|
+
if metadata is not None:
|
|
743
|
+
rows.append(info_of(metadata, self.session_path(session_id)))
|
|
744
|
+
rows.sort(key=lambda info: (info.updated_at, info.session_id), reverse=True)
|
|
745
|
+
return rows[offset : offset + limit]
|
|
746
|
+
|
|
747
|
+
# -- metadata -------------------------------------------------------------
|
|
748
|
+
def _metadata_file(self, session_id: str) -> Path:
|
|
749
|
+
"""Return the file that holds one session's own metadata."""
|
|
750
|
+
return self.session_dir(session_id) / SESSION_METADATA_FILE
|
|
751
|
+
|
|
752
|
+
def _begin_metadata(
|
|
753
|
+
self,
|
|
754
|
+
session_id: str,
|
|
755
|
+
created_at: datetime,
|
|
756
|
+
*,
|
|
757
|
+
title: str | None = None,
|
|
758
|
+
model: str | None = None,
|
|
759
|
+
self_compact: bool | None = None,
|
|
760
|
+
permission: str | None = None,
|
|
761
|
+
) -> SessionMetadata:
|
|
762
|
+
"""Write the metadata of a session that has just been created, and return it."""
|
|
763
|
+
metadata = SessionMetadata(
|
|
764
|
+
session_id=session_id,
|
|
765
|
+
created_at=created_at,
|
|
766
|
+
updated_at=created_at,
|
|
767
|
+
title=title,
|
|
768
|
+
model=model,
|
|
769
|
+
self_compact=self_compact,
|
|
770
|
+
permission=permission,
|
|
771
|
+
)
|
|
772
|
+
write_session_metadata(self._metadata_file(session_id), metadata)
|
|
773
|
+
return metadata
|
|
774
|
+
|
|
775
|
+
def _metadata(self, session_id: str) -> SessionMetadata | None:
|
|
776
|
+
"""Return one session's metadata, from its own file when it has one.
|
|
777
|
+
|
|
778
|
+
The common case is one small read. A session with a conversation but no file — one an
|
|
779
|
+
older version made, or one whose file was lost — is carried over: the old log is asked
|
|
780
|
+
what it knew, the answer is written into the session's folder, and the next look reads
|
|
781
|
+
it from there. A session with no conversation has nothing to describe.
|
|
782
|
+
|
|
783
|
+
Returns:
|
|
784
|
+
The metadata, or ``None`` when the session has no conversation.
|
|
785
|
+
"""
|
|
786
|
+
found = read_session_metadata(self._metadata_file(session_id), session_id)
|
|
787
|
+
if found is not None:
|
|
788
|
+
return found
|
|
789
|
+
header = self._header(session_id)
|
|
790
|
+
if header is None:
|
|
791
|
+
return None
|
|
792
|
+
old = self._legacy_rows().get(session_id)
|
|
793
|
+
metadata = SessionMetadata(
|
|
794
|
+
session_id=session_id,
|
|
795
|
+
created_at=old.created_at if old is not None else header.created_at,
|
|
796
|
+
updated_at=old.updated_at if old is not None else header.created_at,
|
|
797
|
+
title=old.title if old is not None else None,
|
|
798
|
+
)
|
|
799
|
+
write_session_metadata(self._metadata_file(session_id), metadata)
|
|
800
|
+
return metadata
|
|
801
|
+
|
|
802
|
+
def _legacy_rows(self) -> dict[str, SessionInfo]:
|
|
803
|
+
"""Return what the old workspace log says about each session, folded once per change.
|
|
804
|
+
|
|
805
|
+
Raises:
|
|
806
|
+
ValueError: The old log has a damaged line other than its last. That is reported
|
|
807
|
+
rather than skipped: a session carried over from a log that could not be read
|
|
808
|
+
would lose its title for good.
|
|
809
|
+
"""
|
|
810
|
+
path = self.legacy_metadata_path
|
|
811
|
+
try:
|
|
812
|
+
stat = path.stat()
|
|
813
|
+
except OSError:
|
|
814
|
+
self._legacy = (None, {})
|
|
815
|
+
return {}
|
|
816
|
+
stamp = (stat.st_mtime_ns, stat.st_size)
|
|
817
|
+
if self._legacy is None or self._legacy[0] != stamp:
|
|
818
|
+
self._legacy = (stamp, {info.session_id: info for info in read_metadata(path)})
|
|
819
|
+
return self._legacy[1]
|
|
820
|
+
|
|
821
|
+
async def close(self) -> None:
|
|
822
|
+
"""Release nothing; every write opens and closes its own handle."""
|
|
823
|
+
|
|
824
|
+
def prune_empty(self) -> int:
|
|
825
|
+
"""Remove this workspace's session folders that never held a conversation and that nobody has open.
|
|
826
|
+
|
|
827
|
+
What a launch that was killed leaves behind; see :func:`.locks.prune_empty`. Returns how many went.
|
|
828
|
+
"""
|
|
829
|
+
return prune_empty(self.workspace_dir)
|
|
830
|
+
|
|
831
|
+
def _write_header(
|
|
832
|
+
self,
|
|
833
|
+
session: Session,
|
|
834
|
+
session_id: str,
|
|
835
|
+
request_parent: str | None,
|
|
836
|
+
agent_name: str | None,
|
|
837
|
+
) -> datetime | None:
|
|
838
|
+
"""Create the session line, rejecting a parent that changed after creation.
|
|
839
|
+
|
|
840
|
+
A later append that names no parent leaves the one already written alone: the
|
|
841
|
+
header's parent is set when the session is created — by a delegation, or by a
|
|
842
|
+
fork, which records the conversation it was taken from — and the runtime hands
|
|
843
|
+
it back on every append of that run. Naming a *different* parent is the mistake
|
|
844
|
+
this rejects.
|
|
845
|
+
|
|
846
|
+
Returns:
|
|
847
|
+
The new session's creation time, or ``None`` when the file already
|
|
848
|
+
had a header — which is also what tells the caller whether to start
|
|
849
|
+
the session's metadata row.
|
|
850
|
+
"""
|
|
851
|
+
if session.header is not None:
|
|
852
|
+
if request_parent is not None and session.header.parent_session_id != request_parent:
|
|
853
|
+
raise ValueError("Session parent cannot change after creation")
|
|
854
|
+
return None
|
|
855
|
+
created_at = now()
|
|
856
|
+
self._append_line(
|
|
857
|
+
session_id,
|
|
858
|
+
SessionLine(
|
|
859
|
+
version=STORE_VERSION,
|
|
860
|
+
session_id=session_id,
|
|
861
|
+
parent_session_id=request_parent,
|
|
862
|
+
agent_name=agent_name,
|
|
863
|
+
created_at=created_at,
|
|
864
|
+
),
|
|
865
|
+
)
|
|
866
|
+
return created_at
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
class SessionStore(SessionPersistenceMixin, AgentExtension):
|
|
870
|
+
"""The agent plugin: restore and save a conversation through the mixin.
|
|
871
|
+
|
|
872
|
+
``SessionPersistenceMixin`` owns the files. This class adds the framework
|
|
873
|
+
hooks — restore the model context on ``on_state``, append messages and write
|
|
874
|
+
checkpoints from the event stream, and drop per-request bookkeeping on
|
|
875
|
+
success, error, or cancellation.
|
|
876
|
+
|
|
877
|
+
Args:
|
|
878
|
+
root: Store root, grouped by workspace; forwarded to the mixin.
|
|
879
|
+
``None`` uses ``~/.zettcode/sessions``.
|
|
880
|
+
workspace: Project whose sessions this store owns; forwarded to the
|
|
881
|
+
mixin. ``None`` uses the current directory.
|
|
882
|
+
"""
|
|
883
|
+
|
|
884
|
+
def __init__(self, root: str | Path | None = None, workspace: str | Path | None = None) -> None:
|
|
885
|
+
self._requests: WeakKeyDictionary[AgentRunContext, _Request] = WeakKeyDictionary()
|
|
886
|
+
super().__init__(root, workspace)
|
|
887
|
+
|
|
888
|
+
async def on_state(self, context: AgentRunContext) -> None:
|
|
889
|
+
"""Restore the conversation and map every context position to a message id."""
|
|
890
|
+
checkpoint, tail = await self._restore(context)
|
|
891
|
+
ids: list[str | None] = []
|
|
892
|
+
if checkpoint is not None:
|
|
893
|
+
ids.append(checkpoint.parent)
|
|
894
|
+
ids.extend(
|
|
895
|
+
line.id
|
|
896
|
+
for line in tail
|
|
897
|
+
if line.message[0].include_in_messages and not isinstance(line.message[0], SystemMessage)
|
|
898
|
+
)
|
|
899
|
+
self._requests[context] = _Request(
|
|
900
|
+
request_id=context.config.request_id or new_uuid7(),
|
|
901
|
+
checkpoint_version=checkpoint.version if checkpoint is not None else 0,
|
|
902
|
+
context_ids=ids,
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
async def on_event(self, context: AgentRunContext, event: ExtensionEvent) -> None:
|
|
906
|
+
"""Append freshly produced messages and checkpoint completed compactions."""
|
|
907
|
+
if isinstance(event, RunCancelledEvent):
|
|
908
|
+
self._requests.pop(context, None)
|
|
909
|
+
return
|
|
910
|
+
request = self._requests.get(context)
|
|
911
|
+
if request is None:
|
|
912
|
+
return
|
|
913
|
+
match event:
|
|
914
|
+
case MessageAppendedEvent(message=message, timing=timing, usage=usage):
|
|
915
|
+
if is_side(message):
|
|
916
|
+
request.side = True
|
|
917
|
+
# The question says whether the reader watched it and who asked, and every line of the run is
|
|
918
|
+
# tagged with both: a resumed transcript restores the exchanges that were on screen and nothing
|
|
919
|
+
# else. This reads the run's own question because the runtime publishes the input before the step's
|
|
920
|
+
# first message is flushed. That holds for a question; a resumed run (`stream_resume`) sends a
|
|
921
|
+
# resume point instead, so an interrupted `/btw` is stored as an ordinary turn — untagged, in the
|
|
922
|
+
# history, and untouched by any of this. If the order ever changed for a question, the tag would
|
|
923
|
+
# fall back to the defaults and a plugin's question would come back on screen, with nothing here to
|
|
924
|
+
# notice.
|
|
925
|
+
request.watched = is_watched_side(message)
|
|
926
|
+
request.origin = str(message.attributes.get(ORIGIN, READER))
|
|
927
|
+
if request.side:
|
|
928
|
+
# A side question is recorded, never replayed: the same flag
|
|
929
|
+
# the runtime uses for a message that must not re-enter a
|
|
930
|
+
# context. The live transcript is untouched — this is the
|
|
931
|
+
# store's own copy of the message.
|
|
932
|
+
message = replace(message, include_in_messages=False)
|
|
933
|
+
node_id: str | None = None
|
|
934
|
+
if message.persist:
|
|
935
|
+
node_id = await self.append(
|
|
936
|
+
cast(str, context.config.session_id),
|
|
937
|
+
request.request_id,
|
|
938
|
+
message,
|
|
939
|
+
timing,
|
|
940
|
+
parent_session_id=context.state.parent_session_id,
|
|
941
|
+
metadata=context.metadata,
|
|
942
|
+
# A side question's lines are tagged as well as flagged:
|
|
943
|
+
# the tag is what a replay or an export reads, since the
|
|
944
|
+
# flag alone would also match an instruction.
|
|
945
|
+
tags={**context.tags, SIDE: True, ORIGIN: request.origin, WATCHED: request.watched}
|
|
946
|
+
if request.side
|
|
947
|
+
else context.tags,
|
|
948
|
+
usage=usage,
|
|
949
|
+
)
|
|
950
|
+
if message.include_in_messages and not isinstance(message, SystemMessage):
|
|
951
|
+
request.context_ids.append(node_id)
|
|
952
|
+
case CompactionEvent():
|
|
953
|
+
await self._write_checkpoint(context, request, event)
|
|
954
|
+
|
|
955
|
+
async def on_success(self, context: AgentRunContext, result: AssistantMessage) -> None:
|
|
956
|
+
"""Release the bookkeeping; messages were appended as they arrived."""
|
|
957
|
+
self._requests.pop(context, None)
|
|
958
|
+
|
|
959
|
+
async def on_error(self, context: AgentRunContext, error: Exception) -> None:
|
|
960
|
+
"""Release the bookkeeping; the append-only history remains available."""
|
|
961
|
+
self._requests.pop(context, None)
|
|
962
|
+
|
|
963
|
+
async def _restore(self, context: AgentRunContext) -> tuple[CompactionLine | None, tuple[MessageLine, ...]]:
|
|
964
|
+
"""Load the conversation and merge it with the current configuration.
|
|
965
|
+
|
|
966
|
+
Parsing a long JSONL file is file work with no await in it. Running it
|
|
967
|
+
on a worker leaves the loop free to finish the frame the command put up
|
|
968
|
+
before the restore began; the assignment back into the framework's
|
|
969
|
+
context stays here, where the rest of the request lifecycle lives.
|
|
970
|
+
"""
|
|
971
|
+
session = await asyncio.to_thread(self.read, cast(str, context.config.session_id))
|
|
972
|
+
stored_parent = session.parent_session_id
|
|
973
|
+
configured_parent = context.state.parent_session_id
|
|
974
|
+
session_exists = session.header is not None and bool(session.messages)
|
|
975
|
+
if configured_parent is not None and session_exists and stored_parent != configured_parent:
|
|
976
|
+
raise ValueError("Stored session belongs to a different parent session")
|
|
977
|
+
context.state.parent_session_id = stored_parent or configured_parent
|
|
978
|
+
checkpoint, tail = session.active()
|
|
979
|
+
stored: list[AnyMessage] = [checkpoint.message[0]] if checkpoint is not None else []
|
|
980
|
+
# Instructions come from the current configuration; stored context
|
|
981
|
+
# contributes dialogue and checkpoints only, which is what the runtime's
|
|
982
|
+
# own ``include_in_messages`` flag means. Re-adding a persisted
|
|
983
|
+
# instruction (the system prompt, the filesystem note, the tool snippets)
|
|
984
|
+
# would duplicate it at the head of every request, bloating the prompt
|
|
985
|
+
# and breaking the provider's prefix cache from that point on.
|
|
986
|
+
stored.extend(line.message[0] for line in tail if line.message[0].include_in_messages)
|
|
987
|
+
instructions = [message for message in context.state.messages if isinstance(message, SystemMessage)]
|
|
988
|
+
context.replace_messages(
|
|
989
|
+
[*instructions, *self._provider_safe_messages(stored)],
|
|
990
|
+
emit_new=False,
|
|
991
|
+
)
|
|
992
|
+
return checkpoint, tail
|
|
993
|
+
|
|
994
|
+
@staticmethod
|
|
995
|
+
def _provider_safe_messages(messages: Sequence[AnyMessage]) -> list[AnyMessage]:
|
|
996
|
+
"""Exclude interrupted tool batches from restored model context, except the last.
|
|
997
|
+
|
|
998
|
+
A process may have stopped after persisting an assistant message with
|
|
999
|
+
tool calls but before persisting every matching result; providers reject
|
|
1000
|
+
that sequence. A malformed batch in the middle of the history is dropped,
|
|
1001
|
+
so the rest of the restored dialogue still reaches the model. Storage
|
|
1002
|
+
keeps everything.
|
|
1003
|
+
|
|
1004
|
+
The batch at the very end is kept as it is, results that did finish
|
|
1005
|
+
included: it is the request that was interrupted, and the runtime closes
|
|
1006
|
+
it when the next request starts by adding a result for each call that has
|
|
1007
|
+
none. Dropping it would forget the tools that did run and whatever they
|
|
1008
|
+
changed. This follows zett-agent's own persistence extension, which is
|
|
1009
|
+
where the rule is written; a batch there with an orphan result (a result
|
|
1010
|
+
naming no call of its own) is still dropped.
|
|
1011
|
+
"""
|
|
1012
|
+
restored: list[AnyMessage] = []
|
|
1013
|
+
index = 0
|
|
1014
|
+
while index < len(messages):
|
|
1015
|
+
message = messages[index]
|
|
1016
|
+
if isinstance(message, ToolMessage):
|
|
1017
|
+
index += 1 # an orphan result has no meaningful provider context
|
|
1018
|
+
continue
|
|
1019
|
+
if not isinstance(message, AssistantMessage) or not message.tool_calls:
|
|
1020
|
+
restored.append(message)
|
|
1021
|
+
index += 1
|
|
1022
|
+
continue
|
|
1023
|
+
tool_messages: list[ToolMessage] = []
|
|
1024
|
+
cursor = index + 1
|
|
1025
|
+
while cursor < len(messages) and isinstance(current := messages[cursor], ToolMessage):
|
|
1026
|
+
tool_messages.append(current)
|
|
1027
|
+
cursor += 1
|
|
1028
|
+
expected = Counter(call.id for call in message.tool_calls)
|
|
1029
|
+
observed = Counter(result.tool_call_id for result in tool_messages)
|
|
1030
|
+
at_end = cursor == len(messages)
|
|
1031
|
+
if expected == observed or (at_end and not observed - expected):
|
|
1032
|
+
restored.extend((message, *tool_messages))
|
|
1033
|
+
index = cursor
|
|
1034
|
+
return restored
|
|
1035
|
+
|
|
1036
|
+
async def _write_checkpoint(
|
|
1037
|
+
self,
|
|
1038
|
+
context: AgentRunContext,
|
|
1039
|
+
request: _Request,
|
|
1040
|
+
event: CompactionEvent,
|
|
1041
|
+
) -> None:
|
|
1042
|
+
"""Store the summary and remap its context position to the compacted prefix."""
|
|
1043
|
+
if event.compressed_from != 1 or event.compressed_to > len(request.context_ids):
|
|
1044
|
+
raise ValueError("Compaction range does not match the restored context")
|
|
1045
|
+
compacted = [node_id for node_id in request.context_ids[: event.compressed_to] if node_id is not None]
|
|
1046
|
+
if not compacted:
|
|
1047
|
+
raise ValueError("Compaction must represent at least one stored message")
|
|
1048
|
+
boundary_id = compacted[-1]
|
|
1049
|
+
line = await self.checkpoint(
|
|
1050
|
+
cast(str, context.config.session_id),
|
|
1051
|
+
CompactedMessage(content=event.summary),
|
|
1052
|
+
boundary_id,
|
|
1053
|
+
request.checkpoint_version,
|
|
1054
|
+
)
|
|
1055
|
+
request.checkpoint_version = line.version
|
|
1056
|
+
request.context_ids[:] = [boundary_id, *request.context_ids[event.compressed_to :]]
|