pi-repl-py 0.1.0
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.
- package/ARCHITECTURE.md +141 -0
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/docs/how-to-functions.md +107 -0
- package/docs/philosophy.md +88 -0
- package/index.ts +220 -0
- package/package.json +57 -0
- package/scripts/setup-venv.mjs +71 -0
- package/src/engine/guest.py +317 -0
- package/src/engine/index.ts +656 -0
- package/src/engine/protocol.ts +66 -0
- package/src/engine/toolbox/bash.py +72 -0
- package/src/engine/toolbox/edit.py +37 -0
- package/src/engine/toolbox/read.py +26 -0
- package/src/engine/toolbox/write.py +23 -0
- package/src/extension/config.ts +65 -0
- package/src/extension/preview-core.ts +518 -0
- package/src/extension/render-core.ts +348 -0
- package/src/extension/render.ts +93 -0
- package/src/extension/session-engine.ts +155 -0
- package/src/extension/tool-meta.ts +58 -0
- package/src/extension/toolbox.ts +74 -0
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Two processes: the host lives inside pi, the guest owns the Python workspace.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pi
|
|
7
|
+
└─ extension (index.ts) registers `execute`, dormant until --repl
|
|
8
|
+
└─ EngineManager (src/engine/index.ts) spawn host: snapshots, teardown
|
|
9
|
+
│ stdin ──▶ protocol commands (run / snapshot / restore / ping)
|
|
10
|
+
│ fd 3 ◀── stream, done, snapshot_result, ...
|
|
11
|
+
└─ guest.py ▶ jupyter_client ▶ a real ipython kernel (subprocess)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The host is TypeScript; the evaluator is Python in its own process. Splitting
|
|
15
|
+
them is what makes a bad cell survivable: a cell can raise, leak memory, or
|
|
16
|
+
wedge the guest without taking pi down, and the host, being not the thing
|
|
17
|
+
that failed, always gets to report what happened.
|
|
18
|
+
|
|
19
|
+
## The Python environment (the venv)
|
|
20
|
+
|
|
21
|
+
The evaluator is a real `ipython` kernel, so it needs a Python environment with
|
|
22
|
+
`ipykernel` + `jupyter_client`. You cannot fake that with a script; it is a
|
|
23
|
+
hard runtime dependency.
|
|
24
|
+
|
|
25
|
+
When installed as a pi package, `npm install` runs `postinstall`
|
|
26
|
+
(`scripts/setup-venv.mjs`), which creates a stable per-user venv:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
~/.pi/agent/pi-repl-venv/bin/python3
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That path is stable across updates because it sits outside the ephemeral
|
|
33
|
+
package dir under `~/.pi/agent/npm`. If `python3` or the network is missing at
|
|
34
|
+
install time, postinstall prints a clear notice and the host falls back at
|
|
35
|
+
runtime.
|
|
36
|
+
|
|
37
|
+
At spawn, `resolvePythonPath` chooses the interpreter in order:
|
|
38
|
+
|
|
39
|
+
1. the repo's own `.venv` (development)
|
|
40
|
+
2. a cwd-local `.venv` (project)
|
|
41
|
+
3. `~/.pi/agent/pi-repl-venv` (package install)
|
|
42
|
+
4. `$PYTHON` or `python3`
|
|
43
|
+
|
|
44
|
+
The first existing one wins. The model is told (via `help()`) that it runs in a
|
|
45
|
+
project-local venv, not the system interpreter, so it does not leak the wrong
|
|
46
|
+
assumption into commands.
|
|
47
|
+
|
|
48
|
+
## The guest
|
|
49
|
+
|
|
50
|
+
`src/engine/guest.py` uses `jupyter_client.KernelManager` to start a real
|
|
51
|
+
`ipykernel` subprocess (`python -m ipykernel`), keeps a blocking client
|
|
52
|
+
attached, and stays alive for the whole session. Cells run in that kernel via
|
|
53
|
+
`kc.execute(code)`, so state persists because the kernel process does.
|
|
54
|
+
|
|
55
|
+
The wire protocol rides two channels, both load-bearing:
|
|
56
|
+
|
|
57
|
+
*Separation.* Protocol traffic uses a dedicated pipe (fd 3). The guest's real
|
|
58
|
+
stdout/stderr carry only user output, so a cell printing JSON cannot be parsed
|
|
59
|
+
as a protocol message.
|
|
60
|
+
|
|
61
|
+
*Authentication.* Every frame carries a nonce the host mints at spawn and the
|
|
62
|
+
guest erases from its environment before any cell runs. Code inside a cell
|
|
63
|
+
cannot recover it. Without this, a cell could announce its own completion and
|
|
64
|
+
claim success while failing — an agent that cannot trust its own results has
|
|
65
|
+
nothing.
|
|
66
|
+
|
|
67
|
+
## Toolbox loading
|
|
68
|
+
|
|
69
|
+
At boot the guest and the host both read the toolbox directory (default
|
|
70
|
+
`src/engine/toolbox`, overridden by config `toolboxDir` → env `PI_TOOLBOX_DIR`).
|
|
71
|
+
|
|
72
|
+
- **guest** execs each `*.py` into the kernel namespace, making functions
|
|
73
|
+
callable.
|
|
74
|
+
- **host** reads the same files to build the functions list on the `execute`
|
|
75
|
+
tool's `promptGuidelines` (and the tool `description`), so the model sees the
|
|
76
|
+
real signatures and one-line summaries.
|
|
77
|
+
|
|
78
|
+
The loader reads each file's `def (...)`: signature (authoritative) and its
|
|
79
|
+
`function_description = """..."""` (one-line summary, optional). Since both
|
|
80
|
+
sides read the same directory, a function in the prompt also exists in the
|
|
81
|
+
kernel. A file renamed with a `_` prefix is skipped by both, so a disabled
|
|
82
|
+
function is never advertised where it does not load. See
|
|
83
|
+
`docs/how-to-functions.md`.
|
|
84
|
+
|
|
85
|
+
The `promptGuidelines` are built once, when the `execute` tool is registered
|
|
86
|
+
(module load). A toolbox change therefore needs a **session restart / `/reload`**
|
|
87
|
+
to be reflected in the prompt — the kernel also loads the toolbox only at boot.
|
|
88
|
+
|
|
89
|
+
`ls()` and `help(name)` are built into the kernel (not toolbox files), so a
|
|
90
|
+
bare kernel still lets the model discover what is loaded.
|
|
91
|
+
|
|
92
|
+
## Snapshots & honest resets
|
|
93
|
+
|
|
94
|
+
After each successful cell the host schedules a debounced snapshot: it asks
|
|
95
|
+
the guest to pickle the kernel's globals (entry-by-entry so one bad value
|
|
96
|
+
costs only itself), and stores that as `namespace.snapshot` keyed to the
|
|
97
|
+
session file. On a fresh engine it restores, and whatever cannot be pickled
|
|
98
|
+
(live handles, some objects) is reported by name.
|
|
99
|
+
|
|
100
|
+
If the evaluator restarts, the result is prefixed with a `<rlm_engine_reset>`
|
|
101
|
+
block naming what was revived and what was lost, so the model re-verifies
|
|
102
|
+
before reuse rather than trusting state that is gone.
|
|
103
|
+
|
|
104
|
+
## Failure modes
|
|
105
|
+
|
|
106
|
+
| Failure | Behaviour |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| Cell throws | `done { status: "error" }` with traceback; kernel namespace intact |
|
|
109
|
+
| Kernel wedged | timeout → kill kernel subprocess → spawn fresh → restore snapshot |
|
|
110
|
+
| Guest process dies | pending calls settle; engine reports itself down; later calls reject |
|
|
111
|
+
| Host exits | guest is killed; on abrupt death it self-exits on stdin EOF |
|
|
112
|
+
| Output flood | capped per channel, truncation announced |
|
|
113
|
+
|
|
114
|
+
## Testing
|
|
115
|
+
|
|
116
|
+
- **Host (bun):** `test/units.test.ts` (protocol, render, config) +
|
|
117
|
+
`test/preview-core.test.ts`.
|
|
118
|
+
- **Evaluator (pytest):** `test/guest_contract.py` drives a real guest and
|
|
119
|
+
asserts persistence, error-survival, output attribution, snapshots, ls/help.
|
|
120
|
+
- **Integration (slow):** `test/engine.integration.test.ts` boots a real
|
|
121
|
+
engine + guest and proves a variable survives an engine restart.
|
|
122
|
+
|
|
123
|
+
Gate: `just check` = biome + bun test (host) + pytest (guest).
|
|
124
|
+
`just integration` adds the real-host seam.
|
|
125
|
+
|
|
126
|
+
## Configuration reference
|
|
127
|
+
|
|
128
|
+
Loaded from `~/.pi/agent/pi-repl.json` (or `$PI_REPL_CONFIG`), first-found-wins, never
|
|
129
|
+
throws on a missing/malformed file.
|
|
130
|
+
|
|
131
|
+
| Key | Type / default | Meaning |
|
|
132
|
+
| --- | --- | --- |
|
|
133
|
+
| `toolboxDir` | string, optional | Directory of one-function-per-`.py` files that replaces the shipped `src/engine/toolbox`. `~` is expanded; a bare relative path resolves from the process cwd (not reliable) — prefer an absolute path. |
|
|
134
|
+
| `pythonPath` | string, optional | The interpreter used to spawn the guest. Omit to use `resolvePythonPath` (see venv). |
|
|
135
|
+
| `timeoutMs` | number, 60000 | Per-cell execution timeout in ms. |
|
|
136
|
+
| `snapshotDebounceMs` | number, 1500 | Debounce after an ok cell before snapshot, in ms. |
|
|
137
|
+
|
|
138
|
+
## Reference documentation
|
|
139
|
+
|
|
140
|
+
- Philosophy and design rationale: [docs/philosophy.md](docs/philosophy.md)
|
|
141
|
+
- Adding a toolbox function: [docs/how-to-functions.md](docs/how-to-functions.md)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shift Labs
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# pi-repl
|
|
2
|
+
|
|
3
|
+
A [pi](https://pi.dev) extension that gives the agent a single `execute` tool backed by a
|
|
4
|
+
**persistent Python evaluator**: a real `ipython` kernel that keeps variables, functions, imports,
|
|
5
|
+
and data alive across every call and turn.
|
|
6
|
+
|
|
7
|
+
There is no interactive shell. The agent batches code into a Python workspace that lives for the
|
|
8
|
+
whole session, and only the printed result comes back. That is the part of a REPL an agent wants:
|
|
9
|
+
lasting state and code-as-a-workspace, without the interactive loop in the way.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
✓ repl · data = load_json("records.json") · done
|
|
13
|
+
✓ repl · avg = sum(v["score"] for v in data)/len(data)
|
|
14
|
+
✓ repl · print("mean score:", round(avg, 2)) · mean score: 41.7
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`data` is still there in cell three. Nothing was re-read, nothing re-derived from output, because
|
|
18
|
+
the kernel stayed alive.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# from a clone, one-time setup
|
|
24
|
+
just setup # npm install + a project-local .venv with the guest deps
|
|
25
|
+
|
|
26
|
+
# run a session
|
|
27
|
+
pi --repl
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A plain `pi` session is untouched; the extension is dormant until `--repl` is passed (or
|
|
31
|
+
`PI_REPL_FORCE=1`).
|
|
32
|
+
|
|
33
|
+
## Installing as a pi package
|
|
34
|
+
|
|
35
|
+
`npm install` runs a `postinstall` that creates the Python venv the evaluator needs, at a stable
|
|
36
|
+
per-user path (`~/.pi/agent/pi-repl-venv`). If `python3` or the network is missing, it prints a
|
|
37
|
+
clear notice. How the interpreter is resolved is in [docs/philosophy.md](docs/philosophy.md).
|
|
38
|
+
|
|
39
|
+
## What you get
|
|
40
|
+
|
|
41
|
+
- **A persistent namespace.** Variables, functions, imports, and data survive across cells and
|
|
42
|
+
turns; snapshots preserve them across a best-effort restart.
|
|
43
|
+
- **A real `ipython` kernel**, not a hand-rolled `exec` loop.
|
|
44
|
+
- **Shell as values.** `bash("git log --oneline")` returns a `CompletedProcess` you read
|
|
45
|
+
`.stdout`/`.stderr`/`.returncode` on.
|
|
46
|
+
- **Error survival.** A cell that throws reports the traceback and the kernel keeps going.
|
|
47
|
+
- **An honest evaluator.** If it restarts, it names what state it could revive and what it lost, so you don't trust memory that's gone.
|
|
48
|
+
|
|
49
|
+
## The toolbox
|
|
50
|
+
|
|
51
|
+
A small set of Python functions is preloaded into every kernel and surfaced to the
|
|
52
|
+
model through the `execute` tool's prompt guidance (their signatures + one-line
|
|
53
|
+
summaries are listed there, and `ls()`/`help()` discover them at runtime), so the
|
|
54
|
+
model can call `read`, `write`, `edit`, and `bash` without reimplementing them.
|
|
55
|
+
Set `toolboxDir` to point at your own folder.
|
|
56
|
+
|
|
57
|
+
The function list shown to the model is built when the `execute` tool is
|
|
58
|
+
registered, so changing the toolbox (adding/removing a file, renaming one with a
|
|
59
|
+
`_` prefix) needs a **session restart / `/reload`** for the prompt to reflect it —
|
|
60
|
+
the kernel also only loads the toolbox at boot.
|
|
61
|
+
|
|
62
|
+
- Adding a function (the file contract, docstrings, disabling): [docs/how-to-functions.md](docs/how-to-functions.md)
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
`~/.pi/agent/pi-repl.json` (or `$PI_REPL_CONFIG`) sets `toolboxDir`, `pythonPath`, and timeouts.
|
|
67
|
+
Full keys and path rules: [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
68
|
+
|
|
69
|
+
## More
|
|
70
|
+
|
|
71
|
+
- Why this design: [docs/philosophy.md](docs/philosophy.md)
|
|
72
|
+
- How it works, the venv, config reference: [ARCHITECTURE.md](ARCHITECTURE.md)
|
|
73
|
+
|
|
74
|
+
## It is not
|
|
75
|
+
|
|
76
|
+
- A sandbox. The kernel runs with your permissions; the toolbox trusts you.
|
|
77
|
+
- A subagent framework. There are no `rlm.run` subagents; spawn a process with `bash()`.
|
|
78
|
+
- A pi tool-rack. It is one `execute` tool with functions inside.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# How to add a toolbox function
|
|
2
|
+
|
|
3
|
+
A toolbox function is one `.py` file that pi-repl loads into every kernel and
|
|
4
|
+
surfaces to the model through the `execute` tool's prompt guidance (its
|
|
5
|
+
signature + one-line summary appears in `promptGuidelines`). Add a file, and it
|
|
6
|
+
shows up wherever the toolbox is read.
|
|
7
|
+
|
|
8
|
+
> **When a change shows up.** The kernel loads the toolbox at boot, and the
|
|
9
|
+
> `execute` tool builds its function list at registration (module load), so a
|
|
10
|
+
> toolbox change (add/remove a file, rename one with a `_` prefix) is picked up
|
|
11
|
+
> by a **session restart / `/reload`** — not mid-session.
|
|
12
|
+
|
|
13
|
+
## Where functions live
|
|
14
|
+
|
|
15
|
+
By default the extension ships four (`read`, `write`, `edit`, `bash`) in
|
|
16
|
+
`src/engine/toolbox/`. To use your **own** set, set `toolboxDir` in your
|
|
17
|
+
config:
|
|
18
|
+
|
|
19
|
+
```jsonc
|
|
20
|
+
// ~/.pi/agent/pi-repl.json
|
|
21
|
+
{ "toolboxDir": "~/.pi/agent/pi-repl-functions" }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Use an absolute path or a `~`-prefixed one (`~` expands to your home). A bare relative
|
|
25
|
+
path resolves from the process working directory, which is not reliable, so prefer
|
|
26
|
+
an absolute path for a stable per-user folder. Point `toolboxDir` at a directory and
|
|
27
|
+
every `*.py` there is loaded. Note: it **replaces** the shipped defaults; you do not
|
|
28
|
+
get built-ins plus yours, unless you copy the built-ins into your folder too.
|
|
29
|
+
|
|
30
|
+
## The file contract
|
|
31
|
+
|
|
32
|
+
Every toolbox file must:
|
|
33
|
+
|
|
34
|
+
1. have a `def` whose signature is the real call an agent would use, and
|
|
35
|
+
2. may declare `function_description` (a short one-line summary shown in the
|
|
36
|
+
prompt).
|
|
37
|
+
|
|
38
|
+
A minimal, valid file:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# pi-repl-functions/summarize.py
|
|
42
|
+
function_description = """Return a first-sentence summary of a text."""
|
|
43
|
+
|
|
44
|
+
__all__ = ["summarize"]
|
|
45
|
+
|
|
46
|
+
def summarize(text, limit=1):
|
|
47
|
+
return ". ".join(text.split(". ")[:limit]) + "."
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
That is everything. `summarize` loads into the kernel and the `execute` tool's
|
|
51
|
+
prompt guidance shows `summarize(text, limit=1)` after the next session restart.
|
|
52
|
+
|
|
53
|
+
## The two pieces the loader reads
|
|
54
|
+
|
|
55
|
+
**1. The signature comes from the `def`, not the description.**
|
|
56
|
+
Arguments are read from the actual `def` line rather than hand-copied into a
|
|
57
|
+
docstring, so the signature the model sees tracks the code for a normal
|
|
58
|
+
single-line signature. Change `def summarize(text,
|
|
59
|
+
limit=1):` to `limit=200`, and after the next session restart the prompt
|
|
60
|
+
updates to match.
|
|
61
|
+
|
|
62
|
+
**2. The description, from `function_description`, optional.**
|
|
63
|
+
Used as the one-line summary in the `execute` tool's prompt guidance. If you
|
|
64
|
+
omit it, the function is still advertised (by signature), just without a
|
|
65
|
+
one-liner.
|
|
66
|
+
|
|
67
|
+
Each file should also give the function a real docstring (the text under
|
|
68
|
+
`def`). That docstring is shown by `help(name)` in the kernel and carries the
|
|
69
|
+
deeper usage and gotchas. It does not go into the execute tool's prompt
|
|
70
|
+
guidance (only the one-line `function_description` does). Keep it for
|
|
71
|
+
details, the venv note, and edge cases.
|
|
72
|
+
|
|
73
|
+
## How much to document
|
|
74
|
+
|
|
75
|
+
`function_description` is the summary; the `def` docstring is the detail. A
|
|
76
|
+
good `function_description` is one line ("Run a shell command and return its
|
|
77
|
+
result."). A good docstring explains arguments, return value, and any
|
|
78
|
+
non-obvious behavior, including environment facts the model needs
|
|
79
|
+
("the evaluator runs in a project-local venv, not the system python").
|
|
80
|
+
|
|
81
|
+
## Disabling a file without deleting it
|
|
82
|
+
|
|
83
|
+
Rename the file to start with an underscore: `_test_helper.py`. The loader
|
|
84
|
+
**(and the execute tool's prompt guidance)** skip underscore-prefixed files, so
|
|
85
|
+
it never reaches the kernel or the model. Use this for scratch or internal
|
|
86
|
+
helpers.
|
|
87
|
+
|
|
88
|
+
## Good practice
|
|
89
|
+
|
|
90
|
+
- One function per file, name matches the function.
|
|
91
|
+
- Keep `function_description` one line. Everything else goes in the docstring.
|
|
92
|
+
- Let the signature carry the truth; the description says what it's *for*.
|
|
93
|
+
- A function that can hang (a shell call, network) should say so in its
|
|
94
|
+
docstring so the model knows the trade-off.
|
|
95
|
+
|
|
96
|
+
## Confirming it worked
|
|
97
|
+
|
|
98
|
+
At a `pi --repl` prompt, run a cell:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
print(ls()) # list what's loaded
|
|
102
|
+
print(help('summarize')) # signature + full docstring details
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
If `summarize` shows up in `ls()` and `help`, it loaded. The `execute` tool's
|
|
106
|
+
prompt guidance also lists it (same first-line summary) after the next session
|
|
107
|
+
restart.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Philosophy: why a persistent Python workspace
|
|
2
|
+
|
|
3
|
+
## The bet
|
|
4
|
+
|
|
5
|
+
Most coding agents are a pile of point tools. A read tool, a bash tool, an edit
|
|
6
|
+
tool, a find tool, each with its own schema, its own failure modes, its own
|
|
7
|
+
token cost to describe. The model spends context deciding *which* tool, then
|
|
8
|
+
*how* the output should be threaded into the next one.
|
|
9
|
+
|
|
10
|
+
pi-repl makes the opposite bet: **give the model one persistent Python
|
|
11
|
+
workspace and let it write the composition itself.** Configuration, state, and
|
|
12
|
+
file access all happen in code, in one living namespace. The model's interface
|
|
13
|
+
to the world never grows — the *code* it writes adapts instead.
|
|
14
|
+
|
|
15
|
+
That is a "REPL" the way an agent actually wants one. Not an interactive
|
|
16
|
+
lozenge to type into, but a long-lived working memory the model owns.
|
|
17
|
+
|
|
18
|
+
## What persistence buys
|
|
19
|
+
|
|
20
|
+
A separate-tool loop re-parses text every step. `read` returns a string, the
|
|
21
|
+
agent pastes it, `grep` returns lines, the agent re-reads them. Every
|
|
22
|
+
transformation is round-tripped through the transcript and billed as tokens.
|
|
23
|
+
|
|
24
|
+
In a persistent kernel, that work happens once and stays put:
|
|
25
|
+
|
|
26
|
+
- a variable assigned in one cell is there in the next, and the next turn;
|
|
27
|
+
- a function defined once is reusable for the whole session;
|
|
28
|
+
- `bash()` returns a real `subprocess.CompletedProcess`, not a transcript
|
|
29
|
+
snippet, so the agent branches on `.returncode` and slices `.stdout` with
|
|
30
|
+
normal code.
|
|
31
|
+
|
|
32
|
+
The savings compound harder for small models. Holding a whole file in context
|
|
33
|
+
to avoid re-reading it is expensive precisely when context is scarce; the
|
|
34
|
+
kernel lets the model aggregate, filter, and store in code, printing only what
|
|
35
|
+
the current step needs.
|
|
36
|
+
|
|
37
|
+
## Why a real kernel
|
|
38
|
+
|
|
39
|
+
pi-repl does not hand-roll an `exec` loop. It drives a genuine `ipython`
|
|
40
|
+
kernel in a subprocess via `jupyter_client`. That buys:
|
|
41
|
+
|
|
42
|
+
- rich, real tracebacks instead of a wrapped `except`;
|
|
43
|
+
- the full standard library and real `import` semantics;
|
|
44
|
+
- last-expression capture;
|
|
45
|
+
- a namespace that genuinely survives errors, instead of a script string passed
|
|
46
|
+
to `exec`.
|
|
47
|
+
|
|
48
|
+
And it is an honest isolation boundary: the kernel is a separate process from
|
|
49
|
+
pi. A cell can raise, or consume memory, or spin, and pi keeps answering because
|
|
50
|
+
pi is not the process that failed. The host restores from the last completed
|
|
51
|
+
snapshot and tells the model exactly what came back in a `<rlm_engine_reset>`
|
|
52
|
+
notice. More in `ARCHITECTURE.md`.
|
|
53
|
+
|
|
54
|
+
## The venv as part of the design
|
|
55
|
+
|
|
56
|
+
Because the evaluator is real Python, it needs a real Python environment with
|
|
57
|
+
`ipykernel` + `jupyter_client`. You cannot conjure that from nothing.
|
|
58
|
+
|
|
59
|
+
The package's `postinstall` creates it once, at a stable user path
|
|
60
|
+
(`~/.pi/agent/pi-repl-venv`), so a `pi install` ends with a working evaluator
|
|
61
|
+
and updates do not lose it (the venv is outside the ephemeral package dir
|
|
62
|
+
where it would vanish). At runtime the host resolves the interpreter in a
|
|
63
|
+
short deterministic order (repo venv, cwd venv, the install venv, then
|
|
64
|
+
`$PYTHON`/`python3`). The system interpreter is the fallback, never the
|
|
65
|
+
assumption, because the whole tool quietly breaks if it silently runs in the
|
|
66
|
+
wrong environment. This is a fact the toolbox functions' test in `help()`
|
|
67
|
+
exist to keep visible.
|
|
68
|
+
|
|
69
|
+
## Trust, not a sandbox
|
|
70
|
+
|
|
71
|
+
This is deliberately **not** a sandbox. The kernel runs with your user's
|
|
72
|
+
permissions, can read and write anywhere you can, and the toolbox is trusted
|
|
73
|
+
as written. If you need to guard against an untrusted model, this is the wrong
|
|
74
|
+
tool: reach for a real sandbox the way you would for any untrusted user code.
|
|
75
|
+
The philosophy prefers a sharp, honest tool over a pretend-safe one.
|
|
76
|
+
|
|
77
|
+
## What it isn't
|
|
78
|
+
|
|
79
|
+
- A subagent framework. There is no `rlm.run`. To delegate, the model spawns
|
|
80
|
+
a process with `bash()`.
|
|
81
|
+
- A drop-in pi-tool parcel. It exposes one `execute` tool; everything else is
|
|
82
|
+
inside that workspace.
|
|
83
|
+
- A replacement for your own editing/browsing tools required. It is there
|
|
84
|
+
when the working style above is worth it, dormant otherwise.
|
|
85
|
+
|
|
86
|
+
The trade-off is real and accepted: the agent pays a little more per-call to
|
|
87
|
+
hold a heavier environment, and it gets back far fewer re-reads, fewer
|
|
88
|
+
transcript round-trips, and sharper small-model behavior.
|
package/index.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// --- pi-repl: one execute tool over Python; everything else runs as functions inside it ---
|
|
2
|
+
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { EngineBusyError, EngineManager } from "./src/engine/index.js";
|
|
7
|
+
import { loadConfig } from "./src/extension/config.js";
|
|
8
|
+
import { ExecuteCellComponent, type ExecuteDetails, type ExecuteRenderState } from "./src/extension/render.js";
|
|
9
|
+
import { EngineLifecycle, summarizeNames } from "./src/extension/session-engine.js";
|
|
10
|
+
import { EXECUTE_DESCRIPTION, buildExecutePromptGuidelines, EXECUTE_PROMPT_SNIPPET } from "./src/extension/tool-meta.js";
|
|
11
|
+
|
|
12
|
+
const executeSchema = Type.Object({
|
|
13
|
+
code: Type.String({
|
|
14
|
+
description: "Python to execute in the persistent evaluator.",
|
|
15
|
+
}),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function syncRenderState(
|
|
19
|
+
state: Partial<ExecuteRenderState>,
|
|
20
|
+
context: {
|
|
21
|
+
args?: { code?: string };
|
|
22
|
+
isPartial: boolean;
|
|
23
|
+
isError: boolean;
|
|
24
|
+
expanded: boolean;
|
|
25
|
+
executionStarted: boolean;
|
|
26
|
+
},
|
|
27
|
+
): ExecuteRenderState {
|
|
28
|
+
state.code = context.args?.code ?? state.code ?? "";
|
|
29
|
+
state.isPartial = context.isPartial;
|
|
30
|
+
state.isError = context.isError;
|
|
31
|
+
state.expanded = context.expanded;
|
|
32
|
+
state.executionStarted = context.executionStarted;
|
|
33
|
+
state.hasResult = state.hasResult ?? false;
|
|
34
|
+
return state as ExecuteRenderState;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Stack lines kept when surfacing a cell error to the model. */
|
|
38
|
+
const ERROR_STACK_LINES = 10;
|
|
39
|
+
|
|
40
|
+
/** Header plus stack — without repeating the header when the stack already starts with it. */
|
|
41
|
+
function composeErrorLines(error: { name: string; message: string; stack: string[] }): string[] {
|
|
42
|
+
const header = `${error.name}: ${error.message}`;
|
|
43
|
+
const stack = error.stack.slice(0, ERROR_STACK_LINES);
|
|
44
|
+
return stack[0]?.trim() === header ? stack : [header, ...stack];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CFG = loadConfig();
|
|
48
|
+
|
|
49
|
+
export default function (pi: ExtensionAPI) {
|
|
50
|
+
pi.registerFlag("repl", {
|
|
51
|
+
type: "boolean",
|
|
52
|
+
description: "Single execute tool backed by a persistent Python evaluator; replaces the default tool surface",
|
|
53
|
+
});
|
|
54
|
+
// Flag only lands post-factory; gate per event. PI_REPL_FORCE is the dev escape.
|
|
55
|
+
const active = () => pi.getFlag("repl") === true || process.env.PI_REPL_FORCE === "1";
|
|
56
|
+
|
|
57
|
+
let location = { cwd: process.cwd(), sessionFile: undefined as string | undefined };
|
|
58
|
+
// Pi rebuilds thrown errors as bare text; stash details to re-attach in tool_result.
|
|
59
|
+
const pendingErrorResults = new Map<string, { details: ExecuteDetails }>();
|
|
60
|
+
|
|
61
|
+
const lifecycle = new EngineLifecycle<EngineManager>({
|
|
62
|
+
create() {
|
|
63
|
+
const { cwd, sessionFile } = location;
|
|
64
|
+
const sessionKey = sessionFile ? basename(sessionFile).replace(/\.jsonl$/, "") : undefined;
|
|
65
|
+
const stateDir = join(cwd, ".pi-repl", sessionKey ?? "ephemeral");
|
|
66
|
+
return new EngineManager({
|
|
67
|
+
cwd,
|
|
68
|
+
pythonPath: CFG.pythonPath,
|
|
69
|
+
timeoutMs: CFG.timeoutMs,
|
|
70
|
+
toolboxDir: CFG.toolboxDir,
|
|
71
|
+
// --- snapshots are keyed to a session file; ephemeral sessions get none ---
|
|
72
|
+
snapshot: sessionKey ? { path: join(stateDir, "namespace.snapshot") } : undefined,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
async dispose(engine) {
|
|
76
|
+
await engine.dispose();
|
|
77
|
+
},
|
|
78
|
+
// --- a wedged guest can't answer dispose; kill and rely on the last snapshot ---
|
|
79
|
+
async discard(engine) {
|
|
80
|
+
await engine.kill();
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// --- no custom prompt: pi's default prompt stands. session_start collapses
|
|
85
|
+
// the active set to just `execute`, so the default prompt's built-in
|
|
86
|
+
// read/bash/edit/write never appear. All REPL knowledge (description,
|
|
87
|
+
// promptSnippet, promptGuidelines) lives on the tool itself, not in a
|
|
88
|
+
// prompt builder. ---
|
|
89
|
+
|
|
90
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
91
|
+
if (!active()) {
|
|
92
|
+
// --- drop execute so a stock session stays stock ---
|
|
93
|
+
pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "execute"));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// --- active: the whole surface collapses to the one tool ---
|
|
97
|
+
pi.setActiveTools(["execute"]);
|
|
98
|
+
// --- revive the previous run; the engine also self-revives if session_start was skipped ---
|
|
99
|
+
location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile() ?? undefined };
|
|
100
|
+
const { restore } = await lifecycle.acquire("startup");
|
|
101
|
+
if (restore && restore.restored.length > 0) {
|
|
102
|
+
pi.sendMessage({
|
|
103
|
+
customType: "pi-repl-restore",
|
|
104
|
+
content: `Revived ${restore.restored.length} variable(s) from the previous run: ${summarizeNames(restore.restored, 8)}${
|
|
105
|
+
restore.failed.length > 0
|
|
106
|
+
? `. Failed: ${summarizeNames(
|
|
107
|
+
restore.failed.map((f) => f.name),
|
|
108
|
+
8,
|
|
109
|
+
)}`
|
|
110
|
+
: ""
|
|
111
|
+
}`,
|
|
112
|
+
display: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
pi.on("session_shutdown", async () => {
|
|
118
|
+
await lifecycle.shutdown();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
pi.on("tool_result", async (event) => {
|
|
122
|
+
if (event.toolName !== "execute") return undefined;
|
|
123
|
+
const stashed = pendingErrorResults.get(event.toolCallId);
|
|
124
|
+
pendingErrorResults.delete(event.toolCallId);
|
|
125
|
+
if (!stashed || !event.isError) return undefined;
|
|
126
|
+
// --- restore the collapsed details an errored cell lost ---
|
|
127
|
+
return { content: event.content, details: stashed.details, isError: true };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
pi.registerTool<typeof executeSchema, ExecuteDetails, Partial<ExecuteRenderState>>({
|
|
131
|
+
name: "execute",
|
|
132
|
+
label: "execute",
|
|
133
|
+
description: EXECUTE_DESCRIPTION,
|
|
134
|
+
promptSnippet: EXECUTE_PROMPT_SNIPPET,
|
|
135
|
+
promptGuidelines: buildExecutePromptGuidelines(CFG.toolboxDir),
|
|
136
|
+
parameters: executeSchema,
|
|
137
|
+
renderShell: "self",
|
|
138
|
+
renderCall(args, theme, context) {
|
|
139
|
+
const state = syncRenderState(context.state, { ...context, args });
|
|
140
|
+
// --- compact header lives in the call slot ---
|
|
141
|
+
return new ExecuteCellComponent(state, theme, "header");
|
|
142
|
+
},
|
|
143
|
+
renderResult(result, options, _theme, context) {
|
|
144
|
+
const state = syncRenderState(context.state, context);
|
|
145
|
+
state.hasResult = true;
|
|
146
|
+
state.isPartial = options.isPartial;
|
|
147
|
+
state.expanded = options.expanded;
|
|
148
|
+
state.details = (result.details as ExecuteDetails | undefined) ?? state.details;
|
|
149
|
+
state.contentText = result.content
|
|
150
|
+
?.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
151
|
+
.map((block) => block.text)
|
|
152
|
+
.join("\n");
|
|
153
|
+
// --- body (code + output) lives in the result slot; Ctrl+O expands it ---
|
|
154
|
+
return new ExecuteCellComponent(state, _theme, "body");
|
|
155
|
+
},
|
|
156
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
157
|
+
if (!active()) {
|
|
158
|
+
throw new Error("pi-repl is dormant in this session. Start pi with --repl (or PI_REPL_FORCE=1) to use execute.");
|
|
159
|
+
}
|
|
160
|
+
if (ctx?.cwd) location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager?.getSessionFile?.() ?? undefined };
|
|
161
|
+
// --- previous engine died mid-session; acquire revives it ---
|
|
162
|
+
const { engine: m } = await lifecycle.acquire("cell");
|
|
163
|
+
try {
|
|
164
|
+
// --- accumulate partial updates so the row height doesn't oscillate ---
|
|
165
|
+
let streamed = "";
|
|
166
|
+
const r = await m.execute(params.code, {
|
|
167
|
+
signal,
|
|
168
|
+
onStream: (chunk) => {
|
|
169
|
+
streamed += chunk;
|
|
170
|
+
onUpdate?.({ content: [{ type: "text", text: streamed }], details: {} });
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
// --- reset notice leads so the model reads that its namespace was rebuilt ---
|
|
174
|
+
const sections = [lifecycle.takeResetNotice(), r.stdout, r.stderr, r.result];
|
|
175
|
+
const errorLines = r.error ? composeErrorLines(r.error) : undefined;
|
|
176
|
+
if (r.status === "error" && errorLines) sections.push(errorLines.join("\n"));
|
|
177
|
+
if (r.status === "aborted") sections.push("[cell aborted]");
|
|
178
|
+
const text = sections.filter((section) => section !== undefined && section !== "").join("\n");
|
|
179
|
+
|
|
180
|
+
const details: ExecuteDetails = {
|
|
181
|
+
status: r.status,
|
|
182
|
+
durationMs: r.durationMs,
|
|
183
|
+
errorName: r.error?.name,
|
|
184
|
+
stdout: r.stdout || undefined,
|
|
185
|
+
stderr: r.stderr || undefined,
|
|
186
|
+
result: r.result,
|
|
187
|
+
errorStack: errorLines,
|
|
188
|
+
};
|
|
189
|
+
const result = { content: [{ type: "text" as const, text: text || "(no output)" }], details };
|
|
190
|
+
if (r.status === "error") {
|
|
191
|
+
pendingErrorResults.set(toolCallId, { details });
|
|
192
|
+
throw new Error(text || "(no output)");
|
|
193
|
+
}
|
|
194
|
+
if (r.status === "aborted") {
|
|
195
|
+
// A cancelled cell's kernel may still be executing work the guest
|
|
196
|
+
// single-threaded loop can't interrupt. Discard the engine so the
|
|
197
|
+
// NEXT run gets a fresh kernel instead of queuing behind the
|
|
198
|
+
// still-busy one (same class as the stalled-timeout recovery).
|
|
199
|
+
await lifecycle.discard();
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (error instanceof EngineBusyError) {
|
|
204
|
+
// --- discard the wedged engine; the next cell revives from the last snapshot ---
|
|
205
|
+
await lifecycle.discard();
|
|
206
|
+
throw new Error(
|
|
207
|
+
"The evaluator was wedged by a previously interrupted cell and has been killed. " +
|
|
208
|
+
"Run the next cell to get a fresh evaluator revived from the last snapshot; " +
|
|
209
|
+
"anything newer than that snapshot is gone, so re-verify variables before reusing them.",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
// --- a guest that died leaves the engine shutdown; drop it so the next cell rebuilds fresh ---
|
|
213
|
+
if (m.isRunning === false) {
|
|
214
|
+
await lifecycle.discard();
|
|
215
|
+
}
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|