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
3 changes: 2 additions & 1 deletion src/agents/run_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ def _default_run_state_validation_error(
# 3. to_json() always emits CURRENT_SCHEMA_VERSION.
# 4. Forward compatibility is intentionally fail-fast (older SDKs reject newer or unsupported
# versions).
CURRENT_SCHEMA_VERSION = "1.16"
CURRENT_SCHEMA_VERSION = "1.17"
_PROGRAMMATIC_TOOL_CALLING_MIN_SCHEMA_VERSION = "1.13"
_HOSTED_MCP_APPROVALS_MIN_SCHEMA_VERSION = "1.14"
# Keep this mapping in chronological order. Every schema bump must add a one-line summary here.
Expand Down Expand Up @@ -213,6 +213,7 @@ def _default_run_state_validation_error(
"Persists Docker network-isolation state and lets an exact call approval decision "
"override a sticky decision for the same tool."
),
"1.17": "Persists Docker container labels across sandbox resume and replacement.",
}
SUPPORTED_SCHEMA_VERSIONS = frozenset(SCHEMA_VERSION_SUMMARIES)

Expand Down
32 changes: 31 additions & 1 deletion src/agents/sandbox/sandboxes/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from docker.models.containers import Container # type: ignore[import-untyped]
from docker.types import DriverConfig, Mount as DockerSDKMount # type: ignore[import-untyped]
from docker.utils import parse_repository_tag
from pydantic import model_validator
from pydantic import Field, model_validator
from typing_extensions import Self

from .._mount_security import (
Expand Down Expand Up @@ -185,6 +185,7 @@ class DockerSandboxSessionState(SandboxSessionState):
image: str
container_id: str
network_mode: Literal["none"] | None = None
labels: dict[str, str] = Field(default_factory=dict)

@model_validator(mode="after")
def _validate_network_configuration(self) -> Self:
Expand Down Expand Up @@ -214,6 +215,7 @@ class DockerSandboxClientOptions(BaseSandboxClientOptions):
image: str
exposed_ports: tuple[int, ...] = ()
network_mode: Literal["none"] | None = None
labels: dict[str, str] = Field(default_factory=dict)

@model_validator(mode="after")
def _validate_network_configuration(self) -> Self:
Expand All @@ -230,12 +232,14 @@ def __init__(
*,
type: Literal["docker"] = "docker",
network_mode: Literal["none"] | None = None,
labels: dict[str, str] | None = None,
) -> None:
super().__init__(
type=type,
image=image,
exposed_ports=exposed_ports,
network_mode=network_mode,
labels={} if labels is None else labels,
)


Expand Down Expand Up @@ -1535,6 +1539,7 @@ async def create(
exposed_ports=options.exposed_ports,
network_mode=options.network_mode,
session_id=session_id,
labels=options.labels,
)
container.start()
container_id = container.id
Expand All @@ -1549,6 +1554,7 @@ async def create(
container_id=container_id,
exposed_ports=options.exposed_ports,
network_mode=options.network_mode,
labels=options.labels,
)
inner = DockerSandboxSession(
docker_client=self.docker_client,
Expand Down Expand Up @@ -1653,6 +1659,7 @@ async def resume(
container,
state.network_mode,
)
_assert_existing_container_labels_match(container, state.labels)
owns_replacement = container is None
replacement_session_id = (
uuid.uuid4()
Expand Down Expand Up @@ -1681,6 +1688,7 @@ async def resume(
exposed_ports=state.exposed_ports,
network_mode=state.network_mode,
session_id=replacement_session_id,
labels=state.labels,
)
container_id = container.id
assert container_id is not None
Expand Down Expand Up @@ -1715,6 +1723,7 @@ async def _create_container(
exposed_ports: tuple[int, ...] = (),
network_mode: Literal["none"] | None = None,
session_id: uuid.UUID | None = None,
labels: dict[str, str] | None = None,
) -> Container:
if manifest is not None:
_validate_docker_path_grants(manifest)
Expand All @@ -1736,6 +1745,8 @@ async def _create_container(
}
if network_mode is not None:
create_kwargs["network_mode"] = network_mode
if labels:
create_kwargs["labels"] = labels
if manifest is not None:
docker_mounts = _build_docker_volume_mounts(manifest, session_id=session_id)
if docker_mounts:
Expand Down Expand Up @@ -1895,6 +1906,25 @@ def _assert_existing_container_network_configuration_matches(
)


def _assert_existing_container_labels_match(
container: Container,
labels: dict[str, str],
) -> None:
if not labels:
return

container.reload()
attrs = getattr(container, "attrs", {}) or {}
config = attrs.get("Config")
actual_labels = config.get("Labels") if isinstance(config, dict) else None
actual_labels = actual_labels if isinstance(actual_labels, dict) else {}
if any(actual_labels.get(key) != value for key, value in labels.items()):
raise ValueError(
"Existing Docker sandbox labels do not match persisted labels; "
"create a fresh sandbox session"
)


def _assert_existing_container_path_grants_match(
container: Container,
manifest: Manifest,
Expand Down
4 changes: 2 additions & 2 deletions tests/fixtures/run_state/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# RunState compatibility corpus

The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.16. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture.
The `minimal/` fixtures cover every schema version accepted by the current reader. The `features/` fixtures cover the schema-bearing behavior introduced in versions 1.2 through 1.17. The `resume/` fixture records an actual pending function-tool approval emitted by the v0.19.4 writer, and the `security/` fixture records that writer's credential-bearing sandbox state. `sources.json` records the source commit and provenance for every fixture.

Regenerate the feature corpus from the recorded historical source trees with:

Expand All @@ -10,6 +10,6 @@ UV_DEFAULT_INDEX=https://pypi.org/simple uv run python tests/fixtures/run_state/

The generator extracts each recorded commit with `git archive` and runs that commit's writer in a fresh locked environment. It does not import the current checkout.

Versions 1.7, 1.8, and 1.16 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. The 1.16 schema transition likewise has no retained writer commit, but the retained 1.15 writer produces the same minimal and per-call-override payloads. These fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output.
Versions 1.7, 1.8, 1.16, and 1.17 are explicit exceptions. Release-boundary schema renumbering assigned duplicate-agent/sandbox state to 1.7 and prompt-cache state to 1.8 without any writer commit that emitted those final version numbers. The 1.16 schema transition likewise has no retained writer commit, but the retained 1.15 writer produces the same minimal and per-call-override payloads. The 1.17 labels transition uses the labels-capable 1.16 writer and changes only the schema label. These fixtures are therefore marked `canonical_compatibility`: the recorded writer produces the payload, and the generator changes only the schema label so the corresponding reader branch remains covered. They must not be represented as historical-writer output.

Ordinary tests never run the generator. They read the frozen payloads, compare every durable field emitted by the historical writer across the upgrade, rewrite to the current schema, and verify that the rewritten form is idempotent. They also approve and reject the historical pending interruption through actual `Runner` resumes. The schema version itself is the only normalization for the ordinary corpus; fields added by newer writers may be absent from an older payload, but every field present in that payload must survive. The security fixture has one explicit migration normalization: persisted mount credentials and opaque driver options are removed and the trusted-rebind marker is added. All non-authority topology remains part of the comparison.
111 changes: 111 additions & 0 deletions tests/fixtures/run_state/features/v1_17_docker_labels.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
{
"$schemaVersion": "1.17",
"auto_previous_response_id": false,
"context": {
"approvals": {},
"context": {},
"context_meta": {
"omitted": false,
"original_type": "mapping",
"requires_deserializer": false,
"serialized_via": "mapping"
},
"tool_invocations": {},
"usage": {
"input_tokens": 0,
"input_tokens_details": [
{
"cache_write_tokens": 0,
"cached_tokens": 0
}
],
"output_tokens": 0,
"output_tokens_details": [
{
"reasoning_tokens": 0
}
],
"request_usage_entries": [],
"requests": 0,
"total_tokens": 0
}
},
"conversation_id": null,
"current_agent": {
"name": "compat-agent"
},
"current_step": null,
"current_turn": 0,
"current_turn_persisted_item_count": 0,
"generated_items": [],
"generated_prompt_cache_key": null,
"generated_session_item_indexes": [],
"input_guardrail_results": [],
"last_model_response": null,
"last_processed_response": null,
"max_turns": 10,
"model_responses": [],
"nested_history_owned_session_item_refs": [],
"no_active_agent_run": true,
"original_input": "historical input",
"output_guardrail_results": [],
"pending_input": [],
"previous_response_id": null,
"reasoning_item_id_policy": null,
"sandbox": {
"backend_id": "docker",
"current_agent_name": "compat-agent",
"session_state": {
"container_id": "container",
"exposed_ports": [],
"image": "python:3.14-slim",
"labels": {
"com.example.owner": "worker-123"
},
"manifest": {
"entries": {},
"environment": {
"value": {}
},
"extra_path_grants": [],
"groups": [],
"remote_mount_command_allowlist": [
"ls",
"find",
"stat",
"cat",
"less",
"head",
"tail",
"du",
"grep",
"rg",
"wc",
"sort",
"cut",
"cp",
"tee",
"echo",
"mkdir",
"rm"
],
"root": "/workspace",
"users": [],
"version": 1
},
"network_mode": null,
"session_id": "00000000-0000-0000-0000-000000000117",
"snapshot": {
"id": "snapshot",
"type": "noop"
},
"type": "docker",
"workspace_root_ready": false
}
},
"session_items": [],
"tool_input_guardrail_results": [],
"tool_output_guardrail_results": [],
"tool_use_tracker": {},
"trace": null
}
47 changes: 47 additions & 0 deletions tests/fixtures/run_state/generate_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,40 @@ def approval(call_id):
"changed to exercise the canonical compatibility branch."
),
),
Scenario(
"1.17",
"2baa1b1bcc4cebc64e197debd4c59e4bee1093be",
"docker_labels",
"""
from agents.sandbox import Manifest
from agents.sandbox.snapshot import NoopSnapshot

session_state = {
"type": "docker",
"session_id": "00000000-0000-0000-0000-000000000117",
"snapshot": NoopSnapshot(id="snapshot").model_dump(mode="json"),
"manifest": Manifest().model_dump(mode="json"),
"exposed_ports": [],
"workspace_root_ready": False,
"image": "python:3.14-slim",
"container_id": "container",
"network_mode": None,
"labels": {"com.example.owner": "worker-123"},
}
state._sandbox = {
"backend_id": "docker",
"current_agent_name": agent.name,
"session_state": session_state,
}
""",
provenance="canonical_compatibility",
emitted_version="1.16",
note=(
"The labels implementation was first emitted with the unreleased 1.16 writer. "
"The fixture changes only the schema label to exercise the 1.17 compatibility "
"reader while preserving the Docker session payload."
),
),
)


Expand All @@ -443,6 +477,19 @@ def approval(call_id):
"changed to exercise the canonical compatibility branch."
),
),
Scenario(
"1.17",
"2baa1b1bcc4cebc64e197debd4c59e4bee1093be",
"minimal",
"",
provenance="canonical_compatibility",
emitted_version="1.16",
note=(
"The labels implementation was first emitted with the unreleased 1.16 writer. "
"The fixture changes only the schema label to exercise the 1.17 compatibility "
"reader while preserving older payload compatibility."
),
),
)


Expand Down
60 changes: 60 additions & 0 deletions tests/fixtures/run_state/minimal/v1_17.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"$schemaVersion": "1.17",
"auto_previous_response_id": false,
"context": {
"approvals": {},
"context": {},
"context_meta": {
"omitted": false,
"original_type": "mapping",
"requires_deserializer": false,
"serialized_via": "mapping"
},
"tool_invocations": {},
"usage": {
"input_tokens": 0,
"input_tokens_details": [
{
"cache_write_tokens": 0,
"cached_tokens": 0
}
],
"output_tokens": 0,
"output_tokens_details": [
{
"reasoning_tokens": 0
}
],
"request_usage_entries": [],
"requests": 0,
"total_tokens": 0
}
},
"conversation_id": null,
"current_agent": {
"name": "compat-agent"
},
"current_step": null,
"current_turn": 0,
"current_turn_persisted_item_count": 0,
"generated_items": [],
"generated_prompt_cache_key": null,
"generated_session_item_indexes": [],
"input_guardrail_results": [],
"last_model_response": null,
"last_processed_response": null,
"max_turns": 10,
"model_responses": [],
"nested_history_owned_session_item_refs": [],
"no_active_agent_run": true,
"original_input": "historical input",
"output_guardrail_results": [],
"pending_input": [],
"previous_response_id": null,
"reasoning_item_id_policy": null,
"session_items": [],
"tool_input_guardrail_results": [],
"tool_output_guardrail_results": [],
"tool_use_tracker": {},
"trace": null
}
Loading
Loading