mirror of
https://github.com/microsoft/semantic-kernel.git
synced 2026-10-12 02:19:06 +00:00
Python: Deep-copy function_result.value in invoke_function_call to prevent state mutation (#11826)
### Motivation and Context
The existing implementation of `kernel.invoke_function_call(...)` stores
mutable tool return values by reference. Subsequent mutations to the
same in-memory state bleed into earlier chat messages, which corrupts
conversation history and breaks reproducibility. By deep-copying the
function result immediately after execution, we can guarantee an
immutable snapshot of each tool's output at the time of invocation,
which preserves previous tool invocations and removes unwanted side
effects.
<!-- 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
After `await stack(invocation_context)` in
`kernel.invoke_function_call(...)`, we now have:
```python
if invocation_context.function_result and invocation_context.function_result.value is not None:
invocation_context.function_result.value = copy.deepcopy(
invocation_context.function_result.value
)
```
This has no change to our public API or user-facing behavior; internal
state handling is now robust against undesired mutations.
- Adds a unit test to exercise the new behavior and assert on the proper
mutations/states.
<!-- 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 😄
This commit is contained in:
@@ -406,6 +406,10 @@ class Kernel(KernelFilterExtension, KernelFunctionExtension, KernelServicesExten
|
||||
)
|
||||
await stack(invocation_context)
|
||||
|
||||
# Snapshot the tool's return value so later mutations don't leak back
|
||||
if invocation_context.function_result and invocation_context.function_result.value is not None:
|
||||
invocation_context.function_result.value = deepcopy(invocation_context.function_result.value)
|
||||
|
||||
frc = FunctionResultContent.from_function_call_content_and_result(
|
||||
function_call_content=function_call,
|
||||
result=invocation_context.function_result,
|
||||
|
||||
@@ -296,6 +296,70 @@ async def test_invoke_function_call(kernel: Kernel, get_tool_call_mock):
|
||||
)
|
||||
|
||||
|
||||
class LightsPlugin:
|
||||
lights = [
|
||||
{"id": 1, "name": "Table Lamp", "is_on": False},
|
||||
{"id": 2, "name": "Porch light", "is_on": False},
|
||||
{"id": 3, "name": "Chandelier", "is_on": False},
|
||||
]
|
||||
|
||||
@kernel_function(
|
||||
name="get_lights_states",
|
||||
description="Gets a list of lights and their current state",
|
||||
)
|
||||
def get_lights_states(self) -> list[dict]:
|
||||
"""Return a list of lights and their states (read-only)."""
|
||||
return self.lights # returns by reference, but deep-copy fix will snapshot it
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kernel_invoke_deep_copy_preserves_previous_state():
|
||||
kernel = Kernel()
|
||||
plugin = LightsPlugin()
|
||||
kernel.add_plugin(plugin, plugin_name="lights")
|
||||
|
||||
func_call = FunctionCallContent(
|
||||
plugin_name="lights", function_name="get_lights_states", name="get_lights_states", arguments={}
|
||||
)
|
||||
|
||||
chat_history = ChatHistory()
|
||||
|
||||
# 1st invocation: capture snapshot of the list
|
||||
_ = await kernel.invoke_function_call(
|
||||
function_call=func_call,
|
||||
chat_history=chat_history,
|
||||
arguments=None,
|
||||
function_call_count=1,
|
||||
request_index=0,
|
||||
function_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
snapshot1 = chat_history.messages[-1].items[0].result
|
||||
|
||||
# Mutate the plugin's internal state after the first call
|
||||
plugin.lights.append({"id": 4, "name": "Desk lamp", "is_on": True})
|
||||
|
||||
# The snapshot from the first invocation did not change
|
||||
assert snapshot1 == [
|
||||
{"id": 1, "name": "Table Lamp", "is_on": False},
|
||||
{"id": 2, "name": "Porch light", "is_on": False},
|
||||
{"id": 3, "name": "Chandelier", "is_on": False},
|
||||
]
|
||||
|
||||
# 2nd invocation: should pick up the new item
|
||||
_ = await kernel.invoke_function_call(
|
||||
function_call=func_call,
|
||||
chat_history=chat_history,
|
||||
arguments=None,
|
||||
function_call_count=1,
|
||||
request_index=1,
|
||||
function_behavior=FunctionChoiceBehavior.Auto(),
|
||||
)
|
||||
snapshot2 = chat_history.messages[-1].items[0].result
|
||||
|
||||
# The new invoke reflects the mutated state
|
||||
assert snapshot2[-1] == {"id": 4, "name": "Desk lamp", "is_on": True}
|
||||
|
||||
|
||||
async def test_invoke_function_call_throws_during_invoke(kernel: Kernel, get_tool_call_mock):
|
||||
tool_call_mock = get_tool_call_mock
|
||||
result_mock = MagicMock(spec=ChatMessageContent)
|
||||
|
||||
Reference in New Issue
Block a user