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,68 @@
|
|
|
1
|
+
"""Keep one request's tools to what the model behind it can actually use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from zett_agent.agent import AgentRunContext
|
|
8
|
+
from zett_agent.extensions.base import AgentExtension
|
|
9
|
+
from zett_agent.model import AgentModel, ServerToolDefinition
|
|
10
|
+
|
|
11
|
+
from zettcode.config import ModelConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def web_search_tool(model: ModelConfig) -> ServerToolDefinition:
|
|
15
|
+
"""Return the hosted search tool to declare for ``model``.
|
|
16
|
+
|
|
17
|
+
The type is the model's own ``web_search_name`` or the default for its API. Anthropic's wire schema also
|
|
18
|
+
wants the tool's ``name``, which for a search is always ``web_search`` whatever its dated type.
|
|
19
|
+
"""
|
|
20
|
+
configuration = {"name": "web_search"} if model.anthropic else {}
|
|
21
|
+
return ServerToolDefinition(model.search_name, configuration)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ModelCapabilities(AgentExtension):
|
|
25
|
+
"""Register only the tools the selected model can use.
|
|
26
|
+
|
|
27
|
+
``read`` hands a picture to the provider as an image part, which a text-only model rejects: such a model is
|
|
28
|
+
given ``read_file``, the same reader without pictures, in its place. The tool list is rebuilt for every
|
|
29
|
+
request and ``on_tool`` finishes before any ``on_state``, so swapping it here also keeps prompt guidance from
|
|
30
|
+
describing a tool the model does not have.
|
|
31
|
+
|
|
32
|
+
A model configured with ``web_search`` is also given the hosted search tool of its API. It is declared, not
|
|
33
|
+
emulated: the endpoint runs the search and the model answers from the results.
|
|
34
|
+
|
|
35
|
+
The decision follows the model of the request being prepared, not the one
|
|
36
|
+
selected when the runtime was built, so ``/model`` takes effect on the next
|
|
37
|
+
turn without rebuilding the client.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
#: After the coding bundle, which is what registers the tool being filtered.
|
|
41
|
+
priority = 110
|
|
42
|
+
|
|
43
|
+
def __init__(self, models: Mapping[ModelConfig, AgentModel]) -> None:
|
|
44
|
+
"""Remember the runtime's providers, keyed by the model each one serves.
|
|
45
|
+
|
|
46
|
+
The mapping is the runtime's own and grows as models are selected, so
|
|
47
|
+
this reads it rather than a copy taken at construction.
|
|
48
|
+
"""
|
|
49
|
+
self._models = models
|
|
50
|
+
|
|
51
|
+
def config_of(self, provider: AgentModel | None) -> ModelConfig | None:
|
|
52
|
+
"""Return the configured model a provider serves, or ``None`` for one this runtime did not build."""
|
|
53
|
+
if provider is None:
|
|
54
|
+
return None
|
|
55
|
+
return next((entry for entry, built in self._models.items() if built is provider), None)
|
|
56
|
+
|
|
57
|
+
async def on_tool(self, context: AgentRunContext) -> None:
|
|
58
|
+
"""Swap the reader for a text-only model, and declare the search of a model that has one."""
|
|
59
|
+
model = self.config_of(context.model)
|
|
60
|
+
if model is None:
|
|
61
|
+
return
|
|
62
|
+
# Imported here: the file tools pull in the schema library, which the first frame never needs.
|
|
63
|
+
from ..tools.files import read, read_file
|
|
64
|
+
|
|
65
|
+
if not model.multimodal and context.tools.pop(read.name, None) is not None:
|
|
66
|
+
context.register_tool(read_file)
|
|
67
|
+
if model.web_search:
|
|
68
|
+
context.register_server_tool(web_search_tool(model))
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Compaction a reader can ask for, not only a threshold that fires.
|
|
2
|
+
|
|
3
|
+
``CompactionExtension`` compacts a request that grew past ``max_tokens``; a
|
|
4
|
+
manual pass runs whenever a caller asks, whatever the size. zett-agent routes
|
|
5
|
+
that caller through ``on_compact`` (``Agent.compact`` / ``AgentClient.compact``),
|
|
6
|
+
which is the seam this subclass fills.
|
|
7
|
+
|
|
8
|
+
The other half of this module is how the threshold is *measured*. zett-agent counts the
|
|
9
|
+
context with tiktoken's ``o200k_base`` encoding, and loads that encoding the first time it
|
|
10
|
+
counts — on whatever thread asked, which is the event loop, at the start of the first turn.
|
|
11
|
+
Loading it reads a two-megabyte vocabulary and builds the encoder: a tenth of a second when
|
|
12
|
+
everything is cached and warm, over half a second on a first run after an install, and a
|
|
13
|
+
download when the cache is gone, and for that long nothing is painted and no key is read.
|
|
14
|
+
:class:`CompactionCounter` loads it on a thread of its own, ahead of the first turn, and
|
|
15
|
+
gives the extension a counter that never loads anything itself.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import threading
|
|
21
|
+
from collections import OrderedDict
|
|
22
|
+
from collections.abc import Callable, Sequence
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
from weakref import WeakSet
|
|
25
|
+
|
|
26
|
+
from zett_agent.agent import AgentRunContext
|
|
27
|
+
from zett_agent.extensions.compaction import CompactionExtension
|
|
28
|
+
from zett_agent.messages import ImageContent
|
|
29
|
+
from zett_agent.model import ModelRequest
|
|
30
|
+
from zett_agent.tools.base import tool
|
|
31
|
+
|
|
32
|
+
from zettcode.engine.pipeline import DaemonCall
|
|
33
|
+
|
|
34
|
+
from ..dialogue.side import is_side_run
|
|
35
|
+
from . import context as context_module
|
|
36
|
+
from .context import CHARS_PER_TOKEN, message_text
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
39
|
+
from zett_agent.messages import AnyMessage
|
|
40
|
+
|
|
41
|
+
#: The encoding zett-agent's own counter asks tiktoken for. It is named here because warming a
|
|
42
|
+
#: different one (the ``/context`` report uses ``cl100k_base``) leaves this one to be loaded on
|
|
43
|
+
#: the loop; a test pins that the two agree.
|
|
44
|
+
COMPACTION_ENCODING = "o200k_base"
|
|
45
|
+
|
|
46
|
+
#: Seconds the first turn waits, asynchronously, for the encoding to finish loading before it
|
|
47
|
+
#: goes on with an estimate. The wait costs nothing — the interface keeps painting and reading
|
|
48
|
+
#: keys — and it is bounded because a first run with no network would otherwise wait for a
|
|
49
|
+
#: download that is not coming.
|
|
50
|
+
COUNTER_WAIT = 2.0
|
|
51
|
+
|
|
52
|
+
#: What zett-agent charges for one image, so an estimate agrees with it about pictures.
|
|
53
|
+
_IMAGE_TOKENS = 1_100
|
|
54
|
+
|
|
55
|
+
#: At this many messages a count is kept per message. A context with no usage
|
|
56
|
+
#: baseline is counted whole at every step, and a compaction's ``older`` count
|
|
57
|
+
#: then re-reads that same history; keeping the counts makes the later read a
|
|
58
|
+
#: cache hit. A shorter context stays one zett-agent call, which is both faster
|
|
59
|
+
#: and what its tests and a provider-specific counter expect.
|
|
60
|
+
COUNT_CACHE_FROM = 64
|
|
61
|
+
|
|
62
|
+
#: Most message counts kept. A session can be longer than this; the oldest
|
|
63
|
+
#: counts are evicted one at a time, so a long context keeps a stable working
|
|
64
|
+
#: set instead of clearing the cache in the middle of a pass. The cap counts
|
|
65
|
+
#: messages, not bytes, which is the price of not tokenizing the history twice.
|
|
66
|
+
MAX_COUNTED_MESSAGES = 20_000
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CompactionCounter:
|
|
70
|
+
"""Count a context for compaction without ever loading a vocabulary on the caller's thread.
|
|
71
|
+
|
|
72
|
+
Exact counting is zett-agent's own function, so once the encoding is loaded the threshold is
|
|
73
|
+
decided exactly as it was before. Until then — the first moments of a process — a count is
|
|
74
|
+
an estimate from the text's length, which is close enough to tell a context that is nowhere
|
|
75
|
+
near the threshold from one that has crossed it, and is what makes it safe to call from the
|
|
76
|
+
event loop.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
encoding: The tiktoken encoding zett-agent counts with.
|
|
80
|
+
loader: Loads and returns the raw encoder, injectable for tests; the default is the one
|
|
81
|
+
the ``/context`` report uses, which points tiktoken's cache at this program's own
|
|
82
|
+
directory so a purge of the system's temp folder is not a download.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
encoding: str = COMPACTION_ENCODING,
|
|
88
|
+
*,
|
|
89
|
+
loader: Callable[[str], Callable[[str], Sequence[int]]] | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Hold the encoding to load; nothing runs until :meth:`start`."""
|
|
92
|
+
self.encoding = encoding
|
|
93
|
+
# Looked up now, through the module, so that anything that replaces the default loader — a test
|
|
94
|
+
# that must not reach the network — replaces it here as it does for the report's counter.
|
|
95
|
+
load = loader or context_module._tiktoken_encoder
|
|
96
|
+
self._call: DaemonCall[Callable[[str], Sequence[int]]] = DaemonCall(lambda: load(encoding))
|
|
97
|
+
self._ready = False
|
|
98
|
+
#: Exact counts by message identity, for a long context counted more than
|
|
99
|
+
#: once by one compaction pass. The message is kept beside its count so a
|
|
100
|
+
#: reused id cannot answer for a different message; the least recently
|
|
101
|
+
#: used entry is evicted once the cap is reached.
|
|
102
|
+
self._counts: OrderedDict[int, tuple[AnyMessage, int]] = OrderedDict()
|
|
103
|
+
self._counts_lock = threading.RLock()
|
|
104
|
+
|
|
105
|
+
def start(self) -> None:
|
|
106
|
+
"""Begin loading on a daemon thread and return at once. Needs a running event loop."""
|
|
107
|
+
self._call.start()
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def ready(self) -> bool:
|
|
111
|
+
"""Return whether the encoding is loaded, adopting an answer that arrived since the last look."""
|
|
112
|
+
if not self._ready and self._call.started and self._call.peek() is not None:
|
|
113
|
+
self._ready = True
|
|
114
|
+
return self._ready
|
|
115
|
+
|
|
116
|
+
async def wait(self, timeout: float = COUNTER_WAIT) -> bool:
|
|
117
|
+
"""Wait up to ``timeout`` seconds for the encoding, without holding up the event loop.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Whether it is loaded now. ``False`` is not an error: the counter estimates until it is.
|
|
121
|
+
"""
|
|
122
|
+
if self._call.started:
|
|
123
|
+
await self._call.wait(timeout)
|
|
124
|
+
return self.ready
|
|
125
|
+
|
|
126
|
+
def count(self, messages: Sequence[AnyMessage]) -> int:
|
|
127
|
+
"""Return the tokens in ``messages``: zett-agent's exact count once loaded, an estimate before.
|
|
128
|
+
|
|
129
|
+
A long context is counted message by message and the exact figures are
|
|
130
|
+
kept. The whole-history count repeats when there is no usage baseline:
|
|
131
|
+
the first step after a restore, every step of a provider that reports
|
|
132
|
+
no usage, and the first step after a compaction, which drops the
|
|
133
|
+
baseline. A compaction's ``older`` count then re-reads that history.
|
|
134
|
+
``_select_compaction_window``'s slices repeat too. An on-demand
|
|
135
|
+
``/compact`` counts its ``older`` list once, so its own benefit is a
|
|
136
|
+
later pass, and then only for what the summary did not replace. Short
|
|
137
|
+
contexts still go through zett-agent's own batch call, which is what a
|
|
138
|
+
provider-specific counter and the tests expect.
|
|
139
|
+
"""
|
|
140
|
+
if not self.ready:
|
|
141
|
+
return sum(self._estimate(message) for message in messages)
|
|
142
|
+
if len(messages) < COUNT_CACHE_FROM:
|
|
143
|
+
return CompactionExtension._count_tokens(messages)
|
|
144
|
+
total = 0
|
|
145
|
+
missing: list[AnyMessage] = []
|
|
146
|
+
with self._counts_lock:
|
|
147
|
+
for message in messages:
|
|
148
|
+
key = id(message)
|
|
149
|
+
cached = self._counts.get(key)
|
|
150
|
+
if cached is not None and cached[0] is message:
|
|
151
|
+
total += cached[1]
|
|
152
|
+
self._counts.move_to_end(key)
|
|
153
|
+
else:
|
|
154
|
+
missing.append(message)
|
|
155
|
+
counted: list[tuple[AnyMessage, int]] = []
|
|
156
|
+
for message in missing:
|
|
157
|
+
tokens = CompactionExtension._count_tokens([message])
|
|
158
|
+
total += tokens
|
|
159
|
+
counted.append((message, tokens))
|
|
160
|
+
with self._counts_lock:
|
|
161
|
+
for message, tokens in counted:
|
|
162
|
+
key = id(message)
|
|
163
|
+
self._counts[key] = (message, tokens)
|
|
164
|
+
self._counts.move_to_end(key)
|
|
165
|
+
if len(self._counts) > MAX_COUNTED_MESSAGES:
|
|
166
|
+
self._counts.popitem(last=False)
|
|
167
|
+
return total
|
|
168
|
+
|
|
169
|
+
def count_text(self, text: str) -> int:
|
|
170
|
+
"""Return the tokens in one piece of text, estimating from its length until the encoding loads.
|
|
171
|
+
|
|
172
|
+
Sold to the usage counter, which needs a few tokens of a streamed chunk rather than a whole context. Safe to
|
|
173
|
+
call from the event loop: once the encoding is in this is one call into C, and before that it is arithmetic.
|
|
174
|
+
"""
|
|
175
|
+
if not text:
|
|
176
|
+
return 0
|
|
177
|
+
encode = self._call.peek() if self.ready else None
|
|
178
|
+
if encode is not None:
|
|
179
|
+
return len(encode(text))
|
|
180
|
+
return max(1, round(len(text) / CHARS_PER_TOKEN))
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _estimate(message: AnyMessage) -> int:
|
|
184
|
+
"""Estimate one message from the length of its text, and a fixed charge per picture."""
|
|
185
|
+
content: Any = getattr(message, "content", None)
|
|
186
|
+
pictures = sum(isinstance(part, ImageContent) for part in content) if isinstance(content, Sequence) else 0
|
|
187
|
+
text = message_text(message)
|
|
188
|
+
return max(1, round(len(text) / CHARS_PER_TOKEN)) + pictures * _IMAGE_TOKENS
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class OnDemandCompaction(CompactionExtension):
|
|
192
|
+
"""A compaction whose manual pass keeps the last turn instead of a quarter window.
|
|
193
|
+
|
|
194
|
+
Automatic compaction is untouched: ``before_model`` calls ``compact`` with
|
|
195
|
+
the configured ``keep_recent_tokens``, so the working tail it leaves behind
|
|
196
|
+
still follows the model's window. ``on_compact`` is only reached by
|
|
197
|
+
``Agent.compact`` — the call ``/compact`` makes — and a reader who asks wants
|
|
198
|
+
the context small, not merely smaller, so that pass keeps one turn verbatim
|
|
199
|
+
and summarizes everything before it.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, *args: Any, self_compact: Callable[[str], bool] | None = None, **kwargs: Any) -> None:
|
|
203
|
+
"""Use the released compaction policy, optionally offering a tool for enabled sessions."""
|
|
204
|
+
super().__init__(*args, **kwargs)
|
|
205
|
+
self.self_compact = self_compact or (lambda session_id: False)
|
|
206
|
+
self._requested: WeakSet[AgentRunContext] = WeakSet()
|
|
207
|
+
|
|
208
|
+
async def on_tool(self, context: AgentRunContext) -> None:
|
|
209
|
+
"""Offer the tool only to a session that opted in, never to a side question."""
|
|
210
|
+
if is_side_run(context) or not self.self_compact(context.session_id or ""):
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
@tool(name="compact_context")
|
|
214
|
+
async def compact_context() -> str:
|
|
215
|
+
"""Self-compaction: compact your own context by replacing your earlier conversation with a summary you keep
|
|
216
|
+
working from. The user enabled it so that you, not only the size limit, decide when the context is worth
|
|
217
|
+
shrinking. It runs before your next model call and always keeps the current user turn, with its tool calls
|
|
218
|
+
and results, exactly as it is; nothing happens if there are no older turns or the summary would not be
|
|
219
|
+
smaller.
|
|
220
|
+
|
|
221
|
+
Guidelines:
|
|
222
|
+
- Call it when earlier work is settled and its detail no longer needs to stay in context, for
|
|
223
|
+
example after a finished subtask, or before a long unrelated one.
|
|
224
|
+
- Do not call it repeatedly: one request is enough, and the current turn cannot be compacted.
|
|
225
|
+
- Do not call it to finish a turn. Continue the task after requesting it; this is not an answer.
|
|
226
|
+
"""
|
|
227
|
+
if not self.self_compact(context.session_id or ""):
|
|
228
|
+
return "Self-compaction is disabled for this session."
|
|
229
|
+
self._requested.add(context)
|
|
230
|
+
return "Compaction requested for the next model call; the current turn will be kept intact."
|
|
231
|
+
|
|
232
|
+
context.register_tool(compact_context)
|
|
233
|
+
|
|
234
|
+
async def before_model(self, context: AgentRunContext, request: ModelRequest) -> None:
|
|
235
|
+
"""Leave a side question's context alone.
|
|
236
|
+
|
|
237
|
+
Compacting there would summarize a branch the question is not part of,
|
|
238
|
+
and store a checkpoint whose boundary ignores that the exchange is a side
|
|
239
|
+
one. Automatic compaction waits for the next ordinary turn instead.
|
|
240
|
+
"""
|
|
241
|
+
requested = context in self._requested
|
|
242
|
+
self._requested.discard(context)
|
|
243
|
+
if is_side_run(context):
|
|
244
|
+
return
|
|
245
|
+
if requested and self.self_compact(context.session_id or ""):
|
|
246
|
+
# Wait until every tool result has joined the turn. Mutating context inside a tool
|
|
247
|
+
# would race parallel siblings and could separate a call from its result.
|
|
248
|
+
await self.on_compact(context)
|
|
249
|
+
else:
|
|
250
|
+
await super().before_model(context, request)
|
|
251
|
+
|
|
252
|
+
async def on_compact(self, context: AgentRunContext) -> None:
|
|
253
|
+
"""Compact now, keeping only the turn the reader is still in."""
|
|
254
|
+
configured = self.keep_recent_tokens
|
|
255
|
+
self.keep_recent_tokens = 1
|
|
256
|
+
try:
|
|
257
|
+
await super().on_compact(context)
|
|
258
|
+
finally:
|
|
259
|
+
self.keep_recent_tokens = configured
|