diff --git a/.github/workflows/run-examples.yml b/.github/workflows/run-examples.yml index 74a1fa917c..485b59cbd1 100644 --- a/.github/workflows/run-examples.yml +++ b/.github/workflows/run-examples.yml @@ -24,8 +24,19 @@ jobs: runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 60 steps: + - name: Wait for agent server to finish build + uses: lewagon/wait-on-check-action@v1.4.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + check-name: Build & Push (python-amd64) + repo-token: ${{ secrets.GITHUB_TOKEN }} + wait-interval: 10 + - name: Checkout uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Install uv uses: astral-sh/setup-uv@v7 @@ -45,10 +56,12 @@ jobs: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} LLM_MODEL: openhands/claude-haiku-4-5-20251001 LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} + RUNTIME_API_KEY: ${{ secrets.RUNTIME_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} REPO_OWNER: ${{ github.repository_owner }} REPO_NAME: ${{ github.event.repository.name }} + GITHUB_SHA: ${{ github.event.pull_request.head.sha }} run: | # List of examples to test # Excluded examples: @@ -85,6 +98,7 @@ jobs: "examples/02_remote_agent_server/01_convo_with_local_agent_server.py" "examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py" "examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py" + "examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py" ) # GitHub API setup (only for PR events) diff --git a/examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py b/examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py index 100d414ff0..c870797510 100644 --- a/examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py +++ b/examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py @@ -14,6 +14,7 @@ import os import time +import requests from pydantic import SecretStr from openhands.sdk import ( @@ -44,10 +45,43 @@ logger.error("RUNTIME_API_KEY required") exit(1) + +def get_latest_commit_sha( + repo: str = "OpenHands/software-agent-sdk", branch: str = "main" +) -> str: + """ + Return the full SHA of the latest commit on `branch` for the given GitHub repo. + Respects an optional GITHUB_TOKEN to avoid rate limits. + """ + url = f"https://api.github.com/repos/{repo}/commits/{branch}" + headers = {} + token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + + resp = requests.get(url, headers=headers, timeout=20) + if resp.status_code != 200: + raise RuntimeError(f"GitHub API error {resp.status_code}: {resp.text}") + data = resp.json() + sha = data.get("sha") + if not sha: + raise RuntimeError("Could not find commit SHA in GitHub response") + logger.info(f"Latest commit on {repo} branch={branch} is {sha}") + return sha + + +# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency +# Otherwise, get the latest commit SHA from main branch (images are built on main) +server_image_sha = os.getenv("GITHUB_SHA") or get_latest_commit_sha( + "OpenHands/software-agent-sdk", "main" +) +server_image = f"ghcr.io/openhands/agent-server:{server_image_sha[:7]}-python-amd64" +logger.info(f"Using server image: {server_image}") + with APIRemoteWorkspace( runtime_api_url=os.getenv("RUNTIME_API_URL", "https://runtime.eval.all-hands.dev"), runtime_api_key=runtime_api_key, - server_image="ghcr.io/openhands/agent-server:main-python", + server_image=server_image, ) as workspace: agent = get_default_agent(llm=llm, cli_mode=True) received_events: list = [] @@ -78,5 +112,7 @@ def event_callback(event) -> None: conversation.send_message("Great! Now delete that file.") conversation.run() + cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost + print(f"EXAMPLE_COST: {cost}") finally: conversation.close() diff --git a/openhands-sdk/openhands/sdk/workspace/remote/base.py b/openhands-sdk/openhands/sdk/workspace/remote/base.py index 8ab080e500..0b9f340f4d 100644 --- a/openhands-sdk/openhands/sdk/workspace/remote/base.py +++ b/openhands-sdk/openhands/sdk/workspace/remote/base.py @@ -40,7 +40,9 @@ def client(self) -> httpx.Client: # - write: 10 seconds to send request # - pool: 10 seconds to get connection from pool timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0) - client = httpx.Client(base_url=self.host, timeout=timeout) + client = httpx.Client( + base_url=self.host, timeout=timeout, headers=self._headers + ) self._client = client return client diff --git a/openhands-workspace/openhands/workspace/remote_api/workspace.py b/openhands-workspace/openhands/workspace/remote_api/workspace.py index 1a0ed5ca78..013cca9804 100644 --- a/openhands-workspace/openhands/workspace/remote_api/workspace.py +++ b/openhands-workspace/openhands/workspace/remote_api/workspace.py @@ -54,7 +54,7 @@ class APIRemoteWorkspace(RemoteWorkspace): default=1, description="Resource scaling (1, 2, 4, or 8)" ) runtime_class: str | None = Field( - default="sysbox", description="Runtime class (e.g., 'sysbox')" + default="sysbox-runc", description="Runtime class (e.g., 'sysbox')" ) init_timeout: float = Field( default=300.0, description="Runtime init timeout (seconds)" @@ -71,6 +71,21 @@ class APIRemoteWorkspace(RemoteWorkspace): _runtime_url: str | None = PrivateAttr(default=None) _session_api_key: str | None = PrivateAttr(default=None) + @property + def _api_headers(self): + """Headers for runtime API requests." + + This is used to manage new container runtimes via Runtime API. + + For actual interaction with the remote agent server, the + `client` property is used, which includes the session API key + defined by ._headers property. + """ + headers = {} + if self.runtime_api_key: + headers["X-API-Key"] = self.runtime_api_key + return headers + def model_post_init(self, context: Any) -> None: """Set up the remote runtime and initialize the workspace.""" if self.resource_factor not in [1, 2, 4, 8]: @@ -97,12 +112,18 @@ def _start_or_attach_to_runtime(self) -> None: logger.info(f"Runtime ready at {self._runtime_url}") self.host = self._runtime_url.rstrip("/") self.api_key = self._session_api_key + self._client = None # Reset HTTP client with new host and API key + _ = self.client # Initialize client by accessing the property + assert self.client is not None + assert self.client.base_url == self.host def _check_existing_runtime(self) -> bool: """Check if there's an existing runtime for this session.""" try: resp = self._send_api_request( - "GET", f"{self.runtime_api_url}/sessions/{self.session_id}" + "GET", + f"{self.runtime_api_url}/sessions/{self.session_id}", + headers=self._api_headers, ) data = resp.json() status = data.get("status") @@ -149,6 +170,7 @@ def _start_runtime(self) -> None: f"{self.runtime_api_url}/start", json=payload, timeout=self.init_timeout, + headers=self._api_headers, ) self._parse_runtime_response(resp) logger.info(f"Runtime {self._runtime_id} at {self._runtime_url}") @@ -160,6 +182,7 @@ def _resume_runtime(self) -> None: f"{self.runtime_api_url}/resume", json={"runtime_id": self._runtime_id}, timeout=self.init_timeout, + headers=self._api_headers, ) self._parse_runtime_response(resp) @@ -183,7 +206,9 @@ def _wait_until_runtime_alive(self) -> None: logger.info("Waiting for runtime to become alive...") resp = self._send_api_request( - "GET", f"{self.runtime_api_url}/sessions/{self.session_id}" + "GET", + f"{self.runtime_api_url}/sessions/{self.session_id}", + headers=self._api_headers, ) data = resp.json() pod_status = data.get("pod_status", "").lower() @@ -244,12 +269,15 @@ def _wait_until_runtime_alive(self) -> None: def _send_api_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: """Send an API request with error handling.""" logger.debug(f"Sending {method} request to {url}") - logger.debug(f"Client headers: {self._headers}") + logger.debug(f"Request kwargs: {kwargs.keys()}") + response = self.client.request(method, url, **kwargs) try: response.raise_for_status() except httpx.HTTPStatusError: - logger.debug(f"Request headers: {response.request.headers}") + # Log only header keys, not values (to avoid exposing API keys) + header_keys = list(response.request.headers.keys()) + logger.debug(f"Request header keys: {header_keys}") try: error_detail = response.json() logger.info(f"API request failed: {error_detail}") @@ -274,9 +302,10 @@ def cleanup(self) -> None: f"{self.runtime_api_url}/{action}", json={"runtime_id": self._runtime_id}, timeout=30.0, + headers=self._api_headers, ) except Exception as e: - logger.error(f"Cleanup error: {e}") + logger.warning(f"Cleanup error: {e}") finally: self._runtime_id = None self._runtime_url = None