Client Library
The idegym-client package provides a Python async client for interacting with the IdeGYM orchestrator.
It handles authentication, client registration, server lifecycle management, and all tool operations.
Installation
The client is part of the IdeGYM workspace. If you have the repository set up:
uv sync --all-packages --all-extras --all-groups
Or install from PyPI (once published):
uv add idegym-client
Quick Example
import asyncio
from idegym.client.client import IdeGYMClient
async def main():
async with IdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="my-training-run",
namespace="idegym",
) as client:
async with client.with_server(
image_tag="registry.example.com/my-env:latest",
server_name="my-server",
) as server:
result = await server.execute_bash(script="echo hello")
print(result.stdout) # → hello
asyncio.run(main())
IdeGYMClient
from idegym.client.client import IdeGYMClient
The main entry point. Must be used as an async context manager — async with IdeGYMClient(...) as client —
which registers the client with the orchestrator on entry and deregisters it on exit (stopping all
associated servers). A failed deregistration leaks every pod the client owns, so it is never
silent: it raises on exit, unless the body is already raising, in which case it is logged and the
body's exception propagates.
Constructor
IdeGYMClient(
orchestrator_url: str,
name: str,
namespace: str,
nodes_count: int = 0,
auth: Optional[BasicAuth] = None,
client_id: Optional[str] = None,
heartbeat_interval_in_seconds: int = 60,
request_timeout_in_seconds: int = 60,
otel_config: Optional[OTELConfig] = None,
transport: Optional[httpx.AsyncBaseTransport] = None,
limits: Optional[httpx.Limits] = None,
http_client: Optional[httpx.AsyncClient] = None,
)
| Parameter | Description |
|---|---|
orchestrator_url | URL of the orchestrator API (e.g., https://idegym.yourdomain.com) |
name | Client name used for resource quota assignment |
namespace | Kubernetes namespace where servers are created |
nodes_count | Number of nodes to reserve for this client (default: 0) |
auth | BasicAuth(username, password). Defaults to IDEGYM_AUTH_USERNAME / IDEGYM_AUTH_PASSWORD environment variables |
client_id | If provided, attaches to an existing client session without sending heartbeats |
heartbeat_interval_in_seconds | How often to send liveness heartbeats to the orchestrator (default: 60) |
request_timeout_in_seconds | Default HTTP request timeout (default: 60) |
otel_config | OpenTelemetry tracing configuration |
transport | Transport for the HTTP client IdeGYM builds — an alternative HTTP stack, a proxy, or a recording transport in tests |
limits | Connection-pool limits for the HTTP client IdeGYM builds; omit for httpx's defaults (100 connections, 20 keep-alive) |
http_client | A fully configured httpx.AsyncClient to use verbatim; orchestrator_url, auth, request_timeout_in_seconds and otel_config are then ignored |
Configuring the HTTP stack:
import httpx
client = IdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="my-training-run",
namespace="idegym",
limits=httpx.Limits(max_connections=64, max_keepalive_connections=16),
)
A supplied transport is used as-is, and httpx applies limits only to a transport it builds
itself, so the two cannot be combined — passing both raises ValueError. Set the limits on the
transport instead:
client = IdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="my-training-run",
namespace="idegym",
transport=httpx.AsyncHTTPTransport(
limits=httpx.Limits(max_connections=64, max_keepalive_connections=16),
proxy="http://proxy.internal:3128",
),
)
http_client is the full escape hatch. It is used exactly as given — nothing about it is
modified, not even to instrument it for tracing, so it must already carry base_url and any
authentication — and it is not closed when the IdeGYMClient context exits, because it
belongs to its caller. A client IdeGYM builds itself is closed on exit as before. With
http_client, no credentials are required, and orchestrator_url, auth,
request_timeout_in_seconds and otel_config are ignored: they only configure the client IdeGYM
builds. Passing
http_client together with transport or limits raises ValueError rather than ignoring them.
Authentication via environment variables:
export IDEGYM_AUTH_USERNAME=admin
export IDEGYM_AUTH_PASSWORD=your-password
with_server(...) — async context manager
Start a server and yield an IdeGYMServer, then stop or finish it on exit.
async with client.with_server(
image_tag="registry.example.com/my-env:latest",
server_name="my-server",
namespace=None, # defaults to client namespace
runtime_class_name="gvisor",
run_as_root=False,
resources=None, # KubernetesResources
node_selector=None,
labels={"team": "research", "job": "swe-bench-run-42"},
annotations={"idegym.example.com/task-url": "https://tracker.example.com/TASK-1"},
server_start_wait_timeout_in_seconds=300,
reuse_strategy=ServerReuseStrategy.RESET,
close_action=ServerCloseAction.FINISH, # FINISH or STOP
) as server:
...
| Parameter | Description |
|---|---|
image_tag | OCI image to run as the server |
server_name | Kubernetes name for the server deployment (must be a valid k8s object name) |
runtime_class_name | Runtime class for the pod (e.g., "gvisor" for sandboxing) |
run_as_root | Run the container as root (default: False) |
resources | Kubernetes resource requests/limits |
node_selector | Node affinity labels |
labels | Extra labels for the server's Deployment, Pod, Service and PodDisruptionBudget |
annotations | Extra annotations for the server pod |
server_start_wait_timeout_in_seconds | How long to wait for the server pod to become ready (default: 300) |
reuse_strategy | Whether to take over an existing server: NONE (always create from scratch), RESTART (reuse and restart the pod), RESET (reuse and reset project state) — see Server reuse |
close_action | FINISH — release the server but leave it running for the next client; STOP — stop and delete the server |
The server is closed with finish_server or stop_server on the way out, whether the body
succeeded or not. If the body raised and the cleanup fails as well, the cleanup error is logged
rather than raised, so the body's exception — the one that says what went wrong first — is the one
you see. A cleanup failure after a body that succeeded is raised.
Waiting for the server to be ready
server_start_wait_timeout_in_seconds defaults to 300, which covers a cold image pull. A
multi-gigabyte environment image takes minutes to pull onto a node that has never seen it, and
the previous 60-second default expired long before that. Raise it further for larger images
rather than retrying into the same wall — a retry re-pulls onto the same cold node.
The timeout is the orchestrator's: it is sent with the request, and the orchestrator waits that
long for the pod. The client itself waits a grace period longer — the larger of 60 seconds and a
tenth of the timeout, so 360 seconds in all by default — because the orchestrator's wait starts
only after its own bookkeeping and the deploy, and ends with a look at the pod. That way the
orchestrator's diagnosis, not a bare client-side timeout, is what reaches the caller. A 429
from a full quota is retried every retry_delay_in_seconds within the same client deadline. The
client polls for the result with backoff, but never sleeps past its deadline: the last poll is
made at the deadline itself, so a server that became ready shortly before it is still returned
rather than left running unclaimed. restart_server uses the same deadline.
When the wait does expire, the error says what the pod was doing, so a slow pull is not mistaken for a broken health endpoint:
Pods with label 'app=srv-abc' were not ready within 300s
(not waiting on the scheduler; still pulling the image or creating the container (ContainerCreating))
The distinctions it draws are: still pulling or creating the container, the image could not be
pulled, an init container that is still running or has failed (named, since the main container
then waits with PodInitializing, which alone looks like a pull), the container is running but
its readiness probe has not passed, a new pod that is ready but waiting for an old one to
terminate, and no pod matched at all. Only the readiness probe case points at the image's health
endpoint. With several pods it describes the first one that is not ready, prefixed with how many
are (1/2 pods ready; ...).
Scheduling has its own budgets, separate from this timeout; see Clusters with slow node provisioning.
Tagging a sandbox
labels and annotations are how a sandbox gets attributed to the job, team or task that
created it, which makes the usual operator workflows apply:
kubectl get pods -n idegym -l job=swe-bench-run-42
They land on the Deployment, Pod, Service and PodDisruptionBudget. Use a label for anything you
want to select or group by, and an annotation for metadata that is too long or too unstructured
to be one — a Kubernetes label value is capped at 63 characters. Both are checked against the
Kubernetes syntax when the request is built: a key is an optional lowercase DNS prefix (at most
253 characters) and a slash, then a name of at most 63 characters, and all annotation keys and
values together may not exceed 256 KiB. A request that breaks these fails with a
ValidationError instead of a server that fails to start.
Keys IdeGYM manages are rejected, not silently overwritten: app, anything under
app.kubernetes.io/, and anything under idegym.jetbrains.com/. Those are what the Service
selector, the PodDisruptionBudget and the watcher's pod queries match on, so taking one over
would detach the sandbox from the machinery that manages it. The annotations IdeGYM sets are
rejected the same way: cluster-autoscaler.kubernetes.io/safe-to-evict, anything under
prometheus.io/, and anything under podsnapshot.gke.io/. The last one matters most, since it
names the GKE pod snapshot a pod is restored from. Any other annotation key is accepted,
including ones under the managed label prefixes, because annotations carry no selector weight.
Two things they deliberately do not affect. They are not part of the snapshot hash — they
describe who asked for a sandbox, not what is in it, and a per-job label would otherwise make
every snapshot a miss. And they are not part of reuse matching: a server reused through
RESTART or RESET (the default) keeps the labels and annotations it was created with, and the
new request's values are not applied to it. So kubectl get pods -l job=<new job> does not find
a reused sandbox. Pass reuse_strategy=ServerReuseStrategy.NONE when the pod's metadata must
match this request, and do not rely on labels to tell one episode from the next on a reused
server.
start_server(...) / stop_server(...) / finish_server(...)
Explicit lifecycle control without the context manager:
server = await client.start_server(image_tag=..., server_name=..., ...)
# Work with the server...
await client.finish_server(server) # release without stopping
# or
await client.stop_server(server) # stop and delete
stop_server, finish_server and restart_server raise when the operation fails, like the
rest of the API — they do not report failure as a return value. There is no return value to check, so a
failed delete cannot be mistaken for a successful one and leak the pod:
from idegym.client import IdeGYMHTTPError
try:
await client.stop_server(server)
except IdeGYMHTTPError as e:
... # the pod may still be running; the server is not stopped
stop_server and finish_server retry a failed call twice more with exponential backoff before
raising. A permanent client error — a 4xx other than 408 and 429, such as the 404 or 410
for a server that was already reaped — is raised at once instead of being retried.
Server reuse
Reuse is attempted only for RESTART and RESET. A candidate is taken over only if all of
these match:
| Field | Comes from |
|---|---|
| client name | the name this client registered with |
image_tag | the start request |
runtime_class_name | the start request |
run_as_root | the start request |
server_kind | the start request |
server_name | the start request, when set |
availability is FINISHED | how the previous holder released it |
server_name is one of seven filters, not the key — two requests with the same name but
different images will not share a server.
labels and annotations are not among them, and a server that is taken over keeps the
Kubernetes metadata it was created with — see Tagging a sandbox.
The last row is the one that catches people. A server becomes FINISHED only through
finish_server (or close_action=FINISH, which with_server uses by default). A client that
always calls stop_server leaves its servers STOPPED, and reuse can then never hit — the
requests look identical and a new server is created every time.
Because that is invisible from the outside, the start response says what happened:
server = await client.start_server(..., reuse_strategy=ServerReuseStrategy.RESET)
if not server.reused:
... # a fresh server; the project is already in its initial state
list_servers(include_terminal=False) — what am I holding?
for server in await client.list_servers():
print(server.server_id, server.availability, server.image_tag)
Scoped to this client's registration, newest first. Terminal servers (STOPPED, KILLED,
CRASHED, …) are excluded unless include_terminal=True, so the default answers "what is
still mine and running" — which is what makes cleaning up after a crashed run possible without
cluster access.
Each row carries server_id, server_name, generated_name, namespace, availability,
usable, image_tag, created_at, last_activity_at, keepalive_until and details. There
are no pod fields: listing them would mean one Kubernetes call per server. Use
status() for the pod view of one server.
build_and_push_images(path, timeout, poll_interval) — image builds
Submit a YAML image definition for Kaniko build and wait for completion:
from pathlib import Path
summary = await client.build_and_push_images(
path=Path("image.yaml"),
timeout=600, # seconds; None = no timeout
poll_interval=10, # seconds between status polls
)
if summary.failed_jobs > 0:
raise RuntimeError(f"Build failed: {summary.jobs_results}")
image_tag = summary.jobs_results[0].tag
health_check()
response = await client.health_check()
print(response.status) # → "healthy"
Threads and event loops
IdeGYMClient is bound to the event loop that created it. It owns an httpx session and a
heartbeat task, so every call has to be awaited on that same loop. One asyncio program with one
loop needs to know nothing more than this.
It matters when a caller drives sandboxes from several loops — a synchronous facade with one loop per sandbox, for instance. Sharing one registration across them is not optional bookkeeping: deregistering a client terminates every server it owns, so two registrations in one process means either can tear down the other's sandboxes.
SharedIdeGYMClient owns a loop in a dedicated thread and marshals every call onto it, so any
thread — including one already running its own loop — can share the single registration:
from idegym.client import SharedIdeGYMClient
with SharedIdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="my-training-run",
namespace="idegym",
) as shared:
server = shared.run(lambda client: client.start_server(image_tag="registry.example.com/env:latest"))
result = shared.run(lambda _: server.execute_bash("echo hi"))
shared.run(lambda client: client.stop_server(server))
It takes the same arguments as IdeGYMClient and registers on entry, exactly as
async with IdeGYMClient(...) would.
| Method | Description |
|---|---|
run(call, timeout=None) | Run call(client) on the owned loop and return its result; on timeout the call is cancelled, then TimeoutError is raised |
submit(call) | Schedule call(client) and return a concurrent.futures.Future |
client | The underlying IdeGYMClient; only touch it from inside a call |
call takes the client and returns an awaitable, rather than being an awaitable itself, so the
awaitable is created on the owning loop — nothing ever binds to the caller's.
On exit, work still in flight gets a few seconds to finish, and is then cancelled — so a thread
still waiting on a submit future is released with CancelledError rather than left blocked.
Once exit has begun, new run and submit calls raise RuntimeError.
IdeGYMServer
Returned by client.with_server() or client.start_server(). Provides all operations on a running
server environment.
execute_bash(script, ...)
Run a bash script in the environment:
result = await server.execute_bash(
script="python -c 'print(1+1)'",
command_timeout=600.0, # seconds
graceful_termination_timeout=2.0, # seconds
request_timeout=None, # HTTP timeout (uses client default)
max_output_bytes=1048576, # retained bytes for each stream; None disables truncation
)
print(result.exit_code) # 0
print(result.stdout) # "2\n"
print(result.stderr) # ""
The default retains the beginning and end of stdout and stderr independently and inserts a
marker when bytes are omitted. Use max_output_bytes=None when a caller must parse complete,
trusted output. Output is returned verbatim within that window; pass strip_output=True if you
want surrounding whitespace trimmed.
cwd, env and user set per-command context, so you do not have to build cd, export and
runuser into the script yourself:
result = await server.execute_bash(
"python -m pytest -q",
cwd="tests", # relative paths resolve against the project directory
env={"PYTHONHASHSEED": "0"}, # merged over the server's cleaned environment
user="devuser", # needs a root server, or passwordless sudo as in the server image
)
Values passed through env never enter the command text, so they are not written to the
command log — prefer it to an export line for anything sensitive. See
Tools — Per-command context.
reset_project(...)
Reset the project to its original state (re-extracts the project archive):
result = await server.reset_project(
reset_timeout=600.0,
graceful_termination_timeout=2.0,
)
create_file(file_path, content, ...)
Create a new file at the given path:
result = await server.create_file(
file_path="/home/devuser/hello.py",
content='print("hello world")\n',
)
edit_file(file_path, start_line, end_line, new_content, ...)
Replace a range of lines in an existing file (1-indexed, inclusive):
result = await server.edit_file(
file_path="/home/devuser/hello.py",
start_line=1,
end_line=1,
new_content='print("goodbye world")\n',
)
patch_file(file_path, patch, ...)
Apply a unified diff patch to a file:
result = await server.patch_file(
file_path="/home/devuser/hello.py",
patch="--- a/hello.py\n+++ b/hello.py\n@@ -1 +1 @@\n-print...",
)
Binary file transfer
create_file and patch_file carry text. To move bytes — an archive, a wheel, a compiled
artifact, a database fixture — use the transfer methods, which chunk the content as base64 over
the ordinary forwarding path. That path stores and replays a request as JSON text, so base64 is
what makes the round trip lossless; raw bytes would not survive it.
# Upload a local file into the container, then read it back byte-for-byte.
size = await server.upload_file("./fixtures/repo.tar.gz", "/root/work/repo.tar.gz")
await server.download_file("/root/work/out/results.db", "./results.db")
# In-memory variants for smaller payloads.
await server.upload_bytes("/root/work/blob.bin", b"\x00\xff")
blob = await server.download_bytes("/root/work/blob.bin")
| Method | Returns | Description |
|---|---|---|
upload_file(local_path, file_path, chunk_bytes=..., ...) | int | Stream a local file into the container; returns the resulting size |
upload_bytes(file_path, data, chunk_bytes=..., ...) | int | Write bytes already in memory; returns the resulting size |
download_file(file_path, local_path, chunk_bytes=..., ...) | int | Stream a container file to disk; returns the byte count |
download_bytes(file_path, chunk_bytes=..., ...) | bytes | Read a container file into memory |
chunk_bytes is the number of raw bytes per request and defaults to 1 MiB; base64 inflates that
to roughly 1.4 MiB on the wire. The file-based variants read and write one chunk at a time, so a
transfer is bounded by disk rather than by client memory. Uploads are sequential and each chunk
truncates the file at its own end, so an interrupted upload leaves a short file rather than a
file with a stale tail. Downloading a path that does not exist fails with a 404.
Prefer these over base64 through the bash tool: they are not bounded by the size of a single shell script, and the payload never reaches the command log.
keepalive(minutes=15.0) — hold a server that is idle on purpose
The watcher reaps servers on time since the last completed request. That is only a proxy for
"somebody is using this", and a poor one: a sandbox is equally quiet while an agent thinks, a
build runs, or a human reads a stack trace. Call keepalive while you still hold the server.
hold = await server.keepalive(minutes=30)
print(hold.keepalive_until) # epoch milliseconds
Calling again extends the window and never shortens it, so two holders of the same server
cannot cut each other short — which also means keepalive(minutes=1) after
keepalive(minutes=60) leaves the longer hold in place, and the response reports the window
actually in effect rather than the one you asked for. The maximum window is 24 hours.
The hold belongs to the client that took it: when a finished server is handed to another client
for reuse, the hold is cleared, so the new owner starts without one.
A hold on a server that has already reached a terminal state is refused with 410 Gone, which
the client raises as IdeGYMNotFoundError: keepalive keeps a live server alive, it does not
revive a dead one. Catch it as "the sandbox is gone, start a new one". status() reports the
hold as keepalive_until.
status() — is this server usable?
status = await server.status()
if not status.usable:
print(status.availability, status.details) # e.g. "CRASHED", "OOMKilled"
| Field | Type | Description |
|---|---|---|
server_id | int | Numeric server ID |
server_name / generated_name | str | Logical name from the start request, and the Kubernetes resource name |
namespace | str | Namespace the server runs in |
availability | str | Orchestrator status: ALIVE, REUSED, FINISHED, STOPPED, CRASHED, … |
usable | bool | True when the server accepts requests (ALIVE or REUSED) |
image_tag | str | None | Image the server runs |
created_at / last_activity_at | int | Epoch milliseconds |
idle_seconds | float | Seconds since the last recorded activity |
keepalive_until | int | None | Epoch milliseconds until which an explicit keepalive holds the server |
pod_phase | str | None | Kubernetes phase, or None when no pod matches or the pod was not checked |
pod_ready | bool | None | True when the pod is Running with all containers ready; None when the pod was not checked |
details | str | None | Failure reason recorded on a terminal status |
Use this rather than an unrelated call such as list_capabilities as a liveness probe. It
answers for a finished, stopped or crashed server instead of raising, and reading it does not
count as activity — so a polling loop will not keep a server from being reaped.
The pod fields are best-effort: they are None for a server in a terminal state, which is not
looked up in Kubernetes, and when the Kubernetes lookup fails. The rest of the response still
comes from the orchestrator's record, so check usable and availability rather than
pod_ready to decide whether the server is gone.
restart_server(...)
Restart the server pod (preserves the same image and configuration):
response = await server.restart_server(
server_start_wait_timeout_in_seconds=300,
)
list_capabilities() — loaded plugin list
Return the list of server plugins running in the container:
result = await server.list_capabilities()
print(result.plugins) # → ["tools", "rewards"]
This calls GET /api/idegym-servers/{id}/capabilities on the orchestrator, which proxies to
GET /api/capabilities on the server. The response reflects the contents of
/etc/idegym/plugins.json written at image build time.
result: CapabilitiesResponse = await server.list_capabilities()
forward(method, path, body, ...) — generic plugin endpoint call
An escape hatch for calling plugin-provided endpoints that do not have a typed wrapper:
from idegym.api.tools.bash import BashCommandRequest
response = await server.forward(
method="POST",
path="tools/bash",
body=BashCommandRequest(command="echo hello", timeout=30.0),
)
# response is the parsed JSON dict
server.forward(
method: str,
path: str,
body: Optional[BaseModel] = None,
request_timeout: Optional[int] = None,
polling_config: Optional[PollingConfig] = None,
) -> dict[str, Any]
The path is relative to the server's API base (/api/). For typed access to plugin operations,
prefer the attribute-style API described below.
Plugin-Provided Operations
Installed plugins can extend IdeGYMServer with typed operation objects. Each plugin package
registers an operations class via the idegym.plugins.client entry point group. When
IdeGYMServer is constructed, it loads all installed client plugins and attaches each as an
attribute under the entry point name.
PyCharm operations (server.pycharm)
server.pycharm is attached automatically when idegym-plugins[pycharm] is installed
in the local Python environment and its idegym.plugins.client entry point loads successfully.
This is a purely local check — it is independent of whether the running server image was built with
the PyCharm plugin.
inspect(...)
Runs the JetBrains built-in inspect.sh script on the server and returns an InspectResponse:
result = await server.pycharm.inspect(
project_path="/root/work",
profile_path="/root/work/.idea/inspectionProfiles/Default.xml",
output_dir="/tmp/inspect-out",
timeout=300.0,
)
assert result.exit_code == 0
# Read result files from the container:
xml = await server.execute_bash("cat /tmp/inspect-out/*.xml")
| Parameter | Type | Default | Description |
|---|---|---|---|
project_path | str | — | Absolute path to the project inside the container |
profile_path | str | — | Absolute path to an inspection profile XML file |
output_dir | str | — | Directory where result files will be written |
changes_only | bool | False | Only inspect locally changed files (-changes) |
directory | Optional[str] | None | Limit scope to a subdirectory (-d) |
format | str | "xml" | Output format: "xml" or "json" |
verbosity | int | 0 | Verbosity level 0–2 |
timeout | float | 600.0 | Maximum seconds for inspect.sh to run |
request_timeout | Optional[int] | None | HTTP request timeout override (seconds) |
Note: inspect.sh runs in batch/headless mode — no Xvfb or display server is required for
inspection. Xvfb is only needed when PyCharm opens projects interactively (open_project=True).
IDEA operations (server.idea)
server.idea is attached automatically when idegym-plugins[idea] is installed in the
local Python environment. It provides the same inspect() interface as server.pycharm:
result = await server.idea.inspect(
project_path="/root/work",
profile_path="/root/work/.idea/inspectionProfiles/Default.xml",
output_dir="/tmp/inspect-out",
)
IntelliJ IDEA supports true headless mode (java.awt.headless=true) — no Xvfb is needed by
default. Pass Idea(headless=False) to run the IDE against a virtual display (Xvfb on :99),
the same way PyCharm runs, for workloads that need a real AWT toolkit.
Checking for a plugin at runtime
Use server.list_capabilities() to get the definitive list of plugins loaded in the running container,
then check membership before calling plugin-specific methods:
caps = await server.list_capabilities()
# → CapabilitiesResponse(plugins=["tools", "rewards", "pycharm"])
if "pycharm" in caps.plugins and hasattr(server, "pycharm"):
result = await server.pycharm.inspect(
project_path="/root/work",
profile_path="/root/work/.idea/inspectionProfiles/Default.xml",
output_dir="/tmp/inspect-out",
)
list_capabilities() calls GET /api/idegym-servers/{id}/capabilities on the orchestrator, which
proxies to GET /api/capabilities on the server container and returns the contents of
/etc/idegym/plugins.json — the file written at image build time that controls which plugins are
loaded at startup.
hasattr(server, "pycharm") still guards against the local package not being installed, but
caps.plugins is the authoritative runtime check for whether the server image supports a plugin.
See also: Plugin Architecture — full guide for writing plugins that extend the server and client with new endpoints and typed operations.
Reward Operations
IdeGYMServer exposes reward signals used for RL training evaluation:
compilation_reward(compilation_script, ...)
Run a compilation check and get a pass/fail reward:
result = await server.compilation_reward(
compilation_script="cd /home/devuser/project && python -m py_compile main.py",
compilation_timeout=600.0,
)
print(result.success) # True / False
setup_reward(setup_check_script, ...)
Run a setup verification script:
result = await server.setup_reward(
setup_check_script="cd /project && pip check",
setup_timeout=600.0,
)
print(result.success)
test_reward(test_script, ...)
Run a test suite and get a structured report:
result = await server.test_reward(
test_script="cd /project && python -m pytest --tb=short",
test_timeout=600.0,
)
print(result.passed) # number of passing tests
print(result.failed) # number of failing tests
print(result.output) # full test output
WebSocket / OpenEnv Access
For environments that implement the OpenEnv protocol, use server.openenv_url to get the
WebSocket base URL and connect with an OpenEnv client:
url = server.openenv_url
# → "https://idegym.yourdomain.com/api/ws-forward/<client_id>/<server_id>"
# Pass to an OpenEnv-compatible client:
env_client = MyOpenEnvClient(base_url=url)
Environment Variables
| Variable | Description | Default |
|---|---|---|
IDEGYM_AUTH_USERNAME | Orchestrator username | — |
IDEGYM_AUTH_PASSWORD | Orchestrator password | — |
IDEGYM_OTEL_SERVICE_NAME | OpenTelemetry service name for traces | auto-generated |
IDEGYM_OTEL_TRACING_ENDPOINT | OTLP trace export endpoint | — (tracing off) |
IDEGYM_OTEL_TRACING_TIMEOUT | Trace export timeout in seconds | 10 |
IDEGYM_OTEL_TRACING_AUTH_USERNAME | Trace export auth username | — |
IDEGYM_OTEL_TRACING_AUTH_PASSWORD | Trace export auth password | — |
Tracing
Tracing is off unless you give it an endpoint — there is no default collector, so a client constructed with no OpenTelemetry configuration exports nothing. Turn it on with the environment variable above, or with an explicit config:
from idegym.api.config import OTELConfig, TracingConfig
client = IdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="my-training-run",
namespace="idegym",
otel_config=OTELConfig(
service_name="my-training-run",
tracing=TracingConfig(endpoint="https://collector.internal/v1/traces"),
),
)
An explicit otel_config is used as given; the environment variables are only consulted when
none is passed.
Error Handling
An HTTP failure raises a subclass of IdeGYMHTTPError, chosen by status code. The exception
carries status_code, body, method and url as attributes, so a retry policy can branch on
the failure instead of parsing the message.
from idegym.client import IdeGYMBusyError, IdeGYMHTTPError, IdeGYMNotFoundError
try:
async with client.with_server(image_tag="registry.example.com/my-env:latest") as server:
...
except IdeGYMNotFoundError:
... # the sandbox is gone — start a new one
except IdeGYMBusyError as e:
... # the control plane is rate-limiting — back off and retry
except IdeGYMHTTPError as e:
print(f"Failed with {e.status_code}: {e.body}")
| Exception | Statuses | What it means for a retry |
|---|---|---|
IdeGYMBadRequestError | 400, 422, other 4xx | The request is wrong; retrying it unchanged will not help |
IdeGYMAuthError | 401, 403 | Credentials are missing, wrong, or insufficient |
IdeGYMNotFoundError | 404, 410 | The client, server, or operation is gone — including a pod the orchestrator can no longer reach |
IdeGYMTimeoutError | 408, 504, client-side timeout | Safe to retry if the operation is idempotent |
IdeGYMConnectionError | none — no response | The connection failed or broke off, e.g. while the orchestrator restarts; retry with backoff |
IdeGYMBusyError | 429, 503 | Rate-limited or out of capacity; retry with backoff |
IdeGYMCancelledError | 499 | Cancelled before finishing, usually by a disconnect |
IdeGYMServerError | 5xx | The orchestrator failed, or a sandbox call failed on its way through it |
IdeGYMSandboxError | any error the sandbox itself returned | The sandbox is alive and answered a forwarded call (a tool, reward or file request) with an error; read status_code and body |
All of them subclass IdeGYMHTTPError, which subclasses both IdeGYMException and
RuntimeError, and the message text is unchanged from before the typed exceptions existed — so
an existing except RuntimeError still catches everything it used to.
An IdeGYMTimeoutError with status_code is None is a client-side timeout: the request never
reached a status. That is the case to distinguish from a 504, where the orchestrator answered.
It covers every deadline the SDK enforces itself — a single request's request_timeout, the
wait while an async operation is polled (a long tools/bash call, say), and the overall
server-start wait. IdeGYMTimeoutError is also a builtin TimeoutError, which is what those
deadlines raised before, so an existing except TimeoutError still catches them. The underlying
httpx exception, when there is one, is chained as __cause__, so a ConnectTimeout can still be
told apart from a ReadTimeout or a PoolTimeout.
A call forwarded to the sandbox — execute_bash, a file transfer, forward() — can fail in two
places, and the type says which. When the orchestrator cannot reach the pod it reports 410, raised
as IdeGYMNotFoundError: the sandbox really is gone. When the sandbox answers with an error of its
own — 404 Path not found for a missing file, say — the status it sent is relayed as
IdeGYMSandboxError, whatever it is, so a missing file never reads as a missing sandbox. Before
IdeGYMSandboxError existed a relayed status went through the table above; code that caught, for
instance, IdeGYMServerError for a sandbox-side 500 should catch IdeGYMSandboxError too.
A transport failure that produced no response at all — a refused or reset connection, a broken
exchange — raises IdeGYMConnectionError, again with status_code is None and the httpx error
as __cause__. Code that catches httpx.TimeoutException or httpx.HTTPError around an SDK call
no longer sees these; catch IdeGYMTimeoutError, IdeGYMConnectionError or IdeGYMHTTPError
instead.
Complete Example with Rewards
import asyncio
from pathlib import Path
from idegym.client.client import IdeGYMClient
from idegym.image.builder import Image
from idegym.plugins.defaults.image import User, Project
async def train_step(client: IdeGYMClient, image_tag: str, patch: str) -> float:
"""Apply a patch and return a test-pass reward."""
async with client.with_server(
image_tag=image_tag,
server_name="train-server",
reuse_strategy="RESET", # reset project on each episode
server_start_wait_timeout_in_seconds=300,
) as server:
# Apply the model's proposed change
await server.patch_file(
file_path="/home/devuser/project/src/main.py",
patch=patch,
)
# Evaluate
result = await server.test_reward(
test_script="cd /home/devuser/project && python -m pytest -q",
test_timeout=120.0,
)
total = result.passed + result.failed
return result.passed / total if total > 0 else 0.0
async def main():
async with IdeGYMClient(
orchestrator_url="https://idegym.yourdomain.com",
name="rl-training",
namespace="idegym",
nodes_count=4, # reserve 4 nodes for parallel episodes
) as client:
image_tag = "registry.example.com/my-env:latest"
reward = await train_step(client, image_tag, patch="--- ...")
print(f"Reward: {reward:.2f}")
asyncio.run(main())