This expands each headline capability of the FedAgent verl-0.8 overlay into the
concrete config key that turns it on and the source file in fedagent/ that
implements it.
FedAgent is a thin overlay on stock verl 0.8 (migrated from a verl-agent-0.3.1 fork). Every experiment is one flat YAML driven by a single entry point:
python -m fedagent.fed.run_fed --config fedagent/config/<experiment>.yamlThe config keys are the flat DEFAULTS dict in
run_fed.py; a few CLI flags override the YAML
(--model-path --output-dir --rounds --clients --n-gpus --base-seed --port-base --fedprox-mu --local-client-id). Anything verl-specific is passed through to each
client as a Hydra override in the client_overrides: list (each entry is a
key=value string applied to python -m fedagent.main_ppo_fed). The complete field
reference is in configuration.md.
- Algorithms, federated GRPO & PPO
- Models, any HuggingFace backbone
- Environments, WebShop & ALFWorld
- Two-level heterogeneity
- Aggregation, FedAvg / FedProx
- Baselines, federated / centralized / local
- Federation protocol
- FSDP & scaling
- Evaluation
- Logging, W&B-free
- Extensibility
Federated GRPO and PPO, as federated counterparts of the verl trainers. Each
selected client runs a local verl update in its own subprocess
(python -m fedagent.main_ppo_fed); the driver FedAvgs the resulting FSDP
checkpoints and re-enters the next round from the merged model. GRPO uses group
rollouts and no critic; PPO adds a value model that is federated alongside the actor.
Configure
| Capability | Key | Where | Source |
|---|---|---|---|
| Algorithm select | adv_estimator: grpo (default) or gae |
run_fed.py DEFAULTS |
fed/run_fed.py |
| GRPO group size G | actor_rollout_ref.rollout.n=8 |
client_overrides (paper arms = 8; base default 4) |
config/fedagent_ppo.yaml |
| GRPO actor loss | actor_rollout_ref.actor.use_kl_loss=true, kl_loss_coef=0.01, kl_loss_type=low_var_kl, entropy_coeff=0.001 |
base config (inherited by every arm) | config/fedagent_ppo.yaml |
| KL reference role | ref_anchor: base (default since 2026-09-16: pinned to the base model for the whole run) or round (follows each round's aggregate — every earlier run); ref_model_path |
run_fed.py DEFAULTS |
ref_anchor.py + fed/run_fed.py (resolve_ref_model_path, check_resume_objective) |
| PPO, federate the critic | adv_estimator: gae (+ critic.* overrides) |
DEFAULTS + client_overrides |
fed/run_fed.py |
| Per-client trainer entry | python -m fedagent.main_ppo_fed (runs verl's stock run_ppo) |
- | main_ppo_fed.py |
| Multi-turn rollout | actor_rollout_ref.rollout.agent.default_agent_loop: gym_text |
base config + config/agent.yaml |
agent_loops/gym_text_agent_loop.py |
For PPO (adv_estimator: gae), run_fed.py enables the value model, sets
critic.model.path per round (round-1 critic = the base model's backbone; thereafter
the aggregated critic), and FedAvgs both actor and critic each round
(fedavg(..., kind="actor") and kind="critic"). Clients must save the value model
(critic.checkpoint.save_contents=[model] in client_overrides) so it can be
aggregated. See fed/README.md for the round mechanics.
Adding a new RL algorithm → extending.md.
Any HuggingFace causal-LM backbone. The paper sweeps Qwen2.5-1.5B / 3B / 7B-Instruct and Llama-3.2-3B-Instruct.
Configure
| Capability | Key | Where |
|---|---|---|
| Base model (round 1) | model_path ("" → auto-discover a local Qwen2.5-0.5B snapshot) |
DEFAULTS / --model-path |
| Attention impl | +actor_rollout_ref.model.override_config.attn_implementation=sdpa (added by the driver) |
fed/run_fed.py |
| PPO value model init | critic.model.path (set per round by the driver, not pinned) |
fed/run_fed.py |
Each round trains from the previous round's merged HF model
(round_{r-1}/aggregated/hf), produced by verl.model_merger. Model acquisition,
cache location, and offline clusters → installation.md.
Real agent benchmarks WebShop (e-commerce search-and-buy) and ALFWorld
(embodied household tasks on TextWorld), plus an in-process TinyGuess wiring probe and an
in-process Harbor env that runs any Harbor
task (instruction + sandbox + verifier) as an episode (envs/harbor/;
homogeneous clients only, no heterogeneity arms).
WebShop and ALFWorld run as remote HTTP services (their dependencies conflict, so
each lives in its own conda env); the driver launches one service per client so
each client gets its own environment / hidden transition kernel.
Configure
| Capability | Key | Where | Source |
|---|---|---|---|
| Environment select | env_kind: tinyguess | harbor | webshop | alfworld |
DEFAULTS | fed/run_fed.py |
| Env spec (turns / pool) | env_spec: config/envs/<name>.yaml |
DEFAULTS | config/envs/ |
Dataset adapter (verl custom_cls) |
custom_cls_path → data.custom_cls.path |
DEFAULTS | data/agentic_dataset.py |
| WebShop service launcher | webshop_run_service, webshop_base_port (client c → +c), webshop_pool_size, search_return_n |
DEFAULTS | envs/webshop/service/ |
| ALFWorld service launcher | alfworld_run_service, alfworld_base_port (client c → +c), alfworld_pool_size, alfworld_train_eval, alfworld_task_types |
DEFAULTS | envs/alfworld/service/ |
| Service health wait | service_health_timeout (seconds) |
DEFAULTS | fed/run_fed.py |
The env-spec files (e.g. config/envs/webshop_15.yaml,
config/envs/alfworld.yaml) set n_envs, max_turns,
and the gym_text agent name; the per-client service URL is injected by the driver as
WEBSHOP_SERVICE_URL / ALFWORLD_SERVICE_URL. Adding a new environment →
extending.md.
The core research feature: a suite of client-partition strategies along two
structurally distinct axes, selected with partition_strategy plus per-strategy
knobs. The driver forwards them to each client's env service via env vars
(PARTITION_STRATEGY, OMEGA, SIZE_STD, …); the WebShop service dispatches to the matching
module under fedagent/hetero/, the ALFWorld service to the vendored engine's
partition_strategy.py / alfworld_kernel_variants.py.
Task-level: clients differ in their task distribution (observable through the prompt):
partition_strategy |
Knob key(s) | Source |
|---|---|---|
preference |
omega |
hetero/webshop_task.py |
coverage |
size_std |
hetero/webshop_coverage.py |
hardness |
success_std, trajectories_file (required) |
hetero/webshop_hardness.py |
task_disjoint |
(disjoint goal slice, full catalog) | hetero/webshop_catalog_split.py |
Environment-level: clients differ in the transition kernel (hidden from the policy):
partition_strategy |
Knob key(s) | Source |
|---|---|---|
catalog_split |
env_div, keep_ratio |
hetero/webshop_catalog_split.py |
bm25_field_subset |
variant_n |
hetero/webshop_env_variants.py |
bm25_reweight |
variant_n |
hetero/webshop_env_variants.py |
lookalike |
variant_n |
hetero/webshop_env_variants.py |
rank_wrapper |
variant_n |
hetero/webshop_env_variants.py |
scene_disjoint (ALFWorld) |
env_div, alfworld_scenes_per_client, alfworld_holdout_file |
envs/alfworld/engine/…/partition_strategy.py |
obs_variant / dyn_variant / goal_variant (ALFWorld) |
variant_n |
envs/alfworld/engine/…/alfworld_kernel_variants.py (design notes, dev branch) |
partition_strategy: "" (or uniform for ALFWorld) is the homogeneous / i.i.d.
baseline. min_goals_per_client sets the per-client task count; base_seed makes the
client → data assignment deterministic. The full construction and paper mapping are in
heterogeneity.md; adding a new partition →
extending.md.
Server-side combination each round is FedAvg (a weighted parameter mean of the
clients' FSDP shards). The aggregator runs under a matched-world-size process group
(torchrun --nproc_per_node = save-time world_size): each rank averages its own rank
shard across clients in place and writes it back byte-structurally identical to a verl
save. (Real verl-0.8 checkpoints hold DTensor params that would also load
single-process; matched-PG write-back is kept for structural identity and wrap-layout
safety, not out of necessity; see extending.md "The shape (and why)".) FedProx keeps each client near the round's global
model by adding a μ·(w − w_t) term to the actor gradient on every optimizer step;
it changes the client update; the server still aggregates by FedAvg.
Configure
| Capability | Key | Where | Source |
|---|---|---|---|
| FedAvg (default) | (no key, always runs each round) | - | fedagent/fed/aggregate_fedavg_fsdp.py |
| FedAvg weights | weights ("" → uniform; else comma-separated, sums to 1) |
DEFAULTS (YAML only, no CLI flag) | fedagent/fed/aggregate_fedavg_fsdp.py |
| Merge shards → HF | (auto, verl.model_merger merge --backend fsdp) |
- | fed/run_fed.py (merge_to_hf) |
| FedProx | fedprox_mu (>0 enables; 0 ≡ FedAvg) |
DEFAULTS → --fedprox-mu |
fedagent/fedprox.py |
FedProx is injected without a Ray runtime_env hook (which would clobber verl's
per-worker CUDA_VISIBLE_DEVICES): the driver sets FEDPROX_MU in each client's
environment, and the repo-root sitecustomize.py (auto-imported
at interpreter startup in every process on PYTHONPATH) calls
fedagent.fedprox.install_deferred_patch() (fail-closed when verl is present). That arms a
sys.meta_path hook which monkeypatches FSDPEngine.optimizer_step the moment verl first
imports its FSDP-engine module, i.e. after the Ray worker has its per-rank
CUDA_VISIBLE_DEVICES set. (Importing FSDPEngine eagerly at interpreter startup instead
pulls in torch/verl before device assignment and breaks per-rank GPU isolation at multi-GPU,
"Duplicate GPU detected".) Eval passes scrub FEDPROX_MU so the proximal term never fires
during validation. Adding a new aggregation rule → extending.md.
Three regimes share the same driver and config schema; the mode is inferred from the client keys.
| Mode | How to select | Behaviour | Source |
|---|---|---|---|
| Federated | default (total_clients > 1, local_client_id: -1) |
FedAvg across clients_per_round sampled clients each round |
fed/run_fed.py |
| Centralized | total_clients: 1 (+ partition_strategy: "") |
one model on the pooled data; FedAvg of one client is the identity, so the loop is total_rounds × epochs_per_round of continued training |
fed/run_fed.py |
| Local | local_client_id: k >= 0 (with total_clients: N) |
the paper's "Local Agent Training": pin one client of the N-way partition every round, train it alone, no federation | fed/run_fed.py |
select_clients() does deterministic per-round sampling (seed = base_seed + round − 1); local mode pins the one client and launches only its env service
(participating_client_ids()). Worked examples → running.md.
The full protocol is configurable in the flat config (all keys in run_fed.py
DEFAULTS):
| Symbol | Key | Meaning |
|---|---|---|
N |
total_clients |
size of the client pool |
M |
clients_per_round |
clients sampled & trained each round |
T |
total_rounds |
number of federated rounds |
E |
epochs_per_round |
local epochs per selected client (trainer.total_epochs) |
|Xᵢ| |
min_goals_per_client |
tasks per client |
| seed | base_seed |
deterministic client→data + client-selection seed |
| - | total_training_steps |
per-client-round step cap (> 0 for smokes; <= 0 → full E epochs) |
| - | save_freq |
checkpoint cadence within a client round |
| - | wait_between_clients |
seconds to let Ray/GPU release between clients |
| - | parallel_clients |
1 = sequential clients within a round (default); P>1 trains the round's clients concurrently, each on a 1/P GPU slice, numerically identical (FedAvg is order-free, data seeds are client-indexed); needs cross_round + worker/off eval. See running.md § Sequential vs parallel clients |
| - | cleanup_checkpoints |
delete consumed FSDP shards after each merge (keeps HF + logs) |
Paper config filenames encode the protocol
(e.g. …total-100_cl-per-rd-2_rd-70_ep-per-cl-3_min-goals-per-cl-100…); the decoder
and the full 194-cell matrix are in reproducing.md and
configuration.md. The driver threads a per-(round, client) data
seed (FEDAGENT_BASE_SEED = base_seed + round·100 + client_id) so each client re-draws
goals from its fixed shard every round.
Larger backbones (3B / 7B) train via FSDP with optional CPU offload; runs scale
from one GPU to a full node. At 1.5B every paper cell also runs on one H100
(config/paper_accelerated_1gpu/): about ×2 the 4-GPU per-round time at roughly half the
GPU-hours, four cells per node (gpu_recipes.md).
Configure
| Capability | Key | Where |
|---|---|---|
| GPUs per node | n_gpus_per_node (= FedAvg nproc_per_node) |
DEFAULTS → --n-gpus |
| Actor offload | actor_rollout_ref.actor.fsdp_config.param_offload / .optimizer_offload |
client_overrides |
| Ref-policy offload | actor_rollout_ref.ref.fsdp_config.param_offload |
base config / client_overrides |
| Critic offload (PPO) | critic.fsdp.param_offload / .optimizer_offload |
client_overrides |
| vLLM tensor-parallel | actor_rollout_ref.rollout.tensor_model_parallel_size |
base config / client_overrides |
| vLLM memory | actor_rollout_ref.rollout.gpu_memory_utilization |
client_overrides |
The save-time world size (read from fsdp_config.json) is auto-detected and used as
the aggregator's nproc_per_node, so FedAvg matches the training shard layout. The
hardware / scaling matrix → running.md.
The aggregated global model is scored every round on a shared, unperturbed validation service (full env, held-out split), so every arm is measured on the same fixed set. Eval is a verl val-only pass (generate + score, no training, no critic) and never aborts the loop; a failed eval logs a warning and continues.
Fixed means the val rows are seeded 0..n_envs-1 in every eval mode — the eval dataset is
built with FEDAGENT_BASE_SEED cleared, so it never inherits the round's training seed
(persistent_task_runner.unseeded_eval_data, 2026-07-26; before that the worker mode leaked
it and re-drew ALFWorld's set every round). On ALFWorld the seed is additionally treated as a
game index (alfworld_val_seed_is_index, default on), so the set is games[0:n_envs] with no
repeats. Which games a run measured on is logged at val-service start.
Configure
| Capability | Key | Where | Source |
|---|---|---|---|
| Enable eval | val_env_spec ("" → no eval) |
DEFAULTS | fed/run_fed.py (eval_global) |
| Eval cadence | every round (unconditional); test_freq is inert, kept for legacy config name-parity |
DEFAULTS | fed/run_fed.py |
| Round-0 baseline | val_before_train (also eval the base model before round 1) |
DEFAULTS | fed/run_fed.py |
| Sampling temp | val_temperature (paper = 0.4) |
DEFAULTS | fed/run_fed.py |
| Shared val service ports | webshop_val_port, alfworld_val_port, alfworld_val_split |
DEFAULTS | fed/run_fed.py (start_val_service) |
eval_global() dumps verl's per-sample validation JSONL and summarize_val_dump()
reduces it to {n, success_rate, reward_mean}; the round → success curve is written
into federated_summary.json (val_curve). The val env-spec files are
config/envs/webshop_15_val.yaml and
config/envs/alfworld_val.yaml.
Weights & Biases is removed: no tracking account or key is needed. verl logs to
console only (trainer.logger: [console] in the base config), and the driver
post-processes each client's training.log into the FedAgent metrics schema.
Configure / artifacts
| Capability | Key / path | Source |
|---|---|---|
| Console logger | trainer.logger: [console] |
config/fedagent_ppo.yaml |
| Per-client JSON metrics | round_<r>/client_<c>/json_logs/metrics.json |
fed/metrics_logger.py |
| Run summary | <output_dir>/federated_summary.json (per-round provenance + val_curve) |
fed/run_fed.py |
| Per-client / service / eval logs | training.log, *_service_client*.log, eval.log under output_dir |
fed/run_fed.py |
write_metrics_json() parses verl's per-step console dump into
[{"step": int, "metrics": {...}}, …] (the same schema the FedAgent plotting tools
consume) with no verl modification.
FedAgent is built to be extended, not only reproduced.
| Add… | Where | Guide |
|---|---|---|
| a new environment / dataset | fedagent/envs/ + config/envs/ |
extending.md |
| a new heterogeneity (client partition) | fedagent/hetero/ |
heterogeneity.md |
| a new RL algorithm (beyond GRPO/PPO) | client_overrides / verl trainer |
extending.md |
| a new aggregation (beyond FedAvg/FedProx) | fedagent/fed/aggregate_fedavg_fsdp.py / fedagent/fedprox.py |
extending.md |
See also: configuration.md (full key reference) ·
heterogeneity.md (the two-level suite) ·
fed/README.md (round-loop internals).