OpenCode-only LoRA RL on LFM2.5-2.6B (verified against trl 1.16.0 / openenv 0.10.0)
Browse files- README.md +104 -0
- export_adapter.py +95 -0
- run_job.sh +75 -0
- serve_env.sh +20 -0
- serve_policy.sh +25 -0
- train_lora.py +143 -0
README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenCode-only RL on LFM2.5-2.6B with LoRA
|
| 2 |
+
|
| 3 |
+
Reproduction of the OpenCode-only run from
|
| 4 |
+
[The ultimate guide to multi-harness RL](https://huggingface.co/spaces/FineEnvs/multi-harness-rl),
|
| 5 |
+
but training a **LoRA adapter** instead of full weights, so the result is a small,
|
| 6 |
+
distributable adapter on top of `LiquidAI/LFM2.5-2.6B`.
|
| 7 |
+
|
| 8 |
+
Stack: OpenEnv (capture proxy + Harbor integration) → Harbor (tasks + sandboxes) →
|
| 9 |
+
TRL `AsyncGRPOTrainer` with `HarnessRolloutWorker` → vLLM policy server.
|
| 10 |
+
**Every API in this repo was verified against the released wheels (trl 1.16.0,
|
| 11 |
+
openenv 0.10.0) and the FineEnvs production env Space — not just the article.**
|
| 12 |
+
|
| 13 |
+
## Why merged-weight sync (source-verified)
|
| 14 |
+
|
| 15 |
+
Generation here goes harness → capture proxy → vLLM, addressed by a **fixed model
|
| 16 |
+
name**. TRL's adapter-only sync publishes versioned adapters (`trl-policy-v{N}`) and
|
| 17 |
+
only the trainer-built default worker learns the versioned name; an injected
|
| 18 |
+
`HarnessRolloutWorker` never gets one (`async_grpo_trainer.py` builds the name only
|
| 19 |
+
in its own worker branch). So the server runs *without* `--enable-lora` and the
|
| 20 |
+
trainer merges the LoRA into the base and NCCL-streams full weights each step
|
| 21 |
+
(~5.2 GB for 2.6B — trivial on one node). vLLM also generates 1.33–1.39x slower per
|
| 22 |
+
token when serving an adapter, so merged sync is the better default anyway.
|
| 23 |
+
**Checkpoints still contain only the LoRA adapter** — that is what you distribute.
|
| 24 |
+
|
| 25 |
+
## Files
|
| 26 |
+
|
| 27 |
+
| File | Purpose |
|
| 28 |
+
|---|---|
|
| 29 |
+
| `train_lora.py` | Worker wiring + `AsyncGRPOConfig` + `LoraConfig` (r=32 on q/k/v/out_proj, w1/2/3) |
|
| 30 |
+
| `serve_policy.sh` | vLLM with capture-proxy flags + NCCL weight transfer |
|
| 31 |
+
| `serve_env.sh` | `openenv harbor serve` with production env vars (`MAX_CONCURRENT_ENVS`, pinned OpenCode version, 4096 output cap) |
|
| 32 |
+
| `run_job.sh` | Single-container entrypoint for HF Jobs (setup + all 3 processes) |
|
| 33 |
+
| `export_adapter.py` | Publish any adapter checkpoint to the Hub (+ optional merged model) |
|
| 34 |
+
|
| 35 |
+
## Launch on HF Jobs
|
| 36 |
+
|
| 37 |
+
Two runs: a smoke run to validate the stack, then the full run.
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
# 0. One-time: put this folder on the Hub so the job can pull it
|
| 41 |
+
hf repos create YOU/lfm-opencode-lora-rl-code --type model
|
| 42 |
+
hf upload YOU/lfm-opencode-lora-rl-code . --exclude "__pycache__/**" "checkpoints/**"
|
| 43 |
+
|
| 44 |
+
# 1. Smoke run (~1h): validates weight sync, capture contract, importance ratio
|
| 45 |
+
hf jobs run vllm/vllm-openai:latest --flavor h200x2 --timeout 6h --detach \
|
| 46 |
+
--secrets HF_TOKEN="$HF_TOKEN" DAYTONA_API_KEY="..." \
|
| 47 |
+
--env ADAPTER_REPO=YOU/LFM2.5-2.6B-opencode-lora-RL MAX_STEPS=20 \
|
| 48 |
+
-- bash -c 'pip -q install huggingface_hub && hf download YOU/lfm-opencode-lora-rl-code --local-dir /app && bash /app/run_job.sh'
|
| 49 |
+
|
| 50 |
+
# 2. Watch it: weight sync should log merged transfers (no .vllm_lora/),
|
| 51 |
+
# and capture-proxy importance ratios should sit at ~0.998–0.9999.
|
| 52 |
+
hf jobs ps
|
| 53 |
+
hf jobs logs <job-id> --follow
|
| 54 |
+
|
| 55 |
+
# 3. Full run (the article's OpenCode-only run took ~32h on 2x H100)
|
| 56 |
+
hf jobs run vllm/vllm-openai:latest --flavor h200x2 --timeout 72h --detach \
|
| 57 |
+
--secrets HF_TOKEN="$HF_TOKEN" DAYTONA_API_KEY="..." \
|
| 58 |
+
--env ADAPTER_REPO=YOU/LFM2.5-2.6B-opencode-lora-RL MAX_STEPS=1000 \
|
| 59 |
+
-- bash -c 'pip -q install huggingface_hub && hf download YOU/lfm-opencode-lora-rl-code --local-dir /app && bash /app/run_job.sh'
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Checkpoints (adapter-only) push to `ADAPTER_REPO` at every save, so the job can die
|
| 63 |
+
without losing training. `E2B_API_KEY` + `SANDBOX=e2b` works instead of Daytona.
|
| 64 |
+
|
| 65 |
+
## What you distribute
|
| 66 |
+
|
| 67 |
+
Every checkpoint contains `adapter_model.safetensors` + `adapter_config.json`
|
| 68 |
+
(~50 MB at r=32). `export_adapter.py` adds a model card and pushes:
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
python export_adapter.py checkpoints/checkpoint-1000 YOU/LFM2.5-2.6B-opencode-lora-RL [--also-merged]
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
Consumers load it with `PeftModel.from_pretrained("LiquidAI/LFM2.5-2.6B", YOU/...)` or
|
| 75 |
+
`vllm serve LiquidAI/LFM2.5-2.6B --enable-lora --max-lora-rank 32 --lora-modules opencode-lora=YOU/...`
|
| 76 |
+
(`Lfm2ForCausalLM` implements `SupportsLoRA`).
|
| 77 |
+
|
| 78 |
+
## Recipe choices carried over from the article (don't regress these)
|
| 79 |
+
|
| 80 |
+
- `top_p=1.0`, `top_k=0` — truncation biases the policy and breaks logprob agreement
|
| 81 |
+
(importance ratio dropped to ~0.99 with top_p<1).
|
| 82 |
+
- Same 4096-token output cap in training and evaluation (`OPENENV_MAX_OUTPUT_TOKENS`
|
| 83 |
+
+ worker `max_tokens`). A Qwen run with 16k training answers collapsed at 4k eval.
|
| 84 |
+
- Correctness + <=0.1 tool-efficiency bonus on correct answers. Without it,
|
| 85 |
+
all-correct GRPO groups carry no signal and tool calls creep up (13 → 41).
|
| 86 |
+
- `num_generations=8`, one harness per group, so advantage compares actions.
|
| 87 |
+
- `max_tool_calling_iterations=32` on the worker — the loop-owning default would
|
| 88 |
+
silently cap the agent at 8 model calls.
|
| 89 |
+
- `max_staleness=4` (config default) matches the article's "never more than four
|
| 90 |
+
optimizer steps old".
|
| 91 |
+
|
| 92 |
+
## Honest caveats
|
| 93 |
+
|
| 94 |
+
- `HarnessRolloutWorker` + `peft_config` is not a combination the article tested
|
| 95 |
+
(their runs were full fine-tunes). The pieces are all released and compatible on
|
| 96 |
+
paper — the smoke run exists to prove it before you spend 32+ GPU-hours.
|
| 97 |
+
- Expect the article's single-harness shape: gains concentrate in OpenCode
|
| 98 |
+
(34 → 58% there in their run) and transfer unevenly elsewhere.
|
| 99 |
+
- LoRA r=32 may cap peak accuracy vs full FT; no published comparison on this stack.
|
| 100 |
+
- Costs: h200x2 for ~32–46h is real money, plus ~1000s of Daytona sandbox-hours.
|
| 101 |
+
The smoke run is the cheap insurance.
|
| 102 |
+
- Task data: SmolDataEnvs-harbor-train (1,000 tasks) / -test (250), public,
|
| 103 |
+
non-gated. For the pinned, hash-checked variant see the `prepare.py` flow in the
|
| 104 |
+
[FineEnvs env Space](https://huggingface.co/spaces/FineEnvs/smoldataenv-multi-harness-harbor).
|
export_adapter.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Publish a trained LoRA checkpoint to the Hub.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python export_adapter.py checkpoints/checkpoint-1000 you/LFM2.5-2.6B-opencode-lora-RL
|
| 5 |
+
python export_adapter.py checkpoints/final you/LFM2.5-2.6B-opencode-lora-RL --also-merged
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
import shutil
|
| 10 |
+
import tempfile
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from huggingface_hub import HfApi
|
| 14 |
+
|
| 15 |
+
BASE_MODEL = "LiquidAI/LFM2.5-2.6B"
|
| 16 |
+
|
| 17 |
+
MODEL_CARD = """---
|
| 18 |
+
license: other
|
| 19 |
+
base_model: {base}
|
| 20 |
+
tags:
|
| 21 |
+
- lora
|
| 22 |
+
- rl
|
| 23 |
+
- grpo
|
| 24 |
+
- agentic-rl
|
| 25 |
+
- opencode
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
# {repo} — LoRA adapter
|
| 29 |
+
|
| 30 |
+
LoRA adapter (rank {rank}) trained with async GRPO **inside the OpenCode agent harness**
|
| 31 |
+
on [SmolDataEnvs](https://huggingface.co/collections/FineEnvs/smoldataenvs) tasks, using
|
| 32 |
+
the OpenEnv capture proxy + Harbor + TRL stack from
|
| 33 |
+
[The ultimate guide to multi-harness RL](https://huggingface.co/spaces/FineEnvs/multi-harness-rl).
|
| 34 |
+
|
| 35 |
+
Fits `{base}` only.
|
| 36 |
+
|
| 37 |
+
## Load it
|
| 38 |
+
|
| 39 |
+
```python
|
| 40 |
+
from transformers import AutoModelForCausalLM
|
| 41 |
+
from peft import PeftModel
|
| 42 |
+
|
| 43 |
+
base = AutoModelForCausalLM.from_pretrained("{base}")
|
| 44 |
+
model = PeftModel.from_pretrained(base, "{repo}")
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
# vLLM
|
| 49 |
+
vllm serve {base} --enable-lora --max-lora-rank {rank} \\
|
| 50 |
+
--lora-modules opencode-lora={repo}
|
| 51 |
+
```
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def main():
|
| 56 |
+
p = argparse.ArgumentParser()
|
| 57 |
+
p.add_argument("checkpoint", type=Path, help="checkpoint dir containing adapter_model.safetensors")
|
| 58 |
+
p.add_argument("repo_id", help="Hub repo for the adapter, e.g. you/LFM2.5-2.6B-opencode-lora-RL")
|
| 59 |
+
p.add_argument("--also-merged", action="store_true",
|
| 60 |
+
help="also push a merged full model to <repo_id>-merged")
|
| 61 |
+
args = p.parse_args()
|
| 62 |
+
|
| 63 |
+
assert (args.checkpoint / "adapter_model.safetensors").exists(), \
|
| 64 |
+
f"no adapter_model.safetensors in {args.checkpoint} — is this a PEFT checkpoint?"
|
| 65 |
+
|
| 66 |
+
import json
|
| 67 |
+
rank = json.loads((args.checkpoint / "adapter_config.json").read_text())["r"]
|
| 68 |
+
|
| 69 |
+
api = HfApi()
|
| 70 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 71 |
+
tmp = Path(tmp)
|
| 72 |
+
shutil.copy(args.checkpoint / "adapter_model.safetensors", tmp)
|
| 73 |
+
shutil.copy(args.checkpoint / "adapter_config.json", tmp)
|
| 74 |
+
(tmp / "README.md").write_text(MODEL_CARD.format(base=BASE_MODEL, repo=args.repo_id, rank=rank))
|
| 75 |
+
api.create_repo(args.repo_id, exist_ok=True)
|
| 76 |
+
api.upload_folder(repo_id=args.repo_id, folder_path=tmp)
|
| 77 |
+
print(f"adapter -> https://huggingface.co/{args.repo_id}")
|
| 78 |
+
|
| 79 |
+
if args.also_merged:
|
| 80 |
+
import torch
|
| 81 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 82 |
+
from peft import PeftModel
|
| 83 |
+
|
| 84 |
+
base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, torch_dtype=torch.bfloat16)
|
| 85 |
+
model = PeftModel.from_pretrained(base, args.checkpoint)
|
| 86 |
+
merged = model.merge_and_unload()
|
| 87 |
+
merged_id = f"{args.repo_id}-merged"
|
| 88 |
+
api.create_repo(merged_id, exist_ok=True)
|
| 89 |
+
merged.push_to_hub(merged_id)
|
| 90 |
+
AutoTokenizer.from_pretrained(BASE_MODEL).push_to_hub(merged_id)
|
| 91 |
+
print(f"merged -> https://huggingface.co/{merged_id}")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
if __name__ == "__main__":
|
| 95 |
+
main()
|
run_job.sh
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Single-job entrypoint for HF Jobs (2-GPU flavor, e.g. h200x2).
|
| 3 |
+
# Runs all three processes in one container:
|
| 4 |
+
# GPU 1: vLLM policy server (serve_policy.sh)
|
| 5 |
+
# CPU: OpenEnv Harbor env server (serve_env.sh)
|
| 6 |
+
# GPU 0: TRL AsyncGRPOTrainer (train_lora.py)
|
| 7 |
+
#
|
| 8 |
+
# Expected env (via `hf jobs run --secrets/--env`):
|
| 9 |
+
# HF_TOKEN (secret) hub downloads + checkpoint pushes
|
| 10 |
+
# DAYTONA_API_KEY (secret) sandboxes — or E2B_API_KEY with SANDBOX=e2b
|
| 11 |
+
# ADAPTER_REPO (env) Hub repo that receives adapter checkpoints
|
| 12 |
+
# MAX_STEPS (env) 20 for the smoke run, 1000 for the full run
|
| 13 |
+
# SANDBOX (env) daytona (default) | e2b
|
| 14 |
+
set -euo pipefail
|
| 15 |
+
cd "$(dirname "$0")"
|
| 16 |
+
|
| 17 |
+
echo "=== [setup] packages ==="
|
| 18 |
+
# vllm + torch come from the base image (vllm/vllm-openai). Per TRL docs,
|
| 19 |
+
# transformers>=5.2.0 may need --no-deps next to vllm's pinned stack.
|
| 20 |
+
python -m pip install --quiet 'transformers>=5.2.0' --no-deps
|
| 21 |
+
python -m pip install --quiet \
|
| 22 |
+
'trl[peft]>=1.16.0' \
|
| 23 |
+
'openenv[harbor]' \
|
| 24 |
+
'harbor[daytona]>=0.22.0' \
|
| 25 |
+
math-verify \
|
| 26 |
+
aiohttp
|
| 27 |
+
|
| 28 |
+
echo "=== [setup] OpenEnv repo (harbor_env lives in envs/, not the wheel) ==="
|
| 29 |
+
git clone --depth 1 https://github.com/huggingface/OpenEnv.git /opt/OpenEnv
|
| 30 |
+
SITE=$(python -c "import site; print(site.getsitepackages()[0])")
|
| 31 |
+
echo "/opt/OpenEnv/envs" > "$SITE/openenv_examples.pth"
|
| 32 |
+
|
| 33 |
+
echo "=== [check] imports ==="
|
| 34 |
+
python - <<'EOF'
|
| 35 |
+
import trl, openenv, harbor_env # noqa: F401
|
| 36 |
+
from trl.experimental.async_grpo.openenv_harness import HarnessRolloutWorker # noqa: F401
|
| 37 |
+
print("trl", trl.__version__, "| openenv", openenv.__version__)
|
| 38 |
+
EOF
|
| 39 |
+
|
| 40 |
+
echo "=== [start] vLLM policy server (GPU 1) ==="
|
| 41 |
+
CUDA_VISIBLE_DEVICES=1 VLLM_SERVER_DEV_MODE=1 \
|
| 42 |
+
vllm serve "${MODEL:-LiquidAI/LFM2.5-2.6B}" \
|
| 43 |
+
--port 8001 \
|
| 44 |
+
--max-model-len 32768 \
|
| 45 |
+
--return-tokens-as-token-ids \
|
| 46 |
+
--logprobs-mode processed_logprobs \
|
| 47 |
+
--weight-transfer-config '{"backend":"nccl"}' &
|
| 48 |
+
VLLM_PID=$!
|
| 49 |
+
|
| 50 |
+
echo "=== [start] env server (CPU) ==="
|
| 51 |
+
bash serve_env.sh &
|
| 52 |
+
ENV_PID=$!
|
| 53 |
+
|
| 54 |
+
echo "=== [wait] servers ==="
|
| 55 |
+
python - <<'EOF'
|
| 56 |
+
import time, urllib.request
|
| 57 |
+
for name, url in [("vllm", "http://127.0.0.1:8001/v1/models"),
|
| 58 |
+
("env", "http://127.0.0.1:8000/health")]:
|
| 59 |
+
for i in range(360): # up to 30 min for model load
|
| 60 |
+
try:
|
| 61 |
+
urllib.request.urlopen(url, timeout=5)
|
| 62 |
+
print(name, "ready")
|
| 63 |
+
break
|
| 64 |
+
except Exception:
|
| 65 |
+
time.sleep(5)
|
| 66 |
+
else:
|
| 67 |
+
raise SystemExit(f"{name} server never became ready")
|
| 68 |
+
EOF
|
| 69 |
+
|
| 70 |
+
echo "=== [train] GPU 0, MAX_STEPS=${MAX_STEPS:-1000} ==="
|
| 71 |
+
CUDA_VISIBLE_DEVICES=0 VLLM_URL=http://127.0.0.1:8001 ENV_URL=http://127.0.0.1:8000 \
|
| 72 |
+
python train_lora.py
|
| 73 |
+
|
| 74 |
+
kill $VLLM_PID $ENV_PID 2>/dev/null || true
|
| 75 |
+
echo "=== done ==="
|
serve_env.sh
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# OpenEnv Harbor environment server: Task API + capture proxy + web UI.
|
| 3 |
+
# Sandboxes run on Daytona (default) or E2B — needs DAYTONA_API_KEY / E2B_API_KEY.
|
| 4 |
+
#
|
| 5 |
+
# Concurrency and capture settings are env vars, not CLI flags (verified against
|
| 6 |
+
# openenv 0.10.0 and the FineEnvs production env Space). MAX_CONCURRENT_ENVS must
|
| 7 |
+
# exceed the GRPO group size (8) or rollouts are rejected with CAPACITY_REACHED.
|
| 8 |
+
set -euo pipefail
|
| 9 |
+
|
| 10 |
+
export MAX_CONCURRENT_ENVS="${MAX_CONCURRENT_ENVS:-16}"
|
| 11 |
+
export OPENENV_MAX_OUTPUT_TOKENS=4096 # same cap as training + eval
|
| 12 |
+
export OPENENV_HARBOR_REWARD_KEY="correctness,reward"
|
| 13 |
+
# Pin the harness build so rollouts are reproducible (versions from the FineEnvs Space).
|
| 14 |
+
export OPENENV_HARBOR_AGENT_VERSIONS='{"opencode":"1.18.31"}'
|
| 15 |
+
|
| 16 |
+
openenv harbor serve \
|
| 17 |
+
--dataset FineEnvs/SmolDataEnvs-harbor-train,FineEnvs/SmolDataEnvs-harbor-test \
|
| 18 |
+
--port "${ENV_PORT:-8000}" \
|
| 19 |
+
--capture-port "${CAPTURE_PORT:-8100}" \
|
| 20 |
+
--expose gradio
|
serve_policy.sh
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Policy server: vLLM serving LiquidAI/LFM2.5-2.6B on GPU 1.
|
| 3 |
+
#
|
| 4 |
+
# Flag provenance:
|
| 5 |
+
# --return-tokens-as-token-ids capture proxy needs the exact sampled token ids
|
| 6 |
+
# --logprobs-mode processed_logprobs proxy needs per-token logprobs that match the policy
|
| 7 |
+
# --weight-transfer-config TRL AsyncGRPOTrainer pushes merged weights over NCCL
|
| 8 |
+
# VLLM_SERVER_DEV_MODE=1 exposes the weight-update endpoint TRL posts to
|
| 9 |
+
#
|
| 10 |
+
# Deliberately NOT passed:
|
| 11 |
+
# --enable-lora adapter sync rotates adapter names, which the
|
| 12 |
+
# OpenEnv capture proxy cannot address; the trainer
|
| 13 |
+
# merges the LoRA into the base and syncs full weights
|
| 14 |
+
set -euo pipefail
|
| 15 |
+
|
| 16 |
+
MODEL="${MODEL:-LiquidAI/LFM2.5-2.6B}"
|
| 17 |
+
PORT="${PORT:-8001}"
|
| 18 |
+
|
| 19 |
+
CUDA_VISIBLE_DEVICES=1 VLLM_SERVER_DEV_MODE=1 \
|
| 20 |
+
vllm serve "$MODEL" \
|
| 21 |
+
--port "$PORT" \
|
| 22 |
+
--max-model-len 32768 \
|
| 23 |
+
--return-tokens-as-token-ids \
|
| 24 |
+
--logprobs-mode processed_logprobs \
|
| 25 |
+
--weight-transfer-config '{"backend":"nccl"}'
|
train_lora.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenCode-only async GRPO on LFM2.5-2.6B with a LoRA adapter.
|
| 2 |
+
|
| 3 |
+
All APIs verified against the released wheels (trl 1.16.0, openenv 0.10.0):
|
| 4 |
+
- HarnessRolloutOutcome fields: env_reward, tool_call_count, tool_failure_count, ...
|
| 5 |
+
- _AsyncRolloutLoop requires eos_token_ids; max_tool_calling_iterations defaults to
|
| 6 |
+
max_turns=8 in loop-owning mode, so it is set explicitly.
|
| 7 |
+
- AsyncGRPOConfig is TrainingArguments-derived (no use_vllm/vllm_mode); the vLLM
|
| 8 |
+
server is given as vllm_server_base_url; dtype defaults to float32, so set it.
|
| 9 |
+
- With an injected rollout_worker, the trainer never assigns a versioned lora_name,
|
| 10 |
+
so adapter-only sync cannot address the policy through the capture proxy: the
|
| 11 |
+
vLLM server runs WITHOUT --enable-lora and the trainer streams merged weights
|
| 12 |
+
(its default when the server lacks --enable-lora). Checkpoints still contain
|
| 13 |
+
only the LoRA adapter.
|
| 14 |
+
|
| 15 |
+
Reward: correctness (1/0) + tool-efficiency bonus of at most 0.1 on correct
|
| 16 |
+
answers, the recipe the article's LFM runs used. outcome.tool_call_count comes
|
| 17 |
+
from the harness's own ATIF trace.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import os
|
| 21 |
+
from functools import partial
|
| 22 |
+
|
| 23 |
+
from datasets import load_dataset
|
| 24 |
+
from peft import LoraConfig
|
| 25 |
+
from transformers import AutoTokenizer
|
| 26 |
+
from trl.experimental.async_grpo.async_grpo_config import AsyncGRPOConfig
|
| 27 |
+
from trl.experimental.async_grpo.async_grpo_trainer import AsyncGRPOTrainer
|
| 28 |
+
from trl.experimental.async_grpo.openenv_harness import HarnessRolloutWorker
|
| 29 |
+
|
| 30 |
+
from harbor_env.harness import HarborSessionFactory # via .pth to OpenEnv/envs
|
| 31 |
+
|
| 32 |
+
MODEL = "LiquidAI/LFM2.5-2.6B"
|
| 33 |
+
VLLM_URL = os.environ.get("VLLM_URL", "http://127.0.0.1:8001") # serve_policy.sh
|
| 34 |
+
ENV_URL = os.environ.get("ENV_URL", "http://127.0.0.1:8000") # serve_env.sh
|
| 35 |
+
TRAIN_SPLIT = "FineEnvs/SmolDataEnvs-harbor-train"
|
| 36 |
+
SANDBOX = os.environ.get("SANDBOX", "daytona") # or "e2b"
|
| 37 |
+
MAX_STEPS = int(os.environ.get("MAX_STEPS", "1000"))
|
| 38 |
+
ADAPTER_REPO = os.environ.get("ADAPTER_REPO", "") # push checkpoints here
|
| 39 |
+
|
| 40 |
+
TOOL_CALL_BUDGET = 32 # reference for the efficiency bonus; tune to the task pool
|
| 41 |
+
MAX_BONUS = 0.1
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def correctness_plus_efficiency(outcome):
|
| 45 |
+
"""1/0 correctness, plus <= 0.1 for a correct answer in fewer tool calls.
|
| 46 |
+
|
| 47 |
+
A wrong answer stays 0, so the bonus can never be earned by giving up early.
|
| 48 |
+
Returning None keeps an unverified rollout (dead sandbox) unscorable.
|
| 49 |
+
"""
|
| 50 |
+
reward = outcome.env_reward
|
| 51 |
+
if reward is None or reward <= 0:
|
| 52 |
+
return reward
|
| 53 |
+
bonus = MAX_BONUS * max(0.0, (TOOL_CALL_BUDGET - outcome.tool_call_count) / TOOL_CALL_BUDGET)
|
| 54 |
+
return reward + min(bonus, MAX_BONUS)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def make_rollout_worker(dataset, tokenizer):
|
| 58 |
+
factory = partial(
|
| 59 |
+
HarborSessionFactory,
|
| 60 |
+
server_url=ENV_URL,
|
| 61 |
+
split=TRAIN_SPLIT,
|
| 62 |
+
llm_url=VLLM_URL,
|
| 63 |
+
model=MODEL,
|
| 64 |
+
harness="opencode", # OpenCode-only run
|
| 65 |
+
sandbox=SANDBOX,
|
| 66 |
+
reward_key="correctness,reward",
|
| 67 |
+
)
|
| 68 |
+
return HarnessRolloutWorker(
|
| 69 |
+
harness_session_factory=factory,
|
| 70 |
+
harness_adapter=None, # let the harness own the loop (black-box capture)
|
| 71 |
+
rollout_reward_fn=correctness_plus_efficiency,
|
| 72 |
+
model_name=MODEL,
|
| 73 |
+
dataset=dataset,
|
| 74 |
+
processing_class=tokenizer,
|
| 75 |
+
eos_token_ids=[tokenizer.eos_token_id], # required loop arg
|
| 76 |
+
reward_funcs=[],
|
| 77 |
+
num_generations=8, # one GRPO group = 8 rollouts of one task, one harness
|
| 78 |
+
max_inflight_tasks=8,
|
| 79 |
+
vllm_server_url=VLLM_URL,
|
| 80 |
+
max_tokens=4096, # same cap in training and eval — do not regress this
|
| 81 |
+
max_tool_calling_iterations=32, # loop default would cap the agent at 8 turns
|
| 82 |
+
temperature=1.0,
|
| 83 |
+
top_p=1.0, # no truncation: keeps sampled dist. == policy dist.
|
| 84 |
+
top_k=0, # 0 = disabled in current vLLM
|
| 85 |
+
chat_template_kwargs={"enable_thinking": False},
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def main():
|
| 90 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL)
|
| 91 |
+
dataset = load_dataset(TRAIN_SPLIT, split="train")
|
| 92 |
+
worker = make_rollout_worker(dataset, tokenizer)
|
| 93 |
+
|
| 94 |
+
config_kwargs = {
|
| 95 |
+
"output_dir": "./checkpoints",
|
| 96 |
+
"vllm_server_base_url": VLLM_URL,
|
| 97 |
+
"dtype": "bfloat16", # config default is float32
|
| 98 |
+
"bf16": True,
|
| 99 |
+
"learning_rate": 1e-6,
|
| 100 |
+
"max_steps": MAX_STEPS,
|
| 101 |
+
"logging_steps": 1,
|
| 102 |
+
# Checkpoints ARE your LoRA adapters. Never set save_strategy="no".
|
| 103 |
+
"save_strategy": "steps",
|
| 104 |
+
"save_steps": 100,
|
| 105 |
+
"save_total_limit": 12,
|
| 106 |
+
"report_to": [],
|
| 107 |
+
"seed": 42,
|
| 108 |
+
}
|
| 109 |
+
if ADAPTER_REPO:
|
| 110 |
+
# Jobs are ephemeral: push every checkpoint (adapter-only) to the Hub.
|
| 111 |
+
config_kwargs.update(
|
| 112 |
+
push_to_hub=True,
|
| 113 |
+
hub_model_id=ADAPTER_REPO,
|
| 114 |
+
hub_strategy="every_save",
|
| 115 |
+
)
|
| 116 |
+
config = AsyncGRPOConfig(**config_kwargs)
|
| 117 |
+
|
| 118 |
+
# LoRA on attention + MLP projections. Module names verified against the
|
| 119 |
+
# LFM2.5-2.6B safetensors index (self_attn.{q,k,v,out}_proj, feed_forward.w{1,2,3}).
|
| 120 |
+
# Short-conv projections (conv.in_proj/out_proj) are left out for portability.
|
| 121 |
+
peft_config = LoraConfig(
|
| 122 |
+
r=32,
|
| 123 |
+
lora_alpha=64,
|
| 124 |
+
lora_dropout=0.0,
|
| 125 |
+
target_modules=["q_proj", "k_proj", "v_proj", "out_proj", "w1", "w2", "w3"],
|
| 126 |
+
task_type="CAUSAL_LM",
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
trainer = AsyncGRPOTrainer(
|
| 130 |
+
model=MODEL,
|
| 131 |
+
args=config,
|
| 132 |
+
processing_class=tokenizer,
|
| 133 |
+
rollout_worker=worker,
|
| 134 |
+
peft_config=peft_config,
|
| 135 |
+
)
|
| 136 |
+
trainer.train()
|
| 137 |
+
trainer.save_model("./checkpoints/final") # adapter_model.safetensors + adapter_config.json
|
| 138 |
+
if ADAPTER_REPO:
|
| 139 |
+
trainer.push_to_hub()
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
if __name__ == "__main__":
|
| 143 |
+
main()
|