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,166 @@
1
+ """What the next request carries: its size, its breakdown, and shrinking it.
2
+
3
+ Counting a request and summarizing one are the same subject seen from two ends — one says
4
+ how full the window is, the other makes room in it — and both are answered from what the
5
+ runtime assembled or, before it has assembled one, from what the store holds.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+
12
+ from zett_agent.agent import AgentRunConfig
13
+ from zett_agent.extensions.events import CompactionEvent
14
+ from zett_agent.messages import AnyMessage, SystemMessage
15
+ from zett_agent.model import ToolDefinition
16
+
17
+ from zettcode.engine.core import build_system_prompt
18
+ from zettcode.engine.extensions.context import ContextReport
19
+ from zettcode.engine.extensions.usage import UsageSnapshot
20
+ from zettcode.engine.storage import Session
21
+
22
+ from .base import AgentBase
23
+
24
+
25
+ class ContextMixin(AgentBase):
26
+ """Report the request the next turn would send, and compact the conversation behind it."""
27
+
28
+ @property
29
+ def usage(self) -> UsageSnapshot:
30
+ """Return the active session's cumulative token counters.
31
+
32
+ The numbers come from the usage extension's in-memory totals, so the
33
+ status line can read them every frame; a session replayed from the store
34
+ is seeded before this is asked.
35
+ """
36
+ return self.runtime.usage.snapshot(self.session_id)
37
+
38
+ async def context_report(self) -> ContextReport | None:
39
+ """Return what the next request carries by source, or None before the first call.
40
+
41
+ The shares come from the tokenizer, which is prepared first: it prefers
42
+ tiktoken and falls back to a character estimate when that vocabulary
43
+ cannot be opened, which the report names so the numbers are not mistaken
44
+ for exact ones. The total comes from the provider when it counted this
45
+ very request — it counts what it bills — and from the tokenizer when the
46
+ request was primed from the store, whose stored figure belongs to a call
47
+ the dialogue may have outgrown.
48
+ """
49
+ tokenizer = self.runtime.tokenizer
50
+ await tokenizer.prepare()
51
+ model = self.runtime.active_model
52
+ # No model configured means no window to measure against. Nothing is reported either way: a request is
53
+ # refused before one is assembled, so the report is None and no share is ever computed from this 0.
54
+ window = 0 if model is None else model.context_window
55
+ counted = self.runtime.usage.snapshot(self.session_id).context_tokens
56
+ # The provider's own count of this conversation is the total: what it
57
+ # billed for the request this process assembled, or — for a session
58
+ # rebuilt from the store, which has no call of its own yet — what it
59
+ # billed for the last call that session made. Locally counting a rebuilt
60
+ # request is not a substitute: on this machine's own session the rebuilt
61
+ # list measured 1.65 M tokens against a provider's 326 k, because a
62
+ # rebuilt list holds messages the live request no longer carries.
63
+ #
64
+ # A count that reaches the window is refused instead: a request cannot
65
+ # carry more than the window it is measured against, so a figure that does
66
+ # is about something else, and a share pinned at a hundred is worse than
67
+ # the measurement.
68
+ reported = counted if 0 < counted < window else None
69
+ return self.runtime.context.report(
70
+ self.session_id,
71
+ window=window,
72
+ tokenizer=tokenizer,
73
+ reported=reported,
74
+ )
75
+
76
+ async def ensure_compactable(self) -> None:
77
+ """Refuse a compaction that would do nothing, before a row is put up for it.
78
+
79
+ The command opens a working row before it starts; checking the
80
+ checkpoint first keeps a refusal from leaving that row behind with no
81
+ work and no text.
82
+
83
+ Raises:
84
+ SessionLocked: Another process has this session open.
85
+ ValueError: The newest checkpoint already covers the branch.
86
+ """
87
+ self.require_session()
88
+ if await self._compacted_at_the_head():
89
+ raise ValueError("Already compacted; send a message before compacting again")
90
+
91
+ async def compact(self) -> CompactionEvent | None:
92
+ """Summarize the conversation now, without adding a turn to it.
93
+
94
+ zett-agent runs one pass over the restored session and dispatches every
95
+ event it emits, so the transcript animates the same ``Compacting`` row an
96
+ automatic compaction shows. Nothing is appended to the conversation and
97
+ no primary model call is made: the only work is the summary itself.
98
+
99
+ Returns:
100
+ The checkpoint that was stored, or ``None`` when there was nothing
101
+ worth replacing.
102
+
103
+ Raises:
104
+ SessionLocked: Another process has this session open, and a checkpoint is a write.
105
+ ValueError: When the newest checkpoint already covers the branch —
106
+ a second pass would summarize nothing but the checkpoint.
107
+ """
108
+ await self.ensure_compactable()
109
+ return await self._compact_pass()
110
+
111
+ async def _compact_pass(self) -> CompactionEvent | None:
112
+ """Run the pass after the caller has checked that there is something to compact."""
113
+ await self.runtime.start()
114
+ return await self.runtime.started.compact(
115
+ config=AgentRunConfig(session_id=self.session_id),
116
+ model=self.runtime.provider,
117
+ )
118
+
119
+ async def _compacted_at_the_head(self) -> bool:
120
+ """Return whether nothing has been appended since the newest checkpoint.
121
+
122
+ Messages and checkpoints both carry ``created_at``, so the last message
123
+ being older than the last checkpoint is what says the branch has not
124
+ moved since it was summarized — no matter which session is active or
125
+ whether the transcript in this process ever showed that compaction.
126
+ Reading the file is file work, so it runs on a worker: it must not hold
127
+ the loop while a frame is waiting to be drawn.
128
+ """
129
+ try:
130
+ session = await asyncio.to_thread(self.runtime.persistence.read, self.session_id)
131
+ except ValueError:
132
+ # A runtime without a store to read: no checkpoint can cover it.
133
+ return False
134
+ latest = session.latest_compaction
135
+ if latest is None or session.head_id is None:
136
+ return False
137
+ head = session.message(session.head_id)
138
+ return head is not None and head.created_at <= latest.created_at
139
+
140
+ def _context_request(self, session: Session) -> tuple[tuple[AnyMessage, ...], tuple[ToolDefinition, ...]]:
141
+ """Return the messages and tools the next turn would send.
142
+
143
+ The request this process assembled is the honest answer; before one has
144
+ run — a session restored into a fresh process — the instructions and the
145
+ active branch stand in for it, which is all that can be known there.
146
+ """
147
+ assembled = self.runtime.context.assembled(self.session_id)
148
+ if assembled is not None:
149
+ messages, tools = assembled
150
+ return tuple(messages), tuple(tools)
151
+ return self._stored_request(session), self.runtime.context.tools
152
+
153
+ def _stored_request(self, session: Session) -> tuple[AnyMessage, ...]:
154
+ """Return the messages a request for ``session`` would carry, from what is stored.
155
+
156
+ This depends on the session it is given and on nothing that changes with the
157
+ active one, so it can be run later — a restore hands it over unrun, and the
158
+ reader may have moved to another session by the time it is wanted.
159
+ """
160
+ instructions = self.runtime.context.instructions or (
161
+ SystemMessage(content=build_system_prompt(self.runtime.config)),
162
+ )
163
+ checkpoint, tail = session.active()
164
+ stored: list[AnyMessage] = [checkpoint.message[0]] if checkpoint is not None else []
165
+ stored.extend(line.message[0] for line in tail if line.message[0].include_in_messages)
166
+ return (*instructions, *stored)
@@ -0,0 +1,73 @@
1
+ """Writing a session out as a page a reader can open."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from zettcode.engine.export import build_trace, render_html, write_export
10
+ from zettcode.engine.storage import Session
11
+
12
+ from .context import ContextMixin
13
+ from .sessions import SessionsMixin
14
+ from .settings import SettingsMixin
15
+
16
+
17
+ class ExportsMixin(SessionsMixin, SettingsMixin, ContextMixin):
18
+ """Export the active session together with the request the next turn would send."""
19
+
20
+ async def export_session(self, path: str | Path | None = None) -> Path:
21
+ """Write the active session, and the context it now carries, to HTML.
22
+
23
+ The conversation is rendered with the same presenters the shell paints,
24
+ and the request the next turn would send is included beside it, so a
25
+ reader sees both the history and what the model is down to.
26
+
27
+ Args:
28
+ path: File to write; ``None`` puts ``zettcode-<id8>.html`` in the
29
+ workspace. A relative path is taken from the working directory.
30
+
31
+ Returns:
32
+ The absolute path that was written.
33
+
34
+ Raises:
35
+ ValueError: When the session has no stored header, so there is
36
+ nothing to export yet.
37
+ """
38
+ session = self.runtime.persistence.read(self.session_id)
39
+ if session.header is None:
40
+ raise ValueError("Nothing to export: this session has not been stored yet")
41
+ report = await self.context_report()
42
+ messages, tools = self._context_request(session)
43
+ trace = build_trace(
44
+ session,
45
+ title=self.session_name,
46
+ subtitle=f"Session {self.session_id} · {self.workspace}",
47
+ meta=self._export_meta(session),
48
+ tags=(self._export_model(), self.effort),
49
+ context_messages=messages,
50
+ tools=tuple(tool.name for tool in tools),
51
+ report=report,
52
+ )
53
+ trace = replace(trace, exported_at=datetime.now().astimezone().strftime("%Y-%m-%d %H:%M"))
54
+ target = path if path is not None else self.workspace / f"zettcode-{self.session_id[:8]}.html"
55
+ return write_export(target, render_html(trace))
56
+
57
+ def _export_model(self) -> str:
58
+ """Return the name a page is tagged with; a machine with no model says so rather than naming one."""
59
+ model = self.runtime.active_model
60
+ return "no model" if model is None else model.shown_name
61
+
62
+ def _export_meta(self, session: Session) -> tuple[tuple[str, str], ...]:
63
+ """Return the label/value pairs the exported page's sidebar carries."""
64
+ model = self.runtime.active_model
65
+ in_use = "none configured" if model is None else f"{model.shown_name} ({model.model})"
66
+ return (
67
+ ("Workspace", str(self.workspace)),
68
+ ("Model", in_use),
69
+ ("Effort", self.effort),
70
+ ("Created", session.created_at.astimezone().strftime("%Y-%m-%d %H:%M")),
71
+ ("Updated", session.updated_at.astimezone().strftime("%Y-%m-%d %H:%M")),
72
+ ("Messages", f"{session.message_count}"),
73
+ )
@@ -0,0 +1,54 @@
1
+ """Answering what the model asked for: a shell approval, or a question for the reader.
2
+
3
+ The agent is suspended inside a tool call while it waits, so the answer is an external
4
+ event routed to the session and the call the request came from.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from zett_agent.agent import AgentRunConfig
10
+ from zett_agent.extensions.external import ExternalEvent
11
+ from zett_agent.extensions.shell_approval import SHELL_APPROVAL_RESPONSE_EVENT_NAME
12
+
13
+ from zettcode.engine.dialogue.ask import ASK_USER_RESPONSE_EVENT_NAME, AskUserQuestion, answer_payload, decline_payload
14
+
15
+ from .base import AgentBase
16
+
17
+
18
+ class InteractionMixin(AgentBase):
19
+ """Send the reader's answer to an approval request or a question back to the running agent."""
20
+
21
+ def respond_approval(self, session_id: str, call_id: str, decision: str, remember: bool) -> None:
22
+ """Answer a pending shell approval for the originating session."""
23
+ self.runtime.started.agent.emit_external_event(
24
+ ExternalEvent(
25
+ name=SHELL_APPROVAL_RESPONSE_EVENT_NAME,
26
+ payload={"tool_call_id": call_id, "decision": decision, "remember": remember},
27
+ ),
28
+ config=AgentRunConfig(session_id=session_id),
29
+ )
30
+
31
+ def answer_ask(self, question: AskUserQuestion, answer: str) -> None:
32
+ """Answer the model's question; the suspended tool call resumes with it.
33
+
34
+ Args:
35
+ question: The question being answered, which carries the session and
36
+ the tool call the runtime routes the reply on.
37
+ answer: What the reader typed, exactly as it should reach the model.
38
+ """
39
+ self._respond_to_ask(question, answer_payload(question, answer))
40
+
41
+ def decline_ask(self, question: AskUserQuestion) -> None:
42
+ """Tell the model the reader cancelled, rather than leaving it waiting.
43
+
44
+ ``ask_user`` has no rejection channel in the runtime, so the tool result
45
+ says the question was declined and why; the model carries on from that.
46
+ """
47
+ self._respond_to_ask(question, decline_payload(question))
48
+
49
+ def _respond_to_ask(self, question: AskUserQuestion, payload: dict[str, object]) -> None:
50
+ """Emit one ``ask_user_response`` for the session the question came from."""
51
+ self.runtime.started.agent.emit_external_event(
52
+ ExternalEvent(name=ASK_USER_RESPONSE_EVENT_NAME, payload=dict(payload)),
53
+ config=AgentRunConfig(session_id=question.session_id),
54
+ )
@@ -0,0 +1,218 @@
1
+ """Sessions: which one is active, switching and forking, and the title that names it.
2
+
3
+ Resuming is two steps on purpose. :meth:`SessionsMixin.load_session` reads and replays a
4
+ stored session into a value and touches nothing the shell is reading, so the shell can run
5
+ it on a worker thread; :meth:`SessionsMixin.install_session` only assigns, so it runs where
6
+ the rest of the shell does.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from zett_agent.messages import AssistantMessage, UserMessage
16
+ from zett_agent.model import ReasoningEffort
17
+
18
+ from zettcode.engine.dialogue.title import summarize_title
19
+ from zettcode.engine.extensions.usage import UsageSnapshot, session_usage
20
+ from zettcode.engine.storage import Session, SessionInfo, SessionStats
21
+ from zettcode.wording import UNTITLED_SESSION
22
+
23
+ from .context import ContextMixin
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class StoredSession:
28
+ """A session read from the store, not yet made active.
29
+
30
+ Attributes:
31
+ session_id: The session that was read.
32
+ session: Its parsed file.
33
+ usage: Totals accumulated from the usage every stored reply recorded.
34
+ title: Its stored display title, if it has one: read here because folding
35
+ the metadata log is the other thing resuming used to do on the loop.
36
+ model: The model the session last used, by the name the model picker shows, if one
37
+ was recorded; read here for the same reason as the title.
38
+ """
39
+
40
+ session_id: str
41
+ session: Session
42
+ usage: UsageSnapshot
43
+ title: str | None
44
+ model: str | None = None
45
+
46
+
47
+ class SessionsMixin(ContextMixin):
48
+ """Switch, resume, fork, list, and name the conversation the next turn belongs to."""
49
+
50
+ @property
51
+ def session_title(self) -> str | None:
52
+ """Return the active session's stored display title, if it has one.
53
+
54
+ Reading the store folds the whole metadata log, so callers that paint
55
+ often should cache the result instead of asking once per frame.
56
+ """
57
+ return self.runtime.persistence.session_title(self.session_id)
58
+
59
+ def session_path(self) -> Path:
60
+ """Return the file this session is stored in, under the store root.
61
+
62
+ The whole conversation, on disk: what ``/context`` shows beside the token
63
+ breakdown, and what a reader opens when they want the raw records rather
64
+ than a rendered transcript.
65
+ """
66
+ return self.runtime.persistence.session_path(self.session_id)
67
+
68
+ @property
69
+ def session_name(self) -> str:
70
+ """Return the name to show for the active session.
71
+
72
+ A session is named after its first reply; until then the store has no
73
+ title, and a status line or list row that shows nothing at all reads
74
+ like a bug rather than like a fresh session.
75
+ """
76
+ return self.session_title or UNTITLED_SESSION
77
+
78
+ def new_session(self) -> str:
79
+ """Start a new conversation."""
80
+ return self.runtime.new_session()
81
+
82
+ def use_session(self, session_id: str) -> None:
83
+ """Switch future turns to a session."""
84
+ self.runtime.use_session(session_id)
85
+
86
+ def fork_session(self) -> str:
87
+ """Copy this conversation's context into a new session, and return its id.
88
+
89
+ What is carried over is what a request would send — the newest checkpoint's
90
+ summary and the messages after it — not the whole stored history: the fork
91
+ starts from the conversation the model is actually working with. The shell
92
+ installs the result the way a resume does, so the reader lands in the fork
93
+ with the same conversation in front of them.
94
+
95
+ Synchronous file work on purpose, so the shell can run it on a thread.
96
+ """
97
+ return self.runtime.persistence.fork(self.session_id)
98
+
99
+ def load_session(self, session_id: str) -> StoredSession:
100
+ """Read a stored session, changing nothing.
101
+
102
+ This is the expensive half of resuming — parsing the file and adding up its usage — and it touches no
103
+ state anyone is reading, so a caller may run it on a worker thread and hand the result to
104
+ :meth:`install_session` on the loop. What a front end shows of the session it builds from
105
+ ``stored.session`` itself.
106
+
107
+ Raises:
108
+ ValueError: The store holds no such session, or its file is corrupt.
109
+ """
110
+ persistence = self.runtime.persistence
111
+ session = persistence.read(session_id)
112
+ if session.header is None:
113
+ raise ValueError(f"Unknown session: {session_id}")
114
+ return StoredSession(
115
+ session_id=session_id,
116
+ session=session,
117
+ usage=session_usage(session.branch()),
118
+ title=persistence.session_title(session_id),
119
+ model=persistence.session_model(session_id),
120
+ )
121
+
122
+ def install_session(self, stored: StoredSession) -> None:
123
+ """Make a loaded session the active one: its totals, its id, its model.
124
+
125
+ The cheap half, and the one that changes state; everything it does is assignment.
126
+ """
127
+ session_id, session = stored.session_id, stored.session
128
+ self.runtime.usage.seed(session_id, stored.usage)
129
+ self.use_session(session_id)
130
+ # Pick up where the session left off: the model it last used, when this configuration
131
+ # still has it. A session that never recorded one, or whose model is gone, keeps whatever
132
+ # is selected now — the shell says so in the second case.
133
+ model = self.runtime.find_model(stored.model) if stored.model else None
134
+ if model is not None and model is not self.runtime.active_model:
135
+ self.runtime.use_model(model)
136
+ # Prime the report with what the next turn would send, not with what the
137
+ # transcript shows: the display keeps every message a compaction folded
138
+ # away, and measuring those claims a request several times the size of the
139
+ # real one — 2.9 M tokens against a context of 290 k, which is a share
140
+ # pinned at a hundred. `_context_request` is the one definition of that
141
+ # request; the environment and tool notes only exist inside a real one,
142
+ # which the report admits.
143
+ #
144
+ # It is handed over as a function, not built here: the request holds every
145
+ # message since the last checkpoint, decoding them is most of what a long
146
+ # resume would cost, and nothing reads the list until `/context` does.
147
+ self.runtime.context.remember(session_id, lambda: self._stored_request(session))
148
+
149
+ def restore_session(self, session_id: str) -> None:
150
+ """Load the stored branch and make it the active one."""
151
+ self.install_session(self.load_session(session_id))
152
+
153
+ async def list_sessions(self, *, limit: int = 20) -> list[SessionInfo]:
154
+ """List persisted session metadata by recent activity."""
155
+ return await self.runtime.persistence.list_sessions(limit=limit)
156
+
157
+ async def session_stats(self, sessions: Sequence[SessionInfo]) -> dict[str, SessionStats]:
158
+ """Return the entry count and token totals of listed sessions, keyed by session id.
159
+
160
+ A session missing from the answer has no totals to show.
161
+ """
162
+ return await self.runtime.persistence.session_stats([session.session_id for session in sessions])
163
+
164
+ async def rename_session(self, title: str) -> str:
165
+ """Store a display title for the active session and return it cleaned.
166
+
167
+ A title the user typed wins over the one the background summarizer would
168
+ have written: ``title_session`` never renames a session that already has
169
+ a title, so naming by hand also stops the model call.
170
+
171
+ Args:
172
+ title: Name to store; surrounding space is trimmed, and a blank or
173
+ over-long name is rejected by the store.
174
+
175
+ Raises:
176
+ SessionLocked: Another process has this session open.
177
+ """
178
+ self.require_session()
179
+ await self.runtime.persistence.set_title(self.session_id, title)
180
+ return title.strip()
181
+
182
+ async def title_session(self, session_id: str) -> str | None:
183
+ """Name a session from its first exchange, unless it already has a title.
184
+
185
+ The call is a background nicety: it reads the stored branch, asks the
186
+ active model for a short title, and appends that title to the session.
187
+
188
+ Args:
189
+ session_id: Session to name.
190
+
191
+ Returns:
192
+ The stored title, or ``None`` when the session was already named, has
193
+ no first exchange yet, or the model replied with nothing usable.
194
+ """
195
+ if self.runtime.persistence.session_title(session_id) is not None:
196
+ return None
197
+ if session_id == self.session_id and self.session_locked:
198
+ return None # a nicety, and not one that is worth writing into a session that is not ours
199
+ session = self.runtime.persistence.read(session_id)
200
+ branch = session.branch()
201
+ question = next(
202
+ (line.message[0].text for line in branch if isinstance(line.message[0], UserMessage)),
203
+ None,
204
+ )
205
+ answer = next(
206
+ (line.message[0].content for line in reversed(branch) if isinstance(line.message[0], AssistantMessage)),
207
+ None,
208
+ )
209
+ if not question or not answer:
210
+ return None
211
+ await self.runtime.start()
212
+ # A level the model takes, so naming the session cannot be what fails on an endpoint that lacks `low`.
213
+ effort = self.runtime.required_model.accepted_effort(ReasoningEffort.LOW)
214
+ title = await summarize_title(self.runtime.provider, question=question, answer=answer, effort=effort)
215
+ if title is None:
216
+ return None
217
+ await self.runtime.persistence.set_title(session_id, title)
218
+ return title