Skip to content

Commit 08feb2a

Browse files
authored
Python: Deep-copy function_result.value in invoke_function_call to prevent state mutation (microsoft#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 😄
1 parent d3404cc commit 08feb2a

2 files changed

Lines changed: 68 additions & 0 deletions

File tree

‎python/semantic_kernel/kernel.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,10 @@ async def invoke_function_call(
406406
)
407407
await stack(invocation_context)
408408

409+
# Snapshot the tool's return value so later mutations don't leak back
410+
if invocation_context.function_result and invocation_context.function_result.value is not None:
411+
invocation_context.function_result.value = deepcopy(invocation_context.function_result.value)
412+
409413
frc = FunctionResultContent.from_function_call_content_and_result(
410414
function_call_content=function_call,
411415
result=invocation_context.function_result,

‎python/tests/unit/kernel/test_kernel.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,70 @@ async def test_invoke_function_call(kernel: Kernel, get_tool_call_mock):
296296
)
297297

298298

299+
class LightsPlugin:
300+
lights = [
301+
{"id": 1, "name": "Table Lamp", "is_on": False},
302+
{"id": 2, "name": "Porch light", "is_on": False},
303+
{"id": 3, "name": "Chandelier", "is_on": False},
304+
]
305+
306+
@kernel_function(
307+
name="get_lights_states",
308+
description="Gets a list of lights and their current state",
309+
)
310+
def get_lights_states(self) -> list[dict]:
311+
"""Return a list of lights and their states (read-only)."""
312+
return self.lights # returns by reference, but deep-copy fix will snapshot it
313+
314+
315+
@pytest.mark.asyncio
316+
async def test_kernel_invoke_deep_copy_preserves_previous_state():
317+
kernel = Kernel()
318+
plugin = LightsPlugin()
319+
kernel.add_plugin(plugin, plugin_name="lights")
320+
321+
func_call = FunctionCallContent(
322+
plugin_name="lights", function_name="get_lights_states", name="get_lights_states", arguments={}
323+
)
324+
325+
chat_history = ChatHistory()
326+
327+
# 1st invocation: capture snapshot of the list
328+
_ = await kernel.invoke_function_call(
329+
function_call=func_call,
330+
chat_history=chat_history,
331+
arguments=None,
332+
function_call_count=1,
333+
request_index=0,
334+
function_behavior=FunctionChoiceBehavior.Auto(),
335+
)
336+
snapshot1 = chat_history.messages[-1].items[0].result
337+
338+
# Mutate the plugin's internal state after the first call
339+
plugin.lights.append({"id": 4, "name": "Desk lamp", "is_on": True})
340+
341+
# The snapshot from the first invocation did not change
342+
assert snapshot1 == [
343+
{"id": 1, "name": "Table Lamp", "is_on": False},
344+
{"id": 2, "name": "Porch light", "is_on": False},
345+
{"id": 3, "name": "Chandelier", "is_on": False},
346+
]
347+
348+
# 2nd invocation: should pick up the new item
349+
_ = await kernel.invoke_function_call(
350+
function_call=func_call,
351+
chat_history=chat_history,
352+
arguments=None,
353+
function_call_count=1,
354+
request_index=1,
355+
function_behavior=FunctionChoiceBehavior.Auto(),
356+
)
357+
snapshot2 = chat_history.messages[-1].items[0].result
358+
359+
# The new invoke reflects the mutated state
360+
assert snapshot2[-1] == {"id": 4, "name": "Desk lamp", "is_on": True}
361+
362+
299363
async def test_invoke_function_call_throws_during_invoke(kernel: Kernel, get_tool_call_mock):
300364
tool_call_mock = get_tool_call_mock
301365
result_mock = MagicMock(spec=ChatMessageContent)

0 commit comments

Comments
 (0)