Skip to content

Commit 466cea2

Browse files
committed
Refine Harbor runtime CLI integration
1 parent 2b5aefc commit 466cea2

7 files changed

Lines changed: 213 additions & 65 deletions

File tree

docs/v6/advanced/harbor-convert.mdx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ task dirs - is a *frontend* that loads into the same primitives (`Environment`,
99
`Task`, `Taskset`). Integrations are **loaders, not converters**: no codegen
1010
roundtrip to run foreign tasks. The Harbor integration lives in the SDK repo at
1111
[`integrations/harbor.py`](https://github.com/hud-evals/hud-python/blob/main/integrations/harbor.py)
12-
- a recipe built only on the public SDK surface; copy it into your project or
13-
run it from a checkout.
12+
- a public-surface loader that maps Harbor folders into SDK primitives. The
13+
included `HarborRuntime` is maintained with the SDK for local Docker execution;
14+
copy the loader into your project or run it from a checkout.
1415

1516
## Prerequisites
1617

@@ -44,11 +45,11 @@ from integrations.harbor import HarborRuntime
4445
job = await taskset.run(agent, runtime=HarborRuntime("./harbor_tasks"))
4546
```
4647

47-
The eval CLI also detects local Harbor task directories and datasets when using
48-
local runtime placement:
48+
The eval CLI can run local Harbor task directories and datasets when you opt
49+
into the Harbor source format:
4950

5051
```bash
51-
hud eval ./harbor_tasks claude --task-ids cancel-async-tasks --max-steps 30
52+
hud eval ./harbor_tasks claude --format harbor --task-ids cancel-async-tasks --max-steps 30
5253
```
5354

5455
## Export HUD tasks to Harbor

docs/v6/reference/cli.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ For a platform taskset, pass its name or id directly: `hud eval "My Tasks" claud
105105
| `--config`, `-c` | Agent config `key=value` (repeatable). |
106106
| `--verbose`, `-v` | Show agent logs (step progress, tool calls) for batch runs too. |
107107
| `--very-verbose`, `-vv` | Debug-level logs. |
108+
| `--format` | Task source format: `hud` (default) or `harbor`. |
108109
| `--runtime` | Placement: `local`, `hud` (HUD runtime tunnel), or `tcp://host:port`. Defaults to `local` for a tasks file; platform tasksets default to remote hosted execution. |
109110
| `--remote` | Run the whole rollout remotely on the HUD platform. |
110111
| `--yes`, `-y` | Skip confirmation prompt. |
@@ -133,8 +134,9 @@ hud sync env # sync environment metadata
133134
```
134135

135136
External benchmark formats (currently Harbor) load directly into the runtime
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).
137+
as `Taskset`s - no conversion step. For local Harbor directories, opt in with
138+
`--format harbor` so the CLI uses the Harbor loader and Docker-backed runtime
139+
provider. See [Harbor interop](/v6/advanced/harbor-convert).
138140

139141
## Inspect
140142

hud/cli/eval.py

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def _resolve_model_from_catalog(model_id: str) -> tuple[AgentType, str] | None:
6363

6464
_CONFIG_PATH = ".hud_eval.toml"
6565
_PLACEMENT_CONFLICT_ERROR = "--runtime and --remote are mutually exclusive placement options"
66+
_SOURCE_FORMATS = ("hud", "harbor")
6667

6768

6869
def _resolve_env_vars(obj: Any) -> Any:
@@ -167,6 +168,7 @@ class AgentPreset:
167168
# very_verbose = true
168169
# auto_respond = true
169170
# gateway = false # Route LLM API calls through HUD Gateway
171+
# format = "hud" # hud or harbor
170172
# runtime = "local" # local, hud, or tcp://host:port
171173
# remote = false # Run the whole rollout remotely on HUD
172174
@@ -264,6 +266,7 @@ class EvalConfig(BaseModel):
264266
"group_size",
265267
"auto_respond",
266268
"gateway",
269+
"format",
267270
"runtime",
268271
"remote",
269272
}
@@ -279,6 +282,9 @@ class EvalConfig(BaseModel):
279282
auto_respond: bool | None = None
280283
group_size: int = 1
281284
gateway: bool = False
285+
#: Source format. ``None``/``hud`` means normal HUD task source loading;
286+
#: ``harbor`` opts into the Harbor integration loader/runtime.
287+
format: str | None = None
282288
#: Placement: "local" (spawn each row's env from the source), "hud"
283289
#: (HUD runtime tunnel), or a tcp:// url of an already-served env.
284290
#: ``None`` means "infer from the source": a local file runs locally, a
@@ -306,6 +312,20 @@ def _parse_agent_type(cls, v: Any) -> AgentType | None:
306312
) from None
307313
return v
308314

315+
@field_validator("format", mode="before")
316+
@classmethod
317+
def _parse_format(cls, v: Any) -> str | None:
318+
if v is None:
319+
return None
320+
if not isinstance(v, str):
321+
return v
322+
normalized = v.strip().lower()
323+
if normalized in ("", "hud"):
324+
return None
325+
if normalized in _SOURCE_FORMATS:
326+
return normalized
327+
raise ValueError(f"Invalid format: {v}. Must be one of: {', '.join(_SOURCE_FORMATS)}")
328+
309329
def source_is_local_file(self) -> bool:
310330
"""Whether ``source`` points at an on-disk taskset (vs. a platform slug/id)."""
311331
return self.source is not None and Path(self.source).exists()
@@ -319,6 +339,13 @@ def resolve_runtime(self) -> EvalConfig:
319339
``--runtime`` is always honored, except ``local`` against a platform
320340
taskset, which has no env to spawn.
321341
"""
342+
if self.format == "harbor":
343+
if not self.source_is_local_file():
344+
hud_console.error("--format harbor requires a local Harbor task directory")
345+
raise typer.Exit(1)
346+
if self.remote or (self.runtime is not None and self.runtime != "local"):
347+
hud_console.error("--format harbor currently supports only local runtime placement")
348+
raise typer.Exit(1)
322349
if self.runtime is None:
323350
if self.source_is_local_file():
324351
return self.model_copy(update={"runtime": "local"})
@@ -502,6 +529,7 @@ def merge_cli(
502529
gateway: bool = False,
503530
config: list[str] | None = None,
504531
task_ids: str | None = None,
532+
format: str | None = None,
505533
runtime: str | None = None,
506534
remote: bool = False,
507535
) -> EvalConfig:
@@ -517,6 +545,7 @@ def merge_cli(
517545
"max_concurrent": max_concurrent,
518546
"max_steps": max_steps,
519547
"group_size": group_size,
548+
"format": format,
520549
"runtime": runtime,
521550
}.items()
522551
if value is not None
@@ -604,6 +633,8 @@ def display(self) -> None:
604633
table.add_column("Value", style="green")
605634

606635
table.add_row("source", str(self.source or "-"))
636+
if self.format:
637+
table.add_row("format", self.format)
607638
table.add_row("runtime", str(self.runtime or "-"))
608639
table.add_row("agent", self.agent_type.value if self.agent_type else "-")
609640
if self.task_ids:
@@ -728,32 +759,20 @@ def _spawn_target(source: Path) -> Path:
728759
return resolved.parent
729760

730761

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]:
762+
def _load_local_taskset(source_path: Path, source_format: str | None) -> Any:
742763
from hud.eval import Taskset
743764

744-
if _is_harbor_source(source_path):
765+
format_name = source_format or "hud"
766+
if format_name == "hud":
767+
return Taskset.from_file(source_path)
768+
if format_name == "harbor":
745769
from integrations.harbor import load
746770

747-
return load(source_path), "harbor"
748-
return Taskset.from_file(source_path), "hud"
771+
return load(source_path)
772+
raise ValueError(f"unsupported task source format: {format_name}")
749773

750774

751-
def _resolve_placement(
752-
cfg: EvalConfig,
753-
source_path: Path | None,
754-
*,
755-
source_kind: str = "hud",
756-
) -> Any:
775+
def _resolve_placement(cfg: EvalConfig, source_path: Path | None) -> Any:
757776
"""Map the config's ``runtime`` onto a placement for ``Taskset.run``.
758777
759778
"local" spawns each row's env from the source next to the tasks file;
@@ -769,7 +788,7 @@ def _resolve_placement(
769788
if cfg.runtime == "local":
770789
if source_path is None:
771790
raise ValueError("local placement requires a local source path")
772-
if source_kind == "harbor":
791+
if cfg.format == "harbor":
773792
from integrations.harbor import HarborRuntime
774793

775794
return HarborRuntime(source_path)
@@ -798,11 +817,10 @@ async def _run_evaluation(cfg: EvalConfig) -> Any:
798817

799818
source_path = Path(cfg.source)
800819
is_local = source_path.exists()
801-
source_kind = "api"
802820
if is_local:
803821
hud_console.info(f"Loading tasks from: {cfg.source}")
804822
try:
805-
taskset, source_kind = _load_local_taskset(source_path)
823+
taskset = _load_local_taskset(source_path, cfg.format)
806824
except Exception as e:
807825
hud_console.error(f"Failed to load tasks from {cfg.source}: {e}")
808826
raise typer.Exit(1) from e
@@ -862,11 +880,7 @@ async def _run_evaluation(cfg: EvalConfig) -> Any:
862880
)
863881

864882
agent = _build_agent(cfg)
865-
placement = _resolve_placement(
866-
cfg,
867-
source_path if is_local else None,
868-
source_kind=source_kind,
869-
)
883+
placement = _resolve_placement(cfg, source_path if is_local else None)
870884

871885
job = await taskset.run(
872886
agent,
@@ -922,6 +936,11 @@ def eval_command(
922936
gateway: bool = typer.Option(
923937
False, "--gateway", "-g", help="Route LLM API calls through HUD Gateway"
924938
),
939+
format: str | None = typer.Option(
940+
None,
941+
"--format",
942+
help="Task source format: hud (default) or harbor.",
943+
),
925944
runtime: str | None = typer.Option(
926945
None,
927946
"--runtime",
@@ -942,6 +961,7 @@ def eval_command(
942961
hud eval "My Tasks" claude-sonnet-4-6 --full # Platform taskset, run on the platform
943962
hud eval tasks.json claude --config max_tokens=32768
944963
hud eval tasks.json claude --gateway # Route LLM calls through HUD Gateway
964+
hud eval ./harbor_tasks claude --format harbor # Run Harbor task dirs locally
945965
hud eval tasks.json claude-sonnet-4-6 --runtime hud # Use HUD runtime tunnel
946966
hud eval tasks.json claude-sonnet-4-6 --remote # Execute rollout remotely
947967
"""
@@ -972,6 +992,7 @@ def eval_command(
972992
group_size=group_size,
973993
config=config,
974994
gateway=gateway,
995+
format=format,
975996
runtime=runtime,
976997
remote=remote,
977998
)

hud/cli/tests/test_eval_config.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,30 +153,63 @@ def test_resolve_placement_runtime_hud_uses_tunnel(
153153
assert isinstance(placement, HUDRuntime)
154154

155155

156-
def test_load_local_taskset_uses_harbor_loader_for_harbor_dirs(tmp_path: Path) -> None:
156+
def test_load_local_taskset_uses_hud_loader_by_default(tmp_path: Path) -> None:
157157
_write_harbor_task(tmp_path)
158158

159-
taskset, source_kind = eval_mod._load_local_taskset(tmp_path)
159+
taskset = eval_mod._load_local_taskset(tmp_path, None)
160+
161+
assert len(taskset) == 0
162+
163+
164+
def test_load_local_taskset_rejects_unknown_format(tmp_path: Path) -> None:
165+
with pytest.raises(ValueError, match="unsupported task source format"):
166+
eval_mod._load_local_taskset(tmp_path, "unknown")
167+
168+
169+
def test_load_local_taskset_uses_harbor_loader_when_format_is_harbor(tmp_path: Path) -> None:
170+
_write_harbor_task(tmp_path)
171+
172+
taskset = eval_mod._load_local_taskset(tmp_path, "harbor")
160173

161-
assert source_kind == "harbor"
162174
assert len(taskset) == 1
163175
assert taskset["demo-task"].id == "demo-task"
164176

165177

166-
def test_resolve_placement_local_harbor_uses_harbor_runtime(tmp_path: Path) -> None:
178+
def test_resolve_placement_local_harbor_format_uses_harbor_runtime(tmp_path: Path) -> None:
167179
from integrations.harbor import HarborRuntime
168180

169181
_write_harbor_task(tmp_path)
170182

171183
placement = eval_mod._resolve_placement(
172-
EvalConfig(runtime="local"),
184+
EvalConfig(runtime="local", format="harbor"),
173185
tmp_path,
174-
source_kind="harbor",
175186
)
176187

177188
assert isinstance(placement, HarborRuntime)
178189

179190

191+
def test_resolve_placement_local_hud_format_uses_local_runtime(tmp_path: Path) -> None:
192+
from hud.eval import LocalRuntime
193+
194+
_write_harbor_task(tmp_path)
195+
196+
placement = eval_mod._resolve_placement(EvalConfig(runtime="local"), tmp_path)
197+
198+
assert isinstance(placement, LocalRuntime)
199+
200+
201+
def test_harbor_format_rejects_nonlocal_source() -> None:
202+
with pytest.raises(typer.Exit):
203+
EvalConfig(source="platform/taskset", format="harbor").resolve_runtime()
204+
205+
206+
def test_harbor_format_rejects_nonlocal_runtime(tmp_path: Path) -> None:
207+
_write_harbor_task(tmp_path)
208+
209+
with pytest.raises(typer.Exit):
210+
EvalConfig(source=str(tmp_path), format="harbor", runtime="hud").resolve_runtime()
211+
212+
180213
def test_resolve_placement_remote_uses_hosted_runtime(
181214
tmp_path: Path,
182215
monkeypatch: pytest.MonkeyPatch,

integrations/__init__.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
primitives. Integrations are **loaders, not converters**: no codegen roundtrip
66
to run foreign tasks.
77
8-
This package lives outside ``hud`` on purpose: each module is a recipe built
9-
**only on the public SDK surface** (``Environment``, ``Task``,
10-
``Taskset``, ``Runtime``) — that constraint is the proof the core is
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.
8+
This package lives outside ``hud`` on purpose: loaders are recipes built on the
9+
public SDK surface (``Environment``, ``Task``, ``Taskset``, ``Runtime``). Copy a
10+
loader into your project or run it from a checkout. The CLI may call selected
11+
integrations explicitly for polished interop paths. A repo-maintained
12+
integration may also expose a local provider for that explicit CLI path; that
13+
provider is SDK implementation code, not the portable loader contract.
1414
1515
The contract: an integration module exposes ``detect(path) -> bool`` and
1616
``load(path) -> Taskset``. Placement stays an execution-time concern — loaders

0 commit comments

Comments
 (0)