SaylorTwift HF Staff commited on
Commit
f0273c6
·
verified ·
1 Parent(s): 2b008b7

OpenCode-only LoRA RL on LFM2.5-2.6B (verified against trl 1.16.0 / openenv 0.10.0)

Browse files
Files changed (6) hide show
  1. README.md +104 -0
  2. export_adapter.py +95 -0
  3. run_job.sh +75 -0
  4. serve_env.sh +20 -0
  5. serve_policy.sh +25 -0
  6. 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()