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