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