diff --git a/docs/source-en/rst_source/development/memory.rst b/docs/source-en/rst_source/development/memory.rst index 2edfdb0b2..ae61ae4ac 100644 --- a/docs/source-en/rst_source/development/memory.rst +++ b/docs/source-en/rst_source/development/memory.rst @@ -1,6 +1,8 @@ Memory Management ================= +.. _memory-management: + RPent memory is maintained per robot and lets runs reuse already-validated task experience and operating strategy instead of rediscovering it from scratch each time. @@ -34,8 +36,8 @@ use the same directory structure: |-- _recipe.jsonl `-- .md -The default local root is ``memory//``; on the Hugging Face dataset -the same content lives under the ``/`` subdirectory. A custom +The default local root is ``memory//``. On Hugging Face, LIBERO has +model-specific roots, described below; other robots use ``/``. A custom ``--memory-dir`` may point at any directory laid out like the tree above. Every subtree is optional; a robot ships only the directories it uses: @@ -53,12 +55,114 @@ Missing a layer does not stop a task from running. Using memory ------------ -By default RPent syncs the current robot's memory from the Hugging Face -dataset ``RLinf/RPent-memory`` into ``memory//``. The dataset is -public, so a fresh clone downloads it without a token. Set -``HF_HUB_OFFLINE=1`` to skip the sync and use the local copy only. Memory is -optional: if a robot has none on the dataset, or the sync fails, the run -continues with whatever is on disk. +RPent downloads memory from the public ``RLinf/RPent-memory`` dataset. +LIBERO selects one version with ``--memory-version auto`` (the default): + +.. list-table:: LIBERO memory versions + :header-rows: 1 + :widths: 25 35 40 + + * - Running model + - Memory directory under ``libero/`` + - Exploration configuration + * - ``gpt-5.5`` + - ``GPT_5.5_xhigh`` + - Codex, reasoning on, xhigh + * - ``gpt-6-astra`` + - ``GPT_6_astra_low`` + - Codex, reasoning on, low + +Provider prefixes such as ``openai:`` are recognized. Codex uses ``--model`` +first, then ``CODEX_MODEL``. Unknown models, Claude, or an unknown backend +default fall back to ``GPT_5.5_xhigh`` with a warning. Flash replay defaults +to GPT-5.5. An explicit version overrides model selection; it does not change +the running model or reasoning effort. The effort in a directory name records +how that memory was generated. + +.. code-block:: bash + + # Choose Astra memory automatically. + rpent --robot libero --suite libero_goal_swap --task 1 --seed 1 \ + --planner codex --model gpt-6-astra --reasoning-effort low + + # Use the same model with the GPT-5.5 corpus. + rpent --robot libero --suite libero_goal_swap --task 1 --seed 1 \ + --planner codex --model gpt-6-astra --reasoning-effort low \ + --memory-version GPT_5.5_xhigh + +CLI and Dashboard resolve the root before each task. In Dashboard, **Next task +model** changes the model for the next task and reselects auto memory then; +the active task keeps its existing model and corpus. A manually selected +memory version remains selected across model changes. + +Only the chosen version is downloaded. LIBERO caches are isolated by repository, +commit and version under ``memory/libero/.versions/``. Every file is verified +before cache reuse. ``HF_HUB_OFFLINE=1`` requires a complete, unchanged cache +for the selected version and revision; failed downloads never substitute +another model's corpus. Missing or incomplete caches fail explicitly. +Caches created before versioned-source receipts require one successful online +refresh; old unversioned caches are not reused. +Other robots retain their existing optional-memory sync behavior. + +Standalone download and local evaluation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + rpent-memory sync --robot libero --memory-version GPT_6_astra_low + rpent-memory sync --robot libero --model gpt-6-astra \ + --revision --output-dir /path/to/new-astra-memory + rpent --robot libero --suite libero_goal_swap --task 1 --seed 1 \ + --planner codex --model gpt-6-astra --reasoning-effort low \ + --memory-profile local --memory-dir /path/to/new-astra-memory + +``sync`` prints the actual corpus root. ``--output-dir`` must not already +exist. ``--planner`` defaults to ``api``, matching ``rpent``; pass +``--planner codex`` to use ``CODEX_MODEL`` when ``--model`` is omitted. ``--memory-profile local`` never downloads memory; combining it or +``--explore`` with an explicit remote ``--memory-version`` is an error. +Exploration uses a local corpus; use a separate empty ``--memory-dir`` for +each independent exploration. + +Release provenance and compatibility +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The dataset's ``libero/README.md`` and ``libero/manifest.json`` document the +versions, original source snapshots and published files. Each version's +``files`` mapping contains paths relative to that version's root and SHA-256 +hashes of the published bytes; the loader verifies this mapping. Source +revisions and source hashes describe the original snapshots and remain separate +from published hashes after directory, index and reference changes. + +Both version roots use ``MEMORY.md``, ``global/``, ``task-family/`` and +``task-specific/``. GPT-5.5 additionally includes its ``task_card/`` replay +assets. Flash reads generated ``flash/`` plans or published ``task_card/`` +assets within the selected corpus. Astra has no replay assets; selecting it +for Flash reports an error. + +The Astra release merges Long and Spatial/Object/Goal exploration memory, +preserving both versions of three conflicting global notes with source +suffixes. Its 79 task-specific audit/recipe pairs retain their original +content; Long Swap task 6 has no task-specific pair. The historical **741/800** +result used the two original frozen snapshots separately by suite. **The merged +release has not been reevaluated.** Memory was generated at runtime commit +``014a0fa``, before the scene-seed fix. Original snapshots are retained as the +Hub tags ``libero-astra-long-frozen-20260917`` and +``libero-astra-spatial-object-goal-frozen-20260917``. + +The current loader requires a versioned Hub layout and does not convert or +fall back to the historical unversioned corpus. Update code and data together. +Historical reproduction uses the matching historical client and dataset +revision, archived at ``libero-gpt5.5-xhigh-before-versions-20260917``: + +.. code-block:: bash + + hf download RLinf/RPent-memory --repo-type dataset \ + --revision libero-gpt5.5-xhigh-before-versions-20260917 \ + --include 'libero/*' --local-dir /path/to/legacy-download + # With an older RPent client: + rpent --robot libero --suite libero_goal_swap --task 1 --seed 1 \ + --planner codex --model gpt-5.5 --memory-profile local \ + --memory-dir /path/to/legacy-download/libero You can also prepare local memory yourself with the same directory structure and point the run at it through the environment's ``--memory-dir`` option or diff --git a/docs/source-en/rst_source/usage/flash.rst b/docs/source-en/rst_source/usage/flash.rst index 9ed2d1150..15c06093f 100644 --- a/docs/source-en/rst_source/usage/flash.rst +++ b/docs/source-en/rst_source/usage/flash.rst @@ -61,14 +61,18 @@ objects appear at different positions. Flash plan files ---------------- -Flash plans are distributed through the `RLinf/RPent-memory Flash directory -`_ -on Hugging Face rather than tracked in Git. RPent downloads them in HF memory -mode and stores them locally under ``memory/libero/flash``. With -``--memory-profile local --memory-dir /path/to/memory/libero``, it reads plans from -``/path/to/memory/libero/flash`` without downloading data. -There are 78 plans for 80 task identities; ``goal_swap_t0`` and ``10_swap_t9`` -have no plan. Missing plan or anchor files cause an error. +Plans are distributed through the `GPT-5.5 memory directory +`_ +on Hugging Face rather than tracked in Git. Flash defaults to +``--memory-version GPT_5.5_xhigh`` and uses that version's isolated cache. +The published corpus includes 20 Object Task/Swap plans under ``task_card/``. This published coverage differs from the 78 plans used +in the historical full-matrix evaluation above. + +With ``--memory-profile local --memory-dir /path/to/memory/libero``, replay +reads generated ``flash/`` plans or published ``task_card/`` assets under that root +without downloading. +Missing plan or anchor files cause an error. Astra memory has no replay assets; +it cannot be used for Flash. See :ref:`Memory Management `. .. code-block:: text @@ -114,7 +118,10 @@ To download only the Flash plans manually, run: .. code-block:: bash hf download RLinf/RPent-memory --repo-type dataset \ - --include "libero/flash/**" --local-dir memory + --include "libero/GPT_5.5_xhigh/task_card/**" --local-dir /path/to/download + +Use ``--memory-profile local --memory-dir /path/to/download/libero/GPT_5.5_xhigh`` +with the downloaded plans. Run Flash Mode -------------- diff --git a/docs/source-en/rst_source/usage/libero.rst b/docs/source-en/rst_source/usage/libero.rst index da3b5079c..a2a707c1b 100644 --- a/docs/source-en/rst_source/usage/libero.rst +++ b/docs/source-en/rst_source/usage/libero.rst @@ -116,8 +116,10 @@ RPent supports two LIBERO run modes: audit, recipe, and lessons produced by exploration. The HarnessVLA success rate is reproduced in evaluation mode. -Evaluation remains the default mode. Omitting ``--memory-profile`` preserves -the original Hugging Face resource sync and prompt: +Evaluation remains the default mode. Omitting ``--memory-profile`` selects +Hugging Face memory; ``--memory-version auto`` chooses its model-specific +version. See :ref:`Memory Management ` for overrides, +offline downloads and release provenance. Both profiles run the same single-attempt evaluation workflow; they differ only in where the evaluation memory comes from and which memory prompt is used. diff --git a/docs/source-zh/rst_source/development/memory.rst b/docs/source-zh/rst_source/development/memory.rst index 6e5235217..220d68489 100644 --- a/docs/source-zh/rst_source/development/memory.rst +++ b/docs/source-zh/rst_source/development/memory.rst @@ -1,6 +1,8 @@ Memory 管理 =========== +.. _memory-management: + RPent 的 memory 按机器人维护,用于复用已验证的任务经验和操作策略,避免每次运行 都从头试错。 @@ -31,8 +33,8 @@ Exploration 和本地 memory Evaluation 的详细流程见 |-- _recipe.jsonl `-- .md -默认本地目录为 ``memory//``;Hugging Face 数据集中相同内容位于 -``/`` 子目录下。自定义 ``--memory-dir`` 可指向任意采用上述结构的目录。 +默认本地目录为 ``memory//``。Hugging Face 中 LIBERO 按模型版本分目录, +详见下文;其他机器人仍使用 ``/``。自定义 ``--memory-dir`` 可指向任意采用上述结构的目录。 各目录均按需存在,机器人只需提供实际使用的目录: @@ -47,10 +49,99 @@ Exploration 和本地 memory Evaluation 的详细流程见 使用 memory ----------- -默认情况下,RPent 从 Hugging Face 数据集 ``RLinf/RPent-memory`` 把当前机器人的 -memory 同步到 ``memory//``。数据集是公开的,无需 token 即可下载。设 -``HF_HUB_OFFLINE=1`` 可跳过同步,只用本地副本。memory 是可选的:如果某机器人在 -数据集上没有 memory,或同步失败,运行也会用本地已有的内容继续。 +RPent 从公开数据集 ``RLinf/RPent-memory`` 下载 memory。 +LIBERO 默认使用 ``--memory-version auto`` 按模型选择: + +.. list-table:: LIBERO memory 版本 + :header-rows: 1 + :widths: 25 35 40 + + * - 当前运行模型 + - ``libero/`` 下的 memory 目录 + - 探索生成配置 + * - ``gpt-5.5`` + - ``GPT_5.5_xhigh`` + - Codex,Reasoning 开启,xhigh + * - ``gpt-6-astra`` + - ``GPT_6_astra_low`` + - Codex,Reasoning 开启,low + +支持 ``openai:`` 等提供方前缀。Codex 优先使用 ``--model``,其次使用 ``CODEX_MODEL``。 +其他模型、Claude 或无法确定的默认模型会回退到 ``GPT_5.5_xhigh``,并输出提示。 +Flash 重放默认选择 GPT-5.5。显式指定版本优先于自动选择,不改变当前模型或 reasoning +effort;目录名中的 effort 只说明该 memory 的探索生成配置。 + +.. code-block:: bash + + # 自动选择 Astra memory。 + rpent --robot libero --suite libero_goal_swap --task 1 --seed 1 \ + --planner codex --model gpt-6-astra --reasoning-effort low + + # 同一模型使用 GPT-5.5 memory。 + rpent --robot libero --suite libero_goal_swap --task 1 --seed 1 \ + --planner codex --model gpt-6-astra --reasoning-effort low \ + --memory-version GPT_5.5_xhigh + +CLI 和 Dashboard 都在每个任务开始前解析 memory 根目录。在 Dashboard 的 +**下一任务的模型** 中修改模型后,下一任务会重新进行自动选择;正在运行的任务保留原模型 +和 memory。显式指定的 memory 版本不会随模型切换而改变。 + +仅下载所选版本。LIBERO 缓存位于 ``memory/libero/.versions/``,按仓库、提交和版本隔离, +每次复用前校验所有文件。``HF_HUB_OFFLINE=1`` 要求所选版本及 revision 已有完整、未改动的缓存。 +下载失败不会改用另一模型的 memory;缓存缺失或不完整会明确报错。 +旧缓存记录未标明版本来源时,需要联网成功刷新一次;不复用旧的无版本缓存。 +其他机器人保持原有的可选 memory 同步行为。 + +独立下载与本地评测 +~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + rpent-memory sync --robot libero --memory-version GPT_6_astra_low + rpent-memory sync --robot libero --model gpt-6-astra \ + --revision --output-dir /path/to/new-astra-memory + rpent --robot libero --suite libero_goal_swap --task 1 --seed 1 \ + --planner codex --model gpt-6-astra --reasoning-effort low \ + --memory-profile local --memory-dir /path/to/new-astra-memory + +``sync`` 输出实际 memory 根目录,``--output-dir`` 必须是尚不存在的目录。 +``--planner`` 默认是 ``api``,与 ``rpent`` 一致;希望在省略 ``--model`` 时读取 +``CODEX_MODEL``,需指定 ``--planner codex``。 +``--memory-profile local`` 不下载 memory;本地模式或 ``--explore`` 与显式远程 +``--memory-version`` 同时使用会报参数冲突。探索使用本地 memory,每次独立探索应指定单独的空目录。 + +发布来源与兼容性 +~~~~~~~~~~~~~~~~ + +数据集中的 ``libero/README.md`` 和 ``libero/manifest.json`` 记录版本、原始来源快照和 +发布文件。各版本的 ``files`` 保存相对于该版本根目录的路径与发布文件的 SHA-256, +加载器据此校验实际内容。来源 revision 和来源哈希描述原始快照;目录、索引及正文引用 +调整后,发布哈希另行更新,不改写原始来源哈希。 + +两个版本根目录都使用 ``MEMORY.md``、``global/``、``task-family/`` 和 ``task-specific/``。 +GPT-5.5 还包含 ``task_card/`` 重放资产。Flash 在所选版本内读取生成的 ``flash/`` 计划或 +发布的 ``task_card/`` 资产。Astra 没有重放资产,显式选用它执行 Flash 时会报错。 + +Astra 发布版合并了 Long 与 Spatial/Object/Goal 两批探索 memory;三个重名但内容不同的 +global 文件分别加来源后缀并保留两份。79 对任务 audit/recipe 保持原始内容,Long Swap task 6 +没有专属经验,不补造。历史 **741/800** 成绩使用原先两份冻结快照按套件分别评测, +**合并发布版尚未重新评测**。生成环境为运行提交 ``014a0fa``,属于场景 seed 修复前版本。 +原始快照保留在 Hub tag ``libero-astra-long-frozen-20260917`` 和 +``libero-astra-spatial-object-goal-frozen-20260917``。 + +当前加载器要求 Hub 数据按模型分版本存放,不转换旧布局,也不回退到旧的无版本语料。 +代码与数据需要配套更新。历史复现使用匹配的历史客户端与数据 revision;迁移前数据 +归档为 ``libero-gpt5.5-xhigh-before-versions-20260917``: + +.. code-block:: bash + + hf download RLinf/RPent-memory --repo-type dataset \ + --revision libero-gpt5.5-xhigh-before-versions-20260917 \ + --include 'libero/*' --local-dir /path/to/legacy-download + # 旧 RPent 客户端使用: + rpent --robot libero --suite libero_goal_swap --task 1 --seed 1 \ + --planner codex --model gpt-5.5 --memory-profile local \ + --memory-dir /path/to/legacy-download/libero 也可以按相同的目录结构自行准备本地 memory,通过对应环境的 ``--memory-dir`` 选项或 本地 memory 配置使用。Hugging Face memory 和本地 memory 使用相同的目录规范,区别只 diff --git a/docs/source-zh/rst_source/usage/flash.rst b/docs/source-zh/rst_source/usage/flash.rst index 1316f20de..33ab026ef 100644 --- a/docs/source-zh/rst_source/usage/flash.rst +++ b/docs/source-zh/rst_source/usage/flash.rst @@ -51,13 +51,16 @@ RPent 将实时锚点位置与计划保存的偏移组合成新的路点,再 计划文件 -------- -计划不随 Git 仓库提交,而是通过 Hugging Face 上的 `RLinf/RPent-memory 计划目录 -`_ -分发。RPent 在 HF memory 模式下自动下载计划,默认保存到 -``memory/libero/flash``。使用 ``--memory-profile local --memory-dir /path/to/memory/libero`` -时,从 ``/path/to/memory/libero/flash`` 读取,不下载数据。 -80 个任务中有 78 份计划;``goal_swap_t0`` 和 ``10_swap_t9`` 暂无计划。 +计划不随 Git 仓库提交,而是通过 Hugging Face 上的 `GPT-5.5 memory 目录 +`_ +分发。Flash 默认选择 ``--memory-version GPT_5.5_xhigh``,使用该版本的独立缓存。 +发布语料在 ``task_card/`` 中包含 20 份 Object Task/Swap 计划;发布覆盖范围 +与上文历史完整矩阵评测使用的 78 份计划不同。 + +使用 ``--memory-profile local --memory-dir /path/to/memory/libero`` 时,从该根目录下的 +``flash/`` 读取生成的计划,或从 ``task_card/`` 读取发布资产,不下载数据。 缺少计划或锚点文件时会报错。 +Astra memory 没有重放资产,不能用于 Flash。详见 :ref:`Memory 管理 `。 .. code-block:: text @@ -98,7 +101,9 @@ suite/task/seed 字段,必须指向同一个 episode。 .. code-block:: bash hf download RLinf/RPent-memory --repo-type dataset \ - --include "libero/flash/**" --local-dir memory + --include "libero/GPT_5.5_xhigh/task_card/**" --local-dir /path/to/download + +下载后指定 ``--memory-profile local --memory-dir /path/to/download/libero/GPT_5.5_xhigh``。 运行计划 -------- diff --git a/docs/source-zh/rst_source/usage/libero.rst b/docs/source-zh/rst_source/usage/libero.rst index 7f81a82f0..f32295387 100644 --- a/docs/source-zh/rst_source/usage/libero.rst +++ b/docs/source-zh/rst_source/usage/libero.rst @@ -111,8 +111,9 @@ RPent 支持两种 LIBERO 运行模式: memory。使用本地 memory 的 evaluation 会读取 exploration 生成并通过校验的 audit、recipe 和经验。HarnessVLA 的 success rate 在 evaluation mode 下复现。 -默认仍为原有单次评测模式。省略 ``--memory-profile`` 时,会继续同步并使用 -Hugging Face memory 和原有 prompt。两种 profile 都执行相同的单次评测流程; +默认仍为单次评测模式。省略 ``--memory-profile`` 时使用 Hugging Face memory, +``--memory-version auto`` 按模型选择版本。手动覆盖、离线下载和发布来源详见 +:ref:`Memory 管理 `。两种 profile 都执行相同的单次评测流程; 区别仅在于评测 memory 的来源及所使用的 memory prompt。本地 memory 已准备好后 (例如先执行下文的 exploration 流程),即可使用 ``local``。该选项不会开启 exploration,也不会从 Hugging Face 下载 memory;它只会针对 ``--memory-dir`` 执行普通的单次评测,并避免同步覆盖本地 diff --git a/robots/libero/flash/replay.py b/robots/libero/flash/replay.py index 4efe1d99a..e008dade9 100644 --- a/robots/libero/flash/replay.py +++ b/robots/libero/flash/replay.py @@ -38,6 +38,7 @@ from robots.libero import tools as libero_tools from robots.libero.flash.prompts import build as prompt_for +from rpent.memory.versions import replay_directory from rpent.robots.components.molmo_client import MolmoClient from rpent.session import EnvState @@ -212,9 +213,9 @@ def plans(root: Path) -> Path: """Require Flash plans in the selected memory; synchronization belongs to the CLI.""" if not any(root.glob("*_plan.json")): raise FileNotFoundError( - f"no Flash plans found under {root}; download " - "'libero/flash/**' from the RLinf/RPent-memory " - "Hugging Face dataset into memory/" + f"no Flash plans found under {root}; use " + "rpent-memory sync --robot libero --memory-version GPT_5.5_xhigh " + "and pass its output as --memory-profile local --memory-dir " ) return root @@ -466,7 +467,7 @@ def run_flash( # The seed selects the layout to solve, not the plan used to solve it. family, suite, task, _ = match.groups() key = f"{suite}_t{task}" - root = plans(toolkit.memory.root / "flash") + root = plans(replay_directory(toolkit.memory.root)) plan_name = f"{family}_{key}" if not all( (root / f"{plan_name}_{suffix}.json").is_file() diff --git a/robots/libero/robot_spec.py b/robots/libero/robot_spec.py index 81c706336..9845019d3 100644 --- a/robots/libero/robot_spec.py +++ b/robots/libero/robot_spec.py @@ -299,14 +299,17 @@ def _parse_config(args: argparse.Namespace) -> RunConfig: local_eval = not explore and memory_profile == "local" if local_eval: if planner == "flash": + from rpent.memory.versions import replay_directory + + replay_root = replay_directory(memory_dir) plan_name = recipe_tag.rsplit("_s", 1)[0] has_local_memory = all( - (memory_dir / "flash" / f"{plan_name}_{suffix}.json").is_file() + (replay_root / f"{plan_name}_{suffix}.json").is_file() for suffix in ("plan", "anchors") ) if not has_local_memory: raise ValueError( - f"no complete Flash plan for {plan_name} under {memory_dir / 'flash'}; " + f"no complete Flash plan for {plan_name} under {replay_root}; " "both plan and anchors files are required" ) else: diff --git a/rpent/cli/dashboard.py b/rpent/cli/dashboard.py index aefcc29ec..809f1fc5d 100644 --- a/rpent/cli/dashboard.py +++ b/rpent/cli/dashboard.py @@ -113,17 +113,15 @@ def run_dashboard_session( print(f"Dashboard: {dashboard_url}", flush=True) logger.info("Dashboard: %s", dashboard_url) + # Shared robot services may validate memory before a task is claimed. + # LIBERO chooses its model-specific corpus at each task boundary instead. if ( - not getattr(args, "explore", False) + robot_spec.name != "libero" + and not getattr(args, "explore", False) and getattr(args, "memory_profile", "hf") == "hf" ): MemoryManager(get_memory_dir(robot_spec.name)).sync( remote_repo=robot_spec.memory_repo_id, - **( - {"allow_patterns": (f"{robot_spec.name}/flash/**",)} - if args.planner == "flash" - else {} - ), ) controller = DashboardSessionController( @@ -176,6 +174,9 @@ def _run_dashboard_task( raise ValueError(f"--explore is not supported for robot {robot_spec.name!r}") task_args.output_dir = str(claimed.output_dir) run_config = robot_spec.parse_config(task_args) + from rpent.memory.loading import prepare_run_memory + + prepare_run_memory(task_args, robot_spec, run_config) output_dir = init_output_dir(run_config.output_dir, verbose=args.verbose) recipe_tag = run_config.recipe_tag @@ -265,12 +266,13 @@ def _run_dashboard_task( memory_manager = toolkit.memory try: planner = build_planner( - args.planner, + task_args.planner, output_dir=output_dir, recipe_tag=recipe_tag, robot_name=args.robot_name, base_url=args.base_url, - model=args.model, + model=task_args.model, + memory_dir=run_config.prompt_vars.get("memory_dir"), max_tokens=args.max_tokens, planner_timeout_s=args.planner_timeout_s, reasoning_effort=args.reasoning_effort, @@ -333,7 +335,7 @@ def _run_dashboard_task( transcript_path = output_dir / f"transcript_{run_config.recipe_tag}.json" record = { **run_config.task_desc, - "model": args.model, + "model": task_args.model, "elapsed_s": round(time.time() - started, 1), "finish": finish_result, "stats": stats, diff --git a/rpent/cli/main.py b/rpent/cli/main.py index 6a2f85240..dcce6b542 100644 --- a/rpent/cli/main.py +++ b/rpent/cli/main.py @@ -57,7 +57,6 @@ from rpent.planner.base import REASONING_EFFORTS, build_planner from rpent.planner.check import BASE_URL_ENV_BY_PLANNER from rpent.robots import enumerate_robots, get_robot_spec, get_toolkit -from rpent.utils.config import get_memory_dir from rpent.utils.logging import get_logger, init_output_dir logger = get_logger("agent") @@ -199,6 +198,14 @@ def _build_argparser() -> argparse.ArgumentParser: default=None, help="Local memory root (environment default when omitted).", ) + from rpent.memory.versions import MEMORY_VERSIONS + + ap.add_argument( + "--memory-version", + choices=MEMORY_VERSIONS, + default="auto", + help="LIBERO HF memory: auto selects by model; explicit versions override. Effort describes memory generation only.", + ) ap.add_argument( "--explore", action="store_true", @@ -408,6 +415,12 @@ def main() -> int: args.memory_profile = args.memory_profile or ("local" if args.explore else "hf") if args.memory_profile == "hf" and args.memory_dir is not None: parser.error("--memory-dir requires --memory-profile local or --explore") + from rpent.memory.loading import prepare_run_memory, validate_memory_options + + try: + validate_memory_options(args) + except ValueError as exc: + parser.error(str(exc)) if args.dashboard: from rpent.cli.dashboard import run_dashboard_session @@ -425,18 +438,7 @@ def main() -> int: output_dir = init_output_dir(output_dir, verbose=args.verbose) logger.info("physical agent cmd: %s", shlex.join([sys.executable, *sys.argv])) - memory_profile = getattr(args, "memory_profile", "hf") - if not getattr(args, "explore", False) and memory_profile == "hf": - MemoryManager(get_memory_dir(robot_name)).sync( - remote_repo=robot_spec.memory_repo_id, - **( - {"allow_patterns": (f"{robot_name}/flash/**",)} - if args.planner == "flash" - else {} - ), - ) - else: - logger.info("memory: using local %s profile", memory_profile) + prepare_run_memory(args, robot_spec, run_config) dashboard_events = NullDashboardEventSink() @@ -445,6 +447,7 @@ def main() -> int: output_dir=output_dir, recipe_tag=recipe_tag, robot_name=robot_name, + memory_dir=prompt_vars.get("memory_dir"), base_url=args.base_url, model=args.model, max_tokens=args.max_tokens, diff --git a/rpent/cli/memory.py b/rpent/cli/memory.py index 11ce95e43..9ace0f04b 100644 --- a/rpent/cli/memory.py +++ b/rpent/cli/memory.py @@ -21,6 +21,7 @@ from pathlib import Path from rpent.memory import MemoryManager +from rpent.memory.versions import MEMORY_VERSIONS, select_version, sync_version from rpent.utils.config import get_memory_dir @@ -44,11 +45,41 @@ def _parser() -> argparse.ArgumentParser: ) subparsers.add_parser("validate", help="Validate published memory leaves.") subparsers.add_parser("build-index", help="Rebuild MEMORY.md.") + sync = subparsers.add_parser( + "sync", help="Download one model-specific LIBERO memory corpus." + ) + sync.add_argument("--robot", choices=["libero"], default="libero") + sync.add_argument("--memory-version", choices=MEMORY_VERSIONS, default="auto") + sync.add_argument("--model", default=None) + sync.add_argument( + "--planner", + choices=["api", "codex", "claude_code", "flash"], + default="api", + help="Planner backend (default: api, as in rpent); codex uses CODEX_MODEL when --model is omitted.", + ) + sync.add_argument("--revision", default="main", help="Hub commit, tag or branch.") + sync.add_argument( + "--output-dir", + type=Path, + help="Copy the corpus into a new directory for --memory-dir.", + ) return parser def main() -> int: args = _parser().parse_args() + if args.command == "sync": + version = select_version( + args.memory_version, model=args.model, planner=args.planner + ) + root = sync_version( + version=version, + revision=args.revision, + cache_dir=get_memory_dir(args.robot) / ".versions", + output_dir=args.output_dir, + ) + print(root) + return 0 manager = MemoryManager(args.memory_dir) if args.command == "merge": result = manager.merge_memory( diff --git a/rpent/dashboard/index.html b/rpent/dashboard/index.html index 9a9e2e4a5..f243a5067 100644 --- a/rpent/dashboard/index.html +++ b/rpent/dashboard/index.html @@ -13,6 +13,14 @@

RPent
+
+ Next task model +
+ + + +
+
diff --git a/rpent/dashboard/server.py b/rpent/dashboard/server.py index cf487cb31..03c344323 100644 --- a/rpent/dashboard/server.py +++ b/rpent/dashboard/server.py @@ -133,6 +133,29 @@ def api_commands() -> JSONResponse: def api_session_config() -> JSONResponse: return JSONResponse(self._planner_config) + @app.post("/api/session/config") + def api_update_session_config( + payload: dict[str, Any] = Body(default={}), + ) -> JSONResponse: + if self._planner_config.get("planner") == "flash": + return JSONResponse( + {"error": "Flash replay has no language model"}, status_code=422 + ) + if set(payload) != {"model"}: + return JSONResponse( + {"error": "Only model can be changed for the next task"}, + status_code=422, + ) + try: + self._state.set_next_model(payload["model"]) + except ValueError as exc: + return JSONResponse({"error": str(exc)}, status_code=422) + self._planner_config = { + **self._planner_config, + "model": payload["model"].strip(), + } + return JSONResponse(self._planner_config) + @app.post("/api/llm/check") def api_llm_check(payload: dict[str, Any] = Body(default={})) -> JSONResponse: # Single-flight: the button is a diagnostic, not a load generator. diff --git a/rpent/dashboard/state.py b/rpent/dashboard/state.py index 6e73819ae..bcb719cb8 100644 --- a/rpent/dashboard/state.py +++ b/rpent/dashboard/state.py @@ -193,6 +193,7 @@ def __init__( self._lock = threading.Lock() self._condition = threading.Condition(self._lock) + self._next_model: str | None = None self._task_state: str | None = None self._terminated = False self._truncated = False @@ -402,6 +403,13 @@ def request_task(self, request: TaskRequest) -> None: self._session_state = "task_starting" self._interaction_changed_locked() + def set_next_model(self, model: str) -> None: + """Change the model for subsequently claimed tasks, never the active one.""" + if not isinstance(model, str) or not model.strip(): + raise ValueError("model must be a non-empty string") + with self._condition: + self._next_model = model.strip() + def wait_for_task(self, timeout: float | None = None) -> ClaimedTask | None: """Block until the controller can claim the latest pending task.""" with self._condition: @@ -419,6 +427,8 @@ def wait_for_task(self, timeout: float | None = None) -> ClaimedTask | None: return None request = self._pending_task + if self._next_model is not None: + request = {**request, "model": self._next_model} self._pending_task = None self._task_generation += 1 number = self._task_generation diff --git a/rpent/dashboard/static/dashboard.js b/rpent/dashboard/static/dashboard.js index b38c48ebf..41daf101e 100644 --- a/rpent/dashboard/static/dashboard.js +++ b/rpent/dashboard/static/dashboard.js @@ -19,6 +19,9 @@ const COPY = { liveMonitor: "Live Monitor", planner: "planner", model: "model", + nextModel: "Next task model", + applyModel: "Apply to next task", + modelSaved: "Saved; takes effect when the next task starts.", defaultModel: "configured default", runtimeStates: { pending: "waiting", @@ -121,6 +124,9 @@ const COPY = { liveMonitor: "实时监控", planner: "planner", model: "model", + nextModel: "下一任务的模型", + applyModel: "应用于下一任务", + modelSaved: "已保存,将在下一任务开始时生效。", defaultModel: "默认配置", runtimeStates: { pending: "等待中", @@ -291,8 +297,28 @@ function renderPlannerConfig(config) { ? "Flash Mode" : `${copy.planner} ${planner} · ${copy.model} ${model}`; element.title = element.textContent; + $("#modelSettings").hidden = planner === "flash"; + $("#nextModelInput").value = config.model || ""; } +$("#modelForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const feedback = $("#modelFeedback"); + try { + const response = await fetch("/api/session/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: $("#nextModelInput").value }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error); + renderPlannerConfig(result); + feedback.textContent = copy.modelSaved; + } catch (error) { + feedback.textContent = error.message; + } +}); + const runState = { eventSource: null, lastStepCount: -1, diff --git a/rpent/memory/loading.py b/rpent/memory/loading.py new file mode 100644 index 000000000..77b041141 --- /dev/null +++ b/rpent/memory/loading.py @@ -0,0 +1,81 @@ +# Copyright 2026 The RPent Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared CLI/Dashboard memory preparation, before a task starts.""" + +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path + +from rpent.memory import MemoryManager +from rpent.memory.versions import ( + ASTRA, + replay_directory, + resolve_model, + select_version, + sync_version, +) +from rpent.robots.robot_spec import RobotSpec, RunConfig +from rpent.utils.config import get_memory_dir + + +def validate_memory_options(args: Namespace) -> None: + """Reject remote version overrides for local/exploration corpora.""" + version = getattr(args, "memory_version", "auto") + if version != "auto": + if ( + getattr(args, "explore", False) + or getattr(args, "memory_profile", None) == "local" + ): + raise ValueError( + "--memory-version requires --memory-profile hf; local memory and exploration use --memory-dir" + ) + if getattr(args, "robot_name", "libero") != "libero": + raise ValueError("--memory-version is currently supported only for LIBERO") + + +def prepare_run_memory(args: Namespace, spec: RobotSpec, config: RunConfig) -> None: + """Set the single root used by prompts, tools and replay for this task.""" + validate_memory_options(args) + # Pin the same resolved Codex environment model for selection and planner. + args.model = resolve_model(args.planner, args.model) + profile = getattr(args, "memory_profile", None) or ( + "local" if getattr(args, "explore", False) else "hf" + ) + if profile == "local": + return + if spec.name == "libero": + version = select_version( + getattr(args, "memory_version", "auto"), + model=args.model, + planner=args.planner, + ) + if args.planner == "flash" and version == ASTRA: + raise ValueError( + f"{ASTRA} has no Flash/Task Card replay assets; choose GPT_5.5_xhigh" + ) + root = sync_version( + version=version, + repo_id=spec.memory_repo_id, + cache_dir=get_memory_dir(spec.name) / ".versions", + ) + config.prompt_vars["memory_dir"] = root + config.prompt_vars["memory_version"] = version + else: + root = MemoryManager(get_memory_dir(spec.name)).sync( + remote_repo=spec.memory_repo_id + ) + if args.planner == "flash": + replay_directory(Path(root)) diff --git a/rpent/memory/versions.py b/rpent/memory/versions.py new file mode 100644 index 000000000..f21225f03 --- /dev/null +++ b/rpent/memory/versions.py @@ -0,0 +1,232 @@ +# Copyright 2026 The RPent Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Model selection and complete, revision-isolated LIBERO memory downloads.""" + +from __future__ import annotations + +import fcntl +import hashlib +import json +import os +import shutil +import tempfile +from pathlib import Path, PurePosixPath + +from rpent.utils.logging import get_logger + +logger = get_logger("memory") +GPT5 = "GPT_5.5_xhigh" +ASTRA = "GPT_6_astra_low" +MEMORY_VERSIONS = ("auto", GPT5, ASTRA) +DEFAULT_REPO = "RLinf/RPent-memory" + + +def resolve_model(planner: str, model: str | None) -> str | None: + """Resolve the model using the planner's explicit/environment precedence.""" + return model or (os.environ.get("CODEX_MODEL") if planner == "codex" else None) + + +def select_version( + version: str = "auto", *, model: str | None = None, planner: str = "api" +) -> str: + """Select a corpus without changing the running model or reasoning effort.""" + if version not in MEMORY_VERSIONS: + raise ValueError(f"Unknown memory version: {version!r}") + if version != "auto": + return version + if planner == "flash": + return GPT5 + resolved = resolve_model(planner, model) + name = resolved.rsplit(":", 1)[-1].lower() if resolved else "" + selected = {"gpt-5.5": GPT5, "gpt-6-astra": ASTRA}.get(name) + if selected: + return selected + logger.warning( + "No memory mapping for model %r (%s); using %s. " + "Use --memory-version to select explicitly.", + resolved, + planner, + GPT5, + ) + return GPT5 + + +def replay_directory(root: Path) -> Path: + """Locate generated Flash plans or the published Task Card replay assets.""" + for name in ("flash", "task_card"): + directory = root / name + if directory.is_dir() and any(directory.glob("*_plan.json")): + return directory + raise ValueError( + f"Memory {root} has no Flash/Task Card replay assets. " + f"Select --memory-version {GPT5} or provide a local replay corpus." + ) + + +def _hash(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _safe_relative(name: str) -> bool: + path = PurePosixPath(name) + return bool(name) and not path.is_absolute() and ".." not in path.parts + + +def _verified(root: Path) -> bool: + try: + receipt = json.loads((root.parent / f"{root.name}.receipt.json").read_text()) + if receipt.get("prefix") != f"libero/{root.name}/": + return False + files = receipt["files"] + return bool(files) and all( + _safe_relative(name) and _hash(root / name) == digest + for name, digest in files.items() + ) + except (OSError, ValueError, AttributeError, KeyError): + return False + + +def _write_json(path: Path, value: object) -> None: + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(value, indent=2) + "\n") + temporary.replace(path) + + +def sync_version( + *, + version: str, + cache_dir: Path, + repo_id: str = DEFAULT_REPO, + revision: str = "main", + output_dir: Path | None = None, +) -> Path: + """Download one complete corpus; reuse only its verified cache on outage. + + Published file hashes come from ``libero/manifest.json``. A receipt is + written after every selected file has downloaded, then checked on every + reuse. Ref pointers are scoped to the repository, requested revision and + version. Historical unversioned layouts require their matching client. + """ + from huggingface_hub import HfApi, hf_hub_download, snapshot_download + + if version not in (GPT5, ASTRA): + raise ValueError("sync_version requires a resolved memory version") + repo_id = os.environ.get("RPENT_MEMORY_HF_REPO", repo_id) + key = hashlib.sha256(repo_id.encode()).hexdigest()[:20] + base = Path(cache_dir).resolve() / key + base.mkdir(parents=True, exist_ok=True) + ref_key = hashlib.sha256(f"{revision}:{version}".encode()).hexdigest() + ref = base / f"{ref_key}.json" + with (base / "sync.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + # Resolve the requested ref before consulting its cache. Never fall + # back to another version, or to a different explicitly pinned commit. + offline = os.environ.get("HF_HUB_OFFLINE", "").upper() in ( + "1", + "YES", + "TRUE", + "ON", + ) + try: + if offline: + raise ConnectionError("HF_HUB_OFFLINE") + info = HfApi().repo_info(repo_id, repo_type="dataset", revision=revision) + except Exception as exc: + try: + sha = json.loads(ref.read_text())["commit"] + root = base / "snapshots" / sha / version + if not _verified(root): + raise ValueError("incomplete or modified cache") + except (OSError, ValueError, KeyError) as cache_exc: + raise RuntimeError( + f"Cannot resolve {repo_id}@{revision}: no complete cache for {version}" + ) from cache_exc + logger.warning( + "Memory Hub unavailable (%s); using verified %s", + type(exc).__name__, + root, + ) + else: + sha = info.sha + root = base / "snapshots" / sha / version + if not _verified(root): + names = [entry.rfilename for entry in info.siblings] + prefix = f"libero/{version}/" + selected = [name for name in names if name.startswith(prefix)] + if not selected: + raise ValueError( + f"{repo_id}@{sha} has no versioned {version} corpus. " + "Download the current versioned dataset; historical " + "unversioned layouts require the matching historical client." + ) + manifest_path = hf_hub_download( + repo_id, + "libero/manifest.json", + repo_type="dataset", + revision=sha, + ) + manifest = json.loads(Path(manifest_path).read_text()) + expected = manifest["versions"][version]["files"] + relative = [name.removeprefix(prefix) for name in selected] + if any(not _safe_relative(name) for name in relative): + raise ValueError("Unsafe memory file path") + if set(relative) != set(expected): + raise ValueError( + "Memory manifest does not match the selected file list" + ) + if "MEMORY.md" not in relative: + raise ValueError("Memory corpus is missing MEMORY.md") + snapshot = Path( + snapshot_download( + repo_id, + repo_type="dataset", + revision=sha, + allow_patterns=selected, + ) + ) + root.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=root.parent) as staging: + corpus = Path(staging) / version + hashes = {} + for name in relative: + dest = corpus / name + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(snapshot / prefix / name, dest) + hashes[name] = _hash(dest) + if hashes != expected: + raise ValueError("Memory file checksum mismatch") + if root.exists(): + shutil.rmtree(root) + corpus.replace(root) + _write_json( + root.parent / f"{version}.receipt.json", + {"prefix": prefix, "files": hashes}, + ) + _write_json(ref, {"commit": sha, "version": version, "repo": repo_id}) + logger.info("memory: %s @ %s, root=%s", version, sha, root) + if output_dir is not None: + destination = Path(output_dir).resolve() + if destination != root: + if destination.exists(): + raise ValueError( + f"Output directory already exists: {destination}; choose a new directory" + ) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=destination.parent) as staging: + copy = Path(staging) / "corpus" + shutil.copytree(root, copy) + copy.replace(destination) + root = destination + return root diff --git a/rpent/planner/base.py b/rpent/planner/base.py index 654a3cd9d..f794a5363 100644 --- a/rpent/planner/base.py +++ b/rpent/planner/base.py @@ -173,6 +173,7 @@ def build_planner( robot_name: str, base_url: str | None = None, model: str | None = None, + memory_dir: str | Path | None = None, max_tokens: int = 8192, planner_timeout_s: int | None = None, reasoning_effort: str = "none", @@ -214,7 +215,7 @@ def build_planner( model=model or "sonnet", timeout_s=cc_timeout_s, max_budget_usd=cc_budget, - extra_dirs=[str(get_memory_dir(robot_name))], + extra_dirs=[str(memory_dir or get_memory_dir(robot_name))], output_path=Path(output_dir) / f"claude_{recipe_tag}.txt", dashboard_events=dashboard_events, reasoning_effort=reasoning_effort, @@ -235,7 +236,7 @@ def build_planner( repo_root=get_repo_root(), model=model, timeout_s=cx_timeout_s, - extra_dirs=[str(get_memory_dir(robot_name))], + extra_dirs=[str(memory_dir or get_memory_dir(robot_name))], output_path=Path(output_dir) / f"codex_{recipe_tag}.txt", dashboard_events=dashboard_events, reasoning_effort=reasoning_effort, diff --git a/tests/unit_tests/rpent/cli/test_dashboard_contracts.py b/tests/unit_tests/rpent/cli/test_dashboard_contracts.py new file mode 100644 index 000000000..588494526 --- /dev/null +++ b/tests/unit_tests/rpent/cli/test_dashboard_contracts.py @@ -0,0 +1,107 @@ +# Copyright 2026 The RPent Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dashboard startup memory requirements before shared robot services.""" + +import argparse +from types import SimpleNamespace + +import pytest + +from rpent.cli import dashboard +from rpent.cli.main import _build_argparser +from rpent.dashboard.server import DashboardServer +from rpent.dashboard.state import DashboardState +from rpent.memory import MemoryManager + + +@pytest.mark.parametrize( + ("robot", "profile", "sync_before_shared"), + [ + ("robocasa", "hf", True), + ("robotwin", "hf", True), + ("robocasa", "local", False), + ("libero", "hf", False), + ("libero", "local", False), + ], +) +def test_shared_runtime_gets_memory_before_startup( + robot, profile, sync_before_shared, tmp_path, monkeypatch +): + root = tmp_path / "memory" / robot + events = [] + + def sync(manager, *, remote_repo): + assert manager.root == root + assert remote_repo == "test/memory" + global_file = root / "global" / "GLOBAL_MEMORY.md" + global_file.parent.mkdir(parents=True) + global_file.write_text("Live task observations take precedence.") + events.append("sync") + return root + + def init_runtime(args, output_dir, state, components): + assert components == {"vla"} + if sync_before_shared: + assert (root / "global" / "GLOBAL_MEMORY.md").is_file() + events.append("shared-runtime") + return [], {} + + def start(server): + # Exercise actual session orchestration, then exit after service startup. + server._state.request_shutdown() + return "http://127.0.0.1:12345" + + def fail_session(state, error): + pytest.fail(f"Shared service startup failed: {error}") + + monkeypatch.setattr(dashboard, "get_memory_dir", lambda name: root) + monkeypatch.setattr(MemoryManager, "sync", sync) + monkeypatch.setattr(DashboardServer, "start", start) + monkeypatch.setattr(DashboardState, "fail_session", fail_session) + spec = SimpleNamespace( + name=robot, + is_real_robot=False, + memory_repo_id="test/memory", + dashboard={ + "task": { + "command": "/rpent-task", + "usage": "/rpent-task ", + "fields": ({"name": "seed", "kind": "integer", "minimum": 0},), + "display": "seed {seed}", + "output_slug": "s{seed}", + }, + "runtime_components": ({"name": "vla", "scope": "shared"},), + "primitives": (), + }, + init_runtime=init_runtime, + ) + args = _build_argparser().parse_args( + [ + "--robot", + robot, + "--dashboard", + "--planner", + "codex", + "--memory-profile", + profile, + "--output-dir", + str(tmp_path / "run"), + ] + ) + assert ( + dashboard.run_dashboard_session(args, spec, parser=argparse.ArgumentParser()) + == 0 + ) + assert events == (["sync"] if sync_before_shared else []) + ["shared-runtime"] diff --git a/tests/unit_tests/rpent/dashboard/test_server_contracts.py b/tests/unit_tests/rpent/dashboard/test_server_contracts.py index bf9805a89..340def908 100644 --- a/tests/unit_tests/rpent/dashboard/test_server_contracts.py +++ b/tests/unit_tests/rpent/dashboard/test_server_contracts.py @@ -58,6 +58,29 @@ def _client(server: DashboardServer) -> TestClient: return TestClient(server._app) +def test_model_change_applies_at_next_task_boundary(state): + client = _client(_server(state, planner="codex", model="gpt-5.5")) + state.shared_services_ready() + state.request_task({"seed": 1}) + first = state.wait_for_task(timeout=0) + response = client.post("/api/session/config", json={"model": "gpt-6-astra"}) + assert response.status_code == 200 + assert "model" not in first.request + state.request_task({"seed": 2}) + second = state.wait_for_task(timeout=0) + assert second.request["model"] == "gpt-6-astra" + assert client.post("/api/session/config", json={"model": ""}).status_code == 422 + assert ( + client.post("/api/session/config", json={"planner": "api"}).status_code == 422 + ) + assert ( + _client(_server(state, planner="flash")) + .post("/api/session/config", json={"model": "gpt-6-astra"}) + .status_code + == 422 + ) + + def _stub_check( monkeypatch: pytest.MonkeyPatch, result: LlmCheckResult ) -> dict[str, Any]: diff --git a/tests/unit_tests/rpent/memory/test_versions.py b/tests/unit_tests/rpent/memory/test_versions.py new file mode 100644 index 000000000..93ccb674e --- /dev/null +++ b/tests/unit_tests/rpent/memory/test_versions.py @@ -0,0 +1,303 @@ +# Copyright 2026 The RPent Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Version selection, download boundaries and cache failure behavior.""" + +import hashlib +import json +import sys +from argparse import Namespace +from types import SimpleNamespace + +import huggingface_hub +import pytest + +from rpent.memory import loading +from rpent.memory.versions import ( + ASTRA, + GPT5, + replay_directory, + select_version, + sync_version, +) + + +@pytest.mark.parametrize( + ("model", "planner", "version", "expected"), + [ + ("gpt-5.5", "codex", "auto", GPT5), + ("gpt-6-astra", "codex", "auto", ASTRA), + ("openai:gpt-6-astra", "api", "auto", ASTRA), + ("openai-chat:gpt-5.5", "api", "auto", GPT5), + ("gpt-6-astra", "codex", GPT5, GPT5), + ("opus", "claude_code", "auto", GPT5), + (None, "api", "auto", GPT5), + ("gpt-6-astra", "flash", "auto", GPT5), + ], +) +def test_selection(model, planner, version, expected, monkeypatch): + monkeypatch.delenv("CODEX_MODEL", raising=False) + assert select_version(version, model=model, planner=planner) == expected + + +def test_codex_environment_precedence(monkeypatch): + monkeypatch.setenv("CODEX_MODEL", "gpt-6-astra") + assert select_version(planner="codex") == ASTRA + assert select_version(planner="codex", model="gpt-5.5") == GPT5 + assert select_version(planner="api") == GPT5 + + +@pytest.fixture +def hub(tmp_path, monkeypatch): + monkeypatch.delenv("RPENT_MEMORY_HF_REPO", raising=False) + monkeypatch.delenv("HF_HUB_OFFLINE", raising=False) + snapshot = tmp_path / "hub" + files = {} + for version in (GPT5, ASTRA): + root = snapshot / "libero" / version + root.mkdir(parents=True) + contents = { + "MEMORY.md": version, + "global/note.md": f"{version} global", + "task-family/task-family_object_task_t0.md": f"{version} family", + "task-specific/object_task_t0_s0.json": "{}", + "task-specific/object_task_t0_s0_recipe.jsonl": "{}\n", + } + if version == GPT5: + contents["task_card/object_task_t0_plan.json"] = "{}" + contents["task_card/object_task_t0_anchors.json"] = "{}" + for name, content in contents.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + files[version] = { + "files": { + name: hashlib.sha256(content.encode()).hexdigest() + for name, content in contents.items() + }, + "source_files": {"old/original.md": "0" * 64}, + } + manifest = snapshot / "libero/manifest.json" + manifest.write_text(json.dumps({"versions": files})) + state = SimpleNamespace( + sha="a" * 40, calls=[], fail=False, download_fail=False, snapshot=snapshot + ) + + def info(*args, **kwargs): + if state.fail: + raise ConnectionError("offline") + return SimpleNamespace( + sha=state.sha, + siblings=[ + SimpleNamespace(rfilename=str(p.relative_to(snapshot))) + for p in snapshot.rglob("*") + if p.is_file() + ], + ) + + def download(*args, **kwargs): + state.calls.append(kwargs) + if state.download_fail: + raise ConnectionError("interrupted transfer") + return str(snapshot) + + monkeypatch.setattr( + huggingface_hub, "HfApi", lambda: SimpleNamespace(repo_info=info) + ) + monkeypatch.setattr(huggingface_hub, "snapshot_download", download) + monkeypatch.setattr( + huggingface_hub, "hf_hub_download", lambda *a, **k: str(manifest) + ) + return state + + +def test_only_selected_subtree_and_complete_offline_cache(hub, tmp_path): + cache = tmp_path / "cache" + root = sync_version(version=ASTRA, cache_dir=cache) + assert (root / "MEMORY.md").read_text() == ASTRA + downloaded = hub.calls[0]["allow_patterns"] + assert all(name.startswith(f"libero/{ASTRA}/") for name in downloaded) + assert f"libero/{ASTRA}/task-specific/object_task_t0_s0.json" in downloaded + assert f"libero/{ASTRA}/task-family/task-family_object_task_t0.md" in downloaded + assert len(downloaded) == 5 + assert not (root / "task_card").exists() + hub.fail = True + assert sync_version(version=ASTRA, cache_dir=cache) == root + with pytest.raises(RuntimeError, match="no complete cache"): + sync_version(version=GPT5, cache_dir=cache) + (root / "MEMORY.md").write_text("partial") + with pytest.raises(RuntimeError, match="no complete cache"): + sync_version(version=ASTRA, cache_dir=cache) + + +def test_pinned_revisions_and_repositories_never_share_fallback(hub, tmp_path): + cache = tmp_path / "cache" + first = sync_version(version=ASTRA, revision="a" * 40, cache_dir=cache) + hub.sha = "b" * 40 + second = sync_version(version=ASTRA, revision=hub.sha, cache_dir=cache) + assert first != second + hub.fail = True + with pytest.raises(RuntimeError): + sync_version(version=ASTRA, revision="c" * 40, cache_dir=cache) + with pytest.raises(RuntimeError): + sync_version( + version=ASTRA, revision=hub.sha, repo_id="other/repo", cache_dir=cache + ) + + +def test_download_failure_cannot_leave_a_complete_cache(hub, tmp_path): + hub.download_fail = True + with pytest.raises(ConnectionError): + sync_version(version=ASTRA, cache_dir=tmp_path / "cache") + hub.fail = True + with pytest.raises(RuntimeError): + sync_version(version=ASTRA, cache_dir=tmp_path / "cache") + + +def test_manifest_hashes_prevent_partial_or_wrong_corpus(hub, tmp_path): + (hub.snapshot / "libero" / ASTRA / "MEMORY.md").write_text("wrong") + with pytest.raises(ValueError, match="checksum"): + sync_version(version=ASTRA, cache_dir=tmp_path / "cache") + + +@pytest.mark.parametrize("version", [GPT5, ASTRA]) +def test_unversioned_layout_requires_its_historical_client(hub, tmp_path, version): + import shutil + + shutil.rmtree(hub.snapshot / "libero") + root = hub.snapshot / "libero" + root.mkdir() + (root / "MEMORY.md").write_text("legacy") + with pytest.raises(ValueError, match="historical client"): + sync_version(version=version, cache_dir=tmp_path / "cache") + assert not hub.calls + + +def test_output_copy_and_versions_do_not_overwrite_each_other(hub, tmp_path): + from concurrent.futures import ThreadPoolExecutor + + with ThreadPoolExecutor() as executor: + roots = list( + executor.map( + lambda v: sync_version(version=v, cache_dir=tmp_path / "cache"), + [GPT5, ASTRA], + ) + ) + assert [(p / "MEMORY.md").read_text() for p in roots] == [GPT5, ASTRA] + out = tmp_path / "export" + assert ( + sync_version(version=ASTRA, cache_dir=tmp_path / "cache", output_dir=out) == out + ) + assert (out / "MEMORY.md").read_text() == ASTRA + with pytest.raises(ValueError, match="already exists"): + sync_version(version=GPT5, cache_dir=tmp_path / "cache", output_dir=out) + + +def test_local_explore_conflicts_and_replay(tmp_path, monkeypatch): + for profile, explore in [("local", False), ("local", True), ("hf", True)]: + with pytest.raises(ValueError, match="requires"): + loading.validate_memory_options( + Namespace(memory_version=ASTRA, memory_profile=profile, explore=explore) + ) + with pytest.raises(ValueError, match="no Flash"): + replay_directory(tmp_path) + task_card = tmp_path / "task_card" + task_card.mkdir() + (task_card / "object_task_t0_plan.json").write_text("{}") + assert replay_directory(tmp_path) == task_card + + +def test_task_model_changes_resolve_root_again_without_changing_effort( + tmp_path, monkeypatch +): + monkeypatch.setattr(loading, "sync_version", lambda **kw: tmp_path / kw["version"]) + spec = SimpleNamespace(name="libero", memory_repo_id="test/repo") + args = Namespace( + planner="codex", + model="gpt-5.5", + memory_profile="hf", + memory_version="auto", + reasoning_effort="low", + ) + for model, version in [("gpt-5.5", GPT5), ("gpt-6-astra", ASTRA)]: + args.model = model + config = SimpleNamespace(prompt_vars={}) + loading.prepare_run_memory(args, spec, config) + assert config.prompt_vars["memory_dir"] == tmp_path / version + assert args.reasoning_effort == "low" + args.memory_profile = "local" + config = SimpleNamespace(prompt_vars={"memory_dir": tmp_path}) + loading.prepare_run_memory(args, spec, config) + assert config.prompt_vars["memory_dir"] == tmp_path + + +@pytest.mark.parametrize( + ("options", "expected"), + [ + ([], GPT5), + (["--planner", "codex"], ASTRA), + (["--planner", "codex", "--model", "gpt-5.5"], GPT5), + (["--planner", "codex", "--memory-version", GPT5], GPT5), + (["--planner", "api", "--model", "openai:gpt-6-astra"], ASTRA), + ], +) +def test_sync_and_run_use_same_model_selection( + options, expected, monkeypatch, tmp_path +): + from rpent.cli import main as run_cli + from rpent.cli import memory as memory_cli + + monkeypatch.setenv("CODEX_MODEL", "gpt-6-astra") + selected = [] + + def sync(**kwargs): + selected.append(kwargs["version"]) + return tmp_path / kwargs["version"] + + monkeypatch.setattr(memory_cli, "sync_version", sync) + monkeypatch.setattr(loading, "sync_version", sync) + monkeypatch.setattr(sys, "argv", ["rpent-memory", "sync", *options]) + assert memory_cli.main() == 0 + + args = run_cli._build_argparser().parse_args(["--robot", "libero", *options]) + config = SimpleNamespace(prompt_vars={}) + spec = SimpleNamespace(name="libero", memory_repo_id="test/repo") + loading.prepare_run_memory(args, spec, config) + assert selected == [expected, expected] + assert config.prompt_vars["memory_dir"] == tmp_path / expected + if args.planner == "codex": + assert args.model == ("gpt-5.5" if "--model" in options else "gpt-6-astra") + + +def test_manifest_published_file_set_is_required(hub, tmp_path): + extra = hub.snapshot / "libero" / ASTRA / "global" / "unlisted.md" + extra.write_text("not in the published manifest") + with pytest.raises(ValueError, match="selected file list"): + sync_version(version=ASTRA, cache_dir=tmp_path / "cache") + assert not hub.calls + + +def test_legacy_cache_cannot_bypass_versioned_source_requirement(hub, tmp_path): + cache = tmp_path / "cache" + root = sync_version(version=GPT5, cache_dir=cache) + receipt = root.parent / f"{GPT5}.receipt.json" + files = json.loads(receipt.read_text())["files"] + # Pre-version receipts do not identify a validated versioned Hub subtree. + receipt.write_text(json.dumps(files)) + hub.fail = True + with pytest.raises(RuntimeError, match="no complete cache"): + sync_version(version=GPT5, cache_dir=cache) + hub.fail = False + assert sync_version(version=GPT5, cache_dir=cache) == root + assert len(hub.calls) == 2