Skip to content

feat(workflow): add stage-1 stepwise reward plumbing - #1

Closed
Fyrgo8 wants to merge 36 commits into
mainfrom
codex/stepwise-reward-stage1
Closed

feat(workflow): add stage-1 stepwise reward plumbing#1
Fyrgo8 wants to merge 36 commits into
mainfrom
codex/stepwise-reward-stage1

Conversation

@Fyrgo8

@Fyrgo8 Fyrgo8 commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Description

This PR implements a scoped Stage-1 landing for stepwise / process-level reward plumbing.

It does not change the default scalar-reward behavior. Existing reward functions that return a single float continue to work unchanged.

This first landing adds:

  • a structured RewardResult payload that can carry final_reward plus optional per-step rewards;
  • normalization from legacy scalar rewards into the structured format;
  • workflow-side alignment from step-level rewards to completion-token positions in RLVRWorkflow;
  • PPO-side consumption of step_rewards / step_reward_mask by injecting them into token-level total rewards;
  • focused tests for reward normalization, stepwise alignment, and backward compatibility.

The intent is to make process supervision a first-class path in AReaL while keeping the current default behavior intact.

Related Issue

Fixes areal-project#1381

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 📝 Documentation update
  • ♻️ Refactoring
  • ⚡ Performance improvement
  • ✅ Test coverage improvement

Checklist

  • I have read the Contributing Guide
  • Pre-commit hooks pass (pre-commit run --all-files)
  • Relevant tests pass; new tests added for new functionality
  • Documentation updated (if applicable; built with ./docs/build_all.sh)
  • Branch is up to date with main
  • Self-reviewed via /review-pr command
  • This PR was created by a coding agent via /create-pr
  • This PR is a breaking change

Additional Context

Tested on the Aliyun Linux environment with:

  • tests/test_stepwise_reward_stage1.py
  • tests/test_async_reward_wrapper.py

I did not run the full local Windows test flow because the repository lockfile targets Linux/macOS environments.

This PR intentionally stops at Stage 1 plumbing. It does not yet introduce a broader process-reward API across all workflows, and it does not attempt the algorithmic IcePop Plus integration.

mingcheng and others added 30 commits June 15, 2026 10:52
…oject#1409)

- add WeChat group QR code image to assets/figures
- include community engagement section with links to GitHub discussions
- reference Community Repository for meeting materials and details

Signed-off-by: mingcheng <mingcheng@apache.org>
* docs(cli): sync cli reference for icepop and kpop params

* feat(examples): add icepop and kpop configs for gsm8k

* style: fix end-of-file newline in icepop/kpop configs

* fix(icepop/kpop): detach imp_ratio and KL inputs to prevent gradient leak

* fix(kpop): use <= for KL threshold to include boundary

* refactor: unify icepop/kpop into rejection_sampling with binary_kl metric

- Add binary_kl metric to RejectionSamplingConfig for KPop
- Remove enable_icepop/icepop_alpha/icepop_beta/enable_kpop/kpop_phi
- Update gsm8k_icepop.yaml to use rejection_sampling with ratio+lower+upper
- Update gsm8k_kpop.yaml to use rejection_sampling with binary_kl+upper
- Sync CLI docs
areal-project#1408)

## Summary 

- head_version/tail_version now per-sample, filtered by loss_mask==1
- fixes head_version always being -1 due to input token version placeholders
- adds version_rle field (run-length encoded per-token version sequence)
- adds _split_trajectory_for_dump helper for correct multi-turn prompt_end
- adds segments field for multi-turn agent trajectory analysis

## Context 

The previous `_dump_trajectory` had three issues: 
1. `head_version = min(versions)` always returned -1 because input tokens use -1 as placeholder 
2. `prompt_end = seqlen - sum(mask)` is incorrect for multi-turn agent rollouts where loss_mask is interleaved
3. No per-token version granularity was persisted for cross-version analysis  

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add IcePop/KPop feature introduction

* style: fix mdformat

* docs: remove IcePop/KPop from Examples table
…real-project#1429)

VLLMBackend.build_generation_request omitted gconfig.frequency_penalty and
gconfig.stop, while the sibling SGLangBackend forwards both. Both are
GenerationHyperparameters (and neither is in _OPENAI_UNSUPPORTED_ARGS), and
vLLM's OpenAI-compatible /v1/completions and /v1/chat/completions endpoints
accept them. As a result a user who set frequency_penalty (anti-repetition)
or stop strings had them honored on SGLang but silently ignored on vLLM,
changing the sampling distribution between backends.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…GROUP (areal-project#1414)

On some hardware/driver combos (e.g. certain Ascend variants),
torch.distributed.batch_isend_irecv hangs during weight update. Add
AWEX_WU_USE_GROUP env var so callers can fall back to per-op send/recv.

Usage: defaults to 1 (use_group=True). Set AWEX_WU_USE_GROUP=0 to bypass
the group path. Applied at the three adapter call sites:
fsdp/megatron/sglang execute_weight_update().

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…real-project#1393)

* feat: disable megatron grad buffers CPU backup to save host memory

Add disable_grad_buffers_cpu_backup option to MegatronEngineConfig to skip
CPU backup for gradient buffers during offload in colocated training.

Gradient buffers are recomputed each training step, so they don't need to
be backed up to CPU memory during offload. This saves host memory
(∼5.9GB for typical configs) and reduces offload/onload overhead.

- Add config flag disable_grad_buffers_cpu_backup (default: False)
- Call DDP.offload_grad_buffers() before TMS pause to release grad storage
- Call DDP.restore_grad_buffers() after TMS resume to reallocate and zero

Tested: numerical equivalence confirmed, host memory saved ≈ grad buffer size

* docs: add disable_grad_buffers_cpu_backup to cli_reference

* fix: disable synchronize/empty_cache in offload/restore_grad_buffers
…project#1436)

available_range subtracted the full exclude_ports count regardless of
whether those ports lay within [min_port, max_port]. Excluded ports
outside the range deflated the availability count and could raise a
spurious ValueError even when every in-range port was free. Intersect
exclude_ports with the range before subtracting.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…areal-project#1430)

* fix(reward): score non-string answers in clevr_count_70k_reward_fn

clevr_count_70k_reward_fn did not str()-coerce its inputs or guard against
errors, unlike the sibling reward fns (gsm8k, geometry3k). A non-string
answer (e.g. an int) made ans.strip() raise AttributeError, which
WorkflowExecutor catches and uses to reject the whole trajectory instead of
scoring the sample — and even a matching completion was lost rather than
scored 1.0.

Coerce completions and answer to str and wrap the body in try/except,
matching the sibling reward fns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(example): apply the same guard to the clevr GRPO example reward fn

The example defines its own clevr_count_70k_reward_fn (referenced via
workflow_kwargs) that still had the old logic. Mirror the built-in fix:
str-coerce completions/answer and guard, so a non-string answer is scored
rather than raising and dropping the trajectory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…-project#1403)

* feat(megatron): make MTP head opt-in to support Qwen3.6 MoE RL

Qwen3.6 MoE checkpoints store their MTP (multi-token-prediction)
experts in a fused layout that megatron-bridge 0.4.x cannot export, so
update_weights crashes with "Object must exist on at least one PP rank"
at step 1. MTP is an inference-only head, not part of the RL objective
and unused by the rollout, so it should not be built for RL training.

Add MegatronEngineConfig.enable_mtp (default False). In make_mcore_model
(megatron-bridge path), drop MTP (provider.mtp_num_layers=None) when the
model has one and enable_mtp is False; raise if enable_mtp is True but
the model has no MTP head. Set True for SFT or bridge builds that
support the model's MTP format.

Refs: areal-project#1398

* docs(examples): add Qwen3.6-35B-A3B megatron geometry3k GRPO config

GRPO recipe for Qwen3.6-35B-A3B on geometry3k with the megatron actor
(megatron-bridge) + vLLM rollout, demonstrating the new
megatron.enable_mtp=False switch (MTP dropped for RL).

Refs: areal-project#1398

* docs(megatron): refine enable_mtp help + Qwen3.6 example (review)

Address PR areal-project#1403 review:
- Scope the enable_mtp help to bridge_type=megatron-bridge (the flag is
  a no-op on the mbridge/registry paths) and drop the unwired SFT claim.
- Add PYTORCH_ALLOC_CONF=expandable_segments to the Qwen3.6 example to
  avoid 35B-MoE OOM.

Refs: areal-project#1398

* fix(engine): keep non-MTP weights in HF export when MTP head is dropped

With enable_mtp=False the bridge export yields no mtp.* tensors, and
megatron-bridge's save_generator with strict=True silently skips every
source shard containing an MTP key -- discarding the non-MTP weights
packed in those shards (Qwen3.6-35B lost lm_head + 2 layers; 27B lost
~16 layers) while rebuilding a consistent-looking index.

Pass strict=False to save_hf_pretrained when the MTP head was dropped so
incomplete shards are written with all present keys (only mtp.* is
absent, as intended), zero the MTP layer counts in the exported
config.json so external loaders do not fabricate an MTP head over
missing weights, and rebuild the safetensors index from the shard files'
actual contents (the bridge's strict=False path leaves ghost mtp.*
entries and a stale metadata.total_size).

Refs: areal-project#1398
* feat(ppo): add CISPO advantage estimator (MiniMax-M1)

PPO/GRPO clipping zeroes the gradient of any token whose importance-sampling
ratio leaves the clip band: `min(r*A, clip(r)*A)` is constant in theta there.
MiniMax-M1 (https://arxiv.org/abs/2506.13585, Eq. 4-5) observes those are
disproportionately the low-probability "fork" tokens (`However`, `Wait`, ...)
that steer reasoning -- exactly the tokens reasoning RL needs gradient on --
and instead clips the IS *weight* under stop-gradient while keeping the
gradient on every token's `log pi_theta`. ScaleRL (arXiv:2510.13786 Eq. 4)
adopts the same surrogate. AReaL had grpo/gspo/ppo/sapo but no CISPO.

Per token, opt-in via `actor.use_cispo_loss=true`:

    ratio         = exp(logprobs - proximal_logprobs)
    ratio_clipped = clip(ratio, 1 - eps_clip, 1 + eps_clip_higher)   # stop-grad
    pg_loss       = -sg(ratio_clipped) * advantages * logprobs

Advantages are never clipped. The clip reuses the existing delta-from-1
`eps_clip` / `eps_clip_higher` plumbing (same convention as GSPO); CISPO is
canonically single-sided, so the recommended setting is `eps_clip=1.0`
(lower bound 0) with `eps_clip_higher=4.0` for the wide MiniMax-M1 range.

Shape of the change -- additive, fits AReaL's existing estimator seam:
- `functional.py`: `cispo_loss_fn` (+ export), reusing the token-mean reduction
  and the PPO-compatible stat schema so the existing clip-stat logging path
  applies unchanged; `clip_mask` reports band-exit on either side (under CISPO
  no clip zeroes the loss, so band-exit -- not loss-affecting clip -- is the
  meaningful metric).
- `actor.py`: `grpo_loss_fn` dispatches to CISPO before SAPO/PPO.
- `cli_args.py`: `PPOActorConfig.use_cispo_loss` with `__post_init__` validation
  (mutually exclusive with SAPO, requires `eps_clip_higher > 0`, token-level
  importance sampling only) + regenerated CLI docs (en/zh).

Defaults unchanged (`use_cispo_loss=False`) -> byte-identical to before.

`tests/test_cispo_loss.py` pins the two defining invariants, each across a
PPO-like band (0.2/0.28) and the wide MiniMax band (1.0/4.0): the closed-form
surrogate value + clip indices, and gradient routing
(`logprobs.grad == -sg(clip(ratio)) * A / N`, zero gradient through the IS-ratio
path -- mutation-verified to fail if the stop-gradient detach is dropped), plus
config validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(ppo): support decoupled loss for CISPO via rejection sampling

The CISPO branch ignored use_decoupled_loss / rejection_sampling, silently
dropping the off-policy correction in exactly the async/stale regime CISPO
targets. CISPO already anchors its clipped IS ratio at pi_proximal; thread the
behavior logp + rejection_sampling through cispo_loss_fn and rescale each
token's surrogate by the detached pi_proximal/pi_behave weight, mirroring
ppo_actor_loss_fn. Both factors are stop-gradient, so the estimator stays
-sg(behave_imp_weight * ratio_clipped) * A * grad(log pi_theta).

Addresses the maintainer review on the dispatch bypassing the decoupled check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: EazyReal <8047065+EazyReal@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Default fp32 master weights (PR areal-project#1369) push Qwen2.5-VL-3B beyond A100-40G capacity at the first AdamW step. Pin the test to bf16 storage with adam_bf16 (Kahan summation) so it runs on 40G runners.

## Description

<!-- Provide a clear and concise description of what this PR does -->

## Related Issue

<!-- Link to the issue this PR addresses. PRs should be related to a well-templated issue. -->

Fixes #(issue)

## Type of Change

<!-- Select ONE that best describes this PR -->

- [x] 🐛 Bug fix
- [ ] ✨ New feature
- [ ] 💥 Breaking change
- [ ] 📝 Documentation update
- [ ] ♻️ Refactoring
- [ ] ⚡ Performance improvement
- [ ] ✅ Test coverage improvement

## Checklist

<!-- Mark with 'x' what you've done -->

- [ ] I have read the
  [Contributing Guide](https://github.com/areal-project/community/blob/main/CONTRIBUTING.md)
- [ ] Pre-commit hooks pass (`pre-commit run --all-files`)
- [ ] Relevant tests pass; new tests added for new functionality
- [ ] Documentation updated (if applicable; built with `./docs/build_all.sh`)
- [ ] Branch is up to date with `main`
- [ ] Self-reviewed via `/review-pr` command
- [ ] This PR was created by a coding agent via `/create-pr`
- [ ] This PR is a breaking change

**Breaking Change Details (if applicable):**

<!-- Describe what breaks and how users should migrate -->

## Additional Context

<!-- Add any other context, screenshots, logs, or explanations here -->

______________________________________________________________________

**Need help?** Check the
[Contributing Guide](https://github.com/areal-project/community/blob/main/CONTRIBUTING.md)
or ask in [GitHub Discussions](https://github.com/areal-project/AReaL/discussions)!
areal-project#1440)

* feat(cli): add experimental cli scaffold for service-style subcommands

Prepares the ground for feat/inference-service-cli,
feat/training-service-cli, and feat/agent-service-cli to land on top
— each subcommand PR only adds its own click group and one
cli.add_command call, instead of duplicating the same plumbing.

Key pieces:
- empty top-level `cli` click group with version + --help wiring;
  subcommand modules attach themselves via cli.add_command(...)
- `state.areal_home()` + `atomic_write_json()` for AREAL_HOME-rooted
  local state files (atomic .tmp + os.replace)
- `process` module: pid_alive / pick_free_port / signal_pid /
  kill_pids + spawn_process with start_new_session=True so detached
  worker processes survive parent SIGHUP and a graceful
  SIGTERM→grace→SIGKILL teardown sequence is one helper away
- pyproject: `[project.scripts] areal = ...cli.main:cli`,
  optional `[cli]` deps (click, colorlog)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* build(cli): regenerate uv lockfiles for cli optional deps

Pre-commit's uv-lock hook expects the lockfiles to track click and
colorlog after they were added to [project.optional-dependencies] cli.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(cli): address gemini review on process/state utilities

- pid_alive: try os.waitpid(pid, WNOHANG) before os.kill so a zombie
  child does not look alive — otherwise kill_pids waits its full grace
  window and sends a redundant SIGKILL.
- spawn_process: close the parent's log_handle copy after Popen dup()s
  it for the child, so repeated spawns do not leak fds.
- atomic_write_json: use tempfile.NamedTemporaryFile + os.fsync, and
  unlink the tempfile on serialization or rename failure — fixes a
  tempfile leak on bad input, durability against crashes, and a race
  between concurrent writers on the same path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(cli): unify terminate + reuse kill_process_tree, drop pick_free_port

Replace the scaffold's bespoke process primitives with shared utilities
already in the codebase:

- process: kill_pids now delegates to areal.infra.utils.proc.kill_process_tree,
  which walks the descendant tree via psutil. This fixes the bug TaoZex
  raised on signal_pid's inconsistent PermissionError handling — signal_pid
  is gone entirely.
- process: drop pick_free_port. Callers should use
  areal.utils.network.find_free_ports, which draws from a non-ephemeral
  port range and supports exclude_ports — addresses the TOCTOU race
  guozhihao-224 flagged on the naive bind(0) implementation.
- scheduler: new module with a stateless top-level terminate(ref, *,
  backend, grace_s) that subcommand CLIs can funnel teardown through.
  Only the local backend is implemented; future backends extend the
  dispatch in-module. The free-function shape keeps the dispatcher from
  drifting into stateful scheduler instances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: integrate CLI shared files

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style(cli): drop extra blank line after imports in utils.py

Local pre-commit auto-fixed the blank line but the fix wasn't
re-staged before commit 9401918, so CI's ruff hook caught it.

* refactor(cli): collect namespace path helpers into NamespacedStateStore

state.py previously exposed 13 free functions (namespace_root,
services_dir, logs_dir, service_state_path, …, recover_pids_from_raw_state)
that all took ``namespace`` as their first argument. Promote them to
methods on a new NamespacedStateStore class so subcommand CLIs construct
one instance per namespace and stop threading the namespace string
through every call. areal_home, atomic_write_json, SupportsComponentProbe,
and ServiceStateBase remain at module level.

ServiceLifecycle and ConfigLoader each hold a store instance built from
their namespace at __init__ time; LogsCommand resolves log paths via
``lifecycle.store.logs_dir``. No behavior change.

Note: downstream branches (inf / agent / train) will need to swap their
imports from ``state.service_state_path``/``state.clear_current_service``/
etc. to ``store.<method>`` on the next rebase.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…real-project#1448)

* refactor: move 5 experimental modules into areal/v2 for 2.0 release

Move agent_service, inference_service, training_service, weight_update,
and cli from areal/experimental/ to areal/v2/, and rewrite every
reference (Python imports, `python -m` invocations, console-script
entry points, CODEOWNERS, docs, review-pr signals) to the new path.

- 5 directories migrated via `git mv` (history preserved)
- 70 files modified across areal/, tests/, examples/, pyproject{,vllm}.toml,
  .github/CODEOWNERS, ROADMAP.md, and tooling docs
- `areal/v2/__init__.py` added so v2 is an importable package
- Build config (tool.uv.build-backend with module-root="") auto-discovers
  the new package — no pyproject changes beyond the entry point

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style: apply pre-commit auto-fixes after areal/v2 move

CI pre-commit job reformatted 54 files automatically:
- .github/CODEOWNERS: realign owner columns (32-col) after shorter
  /areal/v2/ paths broke the previous 40-col alignment
- areal/**, tests/**, examples/**, docs/**: ruff isort reorders
  `areal.v2.*` imports into their new alphabetical slot (between
  `areal.engine` and `areal.infra`)
- markdown/yaml whitespace normalized by mdformat / ruff-format

Pure formatting; no logic change. `pre-commit run --all-files` is
now green locally.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(tests): point py<3.12 conftest stubs at areal/v2/

Two test conftest.py stubs (Python 3.10/3.11 compat) still had the
`areal/experimental/{inference_service,weight_update}` path as the
namespace package's __path__, broken by the v2 move. The sed pass
missed them because the paths were comma-separated `os.path.join`
args (`"areal", "experimental", "X"`), not slash-form path strings.

Additionally insert an `areal.v2` stub between `areal` and the leaf
package so the parent→child attribute wiring loop (which uses
`name.rsplit(".", 1)`) can find a parent module in sys.modules.
Without it `setattr(parent, child, ...)` silently no-ops and
`unittest.mock.patch` traversal breaks on the new path.

Spotted by gemini-code-assist on PR areal-project#1448.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(tests): mirror areal/v2 move under tests/v2

Move agent_service, inference_service, training_service, and
weight_update test directories from tests/experimental/ to tests/v2/
to mirror the source-tree layout. tests/experimental/ retains archon/
and openai/ (still-experimental modules).

- 50 files migrated via `git mv` (history preserved)
- tests/v2/__init__.py added
- 9 files rewritten for the new dotted/slashed test paths:
  `tests.experimental.{4 modules}` → `tests.v2.{4 modules}`
  `tests/experimental/{4 modules}` → `tests/v2/{4 modules}`
  (covers integration_utils imports, fake_train_engine engine_class,
   pytest invocation strings in docstrings, and the
   tests/v2/weight_update/torchrun/run_nccl_weight_transfer.py path)

conftest stubs (Python <3.12 namespace shims) keep working because
_REPO_ROOT is computed via "..", "..", ".." from the conftest file —
same depth under tests/v2/X/ as under tests/experimental/X/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
)

Support LoRA RL training under actor._version=v2 / rollout._version=v2
* chore: integrate CLI shared files

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(cli): training experiment launcher — `areal train run`

Wraps a user-supplied driver entry point (`module.path:func`) into a
uniform CLI invocation driven by yaml + hydra overrides.  Same lifecycle
as `python -m examples.math.gsm8k_rl ...` — the CLI process is the
driver process, attached, all the way down.

Single verb, permanently.  No ps / stop / status / logs: a training job
is a process, not a service; OS tooling (`ps`, `kill`) and the cluster
scheduler (`squeue`, `kubectl get`) already cover that, and logs live
at `{fileroot}/logs/...` for `tail -F`.  Service-shaped verbs belong
with `areal inf`, whose state is daemon-backed and invisible to OS
tooling.

Usage:

    areal train run --config experiments/grpo.yaml \
        --driver examples.math.gsm8k_rl:main \
        actor.lr=1e-5 trial_name=lr-sweep-3 +actor._version=v2

`--driver MOD:FN` is required; we deliberately don't peek a `driver:`
field out of the yaml — keeps the contract obvious and avoids parsing
yaml in the CLI layer.

Anything after the flags is forwarded verbatim as a hydra override to
the driver (click's `nargs=-1 + UNPROCESSED + ignore_unknown_options`).

Backgrounding is out of scope: users wrap the command in
nohup/tmux/sbatch as appropriate.  Adding `--detach` would force state
files, heartbeat, pid tracking — none of which anything else here needs.

Conflicts with `feat/inference-service-cli` are isolated to main.py +
pyproject.toml's [cli] extra; trivial 3-way merge after the inf branch
lands.

* refactor(experimental): split training CLI commands

Move the training CLI entrypoint into a root CLI module and a training command package so future training commands can be added without growing a monolithic command file.

* docs(cli/train): add training service CLI guide

Document the `areal train` subcommand group: basic concepts, the
driver function contract, hydra override conventions, and exit code
behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(experimental): rebase train CLI onto cli scaffold v2

Train doesn't carry a service / daemon / state, so the scaffold's
ServiceLifecycle / BaseHTTPClient / StatusReporter components don't
apply — train run is a synchronous importlib dispatcher. The change
here is just plumbing alignment:

- Rebase onto feat/experimental-cli-scaffold; scaffold now owns
  cli/__main__.py / cli/cli.py.
- Drop the redundant cli/main.py shim (cli/__main__.py covers it).
- Point [project.scripts] areal entry at cli.cli:cli in both
  pyproject manifests, matching the agent / inf branches.
- Re-flow training/cli_guide.md through mdformat.

* docs(cli/train): drop the evaluation example from the guide

* docs(cli/train): drop the BOBA-GRPO example from the guide

* fix(cli): resolve config merge leftovers

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cli): add experimental cli scaffold for service-style subcommands

Prepares the ground for feat/inference-service-cli,
feat/training-service-cli, and feat/agent-service-cli to land on top
— each subcommand PR only adds its own click group and one
cli.add_command call, instead of duplicating the same plumbing.

Key pieces:
- empty top-level `cli` click group with version + --help wiring;
  subcommand modules attach themselves via cli.add_command(...)
- `state.areal_home()` + `atomic_write_json()` for AREAL_HOME-rooted
  local state files (atomic .tmp + os.replace)
- `process` module: pid_alive / pick_free_port / signal_pid /
  kill_pids + spawn_process with start_new_session=True so detached
  worker processes survive parent SIGHUP and a graceful
  SIGTERM→grace→SIGKILL teardown sequence is one helper away
- pyproject: `[project.scripts] areal = ...cli.main:cli`,
  optional `[cli]` deps (click, colorlog)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(cli): unify terminate + reuse kill_process_tree, drop pick_free_port

Replace the scaffold's bespoke process primitives with shared utilities
already in the codebase:

- process: kill_pids now delegates to areal.infra.utils.proc.kill_process_tree,
  which walks the descendant tree via psutil. This fixes the bug TaoZex
  raised on signal_pid's inconsistent PermissionError handling — signal_pid
  is gone entirely.
- process: drop pick_free_port. Callers should use
  areal.utils.network.find_free_ports, which draws from a non-ephemeral
  port range and supports exclude_ports — addresses the TOCTOU race
  guozhihao-224 flagged on the naive bind(0) implementation.
- scheduler: new module with a stateless top-level terminate(ref, *,
  backend, grace_s) that subcommand CLIs can funnel teardown through.
  Only the local backend is implemented; future backends extend the
  dispatch in-module. The free-function shape keeps the dispatcher from
  drifting into stateful scheduler instances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: integrate CLI shared files

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(cli): collect namespace path helpers into NamespacedStateStore

state.py previously exposed 13 free functions (namespace_root,
services_dir, logs_dir, service_state_path, …, recover_pids_from_raw_state)
that all took ``namespace`` as their first argument. Promote them to
methods on a new NamespacedStateStore class so subcommand CLIs construct
one instance per namespace and stop threading the namespace string
through every call. areal_home, atomic_write_json, SupportsComponentProbe,
and ServiceStateBase remain at module level.

ServiceLifecycle and ConfigLoader each hold a store instance built from
their namespace at __init__ time; LogsCommand resolves log paths via
``lifecycle.store.logs_dir``. No behavior change.

Note: downstream branches (inf / agent / train) will need to swap their
imports from ``state.service_state_path``/``state.clear_current_service``/
etc. to ``store.<method>`` on the next rebase.

* feat(experimental): add agent service CLI

* refactor(experimental): align agent CLI with Click

* refactor(experimental): remove agent rl negotiated flag

Use the existing RL session id and API key as the source of truth instead of keeping a derived boolean in agent session state.

Key changes:

- Drop rl_negotiated from SessionState and tolerate legacy state files

- Route agent CLI and demo output through loggers instead of print

- Update CLI state tests for the revised session schema

* refactor(experimental): avoid creating agent session on run

Agent service startup should only launch runtime components. Sessions are now created explicitly through new_session.

Key changes:

- Remove initial session creation from areal agent run

- Drop the run --session-key option

- Add CLI tests for session-free startup

* refactor(experimental): align agent CLI with inference; drop session verbs

Pulled the agent CLI in line with feat/inference-service-cli and removed
surface the controller-replacement scope does not need.

Style alignment with inference CLI:
- adopt 2-step *_cmd -> do_* (drop the redundant handle layer)
- table / JSON output via click.echo; hard failures via click.ClickException
- Click default_map driven by load_click_default_map(_BINDINGS); drop the
  hand-rolled cfg_get / resolve_* helpers
- single AgentCli logger (registered color in areal/utils/logging.py),
  exported from new agent/common.py alongside running_state /
  load_running_state / wait_http_health
- AgentCLIHTTPError / AgentCLIUnreachable renamed to AgentHTTPError /
  AgentUnreachable; per-server clients renamed (GatewayClient,
  RouterClient, DataProxyClient) to mirror inference/client.py

Removed surface:
- interactive REPL stub (interactive.py + --interactive / --stop-on-exit
  / --history-file on run); chat / reward placeholders never shipped
- destroy / health aliases of stop / status
- new_session / switch_session and session_ops; SessionState /
  SessionsState / InferenceClient go with them. Session lifecycle is
  moving to a gateway /v1/sessions REST endpoint; CLI no longer owns it
- legacy rl_negotiated migration in state.py (the flag was removed two
  commits ago; no production state needs the shim)

Net diff: -830 lines, 6 tests green, pre-commit clean.

* refactor(experimental): drop cli scaffold overlap pending areal-project#1440

The shared cli/cli.py + cli/main.py entry points and the pyproject
[project.scripts] + the example invocation tweak duplicated plumbing
that the cli scaffold PR (areal-project#1440) now provides. Drop them here so this
branch can rebase cleanly on top of areal-project#1440 once that lands; the agent
CLI itself (cli/agent/*) is untouched and continues to live in this
PR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(experimental): rename agent http.py to client.py for parity with inf

inf-cli puts the same kind of HTTP client wrappers in
cli/inference/client.py; rename the agent counterpart to match so the
two sibling subcommand packages have parallel layouts. Pure rename +
three import updates, no behavior change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(experimental): adopt cli scaffold v2 in agent service

Rebase onto feat/experimental-cli-scaffold and replace the per-CLI
duplicates with the shared base classes/utilities:

- state.py now keeps only the agent dataclasses; ServiceState
  satisfies ServiceStateBase and gateway_alive() is anchored on the
  gateway PID only (fixes the prior "any pid alive" check that kept
  reporting running after the gateway died).
- config.py / client.py / lifecycle.py shrink to thin subclasses of
  ConfigLoader / BaseHTTPClient / ServiceLifecycle.
- run.py and stop.py route through ServiceLifecycle for double-start
  refusal, force-replace, and state cleanup.
- status.py renders via StatusReporter + ColumnSpec; ps.py emits via
  json_or_table; the bespoke logs subcommand is dropped in favor of
  LogsCommand(lifecycle=...).build().
- launcher.py reserves all ports up front through find_free_ports
  (non-ephemeral, no TOCTOU) and uses the scaffold spawn/wait helpers.

Net ~360 LOC dropped from the agent module while every behavioral
guarantee carries over.

* refactor(experimental): drop empty Gateway/DataProxy client subclasses

Both classes only inherited BaseHTTPClient.health() and were never
instantiated after the scaffold refactor (StatusReporter probes addrs
directly, launcher only uses RouterClient). Callers that ever need a
gateway/data-proxy health probe can do BaseHTTPClient(addr) — the
variable name carries the role.

RouterClient stays because register_proxy is a real method.

* fix(experimental): point areal CLI script at cli.py entry

main.py was dropped earlier when scaffold landed (its single-line
shim was redundant with __main__.py), but the [project.scripts] entry
still referenced it, leaving the installed ``areal`` console script
broken. Update both pyproject manifests to ``cli.cli:cli``.

* docs(cli/agent): add agent service CLI guide

Document the `areal agent` subcommand group: launching the
gateway/router with N worker/data-proxy pairs, inspecting service
state, log management, two-phase shutdown, configuration precedence,
and the relationship with `areal inf`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style(docs): apply mdformat to agent CLI guide

* refactor(experimental): adopt scaffold NamespacedStateStore in agent

scaffold's state.py promoted the namespace-aware free functions onto
NamespacedStateStore. Update agent accordingly: hold a module-level
``store = NamespacedStateStore(AGENT_NAMESPACE)`` and route every
service_state_path / set_current_service / clear_current_service /
logs_dir call through it. No subclassing needed — agent has no extra
state files (vs inf's two-file split).

Behavior unchanged; 6 tests still pass.

* style: sort v2 CLI imports

* refactor(v2/cli/agent): drop dead --inf-* options

The --inf-addr / --inf-api-key / --inf-model surface was a placeholder
for a session-api-key negotiation that never landed: the values were
written into ServiceState but no downstream code (launcher, gateway,
worker, data_proxy, /v1/responses bridge) reads them. Keeping the
options advertises functionality the CLI does not provide.

- run.py: drop the three click options and do_run params
- launcher.py: drop the inf_* kwargs on launch_agent_stack
- state.py: drop inf_* fields from ServiceState; pop them on load() for
  forward-compat with state files written by older revisions
- config.py: drop the [inference] -> run.inf_* TOML bindings
- cli_guide.md: drop the --inf-* example, the "Relationship with
  areal inf" section, and the [inference] TOML block; also drop the
  "Multiple pairs" example and fix the agent import path syntax from
  the package:Class colon form to the module.Class dot form (the
  worker resolves the path via import_from_string which requires dots)
- tests: drop inf_* kwargs from test_agent_cli_run; rewrite the config
  default_map test to assert on a non-inference key

Will be reintroduced (with real wiring) when /v1/sessions lands on the
gateway and the gateway process needs inf credentials to negotiate
session_api_key for RL trajectory association.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cli): inference service CLI — daemon + 4 verbs

`areal inf` is an ollama-style operator console for the local inference
service.  One daemon per user, one OpenAI-compatible gateway endpoint,
models registered against it.

Verbs:

  inf run     start daemon (gateway + router); inline --model registers
              an external (--api-url) or internal (--backend / --model-path)
              model in the same call
  inf ps      list registered models
  inf status  daemon health + model count
  inf stop    SIGTERM gateway+router, grace period, then SIGKILL

State is a single ~/.areal/inf/state.json (pid + url + admin key +
started_at).  Everything else lives in the gateway/router process
memory; CLI is otherwise stateless.

Layout under areal/experimental/cli/:
  main.py / state.py            shared scaffold (areal_home, pid_alive,
                                atomic_write_json), to be reused by
                                `areal train` in a separate PR
  commands/inf/__init__.py      all four verbs + register helpers in
                                a single file (one place to scan
                                what the user can do)
  commands/inf/state.py         DaemonState dataclass + paths
  commands/inf/launcher.py      subprocess spawn helpers, including
                                base_gpu_id support for sglang dp>1
  commands/inf/client.py        urllib gateway + router HTTP client

Heavy imports (sglang/vllm/torch via areal.api.cli_args) stay lazy —
inside register helpers only — so `areal inf -h` and `areal -h` parse
the click tree without paying for them.

* fix(cli/inf): persist model worker pids in state.json so stop kills them

`_register_internal` returns the list of sglang+data-proxy pids it
spawned, but `_do_run` discarded the return value.  state.json then
only held gateway+router pids, so `areal inf stop` left sglang and
data-proxy processes orphaned.

Add a `worker_pids: list[int]` field to DaemonState, save the list
right after register_internal succeeds, and include those pids in
the stop kill set.

Also fix _do_stop short-circuiting on a dead gateway pid — even when
the daemon front-end is dead, model worker pids in state may still
be alive (the original bug pattern this commit fixes).  Always try
to kill every pid we know about, then drop the state file.

* fix(cli/inf): route startup messages through getLogger; align admin key default

Startup-time prints (router/gateway pids, replica spawn lines, daemon
ready, foreground / shutdown notices) were going through click.echo,
so they bypassed the AReaL log formatter — no timestamps, no log
level, no PascalCase tag.  Switch all of these to
`getLogger("InfCli").info(...)` so output looks like the rest of the
project:

  20260611-09:14:02.157 InfCli INFO: starting inference daemon ...
  20260611-09:14:02.342 InfCli INFO: router pid=304116 http://...
  20260611-09:14:02.910 InfCli INFO: gateway pid=304118 http://...

Register InfCli in LOGGER_COLORS_EXACT under the launcher (blue)
group, since `areal inf run` is conceptually a process launcher.

Reads kept on click.echo on purpose: `inf ps` / `inf status` plain-text
output is structured (tabular or single-field-per-line) and meant to
be consumed by jq / awk in scripts; piping that through the colored
log formatter would break it.

Also flip `--admin-api-key` default from "areal-admin-key" to
"admin-api-key" — matches the existing inference_service convention.

* feat(cli/inf): standalone `register` and `deregister` verbs

Phase 2 — model lifecycle separated from daemon lifecycle.

  areal inf register <name> [external | internal flags]
      Register a new model against a running daemon.  Same flags as
      `inf run --model ...`, just attached to an existing service.

  areal inf deregister <name> [--grace] [--force]
      Drop the model from the router, unregister its proxy workers,
      and SIGTERM/SIGKILL the spawned sglang+data-proxy procs.

DaemonState gains a `models: dict[str, ModelEntry]` mapping each
registered model to its (pids, proxy_addrs).  This replaces the
previous flat `worker_pids: list[int]` so deregister can target one
model's processes without touching the others.  `inf stop` flattens
across all entries and kills the whole set; the foreground / failure
cleanup paths use the same flatten.

`_register_internal` now returns `(pids, proxy_addrs)` rather than
just pids — both are needed at deregister time (router unregister
takes the proxy addr; SIGTERM takes the pid).

External models also get a state entry now (empty pids/addrs) so
`inf deregister` can find them and drop them from the router.

phase 1 verbs unchanged.

* feat(cli/inf): phase 3 — reward + collect verbs

Two verbs that round out the RL data path:

  areal inf reward <session_api_key> <reward> [--model X]
      thin wrapper around POST /rl/set_reward.  set_reward is the
      only thing that flips an active conversation into a ready
      trajectory, so any data-collection flow needs to call it (even
      with a dummy reward=0 just to "flush").  CLI verb is for users
      whose agent is in another language / shell / human raters; agent
      authors writing python are free to POST directly.

  areal inf collect <model> --batch-size N \
                    [--sessions-out FILE] [--output FILE] \
                    [--timeout T] [--poll-interval S] \
                    [--discount D] [--style individual|concat]
      client-side batch orchestrator.  start_session(group_size=N) ->
      hand sessions to the agent (via --sessions-out FILE) -> poll
      /export_trajectories every poll-interval seconds, accumulating
      unique trajectories until N are collected or timeout fires ->
      one final export with remove_session=True for cleanup -> dump
      JSONL.

      Mirrors the controller's rollout_batch path but moves the wait
      loop to the client so gateway / router stay stateless.  Agent
      lifecycle is intentionally NOT inside collect (no --agent-cmd):
      agent runs in its own process and just reads sessions_out.

JSONL is the only output format on purpose -- the gateway already
serializes trajectories to JSON at HTTP boundary, so .pt would mean
re-decoding tensors only to re-encode them; trainers consuming the
output can torch.tensor(x) when they need it.

8 verbs total (run / ps / status / stop / register / deregister /
reward / collect).

* feat(cli/inf): enrich ps/status with model kind / backend / addrs

Phase 2.5 — bring `inf ps` and `inf status` to the design_inf.md
fidelity (sections 11.5 / 11.6) without bringing back the multi-service
concept.

ModelEntry gains four fields:
  kind: 'internal' | 'external'
  backend: spec string ('sglang:tp=2,dp=2') for internal, '' for external
  api_url: external upstream URL, '' for internal
  inference_server_addrs: per-replica sglang/vllm URLs (internal)

`_register_internal` returns (pids, proxy_addrs, inf_addrs); _do_run
and _do_register both fill in the new ModelEntry fields.

inf ps now reads state.models (CLI-side truth) instead of polling
gateway /v1/models.  Output:

  NAME    KIND       BACKEND               WORKERS
  qwen3   internal   sglang:tp=2,dp=2     2
  gpt-4o  external   -                     -

inf status switches to a multi-row table per design 11.5:

  COMPONENT  STATUS      ADDR                   DETAILS
  gateway    ok          http://127.0.0.1:8080  models=2
  router     ok          http://127.0.0.1:..
  qwen3      registered  internal               backend=sglang:tp=2 workers=2
  gpt-4o     registered  external               api_url=https://...

JSON output of both verbs follows suit.

Backwards-compat: ModelEntry's new fields all have defaults, so an
old state.json still loads cleanly.

* feat(cli/inf): add `inf models` verb

`inf ps` currently lists registered models (table: NAME / KIND /
BACKEND / WORKERS).  Add `inf models` as a more explicit alias —
docker-style "different verb for different resource".  `ps` keeps
its current behavior; both call the same _print_models helper so
output is identical.

This isn't part of the multi-service rollback (which we decided not
to do).  It's a small ergonomic addition for the single-daemon shape.

* feat(cli/inf): add `logs` verb + TOML config support + help text for proxy/engine args

Three related additions:

1. `inf logs --component NAME [-f] [-n LINES]`
   Tail a log under ~/.areal/inf/logs/. Component defaults to 'gateway';
   can be 'router' or a full model log basename like 'qwen3-inf-0'.
   -f follows (tail -F), -n sets initial line count (default 200).
   Exec's tail directly for stream fidelity.

2. TOML config support (design 12)
   - Group-level option `areal inf --config FILE` merges FILE on top
     of ~/.areal/inf/config.toml (both optional).
   - config.py loads TOML via tomllib (py3.11+), maps [default] /
     [launch] / [register.internal] / [collect] sections to CLI
     option defaults via click's default_map mechanism.
   - Precedence: CLI flag > --config > ~/.areal/inf/config.toml >
     hardcoded default.

3. Detailed help for --engine-args and --proxy-args
   Users couldn't guess what to pass. Now both flags show inline
   hints (common sglang knobs / data-proxy flags + defaults). Help
   is a single paragraph so click's wrap_text handles terminal width.

10 verbs total (run / stop / ps / status / models / register /
deregister / reward / collect / logs).

* fix(cli/inf): align `collect` flags with design 11.9

Rename + add + drop options on `inf collect` so the surface matches
the design spec exactly:

  rename  --discount       -> --turn-discount
  rename  --style          -> --export-style
  add     --format json|jsonl (default jsonl)
  add     --json           progress-events flag (placeholder; not implemented)
  drop    --task-id        (always 'cli-collect' internally)
  drop    --sessions-out   (agents query gateway directly)
  drop    --poll-interval  (always 2.0s internally)

JSON output (--format json) emits {tid: interaction, ...} pretty-printed.
JSONL output (default) emits one trajectory per line, each prefixed
with trajectory_id.

config.toml [collect] keys renamed to match.

* fix(cli/inf): three bugs (admin key, gpu collision, sglang request log)

P1. --admin-api-key default reverts from "admin-api-key" to
    "areal-admin-key" — matches the v2 inference_service convention
    used everywhere else in the codebase.

P2. Registering a second internal model collided with the first on
    GPUs 0..tp-1. Cause: base_gpu_id was always r * tp, computed only
    against the current model's dp index, ignoring GPUs already used
    by previously-registered models. Fix: track a monotonic
    `next_gpu_id` cursor on DaemonState and pass it into
    `_register_internal` as `base_gpu_id`. Each ModelEntry now
    records its (base_gpu_id, gpu_count) so deregister can roll back
    the cursor when removing the *last* registered model (preserves
    contiguous allocation; doesn't try to coalesce holes in the
    middle, which is fine for the v1 use case).

P3. sglang server logs only had model-load output, no chat requests.
    Cause: SGLangConfig.log_requests defaults to False. Fix: spawn
    sglang with log_requests=True so /chat/completions traffic shows
    up under ~/.areal/inf/logs/<model>-inf-N.log. (data-proxy
    access logs are off via uvicorn config inside the data-proxy
    package itself; that's not under inf CLI's control.)

* refactor(experimental): split inference CLI commands

Move the inference service CLI out of the monolithic commands package and into command-specific modules. Align collect with the session/export flow by returning session keys, polling exports without revoking the router group, and cleaning up at the end.

* feat(experimental): support multiple inference services

* fix(experimental): harden inference CLI lifecycle

Protect inference service state transitions so register and run do not race or leave orphaned processes behind.

Key changes:

- Track engine and proxy PIDs separately for phased shutdown

- Lock model state during register and startup model setup

- Clean up foreground services on SIGTERM and SIGHUP

- Recover raw PIDs before forced service replacement

- Align inference CLI model and session option names with the design

* feat(cli/inf): add scheduler abstraction for worker placement

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(cli/inf): widen probe timeout, parallelize status, drop default_model

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(experimental): adopt cli scaffold v2 in inference service

Rebase onto feat/experimental-cli-scaffold and replace the per-CLI
duplicates with the shared base classes/utilities:

- state.py keeps the inf-specific dataclasses and the two-file
  recover_pids_from_raw_state; RuntimeState now satisfies
  ServiceStateBase (gateway_alive + components + .load classmethod).
- config.py / client.py / lifecycle.py shrink to thin subclasses of
  ConfigLoader / BaseHTTPClient / ServiceLifecycle. The old
  GatewayHTTPError / GatewayUnreachable names are kept as aliases of
  ServiceHTTPError / ServiceUnreachable so subcommands swap mechanically.
- InferenceLifecycle overrides force_replace_slot to walk the inf
  raw-state helper (which knows about the secondary model-state file)
  and to remove both files on cleanup.
- common.py drops scaffold-replaced helpers (running_state /
  load_running_state / refuse_if_running / wait_http_health /
  wait_client_health / print_services / print_models /
  probe_http_health); keeps backend-spec parsing, model registration,
  TaskHandle formatters, and terminate_runtime_state (data-flow order
  is inf-specific).
- commands/run.py uses ServiceLifecycle for refuse / force-replace and
  ForegroundWatcher for the SIGINT/SIGTERM/SIGHUP handling.
- commands/stop / status / ps / models / register / deregister /
  reward / collect route through inf_lifecycle; status.py emits via
  StatusReporter + ColumnSpec; ps/models via json_or_table.
- launcher.py and scheduler/local.py replace pick_free_port with
  find_free_ports (non-ephemeral, no TOCTOU); LocalScheduler tracks
  allocated ports across submits.
- commands/logs.py is removed; LogsCommand(lifecycle=inf_lifecycle)
  wires the verb in __init__.py.

Net ~290 LOC dropped while every behavioral guarantee carries over.

* docs(cli/inf): add inference service CLI guide

Document the `areal inf` subcommand group: launching the gateway/router,
registering models, RL session flow with rewards, trajectory collection,
log management, and configuration file precedence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style(docs): apply mdformat to inference CLI guide

* refactor(experimental): address PR review on inference CLI

Address garrett4wade's review on areal-project#1435:

1+2) Drop the unfinished `collect` verb and the gateway RPCs it
   depended on. `commands/collect.py` is removed entirely; the gateway
   client no longer exposes `start_session` / `export_trajectories`
   (only `set_reward` remains, used by the surviving `reward` verb).
   The cli group, config bindings, cli_guide section, and parser tests
   are cleaned up accordingly.

3) Replace the bespoke `sglang:tp=2,dp=2` mini-DSL in
   `parse_backend_spec` with `ModelAllocation.from_str`, so the CLI
   accepts the same grammar as `InferenceEngineConfig.backend` in YAML
   configs (`sglang:d4`, `vllm:d2t4`). Help text, doc examples, and
   test fixtures are updated to the new form.

4) Collect the free functions in `state.py` (`models_dir`,
   `models_state_path`, `models_lock_path`, `locked_model_state`,
   `recover_pids_from_raw_state`) into an `InferenceStateStore` class
   and route every caller through a module-level `store` instance.
   The dataclasses now obtain paths via `store.<...>`, keeping
   on-disk-layout responsibilities in one place.

Net: 13 files, +170 / -524 (mostly from dropping the collect verb).

* style(tests): drop trailing blank lines after removed collect case

* refactor(experimental): subclass scaffold NamespacedStateStore in inference

scaffold's state.py promoted the namespace-aware free functions onto
NamespacedStateStore. Update inference accordingly:

- InferenceStateStore now subclasses NamespacedStateStore, gaining
  service_state_path / set_current_service / clear_current_service /
  current_service_path / resolve_service_name from the parent. It keeps
  the inf-specific models_*, lock_model_state, and overrides
  recover_pids_from_raw_state to walk both state files.
- ServiceState.save / .remove route through ``store.set_current_service``
  / ``store.clear_current_service`` instead of the deleted free
  functions; commands/run.py / register.py / tests resolve log paths
  via ``store.logs_dir`` and the service-state path via
  ``store.service_state_path``.

Behavior unchanged.

* style: sort v2 CLI imports

* refactor(experimental): drop reward verb + RL session flow docs

Address PR areal-project#1434 review:

- Remove ``areal inf reward`` (commands/reward.py + the ``reward_cmd``
  wiring in __init__.py) and the corresponding GatewayClient.set_reward
  RPC. The reward flow is server-side only for now; the CLI does not
  need to wrap it.
- Drop the "RL session flow" and "Setting reward" sections from
  cli_guide.md plus the trailing "For plain inference there is no need
  to call /rl/start_session" note.
- Drop the ``reward`` entry from the [default].service binding tuple
  in config.py and the reward-related cases in the parser test.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…les (areal-project#1383)

* feat(agent_service): add agent service with OpenClaw and Hermes examples

Add an end-to-end agent service stack under areal/v2/agent_service with
a gateway bridge, data proxy, and worker, plus two example agents
(OpenClaw and Hermes) demonstrating session lifecycle, reward setting,
and training integration.

Key changes:
- Extend gateway bridge and data proxy to drive agent sessions
- Add OpenClaw and Hermes example agents under examples/agent_service
- Add lifecycle demo, run scripts, and config for the Hermes example
- Add integration and per-agent tests under tests/v2/agent_service

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(examples): drop redundant hermes session self-check scripts

The hermes agent service session flow only needs run_agent_service.py:
chat sessions are keyed implicitly by the /v1/responses "user" field,
and self-evolution mints the per-session sk-sess-* key internally. The
start_session.py / demo_lifecycle.py scripts only probed the inference
gateway control plane and duplicated examples/openclaw, so remove them.

Key changes:
- Delete examples/agent_service/hermes/{start_session,demo_lifecycle}.py
- Remove the README "Connectivity self-checks" section and Files rows
- Repoint set_reward.py docs to run_agent_service.py for the session key

* refactor(examples): hoist hermes example to examples/hermes top level

Move the Hermes agent-service example from examples/agent_service/hermes
to examples/hermes, putting it at the same level as examples/openclaw,
and drop the superseded examples/agent_service/openclaw (the standalone
examples/openclaw already replaces it).

Key changes:
- git mv examples/agent_service/hermes -> examples/hermes
- Update agent_cls_path and docs to examples.hermes.hermes.HermesAgent
- Rewrite README paths from examples/agent_service/hermes to examples/hermes
- Remove obsolete examples/agent_service/openclaw

Refs: areal-project#1383

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(examples): split hermes flow into standalone step scripts

Replace the monolithic run_agent_service.py with a 5-step flow matching
the openclaw example: train.py, `areal agent run`, start_session.py,
hermes_loop.py interaction, and set_reward.py. Update start_session.py
for the v2 inference service (HTTP 201 + nested session credentials),
fold training defaults into config.yaml, and rewrite the README.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(examples): inline CLI formatting helpers and trim hermes README

Remove the shared examples/hermes/_fmt.py module by inlining the helpers it
provided into the two scripts that used it, so each script is self-contained.
Also condense the README prose and use a placeholder model path.

Key changes:
- Inline formatting helpers into start_session.py and set_reward.py
- Delete examples/hermes/_fmt.py and its README entry
- Simplify quick-start prose; use actor.path=/path/to/your_model
- Switch config.yaml actor backend to megatron:d1

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(examples): clarify session key usage in hermes and openclaw READMEs

Annotate which placeholders reuse the sk-sess-* key returned by
start_session and distinguish them from the upstream LLM credentials.
Trim the openclaw README to focus on the RL training flow.

Key changes:
- Mark hermes --session-api-key / --api-key as the start_session key
- Note HERMES_UPSTREAM_* are your own upstream LLM credentials
- Use placeholder values instead of sk-... in env exports
- Remove the standalone agent-service section from openclaw README

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style(test): fix import grouping in agent service tests

Remove the stray blank line between the pytest import and the
examples.* imports so ruff's isort check passes in CI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
)

Add a reuse_train_logp option to prox_logp_method for decoupled PPO. It
reuses the training forward-pass logprobs (detached) as the proximal logp,
skipping the extra proximal forward pass and its memory/compute cost.

This is only valid with ppo_n_minibatches=1: with a single minibatch the
training forward still reflects the policy that generated the rollout, so
its logprobs equal the proximal policy. With multiple minibatches the
weights change between steps, so PPOActorConfig.__post_init__ rejects that
combination.

Add tests for the enum/constant wiring, skips_forward_pass, and the
ppo_n_minibatches validation.
…roject#1457)

reuse_train_logp requires ppo_n_minibatches=1 so the training
forward pass still reflects the policy that produced the rollout.
Previously an invalid combination raised ValueError; instead warn and
force ppo_n_minibatches=1, making clear this reduces the PPO update to a
single optimizer step. Remove obsolete tests that asserted behavior no
longer relevant to this follow-up.
* chore: add AReaL 2.0 report paper

* chore: rename paper file to AReaL2.0_report.pdf
Run a configurable post-exit shell command after local, Ray, and Slurm launchers stop jobs on timeout, interrupt, or failure paths. The hook receives LOG_DIR, has a 600-second timeout, and logs failures without interrupting shutdown or recovery. Ensure the hook still runs if launcher shutdown raises. Add the config field, unit tests, and regenerated CLI docs.
…-project#1460)

Add Megatron context-parallel output gathering for forward-only paths and plumb vocabulary logits statistics through PPO/DPO/SFT losses.

Support per-key reduce groups in StatsTracker so CP-local loss and vocab statistics can reduce across the appropriate DP/CP group without changing the default reduction group for unrelated stats.

Expose Megatron/MoE configuration knobs for router fusion, auxiliary-loss-free balancing, router z-loss, FP32 lm_head output, and fused cross entropy. Update Bailing MoE defaults and regenerate CLI docs.
…ion (areal-project#1454)

Use actual trajectory group sizes when applying group-level reward and advantage normalization so failed or filtered rollout samples do not cause fixed-size slices to cross prompt groups.

Pass trajectory metadata through batched_call instead of injecting a sentinel batch key, rename the Normalization argument to group_sizes, and zero singleton leave-one-out groups because they have no peer baseline.

Add tests for variable-size groups, singleton leave-one-out behavior, validation, 2D advantage normalization, and batched_call metadata forwarding.
…e chat template (areal-project#1463)

Some chat templates (e.g. GLM-5.1) iterate over tool_call arguments with .items(), which fails when arguments is a JSON string as per the OpenAI API convention. Normalize tool_call arguments to dicts before applying the chat template in concat_prompt_token_ids_with_parent and the completions/responses tokenizer paths.
Add a SWE-bench RL training workflow under examples/swe:

- train_swe_rl.py: the RL training entrypoint.
- agent.py: the AReaL-SWEAgent rollout workflow that runs SWE-bench agents
  through AReaL's OpenAI-compatible proxy during training.
- preprocessors.py / prefix_matchers.py: message preprocessing and
  interaction-cache prefix matching for the agent proxy.
- filter_function.py: rollout group accept/reject filter.
- qwen3_30b_a3b_grpo.yaml: a runnable Qwen3-Coder GRPO example config.
- README.md: setup guide covering the AReaL-SWEAgent checkout, the
  AEnvironment backend, and Claude Code (cc) agent training.

Also add the SWE dataset loader.
* fix: fix safe-to-test CI workflow

* fix(tests): remove test_hermes_agent.py

Companion cleanup to the openclaw removal in the previous commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: fix wu ci test

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…upport (areal-project#1458)

Extend the OpenAI-compatible proxy rollout server with pluggable message preprocessors and a configurable interaction-cache prefix matcher, add Anthropic-to-OpenAI content handling and tool-call streaming, the Qwen-style tool-call parser, and the accompanying client/proxy/tool-call tests.
…oject#1464)

After areal-project#1454, a size-1 leave-one-out group uses the sample itself as the baseline and normalizes to zero instead of passing through the raw advantage. Update test_group_size_edge_cases to assert the new behavior.
sitabulaixizawaluduo and others added 6 commits July 2, 2026 00:07
swe_sft.py is an SFT-only dataset builder that is not used by the
SWE-bench RL training example (examples/swe/train_swe_rl.py loads raw
problem instances directly) and is referenced only by its own dataset
registration. Remove the module and its registration so the RL example
does not ship unused SFT data-processing code.
…er state correctly (areal-project#1468)

Since the dist_checkpointing refactor in megatron-core v0.14,
flattened_range is legacy, non-reconstructable metadata and is no longer
supported as a persistable layout in the checkpoint serialization path;
the remaining flattened_range code paths were removed upstream in
Megatron-LM PR #2126, and ShardedTensor.validate_metadata_integrity()
now rejects any ShardedTensor with flattened_range set. The
sharded_state_dict API still defaults to fully_sharded_model_space,
which emits flattened_range, so saving a checkpoint with optimizer state
fails on the pinned megatron-core 0.17.0. Request dp_reshardable
sharding instead, matching the optimizer state format upstream now uses
by default.

Also pass is_loading=True when building the load-side template. Without
it a freshly built optimizer skips megatron-core's state pre-allocation,
the template only requests "param", and DCP silently drops
exp_avg/exp_avg_sq on resume -- training continues with a reset
optimizer state at full learning rate, which we observed to cause
gradient-norm spikes and entropy collapse within ~70 steps after
recovery in a large-scale RL run.
Introduce a structured reward result that can carry per-step rewards alongside the final scalar reward, align stepwise rewards to completion tokens in RLVRWorkflow, and let PPO consume the injected token-level process rewards without changing the default scalar-only path.

Key changes:
- add RewardResult and scalar-to-structured normalization helpers
- emit step_rewards and step_reward_mask tensors from RLVRWorkflow
- inject stepwise rewards into PPO total rewards with logging support
- add focused tests for reward normalization and stepwise alignment
@Fyrgo8 Fyrgo8 closed this Jul 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Roadmap] 2026 H2 Milestones

10 participants