The agent's action substrate: an embedded CPython the model writes Python for.
A SESSION here owns one worker process and one embedded interpreter. Its sandbox namespace and trusted extension namespaces share that interpreter, while another session has another process, import table and native-library cache. A one-shot CLI session may run in the process it already owns.
Three rules shape everything below. One dialect crosses the boundary:
JSON, in both directions — host to guest is a json.loads of a literal this
namespace renders, guest to host is python-host's envelope. The guest's
Python is a FILE, never a string built here: the runtime ships the sandbox
runtime, auto-imports, network probe and process redirect as modules it imports,
and Vis' own guest code lives under resources/vis-guest/. The boundaries are
native: the worker's OS jail owns filesystem and egress confinement, while the
runtime audit hook is the fail-closed file/socket backstop before policy setup.
Python modules provide ergonomics, never security.
Public surface used by the loop:
create-python-context / dispose-python-context! / retire-python-context! / interrupt-guest! / take-partial-block-stdout! / python-worker-pids / set-python-binding! / bind-and-bump! / count-top-level-forms / validate-no-banned-defs! / run-python-block / persist-session-defs! / restore-session-defs! / forget-session-defs! / SYSTEM_VAR_NAMES / system-var-sym? / boundary-view / ctx->python-str / bind-ctx!
The agent's action substrate: an embedded CPython the model writes Python for. A SESSION here owns one worker process and one embedded interpreter. Its sandbox namespace and trusted extension namespaces share that interpreter, while another session has another process, import table and native-library cache. A one-shot CLI session may run in the process it already owns. Three rules shape everything below. **One dialect crosses the boundary:** JSON, in both directions — host to guest is a `json.loads` of a literal this namespace renders, guest to host is `python-host`'s envelope. **The guest's Python is a FILE**, never a string built here: the runtime ships the sandbox runtime, auto-imports, network probe and process redirect as modules it imports, and Vis' own guest code lives under `resources/vis-guest/`. **The boundaries are native:** the worker's OS jail owns filesystem and egress confinement, while the runtime audit hook is the fail-closed file/socket backstop before policy setup. Python modules provide ergonomics, never security. Public surface used by the loop: create-python-context / dispose-python-context! / retire-python-context! / interrupt-guest! / take-partial-block-stdout! / python-worker-pids / set-python-binding! / bind-and-bump! / count-top-level-forms / validate-no-banned-defs! / run-python-block / persist-session-defs! / restore-session-defs! / forget-session-defs! / SYSTEM_VAR_NAMES / system-var-sym? / boundary-view / ctx->python-str / bind-ctx!
Project-local Python extensions — trusted-context plug-ins.
Vis extensions are normally Clojure libraries baked into the binary at
build time. This namespace adds a second, fully dynamic authoring path:
drop a *.py file into
~/.vis/extensions/ (global — every project) <project>/.vis/extensions/ (project-local — this project only)
and it loads at startup (and on /reload) in BOTH the JVM and the
native image — the extension is Python all the way down, so nothing here
defines a class at runtime.
Each file is first evaluated in a TRUSTED gateway-wide registration namespace. When one of its callables runs for a gateway session, the extension is realized again in the session's separate trusted worker. The sandbox has its own process, interpreter and host-call connection. Extension imports and native-library state are shared only with other trusted extensions in that session. Another session owns another pair of processes.
The model can call an extension TOOL through the ordinary host wrapper, envelope-
checked like any tool, but cannot choose the trusted identity or evaluate code in
the extension namespace. The blockether.vis.extension API exposes host capabilities;
only sealed data crosses the boundary, never live Python or host objects.
Startup, reload and calls with no owning session use the shared registration worker.
A context is only a NAMESPACE in its owning embedded interpreter: opening one costs
a dict, and closing one drops the extension's Python with it. Calls into an extension
(tool, activation, prompt, slash, op hook) are serialized with locking on its
session name, the same proven pattern as the printer context.
The file's top-level vis.register(vis.Extension(...)) call registers through the
ordinary register-extension! — from the registry's perspective a
Python extension is indistinguishable from a Clojure one (activation,
prompt assembly, slash dispatch, vis-agent extension list all just work).
A file that fails to load becomes a load-failure warning (surfaced via
vis-agent doctor), never a crash.
Project-local Python extensions — trusted-context plug-ins. Vis extensions are normally Clojure libraries baked into the binary at build time. This namespace adds a second, fully dynamic authoring path: drop a `*.py` file into ~/.vis/extensions/ (global — every project) <project>/.vis/extensions/ (project-local — this project only) and it loads at startup (and on `/reload`) in BOTH the JVM and the native image — the extension is Python all the way down, so nothing here defines a class at runtime. Each file is first evaluated in a TRUSTED gateway-wide registration namespace. When one of its callables runs for a gateway session, the extension is realized again in the session's separate trusted worker. The sandbox has its own process, interpreter and host-call connection. Extension imports and native-library state are shared only with other trusted extensions in that session. Another session owns another pair of processes. The model can call an extension TOOL through the ordinary host wrapper, envelope- checked like any tool, but cannot choose the trusted identity or evaluate code in the extension namespace. The `blockether.vis.extension` API exposes host capabilities; only sealed data crosses the boundary, never live Python or host objects. Startup, reload and calls with no owning session use the shared registration worker. A context is only a NAMESPACE in its owning embedded interpreter: opening one costs a dict, and closing one drops the extension's Python with it. Calls into an extension (tool, activation, prompt, slash, op hook) are serialized with `locking` on its session name, the same proven pattern as the printer context. The file's top-level `vis.register(vis.Extension(...))` call registers through the ordinary `register-extension!` — from the registry's perspective a Python extension is indistinguishable from a Clojure one (activation, prompt assembly, slash dispatch, `vis-agent extension list` all just work). A file that fails to load becomes a load-failure warning (surfaced via `vis-agent doctor`), never a crash.
Beautify model-emitted Python with ruff (com.blockether/ruff — in-process via the JDK FFM API, black-compatible) before it is shown. Used by the gateway's code renderer so the trace shows tidy, consistently-wrapped Python instead of the model's raw one-liners.
CACHED: ruff output is deterministic for a given input, and the same code block is rendered many times (pinned trace + live SSE re-emits + reconnect replay), so an LRU memo means each distinct block formats exactly once.
SAFE: ruff/format-or returns the source verbatim if ruff is unavailable
(e.g. the native lib isn't bundled in a particular build) or the code doesn't
parse — the original is never lost.
Beautify model-emitted Python with ruff (com.blockether/ruff — in-process via the JDK FFM API, black-compatible) before it is shown. Used by the gateway's code renderer so the trace shows tidy, consistently-wrapped Python instead of the model's raw one-liners. CACHED: ruff output is deterministic for a given input, and the same code block is rendered many times (pinned trace + live SSE re-emits + reconnect replay), so an LRU memo means each distinct block formats exactly once. SAFE: `ruff/format-or` returns the source verbatim if ruff is unavailable (e.g. the native lib isn't bundled in a particular build) or the code doesn't parse — the original is never lost.
THE door from the sandbox back into Vis, and the only one.
The embedded interpreter calls out through ONE function it was handed, so everything a block can ask the host for arrives here as two strings: the name of a tool and a JSON envelope of its arguments. What crosses is DATA - a tool takes plain values and answers plain values, because the boundary carries text and nothing else. A live object (an interpreter handle, a stream, a file) crossed it and never will; a handle the model holds is a PYTHON object built over calls that come back through here.
One interpreter serves every session in this process, so the envelope names
the session that called (vis-python-runtime's install) and this registry
answers per session: shell bound for two workspaces is two different
functions under one name, and a session that never bound a name gets a
refusal that says so instead of a neighbour's tool.
The host is bound ONCE per process. Binding is idempotent because the
interpreter is: a second bind! would replace a live upcall stub while a
block sits inside it.
THE door from the sandbox back into Vis, and the only one. The embedded interpreter calls out through ONE function it was handed, so everything a block can ask the host for arrives here as two strings: the name of a tool and a JSON envelope of its arguments. What crosses is DATA - a tool takes plain values and answers plain values, because the boundary carries text and nothing else. A live object (an interpreter handle, a stream, a file) crossed it and never will; a handle the model holds is a PYTHON object built over calls that come back through here. One interpreter serves every session in this process, so the envelope names the session that called (`vis-python-runtime`'s `install`) and this registry answers per session: `shell` bound for two workspaces is two different functions under one name, and a session that never bound a name gets a refusal that says so instead of a neighbour's tool. The host is bound ONCE per process. Binding is idempotent because the interpreter is: a second `bind!` would replace a live upcall stub while a block sits inside it.
What a Python project DECLARES about its own layout: import roots (the src
layout every packaging backend spells differently) and pytest's testpaths.
The metadata is parsed by PYTHON'S OWN parsers inside a Python session --
tomllib for pyproject.toml, configparser for setup.cfg / pytest.ini
/ tox.ini -- never a regex over the file text. The Python side
(resources/vis-python/project_config.py) returns RAW declared strings;
everything host-shaped (~ expansion, resolution against the project dir,
existence, canonicalisation, dedup, python.source_paths config) lives here.
Inference is purely declarative: a project without such metadata gets
nothing inferred, and python.source_paths is how a user says it outright.
Every failure degrades to nothing rather than breaking the caller.
What a Python project DECLARES about its own layout: import roots (the `src` layout every packaging backend spells differently) and pytest's `testpaths`. The metadata is parsed by PYTHON'S OWN parsers inside a Python session -- `tomllib` for `pyproject.toml`, `configparser` for `setup.cfg` / `pytest.ini` / `tox.ini` -- never a regex over the file text. The Python side (`resources/vis-python/project_config.py`) returns RAW declared strings; everything host-shaped (`~` expansion, resolution against the project dir, existence, canonicalisation, dedup, `python.source_paths` config) lives here. Inference is purely declarative: a project without such metadata gets nothing inferred, and `python.source_paths` is how a user says it outright. Every failure degrades to nothing rather than breaking the caller.
Getting the embedded CPython onto THIS machine.
The interpreter is a DIRECTORY — a cdylib plus the vendored standard library
beside it, tens of megabytes — so it is neither a maven dependency nor a
resource inside a jar. A native distribution stages it beside the binary and
the wrapper points VIS_PYTHON_NATIVE_PATH at it; everywhere else (a source
checkout, a gateway running from clojure -M:vis) it is fetched ONCE from the
runtime's own GitHub release into ~/.vis/python/runtime/<version>/<platform>/
and named through runtime/use-library!, because a JVM cannot set its own
environment.
Resolution that already answers is never disturbed: an environment variable,
a staged distribution and a built checkout all win over the network. The
archive is unpacked into a sibling directory and MOVED into place, so a second
process sees a complete installation or none — never a half-written standard
library — and tar does the unpacking because the tree carries symlinks and
execute bits that no jar or zip round-trips.
Getting the embedded CPython onto THIS machine. The interpreter is a DIRECTORY — a cdylib plus the vendored standard library beside it, tens of megabytes — so it is neither a maven dependency nor a resource inside a jar. A native distribution stages it beside the binary and the wrapper points `VIS_PYTHON_NATIVE_PATH` at it; everywhere else (a source checkout, a gateway running from `clojure -M:vis`) it is fetched ONCE from the runtime's own GitHub release into `~/.vis/python/runtime/<version>/<platform>/` and named through `runtime/use-library!`, because a JVM cannot set its own environment. Resolution that already answers is never disturbed: an environment variable, a staged distribution and a built checkout all win over the network. The archive is unpacked into a sibling directory and MOVED into place, so a second process sees a complete installation or none — never a half-written standard library — and `tar` does the unpacking because the tree carries symlinks and execute bits that no jar or zip round-trips.
Runs an extension author's Python tests (test_*.py / *_test.py) through
real pytest, each in its own TRUSTED Python worker session
(same trust level as the extension it covers). Tests import the extension's
own package through the SAME sys.path sugar the loader gives extension.py,
so an author ships real Python tests next to the code and runs them with the
project's own tooling. The pytest collector emits structured records, and
the test host refuses session live views so a test cannot publish artifacts.
Split out of python-extensions (which owns loading/registration) so the
runner is a single, testable responsibility. It depends on that namespace's
trusted-context builder; the reverse /test wiring is resolved lazily there
to avoid a require cycle.
The source of truth for the outcome is the collector's PER-TEST record list (nodeid, outcome, message). Counts and pass/fail are DERIVED from those records on the host side — never a separate tally that could drift, and never scraped from stdout.
Runs an extension author's Python tests (`test_*.py` / `*_test.py`) through real `pytest`, each in its own TRUSTED Python worker session (same trust level as the extension it covers). Tests import the extension's own package through the SAME `sys.path` sugar the loader gives `extension.py`, so an author ships real Python tests next to the code and runs them with the project's own tooling. The pytest collector emits structured records, and the test host refuses session live views so a test cannot publish artifacts. Split out of `python-extensions` (which owns loading/registration) so the runner is a single, testable responsibility. It depends on that namespace's trusted-context builder; the reverse `/test` wiring is resolved lazily there to avoid a require cycle. The source of truth for the outcome is the collector's PER-TEST record list (nodeid, outcome, message). Counts and pass/fail are DERIVED from those records on the host side — never a separate tally that could drift, and never scraped from stdout.
Process boundaries for model Python and trusted Python extensions.
Each session's sandbox owns a confined runtime worker. Its trusted extension namespaces run in a separate worker, preserving their host APIs and native library support without sharing interpreter memory or host-call authority with model code. Registration outside a session uses the shared trusted worker.
Confinement, imports, native libraries and interpreter state are process-wide. A per-session extension worker keeps that state separate from other sessions as well as from the sandbox. Only the host chooses a worker's role.
The wire is ONE line of JSON per message over a unix socket, both ways. The
parent asks (install-runtime, install-tool, exec, run, run-block,
eval, confine, network, stdin, interrupt, close); the child asks
back with host, because the registry that knows what a name may call, the
persistence handle and the caller's dynamic binding frame all live in the
parent (python-host/dispatch). stdout is NOT the wire: Python that prints,
or a native library writing to fd 1, would corrupt it, so a child's own stdio
goes to a log file instead.
A message carrying op is a request, one without is its reply, so each side
numbers its own requests and no id can collide. Work has no timeout: a block
or extension tool may legitimately run for minutes. CONTROL is different:
an interrupt that cannot reach the child is bounded, because cancellation
must be able to retire that process instead of parking its caller forever.
A child that DIES is what the pump reports — every call waiting on it fails
at once with the child's log to read.
An interrupt reaches BOTH ends of a host call. The child's async exception
lands only when the guest next runs Python, and a guest parked in host
(a shell handle's wait, a long tool) runs none until the parent answers —
so interrupt! also fails every host call the worker has in flight, and the
tool thread serving it is interrupted. Measured before that: the unwind
watch expired, the worker was killed, and the next block silently started a
FRESH interpreter that had the runtime but none of the session's tools. A
retired key now refuses to restart until the session is rebuilt.
Process boundaries for model Python and trusted Python extensions. Each session's sandbox owns a confined runtime worker. Its trusted extension namespaces run in a separate worker, preserving their host APIs and native library support without sharing interpreter memory or host-call authority with model code. Registration outside a session uses the shared trusted worker. Confinement, imports, native libraries and interpreter state are process-wide. A per-session extension worker keeps that state separate from other sessions as well as from the sandbox. Only the host chooses a worker's role. The wire is ONE line of JSON per message over a unix socket, both ways. The parent asks (`install-runtime`, `install-tool`, `exec`, `run`, `run-block`, `eval`, `confine`, `network`, `stdin`, `interrupt`, `close`); the child asks back with `host`, because the registry that knows what a name may call, the persistence handle and the caller's dynamic binding frame all live in the parent (`python-host/dispatch`). stdout is NOT the wire: Python that prints, or a native library writing to fd 1, would corrupt it, so a child's own stdio goes to a log file instead. A message carrying `op` is a request, one without is its reply, so each side numbers its own requests and no id can collide. Work has no timeout: a block or extension tool may legitimately run for minutes. CONTROL is different: an interrupt that cannot reach the child is bounded, because cancellation must be able to retire that process instead of parking its caller forever. A child that DIES is what the pump reports — every call waiting on it fails at once with the child's log to read. An interrupt reaches BOTH ends of a host call. The child's async exception lands only when the guest next runs Python, and a guest parked in `host` (a shell handle's `wait`, a long tool) runs none until the parent answers — so `interrupt!` also fails every host call the worker has in flight, and the tool thread serving it is interrupted. Measured before that: the unwind watch expired, the worker was killed, and the next block silently started a FRESH interpreter that had the runtime but none of the session's tools. A retired key now refuses to restart until the session is rebuilt.
Host-side client of the runtime worker's JSON-line protocol. Dispatch and cancellation stay in Vis; interpreter operations live in the runtime.
Host-side client of the runtime worker's JSON-line protocol. Dispatch and cancellation stay in Vis; interpreter operations live in the runtime.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |