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.
@@ -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,10 @@
1
+ """`python -m ph_runtime` — how the host spawns the guest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .runner import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -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