Skip to content

Commit aeb4737

Browse files
Python: keep empty tool results through the prompt round trip (#14686)
### Motivation and Context Fixes #14685. Since #14472, `ChatHistory.add_tool_message("")` keeps an empty tool result. But that result doesn't survive the XML prompt round trip that `KernelFunctionFromPrompt` performs on every chat completion call. `to_element()` renders it as a self-closing `<function_result id="call_1" />`. When `from_element()` parses that back, it reads `element.text`, which is `None` for an empty element. `ChatMessageContent` then stringifies the value, so the model receives the literal tool result `"None"` instead of an empty one. ### Description - `FunctionResultContent.from_element` now maps a missing element text to `""`. Non-empty results are unchanged. - Added `test_element_round_trip_keeps_result` (empty and non-empty), which serialises the element to XML and parses it back. - Added `test_chat_history_prompt_round_trip_keeps_empty_tool_result`, which reproduces the issue end to end through `to_prompt()` / `from_rendered_prompt()`. Before (main `4bb64ee`, macOS arm64, Python 3.12): ``` {'role': 'tool', 'content': 'None', 'tool_call_id': 'call_1'} ``` After: ``` {'role': 'tool', 'content': '', 'tool_call_id': 'call_1'} ``` Tests, run from `python/`: - `pytest tests/unit/contents`: 421 passed on `main`, 424 passed with this change. - With the source change reverted, the two empty-result tests fail: `2 failed, 25 passed` in `test_function_result_content.py`. - `ruff check` / `ruff format --check` on both files are clean. This doesn't overlap the open #14375 (the `from_function_call_content_and_result` factory) or #14480 (the `add_tool_message` path), and it doesn't touch the JSON `serialize()` / `restore_chat_history()` path, which already keeps `""`. ### Contribution Checklist - [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 😄 AI-assisted: yes ([Cana](https://cana.build))
1 parent 2a2e9af commit aeb4737

2 files changed

Lines changed: 20 additions & 1 deletion

File tree

‎python/semantic_kernel/contents/function_result_content.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ def from_element(cls: type[_T], element: Element) -> _T:
116116
"""Create an instance from an Element."""
117117
if element.tag != cls.tag:
118118
raise ContentInitializationError(f"Element tag is not {cls.tag}") # pragma: no cover
119-
return cls(id=element.get("id", ""), result=element.text, name=element.get("name", None))
119+
# An empty result renders as a self-closing element, whose text parses back as None.
120+
result = element.text if element.text is not None else ""
121+
return cls(id=element.get("id", ""), result=result, name=element.get("name", None))
120122

121123
@classmethod
122124
def from_function_call_content_and_result(

‎python/tests/unit/contents/test_function_result_content.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33

44
from unittest.mock import Mock
5+
from xml.etree.ElementTree import XML, tostring # nosec
56

67
import pytest
78
from pydantic import BaseModel, ConfigDict, Field
89

10+
from semantic_kernel.contents.chat_history import ChatHistory
911
from semantic_kernel.contents.chat_message_content import ChatMessageContent
1012
from semantic_kernel.contents.function_call_content import FunctionCallContent
1113
from semantic_kernel.contents.function_result_content import FunctionResultContent
@@ -123,6 +125,21 @@ def test_to_cmc():
123125
assert cmc.items[0].result == "test-result"
124126

125127

128+
@pytest.mark.parametrize("result", ["", "test-result"], ids=["empty", "non_empty"])
129+
def test_element_round_trip_keeps_result(result: str):
130+
frc = FunctionResultContent(id="call_1", name="my-plugin-do", result=result)
131+
restored = FunctionResultContent.from_element(XML(tostring(frc.to_element(), encoding="unicode")))
132+
assert restored.result == result
133+
assert restored.to_dict() == {"tool_call_id": "call_1", "content": result}
134+
135+
136+
def test_chat_history_prompt_round_trip_keeps_empty_tool_result():
137+
history = ChatHistory()
138+
history.add_tool_message("", tool_call_id="call_1", function_name="my-plugin-do")
139+
parsed = ChatHistory.from_rendered_prompt(history.to_prompt())
140+
assert parsed.messages[0].to_dict() == {"role": "tool", "content": "", "tool_call_id": "call_1"}
141+
142+
126143
def test_serialize():
127144
class CustomResultClass:
128145
def __init__(self, result):

0 commit comments

Comments
 (0)