Repository navigation
fix: default dataclass metadata to a dict, not a pydantic FieldInfo - #1324
Merged
jayscambler merged 2 commits intoSep 8, 2026
Merged
jayscambler merged 2 commits into
jayscambler merged 2 commits into
Conversation
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
approved these changes
Sep 8, 2026
jayscambler
left a comment
Contributor
There was a problem hiding this comment.
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.
11 of 14 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
BasinCandidateandScenarioRoutingContextare stdlib@dataclass(slots=True)classes, but both declared theirmetadatafield asdict[str, Any] = Field(default_factory=dict)using pydantic'sField. Pydantic only interpretsFieldinside aBaseModel. A stdlib dataclass keeps whatever the expression returns as the literal default, so every instance built without an explicitmetadataargument got theFieldInfoobject itself, the same one shared across all instances, where the annotation promises a dict.dataclasses.field(default_factory=dict). Nothing else changes.The bug
autocontext/src/autocontext/loop/exploration.py:163andautocontext/src/autocontext/providers/scenario_routing.py:31. Onmain:That default has no mapping API.
autocontext/src/autocontext/loop/stage_helpers/exploration.py:269callsbranch.metadata.get("note", "")and line 296 callsdict(branch.metadata)on every non-conservative basin branch, so aBasinCandidatethat reaches those lines without an explicitmetadataraisesAttributeError: 'FieldInfo' object has no attribute 'get'instead of reading an absent note.ScenarioRoutingContext.metadatais not read byresolve_provider_for_context, so nothing in tree touches that one yet.The two in-tree sites that build a non-conservative
BasinCandidateboth passmetadataexplicitly, so this is a wrong default and a latent crash rather than a live one. mypy is blind to it becauseField()is typedAny, which is whymypy srchas been clean over both lines.The fix
Both modules already import
BaseModelandFieldfor 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 importdataclasses.fieldandpydantic.Fieldside 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/srcfound these two classes and no others mixing the two APIs, so this is the whole of it.Surfaces Touched
ts/has no pydantic, so there is no cross-runtime parity work to do or deferVerification
cd autocontext && uv run ruff check src testsgivesAll checks passed!cd autocontext && uv run mypy srcgivesSuccess: no issues found in 916 source filescd autocontext && uv run pytest tests/test_exploration_mechanisms.py tests/test_scenario_routing.py tests/test_training_backend.pygives60 passedcd autocontext && uv run pytest tests -m "not live" -k "explor or routing or basin"gives212 passed, 3 skipped, 10867 deselectedcd ts && npm run lintcd ts && npm testBoth new tests fail on unmodified
mainwithAssertionError: 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 --checkgivesProtocol artifacts are in sync.anduv run python ../scripts/check_markdown_links.pygivesMarkdown link check passed: 257 local link(s) across 130 Markdown files.Everything above ran on CPython 3.11.15, the version the
ciworkflow pins. One test intests/test_model_router_integration.pyis excluded from the count above because it fails on this machine for an unrelated reason, and fails the same way on14641042c0fbf09464ab88819571ee74554dada0without this change:test_orchestrator_resolve_model_uses_harness_coverage, which logsskipping harness 'preflight_synthesized': isolation unavailableand 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 public command, environment variable, package path, or agent-facing workflow changes, so none of the documentation touch points in
CONTRIBUTING.mdapply.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.