Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Run a conversation whose agent server lives in a kubernetes-sigs/agent-sandbox pod.

Prerequisites (see agent_sandbox_deploy/README.md for a full kind walkthrough):
1. A Kubernetes cluster (kind / minikube / GKE) with the agent-sandbox controller
and extensions installed, reachable via your kubeconfig.
2. The agent-server SandboxTemplate + SandboxWarmPool applied:
kubectl apply -f agent_sandbox_deploy/sandboxtemplate.yaml
kubectl apply -f agent_sandbox_deploy/sandboxwarmpool.yaml
3. pip install openhands-workspace[agent-sandbox]
4. export LLM_API_KEY=... (and optionally LLM_MODEL, LLM_BASE_URL)

The LLM is called from inside the pod (the agent runs on the agent server), so the
cluster needs egress to your LLM endpoint.
"""

import os
import time

from pydantic import SecretStr

from openhands.sdk import (
LLM,
Conversation,
RemoteConversation,
get_logger,
)
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import AgentSandboxWorkspace


logger = get_logger(__name__)

# 1) LLM configuration
api_key = os.getenv("LLM_API_KEY")
assert api_key is not None, "LLM_API_KEY environment variable is not set."

llm = LLM(
usage_id="agent",
model=os.getenv("LLM_MODEL", "gpt-5.5"),
base_url=os.getenv("LLM_BASE_URL"),
api_key=SecretStr(api_key),
)

# 2) Claim a pod from the warm pool. With a pre-warmed pool this returns in well
# under a second; the workspace connects to the pod's agent server via
# kubectl port-forward (the default 'port_forward' connection mode).
warmpool = os.getenv("AGENT_SANDBOX_WARMPOOL", "openhands-pool")
namespace = os.getenv("AGENT_SANDBOX_NAMESPACE", "default")
logger.info(f"Claiming a sandbox from warm pool {warmpool!r} in {namespace!r}...")
with AgentSandboxWorkspace(
warmpool=warmpool,
namespace=namespace,
# Safety net: auto-delete the claim after 30 minutes if something leaks it.
shutdown_after_seconds=30 * 60,
) as workspace:
# 3) Create the agent
agent = get_default_agent(llm=llm, cli_mode=True)

# 4) Sanity-check the workspace with a direct command
result = workspace.execute_command("echo 'Hello from agent-sandbox!' && pwd")
logger.info(f"Command exit code: {result.exit_code}")
logger.info(f"Output: {result.stdout}")

# 5) Run a conversation
conversation = Conversation(agent=agent, workspace=workspace)
assert isinstance(conversation, RemoteConversation)
try:
logger.info(f"Conversation ID: {conversation.state.id}")
conversation.send_message(
"Read the current repo and write 3 facts about the project into FACTS.txt."
)
conversation.run()
logger.info(f"Agent status: {conversation.state.execution_status}")

# 6) Demonstrate native pause/resume. The pod is suspended (operatingMode
# -> Suspended) and its PVC is retained, then resumed for a follow-up.
logger.info("Pausing the sandbox (operatingMode -> Suspended)...")
workspace.pause()
time.sleep(3)
logger.info("Resuming the sandbox (operatingMode -> Running)...")
workspace.resume()

conversation.send_message("Great! Now append a 4th fact to FACTS.txt.")
conversation.run()
logger.info("Second task completed after resume.")

cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
finally:
print("\nCleaning up conversation...")
conversation.close()
71 changes: 71 additions & 0 deletions examples/02_remote_agent_server/agent_sandbox_deploy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Running the agent server in agent-sandbox on a local kind cluster

End-to-end walkthrough for
[`17_convo_with_agent_sandbox_server.py`](../17_convo_with_agent_sandbox_server.py)
using [kind](https://kind.sigs.k8s.io/). The same steps work on minikube or a cloud
cluster such as GKE. Only the cluster-creation step differs.

## 1. Create a cluster

```bash
kind create cluster --name openhands
```

## 2. Install the agent-sandbox controller + extensions

Pick a release from
<https://github.com/kubernetes-sigs/agent-sandbox/releases> and apply the core +
extensions manifests:

```bash
export VERSION="vX.Y.Z"
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${VERSION}/manifest.yaml
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${VERSION}/extensions.yaml
```

Wait for the controller to be ready:

```bash
kubectl -n agent-sandbox-system rollout status deploy --timeout=120s
```

## 3. Apply the agent-server template + warm pool

```bash
kubectl apply -f sandboxtemplate.yaml
kubectl apply -f sandboxwarmpool.yaml
```

The first pull of `ghcr.io/openhands/agent-server` can take a minute. Watch the pool
fill up (pods become `Ready` once the agent server passes its `/health` probe):

```bash
kubectl get pods -w
```

## 4. Install the client and run the example

```bash
pip install openhands-workspace[agent-sandbox]

export LLM_API_KEY=... # your LLM key (called from inside the pod)
export LLM_MODEL=... # optional, e.g. a hosted model id
python ../17_convo_with_agent_sandbox_server.py
```

You should see: a sub-second claim from the warm pool, a command run in the pod, the
agent editing files, then a **pause** (pod suspended, PVC retained) and **resume**
(same conversation continues).

## Notes

- **LLM egress.** The agent runs on the agent server *inside* the pod, so the LLM API
is called from the pod. kind allows outbound internet by default. To use a local
Ollama, point `LLM_BASE_URL` at an address reachable from the pod and (on kind)
ensure the container network can reach your host.
- **Cold start vs warm pool.** With `replicas: 0` the pool becomes on-demand and each
claim creates a fresh pod (full cold start). Increase `replicas` to keep ready pods.
- **Strong isolation.** Uncomment `runtimeClassName: gvisor` in `sandboxtemplate.yaml`
(requires a gVisor-enabled node pool) to sandbox untrusted agent code at the kernel
boundary.
- **Cleanup.** `kind delete cluster --name openhands`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
# SandboxTemplate that runs the OpenHands agent server. See the canonical copy and
# docs at openhands-workspace/openhands/workspace/agent_sandbox/.
apiVersion: extensions.agents.x-k8s.io/v1beta1
kind: SandboxTemplate
metadata:
name: openhands-agent-server
namespace: default
spec:
volumeClaimTemplates:
- metadata:
name: workspace-volume
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 5Gi
podTemplate:
spec:
# runtimeClassName: gvisor # uncomment for strong isolation of untrusted code
securityContext:
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
containers:
- name: agent-server
image: ghcr.io/openhands/agent-server:1.42.1-python
args: [--host, 0.0.0.0, --port, '8000']
workingDir: /workspace
ports:
- containerPort: 8000
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 3
periodSeconds: 3
failureThreshold: 40
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: '2'
memory: 2Gi
volumeMounts:
- name: workspace-volume
mountPath: /workspace
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
# Pre-warms agent-server pods so AgentSandboxWorkspace claims are near-instant.
apiVersion: extensions.agents.x-k8s.io/v1beta1
kind: SandboxWarmPool
metadata:
name: openhands-pool
namespace: default
spec:
replicas: 2
sandboxTemplateRef:
name: openhands-agent-server
2 changes: 2 additions & 0 deletions openhands-workspace/openhands/workspace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from openhands.sdk.workspace import PlatformType, TargetType

from .agent_sandbox import AgentSandboxWorkspace
from .apptainer import ApptainerWorkspace
from .cloud import (
CloneResult,
Expand All @@ -21,6 +22,7 @@

__all__ = [
"APIRemoteWorkspace",
"AgentSandboxWorkspace",
"ApptainerWorkspace",
"CloneResult",
"DockerDevWorkspace",
Expand Down
86 changes: 86 additions & 0 deletions openhands-workspace/openhands/workspace/agent_sandbox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# AgentSandboxWorkspace

Run the OpenHands agent server inside a Kubernetes pod managed by
[`kubernetes-sigs/agent-sandbox`](https://github.com/kubernetes-sigs/agent-sandbox).

The pod is claimed from a `SandboxWarmPool`, so a pre-warmed pool gives sub-second
starts instead of paying a container cold-start per conversation. It adds native
pause/resume (via the Sandbox `operatingMode`) and, with a persistent volume,
workspace state that survives a suspend. gVisor / Kata isolation is available by
setting a `runtimeClass` on the `SandboxTemplate`.

It is a drop-in `RemoteWorkspace`, so it works anywhere a `DockerWorkspace` does,
from a laptop kind or minikube cluster to a cloud cluster such as GKE.

## Install

```bash
pip install openhands-workspace[agent-sandbox]
```

This pulls in the [`k8s-agent-sandbox`](https://pypi.org/project/k8s-agent-sandbox/)
client used to create and manage the sandbox.

## Prerequisites

1. A Kubernetes cluster reachable via your kubeconfig, with the agent-sandbox
controller + extensions installed
([install guide](https://github.com/kubernetes-sigs/agent-sandbox#installation)).
2. A `SandboxTemplate` running the agent server, and a `SandboxWarmPool` that
references it. Ready-to-apply manifests live in [`deploy/`](deploy/):

```bash
kubectl apply -f deploy/sandboxtemplate.yaml
kubectl apply -f deploy/sandboxwarmpool.yaml
```

## Usage

```python
from openhands.sdk import Conversation
from openhands.tools.preset.default import get_default_agent
from openhands.workspace import AgentSandboxWorkspace

with AgentSandboxWorkspace(warmpool="openhands-pool", namespace="default") as workspace:
result = workspace.execute_command("echo hello && pwd")
print(result.stdout)

conversation = Conversation(agent=get_default_agent(llm=llm), workspace=workspace)
conversation.send_message("Write 3 facts about this repo into FACTS.txt.")
conversation.run()
```

### Connection modes

* `connection="port_forward"` (default) spawns `kubectl port-forward` to the pod.
This works from a laptop against local kind or minikube, and against any cluster
your kubeconfig can reach. `host_port` picks a free local port for you.
* `connection="direct"` means you supply `host` yourself: a `sandbox-router` or
Gateway URL, or the in-cluster DNS name when OpenHands runs in the cluster. No
port-forward is started.

### Testing

See [`TESTING.md`](TESTING.md) for an end-to-end walkthrough: unit tests, a keyless
workspace smoke test, and a full agent run against a local Ollama model (no API key).

### Pause / resume

```python
workspace.pause() # operatingMode -> Suspended; pod terminated, PVC retained
workspace.resume() # operatingMode -> Running; reconnects to the agent server
```

### Where configuration lives

Most knobs belong in Kubernetes, not on the Python constructor:

- CPU, memory, image, `runtimeClass` and the security context belong in
`SandboxTemplate.spec.podTemplate`.
- Pre-warming and pool size belong in `SandboxWarmPool.spec.replicas`.
- Network egress allow-listing belongs in the template's `NetworkPolicy`.
- Persistent volumes belong in the template's `volumeClaimTemplates`.

Python-side knobs: `warmpool` (required), `namespace`, `server_port`, `connection`,
`host_port`, `kube_context`, `sandbox_ready_timeout`, `health_check_timeout`,
`shutdown_after_seconds`, `labels`, `pod_labels`, `pod_annotations`.
Loading