Embedded-GraalPy sandbox machinery — the agent's action substrate. The agent
writes Python; this ns embeds a GraalPy org.graalvm.polyglot.Context,
marshals values across the Clojure↔Python boundary, wires the Clojure tool
fns into the Python globals as ProxyExecutables (so cat("x") in Python
runs the Clojure cat), and runs the model's code block as ONE whole-block
coroutine.
Public surface used by the loop:
create-python-context / set-python-binding! / bind-and-bump! / bind-and-bump-with-doc! / push-eval-result! / push-eval-error! / reset-eval-bindings! / count-top-level-forms / validate-non-empty-block! / validate-no-banned-defs! / restore-sandbox! / SYSTEM_VAR_NAMES / system-var-sym? / lru-atom / current-turn-position / fresh-lru-atom / run-python-block / map-polyglot-error / bind-ctx! / ctx->python-str
The :python-context slot holds the GraalPy Context; the Python top scope is
context.getBindings("python"). GraalPy ships in the default deps (runs on
Oracle GraalVM 25 → Truffle gets the Graal JIT).
Embedded-GraalPy sandbox machinery — the agent's action substrate. The agent
writes **Python**; this ns embeds a GraalPy `org.graalvm.polyglot.Context`,
marshals values across the Clojure↔Python boundary, wires the Clojure tool
fns into the Python globals as `ProxyExecutable`s (so `cat("x")` in Python
runs the Clojure `cat`), and runs the model's code block as ONE whole-block
coroutine.
Public surface used by the loop:
create-python-context / set-python-binding! / bind-and-bump! /
bind-and-bump-with-doc! / push-eval-result! / push-eval-error! /
reset-eval-bindings! / count-top-level-forms / validate-non-empty-block! /
validate-no-banned-defs! / restore-sandbox! / SYSTEM_VAR_NAMES /
system-var-sym? / *lru-atom* / *current-turn-position* / fresh-lru-atom /
run-python-block / map-polyglot-error / bind-ctx! / ctx->python-str
The `:python-context` slot holds the GraalPy `Context`; the Python top scope is
`context.getBindings("python")`. GraalPy ships in the default deps (runs on
Oracle GraalVM 25 → Truffle gets the Graal JIT).When true, a bracket-balance syntax hint ALSO appends repair-bracket-balance's
single-candidate suggested fix. OFF by default: the walker only DIAGNOSES; the
auto-fix stays gated behind this flag until proven safe in the wild.
When true, a bracket-balance syntax hint ALSO appends `repair-bracket-balance`'s single-candidate suggested fix. OFF by default: the walker only DIAGNOSES; the auto-fix stays gated behind this flag until proven safe in the wild.
(->clj v)Polyglot Value (a Python value) -> Clojure data. STRINGS-ONLY boundary:
dicts -> maps with VERBATIM STRING keys (exactly what Python held — no
keywordizing, no regex key-shape sniffing), lists/tuples -> vectors, host
objects (Java values that crossed the boundary, e.g. UUIDs) -> their
underlying Java value via asHostObject, callables/opaque objects -> the
raw Value. A non-string Python dict key (int, tuple, ...) stringifies via
its Clojure conversion so the map stays string-keyed and total.
Polyglot `Value` (a Python value) -> Clojure data. STRINGS-ONLY boundary: dicts -> maps with VERBATIM STRING keys (exactly what Python held — no keywordizing, no regex key-shape sniffing), lists/tuples -> vectors, host objects (Java values that crossed the boundary, e.g. UUIDs) -> their underlying Java value via `asHostObject`, callables/opaque objects -> the raw `Value`. A non-string Python dict key (int, tuple, ...) stringifies via its Clojure conversion so the map stays string-keyed and total.
(->py x)Clojure value -> something GraalPy accepts as a Python value. STRINGS-ONLY
boundary: map keys must be strings and no keyword/symbol may appear at any
depth — a violation throws boundary-violation! naming the key path.
Primitives and Strings pass through (the Context auto-converts Java boxed
types); collections become polyglot proxies so Python sees dict/list;
leaves convert via leaf->py (UUID/Temporal/Date -> ISO strings).
Clojure value -> something GraalPy accepts as a Python value. STRINGS-ONLY boundary: map keys must be strings and no keyword/symbol may appear at any depth — a violation throws `boundary-violation!` naming the key path. Primitives and Strings pass through (the Context auto-converts Java boxed types); collections become polyglot proxies so Python sees dict/list; leaves convert via `leaf->py` (UUID/Temporal/Date -> ISO strings).
Python constructs refused pre-eval — belt-and-suspenders against the obvious sandbox-escape footguns on top of the Context restrictions.
Python constructs refused pre-eval — belt-and-suspenders against the obvious sandbox-escape footguns on top of the Context restrictions.
(bind-and-bump! env sym val)Set sym -> val in the env's Python sandbox.
Set `sym` -> `val` in the env's Python sandbox.
(bind-and-bump-with-doc! env sym doc val)Like bind-and-bump! but also records doc in the side __vis_docs__ dict
so a future live-vars view can surface name + doc (Python has no var
metadata channel for doc text).
Like `bind-and-bump!` but also records `doc` in the side `__vis_docs__` dict so a future live-vars view can surface name + doc (Python has no var metadata channel for doc text).
(bind-ctx! python-context data)Bind the standing context in the sandbox as an ordered polyglot dict (->py
→ ProxyHashMap, which GraalPy treats as a NATIVE dict: session["k"], .get,
.items(), comprehensions and {**ctx} all work) built from the SAME
projection the renderer prints — so the live dict and the wire's
session["a"]["b"] = … structural deltas agree. Bound under session only —
decoupled from r, no legacy context alias. No JSON round-trip.
Bind the standing context in the sandbox as an ordered polyglot dict (`->py`
→ `ProxyHashMap`, which GraalPy treats as a NATIVE `dict`: `session["k"]`, `.get`,
`.items()`, comprehensions and `{**ctx}` all work) built from the SAME
projection the renderer prints — so the live dict and the wire's
`session["a"]["b"] = …` structural deltas agree. Bound under `session` only —
decoupled from `r`, no legacy `context` alias. No JSON round-trip.(boundary-view x)(boundary-view x path)What a plain-data Clojure value LOOKS LIKE after the GraalPy round trip —
the mechanical composition of ->py then ->clj without a Python context.
STRINGS-ONLY: string map keys pass VERBATIM, sets/seqs -> vectors,
UUID/Temporal/Date leaves -> ISO strings. A keyword/symbol anywhere (key or
value, any depth) throws boundary-violation! exactly like the real
boundary — fix the producer fixture, never catch it. Idempotent.
Every tool result the model sees in production (serialized structurally
by ctx-renderer/render-form-value) has already crossed this boundary,
so assertions about what the model reads MUST be written against THIS
shape. Tests feed (boundary-view raw-result) to pin that contract
without booting GraalPy.
What a plain-data Clojure value LOOKS LIKE after the GraalPy round trip — the mechanical composition of `->py` then `->clj` without a Python context. STRINGS-ONLY: string map keys pass VERBATIM, sets/seqs -> vectors, UUID/Temporal/Date leaves -> ISO strings. A keyword/symbol anywhere (key or value, any depth) throws `boundary-violation!` exactly like the real boundary — fix the producer fixture, never catch it. Idempotent. Every tool result the model sees in production (serialized structurally by `ctx-renderer/render-form-value`) has already crossed this boundary, so assertions about what the model reads MUST be written against THIS shape. Tests feed `(boundary-view raw-result)` to pin that contract without booting GraalPy.
(boundary-violation! kind x path)Throw on a keyword/symbol trying to cross the Clojure->Python boundary.
The boundary is STRINGS-ONLY: every map that crosses (tool results, ctx,
verb payloads) is built with string keys and string enum values at the
SOURCE — there is no silent keyword->string conversion, so a keyword here
means a producer bug, not data. path is the key path down from the value
handed to ->py, so the offending producer field is nameable.
Throw on a keyword/symbol trying to cross the Clojure->Python boundary. The boundary is STRINGS-ONLY: every map that crosses (tool results, ctx, verb payloads) is built with string keys and string enum values at the SOURCE — there is no silent keyword->string conversion, so a keyword here means a producer bug, not data. `path` is the key path down from the value handed to `->py`, so the offending producer field is nameable.
(count-top-level-forms code)Number of top-level Python statements in code. Comment-/whitespace-only
blocks return 0. Raises the underlying PolyglotException on a syntax
error — that's a syntax issue, not a multi-statement issue.
Number of top-level Python statements in `code`. Comment-/whitespace-only blocks return 0. Raises the underlying `PolyglotException` on a syntax error — that's a syntax issue, not a multi-statement issue.
(create-python-context custom-bindings)(create-python-context custom-bindings roots-fn)(create-python-context custom-bindings roots-fn network-opts)Create the embedded-GraalPy sandbox context with all available bindings.
custom-bindings — map of symbol->value (tool fns + engine values). Fns are
wired as Python callables; values are marshalled. Returns:
{:python-context <org.graalvm.polyglot.Context> :sandbox-ns :python ; placeholder (Python has one top scope) :initial-ns-keys #{...baseline globals...}}
roots-fn (optional) — a 0-arg fn returning the current allowed root path
strings; when supplied the sandbox gets REAL filesystem access confined to
them. Omitted ⇒ no Python filesystem (IO-NONE).
Create the embedded-GraalPy sandbox context with all available bindings.
`custom-bindings` — map of symbol->value (tool fns + engine values). Fns are
wired as Python callables; values are marshalled. Returns:
{:python-context <org.graalvm.polyglot.Context>
:sandbox-ns :python ; placeholder (Python has one top scope)
:initial-ns-keys #{...baseline globals...}}
`roots-fn` (optional) — a 0-arg fn returning the current allowed root path
strings; when supplied the sandbox gets REAL filesystem access confined to
them. Omitted ⇒ no Python filesystem (IO-NONE).(ctx->python-str data)Render a Clojure value as the canonical Python-literal string — produced by
Python (__vis_pp__) inside GraalPy, so it matches repr-style Python and
the live ctx dict the agent reads. The value is marshalled to a polyglot
object via ->py (an ordered ProxyHashMap/ProxyArray that GraalPy treats
as a native dict/list) and pretty-printed DIRECTLY — no JSON round-trip.
Render a Clojure value as the canonical Python-literal string — produced by Python (`__vis_pp__`) inside GraalPy, so it matches `repr`-style Python and the live `ctx` dict the agent reads. The value is marshalled to a polyglot object via `->py` (an ordered `ProxyHashMap`/`ProxyArray` that GraalPy treats as a native `dict`/`list`) and pretty-printed DIRECTLY — no JSON round-trip.
(eval-block python-context code)Evaluate a whole Python code block in python-context. Returns
{:source code :result <clj>} on success; throws the PolyglotException on
failure (caller maps it to the engine error shape). Globals (defs/imports/
state) persist across calls in the same context.
Evaluate a whole Python `code` block in `python-context`. Returns
`{:source code :result <clj>}` on success; throws the PolyglotException on
failure (caller maps it to the engine error shape). Globals (defs/imports/
state) persist across calls in the same context.(fork-context! custom-bindings)(fork-context! custom-bindings roots-fn)(fork-context! custom-bindings roots-fn network-opts)Fork a CHILD agent Context for a sub_loop — same deny-by-default sandbox as
the main context, built ON the shared Engine so it is SAFE to create even
while the parent's eval is running (GraalVM-verified: no Truffle deadlock).
custom-bindings wires the child's tool/verb fns, which close over the CHILD's
env (its own ctx-atom). Returns the same
{:python-context :sandbox-ns :initial-ns-keys} shape as
create-python-context. The caller owns the child Context's lifecycle (close
it when the sub_loop ends). roots-fn (optional) confines the child's Python
filesystem to the current filesystem roots, same as the parent.
Fork a CHILD agent Context for a `sub_loop` — same deny-by-default sandbox as
the main context, built ON the shared `Engine` so it is SAFE to create even
while the parent's eval is running (GraalVM-verified: no Truffle deadlock).
`custom-bindings` wires the child's tool/verb fns, which close over the CHILD's
env (its own ctx-atom). Returns the same
`{:python-context :sandbox-ns :initial-ns-keys}` shape as
`create-python-context`. The caller owns the child Context's lifecycle (close
it when the sub_loop ends). `roots-fn` (optional) confines the child's Python
filesystem to the current filesystem roots, same as the parent.(map-polyglot-error e code)Map a GraalPy PolyglotException into the engine's op-error shape. :phase
is :python/syntax for parse errors, else :python/runtime; :line/:column
come from the Python
source location when present. A host (Clojure-tool) exception is unwrapped so
its real message surfaces. Recurring syntax-failure classes get an actionable
hint prepended: a NON-ASCII char in code position (em-dash, x, curly quote -
CPython's invalid character, precise wherever it lands), a PROSE-leading
reply (see prose-leading-syntax-hint, first-line only), and - via
parse-diagnose - an unbalanced double-quote or an unbalanced (), [], {}
bracket pinpointed to its line/col.
Map a GraalPy `PolyglotException` into the engine's op-error shape. `:phase`
is `:python/syntax` for parse errors, else `:python/runtime`; `:line`/`:column`
come from the Python
source location when present. A host (Clojure-tool) exception is unwrapped so
its real message surfaces. Recurring syntax-failure classes get an actionable
hint prepended: a NON-ASCII char in code position (em-dash, x, curly quote -
CPython's `invalid character`, precise wherever it lands), a PROSE-leading
reply (see `prose-leading-syntax-hint`, first-line only), and - via
`parse-diagnose` - an unbalanced double-quote or an unbalanced (), [], {}
bracket pinpointed to its line/col.(normalize-dict-key s)Model-input hygiene at the ONE inbound conversion: a dict key spelled
":from_anchor" is still a STRING (the model drifting into colon
spelling while reading keyword-heavy source), so strip the single leading
colon when an identifier char follows and the call just works — no
lecture, no failure. Data keys are untouched: anchors ("44:f14") start
with a digit, paths with a letter or /, neither with :. Produces
strings, never keywords.
Model-input hygiene at the ONE inbound conversion: a dict key spelled `":from_anchor"` is still a STRING (the model drifting into colon spelling while reading keyword-heavy source), so strip the single leading colon when an identifier char follows and the call just works — no lecture, no failure. Data keys are untouched: anchors (`"44:f14"`) start with a digit, paths with a letter or `/`, neither with `:`. Produces strings, never keywords.
(push-eval-error! env throwable)Park the most recent uncaught error in the sandbox _e slot. The _1/_2/_3
value stack does NOT advance on error.
Park the most recent uncaught error in the sandbox `_e` slot. The `_1/_2/_3` value stack does NOT advance on error.
(push-eval-result! env value)REPL-style stack push for the sandbox _1 _2 _3 recovery slots. Python
convention is _, but we use _1/_2/_3 to match the engine's three-deep
history.
REPL-style stack push for the sandbox `_1 _2 _3` recovery slots. Python convention is `_`, but we use `_1/_2/_3` to match the engine's three-deep history.
(remove-python-binding! python-context sym)Remove sym from the Python sandbox globals ENTIRELY — the member key
disappears, so apropos/dir no longer list it and calling it raises
a plain NameError. This is how a deactivated tool must vanish:
putMember nil only parks a None under the name, which apropos
still lists and which calls as 'NoneType is not callable'.
Remove `sym` from the Python sandbox globals ENTIRELY — the member key disappears, so `apropos`/`dir` no longer list it and calling it raises a plain NameError. This is how a deactivated tool must vanish: `putMember nil` only parks a None under the name, which `apropos` still lists and which calls as 'NoneType is not callable'.
(reset-eval-bindings! env)Clear _1 _2 _3 _e at turn start so a follow-up turn doesn't see leftovers.
Clear `_1 _2 _3 _e` at turn start so a follow-up turn doesn't see leftovers.
(restore-sandbox! _python-context _db-info _session-id)NOOP. The session has ONE persistent interpreter; globals (defs/imports/vars) persist NATURALLY across turns, so there is nothing to restore.
NOOP. The session has ONE persistent interpreter; globals (defs/imports/vars) persist NATURALLY across turns, so there is nothing to restore.
(run-python-block python-context code & [_opts])Evaluate one Python code block in python-context as ONE WHOLE-BLOCK
coroutine, returning the FLAT sum-typed outcome:
{:stdout <printed>} ; SUCCESS — python_execution (what it print()ed) {:result <value>} ; SUCCESS — a native tool value (nothing printed) {:error <op-error>} ; FAILURE — the raised error IS the result
__vis_run_async__ AST-wraps the block in an async def, AUTO-SETTLES every
bare top-level tool call (so cat(x) without await still runs), drives it
as a single coroutine, and maps any raised exception against the WHOLE source.
The program runs exactly as the model wrote it — Python's own
halt-on-exception decides what ran. A pre-eval protected-rebind violation
short-circuits to an :error instead.
Evaluate one Python `code` block in `python-context` as ONE WHOLE-BLOCK
coroutine, returning the FLAT sum-typed outcome:
{:stdout <printed>} ; SUCCESS — python_execution (what it print()ed)
{:result <value>} ; SUCCESS — a native tool value (nothing printed)
{:error <op-error>} ; FAILURE — the raised error IS the result
`__vis_run_async__` AST-wraps the block in an `async def`, AUTO-SETTLES every
bare top-level tool call (so `cat(x)` without `await` still runs), drives it
as a single coroutine, and maps any raised exception against the WHOLE source.
The program runs exactly as the model wrote it — Python's own
halt-on-exception decides what ran. A pre-eval protected-rebind violation
short-circuits to an `:error` instead.(set-python-binding! python-context sym val)Bind sym -> val in the Python sandbox globals. Clojure fns are wired as
callables; everything else is marshalled.
ASYNC-BY-DEFAULT: a tool fn bound here is also DEFERRED (wrapped by
__vis_deferred__, same as build-agent-context's defer step) so
await tool(...) / gather(tool(...)) work. This matters because extension
and foundation tools are (re)installed via this fn AFTER the context's own
defer pass — without deferring here they'd stay raw/synchronous and the
await the prompt teaches would fail. The compaction verbs
(session_fold/session_drop/__vis_par__) are bound via create-python-context, not
here, so they stay direct. No-op when the async preamble isn't installed
(the printer/parser helper contexts never bind tools).
Bind `sym` -> `val` in the Python sandbox globals. Clojure fns are wired as callables; everything else is marshalled. ASYNC-BY-DEFAULT: a tool fn bound here is also DEFERRED (wrapped by `__vis_deferred__`, same as `build-agent-context`'s defer step) so `await tool(...)` / `gather(tool(...))` work. This matters because extension and foundation tools are (re)installed via this fn AFTER the context's own defer pass — without deferring here they'd stay raw/synchronous and the `await` the prompt teaches would fail. The compaction verbs (`session_fold`/`session_drop`/`__vis_par__`) are bound via `create-python-context`, not here, so they stay direct. No-op when the async preamble isn't installed (the printer/parser helper contexts never bind tools).
(sym->py-name sym)Clojure tool/binding symbol -> a Python-LEGAL global name. Purely mechanical:
/ and - fold to _ (alias fold + kebab->snake); a trailing ! (mutation
marker) is dropped; a trailing ? (predicate) becomes an is_ prefix. So
git/status -> git_status, git/commit! -> git_commit, search/web ->
search_web, file-exists -> file_exists. FULL SNAKE:
this is how the agent reaches the tools — git_status() calls git/status.
A tiny compatibility alias layer may additionally expose selected historical
short names (currently find for find_files), but the snake name remains
canonical.
Clojure tool/binding symbol -> a Python-LEGAL global name. Purely mechanical: `/` and `-` fold to `_` (alias fold + kebab->snake); a trailing `!` (mutation marker) is dropped; a trailing `?` (predicate) becomes an `is_` prefix. So `git/status` -> `git_status`, `git/commit!` -> `git_commit`, `search/web` -> `search_web`, `file-exists` -> `file_exists`. FULL SNAKE: this is how the agent reaches the tools — `git_status()` calls `git/status`. A tiny compatibility alias layer may additionally expose selected historical short names (currently `find` for `find_files`), but the snake name remains canonical.
Engine-owned symbols hidden from user live-var listings.
Engine-owned symbols hidden from user live-var listings.
(validate-no-banned-defs! code)Throws :vis/banned-def-head when code references a banned construct
(BANNED_DEF_HEADS). Parse failures are silent — the eval that follows
surfaces a clean syntax error with line/column.
Throws `:vis/banned-def-head` when `code` references a banned construct (`BANNED_DEF_HEADS`). Parse failures are silent — the eval that follows surfaces a clean syntax error with line/column.
(validate-non-empty-block! code)Throws :vis/empty-block when code parses to zero top-level statements
(comment-only blocks). Iterations that produce no evidence are rejected at
the model boundary.
Throws `:vis/empty-block` when `code` parses to zero top-level statements (comment-only blocks). Iterations that produce no evidence are rejected at the model boundary.
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 |