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,156 @@
1
+ """Keep a tool result that is too long to send in a file, and send its start and its end instead.
2
+
3
+ The tools in this repository bound what they return (``engine/tools/output.py``: 50 KB and 2000 lines, with a note
4
+ saying how to go on). Nothing bounds a tool that comes from somewhere else — an MCP server's answer, a subagent's
5
+ report, a plugin's own tool — and one of those can arrive carrying a megabyte: it is paid for in the model's context
6
+ on every later request of the session, and once it is cut nothing in it can be read again.
7
+
8
+ So after a call, and before its result is stored or sent anywhere, a result over the budget is written whole into the
9
+ session's folder beside the shell's kept streams (``tool-output/``), and what replaces it is its start and its end
10
+ around a note naming that file. The model reads the rest with ``read``, by absolute path, which is the same way it
11
+ reads what a long command printed. The file goes away with the session it belongs to, and the reader's project never
12
+ sees it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import re
19
+ from pathlib import Path
20
+
21
+ from zett_agent.agent import AgentRunContext
22
+ from zett_agent.extensions.base import AgentExtension
23
+ from zett_agent.messages import ToolCall, ToolMessage
24
+
25
+ from ..storage import SessionStore
26
+ from ..tools.output import MAX_OUTPUT_BYTES, MAX_OUTPUT_LINES, stream_preview
27
+ from .tool_output import KEPT_AS
28
+
29
+ #: The tool that keeps its own output: it names the streams it kept, and that note is the whole point of its
30
+ #: result, so its preview is never taken apart here.
31
+ OWN_TRUNCATION = "bash"
32
+
33
+ #: What a result may carry and still be sent whole. It is a little over what our own tools keep themselves to, with
34
+ #: room for the note each of them ends with, so a result they bounded is left exactly as they wrote it; anything
35
+ #: larger came from a tool that does not bound itself.
36
+ MAX_RESULT_BYTES = MAX_OUTPUT_BYTES + 4 * 1024
37
+ MAX_RESULT_LINES = MAX_OUTPUT_LINES + 100
38
+
39
+ #: Every character ``str.splitlines()`` treats as a line boundary. A guard that counted only ``\n`` would call a
40
+ #: CR-separated result short, which is how a tool passing on a Windows program's output is written.
41
+ LINE_BREAKS = "\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029"
42
+
43
+ #: What a kept file may be named after, from the tool and the call. A name from the model is not a path: no `/`, no
44
+ #: `..`, and nothing a shell or a filesystem reads as special.
45
+ _UNSAFE = re.compile(r"[^A-Za-z0-9_-]+")
46
+
47
+ #: The longest stem a kept file gets, before the suffix that tells two of them apart.
48
+ MAX_STEM = 80
49
+
50
+
51
+ class LongOutputExtension(AgentExtension):
52
+ """Keep an over-long tool result in the session's folder, and send its start and its end."""
53
+
54
+ def __init__(self, store: SessionStore) -> None:
55
+ """Keep the store whose session folders the output is written into."""
56
+ self._store = store
57
+
58
+ async def after_tool(
59
+ self,
60
+ context: AgentRunContext,
61
+ call: ToolCall,
62
+ result: ToolMessage,
63
+ error: Exception | None,
64
+ ) -> None:
65
+ """Replace an over-long result with a preview of it and a note naming the file that holds it.
66
+
67
+ Both outcomes are handled, since a failed call's message can be long too. Nothing here may fail a tool
68
+ call: whatever goes wrong — no session yet, a folder that cannot be written — the result is left as the
69
+ tool wrote it, and the model still has it.
70
+ """
71
+ if call.name == OWN_TRUNCATION or not isinstance(result.content, str):
72
+ return
73
+ session_id = context.config.session_id
74
+ if not isinstance(session_id, str) or lightly_under_budget(result.content):
75
+ return
76
+ try:
77
+ kept = await asyncio.to_thread(self._keep, result.content, call, session_id)
78
+ except (OSError, ValueError):
79
+ return
80
+ if kept is not None:
81
+ result.content = kept
82
+
83
+ def _keep(self, text: str, call: ToolCall, session_id: str) -> str | None:
84
+ """Write ``text`` whole into the session's folder and return what stands in its place.
85
+
86
+ Returns ``None`` when the result is already small enough: the encode that decides it costs what the text
87
+ costs, so it is paid here, off the event loop, and not twice.
88
+ """
89
+ if not over_budget(text):
90
+ return None
91
+ path = self._write(text, call, session_id)
92
+ marker = f"\n... [middle omitted; the whole of it is at {path} — read that file with the read tool] ...\n"
93
+ preview = stream_preview(path, marker=marker)
94
+ if not preview.truncated:
95
+ # The budget here is a little over the preview's, so this cannot happen while the two constants
96
+ # hold; a file left behind with the text sent whole would be worse than no file at all.
97
+ path.unlink(missing_ok=True)
98
+ return None
99
+ return preview.text
100
+
101
+ def _write(self, text: str, call: ToolCall, session_id: str) -> Path:
102
+ """Write the result to a new file in the session's ``tool-output`` folder, and return its path."""
103
+ kept = self._store.session_dir(session_id) / KEPT_AS
104
+ kept.mkdir(parents=True, exist_ok=True)
105
+ stem = _stem(call)
106
+ suffix = 1
107
+ while True:
108
+ target = kept / (f"{stem}.txt" if suffix == 1 else f"{stem}-{suffix}.txt")
109
+ # The name is claimed by creating it, not by asking whether it is free: two calls of one name can
110
+ # reach the folder at the same time, and `exists()` then a write would let them share a file.
111
+ try:
112
+ # `newline=""` because the preview reads these bytes back: text mode would write `\r\n` on
113
+ # Windows, so the model would be shown a file that says something its `read` does not.
114
+ with target.open("x", encoding="utf-8", newline="") as handle:
115
+ handle.write(text)
116
+ except FileExistsError:
117
+ suffix += 1
118
+ continue
119
+ return target
120
+
121
+
122
+ def over_budget(text: str) -> bool:
123
+ """Return whether a result is longer than a tool may send: either more bytes or more lines than the budget."""
124
+ return len(text.encode("utf-8")) > MAX_RESULT_BYTES or len(text.splitlines()) > MAX_RESULT_LINES
125
+
126
+
127
+ def lightly_under_budget(text: str) -> bool:
128
+ """Return whether ``text`` is certainly within the budget, without encoding it.
129
+
130
+ A character is at most four bytes in UTF-8, so a result of fewer than a quarter of the byte budget in
131
+ characters, with fewer line boundaries than the line budget, cannot be over it. The boundaries are the ones
132
+ ``str.splitlines()`` counts, not just ``\n``: a result written with carriage returns only is as many lines as
133
+ one with newlines, and a guard that missed them would send it whole.
134
+
135
+ This is the cheap answer read before the work goes to a thread: the encode that decides the rest costs what
136
+ the text costs, which is the reason it is off the loop.
137
+ """
138
+ boundaries = sum(text.count(break_) for break_ in LINE_BREAKS)
139
+ return len(text) * 4 <= MAX_RESULT_BYTES and boundaries + 1 <= MAX_RESULT_LINES
140
+
141
+
142
+ def _stem(call: ToolCall) -> str:
143
+ """Return the name a kept file is given: the tool and the call, with anything a path could not hold removed."""
144
+ cleaned = _UNSAFE.sub("-", f"{call.name}-{call.id}").strip("-.")
145
+ return cleaned[:MAX_STEM].strip("-.") or "output"
146
+
147
+
148
+ __all__ = [
149
+ "LINE_BREAKS",
150
+ "MAX_RESULT_BYTES",
151
+ "MAX_RESULT_LINES",
152
+ "OWN_TRUNCATION",
153
+ "LongOutputExtension",
154
+ "lightly_under_budget",
155
+ "over_budget",
156
+ ]
@@ -0,0 +1,304 @@
1
+ """Token accounting for one session, published as custom events.
2
+
3
+ The runtime reports usage per model call; what the shell wants is a running
4
+ total: tokens in and out, how much input came from the provider cache, and how
5
+ fast the model is answering. :class:`UsageExtension` keeps that total per
6
+ session and emits it as one ``session_usage`` custom event after every model
7
+ call, so the status line can mirror it without polling the store.
8
+
9
+ The counter invariants are ``zett-agent``'s: ``input_tokens`` already contains
10
+ cache reads and writes, and ``output_tokens`` already contains reasoning.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable, Mapping, Sequence
16
+ from contextlib import aclosing
17
+ from dataclasses import dataclass, replace
18
+ from time import monotonic
19
+ from typing import TYPE_CHECKING, cast
20
+ from weakref import WeakKeyDictionary
21
+
22
+ from zett_agent.agent import AgentRunContext
23
+ from zett_agent.events import AgentEvent, AgentEventType
24
+ from zett_agent.extensions.base import AgentExtension
25
+ from zett_agent.model import ModelEvent, ModelEventType, ModelRequest, ModelResponse, ModelUsage
26
+
27
+ if TYPE_CHECKING: # pragma: no cover - annotations only
28
+ from collections.abc import AsyncGenerator, AsyncIterator
29
+
30
+ from zett_agent.extensions.base import ModelRequestNext
31
+ from zett_agent.extensions.events import MessageTiming
32
+ from zett_agent.messages import AnyMessage
33
+
34
+ from ..storage import MessageLine
35
+
36
+ #: Custom event name for one cumulative usage update; its payload is
37
+ #: :meth:`UsageSnapshot.to_payload`.
38
+ USAGE_EVENT_NAME = "session_usage"
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class UsageSnapshot:
43
+ """Cumulative token counters and measured generation time for one session.
44
+
45
+ Attributes:
46
+ input_tokens: Complete model input, including cache reads and writes.
47
+ output_tokens: Generated tokens, including reasoning when reported.
48
+ cache_read_tokens: Input tokens the provider served from its cache.
49
+ cache_write_tokens: Input tokens written into the provider cache.
50
+ reasoning_tokens: Reasoning subset of ``output_tokens``.
51
+ seconds: Time from the first token of each call to that call's terminal
52
+ response, summed: the window output was generated in, the rate's
53
+ denominator.
54
+ requests: Model calls counted, zero before the first answer.
55
+ context_tokens: Input tokens the newest call carried, which is how full
56
+ the context is right now; zero before the first answer.
57
+ """
58
+
59
+ input_tokens: int = 0
60
+ output_tokens: int = 0
61
+ cache_read_tokens: int = 0
62
+ cache_write_tokens: int = 0
63
+ reasoning_tokens: int = 0
64
+ seconds: float = 0.0
65
+ requests: int = 0
66
+ context_tokens: int = 0
67
+
68
+ @property
69
+ def total_tokens(self) -> int:
70
+ """Return input plus output without counting cache or reasoning twice."""
71
+ return self.input_tokens + self.output_tokens
72
+
73
+ @property
74
+ def cache_hit_rate(self) -> float | None:
75
+ """Return the cached share of all input, or None before any input."""
76
+ return self.cache_read_tokens / self.input_tokens if self.input_tokens else None
77
+
78
+ @property
79
+ def output_rate(self) -> float | None:
80
+ """Return generated tokens per second of generation time, if it was measured.
81
+
82
+ The numerator is the provider's own count, and the denominator is the time from the first token to the end of
83
+ the response, both taken from the runtime's stream measurements. Counting the tokens that arrived with the
84
+ first chunk against a window that opens at that chunk reads a little high — those tokens were made before the
85
+ window — and that is the error this trades for: a rate that is never understated by a clock the program runs
86
+ itself, and one number for every provider instead of one per protocol.
87
+
88
+ A session that mixed in a call the runtime could not time keeps that call's tokens in the numerator and adds no
89
+ seconds for it, so the figure reads high; it is still the rate over the calls that were measured.
90
+ """
91
+ return self.output_tokens / self.seconds if self.seconds > 0 and self.output_tokens > 0 else None
92
+
93
+ def with_usage(self, usage: ModelUsage, seconds: float, *, context_tokens: int | None = None) -> UsageSnapshot:
94
+ """Return these totals plus one model call and its measured generation time.
95
+
96
+ Args:
97
+ usage: Provider-reported counters for one model call.
98
+ seconds: Time from that call's first token to its terminal response,
99
+ clamped at zero; zero when the call streamed nothing to measure.
100
+ context_tokens: How full the context is after this call; ``None``
101
+ keeps the previous reading, which is all a call that reported no
102
+ input can say.
103
+ """
104
+ return replace(
105
+ self,
106
+ input_tokens=self.input_tokens + usage.input_tokens,
107
+ output_tokens=self.output_tokens + usage.output_tokens,
108
+ cache_read_tokens=self.cache_read_tokens + usage.cache_read_tokens,
109
+ cache_write_tokens=self.cache_write_tokens + usage.cache_write_tokens,
110
+ reasoning_tokens=self.reasoning_tokens + usage.reasoning_tokens,
111
+ seconds=self.seconds + max(0.0, seconds),
112
+ requests=self.requests + 1,
113
+ # A call that reported no input says nothing about the size of the context, so the last reading stands:
114
+ # a zero would hide the share until the next answer.
115
+ context_tokens=(usage.input_tokens or self.context_tokens) if context_tokens is None else context_tokens,
116
+ )
117
+
118
+ def to_payload(self) -> dict[str, int | float]:
119
+ """Return the JSON-serializable form carried by the custom event."""
120
+ return {
121
+ "input_tokens": self.input_tokens,
122
+ "output_tokens": self.output_tokens,
123
+ "cache_read_tokens": self.cache_read_tokens,
124
+ "cache_write_tokens": self.cache_write_tokens,
125
+ "reasoning_tokens": self.reasoning_tokens,
126
+ "seconds": self.seconds,
127
+ "requests": self.requests,
128
+ "context_tokens": self.context_tokens,
129
+ }
130
+
131
+ @classmethod
132
+ def from_payload(cls, payload: Mapping[str, object]) -> UsageSnapshot:
133
+ """Rebuild a snapshot from a custom event payload, ignoring junk."""
134
+
135
+ def count(key: str) -> int:
136
+ value = payload.get(key)
137
+ return int(value) if isinstance(value, (int, float)) else 0
138
+
139
+ def seconds(key: str) -> float:
140
+ value = payload.get(key)
141
+ return float(value) if isinstance(value, (int, float)) else 0.0
142
+
143
+ return cls(
144
+ input_tokens=count("input_tokens"),
145
+ output_tokens=count("output_tokens"),
146
+ cache_read_tokens=count("cache_read_tokens"),
147
+ cache_write_tokens=count("cache_write_tokens"),
148
+ reasoning_tokens=count("reasoning_tokens"),
149
+ seconds=seconds("seconds"),
150
+ requests=count("requests"),
151
+ context_tokens=count("context_tokens"),
152
+ )
153
+
154
+
155
+ def _stored_generation_seconds(timing: MessageTiming, usage: ModelUsage | None) -> float:
156
+ """Return the span between the first and last segment a stored reply recorded.
157
+
158
+ The line's own usage carries the first token's arrival when the runtime measured it, which is what a reply stored
159
+ by this version does: the window is then the call's duration less that wait, the same subtraction the live counter
160
+ makes. A line written before that measurement existed has only the segment marks — reasoning and content arrivals,
161
+ not tool-call arguments and not the provider's first byte — so the span between them stands in; it is shorter than
162
+ the live window when a tool call follows reasoning, and there is no measurement at all when neither segment was
163
+ recorded.
164
+ """
165
+ if usage is not None and usage.ttft_any_ns is not None:
166
+ return max(0.0, (timing.duration_ns - usage.ttft_any_ns) / 1e9)
167
+ starts = [moment for moment in (timing.reasoning_started_at, timing.content_started_at) if moment is not None]
168
+ ends = [moment for moment in (timing.reasoning_completed_at, timing.content_completed_at) if moment is not None]
169
+ if starts and ends:
170
+ return max(0.0, (max(ends) - min(starts)).total_seconds())
171
+ return 0.0
172
+
173
+
174
+ def session_usage(lines: Sequence[MessageLine]) -> UsageSnapshot:
175
+ """Return the totals a stored branch adds up to: what every recorded reply used, oldest first.
176
+
177
+ Needs no message decoded, which is why reading a long session for its totals is cheap. Each line records the
178
+ provider's counters, when the call started and ended, and when its first token arrived, so a replayed branch
179
+ divides by the window the runtime recorded, where a live one measures its own — a gap the width of the entry into
180
+ this program's middleware, not an error.
181
+ """
182
+ usage = UsageSnapshot()
183
+ for line in lines:
184
+ if line.usage is not None:
185
+ usage = usage.with_usage(line.usage, _stored_generation_seconds(line.timing, line.usage))
186
+ return usage
187
+
188
+
189
+ class UsageExtension(AgentExtension):
190
+ """Accumulate per-session token usage and publish it after every model call."""
191
+
192
+ def __init__(
193
+ self,
194
+ *,
195
+ clock: Callable[[], float] = monotonic,
196
+ count_context: Callable[[Sequence[AnyMessage]], int] | None = None,
197
+ ) -> None:
198
+ """Start with no totals and a monotonic clock the tests can replace.
199
+
200
+ Args:
201
+ clock: Time source used to measure a call; it must be monotonic so a
202
+ wall-clock correction cannot skew the rate.
203
+ count_context: Token counter for a conversation, used only when a
204
+ provider omits its usage counters. Without one the previous
205
+ reading stands, which is what a provider that never answered
206
+ leaves behind.
207
+ """
208
+ self.clock = clock
209
+ self.count_context = count_context
210
+ #: Whole-call time by request context, from the call's start to its
211
+ #: terminal response, filled by the model middleware and consumed by
212
+ #: after_model, which takes the first token's wait out of it.
213
+ self._spans: WeakKeyDictionary[AgentRunContext, float] = WeakKeyDictionary()
214
+ #: Per session, the last input a provider reported and how many messages
215
+ #: there were then, so a silent provider can be answered by counting only
216
+ #: what arrived since.
217
+ self._baselines: dict[str, tuple[int, int]] = {}
218
+ #: Per session, how many messages the previous call saw, so a conversation
219
+ #: that did not grow can be recognised as one that was replaced.
220
+ self._lengths: dict[str, int] = {}
221
+ self._totals: dict[str, UsageSnapshot] = {}
222
+
223
+ def snapshot(self, session_id: str) -> UsageSnapshot:
224
+ """Return one session's running totals, zeros when it never answered."""
225
+ return self._totals.get(session_id, UsageSnapshot())
226
+
227
+ def seed(self, session_id: str, snapshot: UsageSnapshot) -> None:
228
+ """Replace one session's totals, as when a stored branch is replayed."""
229
+ self._totals[session_id] = snapshot
230
+
231
+ async def on_model_request(
232
+ self, context: AgentRunContext, request: ModelRequest, call_next: ModelRequestNext
233
+ ) -> AsyncIterator[ModelEvent]:
234
+ """Measure one call from its start to its terminal response.
235
+
236
+ The whole call, and not just the streaming part: the first token's own wait has to be known to be taken out of
237
+ it, and that measurement is the runtime's (``ModelUsage.ttft_any_ns``), not this clock's. What is kept here is
238
+ the span the runtime cannot see — when this program asked and when the answer ended.
239
+ """
240
+ started = self.clock()
241
+ # ``call_next`` widens to AsyncIterator, which aclosing cannot close; the
242
+ # provider stream behind it is an async generator.
243
+ stream = cast("AsyncGenerator[ModelEvent]", call_next(request))
244
+ async with aclosing(stream) as events:
245
+ async for event in events:
246
+ if event.type is ModelEventType.RESPONSE:
247
+ self._spans[context] = max(0.0, self.clock() - started)
248
+ yield event
249
+
250
+ def _context_tokens(self, session_id: str, context: AgentRunContext, usage: ModelUsage) -> int | None:
251
+ """Return how full the context is, measuring it when the provider did not say.
252
+
253
+ A provider that omits usage answers zero, and zero is not a measurement: keeping an earlier reading would leave
254
+ the figure standing still while the conversation grows, so the share would silently stop moving. The last input
255
+ a provider did report is the baseline, and only what was appended since is counted, which is how zett-agent's
256
+ own compaction keeps its threshold honest under a provider that reports nothing. Before any provider has
257
+ answered there is no baseline and the whole conversation is counted.
258
+
259
+ The reply is already in the conversation when this runs, and the prompt it answered does not contain it, so the
260
+ baseline pairs that prompt with that answer. Counting a baseline of the prompt alone would drop one whole reply
261
+ from the figure every time the provider stayed quiet.
262
+ """
263
+ messages = context.state.messages
264
+ length = len(messages)
265
+ seen_before = self._lengths.get(session_id)
266
+ self._lengths[session_id] = length
267
+ if seen_before is not None and length <= seen_before:
268
+ # A running session only grows, so a length that did not means a compaction or a branch switch replaced
269
+ # the conversation: a baseline counted against the old messages no longer describes anything.
270
+ self._baselines.pop(session_id, None)
271
+ if usage.input_tokens > 0:
272
+ self._baselines[session_id] = (usage.input_tokens + usage.output_tokens, length)
273
+ return usage.input_tokens
274
+ if self.count_context is None:
275
+ return None
276
+ baseline = self._baselines.get(session_id)
277
+ if baseline is None:
278
+ return self.count_context(messages)
279
+ reported, seen = baseline
280
+ return reported + self.count_context(messages[seen:])
281
+
282
+ async def after_model(self, context: AgentRunContext, response: ModelResponse) -> None:
283
+ """Add this call's counters and publish the new totals as a custom event."""
284
+ session_id = cast(str, context.config.session_id)
285
+ # The window output was generated in: the whole call less the wait for its first token. A call that streamed
286
+ # nothing has no first token to start from, so it adds no time. Its tokens still count, so a session that mixed
287
+ # such a call in reads high rather than "not measured".
288
+ waited = response.usage.ttft_any_ns
289
+ span = self._spans.pop(context, 0.0)
290
+ seconds = 0.0 if waited is None else span - waited / 1e9
291
+ totals = self.snapshot(session_id).with_usage(
292
+ response.usage,
293
+ seconds,
294
+ context_tokens=self._context_tokens(session_id, context, response.usage),
295
+ )
296
+ self.seed(session_id, totals)
297
+ await context.emit(
298
+ AgentEvent(
299
+ type=AgentEventType.CUSTOM,
300
+ session_id=session_id,
301
+ name=USAGE_EVENT_NAME,
302
+ payload=totals.to_payload(),
303
+ )
304
+ )
@@ -0,0 +1,74 @@
1
+ """Where a session's just-in-time tools live: one workspace folder, then the session, then the repository.
2
+
3
+ The layout is ``<harness_dir>/<workspace>/<session id>/<repository>``. The framework underneath takes a root and
4
+ appends the session id, so the plugin passes it the workspace folder; the command that lists repositories asks this
5
+ module for the same path. An installation from before the workspace level has ``<harness_dir>/<session id>/``; that
6
+ directory is moved under the workspace the first time the session asks for it, so the model keeps its tools.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import os
13
+ import re
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING: # pragma: no cover - annotations only
18
+ from zettcode.config import ZettCodeConfig
19
+
20
+
21
+ def workspace_directory_name(workspace: Path) -> str:
22
+ """Return the folder name one workspace's sessions live under: its directory name and a short path digest.
23
+
24
+ Two checkouts called ``app`` must not share a folder, and a name that is a Windows device name (``CON``) must
25
+ not be created at all; the digest makes the first unambiguous and the second harmless.
26
+ """
27
+ name = re.sub(r"[^A-Za-z0-9_.-]+", "_", workspace.name, flags=re.ASCII).strip("._-") or "workspace"
28
+ digest = hashlib.sha1(str(workspace.resolve()).encode()).hexdigest()[:8]
29
+ return f"{name}-{digest}"
30
+
31
+
32
+ def harness_root(config: ZettCodeConfig) -> Path:
33
+ """Return the root the harness appends a session id to: ``<harness_dir>/<workspace>``."""
34
+ return config.harness_dir / workspace_directory_name(config.workspace)
35
+
36
+
37
+ def move_session(legacy_root: Path, root: Path, session_id: str) -> Path:
38
+ """Return this session's directory under ``root``, moving ``legacy_root``'s copy into it first.
39
+
40
+ A move this cannot make leaves the old directory where it is and returns that path, so nothing is lost and a
41
+ reader still sees the repositories.
42
+ """
43
+ from zett_agent.jit_harness.repo import session_directory_name
44
+
45
+ name = session_directory_name(session_id)
46
+ current = root / name
47
+ legacy = legacy_root / name
48
+ if not legacy.is_dir():
49
+ return current
50
+ try:
51
+ current.parent.mkdir(parents=True, exist_ok=True)
52
+ if not current.exists():
53
+ os.replace(legacy, current)
54
+ return current
55
+ # Both exist: keep what the new folder already has, move over only what it lacks, then drop the empty old one.
56
+ for child in legacy.iterdir():
57
+ target = current / child.name
58
+ if not target.exists():
59
+ try:
60
+ os.replace(child, target)
61
+ except OSError:
62
+ pass
63
+ try:
64
+ legacy.rmdir()
65
+ except OSError:
66
+ pass
67
+ except OSError:
68
+ return legacy
69
+ return current
70
+
71
+
72
+ def session_directory(config: ZettCodeConfig, session_id: str) -> Path:
73
+ """Return this session's directory under the workspace root, moving a pre-workspace one into it first."""
74
+ return move_session(config.harness_dir, harness_root(config), session_id)