Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,40 @@ labeled and must only be compared against other nested runs.

<!-- paste `make bench` output below -->

### 2026-09-02 — bare metal (4604b69, Batch 0 close-out round)

| environment | |
|---|---|
| host | bare metal, AMD Ryzen 7 9700X 8-Core Processor, 16 cores, 60 GiB |
| kernel | 7.0.0-30-generic |
| cpufreq | powersave/balance_performance |
| cocoon | master-8962579 |
| cloud-hypervisor | dev 5633d38 (cocoonstack fork, v54.0.0) |
| template | ghcr.io/cocoonstack/sandbox/rt:24.04 @ sha256:804a596ee808 |

| claim tier | p50 | p90 | max | n |
|---|---|---|---|---|
| warm pool hit | 0.3 ms | 0.3 ms | 0.6 ms | 6 |
| clone from golden | 0.3 ms | 0.4 ms | 0.5 ms | 10 |
| cold boot (unpooled ghcr.io/cocoonstack/sandbox/python:3.12) | 216.5 ms | 216.5 ms | 218.3 ms | 3 |
| burst: 16 concurrent clones | 208.5 ms | 247.2 ms | 395.0 ms | 16 |

The clone-tier row still measures warm hits (see 2026-07-22); the burst
row is the clone path. Burst reads slower than 2026-07-22 (102 → 208 ms
p50) on a newer host kernel (7.0.0-30 vs -28). The same-day A/B of this
sandboxd (4604b69) against the pre-round main (d5ba35d), three interleaved
reps each on this host and kernel, read burst p50 104 / 75 / 51 ms vs
85 / 134 / 159 ms, warm 0.3 ms on both, cold 216–221 vs 213–219 ms, so the
delta is the host, not the round's code (cocoon-specs
`tests/2026-09-02-batch0-hardware-regression.md`, B0-05).

| data plane | measured |
|---|---|
| exec RTT (dial per RPC) | n=200 p50=0.14ms p90=0.22ms p99=0.34ms |
| fs_pull throughput (128 MiB) | 659.4 MiB/s best of 3 |
| burst wall (16 concurrent clones) | 458 ms |
| warm refill recovery (0 → 6) | 3 ms |

### 2026-07-22 — bare metal (3e54866, CH-only round: both lanes on Cloud Hypervisor)

| environment | |
Expand Down
26 changes: 13 additions & 13 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,21 @@ Build: `cd mcp && go build -o sandbox-mcp .`

| tool | what it does |
|---|---|
| `create_sandbox` | claim a microVM (warm claims are milliseconds); returns `sandbox_id` |
| `exec` | run a shell command; returns stdout/stderr/exit code; a hibernated sandbox wakes transparently |
| `spawn` | start a command detached; returns its pid immediately |
| `ps` | list tracked processes with state and exit codes |
| `logs` | replay a process's buffered stdout/stderr (+ exit code once ended) |
| `kill` | signal a tracked process (default SIGKILL) |
| `write_file` / `read_file` / `list_dir` | text file operations |
| `fork` | clone into N children carrying exact memory + disk state |
| `checkpoint` | capture full state without stopping; returns `checkpoint_id` |
| `create_sandbox` | claim a microVM and return its id plus deadline; warm claims take milliseconds, nothing renews the deadline |
| `exec` | run a shell command to completion (5-minute cap); returns stdout, stderr and the exit code; a hibernated sandbox wakes transparently |
| `spawn` | start a command detached and return its pid; output goes to a 256 KiB ring buffer that `logs` replays |
| `ps` | list tracked processes (exec, spawn, pty) with state, exit code and start time |
| `logs` | replay up to 256 KiB of a tracked process's newest whole stdout/stderr chunks (+ exit code once ended) |
| `kill` | signal a tracked process (0 = SIGKILL); an exited process is a no-op success |
| `write_file` / `read_file` / `list_dir` | atomic whole-file write (parent must exist); whole-file text read (invalid UTF-8 replaced, missing path is an error); one-level listing of `{name, kind, size}` entries |
| `fork` | clone into N children (1 to the node's `max_fork_count`, default 16) carrying exact memory + disk state, all-or-nothing; the parent keeps running |
| `checkpoint` | capture full state without stopping; returns a `checkpoint_id` that can be branched repeatedly |
| `branch_checkpoint` | claim a fresh sandbox from a checkpoint's captured moment |
| `list_checkpoints` / `delete_checkpoint` | checkpoint lifecycle |
| `hibernate` | snapshot + stop, freeing memory; wakes on the next tool call |
| `promote` | publish the sandbox as a named template |
| `release` | destroy the sandbox |
| `node_info` | pool, claim, drain, and capacity state |
| `hibernate` | snapshot + stop, freeing memory while keeping id, files and processes; the next call that reaches the guest wakes it |
| `promote` | publish the sandbox as a named template on its node; re-promoting replaces it |
| `release` | destroy the sandbox and its files; the session forgets the id, so a second release is rejected as unknown |
| `node_info` | warm pools, live claims, drain state, capacity and mesh peers |

Sandbox handles (and their tokens) are held by the server process for the
session. Checkpoints outlive sessions: `branch_checkpoint` accepts any known
Expand Down
64 changes: 32 additions & 32 deletions mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,65 +13,65 @@ import (

var tools = []tool{
{
"create_sandbox", "Claim a fresh microVM sandbox; returns its id. Warm claims are milliseconds.",
schema(props{"template": str("template image ref; empty uses the server default"), "net": str("network lane: none (default) or egress"), "size": str("resource tier: small (default), medium, large, xlarge"), "ttl_seconds": integer("sandbox lifetime in seconds; 0 means one hour, and nothing renews it")}), toolCreateSandbox,
"create_sandbox", "Claim a fresh microVM sandbox and return its id plus deadline. Warm claims take milliseconds; a cold template boots in well under a second. Every sandbox-scoped tool takes the returned sandbox_id. The sandbox is destroyed at its deadline unless released earlier; nothing renews it.",
schema(props{"template": str("template image ref, or a name published by promote; empty uses the server default"), "net": str("network lane: none (default, no NIC, vsock-only I/O) or egress (bridge NIC, outbound network)"), "size": str("resource tier: small (default), medium, large, xlarge"), "ttl_seconds": integer("sandbox lifetime in seconds; 0 means one hour, and nothing renews it")}), toolCreateSandbox,
},
{
"exec", "Run a command in a sandbox and return stdout/stderr/exit code. A hibernated sandbox wakes transparently.",
schema(props{"sandbox_id": str("sandbox to run in"), "command": str("shell command, run via sh -c"), "cwd": str("working directory (optional)")}, "sandbox_id", "command"), toolExec,
"exec", "Run a shell command in a sandbox, wait for it to exit, and return stdout, stderr, and the exit code as JSON. The call is cut off after 5 minutes; for servers or long jobs use spawn instead. A hibernated sandbox wakes transparently on this call.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint"), "command": str("shell command, run via sh -c"), "cwd": str("working directory; empty runs in the guest's default")}, "sandbox_id", "command"), toolExec,
},
{
"spawn", "Start a command detached in a sandbox; returns its pid. Read output later with logs.",
schema(props{"sandbox_id": str(""), "command": str("shell command, run via sh -c"), "cwd": str("working directory (optional)")}, "sandbox_id", "command"), toolSpawn,
"spawn", "Start a shell command detached in a sandbox and return its guest pid immediately. The process keeps running across later tool calls; its output goes to a per-process ring buffer that keeps up to 256 KiB of the newest whole output chunks, which logs replays. Use exec instead when you need the result now.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint"), "command": str("shell command, run via sh -c"), "cwd": str("working directory; empty runs in the guest's default")}, "sandbox_id", "command"), toolSpawn,
},
{
"ps", "List a sandbox's tracked processes with state and exit codes.",
schema(props{"sandbox_id": str("")}, "sandbox_id"), toolPs,
"ps", "List the sandbox's tracked processes (exec, spawn, and pty commands) as JSON: pid, argv, detached, state (running or exited), exit_code once exited, and start time. Processes started inside the guest by other means are not listed.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint")}, "sandbox_id"), toolPs,
},
{
"kill", "Signal a tracked process (default SIGKILL).",
schema(props{"sandbox_id": str(""), "pid": integer("guest pid from spawn/ps"), "signal": integer("signal number; 0 = SIGKILL")}, "sandbox_id", "pid"), toolKill,
"kill", "Send a signal to a tracked process. Killing a process that already exited is a no-op success; the guest never re-signals a reaped pid.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint"), "pid": integer("guest pid from spawn or ps"), "signal": integer("signal number, e.g. 15 for SIGTERM; 0 sends SIGKILL")}, "sandbox_id", "pid"), toolKill,
},
{
"logs", "Replay a process's buffered output (stdout/stderr, exit code when ended).",
schema(props{"sandbox_id": str(""), "pid": integer("guest pid from spawn/ps")}, "sandbox_id", "pid"), toolLogs,
"logs", "Return a tracked process's buffered stdout and stderr, plus exit_code once it has ended. The buffer keeps up to 256 KiB of the newest whole output chunks per process, so redirect long output to a file when it must be complete.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint"), "pid": integer("guest pid from spawn or ps")}, "sandbox_id", "pid"), toolLogs,
},
{
"write_file", "Write text to a file in a sandbox (atomic on the guest).",
schema(props{"sandbox_id": str(""), "path": str("absolute path"), "content": str("file content")}, "sandbox_id", "path", "content"), toolWriteFile,
"write_file", "Write text content to a file in a sandbox, replacing any existing file. The write is atomic (temp file plus rename); an existing file keeps its permission bits. The parent directory must already exist.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint"), "path": str("absolute path of the file"), "content": str("full file content; written verbatim")}, "sandbox_id", "path", "content"), toolWriteFile,
},
{
"read_file", "Read a text file from a sandbox.",
schema(props{"sandbox_id": str(""), "path": str("absolute path")}, "sandbox_id", "path"), toolReadFile,
"read_file", "Return the whole content of a file in a sandbox as text; bytes that are not valid UTF-8 are replaced, so binary content is lossy. For large or binary files use exec with head, tail, or a checksum instead. A missing path is an error.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint"), "path": str("absolute path of the file")}, "sandbox_id", "path"), toolReadFile,
},
{
"list_dir", "List a directory in a sandbox.",
schema(props{"sandbox_id": str(""), "path": str("absolute path")}, "sandbox_id", "path"), toolListDir,
"list_dir", "List one directory in a sandbox (not recursive) as JSON entries with name, kind (file, dir, symlink, or other), and size in bytes.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint"), "path": str("absolute path of the directory")}, "sandbox_id", "path"), toolListDir,
},
{
"fork", "Clone a sandbox into N independent children carrying its exact memory and disk state; children live one hour.",
schema(props{"sandbox_id": str(""), "count": integer("children, 1-16")}, "sandbox_id", "count"), toolFork,
"fork", "Clone a sandbox into N independent children that start from its exact memory and disk state, including running processes; each child gets its own id and lives one hour. N is capped by the node's max_fork_count (default 16). All-or-nothing: on failure no child survives. The parent keeps running.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint"), "count": integer("number of children, 1 to the node's max_fork_count (default 16)")}, "sandbox_id", "count"), toolFork,
},
{
"checkpoint", "Capture a sandbox's full state without stopping it; returns a checkpoint id to branch from.",
schema(props{"sandbox_id": str(""), "name": str("optional label")}, "sandbox_id"), toolCheckpoint,
"checkpoint", "Capture a sandbox's full state (memory, disk, running processes) without stopping it and return a checkpoint id. branch_checkpoint claims new sandboxes from that moment any number of times; a checkpoint outlives this session until it is deleted or expires under the node's checkpoint TTL.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint"), "name": str("optional human label")}, "sandbox_id"), toolCheckpoint,
},
{
"branch_checkpoint", "Claim a fresh sandbox branched from a checkpoint's exact captured moment; it lives one hour.",
schema(props{"checkpoint_id": str("")}, "checkpoint_id"), toolBranchCheckpoint,
"branch_checkpoint", "Claim a fresh sandbox that resumes from a checkpoint's exact captured moment and return its id; it lives one hour. The checkpoint is unchanged and can be branched again.",
schema(props{"checkpoint_id": str("id returned by checkpoint or list_checkpoints")}, "checkpoint_id"), toolBranchCheckpoint,
},
{"list_checkpoints", "List checkpoints on the node, newest first.", schema(props{}), toolListCheckpoints},
{"delete_checkpoint", "Delete a checkpoint.", schema(props{"checkpoint_id": str("")}, "checkpoint_id"), toolDeleteCheckpoint},
{"list_checkpoints", "List the node's checkpoints newest first: checkpoint_id, name, source sandbox_id, created_at.", schema(props{}), toolListCheckpoints},
{"delete_checkpoint", "Delete the node's copy of a checkpoint and tell peers to drop theirs; a peer replica that misses that broadcast stays branchable until its own TTL sweep. Sandboxes already branched from it are unaffected.", schema(props{"checkpoint_id": str("id returned by checkpoint or list_checkpoints")}, "checkpoint_id"), toolDeleteCheckpoint},
{
"hibernate", "Snapshot and stop a sandbox, freeing its memory; any later tool call wakes it transparently.",
schema(props{"sandbox_id": str("")}, "sandbox_id"), toolHibernate,
"hibernate", "Snapshot a sandbox and stop its VM, freeing memory while keeping its id, files, processes, and shell state. The next call that reaches the guest (exec, spawn, ps, kill, logs, the file tools) wakes it transparently in tens of milliseconds; fork, checkpoint, promote and release act on the snapshot without waking it.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint")}, "sandbox_id"), toolHibernate,
},
{
"promote", "Publish a sandbox's state as a named template; later create_sandbox calls can claim it by name.",
schema(props{"sandbox_id": str(""), "template_name": str("template name to publish as")}, "sandbox_id", "template_name"), toolPromote,
"promote", "Publish the sandbox's current state as a named template on its node; later create_sandbox calls with that name (and the same net and size) start from it. Re-promoting to the same name replaces the template.",
schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint"), "template_name": str("template name to publish as")}, "sandbox_id", "template_name"), toolPromote,
},
{"release", "Release a sandbox; its VM is destroyed.", schema(props{"sandbox_id": str("")}, "sandbox_id"), toolRelease},
{"node_info", "The node's pool, claim, drain, and capacity state.", schema(props{}), toolNodeInfo},
{"release", "Destroy a sandbox and free its resources; files and processes inside it are lost. This session forgets the id, so a second release of it is rejected as unknown.", schema(props{"sandbox_id": str("id returned by create_sandbox, fork, or branch_checkpoint")}, "sandbox_id"), toolRelease},
{"node_info", "Report the connected node's warm pools, live claims, drain state, capacity, and mesh peers as JSON.", schema(props{}), toolNodeInfo},
}

// tool binds one MCP tool's spec to its handler, so a tool can never exist
Expand Down
18 changes: 13 additions & 5 deletions sdk/langchain/cocoonsandbox_langchain/toolkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,25 @@ def get_tools(self) -> list[StructuredTool]:
"""The sandbox tool set; sync-native (_run), async via to_thread."""
return [
self._tool("sandbox_exec",
"Run a shell command in the sandbox; returns stdout, stderr, "
"and the exit code. State on disk persists across calls.",
"Run a shell command in the sandbox and wait for it to exit. "
"Returns stdout; a non-empty stderr is appended as a 'stderr:' "
"line and a non-zero status as an 'exit code: N' line; a "
"command that prints nothing and exits 0 returns '(no output)'. "
"Files and installed packages persist across calls; environment "
"variables and the working directory do not.",
ExecInput, self._exec),
self._tool("sandbox_write_file",
"Write a text file in the sandbox (atomic).",
"Write text to a file in the sandbox, replacing any existing "
"file atomically. The parent directory must already exist.",
WriteFileInput, self._write_file),
self._tool("sandbox_read_file",
"Read a text file from the sandbox.",
"Return the whole content of a file in the sandbox as text "
"(undecodable bytes are replaced); a missing path is a tool "
"error. Prefer sandbox_exec with head or tail for large files.",
PathInput, self._read_file),
self._tool("sandbox_list_dir",
"List a sandbox directory as JSON entries.",
"List one directory (not recursive) as a JSON array of "
"{name, kind, size}; kind is file, dir, symlink, or other.",
PathInput, self._list_dir),
]

Expand Down