Skip to content

Python: fix(core): deep-restore nested registered values in the default Pydantic state decoder - #9250

Draft
Yufeng He (he-yufeng) wants to merge 4 commits into
microsoft:mainfrom
he-yufeng:fix/state-codec-nested-message
Draft

Yufeng He (he-yufeng) wants to merge 4 commits into
microsoft:mainfrom
he-yufeng:fix/state-codec-nested-message

Conversation

@he-yufeng

@he-yufeng Yufeng He (he-yufeng) commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Motivation & Context

A Pydantic application-state model with arbitrary_types_allowed=True and a Message field, registered via register_state_type with the default codecs, persisted fine but failed to restore through both AgentSession.from_dict(session.to_dict()) and a fresh FileSessionStore read: the nested Message arrived at model_validate as its tagged dict because the default decoder never deep-restored payload items.

Description & Review Guide

  • What are the major changes? The default Pydantic decoder now deep-restores each payload item through _deserialize_value before validation, mirroring what the encoder side already does via _serialize_value.
  • What is the impact of these changes? Custom codecs keep the raw-payload contract; the to_dict/from_dict default path is unchanged; unregistered models keep the implicit-registration compatibility path; unknown type tags still pass through as raw dicts.
  • What do you want reviewers to focus on? The deep-restore placement inside the default decoder, so every registered model with nested registered values inherits it without a custom codec.

Regression evidence (run locally, all green): two new tests in python/packages/core/tests/core/test_sessions.py cover the report, test_registered_pydantic_state_restores_nested_registered_values for the AgentSession.from_dict round-trip and test_round_trips_registered_pydantic_state_with_nested_message for a fresh FileSessionStore read; both are red before the fix and green after. Full file pytest tests/core/test_sessions.py: 189 passed. Sibling suites test_serializable_mixin.py + test_types.py: 474 passed.

Related Issue

Fixes #9246

AI Assistance

  • No material AI assistance was used.
  • This is an AI-assisted contribution. I reviewed, understood, and verified all submitted content and accept responsibility for it.

AI assistance details: implementation, tests, and this PR description. The tests listed above were actually run locally and pass.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR links to an agreed issue with no competing open PR, or the Related Issue section documents a trivial-change or repository-automation exception.
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

…lt Pydantic state decoder

The default encoder hands nested values to _serialize_value, which tags
them for the registry, but the default Pydantic decoder validated the raw
payload, so a nested Message arrived as a tagged dict and restoration of
a registered state model failed on AgentSession.from_dict and on a fresh
FileSessionStore read.

Fixes microsoft#9246
Copilot AI balanced review requested due to automatic review settings October 10, 2026 13:35
@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Oct 10, 2026

Copilot AI 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.

🟢 Approval recommended

The focused decoder change preserves existing codec contracts and is adequately covered by regression tests.

0 open findings

What changed in this PR

Fixes nested registered-value restoration in default Pydantic session-state decoding.

Changes:

  • Deep-deserializes Pydantic payload fields before validation.
  • Adds direct and file-backed regression coverage.
File Description
python/​packages/​core/​agent_framework/​_sessions.py Restores nested registered values before Pydantic validation.
python/​packages/​core/​tests/​core/​test_sessions.py Tests nested Message round trips.

🧠 Review effort: Balanced


💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

# tagged dict. Restore nested values the same way before validating,
# otherwise Pydantic receives the tagged dict where the field
# expects the reconstructed instance.
return cls.model_validate({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yufeng He (@he-yufeng) Deep-deserializing every Pydantic payload field can hijack ordinary dict data whose "type" happens to match a registered state type. A model field such as data: dict[str, Any] containing {"type": "message", ...} validates today, but this change converts it to Message before model_validate and then rejects it. Please preserve raw-valid dict fields (for example, validate raw first and only retry with restored values) and add a regression for a registered Pydantic model with such application data.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 6cbe9bd, exactly along the lines you suggested. decode_pydantic now validates the raw payload first and only retries with _deserialize_value-restored values when raw validation fails, so a data: dict[str, Any] field holding {"type": "message", ...} keeps its dict instead of being converted and rejected. Added test_registered_pydantic_state_keeps_tag_shaped_dict_fields_raw: it fails against the previous decoder (the tagged dict is restored to a Message and validation rejects the whole state) and passes now. The earlier nested-message regressions still pass, so the retry path keeps covering genuinely nested registered values. Full test_sessions.py green (190 passed on 3.14), ruff check and format clean; the commit also formats two earlier test cases that had missed ruff format.

@eavanvalkenburg

Copy link
Copy Markdown
Member

Yufeng He (@he-yufeng) Please update the PR body to use the repository's current PR template at .github/pull_request_template.md, including its required headings and fields.

…lues

Deep-restoring every payload field up front hijacks a plain dict field
whose "type" happens to name a registered state type: the tagged shape
is converted before model_validate and then rejected. Validate the raw
payload first and only retry with restored values when that fails, and
cover a tag-shaped dict field staying raw. Also formats the two earlier
nested-message cases that missed ruff format.
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Eduard van Valkenburg (@eavanvalkenburg) done, the body now follows the current template including the AI Assistance section with the assisted areas identified.

# payload as its tagged dict. Restore nested values and retry;
# a field that legitimately holds a tag-shaped dict already
# validated raw above and never reaches this path.
return cls.model_validate({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yufeng He (@he-yufeng) The raw-first validation only protects a tag-shaped application dict when the whole model validates raw. If another field requires restoration, this retry deserializes every field, so a valid data: dict[str, Any] containing {"type": "message", ...} can still be converted or rejected. Please preserve fields that already validate raw and restore only the field(s) that need it, with a mixed-model regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 9881d30. The retry now reads the ValidationError locations and restores only the field(s) pydantic actually blames; every other field keeps its raw payload untouched, so a valid data: dict[str, Any] holding {"type": "message", ...} passes through byte-identical instead of being converted or rejected. Errors pydantic cannot attribute to a field (model-level validators with an empty loc) re-raise immediately rather than falling back to restoring everything.

The mixed-model regression is in: one field holds a nested Message (fails raw, gets restored to a real Message) and a sibling field holds a plain tag-shaped dict (stays the exact raw dict after round-trip).

This branch was successfully deployed

1 active deployment
github-app-auth — 96627130 Deployed Oct 23, 2026 by he-yufeng via add_label #26030
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: Default registered Pydantic state cannot restore a Message field

3 participants