starforge-cli 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. starforge_cli/__init__.py +3 -0
  2. starforge_cli/api_client.py +589 -0
  3. starforge_cli/auth.py +349 -0
  4. starforge_cli/catalog.py +124 -0
  5. starforge_cli/cli.py +74 -0
  6. starforge_cli/cli_ui.py +469 -0
  7. starforge_cli/client_device.py +104 -0
  8. starforge_cli/commands/__init__.py +1 -0
  9. starforge_cli/commands/admin.py +140 -0
  10. starforge_cli/commands/bench.py +94 -0
  11. starforge_cli/commands/common.py +178 -0
  12. starforge_cli/commands/dataset.py +150 -0
  13. starforge_cli/commands/exp.py +213 -0
  14. starforge_cli/commands/init.py +52 -0
  15. starforge_cli/commands/jobs.py +223 -0
  16. starforge_cli/commands/login.py +54 -0
  17. starforge_cli/commands/plugin.py +243 -0
  18. starforge_cli/commands/recipe.py +163 -0
  19. starforge_cli/commands/serve.py +79 -0
  20. starforge_cli/commands/submit.py +467 -0
  21. starforge_cli/commands/sweep.py +154 -0
  22. starforge_cli/config_resolve.py +17 -0
  23. starforge_cli/data_prep.py +60 -0
  24. starforge_cli/new_experiment.py +195 -0
  25. starforge_cli/packing.py +179 -0
  26. starforge_cli/plugins_lock.py +73 -0
  27. starforge_cli/project.py +130 -0
  28. starforge_cli/recipe_lock.py +453 -0
  29. starforge_cli/scaffold/agent-run.py.tmpl +146 -0
  30. starforge_cli/scaffold/custom-framework/train.sh +56 -0
  31. starforge_cli/scaffold/experiment-template/.gitkeep +0 -0
  32. starforge_cli/scaffold/experiment-template/README.md +36 -0
  33. starforge_cli/scaffold/experiment-template/config.yaml +44 -0
  34. starforge_cli/scaffold/project/common/README.md +12 -0
  35. starforge_cli/scaffold/project/common/__init__.py +0 -0
  36. starforge_cli/scaffold/project/configs/README.md +103 -0
  37. starforge_cli/scaffold/project/configs/base/README.md +24 -0
  38. starforge_cli/scaffold/project/configs/base/distillation_math.yaml +284 -0
  39. starforge_cli/scaffold/project/configs/base/grpo_lora.yaml +30 -0
  40. starforge_cli/scaffold/project/configs/base/grpo_math_1B.yaml +470 -0
  41. starforge_cli/scaffold/project/configs/base/grpo_megatron.yaml +43 -0
  42. starforge_cli/scaffold/project/configs/base/grpo_noncolocated.yaml +18 -0
  43. starforge_cli/scaffold/project/configs/base/grpo_sliding_puzzle.yaml +81 -0
  44. starforge_cli/scaffold/project/configs/base/ppo_math_1B.yaml +454 -0
  45. starforge_cli/scaffold/project/configs/base/rm.yaml +224 -0
  46. starforge_cli/scaffold/project/configs/base/sft.yaml +294 -0
  47. starforge_cli/scaffold/project/configs/models/README.md +16 -0
  48. starforge_cli/scaffold/project/configs/models/qwen3.5-4b.yaml +12 -0
  49. starforge_cli/scaffold/project/configs/models/qwen3.5-9b.yaml +10 -0
  50. starforge_cli/scaffold/project/gitignore +11 -0
  51. starforge_cli/spec_builder.py +372 -0
  52. starforge_cli-0.1.6.dist-info/METADATA +40 -0
  53. starforge_cli-0.1.6.dist-info/RECORD +55 -0
  54. starforge_cli-0.1.6.dist-info/WHEEL +4 -0
  55. starforge_cli-0.1.6.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,453 @@
1
+ """实验 recipe 锁文件(recipe.lock.json)的生成、检查与显式升级。
2
+
3
+ 对外只暴露三个稳定动作:inspect / upgrade / require_current。
4
+ 锁住的是当前 catalog 的 recipe bundle 与精确 runtime,不是创建它的 SDK 版本。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Iterable
14
+
15
+ from starforge_core import __version__ as CORE_VERSION
16
+ from starforge_core.contract import SpecError
17
+ from starforge_core.recipes import Recipe, get_recipe
18
+
19
+ LOCK_FILE = "recipe.lock.json"
20
+ LOCK_VERSION = "forge/recipe-lock/v3"
21
+ _LEGACY_LOCK_VERSION = "forge/recipe-lock/v2"
22
+ _SUPPORTED_LOCK_VERSIONS = (LOCK_VERSION, _LEGACY_LOCK_VERSION)
23
+ _EXPERIMENT_KINDS = ("experiments", "projects", "smoke")
24
+
25
+
26
+ class LockState(str, Enum):
27
+ CURRENT = "current"
28
+ CORE_INCOMPATIBLE = "sdk_incompatible"
29
+ RECIPE_STALE = "recipe_stale"
30
+ RUNTIME_REMOVED = "runtime_removed"
31
+ MALFORMED = "malformed"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class LockDiff:
36
+ field: str
37
+ locked: Any
38
+ current: Any
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class LockInspection:
43
+ state: LockState
44
+ path: Path
45
+ recipe_name: str
46
+ framework_version: str
47
+ payload: dict[str, Any] | None
48
+ expected: dict[str, Any] | None
49
+ diffs: tuple[LockDiff, ...]
50
+ message: str
51
+
52
+ @property
53
+ def is_current(self) -> bool:
54
+ return self.state is LockState.CURRENT
55
+
56
+
57
+ class RecipeLockError(ValueError):
58
+ def __init__(self, inspection: LockInspection):
59
+ super().__init__(inspection.message)
60
+ self.inspection = inspection
61
+
62
+
63
+ class RecipeLockManager:
64
+ """锁文件的唯一入口:解析、分类、升级、提交前校验。"""
65
+
66
+ def inspect(self, exp_dir: Path, recipe_name: str = "") -> LockInspection:
67
+ path = Path(exp_dir) / LOCK_FILE
68
+ if not path.is_file():
69
+ return _malformed(
70
+ path,
71
+ recipe_name,
72
+ f"实验缺少 {LOCK_FILE};用 `sf new` 重建,或 `sf recipe upgrade` 生成",
73
+ )
74
+ try:
75
+ payload = json.loads(path.read_text(encoding="utf-8"))
76
+ except (OSError, json.JSONDecodeError) as exc:
77
+ return _malformed(path, recipe_name, f"{path} 非法: {exc}")
78
+ if not isinstance(payload, dict):
79
+ return _malformed(path, recipe_name, f"{path} 根节点必须是对象")
80
+
81
+ normalized = _normalize_lock(payload)
82
+ if normalized is None:
83
+ return _malformed(
84
+ path,
85
+ recipe_name,
86
+ f"{path} 不是支持的 recipe lock({_LEGACY_LOCK_VERSION} / {LOCK_VERSION})",
87
+ )
88
+ locked_name = recipe_name.strip() or normalized["recipe_name"]
89
+ if not locked_name:
90
+ return _malformed(path, "", f"{path} 缺少 recipe.name")
91
+ if recipe_name.strip() and normalized["recipe_name"] not in {"", locked_name}:
92
+ return _malformed(
93
+ path,
94
+ locked_name,
95
+ f"{path} 锁定的是 {normalized['recipe_name']!r},与请求的 {locked_name!r} 不一致",
96
+ )
97
+ try:
98
+ recipe = get_recipe(locked_name)
99
+ except SpecError as exc:
100
+ return _malformed(path, locked_name, str(exc))
101
+
102
+ sdk_reason = recipe.core_incompatibility()
103
+ if sdk_reason:
104
+ return LockInspection(
105
+ state=LockState.CORE_INCOMPATIBLE,
106
+ path=path,
107
+ recipe_name=locked_name,
108
+ framework_version=normalized["framework_version"],
109
+ payload=payload,
110
+ expected=None,
111
+ diffs=(),
112
+ message=f"{path}: {sdk_reason}",
113
+ )
114
+
115
+ locked_runtime = normalized["framework_version"]
116
+ if locked_runtime and locked_runtime not in recipe.runtime.supported_versions:
117
+ expected = recipe_lock(locked_name, recipe.runtime.default_version)
118
+ return LockInspection(
119
+ state=LockState.RUNTIME_REMOVED,
120
+ path=path,
121
+ recipe_name=locked_name,
122
+ framework_version=locked_runtime,
123
+ payload=payload,
124
+ expected=expected,
125
+ diffs=_diffs(normalized, expected),
126
+ message=(
127
+ f"{path} 锁定的 {recipe.framework}@{locked_runtime} 已从 catalog 移除;"
128
+ f"可用: {', '.join(recipe.runtime.supported_versions)}。"
129
+ f"执行 `sf recipe upgrade {exp_dir.name} --accept-runtime-change`"
130
+ " 或指定 --framework-version"
131
+ ),
132
+ )
133
+
134
+ expected = recipe_lock(locked_name, locked_runtime)
135
+ diffs = _diffs(normalized, expected)
136
+ if diffs:
137
+ return LockInspection(
138
+ state=LockState.RECIPE_STALE,
139
+ path=path,
140
+ recipe_name=locked_name,
141
+ framework_version=locked_runtime or expected["framework"]["version"],
142
+ payload=payload,
143
+ expected=expected,
144
+ diffs=diffs,
145
+ message=(
146
+ f"{path} 与当前 core recipe bundle 不一致。"
147
+ f"执行 `sf recipe upgrade {exp_dir.name}` 后重新提交"
148
+ ),
149
+ )
150
+ return LockInspection(
151
+ state=LockState.CURRENT,
152
+ path=path,
153
+ recipe_name=locked_name,
154
+ framework_version=expected["framework"]["version"],
155
+ payload=payload,
156
+ expected=expected,
157
+ diffs=(),
158
+ message=f"{path} 已锁定当前 recipe bundle",
159
+ )
160
+
161
+ def require_current(self, exp_dir: Path, recipe_name: str = "") -> str:
162
+ inspection = self.inspect(exp_dir, recipe_name)
163
+ if not inspection.is_current:
164
+ raise RecipeLockError(inspection)
165
+ return inspection.framework_version
166
+
167
+ def write_current(
168
+ self,
169
+ exp_dir: Path,
170
+ recipe_name: str,
171
+ framework_version: str = "",
172
+ ) -> dict[str, Any]:
173
+ payload = recipe_lock(recipe_name, framework_version)
174
+ _atomic_write(Path(exp_dir) / LOCK_FILE, payload)
175
+ return payload
176
+
177
+ def upgrade(
178
+ self,
179
+ exp_dir: Path,
180
+ *,
181
+ recipe_name: str = "",
182
+ framework_version: str = "",
183
+ accept_runtime_change: bool = False,
184
+ dry_run: bool = False,
185
+ validate_config: Callable[[Path, Recipe], list[str]] | None = None,
186
+ ) -> LockInspection:
187
+ inspection = self.inspect(exp_dir, recipe_name)
188
+ if inspection.state is LockState.MALFORMED:
189
+ raise RecipeLockError(inspection)
190
+ if inspection.state is LockState.CORE_INCOMPATIBLE:
191
+ raise RecipeLockError(inspection)
192
+
193
+ selected = framework_version.strip()
194
+ if inspection.state is LockState.RUNTIME_REMOVED:
195
+ if not selected and not accept_runtime_change:
196
+ raise RecipeLockError(inspection)
197
+ selected = selected or get_recipe(inspection.recipe_name).runtime.default_version
198
+ elif not selected:
199
+ selected = inspection.framework_version
200
+
201
+ recipe = get_recipe(inspection.recipe_name)
202
+ try:
203
+ recipe.runtime.resolve(selected)
204
+ except SpecError as exc:
205
+ raise RecipeLockError(
206
+ LockInspection(
207
+ state=LockState.RUNTIME_REMOVED,
208
+ path=inspection.path,
209
+ recipe_name=inspection.recipe_name,
210
+ framework_version=selected,
211
+ payload=inspection.payload,
212
+ expected=recipe_lock(inspection.recipe_name, recipe.runtime.default_version),
213
+ diffs=inspection.diffs,
214
+ message=str(exc),
215
+ )
216
+ ) from exc
217
+
218
+ if validate_config is not None:
219
+ errors = validate_config(Path(exp_dir), recipe)
220
+ if errors:
221
+ raise RecipeLockError(
222
+ LockInspection(
223
+ state=LockState.RECIPE_STALE,
224
+ path=inspection.path,
225
+ recipe_name=inspection.recipe_name,
226
+ framework_version=selected,
227
+ payload=inspection.payload,
228
+ expected=recipe_lock(inspection.recipe_name, selected),
229
+ diffs=inspection.diffs,
230
+ message=(
231
+ f"{inspection.path} 升级后 config 不兼容: "
232
+ + ";".join(errors)
233
+ ),
234
+ )
235
+ )
236
+
237
+ expected = recipe_lock(inspection.recipe_name, selected)
238
+ if not dry_run:
239
+ _atomic_write(inspection.path, expected)
240
+ return LockInspection(
241
+ state=LockState.CURRENT,
242
+ path=inspection.path,
243
+ recipe_name=inspection.recipe_name,
244
+ framework_version=selected,
245
+ payload=expected if not dry_run else inspection.payload,
246
+ expected=expected,
247
+ diffs=_diffs(_normalize_lock(inspection.payload or {}) or {}, expected),
248
+ message=(
249
+ f"{inspection.path} 将升级到当前 recipe bundle"
250
+ if dry_run
251
+ else f"{inspection.path} 已升级到当前 recipe bundle"
252
+ ),
253
+ )
254
+
255
+ def upgrade_all(
256
+ self,
257
+ repo_root: Path,
258
+ *,
259
+ framework_version: str = "",
260
+ accept_runtime_change: bool = False,
261
+ dry_run: bool = False,
262
+ validate_config: Callable[[Path, Recipe], list[str]] | None = None,
263
+ ) -> list[LockInspection]:
264
+ targets = list(iter_lock_dirs(repo_root))
265
+ planned: list[tuple[Path, LockInspection]] = []
266
+ failures: list[LockInspection] = []
267
+ for exp_dir in targets:
268
+ inspection = self.inspect(exp_dir)
269
+ if inspection.is_current:
270
+ planned.append((exp_dir, inspection))
271
+ continue
272
+ try:
273
+ planned.append((
274
+ exp_dir,
275
+ self.upgrade(
276
+ exp_dir,
277
+ framework_version=framework_version,
278
+ accept_runtime_change=accept_runtime_change,
279
+ dry_run=True,
280
+ validate_config=validate_config,
281
+ ),
282
+ ))
283
+ except RecipeLockError as exc:
284
+ failures.append(exc.inspection)
285
+ if failures:
286
+ raise RecipeLockBatchError(failures)
287
+
288
+ if dry_run:
289
+ return [item for _, item in planned]
290
+
291
+ backups: list[tuple[Path, bytes | None]] = []
292
+ written: list[LockInspection] = []
293
+ try:
294
+ for exp_dir, inspection in planned:
295
+ if inspection.expected is None or inspection.is_current and inspection.payload == inspection.expected:
296
+ written.append(inspection)
297
+ continue
298
+ path = exp_dir / LOCK_FILE
299
+ previous = path.read_bytes() if path.is_file() else None
300
+ _atomic_write(path, inspection.expected)
301
+ backups.append((path, previous))
302
+ written.append(
303
+ LockInspection(
304
+ state=LockState.CURRENT,
305
+ path=path,
306
+ recipe_name=inspection.recipe_name,
307
+ framework_version=inspection.expected["framework"]["version"],
308
+ payload=inspection.expected,
309
+ expected=inspection.expected,
310
+ diffs=inspection.diffs,
311
+ message=f"{path} 已升级到当前 recipe bundle",
312
+ )
313
+ )
314
+ except Exception:
315
+ _rollback(backups)
316
+ raise
317
+ return written
318
+
319
+
320
+ class RecipeLockBatchError(ValueError):
321
+ def __init__(self, failures: list[LockInspection]):
322
+ lines = [item.message for item in failures]
323
+ super().__init__("批量升级中止,未写入任何锁文件:\n" + "\n".join(f"- {line}" for line in lines))
324
+ self.failures = failures
325
+
326
+
327
+ def recipe_lock(recipe_name: str, framework_version: str = "") -> dict[str, Any]:
328
+ recipe = get_recipe(recipe_name)
329
+ selected = framework_version.strip() or recipe.runtime.default_version
330
+ variant = recipe.runtime.resolve(selected)
331
+ return {
332
+ "apiVersion": LOCK_VERSION,
333
+ "recipe": {
334
+ "name": recipe.id,
335
+ "version": recipe.version,
336
+ "digest": recipe.digest,
337
+ "manifest_digest": recipe.manifest_digest,
338
+ "template_digest": recipe.template_digest,
339
+ },
340
+ "framework": {
341
+ "kind": recipe.framework,
342
+ "version": selected,
343
+ "runtime_id": variant.runtime_id,
344
+ },
345
+ "created_by": {"core_version": CORE_VERSION},
346
+ "requires": {"core": recipe.core_requires},
347
+ }
348
+
349
+
350
+ def write_recipe_lock(exp_dir: Path, recipe_name: str, framework_version: str = "") -> None:
351
+ RecipeLockManager().write_current(exp_dir, recipe_name, framework_version)
352
+
353
+
354
+ def validate_recipe_lock(exp_dir: Path, recipe_name: str) -> str:
355
+ """提交/校验入口:锁必须已经是当前 bundle,绝不静默改写。"""
356
+ return RecipeLockManager().require_current(exp_dir, recipe_name)
357
+
358
+
359
+ def iter_lock_dirs(repo_root: Path) -> Iterable[Path]:
360
+ root = Path(repo_root)
361
+ for kind in _EXPERIMENT_KINDS:
362
+ base = root / kind
363
+ if not base.is_dir():
364
+ continue
365
+ for path in sorted(base.iterdir()):
366
+ if path.is_dir() and (path / LOCK_FILE).is_file():
367
+ yield path
368
+
369
+
370
+ def _normalize_lock(payload: dict[str, Any]) -> dict[str, Any] | None:
371
+ api = str(payload.get("apiVersion") or "").strip()
372
+ recipe = payload.get("recipe") if isinstance(payload.get("recipe"), dict) else {}
373
+ if api == LOCK_VERSION:
374
+ framework = payload.get("framework") if isinstance(payload.get("framework"), dict) else {}
375
+ requires = payload.get("requires") if isinstance(payload.get("requires"), dict) else {}
376
+ return {
377
+ "apiVersion": api,
378
+ "recipe_name": str(recipe.get("name") or "").strip(),
379
+ "recipe_version": str(recipe.get("version") or "").strip(),
380
+ "digest": str(recipe.get("digest") or "").strip(),
381
+ "manifest_digest": str(recipe.get("manifest_digest") or "").strip(),
382
+ "template_digest": str(recipe.get("template_digest") or "").strip(),
383
+ "framework_kind": str(framework.get("kind") or "").strip(),
384
+ "framework_version": str(framework.get("version") or "").strip(),
385
+ "runtime_id": str(framework.get("runtime_id") or "").strip(),
386
+ "core_requires": str(requires.get("core") or "").strip(),
387
+ }
388
+ if api == _LEGACY_LOCK_VERSION:
389
+ return {
390
+ "apiVersion": api,
391
+ "recipe_name": str(recipe.get("name") or "").strip(),
392
+ "recipe_version": str(recipe.get("version") or "").strip(),
393
+ "digest": str(recipe.get("digest") or "").strip(),
394
+ "manifest_digest": "",
395
+ "template_digest": "",
396
+ "framework_kind": str(recipe.get("framework") or "").strip(),
397
+ "framework_version": str(recipe.get("framework_version") or "").strip(),
398
+ "runtime_id": "",
399
+ "core_requires": "",
400
+ }
401
+ return None
402
+
403
+
404
+ def _diffs(normalized: dict[str, Any], expected: dict[str, Any]) -> tuple[LockDiff, ...]:
405
+ current = _normalize_lock(expected) or {}
406
+ fields = (
407
+ ("apiVersion", "apiVersion"),
408
+ ("recipe.name", "recipe_name"),
409
+ ("recipe.version", "recipe_version"),
410
+ ("recipe.digest", "digest"),
411
+ ("recipe.manifest_digest", "manifest_digest"),
412
+ ("recipe.template_digest", "template_digest"),
413
+ ("framework.kind", "framework_kind"),
414
+ ("framework.version", "framework_version"),
415
+ ("framework.runtime_id", "runtime_id"),
416
+ ("requires.core", "core_requires"),
417
+ )
418
+ out: list[LockDiff] = []
419
+ for label, key in fields:
420
+ locked = normalized.get(key, "")
421
+ wanted = current.get(key, "")
422
+ if locked != wanted:
423
+ out.append(LockDiff(field=label, locked=locked, current=wanted))
424
+ return tuple(out)
425
+
426
+
427
+ def _malformed(path: Path, recipe_name: str, message: str) -> LockInspection:
428
+ return LockInspection(
429
+ state=LockState.MALFORMED,
430
+ path=path,
431
+ recipe_name=recipe_name,
432
+ framework_version="",
433
+ payload=None,
434
+ expected=None,
435
+ diffs=(),
436
+ message=message,
437
+ )
438
+
439
+
440
+ def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
441
+ path.parent.mkdir(parents=True, exist_ok=True)
442
+ data = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
443
+ tmp = path.with_name(path.name + ".tmp")
444
+ tmp.write_text(data, encoding="utf-8")
445
+ os.replace(tmp, path)
446
+
447
+
448
+ def _rollback(backups: list[tuple[Path, bytes | None]]) -> None:
449
+ for path, previous in reversed(backups):
450
+ if previous is None:
451
+ path.unlink(missing_ok=True)
452
+ else:
453
+ path.write_bytes(previous)
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python
2
+ # 多轮 Agent(GRPO)训练骨架(NeMo-RL 0.6.0)。
3
+ # agent 本质就是 GRPO + max_rollout_turns>1 + 一个自定义环境;入口由所选 recipe 明确声明。
4
+ #
5
+ # 这是一个【可运行的最小骨架】:默认指向 NeMo-RL 官方 examples/run_grpo_sliding_puzzle.py 的范式,
6
+ # 你要做的是把下面两个 TODO 换成自己的「环境 + 数据集」:
7
+ # · 完整实战参考(自定义多工具环境 + 随机出题):experiments/agent-grpo_qwen3.5-9b_multitool_v1/run.py
8
+ # · 官方范式(滑块拼图):<NEMO_RL_DIR>/examples/run_grpo_sliding_puzzle.py
9
+ # 环境写法见 common/environments/(实现 EnvironmentInterface),config 的 env.<task>.cfg 由本文件读取。
10
+ import argparse
11
+ import itertools
12
+ import os
13
+ import pprint
14
+ import sys
15
+ from typing import Any, Iterator
16
+
17
+ from omegaconf import OmegaConf
18
+ from torch.utils.data import IterableDataset
19
+
20
+ THIS_DIR = os.path.dirname(os.path.abspath(__file__))
21
+ REPO_ROOT = os.path.abspath(os.path.join(THIS_DIR, "..", ".."))
22
+ if REPO_ROOT not in sys.path:
23
+ sys.path.insert(0, REPO_ROOT)
24
+
25
+ from nemo_rl.algorithms.grpo import MasterConfig, grpo_train, setup
26
+ from nemo_rl.algorithms.utils import get_tokenizer, set_seed
27
+ from nemo_rl.data.interfaces import DatumSpec, LLMMessageLogType
28
+ from nemo_rl.distributed.virtual_cluster import init_ray
29
+ from nemo_rl.models.generation import configure_generation_config
30
+ from nemo_rl.utils.config import (
31
+ load_config,
32
+ parse_hydra_overrides,
33
+ register_omegaconf_resolvers,
34
+ )
35
+ from nemo_rl.utils.logger import get_next_experiment_dir
36
+
37
+ # TODO(1/2):换成你的环境(实现 nemo_rl 的 EnvironmentInterface,放在 common/environments/)。
38
+ # from common.environments.your_env import YourAgentEnv
39
+
40
+ TASK_NAME = "your_task" # 须与 config.yaml 的 env.<TASK_NAME>.cfg 对齐
41
+ STOP_STRINGS = ["</tool>", "</answer>"] # 按你的多轮协议设停止符
42
+
43
+
44
+ def parse_args():
45
+ parser = argparse.ArgumentParser(description="多轮 Agent GRPO 训练")
46
+ parser.add_argument("--config", type=str, default=None, help="YAML 配置路径")
47
+ args, overrides = parser.parse_known_args()
48
+ return args, overrides
49
+
50
+
51
+ def generate_datum(tokenizer, env_cfg: dict[str, Any], idx: int) -> DatumSpec:
52
+ # TODO(2/2):构造一条训练样本(题面 + 目标),metadata 会被你的环境读取用于判分/多轮控制。
53
+ question = "TODO: 在这里生成/取一条需要多轮工具调用的题目"
54
+ prompt_text = tokenizer.apply_chat_template(
55
+ [{"role": "user", "content": question}],
56
+ tokenize=False, add_generation_prompt=True, add_special_tokens=False,
57
+ ).strip()
58
+ token_ids = tokenizer(prompt_text, return_tensors="pt", add_special_tokens=False)["input_ids"][0]
59
+ message_log: LLMMessageLogType = [
60
+ {"role": "user", "content": prompt_text, "token_ids": token_ids}
61
+ ]
62
+ metadata = {
63
+ "num_turns": 0,
64
+ "max_turns": int(env_cfg.get("max_turns", 6)),
65
+ # ... 你的环境判分所需字段(如 target / kb / tolerance)放这里
66
+ }
67
+ return {
68
+ "message_log": message_log,
69
+ "length": len(token_ids),
70
+ "extra_env_info": metadata,
71
+ "loss_multiplier": 1.0,
72
+ "idx": idx,
73
+ "task_name": TASK_NAME,
74
+ "stop_strings": STOP_STRINGS,
75
+ }
76
+
77
+
78
+ class IterableAgentDataset(IterableDataset):
79
+ def __init__(self, tokenizer, env_cfg, length):
80
+ super().__init__()
81
+ self.tokenizer, self.env_cfg, self.length = tokenizer, env_cfg, length
82
+
83
+ def __iter__(self) -> Iterator[DatumSpec]:
84
+ for i in itertools.count():
85
+ yield generate_datum(self.tokenizer, self.env_cfg, i)
86
+
87
+ def __len__(self):
88
+ return self.length
89
+
90
+
91
+ def main():
92
+ register_omegaconf_resolvers()
93
+ args, overrides = parse_args()
94
+ if not args.config:
95
+ args.config = os.path.join(THIS_DIR, "config.yaml")
96
+
97
+ config = load_config(args.config)
98
+ print(f"已加载配置: {args.config}")
99
+ if overrides:
100
+ print(f"CLI overrides: {overrides}")
101
+ config = parse_hydra_overrides(config, overrides)
102
+ config = OmegaConf.to_container(config, resolve=True)
103
+ config: MasterConfig = MasterConfig(**config)
104
+ print("最终配置:")
105
+ pprint.pprint(config)
106
+
107
+ config.logger["log_dir"] = get_next_experiment_dir(config.logger["log_dir"])
108
+ print(f"日志目录: {config.logger['log_dir']}")
109
+
110
+ init_ray()
111
+ set_seed(config.grpo["seed"])
112
+
113
+ tokenizer = get_tokenizer(config.policy["tokenizer"])
114
+ config.policy["generation"] = configure_generation_config(
115
+ config.policy["generation"], tokenizer
116
+ )
117
+
118
+ env_cfg = config.env[TASK_NAME]["cfg"]
119
+ # TODO:实例化你的环境(CPU actor:num_gpus=0)。
120
+ # env = YourAgentEnv.options(num_gpus=0).remote(cfg=dict(env_cfg))
121
+ raise SystemExit(
122
+ "agent 骨架未完成:请实现 TASK_NAME 对应的环境与 generate_datum,再取消下方训练调用的注释。\n"
123
+ "参考 experiments/agent-grpo_qwen3.5-9b_multitool_v1/run.py。"
124
+ )
125
+ task_to_env = {TASK_NAME: env} # noqa: F821 (实现 env 后取消上方 raise)
126
+
127
+ ds_length = (
128
+ config.grpo["num_prompts_per_step"]
129
+ * config.grpo["num_generations_per_prompt"]
130
+ * config.grpo["max_num_steps"]
131
+ )
132
+ dataset = IterableAgentDataset(tokenizer, env_cfg, ds_length)
133
+ val_dataset = IterableAgentDataset(tokenizer, env_cfg, config.grpo["max_val_samples"])
134
+
135
+ (policy, policy_generation, cluster, dataloader, val_dataloader,
136
+ loss_fn, logger, checkpointer, grpo_state, master_config) = setup(
137
+ config, tokenizer, dataset, val_dataset
138
+ )
139
+ grpo_train(
140
+ policy, policy_generation, dataloader, val_dataloader, tokenizer, loss_fn,
141
+ task_to_env, task_to_env, logger, checkpointer, grpo_state, master_config,
142
+ )
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # 显式 custom recipe 的训练脚本。
3
+ #
4
+ # 由 Job Capsule runner 内的 CustomAdapter 直接执行。框架来自版本化 recipe,不读取
5
+ # FRAMEWORK 环境变量或 framework 文件,也不会由其他 adapter 失败后回退至此。
6
+ #
7
+ # ── 已经给你准备好的环境变量(契约,别改名)─────────────────────────────────────
8
+ # FORGE_OUT_DIR 产物目录(已 mkdir)。checkpoint / 日志请写这里,
9
+ # 它已按 <用户>/<实验>/<run_id> 隔离好,不会和别人互相覆盖
10
+ # FORGE_EXP_DIR 本实验目录(就是这个文件所在目录)
11
+ # FORGE_WORK_DIR 上传包根目录
12
+ # FORGE_FRAMEWORK / FORGE_RECIPE 固定为 custom / custom
13
+ # FORGE_CLUSTER_NUM_NODES ★服务端权威拓扑:你实际能用几个节点
14
+ # FORGE_CLUSTER_GPUS_PER_NODE ★服务端权威拓扑:每节点几张卡
15
+ # STARFORGE_ENDPOINT/RUN_ID/TOKEN 指标上报凭据(无则为本地直跑,上报自动 no-op)
16
+ # HF_HOME / HF_TOKEN / 各 *_DATA_DIR 由服务端按需注入
17
+ #
18
+ # ── 三件必须做的事 ─────────────────────────────────────────────────────────────
19
+ # ① checkpoint 写进 $FORGE_OUT_DIR —— 写别处的话作业结束就随临时目录一起没了
20
+ # ② 用 common/observability/report.py 上报指标 —— 否则 console 上只有日志、没有曲线:
21
+ # from common.observability.report import StarForgeCallback
22
+ # trainer = SFTTrainer(..., callbacks=[StarForgeCallback()])
23
+ # 手写循环则用 report.init() / report.log({...}, step=i) / report.finish()
24
+ # ③ 遵守 FORGE_CLUSTER_* 拓扑 —— 配额按它记账,watchdog 会做集群级 Ray 用卡对账,
25
+ # 实际占卡超出记账会被告警甚至停止作业
26
+ #
27
+ # ⚠️ 训练依赖必须烘焙进本作业引用的 digest runtime image。需要 TRL / flash-attn
28
+ # 等依赖时发布新的 runtime artifact;新作业自动采用,无需重启长驻集群。
29
+ set -euo pipefail
30
+
31
+ : "${FORGE_WORK_DIR:?FORGE_WORK_DIR is required}"
32
+ : "${FORGE_EXP_DIR:?FORGE_EXP_DIR is required}"
33
+ : "${FORGE_OUT_DIR:?FORGE_OUT_DIR is required}"
34
+ : "${FORGE_FRAMEWORK:?FORGE_FRAMEWORK is required}"
35
+ : "${FORGE_RECIPE:?FORGE_RECIPE is required}"
36
+ : "${FORGE_CLUSTER_NUM_NODES:?FORGE_CLUSTER_NUM_NODES is required}"
37
+ : "${FORGE_CLUSTER_GPUS_PER_NODE:?FORGE_CLUSTER_GPUS_PER_NODE is required}"
38
+
39
+ echo "[train] out_dir : ${FORGE_OUT_DIR}"
40
+ echo "[train] topology : ${FORGE_CLUSTER_NUM_NODES:-?} 节点 × ${FORGE_CLUSTER_GPUS_PER_NODE:-?} 卡"
41
+
42
+ # ── TODO: 换成你自己的启动命令 ────────────────────────────────────────────────
43
+ # 示例(HF accelerate,单节点多卡):
44
+ #
45
+ # exec accelerate launch \
46
+ # --num_processes "${FORGE_CLUSTER_GPUS_PER_NODE:-1}" \
47
+ # "${FORGE_EXP_DIR}/train.py" \
48
+ # --output_dir "${FORGE_OUT_DIR}" \
49
+ # --config "${FORGE_EXP_DIR}/config.yaml"
50
+ #
51
+ # 示例(直接跑一个 python 脚本):
52
+ #
53
+ # exec python "${FORGE_EXP_DIR}/train.py" --output-dir "${FORGE_OUT_DIR}"
54
+
55
+ echo "[train] 还没填训练命令:请编辑 ${FORGE_EXP_DIR}/train.sh" >&2
56
+ exit 1
File without changes