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,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
|