Configuration

August 15, 2026 · View on GitHub

A run is assembled from three places, and knowing which is which saves most of the confusion:

what it setswhere
The training scriptmodel, GPUs, batch sizes, budgets — as hydra overridesexamples/train/<env>/*.sh
The dataset yamlwhich environments, how many, how many turns, per-turn budgetsdata.train_files / data.val_files
The base configeverything else, with defaultsvagen/configs/vagen_multiturn.yaml

!!! warning "baseline_vllm.flags overrides the base config" Every shipped script sources vagen/configs/baseline_vllm.flags, and it wins over vagen_multiturn.yaml. Two that matter: data.max_prompt_length is 1000 in the flags file and 9000 in the yaml, and actor_rollout_ref.rollout.name is vllm in the flags file and sglang in the yaml. A model launcher can override the flags again later (InternVL uses 1600 prompt tokens and GLM uses 1400), so the final command-line occurrence wins.


Context policy — trainer.harness

The central choice. An episode is many turns; a training row is one conversation. The harness decides how the first maps onto the second.

harnessone episode becomesuse when
concatone row holding every turnthe default; the episode fits the response region
no_concatone row per turn, each a fresh conversationhistory is not needed, or will not fit
compacta row per conversation, summarised and reopened when fulllong episodes that must keep context

compact is closely related to CompactionRL (arXiv:2607.05378, Li et al., 2026), which trains task execution and summary generation jointly under context compaction. Here the summary is produced by the policy and trained with everything else, so the same comparison applies.

trainer:
  harness: compact
  compact_budget: 1200          # compact only: the conversation size that triggers a summary.
                                # ★ Size it against max_turns -- see the tip below. The
                                # shipped default is 4000, which at sokoban's max_turns: 5
                                # holds a whole episode and so never fires. 1200 is what
                                # train_default_gae_compact_qwen25vl3b.sh uses.
  compact_summary_budget: null  # null -> max(1, min(response_length_per_turn, compact_budget // 4))

!!! danger "harness and algorithm.adv_estimator are one choice" no_concat and compact split an episode across rows, so a per-row estimator scores a fraction of an episode as though it were the whole thing. The trainer refuses the combination at startup rather than training on it:

```
ValueError: algorithm.adv_estimator=... scores one row at a time, but
trainer.harness=... splits an episode across rows
```

Use a trajectory estimator with those two — see the table below.

!!! tip "compact_budget has to be sized against max_turns" Compaction fires only if a whole episode does not fit in one conversation. At sokoban's max_turns: 5 a budget of 2100 holds the entire episode, nothing is ever summarised, and the arm silently reports concat's numbers under another name. 1200 buys about three turns and then two.

Adding your own

harness is not a closed set. Either register a BaseHarness subclass, or give an import path and register nothing:

from vagen.core.harness import BaseHarness
from vagen.harness import register_harness

@register_harness("mine")
class MyHarness(BaseHarness): ...
# training
trainer:
  harness: mine    # a registered name; needs the module imported in every worker, below
# evaluation -- examples/evaluate/<env>/config.yaml
envs:
  - name: Sokoban
    harness: mypkg.harnesses:MyHarness   # an import path

(Only one harness key per block. Writing it twice under the same trainer: is a duplicate mapping key, and OmegaConf raises an error.)

In training, verl builds a separate registry in each worker process, so your module has to be imported inside every one of them. That is what actor_rollout_ref.model.external_lib does.

★ In evaluation, use the import path. run_eval never imports anything on your behalf — there is no external_lib setting there — so a registered name on its own fails with:

unknown harness 'mine'; choose from ['compact', 'concat', 'no_concat']

Advantage estimator — algorithm.adv_estimator

VAGEN overrides verl's default gae with default_gae. verl's own estimators open every row with nextvalues=0, which is right only when the row ends in a true terminal state and wrong when it is merely one turn of a longer trajectory.

estimatorscoresextra parameter
default_gaethe trajectory, rows stitched back together — the default
bi_level_gaeturn-level outer chain, token-level inner — the published VAGEN algorithm+algorithm.high_level_gamma=0.9 in shipped experiments
turn_level_gaeper turn
token_level_gaeper token, across the trajectory
trajectory_grpogroup-relative, over trajectories
verl's gae, grpo, …one row — concat only

high_level_gamma does not appear in the base schema, so it goes on the command line. The code can fall back to algorithm.gamma, but that is not the published/shipped experiment: when the two clocks use the same default, bi-level GAE can degenerate toward token-level GAE. Set it explicitly for reproduction.

algorithm.adv_estimator=bi_level_gae +algorithm.high_level_gamma=0.9

!!! danger "One more startup refusal" All but trajectory_grpo need a critic and refuse without critic.enable=True — a ValueError at startup, the same class of trap as the harness rule above.

An estimator may also declare `undiscounted=True` at registration, which refuses the
run unless `algorithm.gamma=1.0`. None of the shipped estimators does; it is there for
a custom one that mixes a per-token and a per-turn clock, where the two only agree at
1.0 and the disagreement otherwise looks like noise rather than a bug.

!!! warning "Timeout truncation is not yet value-bootstrapped" TurnLimit distinguishes an environment termination from a max_turns truncation, but the final observation value is not yet carried into the training batch. Both GAE implementations therefore currently start their backward recursion from zero at either kind of episode boundary. Do not describe a time-limit cutoff as theoretically terminal when interpreting returns; proper truncation bootstrapping remains follow-up work.


The dataset yaml

One entry per environment family. examples/train/sokoban/train_sokoban_vision.yaml is the worked example.

envs:
  - name: Sokoban          # a key in vagen/configs/env_registry.yaml
    n_envs: 10000          # how many instances to materialise
    data_source: sokoban   # a label, for logging
    seed: [1, 10000, 1]
    max_turns: 5
    response_length_per_turn: 512
    max_env_response_per_turn: 256
    config: {}             # environment settings; shared wrappers are handled first
keymeaning
nameregistered environment name
tag_idrequired in an eval config. Names the results directory (tag_<id>) and is part of the resume key, so two env entries in one run must not share one
n_envsnumber of instances
data_sourcelabel only
seedsee Seeds
seed_listexplicit seeds, at least n_envs of them; overrides seed
max_turnsenvironment steps per episode. A cap, not an expectation
response_length_per_turnhard cap on one generation — it becomes max_tokens
max_env_response_per_turnceiling on one observation; over it the text is cut. Default 2048
thinking_token_budgettokens allowed inside a reasoning block — see Budgets
env_response_lengthdeprecated spelling of max_env_response_per_turn; setting both raises
configenvironment-specific settings. state_reward is removed to build its shared wrapper; remaining keys initialize the environment config dataclass

Seeds

One of these three forms (not a block to paste — each line is a whole alternative):

seed: [7]                # 1 element: a base seed; actual seeds are sampled from [0, 2**31-1]
seed: [0, 99]            # 2 elements: sampled from the INCLUSIVE range, with repeats
seed: [0, 99, 1]         # 3 elements: as above, each value used at most `limit` times

!!! warning "The third element is an occurrence limit, not a step, and the range is inclusive" With limit: 1 the loader draws n_envs values from range(min, max+1) without replacement. So n_envs must equal max - min + 1, or one value is dropped — the same one on every run at a fixed data.base_seed, since the RNG is seeded from it — and where an environment indexes its dataset as seed % len(dataset), the surplus wraps onto item 0 and scores it twice. tests/test_eval_matches_val.py checks this for the shipped eval configs.

Training and evaluation derive seeds identically, so one directive gives the same seeds on both sides. The global offset is data.base_seed, which is not in verl's data schema and so needs the + prefix on the command line:

+data.base_seed=1234

Without it hydra rejects the key; written as data.base_seed= it is silently ignored and stays 0.

Budgets

Two independent caps that do different things:

  • response_length_per_turn becomes max_tokens. A guillotine: the model is never told about it, so it plans as if unbounded and is cut wherever it happens to be.
  • thinking_token_budget makes the engine force the closing tag when a reasoning block runs long, so the turn is bounded rather than truncated and still produces an answer.

The second needs the engine to know where a reasoning block begins and ends. That is per-model, so it lives in the training script rather than here:

+actor_rollout_ref.rollout.engine_kwargs.vllm.reasoning_config.reasoning_start_str="'<think>'" \
+actor_rollout_ref.rollout.engine_kwargs.vllm.reasoning_config.reasoning_end_str="'</think>'"

vLLM refuses the request if the budget is set and these are not. The nested quoting is required: hydra's override lexer rejects a bare <think>.

!!! note "A thinking budget bounds the block, not the response" Measured on Qwen3.5-4B, thinking_token_budget: 512 closes the block at exactly 511 tokens — but the model often carries on in the visible content. It buys a well-formed, scoreable turn; it does not buy brevity.


Per-environment config:

Most keys are passed to the environment's config dataclass, so they differ per environment — and so do the defaults for keys they share. state_reward is the shared exception: VAGEN removes it first and builds StateRewardWrapper; max_turns lives outside this block and builds TurnLimit. Sokoban (vagen/envs/sokoban/sokoban_env.py):

keydefaultnotes
render_modetexttext or vision
prompt_formatwmsee below
format_reward0.1ordinary shipped yamls set 0.02; the state-reward yamls set 0.03
success_reward1.0
strict_formattruea malformed turn has its actions discarded
use_example_in_sys_prompttrue
max_actions_per_step, action_sep, dim_room, num_boxes, max_steps, min_solution_steps

FrozenLake's defaults are not the same — prompt_format defaults to free_think and format_reward to 0.02. Read the dataclass for the environment you are configuring.

prompt_format

formatshapeavailable on
wm<observation><think><answer><prediction>sokoban, frozenlake, primitive_skill
free_thinkfree reasoning closed by </think>, then an accepted action markersokoban, frozenlake, primitive_skill
free_wmobservation / answer / prediction, free prose betweensokoban only
answer<answer> onlysokoban only
wm, free_think, no_think, eval_modenavigation's own set, and it tags the action <action>, not <answer>navigation only

SpatialGym has no prompt_format: the field is init=False and it parses THINK: / FINAL ANSWER: labels with a whole-text fallback.

!!! danger "Thinking delimiters are model-family specific" Qwen2.5-VL and InternVL3/3.5 can emit a literal <think> string. On Qwen3-VL, Qwen3.5 and GLM, thinking delimiters are reserved control tokens and the chat template may open the reasoning channel before generation. A strict wm prompt that demands a newly generated literal <think> can therefore score zero even when actions are sound.

`free_think` permits either opening convention, but still requires reasoning to close
with `</think>` before the action. For GLM, the parser also accepts its native
`<|begin_of_box|>…<|end_of_box|>` action marker. This is parser equivalence only: VAGEN
does not replace or rewrite the sampled text, token IDs, or rollout logprobs into
`<answer>`. Note that `primitive_skill` *defaults* to `wm`.

Model-family compatibility

The shared vLLM flags enable fused kernels, but the experimental model launchers need family-specific exceptions:

  • InternVL3.5: set actor_rollout_ref.model.use_fused_kernels=False; the generic fused path is text-only for an unknown VLM. Also keep engine_kwargs.vllm.hf_overrides.tie_word_embeddings=false, so vLLM loads the checkpoint's separate language-model head.
  • GLM-4.6V-Flash: keep native thinking enabled, raw rollout logprobs enabled, and the GPT-J-style multimodal RoPE compatibility hook used by its launcher.

The shipped launchers still select vLLM. Installing the SGLang extra does not switch their training backend. Before a long run, use the exact launcher and append trainer.val_only=true trainer.save_freq=-1 trainer.test_freq=-1 to validate prompt, parser, vision inputs and harness together.


State reward

An optional extra reward for what the agent says about the world, alongside what it does. A judge model reads the <observation> and <prediction> sections, turns them into structured relations, and compares those with the environment's real state. Off by default.

★ It is configured under envs[].config.state_reward, not under trainer. Training and evaluation both construct the environment from that block, so the same reward definition applies in both paths. The old trainer.state_reward location is deleted, with no fallback.

There are two halves to it:

  1. The per-environment settings, under envs[].config.state_reward: which sections to score, the absolute reward for one perfect section on one turn, and the judge endpoint.
  2. The environment's own spec, a STATE_REWARD_SPEC attribute on the environment class. This is what reads the true state and phrases the question for the judge, so it has to be written per environment. Sokoban's is vagen/envs/sokoban/state_reward_spec.py, set on the class in sokoban_env.py. Turning the reward on for an environment that has no spec raises an error rather than quietly scoring zero.

Sokoban already ships a spec. A complete working example is examples/train/sokoban/train_bi_level_gae_sr_qwen25vl3b.sh — the sr in the name. It starts the judge, waits for /health, and reaps it on exit; its train/validation yaml files contain:

envs:
  - name: Sokoban
    config:
      state_reward:
        state_estimation:      {enable: true, reward: 0.03}
        transition_prediction: {enable: true, reward: 0.03}
        score_base: 0.334
        judge_base_url: ${oc.env:JUDGE_BASE_URL,http://127.0.0.1:8123/v1}
        judge_model: ${oc.env:JUDGE_MODEL,Qwen/Qwen3-4B-Instruct-2507}

Before spending GPU time on it, check the judge can actually do the conversion on your environment: JUDGE_URL=http://127.0.0.1:8123/v1 python tools/judge_eval.py scores it against hand-labelled cases. A judge that reads the descriptions wrong pays a reward signal that looks like learning.

state_estimation scores the <observation>, i.e. the state the agent acted from. transition_prediction scores the <prediction>, the state it acted into. They switch on independently, and whichever are on decide the response format the agent is asked for — there is no separate prompt setting to keep in step.

reward is absolute and per turn. The number in the yaml is exactly what a perfect description of that section pays on one turn; it is not divided by max_turns and there is no weight or episode budget. In the example above, both judged sections over five perfect turns pay 2 × 0.03 × 5 = 0.30; format pays up to 0.03 × 5 = 0.15. The shaping cap is therefore 0.45, and including Sokoban's 1.0 success reward the episode cap is 1.45. Raising max_turns changes that maximum explicitly rather than silently shrinking every turn's learning signal.

score_base is subtracted from each description's F1 before it is paid, then the rest is rescaled so a perfect description still earns the configured per-turn reward. It exists because scoring about a third is free: naming any relation at all gets you there. Measured over 300 real Sokoban starts, uniform random scores 0.334 and the best constant answer — "same, same", which looks at nothing — scores 0.391. Set 0.0 to restore the old behaviour.

There is no state-reward format_reward. A turn that omits any enabled section earns no state reward for that turn; writing the sections is the gate that makes them scoreable, not a separate line item. The environment's own format_reward remains the single format knob.

There is also no placement. The environment pays each score on the final token of the span that earned it. An estimator that requires one reward slot per turn performs that reduction itself; bi_level_gae sums each turn onto its boundary before its outer recursion. This keeps the environment independent of the estimator and preserves the richer per-span signal for estimators that can use it.


Logging and checkpoints

trainer:
  val_before_train: true
  log_val_generations: 32
  val_log_select: balanced        # balanced | first | failures | successes | worst | best
  save_best_actor: true
  save_freq: 100                  # ★ NOT the default. verl's is -1, i.e. never save
  max_actor_ckpt_to_keep: 1
  max_critic_ckpt_to_keep: 1
  rollout_data_dir: ...           # per-step rollout dumps, as jsonl
  validation_data_dir: ...        # the same for validation. Every shipped script sets it
  replace_image_tokens_for_logging: true
  log_image: {enable: false, max_pending: 2, png_compress_level: 0}

!!! danger "save_freq has no useful default" verl's default is -1 — never checkpoint. Every shipped script sets it by hand; a script written from this block without it trains for days and saves nothing.

!!! tip "Set save_freq against your wall clock" A requeued job resumes from the last checkpoint (resume_mode: auto), so save_freq is how much work a preemption costs. At 15 minutes a step against a 48-hour limit, save_freq: 100 throws away up to 25 hours of it; 25 costs at most 6.


Filters

filter:
  name: reward_variance_top_p
  filter_kwargs: {top_p: 0.9}
  enable: false

filter_kwargs is splatted into the registered function, so the accepted keys are that function's. The two built in take different ones:

One of these two (each line is a whole alternative):

filter: {name: reward_variance,       filter_kwargs: {topk: 0.2,  ddof: 0}, enable: true}
filter: {name: reward_variance_top_p, filter_kwargs: {top_p: 0.9, ddof: 0}, enable: true}

An unrecognised key is ignored in silence, so topk on reward_variance_top_p leaves it at its default top_p of 0.9.

See Custom Filter, Custom Metric and Known Issues.