Skip to content

Commit d8af1ed

Browse files
authored
Python: Safely clone plugins (#13109)
### Motivation and Context Currently, we attempt to deep copy plugins, no matter what the plugin instance holds. This will cause issues when plugins contain unpickleable items like an async generator. <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> ### Description - Closes #12819 <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [X] I didn't break anyone 😄
1 parent 1404a72 commit d8af1ed

File tree

2 files changed

+60
-1
lines changed

2 files changed

+60
-1
lines changed

python/semantic_kernel/kernel.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,9 +543,23 @@ def clone(self) -> "Kernel":
543543
New lists of plugins and filters are created. It will not affect the original lists when the new instance
544544
is mutated. A new `ai_service_selector` is created. It will not affect the original instance when the new
545545
instance is mutated.
546+
547+
Important: Plugins are cloned without deep-copying their underlying callable methods. This avoids attempting
548+
to pickle/clone unpickleable objects (e.g., async generators), which can be present when plugins wrap async
549+
context managers such as MCP client sessions. Function metadata is deep-copied while callables are shared.
546550
"""
551+
# Safely clone plugins by copying function metadata while retaining callable references.
552+
# This avoids deepcopying bound methods that may reference unpickleable async components.
553+
new_plugins: dict[str, KernelPlugin] = {}
554+
for plugin_name, plugin in self.plugins.items():
555+
cloned_plugin = KernelPlugin(name=plugin.name, description=plugin.description)
556+
# Using KernelPlugin.add will copy functions via KernelFunction.function_copy(),
557+
# which deep-copies metadata but keeps callables shallow.
558+
cloned_plugin.add(plugin.functions)
559+
new_plugins[plugin_name] = cloned_plugin
560+
547561
return Kernel(
548-
plugins=deepcopy(self.plugins),
562+
plugins=new_plugins,
549563
# Shallow copy of the services, as they are not serializable
550564
services={k: v for k, v in self.services.items()},
551565
ai_service_selector=deepcopy(self.ai_service_selector),

python/tests/unit/kernel/test_kernel.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import tempfile
55
from collections.abc import Callable
6+
from copy import deepcopy
67
from dataclasses import dataclass
78
from pathlib import Path
89
from typing import Union
@@ -361,6 +362,50 @@ async def test_kernel_invoke_deep_copy_preserves_previous_state():
361362
assert snapshot2[-1] == {"id": 4, "name": "Desk lamp", "is_on": True}
362363

363364

365+
# region Clone safety with unpickleable async context
366+
367+
368+
class _AsyncGenPlugin:
369+
"""Plugin holding an async generator to emulate MCP-style async internals.
370+
371+
Deep-copying objects that reference async generators typically fails with
372+
`TypeError: cannot pickle 'async_generator' object`.
373+
"""
374+
375+
def __init__(self):
376+
async def _agen():
377+
yield "tick"
378+
379+
# Store an async generator object on the instance to make it unpickleable.
380+
self._unpickleable_async_gen = _agen()
381+
382+
@kernel_function(name="do", description="Return OK to validate plugin wiring")
383+
async def do(self) -> str:
384+
return "ok"
385+
386+
387+
@pytest.mark.asyncio
388+
async def test_kernel_clone_with_unpickleable_plugin_does_not_raise():
389+
kernel = Kernel()
390+
plugin_instance = _AsyncGenPlugin()
391+
kernel.add_plugin(plugin_instance)
392+
393+
# Sanity: naive deepcopy of plugins should raise due to async generator state
394+
with pytest.raises(TypeError):
395+
deepcopy(kernel.plugins)
396+
397+
# Clone should succeed and preserve function usability
398+
cloned = kernel.clone()
399+
400+
func = cloned.get_function(plugin_instance.__class__.__name__, "do")
401+
result = await func.invoke(cloned)
402+
assert result is not None
403+
assert result.value == "ok"
404+
405+
406+
# endregion
407+
408+
364409
async def test_invoke_function_call_throws_during_invoke(kernel: Kernel, get_tool_call_mock):
365410
tool_call_mock = get_tool_call_mock
366411
result_mock = MagicMock(spec=ChatMessageContent)

0 commit comments

Comments
 (0)