Run the coder in the Gym: the Harbor adapter and its first real trial

e23a34eb11b1 · AtlantisPleb · · parent b9f387483358

Run the coder in the Gym: the Harbor adapter and its first real trial

bench/adapters/openagents_coder.py is the Harbor installed-agent
adapter for OpenAgentsInc/openagents#35: it provisions Node 22 in the
task container, installs the working tree's CLI from a pnpm-packed
tarball, runs `openagents coder --plain` on the thread lane against an
OpenAgents server (host.docker.internal for the dev forge), and
collects the coder's own ATIF export as the trial trajectory.
bench/post_gym_run.py posts a graded job to POST /api/v3/gym/runs —
only when the verifier actually ran.

The first live trial (terminal-bench@2.0 fix-git) drove the whole
stack: the coder found the lost commit in the reflog, cherry-picked it,
resolved the conflict, and exported a 7-step trajectory. It also found
the one broken link: the grant names the inference proxy with the
server's own idea of its host, and inside a container the server's
localhost is the wrong machine — resolveProxyUrl now resolves the
grant's path against the origin the client authenticated with.

Recorded constraint: on Apple Silicon the amd64 task images run the
agent fine under qemu, but the verifier's uv/pytest segfaults; scored
runs need Rosetta, a cloud environment, or amd64 hardware.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added bench/.gitignore
  • added bench/README.md
  • added bench/adapters/__init__.py
  • added bench/adapters/openagents_coder.py
  • added bench/post_gym_run.py
  • modified packages/openagents-cli/src/coder-thread.ts
  • modified packages/openagents-cli/test/coder-thread.test.ts

Diff

7 files changed, +351 -3

bench/.gitignore added +2

@@ -0,0 +1,2 @@

1
*.tgz
2
__pycache__/
bench/README.md added +36

@@ -0,0 +1,36 @@

1
# Bench: the Gym's harness lane
2
3
`bench/adapters/openagents_coder.py` is the Harbor installed-agent adapter
4
for `openagents coder` (OpenAgentsInc/openagents#35). It runs the working
5
tree's CLI, not the published npm version, so pack a tarball first:
6
7
```sh
8
cd packages/openagents-cli
9
pnpm build
10
pnpm pack --pack-destination ../../bench
11
```
12
13
Use `pnpm pack`, not `npm pack`: the manifest carries pnpm `catalog:`
14
versions that only `pnpm pack` rewrites into versions npm can install.
15
16
Run against the dev forge on the host (`PHX_LISTEN_ALL=true mix
17
phx.server` in openagents.com, plus a `chat:account` token):
18
19
```sh
20
PYTHONPATH=bench OPENAGENTS_TOKEN=... harbor run \
21
  --dataset terminal-bench@2.0 \
22
  --agent-import-path adapters.openagents_coder:OpenAgentsCoder \
23
  -m openai/gpt-5.6-luna \
24
  -i fix-git --n-concurrent 1 --jobs-dir /tmp/gym-jobs
25
```
26
27
`bench/post_gym_run.py` posts a completed job's graded result to
28
`POST /api/v3/gym/runs`. Post only runs whose verifier actually ran: a
29
score is a claim, and a crashed grader is not a grade.
30
31
Known constraint on Apple Silicon: Terminal-Bench task images are amd64,
32
and under qemu emulation the verifier's `uv`/`pytest` segfaults after the
33
agent phase completes. Enable Docker Desktop's Rosetta emulation
34
(Settings → General → "Use Rosetta for x86_64/amd64 emulation"), use a
35
cloud environment (`--env daytona` and peers), or run on amd64 hardware
36
for scored runs. The agent phase itself runs fine under qemu.
bench/adapters/__init__.py added

No content change.

bench/adapters/openagents_coder.py added +143

@@ -0,0 +1,143 @@

1
"""Harbor installed-agent adapter for `openagents coder`.
2
3
OpenAgentsInc/openagents#35, first slice. Loads out-of-tree by import path:
4
5
    PYTHONPATH=bench harbor run \
6
      --dataset terminal-bench@2.0 \
7
      --agent adapters.openagents_coder:OpenAgentsCoder \
8
      --model openai/gpt-5.6-luna \
9
      --n-concurrent 1
10
11
The coder runs headless (`--plain`) on its thread lane against an
12
OpenAgents server: `OPENAGENTS_TOKEN` carries authority and
13
`OPENAGENTS_CODER_API_URL` names the server (default
14
`http://host.docker.internal:4000`, the dev forge on the container's host,
15
which needs `PHX_LISTEN_ALL=true`). The server holds the provider keys; the
16
container holds only the scoped OpenAgents token. Harbor's `--model`
17
provider prefix is dropped: `openai/gpt-5.6-luna` becomes catalog id
18
`gpt-5.6-luna` on `POST /api/v3/threads`.
19
20
The CLI installs from a tarball packed beside this file (`npm pack` in
21
`packages/openagents-cli`), so the run measures the working tree, not the
22
published npm version. The trajectory is the coder's own ATIF export: the
23
run pipes `/export` after the instruction and copies the newest export to
24
the trial's `trajectory.json`.
25
"""
26
27
import os
28
import shlex
29
from pathlib import Path
30
from typing import override
31
32
from harbor.agents.installed.base import BaseInstalledAgent, with_prompt_template
33
from harbor.environments.base import BaseEnvironment
34
from harbor.models.agent.context import AgentContext
35
36
_TARBALL = Path(__file__).resolve().parent.parent / "openagentsinc-cli-0.3.5.tgz"
37
_REMOTE_TARBALL = "/installed-agent/openagents-cli.tgz"
38
_DEFAULT_API_URL = "http://host.docker.internal:4000"
39
_EXPORT_DIR = "$HOME/.openagents/exports"
40
41
42
class OpenAgentsCoder(BaseInstalledAgent):
43
    SUPPORTS_ATIF = True
44
45
    @staticmethod
46
    @override
47
    def name() -> str:
48
        return "openagents-coder"
49
50
    @override
51
    def get_version_command(self) -> str | None:
52
        return "openagents --version"
53
54
    @property
55
    def _api_url(self) -> str:
56
        return os.environ.get("OPENAGENTS_CODER_API_URL", _DEFAULT_API_URL)
57
58
    @property
59
    def _token(self) -> str:
60
        token = os.environ.get("OPENAGENTS_TOKEN", "")
61
        if not token:
62
            raise ValueError(
63
                "OPENAGENTS_TOKEN is not set. The coder's thread lane needs a "
64
                "chat:account token for the server at OPENAGENTS_CODER_API_URL."
65
            )
66
        return token
67
68
    @property
69
    def _catalog_model(self) -> str | None:
70
        if not self.model_name:
71
            return None
72
        # Harbor spells models provider/name; the catalog id is the name.
73
        return self.model_name.split("/", 1)[-1]
74
75
    @override
76
    async def install(self, environment: BaseEnvironment) -> None:
77
        if not _TARBALL.exists():
78
            raise FileNotFoundError(
79
                f"CLI tarball missing at {_TARBALL}. Run `pnpm build && npm pack "
80
                "--pack-destination ../../bench` in packages/openagents-cli first."
81
            )
82
83
        await self.ensure_system_dependencies(environment, ("curl", "bash"))
84
85
        # Node >= 20 (the CLI's engines floor). Task images rarely carry it,
86
        # so provision Node 22 through NodeSource on apt images and fail
87
        # loudly elsewhere rather than running on a node that cannot.
88
        await self.exec_as_root(
89
            environment,
90
            command=(
91
                "set -euo pipefail; "
92
                "if command -v node >/dev/null 2>&1 && "
93
                '[ "$(node -e \'process.stdout.write(String(process.versions.node.split(".")[0]))\')" -ge 20 ]; '
94
                "then echo 'node present'; "
95
                "elif command -v apt-get >/dev/null 2>&1; then "
96
                "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && "
97
                "apt-get install -y nodejs; "
98
                "else echo 'no node >= 20 and no apt-get' >&2; exit 1; fi; "
99
                "node --version"
100
            ),
101
        )
102
103
        await environment.upload_file(_TARBALL, _REMOTE_TARBALL)
104
        await self.exec_as_root(
105
            environment,
106
            command=f"npm install -g {shlex.quote(_REMOTE_TARBALL)} && openagents --version",
107
        )
108
109
    @override
110
    @with_prompt_template
111
    async def run(
112
        self,
113
        instruction: str,
114
        environment: BaseEnvironment,
115
        context: AgentContext,
116
    ) -> None:
117
        model_flag = ""
118
        if self._catalog_model:
119
            model_flag = f"--model {shlex.quote(self._catalog_model)} "
120
121
        # The instruction goes down stdin, then /export so the session writes
122
        # its ATIF trajectory before exiting on end-of-file. stdin carries the
123
        # instruction verbatim; nothing here re-quotes its content.
124
        command = (
125
            "set -uo pipefail; "
126
            f"printf '%s\\n/export\\n' {shlex.quote(instruction)} | "
127
            "openagents coder --plain "
128
            f"--api-url {shlex.quote(self._api_url)} "
129
            f"{model_flag}"
130
            f"2>&1 | tee {shlex.quote(str(self.environment_logs_dir))}/coder.txt; "
131
            "status=$?; "
132
            f"latest=$(ls -t {_EXPORT_DIR}/*.json 2>/dev/null | head -1 || true); "
133
            'if [ -n "$latest" ]; then '
134
            f'cp "$latest" {shlex.quote(str(self.environment_logs_dir))}/trajectory.json; '
135
            "fi; "
136
            "exit $status"
137
        )
138
139
        await self.exec_as_agent(
140
            environment,
141
            command=command,
142
            env={"OPENAGENTS_TOKEN": self._token},
143
        )
bench/post_gym_run.py added +137

@@ -0,0 +1,137 @@

1
"""Post one Harbor job's graded result to the Gym.
2
3
Usage:
4
5
    OPENAGENTS_TOKEN=... python3 bench/post_gym_run.py \
6
      <job-dir> [--api-url http://localhost:4000] [--lane proxy]
7
8
Reads the job's `result.json` and each trial's ATIF `trajectory.json` (for
9
token totals), derives the recipe digest from the job's `config.json` plus
10
the agent tarball digest, and POSTs to `/api/v3/gym/runs`. Idempotent: the
11
server replays a repeated digest rather than duplicating the row.
12
"""
13
14
import argparse
15
import hashlib
16
import json
17
import os
18
import sys
19
import urllib.request
20
from pathlib import Path
21
22
23
def sha256_file(path: Path) -> str:
24
    digest = hashlib.sha256()
25
    with open(path, "rb") as handle:
26
        for chunk in iter(lambda: handle.read(65536), b""):
27
            digest.update(chunk)
28
    return digest.hexdigest()
29
30
31
def main() -> int:
32
    parser = argparse.ArgumentParser()
33
    parser.add_argument("job_dir", type=Path)
34
    parser.add_argument("--api-url", default="http://localhost:4000")
35
    parser.add_argument("--lane", default="proxy")
36
    parser.add_argument("--suite", default="terminal-bench@2.0")
37
    arguments = parser.parse_args()
38
39
    token = os.environ.get("OPENAGENTS_TOKEN", "")
40
    if not token:
41
        print("OPENAGENTS_TOKEN is not set", file=sys.stderr)
42
        return 2
43
44
    job_dir = arguments.job_dir
45
    result = json.loads((job_dir / "result.json").read_text())
46
    config = json.loads((job_dir / "config.json").read_text())
47
48
    trials = []
49
    input_tokens = 0
50
    output_tokens = 0
51
    agent_version = None
52
    model = None
53
    for trial_dir in sorted(job_dir.iterdir()):
54
        trial_result = trial_dir / "result.json"
55
        if not trial_dir.is_dir() or not trial_result.exists():
56
            continue
57
        trial = json.loads(trial_result.read_text())
58
        trials.append(trial)
59
        trajectory_path = trial_dir / "agent" / "trajectory.json"
60
        if trajectory_path.exists():
61
            trajectory = json.loads(trajectory_path.read_text())
62
            metrics = trajectory.get("final_metrics") or {}
63
            input_tokens += metrics.get("total_prompt_tokens") or 0
64
            output_tokens += metrics.get("total_completion_tokens") or 0
65
            agent = trajectory.get("agent") or {}
66
            agent_version = agent.get("version") or agent_version
67
            model = agent.get("model_name") or model
68
69
    def passed(trial: dict) -> bool:
70
        verifier = trial.get("verifier_result") or {}
71
        rewards = verifier.get("rewards") or trial.get("rewards") or {}
72
        reward = rewards.get("reward")
73
        if reward is None and isinstance(rewards, dict) and rewards:
74
            reward = next(iter(rewards.values()))
75
        return bool(reward) and float(reward) > 0
76
77
    tasks_total = len(trials)
78
    tasks_passed = sum(1 for trial in trials if passed(trial))
79
80
    recipe_source = json.dumps(
81
        {
82
            "config": config,
83
            "suite": arguments.suite,
84
            "lane": arguments.lane,
85
            "job_id": result.get("id"),
86
        },
87
        sort_keys=True,
88
    ).encode()
89
    recipe_digest = "harbor:" + hashlib.sha256(recipe_source).hexdigest()
90
91
    payload = {
92
        "suite": arguments.suite,
93
        "agent": "openagents-coder",
94
        "agent_version": agent_version,
95
        "model": model or "unknown",
96
        "lane": arguments.lane,
97
        "tasks_total": tasks_total,
98
        "tasks_passed": tasks_passed,
99
        "input_tokens": input_tokens or None,
100
        "output_tokens": output_tokens or None,
101
        "recipe_digest": recipe_digest,
102
        "report": {
103
            "job_id": result.get("id"),
104
            "trials": [
105
                {
106
                    "task": (trial.get("task_name") or trial.get("trial_name") or "?"),
107
                    "passed": passed(trial),
108
                    "exception": (trial.get("exception_info") or {}).get("exception_type")
109
                    if trial.get("exception_info")
110
                    else None,
111
                }
112
                for trial in trials
113
            ],
114
        },
115
    }
116
117
    request = urllib.request.Request(
118
        f"{arguments.api_url}/api/v3/gym/runs",
119
        data=json.dumps(payload).encode(),
120
        headers={
121
            "Content-Type": "application/json",
122
            "Authorization": f"Bearer {token}",
123
        },
124
        method="POST",
125
    )
126
    with urllib.request.urlopen(request) as response:
127
        body = json.loads(response.read())
128
        run = body.get("run") or {}
129
        print(
130
            f"{response.status}: run {run.get('id')} score={run.get('score')} "
131
            f"replayed={body.get('replayed')}"
132
        )
133
    return 0
134
135
136
if __name__ == "__main__":
137
    raise SystemExit(main())
packages/openagents-cli/src/coder-thread.ts modified +18 -2

@@ -225,7 +225,7 @@ export async function openThread(options: ThreadOptions): Promise<ThreadReplySou

225 225
    accountToken: options.token,
226 226
    threadId: id,
227 227
    grantToken: Redacted.make(token),
228
    proxyUrl: url,
228
    proxyUrl: resolveProxyUrl(url, options.origin),
229 229
    model,
230 230
    budget: budgetOf(record(grant["limits"]), record(grant["limits"])),
231 231
  });

@@ -321,7 +321,7 @@ export async function remintThread(options: ResumeGrantOptions): Promise<ThreadR

321 321
    accountToken: options.token,
322 322
    threadId: string(record(body["thread"])["id"]) ?? options.threadId,
323 323
    grantToken: Redacted.make(token),
324
    proxyUrl: url,
324
    proxyUrl: resolveProxyUrl(url, options.origin),
325 325
    model,
326 326
    budget: budgetOf(record(grant["remaining"]), record(grant["limits"])),
327 327
  });

@@ -1087,7 +1087,23 @@ function dollars(microusd: number): string {

1087 1087
  return `$${(microusd / 1_000_000).toFixed(2)}`;
1088 1088
}
1089 1089
1090
/**
1091
 * The grant names the proxy with the server's own idea of its host, and
1092
 * inside a container the server's "localhost" is the wrong machine. The
1093
 * path is the server's contract; the origin is the client's — the same one
1094
 * it authenticated against — so the URL resolves against it.
1095
 */
1096
export const resolveProxyUrl = (grantUrl: string, origin: string): string => {
1097
  try {
1098
    const named = new URL(grantUrl);
1099
    return new URL(named.pathname, origin).toString();
1100
  } catch {
1101
    return grantUrl;
1102
  }
1103
};
1104
1090 1105
function record(value: unknown): Record<string, unknown> {
1106
1091 1107
  return typeof value === "object" && value !== null && !Array.isArray(value)
1092 1108
    ? (value as Record<string, unknown>)
1093 1109
    : {};
packages/openagents-cli/test/coder-thread.test.ts modified +15 -1

@@ -1,7 +1,7 @@

1 1
import { afterEach, describe, expect, it, vi } from "vitest";
2 2
3 3
import type { ReplyChunk } from "../src/coder-session.js";
4
import { openThread, ThreadUnavailable, type ThreadReplySource } from "../src/coder-thread.js";
4
import { openThread, resolveProxyUrl, ThreadUnavailable, type ThreadReplySource } from "../src/coder-thread.js";
5 5
import { ThreadTranscriptWriter } from "../src/coder-transcript.js";
6 6
7 7
const ORIGIN = "https://openagents.test";

@@ -1020,3 +1020,17 @@ describe("the session's anchor on the thread lane", () => {

1020 1020
    expect(shown).not.toContain("composed by the server");
1021 1021
  });
1022 1022
});
1023
1024
describe("resolveProxyUrl", () => {
1025
  it("resolves the grant's path against the client's origin", () => {
1026
    expect(
1027
      resolveProxyUrl("http://localhost:4000/api/inference/proxy", "http://host.docker.internal:4000"),
1028
    ).toBe("http://host.docker.internal:4000/api/inference/proxy");
1029
  });
1030
1031
  it("keeps a same-origin grant URL intact", () => {
1032
    expect(
1033
      resolveProxyUrl("https://openagents.com/api/inference/proxy", "https://openagents.com"),
1034
    ).toBe("https://openagents.com/api/inference/proxy");
1035
  });
1036
});

This page updates live while a promote is in flight · changelog