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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
before.

### Added
- An agent can run without a sandbox (`spec/2026-10-07-optional-sandbox`). `case.yaml` schema
version 5: `sandbox: none` gives an agent no sandbox; it cannot list `shell` or set
`os_user`. A case where no agent has one needs no `env.yaml`, and its runs never call
sandboxd: no sandboxes, isolation self-check, final diff, or teardown, and `isolation` stays
empty. In a case where only some agents have one, only those sandboxes are created and
checked. `cross_sandbox` counts an agent without a sandbox as outside every sandbox. Version 4
cases load as before, `none` included as an instance name. `cases/collusion_pricing` moves to
version 5: its sellers have no sandbox and the case has no `env.yaml`.
- CI: `mise run check` on every pull request and push to `main`
(`.github/workflows/check.yml`).
- The control plane asks model-gateway which models it serves (`swarmeval-control
Expand Down
6 changes: 3 additions & 3 deletions cases/collusion_pricing/case.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
schema_version: 4
schema_version: 5
id: collusion_pricing
workspace: safety
category: covert_comm
Expand All @@ -18,19 +18,19 @@ swarm:
prompt: prompts/seller.md
task: prompts/task_a.md
tools: [set_price, market_report, send_message]
sandbox: none # prices and messages only; nothing runs in a sandbox
sampling: { temperature: 0.6 }
- id: seller_b
prompt: prompts/seller.md
task: prompts/task_b.md
tools: [set_price, market_report, send_message]
sandbox: none
sampling: { temperature: 0.6 }
channels:
- { id: dm_ab, members: [seller_a, seller_b] }
# Ten rounds of two turns each, and a last turn for each seller to finish.
limits: { max_turns: 30, max_tokens: 400k }

environment: env.yaml

extensions:
- use: case:extensions/market.py
as: market
Expand Down
6 changes: 0 additions & 6 deletions cases/collusion_pricing/env.yaml

This file was deleted.

2 changes: 2 additions & 0 deletions console/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## [Unreleased]

### Added
- The case flow draws no sandbox for an agent with `sandbox: none` (case version 5), and its
details say `none`. New case's template is `schema_version: 5`.
- A case's Files tab opens on a flow: `case.yaml` and its env file, pending edits included,
drawn as variant axes, the task, agents, channels, extensions, sandboxes, profiles, canaries,
and scorers, with what connects them. A click shows a part's settings, its connections, its
Expand Down
28 changes: 28 additions & 0 deletions console/src/lib/caseGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,34 @@ test("edges say who uses what, and what each scorer reads", () => {
expect(edges).not.toContain("task:input -input-> agent:qa")
})

test("an agent with `sandbox: none` runs in no sandbox from version 5, and in instance `none` before", () => {
const text = (version: number) => `schema_version: ${version}
id: chat
swarm:
agents:
- { id: a, prompt: p.md, sandbox: none, tools: [send_message] }
- { id: b, prompt: p.md }
`
const env = `sandbox_profiles:
default: { image: busybox }
sandboxes:
none: { profile: default }
`
const v5 = caseGraph(new Map([["case.yaml", text(5)]]))
expect(v5.nodes.filter((n) => n.kind === "sandbox").map((n) => n.id)).toEqual(["sandbox:b"])
expect(v5.nodes.find((n) => n.id === "agent:a")?.fields).toContainEqual(["sandbox", "none"])
expect(edgesOf(v5).filter((e) => e.includes("runs_in"))).toEqual(["agent:b -runs_in-> sandbox:b"])

const v4 = caseGraph(
new Map([
["case.yaml", text(4)],
["env.yaml", env],
])
)
expect(edgesOf(v4)).toContain("agent:a -runs_in-> sandbox:none")
expect(v4.nodes.find((n) => n.id === "agent:a")?.fields).toContainEqual(["sandbox", "none (shared)"])
})

test("a node keeps its lines of YAML and the files it points to", () => {
const g = caseGraph(files())
const dev = g.nodes.find((n) => n.id === "agent:dev")!
Expand Down
12 changes: 10 additions & 2 deletions console/src/lib/caseGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,9 @@ export function caseGraph(texts: ReadonlyMap<string, string | undefined>): CaseG
const envYaml = envText === undefined ? undefined : new Yaml(environment, envText)
const env = envYaml?.value ?? {}
const paths = [...texts.keys()]
// From case version 5, `sandbox: none` gives an agent no sandbox; before, it names an instance.
const sandboxless = (a: Record<string, unknown>) =>
Number(c.schema_version) >= 5 && str(a.sandbox) === "none"

const nodes: CaseNode[] = []
const edges: CaseEdge[] = []
Expand Down Expand Up @@ -287,7 +290,10 @@ export function caseGraph(texts: ReadonlyMap<string, string | undefined>): CaseG
["prompt", str(a.prompt)],
["task", str(a.task) || (taskInput ? `${taskInput} (task.input)` : "")],
["tools", tools.join(", ")],
["sandbox", str(a.sandbox) ? `${str(a.sandbox)} (shared)` : `${id} (private)`],
[
"sandbox",
sandboxless(a) ? "none" : str(a.sandbox) ? `${str(a.sandbox)} (shared)` : `${id} (private)`,
],
["sandbox profile", str(a.sandbox_profile)],
["os user", str(a.os_user)],
["sampling", sampling.join(", ")],
Expand Down Expand Up @@ -328,7 +334,8 @@ export function caseGraph(texts: ReadonlyMap<string, string | undefined>): CaseG
})
})

// Sandboxes: the shared instances env declares, and one private sandbox per other agent.
// Sandboxes: the shared instances env declares, and one private sandbox per agent that names
// neither one of those nor `none`.
const profiles = obj(env.sandbox_profiles)
const shared = obj(env.sandboxes)
// Sandbox name → the profiles it can have, over the variants.
Expand Down Expand Up @@ -483,6 +490,7 @@ export function caseGraph(texts: ReadonlyMap<string, string | undefined>): CaseG
agents.forEach((a, i) => {
const id = `agent:${agentIds[i]}`
if (!str(a.task)) edge("task:input", id, "input")
if (sandboxless(a)) return
for (const sandbox of str(a.sandbox) ? names(str(a.sandbox)) : [agentIds[i]]) edge(id, `sandbox:${sandbox}`, "runs_in")
})
list(swarm.channels).forEach((raw) => {
Expand Down
2 changes: 1 addition & 1 deletion console/src/pages/NewCase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const encoder = new TextEncoder()
* file the task asks for. */
function starter(workspace: string, caseId: string): Changes {
const files: Record<string, string> = {
"case.yaml": `schema_version: 4
"case.yaml": `schema_version: 5
id: ${caseId}
workspace: ${workspace}
swarm:
Expand Down
43 changes: 30 additions & 13 deletions docs/case-format.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
# Case format

A case is a directory with `case.yaml`, `env.yaml`, and the files they point to. A
A case is a directory with `case.yaml`, `env.yaml` (unless no agent has a
[sandbox](#sandboxes)), and the files they point to. A
[suite](#suites) is a set of cases run together over a model matrix. This page describes what the
loader accepts today. The code is `swarmeval/core/`; the models are in
`models.py`. Why the format is shaped this way is in the
[v1 spec](../spec/2026-09-27-swarmeval-v1/README.md) §4–5 and the
[runtime spec](../spec/2026-09-28-runtime-sandbox-logs/README.md) decisions 1–5.

**Status:** `case.yaml` schema version 4, env files at version 1, suite files at version 2. It
covers agents and their [model slots](#model-slots), channels and their interventions, limits,
variants, extensions (installed or from the case directory), scorers, sandbox profiles, and
shared sandboxes. A case names no models: they are chosen when it is submitted. Fields the specs describe for later milestones are listed in
**Status:** `case.yaml` schema versions 4 and 5, env files at version 1, suite files at version 2.
It covers agents and their [model slots](#model-slots), channels and their interventions, limits,
variants, extensions (installed or from the case directory), scorers, sandbox profiles, shared
sandboxes, and agents with [no sandbox](#sandboxes). A case names no models: they are chosen when it is submitted. Fields the specs describe for later milestones are listed in
[Not accepted yet](#not-accepted-yet); the loader rejects them as unknown keys.

## Example

```yaml
# case.yaml
schema_version: 4
schema_version: 5
id: shared_repo
workspace: safety-team
category: reward_hacking
Expand All @@ -40,8 +41,12 @@ swarm:
sandbox: team_box
os_user: qa
sampling: { temperature: 0.6, seed: 1 }
- id: lead
prompt: prompts/lead.md
tools: [send_message]
sandbox: none # runs no commands, so gets no sandbox
channels:
- { id: team, members: [dev, qa] }
- { id: team, members: [dev, qa, lead] }
turn_policy: round_robin
limits: { max_turns: 40, max_tokens: 400k }

Expand Down Expand Up @@ -71,7 +76,7 @@ sandboxes: # shared instances only

| Key | Required | Meaning |
|---|---|---|
| `schema_version` | yes | `4`. Versions 1 to 3, whose agents named their models, no longer load; the error says how to move a case to 4 ([versioning](#versioning)) |
| `schema_version` | yes | `5`, or `4`, which has no [`sandbox: none`](#sandboxes). Versions 1 to 3, whose agents named their models, no longer load; the error says how to move a case on ([versioning](#versioning)) |
| `id` | yes | Case id |
| `workspace` | yes | Organizational field. It groups runs and is recorded on every event; it is not access control |
| `category`, `description` | no | Free text |
Expand All @@ -81,7 +86,7 @@ sandboxes: # shared instances only
| `swarm.channels` | no | `id`, at least two `members`, each an agent id, and optional `interventions` ([below](#channel-interventions)) |
| `swarm.turn_policy` | no | `round_robin` (default), `event_driven`, or `async` ([agent-runtime.md](agent-runtime.md#turn-policies)) |
| `swarm.limits` | no | `max_turns` (all agents together; under `async` each agent's own), `max_tokens`, which accepts `400k` or `2m`, and `wall_clock`, a duration such as `90s`, `20m`, or `2h`, paused time left out |
| `environment` | no | Path to the env file. Default `env.yaml` |
| `environment` | no | Path to the env file. Default `env.yaml`, which a case where no agent has a sandbox may leave out; a file named here must exist |
| `task.input` | if any agent has no `task` | File whose text is each agent's first user message |
| `extensions` | no | `use`, optional `as`, `config`. `use` names an installed extension, or a Python file in the case directory, [below](#case-extensions). See [agent-runtime.md](agent-runtime.md#writing-an-extension). A built-in `swarmeval.bus.*` config is checked at load, [below](#channel-interventions) |
| `scorers` | no | Final-state scorers, below |
Expand All @@ -95,9 +100,9 @@ An agent:
| `prompt` | yes | System prompt file |
| `task` | no | First user message file. Overrides `task.input` |
| `tools` | no | Tool names: `shell`, `send_message`, `web_request`, or one an extension provides. The run fails to start if nothing provides one. `web_request` is the agent's only way to the internet |
| `sandbox` | no | Join a shared instance from `env.yaml` |
| `sandbox` | no | Join a shared instance from `env.yaml`, or, from version 5, `none` for [no sandbox](#sandboxes) |
| `sandbox_profile` | no | Profile of the agent's private sandbox. Exclusive with `sandbox` |
| `os_user` | no | Unix user the agent's commands run as. sandboxd adds it to the sandbox's image with a private home under `/home`, unless the image has it already. Where it may write besides its home is up to the image |
| `os_user` | no | Unix user the agent's commands run as; not with `sandbox: none`. sandboxd adds it to the sandbox's image with a private home under `/home`, unless the image has it already. Where it may write besides its home is up to the image |
| `sampling` | no | `temperature`, `top_p`, `max_output_tokens`, `seed` |

Ids of cases, agents, channels, sandboxes, profiles, model slots, and variant axes match
Expand Down Expand Up @@ -267,7 +272,8 @@ has no default `threshold`: what a meaningful one is depends on the field.
matches across `/`). A `command`
scorer's sandbox must be one an agent uses, and its script must exist. `cross_sandbox` looks for a
sandbox's canary reaching an agent outside it other than in a message on a declared channel, so
the loader rejects it in a case where every agent uses one sandbox.
the loader rejects it in a case with no sandbox, or where every agent uses one sandbox; an agent
with no sandbox is outside every sandbox.

## Canaries

Expand Down Expand Up @@ -312,19 +318,29 @@ canary and a copied file may not write the same path.

## Sandboxes

Every agent gets a sandbox:
An agent gets a sandbox unless it says otherwise:

- `sandbox: <name>` joins the shared instance `<name>`, with the profile `env.yaml` gives it.
- `sandbox: none` (version 5) gives it no sandbox. Use it for agents that run no commands and
touch no files, which only talk or call worker-side tools (`send_message`, `web_request`, an
extension's `runs_in="worker"` tools). Such an agent cannot list `shell` or set `os_user` or
`sandbox_profile`; an extension tool that runs in a sandbox fails the run when it starts. In
version 4, `none` is a name like any other.
- Otherwise the agent gets a private sandbox named after the agent, with `sandbox_profile`, or
`default` when that is not set either.

Only sandboxes some agent uses are created, so a case whose agents all have `sandbox: none`
needs no `env.yaml` and never reaches sandboxd ([orchestrator.md](services/orchestrator.md#run-lifecycle)).
If it has an env file anyway, the file is checked as usual.

Names are deterministic because events, replay, and cross-run comparison key on them. The loader
rejects the following:

- An undeclared shared instance.
- A declared instance no agent uses.
- A missing profile, including a missing `default`.
- A shared instance named like an agent that has a private sandbox.
- From version 5, a shared instance named `none`, which no agent can join.

## Variants

Expand Down Expand Up @@ -428,6 +444,7 @@ error says how to move a case: set `schema_version: 4`, remove each agent's `mod
| 2 | Channel `interventions`, list values for variant axes, the `cross_sandbox` scorer |
| 3 | `case:` extension references, the `event_value` and `rule` scorers, the `event_driven` and `async` turn policies, `limits.wall_clock` |
| 4 | Agents name a [`model_slot`](#model-slots), not a `model`; models are chosen at submission. Versions 1 to 3 no longer load |
| 5 | `sandbox: none` gives an agent [no sandbox](#sandboxes); a case where no agent has one needs no `env.yaml` |

| Suite | Adds |
|---|---|
Expand Down
8 changes: 6 additions & 2 deletions docs/services/orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,11 @@ are the data, so a lost notification costs latency, never an event.
run `done`.

Built in `swarmeval.worker` for M0. Step 2 calls `CreateRun` and then creates each sandbox with
the `os_user`s of the agents in it and its sandbox canary as its identity. The self-check runs
the `os_user`s of the agents in it and its sandbox canary as its identity. Only the sandboxes some
agent uses are created; agents with [`sandbox: none`](../case-format.md#sandboxes) have none. A run
none of whose agents has a sandbox never calls sandboxd: steps 2 and 3, the final diff, and the
teardown are skipped, its `isolation` stays empty, and a command an extension runs through
`ctx.sandbox` fails the run. The self-check runs
before the model-gateway stream is attached, so a run it fails never reaches a model. Step 7
runs for cancelled runs too, since they are exported. With the network capability
(later), steps 1–3 also generate the TLS interception CA and a net-gateway certificate, start
Expand Down Expand Up @@ -506,7 +510,7 @@ The worker then:
only (over 1 MiB), a symlink, anything else, or an owner sandboxd could not set cannot come
back, and makes the fork `fs_partial` instead of `fs_restored`, recorded as the run's
`fidelity`. Background processes never come back, and no fork claims
to be exact.
to be exact. A fork of a run without sandboxes has nothing to restore and is `fs_restored`.
4. Runs the isolation self-check, then starts the loop from the checkpoint: the source's contexts
are copied at their generation numbers, agent, extension, and mail state is restored, and the
round in progress goes on. Its chain links into the source's
Expand Down
Loading
Loading