A Custom Env for Your Real Task

Lesson 5 · SkillOpt & Agent Harnesses · ~30 minutes · hands-on

Warm-up: the env contract

From Lesson 4: what three files define a new SkillOpt benchmark environment?
That's the env contract. The dataloader loads task items with train/val/test splits. The rollout helper runs the target model on items and scores them. The initial.md is the seed skill (can be empty). Everything else is inherited from EnvAdapter.
The env contract is: dataloader.py (loads items with splits), rollout.py (runs target + scores), and initial.md (seed skill). The backend is separate (model interface), and configs reference your env by name. See the new-benchmark guide.

What you'll do

Build a custom environment for one of your own software development workflows. Instead of training on SearchQA's trivia questions, you'll define tasks that match what your agent actually does — and produce a skill that makes your daily work better.

The officeqa env is the simplest reference to copy from. We'll follow the same pattern, adapted to your domain.

Step 1 — Define your task domain

Pick a recurring workflow where your agent has a checkable correctness signal. Good candidates:

DomainTask shapeScoring signal
Code reviewGiven a diff, produce review commentsMatch against known issues (precision/recall)
Test generationGiven a function, produce tests that passpytest exit code (binary)
Commit messagesGiven a diff, produce a conventional commitFormat regex + semantic match
Bug fixGiven a failing test, produce a fixTest passes after patch (binary)
DocumentationGiven a function, produce docstringCoverage of params/returns/raises
The scoring function matters most

Per the guide: "Noisy scoring kills the optimizer. Spend time on run_batch's scoring before you spend time on prompts." Pick a domain where you can score deterministically — binary (pass/fail) is ideal for your first custom env.

Step 2 — Create the data

You need 20–50 task items split into train/val/test. Each item is a dict with at minimum:

# Each item needs:
{
    "id": "unique_string",
    "task_type": "your_domain",
    # ... your domain-specific fields:
    "input": "...",       # what the agent receives
    "ground_truth": "..." # what correct looks like
}

Create the split directories:

mkdir -p ~/skillopt-repo/data/my_env_split/{train,val,test}

# Write your items as JSON — split ratio ~2:1:7 (train:val:test)
# train: items the optimizer sees during reflection
# val: the gate scores against these (NEVER trained on)
# test: final evaluation only
Experience pool design matters

From SkillLens: all-failure experience pools produce the worst skills. Your train split should include mostly successful trajectories — the optimizer learns what to replicate, not just what to avoid. For coding tasks, this means including examples where the agent got it right, not just cases where it failed.

Step 3 — Implement the three files

dataloader.py

from skillopt.datasets.base import SplitDataLoader
from pathlib import Path
import json


class MyEnvDataLoader(SplitDataLoader):
    """Load items from JSON files in each split directory."""

    def load_split_items(self, split_path: str) -> list[dict]:
        json_files = sorted(Path(split_path).glob("*.json"))
        if not json_files:
            raise FileNotFoundError(f"No .json in {split_path}")
        with json_files[0].open() as f:
            return json.load(f)

rollout.py

from skillopt.model import chat_target
import os, json
from pathlib import Path


def _score(prediction: str, ground_truth: str) -> tuple[int, float]:
    """Your scoring function. Binary is simplest."""
    # Example: exact match (replace with your domain logic)
    match = prediction.strip() == ground_truth.strip()
    return (1 if match else 0), (1.0 if match else 0.0)


def run_batch(*, items, skill_content, out_root,
              workers=4, max_completion_tokens=4096) -> list[dict]:
    os.makedirs(out_root, exist_ok=True)
    results = []
    for item in items:
        prediction, _usage = chat_target(
            system=skill_content,
            user=item["input"],
            max_completion_tokens=max_completion_tokens,
        )
        hard, soft = _score(prediction, item["ground_truth"])
        results.append({
            "id": item["id"],
            "hard": hard,
            "soft": soft,
            "predicted_answer": prediction,
            "task_type": item.get("task_type", "my_env"),
        })
    Path(out_root, "rollouts.json").write_text(json.dumps(results, indent=2))
    return results

initial.md (seed skill)

# My Task Skill

You are a software development assistant. When given a task:
1. Read the input carefully
2. Produce the requested output
3. Follow the project's conventions

The seed skill can be minimal — the optimizer will grow it. But giving it a reasonable starting point (rather than empty) helps the first epoch.

Step 4 — Wire it up

Create the adapter (copy from officeqa/adapter.py and change the class names and dataloader import), then register it in scripts/train.py:

# In scripts/train.py → _register_builtins()
try:
    from skillopt.envs.my_env.adapter import MyEnvAdapter
    _ENV_REGISTRY["my_env"] = MyEnvAdapter
except ImportError:
    pass

Create a config at configs/my_env/default.yaml:

_base_: ../_base_/default.yaml

train:
  batch_size: 8
  num_epochs: 2

env:
  name: my_env
  skill_init: skillopt/envs/my_env/skills/initial.md
  split_mode: split_dir
  split_dir: data/my_env_split
  workers: 4
  max_completion_tokens: 4096

Step 5 — Train and evaluate

# Train
python scripts/train.py --config configs/my_env/default.yaml

# Evaluate the produced skill on held-out test
python scripts/evaluate.py \
  --config configs/my_env/default.yaml \
  --skill outputs/my_env/skills/best_skill.md \
  --split test

The best_skill.md that comes out is trained on your tasks. Read it with the rubric — and compare it to the generic seed skill you started with. The difference is what SkillOpt learned about your specific workflow.

Your custom env scores predictions with a noisy LLM judge (agreement ~70%). What's the likely outcome?
Exactly — "noisy scoring kills the optimizer." If your score function has 30% noise, a +3pp improvement is indistinguishable from random variance. The gate can't compensate because it uses the same scorer. Deterministic or near-deterministic scoring is essential for the first custom env.
The gate doesn't auto-adjust its threshold — it uses the same scorer. With 70% agreement, a +3pp improvement is noise. The optimizer will thrash: accepting harmful edits and rejecting good ones randomly. Use deterministic scoring (binary pass/fail, regex match, exact string) for your first custom env.
Recommended Reading

Add a New Benchmark — Microsoft Research. The full walkthrough with a worked docfaithful example, explaining every method on the EnvAdapter ABC. ~15 minutes. Copy skillopt/envs/_template/ for a skeleton.

Questions? If you're stuck picking a domain, designing a scoring function, or wiring the adapter — describe your workflow and I'll help you design the env contract for it.
Prev Next