Skip to content

Harness runtime replacement - #652

Merged
burtenshaw merged 20 commits into
mainfrom
codex/pr-471-replacement
May 11, 2026
Merged

burtenshaw merged 20 commits into
mainfrom
codex/pr-471-replacement

Conversation

@burtenshaw

Copy link
Copy Markdown
Collaborator

This PR replaces #471 and references that blocked review thread.

It carries forward the session harness runtime, collection pipeline, env adapters, BrowserGym examples, and the latest review fixes from #471. The original PR currently has green checks but is blocked by a stale changes-requested review state.

Local verification before opening this replacement:

  • PYTHONPATH=src:envs:examples uv run pytest tests/core/test_harness_runtime.py tests/core/test_harness_collect.py tests/envs/test_browsergym_harness.py tests/scripts/test_browsergym_harness_eval_examples.py tests/test_cli/test_collect.py tests/envs/test_openspiel_collect.py
  • uv run usort format src/ tests/ && uv run ruff format src/ tests/ && uv run ruff check src/ tests/
  • Targeted ruff checks for modified env/example files

Original PR: #471

burtenshaw and others added 20 commits March 28, 2026 14:45
* refactor(harness): move harness.py into harness/ package

Preserves the public API (`from openenv.core.harness import X` keeps
working) while making room for a ``collect`` submodule that layers
synthetic-dataset generation on top of the runtime primitives from
RFC 005 / PR #471. Relative imports in the module are adjusted from
``.client_types`` → ``..client_types``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(harness): add rollout collection module

Introduces ``openenv.core.harness.collect``, a thin layer on top of the
harness runtime from RFC 005 / PR #471 for generating synthetic datasets
from deployed environments:

- ``EpisodeRecord`` — serializable view of one rollout + its verification.
  Uses ``_resolve_env_reward`` so any mismatch between a tool-result
  reward and ``verify.env_reward`` raises, preserving the "rewards in
  env" invariant end-to-end.
- ``RolloutSerializer`` — append-only JSONL writer with a
  ``metadata.json`` sidecar. ``collected_episode_ids()`` enables resume.
- ``CollectRunner.run()`` — orchestrates N episodes: session.create →
  harness.run_white_box → verify → optional rubric filter → serialize.
  Returns a ``CollectResult`` with aggregate stats.
- ``build_model_step(llm_client)`` — adapts any ``LLMClient`` (OpenAI,
  Anthropic, and any OpenAI-compatible endpoint such as vLLM, TGI,
  Ollama, HF Inference, Together, Groq, Fireworks) into a ``ModelStep``
  for the white-box harness.
- ``push_to_hf_hub(output_dir, repo_id)`` — uploads ``results.jsonl``,
  ``metadata.json``, and an auto-generated dataset card to the Hub. The
  card's YAML front-matter tells the HF Dataset Viewer to treat the
  JSONL as ``split=train`` instead of trying to merge it with the
  metadata sidecar.

37 new tests (EpisodeRecord, RolloutSerializer, CollectRunner,
build_model_step, push_to_hf_hub, build_dataset_readme).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(envs/openspiel): add TTT harness session factory

``envs/openspiel_env/harness.py`` exposes an ``OpenSpielSessionFactory``
that wraps ``OpenSpielEnv`` (via ``StepEnvSessionAdapter``) and lets a
harness drive any OpenSpiel game — tic_tac_toe initially — through a
single ``play_move(action_id)`` MCP-style tool.

- Initial prompt renders legal actions plus a human-readable board for
  TTT so the LLM can reason about positions without needing to decode
  the 27-float info_state tensor.
- ``render_tic_tac_toe_board`` decodes the OpenSpiel TTT info_state
  (empty/X/O planes) into a 3x3 grid. Empty cells show their action_id
  so the prompt doubles as an action legend.
- Follows the pattern established by ``envs/browsergym_env/harness.py``
  in PR #471 — no changes to the underlying env, client, or protocol.

Tests cover the board rendering, tool dispatch, reset-kwargs forwarding,
and an end-to-end collect run against a scripted client exercising the
full ``MCPHarnessAdapter`` → ``CollectRunner`` → JSONL pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cli): add openenv collect command

Wraps ``CollectRunner`` + ``OpenSpielSessionFactory`` into a single
command so a deployed OpenEnv environment can produce a dataset with
one call:

    openenv collect openspiel:tic_tac_toe \\
        --base-url https://user-space.hf.space \\
        --output-dir /tmp/ttt-sft-v1 \\
        -n 200 --provider openai --model gpt-5-mini \\
        --push-to-hub user/ttt-sft-v1

Flags in short:
- ``--provider scripted | openai | anthropic`` — teacher selection.
  Scripted picks the first legal action and requires no API key,
  making ``openenv collect`` smoke-testable out of the box.
- ``--llm-endpoint / --llm-port`` — point at any OpenAI-compatible
  endpoint (vLLM, TGI, Ollama, HF Inference, Together, Groq, ...).
- ``--push-to-hub REPO`` — upload the directory as a dataset after
  collect; ``--private``/``--commit-message`` available.
- ``--resume / --no-resume`` — ``CollectRunner`` skips ``episode_ids``
  already serialized on disk.
- ``--keep-losses`` — by default filters rollouts with reward < 0 so
  the output is SFT-ready.

Env dispatch is via ``"family:variant"`` strings (e.g.
``openspiel:tic_tac_toe``). Unknown families raise a typer
``BadParameter`` with the supported set.

7 new CLI tests mocking ``CollectRunner`` + ``OpenSpielEnv`` so the
dispatch logic (scripted vs hosted provider, push-to-hub, filter
defaults, bad ``--env``) is exercised without network.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(harness): add README and runnable examples

- ``src/openenv/core/harness/README.md`` documents the collect module:
  quick-start (CLI + programmatic), output schema, and design notes
  covering the "thin envs" / "rewards in env" / "provider-agnostic
  teacher" choices.
- ``examples/ttt_collect_demo.py`` — scripted teacher against either a
  built-in fake OpenSpiel client or a real deployed server (``--base-url``).
  Runs with zero setup for pipeline smoke-testing.
- ``examples/ttt_collect_with_llm.py`` — provider-agnostic example that
  picks between hosted OpenAI/Anthropic (via ``--provider``) and any
  OpenAI-compatible self-hosted endpoint (via ``--llm-endpoint``), using
  the same collect pipeline unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address collect feedback

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: burtenshaw <ben.burtenshaw@gmail.com>
* feat(opencode_env): OpenCode harness primitive (M1.1-M1.5)

Harness primitive for running the OpenCode CLI agent in a sandbox against
any OpenAI-compatible endpoint. Stacked on the PR #471 harness-runtime
branch; implements the ResourceSession / ResourceSessionFactory contracts.

Modes:
- black_box: opencode talks directly to the configured endpoint. Verified
  end-to-end against real OpenAI (gpt-4o-mini) inside an E2B sandbox; the
  agent produces a correct fizzbuzz and the verifier scores reward=1.0.
- transparent_proxy: a per-session FastAPI proxy runs inside the sandbox,
  forwards /v1/chat/completions to the configured upstream with
  logprobs=true injected, captures per-turn (messages, completion tokens,
  logprobs, finish reason) to a JSON-lines trace, and strips logprobs
  from what opencode sees. Handles both unary and streaming (SSE). Caps
  max_tokens and auto-translates to max_completion_tokens for gpt-5.x/o*
  models.

Components:
- config.py: OpenCodeConfig (generic provider/base_url/api_key/model
  fields supporting OpenAI, Anthropic, and OpenAI-compatible endpoints;
  proxy tuning knobs; sandbox_home override for non-E2B backends).
- opencode_runtime.py: pure builders for opencode.json, install/run
  shell commands, and env vars.
- task.py: OpenCodeTask pydantic model (instruction + optional
  setup_shell + file uploads + opaque metadata); coerces from str/dict.
- sandbox/base.py: SandboxBackend / SandboxHandle / BgJob Protocols.
- sandbox/e2b.py: E2BSandboxBackend with a threaded BgJob wrapper that
  provides timeout support over E2B's CommandHandle.
- harness.py: OpenCodeSession + OpenCodeSessionFactory; in Mode B,
  installs proxy deps, uploads the proxy module, starts it as a bg job
  on localhost:7000, rewrites opencode.json to point at the proxy, and
  forces @ai-sdk/openai-compatible so routing goes through
  /v1/chat/completions.
- interception.py: InterceptionProxy (FastAPI, unary + streaming),
  per-turn trace capture, CLI entry point for sandbox-side execution.

Tests: 37 unit tests plus 3 live integration tests gated on
E2B_API_KEY / OPENAI_API_KEY.

Known limitation: OpenAI's gpt-5.x chat family refuses logprob requests,
so Mode B live validation against OpenAI requires gpt-4o-mini or older.
vLLM (the intended training-time upstream) returns logprobs natively.

* feat(opencode_env): live vLLM validation + post-rollout summary

End-to-end Mode B now verified against a live vLLM tunnel:
- Qwen/Qwen3.5-4B on 2x A100 (tp=2, 16K ctx) via `vllm serve`
- Cloudflared tunnel exposes the endpoint publicly
- E2B sandbox runs opencode against the tunnel through the in-sandbox proxy
- Proxy captures 4 turns, 36 tokens with real per-token logprobs
- Agent produces correct fizzbuzz.py, verifier scores reward=1.0
- 55.86s total (sandbox + install + 4 LLM turns + verify)

Changes:
- config: add proxy_disable_thinking flag; plumbed through harness -> proxy
  via --disable-thinking CLI arg.
- interception: inject chat_template_kwargs.enable_thinking=false on
  forwarded requests when enabled (Qwen3/Qwen3.5 tokenizer hook); also
  split the request handler into unary + streaming paths (SSE) and
  auto-translate max_tokens -> max_completion_tokens for gpt-5.x/o*
  models.
- sandbox/e2b: E2B's commands.run(background=True) has a default
  server-side timeout=60 that kills long-running opencode bg jobs;
  pass timeout=0 to disable it.
- live_watch: RolloutSummary / collect_rollout_summary /
  print_rollout_summary — post-rollout structured report reading proxy
  trace, opencode event log, and the workdir listing + file contents.
- tests/test_harness_live_vllm: end-to-end Mode B test against a live
  vLLM tunnel (gated on VLLM_TUNNEL_URL + E2B_API_KEY), asserts
  logprobs are captured with shape matching completion tokens.

* docs(opencode_env): README with Mode A + Mode B quickstarts and full config

* feat(opencode_env): retries, error surfacing, model override

Fixes the server-mode rollout path. Under load E2B can return sandboxes
that aren't yet accepting commands, curl-install can transiently fail,
and opencode's internal title-generation call can emit a stripped model
id (e.g. ``Qwen3.5-4B`` instead of ``Qwen/Qwen3.5-4B``) that vLLM
rejects with 404. Previously the proxy silently swallowed upstream error
bodies and returned an empty event-stream, which opencode interpreted as
an empty assistant turn.

Changes:
- harness: ``_wait_for_sandbox_ready`` probes ``echo ok`` up to 15x
  before issuing commands.
- harness: ``_exec_with_retry`` wraps install / pip-deps / extra-setup
  with exponential backoff, up to 3 attempts, bailing on deterministic
  errors (non-empty stderr).
- interception: ``ProxyConfig.model_override`` rewrites the ``model``
  field on every forwarded request to the exact upstream id, bypassing
  opencode's provider-prefix quirks. Plumbed through as
  ``--model-override`` on the CLI.
- interception: ``_proxy_streaming`` now inspects upstream status before
  committing to an SSE response — non-2xx returns a JSON error response
  to opencode AND logs the full upstream body to proxy.log, so the
  caller sees the real failure reason.
- harness: ``_start_proxy`` passes ``--model-override`` built from
  ``config.model`` so the upstream always sees the right id.
- harness: proxy deps install now uses ``_exec_with_retry`` too.

Verified via local uvicorn: fizzbuzz + fibonacci tasks both succeed
end-to-end through the server path in 19-20s with reward=1.0, 3-4
productive turns, and real per-token logprobs captured on every turn.

* feat(opencode_env): add serve driver + opencode_client (Phase 2b primitive)

Adds the infrastructure for driving opencode via its HTTP server instead
of the CLI. This is the Phase 2b foundation — fine-grained MCP tools in
the consumer server wrap these primitives.

Changes:
- opencode_client.OpenCodeServerClient: typed httpx wrapper over the
  OpenAPI spec at /doc. Sync and async methods for create_session,
  send_message / send_prompt_async, list_messages, get_session,
  get_all_status, abort, plus stream_events / astream_events (SSE) and
  a wait_for_ready helper. Base64 basic-auth when
  OPENCODE_SERVER_PASSWORD is set.
- harness.OpenCodeSession: new ``driver: Literal["cli", "serve"]`` field.
  driver="cli" is today's `opencode run` path. driver="serve" stores
  serve_public_url + serve_client + serve_session_id on the session.
  start_agent() dispatches on driver; wait_for_completion() polls
  /session/:id for idle when driver="serve". New abort() method hits
  /session/:id/abort for cancellation.
- harness.OpenCodeSessionFactory: new ``driver`` constructor arg plumbs
  through to create(). _start_serve() runs opencode serve bound to
  0.0.0.0:4096 as a bg job, probes it internally via curl, then uses
  the sandbox backend's get_host(4096) to build a public URL (E2B
  returns https://4096-<sandbox_id>.e2b.app). Fails fast if the backend
  doesn't support get_host.
- tests/test_opencode_client.py: 7 unit tests covering URL/method/body
  shape, auth header, prompt text extraction, abort bool, limit param,
  wait_for_ready polling, SSE event helpers. Uses httpx MockTransport
  patched via monkeypatch — no live opencode serve needed.
- tests/test_harness.py: _FakeSandbox now responds to the health-probe
  "echo ok" command so the existing factory tests work after the
  Phase-1 reliability layer landed.

Verified: 34 unit tests pass (7 new + 27 existing), driver=cli path
unchanged. End-to-end E2B spike confirms:
  sandbox_id assigned in 0.4s
  opencode install 2.5s
  opencode serve --port 4096 --hostname 0.0.0.0 listening in 1s
  sandbox.get_host(4096) returns https://4096-<id>.e2b.app
  external /doc returns HTTP 200 with OpenAPI spec
  external POST /session returns real session metadata

Next: wire 4 new MCP tools (start_rollout / get_state / abort_rollout /
finalize_rollout) in the consumer env + SSE endpoint for live rollout
events. Ship to HF Space.

* fix(opencode_env): bump proxy-start wait from 10s to 60s

Uvicorn+fastapi cold boot inside E2B can take >10s under load; the tight
probe loop was producing false 'proxy did not start within 10s' errors.
60s cap at 0.5s intervals keeps the retry fast while tolerating the slow
path.

* fix(opencode_env): cwd into workdir, fix idle detection, preserve reasoning

Three audit fixes surfaced by end-to-end testing through the deployed
env server:

1. `_start_serve` now `cd`s into workdir_path(config) before launching
   opencode serve. Without this, the agent writes files to $HOME and
   RolloutResult.workdir_files (reading /home/user/workdir) comes back
   empty — the "rollout succeeded but nothing appeared" symptom.

2. `wait_for_completion` idle check was `status.get("idle")` but
   opencode's /session/status returns `{"type":"idle"}`, not
   `{"idle":true}`. Every serve-driver rollout silently timed out at
   agent_timeout_s. Now checks `status.get("type") == "idle"` and adds
   structured logging on every tick.

3. Interception proxy now preserves `delta.reasoning` on streaming
   chunks and surfaces it as `message.reasoning` on the assembled
   response. HF Router's Qwen3.5 thinking mode returns reasoning as a
   separate field from content; previously it was dropped.

4. `upstream_model` no longer strips the Qwen/ org prefix — full
   `config.model` is forwarded as the model-override so both vLLM
   (served as `Qwen/Qwen3.5-4B`) and HF Router (requires
   `Qwen/<repo>:<provider>`) work.

5. Structured logging at every factory.create phase so operators can
   see exactly which step is stuck (sandbox, bootstrap, proxy, serve,
   wait_for_completion).

* test(opencode_env): drop live integration tests requiring external secrets

The four live tests (OpenAI / vLLM / mode-B / E2B) required
OPENAI_API_KEY, VLLM_URL, or E2B_API_KEY to execute and were
development-time fixtures rather than CI checks. The core functionality
is already covered offline by test_harness.py (end-to-end factory
lifecycle against a mock sandbox + mock OpenAI endpoint),
test_interception.py (proxy forward + per-turn record assembly),
test_opencode_client.py (serve client over httpx mocks), and
test_sandbox_base.py (E2BSandboxBackend key-required unit).

* feat(opencode_env): deployable env with HF Space, Gradio UI, and 3-endpoint catalog

Wraps the existing OpenCode harness primitive in a deployable OpenEnv
environment that can run as an HF Space, exposing a single MCP
``run_rollout`` tool plus a Gradio web UI at /web.

Highlights
----------
- Single MCP tool ``run_rollout`` accepting a uniform Task shape
  (instruction + setup[] + verify[] bash commands), reward = passed_verify
  / total or override via /home/user/logs/verifier/reward.txt.
- Endpoint shorthand catalog (``vllm`` / ``openai`` / ``hf_router``) that
  resolves base_url / api_key / model from env vars + sane defaults.
- In-sandbox FastAPI proxy (``transparent_proxy`` mode) injects
  logprobs=true and captures per-token logprobs for GRPO training.
- Optional ``black_box`` mode skips the proxy for SFT / eval rollouts.
- Pre-baked E2B template (``opencode-rl``) drops sandbox cold start
  from ~2min to ~6s by shipping opencode + proxy deps in the image.
- Streaming Gradio UI: /run handler is a generator that yields a live
  phase log (sandbox boot → setup → agent → verify → collect) so the
  user sees progress instead of a spinner.
- HF Space deployed at AdithyaSK/opencode-env, end-to-end verified
  against vLLM, OpenAI, and HF Router (all 3 reward=1.0 on the
  binary_search smoke task).

Layout
------
  envs/opencode_env/
    {client.py, models.py, __init__.py}            # HTTP client + pydantic
    {config.py, harness.py, opencode_runtime.py,
     task.py}                                      # primitive (CLI-only)
    server/{app.py, opencode_environment.py,
            gradio_ui.py, catalog.py, Dockerfile}  # FastAPI + Gradio + MCP
    sandbox/{base.py, e2b.py, interception.py,
             build_template.py}                    # E2B + proxy + template
    {pyproject.toml, openenv.yaml, uv.lock,
     README.md, .dockerignore, .gitignore}

Removed (CLI-only refactor)
---------------------------
- harness.py: dropped the ``opencode serve`` driver path (~270 LOC).
- Deleted opencode_client.py, live_watch.py, env-local tests/.

CI / tests
----------
- New tests/envs/test_opencode_env.py: 14 unit tests (no E2B, no LLM,
  no network) covering catalog resolution, model serialization, and
  task coercion. Plus one @pytest.mark.integration test that runs
  opencode end-to-end against the deployed Space (skipped by default).
- sandbox/__init__.py: e2b import wrapped in try/except so the package
  loads cleanly without e2b installed (CI-friendly).
- Added opencode-env to .github/workflows/docker-build.yml matrix so
  the image is built and pushed to GHCR alongside other envs.

openenv-core dependency
-----------------------
Currently pinned to the ``opencode-harness`` branch via git because
PyPI's ``openenv-core`` (0.2.x) does not yet ship the
``openenv.core.harness`` module that this env imports. Switch to
``openenv-core[core]>=0.2.2`` once RFC 5 / PR #471 ships in a
published release. The intended end-state is documented inline in
pyproject.toml.

* docs(opencode_env): add examples/opencode_env_simple.py

Minimal end-to-end example: hits the deployed HF Space, runs a
binary_search rollout via the MCP run_rollout tool, prints the
reward + per-turn logprobs + the file the agent produced.

Mirrors the per-env convention in ``examples/`` (echo_mcp_demo.py /
coding_env_inference.py / atari_simple.py etc.). Defaults point at
``https://adithyask-opencode-env.hf.space``; override with
``OPENCODE_ENV_SPACE`` to target a different Space or local container.

Requires ``OPENAI_API_KEY`` in the environment (passed in the
request body, no Space secret required). Swap ``endpoint="openai"``
for ``"vllm"`` or ``"hf_router"`` to exercise the other backends.

* fix: secure opencode proxy command

---------

Co-authored-by: burtenshaw <ben.burtenshaw@gmail.com>
…support (#636)

* feat(collect): add reasoning_gym support + --dataset-config + --system-prompt

Extends `openenv collect` to support reasoning_gym environments:
- Register ReasoningGymSessionFactory in _build_session_factory
- Add --dataset-config option (JSON string) for env-specific config
- Add --system-prompt option to override the default system prompt
- Add ReasoningGymSessionFactory in envs/reasoning_gym_env/harness.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(harness): generate random seed per episode when none provided

reasoning_gym server requires seed when dataset_name is specified.
CollectRunner never passes a seed, so generate one per episode to
ensure variety across collected rollouts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(llm_client): use max_completion_tokens for OpenAI client

Newer OpenAI models (gpt-5-mini, o1, o3) reject max_tokens and require
max_completion_tokens instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(llm_client): use max_completion_tokens only for OpenAI API, not self-hosted

Newer OpenAI models require max_completion_tokens; self-hosted OpenAI-compatible
endpoints (vLLM, Ollama, TGI) only support max_tokens. Add use_max_completion_tokens
flag to OpenAIClient, enabled automatically by create_llm_client for the openai
provider and left off for self-hosted endpoints via --llm-endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(llm_client): omit temperature for OpenAI API newer models

gpt-5-mini and other newer OpenAI models only accept the default
temperature (1) and reject any explicit value. Omit temperature
entirely when use_max_completion_tokens is set; self-hosted endpoints
are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(collect): add rich progress bar to CollectRunner

Shows episode count, collected count, running avg reward, and elapsed
time during collection — previously the loop ran silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(tutorials): add SFT warm-up tutorial with end-to-end validated notebook

Adds docs/source/tutorials/sft-warmup.md and examples/sft_warmup.ipynb.
Tutorial collects rollouts via CollectRunner Python API, pushes to Hub,
filters by reward, and fine-tunes Qwen3-1.7B with SFTTrainer. Validated
end-to-end: 0% → 64% format compliance, 4% → 60% accuracy on chain_sum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tutorials,collect): address PR review findings

- Scope use_max_completion_tokens to models that require it (gpt-5-mini,
  o1, o3, o4-mini) — was incorrectly applied to all OpenAI models,
  breaking gpt-4o and other standard models
- Remove duplicate YOUR_HF_USERNAME re-declaration in tutorial section 10
- Add missing asyncio import in tutorial section 10 code block
- Fix prose: max_seq_length → max_length (correct SFTConfig field name)
- Fix usort import order in collect.py and harness/collect.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(collect,tutorials): address Greptile review findings

- Move _MAX_COMPLETION_TOKENS_PREFIXES to module level as frozenset
- Use prefix matching for versioned model names (o1-2024-12-17, etc.)
- Replace asyncio.run() with await in tutorial section 10 (Jupyter compat)
- Use json.dumps for tool_call text in to_qwen3_messages (safe escaping)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: burtenshaw <ben.burtenshaw@gmail.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label May 11, 2026
@burtenshaw
burtenshaw merged commit 8d4ed80 into main May 11, 2026
21 checks passed
@greptile-apps

greptile-apps Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces the session harness runtime (MCPHarnessAdapter, StepEnvSessionAdapter, SessionMCPBridge), a collection pipeline (CollectRunner, RolloutSerializer, EpisodeRecord), an openenv collect CLI command, and harness adapters for BrowserGym, OpenSpiel, and ReasoningGym. The dual-API boundary invariant \u2014 agents see only MCP tool calls while Gym reset/step/state stay in the orchestration layer \u2014 is correctly implemented and enforced by RESERVED_TOOL_NAMES checks at both the session and bridge level.

  • Harness runtime: StepEnvSessionAdapter bridges agent tool calls to client.step(); MCPHarnessAdapter drives the white-box MCP loop with token/logprob accumulation; build_harness_rollout_func produces a TRL-compatible rollout function.
  • Collect pipeline: CollectRunner orchestrates repeated rollouts into an append-only JSONL dataset with resume support, Rich progress display, and optional HF Hub push.
  • ALIGNMENT FLAG: build_harness_rollout_func passes a trainer object into model_step_builder, coupling training-infrastructure state into the session loop in a way that differs from the evaluation path. This trade-off should be documented in RFC 005. Principle at stake: \u201cMinimize lifecycle deltas\u201d. Suggested reviewer: @darktex

Confidence Score: 3/5

The harness runtime and collection pipeline are well-structured, but two defects in the core loop need fixing before this is safe to merge for production use.

The max_tool_calls_per_turn truncation leaves tool-call IDs in the assistant message without matching tool-result entries, causing OpenAI-compatible APIs to reject the subsequent request. Separately, CollectResult.episode_ids returns all planned IDs rather than only those written to results.jsonl, so downstream consumers loading episodes by ID will hit missing-record errors. Both issues sit in central code paths shared by all three env adapters.

src/openenv/core/harness/init.py (tool-call truncation) and src/openenv/core/harness/collect.py (CollectResult.episode_ids semantics and _tasks iterator exhaustion)

Important Files Changed

Filename Overview
src/openenv/core/harness/init.py Core harness runtime with MCPHarnessAdapter, StepEnvSessionAdapter, and SessionMCPBridge. Dual-API boundary correctly enforced. One bug: max_tool_calls_per_turn truncation leaves orphaned tool-call IDs in the assistant message.
src/openenv/core/harness/collect.py CollectRunner, RolloutSerializer, EpisodeRecord, and HF Hub helpers. Two issues: episode_ids contains all planned IDs not just collected ones; _tasks iterator is exhausted after the first run() call.
envs/browsergym_env/harness.py BrowserGymSessionFactory with ast.parse-based action conversion and environment-forwarding verify builder. No issues found.
envs/openspiel_env/harness.py OpenSpielSessionFactory with TTT board renderer. Clean implementation.
envs/reasoning_gym_env/harness.py ReasoningGymSessionFactory with single answer tool and random seed fallback. No issues found.
src/openenv/cli/commands/collect.py New openenv collect CLI command. system_prompt is passed redundantly to both LLMClient and build_model_step but is harmless. Otherwise clean.
src/openenv/core/llm_client.py OpenAI and Anthropic clients with MCP schema converters. No issues found.
tests/core/test_harness_runtime.py Comprehensive unit tests covering the full harness lifecycle and reward invariants. No issues found.

Sequence Diagram

sequenceDiagram
    participant TRL as TRL Trainer / CollectRunner
    participant HF as HarnessAdapter (MCPHarnessAdapter)
    participant SA as StepEnvSessionAdapter (ResourceSession)
    participant GC as Gym Client (step/reset)
    participant LLM as LLM (ModelStep)

    TRL->>SA: session_factory.create(task, episode_id)
    SA->>GC: client.reset(seed, episode_id, task_name)
    GC-->>SA: StepResult (initial observation)
    SA-->>TRL: ResourceSession

    TRL->>HF: run_white_box(model_step, session, limits)
    HF->>SA: session.initial_messages()
    SA-->>HF: user message with observation

    loop Each harness turn (max_turns)
        HF->>LLM: model_step(messages, tools, sampling)
        LLM-->>HF: ModelStepResult (tool_calls, logprobs)
        HF->>SA: session.call_tool(name, arguments)
        Note over SA,GC: Agent sees only tool calls, never reset/step/state
        SA->>GC: client.step(action)
        GC-->>SA: StepResult (observation, reward, done)
        SA-->>HF: ToolResult (data, done, metadata.reward)
        HF->>HF: append tool result message
        alt "done=True"
            HF-->>TRL: HarnessRolloutResult
        end
    end

    TRL->>SA: session.verify(transcript, final_state)
    SA-->>TRL: VerifyResult (env_reward forwarded)
    Note over TRL: _resolve_env_reward enforces rewards-in-env invariant
    TRL->>SA: session.close()
Loading
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
src/openenv/core/harness/__init__.py:531-534
**Orphaned tool-call IDs when `max_tool_calls_per_turn` truncates**

`assistant_message` is built from `step_result.response.to_message_dict()`, which embeds **all** tool-call IDs returned by the model. Only the first `max_tool_calls_per_turn` tool calls then get a corresponding `role="tool"` result message. On the next iteration, the messages list is handed back to the LLM with N+M tool-call entries in the assistant turn but only N matching tool-result entries — the OpenAI API (and most OpenAI-compatible servers) treat this as a validation error and reject the request outright. The bug is dormant when the option is `None` (default), but fires whenever a caller sets `max_tool_calls_per_turn` and the model returns more calls than the limit.

### Issue 2 of 4
src/openenv/core/harness/collect.py:341-348
**`episode_ids` in `CollectResult` contains all planned IDs, not only collected ones**

`planned_ids` is the full range generated before the loop, so `CollectResult.episode_ids` will contain IDs for skipped, dropped, and failed episodes as well as collected ones. Any consumer that iterates `result.episode_ids` expecting a list of episodes present in `results.jsonl` will attempt to load records that don't exist there. Concretely, with `num_episodes=10`, `num_skipped=3`, `num_dropped=2`, and `num_failed=1`, only 4 IDs are in the file but all 10 appear in the field.

### Issue 3 of 4
src/openenv/core/harness/collect.py:215-228
**`_tasks` iterator is exhausted after the first `run()` call**

`CollectRunner.__init__` converts the `tasks` argument to an iterator once and stores it in `self._tasks`. If `run()` is called a second time (which the class docstring explicitly invites: "A single runner instance can be reused across runs"), every `_next_task()` call will return `None` because the iterator is spent — silently destroying task-to-episode alignment without any warning. Either the raw iterable should be stored and re-iterated on each `run()` call, or the docstring should be corrected to say the runner is stateful and must not be re-used when `tasks` is provided.

### Issue 4 of 4
src/openenv/core/harness/collect.py:328-334
**`avg` is recomputed on every collected episode**

`sum(rewards) / len(rewards)` re-traverses the growing `rewards` list on each iteration. An incremental running sum would be O(1) per step instead of O(n).

```suggestion
                self._serializer.write_episode(record)
                num_collected += 1
                rewards.append(record.reward)
                running_sum = running_sum + record.reward if num_collected > 1 else record.reward
                avg = running_sum / num_collected
                progress.update(
                    task_id, advance=1, collected=num_collected, avg_reward=avg
                )
```

Reviews (1): Last reviewed commit: "Merge branch 'main' into feature/harness..." | Re-trigger Greptile

Comment on lines +531 to +534
tool_calls = step_result.response.tool_calls
if run_limits.max_tool_calls_per_turn is not None:
tool_calls = tool_calls[: run_limits.max_tool_calls_per_turn]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Orphaned tool-call IDs when max_tool_calls_per_turn truncates

assistant_message is built from step_result.response.to_message_dict(), which embeds all tool-call IDs returned by the model. Only the first max_tool_calls_per_turn tool calls then get a corresponding role="tool" result message. On the next iteration, the messages list is handed back to the LLM with N+M tool-call entries in the assistant turn but only N matching tool-result entries — the OpenAI API (and most OpenAI-compatible servers) treat this as a validation error and reject the request outright. The bug is dormant when the option is None (default), but fires whenever a caller sets max_tool_calls_per_turn and the model returns more calls than the limit.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/openenv/core/harness/__init__.py
Line: 531-534

Comment:
**Orphaned tool-call IDs when `max_tool_calls_per_turn` truncates**

`assistant_message` is built from `step_result.response.to_message_dict()`, which embeds **all** tool-call IDs returned by the model. Only the first `max_tool_calls_per_turn` tool calls then get a corresponding `role="tool"` result message. On the next iteration, the messages list is handed back to the LLM with N+M tool-call entries in the assistant turn but only N matching tool-result entries — the OpenAI API (and most OpenAI-compatible servers) treat this as a validation error and reject the request outright. The bug is dormant when the option is `None` (default), but fires whenever a caller sets `max_tool_calls_per_turn` and the model returns more calls than the limit.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +341 to +348
return CollectResult(
num_collected=num_collected,
num_skipped=num_skipped,
num_dropped=num_dropped,
num_failed=num_failed,
episode_ids=planned_ids,
avg_reward=avg_reward,
success_rate=success_rate,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 episode_ids in CollectResult contains all planned IDs, not only collected ones

planned_ids is the full range generated before the loop, so CollectResult.episode_ids will contain IDs for skipped, dropped, and failed episodes as well as collected ones. Any consumer that iterates result.episode_ids expecting a list of episodes present in results.jsonl will attempt to load records that don't exist there. Concretely, with num_episodes=10, num_skipped=3, num_dropped=2, and num_failed=1, only 4 IDs are in the file but all 10 appear in the field.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/openenv/core/harness/collect.py
Line: 341-348

Comment:
**`episode_ids` in `CollectResult` contains all planned IDs, not only collected ones**

`planned_ids` is the full range generated before the loop, so `CollectResult.episode_ids` will contain IDs for skipped, dropped, and failed episodes as well as collected ones. Any consumer that iterates `result.episode_ids` expecting a list of episodes present in `results.jsonl` will attempt to load records that don't exist there. Concretely, with `num_episodes=10`, `num_skipped=3`, `num_dropped=2`, and `num_failed=1`, only 4 IDs are in the file but all 10 appear in the field.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +215 to +228
def __init__(
self,
*,
session_factory: ResourceSessionFactory,
harness_adapter: HarnessAdapter,
serializer: RolloutSerializer,
tasks: Iterable[Any] | None = None,
limits: HarnessRunLimits | None = None,
):
self._session_factory = session_factory
self._harness_adapter = harness_adapter
self._serializer = serializer
self._tasks: Iterator[Any] | None = iter(tasks) if tasks is not None else None
self._limits = limits

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _tasks iterator is exhausted after the first run() call

CollectRunner.__init__ converts the tasks argument to an iterator once and stores it in self._tasks. If run() is called a second time (which the class docstring explicitly invites: "A single runner instance can be reused across runs"), every _next_task() call will return None because the iterator is spent — silently destroying task-to-episode alignment without any warning. Either the raw iterable should be stored and re-iterated on each run() call, or the docstring should be corrected to say the runner is stateful and must not be re-used when tasks is provided.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/openenv/core/harness/collect.py
Line: 215-228

Comment:
**`_tasks` iterator is exhausted after the first `run()` call**

`CollectRunner.__init__` converts the `tasks` argument to an iterator once and stores it in `self._tasks`. If `run()` is called a second time (which the class docstring explicitly invites: "A single runner instance can be reused across runs"), every `_next_task()` call will return `None` because the iterator is spent — silently destroying task-to-episode alignment without any warning. Either the raw iterable should be stored and re-iterated on each `run()` call, or the docstring should be corrected to say the runner is stateful and must not be re-used when `tasks` is provided.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +328 to +334
self._serializer.write_episode(record)
num_collected += 1
rewards.append(record.reward)
avg = sum(rewards) / len(rewards)
progress.update(
task_id, advance=1, collected=num_collected, avg_reward=avg
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 avg is recomputed on every collected episode

sum(rewards) / len(rewards) re-traverses the growing rewards list on each iteration. An incremental running sum would be O(1) per step instead of O(n).

Suggested change
self._serializer.write_episode(record)
num_collected += 1
rewards.append(record.reward)
avg = sum(rewards) / len(rewards)
progress.update(
task_id, advance=1, collected=num_collected, avg_reward=avg
)
self._serializer.write_episode(record)
num_collected += 1
rewards.append(record.reward)
running_sum = running_sum + record.reward if num_collected > 1 else record.reward
avg = running_sum / num_collected
progress.update(
task_id, advance=1, collected=num_collected, avg_reward=avg
)
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/openenv/core/harness/collect.py
Line: 328-334

Comment:
**`avg` is recomputed on every collected episode**

`sum(rewards) / len(rewards)` re-traverses the growing `rewards` list on each iteration. An incremental running sum would be O(1) per step instead of O(n).

```suggestion
                self._serializer.write_episode(record)
                num_collected += 1
                rewards.append(record.reward)
                running_sum = running_sum + record.reward if num_collected > 1 else record.reward
                avg = running_sum / num_collected
                progress.update(
                    task_id, advance=1, collected=num_collected, avg_reward=avg
                )
```

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants