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,179 @@
1
+ """What a provider-run tool left behind: the rows for a hosted search, live and replayed.
2
+
3
+ A hosted tool is not called by the agent: the endpoint runs it, and the framework reports the call and its result as
4
+ events. Those events are not messages, so the store never sees them; but the provider's own blocks are kept as the
5
+ answer's opaque ``replay_blocks``, which the store writes and reads back. This module is the one place that turns
6
+ either of those into the words of a row, so a resumed session shows what the live one showed — except a hosted call
7
+ the provider closed without a result: the runtime strips that call's block so the next request is accepted, so the row
8
+ that explains it exists only in the live transcript.
9
+
10
+ The result of a search is a list of pages, each with a title, a URL and an opaque encrypted payload that must never be
11
+ put in front of the reader or sent back in a prompt as text: only the title and the URL are shown here.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Iterable, Mapping
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ from zett_agent.model import NOT_REPORTED_STATUS
21
+
22
+ #: The most pages one row lists. A row is a note that a search happened, not the search's own page.
23
+ MAX_RESULTS = 5
24
+
25
+ #: Result blocks whose payload is the provider's own and is never shown as text.
26
+ _SEARCH_RESULT_TYPES = frozenset({"web_search_result", "search_result"})
27
+
28
+ #: Block types that record a hosted call rather than text.
29
+ _CALL_TYPES = frozenset({"server_tool_use"})
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class ServerToolRun:
34
+ """One hosted tool call as the transcript needs it.
35
+
36
+ Attributes:
37
+ call_id: The provider's identifier for the call.
38
+ name: The tool's name, such as ``web_search``.
39
+ arguments: What it was asked, when the provider said; ``{"query": ...}`` for a search.
40
+ output: What it returned, as the provider reported it.
41
+ error: The provider's error code when the tool failed.
42
+ """
43
+
44
+ call_id: str
45
+ name: str
46
+ arguments: Mapping[str, Any]
47
+ output: Any = None
48
+ error: str | None = None
49
+
50
+
51
+ def _results_in(output: Any) -> list[Mapping[str, Any]]:
52
+ """Return the page blocks of a search result, wherever the provider put them.
53
+
54
+ The adapter hands a hosted tool's output over as the block's own ``content``: a list of pages for a search, with
55
+ the provider's encrypted payload beside each title and URL.
56
+ """
57
+ match output:
58
+ case bool() | str() | int() | float() | None:
59
+ return []
60
+ case Mapping():
61
+ content = output.get("content")
62
+ if isinstance(content, Iterable) and not isinstance(content, str):
63
+ return [item for item in content if isinstance(item, Mapping)]
64
+ if output.get("type") in _SEARCH_RESULT_TYPES:
65
+ return [output]
66
+ return []
67
+ case Iterable():
68
+ return [item for item in output if isinstance(item, Mapping)]
69
+ case _:
70
+ return []
71
+
72
+
73
+ def describe_search(output: Any) -> str:
74
+ """Return a search result as the few lines a row shows: how many pages, and which.
75
+
76
+ Only a page's title and URL are shown; the encrypted payload the provider returns beside them is its own business
77
+ and is neither printed nor kept.
78
+ """
79
+ pages = _results_in(output)
80
+ if not pages:
81
+ return "No pages"
82
+ lines = [f"{len(pages)} page{'s' if len(pages) != 1 else ''}"]
83
+ for page in pages[:MAX_RESULTS]:
84
+ title = page.get("title")
85
+ url = page.get("url")
86
+ named = title.strip() if isinstance(title, str) and title.strip() else ""
87
+ address = url.strip() if isinstance(url, str) and url.strip() else ""
88
+ lines.append(f"- {named} \u2014 {address}" if named and address else f"- {named or address}")
89
+ if len(pages) > MAX_RESULTS:
90
+ lines.append(f"\u2026 and {len(pages) - MAX_RESULTS} more")
91
+ return "\n".join(lines)
92
+
93
+
94
+ def not_reported(output: Any) -> bool:
95
+ """Return whether a hosted call's output is the adapter's marker that no result was reported.
96
+
97
+ ``zett-agent`` closes a call the provider left open with ``output={"status": NOT_REPORTED_STATUS}``. The status is
98
+ the adapter's own, never a provider result shape, so that one key is read as the marker here and in the event the
99
+ live row is closed with.
100
+ """
101
+ return isinstance(output, Mapping) and output.get("status") == NOT_REPORTED_STATUS
102
+
103
+
104
+ def describe_output(run: ServerToolRun) -> str:
105
+ """Return what one hosted tool said, in the words of its own kind.
106
+
107
+ A call the provider left open is closed by the runtime with ``output={"status": "not_reported"}``: the call
108
+ ended, but nothing about what it found was reported, so the row says that rather than claiming a result (or, as it
109
+ once did, showing the word ``Completed`` for a search that never answered).
110
+ """
111
+ if run.error:
112
+ return run.error
113
+ if not_reported(run.output):
114
+ return "The provider did not report what this call returned"
115
+ if run.name == "web_search" or _results_in(run.output):
116
+ return describe_search(run.output)
117
+ # Another provider's hosted tool: its payload is already text or a plain structure.
118
+ return run.output if isinstance(run.output, str) else ""
119
+
120
+
121
+ def runs_in_blocks(blocks: Iterable[Mapping[str, Any]]) -> list[ServerToolRun]:
122
+ """Return the hosted tool calls one answer's ``replay_blocks`` record, results paired with their calls.
123
+
124
+ A block that is not a hosted call is skipped: a thinking block, or the answer's text. A call whose result is not
125
+ in the same answer keeps an empty output, which reads as a search that said nothing.
126
+ """
127
+ calls: dict[str, ServerToolRun] = {}
128
+ order: list[str] = []
129
+ for block in blocks:
130
+ match block.get("type"):
131
+ case "server_tool_use":
132
+ call_id = block.get("id")
133
+ name = block.get("name")
134
+ if not isinstance(call_id, str) or not isinstance(name, str):
135
+ continue
136
+ call_input = block.get("input")
137
+ arguments = dict(call_input) if isinstance(call_input, Mapping) else {}
138
+ calls[call_id] = ServerToolRun(call_id, name, arguments)
139
+ order.append(call_id)
140
+ case "web_search_tool_result" | "server_tool_result" | "web_fetch_tool_result":
141
+ found = block.get("tool_use_id") or block.get("call_id")
142
+ if not isinstance(found, str) or found not in calls:
143
+ continue
144
+ error = block.get("error_code")
145
+ calls[found] = ServerToolRun(
146
+ found,
147
+ calls[found].name,
148
+ calls[found].arguments,
149
+ output=block,
150
+ error=str(error) if isinstance(error, str) and error else None,
151
+ )
152
+ return [calls[call_id] for call_id in order]
153
+
154
+
155
+ def answer_after_tools(blocks: Iterable[Mapping[str, Any]]) -> bool:
156
+ """Return whether the answer's text came after its hosted tool, as the provider streamed them.
157
+
158
+ Anthropic runs a hosted search before it writes the answer, so the row belongs above it; a provider that writes
159
+ first and searches after (DeepSeek does, when it announces the search) has it below. The blocks record which.
160
+ """
161
+ first_tool: int | None = None
162
+ for index, block in enumerate(blocks):
163
+ kind = block.get("type")
164
+ if first_tool is None and kind in _CALL_TYPES:
165
+ first_tool = index
166
+ elif kind == "text" and isinstance(block.get("text"), str) and block["text"].strip():
167
+ return first_tool is not None
168
+ return False
169
+
170
+
171
+ __all__ = [
172
+ "MAX_RESULTS",
173
+ "ServerToolRun",
174
+ "answer_after_tools",
175
+ "describe_output",
176
+ "describe_search",
177
+ "not_reported",
178
+ "runs_in_blocks",
179
+ ]
@@ -0,0 +1,51 @@
1
+ """Session storage: the conversation log, its metadata, and the agent plugin.
2
+
3
+ One folder per workspace, one folder per session, and everything about a session in its own::
4
+
5
+ ~/.zettcode/sessions/<workspace path, levels joined with ->/
6
+ ├── workspace.txt <- the path the folder belongs to (see :mod:`layout`)
7
+ ├── metadata.jsonl <- the old index; read for sessions that predate their own file
8
+ ├── usage.json <- what ``/usage`` has counted so far, so it reads only what is new (see :mod:`ledger`)
9
+ ├── sessions.json <- each session's entry count and token totals, so ``/resume`` reads no conversation
10
+ └── <session-id>/
11
+ ├── data.jsonl <- the append-only message tree
12
+ ├── metadata.json <- title and times: written on creation and rename only
13
+ ├── session.lock <- held by the process that has the session open (see :mod:`locks`)
14
+ └── tool-output/ <- long output any tool produced, kept for the model to read
15
+
16
+ :mod:`records` owns the conversation's record types and the parsed read model,
17
+ :mod:`metadata` owns the per-session file and the old log, and :mod:`store` puts them
18
+ behind one API: the persistence mixin and the :class:`SessionStore` plugin. :mod:`layout` names the
19
+ workspace folder, and renames one that an older version named with base64.
20
+ """
21
+
22
+ from .layout import claim_workspace, find_workspace, legacy_workspace_key, open_workspace, workspace_key
23
+ from .ledger import SessionStats
24
+ from .locks import LOCK_FILE, LOCKED_MESSAGE, NoLocks, SessionGuard, SessionLocked, SessionLocks, prune_empty
25
+ from .metadata import MAX_TITLE, SessionInfo
26
+ from .records import STORE_FILE, MessageLine, Session, now
27
+ from .store import SessionPersistenceMixin, SessionStore
28
+
29
+ __all__ = [
30
+ "LOCKED_MESSAGE",
31
+ "LOCK_FILE",
32
+ "MAX_TITLE",
33
+ "MessageLine",
34
+ "STORE_FILE",
35
+ "NoLocks",
36
+ "Session",
37
+ "SessionGuard",
38
+ "SessionInfo",
39
+ "SessionLocked",
40
+ "SessionLocks",
41
+ "SessionPersistenceMixin",
42
+ "SessionStats",
43
+ "SessionStore",
44
+ "claim_workspace",
45
+ "find_workspace",
46
+ "legacy_workspace_key",
47
+ "now",
48
+ "open_workspace",
49
+ "prune_empty",
50
+ "workspace_key",
51
+ ]
@@ -0,0 +1,161 @@
1
+ """Which folder under the store root holds one workspace's sessions.
2
+
3
+ A workspace's folder is named after its path, the levels joined with ``-``::
4
+
5
+ /Users/me/projects/api -> ~/.zettcode/sessions/-Users-me-projects-api/
6
+
7
+ so ``ls ~/.zettcode/sessions`` says which folder is which project. Older versions named it with the
8
+ URL-safe base64 of the path (``L1VzZXJzL21l...``); a store written that way is **moved** to the new
9
+ name the first time it is opened, with one rename, which is atomic: a reader sees the folder under
10
+ the old name or under the new one and never half of each.
11
+
12
+ The move is a courtesy, not a requirement, and it is skipped whenever it is not safe:
13
+
14
+ * a session in the folder is open in another process (its ``session.lock`` is held), or the system
15
+ refuses the rename because a file in the folder is open (Windows does), and
16
+ * the rename fails for any other reason.
17
+
18
+ In every one of those the workspace **keeps its old name** and is used from there, exactly as before;
19
+ the next start tries again. Nothing is copied, so there is never a second copy to reconcile.
20
+
21
+ Joining with ``-`` is not injective (``/a-b/c`` and ``/a/b-c`` both read ``-a-b-c``), so each folder
22
+ records the path it belongs to in :data:`OWNER_FILE`. A folder that names a different path is not
23
+ shared: the workspace that finds it taken falls back to its base64 name, which is unique.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import hashlib
30
+ import os
31
+ import re
32
+ from pathlib import Path
33
+
34
+ from zettcode.filelock import is_held
35
+
36
+ from .locks import LOCK_FILE
37
+
38
+ #: Longest workspace folder name; a longer path keeps a hash instead of its tail.
39
+ MAX_KEY_CHARS = 120
40
+
41
+ #: The file in a workspace folder that says which path the folder belongs to.
42
+ OWNER_FILE = "workspace.txt"
43
+
44
+ #: Everything a folder name keeps as it is; any other character becomes ``-``.
45
+ _UNSAFE = re.compile(r"[^\w.-]")
46
+
47
+
48
+ def _resolve(workspace: str | Path) -> Path:
49
+ return Path(workspace).expanduser().resolve()
50
+
51
+
52
+ def legacy_workspace_key(workspace: str | Path) -> str:
53
+ """Return the name older versions gave the workspace's folder: the base64 of its path.
54
+
55
+ The URL-safe base64 of the resolved absolute path without padding. A path too long for one file
56
+ name keeps a short digest instead of its tail.
57
+ """
58
+ encoded = base64.urlsafe_b64encode(str(_resolve(workspace)).encode()).decode().rstrip("=")
59
+ if len(encoded) <= MAX_KEY_CHARS:
60
+ return encoded
61
+ digest = hashlib.sha256(encoded.encode()).hexdigest()[:12]
62
+ return f"{encoded[: MAX_KEY_CHARS - 13]}-{digest}"
63
+
64
+
65
+ def workspace_key(workspace: str | Path) -> str:
66
+ """Return the folder name that groups one workspace's sessions: its path with ``-`` between the levels.
67
+
68
+ Every character that is not a letter, a digit, ``_``, ``.`` or ``-`` becomes ``-`` (so the path
69
+ separators, a drive's colon and a space all do), which keeps the name one folder and legal on every
70
+ platform. A name too long for one file keeps a short digest of the whole instead of its tail.
71
+ """
72
+ text = _UNSAFE.sub("-", str(_resolve(workspace)))
73
+ if len(text) <= MAX_KEY_CHARS:
74
+ return text
75
+ digest = hashlib.sha256(str(_resolve(workspace)).encode()).hexdigest()[:12]
76
+ return f"{text[: MAX_KEY_CHARS - 13]}-{digest}"
77
+
78
+
79
+ def _owner(folder: Path) -> str | None:
80
+ """Return the path a folder says it belongs to, or ``None`` when it does not say."""
81
+ try:
82
+ return (folder / OWNER_FILE).read_text(encoding="utf-8").strip() or None
83
+ except (OSError, UnicodeError):
84
+ return None
85
+
86
+
87
+ def _is_ours(folder: Path, path: Path) -> bool:
88
+ """Say whether a folder under the readable name may hold this workspace's sessions.
89
+
90
+ A folder that records no owner is taken as ours: it was made by a run that stopped before it wrote
91
+ the note, and only a path joined to the same name could be the other claimant.
92
+ """
93
+ owner = _owner(folder)
94
+ return owner is None or owner == str(path)
95
+
96
+
97
+ def _claim(folder: Path, path: Path) -> None:
98
+ """Write the note that says the folder is this path's, if it has none. Never fails."""
99
+ if _owner(folder) is not None:
100
+ return
101
+ try:
102
+ (folder / OWNER_FILE).write_text(f"{path}\n", encoding="utf-8")
103
+ except OSError:
104
+ pass
105
+
106
+
107
+ def _in_use(folder: Path) -> bool:
108
+ """Say whether a process has a session in the folder open (its lock is held)."""
109
+ try:
110
+ locks = sorted(folder.glob(f"*/{LOCK_FILE}"))
111
+ except OSError:
112
+ return True
113
+ return any(is_held(lock) for lock in locks)
114
+
115
+
116
+ def _choose(root: Path, path: Path, *, move: bool) -> Path:
117
+ """Return the folder to use, moving the old one to the new name first when ``move`` allows."""
118
+ readable = root / workspace_key(path)
119
+ legacy = root / legacy_workspace_key(path)
120
+ if readable.is_dir():
121
+ return readable if _is_ours(readable, path) else legacy
122
+ if legacy == readable or not legacy.is_dir():
123
+ return readable
124
+ if not move or _in_use(legacy):
125
+ return legacy
126
+ try:
127
+ os.rename(legacy, readable)
128
+ except OSError:
129
+ # Another process may have moved it a moment ago; otherwise the system said no (a file is
130
+ # open), and the workspace stays where it was.
131
+ return readable if readable.is_dir() and _is_ours(readable, path) else legacy
132
+ _claim(readable, path)
133
+ return readable
134
+
135
+
136
+ def find_workspace(root: str | Path, workspace: str | Path) -> Path:
137
+ """Return the folder a workspace's sessions are in now, changing nothing on disk.
138
+
139
+ For a reader that must not create anything: it names the readable folder, the old one when that
140
+ is where the sessions still are, and the readable one when there are none yet.
141
+ """
142
+ return _choose(Path(root), _resolve(workspace), move=False)
143
+
144
+
145
+ def open_workspace(root: str | Path, workspace: str | Path) -> Path:
146
+ """Return the folder a workspace's sessions are written in, moving an old one to the readable name.
147
+
148
+ An old base64 folder is renamed to the readable name when nothing has it open, and stays where it is
149
+ when something does (see the module's notes). **Nothing is created**: the folder may not exist yet, and
150
+ a project that is opened and never used leaves no trace in the store. :func:`claim_workspace` makes it
151
+ when the first record is written.
152
+ """
153
+ return _choose(Path(root), _resolve(workspace), move=True)
154
+
155
+
156
+ def claim_workspace(folder: Path, workspace: str | Path) -> None:
157
+ """Create a workspace's folder if it is not there, and write the note saying whose it is."""
158
+ path = _resolve(workspace)
159
+ folder.mkdir(parents=True, exist_ok=True)
160
+ if folder.name == workspace_key(path):
161
+ _claim(folder, path)