ph-runtime-guest 0.1.0__tar.gz
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.
- ph_runtime_guest-0.1.0/.gitignore +31 -0
- ph_runtime_guest-0.1.0/LICENSE +21 -0
- ph_runtime_guest-0.1.0/PKG-INFO +205 -0
- ph_runtime_guest-0.1.0/README.md +179 -0
- ph_runtime_guest-0.1.0/pyproject.toml +85 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/__init__.py +23 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/__main__.py +10 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/_json.py +41 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/cell.py +193 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/channel.py +89 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/errors.py +37 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/lifecycle.py +89 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/limits.py +103 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/protocol.py +147 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/proxies.py +106 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/py.typed +0 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/runner.py +483 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/skill.py +74 -0
- ph_runtime_guest-0.1.0/src/ph_runtime/snapshot.py +223 -0
- ph_runtime_guest-0.1.0/tests/test_cell_bindings.py +182 -0
- ph_runtime_guest-0.1.0/tests/test_snapshot_paths.py +174 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Build and environment
|
|
2
|
+
.venv/
|
|
3
|
+
dist/
|
|
4
|
+
build/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
jjt/
|
|
9
|
+
w2/
|
|
10
|
+
|
|
11
|
+
# Tooling caches
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
# Written by the guest's subprocess collectors and combined at the end of the
|
|
17
|
+
# run; see `conftest.GUEST_COVERAGE_RC`.
|
|
18
|
+
.coverage-guest*
|
|
19
|
+
htmlcov/
|
|
20
|
+
# Dropped at the repo root by pytest-textual-snapshot when a snapshot test
|
|
21
|
+
# fails. The reference snapshots under `__snapshots__/` are the committed
|
|
22
|
+
# expectation; this is the diff viewer for a run that did not match one.
|
|
23
|
+
snapshot_report.html
|
|
24
|
+
|
|
25
|
+
# Reference checkouts of the upstream projects this port reads from. Vendored
|
|
26
|
+
# locally so the plans' citations are verifiable; never part of this repo.
|
|
27
|
+
sources/
|
|
28
|
+
|
|
29
|
+
# Local scratch
|
|
30
|
+
.ph/
|
|
31
|
+
*.local.yaml
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Charles Tabor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ph-runtime-guest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The guest half of pH's code runtime: the fd-3 protocol, the run loop, and the binding proxies a cell awaits.
|
|
5
|
+
Project-URL: Homepage, https://github.com/chastabor/pH
|
|
6
|
+
Project-URL: Repository, https://github.com/chastabor/pH
|
|
7
|
+
Project-URL: Documentation, https://github.com/chastabor/pH/blob/main/docs/README.md
|
|
8
|
+
Project-URL: Issues, https://github.com/chastabor/pH/issues
|
|
9
|
+
Author: Charles Tabor
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,code-mode,runtime,sandbox
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Classifier: Topic :: Software Development
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: dill>=0.3.8
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# ph-runtime-guest
|
|
28
|
+
|
|
29
|
+
*The other side of the process boundary: the interpreter the model's code
|
|
30
|
+
actually runs in.*
|
|
31
|
+
|
|
32
|
+
pH's Python code runtime is two halves in two venvs. The **host** half is
|
|
33
|
+
[`ph_rlm.kernel`](../ph-rlm/), which spawns and governs; this is the **guest** —
|
|
34
|
+
it runs inside `$PH_CACHE/runtime-venv`, in a subprocess the host starts per
|
|
35
|
+
agent, and reaches the host over one newline-delimited JSON channel on **fd 3**.
|
|
36
|
+
|
|
37
|
+
It imports neither `ph-core` nor `ph-rlm`, and that is the whole point: the
|
|
38
|
+
process boundary exists so that model-written code cannot reach the harness, and
|
|
39
|
+
importing the harness would put it back inside. Its only dependency is `dill`,
|
|
40
|
+
imported lazily — a venv without it still runs cells and forgoes snapshots.
|
|
41
|
+
|
|
42
|
+
One module bends that without breaking it. `_json.py` holds the narrowings this package needs out of `ph.json` — one today, `as_str` — copied rather than imported for the reason above: that module ships in the ph-core wheel, and depending on the wheel is what this package exists not to do. `test_protocol_mirror.py` compares each definition against ph-core's character for character — it holds this package's other copy, `truncation_marker`, to `ph.text`'s in the same file — so a copy that drifts fails there rather than in a guest.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m ph_runtime # how the host spawns it; not a command you type
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**This package registers no rows.** It has no `ph.plugins` and no `ph.bundles`
|
|
49
|
+
entry point, is never mounted, and appears in no profile. Everything about it is
|
|
50
|
+
governed from the other side — see *Configuring it* below.
|
|
51
|
+
|
|
52
|
+
## What it provides
|
|
53
|
+
|
|
54
|
+
| module | what it owns |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `channel` | newline-delimited JSON over one duplex descriptor. `json.dumps` never emits a literal newline, so a line is exactly a frame |
|
|
57
|
+
| `protocol` | the fd-3 frame vocabulary, guest side — written twice on purpose, and `FRAME_FIELDS` is the *only* declaration of it here |
|
|
58
|
+
| `runner` | the run loop: one process, many cells, one namespace, runs serialized |
|
|
59
|
+
| `cell` | compiling one cell so that top-level `await`, top-level `return` and cross-cell persistence all hold at once |
|
|
60
|
+
| `proxies` | what the model sees in `globals()`: namespaces whose every call is a governed dispatch |
|
|
61
|
+
| `skill` | Python skills as pre-imported callables |
|
|
62
|
+
| `limits` | `RLIMIT_CPU`, address space and log caps, applied in the child before it reports ready |
|
|
63
|
+
| `lifecycle` | dying with the parent, per platform |
|
|
64
|
+
| `snapshot` | per-variable serialization for `kernel/snapshot` |
|
|
65
|
+
| `errors` | the two failures a cell can see, and the line between them |
|
|
66
|
+
|
|
67
|
+
### Compiling a cell
|
|
68
|
+
|
|
69
|
+
Three requirements pull against each other, and reconciling them is the only
|
|
70
|
+
interesting thing in `cell.py`. Top-level `await` needs the program to be a
|
|
71
|
+
coroutine on the child's one loop. Top-level `return` is a syntax error in
|
|
72
|
+
module code even with `PyCF_ALLOW_TOP_LEVEL_AWAIT`, so the body is wrapped in an
|
|
73
|
+
`async def` — which makes the first fall out for free. But a name assigned
|
|
74
|
+
inside a function is *local* to it, so the naive wrapping would lose every
|
|
75
|
+
variable the cell defined and quietly undo the persistent namespace. So the
|
|
76
|
+
wrapper declares every name the cell binds at its top level as `global`,
|
|
77
|
+
computed from the AST including the cases that are easy to forget — `import`,
|
|
78
|
+
`with … as`, `for`, `except … as`, walrus, `del`, and function and class
|
|
79
|
+
definitions. A trailing expression becomes the cell's value, as in a REPL.
|
|
80
|
+
|
|
81
|
+
### What the model sees
|
|
82
|
+
|
|
83
|
+
The programming model is prime-agent's, deliberately and exactly:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
files = await tools.glob(pattern="src/**/*.py")
|
|
87
|
+
child = await rlm.run("review this diff", name="reviewer")
|
|
88
|
+
await agent_message.send("done", receiver_role="parent")
|
|
89
|
+
answer = await websearch(query="…") # a skill: module-as-callable
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Namespaces are built from what the **`boot` frame declared**, not from a list in
|
|
93
|
+
this package, so a namespace a plugin adds reaches the cell without this module
|
|
94
|
+
changing (I1, I7). Unknown attributes and unknown keyword arguments fail loudly
|
|
95
|
+
with the available names in the message — the reader is a model, and a silent
|
|
96
|
+
`None` is how a cell spends a turn discovering a capability it invented does not
|
|
97
|
+
exist. A skill that fails to import binds a **stub that explains itself** rather
|
|
98
|
+
than leaving the name undefined, for the same reason.
|
|
99
|
+
|
|
100
|
+
### Cancellation, three mechanisms for three situations
|
|
101
|
+
|
|
102
|
+
The channel is read by one task and a cell runs in another — otherwise a cell
|
|
103
|
+
awaiting `tools.read(...)` would be blocked on a reply its own loop was supposed
|
|
104
|
+
to deliver, which is the classic control-channel deadlock and the reason fd 3 is
|
|
105
|
+
not the channel a run occupies (D5). On top of that:
|
|
106
|
+
|
|
107
|
+
- awaiting a reply or a sleep → the `cancel` frame, or the `SIGINT` callback,
|
|
108
|
+
cancels the cell's task. `SIGINT` is installed with
|
|
109
|
+
`loop.add_signal_handler`, **not** the default handler: that one raises
|
|
110
|
+
`KeyboardInterrupt` into whatever frame is executing, and when a cell is
|
|
111
|
+
`await`ing that frame is `asyncio`'s own — so the signal killed the entire
|
|
112
|
+
guest instead of the cell, in the common case rather than a rare one;
|
|
113
|
+
- spinning in Python → the loop is starved, so neither arrives. `SIGXCPU` from
|
|
114
|
+
the per-run CPU budget lands in the cell's own frame, because it is executing
|
|
115
|
+
bytecode, which is exactly why it is unreachable by the others;
|
|
116
|
+
- neither works in time → the host escalates to `SIGKILL` and restarts. The
|
|
117
|
+
namespace is lost and the model is told so, which beats a wedged kernel.
|
|
118
|
+
|
|
119
|
+
### Dying with the parent
|
|
120
|
+
|
|
121
|
+
The OS does not do what one would hope: a parent's death does not kill its
|
|
122
|
+
children (POSIX re-parents them to PID 1) and `atexit` never runs under
|
|
123
|
+
`SIGKILL`. Each platform needs its own mechanism and only one is in the guest's
|
|
124
|
+
gift — **Linux** sets `prctl(PR_SET_PDEATHSIG, SIGKILL)` here; **macOS** has no
|
|
125
|
+
equivalent, so a daemon thread watches `os.getppid()` and `os._exit`s when it
|
|
126
|
+
changes; **Windows** is the host's job, a Job Object with `KILL_ON_JOB_CLOSE`.
|
|
127
|
+
The host's orphan journal is the backstop for all three.
|
|
128
|
+
|
|
129
|
+
## Configuring it
|
|
130
|
+
|
|
131
|
+
Nothing here is configured directly. Every knob belongs to `ph-rlm`'s
|
|
132
|
+
`code-runtime-python` row, which spawns this package and sends the `boot` frame
|
|
133
|
+
that parameterises it — and the **host owns every default**, because there are
|
|
134
|
+
two definitions of the protocol across the two sides and exactly one owner of
|
|
135
|
+
each value. `boot` carries every limit as a required field, so a guest has
|
|
136
|
+
nothing to guess and a changed default cannot mean two things at once.
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
- id: code-runtime-python
|
|
140
|
+
config:
|
|
141
|
+
python: managed # which interpreter runs model code
|
|
142
|
+
cpuSeconds: 30 # per cell, not per kernel — RLIMIT_CPU is re-armed each run
|
|
143
|
+
addressSpaceBytes: 2147483648
|
|
144
|
+
maxLogBytes: 65536 # per stream
|
|
145
|
+
maxValueBytes: 65536 # per snapshotted variable
|
|
146
|
+
maxSnapshotBytes: 16777216
|
|
147
|
+
skills: ["acme-websearch"]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`python: managed` is what *creates* the venv this package lives in: `uv venv
|
|
151
|
+
--seed` at `$PH_CACHE/runtime-venv`, then `uv pip install ph-runtime-guest` (the
|
|
152
|
+
checkout's own source directory when pH is running from one) plus each
|
|
153
|
+
configured skill — a local skill directory **editable**, because the staleness
|
|
154
|
+
marker digests the specs rather than their contents. `python: host` runs the
|
|
155
|
+
guest on pH's own interpreter instead: fast, needs no `uv` and no network, and
|
|
156
|
+
what the test suite uses — and also what puts `ph-core`, pydantic and Textual on
|
|
157
|
+
the child's `sys.path`, which is a wider surface and the reason it is not the
|
|
158
|
+
default. `$PH_RUNTIME_PYTHON` (or `interpreter:`) is the third answer.
|
|
159
|
+
|
|
160
|
+
Staleness is a marker file (`.ph-runtime.json`), not a heuristic: it records the
|
|
161
|
+
protocol version, this package's version and the skill set, and any difference
|
|
162
|
+
rebuilds. A guest one protocol behind would otherwise be discovered as a refused
|
|
163
|
+
`boot` at the first cell of somebody's session (D7).
|
|
164
|
+
|
|
165
|
+
See [`ph-rlm`'s README](../ph-rlm/README.md#including-it-and-adjusting-it) for
|
|
166
|
+
the full row table.
|
|
167
|
+
|
|
168
|
+
## Limitations, and things that are deliberate
|
|
169
|
+
|
|
170
|
+
- **The protocol is written twice and neither side imports the other.**
|
|
171
|
+
`ph_rlm.kernel.protocol` is the twin. What keeps them honest is
|
|
172
|
+
`packages/ph-rlm/tests/test_protocol_mirror.py`, which compares
|
|
173
|
+
`PROTOCOL_VERSION`, every frame's required and optional field set, and the
|
|
174
|
+
truncation marker byte for byte. **That test is the contract**; there is no
|
|
175
|
+
shared module to fall back on.
|
|
176
|
+
- **A refusal is not catchable.** `ToolFailed` is the program's to handle — a
|
|
177
|
+
timeout, a bad argument, a missing file — and catching it to try something
|
|
178
|
+
else is exactly right. `RunStopped` derives from `BaseException` so
|
|
179
|
+
`except Exception` cannot swallow it, because a program that can catch a
|
|
180
|
+
refusal can route around it (C3). A CPU budget raises the same way.
|
|
181
|
+
- **Runs are serialized.** One namespace, one program at a time.
|
|
182
|
+
- **Snapshots are per variable, and some things are never captured.** An
|
|
183
|
+
unchanged 200 MiB DataFrame emits nothing because its digest did not move. A
|
|
184
|
+
definitively-immutable value is compared by *identity* — deliberately not
|
|
185
|
+
extended to tuples or frozensets, since `t = ([1],)` keeps its identity while
|
|
186
|
+
its contents change, and a fast path that is wrong once is worse than none.
|
|
187
|
+
The binding proxies, the imported skill modules and anything else the
|
|
188
|
+
bootstrap put in `globals()` are excluded: they are rebuilt from the `boot`
|
|
189
|
+
frame, so pickling them would store a stale copy of the harness's own surface.
|
|
190
|
+
- **The C pickler is tried first**, with `dill` as the fallback for what it
|
|
191
|
+
refuses (a cell-defined function, class or lambda). Each object takes the same
|
|
192
|
+
path every time, so digests stay stable — and `dill.loads` reads standard
|
|
193
|
+
pickle bytes unchanged either way.
|
|
194
|
+
- **This package must stay nearly dependency-free.** Every dependency in the
|
|
195
|
+
runtime venv is another import model code can reach and another thing that can
|
|
196
|
+
break a session the host cannot repair.
|
|
197
|
+
|
|
198
|
+
## Tests
|
|
199
|
+
|
|
200
|
+
Its suite lives with the host half, in `packages/ph-rlm/tests/` — the mirror
|
|
201
|
+
test above, plus `test_kernel.py` (the process boundary), `test_codec.py` (the
|
|
202
|
+
decoder, fuzzed), `test_lifecycle.py`, `test_snapshot.py`, `test_venv.py` and
|
|
203
|
+
`test_runtime_integration.py`. Testing the guest from the host's side is the
|
|
204
|
+
honest arrangement: what matters is that the two halves agree, and only a test
|
|
205
|
+
that holds both can say so.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# ph-runtime-guest
|
|
2
|
+
|
|
3
|
+
*The other side of the process boundary: the interpreter the model's code
|
|
4
|
+
actually runs in.*
|
|
5
|
+
|
|
6
|
+
pH's Python code runtime is two halves in two venvs. The **host** half is
|
|
7
|
+
[`ph_rlm.kernel`](../ph-rlm/), which spawns and governs; this is the **guest** —
|
|
8
|
+
it runs inside `$PH_CACHE/runtime-venv`, in a subprocess the host starts per
|
|
9
|
+
agent, and reaches the host over one newline-delimited JSON channel on **fd 3**.
|
|
10
|
+
|
|
11
|
+
It imports neither `ph-core` nor `ph-rlm`, and that is the whole point: the
|
|
12
|
+
process boundary exists so that model-written code cannot reach the harness, and
|
|
13
|
+
importing the harness would put it back inside. Its only dependency is `dill`,
|
|
14
|
+
imported lazily — a venv without it still runs cells and forgoes snapshots.
|
|
15
|
+
|
|
16
|
+
One module bends that without breaking it. `_json.py` holds the narrowings this package needs out of `ph.json` — one today, `as_str` — copied rather than imported for the reason above: that module ships in the ph-core wheel, and depending on the wheel is what this package exists not to do. `test_protocol_mirror.py` compares each definition against ph-core's character for character — it holds this package's other copy, `truncation_marker`, to `ph.text`'s in the same file — so a copy that drifts fails there rather than in a guest.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python -m ph_runtime # how the host spawns it; not a command you type
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**This package registers no rows.** It has no `ph.plugins` and no `ph.bundles`
|
|
23
|
+
entry point, is never mounted, and appears in no profile. Everything about it is
|
|
24
|
+
governed from the other side — see *Configuring it* below.
|
|
25
|
+
|
|
26
|
+
## What it provides
|
|
27
|
+
|
|
28
|
+
| module | what it owns |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `channel` | newline-delimited JSON over one duplex descriptor. `json.dumps` never emits a literal newline, so a line is exactly a frame |
|
|
31
|
+
| `protocol` | the fd-3 frame vocabulary, guest side — written twice on purpose, and `FRAME_FIELDS` is the *only* declaration of it here |
|
|
32
|
+
| `runner` | the run loop: one process, many cells, one namespace, runs serialized |
|
|
33
|
+
| `cell` | compiling one cell so that top-level `await`, top-level `return` and cross-cell persistence all hold at once |
|
|
34
|
+
| `proxies` | what the model sees in `globals()`: namespaces whose every call is a governed dispatch |
|
|
35
|
+
| `skill` | Python skills as pre-imported callables |
|
|
36
|
+
| `limits` | `RLIMIT_CPU`, address space and log caps, applied in the child before it reports ready |
|
|
37
|
+
| `lifecycle` | dying with the parent, per platform |
|
|
38
|
+
| `snapshot` | per-variable serialization for `kernel/snapshot` |
|
|
39
|
+
| `errors` | the two failures a cell can see, and the line between them |
|
|
40
|
+
|
|
41
|
+
### Compiling a cell
|
|
42
|
+
|
|
43
|
+
Three requirements pull against each other, and reconciling them is the only
|
|
44
|
+
interesting thing in `cell.py`. Top-level `await` needs the program to be a
|
|
45
|
+
coroutine on the child's one loop. Top-level `return` is a syntax error in
|
|
46
|
+
module code even with `PyCF_ALLOW_TOP_LEVEL_AWAIT`, so the body is wrapped in an
|
|
47
|
+
`async def` — which makes the first fall out for free. But a name assigned
|
|
48
|
+
inside a function is *local* to it, so the naive wrapping would lose every
|
|
49
|
+
variable the cell defined and quietly undo the persistent namespace. So the
|
|
50
|
+
wrapper declares every name the cell binds at its top level as `global`,
|
|
51
|
+
computed from the AST including the cases that are easy to forget — `import`,
|
|
52
|
+
`with … as`, `for`, `except … as`, walrus, `del`, and function and class
|
|
53
|
+
definitions. A trailing expression becomes the cell's value, as in a REPL.
|
|
54
|
+
|
|
55
|
+
### What the model sees
|
|
56
|
+
|
|
57
|
+
The programming model is prime-agent's, deliberately and exactly:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
files = await tools.glob(pattern="src/**/*.py")
|
|
61
|
+
child = await rlm.run("review this diff", name="reviewer")
|
|
62
|
+
await agent_message.send("done", receiver_role="parent")
|
|
63
|
+
answer = await websearch(query="…") # a skill: module-as-callable
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Namespaces are built from what the **`boot` frame declared**, not from a list in
|
|
67
|
+
this package, so a namespace a plugin adds reaches the cell without this module
|
|
68
|
+
changing (I1, I7). Unknown attributes and unknown keyword arguments fail loudly
|
|
69
|
+
with the available names in the message — the reader is a model, and a silent
|
|
70
|
+
`None` is how a cell spends a turn discovering a capability it invented does not
|
|
71
|
+
exist. A skill that fails to import binds a **stub that explains itself** rather
|
|
72
|
+
than leaving the name undefined, for the same reason.
|
|
73
|
+
|
|
74
|
+
### Cancellation, three mechanisms for three situations
|
|
75
|
+
|
|
76
|
+
The channel is read by one task and a cell runs in another — otherwise a cell
|
|
77
|
+
awaiting `tools.read(...)` would be blocked on a reply its own loop was supposed
|
|
78
|
+
to deliver, which is the classic control-channel deadlock and the reason fd 3 is
|
|
79
|
+
not the channel a run occupies (D5). On top of that:
|
|
80
|
+
|
|
81
|
+
- awaiting a reply or a sleep → the `cancel` frame, or the `SIGINT` callback,
|
|
82
|
+
cancels the cell's task. `SIGINT` is installed with
|
|
83
|
+
`loop.add_signal_handler`, **not** the default handler: that one raises
|
|
84
|
+
`KeyboardInterrupt` into whatever frame is executing, and when a cell is
|
|
85
|
+
`await`ing that frame is `asyncio`'s own — so the signal killed the entire
|
|
86
|
+
guest instead of the cell, in the common case rather than a rare one;
|
|
87
|
+
- spinning in Python → the loop is starved, so neither arrives. `SIGXCPU` from
|
|
88
|
+
the per-run CPU budget lands in the cell's own frame, because it is executing
|
|
89
|
+
bytecode, which is exactly why it is unreachable by the others;
|
|
90
|
+
- neither works in time → the host escalates to `SIGKILL` and restarts. The
|
|
91
|
+
namespace is lost and the model is told so, which beats a wedged kernel.
|
|
92
|
+
|
|
93
|
+
### Dying with the parent
|
|
94
|
+
|
|
95
|
+
The OS does not do what one would hope: a parent's death does not kill its
|
|
96
|
+
children (POSIX re-parents them to PID 1) and `atexit` never runs under
|
|
97
|
+
`SIGKILL`. Each platform needs its own mechanism and only one is in the guest's
|
|
98
|
+
gift — **Linux** sets `prctl(PR_SET_PDEATHSIG, SIGKILL)` here; **macOS** has no
|
|
99
|
+
equivalent, so a daemon thread watches `os.getppid()` and `os._exit`s when it
|
|
100
|
+
changes; **Windows** is the host's job, a Job Object with `KILL_ON_JOB_CLOSE`.
|
|
101
|
+
The host's orphan journal is the backstop for all three.
|
|
102
|
+
|
|
103
|
+
## Configuring it
|
|
104
|
+
|
|
105
|
+
Nothing here is configured directly. Every knob belongs to `ph-rlm`'s
|
|
106
|
+
`code-runtime-python` row, which spawns this package and sends the `boot` frame
|
|
107
|
+
that parameterises it — and the **host owns every default**, because there are
|
|
108
|
+
two definitions of the protocol across the two sides and exactly one owner of
|
|
109
|
+
each value. `boot` carries every limit as a required field, so a guest has
|
|
110
|
+
nothing to guess and a changed default cannot mean two things at once.
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
- id: code-runtime-python
|
|
114
|
+
config:
|
|
115
|
+
python: managed # which interpreter runs model code
|
|
116
|
+
cpuSeconds: 30 # per cell, not per kernel — RLIMIT_CPU is re-armed each run
|
|
117
|
+
addressSpaceBytes: 2147483648
|
|
118
|
+
maxLogBytes: 65536 # per stream
|
|
119
|
+
maxValueBytes: 65536 # per snapshotted variable
|
|
120
|
+
maxSnapshotBytes: 16777216
|
|
121
|
+
skills: ["acme-websearch"]
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`python: managed` is what *creates* the venv this package lives in: `uv venv
|
|
125
|
+
--seed` at `$PH_CACHE/runtime-venv`, then `uv pip install ph-runtime-guest` (the
|
|
126
|
+
checkout's own source directory when pH is running from one) plus each
|
|
127
|
+
configured skill — a local skill directory **editable**, because the staleness
|
|
128
|
+
marker digests the specs rather than their contents. `python: host` runs the
|
|
129
|
+
guest on pH's own interpreter instead: fast, needs no `uv` and no network, and
|
|
130
|
+
what the test suite uses — and also what puts `ph-core`, pydantic and Textual on
|
|
131
|
+
the child's `sys.path`, which is a wider surface and the reason it is not the
|
|
132
|
+
default. `$PH_RUNTIME_PYTHON` (or `interpreter:`) is the third answer.
|
|
133
|
+
|
|
134
|
+
Staleness is a marker file (`.ph-runtime.json`), not a heuristic: it records the
|
|
135
|
+
protocol version, this package's version and the skill set, and any difference
|
|
136
|
+
rebuilds. A guest one protocol behind would otherwise be discovered as a refused
|
|
137
|
+
`boot` at the first cell of somebody's session (D7).
|
|
138
|
+
|
|
139
|
+
See [`ph-rlm`'s README](../ph-rlm/README.md#including-it-and-adjusting-it) for
|
|
140
|
+
the full row table.
|
|
141
|
+
|
|
142
|
+
## Limitations, and things that are deliberate
|
|
143
|
+
|
|
144
|
+
- **The protocol is written twice and neither side imports the other.**
|
|
145
|
+
`ph_rlm.kernel.protocol` is the twin. What keeps them honest is
|
|
146
|
+
`packages/ph-rlm/tests/test_protocol_mirror.py`, which compares
|
|
147
|
+
`PROTOCOL_VERSION`, every frame's required and optional field set, and the
|
|
148
|
+
truncation marker byte for byte. **That test is the contract**; there is no
|
|
149
|
+
shared module to fall back on.
|
|
150
|
+
- **A refusal is not catchable.** `ToolFailed` is the program's to handle — a
|
|
151
|
+
timeout, a bad argument, a missing file — and catching it to try something
|
|
152
|
+
else is exactly right. `RunStopped` derives from `BaseException` so
|
|
153
|
+
`except Exception` cannot swallow it, because a program that can catch a
|
|
154
|
+
refusal can route around it (C3). A CPU budget raises the same way.
|
|
155
|
+
- **Runs are serialized.** One namespace, one program at a time.
|
|
156
|
+
- **Snapshots are per variable, and some things are never captured.** An
|
|
157
|
+
unchanged 200 MiB DataFrame emits nothing because its digest did not move. A
|
|
158
|
+
definitively-immutable value is compared by *identity* — deliberately not
|
|
159
|
+
extended to tuples or frozensets, since `t = ([1],)` keeps its identity while
|
|
160
|
+
its contents change, and a fast path that is wrong once is worse than none.
|
|
161
|
+
The binding proxies, the imported skill modules and anything else the
|
|
162
|
+
bootstrap put in `globals()` are excluded: they are rebuilt from the `boot`
|
|
163
|
+
frame, so pickling them would store a stale copy of the harness's own surface.
|
|
164
|
+
- **The C pickler is tried first**, with `dill` as the fallback for what it
|
|
165
|
+
refuses (a cell-defined function, class or lambda). Each object takes the same
|
|
166
|
+
path every time, so digests stay stable — and `dill.loads` reads standard
|
|
167
|
+
pickle bytes unchanged either way.
|
|
168
|
+
- **This package must stay nearly dependency-free.** Every dependency in the
|
|
169
|
+
runtime venv is another import model code can reach and another thing that can
|
|
170
|
+
break a session the host cannot repair.
|
|
171
|
+
|
|
172
|
+
## Tests
|
|
173
|
+
|
|
174
|
+
Its suite lives with the host half, in `packages/ph-rlm/tests/` — the mirror
|
|
175
|
+
test above, plus `test_kernel.py` (the process boundary), `test_codec.py` (the
|
|
176
|
+
decoder, fuzzed), `test_lifecycle.py`, `test_snapshot.py`, `test_venv.py` and
|
|
177
|
+
`test_runtime_integration.py`. Testing the guest from the host's side is the
|
|
178
|
+
honest arrangement: what matters is that the two halves agree, and only a test
|
|
179
|
+
that holds both can say so.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ph-runtime-guest"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The guest half of pH's code runtime: the fd-3 protocol, the run loop, and the binding proxies a cell awaits."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Charles Tabor" }]
|
|
14
|
+
keywords = ["agent", "code-mode", "runtime", "sandbox"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
22
|
+
"Operating System :: MacOS",
|
|
23
|
+
"Topic :: Software Development",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
# Deliberately almost dependency-free. This package is installed into
|
|
28
|
+
# `$PH_CACHE/runtime-venv`, a venv that executes model-written code; every
|
|
29
|
+
# dependency there is another import that code can reach and another thing that
|
|
30
|
+
# can break a session the host cannot repair. `dill` is the one exception and it
|
|
31
|
+
# is imported lazily, so a venv without it runs cells and forgoes snapshots.
|
|
32
|
+
dependencies = ["dill>=0.3.8"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/chastabor/pH"
|
|
36
|
+
Repository = "https://github.com/chastabor/pH"
|
|
37
|
+
Documentation = "https://github.com/chastabor/pH/blob/main/docs/README.md"
|
|
38
|
+
Issues = "https://github.com/chastabor/pH/issues"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/ph_runtime"]
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------- coverage ----
|
|
44
|
+
# **The guest runs in a subprocess, so the parent's collector records none of
|
|
45
|
+
# it.** Every cell is a fresh `python -m ph_runtime`, which read as `runner.py`
|
|
46
|
+
# at 0 % while twenty-six kernel tests were driving cells through it, and as
|
|
47
|
+
# 26.1 % for this package against a true 84.1 %.
|
|
48
|
+
#
|
|
49
|
+
# `conftest.guest_coverage` points `COVERAGE_PROCESS_START` at this file for the
|
|
50
|
+
# tests that start guests; `coverage`'s own `.pth` hook reads that variable at
|
|
51
|
+
# interpreter startup and starts a collector in the child. It survives
|
|
52
|
+
# `ph.seams.subprocess.scrub_env` because that is a credential denylist rather
|
|
53
|
+
# than an allowlist, so nothing needs a hole poked in the scrub.
|
|
54
|
+
#
|
|
55
|
+
# **Here rather than the workspace gate, and that split is deliberate.** The gate
|
|
56
|
+
# is `--cov=ph` against `fail_under = 93` in the root manifest, measuring
|
|
57
|
+
# `ph-core` alone. Folding `ph_runtime` into that `source` would change the
|
|
58
|
+
# denominator the ratchet was measured against — 683 statements at ~84 % beside
|
|
59
|
+
# 16 821 at ~93 % lands the total just under the floor — so a package getting
|
|
60
|
+
# *measured* for the first time would read as one that had regressed. Two
|
|
61
|
+
# questions, two numbers.
|
|
62
|
+
[tool.coverage.run]
|
|
63
|
+
source = ["ph_runtime"]
|
|
64
|
+
branch = true
|
|
65
|
+
|
|
66
|
+
# One data file per process, merged afterwards: every cell is a fresh
|
|
67
|
+
# interpreter, and without this they would overwrite each other and the report
|
|
68
|
+
# would describe whichever guest exited last.
|
|
69
|
+
parallel = true
|
|
70
|
+
|
|
71
|
+
# **Absolute, from the environment, because the guest's cwd is not the repo.** A
|
|
72
|
+
# kernel spawns it in the session's workspace — a `tmp_path` that pytest deletes
|
|
73
|
+
# — so a relative `data_file` writes the measurements into a directory that is
|
|
74
|
+
# gone before anything can combine them. That is one of the two reasons this
|
|
75
|
+
# collection silently produced nothing for so long; the other is that nothing
|
|
76
|
+
# set `COVERAGE_PROCESS_START` at all.
|
|
77
|
+
data_file = "${PH_GUEST_COVERAGE_DATA}"
|
|
78
|
+
|
|
79
|
+
[tool.coverage.report]
|
|
80
|
+
# No `fail_under` yet, deliberately. The subprocess baseline is 84.1 %, and this
|
|
81
|
+
# number does *not* include this package's own in-process unit tests — those are
|
|
82
|
+
# measured by neither report. A floor belongs here once it means "every way
|
|
83
|
+
# `ph_runtime` is exercised"; the root manifest's own comment makes the argument
|
|
84
|
+
# about a number set from one measurement.
|
|
85
|
+
show_missing = true
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The guest half of pH's Python code runtime.
|
|
2
|
+
|
|
3
|
+
Runs inside `$PH_CACHE/runtime-venv`, in a subprocess the host spawns per agent,
|
|
4
|
+
and reaches the host over one framed channel on fd 3. It imports neither
|
|
5
|
+
`ph-core` nor `ph-rlm`: the process boundary exists so that model code cannot
|
|
6
|
+
reach the harness, and importing the harness would put it back inside.
|
|
7
|
+
|
|
8
|
+
@module ph_runtime
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .errors import RunStopped, ToolFailed
|
|
14
|
+
from .protocol import PROTOCOL_FD, PROTOCOL_VERSION
|
|
15
|
+
from .skill import wrap_skill_module
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"PROTOCOL_FD",
|
|
19
|
+
"PROTOCOL_VERSION",
|
|
20
|
+
"RunStopped",
|
|
21
|
+
"ToolFailed",
|
|
22
|
+
"wrap_skill_module",
|
|
23
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The narrowings `ph_runtime` needs from `ph.json` — a copy, pinned by a test.
|
|
2
|
+
|
|
3
|
+
This package ships into the guest venv with `dill` as its only dependency, so it
|
|
4
|
+
cannot import `ph.json`: that module lives in the ph-core wheel, and depending on
|
|
5
|
+
the wheel is the thing this package exists not to do.
|
|
6
|
+
|
|
7
|
+
**Copied by hand, compared character for character by
|
|
8
|
+
`test_protocol_mirror.py`.** What keeps the two in step is the test rather than
|
|
9
|
+
anyone's memory — that file opens by recording that *"a third copy that no test
|
|
10
|
+
compared had already drifted"*. When it fails, copy the definition across again;
|
|
11
|
+
when the guest comes to need a second narrowing, paste that one in and the test
|
|
12
|
+
picks it up by name.
|
|
13
|
+
|
|
14
|
+
The arrangement `truncation_marker` already has in `ph_runtime.protocol` — the
|
|
15
|
+
other thing this package copies rather than imports — though that one is held to
|
|
16
|
+
ph-core's by its output and this by its source, because a marker is a sentence
|
|
17
|
+
built from two numbers and a narrowing is its text.
|
|
18
|
+
|
|
19
|
+
Private, and re-exported by `ph_runtime.protocol` for the names the guest uses:
|
|
20
|
+
this is ph-core's code, not the guest's API.
|
|
21
|
+
|
|
22
|
+
@module ph_runtime._json
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def as_str(value: object, default: str = "") -> str:
|
|
29
|
+
"""A JSON string, or `default` — the fourth of the family, and the copied one.
|
|
30
|
+
|
|
31
|
+
One policy, the one `as_int`'s docstring argues for: a mis-shaped field
|
|
32
|
+
answers with the empty value rather than raising, because a reader of a log
|
|
33
|
+
some other build wrote must lose a row and not a session.
|
|
34
|
+
|
|
35
|
+
**Not `str(value)`**, which is what a reader writes without this and is worse
|
|
36
|
+
than useless: `str(None)` is `"None"` and `str(3)` is `"3"`, so a field that
|
|
37
|
+
is absent or of the wrong type comes back as a plausible-looking answer that
|
|
38
|
+
no assertion catches. Narrowing says "this was not a string" by giving back
|
|
39
|
+
nothing, which is the same thing `as_obj` and `as_seq` say.
|
|
40
|
+
"""
|
|
41
|
+
return value if isinstance(value, str) else default
|