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,6 @@
1
+ """What is said in the conversation besides the turns themselves.
2
+
3
+ ``mentions`` are the resources a draft can pull in with ``@``, ``ask`` is the model
4
+ asking the reader a question, ``side`` is a question that never joins the
5
+ conversation, and ``title`` is the small call that names a session.
6
+ """
@@ -0,0 +1,113 @@
1
+ """The ``ask_user`` protocol: what the model asked, and what answers it.
2
+
3
+ ``zett-agent`` owns the tool, the suspension and the routing; this module owns
4
+ the little the shell and the agent have to agree on — the question parsed out of
5
+ the event payload, and the payloads that answer it. Keeping both here means the
6
+ panel never reads a raw payload and the agent never invents one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from zett_agent.events import AgentEvent
16
+ from zett_agent.extensions.ask_user import ASK_USER_EVENT_NAME, ASK_USER_RESPONSE_EVENT_NAME
17
+
18
+ __all__ = [
19
+ "ASK_USER_RESPONSE_EVENT_NAME",
20
+ "AskUserQuestion",
21
+ "answer_payload",
22
+ "decline_payload",
23
+ "declined_answer",
24
+ "question_from",
25
+ ]
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class AskUserQuestion:
30
+ """One question the model is waiting on.
31
+
32
+ Attributes:
33
+ session_id: Session the question belongs to, echoed back with the answer.
34
+ call_id: Tool call the answer resolves; the agent routes on it.
35
+ question: The question, trimmed.
36
+ options: Choices the model offered, in display order; empty when it
37
+ offered none.
38
+ allow_multiple: Whether more than one option may be chosen.
39
+ """
40
+
41
+ session_id: str
42
+ call_id: str
43
+ question: str
44
+ options: tuple[str, ...] = ()
45
+ allow_multiple: bool = False
46
+
47
+
48
+ def _options(value: object) -> tuple[str, ...]:
49
+ """Return the usable choices in a payload field, dropping anything else."""
50
+ if not isinstance(value, list):
51
+ return ()
52
+ return tuple(item.strip() for item in value if isinstance(item, str) and item.strip())
53
+
54
+
55
+ def question_from(event: AgentEvent) -> AskUserQuestion | None:
56
+ """Return the question an event carries, or ``None`` when it is not one.
57
+
58
+ The payload comes from the runtime, but a malformed one must not take the
59
+ shell down: it is read defensively and reported as "not a question".
60
+ """
61
+ if event.name != ASK_USER_EVENT_NAME:
62
+ return None
63
+ payload = event.payload if isinstance(event.payload, dict) else {}
64
+ question = payload.get("question")
65
+ call_id = payload.get("tool_call_id")
66
+ if not isinstance(question, str) or not question.strip():
67
+ return None
68
+ if not isinstance(call_id, str) or not call_id:
69
+ return None
70
+ session_id = payload.get("session_id") or event.session_id
71
+ return AskUserQuestion(
72
+ session_id=str(session_id or ""),
73
+ call_id=call_id,
74
+ question=question.strip(),
75
+ options=_options(payload.get("options")),
76
+ allow_multiple=bool(payload.get("allow_multiple")),
77
+ )
78
+
79
+
80
+ def answer_payload(question: AskUserQuestion, answer: str) -> dict[str, Any]:
81
+ """Return the response payload for a question the reader answered."""
82
+ return {"tool_call_id": question.call_id, "answer": answer}
83
+
84
+
85
+ def decline_payload(question: AskUserQuestion) -> dict[str, Any]:
86
+ """Return the response payload for a question the reader cancelled.
87
+
88
+ ``ask_user`` has no rejection channel: the tool returns whatever payload the
89
+ UI sends. A cancel therefore answers with a result that says the question was
90
+ declined — the model reads that and carries on, instead of a suspended call
91
+ waiting for a reply that is never coming.
92
+ """
93
+ return {
94
+ "tool_call_id": question.call_id,
95
+ "answer": None,
96
+ "declined": True,
97
+ "reason": "the user cancelled the question",
98
+ }
99
+
100
+
101
+ def declined_answer(text: str) -> bool:
102
+ """Return whether a tool result is the one a cancelled question leaves behind.
103
+
104
+ The tool answers with the payload the shell sent, serialized as JSON. A cancelled question carries
105
+ ``"declined": true`` and no answer; a row that has one is not a row that failed, it is a question nobody
106
+ needed to answer, and the transcript says so.
107
+ """
108
+ try:
109
+ result = json.loads(text)
110
+ except ValueError:
111
+ return False
112
+ payload = result.get("payload") if isinstance(result, dict) else None
113
+ return isinstance(payload, dict) and payload.get("declined") is True
@@ -0,0 +1,197 @@
1
+ """Side questions: answers that never join the conversation.
2
+
3
+ A side question — "btw, what does this function do?" — runs in the same session,
4
+ so the model sees the conversation it is about, but nothing it produces joins it.
5
+ The store writes every message of that request with ``include_in_messages=False``,
6
+ the flag the runtime already defines as "record it, never replay it". Nothing has
7
+ to be undone in memory afterwards: the next request rebuilds its context from the
8
+ stored branch.
9
+
10
+ The tools are reading ones for the same reason. An edit made while asking a side
11
+ question would be invisible to the conversation that continues after it, so the
12
+ model would carry on with a false picture of the workspace.
13
+
14
+ Searching is one of them. The main agent searches with its shell and has no ``glob`` or ``grep``, but a side
15
+ question has no shell, so it is given the two search tools here.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Mapping
21
+ from typing import Any
22
+
23
+ from zett_agent.agent import AgentRunContext
24
+ from zett_agent.extensions.base import AgentExtension
25
+ from zett_agent.messages import UserMessage
26
+
27
+ #: Attribute marking the message a side question is asked as.
28
+ SIDE = "side"
29
+
30
+ #: Attribute (and stored tag) saying who asked a side question: ``/btw``, or a plugin's own ask.
31
+ ORIGIN = "origin"
32
+
33
+ #: The origin of a side question the reader asked, which is the one a resumed transcript restores by default.
34
+ READER = "reader"
35
+
36
+ #: Attribute (and stored tag) saying the reader watched the exchange on screen.
37
+ WATCHED = "watched"
38
+
39
+ #: Tools a side question may use: the ones that only read.
40
+ READ_ONLY_TOOLS = frozenset({"read", "read_file", "glob", "grep", "read_skill", "list_sessions", "message_history"})
41
+
42
+
43
+ def is_reader(origin: object) -> bool:
44
+ """Return whether an origin names the reader, whose side questions are the ones a front end draws."""
45
+ return origin == READER
46
+
47
+
48
+ def question(text: str, *, origin: str = READER, watched: bool | None = None) -> UserMessage:
49
+ """Return the message a side question is asked as.
50
+
51
+ Args:
52
+ text: The question.
53
+ origin: Who asked it — :data:`READER` for the reader's own ``/btw``, or the name of the plugin asking for
54
+ itself (``"recap"``). It is written down with the exchange, and a resumed transcript restores only what the
55
+ reader asked.
56
+ watched: Whether a reopened transcript restores this exchange. Left out, it follows ``origin``: a question the
57
+ reader asked comes back, a plugin's own does not. It says nothing about a front end drawing the exchange
58
+ now — a shell still drops a side question it did not begin — so setting it for something never drawn
59
+ restores a row nobody saw.
60
+ """
61
+ return UserMessage(
62
+ content=text,
63
+ attributes={SIDE: True, ORIGIN: origin, WATCHED: watched_by(origin, watched)},
64
+ include_in_messages=False,
65
+ )
66
+
67
+
68
+ def is_side(message: object) -> bool:
69
+ """Return whether a message is a side question."""
70
+ return isinstance(message, UserMessage) and bool(getattr(message, "attributes", {}).get(SIDE))
71
+
72
+
73
+ def _watched(values: Mapping[str, Any]) -> bool:
74
+ """Return whether a side question's attributes — or a stored line's tags — say the reader watched it.
75
+
76
+ ``watched`` is the answer, and it is written beside the origin on every new run, so it is what a stored exchange is
77
+ read from. ``origin`` is not consulted here: it says who asked, for whoever reads the file later, and the two are
78
+ written together, so a file that carries one without the other was not written by this program.
79
+
80
+ Without ``watched`` the answer is no. That is the conservative reading, because the two errors are not equal:
81
+ dropping an aside the reader saw costs a line of context, while restoring one they did not ask puts the model's own
82
+ instructions in their mouth. Files from before both tags hold the recap's asks and the reader's ``/btw`` looking
83
+ alike, and the only thing that asked on its own back then was the recap, so those are the lines this keeps off the
84
+ screen; a ``/btw`` from such a file goes with them, which is the price of not keeping the recap's words.
85
+ """
86
+ return bool(values.get(WATCHED, False))
87
+
88
+
89
+ def watched_by(origin: str, watched: bool | None) -> bool:
90
+ """Return whether a side question asked as ``origin`` was watched, when the caller left it out.
91
+
92
+ A question the reader asked is restored; a plugin's own is not. This is the one place the rule is written: the
93
+ message and the request that carries it both come through here.
94
+ """
95
+ return is_reader(origin) if watched is None else watched
96
+
97
+
98
+ def is_watched_side(message: object) -> bool:
99
+ """Return whether a side question's message says the reader watched it, False for anything that is not one."""
100
+ if not is_side(message):
101
+ return False
102
+ # ``is_side`` read ``attributes.get`` a moment ago, so a side message always holds a mapping here; the default is
103
+ # for the type checker, which sees only ``object``.
104
+ return _watched(getattr(message, "attributes", {}))
105
+
106
+
107
+ def is_side_run(context: AgentRunContext) -> bool:
108
+ """Return whether the request being prepared is a side question."""
109
+ # Read defensively: a request that never carried an input — a compaction pass
110
+ # over a restored session, or a test's stand-in context — is not a side one.
111
+ return is_side(getattr(context, "input_message", None))
112
+
113
+
114
+ def is_side_line(line: object) -> bool:
115
+ """Return whether a stored line belongs to a side question.
116
+
117
+ The store tags every line of that request, so a reader that walks the branch
118
+ — the replay a resumed session builds, the HTML trace an export writes — can
119
+ tell a side exchange from an ordinary one. The message's own
120
+ ``include_in_messages`` flag would not do: instructions carry it too, and
121
+ those are exactly what a trace wants to show.
122
+ """
123
+ tags = getattr(line, "tags", None)
124
+ return isinstance(tags, Mapping) and bool(tags.get(SIDE))
125
+
126
+
127
+ def is_watched_side_line(line: object) -> bool:
128
+ """Return whether a stored line belongs to a side question the reader watched on screen.
129
+
130
+ The run is tagged with what the caller said when it asked, and ``watched`` alone is read back: a question a plugin
131
+ asked for itself stays out of a reopened transcript, so the model's instructions to itself do not come back as the
132
+ reader's words. ``origin`` sits beside it for whoever reads the file, and a line carrying neither tag is not
133
+ restored either — see :func:`_watched` for why that is the cheaper error. What a front end draws *live* is a
134
+ separate decision, taken where the run is begun (``app.ui.app``), not here.
135
+ """
136
+ if not is_side_line(line):
137
+ return False
138
+ # ``is_side_line`` returned True, so the line carries tags.
139
+ return _watched(line.tags) # type: ignore[attr-defined]
140
+
141
+
142
+ class SideQuestions(AgentExtension):
143
+ """Keep one side question's tools to reading.
144
+
145
+ The shell marks the run it is about to start, because the hook that registers
146
+ tools runs before the question itself is known. Pruning there rather than
147
+ later matters twice over: the tool list is rebuilt for every request, and the
148
+ guidance a prompt carries is written from that same list, so a write tool
149
+ dropped now is never described to the model either.
150
+
151
+ The question itself is a second signal, checked before every turn: it prunes
152
+ the same tools if the mark was not set, which keeps a side question read-only
153
+ even when something else starts it.
154
+ """
155
+
156
+ #: After the coding bundle, which is what registers the tools being filtered.
157
+ priority = 120
158
+
159
+ def __init__(self) -> None:
160
+ """Start with no side question pending."""
161
+ self._pending = False
162
+
163
+ def begin(self) -> None:
164
+ """Mark the run that is about to start as a side question."""
165
+ self._pending = True
166
+
167
+ def end(self) -> None:
168
+ """Forget the mark, so the next run is an ordinary one."""
169
+ self._pending = False
170
+
171
+ @property
172
+ def pending(self) -> bool:
173
+ """Return whether a side question is the run in flight."""
174
+ return self._pending
175
+
176
+ async def on_tool(self, context: AgentRunContext) -> None:
177
+ """Offer a side question the read tools only."""
178
+ if self._pending:
179
+ self._read_only(context)
180
+
181
+ async def before_turn(self, context: AgentRunContext) -> None:
182
+ """Prune again for a question that carried its own mark."""
183
+ if is_side_run(context):
184
+ self._read_only(context)
185
+
186
+ @staticmethod
187
+ def _read_only(context: AgentRunContext) -> None:
188
+ """Drop every registered tool that is not one of the reading ones, and make sure the two search tools are there."""
189
+ # Imported here: the tools bring the schema library, which a request that is not a side question never needs.
190
+ from ..tools.files import glob, grep
191
+
192
+ for name in tuple(context.tools):
193
+ if name not in READ_ONLY_TOOLS:
194
+ context.tools.pop(name, None)
195
+ for search in (glob, grep):
196
+ if search.name not in context.tools:
197
+ context.register_tool(search)
@@ -0,0 +1,80 @@
1
+ """Session titles: one small model call that names the first exchange.
2
+
3
+ A title is a nicety, not conversation data: it never enters the message history
4
+ and it is produced by a single provider request instead of another agent turn,
5
+ so no tools, hooks, or extra session records are involved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from zett_agent.messages import SystemMessage, UserMessage
13
+ from zett_agent.model import AgentModel, ModelEventType, ModelRequest, ReasoningEffort
14
+
15
+ #: Most characters a stored title may use, matching the store's own guard.
16
+ MAX_TITLE_CHARS = 60
17
+ #: Most characters of each side of the exchange sent to the model.
18
+ MAX_EXCERPT_CHARS = 1200
19
+
20
+ INSTRUCTIONS = (
21
+ "You name conversations. Reply with a single short title of at most six words for the "
22
+ "exchange below, in the language the user wrote in. Reply with the title only: no quotes, "
23
+ "no markdown, no trailing period."
24
+ )
25
+
26
+ #: Decoration a model wraps a title in: bullets, headings, or a list number.
27
+ _LEADING_MARKER = re.compile(r"^(?:[-*\u2022\u25aa>#]+\s*|\d+[.)]\s+)+")
28
+ #: Trailing punctuation that only marks the end of a sentence.
29
+ _TRAILING = ".:;,\u3002"
30
+
31
+
32
+ async def summarize_title(
33
+ model: AgentModel,
34
+ *,
35
+ question: str,
36
+ answer: str,
37
+ effort: ReasoningEffort = ReasoningEffort.LOW,
38
+ ) -> str | None:
39
+ """Return a short title for the first exchange, or ``None`` when there is none.
40
+
41
+ Args:
42
+ model: Model to ask; the request carries no tools, so the provider's own
43
+ retry policy applies and no agent turn or session record is created.
44
+ question: The user's first message.
45
+ answer: The assistant's reply to it.
46
+ effort: How hard the model thinks about it. Cheap by default; the caller moves it to a
47
+ level the model takes, because an endpoint that rejects one fails the request.
48
+ """
49
+ request = ModelRequest(
50
+ messages=[
51
+ SystemMessage(content=INSTRUCTIONS),
52
+ UserMessage(content=f"User: {excerpt(question)}\n\nAssistant: {excerpt(answer)}"),
53
+ ],
54
+ reasoning_effort=effort,
55
+ )
56
+ reply = ""
57
+ async for event in model.stream(request):
58
+ if event.type is ModelEventType.TEXT_DELTA:
59
+ reply += event.delta
60
+ return clean_title(reply)
61
+
62
+
63
+ def excerpt(text: str) -> str:
64
+ """Collapse one side of the exchange to a single bounded paragraph."""
65
+ return " ".join(text.split())[:MAX_EXCERPT_CHARS]
66
+
67
+
68
+ def clean_title(reply: str) -> str | None:
69
+ """Reduce a model reply to one title line, or ``None`` when nothing is left.
70
+
71
+ Models answer with a bullet, a heading, backticks, or a quoted sentence even
72
+ when told not to, so the first non-empty line is unwrapped before the length
73
+ cap is applied. A reply that is only punctuation leaves no title at all.
74
+ """
75
+ for line in reply.splitlines():
76
+ cleaned = _LEADING_MARKER.sub("", line.strip())
77
+ cleaned = cleaned.strip("`*_# \t").strip("\"'").strip().rstrip(_TRAILING).strip()
78
+ if cleaned:
79
+ return cleaned[:MAX_TITLE_CHARS].strip() or None
80
+ return None