Repository navigation
Harness runtime replacement - #652
Conversation
* 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>
Greptile SummaryThis PR introduces the session harness runtime (
Confidence Score: 3/5The 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
Sequence DiagramsequenceDiagram
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()
Prompt To Fix All With AIFix 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 |
| 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] | ||
|
|
There was a problem hiding this 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.
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.| 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, |
There was a problem hiding this 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.
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.| 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 |
There was a problem hiding this 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.
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.| 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 | ||
| ) |
There was a problem hiding this 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).
| 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.
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.pyuv run usort format src/ tests/ && uv run ruff format src/ tests/ && uv run ruff check src/ tests/Original PR: #471