Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5d65776
fix: optimize function_copy to avoid unnecessary deepcopy
nimanikoo Feb 27, 2026
9e93880
Merge branch 'main' into fix/optimize-function-copy-deepcopy
nimanikoo Feb 27, 2026
2e65593
Update python/semantic_kernel/functions/kernel_function.py
nimanikoo Mar 24, 2026
7e062b6
Merge branch 'main' into fix/optimize-function-copy-deepcopy
nimanikoo Mar 24, 2026
e50fbd0
Update python/semantic_kernel/functions/kernel_function.py
nimanikoo Mar 24, 2026
9fa1db6
Update python/tests/unit/functions/test_function_copy_optimization.py
nimanikoo Mar 24, 2026
a7bcbbc
Merge branch 'main' into fix/optimize-function-copy-deepcopy
nimanikoo Mar 24, 2026
4bb0c42
Update python/tests/unit/functions/test_function_copy_optimization.py
nimanikoo Mar 24, 2026
e84eb17
Update python/tests/unit/functions/test_function_copy_optimization.py
nimanikoo Mar 24, 2026
8ef520b
Merge branch 'main' into fix/optimize-function-copy-deepcopy
nimanikoo Apr 5, 2026
8296e42
Fixup errors on pre commit
nimanikoo Apr 5, 2026
a3d3d71
Merge branch 'main' into fix/optimize-function-copy-deepcopy
nimanikoo Apr 17, 2026
083b49f
Apply ruff formatting fixes
nimanikoo Apr 22, 2026
56934fd
Merge branch 'main' into fix/optimize-function-copy-deepcopy
nimanikoo Apr 22, 2026
e4f3ef7
Merge branch 'main' into fix/optimize-function-copy-deepcopy
nimanikoo Apr 23, 2026
1d829d6
Fixup
nimanikoo Apr 24, 2026
9fafdf8
Merge branch 'main' into fix/optimize-function-copy-deepcopy
nimanikoo Apr 28, 2026
337d621
Merge branch 'main' into fix/optimize-function-copy-deepcopy
nimanikoo May 14, 2026
bbbfd84
Merge branch 'main' into fix/optimize-function-copy-deepcopy
eavanvalkenburg Sep 28, 2026
c420c52
fix: preserve plugin_name validation in function_copy
nimanikoo Sep 28, 2026
cfa771a
Merge branch 'main' into fix/optimize-function-copy-deepcopy
nimanikoo Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions python/semantic_kernel/functions/kernel_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,19 +379,25 @@ async def invoke_stream(
KernelFunctionLogMessages.log_function_streaming_completed(logger, duration)

def function_copy(self, plugin_name: str | None = None) -> "KernelFunction":
"""Copy the function, can also override the plugin_name.
"""Copy the function and optionally override the plugin name.

Metadata is always deep-copied so each function copy owns an independent
metadata instance, even when the plugin name does not change.

Args:
plugin_name (str): The new plugin name.
plugin_name (str | None): The new plugin name, or None to preserve the current one.

Returns:
KernelFunction: The copied function.
"""
cop: KernelFunction = copy(self)
cop.metadata = deepcopy(self.metadata)
if plugin_name:
cop.metadata.plugin_name = plugin_name
return cop
copied_function: KernelFunction = copy(self)
# Keep metadata isolated across copies to avoid shared mutable state.
copied_function.metadata = self.metadata.model_copy(deep=True)
if plugin_name is not None:
# Assign normally (rather than via model_copy(update=...)) so that
# validate_assignment=True runs and rejects an invalid plugin name.
copied_function.metadata.plugin_name = plugin_name
return copied_function

def _handle_exception(self, current_span: trace.Span, exception: Exception, attributes: dict[str, str]) -> None:
"""Handle the exception.
Expand Down
110 changes: 110 additions & 0 deletions python/tests/unit/functions/test_function_copy_optimization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Copyright (c) Microsoft. All rights reserved.

"""Tests for function_copy metadata copy behavior."""

from unittest.mock import patch

import pytest

from semantic_kernel.functions import kernel_function


@pytest.fixture
def sample_function():
@kernel_function
def test_func(input: str) -> str:
return f"Result: {input}"

from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod

return KernelFunctionFromMethod(method=test_func, plugin_name="test_plugin")


class TestFunctionCopyOptimization:
"""Test suite for function_copy metadata isolation."""

def test_function_copy_same_plugin_creates_independent_metadata(self, sample_function):
"""Test that function_copy isolates metadata even when plugin_name is unchanged."""
original_plugin = sample_function.plugin_name

# Case 1: No plugin_name provided (metadata should still be copied)
copy1 = sample_function.function_copy()
assert copy1.metadata is not sample_function.metadata
assert copy1.plugin_name == original_plugin

# Case 2: Same plugin_name explicitly provided (metadata should still be copied)
copy2 = sample_function.function_copy(original_plugin)
assert copy2.metadata is not sample_function.metadata
assert copy2.plugin_name == original_plugin

def test_function_copy_different_plugin_creates_copy(self, sample_function):
"""Test that function_copy does create a shallow copy when plugin_name changes.

This tests that when we actually need to change the plugin_name,
a shallow copy is created.
"""
new_plugin_name = "new_plugin"
copy = sample_function.function_copy(new_plugin_name)

# Metadata should be different object (copied)
assert copy.metadata is not sample_function.metadata
# But should have the new plugin_name
assert copy.metadata.plugin_name == new_plugin_name
# Original should be unchanged
assert sample_function.metadata.plugin_name != new_plugin_name

def test_function_copy_preserves_function_behavior(self, sample_function):
"""Test that copied function still works correctly."""
copy = sample_function.function_copy()

# Verify function metadata is preserved
assert copy.name == sample_function.name
Comment thread
moonbox3 marked this conversation as resolved.
assert copy.description == sample_function.description
# Verify function is callable (indirectly through having same underlying function)
assert hasattr(copy, "invoke")

@patch(
"semantic_kernel.functions.kernel_function.deepcopy",
side_effect=AssertionError("deepcopy should not be called"),
)
def test_function_copy_no_module_deepcopy_usage(self, mock_deepcopy, sample_function):
"""Test that function_copy does not call the module-level deepcopy helper."""
original_metadata = sample_function.metadata
copy = sample_function.function_copy()

assert copy.metadata is not original_metadata
mock_deepcopy.assert_not_called()

def test_function_copy_rejects_invalid_plugin_name(self, sample_function):
"""Test that function_copy still validates the plugin name on assignment.

model_copy(update=...) silently skips validation, so this guards against
that regression by asserting an invalid plugin name is rejected.
"""
from pydantic import ValidationError

with pytest.raises(ValidationError):
sample_function.function_copy("bad-name")

# Original metadata must remain untouched by the failed copy attempt.
assert sample_function.metadata.plugin_name == "test_plugin"

def test_function_copy_rejects_empty_plugin_name(self, sample_function):
"""Test that an empty string plugin name is rejected, not silently accepted."""
from pydantic import ValidationError

with pytest.raises(ValidationError):
sample_function.function_copy("")

def test_function_copy_multiple_calls_same_plugin_are_isolated(self, sample_function):
"""Test that multiple copies with same plugin keep independent metadata."""
copy1 = sample_function.function_copy()
copy2 = sample_function.function_copy()
copy3 = sample_function.function_copy(sample_function.plugin_name)

assert copy1.metadata is not sample_function.metadata
assert copy2.metadata is not sample_function.metadata
assert copy3.metadata is not sample_function.metadata
assert copy1.metadata is not copy2.metadata
assert copy2.metadata is not copy3.metadata
assert copy1.metadata is not copy3.metadata
Loading