Skip to content

Python: [Bug]: SerializationMixin.from_dict() mutates caller input when merging dictionary dependencies #7899

Description

Description

SerializationMixin.from_dict() unexpectedly mutates the caller-provided input dictionary when a dictionary-shaped dependency is merged into an existing dictionary field.

The issue is caused by a shallow copy of the input dictionary:

kwargs = {k: v for k, v in value.items() if k != "type"}

While kwargs is a new outer dictionary, nested dictionaries are still shared with the original value.

When a dictionary dependency is subsequently merged using .update(), the nested dictionary in the caller's original input is modified as a side effect.

This occurs in both dictionary dependency merge paths within SerializationMixin.from_dict().

The problem is especially visible when the same serialized specification is reused for multiple from_dict() calls. Data injected during an earlier reconstruction can remain in the original specification and affect later reconstructions.

from_dict() should construct the object without unexpectedly modifying the caller's input data.

Code Sample

from copy import deepcopy

shared_value = {
    "type": "foo",
    "name": "bar",
    "config": {
        "a": 1,
    },
}

original_value = deepcopy(shared_value)

Foo.from_dict(
    shared_value,
    dependencies={
        "foo": {
            "config": {
                "b": 2,
            },
        },
    },
)

print(shared_value)


The input dictionary is unexpectedly changed to:


{
    "type": "foo",
    "name": "bar",
    "config": {
        "a": 1,
        "b": 2,
    },
}


The caller's input should instead remain unchanged:


assert shared_value == original_value


The underlying aliasing can be demonstrated with:


kwargs = {k: v for k, v in shared_value.items() if k != "type"}

assert kwargs["config"] is shared_value["config"]


Therefore, the following operation:


kwargs["config"].update({"b": 2})


also modifies:


shared_value["config"]


A repeated reconstruction demonstrates the potential state leakage:


spec = {
    "type": "foo",
    "config": {
        "base": True,
    },
}

Foo.from_dict(
    spec,
    dependencies={
        "foo": {
            "config": {
                "first": True,
            },
        },
    },
)

second = Foo.from_dict(
    spec,
    dependencies={
        "foo": {
            "config": {
                "second": True,
            },
        },
    },
)


After the first call, `spec["config"]` has already been modified with `"first": True`.

Consequently, the second reconstruction can observe state originating from the first call, even though the second call only supplied `"second": True`.

Error Messages / Stack Traces

No exception or warning is raised.

Package Versions

agent-framework-core: 1.15.0

Python Version

Python 3.13.15

Additional Context

The root cause is the combination of a shallow copy and an in-place update of nested dictionaries.

The input is initially copied using:

kwargs = {k: v for k, v in value.items() if k != "type"}

This only creates a new top-level dictionary. Nested mutable values remain aliased with the caller's input.

The first affected merge path performs:

kwargs[dep_key].update(dep_value)

The second affected merge path performs:

kwargs[param_name].update(param_value)

In both cases, the existing nested dictionary can still belong to the caller's original input.

The existing behavior appears to intentionally merge dependency dictionaries rather than replace them. Therefore, the proposed fix should preserve that merge behavior while avoiding mutation of the existing dictionary.

A minimal approach is to replace the in-place updates with non-mutating dictionary merges:

kwargs[dep_key] = {**kwargs[dep_key], **dep_value}

and:

kwargs[param_name] = {**kwargs[param_name], **param_value}

This preserves the existing precedence behavior while ensuring that the original nested dictionary is not modified.

For example:

existing = {
    "a": 1,
    "b": 2,
}

dependency = {
    "b": 3,
    "c": 4,
}

merged = {
    **existing,
    **dependency,
}

assert merged == {
    "a": 1,
    "b": 3,
    "c": 4,
}

assert existing == {
    "a": 1,
    "b": 2,
}

I believe regression tests should cover both affected dependency merge paths and should specifically verify that:

  • the caller's input dictionary remains unchanged;
  • nested dictionaries are not mutated;
  • dictionary merge precedence remains unchanged;
  • repeated from_dict() calls using the same input do not leak state from one reconstruction into another.

The issue does not appear to require a broad deepcopy() of the entire serialized input. A targeted non-mutating merge addresses the actual mutation points while keeping the behavioral change small and localized.

Metadata

Metadata

Labels

agentsUsage: [Issues, PRs], Target: Single agentpythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions