Repository navigation
harbor: --harness module:Class for your own agents, and loopback for host-side agents - #1573
adithya-s-k wants to merge 13 commits into
Conversation
The CLI and docs offer `--harness module:Class`, but seams.get raised "no seam" for any import path. A custom agent now resolves by what it is: a subclass of an agent with a seam is wired like that agent; a host-side agent (a plain BaseAgent, like terminus-2) gets the capture proxy as api_base/api_key kwargs; an unknown installed agent is refused with a clear message rather than given guessed wiring.
terminus-2, computer-1 and host-side agents of your own call the model from this process, yet were given the public forward. A gradio share link answers 502 after about a minute while a long completion is still generating, so the agent retried and the capture recorded the same prompt again as a new root. run_rollout takes an optional local_intercept_url, used only for seams marked host_side; the rollout CLI and serve pass it. Callers that don't are unchanged.
64 validated test tasks, Qwen3.5-9B (deepinfra, thinking off), qed-proof on Daytona: openenv harbor serve 0.667 vs harbor run 0.717 mean reward, paired difference -0.049 (SE 0.046), 2 model calls per rollout on both paths.
…md, the stub's source
A seam with no native step-limit setting logged the same warning on every rollout: a training run serving a custom host-side agent (which has none) printed it hundreds of times. The warning is a fact about the harness, so it is now said once per harness per process.
- training tier on self-hosted vLLM: 63/64 graded, every trace valid (results/openenv-training-tier-v1.json) - HF Jobs: write outputs locally and hf buckets sync them (the bucket mount dropped multi-GB checkpoints); bucket mounted read-only for eval inputs - pin kernels <0.18 (0.18 breaks the pinned transformers), expose the capture proxy directly, pin OpenEnv to openenv/OpenEnv#1573 head - judge: 6 tries, honour Retry-After, no sleep after the last try - docs: two training rows per rollout with Qwen3.5, GPU and cluster caveats - pin Qwen3.5-4B/9B revisions; pass the round count to HF Jobs
burtenshaw
left a comment
There was a problem hiding this comment.
Needs changes before merge: the branch conflicts with main, the Python 3.11 CI job fails on the new tests, and a subclass of seven known agents is wired for an OpenEnv class it does not inherit from. The design itself looks sound, and the loopback change is a clear fix.
Please merge main first. One file conflicts:
tests/envs/test_harbor_rollout_contract.py
#1567 added test_ambiguous_rewards_fail_training_but_not_a_rollout_to_look_at at the same place as your test_only_a_host_side_agent_takes_the_loopback_url and test_host_side_seams. Keep all three tests. With that resolution, rollout.py and runner.py merge on their own and stay consistent: run_rollout takes both local_intercept_url and require_reward, and the runner passes both.
Blocking
- CI
test (3.11)fails on the four new tests intests/envs/test_harbor_seams.pywithModuleNotFoundError: No module named 'harbor.agents'. Harbor is installed only on Python 3.12+ (pyproject.toml:75), and in the full CI runpytest.importorskip("harbor")(test_harbor_seams.py:127and:186) finds aharbormodule withoutharbor.agents, so it does not skip. Skip on the module the tests use, as the other Harbor tests do:pytest.importorskip("harbor.agents.installed.base"). I reproduced the CI failure pattern with a bareharborpackage on the path, and with that change the four tests skip there and still pass with Harbor 0.25 installed. seams.customwires a class like its parent whenevercls.name()is a known seam (src/openenv/harbor/seams.py:988-995). Seven seams (pi, cline-cli, kimi-cli, openclaw, swe-agent, openhands, gemini-cli) do not build Harbor's class. They build an OpenEnv subclass fromimport_path(InterceptPi,InterceptOpenHandsand so on), and that subclass carries part of the wiring or an install fix. A subclass of Harbor's ownPireceivesintercept_config, which onlyInterceptPireads, and Harbor refuses it at construction (reproduction below). A subclass of Harbor'sOpenHandsis accepted and gets the env vars, but it runs Harbor's own install instead of the pinned one, which the openhands seam's comment says fails inside the sandbox. When the parent seam has animport_path, requireissubclass(cls, <that class>)and refuse otherwise with a message that names the class to subclass. The docs row "a subclass of an agent above ... that agent's wiring" (envs/harbor_env/README.md:580) needs the same caveat.
Smaller, in scope
src/openenv/harbor/ui_runs.py:433-439: rollouts started from theserveweb page still pass onlyintercept_url=service.public_url, so terminus-2 there keeps the public hop and the 502 you measured. Passlocal_intercept_url=service.local_urlthere too.src/openenv/harbor/environment.py:313:getattr(service, "local_url", "")can beservice.local_url.HarborServicehas the property now, and the Harbor tests that build fake services pass with the direct attribute. CLAUDE.md asks to avoidgetattrfor cases like this.src/openenv/harbor/capabilities.py:291-293:list_harnessesiterates only overSEAMS, wherehost_sideis true for terminus-2 and computer-1, and both are already inHOST_SIDE_AGENTS. The addedor seam.host_sidenever changes the output. Useseam.host_sidealone and deleteHOST_SIDE_AGENTS(nothing else reads it), so there is one source of truth, or leave this file unchanged.src/openenv/harbor/rollout.py:443-447: the seam is resolved here and again at line 472. Moveseam = seams.get(...)aboveconfig_kwargsand reuse it. For amodule:Classharness every call imports and classifies the class again.seams.customreturns a host-side seam for any class that is not aBaseInstalledAgent, including classes that are not agents:seams.get("collections:OrderedDict")succeeds. Checkissubclass(cls, BaseAgent)and refuse otherwise, so the error comes from OpenEnv with a clear message.- Docs (
envs/harbor_env/README.md:575and the synced page): inserveand from a trainer, the server process imports the class, so the module has to be installed where the server runs (in the image, for a hosted env). Please say so. A trainer that passes a module only it has getscannot import harness. - Tests:
test_a_host_side_agent_of_your_own_gets_the_proxy_as_kwargsdoes not assertseam.host_side, which is what gives a custom agent the loopback URL. A two-line test ofHarborService.local_url(empty when mounted, loopback otherwise) would cover the Space case.
Design questions I checked
--harness module:Classadds no registry or plugin layer.customimports the class the caller names, classifies it and returns aSeam. Nothing is registered andSEAMSdoes not change. Main already documented the flag (therollout --harnesshelp, theHarborEnv.run_rolloutdocstring and the Harbor docs) and then raisedno seam, so this makes documented behavior work.- Loopback exposes nothing new. The capture proxy already binds
0.0.0.0on main, and this PR opens no port. Only host-side harness code in the server process receives the127.0.0.1URL. The proxy checks the same session key on that URL, and the admin routes check the admin key, never the caller's address. On a Space (mounted),local_urlis empty and the public URL is kept. Sandboxed agents, and callers that do not passlocal_intercept_url, behave as before. - The dual API boundary holds. A host-side agent gets the capture proxy's
/v1URL and its session id, the same data plane every harness gets. It never sees the env server's WebSocket or MCP endpoints, and the loopback URL points at the capture port, not the env server's port.
What I verified
- On this branch,
PYTHONPATH=src:envs pytest tests/envs/ -k harborgives 618 passed and 1 failed. The failure istest_start_refuses_a_port_held_by_a_non_capture_listener, which fails on main on macOS too. - With main merged in and the conflict resolved as above: 648 passed, the same single failure.
- usort, ruff format, ruff check and
scripts/sync_env_docs.py --checkpass.
A subclass of Harbor's Pi named by import path
import sys, tempfile, types
from pathlib import Path
from harbor.agents.installed.pi import Pi
from openenv.harbor import seams
class MyPi(Pi): # a user's own pi variant, subclassing Harbor's Pi
pass
mod = types.ModuleType("my_agents")
mod.MyPi = MyPi
sys.modules["my_agents"] = mod
seam = seams.get("my_agents:MyPi")
model, kwargs, _, _ = seam.resolve(base_url="http://127.0.0.1:8100", session="s", model="m")
print(seam.notes, kwargs)
MyPi(logs_dir=Path(tempfile.mkdtemp()), model_name=model, **kwargs)my_agents:MyPi is a pi subclass, wired like pi. {'intercept_config': {'base_url': 'http://127.0.0.1:8100', 'api_key': 's', 'model': 'm'}}
ValueError: Invalid kwargs for agent 'pi':
Unknown option 'intercept_config'.
See harbor agent schema pi for available options.
Resolves the conflict in tests/envs/test_harbor_rollout_contract.py, where both sides added a test at the same place. Both tests are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Harbor is installed only on Python 3.12+. On 3.11, `importorskip("harbor")`
still passes in the full run: a script test puts `examples/` on sys.path, so
`examples/harbor` imports as a namespace package, and the next
`import harbor.agents...` fails. This is the `test (3.11)` CI failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_AGENTS - serve: a client's `run_rollout(harness="pkg:Cls")` made the server import `pkg`, running its top-level code. Only the import paths the operator lists with `openenv harbor serve --allow-harness pkg:Cls` (OPENENV_HARBOR_ALLOW_HARNESS) are accepted now. Others are refused before any import. `rollout` and in-process trainers are unchanged, and the UI already offers only known harnesses. - custom(): pi, gemini-cli, openhands, swe-agent, openclaw, kimi-cli and cline-cli run through an OpenEnv `install_fixes:Intercept*` class. A subclass of Harbor's own class skipped it: `MyPi(Pi)` failed at construction on `intercept_config`. It is refused now, and the error names the class to subclass. - capabilities: drop HOST_SIDE_AGENTS. `list_harnesses` only walks SEAMS, and the set's members there now set `Seam.host_side`. - environment: read `service.local_url` directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
I pushed three commits on top of this branch. The commit messages have the details.
CI's test command passes on 3.11 and 3.12 with these changes (run in an HF Sandbox). One design question before this merges. Harbor 0.25.0, the version in our lock, already has a connection override: Could
Could the session key travel in |
…; OpenEnv pin overrides openenv/OpenEnv#1573 now serves a module:Class harness only when the operator allows it (OPENENV_HARBOR_ALLOW_HARNESS). run.py sets it for fineproof_agent.agent:FineProofAgent; older OpenEnv ignores it. hf_job.py passes OPENENV_REPO / OPENENV_COMMIT through, so a smoke can try a new OpenEnv head before the pin in jobs/install.sh moves.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit bfe8b9d. Configure here.
|
|
||
| def get(name: str, *, profile: str | None = None) -> Seam: | ||
| if name not in SEAMS and ":" in name and profile is None: | ||
| return custom(name) |
There was a problem hiding this comment.
UI bypasses harness allow-list
High Severity
seams.get now imports any module:Class harness, but OPENENV_HARBOR_ALLOW_HARNESS is enforced only in HarborEnvironment._run_rollout. The Harbor UI calls run_rollout directly, so a client-supplied import path still loads that module on the server. Before this change unknown names failed without importing.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit bfe8b9d. Configure here.
| sandbox=sandbox, | ||
| registry=service.capture.registry, | ||
| intercept_url=service.public_url, | ||
| local_intercept_url=service.local_url, |
There was a problem hiding this comment.
UI omits host-side loopback URL
Medium Severity
local_intercept_url is passed from the CLI runner and the MCP serve path, but the Harbor UI still calls run_rollout with only service.public_url. Host-side agents started from the UI, including terminus-2, keep going through the public forward, so long completions can still be cut with a 502 and retried as extra roots.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit bfe8b9d. Configure here.
litellm, which Harbor's agents import, runs load_dotenv() when first imported and picks up any .env above the checkout, so test_info_constructs_with_dummy_capture_wiring failed locally (not in CI) on keys the preflight never set. Import it before taking the snapshot.
main + #1578 merged in, with module:Class harnesses allowed per server (run.py sets OPENENV_HARBOR_ALLOW_HARNESS). fp-smoke-oe1573 passed on it: two updates with a gradient, a crash and resume from checkpoint 1, and the reload eval graded 4/4.


Summary
Two fixes for running your own Harbor agent through OpenEnv.
--harness module:Classnow works. TherolloutCLI help and the Harbor docs offer it, butseams.getraisedno seam for '<path>'for any import path.BaseAgent. Through a gradio share link, a long completion was cut with a 502 while the model was still generating. The agent retried, and the capture recorded the same prompt again as a new root.I found both while serving an LLM-judged proof environment (QED-Nano's training problems as Harbor tasks) with a custom host-side agent, through
openenv harbor serveon Daytona and HF Inference Providers.Type of Change
What was wrong
module:Class.rollout.build_trial_configalready passesseam.import_pathto Harbor, butseams.getonly knows the names inSEAMS:Loopback.
runner.run_batchandHarborEnvironment._run_rolloutpassintercept_url=public_urlto every harness. Host-side agents make their model calls from the server process, yet they too went out through the forward and back. The docs already say terminus-2 "reaches the proxy on localhost". The capture proxy calls the upstream without streaming, so a long completion holds the agent's request open for its whole duration, and the gradio share link answered502 Bad Gatewayafter about 73 seconds. The same task with a 2-call agent:FAILED (ApiInternalServerError):502 Bad Gateway for url 'https://….gradio.live/v1/chat/completions'after 73 sFix
seams.custom(import_path)is whatseams.getreturns for an unknown name containing:. It resolves by what the class is:cls.name()inSEAMS) is wired like that agent;BaseAgent) getsapi_base(the proxy's/v1URL) andapi_key(the session id) as constructor kwargs, terminus-2's convention. Nothing goes into the sandbox's or the server's environment, so nothing can reach the verifier;The status is
untestedin every case.Seam.host_sideis set for terminus-2, computer-1 and custom host-side agents.capabilitiesnow uses it as well asHOST_SIDE_AGENTS.run_rollout(local_intercept_url=...)is used only when the seam ishost_side. TherolloutCLI passeshttp://127.0.0.1:<port>.servepassesHarborService.local_url, which is empty when the proxy is mounted on a Space, so a Space keeps its public URL. Callers that don't pass it, such as TRL's runner, behave exactly as before.Docs: a "Your own agent" section explains what each kind of class receives.
Alignment Checklist
.claude/docs/PRINCIPLES.mdand this PR aligns with our principles.claude/docs/INVARIANTS.mdand no invariants are violated. The agent gets only the capture session id, never a credential, and no simulation control.usort check,ruff format --checkandruff checkon the changed files, plus the Harbor testsRFC Status
Test Plan
New unit tests:
test_harbor_seams.py: a host-side agent getsapi_base/api_keyand no env; an opencode subclass is wired like opencode; an unknown installed agent is refused; a bad import path says why.test_harbor_rollout_contract.py: terminus-2 takes the loopback URL and opencode keeps the public one;host_sideis set on the right seams.test_harbor_seams.py: the step-limit warning appears once per harness across repeated rollouts.Harbor test suite:
PYTHONPATH=src:envs pytest tests/envs/test_harbor*.pygives 611 passed and 1 failed. The failure,test_start_refuses_a_port_held_by_a_non_capture_listener, also fails on unmodifiedmainon macOS, where binding0.0.0.0succeeds while127.0.0.1on the same port is held.End to end:
Setup:
openenv harbor serveon Daytona, withQwen/Qwen3.5-9B:deepinfrathrough HF Inference Providers (eval tier; the:providersuffix needs Harbor: make endpoint validation work with hosted providers (probe retry/eval fallback, router provider suffixes) #1565). A host-side custom agent (--harness qed_proof_agent.agent:QedProofAgent) ran 64 tasks, 12 at a time, graded by an LLM judge in Harbor's separate verifier environment.Results:
openenv harbor serveharbor runThe paired difference is −0.049 per task with a standard error of 0.046. 13 tasks were better through OpenEnv, 15 through
harbor run, and 36 were identical.Every rollout made exactly 2 model calls on both sides: 128 POSTs to the proxy, no retries, no 502s, no empty proofs.
terminus-2 through
serve(8 of the same tasks): 8/8 graded, mean 0.714, one root per rollout, no 502s or retries. Itsempty_completionwarnings come from the provider: deepinfra currently ignoresenable_thinking: falsefor Qwen3.5-9B (it honoursreasoning_effort: "none", which terminus-2 doesn't send), so some turns spend the whole budget reasoning. That is unrelated to this PR.Claude Code Review
N/A
🤖 Generated with Claude Code
Note
Medium Risk
Harbor rollout and serve paths change URL selection and allow dynamic imports of user-specified modules; misconfiguration or overly broad
--allow-harnesscould widen server attack surface, though custom paths are gated by an explicit allowlist.Overview
Enables custom Harbor agents via
--harness module:Classby addingseams.custom()wiring (known subclasses inherit seam config, host-sideBaseAgentgetsapi_base/api_key, sandbox-installed agents are rejected) andopenenv harbor serve --allow-harnessso servers only import operator-approved import paths.Host-side agents (including custom ones) now use the capture proxy’s loopback URL when
local_intercept_urlis set, avoiding long-completion failures through public forwards;Seam.host_sidereplaces the oldHOST_SIDE_AGENTSset, and step-limit warnings log once per harness.openenv harbor info --harnesspreflights construction with dummy capture credentials (JSONharnessblock, exit 1 on failure). Docs and tutorials describe the custom-agent contract and the new flags..claude/hooks/lint.shwas rewritten to mirror CI (usort+ruffonsrc/andtests/) using a temp git tree so checks never overwrite uncommitted work; tests were extended accordingly.Reviewed by Cursor Bugbot for commit a0068bf. Bugbot is set up for automated code reviews on this repo. Configure here.