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,208 @@
1
+ """The just-in-time harness, kept behind a switch the reader can flip between two requests.
2
+
3
+ The framework's :class:`~zett_agent.jit_harness.JITHarnessExtension` lets the model write small Python tools of its
4
+ own and run them. That is opt-in by nature, and it is not behind the shell's permission level by itself: the plugin
5
+ puts a child process (and, below `full`, a sandbox) between that code and this window. So it is registered always and
6
+ does nothing until switched on, which is what lets the ``/plugins`` switch take effect on the next start without rebuilding
7
+ the client.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import AsyncIterator, Callable
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from zett_agent.agent import AgentRunContext
17
+ from zett_agent.events import AgentEvent, AgentEventType
18
+ from zett_agent.extensions.base import ModelRequestNext, ToolCallNext
19
+ from zett_agent.jit_harness import JITHarnessExtension
20
+ from zett_agent.messages import ToolCall, ToolMessage
21
+ from zett_agent.model import ModelEvent, ModelRequest
22
+ from zett_agent.tools.base import ToolResult
23
+
24
+ from .harness_switch import HarnessSwitch
25
+ from .mcp_notice import HARNESS_NOTICE_EVENT_NAME, notice_payload
26
+
27
+ if TYPE_CHECKING: # pragma: no cover - annotations only
28
+ from zett_agent.jit_harness import JITToolHost
29
+
30
+ __all__ = ["SwitchedHarness"]
31
+
32
+
33
+ class SwitchedHarness(JITHarnessExtension):
34
+ """The framework's harness, inert while its switch is off.
35
+
36
+ Every hook that changes a request first asks the switch. A hook that only releases what a request held is left
37
+ alone, so a request that began with the harness on still lets go of it cleanly. The switch is read once per
38
+ request, in ``on_tool``, because ``/harness`` waits for an idle shell: no request sees it change halfway.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ root: str | Path,
44
+ switch: HarnessSwitch,
45
+ *,
46
+ host_factory: Callable[[], JITToolHost] | None = None,
47
+ boundary: Callable[[], str] | None = None,
48
+ unbounded: Callable[[], bool] | None = None,
49
+ legacy_root: str | Path | None = None,
50
+ ) -> None:
51
+ """Keep each session's repository under ``root``, and follow ``switch``.
52
+
53
+ Args:
54
+ root: Directory that holds one subdirectory per session.
55
+ switch: Whether the harness is on for a request.
56
+ host_factory: Where a session's repository code runs, called once per session; ``None`` keeps the
57
+ framework's default, which imports it here. The plugin passes one that puts a child process between
58
+ the model's code and this window, inside a sandbox unless the reader gave the model everything.
59
+ boundary: What the host was built for — the shell level, in the plugin's words — read once per request.
60
+ A session's host is made once, so a level that changed since it was built (``/permission full``
61
+ while code was already running under a sandbox) would otherwise leave the old answer in force.
62
+ When it differs, the session is forgotten, which closes its child and builds the next host for the
63
+ level now in force.
64
+ unbounded: Whether the level in force promises a boundary this machine does not have. The code the model
65
+ wrote then runs with the reader's own access, which they are told once per session.
66
+ legacy_root: The root from before the workspace level, whose session directory is moved under ``root``
67
+ the first time that session asks for it.
68
+ """
69
+ super().__init__(root, host_factory=host_factory)
70
+ self._root = Path(root)
71
+ self._legacy_root = Path(legacy_root) if legacy_root is not None else None
72
+ self._switch = switch
73
+ self._boundary = boundary
74
+ self._unbounded = unbounded
75
+ #: Sessions already told that their repository code has no boundary on this machine.
76
+ self._said_unbounded: set[str] = set()
77
+ #: The boundary each session's host was built for, so a change of level is noticed.
78
+ self._built: dict[str, str] = {}
79
+ #: Sessions whose child must be closed once the request in flight lets go of them.
80
+ self._pending: set[str] = set()
81
+ #: Sessions whose current request has the harness, set by ``on_tool`` and cleared when it is released.
82
+ self._engaged: set[str] = set()
83
+
84
+ def engaged(self, context: AgentRunContext) -> bool:
85
+ """Return whether this request was given the harness."""
86
+ return context.session_id in self._engaged
87
+
88
+ async def on_tool(self, context: AgentRunContext) -> None:
89
+ """Mount the harness's tools, when it is on — in a host built for the level in force now."""
90
+ if self._legacy_root is not None:
91
+ from zettcode.engine.harness_paths import move_session
92
+
93
+ move_session(self._legacy_root, self._root, context.session_id)
94
+ if not self._switch.on:
95
+ return
96
+ self._follow_boundary(context.session_id)
97
+ self._engaged.add(context.session_id)
98
+ await self._say_unbounded(context)
99
+ await super().on_tool(context)
100
+
101
+ async def _say_unbounded(self, context: AgentRunContext) -> None:
102
+ """Tell the reader, once per session, that this machine cannot bound the code the model writes.
103
+
104
+ A machine with no sandbox passes ``None`` to the host, which runs the child with the reader's own access. The
105
+ harness says so because the model's own tools are not a shell command and none of the shell's fallbacks apply
106
+ to them. A line of news must never be what stops a request, so a failure to publish it is dropped.
107
+ """
108
+ if self._unbounded is None or not self._unbounded() or context.session_id in self._said_unbounded:
109
+ return
110
+ try:
111
+ await context.emit(
112
+ AgentEvent(
113
+ type=AgentEventType.CUSTOM,
114
+ session_id=context.session_id,
115
+ name=HARNESS_NOTICE_EVENT_NAME,
116
+ payload=notice_payload(
117
+ "this machine has no sandbox, so the tools the model writes run with your own access; "
118
+ "`/plugins` turns the harness plugin off",
119
+ "warn",
120
+ ),
121
+ )
122
+ )
123
+ except Exception: # noqa: BLE001 - news is never what stops a request
124
+ return
125
+ self._said_unbounded.add(context.session_id)
126
+
127
+ def _follow_boundary(self, session_id: str) -> None:
128
+ """Forget a session whose host was built for a boundary that is no longer in force."""
129
+ if self._boundary is None:
130
+ return
131
+ now = self._boundary()
132
+ built = self._built.get(session_id)
133
+ if built is None:
134
+ self._built[session_id] = now
135
+ return
136
+ if built == now:
137
+ return
138
+ # Its child was started without (or with) a boundary the reader has since changed: close it, and let the next
139
+ # request build the host the boundary asks for. A fail-open sandbox is the case that matters. The new value is
140
+ # recorded only once the old child is really gone, so a forget that had to wait is tried again next time.
141
+ if self._forget(session_id):
142
+ self._built[session_id] = now
143
+ else:
144
+ self._pending.add(session_id)
145
+
146
+ def _forget(self, session_id: str) -> bool:
147
+ """Close a session's child now, or say that the request in flight still holds it."""
148
+ try:
149
+ self.forget_session(session_id)
150
+ except KeyError:
151
+ # The framework dropped it already (an idle session past its cap): nothing is left to close, and nothing
152
+ # that is keyed by it is worth keeping either.
153
+ self._drop(session_id)
154
+ return True
155
+ except RuntimeError:
156
+ return False
157
+ self._drop(session_id)
158
+ return True
159
+
160
+ def _drop(self, session_id: str) -> None:
161
+ """Forget what this window remembered about a session the framework no longer holds."""
162
+ self._built.pop(session_id, None)
163
+ self._said_unbounded.discard(session_id)
164
+
165
+ async def on_state(self, context: AgentRunContext) -> None:
166
+ """Adopt late tool registrations, for a request that has the harness."""
167
+ if self.engaged(context):
168
+ await super().on_state(context)
169
+
170
+ async def on_tool_call(self, context: AgentRunContext, call: ToolCall, call_next: ToolCallNext) -> ToolResult:
171
+ """Run a call through the harness when it is engaged, and straight through when not."""
172
+ if not self.engaged(context):
173
+ return await call_next()
174
+ return await super().on_tool_call(context, call, call_next)
175
+
176
+ async def before_tool(self, context: AgentRunContext, call: ToolCall) -> None:
177
+ """Announce a harness tool call, when engaged."""
178
+ if self.engaged(context):
179
+ await super().before_tool(context, call)
180
+
181
+ async def after_tool(
182
+ self, context: AgentRunContext, call: ToolCall, result: ToolMessage, error: Exception | None
183
+ ) -> None:
184
+ """Report a harness tool call's outcome, when engaged."""
185
+ if self.engaged(context):
186
+ await super().after_tool(context, call, result, error)
187
+
188
+ async def on_model_request(
189
+ self, context: AgentRunContext, request: ModelRequest, call_next: ModelRequestNext
190
+ ) -> AsyncIterator[ModelEvent]:
191
+ """Add the harness's guidance to the request, when engaged; leave it untouched when not."""
192
+ if not self.engaged(context):
193
+ async for event in call_next(request):
194
+ yield event
195
+ return
196
+ async for event in super().on_model_request(context, request, call_next):
197
+ yield event
198
+
199
+ def _release(self, context: AgentRunContext) -> None:
200
+ """Let go of what the request held, and forget that it had the harness."""
201
+ self._engaged.discard(context.session_id)
202
+ # Detach first: the framework refuses to forget a session an active request still holds, and the release is
203
+ # what makes it idle. Only then can the child built for the old boundary be closed.
204
+ super()._release(context)
205
+ if context.session_id in self._pending:
206
+ self._pending.discard(context.session_id)
207
+ if self._forget(context.session_id) and self._boundary is not None:
208
+ self._built[context.session_id] = self._boundary()
@@ -0,0 +1,18 @@
1
+ """Whether the just-in-time harness is on. Nothing here imports the framework, so a launch can hold it for free."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ __all__ = ["HarnessSwitch"]
8
+
9
+
10
+ @dataclass
11
+ class HarnessSwitch:
12
+ """Whether the harness is on; the runtime owns it, the command flips it, the extension reads it.
13
+
14
+ Attributes:
15
+ on: ``True`` while the model is given the harness.
16
+ """
17
+
18
+ on: bool = False
@@ -0,0 +1,49 @@
1
+ """What an extension tells the reader as a custom event the transcript can show: MCP news, and the harness's.
2
+
3
+ This is a module of its own, apart from :mod:`.mcp`, because the transcript has to recognise the
4
+ event and :mod:`.mcp` imports the MCP SDK, which costs more than the first frame. The name and the
5
+ payload live here, where importing them costs nothing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from zett_agent.events import AgentEvent
11
+
12
+ #: Name of the custom event that carries one line of MCP news for the reader.
13
+ MCP_NOTICE_EVENT_NAME = "mcp_notice"
14
+
15
+ #: The same, for a line about the just-in-time harness: one channel, so a reader sees both in one place.
16
+ HARNESS_NOTICE_EVENT_NAME = "harness_notice"
17
+
18
+ #: Every event that carries a line for the reader.
19
+ NOTICE_EVENT_NAMES = frozenset({MCP_NOTICE_EVENT_NAME, HARNESS_NOTICE_EVENT_NAME})
20
+
21
+
22
+ #: How serious a line of news is: ``info`` is progress (a server is starting, or ready), ``warn`` is something the
23
+ #: reader may need to act on (a server is down, an entry is ignored). A payload that names neither is ``info``.
24
+ LEVELS = ("info", "warn")
25
+
26
+
27
+ def notice_payload(text: str, level: str = "info") -> dict[str, str]:
28
+ """Return the JSON-serializable payload that carries one line of MCP news and how serious it is."""
29
+ return {"text": text, "level": level if level in LEVELS else "info"}
30
+
31
+
32
+ def notice_level_from(event: AgentEvent) -> str:
33
+ """Return how serious the notice an event carries is; ``info`` for a payload that does not say."""
34
+ payload = event.payload
35
+ level = payload.get("level") if isinstance(payload, dict) else None
36
+ return level if level in LEVELS else "info"
37
+
38
+
39
+ def notice_from(event: AgentEvent) -> str | None:
40
+ """Return the line an MCP notice event carries, or ``None`` when it carries nothing readable.
41
+
42
+ Junk is ignored rather than raised on: an event reaches here from a hook, and a malformed one
43
+ must not be able to fail the request that produced it.
44
+ """
45
+ payload = event.payload
46
+ if event.name not in NOTICE_EVENT_NAMES or not isinstance(payload, dict):
47
+ return None
48
+ text = payload.get("text")
49
+ return text.strip() if isinstance(text, str) and text.strip() else None
@@ -0,0 +1,93 @@
1
+ """What a child agent has to say to the person: the shell approvals it cannot decide for itself.
2
+
3
+ A subagent runs in its own session, and nothing watches its event stream, so a command that needs approval would be
4
+ put to nobody and wait for ever. That one kind of event is handed to the parent run instead, which is the one the
5
+ person is watching. It goes out under the parent's session id, because the parent's stream refuses an event that
6
+ belongs to another session, but the payload still names the child's session and the tool call: the shell and the SDK
7
+ answer with those, the answer is routed to the extension that is waiting for it (the same instance serves parent and
8
+ child), and the child's tool call carries on. Everything else a child does stays in its own log.
9
+
10
+ A question (``ask_user``) is deliberately not relayed: a child does not have that tool, because a child works until it
11
+ has finished and a question put to the person in the middle of it would be a conversation the person never started.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping
17
+ from contextlib import aclosing
18
+ from typing import cast
19
+
20
+ from zett_agent.agent import Agent, AgentRunConfig, AgentRunContext
21
+ from zett_agent.events import AgentEvent, AgentEventType
22
+ from zett_agent.extensions.shell_approval import SHELL_APPROVAL_EVENT_NAME
23
+ from zett_agent.json_types import JsonValue
24
+ from zett_agent.messages import UserMessage
25
+ from zett_agent.model import AgentModel, ReasoningEffort
26
+
27
+ #: The one extension event a person has to see to let a child go on.
28
+ RELAYED = frozenset({SHELL_APPROVAL_EVENT_NAME})
29
+
30
+ #: Where one such event is sent.
31
+ Relay = Callable[[AgentEvent], Awaitable[None]]
32
+
33
+
34
+ def relay_to(parent: AgentRunContext) -> Relay:
35
+ """Return the function that hands a child's event to ``parent``'s run, under the parent's session id."""
36
+
37
+ async def relay(event: AgentEvent) -> None:
38
+ await parent.emit(
39
+ AgentEvent(
40
+ AgentEventType.CUSTOM,
41
+ parent.session_id,
42
+ tool_calls=list(event.tool_calls),
43
+ name=event.name,
44
+ payload=dict(event.payload or {}),
45
+ )
46
+ )
47
+
48
+ return relay
49
+
50
+
51
+ class RelayingAgent(Agent):
52
+ """An agent that passes the events a person has to answer to :attr:`relay` as they are emitted.
53
+
54
+ Attributes:
55
+ relay: Where such an event goes; ``None`` drops them, as an agent nobody watches does.
56
+ """
57
+
58
+ relay: Relay | None = None
59
+
60
+ async def stream(
61
+ self,
62
+ message: UserMessage | str,
63
+ *,
64
+ config: AgentRunConfig | None = None,
65
+ model: AgentModel | None = None,
66
+ reasoning_effort: ReasoningEffort | None = None,
67
+ parallel_tool_call: bool | None = None,
68
+ metadata: Mapping[str, JsonValue] | None = None,
69
+ tags: Mapping[str, JsonValue] | None = None,
70
+ ) -> AsyncIterator[AgentEvent]:
71
+ """Stream one run, relaying approvals and questions on the way; the events themselves are unchanged."""
72
+ # ``Agent.stream`` is declared as an iterator and is an async generator: closing it is what stops the run
73
+ # underneath when the caller stops reading.
74
+ events = cast(
75
+ AsyncGenerator[AgentEvent, None],
76
+ super().stream(
77
+ message,
78
+ config=config,
79
+ model=model,
80
+ reasoning_effort=reasoning_effort,
81
+ parallel_tool_call=parallel_tool_call,
82
+ metadata=metadata,
83
+ tags=tags,
84
+ ),
85
+ )
86
+ async with aclosing(events):
87
+ async for event in events:
88
+ if self.relay is not None and event.type is AgentEventType.CUSTOM and event.name in RELAYED:
89
+ await self.relay(event)
90
+ yield event
91
+
92
+
93
+ __all__ = ["RELAYED", "Relay", "RelayingAgent", "relay_to"]