FedAgent is a thin overlay on stock verl 0.8, and it is a library first: the federated control loop, the environments, the heterogeneity constructors, and the model aggregation are deliberately decoupled, so you can replace any one of them without touching the others. This document is the reference for doing exactly that.
The maintained entry point never changes, every extension is reachable through
python -m fedagent.fed.run_fed --config <yaml>The driver (fedagent/fed/run_fed.py) is verl-agnostic: a client is just a
subprocess (python -m fedagent.main_ppo_fed). It trains each selected client
sequentially (or concurrently, parallel_clients, see running.md), FedAvgs the resulting FSDP shards, merges them back to a HuggingFace
model, and re-enters the next round from that aggregated model. Because of this split,
each extension point is isolated to a small number of files:
| # | Extension point | Primary file(s) | Selected by |
|---|---|---|---|
| 1 | New environment | fedagent/envs/<name>/ + fedagent/envs/registry.py + a config/envs/<name>.yaml |
the env-spec row's name: |
| 2 | New heterogeneity strategy | fedagent/hetero/<name>.py + the service env-var bridge + run_fed.py |
partition_strategy (YAML / env PARTITION_STRATEGY) |
| 3 | New RL algorithm | the verl trainer (algorithm.adv_estimator); FedAgent only carries the checkpoints |
adv_estimator (YAML → Hydra) |
| 4 | New aggregation rule | fedagent/fed/aggregate_fedavg_fsdp.py (server FedAvg) · sitecustomize.py + fedagent/fedprox.py (client-side hook) |
the aggregator CLI / fedprox_mu |
How the layers fit together. The overlay owns the orchestration (the round loop, per-client env services, aggregation, eval) and the agent rollout (
fedagent/agent_loops/gym_text_agent_loop.pydrives oneBaseTextEnvper dataset row). Stock verl owns the RL update (advantage estimation, the actor/critic FSDP workers, the optimizer). Extension points 1 and 2 live entirely in the overlay; point 3 lives in verl (the overlay only selects it and carries the extra checkpoint); point 4 straddles both, the server rule is an overlay tool, the client-side proximal term is a verl monkeypatch loaded at interpreter startup.
Before you start. Read
./architecture.mdfor the round loop and./installation.mdfor the three conda envs (the trainer envfedagent-verl08plus the per-serviceverl-agent-webshop/verl-agent-alfworldenvs). Environments whose Python dependencies conflict with verl 0.8 (WebShop's pyserini/Java, ALFWorld's TextWorld) run in their own conda env behind an HTTP service; in-process environments (TinyGuess) need none of this.
Every environment a FedAgent agent-loop drives implements one async contract,
BaseTextEnv (fedagent/envs/base.py), and is looked up by name from
fedagent/envs/registry.py. The agent-loop instantiates one env instance per
dataset row and awaits its reset/step (the verl-0.8 agent-loop is per-row async,
not the old batched, synchronous EnvironmentManager).
BaseTextEnv is the whole surface, four methods, three of them abstract. The
observation convention is a dict with at least obs_str (the text shown to the
model):
# fedagent/envs/base.py
Obs = Dict[str, Any]
class BaseTextEnv(ABC):
def __init__(self, env_config: Optional[Dict[str, Any]] = None):
self.env_config: Dict[str, Any] = dict(env_config or {})
@abstractmethod
async def system_prompt(self) -> Obs:
"""The system message ({"obs_str": ...}) shown once at episode start."""
@abstractmethod
async def reset(self, seed: int = 0) -> Tuple[Obs, Dict[str, Any]]:
"""Reset to a fresh episode, deterministically in `seed`. Returns (obs, info)."""
@abstractmethod
async def step(self, action_str: str) -> Tuple[Obs, float, bool, Dict[str, Any]]:
"""Apply the model's decoded text action. Returns (obs, reward, done, info)."""
async def close(self) -> None:
"""Release any resources held by this instance (override if needed)."""
return NoneThree invariants the existing envs honor, and you must too:
infocarriessuccess(bool). The agent-loop records the episode outcome frominfo["success"]; it becomes FedAgent's headline metricval/success_rate. (See howTinyGuessEnv.stepreturns{"success": self.solved, ...}and howWebShopEnv.stepmaps the service'ssuccessfield.)resetis deterministic inseed. Every dataset row is a distinct instance with a distinct seed; reproducibility (and the per-(round, client)re-draw) rides onseedalone. The federated driver threadsFEDAGENT_BASE_SEED = base_seed + round*100 + client_idinto the dataset, which sets each row's seed.obs_stris the only required observation key. Image/multimodal envs may later addmulti_modal_datawithout changing the contract.
TinyGuessEnv (fedagent/envs/tiny_guess.py, ~70 lines) is the cleanest reference,
fully in-process, dependency-free, parses the action with a regex, returns
higher/lower. Read it first.
-
Subclass
BaseTextEnv. For an in-process env, do the work directly inreset/step(mirrorTinyGuessEnv). For an env whose dependencies conflict with verl 0.8, make the subclass a thin HTTP client (see §1b) and put the real env in a service. -
Register it. Add the class to
ENV_REGISTRYinfedagent/envs/registry.py:# fedagent/envs/registry.py from fedagent.envs.myenv import MyEnv ENV_REGISTRY: Dict[str, Type[BaseTextEnv]] = { "TinyGuess": TinyGuessEnv, "WebShop": WebShopEnv, "ALFWorld": AlfworldEnv, "MyEnv": MyEnv, # <- the name the agent-loop looks up }
The dict key is the
env_name;make_env(env_name, env_config)raisesKeyErrorfor anything not registered. -
Write an env-spec YAML under
fedagent/config/envs/. This is whatfedagent.data.agentic_dataset.AgenticDataset(verl'sdata.custom_cls) reads to emit one row per env instance. The schema is a flat list of env blocks:# fedagent/config/envs/myenv.yaml envs: - name: MyEnv # MUST match the ENV_REGISTRY key n_envs: 8 # rows (= distinct instances/seeds) emitted for this spec max_turns: 15 # per-episode turn budget enforced by the agent-loop agent_name: gym_text # optional (default: gym_text) config: # forwarded verbatim as `env_config` to the constructor timeout: 180.0
The dataset emits
n_envsrows, each with a distinctseed; GRPO grouping is then handled downstream by verl'srollout.n(each row repeatedntimes = one GRPO group per instance). Keepn_envs == data.train_batch_sizeso the batch holds one group per step (see thewebshop_15.yamlheader for why). -
Point the federated config at the spec. In your
run_fedYAML setenv_spec: config/envs/myenv.yaml(resolved relative to the package). For an in-process env that is all, setenv_kindto a value other thanwebshop/alfworldso the driver starts no services (tinyguessis the in-process sentinel). See the smoke recipe at the end.
WebShop and ALFWorld cannot import alongside verl 0.8 (pyserini/Java/gym 0.24;
TextWorld), so the real env runs in its own conda env behind a FastAPI service and the
BaseTextEnv subclass is a thin client. Mirror fedagent/envs/webshop/service/ (the
fullest reference) or fedagent/envs/alfworld/service/.
The client side reads its service URL from a per-env environment variable that the driver sets per client, then ferries text in and observations out:
# fedagent/envs/myenv/myenv_env.py (mirrors fedagent/envs/webshop/webshop_env.py)
class MyEnv(BaseTextEnv):
def __init__(self, env_config=None):
super().__init__(env_config)
self.base_url = (
os.environ.get("MYENV_SERVICE_URL") # set per-client by run_fed (authoritative)
or self.env_config.get("service_url") # ad-hoc single-service fallback
or "http://localhost:8080"
).rstrip("/")
self.session_id = uuid4().hex
self._client = None # lazily-created httpx.AsyncClient
async def reset(self, seed=0):
c = self._c()
await c.post("/create", json={"session_id": self.session_id})
r = await c.post("/reset", json={"session_id": self.session_id, "seed": int(seed)})
... # format the service obs into {"obs_str": ...}, return (obs, {})
async def step(self, action_str):
r = await self._c().post("/step", json={"session_id": self.session_id, "text": action_str})
d = r.json()
info = {"success": bool(d.get("success", False)), ...}
return {"obs_str": ...}, float(d.get("reward", 0.0)), bool(d.get("done", False)), info
async def close(self):
... # POST /close, aclose the httpx clientThe service side is a FastAPI app that pre-warms a pool of env instances (so
episodes don't pay startup cost) and serves the borrow → reset → step* → return
lifecycle. The five endpoints the client expects are /health, /create, /reset,
/step, /close. Parse the model's action text server-side (where the env's
projection function lives). The pool size is read from an env var and must be ≥ the
generation batch:
| Endpoint | Role |
|---|---|
GET /health |
readiness probe; the driver polls it until the pool is warm (and echoes partition info) |
POST /create |
borrow a warm env from the pool for a session |
POST /reset |
reset that env to a goal/seed; return obs + admissible actions |
POST /step |
parse text server-side, step the env, return obs/reward/done/success |
POST /close |
return the env to the pool |
Add a run_service.sh next to server.py that activates the right conda env and
launches uvicorn. Then in your run_fed.py you need a launcher analogous to
start_webshop_services / start_alfworld_services (it sets MYENV_PORT,
MYENV_POOL_SIZE, the partition env-var bridge below, and waits on /health), and an
env_kind: myenv branch in run() that calls it. Set the per-client
MYENV_SERVICE_URL in run_client so client c talks to the service on
base_port + c. The shared unperturbed validation service (one full-env service on
a separate port, used to score the aggregated global model every round) is
wired the same way in start_val_service / eval_global.
See ../envs/README.md for the full client/service contract and
../envs/webshop/service/README.md /
../envs/alfworld/service/README.md for the two
worked services.
Each construction is a self-contained, numpy-only module under fedagent/hetero/
exposing a public *_for_client(...) function. (The functions are copied
verbatim from the verl-agent-0.3.1 partition_strategy.py so per-client assignment
is bit-identical to the baseline, the science red line; only the thin
*_for_client public API is new.) The strategy is selected by partition_strategy,
threaded into each env service through an environment-variable bridge.
| Axis | Enters via | Returns | Example helpers |
|---|---|---|---|
| Task-level (preference / coverage / hardness) | the prompt (which goals a client draws) | a list of goal indices for this client | preference_for_client, coverage_for_client, hardness_for_client |
| Env-level (catalog_split / variants 2–5) | the transition kernel P_i (catalog / search dynamics) |
a catalog + goal-idx pair, or an env_kwargs dict |
catalog_split_for_client, bm25_variant_for_client, lookalike_injection_for_client, rank_wrapper_for_client |
Task-level strategies leave the env unperturbed (full catalog) and are FedAvg-robust;
env-level strategies perturb the hidden dynamics and are worst-case non-robust. See
./heterogeneity.md and ../hetero/README.md
for the taxonomy and the knob-naming caveats (e.g. size_std/success_std are the
Beta concentration ξ/ξ′, not standard deviations).
The public functions share a fixed shape (keyword-only knobs after client_id,
client_num). A task-level function returns the client's goal indices:
# fedagent/hetero/webshop_catalog_split.py (env-level: returns a (catalog, idxs) pair)
def catalog_split_for_client(
client_id: int,
client_num: int,
*,
env_div: float = 0.7,
keep_ratio: float = 0.7,
min_goals_per_client: int = 100,
holdout_file: Optional[str] = None,
base_seed: int = 42,
data_dir: Optional[str] = None,
) -> Tuple[List[str], List[int]]: # (catalog_asins, client_goal_idxs)
...Two invariants every strategy honors:
- Deterministic in
client_id. Each client process runs the function independently and must agree on the global allocation, so FedAvg sees the same per-client slice every round. The existing code hardcodesbase_seed=42and seeds a per-client RNG asnp.random.RandomState(42 + client_id)(or a shareddefault_rng(42)indexed byclient_id). Do not seed from Python's builtinhash()of a string (it is salted per interpreter). - Guarantee the floor. Top up to
min_goals_per_clientif your draw comes up short.
-
Write
fedagent/hetero/myhet.pyexposingmyhet_for_client(client_id, client_num, *, <knobs>, min_goals_per_client=100, env_goals=...). Task-level functions that select by goal content (category/size/hardness) take the env's real goal list asenv_goalsand return absolute indices into it; env-level functions return a catalog/idx pair or anenv_kwargsdict to merge into the env constructor. -
Wire the env-var bridge in the service
server.py. The driver passes knobs as environment variables; the service dispatches onPARTITION_STRATEGYand calls your function. The current WebShop bridge (fedagent/envs/webshop/service/server.py) reads:Env var Meaning PARTITION_STRATEGYthe dispatch key (your new key) CLIENT_ID/CLIENT_NUMthis client's id and the cohort size MIN_GOALS_PER_CLIENTthe per-client floor OMEGApreference Dirichlet spread (ω) SIZE_STD/SUCCESS_STDcoverage / hardness Beta concentration (ξ / ξ′) ENV_DIV/KEEP_RATIOcatalog-split strength / distractor density VARIANT_N# of env-variant arms in the pool (bm25/lookalike/rank) TRAJECTORIES_FILEhardness: the task_id → successlabels fileAdd an
elifbranch:# fedagent/envs/webshop/service/server.py elif PARTITION_STRATEGY == "myhet": from fedagent.hetero.myhet import myhet_for_client # task-level (content-dependent) idxs are DEFERRED to _lifespan, where the warmed # env's real server.goals exist (see _compute_task_partition). Order-independent # strategies (a contiguous range / env_kwargs) can be computed here at import time. _DEFERRED_TASK_PARTITION = "myhet" # then add a branch in _compute_task_partition()
Note the deferral subtlety: a content-dependent task partition must address the env's actual seed-42-shuffled
server.goals, which only exist after the pool is warmed, so those are computed in_compute_task_partition()(called from_lifespan), not at import. Catalog/variant strategies (order-independent ranges orenv_kwargs) are computed at import time. The unperturbed validation service is launched unsharded (WebShop val:PARTITION_STRATEGY=""; the ALFWorld val service passes"uniform"to the engine over the full val split), so divergence is attributable to the perturbation alone. -
Forward the knobs from
run_fed.py. Add any new knob as a key in theDEFAULTSdict offedagent/fed/run_fed.py, then export it as an env var inside the service launcher (start_webshop_services/start_alfworld_services), e.g.:env.update({ "PARTITION_STRATEGY": cfg.partition_strategy or "uniform", # "" is reserved for the val service "CLIENT_ID": str(c), "CLIENT_NUM": str(cfg.total_clients), "MYHET_KNOB": str(cfg.get("myhet_knob", <default>)), # <- your new knob ... })
-
Select it from config. Heterogeneity is chosen entirely through the federated YAML (a flat schema = the
DEFAULTSdict, there is no nestedfederated:block):partition_strategy: "myhet" # the dispatch key myhet_knob: 0.5 # your knob (also add it to DEFAULTS) min_goals_per_client: 100
The RL update lives entirely in stock verl, selected by algorithm.adv_estimator.
The federated overlay never touches the loss; it only (a) selects the estimator and (b)
carries whatever checkpoints the client writes.
The federated YAML key adv_estimator drives a single branch in run_fed.py's
run_client:
# fedagent/fed/run_fed.py
if str(cfg.get("adv_estimator", "grpo")).lower() == "gae":
cmd += ["algorithm.adv_estimator=gae"] # PPO: flips need_critic on
if critic_model_path:
cmd += [f"critic.model.path={critic_model_path}"]- GRPO (default,
adv_estimator: grpo): actor-only. The group size G comes fromrollout.n(paper G = 8 viaactor_rollout_ref.rollout.n=8inclient_overrides). The client writes only an actor checkpoint, and the driver FedAvgs/merges just the actor, the GRPO command is byte-identical to the verified baseline. - PPO (
adv_estimator: gae): adds a critic (value model). The driver detects the critic shard dir alongside the actor (critic_dir_for), FedAvgs both components, merges each to HF, and carries the federated value model forward (critic.model.pathis set per round; round 1's critic = the base model, a random value head on the backbone). PPO rollouts are ungrouped (rollout.n=1; the critic is the baseline — the original'senv.rollout.nnever applied to PPO; 2026-07-20 fix, seedocs/bugfixes.md), withppo_mini_batch_size=64+critic.ppo_mini_batch_size=64(the 64-row minibatch). For this to work the PPO config must includecritic.checkpoint.save_contents=[model](and...actor.checkpoint.save_contents=[model]) inclient_overrides, so the aggregator finds the value-model weights. Seefedagent/config/examples/webshop/scaled/ppo.yaml.
-
Add the estimator in verl, so
algorithm.adv_estimator: my_algoresolves (verl ships PPO/GAE, GRPO, and others upstream; a new one is added alongside in verl's advantage-computation dispatch). FedAgent does not patch this. -
Select it from the federated YAML. If your algorithm is actor-only and needs no extra checkpoint, add a one-line branch in
run_client(mirror thegaebranch) to passalgorithm.adv_estimator=my_algo; surface its hyperparameters throughclient_overrides(each entry is a literal Hydra override applied to the per-client subprocess). -
Mind the checkpoint shape, this is the only federation-level concern. The aggregator (§4) operates on the FSDP shard layout (
checkpoints/global_step_<n>/<component>/model_world_size_*_rank_*.pt), not on algorithm internals. An actor-only algorithm needs nothing extra. If your algorithm adds a trainable component beyond the actor (as PPO adds the critic), make sure that component lands under the sameglobal_step_<n>/<component>/layout withcheckpoint.save_contents=[model], and FedAvg/merge it with the same machinery,run_fed.fedavg(..., kind="<component>")andmerge_to_hf(..., kind="<component>")are already component-agnostic (they average and merge whatever shard dir they are given). The merger reads the architecture from the shard'shuggingface/config.json, and both the actor and the value model serialize as...ForCausalLM(the value model just carries an extra scalar value head), so no per-component special-casing is needed.
Aggregation has two seams, because FedProx is not a server rule, it is a client-side proximal term, with server aggregation left as FedAvg.
fedagent/fed/aggregate_fedavg_fsdp.py is the live server aggregator. The
driver shells out to it once per round (per component) via torchrun:
# fedagent/fed/run_fed.py :: fedavg()
cmd = [
"torchrun", "--standalone", f"--nproc_per_node={ws}", str(AGGREGATOR),
"--phase", "aggregate",
"--client-actor-dirs", ",".join(str(a) for a in client_dirs),
"--output-actor-dir", str(agg),
"--global-step", "0",
] # + ["--weights", "0.5,0.5"] when cfg.weights is setFedAvg runs under a matched-world-size process group
(torchrun --nproc_per_node == the save-time world_size): each rank loads its own
rank shard from every client, (weighted-)averages the local tensors in place
(_get_local handles DTensor / ShardedTensor / plain), and torch.saves the dict
back. The output is byte-structurally identical to a verl checkpoint (same tensor
objects, only local values changed), so the next round loads it with verl's own FSDP
wrap unchanged.
Why write-back rather than a single-process load: on real verl-0.8 training
checkpoints the params are DTensor and do load single-process, the earlier
"ShardedTensor cannot be loaded single-process" belief was an artifact of a synthetic
spike's own save path, not of verl's. Matched-PG write-back is kept because structural
identity makes the result provably round-trippable and sidesteps the re-wrap trap
below, which is real.
# the averaging core (aggregate_fedavg_fsdp.py)
sds = [torch.load(c / rank_file, weights_only=False) for c in clients]
base = sds[0]
for k in base:
acc = _get_local(base[k]) # writable local shard (ShardedTensor/DTensor/plain)
acc.mul_(weights[0])
for w, other in zip(weights[1:], sds[1:]):
acc.add_(_get_local(other[k]), alpha=w)
torch.save(base, out / rank_file) # same objects, averaged local values- Add a
--phase(or a sibling averaging routine). The CLI is--phase {aggregate,verify},--client-actor-dirs A,B,--output-actor-dir OUT,--weights,--global-step. Reuse the load → average-local-shard → save skeleton above; most rules (trimmed mean, median, FedAvgM, per-client weighting by|X_i|) are a different reduction over the same per-ranksdslist. Keep the output structure intact: writemodel_world_size_<ws>_rank_<rank>.pt, copyfsdp_config.json+huggingface/, and writelatest_checkpointed_iteration.txt, that is the format the next round's clients load from. - A weighting hook already exists. If your rule is "FedAvg but weighted", you do
not need new code in the aggregator: compute the weights and pass them through
cfg.weights(--weights w0,w1,...; they must sum to 1). The driver forwardscfg.weightsto every component. - Validate with the built-in
verifyphase.--phase verifyre-loads the written shards and asserts the local values equal the (weighted) mean of the clients (FedAvg correctness) and that they round-trip asShardedTensor(so verl will load them). Run it on a real round before trusting a new rule.
FedProx adds mu * (w - w_t) to the actor gradient before each optimizer step, where
w_t is the round-start global model. In FedAgent's subprocess-per-round design each
client-round is a fresh process that loads the aggregated model, so w_t is simply
the params at the first optimizer step, no external per-round reset is needed.
The hook is injected without a Ray runtime_env worker hook (which clobbered
verl's per-worker CUDA_VISIBLE_DEVICES). Instead, sitecustomize.py at the repo
root is auto-imported by CPython at interpreter startup in every process on
PYTHONPATH (the driver and its Ray workers, since run_fed sets
PYTHONPATH=REPO_ROOT), gated on FEDPROX_MU:
# sitecustomize.py (repo root)
import importlib.util, os
mu = float(os.environ.get("FEDPROX_MU", "0") or "0")
if mu > 0:
if importlib.util.find_spec("verl") is None:
pass # non-trainer env (e.g. a service conda env without verl) -> silent no-op
else:
from fedagent.fedprox import install_deferred_patch
if not install_deferred_patch(mu): # arms a meta-path hook; fail CLOSED
raise RuntimeError("FedProx requested (FEDPROX_MU>0) but the patch could not be armed")install_deferred_patch arms a sys.meta_path finder that monkeypatches
FSDPEngine.optimizer_step on verl's first import of
verl/workers/engine/fsdp/transformer_impl.py, which happens after the Ray worker has
its per-rank CUDA_VISIBLE_DEVICES set. (Importing FSDPEngine eagerly here at interpreter
startup would pull in torch/verl before device assignment and break per-rank GPU isolation
at multi-GPU, "Duplicate GPU detected".) The patch snapshots w_t on the first call, then
adds the proximal gradient per local shard (FSDP1 sharded view / FSDP2 DTensor, the
elementwise grad.add_ is correct on each shard) before the original step. The driver
sets FEDPROX_MU per client when fedprox_mu > 0; plain FedAvg leaves mu = 0 (a
no-op, and fedagent is never even imported). Eval always strips FEDPROX_MU.
To add another client-side rule on the same pattern: write the patch in a module
under fedagent/, add an env-var gate to sitecustomize.py, and have run_fed.py set
that env var per client. Keep the patch CUDA-free at import time (importing
FSDPEngine must not initialize CUDA, it runs before verl assigns devices).
Validate any extension end-to-end with the in-process TinyGuess smoke (2 clients × 2 rounds, 1 step/client/round, closes the full federated loop in minutes, no services):
# inside the fedagent-verl08 conda env, on a GPU node
python -m fedagent.fed.run_fed --config fedagent/config/examples/tinyguess_2cl_2rd.yamlThis trains each client (python -m fedagent.main_ppo_fed), FedAvgs the two clients'
FSDP shards under a matched process group, merges back to HF, and re-enters round 2
from the aggregated model. A clean run ends with FEDERATED LOOP CLOSED and writes
<output_dir>/federated_summary.json (per-round provenance:
started_from → aggregated_hf). CLI flags override the YAML:
python -m fedagent.fed.run_fed --config fedagent/config/examples/tinyguess_2cl_2rd.yaml \
--rounds 3 --clients 4 --n-gpus 2 --output-dir /tmp/my_smokeWhat to check per extension point:
| You changed | The smoke proves | Then exercise |
|---|---|---|
| A new in-process env | point env_spec at your config/envs/<env>.yaml: it resolves, rolls out, and aggregates |
full run on real data |
| A service-backed env | (TinyGuess can't test the service) | set env_kind: <env>, watch /health come up, confirm episodes step |
| A heterogeneity strategy | set partition_strategy + knobs; confirm the service /health echoes your partition and per-client slices differ |
the het arm under ./reproducing.md |
| A new algorithm | set adv_estimator; for a critic-bearing algo confirm both components FedAvg/merge |
examples/webshop/scaled/ppo.yaml as the PPO template |
| An aggregation rule | run --phase verify on a real round's shards |
a multi-round run, diffing against average-of-clients |
For the run-mode/GPU matrix and the federated-key reference see
./running.md and ./configuration.md; for the
round loop, ./architecture.md; for per-component code detail,
../fed/README.md, ../envs/README.md, and
../hetero/README.md.