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.
Files changed (90) hide show
  1. zettcode/_compat.py +35 -0
  2. zettcode/budget.py +24 -0
  3. zettcode/config.py +1088 -0
  4. zettcode/config_edit.py +457 -0
  5. zettcode/engine/__init__.py +39 -0
  6. zettcode/engine/agent/__init__.py +81 -0
  7. zettcode/engine/agent/base.py +60 -0
  8. zettcode/engine/agent/context.py +166 -0
  9. zettcode/engine/agent/exports.py +73 -0
  10. zettcode/engine/agent/interaction.py +54 -0
  11. zettcode/engine/agent/sessions.py +218 -0
  12. zettcode/engine/agent/settings.py +269 -0
  13. zettcode/engine/agent/turns.py +308 -0
  14. zettcode/engine/core.py +819 -0
  15. zettcode/engine/dialogue/__init__.py +6 -0
  16. zettcode/engine/dialogue/ask.py +113 -0
  17. zettcode/engine/dialogue/side.py +197 -0
  18. zettcode/engine/dialogue/title.py +80 -0
  19. zettcode/engine/export/__init__.py +421 -0
  20. zettcode/engine/export/trace/script.js +18 -0
  21. zettcode/engine/export/trace/style.css +136 -0
  22. zettcode/engine/export/trace/template.html +31 -0
  23. zettcode/engine/extensions/__init__.py +8 -0
  24. zettcode/engine/extensions/capabilities.py +68 -0
  25. zettcode/engine/extensions/compaction.py +259 -0
  26. zettcode/engine/extensions/context.py +521 -0
  27. zettcode/engine/extensions/files.py +101 -0
  28. zettcode/engine/extensions/guidance.py +17 -0
  29. zettcode/engine/extensions/harness.py +208 -0
  30. zettcode/engine/extensions/harness_switch.py +18 -0
  31. zettcode/engine/extensions/mcp_notice.py +49 -0
  32. zettcode/engine/extensions/relay.py +93 -0
  33. zettcode/engine/extensions/subagents.py +391 -0
  34. zettcode/engine/extensions/tool_output.py +153 -0
  35. zettcode/engine/extensions/truncation.py +156 -0
  36. zettcode/engine/extensions/usage.py +304 -0
  37. zettcode/engine/harness_paths.py +74 -0
  38. zettcode/engine/permission/__init__.py +197 -0
  39. zettcode/engine/permission/approval.py +66 -0
  40. zettcode/engine/permission/readonly.py +435 -0
  41. zettcode/engine/permission/sandbox.py +140 -0
  42. zettcode/engine/pipeline.py +191 -0
  43. zettcode/engine/plugins/__init__.py +72 -0
  44. zettcode/engine/plugins/commands.py +152 -0
  45. zettcode/engine/plugins/context.py +251 -0
  46. zettcode/engine/plugins/extension.py +183 -0
  47. zettcode/engine/plugins/groups.py +74 -0
  48. zettcode/engine/plugins/loader.py +367 -0
  49. zettcode/engine/plugins/mixins.py +156 -0
  50. zettcode/engine/plugins/plugin.py +190 -0
  51. zettcode/engine/plugins/services.py +68 -0
  52. zettcode/engine/plugins/settings.py +117 -0
  53. zettcode/engine/plugins/state.py +64 -0
  54. zettcode/engine/plugins/switches.py +553 -0
  55. zettcode/engine/py.typed +0 -0
  56. zettcode/engine/server_tools.py +179 -0
  57. zettcode/engine/storage/__init__.py +51 -0
  58. zettcode/engine/storage/layout.py +161 -0
  59. zettcode/engine/storage/ledger.py +659 -0
  60. zettcode/engine/storage/locks.py +182 -0
  61. zettcode/engine/storage/metadata.py +278 -0
  62. zettcode/engine/storage/records.py +373 -0
  63. zettcode/engine/storage/store.py +1056 -0
  64. zettcode/engine/tools/__init__.py +7 -0
  65. zettcode/engine/tools/boundary.py +56 -0
  66. zettcode/engine/tools/files.py +262 -0
  67. zettcode/engine/tools/images.py +32 -0
  68. zettcode/engine/tools/output.py +81 -0
  69. zettcode/engine/tools/shell.py +181 -0
  70. zettcode/engine/tools/workspace.py +271 -0
  71. zettcode/error_text.py +217 -0
  72. zettcode/filelock.py +125 -0
  73. zettcode/logging_setup.py +196 -0
  74. zettcode/mention.py +124 -0
  75. zettcode/modes.py +18 -0
  76. zettcode/paths.py +113 -0
  77. zettcode/plugin_tui.py +133 -0
  78. zettcode/processes.py +126 -0
  79. zettcode/prompt.py +94 -0
  80. zettcode/providers.py +215 -0
  81. zettcode/py.typed +0 -0
  82. zettcode/registry.py +63 -0
  83. zettcode/relay.py +267 -0
  84. zettcode/statusline_parts.py +84 -0
  85. zettcode/version.py +11 -0
  86. zettcode/wire.py +30 -0
  87. zettcode/wording.py +11 -0
  88. zettcode_engine-0.3.4.dist-info/METADATA +23 -0
  89. zettcode_engine-0.3.4.dist-info/RECORD +90 -0
  90. 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 :]]