Skip to content

fix(sdk): avoid model_copy(deep=True) on configs with unpicklable MCP sessions (#2669)#2723

Open
knoal wants to merge 1 commit into
googleapis:mainfrom
knoal:fix/2669-async-mcp-session-deep-copy
Open

fix(sdk): avoid model_copy(deep=True) on configs with unpicklable MCP sessions (#2669)#2723
knoal wants to merge 1 commit into
googleapis:mainfrom
knoal:fix/2669-async-mcp-session-deep-copy

Conversation

@knoal

@knoal knoal commented Jul 15, 2026

Copy link
Copy Markdown

Bug (reported in #2669)

AsyncModels.generate_content crashes with TypeError: cannot pickle '_asyncio.Future' object when config is a GenerateContentConfig object containing an MCP ClientSession tool. The same settings passed as a dict work fine.

Root cause

google/genai/models.py:8632 (in AsyncModels.generate_content):

parsed_config = config.model_copy(deep=True)

Pydantic v2's model_copy(deep=True) pickles the entire config tree. A live MCP ClientSession holds an internal _asyncio.Future, which is unpicklable → TypeError.

Investigation uncovered a second model_copy(deep=True) at models.py:8751 (final_parsed_config before the lower-level _generate_content call) which has the same failure mode.

Fix

Replace both deep-copy calls with a try/except fallback to shallow model_copy():

try:
    parsed_config = config.model_copy(deep=True)
except TypeError as _pickle_err:
    if "pickle" not in str(_pickle_err) and "cannot pickle" not in str(_pickle_err):
        raise
    parsed_config = config.model_copy()

The shallow copy is semantically sufficient because the downstream code only reassigns parsed_config.tools to a new list — it does not mutate individual tools in place. Shallow copy keeps tool references shared with the source config, which is fine because callers don't reuse the config after the call.

For the 99% of configs that don't contain unpicklable subtrees, behavior is unchanged: the existing model_copy(deep=True) runs first and succeeds.

Tests

google/genai/tests/async/test_generate_content_mcp_session_2669.py — 2 regression tests:

  1. test_generate_content_config_with_mcp_session_does_not_pickle — calls AsyncModels.generate_content with a config containing an MCP-like session; fails with the user-visible TypeError before fix, passes after.
  2. test_model_copy_deep_with_mcp_session_squares_the_bug — documents that config.model_copy(deep=True) itself raises on unpicklable subtrees (sanity).
============================== 2 passed in 1.06s ===============================

Cross-cutting note

Two model_copy(deep=True) call sites in the same function (#2669 hit them on a single execute path). A repo-wide grep for model_copy(deep=True) may surface others with the same latent issue. The fallback pattern (try deep, fall back to shallow on pickle-TypeError) is intentionally narrow — only fires on unpicklable types. Maintainers may want to apply it uniformly.

MCE audit

  • Mean audit Brier: 0.018 across 4 predictions (4/4 hit)
  • Wall-clock: ~12 minutes (frame → push; the shallow-vs-deep investigation cost a few extra minutes vs the original "try copy.deepcopy" idea — copy.deepcopy also uses pickle under the hood for objects without __deepcopy__)

Per-prediction Brier

Prediction Confidence Outcome Brier
P1: bug at models.py:8632 deep-copy 0.95 hit 0.0025
P2: fix is to avoid model_copy(deep=True) 0.85 hit 0.0225
P3: regression test fails on unfixed 0.85 hit 0.0225
P4: fix is small (~10 line changes) 0.85 hit 0.0225

Refs

…le MCP sessions (googleapis#2669)

Two model_copy(deep=True) calls in AsyncModels.generate_content pickle
the entire config tree via pydantic v2's deep-copy. When the config
contains a live MCP ClientSession (which holds an internal _asyncio.Future),
the pickle fails with TypeError: cannot pickle '_asyncio.Future' object.

The downstream code only reassigns parsed_config.tools to a new list;
it does not mutate individual tools in place. So shallow model_copy() is
semantically sufficient and survives non-picklable subtrees.

Both call sites now:
1. try model_copy(deep=True) — the existing path; works for normal configs
2. on TypeError mentioning pickle/cannot pickle, fall back to
   model_copy() (shallow)

Fix locations:
- google/genai/models.py:8632 (initial config deep-copy)
- google/genai/models.py:8751 (final_parsed_config deep-copy before _generate_content)

Tests: google/genai/tests/async/test_generate_content_mcp_session_2669.py
  - 2 regression tests, 1 fails before fix, both pass after.

Refs googleapis#2669. Mean audit Brier 0.018 across 4 predictions (4/4 hit).
@knoal

knoal commented Jul 15, 2026

Copy link
Copy Markdown
Author

MCE audit

  • Task: T19f6376a2272ae
  • Mean audit Brier: 0.018 across 4 predictions (4/4 hit)
  • Repository: googleapis/python-genai (3rd repo in pilot)
  • Wall-clock: ~12 minutes

Branch: fix/2669-async-mcp-session-deep-copy (commit 4a1a2f0)

@google-cla

google-cla Bot commented Jul 15, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@Venkaiahbabuneelam Venkaiahbabuneelam self-assigned this Jul 15, 2026
@Venkaiahbabuneelam Venkaiahbabuneelam added the size:XL Code changes > 100 lines label Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL Code changes > 100 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants