Toolbox-agnostic stateful Python REPL engine for LLM agents
Project description
dolsot
A toolbox-agnostic, stateful Python REPL engine for LLM agents.
You inject a "toolbox" — any importable Python module — into a namespace, then run cells of Python against it. Each cell can read and rebind whatever the previous cell left behind, keyed by a session id: variables, DataFrames, functions, imports.
Backends
dolsot ships two ways to run a cell, and the choice is a real trade-off between isolation and what you can inject.
Subprocess backend (Repl) — the default, and what the rest of this
README describes. Toolboxes are importable, stateless modules referenced by
name; dolsot itself never imports or knows anything about a toolbox, it only
ever handles a module name, because only a name can cross into the child
worker. Sessions are persistent across calls, and each cell runs in its own
worker process, so a timeout is enforced by hard-killing that process
(SIGKILL) — a runaway cell cannot take the host down, and the worker is
fully isolated from the host's live objects and memory. That process
boundary is also the cost: state must be pickled between cells, and a live
object (a socket, a lock, an open connection) cannot cross it.
In-process backend (InProcessSession) — runs cells directly in the
host process against a live namespace dict, with no process boundary. This
lets you inject live objects as-is — a database connection, a client, a
generator — and state persists across cells by reference, not by pickling.
It is less isolated than Repl: there is no worker to kill, so a
per-call timeout is cooperative only — a cell that ignores it keeps
running on a leaked daemon thread that cannot be forcibly stopped. An
optional caller-supplied AST gate (dolsot.sandbox.check_ast, or your own)
can reject dangerous constructs before a cell runs, but dolsot provides that
gate as a mechanism, not a safety guarantee; in-process CPython sandboxing is
inherently leaky. Use it only for code and toolboxes you already trust to
run unsandboxed.
State crosses between Repl cells by being pickled to disk (via dill) and
reloaded in the next cell's fresh worker process — so what persists is exactly
what dill can serialize. A live object — an open database connection, a
socket, a file handle, a generator, a lock — cannot cross that boundary. Such a
value is dropped (best-effort, so one of them never sinks the rest of the
session), reported on Result.dropped, and gone on the next call. If your work
needs a connection to live across cells, re-open it at the top of each cell.
dolsot is not a sandbox. Every cell runs in a child worker process, and that process is killed if it runs past its timeout — that protects the host from a hung or runaway cell, nothing more. The worker has full filesystem, network, and process access, exactly like the toolbox code it runs. Only inject toolboxes, and only run code, that you'd trust to run unsandboxed.
Install
uv add dolsot # core: the REPL engine
uv add "dolsot[typecheck]" # + the ty type gate (optional)
The only hard runtime dependency is dill (session pickling). The type gate is
powered by ty, pulled in by the typecheck extra; without it the gate fails
open (a no-op) and the REPL still runs. Requires Python >= 3.11 on a POSIX
platform — the worker timeout kills a process group with SIGKILL, so Windows
is not supported.
Library usage
import mytools # your toolbox module
from dolsot import Repl
repl = Repl(inject={"mt": mytools})
r = repl.run("task-42", "mt.add(1, 2)")
print(r.value) # "3"
# state from the previous cell is still there
r = repl.run("task-42", "df = mt.load('orders')")
r = repl.run("task-42", "len(df)")
print(r.value)
inject maps the alias code will use (mt) to either a module object or an
importable module name ("mytools") — a module object is resolved to its
__name__ and re-imported inside the worker, since only something importable
by name can cross the process boundary.
An async twin, arun, exists for event-loop hosts:
r = await repl.arun("task-42", "mt.add(1, 2)")
Result
Every call returns a Result, never raises for a user-code failure:
| field | meaning |
|---|---|
ok |
True if the cell ran without raising |
value |
the trailing expression's value, stringified, or None |
stdout |
anything the cell printed |
error |
the error message, or None |
exit_code |
0 on success; see below otherwise |
held |
names now persisted in the session (variables, imports, functions) |
dropped |
names the cell computed but could not persist — unpicklable live objects (a connection, socket, generator, lock). Gone next call; not a failure |
text |
stdout + value + error + the held-names banner, pre-joined |
exit_code is:
0— the cell ran and returned normally.- an
intyour toolbox exception carries as.exit_code— a toolbox can signal its own failure classes this way (e.g. "not found" vs. "bad input"). 1— any other unhandled exception, or a worker-side failure (session wouldn't load, type gate failed, etc.).124— the cell was killed for exceeding its timeout.
Repl.run/arun raise only for a caller mistake, before any code runs:
InvalidSession (bad session id, or empty code) and InvalidToolbox (an
inject value that isn't a module or module name).
Session housekeeping
repl.sessions() # -> list[SessionInfo] (id, size_bytes, last_used)
repl.reset("task-42") # forget one session
repl.reset("all") # forget every session in the configured store
CLI usage
Installing dolsot also installs the dolsot command — the shape pi (or any
agent host) spawns as a subprocess. It is a thin shell over Repl: parse
arguments, run one cell, print, exit with Result.exit_code.
# run code given as an argument
dolsot --toolbox mytools:mt --session work "mt.add(1, 2)"
# or piped in on stdin
echo "2 ** 10" | dolsot --toolbox mytools:mt --session work
# multiple toolboxes, repeatable
dolsot --toolbox mytools:mt --toolbox otherlib:ol --session work "mt.add(1, ol.scale(2))"
# a session works fine with no toolbox at all
dolsot --session scratch "1 + 1"
# session management
dolsot --list-sessions
dolsot --reset work
dolsot --reset all
# per-call timeout override (seconds)
dolsot --toolbox mytools:mt --session work --timeout 5 "slow_call()"
--toolbox MODULE:ALIAS may be repeated; it is not required to run code —
a session with no toolbox still runs plain Python. A malformed spec (missing
:, or an empty module/alias) is a usage error.
--session is required for running code — not for --list-sessions or
--reset. Without it dolsot exits 2 and explains why: state (variables,
imports, DataFrames) persists per session id, so a stable id per task is
what makes that useful.
Output contract
This split is deliberate and stable, because callers parse it:
| stream | content |
|---|---|
| stdout | the cell's captured stdout, then the trailing expression's value |
| stderr | the error line, the "session now holds" banner, any save warning |
| exit code | Result.exit_code (see the table above), or 2 for a CLI usage error |
The toolbox contract
A toolbox is just an importable Python module — dolsot places no import-time requirements on it — but a few conventions make it far more usable by an agent driving it through a REPL:
| convention | why |
|---|---|
importable by name (import mytools works in the worker) |
only a module name crosses the host/worker boundary; a live object can't |
__all__ |
declares the public surface dolsot's error hints and the type gate reflect over; without it, every non-underscore attribute is public |
an exception with an int .exit_code |
lets a toolbox signal its own failure classes through the process exit code, instead of everything collapsing to 1 |
a help() callable |
dolsot's argument-count hints point the agent at mt.help("fn_name") when a call fails, so it's worth having one |
| real type annotations | the type gate (ty) type-checks a cell against the toolbox's own signatures before running it, turning a wrong-arguments call into a fixable diagnostic instead of a raw traceback |
Example toolbox shape:
# mytools/__init__.py
__all__ = ["add", "load", "help", "ToolError"]
class ToolError(Exception):
exit_code = 3
def add(a: int, b: int) -> int:
"""Add two ints."""
return a + b
def load(name: str) -> list[int]:
...
def help(topic: str | None = None) -> str:
return "mytools cheat-sheet"
Sessions and SessionStore
A session is opaque bytes to dolsot — the worker process owns pickling (via
dill) and unpickling; the host only ever loads and saves a blob under a
session id. Only what dill can serialize survives: when the namespace holds an
unpicklable live object, the worker saves everything else and drops just that
name (surfaced on Result.dropped), rather than losing the whole session. That's
the whole SessionStore protocol:
from typing import Protocol
class SessionStore(Protocol):
def load(self, sid: str) -> bytes | None: ...
def save(self, sid: str, blob: bytes) -> None: ...
def delete(self, sid: str) -> int: ... # sid == "all" wipes everything
def list(self) -> list[SessionInfo]: ...
Two implementations ship in dolsot.store:
FileStore(the default) — one pickle file per session under a directory (DOLSOT_CACHE_DIR, or an OS-agnostic default under the platform temp dir,{tempdir}/dolsot-{uid}/sessions). The temp dir can be shared and world-writable (e.g./tmpon Linux), and a session pickle runs code when dill loads it, so the default is namespaced per uid and created0700; FileStore refuses a session dir that is a symlink or not owned by the current user. Because temp dirs are cleared on reboot and by system reapers, default persistence is best-effort; pointDOLSOT_CACHE_DIRat a durable directory when a session must outlive that. It also reaps files untouched forDOLSOT_SESSION_TTL_DAYSon each access (0disables it), where "untouched" means not written — every run rewrites its session, so an actively-used session is never reaped.MemoryStore— an in-process dict, no TTL. No persistence beyond the host process's lifetime; mainly for tests and short-lived hosts.
Writing a custom store (e.g. Redis, S3, a database row) means implementing
those four methods and passing an instance to Repl:
from dolsot import Repl
class RedisStore:
def __init__(self, client):
self.client = client
def load(self, sid: str) -> bytes | None:
return self.client.get(f"dolsot:{sid}")
def save(self, sid: str, blob: bytes) -> None:
self.client.set(f"dolsot:{sid}", blob)
def delete(self, sid: str) -> int:
if sid == "all":
keys = self.client.keys("dolsot:*")
return self.client.delete(*keys) if keys else 0
return self.client.delete(f"dolsot:{sid}")
def list(self):
... # SessionInfo(id, size_bytes, last_used) per key
repl = Repl(inject={"mt": "mytools"}, store=RedisStore(my_redis_client))
A store deals in bytes only — it must never deserialize a session, and dolsot never asks it to.
Configuration
Every setting can be passed explicitly to Repl(...), or left to fall back
to an environment variable, or a hardcoded default. Precedence is always:
explicit argument > environment variable > default.
| variable | controls | default |
|---|---|---|
DOLSOT_CACHE_DIR |
FileStore's session directory |
{tempdir}/dolsot-{uid}/sessions |
DOLSOT_SESSION_TTL_DAYS |
days a session file survives unused before FileStore reaps it (0 disables reaping) |
7 |
DOLSOT_TIMEOUT |
wall-clock seconds before a cell is killed. Bounds the whole worker lifetime — interpreter startup, session load/dump, the type gate, and your code — not your code alone, so loading a very large session eats into it. The gate caps its own run at this budget too | 20 |
DOLSOT_MAX_OUTPUT |
max chars of stdout/value kept before truncation (0 disables the cap) |
100000 |
DOLSOT_TYPECHECK |
set to 0 to skip the ty type gate before running a cell |
1 (on) |
DOLSOT_TY_BIN |
the ty binary to invoke for the type gate |
ty |
The dolsot CLI's --timeout maps straight to Repl(timeout=...); when the
flag is omitted the CLI passes None, so DOLSOT_TIMEOUT (or the built-in
default) applies exactly as it would from the library. There is no
--cache-dir flag — set DOLSOT_CACHE_DIR instead.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dolsot-0.1.0.tar.gz.
File metadata
- Download URL: dolsot-0.1.0.tar.gz
- Upload date:
- Size: 43.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
151044bd3bd08bb578c2987214550fc6aba7d29f8ec9a37a7ed27c819cdee0f6
|
|
| MD5 |
ff2f9b09bb1e2f655eb80d740e44bbb7
|
|
| BLAKE2b-256 |
1c9f1afa6e84f1de25c2d164faf86cd7114690c53869b30eca1fc9bbe169a9bf
|
File details
Details for the file dolsot-0.1.0-py3-none-any.whl.
File metadata
- Download URL: dolsot-0.1.0-py3-none-any.whl
- Upload date:
- Size: 29.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6acdc7743d21082149e88c43b90b0fb29326c35d4ba1d5afd9e2a7aa40e142fa
|
|
| MD5 |
674fb3f1f3003ee29f5df10daf99aa45
|
|
| BLAKE2b-256 |
c6d1baed2d99946214cbf32f02980cfcc348723e1def95da73d0eac6037a0e44
|