Skip to content

Commit 2b5aefc

Browse files
committed
Add Harbor runtime support and split the code to make it cleaner
1 parent ed75db2 commit 2b5aefc

10 files changed

Lines changed: 1040 additions & 89 deletions

File tree

docs/v6/advanced/harbor-convert.mdx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,29 @@ directly - one row per task dir (`id` = the dir name), sharing one declarative
2626
```python
2727
from integrations.harbor import detect, load
2828

29-
assert detect("./terminal-bench")
30-
taskset = load("./terminal-bench")
29+
assert detect("./harbor_tasks")
30+
taskset = load("./harbor_tasks")
3131

3232
for task in taskset:
3333
print(task.env, task.id)
3434
```
3535

36-
Like every task row, the result carries no placement. Run it by supplying one -
37-
today that means a substrate already serving the control channel
38-
(`runtime=Runtime(url)`); a docker provider that builds and runs each task's
39-
`environment/` image is the planned follow-up:
36+
Like every task row, the result carries no placement. Run it by supplying one.
37+
For local Docker-backed Harbor execution, use `HarborRuntime`; it builds the
38+
task's `environment/` image, runs a fresh container, exposes the workspace
39+
through HUD's normal shell capability, and grades by running `tests/test.sh`:
4040

4141
```python
42-
from hud import Runtime
42+
from integrations.harbor import HarborRuntime
4343

44-
job = await taskset.run(agent, runtime=Runtime("tcp://127.0.0.1:8765"))
44+
job = await taskset.run(agent, runtime=HarborRuntime("./harbor_tasks"))
45+
```
46+
47+
The eval CLI also detects local Harbor task directories and datasets when using
48+
local runtime placement:
49+
50+
```bash
51+
hud eval ./harbor_tasks claude --task-ids cancel-async-tasks --max-steps 30
4552
```
4653

4754
## Export HUD tasks to Harbor

docs/v6/reference/cli.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ hud sync env # sync environment metadata
133133
```
134134

135135
External benchmark formats (currently Harbor) load directly into the runtime
136-
as `Taskset`s - no conversion step. See [Harbor interop](/v6/advanced/harbor-convert).
136+
as `Taskset`s - no conversion step. Local Harbor directories run with the Harbor
137+
Docker-backed runtime provider. See [Harbor interop](/v6/advanced/harbor-convert).
137138

138139
## Inspect
139140

hud/cli/eval.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -728,7 +728,32 @@ def _spawn_target(source: Path) -> Path:
728728
return resolved.parent
729729

730730

731-
def _resolve_placement(cfg: EvalConfig, source_path: Path | None) -> Any:
731+
def _is_harbor_source(source_path: Path | None) -> bool:
732+
if source_path is None or not source_path.exists():
733+
return False
734+
if not source_path.is_dir():
735+
return False
736+
from integrations.harbor import detect
737+
738+
return detect(source_path)
739+
740+
741+
def _load_local_taskset(source_path: Path) -> tuple[Any, str]:
742+
from hud.eval import Taskset
743+
744+
if _is_harbor_source(source_path):
745+
from integrations.harbor import load
746+
747+
return load(source_path), "harbor"
748+
return Taskset.from_file(source_path), "hud"
749+
750+
751+
def _resolve_placement(
752+
cfg: EvalConfig,
753+
source_path: Path | None,
754+
*,
755+
source_kind: str = "hud",
756+
) -> Any:
732757
"""Map the config's ``runtime`` onto a placement for ``Taskset.run``.
733758
734759
"local" spawns each row's env from the source next to the tasks file;
@@ -744,6 +769,10 @@ def _resolve_placement(cfg: EvalConfig, source_path: Path | None) -> Any:
744769
if cfg.runtime == "local":
745770
if source_path is None:
746771
raise ValueError("local placement requires a local source path")
772+
if source_kind == "harbor":
773+
from integrations.harbor import HarborRuntime
774+
775+
return HarborRuntime(source_path)
747776
return LocalRuntime(_spawn_target(source_path))
748777
if cfg.runtime == "hud":
749778
require_api_key("run HUD runtime tunnel evals")
@@ -767,18 +796,19 @@ async def _run_evaluation(cfg: EvalConfig) -> Any:
767796
if cfg.source is None or cfg.agent_type is None:
768797
raise ValueError("source and agent_type must be set")
769798

770-
from hud.eval import Taskset
771-
772799
source_path = Path(cfg.source)
773800
is_local = source_path.exists()
801+
source_kind = "api"
774802
if is_local:
775803
hud_console.info(f"Loading tasks from: {cfg.source}")
776804
try:
777-
taskset = Taskset.from_file(source_path)
805+
taskset, source_kind = _load_local_taskset(source_path)
778806
except Exception as e:
779807
hud_console.error(f"Failed to load tasks from {cfg.source}: {e}")
780808
raise typer.Exit(1) from e
781809
else:
810+
from hud.eval import Taskset
811+
782812
hud_console.info(f"Loading platform taskset: {cfg.source}")
783813
try:
784814
taskset = Taskset.from_api(cfg.source)
@@ -832,7 +862,11 @@ async def _run_evaluation(cfg: EvalConfig) -> Any:
832862
)
833863

834864
agent = _build_agent(cfg)
835-
placement = _resolve_placement(cfg, source_path if is_local else None)
865+
placement = _resolve_placement(
866+
cfg,
867+
source_path if is_local else None,
868+
source_kind=source_kind,
869+
)
836870

837871
job = await taskset.run(
838872
agent,

hud/cli/tests/test_eval_config.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@
2020
_ARN = "arn:aws:bedrock:us-east-1:123456789012:inference-profile/anthropic.claude"
2121

2222

23+
def _write_harbor_task(root: Path, name: str = "demo-task") -> Path:
24+
task = root / name
25+
(task / "environment").mkdir(parents=True)
26+
(task / "tests").mkdir()
27+
(task / "instruction.md").write_text("Fix the demo task.\n", encoding="utf-8")
28+
(task / "task.toml").write_text(
29+
'schema_version = "1.3"\n\n[task]\nname = "demo/demo-task"\n',
30+
encoding="utf-8",
31+
)
32+
(task / "environment" / "Dockerfile").write_text("FROM python:3.12-slim\n", encoding="utf-8")
33+
(task / "tests" / "test.sh").write_text(
34+
"#!/usr/bin/env bash\nmkdir -p /logs/verifier\necho 1 > /logs/verifier/reward.txt\n",
35+
encoding="utf-8",
36+
)
37+
return task
38+
39+
2340
def test_is_bedrock_arn() -> None:
2441
assert _is_bedrock_arn(_ARN) is True
2542
assert _is_bedrock_arn("claude-sonnet-4-6") is False
@@ -136,6 +153,30 @@ def test_resolve_placement_runtime_hud_uses_tunnel(
136153
assert isinstance(placement, HUDRuntime)
137154

138155

156+
def test_load_local_taskset_uses_harbor_loader_for_harbor_dirs(tmp_path: Path) -> None:
157+
_write_harbor_task(tmp_path)
158+
159+
taskset, source_kind = eval_mod._load_local_taskset(tmp_path)
160+
161+
assert source_kind == "harbor"
162+
assert len(taskset) == 1
163+
assert taskset["demo-task"].id == "demo-task"
164+
165+
166+
def test_resolve_placement_local_harbor_uses_harbor_runtime(tmp_path: Path) -> None:
167+
from integrations.harbor import HarborRuntime
168+
169+
_write_harbor_task(tmp_path)
170+
171+
placement = eval_mod._resolve_placement(
172+
EvalConfig(runtime="local"),
173+
tmp_path,
174+
source_kind="harbor",
175+
)
176+
177+
assert isinstance(placement, HarborRuntime)
178+
179+
139180
def test_resolve_placement_remote_uses_hosted_runtime(
140181
tmp_path: Path,
141182
monkeypatch: pytest.MonkeyPatch,

integrations/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
This package lives outside ``hud`` on purpose: each module is a recipe built
99
**only on the public SDK surface** (``Environment``, ``Task``,
1010
``Taskset``, ``Runtime``) — that constraint is the proof the core is
11-
flexible. Copy a module into your project or run it from a checkout; nothing
12-
in the SDK or CLI imports it.
11+
flexible. Copy a module into your project or run it from a checkout. The CLI may
12+
call selected integrations explicitly for polished interop paths, but the
13+
integration contract itself stays independent of private SDK hooks.
1314
1415
The contract: an integration module exposes ``detect(path) -> bool`` and
1516
``load(path) -> Taskset``. Placement stays an execution-time concern — loaders

integrations/harbor.py

Lines changed: 15 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,9 @@
1111
1212
:func:`load` parses a task dir (or a dataset of them) into rows sharing one
1313
env name per distinct ``environment/`` build context — no codegen, no
14-
roundtrip. Like every row, the result is runnable
15-
once a placement is supplied (``runtime=Runtime(url)`` against a served substrate
16-
today). Providers receive the row being placed, so a docker provider that
17-
builds and runs each row's ``environment/`` image is the named follow-up —
18-
expressible without engine changes.
14+
roundtrip. Like every row, the result is runnable once a placement is supplied.
15+
Use :class:`HarborRuntime` for local Docker-backed execution of Harbor tasks, or
16+
``runtime=Runtime(url)`` to attach to a substrate served elsewhere.
1917
2018
:func:`export` is the reverse direction: turn a HUD task source into
2119
self-contained Harbor task folders (``task.toml`` + ``instruction.md`` +
@@ -40,19 +38,23 @@
4038

4139
from __future__ import annotations
4240

43-
import hashlib
4441
import json
4542
import logging
46-
import re
4743
import shutil
48-
import tomllib
49-
from dataclasses import dataclass
5044
from pathlib import Path
5145
from typing import TYPE_CHECKING, Any
5246

5347
from hud.environment import Environment
5448
from hud.environment.server import TaskRunner
5549
from hud.eval import Task, Taskset
50+
from integrations.harbor_common import (
51+
_HarborTask,
52+
_is_harbor_task,
53+
_parse_task,
54+
_slugify,
55+
_task_dirs,
56+
)
57+
from integrations.harbor_runtime import HarborRuntime
5658

5759
if TYPE_CHECKING:
5860
from collections.abc import Callable
@@ -74,18 +76,12 @@
7476
"__pycache__", "*.pyc", ".git", ".venv", "venv", "*.egg-info", ".pytest_cache"
7577
)
7678

77-
7879
# ─── load: Harbor dirs -> Taskset ──────────────────────────────────────
7980

8081

8182
def detect(path: str | Path) -> bool:
8283
"""True when *path* is a Harbor task dir or a dataset of them."""
83-
root = Path(path)
84-
if _is_harbor_task(root):
85-
return True
86-
if root.is_dir():
87-
return any(_is_harbor_task(d) for d in root.iterdir() if d.is_dir())
88-
return False
84+
return bool(_task_dirs(path))
8985

9086

9187
def load(path: str | Path) -> Taskset:
@@ -96,12 +92,8 @@ def load(path: str | Path) -> Taskset:
9692
context (content-hashed), derived from the dataset name.
9793
"""
9894
root = Path(path).resolve()
99-
if _is_harbor_task(root):
100-
task_dirs = [root]
101-
dataset_name = root.parent.name
102-
else:
103-
task_dirs = sorted(d for d in root.iterdir() if d.is_dir() and _is_harbor_task(d))
104-
dataset_name = root.name
95+
task_dirs = _task_dirs(root)
96+
dataset_name = root.parent.name if _is_harbor_task(root) else root.name
10597
if not task_dirs:
10698
raise ValueError(f"no Harbor tasks found in {path}")
10799

@@ -126,54 +118,6 @@ def load(path: str | Path) -> Taskset:
126118
return Taskset(base_name, tasks)
127119

128120

129-
def _slugify(name: str) -> str:
130-
"""A valid env name (lowercase ``[a-z0-9-]``) from a dataset dir name."""
131-
normalized = re.sub(r"[^a-z0-9-]", "", name.strip().lower().replace(" ", "-").replace("_", "-"))
132-
return re.sub(r"-+", "-", normalized).strip("-") or "harbor"
133-
134-
135-
def _is_harbor_task(path: Path) -> bool:
136-
return path.is_dir() and (path / "task.toml").exists() and (path / "instruction.md").exists()
137-
138-
139-
def _hash_directory(path: Path) -> str:
140-
"""Content-hash a directory for grouping tasks by identical environments."""
141-
hasher = hashlib.sha256()
142-
if not path.exists():
143-
return "empty"
144-
for file_path in sorted(path.rglob("*")):
145-
if file_path.is_file():
146-
hasher.update(str(file_path.relative_to(path)).encode())
147-
hasher.update(file_path.read_bytes())
148-
return hasher.hexdigest()[:16]
149-
150-
151-
@dataclass(frozen=True, slots=True)
152-
class _HarborTask:
153-
"""One parsed Harbor task dir."""
154-
155-
task_id: str
156-
config: dict[str, Any]
157-
env_hash: str
158-
159-
160-
def _parse_task(task_dir: Path) -> _HarborTask | None:
161-
if not (task_dir / "instruction.md").is_file():
162-
LOGGER.warning("failed to read instruction.md in %s", task_dir)
163-
return None
164-
try:
165-
config: dict[str, Any] = tomllib.loads((task_dir / "task.toml").read_text("utf-8"))
166-
except (OSError, tomllib.TOMLDecodeError):
167-
LOGGER.warning("failed to parse task.toml in %s", task_dir)
168-
config = {}
169-
env_dir = task_dir / "environment"
170-
return _HarborTask(
171-
task_id=task_dir.name,
172-
config=config,
173-
env_hash=_hash_directory(env_dir) if env_dir.exists() else "no-env",
174-
)
175-
176-
177121
# ─── export: HUD tasks -> Harbor task folders ───────────────────────────
178122

179123

@@ -443,6 +387,7 @@ async def export(
443387
"ALLOWED_PROTOCOLS",
444388
"CONTROL_PORT",
445389
"DEFAULT_ANSWER_FILE",
390+
"HarborRuntime",
446391
"detect",
447392
"export",
448393
"load",

0 commit comments

Comments
 (0)