Skip to content

envs/opencode_env: OpenCode coding-agent harness environment#656

Merged
burtenshaw merged 2 commits into
huggingface:mainfrom
adithya-s-k:opencode-harness-integration
May 11, 2026
Merged

envs/opencode_env: OpenCode coding-agent harness environment#656
burtenshaw merged 2 commits into
huggingface:mainfrom
adithya-s-k:opencode-harness-integration

Conversation

@adithya-s-k

Copy link
Copy Markdown
Collaborator

Summary

Re-introduces the OpenCode env originally developed in #603. That PR was merged into the stacked feature/harness-interface branch, and the envs/opencode_env/ contents were dropped before that branch landed on main. With #652 (harness runtime) and #654 (openenv-core 0.3.0 release) now on main, this lands cleanly on main directly — no more stack dependency.

This branch is built fresh off the current main tip and contains only the 27 opencode-specific files from the tip of adithya-s-k:opencode-harness (f216c19). The openenv-core dependency pin is updated from the dev-time git ref to openenv-core[core]>=0.3.0.

What's in envs/opencode_env/

Deployable env that wraps the OpenCode CLI agent in an E2B sandbox against any OpenAI-compatible LLM endpoint.

  • Single MCP tool run_rollout with 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) — resolves base_url / api_key / model from env vars with sane defaults.
  • transparent_proxy mode: in-sandbox FastAPI proxy injects logprobs=true on forwarded /v1/chat/completions and captures per-turn logprobs to a JSONL trace (for GRPO). Strips logprobs from what opencode sees.
  • black_box mode: skips the proxy for SFT / eval rollouts.
  • Pre-baked E2B template support drops sandbox cold start from ~2min to ~6s.
  • Streaming Gradio UI at /web with a live phase log (sandbox boot → setup → agent → verify → collect).
  • Implements ResourceSession / ResourceSessionFactory from openenv.core.harness.

Tests

tests/envs/test_opencode_env.py — 14 offline unit tests covering catalog resolution, model serialization, and task coercion. No E2B / LLM / network. Plus one @pytest.mark.integration test against the deployed HF Space (skipped by default).

Example

examples/opencode_env_simple.py — end-to-end binary_search rollout against the deployed HF Space, prints reward + per-turn logprobs.

CI

Adds opencode-env to .github/workflows/docker-build.yml so the image is built and pushed to GHCR alongside other envs.

Provenance

Test plan

  • PYTHONPATH=src:envs uv run pytest tests/envs/test_opencode_env.py -v passes offline
  • Docker build matrix picks up opencode-env and pushes to GHCR
  • Existing deployed Space (AdithyaSK/opencode-env) still works against this code (binary_search smoke task, all 3 endpoints)

Re-introduces the OpenCode env that was developed in PR huggingface#603 (merged into
the stacked feature/harness-interface branch and dropped before that
branch landed on main). With openenv-core 0.3.0 now published with the
harness runtime, this lands cleanly on main without the stack dependency.

Environment
- envs/opencode_env/: deployable env wrapping the OpenCode CLI agent in
  an E2B sandbox against any OpenAI-compatible LLM endpoint.
  - Single MCP tool run_rollout (instruction + setup[] + verify[] bash;
    reward = passed_verify / total or override via reward.txt).
  - Endpoint shorthand catalog (vllm / openai / hf_router) resolves
    base_url / api_key / model from env vars.
  - transparent_proxy mode runs an in-sandbox FastAPI proxy that
    injects logprobs=true on forwarded /v1/chat/completions, captures
    per-turn logprobs to a JSON-lines trace (for GRPO), and strips
    logprobs from what opencode sees.
  - black_box mode skips the proxy for SFT / eval rollouts.
  - Pre-baked E2B template support drops sandbox cold start ~2min -> ~6s.
  - Streaming Gradio UI at /web with live phase log.
  - Implements ResourceSession / ResourceSessionFactory from
    openenv.core.harness.

Pinned to openenv-core[core]>=0.3.0 (no more git-ref pin to a fork).

Tests
- tests/envs/test_opencode_env.py: 14 offline unit tests covering
  catalog resolution, model serialization, task coercion. No E2B / LLM
  / network. Plus one integration test gated on a deployed Space.

Example
- examples/opencode_env_simple.py: end-to-end binary_search rollout
  against the deployed HF Space, prints reward + per-turn logprobs.

CI
- .github/workflows/docker-build.yml: adds opencode-env to the matrix
  so the image is built and pushed to GHCR alongside other envs.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label May 11, 2026
@greptile-apps

greptile-apps Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR re-introduces the opencode_env \u2014 a deployable environment that wraps the OpenCode CLI agent in an E2B sandbox against any OpenAI-compatible LLM endpoint. It adds 27 new files (~7800 lines) including the harness primitive, a transparent FastAPI proxy for logprob capture, the MCP server, a Gradio UI, an endpoint catalog, and offline unit tests.

  • Transparent proxy (interception.py): forwards POST /v1/chat/completions to an upstream LLM, injects logprobs=true, captures per-turn logprobs to JSONL, and is supposed to strip logprobs before returning to OpenCode \u2014 but the streaming path does not strip them, diverging from both the unary path and the module docstring.
  • Setup/agent ordering (opencode_environment.py): setup commands execute after factory.create() has already launched the agent subprocess, creating an acknowledged race that can corrupt workspace state for slower setup workloads.
  • Client-server separation (__init__.py): the server imports harness symbols via the top-level package __init__, which unconditionally re-exports OpenCodeEnv from client.py \u2014 transitive import of client code into the server process, contrary to INVARIANTS.md.

Confidence Score: 3/5

The core rollout logic works end-to-end, but three issues in the new code produce incorrect behavior on realistic inputs: the streaming proxy path doesn't strip logprobs as documented, setup commands race with an already-running agent, and the server's import path pulls in the MCP client module contrary to the project's architectural contract.

The streaming-vs-unary asymmetry in logprob stripping means OpenCode receives logprobs in streaming mode contrary to the module's stated contract. The setup/agent race is self-documented as a known limitation but can cause real workspace corruption for slower setup workloads on pre-baked templates that boot in ~6 s, well under the assumed 20 s safety margin. The transitive client import through init.py is an architectural invariant violation that the team explicitly tracks in INVARIANTS.md. Together these three issues warrant a closer pass before merge.

envs/opencode_env/sandbox/interception.py (streaming logprob stripping), envs/opencode_env/server/opencode_environment.py (setup/agent ordering), envs/opencode_env/init.py (client re-export pulled into server process)

Important Files Changed

Filename Overview
envs/opencode_env/sandbox/interception.py Transparent forwarding proxy with logprob capture — streaming path forwards logprobs verbatim to OpenCode (inconsistent with docstring/unary path), and trace_file is opened outside the lifespan context manager risking a file-descriptor leak.
envs/opencode_env/server/opencode_environment.py Core MCP environment — setup commands run after the agent has already started (acknowledged race condition that can break slow-setup tasks), reward computation correctly stays inside the environment per PRINCIPLES.md.
envs/opencode_env/init.py Package re-exports — importing from here in server code transitively pulls in client.py, violating the client-server separation invariant in INVARIANTS.md.
envs/opencode_env/harness.py Session factory and session implementation — API key correctly passed via env var (not argv), retry/backoff logic for transient sandbox failures is solid, proxy startup polling is well-handled.
envs/opencode_env/client.py Typed MCP client wrapper — clean implementation; _extract_text helper is private but used in examples, making it an implicit public API.
envs/opencode_env/server/catalog.py Endpoint shorthand catalog — correct precedence logic (explicit arg > env var > default), proper error messages for missing creds, well-tested.
envs/opencode_env/sandbox/e2b.py E2B backend implementation — thread-based wait with timeout for CommandHandle, clean mapping to SandboxHandle protocol, background job defaults to timeout=0 to avoid 60s SDK kill.
envs/opencode_env/models.py Shared Pydantic models for wire types — correctly placed in models.py per invariant, round-trip serialization tested.
tests/envs/test_opencode_env.py 14 offline unit tests covering catalog, model serialization, task coercion, and API key security; integration test gated behind pytest mark.
envs/opencode_env/server/app.py FastAPI app entrypoint — lightweight .env loader for dev convenience, Gradio UI builder wired in correctly; no issues found.

Sequence Diagram

sequenceDiagram
    participant Trainer as Trainer / Client
    participant Server as OpenCodeEnvironment (MCP Server)
    participant Factory as OpenCodeSessionFactory
    participant Sandbox as E2B Sandbox
    participant Proxy as InterceptionProxy (port 7000)
    participant LLM as Upstream LLM

    Trainer->>Server: run_rollout(instruction, setup[], verify[])
    Server->>Factory: create(task)
    Factory->>Sandbox: create(timeout_s)
    Factory->>Sandbox: bootstrap (install opencode, write config)
    Factory->>Proxy: start_bg(interception.py --upstream-url ...)
    Note over Factory,Proxy: API key via env var OPENCODE_UPSTREAM_API_KEY
    Factory->>Sandbox: start_bg(opencode run ...) [agent starts HERE]
    Factory-->>Server: OpenCodeSession
    Note over Server,Sandbox: Race - setup commands run AFTER agent already started
    Server->>Sandbox: exec(setup[0..n]) sequentially
    Server->>Server: wait_for_completion(agent_timeout_s)
    loop each LLM turn
        Sandbox->>Proxy: POST /v1/chat/completions
        Proxy->>LLM: "forward + inject logprobs=true"
        LLM-->>Proxy: response (with logprobs)
        Proxy->>Proxy: write TurnRecord to proxy_trace.jsonl
        Proxy-->>Sandbox: "unary=stripped / streaming=NOT stripped"
    end
    Server->>Sandbox: exec(verify[0..n]) sequentially
    Server->>Server: "compute reward = passed/total or reward.txt"
    Server->>Sandbox: kill()
    Server-->>Trainer: JSON(RolloutResult)
Loading

Comments Outside Diff (3)

  1. envs/opencode_env/sandbox/interception.py, line 869-907 (link)

    P1 Streaming path forwards logprobs verbatim to OpenCode

    The module docstring promises that the proxy "strips logprobs from what opencode sees," but this only holds for the unary path (_proxy_unary calls _strip_logprobs before returning). In _proxy_streaming, every raw SSE data: line is forwarded to the caller unchanged — including the logprobs field injected into each chunk. If OpenCode ever validates or trips over unexpected fields in streaming deltas, or if a future OpenCode version surfaces logprobs in its UI/context, it will see data the proxy is supposed to hide. The unary and streaming paths need symmetric stripping.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: envs/opencode_env/sandbox/interception.py
    Line: 869-907
    
    Comment:
    **Streaming path forwards logprobs verbatim to OpenCode**
    
    The module docstring promises that the proxy "strips logprobs from what opencode sees," but this only holds for the unary path (`_proxy_unary` calls `_strip_logprobs` before returning). In `_proxy_streaming`, every raw SSE `data:` line is forwarded to the caller unchanged — including the `logprobs` field injected into each chunk. If OpenCode ever validates or trips over unexpected fields in streaming deltas, or if a future OpenCode version surfaces logprobs in its UI/context, it will see data the proxy is supposed to hide. The unary and streaming paths need symmetric stripping.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. envs/opencode_env/server/opencode_environment.py, line 1546-1563 (link)

    P1 Setup commands race with an already-running agent

    factory.create() calls session.start_agent() before returning (see harness.py line 294). The setup commands are then executed sequentially — but the agent is already running. The inline comment acknowledges this and dismisses it as "fine for typical pip/git/download work because opencode itself takes ≥20s to make its first model call." That assumption breaks whenever setup is slow (large download, slow network) or when running against a pre-baked template that boots OpenCode in ~6 s. A setup command that takes 15–30 s on a cold sandbox will overlap with the agent's first tool invocations, producing undefined state in the workspace the agent is trying to modify.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: envs/opencode_env/server/opencode_environment.py
    Line: 1546-1563
    
    Comment:
    **Setup commands race with an already-running agent**
    
    `factory.create()` calls `session.start_agent()` before returning (see `harness.py` line 294). The setup commands are then executed sequentially — but the agent is already running. The inline comment acknowledges this and dismisses it as "fine for typical pip/git/download work because opencode itself takes ≥20s to make its first model call." That assumption breaks whenever setup is slow (large download, slow network) or when running against a pre-baked template that boots OpenCode in ~6 s. A setup command that takes 15–30 s on a cold sandbox will overlap with the agent's first tool invocations, producing undefined state in the workspace the agent is trying to modify.
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. envs/opencode_env/sandbox/interception.py, line 655-663 (link)

    P2 trace_file opened outside the lifespan context manager

    trace_file = open(cfg.trace_path, "a", buffering=1) is created eagerly at the top of _build_app. If uvicorn shuts down before the lifespan's finally block ever runs (e.g., a startup exception elsewhere in the app), the file descriptor is leaked. Move the open() call inside the lifespan context — open in the try, close in the finally — so the handle is always released.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: envs/opencode_env/sandbox/interception.py
    Line: 655-663
    
    Comment:
    **`trace_file` opened outside the lifespan context manager**
    
    `trace_file = open(cfg.trace_path, "a", buffering=1)` is created eagerly at the top of `_build_app`. If `uvicorn` shuts down before the lifespan's `finally` block ever runs (e.g., a startup exception elsewhere in the app), the file descriptor is leaked. Move the `open()` call inside the lifespan context — open in the `try`, close in the `finally` — so the handle is always released.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
envs/opencode_env/sandbox/interception.py:869-907
**Streaming path forwards logprobs verbatim to OpenCode**

The module docstring promises that the proxy "strips logprobs from what opencode sees," but this only holds for the unary path (`_proxy_unary` calls `_strip_logprobs` before returning). In `_proxy_streaming`, every raw SSE `data:` line is forwarded to the caller unchanged — including the `logprobs` field injected into each chunk. If OpenCode ever validates or trips over unexpected fields in streaming deltas, or if a future OpenCode version surfaces logprobs in its UI/context, it will see data the proxy is supposed to hide. The unary and streaming paths need symmetric stripping.

### Issue 2 of 5
envs/opencode_env/server/opencode_environment.py:1546-1563
**Setup commands race with an already-running agent**

`factory.create()` calls `session.start_agent()` before returning (see `harness.py` line 294). The setup commands are then executed sequentially — but the agent is already running. The inline comment acknowledges this and dismisses it as "fine for typical pip/git/download work because opencode itself takes ≥20s to make its first model call." That assumption breaks whenever setup is slow (large download, slow network) or when running against a pre-baked template that boots OpenCode in ~6 s. A setup command that takes 15–30 s on a cold sandbox will overlap with the agent's first tool invocations, producing undefined state in the workspace the agent is trying to modify.

### Issue 3 of 5
envs/opencode_env/__init__.py:24-32
**ALIGNMENT FLAG: Server code transitively imports client code**

`server/opencode_environment.py` does `from opencode_env import (E2BSandboxBackend, OpenCodeConfig, OpenCodeSessionFactory, OpenCodeTask)`. This resolves through `envs/opencode_env/__init__.py`, which unconditionally executes `from .client import OpenCodeEnv` — pulling the MCP client into the server process. `INVARIANTS.md` states: "Server code must never import client code." The fix is either to have the server import the symbols it needs directly from their defining modules (`harness.py`, `config.py`, `sandbox/`) rather than from `__init__`, or to restructure `__init__` so the client re-export is deferred.

**Principle at stake**: Client-server separation (INVARIANTS.md § Architectural Invariants)
**Suggested reviewer**: `@darktex`

### Issue 4 of 5
envs/opencode_env/sandbox/interception.py:655-663
**`trace_file` opened outside the lifespan context manager**

`trace_file = open(cfg.trace_path, "a", buffering=1)` is created eagerly at the top of `_build_app`. If `uvicorn` shuts down before the lifespan's `finally` block ever runs (e.g., a startup exception elsewhere in the app), the file descriptor is leaked. Move the `open()` call inside the lifespan context — open in the `try`, close in the `finally` — so the handle is always released.

### Issue 5 of 5
examples/opencode_env_simple.py:47
**Private helper exposed in example**

`from opencode_env.client import _extract_text` — the leading underscore signals this is not part of the public API. If the internal implementation changes, this example (and any user code that copies it) silently breaks. Consider promoting `_extract_text` to a public name or having `OpenCodeEnv.run_rollout` return a `RolloutResult` directly.

Reviews (1): Last reviewed commit: "feat(opencode_env): OpenCode coding-agen..." | Re-trigger Greptile

Comment on lines +24 to +32
from .client import OpenCodeEnv
from .config import OpenCodeConfig, Provider
from .harness import OpenCodeSession, OpenCodeSessionFactory
from .models import (
CommandResult,
OpenCodeState,
RolloutResult,
RolloutTurn,
)

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 ALIGNMENT FLAG: Server code transitively imports client code

server/opencode_environment.py does from opencode_env import (E2BSandboxBackend, OpenCodeConfig, OpenCodeSessionFactory, OpenCodeTask). This resolves through envs/opencode_env/__init__.py, which unconditionally executes from .client import OpenCodeEnv — pulling the MCP client into the server process. INVARIANTS.md states: "Server code must never import client code." The fix is either to have the server import the symbols it needs directly from their defining modules (harness.py, config.py, sandbox/) rather than from __init__, or to restructure __init__ so the client re-export is deferred.

Principle at stake: Client-server separation (INVARIANTS.md § Architectural Invariants)
Suggested reviewer: @darktex

Context Used: .claude/docs/INVARIANTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: envs/opencode_env/__init__.py
Line: 24-32

Comment:
**ALIGNMENT FLAG: Server code transitively imports client code**

`server/opencode_environment.py` does `from opencode_env import (E2BSandboxBackend, OpenCodeConfig, OpenCodeSessionFactory, OpenCodeTask)`. This resolves through `envs/opencode_env/__init__.py`, which unconditionally executes `from .client import OpenCodeEnv` — pulling the MCP client into the server process. `INVARIANTS.md` states: "Server code must never import client code." The fix is either to have the server import the symbols it needs directly from their defining modules (`harness.py`, `config.py`, `sandbox/`) rather than from `__init__`, or to restructure `__init__` so the client re-export is deferred.

**Principle at stake**: Client-server separation (INVARIANTS.md § Architectural Invariants)
**Suggested reviewer**: `@darktex`

**Context Used:** .claude/docs/INVARIANTS.md ([source](https://app.greptile.com/review/custom-context?memory=dbd1ab5e-bd4d-4701-9de0-9817404155a9))

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

import asyncio
import os
import sys

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 Private helper exposed in example

from opencode_env.client import _extract_text — the leading underscore signals this is not part of the public API. If the internal implementation changes, this example (and any user code that copies it) silently breaks. Consider promoting _extract_text to a public name or having OpenCodeEnv.run_rollout return a RolloutResult directly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: examples/opencode_env_simple.py
Line: 47

Comment:
**Private helper exposed in example**

`from opencode_env.client import _extract_text` — the leading underscore signals this is not part of the public API. If the internal implementation changes, this example (and any user code that copies it) silently breaks. Consider promoting `_extract_text` to a public name or having `OpenCodeEnv.run_rollout` return a `RolloutResult` directly.

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

@burtenshaw burtenshaw merged commit 17cf2fc into huggingface:main May 11, 2026
9 checks passed
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