Customize the SDK¶
Subclass a session to change command defaults, image resolution, or operation handling. To replace storage or file transfer, implement the matching sync or async interface and pass it to the sandbox factory. See Advanced: assemble a sandbox yourself if you also need to manage component construction and cleanup.
Customize command policy and operations¶
RunRequest is a frozen configuration value. Its argument tuple and environment
mapping are copied at construction. Files and stdin remain caller-owned resources.
Use dataclasses.replace() to apply policy without mutating the caller’s request.
This example rejects shell commands, applies a default timeout, and counts waits. Configure a connection as described in Connection sources.
from dataclasses import replace
from contree_client.models import InstanceResult, InstanceSpawnResponse
from contree_sdk import ContreeSession, OperationContext, RunRequest, create_sandbox_session
from contree_sdk.session import Operation, OperationContract
class AuditedOperation(Operation):
waits = 0
def wait(self, *, timeout: float | None = None) -> InstanceResult:
self.waits += 1
return super().wait(timeout=timeout)
class PolicySession(ContreeSession):
def prepare_request(self, request: RunRequest) -> RunRequest:
request = super().prepare_request(request)
if request.shell is not None:
raise ValueError("this session only accepts commands with explicit arguments")
return replace(request, timeout=request.timeout or 30, env={**(request.env or {}), "APP_MODE": "test"})
def create_operation(self, response: InstanceSpawnResponse, context: OperationContext) -> OperationContract:
if not isinstance(response.uuid, str):
raise TypeError("missing operation UUID")
return AuditedOperation(self.client, response.uuid, timeout=context.request.timeout_seconds, context=context)
with create_sandbox_session(session_type=PolicySession, image="tag:alpine:latest") as session:
with session.spawn("echo", args=["hello"]) as operation:
result = operation.wait()
session.commit_result(operation)
assert isinstance(operation, AuditedOperation)
print(operation.waits)
from dataclasses import replace
from contree_client.models import InstanceResult, InstanceSpawnResponse
from contree_sdk import ContreeAsyncSession, OperationContext, RunRequest, create_sandbox_session
from contree_sdk.session import AsyncOperation, AsyncOperationContract
class AuditedAsyncOperation(AsyncOperation):
waits = 0
async def wait(self, *, timeout: float | None = None) -> InstanceResult:
self.waits += 1
return await super().wait(timeout=timeout)
class PolicySession(ContreeAsyncSession):
def prepare_request(self, request: RunRequest) -> RunRequest:
request = super().prepare_request(request)
if request.shell is not None:
raise ValueError("this session only accepts commands with explicit arguments")
return replace(request, timeout=request.timeout or 30, env={**(request.env or {}), "APP_MODE": "test"})
def create_operation(self, response: InstanceSpawnResponse, context: OperationContext) -> AsyncOperationContract:
if not isinstance(response.uuid, str):
raise TypeError("missing operation UUID")
return AuditedAsyncOperation(
self.client, response.uuid, timeout=context.request.timeout_seconds, context=context
)
async with create_sandbox_session(session_type=PolicySession, image="tag:alpine:latest") as session:
async with await session.spawn("echo", args=["hello"]) as operation:
result = await operation.wait()
await session.commit_result(operation)
assert isinstance(operation, AuditedAsyncOperation)
print(operation.waits)
The session calls prepare_request() before uploading files or submitting work.
It passes the prepared inputs to submit_request(), then constructs the handle
through create_operation(). The OperationContext holds the effective request
and the source image, history entry, and branch used for the eventual commit.
If operation construction fails after submission, the session attempts remote
cancellation and preserves the factory error.
create_store() and create_file_transfer() supply defaults only when no component
was passed explicitly. Constructor-time factories must not depend on attributes
that a subclass initializes after super().__init__().
Choose what to override¶
Requirement |
Extension boundary |
|---|---|
Add command defaults or reject commands |
Override |
Resolve images from another naming scheme |
Override |
Modify transport submission |
Override |
Instrument or replace operation lifecycle |
Override |
Replace subprocess handles |
Override |
Replace the event source |
Override |
Observe operation events |
Register |
Upload from another storage service |
Implement |
Store history remotely |
Implement |
Replace build caching |
Implement |
Add or replace Dockerfile instructions |
Register a |
Use a session subclass inside builds |
Pass |
Add per-build state |
Set |
Audit or restrict build instructions |
Override |
Use another execution engine in deepagents |
Implement |
Use native async I/O in async overrides; offload blocking work explicitly.
Implement file transfer¶
Inherit SyncFileTransfer or AsyncFileTransfer and implement:
upload(UploadFileSpec) -> FileSpec: return a file identifier valid on the session’s server.read_file(image_uuid, path) -> bytes: return exact bytes from the specified image.
The default prepare_files() expands directories, rejects destination conflicts,
and calls upload() with a bounded worker count. Set max_concurrency on a custom
transfer to select that limit. Override iter_file() to yield chunks and release
its reader when the generator closes. Its default implementation buffers through
read_file(). The inherited download_file() writes those chunks atomically.
See Work with sandbox files for tree preparation, callbacks, cancellation, and ownership.
The async implementation cancels and joins sibling uploads if one fails. Override
this method for batching or bounded concurrency. Override read_stdin() to support
additional input handling. Never close a caller-owned input stream.
Inject a component with file_transfer=. ClientFileTransfer and
AsyncClientFileTransfer provide the standard implementations and can also be
subclassed. Import UploadFileSpec, UploadedFile, InputSource, and the readable
protocols from contree_sdk.files.
Implement history storage¶
Inherit SyncStore or AsyncStore to keep history in your own database or service.
Pass the implementation through store=. See Implement a custom Store for executable
extension examples, required methods, and storage guarantees. For standard memory
and SQLite usage, start with Save and resume a session.
Store reusable results¶
The same Store holds history and reusable results. Implement its contract with
Redis or PostgreSQL and supply it through store= in helpers and
direct constructors. Tree kind distinguishes purposes; the backend does not
interpret user JSON.
Stores expose close() and a matching context manager. Sessions and builders
leave caller-supplied stores open. See Reuse uploads and retain their sources for upload reuse and
Advanced: resume a background operation for operation recovery.
Replace an integration backend¶
The framework adapters depend only on SyncExecutor or AsyncExecutor:
session_ididentifies the execution context.execute(RunRequest)returnscontree_client.models.InstanceResult.write_file(path, content)writes bytes and returnsNoneafter application.read_file(path)returns bytes; use clientNotFoundErrororUnprocessableEntityErrorfor missing or invalid paths.
A nonzero process exit code belongs in the result. Operation-level failure is an
exception. Preserve cancellation and timeout errors. An executor used by the
sandbox must retain filesystem changes when disposable=False; the adapter uses
this for commands and uploads. It serializes its own mutating calls, but callers
sharing the same executor must coordinate any additional concurrent mutations.
The adapters never access a concrete session’s client, store, or history internals.
They do not close the executor. The async adapter requires native async methods;
its sync entry points raise NotImplementedError.
write_file() has a default implementation using shell execution and binary
RunRequest.stdin. Existing executors inherit it. Override this method to replace
the transfer mechanism; adapters do not construct upload commands themselves.
A failed write raises FileWriteError(path, message). Transport errors, timeouts,
and cancellation must propagate unchanged.
A write must apply the content before returning. Uploading a blob or staging files for a later command is not sufficient. A native transfer implementation must also coordinate with VM startup, active commands, and snapshots. Keep the VM selected for the transfer until the backend confirms application.
Blob upload and application to a VM are separate operations. A future backend
implementation can reuse uploaded blobs here without changing framework calls.
Archive extraction needs explicit extraction semantics; write_file always means
writing the supplied bytes, even when those bytes contain an archive.
See File transfers and executor contracts.
Extend Dockerfile builds¶
The same directive class supports both builders through execute() and
execute_async(). Create a parser per dialect, then register your directive:
"""Register a Dockerfile directive without changing the SDK's global registry."""
from dataclasses import dataclass
from typing import ClassVar
from contree_sdk.docker import AsyncBuildContext, BuildContext, DockerfileParser, DockerKeyword
@dataclass(frozen=True, repr=False)
class BuildMode(DockerKeyword):
NAME: ClassVar[str] = "BUILDMODE"
value: str = ""
@classmethod
def parse(cls, args_text: str) -> "BuildMode":
if args_text not in {"test", "release"}:
raise ValueError("BUILDMODE requires test or release")
return cls(value=args_text)
def serialize(self) -> str:
return f"BUILDMODE {self.value}"
def execute(self, ctx: BuildContext) -> None:
ctx.env["BUILD_MODE"] = self.value
async def execute_async(self, ctx: AsyncBuildContext) -> None:
ctx.env["BUILD_MODE"] = self.value
def make_parser() -> DockerfileParser:
parser = DockerfileParser()
parser.register(BuildMode)
return parser
parser = make_parser()
print(parser.parse("BUILDMODE test"))
Pass the parser with parser=make_parser() to either builder. Registration copies
no state into the module registry and cannot change another builder’s dialect.
execute() and execute_async() define the instruction’s sync and async behavior.
Implement both if the dialect supports both builders.
The session_factory receives client as its first positional argument, then
session_id, store, image, node_id, and tree_kind as keyword arguments. It returns a ContreeSession or ContreeAsyncSession implementation.
Both fresh layers and cache hits use this factory. Set context_class to a
BuildContext or AsyncBuildContext subclass when only additional methods or state
are needed. Override create_context(BuildRequest) for a different construction
policy. execute_directive() intercepts normal steps, stage sealing, and final file materialization.
Custom directives that execute nested directives must call context.execute_directive().
A custom create_context() must pass directive_executor=self.run_step
to preserve event reporting and policy dispatch for nested steps.
run_step() wraps execute_directive() with step notifications.
Override BuildContext.run_operation(request, branch=...) or its async equivalent
to customize RUN orchestration. The default uses session spawn_request(),
operation events()/wait(), and session commit_result(). It applies
prepare_request() and the session operation factory through these public hooks.
A custom directive must include every input that affects a layer in the context’s cache state or its hash contribution. Changing the implementation of an instruction can invalidate previous cached layers; use a new session/cache namespace or disable cache reuse. A parser extension alone does not automatically version existing caches.
Operation contract obligations¶
OperationContract and AsyncOperationContract expose status, events, waiting,
stdin, signals, cancellation, context management, and subprocess creation. A
session-created operation has an OperationContext. A reattached UUID-only handle
may have no context and cannot be committed to session history.
wait() must store the final OperationResponse before returning its
InstanceResult. Cancellation and cleanup must preserve the primary exception.
Use SubprocessContract or AsyncSubprocessContract when replacing subprocess
handles. Iteration followed by wait() must not wait for a second exit event.
A stream failure must reach its consumer instead of appearing as successful EOF.
The built-in operations own one unfiltered reader per handle. Override
open_event_stream() to replace its source. Calling events() creates a local
subscription; overriding it does not replace the source used by wait() or
subprocesses. Register observers before entering the context or first consuming
events() or wait(). Sync observers return directly; async observers are awaited.
They receive (event, None) for events and (None, error) at EOF. Keep them short;
do not wait for the operation from its own observer. Observer errors fail the reader.
LazySession requires add_observer() support from a custom operation contract.
It delegates request policy and operation creation to the wrapped session. Override
keepalive_request() on the lazy wrapper to use another main process. See
Sessions and operations for the public snapshot-policy interface.
Resolve application-specific image names¶
A sync override runs during session construction. An async override runs during
ensure_ready() or the first operation. Return a valid UUID for the selected server;
the example uses a mapping that your application supplies.
from contree_sdk import ContreeSession, create_sandbox_session
class NamedImageSession(ContreeSession):
def resolve_image(self, image: str) -> str:
return {"test-base": "8a0269a2-7720-4daa-aa96-dca93b20bb33"}[image]
with create_sandbox_session(session_type=NamedImageSession, image="test-base") as session:
print(session.image_uuid)
from contree_sdk import ContreeAsyncSession, create_sandbox_session
class NamedAsyncImageSession(ContreeAsyncSession):
async def resolve_image(self, image: str) -> str:
return {"test-base": "8a0269a2-7720-4daa-aa96-dca93b20bb33"}[image]
async with create_sandbox_session(session_type=NamedAsyncImageSession, image="test-base") as session:
print(session.image_uuid)
Select a commit policy¶
ApiSuccessCommitPolicy is the default for both session implementations. It
retains successful API results from non-disposable commands, including nonzero
and unknown process exit codes. API failure and cancellation raise errors and
never change history.
Pass commit_policy=ZeroExitCommitPolicy() to retain only results with an explicit
zero exit code. A rejected result is still returned to the caller. Rejection does
not change the session image, branches, or pending files. commit_result() uses
the same policy after an explicit spawn() and wait(); it returns None when
it skips the commit. Disposable requests cannot create history entries through
commit_result(), even if the server returns an image.
from contree_sdk import ContreeSession, create_sandbox_session
from contree_sdk.session import ZeroExitCommitPolicy
with create_sandbox_session(
session_type=ContreeSession, image="tag:alpine:latest", commit_policy=ZeroExitCommitPolicy()
) as session:
original_image = session.image_uuid
result = session.run(shell="exit 7")
assert result.state.exit_code == 7
assert session.image_uuid == original_image
operation = session.spawn(shell="exit 7")
result = operation.wait()
entry = session.commit_result(operation)
assert result.state.exit_code == 7
assert entry is None
from contree_sdk import ContreeAsyncSession, create_sandbox_session
from contree_sdk.session import ZeroExitCommitPolicy
async with create_sandbox_session(
session_type=ContreeAsyncSession, image="tag:alpine:latest", commit_policy=ZeroExitCommitPolicy()
) as session:
original_image = session.image_uuid
result = await session.run(shell="exit 7")
assert result.state.exit_code == 7
assert session.image_uuid == original_image
operation = await session.spawn(shell="exit 7")
result = await operation.wait()
entry = await session.commit_result(operation)
assert result.state.exit_code == 7
assert entry is None
For an application-specific rule, inherit AbstractCommitPolicy and implement
should_commit(context: OperationContext, result: InstanceResult) -> bool. The
session passes the effective request and its original history position. The
method must not perform blocking I/O. The session validates API success before
calling it and requires a result image before committing an accepted result.
Policies are session components; pass the same policy when resuming a session.
Session commands save by default; disposable=True opts out. Snapshots are
lightweight and can run after every command, but stop the VM. An unchanged
filesystem can return the same image UUID; policies must not require a new UUID.
A LazySession snapshot also uses the underlying session’s commit policy. If the
policy rejects that snapshot, the lazy session fails and leaves history unchanged.
It cannot restart from the previous image while claiming that changes were saved.
The policy sees the keepalive process result, not individual subprocess results.
Use the default policy to preserve snapshots after terminating the keepalive.