Skip to content

fix: default dataclass metadata to a dict, not a pydantic FieldInfo - #1324

Merged
jayscambler merged 2 commits into
greyhaven-ai:mainfrom
MaxFreedomPollard:fix/dataclass-metadata-dict-default
Sep 8, 2026
Merged

jayscambler merged 2 commits into
greyhaven-ai:mainfrom
MaxFreedomPollard:fix/dataclass-metadata-dict-default

Conversation

@MaxFreedomPollard

@MaxFreedomPollard MaxFreedomPollard commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • BasinCandidate and ScenarioRoutingContext are stdlib @dataclass(slots=True) classes, but both declared their metadata field as dict[str, Any] = Field(default_factory=dict) using pydantic's Field. Pydantic only interprets Field inside a BaseModel. A stdlib dataclass keeps whatever the expression returns as the literal default, so every instance built without an explicit metadata argument got the FieldInfo object itself, the same one shared across all instances, where the annotation promises a dict.
  • Both now use dataclasses.field(default_factory=dict). Nothing else changes.

The bug

autocontext/src/autocontext/loop/exploration.py:163 and autocontext/src/autocontext/providers/scenario_routing.py:31. On main:

>>> BasinCandidate(branch_type="divergent", playbook="", lessons="L", temperature=0.7).metadata
FieldInfo(annotation=NoneType, required=False, default_factory=dict)
>>> ScenarioRoutingContext(scenario="grid_ctf").metadata is ScenarioRoutingContext(scenario="othello").metadata
True

That default has no mapping API. autocontext/src/autocontext/loop/stage_helpers/exploration.py:269 calls branch.metadata.get("note", "") and line 296 calls dict(branch.metadata) on every non-conservative basin branch, so a BasinCandidate that reaches those lines without an explicit metadata raises AttributeError: 'FieldInfo' object has no attribute 'get' instead of reading an absent note. ScenarioRoutingContext.metadata is not read by resolve_provider_for_context, so nothing in tree touches that one yet.

The two in-tree sites that build a non-conservative BasinCandidate both pass metadata explicitly, so this is a wrong default and a latent crash rather than a live one. mypy is blind to it because Field() is typed Any, which is why mypy src has been clean over both lines.

The fix

Both modules already import BaseModel and Field for the pydantic models next door (BranchRecord, RoutingDecision, PiModelHandoff, PiExecutionTrace), so only the two dataclass defaults change and both imports stay. Five modules in the package already import dataclasses.field and pydantic.Field side by side: training/model_registry.py, harness/scoring/backends.py, execution/trajectory_harness.py, execution/agent_task_evolution.py, scenarios/custom/spec.py.

An AST sweep over all 916 files in autocontext/src found these two classes and no others mixing the two APIs, so this is the whole of it.

Surfaces Touched

  • Parity decision: nothing here is user-visible and ts/ has no pydantic, so there is no cross-runtime parity work to do or defer
  • Python package
  • TypeScript package
  • TUI
  • Docs or examples
  • CI or release metadata

Verification

  • cd autocontext && uv run ruff check src tests gives All checks passed!
  • cd autocontext && uv run mypy src gives Success: no issues found in 916 source files
  • cd autocontext && uv run pytest tests/test_exploration_mechanisms.py tests/test_scenario_routing.py tests/test_training_backend.py gives 60 passed
  • cd autocontext && uv run pytest tests -m "not live" -k "explor or routing or basin" gives 212 passed, 3 skipped, 10867 deselected
  • cd ts && npm run lint
  • cd ts && npm test
  • additional manual verification described below

Both new tests fail on unmodified main with AssertionError: assert FieldInfo(annotation=NoneType, required=False, default_factory=dict) == {} and pass with the change. They assert the default is {}, answers .get("note", ""), and is a fresh object per instance, so a shared mutable default would fail them too.

The other two lint-job steps also pass: uv run python ../scripts/generate_protocol.py --check gives Protocol artifacts are in sync. and uv run python ../scripts/check_markdown_links.py gives Markdown link check passed: 257 local link(s) across 130 Markdown files.

Everything above ran on CPython 3.11.15, the version the ci workflow pins. One test in tests/test_model_router_integration.py is excluded from the count above because it fails on this machine for an unrelated reason, and fails the same way on 14641042c0fbf09464ab88819571ee74554dada0 without this change: test_orchestrator_resolve_model_uses_harness_coverage, which logs skipping harness 'preflight_synthesized': isolation unavailable and then resolves to the wrong model tier. Local sandboxing is not available here.

TypeScript was not run: no file under ts/ is touched.

Docs And Release Impact

  • no user-facing docs changes needed

No public command, environment variable, package path, or agent-facing workflow changes, so none of the documentation touch points in CONTRIBUTING.md apply.

Notes

Behaviour is unchanged for every caller that already passes metadata, which is both in-tree construction sites that reach the consuming code. Python only.

BasinCandidate (loop/exploration.py:163) and ScenarioRoutingContext
(providers/scenario_routing.py:31) are stdlib @DataClass(slots=True)
classes, but both declared `metadata: dict[str, Any] =
Field(default_factory=dict)` using pydantic's Field. Pydantic's Field is
only interpreted by BaseModel; a stdlib dataclass stores whatever it
returns as the literal default, so every instance built without an
explicit metadata argument got the FieldInfo object itself, shared across
all instances, where a dict was annotated.

That default has no mapping API. loop/stage_helpers/exploration.py:268
calls `branch.metadata.get("note", "")` on every non-conservative basin
branch, so a BasinCandidate constructed without metadata raises
AttributeError there instead of reading an absent note. mypy does not
catch it because Field() is typed Any.

Both files already import BaseModel and Field for their pydantic models
in the same module, so only the two dataclass fields change, to
dataclasses.field(default_factory=dict). Five other modules in the
package already import both names side by side.

Regression tests assert the default is an empty dict, supports .get, and
is a fresh object per instance. Both fail on main with
"assert FieldInfo(annotation=NoneType, required=False,
default_factory=dict) == {}".

@jayscambler jayscambler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed this exact four-file dataclass default fix. The standard-library dataclasses now use field(default_factory=dict), while neighboring Pydantic fields remain intact. Both new regression cases fail against the original source; all 60 focused tests pass with the fix. There are no dependency, workflow, permission, or network changes. The newly authorized CI run is blocked by the existing fast-uri/qs dependency audit baseline; merging remains gated on resolving that baseline and passing current-base checks.

@jayscambler
jayscambler merged commit bb450dc into greyhaven-ai:main Sep 8, 2026
25 checks passed
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.

2 participants