diff --git a/.github/actions/pr-review/action.yml b/.github/actions/pr-review/action.yml index 81f83bb58c..4334b18020 100644 --- a/.github/actions/pr-review/action.yml +++ b/.github/actions/pr-review/action.yml @@ -62,7 +62,7 @@ runs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.13' - name: Install uv uses: astral-sh/setup-uv@v6 diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 3a2251bfab..e27a6e68a6 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -35,7 +35,7 @@ jobs: uses: astral-sh/setup-uv@v7 with: version: latest - python-version: '3.12' + python-version: '3.13' - name: Build and publish all packages env: diff --git a/.github/workflows/run-examples.yml b/.github/workflows/run-examples.yml index a280b819e7..5ebfed0401 100644 --- a/.github/workflows/run-examples.yml +++ b/.github/workflows/run-examples.yml @@ -70,7 +70,9 @@ jobs: 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 }} + # Use custom var name to avoid conflict with GitHub's default GITHUB_SHA + # which points to the merge commit, not the PR head commit + AGENT_SERVER_SHA: ${{ github.event.pull_request.head.sha }} OPENHANDS_CLOUD_API_KEY: ${{ secrets.ALLHANDS_BOT_OPENHANDS_SAAS_API_KEY }} run: | RESULTS_DIR=".example-test-results" diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index da4255bebf..223baf95d7 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -162,17 +162,15 @@ jobs: # Each job specifies exactly what it builds and where it runs include: # Python variant - # NOTE: Using Python 3.12 due to PyInstaller+libtmux compatibility - # issue with Python 3.13. See issue #1886 for details. - variant: python arch: amd64 - base_image: nikolaik/python-nodejs:python3.12-nodejs22 + base_image: nikolaik/python-nodejs:python3.13-nodejs22 runner: ubuntu-24.04 platform: linux/amd64 - variant: python arch: arm64 - base_image: nikolaik/python-nodejs:python3.12-nodejs22 + base_image: nikolaik/python-nodejs:python3.13-nodejs22 runner: ubuntu-24.04-arm platform: linux/arm64 @@ -212,9 +210,10 @@ jobs: ARCH: ${{ matrix.arch }} TARGET: binary PLATFORM: ${{ matrix.platform }} - # Use PR head SHA for pull requests to match the image tag expected by run-examples.yml - GITHUB_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - GITHUB_REF: ${{ github.ref }} + # Use SDK_SHA to pass PR head SHA since GITHUB_SHA is a reserved GitHub variable + # that cannot be overridden at job level + SDK_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + SDK_REF: ${{ github.ref }} CI: 'true' steps: @@ -240,10 +239,15 @@ jobs: - name: Prepare build context and metadata id: prep run: | + # Debug: verify SDK_SHA is set correctly + echo "DEBUG: SDK_SHA env var = $SDK_SHA" + echo "DEBUG: Expected PR head SHA for pull requests" + uv sync --frozen # Generate build context and tags with arch suffix # build.py now handles architecture tagging internally via --arch flag + # build.py reads SDK_SHA from environment to get the correct SHA # Add --versioned-tag when triggered by a git tag (e.g., v1.0.0) BUILD_CMD="uv run ./openhands-agent-server/openhands/agent_server/docker/build.py --build-ctx-only --arch ${{ matrix.arch }}" if [[ "${{ github.ref }}" == refs/tags/* ]]; then @@ -255,8 +259,9 @@ jobs: TAGS=$(grep '^tags_csv=' $GITHUB_OUTPUT | cut -d= -f2-) echo "tags=$TAGS" >> $GITHUB_OUTPUT - # Extract short SHA for consolidation - SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) + # Extract short SHA for consolidation (use SDK_SHA env var) + # SDK_SHA is set to PR head SHA for pull_request events + SHORT_SHA=$(echo $SDK_SHA | cut -c1-7) echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT # Extract versioned tags CSV for consolidation diff --git a/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py b/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py index 9bda66c987..3796ae0dbe 100644 --- a/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py +++ b/examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py @@ -40,11 +40,11 @@ def get_server_image(): """Get the server image tag, using PR-specific image in CI.""" platform_str = detect_platform() arch = "arm64" if "arm64" in platform_str else "amd64" - # If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency - # Otherwise, use the latest image from main - github_sha = os.getenv("GITHUB_SHA") - if github_sha: - return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}" + # AGENT_SERVER_SHA is set by CI to the PR head commit SHA + # This avoids conflict with GitHub's default GITHUB_SHA (merge commit) + server_sha = os.getenv("AGENT_SERVER_SHA") + if server_sha: + return f"ghcr.io/openhands/agent-server:{server_sha[:7]}-python-{arch}" return "ghcr.io/openhands/agent-server:latest-python" diff --git a/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py b/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py index 2d572cb152..6702d23388 100644 --- a/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py +++ b/examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py @@ -35,11 +35,11 @@ def get_server_image(): """Get the server image tag, using PR-specific image in CI.""" platform_str = detect_platform() arch = "arm64" if "arm64" in platform_str else "amd64" - # If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency - # Otherwise, use the latest image from main - github_sha = os.getenv("GITHUB_SHA") - if github_sha: - return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}" + # AGENT_SERVER_SHA is set by CI to the PR head commit SHA + # This avoids conflict with GitHub's default GITHUB_SHA (merge commit) + server_sha = os.getenv("AGENT_SERVER_SHA") + if server_sha: + return f"ghcr.io/openhands/agent-server:{server_sha[:7]}-python-{arch}" return "ghcr.io/openhands/agent-server:latest-python" 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 9e78f7171c..6f03234ee6 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 @@ -45,9 +45,9 @@ exit(1) -# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency -# Otherwise, use the latest image from main -server_image_sha = os.getenv("GITHUB_SHA") or "main" +# AGENT_SERVER_SHA is set by CI to the PR head commit SHA +# This avoids conflict with GitHub's default GITHUB_SHA (merge commit) +server_image_sha = os.getenv("AGENT_SERVER_SHA") or "main" server_image = f"ghcr.io/openhands/agent-server:{server_image_sha[:7]}-python-amd64" logger.info(f"Using server image: {server_image}") diff --git a/examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py b/examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py index ab9a0d59a6..aa6057d359 100644 --- a/examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py +++ b/examples/02_remote_agent_server/05_vscode_with_docker_sandboxed_server.py @@ -37,11 +37,11 @@ def get_server_image(): """Get the server image tag, using PR-specific image in CI.""" platform_str = detect_platform() arch = "arm64" if "arm64" in platform_str else "amd64" - # If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency - # Otherwise, use the latest image from main - github_sha = os.getenv("GITHUB_SHA") - if github_sha: - return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}" + # AGENT_SERVER_SHA is set by CI to the PR head commit SHA + # This avoids conflict with GitHub's default GITHUB_SHA (merge commit) + server_sha = os.getenv("AGENT_SERVER_SHA") + if server_sha: + return f"ghcr.io/openhands/agent-server:{server_sha[:7]}-python-{arch}" return "ghcr.io/openhands/agent-server:latest-python" diff --git a/examples/02_remote_agent_server/08_convo_with_apptainer_sandboxed_server.py b/examples/02_remote_agent_server/08_convo_with_apptainer_sandboxed_server.py index 713d9199d9..69dce2ef8f 100644 --- a/examples/02_remote_agent_server/08_convo_with_apptainer_sandboxed_server.py +++ b/examples/02_remote_agent_server/08_convo_with_apptainer_sandboxed_server.py @@ -40,11 +40,11 @@ def get_server_image(): """Get the server image tag, using PR-specific image in CI.""" platform_str = detect_platform() arch = "arm64" if "arm64" in platform_str else "amd64" - # If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency - # Otherwise, use the latest image from main - github_sha = os.getenv("GITHUB_SHA") - if github_sha: - return f"ghcr.io/openhands/agent-server:{github_sha[:7]}-python-{arch}" + # AGENT_SERVER_SHA is set by CI to the PR head commit SHA + # This avoids conflict with GitHub's default GITHUB_SHA (merge commit) + server_sha = os.getenv("AGENT_SERVER_SHA") + if server_sha: + return f"ghcr.io/openhands/agent-server:{server_sha[:7]}-python-{arch}" return "ghcr.io/openhands/agent-server:latest-python" diff --git a/openhands-agent-server/openhands/agent_server/docker/Dockerfile b/openhands-agent-server/openhands/agent_server/docker/Dockerfile index 7684064ff2..361e11b727 100644 --- a/openhands-agent-server/openhands/agent_server/docker/Dockerfile +++ b/openhands-agent-server/openhands/agent_server/docker/Dockerfile @@ -1,8 +1,8 @@ # syntax=docker/dockerfile:1.7 -# NOTE: Using Python 3.12 due to PyInstaller+libtmux compatibility issue -# with Python 3.13. See issue #1886 for details. -ARG BASE_IMAGE=nikolaik/python-nodejs:python3.12-nodejs22 +# NOTE: Python 3.13 is used with libtmux pinned to neubig's branch that fixes +# the race condition in new_session(). See libtmux#624 and libtmux#625. +ARG BASE_IMAGE=nikolaik/python-nodejs:python3.13-nodejs22 ARG USERNAME=openhands ARG UID=10001 ARG GID=10001 @@ -12,7 +12,7 @@ ARG PORT=8000 # Builder (source mode) # We copy source + build a venv here for local dev and debugging. #################################################################################### -FROM python:3.12-bullseye AS builder +FROM python:3.13-bookworm AS builder ARG USERNAME UID GID ENV UV_PROJECT_ENVIRONMENT=/agent-server/.venv ENV UV_PYTHON_INSTALL_DIR=/agent-server/uv-managed-python @@ -30,7 +30,7 @@ COPY --chown=${USERNAME}:${USERNAME} openhands-tools ./openhands-tools COPY --chown=${USERNAME}:${USERNAME} openhands-workspace ./openhands-workspace COPY --chown=${USERNAME}:${USERNAME} openhands-agent-server ./openhands-agent-server RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \ - uv python install 3.12 && uv venv --python 3.12 .venv && uv sync --frozen --no-editable --managed-python + uv python install 3.13 && uv venv --python 3.13 .venv && uv sync --frozen --no-editable --managed-python #################################################################################### # Binary Builder (binary mode) diff --git a/openhands-sdk/openhands/sdk/agent/base.py b/openhands-sdk/openhands/sdk/agent/base.py index 1709003f62..a27a21361d 100644 --- a/openhands-sdk/openhands/sdk/agent/base.py +++ b/openhands-sdk/openhands/sdk/agent/base.py @@ -486,7 +486,7 @@ def model_dump_succint(self, **kwargs): dumped["tools"] = list(dumped["tools"].keys()) return dumped - def get_all_llms(self) -> Generator[LLM, None, None]: + def get_all_llms(self) -> Generator[LLM]: """Recursively yield unique *base-class* LLM objects reachable from `self`. - Returns actual object references (not copies). diff --git a/openhands-sdk/openhands/sdk/utils/paging.py b/openhands-sdk/openhands/sdk/utils/paging.py index bc196e8f69..40bd68c13f 100644 --- a/openhands-sdk/openhands/sdk/utils/paging.py +++ b/openhands-sdk/openhands/sdk/utils/paging.py @@ -20,7 +20,7 @@ async def page_iterator[T]( search_func: Callable[..., Awaitable[PageProtocol[T]]], *args: Any, **kwargs: Any, -) -> AsyncGenerator[T, None]: +) -> AsyncGenerator[T]: """ Iterate over items from paginated search results. diff --git a/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py b/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py index d41b8ee9db..4aec5bae53 100644 --- a/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py +++ b/openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py @@ -1,5 +1,6 @@ """Tmux-based terminal backend implementation.""" +import subprocess import time import uuid @@ -15,6 +16,46 @@ logger = get_logger(__name__) +def _get_session_id_directly(session_name: str) -> str | None: + """Get session_id directly from tmux using a simple format string. + + This is a fallback for when libtmux's complex format parsing fails. + The issue is that libtmux's new_session() uses a format string with 125+ + fields, and if tmux doesn't output all fields correctly, the session_id + (at index 92) may be missing from the parsed output. + + This function uses a simple tmux command to get just the session_id. + """ + try: + result = subprocess.run( + [ + "tmux", + "list-sessions", + "-F", + "#{session_id}:#{session_name}", + ], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + logger.debug(f"tmux list-sessions failed: {result.stderr}") + return None + + for line in result.stdout.strip().split("\n"): + if not line: + continue + parts = line.split(":", 1) + if len(parts) == 2 and parts[1] == session_name: + session_id = parts[0] + if session_id.startswith("$"): + return session_id + return None + except Exception as e: + logger.debug(f"Failed to get session_id directly: {e}") + return None + + class TmuxTerminal(TerminalInterface): """Tmux-based terminal backend. @@ -52,13 +93,74 @@ def initialize(self) -> None: logger.debug(f"Initializing tmux terminal with command: {window_command}") session_name = f"openhands-{self.username}-{uuid.uuid4()}" - self.session = self.server.new_session( - session_name=session_name, - start_directory=self.work_dir, - kill_session=True, - x=1000, - y=1000, - ) + + # Workaround for libtmux race condition (https://github.com/tmux-python/libtmux/issues/624): + # The fix in neubig's PR #625 tries to parse 125+ fields from tmux output, + # but tmux may not output all fields, causing session_id to be missing. + # We create the session and retry getting session data if session_id is None. + max_retries = 5 + last_error = None + + for attempt in range(max_retries): + try: + self.session = self.server.new_session( + session_name=session_name, + start_directory=self.work_dir, + kill_session=True, + x=1000, + y=1000, + ) + + if self.session.session_id is not None: + break + + # session_id is None - try multiple fallback approaches + # Add a small delay to let tmux register the session + time.sleep(0.1 * (attempt + 1)) + + # Fallback 1: Try to get session_id directly via simple tmux command + # This bypasses libtmux's complex format parsing + direct_session_id = _get_session_id_directly(session_name) + if direct_session_id: + self.session.session_id = direct_session_id + logger.debug( + f"Session ID resolved via direct tmux command after " + f"{attempt + 1} attempts: {direct_session_id}" + ) + break + + # Fallback 2: Try to get it from server.sessions (uses same parsing) + sessions = self.server.sessions.filter(session_name=session_name) + if sessions and sessions[0].session_id is not None: + self.session = sessions[0] + logger.debug( + f"Session ID resolved via list-sessions after " + f"{attempt + 1} attempts" + ) + break + + except Exception as e: + last_error = e + # If session was created but we couldn't get session_id, + # try to kill it before retrying + try: + for s in self.server.sessions.filter(session_name=session_name): + s.kill() + except Exception: + pass + time.sleep(0.2 * (attempt + 1)) + continue + + if self.session.session_id is None: + error_msg = ( + f"Failed to get session_id for session '{session_name}' " + f"after {max_retries} attempts. " + ) + if last_error: + error_msg += f"Last error: {last_error}. " + error_msg += "See https://github.com/tmux-python/libtmux/issues/624" + raise RuntimeError(error_msg) + for k, v in env.items(): self.session.set_environment(k, v) diff --git a/openhands-tools/pyproject.toml b/openhands-tools/pyproject.toml index 6d95e007f0..48b8e467f4 100644 --- a/openhands-tools/pyproject.toml +++ b/openhands-tools/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "bashlex>=0.18", "binaryornot>=0.4.4", "cachetools", - "libtmux>=0.53.0", + "libtmux @ git+https://github.com/neubig/libtmux.git@fix/new-session-race-condition", "pydantic>=2.11.7", "browser-use>=0.8.0", "func-timeout>=4.3.5", diff --git a/pyproject.toml b/pyproject.toml index 2f1c150489..0f167f6f72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dev = [ # Ruff configuration [tool.ruff] -target-version = "py312" +target-version = "py313" line-length = 88 [tool.ruff.format] @@ -100,7 +100,7 @@ extraPaths = [ ] venvPath = "." venv = ".venv" -pythonVersion = "3.12" +pythonVersion = "3.13" useLibraryCodeForTypes = true typeCheckingMode = "standard" diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py index 9ea4181e76..859cebdfb5 100644 --- a/tests/cross/test_remote_conversation_live_server.py +++ b/tests/cross/test_remote_conversation_live_server.py @@ -34,9 +34,7 @@ @pytest.fixture -def server_env( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> Generator[dict, None, None]: +def server_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[dict]: """Launch a real FastAPI server backed by temp workspace and conversations. We set OPENHANDS_AGENT_SERVER_CONFIG_PATH before creating the app so that diff --git a/tests/sdk/mcp/test_create_mcp_tool.py b/tests/sdk/mcp/test_create_mcp_tool.py index f774a0b141..ce4aad2447 100644 --- a/tests/sdk/mcp/test_create_mcp_tool.py +++ b/tests/sdk/mcp/test_create_mcp_tool.py @@ -110,7 +110,7 @@ def stop(self): @pytest.fixture -def http_mcp_server() -> Generator[MCPTestServer, None, None]: +def http_mcp_server() -> Generator[MCPTestServer]: """Fixture providing a running HTTP MCP server with test tools.""" server = MCPTestServer("http-test-server") @@ -130,7 +130,7 @@ def add_numbers(a: int, b: int) -> int: @pytest.fixture -def sse_mcp_server() -> Generator[MCPTestServer, None, None]: +def sse_mcp_server() -> Generator[MCPTestServer]: """Fixture providing a running SSE MCP server with test tools.""" server = MCPTestServer("sse-test-server") diff --git a/tests/tools/browser_use/test_browser_executor_e2e.py b/tests/tools/browser_use/test_browser_executor_e2e.py index d8a140b029..35e480f713 100644 --- a/tests/tools/browser_use/test_browser_executor_e2e.py +++ b/tests/tools/browser_use/test_browser_executor_e2e.py @@ -106,7 +106,7 @@ @pytest.fixture(scope="module") -def test_server() -> Generator[str, None, None]: +def test_server() -> Generator[str]: """Set up a local HTTP server for testing.""" temp_dir = tempfile.mkdtemp() server_process = None @@ -147,7 +147,7 @@ def test_server() -> Generator[str, None, None]: @pytest.fixture -def browser_executor() -> Generator[BrowserToolExecutor, None, None]: +def browser_executor() -> Generator[BrowserToolExecutor]: """Create a real BrowserToolExecutor for testing.""" executor = None try: diff --git a/uv.lock b/uv.lock index 49ce6e9dd8..c47bcb026f 100644 --- a/uv.lock +++ b/uv.lock @@ -1547,11 +1547,7 @@ wheels = [ [[package]] name = "libtmux" version = "0.53.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/28/e2b252817cb181aec2f42fe2d1d7fac5ec9c4d15bfb2b8ea4bd1179e4244/libtmux-0.53.0.tar.gz", hash = "sha256:1d19af4cea0c19543954d7e7317c7025c0739b029cccbe3b843212fae238f1bd", size = 405001, upload-time = "2025-12-14T11:59:11.337Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/d0/2e8bc5caa639ebb9f8801ba0be7070a28d48d8ed60e2a428d40f71fb88b8/libtmux-0.53.0-py3-none-any.whl", hash = "sha256:024b7ae6a12aae55358e8feb914c8632b3ab9bd61c0987c53559643c6a58ee4f", size = 77582, upload-time = "2025-12-14T11:59:09.739Z" }, -] +source = { git = "https://github.com/neubig/libtmux.git?rev=fix%2Fnew-session-race-condition#6d202e082030efb04074b9231c152510386aebca" } [[package]] name = "litellm" @@ -2205,7 +2201,7 @@ requires-dist = [ { name = "browser-use", specifier = ">=0.8.0" }, { name = "cachetools" }, { name = "func-timeout", specifier = ">=4.3.5" }, - { name = "libtmux", specifier = ">=0.53.0" }, + { name = "libtmux", git = "https://github.com/neubig/libtmux.git?rev=fix%2Fnew-session-race-condition" }, { name = "openhands-sdk", editable = "openhands-sdk" }, { name = "pydantic", specifier = ">=2.11.7" }, { name = "tom-swe", specifier = ">=1.0.3" },