File size: 7,415 Bytes
0b2c941 5845692 0b2c941 5845692 0b2c941 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | # `openenv.core.harness`
Session-based runtime for driving rollouts against an OpenEnv environment
through an LLM + MCP-style tool loop.
- `__init__.py` β runtime primitives (from RFC 005 / PR #471):
`ResourceSession`, `ResourceSessionFactory`, `StepEnvSessionAdapter`,
`HarnessAdapter`, `MCPHarnessAdapter`, `CLIHarnessAdapter`,
`build_harness_rollout_func`, etc.
- `environment.py`, `adapter.py` β `HarnessEnvironment` and
`AgenticHarnessAdapter` (RFC 005), to run an external agentic harness inside
an environment, one conversational turn per `step()`. Worked example:
[`examples/claude_code_harness_eval`](../../../../examples/claude_code_harness_eval).
- `collect.py` β synthetic dataset generation on top of the runtime:
`EpisodeRecord`, `RolloutSerializer`, `CollectRunner`, `build_model_step`,
`push_to_hf_hub`.
## Quick start
The `openenv collect` CLI wires everything together. See `openenv collect --help`.
```bash
# Smoke test (no API key, no Docker β scripted teacher picks the first legal action)
openenv collect openspiel:tic_tac_toe \
--base-url https://<user>-<space>.hf.space \
--output-dir /tmp/ttt-scripted \
-n 10 --provider scripted
# Real LLM teacher + push the dataset to the Hub
OPENAI_API_KEY=... 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
# Self-hosted teacher (vLLM/TGI/Ollama) via any OpenAI-compatible base URL
openenv collect openspiel:tic_tac_toe \
--base-url https://<user>-<space>.hf.space \
--output-dir /tmp/ttt-sft-local \
-n 200 --llm-endpoint http://localhost:8000 --model Qwen/Qwen3-1.7B
```
Programmatic use:
```python
from openenv.core.harness import HarnessRunLimits, MCPHarnessAdapter
from openenv.core.harness.collect import (
CollectRunner,
RolloutSerializer,
build_model_step,
push_to_hf_hub,
)
from openenv.core.llm_client import create_llm_client
from openspiel_env.client import OpenSpielEnv
from openspiel_env.harness import OpenSpielSessionFactory
factory = OpenSpielSessionFactory(
lambda: OpenSpielEnv(base_url="https://<user>-<space>.hf.space"),
game_name="tic_tac_toe",
)
llm = create_llm_client(provider="openai", model="gpt-5-mini", api_key=...)
runner = CollectRunner(
session_factory=factory,
harness_adapter=MCPHarnessAdapter(),
serializer=RolloutSerializer("/tmp/ttt-sft-v1"),
limits=HarnessRunLimits(max_turns=9),
)
result = runner.run(
model_step=build_model_step(llm),
num_episodes=200,
should_keep=lambda r: r.reward >= 0.0,
)
push_to_hf_hub("/tmp/ttt-sft-v1", "<user>/ttt-sft-v1")
```
## Output schema
`results.jsonl` β one JSON object per episode:
| field | type | description |
| --- | --- | --- |
| `episode_id` | string | Stable id for resume (`<prefix>-<index>`) |
| `messages` | list | Chat transcript (user/assistant/tool). TRL `SFTTrainer`-compatible |
| `reward` | float | Env reward (derived via `_resolve_env_reward`, never synthesized) |
| `done` | bool | Whether the episode terminated |
| `tool_trace` | list | Structured tool calls with args & results |
| `metrics` | struct | Harness-level metrics (turns, tool_calls) |
| `verify_metrics` | struct | Session-level metrics (step_count, β¦) |
| `artifacts` | struct | Verification artifacts (final state, etc.) |
| `task` | any | Optional task spec passed by `CollectRunner.tasks` |
| `extra` | struct | Free-form annotations supplied by the caller |
`metadata.json` β run-level info (env, model, n, temperature, filter flags).
`README.md` is auto-generated by `push_to_hf_hub` with YAML front-matter so the
HF Dataset Viewer renders `results.jsonl` as `split=train`.
## Design notes
- **Thin envs** β `OpenSpielSessionFactory` and friends live in the env package
(`envs/<env>/harness.py`) and wrap the existing `EnvClient`. The collector
treats the env as a black box.
- **Rewards in env** β `EpisodeRecord.from_rollout` reuses the runtime's
`_resolve_env_reward` so any mismatch between the reward emitted by a tool
result and the one forwarded by `session.verify()` raises immediately.
- **Provider-agnostic teacher** β `build_model_step` adapts any
`openenv.core.llm_client.LLMClient` (OpenAI, Anthropic, OpenAI-compatible
self-hosted endpoints) into a `ModelStep`. No schema code per provider.
- **Resume** β `RolloutSerializer.collected_episode_ids()` reads prior runs
from disk; `CollectRunner.run(resume=True)` skips ids already serialized.
- **Filtering** β `should_keep` is a `Callable[[EpisodeRecord], bool]`. Defaults
to keeping everything. The CLI filters `reward >= 0` unless `--keep-losses`
is set.
## Training captures
A loop-owning session can implement `TrainableSession.fetch_training_trace()` to
return a `TrainingTrace`. Harbor implements this API. Raw
`fetch_proxy_trace()` remains available for diagnostics and older consumers.
```python
from openenv.core.harness import TrainingTrace, TrainingTurn
trace = TrainingTrace(turns=[TrainingTurn(
node_id="call-0",
prompt_token_ids=[10, 11],
completion_token_ids=[20, 21, 22],
per_token_logps=[-0.2, -0.4, -0.1],
loss_mask=[0, 0, 1, 0, 1],
)])
restored = TrainingTrace.model_validate_json(trace.model_dump_json())
```
The mask covers prompt plus completion. Prompt positions are zero; completion
positions select the sampled tokens that receive loss. A zero-masked turn stays
available as context. Tokens, masks and finite nonpositive logprobs are validated
when the object is built or deserialized. Missing masks and duplicate call IDs
are errors. Tokens are never reconstructed from text.
The environment selects agent calls and excludes auxiliary calls and discarded
retries before returning this object. Shared graph nodes appear once. Selection
uses the existing capture classifier; its tool-manifest and retry heuristics are
not a substitute for explicit harness evidence. The raw graph remains available
for auditing. Changing a task's supervision means changing its producer mask,
not adding a turn-selection callback in the trainer.
`verify()` remains the source of the task reward. `None` is ungraded and `0` is an
incorrect answer. A single-call rollout produces a diagnostic warning, not a
capture failure. Masks do not alter the verifier score. Trainers own advantages,
loss weighting, packing and weight updates. Graph identity does not by itself
normalize a rollout that expands into multiple training rows.
For training, construct the session factory with the trainer's `sampling` policy.
Harbor applies it before inference, so the trainer need not compare sampling
metadata after generation. When logprob entries identify their tokens
(`token_id:N`), training export checks that each one pairs with its token. A disagreement on an agent turn rejects the training trace; raw
capture stays available for inspection. Plain token text is not
re-tokenized to infer an ID.
Migration from TRL selection hooks: use `fetch_training_trace()` instead of a raw
trace, move whole-turn exclusions into the producer's `loss_mask`, and keep final
answers eligible unless your task explicitly masks them. The old Harbor export
continues to read legacy captures; the strict API requires explicit masks and
node IDs. Evaluation-only captures cannot be used for training.
|