Skip to content

feat(bundle): ask each artifact type what it reads from its folder, and read a universe's as the inverse of get - #232

Open
earakely-scale wants to merge 7 commits into
mainfrom
edgararakelyan/fd-3987-universe-metadata-keys
Open

earakely-scale wants to merge 7 commits into
mainfrom
edgararakelyan/fd-3987-universe-metadata-keys

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A bundle's environment_universe folder is laid out as environment-universe get --output-dir writes one, but the reader only took one folder level.

  • What get writes: each environment to <environment_name>/<file> and each metadata file to metadata/<key>/<file>, names verbatim. A key or name holding /, such as scenarios/<name>.yaml, comes out as nested folders.
  • What failed: the reader refused those folders ("holds its one file directly … with no folders inside"). A stored universe with such keys couldn't be downloaded and written back from a bundle without renaming them.
  • What else was refused: names holding __, characters an @local id can't hold, or a segment opening or closing with a space, even though get writes them all.

The folder is now read as the exact inverse of get, whatever it holds:

  • Nesting. Any folder directly holding one file is an entry named by its path. That's an environment from the universe's folder, or a metadata key from metadata/. A folder holding only folders is part of their names, and a folder can hold its own file and nested entries at once, so a and a/b both work.
  • Ids. A name an @local id can hold keeps the ids it has today: <universe>__<name> over <universe>__<name>__file, and <universe>__metadata__<key>. Any other name is written under …__sha256__<16 hex of its SHA-256> in its place. Names holding __ are always hashed, so no name's own id can take that form, and a name spelled like another's hash keeps an id of its own. That covers __ (which could clash with another child's ids), characters outside the id set, a segment opening or closing with a space, and ids over 4096 bytes. The universe still stores the name or key itself.
  • Still refused, each with its fix:
    • a file directly in the universe folder or directly in metadata/;
    • a folder directly holding two or more files;
    • a folder with nothing beneath it, which is now named at its deepest point;
    • metadata spelled in another case.
  • The format's own limits, written in the docstring: an environment whose name starts with metadata/ reads back as a metadata key, so it needs an artifact folder of its own.

The bundle asks each artifact type about its folder, so a plugin's type can be written from one. The bundle used to keep a table of the core types and what each one's write reads from its folder. It branched on the environment and universe readers to list child ids and the environments a load seeds. A plugin type with a from_toml of its own was refused ("writing … isn't supported yet").

  • The hook. Artifact.bundle_folder(ctx) returns an ArtifactFolder with two parts:
    • files: everything from_toml reads, which the plan checks and the ledger hashes;
    • children: the ids it writes besides its own, each a FolderChild with its path in the folder and the environment it seeds.
  • It's optional. The default is every file in the folder and no children. Hashing more than the write reads can only rewrite more often, never reuse a stale artifact.
    • The file, file universe, skill and CLI types take the default.
    • So does any plugin whose folder is "all my files, one artifact", which needs only a from_toml.
    • Only environment and environment universe override it, because they write children: the environment's <id>__file, and the universe's environments, their files and its metadata files.
  • Checks before anything is written stay in accept_toml. The rule that a type = "file" folder holds one file moves there, beside the checks skill and CLI already make of their folders. A file's folder now reads like a file universe holding one file.
  • The bundle side asks the type and no longer checks types. That covers the plan's file, one-writer and seeding checks, the ledger's digest and materialize's writer check.
    • A type is written from its folder when its from_toml isn't the base's, which writes a document and no folder.
    • The duplicate-environment-name check now covers any artifact's environments.
  • Where the universe format lives: artifact/artifacts/environment_universe_folder.py.
    • environment_path and metadata_path say where get writes each file, and read_folder is their inverse.
    • The module owns METADATA and the ids of what's written from each folder, so it doesn't import the universe class.
    • bundle/authoring.py keeps only generic helpers: listing(), only() and refuse_within().
  • The write side (an entity written out as its folder, as get does) lands with bundle import, its first consumer.

Bundle authoring and get only, so not hot path.

Tests

  • Round trip through the real CLI. It runs environment-universe put, get --output-dir into a bundle, then a bundle write, and compares the stored universe with the original: names, keys, filenames and bytes.
    • The universe: environments gmail, drive/shared, g__mail, crm: v2 and slack , and keys manifest, scenarios, scenarios/a.yaml, scenarios/b.yaml, deep/x/y/z.json, m__k and q&a?.
    • The ledger: a rerun reuses everything, and changing one deep file rewrites only the universe, naming the file.
  • Ids. Names an id can hold keep their ids, verbatim. g__mail and q&a? get the hashed form. A name spelled like another's hash keeps an id of its own.
  • Nesting. A folder holding its own file and nested ones gives an entry for each, and a folder holding only folders is part of a name.
  • Refusals. Metadata in another case is refused even as a link to an environment's folder. A nested folder with two files is refused, naming it, and the empty-folder test now names the deepest empty folder. The six cases the old reader refused for nesting, __ or edge spaces are now accepted layouts.
  • The format module on its own: read_folder reads back what environment_path and metadata_path lay out, and names each problem by its path in the folder.
  • Artifact types in bundles (bundle_folder_test.py). The test uses a plugin type that subclasses no core type and writes a file artifact per file, each seeding an environment.
    • It's written with its children, reused on a rerun, and rewritten when a file it reads changes.
    • A problem with its folder is reported before any write.
    • A child id that another write claims is refused.
    • A child seeding no server of the env it loads into is refused.
    • Two children seeding one environment are refused.
    • A file type with only a from_toml of its own is written by it, rewritten when its folder changes, and checked by the accept_toml it inherits.
  • Unit tier: 7,897 passed locally. Ids, digests and messages are unchanged, except the duplicate-name message, which names the type and the other environment by its path.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no new actionable issues were found.

Summary

The PR lets artifact types describe their bundle folders and reads nested universe folders back into environment names and metadata keys.

  • Universe folders keep nested names when bundles read them.
  • Artifact types can describe the files and child writes in their folders.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Artifact type] --> B[bundle_folder]
  B --> C[Files to check and hash]
  B --> D[Child IDs and environment names]
  C --> E[Plan checks]
  D --> E
  E --> F[Ledger compares inputs]
  F --> G[Reuse saved version]
  F --> H[Write through from_toml]
Loading

Reviews (7) · Last reviewed commit: "refactor(artifact): default bundle_folde..." · Reviewed by Greptile

…nvironment-universe get, whatever its names

A folder directly holding one file is an environment named by its path from the universe's folder, or, under
metadata/, a metadata key named by its path from there, so names and keys holding / round-trip as the nested
folders get writes. A name an @Local id can't hold as it is (__, characters outside the id set, a segment opening
or closing with a space, too long) is written under a hash of it instead of being refused; every name that works
today keeps its ids.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale
earakely-scale requested a review from a team as a code owner October 16, 2026 17:59
Comment thread src/agent_env/bundle/authoring.py Outdated
Comment thread src/agent_env/bundle/authoring.py Outdated
…d can take, and list every path to a folder

A hashed id is now <base>__sha256__<16 hex>: names holding __ are always hashed, so no name's own id holds it, and
a name spelled like another's hash keeps an id of its own. Folders are listed by every path to them, guarding only
against a link back to a folder that holds it, so metadata in another case is refused even as a link to an
environment's folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread src/agent_env/bundle/authoring.py Outdated
@earakely-scale

earakely-scale commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Chaos verification

On an Ubuntu 24.04 VM (m5.2xlarge) and a stock macOS 15.8 VM (below), the old release (agentenv-framework 0.9.1334 from PyPI) against this PR (481e31c, with the review fixes). Both commits gave the same results.

Fuzzed round trip.

  • The universes: each case stores a random universe under bare ids, with 1–6 environments and 0–10 metadata keys. Names are 1–4 levels deep and include prefix pairs, ASCII punctuation, non-Latin letters, __ and spaces at the ends of path segments.
  • The steps: download it with the real environment-universe get --output-dir into a bundle, write the bundle, and compare names, keys, filenames and bytes with the original.
Old New
300 cases (298 with nested names) 298 refused, 2 accepted (the 2 with flat names an id can hold) 300/300 round-trip exactly
200 more, names kept apart as a case-insensitive disk would — 200/200
  • Ids: 546 children's ids are the name verbatim and 3,805 are …__sha256__<hash>, each the form expected for its name, with no two alike.
  • Ledger: a rerun reused everything (100/100), and changing one file rewrote only the universe (100/100).
  • End to end: every tenth case also went through agent-env run, loading the universe into a local sandbox: 30/30 exit 0.

No regression. 60 flat universes of the kind the old reader accepts were written by the old release, then run by this PR in the same state root. All 60 came back artifacts/u: v1, unchanged, with the same digests and ids. Every run exited 0 on both versions.

CoopBench. This is the materialized copy, with its universe's 15 scenarios/<name>.yaml keys put back as get writes them, metadata/scenarios/<name>.yaml/<file>. The stored artifacts weren't touched.

  • Old, dry run: 15 refusals, one per key.
  • New, dry run: passes.
  • New, full task on the local provider: 18/18 steps. The universe loaded into the env (all 11 services re-ingested), world-warmup and collect-evidence passed, and the judges scored the solver's work.
  • The stored universe: 22 metadata keys, 15 holding /, each under its own verbatim id (…__metadata__scenarios/<name>.yaml), with none flattened.
  • The run key appears in no log.

macOS

The same battery ran on a stock macOS 15.8 VM (mac-m4.metal; APFS, case-insensitive). Both fuzz passes kept apart names that differ only in case, since get itself merges them on such a disk. The results match Ubuntu's:

Old New
300 fuzz cases (298 nested) 298 refused, 2 accepted 300/300 round-trip exactly
200 more — 200/200
60 flat universes, written by old, then run by new — 60/60 v1, unchanged
CoopBench, its 15 keys nested dry run refuses all 15 dry run passes; full task runs 18/18 steps
  • Ids and ledger: every id is the expected form. A rerun reused everything (100/100), and a change rewrote only the universe (100/100).
  • End to end: 30/30 agent-env runs exited 0.
  • Decomposed names (NFD). APFS keeps folder names as written, so a universe folder can hold decomposed names, as some Mac tools write them.
    • Both versions read flat ones in NFC: café, résumé.yaml.
    • Only the new one reads nested ones (scenarios/résumé.yaml), and it round-trips them through get.

After the maintainability refactor (6c68921)

The whole battery ran again on fresh Ubuntu and macOS VMs. That covered the fuzz on both versions, the 60-universe no-regression run, CoopBench (dry runs and the full task) and the NFD probe. Every result matched the earlier rounds:

  • Fuzz: 300/300 and 200/200 round-trip on new, and old refuses 298/300.
  • Ids: the same verbatim and hashed counts as the earlier rounds.
  • No regression: 60/60 v1, unchanged.
  • CoopBench: old refuses the 15 nested keys; new runs 18/18.

After moving the format into its own module (7a517bb)

The whole battery ran again on fresh Ubuntu and macOS VMs, with the same results as every earlier round on both:

  • Fuzz: new round-trips 300/300 and 200/200, and old refuses 298/300.
  • No regression: 60/60 v1, unchanged.
  • CoopBench: old refuses the 15 nested keys; new runs 18/18 steps.
  • NFD (macOS): both versions read flat names in NFC, and only new reads and round-trips nested ones.

After artifact types describe their own folders (81c3944)

On fresh Ubuntu and macOS VMs, the battery's results match every earlier round on both:

  • Fuzz: 300/300 and 200/200 round-trip, and old refuses 298/300.
  • No regression: 60/60 v1, unchanged.
  • CoopBench: old refuses 15; new runs 18/18 steps.
  • NFD (macOS): the same as before.

A plugin's artifact types, installed as a real agent_env.artifacts entry-point package. Both types define their own bundle_folder and from_toml. agent-env plugin check passes on both releases. The results are the same on both hosts.

0.9.1334 This PR
rooms_chaos: no core type's subclass. Each file is written as a file artifact beside it, seeding the environment it's named after refused ("writing a rooms_chaos artifact isn't supported yet") written, with children rooms-crm.json and rooms-slack.json
notes_chaos: a file_artifact_universe subclass that reads only its *.md files refused written with a.md and sub/b.md only, and agent-env run loads it into a local sandbox (exit 0)
Rerun — both reused, in the plan and on agent-env run
Change a file notes_chaos doesn't read (skip.txt) — still reused
Change a file each type reads — each rewritten as v2, the other reused
A folder whose id is one of the children's — refused: "… is also written by the artifact written from artifacts/rooms/crm.json"

earakely-scale and others added 4 commits October 16, 2026 11:58
… id is refused naming the fix

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…its files, and its children's ids from its own naming

_Walk now records every folder it visits, so universe_layout reads files and folders from one walk with one set of
rules, and _subfolders goes. _child_id derives through EnvironmentUniverseArtifact.derived_environment_id and
derived_metadata_id, which the CLI uses too, adding only the hashed fallback. Every id is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ule of its own, read and written from it

environment_universe_folder holds the format both ways: environment_path and metadata_path, where
environment-universe get writes each file, and read_folder, their inverse, which takes a folder's listing and
raises UniverseFolderError naming paths in the folder. The bundle keeps only the walk and the bundle-relative
prefix on each problem; messages and ids are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r, so a plugin's type is written as a core one is

The bundle kept a table of core types and what each one's write reads from its folder, and branched on the
environment and universe readers to list child ids and the environments a load seeds. A plugin type with a
from_toml of its own was refused.

Artifact.bundle_folder(ctx) now returns what from_toml reads (files, which the ledger hashes) and the ids it writes
besides its own (children, each with its path in the folder and the environment it seeds). Each core type describes
its own folder, and the plan, ledger and materialize ask the type. A type is written from its folder when one class
defines both hooks, so a plugin type defining both is planned, checked, written and reused as a core one is.

The universe folder module no longer imports the universe class: it owns METADATA and the ids of what's written
from each folder. Ids, digests and messages are unchanged, except the duplicate-name message, which names the
type and the folder by its path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale earakely-scale changed the title feat(bundle): read an environment_universe folder as the inverse of environment-universe get, whatever its names feat(bundle): ask each artifact type what it reads from its folder, and read a universe's as the inverse of get Oct 17, 2026
@earakely-scale

Copy link
Copy Markdown
Collaborator Author

🤖 Automated Claude routine — Env Pod PR Context Review. This is a bot, not Edgar Arakelyan; not a human review.

Context from a sibling PR rather than from this diff: the folder round-trip this PR pins carries a universe's files, but none of the stored document's own fields — and #170 adds the first such field whose loss would change what a restored env does.

  • Here: src/agent_env/artifact/artifacts/environment_universe.py:166 — from_toml ends at cls.put(ctx.id, environment_artifacts=environments + named, metadata=metadata or None), and toml_keys (:42) accepts only environment_artifacts, so no other document key can be named in an artifact.toml either. environment-universe get --output-dir writes only the environment files and metadata/<key>/ files, so there's nothing in the folder for the rest of the document to come from. Today that's exactly right, and the class comment says as much.
  • There: feat(task_step): resume a restored env's clock from its snapshot's virtual time #170 (feat(task_step): resume a restored env's clock from its snapshot's virtual time, open, same file) adds virtual_clock: Optional[VirtualClockReading] (:72 on that branch) and put(..., virtual_clock=...) (:118, :130), filled by snapshot_env_state and read back when a restored env's clock is armed.

With both landed, a snapshot-derived universe downloaded with get --output-dir and written back from a bundle comes back without its virtual_clock, so a restore from the bundled copy re-arms at the task's start time — the case #170 exists to fix — with nothing refused and nothing logged. That's the same download-and-write-back path this PR's summary is about, and a snapshot universe is a stored universe like any other.

If carrying it is out of scope, the docstring's "The format's own limits" list looks like the right place to say that a universe's non-file fields don't survive the folder round-trip, so the next field with runtime meaning doesn't get added in the same shape. Ordering-wise it's whichever of the two lands second that would want the note.


Generated by Claude Code

…, and check a file's one file in accept_toml

bundle_folder is now optional. Its default is every file in the folder and nothing besides, which is what a file
universe, a skill and a CLI read, so they drop their overrides, and a plugin type whose folder is all its files
needs only a from_toml. A type is written from its folder when its from_toml isn't the base's. Hashing every file
can only rewrite more often, never reuse a stale write.

A file's folder reads as a one-file universe: the rule that it holds one file moves into FileArtifact.accept_toml,
which the plan runs before anything is written, beside the checks a skill and a CLI make of their folders.

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

This branch has not been deployed

No deployments
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.

1 participant