Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/actions/pr-review/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pypi-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/run-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 14 additions & 9 deletions .github/workflows/server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
10 changes: 5 additions & 5 deletions openhands-agent-server/openhands/agent_server/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion openhands-sdk/openhands/sdk/agent/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion openhands-sdk/openhands/sdk/utils/paging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
116 changes: 109 additions & 7 deletions openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tmux-based terminal backend implementation."""

import subprocess
import time
import uuid

Expand All @@ -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.

Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion openhands-tools/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ dev = [

# Ruff configuration
[tool.ruff]
target-version = "py312"
target-version = "py313"
line-length = 88

[tool.ruff.format]
Expand Down Expand Up @@ -100,7 +100,7 @@ extraPaths = [
]
venvPath = "."
venv = ".venv"
pythonVersion = "3.12"
pythonVersion = "3.13"
useLibraryCodeForTypes = true
typeCheckingMode = "standard"

Expand Down
4 changes: 1 addition & 3 deletions tests/cross/test_remote_conversation_live_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading