Skip to content

feat(bundle): write any artifact type from its folder through its own hooks, and read a universe's as the inverse of get - #232

Merged
earakely-scale merged 13 commits into
mainfrom
edgararakelyan/fd-3987-universe-metadata-keys
Oct 18, 2026
Merged

earakely-scale merged 13 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 no longer checks artifact types. 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"). Now each type's authoring hooks carry what the bundle needs:

  • from_toml writes the artifact. Any type whose from_toml isn't the base's is written from its folder.
  • What the write reads is every file in the folder, which the plan checks and the ledger hashes. Hashing more than a write reads can only rewrite more often, never reuse a stale artifact.
  • Any artifact folder may be empty. A type whose write needs a file says so in its accept_toml. Environment and universe folders hold only artifact.toml when their toml names what they're made of (file, environment_artifacts). A plugin type built only from its toml values works the same way.
  • accept_toml makes the checks the plan runs before anything is written:
    • a type = "file" folder holds one file (a file's folder reads like a file universe holding one file), and a file universe or CLI folder holds at least one;
    • an environment's folder holds one file, or none when file is set;
    • the universe's layout rules and "needs an environment";
    • skill and cli keep their existing folder checks there.
  • written_besides(ctx) names the artifacts from_toml writes besides its own id, each a FolderChild (its id and its path in the folder). The plan uses it to refuse an id clash before any write.
    • The default is none, and only environment_universe overrides it: its environments, their files and its metadata files, as read_folder names them.
    • An environment's <id>__file can't collide, since artifact names can't hold __.
  • SeedsEnvironments is an abstract mixin, beside EnvironmentArtifact and exported from agent_env.artifact, for a type whose write seeds environments. Its seeded_environments(ctx) returns (path, environment_name) for each environment it seeds, with the path "" meaning the folder itself.
    • environment (its own name) and environment_universe (each environment its folder holds) declare it, and the plan asks only types that declare it.
    • A plugin that declares it but misspells the method is skipped when it loads, with "… must implement seeded_environments".
    • The plan uses it to check what a load seeds against the env's servers, to check that one artifact's environments have names of their own, and to find a referenced bundle environment's name. It reads no artifact's accept_toml result for a name.
    • The base artifact module says nothing about environments.
  • The bundle side calls these hooks for every type, with no per-type branches. That covers the plan's file, one-writer and seeding checks, the ledger's digest and materialize's writer check. 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(), not_one_file(remedy), no_files(...) 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 (plugin_artifact_types_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 blocking issue remains.

Summary

Bundles now use each artifact type’s hooks to check and write its folder. Universe folders support nested names and reserved hash IDs.

  • Universe folders keep nested environment names and metadata keys when bundled.
  • Artifact types can describe their own bundle writes and seeded environments.
  • Artifact types check their folder rules before the bundle writes them.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  F[Artifact folder] --> P[Bundle plan]
  P --> A[accept_toml checks]
  P --> W[written_besides IDs]
  P --> S[SeedsEnvironments names]
  A --> C[Checked plan]
  W --> C
  S --> C
  C --> L[Ledger checks files and references]
  L --> R[Reuse saved version]
  L --> T[from_toml writes artifact]
Loading

Reviews (13) · Last reviewed commit: "refactor(artifact): declare seeding envi..." · 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"

After the hook shrinks to written_besides (07ce7bb, Ubuntu)

macOS wasn't rerun, because the folder walk and read_folder are unchanged. On Ubuntu the battery matches every earlier round:

  • 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.

The plugin package, with rooms_chaos on written_besides and notes_chaos defining only from_toml:

  • Old release: refuses both.
  • With the PR:
    • both are written, with rooms_chaos's children;
    • notes_chaos stores only its *.md files, and agent-env run loads it, then reuses it;
    • a rerun reuses both;
    • a folder whose id is a child's is refused.
  • One change, as intended: editing skip.txt, which notes_chaos doesn't read, now rewrites it. The ledger hashes every file in the folder, so it rewrites more often but never reuses a stale artifact.

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

earakely-scale and others added 2 commits October 17, 2026 23:58
…, 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>
…en_besides), and check folders in accept_toml

bundle_folder becomes written_besides(ctx): the artifacts from_toml writes besides ctx.id, none by default. What a
write reads is every file in its folder, which the ledger hashes; a folder may hold none when the type's toml names
what it's made of (toml_refs), as an environment's file and a universe's environment_artifacts do.

The environment's and the universe's folder rules move into their accept_toml, which the plan runs before anything
is written: one file or none for an environment, and the universe's layout and "needs an environment". Only the
universe overrides written_besides, naming its environments, their files and its metadata files, as read_folder
names them; an environment's <id>__file can't collide, since artifact names can't hold "__". Messages are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale earakely-scale changed the title feat(bundle): ask each artifact type what it reads from its folder, and read a universe's as the inverse of get feat(bundle): write any artifact type from its folder through its own hooks, and read a universe's as the inverse of get Oct 18, 2026
Comment thread src/agent_env/bundle/plan.py Outdated
@greptile-apps

This comment has been minimized.

earakely-scale and others added 5 commits October 18, 2026 08:23
… is written

The plan dropped a BundleError from written_besides, so a plugin type whose hook refuses its folder, and whose
accept_toml doesn't, passed planning and failed partway through the run. The file check now reports it, less any
problem accept_toml already reported, as a universe's does for its layout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…names the environments its folder holds itself

FolderChild carried the environment a written artifact seeds, which put an environment concept in the base artifact
module. It's now an id and its path in the folder. The plan's environment checks (what a load seeds, and names of
their own) ask a type that seeds environments from its folder for seeded_environments(ctx), (path, environment_name)
pairs, which the environment universe answers; a type without it holds none. Messages are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…_environments, and keep type wording out of helpers

An environment artifact answers seeded_environments with its own name, the path "" for its folder, so the plan's
environment checks read no artifact's accept_toml result for a name: what a load seeds, names of their own, and a
referenced bundle environment's name all come through the one capability. Envs keep theirs from env.toml.

AuthoringContext.not_one_file takes its remedy from the caller, FileArtifact naming file_artifact_universe itself,
and no_files words a folder holding nothing in place of exposing the walk's ignore list. A comment in the plan no
longer names a type, and four long lines are wrapped. Messages are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s that need a file say so

The plan's walk refused an artifact folder holding no file unless its type declared toml_refs, which stood in for
"its toml names what it's made of". The walk now accepts an empty folder for every type, and a type whose write
needs a file checks for one in its accept_toml, with the walk's message: a file, a file universe and a CLI. A skill
folder is only one when it holds SKILL.md; an environment and a universe already check their folders. A plugin type
built only from its toml values is written from a folder holding nothing else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ents mixin, not a method name the plan looks up

The plan found seeded_environments by getattr, so nothing declared the contract: a plugin couldn't discover it, and
one that misspelt it lost the seeding checks without a word. SeedsEnvironments, beside EnvironmentArtifact and
exported from agent_env.artifact, is an abstract mixin the environment and the universe declare, and the plan asks
only a type that declares it. A plugin declaring it without the method is skipped when it loads, naming what it
lacks, as for any unimplemented abstract method. The base artifact module still says nothing of environments.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit 7ca617e into main Oct 18, 2026
15 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/fd-3987-universe-metadata-keys branch October 18, 2026 21:17
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