Skip to content

Commit eb9373b

Browse files
authored
Merge branch 'main' into img
2 parents d7e27e8 + 3aa647e commit eb9373b

File tree

11 files changed

+668
-64
lines changed

11 files changed

+668
-64
lines changed

.github/workflows/run-examples.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,19 @@ jobs:
2424
runs-on: blacksmith-2vcpu-ubuntu-2404
2525
timeout-minutes: 60
2626
steps:
27+
- name: Wait for agent server to finish build
28+
uses: lewagon/wait-on-check-action@v1.4.1
29+
with:
30+
ref: ${{ github.event.pull_request.head.ref }}
31+
check-name: Build & Push (python-amd64)
32+
repo-token: ${{ secrets.GITHUB_TOKEN }}
33+
wait-interval: 10
34+
2735
- name: Checkout
2836
uses: actions/checkout@v5
37+
with:
38+
ref: ${{ github.event.pull_request.head.ref }}
39+
repository: ${{ github.event.pull_request.head.repo.full_name }}
2940

3041
- name: Install uv
3142
uses: astral-sh/setup-uv@v7
@@ -45,10 +56,12 @@ jobs:
4556
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
4657
LLM_MODEL: openhands/claude-haiku-4-5-20251001
4758
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
59+
RUNTIME_API_KEY: ${{ secrets.RUNTIME_API_KEY }}
4860
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4961
PR_NUMBER: ${{ github.event.pull_request.number }}
5062
REPO_OWNER: ${{ github.repository_owner }}
5163
REPO_NAME: ${{ github.event.repository.name }}
64+
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
5265
run: |
5366
# List of examples to test
5467
# Excluded examples:
@@ -85,6 +98,7 @@ jobs:
8598
"examples/02_remote_agent_server/01_convo_with_local_agent_server.py"
8699
"examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py"
87100
"examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py"
101+
"examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py"
88102
)
89103
90104
# GitHub API setup (only for PR events)

.github/workflows/server.yml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ on:
55
push:
66
branches: [main]
77
tags:
8-
- build-docker
8+
- '*' # Trigger on any tag (e.g., 1.0.0, 1.0.0a5, build-docker)
99
pull_request:
1010
branches: [main]
1111
workflow_dispatch:
@@ -237,9 +237,12 @@ jobs:
237237
238238
# Generate build context and tags with arch suffix
239239
# build.py now handles architecture tagging internally via --arch flag
240-
uv run ./openhands-agent-server/openhands/agent_server/docker/build.py \
241-
--build-ctx-only \
242-
--arch ${{ matrix.arch }}
240+
# Add --versioned-tag when triggered by a git tag (e.g., v1.0.0)
241+
BUILD_CMD="uv run ./openhands-agent-server/openhands/agent_server/docker/build.py --build-ctx-only --arch ${{ matrix.arch }}"
242+
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
243+
BUILD_CMD="$BUILD_CMD --versioned-tag"
244+
fi
245+
eval "$BUILD_CMD"
243246
244247
# Alias tags_csv output to tags for the build action
245248
TAGS=$(grep '^tags_csv=' $GITHUB_OUTPUT | cut -d= -f2-)

examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import os
1515
import time
1616

17+
import requests
1718
from pydantic import SecretStr
1819

1920
from openhands.sdk import (
@@ -44,10 +45,43 @@
4445
logger.error("RUNTIME_API_KEY required")
4546
exit(1)
4647

48+
49+
def get_latest_commit_sha(
50+
repo: str = "OpenHands/software-agent-sdk", branch: str = "main"
51+
) -> str:
52+
"""
53+
Return the full SHA of the latest commit on `branch` for the given GitHub repo.
54+
Respects an optional GITHUB_TOKEN to avoid rate limits.
55+
"""
56+
url = f"https://api.github.com/repos/{repo}/commits/{branch}"
57+
headers = {}
58+
token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN")
59+
if token:
60+
headers["Authorization"] = f"Bearer {token}"
61+
62+
resp = requests.get(url, headers=headers, timeout=20)
63+
if resp.status_code != 200:
64+
raise RuntimeError(f"GitHub API error {resp.status_code}: {resp.text}")
65+
data = resp.json()
66+
sha = data.get("sha")
67+
if not sha:
68+
raise RuntimeError("Could not find commit SHA in GitHub response")
69+
logger.info(f"Latest commit on {repo} branch={branch} is {sha}")
70+
return sha
71+
72+
73+
# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency
74+
# Otherwise, get the latest commit SHA from main branch (images are built on main)
75+
server_image_sha = os.getenv("GITHUB_SHA") or get_latest_commit_sha(
76+
"OpenHands/software-agent-sdk", "main"
77+
)
78+
server_image = f"ghcr.io/openhands/agent-server:{server_image_sha[:7]}-python-amd64"
79+
logger.info(f"Using server image: {server_image}")
80+
4781
with APIRemoteWorkspace(
4882
runtime_api_url=os.getenv("RUNTIME_API_URL", "https://runtime.eval.all-hands.dev"),
4983
runtime_api_key=runtime_api_key,
50-
server_image="ghcr.io/openhands/agent-server:main-python",
84+
server_image=server_image,
5185
) as workspace:
5286
agent = get_default_agent(llm=llm, cli_mode=True)
5387
received_events: list = []
@@ -78,5 +112,7 @@ def event_callback(event) -> None:
78112

79113
conversation.send_message("Great! Now delete that file.")
80114
conversation.run()
115+
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
116+
print(f"EXAMPLE_COST: {cost}")
81117
finally:
82118
conversation.close()

openhands-agent-server/openhands/agent_server/docker/build.py

Lines changed: 116 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -102,31 +102,76 @@ def pump(stream, sink: list[str], log_fn, prefix: str) -> None:
102102
return result
103103

104104

105-
def _base_slug(image: str) -> str:
106-
return image.replace("/", "_s_").replace(":", "_tag_")
107-
108-
109105
def _sanitize_branch(ref: str) -> str:
110106
ref = re.sub(r"^refs/heads/", "", ref or "unknown")
111107
return re.sub(r"[^a-zA-Z0-9.-]+", "-", ref).lower()
112108

113109

114-
def _sdk_version() -> str:
115-
from importlib.metadata import version
110+
def _base_slug(image: str, max_len: int = 64) -> str:
111+
"""
112+
If the slug is too long, keep the most identifiable parts:
113+
- repository name (last path segment)
114+
- tag (if present)
115+
Then append a short digest for uniqueness.
116+
Format preserved with existing separators: '_s_' for '/', '_tag_' for ':'.
117+
118+
Example:
119+
'ghcr.io_s_org_s/very-long-repo_tag_v1.2.3-extra'
120+
-> 'very-long-repo_tag_v1.2.3-<digest>'
121+
"""
122+
base_slug = image.replace("/", "_s_").replace(":", "_tag_")
123+
124+
if len(base_slug) <= max_len:
125+
return base_slug
126+
127+
digest = hashlib.sha256(base_slug.encode()).hexdigest()[:12]
128+
suffix = f"-{digest}"
129+
130+
# Parse components from the slug form
131+
if "_tag_" in base_slug:
132+
left, tag = base_slug.split("_tag_", 1)
133+
else:
134+
left, tag = base_slug, ""
135+
136+
parts = left.split("_s_") if left else []
137+
repo = parts[-1] if parts else left # last path segment is the repo
138+
139+
# Reconstruct a compact, identifiable core: "<repo>[_tag_<tag>]"
140+
ident = repo + (f"_tag_{tag}" if tag else "")
141+
142+
# Fit within budget, reserving space for the digest suffix
143+
visible_budget = max_len - len(suffix)
144+
assert visible_budget > 0, (
145+
f"max_len too small to fit digest suffix with length {len(suffix)}"
146+
)
116147

117-
return version("openhands-sdk")
148+
kept = ident[:visible_budget]
149+
return kept + suffix
118150

119151

120152
def _git_info() -> tuple[str, str, str]:
121-
git_sha = os.environ.get("GITHUB_SHA")
153+
"""
154+
Get git info (ref, sha, short_sha) for the current working directory.
155+
156+
Priority order for SHA:
157+
1. SDK_SHA - Explicit override (e.g., for submodule builds)
158+
2. GITHUB_SHA - GitHub Actions environment
159+
3. git rev-parse HEAD - Local development
160+
161+
Priority order for REF:
162+
1. SDK_REF - Explicit override (e.g., for submodule builds)
163+
2. GITHUB_REF - GitHub Actions environment
164+
3. git symbolic-ref HEAD - Local development
165+
"""
166+
git_sha = os.environ.get("SDK_SHA") or os.environ.get("GITHUB_SHA")
122167
if not git_sha:
123168
try:
124169
git_sha = _run(["git", "rev-parse", "--verify", "HEAD"]).stdout.strip()
125170
except subprocess.CalledProcessError:
126171
git_sha = "unknown"
127172
short_sha = git_sha[:7] if git_sha != "unknown" else "unknown"
128173

129-
git_ref = os.environ.get("GITHUB_REF")
174+
git_ref = os.environ.get("SDK_REF") or os.environ.get("GITHUB_REF")
130175
if not git_ref:
131176
try:
132177
git_ref = _run(
@@ -137,8 +182,30 @@ def _git_info() -> tuple[str, str, str]:
137182
return git_ref, git_sha, short_sha
138183

139184

185+
def _package_version() -> str:
186+
"""
187+
Get the semantic version from the openhands-sdk package.
188+
This is used for versioned tags during releases.
189+
"""
190+
try:
191+
from importlib.metadata import version
192+
193+
return version("openhands-sdk")
194+
except Exception:
195+
# If package is not installed, try reading from pyproject.toml
196+
try:
197+
sdk_root = _default_sdk_project_root()
198+
pyproject_path = sdk_root / "openhands-sdk" / "pyproject.toml"
199+
if pyproject_path.exists():
200+
cfg = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
201+
return cfg.get("project", {}).get("version", "unknown")
202+
except Exception:
203+
pass
204+
return "unknown"
205+
206+
140207
GIT_REF, GIT_SHA, SHORT_SHA = _git_info()
141-
SDK_VERSION = _sdk_version()
208+
PACKAGE_VERSION = _package_version()
142209

143210

144211
# --- options ---
@@ -263,6 +330,13 @@ class BuildOptions(BaseModel):
263330
default=None,
264331
description="Architecture suffix (e.g., 'amd64', 'arm64') to append to tags",
265332
)
333+
include_versioned_tag: bool = Field(
334+
default=False,
335+
description=(
336+
"Whether to include the versioned tag (e.g., v1.0.0_...) in all_tags "
337+
"output. Should only be True for release builds."
338+
),
339+
)
266340

267341
@field_validator("target")
268342
@classmethod
@@ -280,46 +354,16 @@ def base_image_slug(self) -> str:
280354
return _base_slug(self.base_image)
281355

282356
@property
283-
def is_dev(self) -> bool:
284-
return self.target in ("source", "source-minimal")
357+
def versioned_tag(self) -> str:
358+
return f"v{PACKAGE_VERSION}_{self.base_image_slug}"
285359

286360
@property
287-
def versioned_tag(self) -> str:
288-
return f"v{SDK_VERSION}_{self.base_image_slug}_{self.target}"
361+
def base_tag(self) -> str:
362+
return f"{SHORT_SHA}-{self.base_image_slug}"
289363

290364
@property
291365
def cache_tags(self) -> tuple[str, str]:
292-
# Docker image tags have a 128-character limit.
293-
# If the base slug is too long, hash it to create a shorter unique identifier.
294-
MAX_TAG_LENGTH = 128
295-
base_slug = self.base_image_slug
296-
297-
# Reserve space for prefix, branch, and separators
298-
prefix = f"buildcache-{self.target}-"
299-
branch_suffix = (
300-
f"-{_sanitize_branch(GIT_REF)}"
301-
if GIT_REF not in ("main", "refs/heads/main", "unknown")
302-
else ""
303-
)
304-
main_suffix = "-main" if GIT_REF in ("main", "refs/heads/main") else ""
305-
306-
# Calculate available space for base_slug
307-
reserved = len(prefix) + max(len(branch_suffix), len(main_suffix))
308-
available = MAX_TAG_LENGTH - reserved
309-
310-
# If base_slug is too long, use a hash
311-
if len(base_slug) > available:
312-
# Use first 8 chars of SHA256 hash for uniqueness while keeping it short
313-
hash_digest = hashlib.sha256(base_slug.encode()).hexdigest()[:12]
314-
base_slug_short = hash_digest
315-
logger.debug(
316-
f"[build] Base image slug too long ({len(base_slug)} chars), "
317-
f"using hash: {base_slug_short}"
318-
)
319-
else:
320-
base_slug_short = base_slug
321-
322-
base = f"{prefix}{base_slug_short}"
366+
base = f"buildcache-{self.target}-{self.base_image_slug}"
323367
if GIT_REF in ("main", "refs/heads/main"):
324368
return f"{base}-main", base
325369
elif GIT_REF != "unknown":
@@ -332,14 +376,24 @@ def all_tags(self) -> list[str]:
332376
tags: list[str] = []
333377
arch_suffix = f"-{self.arch}" if self.arch else ""
334378

379+
# Use git commit SHA for commit-based tags
335380
for t in self.custom_tag_list:
336381
tags.append(f"{self.image}:{SHORT_SHA}-{t}{arch_suffix}")
382+
337383
if GIT_REF in ("main", "refs/heads/main"):
338384
for t in self.custom_tag_list:
339385
tags.append(f"{self.image}:main-{t}{arch_suffix}")
340-
tags.append(f"{self.image}:{self.versioned_tag}{arch_suffix}")
341-
if self.is_dev:
342-
tags = [f"{t}-dev" for t in tags]
386+
387+
# Always include base tag as default
388+
tags.append(f"{self.image}:{self.base_tag}{arch_suffix}")
389+
390+
# Only include versioned tag if requested (for releases)
391+
if self.include_versioned_tag:
392+
tags.append(f"{self.image}:{self.versioned_tag}{arch_suffix}")
393+
394+
# Append target suffix for clarity (binary is default, no suffix needed)
395+
if self.target != "binary":
396+
tags = [f"{t}-{self.target}" for t in tags]
343397
return tags
344398

345399

@@ -519,7 +573,10 @@ def build(opts: BuildOptions) -> list[str]:
519573
f"custom_tags='{opts.custom_tags}' from base='{opts.base_image}' "
520574
f"for platforms='{opts.platforms if push else 'local-arch'}'"
521575
)
522-
logger.info(f"[build] Git ref='{GIT_REF}' sha='{GIT_SHA}' version='{SDK_VERSION}'")
576+
logger.info(
577+
f"[build] Git ref='{GIT_REF}' sha='{GIT_SHA}' "
578+
f"package_version='{PACKAGE_VERSION}'"
579+
)
523580
logger.info(f"[build] Cache tag: {cache_tag}")
524581

525582
try:
@@ -609,6 +666,14 @@ def main(argv: list[str]) -> int:
609666
action="store_true",
610667
help="Only create the clean build context directory and print its path.",
611668
)
669+
parser.add_argument(
670+
"--versioned-tag",
671+
action="store_true",
672+
help=(
673+
"Include versioned tag (e.g., v1.0.0_...) in output. "
674+
"Should only be used for release builds."
675+
),
676+
)
612677

613678
args = parser.parse_args(argv)
614679

@@ -636,6 +701,7 @@ def main(argv: list[str]) -> int:
636701
push=None, # Not relevant for build-ctx-only
637702
sdk_project_root=sdk_project_root,
638703
arch=args.arch or None,
704+
include_versioned_tag=args.versioned_tag,
639705
)
640706

641707
# If running in GitHub Actions, write outputs directly to GITHUB_OUTPUT
@@ -678,6 +744,7 @@ def main(argv: list[str]) -> int:
678744
push=push,
679745
sdk_project_root=sdk_project_root,
680746
arch=args.arch or None,
747+
include_versioned_tag=args.versioned_tag,
681748
)
682749
tags = build(opts)
683750

0 commit comments

Comments
 (0)