Result Store Design¶
Status: accepted, implemented (src/toolplane/results.py). Tracking issue:
#43. This document
consolidates the issue thread (three review rounds); where they differ, this
document wins.
Problem¶
Every execute_code run is a fresh sandbox. Multi-step agent work therefore
routes intermediate data through the model's context twice — once returned,
once pasted back into the next snippet as a literal — or re-fetches, which is
wasteful for reads and wrong for non-idempotent tools. Routing data through
the context is the exact failure Toolplane exists to prevent.
Decision¶
Persist data, not interpreters. Snippets can save JSON-shaped values to a host-side, in-memory, session-scoped store and load them in later runs. The sandbox stays provably fresh on every run; only named data survives.
"Later runs" means later execute_code calls within one long-lived
Toolplane process — a serve mcp process or a Python-embedded runtime.
toolplane run constructs a fresh runtime per invocation, so handles never
survive across separate CLI invocations; that is the in-memory contract
working as intended, not a gap.
# run 1
issues = await linear_list_issues(query="label:bug")
handle = await save_result(issues, label="issues") # full data -> host store
return {"count": len(issues), "sample": issues[:3], "issues_handle": handle}
# run 2 — fresh sandbox
issues = await load_result("res_...") # host -> sandbox, never through the model
The strategic property: the model sees a summary and a handle; the full value never transits the context window in either direction.
save_result is the core primitive. Auto-saving successful return values may
be added later as convenience, but it cannot deliver the summary-plus-handle
pattern and is not part of v1.
Contract¶
One sentence: if save_result can canonicalize a value to JSON and measure
its serialized bytes, the store can hold it; if it cannot, it belongs in
artifacts. The test is the serialization itself, not the transport: on
local_unsafe the bridge is an in-process call where live objects can cross,
so a transport-based rule would silently admit them on exactly the backend
where they can sneak in. save_result serializes at save time on every
backend and rejects what does not round-trip.
- Values are JSON-shaped (the bridge boundary already enforces this on the sandboxed backends). Known fidelity limits apply: datetime becomes str, tuple becomes list. Non-finite floats (NaN, Infinity) are rejected at save: they are not valid JSON, and MCP response serialization would silently turn them into null on load.
- No pickle, ever: unpickling agent-influenced bytes is arbitrary code execution plus version fragility.
- No live Python objects: they cannot re-enter monty/pyodide sandboxes, they alias mutably across runs, and their memory cannot be measured for caps.
- No persistent interpreter. Monty's
MontyReplremains the documented upgrade path if real usage proves agents need live state; this design does not foreclose it. (Status 2026-07: that path was taken — monty sessions now persist the live namespace by default; see the session spike and the[session]config section. The store keeps the jobs sessions cannot do: surviving a session reset, crossing backends, and direct MCP-resource reads.) - The name is result store, never "variables" or "session state". The mental model taught to agents is "save what you want to keep".
Scope, retention, and security¶
- In-memory only, never persisted to disk. Process lifetime is the
privacy boundary; restart is the clear operation. There is deliberately no
toolplane result clearcommand — a CLI cannot reach a running serve process's memory, and disk-backing the store to enable one would create the retention surface it tries to manage. - The handle is the sole authority (the file-descriptor lesson): handles
are unguessable (
secrets.token_urlsafe), prefixedres_. The optionallabelis metadata for debugging and future listing only — it is never a lookup key, so two snippets savinglabel="issues"cannot collide or overwrite each other. - Session scoping: on stdio transport one process serves one client, so process scope equals session scope. On HTTP multi-client transports the store is disabled (fail-closed) in v1 rather than shipped global, which would let one client load another client's data.
- Caps, enforced on save: max entries (default 64), max total bytes
(default 32 MiB), max bytes per entry (default 8 MiB), TTL (default 1
hour). When a cap would be exceeded,
save_resultfails with a clear error telling the agent the limit and what to do (save a smaller projection, or re-use an existing handle). No silent LRU eviction: quietly losing data an agent believes is saved is worse than an explicit no. - Config: a
[results]table (enabled,max_entries,max_total_bytes,max_entry_bytes,ttl_seconds),extra="forbid"like every other table.
High-level design¶
sandbox (fresh per run) host (one process)
┌─────────────────────────┐ ┌──────────────────────────────┐
│ save_result(val, label) │ bridge │ InProcessBridge │
│ load_result(handle) │ ───────► │ └─ ResultStore │
│ │ ◄─────── │ {res_ab12: entry, ...} │
│ (bindings, like cli_run)│ │ caps + TTL, in-memory │
└─────────────────────────┘ └──────────────────────────────┘
save_result/load_result dispatch through the existing bridge exactly like
every other capability, which is what keeps the security model unchanged: the
host owns the store, the sandbox only sends requests.
Low-level design¶
src/toolplane/results.py:ResultStore— a dict ofhandle -> (label, json_bytes, saved_at)plus cap/TTL enforcement. Signature:save_result(value, label=None) -> handle;load_result(handle) -> value. Size accounting uses the serialized JSON byte length plus the label's UTF-8 bytes (labels are host memory too, so they cannot smuggle uncounted bytes past the caps). TTL is checked lazily on access and save.- Exposure as hidden capabilities:
toolplane:results/saveandtoolplane:results/load, registered hidden. This makes the store reachable from every backend with zero backend-specific plumbing viacall_tool("toolplane:results/save", {...}). Note the precise meaning of hidden in today's registry: hidden from discovery (search, list, and namespaces omit it) but callable by canonical name, andget_capability_schemas(["toolplane:results/save"])resolves it. That is the intended behavior — the store is infrastructure, not a discoverable capability, but nothing about it is secret. - Dispatch authority is the bridge, not the registry. Each runtime's
InProcessBridgeholds that runtime's store and resolves the two names against it before registry dispatch; the registry entries are discovery-only and refuse direct calls. Registries can be shared acrossToolplaneinstances, so binding a store into a registry entry would let the first registration win — leaking handles across runtimes and bypassing a disabled-store policy. This mirrors CLI allowlist enforcement, which is also bridge-owned. - Sugar bindings per backend, exactly like the CLI surface: monty binds
save_result/load_resultas external functions; local and pyodide gain the same names through their existing namespace injection. Capability and input names take precedence over the bindings, mirroring existing reserved-name handling. - The store hangs off the runtime (one per
Toolplaneinstance), constructed from config. The facade passes nothing new: the bridge already reaches it. - Errors: unknown or expired handle raises a
ResultStoreErrorwith the handle in the message; cap violations raise with the specific limit. Both surface in-sandbox as catchable exceptions, consistent with tool errors.
Failure modes (decided)¶
| event | behavior |
|---|---|
| store full | save_result fails loudly with the limit in the message |
| entry too large | fails loudly, suggests saving a projection |
| unknown handle | fails loudly with the handle |
| expired handle (TTL) | same as unknown; expiry is lazy |
| non-JSON value | fails at save with the offending type |
| NaN / Infinity | fails at save; would corrupt to null in MCP responses |
| store disabled | save_result/load_result fail with "results store is disabled" |
Error-catching contract¶
Store errors are catchable in-sandbox as ValueError on every backend —
except ValueError handles "handle expired → re-save and retry" without
string-matching:
Mechanism: ResultStoreError subclasses ValueError; monty maps external
exceptions to their nearest builtin base, local raises the real instance
(caught via inheritance), and the pyodide bridge re-raises the builtin named
in the RPC error (ToolCallError.builtin). The toolplane-specific type name
only exists host-side. The same scheme gives PermissionError for CLI
policy rejections and LookupError for unknown capability names.
MCP resource lane¶
Saved values are also readable as MCP resources: toolplane://results/<handle>
serves the stored canonical JSON verbatim, letting a client (or a human via
@-mention in Claude Code) read a result without an execute_code round-trip.
- Same authority model: the handle is the sole key; the resource handler
reads through the runtime's own store (
ResultStore.payload). - Same transport policy: the template is only registered when the store is
enabled —
resolve_serve_configdisables the store on multi-client transports before the facade is built, so no dead template is advertised. - Unknown or expired handles surface the store's own error message through the resource read.
- Discovery is pointer-driven, not enumeration-driven (Claude Code does not
list resource templates, and agents do not browse resources unaided): the
URI shape is stated in the
toolplane://namespacemanifest and in thesave_resultcapability description. - Known cosmetic quirk: fastmcp labels str-returning template reads
text/plainat read time even though the template advertisesapplication/json; content is canonical JSON either way.
Artifacts lane (built — the bytes sibling)¶
src/toolplane/artifacts.py is the second lane promised above, now real
(issue #46; evidence gate fired on a live pandas session). Same contract,
different substrate:
- Bytes on disk, not JSON in memory.
save_artifact(data, filename=...)takes bytes only and points JSON-shaped values back atsave_result. Files live in a private tempdir owned by the store;close()runs at process exit, so restart is still the clear operation — the privacy boundary is unchanged, just extended to disk for the process lifetime. - Same authority model: unguessable
art_handles, labels metadata-only, loud caps (per-artifact, total, entry count), TTL sweep deleting bytes from disk, fail-closed on multi-client transports, bridge-owned dispatch. - Transport is base64 over the bridge on every backend — the narrowest
common shape (pyodide's RPC is JSON-only). Monty/local bindings are
host-side closures, so snippets pass real bytes and never see base64. A
monty
MountDirfast path (spiked on #46: rw/overlay/ro mounts, cumulativewrite_bytes_limitper mount object, escape-blocking all verified) is a later optimization, not part of the contract. - Read-back is an MCP resource:
toolplane://artifacts/{handle}serves the raw bytes (application/octet-stream). Claude Code materializes binary resources to a local file; Codex returns inline base64. Since agents don't enumerate templates,ExecutionResult.artifactscarries the handle + URI for every artifact saved during a run — the pointer arrives in the response the agent is already reading. - Same error contract:
ArtifactStoreErrorsubclassesValueError, soexcept ValueErrorworks identically on all backends.
Out of scope (follow-ups)¶
- Monty
MountDirscratch-dir fast path for large artifacts (skips base64 framing; the cumulative-limit accounting decision lives there). - Auto-saving successful return values (convenience only).
- Per-session stores on HTTP multi-client transports.
Precedent¶
The ship-names-not-values pattern under an expensive channel, independently derived by several lineages, each contributing one lesson to this design:
- Cap'n Proto promise pipelining — composable interfaces without do-everything endpoints.
- Ray
ObjectRef—save_result -> handleis the primitive; move compute to data because the coordinator channel (here, the context window) is the scarce resource. - PostgreSQL cursors and temp tables — scope and lifetime are part of the API, not an afterthought.
- Unix file descriptors — the handle is the capability; process-scoped, unguessable, dies with the process; names are never the authority.
- LangGraph persistence — keep runtime state and application memory distinct; Toolplane's version is deliberately narrower than both.
Rule of thumb: save_result/load_result is a Ray ObjectRef plus a Postgres
temp table plus an fd-like capability — scoped to one Toolplane execution
client, and JSON-only.
Test plan¶
- save/load round-trip across two
execute_coderuns (facade-level, monty). - Summary-plus-handle flow: full value never appears in the first response.
- Cap enforcement: entries, total bytes, entry bytes, TTL expiry — each fails loudly with the documented message.
- Unknown handle, disabled store, non-JSON value.
call_tool("toolplane:results/save", ...)works on a backend with no sugar bindings (proves the zero-plumbing path).- Handle unguessability shape (
res_prefix, token length). - Labels are not authority: two saves with the same label yield distinct handles and distinct values.