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.
- zettcode/jit_harness/__init__.py +15 -0
- zettcode/jit_harness/command.py +93 -0
- zettcode/jit_harness/extension.py +208 -0
- zettcode/jit_harness/page.py +58 -0
- zettcode/jit_harness/plugin.py +84 -0
- zettcode/jit_harness/py.typed +0 -0
- zettcode_jit_harness-0.4.1.dist-info/METADATA +21 -0
- zettcode_jit_harness-0.4.1.dist-info/RECORD +10 -0
- zettcode_jit_harness-0.4.1.dist-info/WHEEL +4 -0
- zettcode_jit_harness-0.4.1.dist-info/entry_points.txt +2 -0
|
@@ -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,,
|