zettcode-jit-harness 0.4.1__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.
@@ -0,0 +1,15 @@
1
+ """The just-in-time harness of ZettCode: the plugin, the switchable extension, and the screen the reader opens.
2
+
3
+ A part of the program that lives in a distribution of its own, found through the ``zettcode.agent`` entry point.
4
+ ``[harness] jit`` in the configuration is what turns it on. The extension (:mod:`zettcode.jit_harness.extension`)
5
+ imports the framework's harness, so nothing here imports it until the agent is built. The part also owns the read-only
6
+ page ``/harness`` opens (:mod:`zettcode.jit_harness.page`) and the command that opens it
7
+ (:mod:`zettcode.jit_harness.command`), each drawn on the widget framework and the plugin contract alone; the command is
8
+ registered by the plugin, and only where there is a screen.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .plugin import Harness
14
+
15
+ __all__ = ["Harness"]
@@ -0,0 +1,93 @@
1
+ """The ``/harness`` command: a read-only panel on the just-in-time harness, shipped with the harness itself.
2
+
3
+ The command used to live with the shell's own commands, which meant the screen it opens belonged to one distribution
4
+ and the command to another. Both are here now: the part owns what it shows and the way to see it. It is written
5
+ against the plugin contract — the context, the result and ``bottom_panel`` — so the panel is drawn without the
6
+ application, and the SDK is only what ``host.client`` is typed as.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from zettcode.engine.harness_paths import session_directory
15
+ from zettcode.plugin_api.commands import CommandContext, CommandResult
16
+ from zettcode.plugin_api.panels import bottom_panel
17
+ from zettcode.plugin_api.plugin import TuiPlugin
18
+
19
+ from .page import HARNESS_ROWS, HarnessPage
20
+
21
+ if TYPE_CHECKING: # pragma: no cover - annotations only
22
+ from zettcode.engine.plugins.context import PluginContext
23
+
24
+
25
+ class HarnessCommand(TuiPlugin):
26
+ """``/harness``: what the harness is doing now, where its files are, and what the model has written."""
27
+
28
+ name = "harness-command"
29
+ description = "the just-in-time harness's state and this session's repositories"
30
+
31
+ def activate(self, ctx: PluginContext) -> None:
32
+ """Register ``/harness``; it opens a page, so it is refused while a request runs rather than opened over it.
33
+
34
+ Reading the files costs nothing, and a command that only reports may run beside a request (``/context``
35
+ does); this one draws a panel over the conversation, which is why it waits for the request to end.
36
+ """
37
+ if ctx.tui is None:
38
+ return
39
+ ctx.tui.register_command(
40
+ "harness",
41
+ "what the just-in-time harness is doing, and where its tools are",
42
+ self.harness,
43
+ when_busy="refuse",
44
+ )
45
+
46
+ async def harness(self, context: CommandContext) -> CommandResult:
47
+ """Open a read-only panel on the just-in-time harness.
48
+
49
+ The switch is a plugin setting now: `/plugins` turns the harness on or off for the next start, and this page
50
+ says which state it found, where the repositories live, and what this session has written. Nothing on the
51
+ page changes anything; the repositories are read with
52
+ :func:`zett_agent.jit_harness.inspect_repository`, which imports nothing.
53
+ """
54
+ host = context.require_host()
55
+ if context.argument.strip():
56
+ return CommandResult(
57
+ message="/harness no longer takes on or off; /plugins switches the harness, and /harness shows it."
58
+ )
59
+ directory = session_directory(host.client.config, host.client.session.id)
60
+ state = "on" if host.client.harness else "off"
61
+ repositories = self._repositories(directory)
62
+ page = HarnessPage(state=state, directory=directory, repositories=repositories, on_close=host.close_page)
63
+ return CommandResult(widget=bottom_panel(page, rows=HARNESS_ROWS))
64
+
65
+ def _repositories(self, directory: Path) -> list[tuple[str, str]]:
66
+ """Return this session's repositories as ``(name, detail)``: what the files declare, or why they cannot load."""
67
+ from zett_agent.jit_harness import inspect_repository
68
+
69
+ try:
70
+ found = (
71
+ sorted(path for path in directory.iterdir() if (path / "__init__.py").is_file())
72
+ if directory.is_dir()
73
+ else []
74
+ )
75
+ except OSError as error:
76
+ return [("Could not be read", str(error))]
77
+ rows: list[tuple[str, str]] = []
78
+ for path in found:
79
+ policy = inspect_repository(path)
80
+ if not policy.ok or policy.manifest is None:
81
+ rows.append((path.name, f"cannot load: {'; '.join(policy.problems)}"))
82
+ continue
83
+ manifest = policy.manifest
84
+ declared = (
85
+ f"declares {len(manifest.tools)} tool{'s' if len(manifest.tools) != 1 else ''}"
86
+ if manifest.tools
87
+ else "declares no tools"
88
+ )
89
+ banned = f"; {len(policy.banned)} banned" if policy.banned else ""
90
+ rows.append(
91
+ (path.name, f"{manifest.name} {manifest.version} — {manifest.description} ({declared}{banned})")
92
+ )
93
+ return rows
@@ -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 zettcode.engine.extensions.harness_switch import HarnessSwitch
25
+ from zettcode.engine.extensions.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,58 @@
1
+ """The read-only panel `/harness` opens: the switch's state, the folder, and this session's repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from pathlib import Path
7
+
8
+ from zettcode.tui import ListItem, ListPage
9
+
10
+ #: Rows the panel asks for when it is anchored to the bottom edge.
11
+ HARNESS_ROWS = 12
12
+
13
+
14
+ def _shown(directory: Path) -> str:
15
+ """Return the folder as the subtitle shows it: the whole path, with the home directory collapsed to ``~``.
16
+
17
+ The panel has the width of the screen, so unlike the header it does not shorten the tail; the one substitution is
18
+ the home directory, which is longer than it says.
19
+ """
20
+ value = str(directory)
21
+ home = str(Path.home())
22
+ if value == home or value.startswith(home + "/"):
23
+ return "~" + value[len(home) :]
24
+ return value
25
+
26
+
27
+ class HarnessPage(ListPage):
28
+ """What the harness is doing now: on or off, where its files are, and what the model has written.
29
+
30
+ The page writes nothing: the switch lives on the `/plugins` page, which is where a reader turns the harness on or
31
+ off for the next start. Each repository is one row; selecting any row (or Escape) closes the page.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ *,
37
+ state: str,
38
+ directory: Path,
39
+ repositories: Sequence[tuple[str, str]],
40
+ on_close: Callable[[], None],
41
+ ) -> None:
42
+ items = [ListItem(name, name, detail) for name, detail in repositories]
43
+ if not items:
44
+ items = [
45
+ ListItem(
46
+ "none",
47
+ "No repositories yet",
48
+ "the model makes one with repo_create when the harness is on",
49
+ )
50
+ ]
51
+ super().__init__(
52
+ items,
53
+ title=f"Harness is {state}",
54
+ subtitle=_shown(directory),
55
+ footer="/plugins switches it \u00b7 esc close",
56
+ on_select=lambda _value: on_close(),
57
+ on_cancel=on_close,
58
+ )
@@ -0,0 +1,84 @@
1
+ """The plugin that hands the agent the just-in-time harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from zettcode.engine.extensions.harness_switch import HarnessSwitch
8
+ from zettcode.engine.permission import ShellPolicy
9
+ from zettcode.engine.plugins.plugin import Plugin, PluginSetting
10
+ from zettcode.engine.plugins.services import HARNESS, POLICY
11
+ from zettcode.plugin_api.host import HOST_LINK, HostLink
12
+
13
+ if TYPE_CHECKING: # pragma: no cover - annotations only
14
+ from zett_agent.extensions.base import AgentExtension
15
+ from zett_agent.jit_harness import JITToolHost
16
+
17
+ from zettcode.engine.plugins.context import PluginContext
18
+ from zettcode.engine.plugins.state import AgentBuild
19
+
20
+
21
+ class Harness(Plugin):
22
+ """The just-in-time harness: the model keeps small Python tools of its own, when the reader has it on.
23
+
24
+ The extension is always registered and inert until the switch is on, so a switch written by ``/plugins`` reaches the
25
+ next request of a client that was already built. Each session keeps its tools in its own folder under
26
+ ``config.harness_dir``, which is ``~/.zettcode/harness`` unless the configuration object says otherwise. The page
27
+ ``/harness`` opens (:mod:`.page`) and the command that opens it (:mod:`.command`) ship with the part; the command
28
+ is registered here, only for a process that has a screen and the shell's link, so the SDK and the server hold
29
+ nothing of the terminal.
30
+ """
31
+
32
+ name = "harness"
33
+ description = "The just-in-time tool harness, off by default"
34
+ setting = PluginSetting("jit_harness", "harness", "jit")
35
+
36
+ def activate(self, ctx: PluginContext) -> None:
37
+ """Register the switchable extension; the framework's harness is imported when the agent is built."""
38
+ from zettcode.engine.harness_paths import harness_root
39
+
40
+ switch = ctx.service(HARNESS, HarnessSwitch)
41
+ root = harness_root(ctx.config)
42
+ policy = ctx.service(POLICY, ShellPolicy)
43
+
44
+ def host() -> JITToolHost:
45
+ """Where one session's repository code runs.
46
+
47
+ A child process either way, so a crash, a hang or a runaway allocation in code the model wrote stays
48
+ out of this window. The boundary comes from the policy — none at `full`, the boundary `safe` uses below
49
+ it — and `None` on a machine that has no sandbox.
50
+ """
51
+ from zett_agent.jit_harness import SubprocessToolHost
52
+
53
+ return SubprocessToolHost(sandbox=policy.jit_sandbox())
54
+
55
+ def boundary() -> str:
56
+ """What a session's child was built around: the sandbox itself, or none.
57
+
58
+ The *level* is not the key. `ask` and `safe` hand the harness the same sandbox, and restarting a child for
59
+ nothing costs the model its loaded repository — the next tool call fails until it is loaded again — so this
60
+ changes only when the boundary does.
61
+ """
62
+ sandbox = policy.jit_sandbox()
63
+ return "none" if sandbox is None else type(sandbox).__name__ + str(id(sandbox))
64
+
65
+ def build(_: AgentBuild) -> AgentExtension:
66
+ from .extension import SwitchedHarness
67
+
68
+ return SwitchedHarness(
69
+ root,
70
+ switch,
71
+ host_factory=host,
72
+ boundary=boundary,
73
+ unbounded=policy.sandbox_missing,
74
+ legacy_root=ctx.config.harness_dir,
75
+ )
76
+
77
+ ctx.agent.register_extension_factory("SwitchedHarness", build)
78
+
79
+ # The page and the command that opens it are this part's; only a terminal can hold a command, and only the
80
+ # shell attaches the link a command runs against, so a process without a screen registers nothing here.
81
+ if ctx.tui is not None and ctx.find(HOST_LINK, HostLink) is not None:
82
+ from .command import HarnessCommand
83
+
84
+ HarnessCommand().activate(ctx)
File without changes
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.5
2
+ Name: zettcode-jit-harness
3
+ Version: 0.4.1
4
+ Summary: The just-in-time tool harness of ZettCode: its extension, its page and the command that opens it
5
+ Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
+ Author: Chang-LeHung
7
+ License-Expression: MIT
8
+ Keywords: agent,harness,zettcode
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: zett-agent<0.3,>=0.2.7
19
+ Requires-Dist: zettcode-engine
20
+ Requires-Dist: zettcode-plugin-api
21
+ Requires-Dist: zettcode-tui
@@ -0,0 +1,10 @@
1
+ zettcode/jit_harness/__init__.py,sha256=gXGDEvWL6jdv4QV2w69dG2Ml9yuI3KLmCprHMjeoLow,820
2
+ zettcode/jit_harness/command.py,sha256=Y_QwMjhi3QI1hpqMlQTG9uFP94EUXyvuYEPqlmOZQrQ,4407
3
+ zettcode/jit_harness/extension.py,sha256=cFWfPuwxRChHpBMMveKmZR-6Asl5UgGh6cTKo-J6bdI,10582
4
+ zettcode/jit_harness/page.py,sha256=ujBTyXgmgNsxBchbo220MmnJyz-Sud_3EhXXxSGO7JU,2012
5
+ zettcode/jit_harness/plugin.py,sha256=pXbleP0Y9D-KrB4ClfHGrb1oHOL_c0T66JoX9na_hyY,3934
6
+ zettcode/jit_harness/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ zettcode_jit_harness-0.4.1.dist-info/METADATA,sha256=3Tvc-UKMVdDW3WDnOH1ua3SW0o45wYFbvfNHxlu_F5s,860
8
+ zettcode_jit_harness-0.4.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ zettcode_jit_harness-0.4.1.dist-info/entry_points.txt,sha256=dBHokz-z1rYKvIYdYW_Z-zThI3oBA7BCyhBbQdqxGiY,63
10
+ zettcode_jit_harness-0.4.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [zettcode.agent]
2
+ harness = zettcode.jit_harness.plugin:Harness