chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -0,0 +1,61 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.contents.annotation_content import AnnotationContent
from semantic_kernel.contents.audio_content import AudioContent
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.file_reference_content import FileReferenceContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.history_reducer.chat_history_reducer import ChatHistoryReducer
from semantic_kernel.contents.history_reducer.chat_history_summarization_reducer import ChatHistorySummarizationReducer
from semantic_kernel.contents.history_reducer.chat_history_truncation_reducer import ChatHistoryTruncationReducer
from semantic_kernel.contents.image_content import ImageContent
from semantic_kernel.contents.realtime_events import (
RealtimeAudioEvent,
RealtimeEvent,
RealtimeEvents,
RealtimeFunctionCallEvent,
RealtimeFunctionResultEvent,
RealtimeImageEvent,
RealtimeTextEvent,
)
from semantic_kernel.contents.reasoning_content import ReasoningContent
from semantic_kernel.contents.streaming_annotation_content import StreamingAnnotationContent
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent
from semantic_kernel.contents.streaming_reasoning_content import StreamingReasoningContent
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
from semantic_kernel.contents.text_content import TextContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.contents.utils.finish_reason import FinishReason
__all__ = [
"AnnotationContent",
"AudioContent",
"AuthorRole",
"ChatHistory",
"ChatHistoryReducer",
"ChatHistorySummarizationReducer",
"ChatHistoryTruncationReducer",
"ChatMessageContent",
"FileReferenceContent",
"FinishReason",
"FunctionCallContent",
"FunctionResultContent",
"ImageContent",
"RealtimeAudioEvent",
"RealtimeEvent",
"RealtimeEvents",
"RealtimeFunctionCallEvent",
"RealtimeFunctionResultEvent",
"RealtimeImageEvent",
"RealtimeTextEvent",
"ReasoningContent",
"StreamingAnnotationContent",
"StreamingChatMessageContent",
"StreamingFileReferenceContent",
"StreamingReasoningContent",
"StreamingTextContent",
"TextContent",
]
@@ -0,0 +1,93 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from enum import Enum
from typing import Any, ClassVar, Literal, TypeVar
from xml.etree.ElementTree import Element # nosec
from pydantic import ConfigDict, Field
from semantic_kernel.contents.const import ANNOTATION_CONTENT_TAG, ContentTypes
from semantic_kernel.contents.kernel_content import KernelContent
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
_T = TypeVar("_T", bound="AnnotationContent")
@experimental
class CitationType(str, Enum):
"""Citation type."""
URL_CITATION = "url_citation"
FILE_PATH = "file_path"
FILE_CITATION = "file_citation"
CONTAINER_FILE_CITATION = "container_file_citation"
@experimental
class AnnotationContent(KernelContent):
"""Annotation content."""
content_type: Literal[ContentTypes.ANNOTATION_CONTENT] = Field(ANNOTATION_CONTENT_TAG, init=False) # type: ignore
tag: ClassVar[str] = ANNOTATION_CONTENT_TAG
file_id: str | None = None
quote: str | None = None
start_index: int | None = None
end_index: int | None = None
url: str | None = None
title: str | None = None
citation_type: CitationType | None = Field(None, alias="type")
container_id: str | None = None
filename: str | None = None
model_config = ConfigDict(
extra="ignore",
populate_by_name=True,
)
def __str__(self) -> str:
"""Return the string representation of the annotation content."""
ctype = self.citation_type.value if self.citation_type else None
return f"AnnotationContent(type={ctype}, file_id={self.file_id}, url={self.url}, quote={self.quote}, start_index={self.start_index}, end_index={self.end_index})" # noqa: E501
def to_element(self) -> Element:
"""Convert the annotation content to an Element."""
element = Element(self.tag)
if self.citation_type:
element.set("type", self.citation_type)
if self.file_id:
element.set("file_id", self.file_id)
if self.quote:
element.set("quote", self.quote)
if self.start_index is not None:
element.set("start_index", str(self.start_index))
if self.end_index is not None:
element.set("end_index", str(self.end_index))
if self.url is not None:
element.set("url", self.url)
if self.title is not None:
element.set("title", self.title)
return element
@classmethod
def from_element(cls: type[_T], element: Element) -> _T:
"""Create an instance from an Element."""
return cls(
type=element.get("type"),
file_id=element.get("file_id"),
quote=element.get("quote"),
start_index=int(element.get("start_index")) if element.get("start_index") else None, # type: ignore
end_index=int(element.get("end_index")) if element.get("end_index") else None, # type: ignore
url=element.get("url") if element.get("url") else None, # type: ignore
title=element.get("title") if element.get("title") else None, # type: ignore
)
def to_dict(self) -> dict[str, Any]:
"""Convert the instance to a dictionary."""
ctype = self.citation_type.value if self.citation_type else None
return {
"type": "text",
"text": f"type={ctype}, {self.file_id or self.url} {self.quote} (Start Index={self.start_index}->End Index={self.end_index})", # noqa: E501
}
@@ -0,0 +1,88 @@
# Copyright (c) Microsoft. All rights reserved.
import mimetypes
from typing import Any, ClassVar, Literal, TypeVar
from numpy import ndarray
from pydantic import Field
from semantic_kernel.contents.binary_content import BinaryContent
from semantic_kernel.contents.const import AUDIO_CONTENT_TAG, ContentTypes
from semantic_kernel.utils.feature_stage_decorator import experimental
_T = TypeVar("_T", bound="AudioContent")
@experimental
class AudioContent(BinaryContent):
"""Audio Content class.
This can be created either the bytes data or a data uri, additionally it can have a uri.
The uri is a reference to the source, and might or might not point to the same thing as the data.
Use the .from_audio_file method to create an instance from an audio file.
This reads the file and guesses the mime_type.
If both data_uri and data is provided, data will be used and a warning is logged.
Args:
uri (Url | None): The reference uri of the content.
data_uri (DataUrl | None): The data uri of the content.
data (str | bytes | None): The data of the content.
data_format (str | None): The format of the data (e.g. base64).
mime_type (str | None): The mime type of the audio, only used with data.
kwargs (Any): Any additional arguments:
inner_content (Any): The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer can leverage the full thing.
ai_model_id (str | None): The id of the AI model that generated this response.
metadata (dict[str, Any]): Any metadata that should be attached to the response.
"""
content_type: Literal[ContentTypes.AUDIO_CONTENT] = Field(default=AUDIO_CONTENT_TAG, init=False) # type: ignore
tag: ClassVar[str] = AUDIO_CONTENT_TAG
def __init__(
self,
uri: str | None = None,
data_uri: str | None = None,
data: str | bytes | ndarray | None = None,
data_format: str | None = None,
mime_type: str | None = None,
**kwargs: Any,
):
"""Create an Audio Content object, either from a data_uri or data.
Args:
uri: The reference uri of the content.
data_uri: The data uri of the content.
data: The data of the content.
data_format: The format of the data (e.g. base64).
mime_type: The mime type of the audio, only used with data.
kwargs: Any additional arguments:
inner_content: The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer
can leverage the full thing.
ai_model_id: The id of the AI model that generated this response.
metadata: Any metadata that should be attached to the response.
"""
super().__init__(
uri=uri,
data_uri=data_uri,
data=data,
data_format=data_format,
mime_type=mime_type,
**kwargs,
)
@classmethod
def from_audio_file(cls: type[_T], path: str) -> _T:
"""Create an instance from an audio file."""
mime_type = mimetypes.guess_type(path)[0]
with open(path, "rb") as audio_file:
return cls(data=audio_file.read(), data_format="base64", mime_type=mime_type, uri=path)
def to_dict(self) -> dict[str, Any]:
"""Convert the instance to a dictionary."""
return {"type": "audio_url", "audio_url": {"uri": str(self)}}
@@ -0,0 +1,263 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import os
from pathlib import Path
from typing import Annotated, Any, ClassVar, Literal, TypeVar
from xml.etree.ElementTree import Element # nosec
from numpy import ndarray
from pydantic import Field, FilePath, PrivateAttr, UrlConstraints, computed_field
from pydantic_core import Url
from semantic_kernel.contents.const import BINARY_CONTENT_TAG, ContentTypes
from semantic_kernel.contents.kernel_content import KernelContent
from semantic_kernel.contents.utils.data_uri import DataUri
from semantic_kernel.exceptions.content_exceptions import ContentException, ContentInitializationError
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
_T = TypeVar("_T", bound="BinaryContent")
DataUrl = Annotated[Url, UrlConstraints(allowed_schemes=["data"])]
@experimental
class BinaryContent(KernelContent):
"""This is a base class for different types of binary content.
This can be created either the bytes data or a data uri, additionally it can have a uri.
The uri is a reference to the source, and might or might not point to the same thing as the data.
Ideally only subclasses of this class are used, like ImageContent.
Methods:
__str__: Returns the string representation of the content.
Raises:
ValidationError: If any arguments are malformed.
"""
content_type: Literal[ContentTypes.BINARY_CONTENT] = Field(default=BINARY_CONTENT_TAG, init=False) # type: ignore
uri: Url | str | None = None
default_mime_type: ClassVar[str] = "text/plain"
tag: ClassVar[str] = BINARY_CONTENT_TAG
_data_uri: DataUri | None = PrivateAttr(default=None)
def __init__(
self,
uri: Url | str | None = None,
data_uri: DataUrl | str | None = None,
data: str | bytes | ndarray | None = None,
data_format: str | None = None,
mime_type: str | None = None,
**kwargs: Any,
):
"""Create a Binary Content object, either from a data_uri or data.
Args:
uri: The reference uri of the content.
data_uri: The data uri of the content.
data: The data of the content.
data_format: The format of the data (e.g. base64).
mime_type: The mime type of the content, not always relevant.
kwargs: Any additional arguments:
inner_content: The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer can leverage the full thing.
ai_model_id: The id of the AI model that generated this response.
metadata: Any metadata that should be attached to the response.
"""
temp_data_uri: DataUri | None = None
if data_uri:
temp_data_uri = DataUri.from_data_uri(data_uri, self.default_mime_type)
kwargs.setdefault("metadata", {})
kwargs["metadata"].update(temp_data_uri.parameters)
elif data is not None:
match data:
case bytes():
temp_data_uri = DataUri(
data_bytes=data, data_format=data_format, mime_type=mime_type or self.default_mime_type
)
case ndarray():
temp_data_uri = DataUri(
data_array=data, data_format=data_format, mime_type=mime_type or self.default_mime_type
)
case str():
temp_data_uri = DataUri(
data_str=data, data_format=data_format, mime_type=mime_type or self.default_mime_type
)
if uri is not None:
if isinstance(uri, str) and os.path.exists(uri):
if os.path.isfile(uri):
uri = str(Path(uri))
else:
raise ContentInitializationError("URI must be a file path, not a directory.")
elif isinstance(uri, str):
uri = Url(uri)
super().__init__(uri=uri, **kwargs)
self._data_uri = temp_data_uri
@computed_field # type: ignore
@property
def data_uri(self) -> str:
"""Get the data uri."""
if self._data_uri:
return self._data_uri.to_string(self.metadata)
return ""
@data_uri.setter
def data_uri(self, value: str):
"""Set the data uri."""
if not self._data_uri:
self._data_uri = DataUri.from_data_uri(value, self.default_mime_type)
else:
self._data_uri.update_data(value)
self.metadata.update(self._data_uri.parameters)
@property
def data_string(self) -> str:
"""Returns the data as a string, using the data format."""
if self._data_uri:
return self._data_uri._data_str()
return ""
@property
def data(self) -> bytes | ndarray:
"""Get the data."""
if self._data_uri and self._data_uri.data_array is not None:
return self._data_uri.data_array.tobytes()
if self._data_uri and self._data_uri.data_bytes:
return self._data_uri.data_bytes
return b""
@data.setter
def data(self, value: str | bytes | ndarray):
"""Set the data."""
if self._data_uri:
self._data_uri.update_data(value)
return
match value:
case ndarray():
self._data_uri = DataUri(data_array=value, mime_type=self.mime_type)
case str():
self._data_uri = DataUri(data_str=value, mime_type=self.mime_type)
case bytes():
self._data_uri = DataUri(data_bytes=value, mime_type=self.mime_type)
case _:
raise ContentException("Data must be a string, bytes, or numpy array.")
@property
def mime_type(self) -> str:
"""Get the mime type."""
if self._data_uri and self._data_uri.mime_type:
return self._data_uri.mime_type
return self.default_mime_type
@mime_type.setter
def mime_type(self, value: str):
"""Set the mime type."""
if self._data_uri:
self._data_uri.mime_type = value
@property
def can_read(self) -> bool:
"""Get whether the content can be read.
Returns True if the content has data available for reading.
"""
return self._data_uri is not None
@classmethod
def from_file(
cls: type[_T],
file_path: str | Path,
mime_type: str | None = None,
) -> _T:
"""Create BinaryContent from a file.
Args:
file_path: Path to the file to read
mime_type: MIME type of the file content
Returns:
BinaryContent instance with file data
Raises:
FileNotFoundError: If the file doesn't exist
ContentInitializationError: If the path is not a file
"""
from semantic_kernel.exceptions.content_exceptions import ContentInitializationError
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
if not path.is_file():
raise ContentInitializationError(f"Path is not a file: {file_path}")
# Read file as binary data to handle all file types properly
data = path.read_bytes()
return cls(
data=data,
mime_type=mime_type,
uri=str(path),
data_format="base64",
)
def __str__(self) -> str:
"""Return the string representation of the content."""
return self.data_uri if self._data_uri else str(self.uri)
def to_element(self) -> Element:
"""Convert the instance to an Element."""
element = Element(self.tag)
if self._data_uri:
element.text = self.data_uri
if self.uri:
element.set("uri", str(self.uri))
return element
@classmethod
def from_element(cls: type[_T], element: Element) -> _T:
"""Create an instance from an Element."""
if element.tag != cls.tag:
raise ContentInitializationError(f"Element tag is not {cls.tag}") # pragma: no cover
if element.text:
return cls(data_uri=element.text, uri=element.get("uri", None))
return cls(uri=element.get("uri", None))
def write_to_file(self, path: str | FilePath, *, overwrite: bool = False) -> None:
"""Write the data to a file.
Args:
path: The path to write the file to.
overwrite: If True, overwrite existing files. If False, raise an error if file exists.
Defaults to False.
Raises:
FileExistsError: If overwrite is False and the file already exists.
"""
file_path = Path(path)
if not overwrite and file_path.exists():
raise FileExistsError(f"File already exists and overwrite is disabled: {path}")
if self._data_uri and self._data_uri.data_array is not None:
self._data_uri.data_array.tofile(path)
return
with open(path, "wb") as file:
assert isinstance(self.data, bytes) # nosec
file.write(self.data)
def to_dict(self) -> dict[str, Any]:
"""Convert the instance to a dictionary."""
return {"type": "binary", "binary": {"uri": str(self)}}
@@ -0,0 +1,439 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from collections.abc import Generator, Iterable
from functools import singledispatchmethod
from html import unescape
from typing import Any, TypeVar
from xml.etree.ElementTree import Element, tostring # nosec
from defusedxml.ElementTree import XML, ParseError
from pydantic import Field, field_validator, model_validator
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.const import CHAT_HISTORY_TAG, CHAT_MESSAGE_CONTENT_TAG
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.kernel_content import KernelContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions import ContentInitializationError, ContentSerializationError
from semantic_kernel.kernel_pydantic import KernelBaseModel
logger = logging.getLogger(__name__)
_T = TypeVar("_T", bound="ChatHistory")
class ChatHistory(KernelBaseModel):
"""This class holds the history of chat messages from a chat conversation.
Note: the system_message is added to the messages as a ChatMessageContent instance with role=AuthorRole.SYSTEM,
but updating it will not update the messages list.
Args:
messages: The messages to add to the chat history.
system_message: A system message to add to the chat history, optional.
if passed, it is added to the messages
as a ChatMessageContent instance with role=AuthorRole.SYSTEM
before any other messages.
"""
messages: list[ChatMessageContent] = Field(default_factory=list, kw_only=False)
system_message: str | None = Field(default=None, kw_only=False, repr=False)
@model_validator(mode="before")
@classmethod
def _parse_system_message(cls, data: Any) -> Any:
"""Parse the system_message and add it to the messages."""
if isinstance(data, dict) and (system_message := data.pop("system_message", None)):
msg = ChatMessageContent(role=AuthorRole.SYSTEM, content=system_message)
if "messages" in data:
data["messages"] = [msg] + data["messages"]
else:
data["messages"] = [msg]
return data
@field_validator("messages", mode="before")
@classmethod
def _validate_messages(cls, messages: list[ChatMessageContent]) -> list[ChatMessageContent]:
if not messages:
return messages
out_msgs: list[ChatMessageContent] = []
for message in messages:
if isinstance(message, dict):
out_msgs.append(ChatMessageContent.model_validate(message))
else:
out_msgs.append(message)
return out_msgs
@singledispatchmethod
def add_system_message(self, content: str | list[KernelContent], **kwargs) -> None:
"""Add a system message to the chat history.
Args:
content: The content of the system message, can be a string or a
list of KernelContent instances that are turned into a single ChatMessageContent.
**kwargs: Additional keyword arguments.
"""
raise NotImplementedError
@add_system_message.register
def _(self, content: str, **kwargs: Any) -> None:
"""Add a system message to the chat history."""
self.add_message(message=self._prepare_for_add(role=AuthorRole.SYSTEM, content=content, **kwargs))
@add_system_message.register(list)
def _(self, content: list[KernelContent], **kwargs: Any) -> None:
"""Add a system message to the chat history."""
self.add_message(message=self._prepare_for_add(role=AuthorRole.SYSTEM, items=content, **kwargs))
@singledispatchmethod
def add_developer_message(self, content: str | list[KernelContent], **kwargs) -> None:
"""Add a system message to the chat history.
Args:
content: The content of the developer message, can be a string or a
list of KernelContent instances that are turned into a single ChatMessageContent.
**kwargs: Additional keyword arguments.
"""
raise NotImplementedError
@add_developer_message.register
def _(self, content: str, **kwargs: Any) -> None:
"""Add a system message to the chat history."""
self.add_message(message=self._prepare_for_add(role=AuthorRole.DEVELOPER, content=content, **kwargs))
@add_developer_message.register(list)
def _(self, content: list[KernelContent], **kwargs: Any) -> None:
"""Add a system message to the chat history."""
self.add_message(message=self._prepare_for_add(role=AuthorRole.DEVELOPER, items=content, **kwargs))
@singledispatchmethod
def add_user_message(self, content: str | list[KernelContent], **kwargs: Any) -> None:
"""Add a user message to the chat history.
Args:
content: The content of the user message, can be a string or a
list of KernelContent instances that are turned into a single ChatMessageContent.
**kwargs: Additional keyword arguments.
"""
raise NotImplementedError
@add_user_message.register
def _(self, content: str, **kwargs: Any) -> None:
"""Add a user message to the chat history."""
self.add_message(message=self._prepare_for_add(role=AuthorRole.USER, content=content, **kwargs))
@add_user_message.register(list)
def _(self, content: list[KernelContent], **kwargs: Any) -> None:
"""Add a user message to the chat history."""
self.add_message(message=self._prepare_for_add(role=AuthorRole.USER, items=content, **kwargs))
@singledispatchmethod
def add_assistant_message(self, content: str | list[KernelContent], **kwargs: Any) -> None:
"""Add an assistant message to the chat history.
Args:
content: The content of the assistant message, can be a string or a
list of KernelContent instances that are turned into a single ChatMessageContent.
**kwargs: Additional keyword arguments.
"""
raise NotImplementedError
@add_assistant_message.register
def _(self, content: str, **kwargs: Any) -> None:
"""Add an assistant message to the chat history."""
self.add_message(message=self._prepare_for_add(role=AuthorRole.ASSISTANT, content=content, **kwargs))
@add_assistant_message.register(list)
def _(self, content: list[KernelContent], **kwargs: Any) -> None:
"""Add an assistant message to the chat history."""
self.add_message(message=self._prepare_for_add(role=AuthorRole.ASSISTANT, items=content, **kwargs))
@singledispatchmethod
def add_tool_message(self, content: str | list[KernelContent], **kwargs: Any) -> None:
"""Add a tool message to the chat history.
Args:
content: The content of the tool message. If a string, tool_call_id must be provided
as a keyword argument. If a list of KernelContent instances, they should contain
properly configured FunctionResultContent objects.
**kwargs: Additional keyword arguments. For string content, tool_call_id is required.
Optionally one may provide function_name to specify the tool function name. The
function_name is only used for bookkeeping purposes as part of ChatHistory and is
not included in the call to the model.
"""
raise NotImplementedError
@add_tool_message.register
def _(self, content: str, **kwargs: Any) -> None:
"""Add a tool message to the chat history.
Args:
content: The result content of the tool call.
**kwargs: Additional keyword arguments. 'tool_call_id' is required when using string content.
"""
if "tool_call_id" not in kwargs:
raise ContentInitializationError(
"tool_call_id is required when adding a tool message with string content. "
"Tool messages must reference the specific tool call they respond to."
)
self.add_message(message=self._prepare_for_add(role=AuthorRole.TOOL, content=content, **kwargs))
@add_tool_message.register(list)
def _(self, content: list[KernelContent], **kwargs: Any) -> None:
"""Add a tool message to the chat history."""
self.add_message(message=self._prepare_for_add(role=AuthorRole.TOOL, items=content, **kwargs))
def add_message(
self,
message: ChatMessageContent | dict[str, Any],
encoding: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
"""Add a message to the history.
This method accepts either a ChatMessageContent instance or a
dictionary with the necessary information to construct a ChatMessageContent instance.
Args:
message (Union[ChatMessageContent, dict]): The message to add, either as
a pre-constructed ChatMessageContent instance or a dictionary specifying 'role' and 'content'.
encoding (Optional[str]): The encoding of the message. Required if 'message' is a dict.
metadata (Optional[dict[str, Any]]): Any metadata to attach to the message. Required if 'message' is a dict.
"""
if isinstance(message, ChatMessageContent):
self.messages.append(message)
return
if "role" not in message:
raise ContentInitializationError(f"Dictionary must contain at least the role. Got: {message}")
if encoding:
message["encoding"] = encoding
if metadata:
message["metadata"] = metadata
self.messages.append(ChatMessageContent(**message))
def _prepare_for_add(
self, role: AuthorRole, content: str | None = None, items: list[KernelContent] | None = None, **kwargs: Any
) -> dict[str, str]:
"""Prepare a message to be added to the history."""
kwargs["role"] = role
if role == AuthorRole.TOOL and content and not items:
tool_call_id = kwargs.pop("tool_call_id", None)
function_name = kwargs.pop("function_name", "unknown")
function_result_content = FunctionResultContent(
function_name=function_name,
result=content,
id=tool_call_id, # Set both id and call_id for compatibility
call_id=tool_call_id,
**kwargs,
)
kwargs["items"] = [function_result_content]
elif content:
kwargs["content"] = content
elif items:
kwargs["items"] = items
return kwargs
def remove_message(self, message: ChatMessageContent) -> bool:
"""Remove a message from the history.
Args:
message (ChatMessageContent): The message to remove.
Returns:
bool: True if the message was removed, False if the message was not found.
"""
try:
self.messages.remove(message)
return True
except ValueError:
return False
def __len__(self) -> int:
"""Return the number of messages in the history."""
return len(self.messages)
def __getitem__(self, index: int) -> ChatMessageContent:
"""Get a message from the history using the [] operator.
Args:
index (int): The index of the message to get.
Returns:
ChatMessageContent: The message at the specified index.
"""
return self.messages[index]
def __contains__(self, item: ChatMessageContent) -> bool:
"""Check if a message is in the history.
Args:
item (ChatMessageContent): The message to check for.
Returns:
bool: True if the message is in the history, False otherwise.
"""
return item in self.messages
def __str__(self) -> str:
"""Return a string representation of the history."""
chat_history_xml = Element(CHAT_HISTORY_TAG)
for message in self.messages:
chat_history_xml.append(message.to_element())
return tostring(chat_history_xml, encoding="unicode", short_empty_elements=True)
def clear(self) -> None:
"""Clear the chat history."""
self.messages.clear()
def extend(self, messages: Iterable[ChatMessageContent]) -> None:
"""Extend the chat history with a list of messages.
Args:
messages: The messages to add to the history.
Can be a list of ChatMessageContent instances or a ChatHistory itself.
"""
self.messages.extend(messages)
def replace(self, messages: Iterable[ChatMessageContent]) -> None:
"""Replace the chat history with a list of messages.
This calls clear() and then extend(messages=messages).
Args:
messages: The messages to add to the history.
Can be a list of ChatMessageContent instances or a ChatHistory itself.
"""
self.clear()
self.extend(messages=messages)
def to_prompt(self) -> str:
"""Return a string representation of the history."""
chat_history_xml = Element(CHAT_HISTORY_TAG)
for message in self.messages:
chat_history_xml.append(message.to_element())
return tostring(chat_history_xml, encoding="unicode", short_empty_elements=True)
def __iter__(self) -> Generator[ChatMessageContent, None, None]: # type: ignore
"""Return an iterator over the messages in the history."""
yield from self.messages
def __eq__(self, other: Any) -> bool:
"""Check if two ChatHistory instances are equal."""
if not isinstance(other, ChatHistory):
return False
return self.messages == other.messages
@classmethod
def from_rendered_prompt(cls: type[_T], rendered_prompt: str) -> _T:
"""Create a ChatHistory instance from a rendered prompt.
Args:
rendered_prompt (str): The rendered prompt to convert to a ChatHistory instance.
Returns:
ChatHistory: The ChatHistory instance created from the rendered prompt.
"""
prompt_tag = "root"
messages: list["ChatMessageContent"] = []
prompt = rendered_prompt.strip()
try:
xml_prompt = XML(text=f"<{prompt_tag}>{prompt}</{prompt_tag}>")
except ParseError as exc:
logger.info(f"Could not parse prompt {prompt} as xml, treating as text, error was: {exc}")
return cls(messages=[ChatMessageContent(role=AuthorRole.USER, content=unescape(prompt))])
if xml_prompt.text and xml_prompt.text.strip():
messages.append(ChatMessageContent(role=AuthorRole.SYSTEM, content=unescape(xml_prompt.text.strip())))
for item in xml_prompt:
if item.tag == CHAT_MESSAGE_CONTENT_TAG:
messages.append(ChatMessageContent.from_element(item))
elif item.tag == CHAT_HISTORY_TAG:
for message in item:
messages.append(ChatMessageContent.from_element(message))
else:
# Unknown XML tags (e.g. HTML tags like <p>, <div>) are not SK
# template tags. Serialize them back to text and append to the
# previous message so that the original content is preserved.
saved_tail = item.tail
item.tail = None
raw = unescape(tostring(item, encoding="unicode", short_empty_elements=False))
item.tail = saved_tail
if messages:
messages[-1].content = (messages[-1].content or "") + raw
else:
messages.append(ChatMessageContent(role=AuthorRole.USER, content=raw))
# For unknown tags the tail is part of the surrounding text,
# so keep it in the same message instead of starting a new one.
if item.tail:
messages[-1].content = (messages[-1].content or "") + unescape(item.tail)
continue
if item.tail and item.tail.strip():
messages.append(ChatMessageContent(role=AuthorRole.USER, content=unescape(item.tail.strip())))
if len(messages) == 1 and messages[0].role == AuthorRole.SYSTEM:
messages[0].role = AuthorRole.USER
return cls(messages=messages)
def serialize(self) -> str:
"""Serializes the ChatHistory instance to a JSON string.
Returns:
str: A JSON string representation of the ChatHistory instance.
Raises:
ValueError: If the ChatHistory instance cannot be serialized to JSON.
"""
try:
return self.model_dump_json(exclude_none=True, indent=2)
except Exception as e: # pragma: no cover
raise ContentSerializationError(f"Unable to serialize ChatHistory to JSON: {e}") from e
@classmethod
def restore_chat_history(cls: type[_T], chat_history_json: str) -> _T:
"""Restores a ChatHistory instance from a JSON string.
Args:
chat_history_json (str): The JSON string to deserialize
into a ChatHistory instance.
Returns:
ChatHistory: The deserialized ChatHistory instance.
Raises:
ValueError: If the JSON string is invalid or the deserialized data
fails validation.
"""
try:
return cls.model_validate_json(chat_history_json)
except Exception as e:
raise ContentInitializationError(f"Invalid JSON format: {e}")
def store_chat_history_to_file(self, file_path: str) -> None:
"""Stores the serialized ChatHistory to a file.
Uses mode "w" which means the file is created if it does not exist and gets truncated if it does.
Args:
file_path: The path to the file where the serialized data will be stored.
"""
json_str = self.serialize()
with open(file_path, "w") as local_file:
local_file.write(json_str)
@classmethod
def load_chat_history_from_file(cls: type[_T], file_path: str) -> _T:
"""Loads the ChatHistory from a file.
Uses mode "r" which means it can only be read if it exists.
Args:
file_path: The path to the file from which to load the ChatHistory.
Returns:
ChatHistory: The deserialized ChatHistory instance.
"""
with open(file_path) as file:
json_str = file.read()
return cls.restore_chat_history(json_str)
@@ -0,0 +1,338 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from enum import Enum
from html import unescape
from typing import Annotated, Any, ClassVar, Literal, overload
from xml.etree.ElementTree import Element # nosec
from defusedxml import ElementTree
from pydantic import Field
from semantic_kernel.contents.annotation_content import AnnotationContent
from semantic_kernel.contents.audio_content import AudioContent
from semantic_kernel.contents.binary_content import BinaryContent
from semantic_kernel.contents.const import (
ANNOTATION_CONTENT_TAG,
CHAT_MESSAGE_CONTENT_TAG,
DISCRIMINATOR_FIELD,
FILE_REFERENCE_CONTENT_TAG,
FUNCTION_CALL_CONTENT_TAG,
FUNCTION_RESULT_CONTENT_TAG,
IMAGE_CONTENT_TAG,
REASONING_CONTENT_TAG,
STREAMING_ANNOTATION_CONTENT_TAG,
STREAMING_FILE_REFERENCE_CONTENT_TAG,
TEXT_CONTENT_TAG,
ContentTypes,
)
from semantic_kernel.contents.file_reference_content import FileReferenceContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.image_content import ImageContent
from semantic_kernel.contents.kernel_content import KernelContent
from semantic_kernel.contents.reasoning_content import ReasoningContent
from semantic_kernel.contents.streaming_annotation_content import StreamingAnnotationContent
from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent
from semantic_kernel.contents.text_content import TextContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.contents.utils.finish_reason import FinishReason
from semantic_kernel.contents.utils.hashing import make_hashable
from semantic_kernel.contents.utils.status import Status
from semantic_kernel.exceptions.content_exceptions import ContentInitializationError
TAG_CONTENT_MAP = {
ANNOTATION_CONTENT_TAG: AnnotationContent,
TEXT_CONTENT_TAG: TextContent,
FILE_REFERENCE_CONTENT_TAG: FileReferenceContent,
FUNCTION_CALL_CONTENT_TAG: FunctionCallContent,
FUNCTION_RESULT_CONTENT_TAG: FunctionResultContent,
IMAGE_CONTENT_TAG: ImageContent,
REASONING_CONTENT_TAG: ReasoningContent,
STREAMING_FILE_REFERENCE_CONTENT_TAG: StreamingFileReferenceContent,
STREAMING_ANNOTATION_CONTENT_TAG: StreamingAnnotationContent,
}
CMC_ITEM_TYPES = Annotated[
AnnotationContent
| BinaryContent
| ImageContent
| TextContent
| FunctionResultContent
| FunctionCallContent
| FileReferenceContent
| ReasoningContent
| StreamingAnnotationContent
| StreamingFileReferenceContent
| AudioContent,
Field(discriminator=DISCRIMINATOR_FIELD),
]
logger = logging.getLogger(__name__)
class ChatMessageContent(KernelContent):
"""This is the class for chat message response content.
All Chat Completion Services should return an instance of this class as response.
Or they can implement their own subclass of this class and return an instance.
Args:
inner_content: Optional[Any] - The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer can leverage the full thing.
ai_model_id: Optional[str] - The id of the AI model that generated this response.
metadata: Dict[str, Any] - Any metadata that should be attached to the response.
role: ChatRole - The role of the chat message.
content: Optional[str] - The text of the response.
encoding: Optional[str] - The encoding of the text.
Methods:
__str__: Returns the content of the response.
"""
content_type: Literal[ContentTypes.CHAT_MESSAGE_CONTENT] = Field(default=CHAT_MESSAGE_CONTENT_TAG, init=False) # type: ignore
tag: ClassVar[str] = CHAT_MESSAGE_CONTENT_TAG
role: AuthorRole
name: str | None = None
items: list[CMC_ITEM_TYPES] = Field(default_factory=list)
encoding: str | None = None
finish_reason: FinishReason | None = None
status: Status | None = None
@overload
def __init__(
self,
role: AuthorRole,
items: list[CMC_ITEM_TYPES],
name: str | None = None,
inner_content: Any | None = None,
encoding: str | None = None,
finish_reason: FinishReason | None = None,
status: Status | None = None,
ai_model_id: str | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> None: ...
@overload
def __init__(
self,
role: AuthorRole,
content: str,
name: str | None = None,
inner_content: Any | None = None,
encoding: str | None = None,
finish_reason: FinishReason | None = None,
status: Status | None = None,
ai_model_id: str | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> None: ...
def __init__( # type: ignore
self,
role: AuthorRole,
items: list[CMC_ITEM_TYPES] | None = None,
content: str | None = None,
inner_content: Any | None = None,
name: str | None = None,
encoding: str | None = None,
finish_reason: FinishReason | None = None,
status: Status | None = None,
ai_model_id: str | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Create a ChatMessageContent instance.
Args:
role: AuthorRole - The role of the chat message.
items: list[TextContent, StreamingTextContent, FunctionCallContent, FunctionResultContent, ImageContent]
- The content.
content: str - The text of the response.
inner_content: Optional[Any] - The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer can leverage the full thing.
name: Optional[str] - The name of the response.
encoding: Optional[str] - The encoding of the text.
finish_reason: Optional[FinishReason] - The reason the response was finished.
status: Optional[Status] - The status of the response for the Responses API.
ai_model_id: Optional[str] - The id of the AI model that generated this response.
metadata: Dict[str, Any] - Any metadata that should be attached to the response.
**kwargs: Any - Any additional fields to set on the instance.
"""
kwargs["role"] = role
if encoding:
kwargs["encoding"] = encoding
if finish_reason:
kwargs["finish_reason"] = finish_reason
if status:
kwargs["status"] = status
if name:
kwargs["name"] = name
if content:
item = TextContent(
ai_model_id=ai_model_id,
inner_content=inner_content,
metadata=metadata or {},
text=content,
encoding=encoding,
)
if items:
items.append(item)
else:
items = [item]
if items:
kwargs["items"] = items
if inner_content:
kwargs["inner_content"] = inner_content
if metadata:
kwargs["metadata"] = metadata
if ai_model_id:
kwargs["ai_model_id"] = ai_model_id
super().__init__(
**kwargs,
)
@property
def content(self) -> str:
"""Get the content of the response, will find the first TextContent's text."""
for item in self.items:
if isinstance(item, TextContent):
return item.text
return ""
@content.setter
def content(self, value: str):
"""Set the content of the response."""
if not value:
logger.warning(
"Setting empty content on ChatMessageContent does not work, "
"you can do this through the underlying items if needed, ignoring."
)
return
for item in self.items:
if isinstance(item, TextContent):
item.text = value
item.encoding = self.encoding
return
self.items.append(
TextContent(
ai_model_id=self.ai_model_id,
inner_content=self.inner_content,
metadata=self.metadata,
text=value,
encoding=self.encoding,
)
)
def __str__(self) -> str:
"""Get the content of the response as a string."""
return self.content or ""
def to_element(self) -> "Element":
"""Convert the ChatMessageContent to an XML Element.
Args:
root_key: str - The key to use for the root of the XML Element.
Returns:
Element - The XML Element representing the ChatMessageContent.
"""
root = Element(self.tag)
for field in self.model_fields_set:
if field not in ["role", "name", "encoding", "finish_reason", "ai_model_id"]:
continue
value = getattr(self, field)
if isinstance(value, Enum):
value = value.value
root.set(field, value)
for index, item in enumerate(self.items):
root.insert(index, item.to_element())
return root
@classmethod
def from_element(cls, element: Element) -> "ChatMessageContent":
"""Create a new instance of ChatMessageContent from an XML element.
Args:
element: Element - The XML Element to create the ChatMessageContent from.
Returns:
ChatMessageContent - The new instance of ChatMessageContent or a subclass.
"""
if element.tag != cls.tag:
raise ContentInitializationError(f"Element tag is not {cls.tag}") # pragma: no cover
kwargs: dict[str, Any] = {key: value for key, value in element.items()}
items: list[KernelContent] = []
if element.text:
items.append(TextContent(text=unescape(element.text)))
for child in element:
if child.tag not in TAG_CONTENT_MAP:
logger.warning('Unknown tag "%s" in ChatMessageContent, treating as text', child.tag)
text = ElementTree.tostring(child, encoding="unicode", short_empty_elements=False)
items.append(TextContent(text=unescape(text) or ""))
else:
items.append(TAG_CONTENT_MAP[child.tag].from_element(child)) # type: ignore
if len(items) == 1 and isinstance(items[0], TextContent):
kwargs["content"] = items[0].text
elif all(isinstance(item, TextContent) for item in items):
kwargs["content"] = "".join(item.text for item in items) # type: ignore
else:
kwargs["items"] = items
if "choice_index" in kwargs and cls is ChatMessageContent:
logger.info(
"Seems like you are trying to create a StreamingChatMessageContent, "
"use StreamingChatMessageContent.from_element instead, ignoring that field "
"and creating a ChatMessageContent instance."
)
kwargs.pop("choice_index")
return cls(**kwargs)
def to_prompt(self) -> str:
"""Convert the ChatMessageContent to a prompt.
Returns:
str - The prompt from the ChatMessageContent.
"""
root = self.to_element()
return ElementTree.tostring(root, encoding=self.encoding or "unicode", short_empty_elements=False)
def to_dict(self, role_key: str = "role", content_key: str = "content") -> dict[str, Any]:
"""Serialize the ChatMessageContent to a dictionary.
Returns:
dict - The dictionary representing the ChatMessageContent.
"""
ret: dict[str, Any] = {
role_key: self.role.value,
}
if self.role == AuthorRole.ASSISTANT and any(isinstance(item, FunctionCallContent) for item in self.items):
ret["tool_calls"] = [item.to_dict() for item in self.items if isinstance(item, FunctionCallContent)]
else:
ret[content_key] = self._parse_items()
if self.role == AuthorRole.TOOL:
assert isinstance(self.items[0], FunctionResultContent) # nosec
ret["tool_call_id"] = self.items[0].id or ""
if self.role != AuthorRole.TOOL and self.name:
ret["name"] = self.name
return ret
def _parse_items(self) -> str | list[dict[str, Any]]:
"""Parse the items of the ChatMessageContent.
Returns:
str | list of dicts - The parsed items.
"""
if len(self.items) == 1 and isinstance(self.items[0], TextContent):
return self.items[0].text
if len(self.items) == 1 and isinstance(self.items[0], FunctionResultContent):
return str(self.items[0].result)
return [item.to_dict() for item in self.items]
def __hash__(self) -> int:
"""Return the hash of the chat message content."""
hashable_items = [make_hashable(item) for item in self.items] if self.items else []
return hash((self.tag, self.role, self.content, self.encoding, self.finish_reason, *hashable_items))
+35
View File
@@ -0,0 +1,35 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from typing import Final
AUDIO_CONTENT_TAG: Final[str] = "audio"
CHAT_MESSAGE_CONTENT_TAG: Final[str] = "message"
CHAT_HISTORY_TAG: Final[str] = "chat_history"
TEXT_CONTENT_TAG: Final[str] = "text"
IMAGE_CONTENT_TAG: Final[str] = "image"
ANNOTATION_CONTENT_TAG: Final[str] = "annotation"
STREAMING_ANNOTATION_CONTENT_TAG: Final[str] = "streaming_annotation"
BINARY_CONTENT_TAG: Final[str] = "binary"
FILE_REFERENCE_CONTENT_TAG: Final[str] = "file_reference"
STREAMING_FILE_REFERENCE_CONTENT_TAG: Final[str] = "streaming_file_reference"
FUNCTION_CALL_CONTENT_TAG: Final[str] = "function_call"
FUNCTION_RESULT_CONTENT_TAG: Final[str] = "function_result"
REASONING_CONTENT_TAG: Final[str] = "reasoning"
DISCRIMINATOR_FIELD: Final[str] = "content_type"
class ContentTypes(str, Enum):
"""Content types enumeration."""
AUDIO_CONTENT = AUDIO_CONTENT_TAG
ANNOTATION_CONTENT = ANNOTATION_CONTENT_TAG
BINARY_CONTENT = BINARY_CONTENT_TAG
CHAT_MESSAGE_CONTENT = CHAT_MESSAGE_CONTENT_TAG
IMAGE_CONTENT = IMAGE_CONTENT_TAG
FILE_REFERENCE_CONTENT = FILE_REFERENCE_CONTENT_TAG
FUNCTION_CALL_CONTENT = FUNCTION_CALL_CONTENT_TAG
FUNCTION_RESULT_CONTENT = FUNCTION_RESULT_CONTENT_TAG
REASONING_CONTENT = REASONING_CONTENT_TAG
STREAMING_ANNOTATION_CONTENT = STREAMING_ANNOTATION_CONTENT_TAG
STREAMING_FILE_REFERENCE_CONTENT = STREAMING_FILE_REFERENCE_CONTENT_TAG
TEXT_CONTENT = TEXT_CONTENT_TAG
@@ -0,0 +1,50 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import Any, ClassVar, Literal, TypeVar
from xml.etree.ElementTree import Element # nosec
from pydantic import Field
from semantic_kernel.contents.const import FILE_REFERENCE_CONTENT_TAG, ContentTypes
from semantic_kernel.contents.kernel_content import KernelContent
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
_T = TypeVar("_T", bound="FileReferenceContent")
@experimental
class FileReferenceContent(KernelContent):
"""File reference content."""
content_type: Literal[ContentTypes.FILE_REFERENCE_CONTENT] = Field(FILE_REFERENCE_CONTENT_TAG, init=False) # type: ignore
tag: ClassVar[str] = FILE_REFERENCE_CONTENT_TAG
file_id: str | None = None
tools: list[Any] = Field(default_factory=list)
data_source: Any | None = None
def __str__(self) -> str:
"""Return the string representation of the file reference content."""
return f"FileReferenceContent(file_id={self.file_id})"
def to_element(self) -> Element:
"""Convert the file reference content to an Element."""
element = Element(self.tag)
if self.file_id:
element.set("file_id", self.file_id)
return element
@classmethod
def from_element(cls: type[_T], element: Element) -> _T:
"""Create an instance from an Element."""
return cls(
file_id=element.get("file_id"),
)
def to_dict(self) -> dict[str, Any]:
"""Convert the instance to a dictionary."""
return {
"file_id": self.file_id,
}
@@ -0,0 +1,239 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import logging
import re
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, TypeVar
from xml.etree.ElementTree import Element # nosec
from pydantic import Field
from typing_extensions import deprecated
from semantic_kernel.const import DEFAULT_FULLY_QUALIFIED_NAME_SEPARATOR
from semantic_kernel.contents.const import FUNCTION_CALL_CONTENT_TAG, ContentTypes
from semantic_kernel.contents.kernel_content import KernelContent
from semantic_kernel.exceptions import (
ContentAdditionException,
ContentInitializationError,
FunctionCallInvalidArgumentsException,
FunctionCallInvalidNameException,
)
if TYPE_CHECKING:
from semantic_kernel.functions.kernel_arguments import KernelArguments
logger = logging.getLogger(__name__)
_T = TypeVar("_T", bound="FunctionCallContent")
EMPTY_VALUES: Final[list[str | None]] = ["", "{}", None]
class FunctionCallContent(KernelContent):
"""Class to hold a function call response."""
content_type: Literal[ContentTypes.FUNCTION_CALL_CONTENT] = Field(FUNCTION_CALL_CONTENT_TAG, init=False) # type: ignore
tag: ClassVar[str] = FUNCTION_CALL_CONTENT_TAG
id: str | None
call_id: str | None = None
index: int | None = None
name: str | None = None
function_name: str
plugin_name: str | None = None
arguments: str | Mapping[str, Any] | None = None
def __init__(
self,
inner_content: Any | None = None,
ai_model_id: str | None = None,
id: str | None = None,
index: int | None = None,
name: str | None = None,
function_name: str | None = None,
plugin_name: str | None = None,
arguments: str | Mapping[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
call_id: str | None = None,
**kwargs: Any,
) -> None:
"""Create function call content.
Args:
inner_content (Any | None): The inner content.
ai_model_id (str | None): The id of the AI model.
id (str | None): The id of the function call.
index (int | None): The index of the function call.
name (str | None): The name of the function call.
When not supplied function_name and plugin_name should be supplied.
function_name (str | None): The function name.
Not used when 'name' is supplied.
plugin_name (str | None): The plugin name.
Not used when 'name' is supplied.
arguments (str | dict[str, Any] | None): The arguments of the function call.
metadata (dict[str, Any] | None): The metadata of the function call.
call_id (str | None): The id of the call.
kwargs (Any): Additional arguments.
"""
if function_name and plugin_name and not name:
name = f"{plugin_name}{DEFAULT_FULLY_QUALIFIED_NAME_SEPARATOR}{function_name}"
if name and not function_name and not plugin_name:
if DEFAULT_FULLY_QUALIFIED_NAME_SEPARATOR in name:
plugin_name, function_name = name.split(DEFAULT_FULLY_QUALIFIED_NAME_SEPARATOR, maxsplit=1)
else:
function_name = name
args = {
"inner_content": inner_content,
"ai_model_id": ai_model_id,
"id": id,
"call_id": call_id,
"index": index,
"name": name,
"function_name": function_name or "",
"plugin_name": plugin_name,
"arguments": arguments,
}
if metadata:
args["metadata"] = metadata
super().__init__(**args)
def __str__(self) -> str:
"""Return the function call as a string."""
if isinstance(self.arguments, dict):
return f"{self.name}({json.dumps(self.arguments)})"
return f"{self.name}({self.arguments})"
def __add__(self, other: "FunctionCallContent | None") -> "FunctionCallContent":
"""Add two function calls together, combines the arguments, ignores the name.
When both function calls have a dict as arguments, the arguments are merged,
which means that the arguments of the second function call
will overwrite the arguments of the first function call if the same key is present.
When one of the two arguments are a dict and the other a string, we raise a ContentAdditionException.
"""
if not other:
return self
if self.id and other.id and self.id != other.id:
raise ContentAdditionException("Function calls have different ids.")
if self.index != other.index:
raise ContentAdditionException("Function calls have different indexes.")
if self.call_id and other.call_id and self.call_id != other.call_id:
raise ContentAdditionException("Function calls have different call ids.")
return FunctionCallContent(
id=self.id or other.id,
call_id=self.call_id or other.call_id,
index=self.index or other.index,
name=self.name or other.name,
arguments=self.combine_arguments(self.arguments, other.arguments),
metadata=self.metadata | other.metadata,
)
def combine_arguments(
self, arg1: str | Mapping[str, Any] | None, arg2: str | Mapping[str, Any] | None
) -> str | Mapping[str, Any]:
"""Combine two arguments."""
if isinstance(arg1, Mapping) and isinstance(arg2, Mapping):
return {**arg1, **arg2}
# when one of the two is a dict, and the other isn't, we raise.
if isinstance(arg1, Mapping) or isinstance(arg2, Mapping):
raise ContentAdditionException("Cannot combine a dict with a string.")
if arg1 in EMPTY_VALUES and arg2 in EMPTY_VALUES:
return "{}"
if arg1 in EMPTY_VALUES:
return arg2 or "{}"
if arg2 in EMPTY_VALUES:
return arg1 or "{}"
return (arg1 or "") + (arg2 or "")
def parse_arguments(self) -> Mapping[str, Any] | None:
"""Parse the arguments into a dictionary."""
if not self.arguments:
return None
if isinstance(self.arguments, Mapping):
return self.arguments
try:
return json.loads(self.arguments)
except json.JSONDecodeError as exc:
logger.debug("Function Call arguments are not valid JSON. Trying to preprocess.")
try:
# Python strings can be single quoted, but JSON strings should be double quoted.
# JSON keys and values should be enclosed in double quotes.
# Replace single quotes with double quotes, but not if it's an escaped single quote.
return json.loads(re.sub(r"(?<!\\)'", '"', self.arguments).replace("\\'", "'"))
except json.JSONDecodeError:
raise FunctionCallInvalidArgumentsException(
"Function Call arguments are not valid JSON even after preprocessing."
) from exc
def to_kernel_arguments(self) -> "KernelArguments":
"""Return the arguments as a KernelArguments instance."""
from semantic_kernel.functions.kernel_arguments import KernelArguments
args = self.parse_arguments()
if not args:
return KernelArguments()
return KernelArguments(**args)
@deprecated("The function_name and plugin_name properties should be used instead.")
def split_name(self) -> list[str | None]:
"""Split the name into a plugin and function name."""
if not self.function_name:
raise FunctionCallInvalidNameException("Function name is not set.")
return [self.plugin_name or "", self.function_name]
@deprecated("The function_name and plugin_name properties should be used instead.")
def split_name_dict(self) -> dict:
"""Split the name into a plugin and function name."""
return {"plugin_name": self.plugin_name, "function_name": self.function_name}
def custom_fully_qualified_name(self, separator: str) -> str:
"""Get the fully qualified name of the function with a custom separator.
Args:
separator (str): The custom separator.
Returns:
The fully qualified name of the function with a custom separator.
"""
return f"{self.plugin_name}{separator}{self.function_name}" if self.plugin_name else self.function_name
def to_element(self) -> Element:
"""Convert the function call to an Element."""
element = Element(self.tag)
if self.id:
element.set("id", self.id)
if self.name:
element.set("name", self.name)
if self.arguments:
element.text = json.dumps(self.arguments) if isinstance(self.arguments, Mapping) else self.arguments
return element
@classmethod
def from_element(cls: type[_T], element: Element) -> _T:
"""Create an instance from an Element."""
if element.tag != cls.tag:
raise ContentInitializationError(f"Element tag is not {cls.tag}") # pragma: no cover
return cls(name=element.get("name"), id=element.get("id"), arguments=element.text or "")
def to_dict(self) -> dict[str, str | Any]:
"""Convert the instance to a dictionary."""
args = json.dumps(self.arguments) if isinstance(self.arguments, Mapping) else self.arguments
return {"id": self.id, "type": "function", "function": {"name": self.name, "arguments": args}}
def __hash__(self) -> int:
"""Return the hash of the function call content."""
args_hashable = frozenset(self.arguments.items()) if isinstance(self.arguments, Mapping) else None
return hash((
self.tag,
self.id,
self.call_id,
self.index,
self.name,
self.function_name,
self.plugin_name,
args_hashable,
))
@@ -0,0 +1,212 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
from xml.etree.ElementTree import Element # nosec
from pydantic import Field, field_serializer
from typing_extensions import deprecated
from semantic_kernel.const import DEFAULT_FULLY_QUALIFIED_NAME_SEPARATOR
from semantic_kernel.contents.const import FUNCTION_RESULT_CONTENT_TAG, TEXT_CONTENT_TAG, ContentTypes
from semantic_kernel.contents.image_content import ImageContent
from semantic_kernel.contents.kernel_content import KernelContent
from semantic_kernel.contents.text_content import TextContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.contents.utils.hashing import make_hashable
from semantic_kernel.exceptions.content_exceptions import ContentInitializationError
if TYPE_CHECKING:
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
from semantic_kernel.functions.function_result import FunctionResult
TAG_CONTENT_MAP = {
TEXT_CONTENT_TAG: TextContent,
}
_T = TypeVar("_T", bound="FunctionResultContent")
class FunctionResultContent(KernelContent):
"""This class represents function result content."""
content_type: Literal[ContentTypes.FUNCTION_RESULT_CONTENT] = Field(FUNCTION_RESULT_CONTENT_TAG, init=False) # type: ignore
tag: ClassVar[str] = FUNCTION_RESULT_CONTENT_TAG
id: str | None = None
call_id: str | None = None
result: Any
name: str | None = None
function_name: str
plugin_name: str | None = None
encoding: str | None = None
def __init__(
self,
inner_content: Any | None = None,
ai_model_id: str | None = None,
id: str | None = None,
call_id: str | None = None,
name: str | None = None,
function_name: str | None = None,
plugin_name: str | None = None,
result: Any | None = None,
encoding: str | None = None,
metadata: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Create function result content.
Args:
inner_content (Any | None): The inner content.
ai_model_id (str | None): The id of the AI model.
id (str | None): The id of the function call that the result relates to.
call_id (str | None): The call id of the function call from the Responses API.
name (str | None): The name of the function.
When not supplied function_name and plugin_name should be supplied.
function_name (str | None): The function name.
Not used when 'name' is supplied.
plugin_name (str | None): The plugin name.
Not used when 'name' is supplied.
result (Any | None): The result of the function.
encoding (str | None): The encoding of the result.
metadata (dict[str, Any] | None): The metadata of the function call.
kwargs (Any): Additional arguments.
"""
if function_name and plugin_name and not name:
name = f"{plugin_name}{DEFAULT_FULLY_QUALIFIED_NAME_SEPARATOR}{function_name}"
if name and not function_name and not plugin_name:
if DEFAULT_FULLY_QUALIFIED_NAME_SEPARATOR in name:
plugin_name, function_name = name.split(DEFAULT_FULLY_QUALIFIED_NAME_SEPARATOR, maxsplit=1)
else:
function_name = name
args = {
"inner_content": inner_content,
"ai_model_id": ai_model_id,
"id": id,
"name": name,
"function_name": function_name or "",
"plugin_name": plugin_name,
"result": result,
"encoding": encoding,
}
if call_id:
args["call_id"] = call_id
if metadata:
args["metadata"] = metadata
super().__init__(**args)
def __str__(self) -> str:
"""Return the text of the response."""
return str(self.result)
def to_element(self) -> Element:
"""Convert the instance to an Element."""
element = Element(self.tag)
if self.id:
element.set("id", self.id)
if self.name:
element.set("name", self.name)
element.text = str(self.result)
return element
@classmethod
def from_element(cls: type[_T], element: Element) -> _T:
"""Create an instance from an Element."""
if element.tag != cls.tag:
raise ContentInitializationError(f"Element tag is not {cls.tag}") # pragma: no cover
return cls(id=element.get("id", ""), result=element.text, name=element.get("name", None))
@classmethod
def from_function_call_content_and_result(
cls: type[_T],
function_call_content: "FunctionCallContent",
result: "FunctionResult | TextContent | ChatMessageContent | Any",
metadata: dict[str, Any] | None = None,
) -> _T:
"""Create an instance from a FunctionCallContent and a result."""
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.functions.function_result import FunctionResult
metadata = metadata or {}
metadata = metadata | (function_call_content.metadata or {})
metadata = metadata | getattr(result, "metadata", {})
inner_content = result
if isinstance(result, FunctionResult):
result = result.value
if isinstance(result, TextContent):
res = result.text
elif isinstance(result, ChatMessageContent):
if isinstance(result.items[0], TextContent):
res = result.items[0].text
elif isinstance(result.items[0], ImageContent):
res = result.items[0].data_uri
elif isinstance(result.items[0], FunctionResultContent):
res = result.items[0].result
res = str(result)
else:
res = result
return cls(
id=function_call_content.id or "unknown",
call_id=function_call_content.call_id if hasattr(function_call_content, "call_id") else None,
inner_content=inner_content,
result=res,
function_name=function_call_content.function_name,
plugin_name=function_call_content.plugin_name,
ai_model_id=function_call_content.ai_model_id,
metadata=metadata,
)
def to_chat_message_content(self) -> "ChatMessageContent":
"""Convert the instance to a ChatMessageContent."""
from semantic_kernel.contents.chat_message_content import ChatMessageContent
return ChatMessageContent(role=AuthorRole.TOOL, items=[self])
def to_streaming_chat_message_content(self) -> "StreamingChatMessageContent":
"""Convert the instance to a StreamingChatMessageContent."""
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
return StreamingChatMessageContent(role=AuthorRole.TOOL, choice_index=0, items=[self])
def to_dict(self) -> dict[str, str | Any]:
"""Convert the instance to a dictionary."""
return {
"tool_call_id": self.id,
"content": self.result,
}
@deprecated("The function_name and plugin_name attributes should be used instead.")
def split_name(self) -> list[str]:
"""Split the name into a plugin and function name."""
return [self.plugin_name or "", self.function_name]
def custom_fully_qualified_name(self, separator: str) -> str:
"""Get the fully qualified name of the function with a custom separator.
Args:
separator (str): The custom separator.
Returns:
The fully qualified name of the function with a custom separator.
"""
return f"{self.plugin_name}{separator}{self.function_name}" if self.plugin_name else self.function_name
@field_serializer("result")
def serialize_result(self, value: Any) -> str:
"""Serialize the result."""
return str(value)
def __hash__(self) -> int:
"""Return the hash of the function result content."""
hashable_result = make_hashable(self.result)
return hash((
self.tag,
self.id,
hashable_result,
self.name,
self.function_name,
self.plugin_name,
self.encoding,
))
@@ -0,0 +1,63 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from abc import ABC, abstractmethod
from typing import Any
if sys.version < "3.11":
from typing_extensions import Self # pragma: no cover
else:
from typing import Self # type: ignore # pragma: no cover
from pydantic import Field
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.exceptions.content_exceptions import ContentInitializationError
from semantic_kernel.utils.feature_stage_decorator import experimental
@experimental
class ChatHistoryReducer(ChatHistory, ABC):
"""Defines a contract for reducing chat history."""
target_count: int = Field(..., gt=0, description="Target message count.")
threshold_count: int = Field(default=0, ge=0, description="Threshold count to avoid orphaning messages.")
auto_reduce: bool = Field(
default=False,
description="Whether to automatically reduce the chat history, this happens when using add_message_async.",
)
@abstractmethod
async def reduce(self) -> Self | None:
"""Reduce the chat history in some way (e.g., truncate, summarize).
Returns:
A possibly shorter list of messages, or None if no change is needed.
"""
...
async def add_message_async(
self,
message: ChatMessageContent | dict[str, Any],
encoding: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
"""Add a message to the chat history.
If auto_reduce is enabled, the history will be reduced after adding the message.
"""
if isinstance(message, ChatMessageContent):
self.messages.append(message)
if self.auto_reduce:
await self.reduce()
return
if "role" not in message:
raise ContentInitializationError(f"Dictionary must contain at least the role. Got: {message}")
if encoding:
message["encoding"] = encoding
if metadata:
message["metadata"] = metadata
self.messages.append(ChatMessageContent(**message))
if self.auto_reduce:
await self.reduce()
@@ -0,0 +1,248 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from collections.abc import Callable
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
SUMMARY_METADATA_KEY = "__summary__"
@experimental
def get_call_result_pairs(history: list[ChatMessageContent]) -> list[tuple[int, int]]:
"""Identify all (FunctionCallContent, FunctionResultContent) pairs in the history.
Return a list of (call_index, result_index) pairs for safe referencing.
"""
pairs: list[tuple[int, int]] = [] # Correct type: list of tuples with integers
call_ids_seen: dict[str, int] = {} # Map call IDs (str) to their indices (int)
# Gather all function-call IDs and their indices.
for i, msg in enumerate(history):
for item in msg.items:
if isinstance(item, FunctionCallContent) and item.id is not None:
call_ids_seen[item.id] = i
# Now, match each FunctionResultContent to the earliest call ID with the same ID.
for j, msg in enumerate(history):
for item in msg.items:
if isinstance(item, FunctionResultContent) and item.id is not None:
call_id = item.id
if call_id in call_ids_seen:
call_index = call_ids_seen[call_id]
pairs.append((call_index, j))
# Remove the call ID so we don't match it a second time
del call_ids_seen[call_id]
break
return pairs
@experimental
def locate_summarization_boundary(history: list[ChatMessageContent]) -> int:
"""Identify the index of the first message that is not a summary message.
This is indicated by the presence of the SUMMARY_METADATA_KEY in the message metadata.
Returns:
The insertion point index for normal history messages (i.e., after all summary messages).
"""
for idx, msg in enumerate(history):
if not msg.metadata or SUMMARY_METADATA_KEY not in msg.metadata:
return idx
return len(history)
@experimental
def locate_safe_reduction_index(
history: list[ChatMessageContent],
target_count: int,
threshold_count: int = 0,
offset_count: int = 0,
has_system_message: bool = False,
) -> int | None:
"""Identify the index of the first message at or beyond the specified target_count.
This index does not orphan sensitive content (function calls/results).
This method ensures that the presence of a function-call always follows with its result,
so the function-call and its function-result are never separated.
In addition, it attempts to locate a user message within the threshold window so that
context with the subsequent assistant response is preserved.
Args:
history: The entire chat history.
target_count: The desired message count after reduction.
threshold_count: The threshold beyond target_count required to trigger reduction.
If total messages <= (target_count + threshold_count), no reduction occurs.
offset_count: Optional number of messages to skip at the start (e.g. existing summary messages).
has_system_message: Whether the history contains a system message that will be preserved
separately. When True, the target_count is adjusted to account for the
system message being re-added after reduction.
Returns:
The index that identifies the starting point for a reduced history that does not orphan
sensitive content. Returns None if reduction is not needed.
"""
# Adjust target_count to account for the system message that will be preserved separately.
# This matches the .NET SDK behavior.
if has_system_message:
target_count -= 1
if target_count <= 0:
logger.warning(
"target_count after accounting for system message is %d; reduction will keep only the system message.",
target_count,
)
# Reduce to just the system message — return index past all non-system messages.
# The caller will prepend the system message to the empty/minimal tail.
return len(history)
total_count = len(history)
threshold_index = total_count - (threshold_count or 0) - target_count
if threshold_index <= offset_count:
return None
message_index = total_count - target_count
# Move backward to avoid cutting function calls / results
# Stop if we encounter developer/system or a non-call/result message
while message_index >= offset_count:
msg = history[message_index]
if msg.role in (AuthorRole.DEVELOPER, AuthorRole.SYSTEM):
break
# If current is not a call/result, we've reached a safe boundary
if not contains_function_call_or_result(msg):
break
# Avoid stepping back past a user message boundary when current is a call/result
prev_idx = message_index - 1
if (prev_idx < offset_count) or not contains_function_call_or_result(history[prev_idx]):
break
message_index -= 1
# This is our initial target truncation index
target_index = message_index
# Attempt to see if there's a user message in the threshold window
while message_index >= threshold_index:
if history[message_index].role == AuthorRole.USER:
return message_index
message_index -= 1
return target_index
@experimental
def extract_range(
history: list[ChatMessageContent],
start: int,
end: int | None = None,
filter_func: Callable[[ChatMessageContent], bool] | None = None,
preserve_pairs: bool = False,
) -> list[ChatMessageContent]:
"""Extract a range of messages from the source history, skipping any message for which we do not want to keep.
For example, function calls/results, if desired.
Args:
history: The source history.
start: The index of the first message to extract (inclusive).
end: The index of the last message to extract (exclusive). If None, extracts through end.
filter_func: A function that takes a ChatMessageContent and returns True if the message should
be skipped, False otherwise.
preserve_pairs: If True, ensures that function call and result pairs are either both kept or both skipped.
Returns:
A list of extracted messages.
"""
if end is None:
end = len(history)
sliced = list(range(start, end))
# If we need to preserve call->result pairs, gather them
pair_map = {}
if preserve_pairs:
pairs = get_call_result_pairs(history)
# store in a dict for quick membership checking
# call_idx -> result_idx, and also result_idx -> call_idx
for cidx, ridx in pairs:
pair_map[cidx] = ridx
pair_map[ridx] = cidx
extracted: list[ChatMessageContent] = []
i = 0
while i < len(sliced):
idx = sliced[i]
msg = history[idx]
# If filter_func excludes it, skip it
if filter_func and filter_func(msg):
i += 1
continue
# skipping system/developer message
if msg.role in (AuthorRole.DEVELOPER, AuthorRole.SYSTEM):
i += 1
continue
# If preserve_pairs is on, and there's a paired index, skip or include them both
if preserve_pairs and idx in pair_map:
paired_idx = pair_map[idx]
# If the pair is within [start, end), we must keep or skip them together
if start <= paired_idx < end:
# Check if the pair or itself fails filter_func
if filter_func and (filter_func(history[paired_idx]) or filter_func(msg)):
# skip both
i += 1
# Also skip the paired index if it's in our current slice
if paired_idx in sliced:
# remove it from the slice so we don't process it again
sliced.remove(paired_idx)
continue
# keep both
extracted.append(msg)
if paired_idx > idx:
# We'll skip the pair in the normal iteration by removing from slice
# but add it to extracted right now
extracted.append(history[paired_idx])
if paired_idx in sliced:
sliced.remove(paired_idx)
else:
# if paired_idx < idx, it might appear later, so skip for now
# but we may have already processed it if i was the 2nd item
# either way, do not add duplicates
pass
i += 1
continue
# If the paired_idx is outside [start, end), there's no conflict
# so we can just do normal logic
extracted.append(msg)
i += 1
else:
# keep it if filter_func not triggered
extracted.append(msg)
i += 1
return extracted
@experimental
def contains_function_call_or_result(msg: ChatMessageContent) -> bool:
"""Return True if the message has any function call or function result.
Also returns True for TOOL role messages, which are always responses to
a preceding assistant message with tool_calls and must not be separated
from it.
"""
if msg.role == AuthorRole.TOOL:
return True
return any(isinstance(item, (FunctionCallContent, FunctionResultContent)) for item in msg.items)
@@ -0,0 +1,216 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
if sys.version < "3.11":
from typing_extensions import Self # pragma: no cover
else:
from typing import Self # type: ignore # pragma: no cover
if sys.version < "3.12":
from typing_extensions import override # pragma: no cover
else:
from typing import override # type: ignore # pragma: no cover
from pydantic import Field
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
from semantic_kernel.contents.chat_history import ChatHistory
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.history_reducer.chat_history_reducer import ChatHistoryReducer
from semantic_kernel.contents.history_reducer.chat_history_reducer_utils import (
SUMMARY_METADATA_KEY,
contains_function_call_or_result,
extract_range,
locate_safe_reduction_index,
locate_summarization_boundary,
)
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.exceptions.content_exceptions import ChatHistoryReducerException
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
DEFAULT_SUMMARIZATION_PROMPT = """
Provide a concise and complete summarization of the entire dialog that does not exceed 5 sentences.
This summary must always:
- Consider both user and assistant interactions
- Maintain continuity for the purpose of further dialog
- Include details from any existing summary
- Focus on the most significant aspects of the dialog
This summary must never:
- Critique, correct, interpret, presume, or assume
- Identify faults, mistakes, misunderstanding, or correctness
- Analyze what has not occurred
- Exclude details from any existing summary
"""
@experimental
class ChatHistorySummarizationReducer(ChatHistoryReducer):
"""A ChatHistory with logic to summarize older messages past a target count.
This class inherits from ChatHistoryReducer, which in turn inherits from ChatHistory.
It can be used anywhere a ChatHistory is expected, while adding summarization capability.
Args:
target_count: The target message count.
threshold_count: The threshold count to avoid orphaning messages.
auto_reduce: Whether to automatically reduce the chat history, default is False.
service: The ChatCompletion service to use for summarization.
summarization_instructions: The summarization instructions, optional.
use_single_summary: Whether to use a single summary message, default is True.
fail_on_error: Raise error if summarization fails, default is True.
include_function_content_in_summary: Whether to include function calls/results in the summary, default is False.
execution_settings: The execution settings for the summarization prompt, optional.
"""
service: ChatCompletionClientBase
summarization_instructions: str = Field(
default=DEFAULT_SUMMARIZATION_PROMPT,
description="The summarization instructions.",
kw_only=True,
)
use_single_summary: bool = Field(default=True, description="Whether to use a single summary message.")
fail_on_error: bool = Field(default=True, description="Raise error if summarization fails.")
include_function_content_in_summary: bool = Field(
default=False, description="Whether to include function calls/results in the summary."
)
execution_settings: PromptExecutionSettings | None = None
@override
async def reduce(self) -> Self | None:
history = self.messages
if len(history) <= self.target_count + (self.threshold_count or 0):
return None # No summarization needed
logger.info("Performing chat history summarization check...")
# Preserve system/developer messages so they are not lost during summarization.
# This matches the .NET SDK behavior and the truncation reducer.
# Only the first system/developer message is preserved; this mirrors .NET semantics.
# Exclude summary messages (which may have SYSTEM role) — they are generated content,
# not original system prompts.
system_message_index = next(
(
i
for i, msg in enumerate(history)
if msg.role in (AuthorRole.SYSTEM, AuthorRole.DEVELOPER) and not msg.metadata.get(SUMMARY_METADATA_KEY)
),
-1,
)
system_message = history[system_message_index] if system_message_index >= 0 else None
# 1. Identify where existing summary messages end
insertion_point = locate_summarization_boundary(history)
if insertion_point == len(history):
# fallback fix: force boundary to something reasonable
logger.warning("All messages are summaries, forcing boundary to 0.")
insertion_point = 0
# Only adjust target_count if the system message would be truncated away.
# If the system message is already in the retained portion, no adjustment needed.
system_would_be_truncated = (
system_message is not None and system_message_index < len(history) - self.target_count
)
# 2. Locate the safe reduction index
truncation_index = locate_safe_reduction_index(
history,
self.target_count,
self.threshold_count,
offset_count=insertion_point,
has_system_message=system_would_be_truncated,
)
if truncation_index is None:
logger.info("No valid truncation index found.")
return None
# 3. Extract only the chunk of messages that need summarizing
# If include_function_content_in_summary=False, skip function calls/results
# Otherwise, keep them but never split pairs.
messages_to_summarize = extract_range(
history,
start=0 if self.use_single_summary else insertion_point,
end=truncation_index,
filter_func=(contains_function_call_or_result if not self.include_function_content_in_summary else None),
preserve_pairs=self.include_function_content_in_summary,
)
if not messages_to_summarize:
logger.info("No messages to summarize.")
return None
try:
# 4. Summarize the extracted messages
summary_msg = await self._summarize(messages_to_summarize)
logger.info("Chat History Summarization completed.")
if not summary_msg:
return None
# Mark the newly-created summary with metadata
summary_msg.metadata[SUMMARY_METADATA_KEY] = True
# 5. Reassemble the new history
keep_existing_summaries = []
if insertion_point > 0 and not self.use_single_summary:
keep_existing_summaries = history[:insertion_point]
remainder = history[truncation_index:]
# Prepend the system/developer message if it was summarized away.
# Use identity comparison to avoid false matches from value-equal messages.
new_history = [*keep_existing_summaries, summary_msg, *remainder]
if system_message is not None and not any(m is system_message for m in new_history):
new_history = [system_message, *new_history]
self.messages = new_history
return self
except Exception as ex:
logger.warning("Summarization failed, continuing without summary.")
if self.fail_on_error:
raise ChatHistoryReducerException("Chat History Summarization failed.") from ex
return None
async def _summarize(self, messages: list[ChatMessageContent]) -> ChatMessageContent | None:
"""Use the ChatCompletion service to generate a single summary message."""
chat_history = ChatHistory(messages=messages)
execution_settings = self.execution_settings or self.service.get_prompt_execution_settings_from_settings(
PromptExecutionSettings()
)
chat_history.add_message(
ChatMessageContent(
role=getattr(execution_settings, "instruction_role", AuthorRole.SYSTEM),
content=self.summarization_instructions,
)
)
return await self.service.get_chat_message_content(chat_history=chat_history, settings=execution_settings)
def __eq__(self, other: object) -> bool:
"""Check if two ChatHistorySummarizationReducer objects are equal."""
if not isinstance(other, ChatHistorySummarizationReducer):
return False
return (
self.threshold_count == other.threshold_count
and self.target_count == other.target_count
and self.use_single_summary == other.use_single_summary
and self.summarization_instructions == other.summarization_instructions
)
def __hash__(self) -> int:
"""Hash the object based on its properties."""
return hash((
self.__class__.__name__,
self.threshold_count,
self.target_count,
self.summarization_instructions,
self.use_single_summary,
self.fail_on_error,
self.include_function_content_in_summary,
))
@@ -0,0 +1,105 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
if sys.version < "3.11":
from typing_extensions import Self # pragma: no cover
else:
from typing import Self # type: ignore # pragma: no cover
if sys.version < "3.12":
from typing_extensions import override # pragma: no cover
else:
from typing import override # type: ignore # pragma: no cover
from semantic_kernel.contents.history_reducer.chat_history_reducer import ChatHistoryReducer
from semantic_kernel.contents.history_reducer.chat_history_reducer_utils import (
locate_safe_reduction_index,
)
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
@experimental
class ChatHistoryTruncationReducer(ChatHistoryReducer):
"""A ChatHistory that supports truncation logic.
Because this class inherits from ChatHistoryReducer (which in turn inherits from ChatHistory),
it can also be used anywhere a ChatHistory is expected, while adding truncation capability.
Args:
target_count: The target message count.
threshold_count: The threshold count to avoid orphaning messages.
auto_reduce: Whether to automatically reduce the chat history, default is False.
"""
@override
async def reduce(self) -> Self | None:
history = self.messages
if len(history) <= self.target_count + (self.threshold_count or 0):
# No need to reduce
return None
logger.info("Performing chat history truncation check...")
# Preserve system/developer messages so they are not lost during truncation.
# This matches the .NET SDK behavior where system messages are always retained.
# Only the first system/developer message is preserved; this mirrors .NET semantics.
system_message_index = next(
(i for i, msg in enumerate(history) if msg.role in (AuthorRole.SYSTEM, AuthorRole.DEVELOPER)),
-1,
)
system_message = history[system_message_index] if system_message_index >= 0 else None
# Only adjust target_count if the system message would be truncated away
# (i.e., it falls before the naive tail). If the system message is already in the
# retained portion, no adjustment is needed — it naturally occupies a slot.
system_would_be_truncated = (
system_message is not None and system_message_index < len(history) - self.target_count
)
truncation_index = locate_safe_reduction_index(
history,
self.target_count,
self.threshold_count,
has_system_message=system_would_be_truncated,
)
if truncation_index is None:
logger.info(
f"No truncation index found. Target count: {self.target_count}, Threshold: {self.threshold_count}"
)
return None
logger.info(f"Truncating history to {truncation_index} messages.")
truncated_list = history[truncation_index:]
# Prepend the system/developer message if it was truncated away.
# Use identity comparison (is) to avoid false matches from value-equal messages.
if system_message is not None and all(msg is not system_message for msg in truncated_list):
truncated_list = [system_message, *truncated_list]
self.messages = truncated_list
return self
def __eq__(self, other: object) -> bool:
"""Compare equality based on truncation settings.
(We don't factor in the actual ChatHistory messages themselves.)
Returns:
True if the other object is a ChatHistoryTruncationReducer with the same truncation settings.
"""
if not isinstance(other, ChatHistoryTruncationReducer):
return False
return self.threshold_count == other.threshold_count and self.target_count == other.target_count
def __hash__(self) -> int:
"""Return a hash code based on truncation settings.
Returns:
A hash code based on the truncation settings.
"""
return hash((self.__class__.__name__, self.threshold_count, self.target_count))
@@ -0,0 +1,105 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import mimetypes
from typing import Any, ClassVar, Literal, TypeVar
from numpy import ndarray
from pydantic import Field
from typing_extensions import deprecated
from semantic_kernel.contents.binary_content import BinaryContent
from semantic_kernel.contents.const import IMAGE_CONTENT_TAG, ContentTypes
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
_T = TypeVar("_T", bound="ImageContent")
@experimental
class ImageContent(BinaryContent):
"""Image Content class.
This can be created either the bytes data or a data uri, additionally it can have a uri.
The uri is a reference to the source, and might or might not point to the same thing as the data.
Use the .from_image_file method to create an instance from a image file.
This reads the file and guesses the mime_type.
If both data_uri and data is provided, data will be used and a warning is logged.
Args:
uri (Url | None): The reference uri of the content.
data_uri (DataUrl | None): The data uri of the content.
data (str | bytes | None): The data of the content.
data_format (str | None): The format of the data (e.g. base64).
mime_type (str | None): The mime type of the image, only used with data.
kwargs (Any): Any additional arguments:
inner_content (Any): The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer can leverage the full thing.
ai_model_id (str | None): The id of the AI model that generated this response.
metadata (dict[str, Any]): Any metadata that should be attached to the response.
Methods:
from_image_path: Create an instance from an image file.
__str__: Returns the string representation of the image.
Raises:
ValidationError: If neither uri or data is provided.
"""
content_type: Literal[ContentTypes.IMAGE_CONTENT] = Field(IMAGE_CONTENT_TAG, init=False) # type: ignore
tag: ClassVar[str] = IMAGE_CONTENT_TAG
def __init__(
self,
uri: str | None = None,
data_uri: str | None = None,
data: str | bytes | ndarray | None = None,
data_format: str | None = None,
mime_type: str | None = None,
**kwargs: Any,
):
"""Create an Image Content object, either from a data_uri or data.
Args:
uri: The reference uri of the content.
data_uri: The data uri of the content.
data: The data of the content.
data_format: The format of the data (e.g. base64).
mime_type: The mime type of the image, only used with data.
kwargs: Any additional arguments:
inner_content: The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer
can leverage the full thing.
ai_model_id: The id of the AI model that generated this response.
metadata: Any metadata that should be attached to the response.
"""
super().__init__(
uri=uri,
data_uri=data_uri,
data=data,
data_format=data_format,
mime_type=mime_type,
**kwargs,
)
@classmethod
@deprecated("The `from_image_path` method is deprecated; use `from_image_file` instead.", category=None)
def from_image_path(cls: type[_T], image_path: str) -> _T:
"""Create an instance from an image file."""
return cls.from_image_file(image_path)
@classmethod
def from_image_file(cls: type[_T], path: str) -> _T:
"""Create an instance from an image file."""
mime_type = mimetypes.guess_type(path)[0]
with open(path, "rb") as image_file:
return cls(data=image_file.read(), data_format="base64", mime_type=mime_type, uri=path)
def to_dict(self) -> dict[str, Any]:
"""Convert the instance to a dictionary."""
return {"type": "image_url", "image_url": {"url": str(self)}}
@@ -0,0 +1,41 @@
# Copyright (c) Microsoft. All rights reserved.
from abc import ABC, abstractmethod
from typing import Annotated, Any, TypeVar
from pydantic import Field
from semantic_kernel.kernel_pydantic import KernelBaseModel
_T = TypeVar("_T", bound="KernelContent")
class KernelContent(KernelBaseModel, ABC):
"""Base class for all kernel contents."""
# NOTE: if you wish to hold on to the inner content, you are responsible
# for saving it before serializing the content/chat history as it won't be included.
inner_content: Annotated[Any | None, Field(exclude=True)] = None
ai_model_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
@abstractmethod
def __str__(self) -> str:
"""Return the string representation of the content."""
pass
@abstractmethod
def to_element(self) -> Any:
"""Convert the instance to an Element."""
pass
@classmethod
@abstractmethod
def from_element(cls: type[_T], element: Any) -> _T:
"""Create an instance from an Element."""
pass
@abstractmethod
def to_dict(self) -> dict[str, Any]:
"""Convert the instance to a dictionary."""
pass
@@ -0,0 +1,67 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Annotated, Any, ClassVar, Literal, Union
from pydantic import Field
from semantic_kernel.contents.audio_content import AudioContent
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.image_content import ImageContent
from semantic_kernel.contents.text_content import TextContent
from semantic_kernel.kernel_pydantic import KernelBaseModel
RealtimeEvents = Annotated[
Union[
"RealtimeEvent",
"RealtimeAudioEvent",
"RealtimeTextEvent",
"RealtimeFunctionCallEvent",
"RealtimeFunctionResultEvent",
"RealtimeImageEvent",
],
Field(discriminator="event_type"),
]
class RealtimeEvent(KernelBaseModel):
"""Base class for all service events."""
service_event: Any | None = Field(default=None, description="The event content.")
service_type: str | None = None
event_type: ClassVar[Literal["service"]] = "service"
class RealtimeAudioEvent(RealtimeEvent):
"""Audio event type."""
event_type: ClassVar[Literal["audio"]] = "audio" # type: ignore
audio: AudioContent = Field(..., description="Audio content.")
class RealtimeTextEvent(RealtimeEvent):
"""Text event type."""
event_type: ClassVar[Literal["text"]] = "text" # type: ignore
text: TextContent = Field(..., description="Text content.")
class RealtimeFunctionCallEvent(RealtimeEvent):
"""Function call event type."""
event_type: ClassVar[Literal["function_call"]] = "function_call" # type: ignore
function_call: FunctionCallContent = Field(..., description="Function call content.")
class RealtimeFunctionResultEvent(RealtimeEvent):
"""Function result event type."""
event_type: ClassVar[Literal["function_result"]] = "function_result" # type: ignore
function_result: FunctionResultContent = Field(..., description="Function result content.")
class RealtimeImageEvent(RealtimeEvent):
"""Image event type."""
event_type: ClassVar[Literal["image"]] = "image" # type: ignore
image: ImageContent = Field(..., description="Image content.")
@@ -0,0 +1,59 @@
# Copyright (c) Microsoft. All rights reserved.
"""Reasoning content model."""
from html import unescape
from typing import ClassVar, Literal, TypeVar
from xml.etree.ElementTree import Element # nosec
from pydantic import Field
from semantic_kernel.contents.const import REASONING_CONTENT_TAG, ContentTypes
from semantic_kernel.contents.kernel_content import KernelContent
from semantic_kernel.exceptions.content_exceptions import ContentInitializationError
_T = TypeVar("_T", bound="ReasoningContent")
class ReasoningContent(KernelContent):
"""Represents reasoning content.
Exposes a human-readable reasoning ``text``. Any provider-specific fields (for example: ids, encrypted blobs,
statuses, token info) must be carried in ``metadata`` on the base ``KernelContent``.
Attributes:
content_type: Literal identifying this instance as reasoning content.
tag: XML tag name used when serializing to/from XML.
text: The reasoning text to surface to callers.
Methods:
__str__: Return the reasoning text.
to_element: Serialize to an XML Element using ``tag`` and ``text``.
from_element: Deserialize from an XML Element into a ReasoningContent.
to_dict: Serialize to a dict suitable for message payloads.
"""
content_type: Literal[ContentTypes.REASONING_CONTENT] = Field(ContentTypes.REASONING_CONTENT, init=False)
tag: ClassVar[str] = REASONING_CONTENT_TAG
text: str | None = None
def __str__(self) -> str:
"""Return the text of the reasoning content."""
return self.text or ""
def to_element(self) -> Element:
"""Convert the instance to an XML Element."""
element = Element(self.tag)
element.text = self.text
return element
@classmethod
def from_element(cls: type[_T], element: Element) -> _T:
"""Create an instance from an XML Element."""
if element.tag != cls.tag:
raise ContentInitializationError(f"Element tag is not {cls.tag}") # pragma: no cover
return cls(text=unescape(element.text) if element.text else "")
def to_dict(self) -> dict[str, str]:
"""Convert the instance to a dictionary suitable for message serialization."""
return {"type": "reasoning", "text": self.text or ""}
@@ -0,0 +1,84 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import Any, ClassVar, Literal, TypeVar
from xml.etree.ElementTree import Element # nosec
from pydantic import ConfigDict, Field
from semantic_kernel.contents.annotation_content import CitationType
from semantic_kernel.contents.const import STREAMING_ANNOTATION_CONTENT_TAG, ContentTypes
from semantic_kernel.contents.kernel_content import KernelContent
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
_T = TypeVar("_T", bound="StreamingAnnotationContent")
@experimental
class StreamingAnnotationContent(KernelContent):
"""Streaming Annotation content."""
content_type: Literal[ContentTypes.STREAMING_ANNOTATION_CONTENT] = Field(
STREAMING_ANNOTATION_CONTENT_TAG, # type: ignore
init=False,
)
tag: ClassVar[str] = STREAMING_ANNOTATION_CONTENT_TAG
file_id: str | None = None
quote: str | None = None
start_index: int | None = None
end_index: int | None = None
url: str | None = None
title: str | None = None
citation_type: CitationType | None = Field(None, alias="type")
model_config = ConfigDict(
extra="ignore",
populate_by_name=True,
)
def __str__(self) -> str:
"""Return the string representation of the annotation content."""
ctype = self.citation_type.value if self.citation_type else None
return f"StreamingAnnotationContent(type={ctype}, file_id={self.file_id}, url={self.url}, quote={self.quote}, title={self.title}, start_index={self.start_index}, end_index={self.end_index})" # noqa: E501
def to_element(self) -> Element:
"""Convert the annotation content to an Element."""
element = Element(self.tag)
if self.citation_type:
element.set("type", self.citation_type)
if self.file_id:
element.set("file_id", self.file_id)
if self.quote:
element.set("quote", self.quote)
if self.start_index is not None:
element.set("start_index", str(self.start_index))
if self.end_index is not None:
element.set("end_index", str(self.end_index))
if self.url is not None:
element.set("url", self.url)
if self.title is not None:
element.set("title", self.title)
return element
@classmethod
def from_element(cls: type[_T], element: Element) -> _T:
"""Create an instance from an Element."""
return cls(
type=element.get("type"),
file_id=element.get("file_id"),
quote=element.get("quote"),
start_index=int(element.get("start_index")) if element.get("start_index") else None, # type: ignore
end_index=int(element.get("end_index")) if element.get("end_index") else None, # type: ignore
url=element.get("url") if element.get("url") else None, # type: ignore
title=element.get("title") if element.get("title") else None, # type: ignore
)
def to_dict(self) -> dict[str, Any]:
"""Convert the instance to a dictionary."""
ctype = self.citation_type.value if self.citation_type else None
return {
"type": "text",
"text": f"type={ctype}, {self.file_id or self.url}, quote={self.quote}, title={self.title} (Start Index={self.start_index}->End Index={self.end_index})", # noqa: E501
}
@@ -0,0 +1,243 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
from typing import Annotated, Any, overload
from xml.etree.ElementTree import Element # nosec
from pydantic import Field
from semantic_kernel.contents.audio_content import AudioContent
from semantic_kernel.contents.binary_content import BinaryContent
from semantic_kernel.contents.chat_message_content import ChatMessageContent
from semantic_kernel.contents.const import DISCRIMINATOR_FIELD
from semantic_kernel.contents.function_call_content import FunctionCallContent
from semantic_kernel.contents.function_result_content import FunctionResultContent
from semantic_kernel.contents.image_content import ImageContent
from semantic_kernel.contents.streaming_annotation_content import StreamingAnnotationContent
from semantic_kernel.contents.streaming_content_mixin import StreamingContentMixin
from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
from semantic_kernel.contents.utils.author_role import AuthorRole
from semantic_kernel.contents.utils.finish_reason import FinishReason
from semantic_kernel.contents.utils.hashing import make_hashable
from semantic_kernel.exceptions import ContentAdditionException
STREAMING_CMC_ITEM_TYPES = Annotated[
BinaryContent
| AudioContent
| ImageContent
| FunctionResultContent
| FunctionCallContent
| StreamingTextContent
| StreamingAnnotationContent
| StreamingFileReferenceContent,
Field(discriminator=DISCRIMINATOR_FIELD),
]
class StreamingChatMessageContent(ChatMessageContent, StreamingContentMixin):
"""This is the class for streaming chat message response content.
All Chat Completion Services should return an instance of this class as streaming response,
where each part of the response as it is streamed is converted to an instance of this class,
the end-user will have to either do something directly or gather them and combine them into a
new instance. A service can implement their own subclass of this class and return instances of that.
Args:
choice_index: int - The index of the choice that generated this response.
inner_content: Optional[Any] - The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer can leverage the full thing.
ai_model_id: Optional[str] - The id of the AI model that generated this response.
metadata: Dict[str, Any] - Any metadata that should be attached to the response.
role: Optional[ChatRole] - The role of the chat message, defaults to ASSISTANT.
content: Optional[str] - The text of the response.
encoding: Optional[str] - The encoding of the text.
Methods:
__str__: Returns the content of the response.
__bytes__: Returns the content of the response encoded in the encoding.
__add__: Combines two StreamingChatMessageContent instances.
"""
function_invoke_attempt: int | None = Field(
default=0,
description="Tracks the current attempt count for automatically invoking functions. "
"This value increments with each subsequent automatic invocation attempt.",
)
@overload
def __init__(
self,
role: AuthorRole,
items: list[STREAMING_CMC_ITEM_TYPES],
choice_index: int,
name: str | None = None,
inner_content: Any | None = None,
encoding: str | None = None,
finish_reason: FinishReason | None = None,
ai_model_id: str | None = None,
metadata: dict[str, Any] | None = None,
function_invoke_attempt: int | None = None,
) -> None: ...
@overload
def __init__(
self,
role: AuthorRole,
content: str,
choice_index: int,
name: str | None = None,
inner_content: Any | None = None,
encoding: str | None = None,
finish_reason: FinishReason | None = None,
ai_model_id: str | None = None,
metadata: dict[str, Any] | None = None,
function_invoke_attempt: int | None = None,
) -> None: ...
def __init__( # type: ignore
self,
role: AuthorRole,
choice_index: int,
items: list[STREAMING_CMC_ITEM_TYPES] | None = None,
content: str | None = None,
inner_content: Any | None = None,
name: str | None = None,
encoding: str | None = None,
finish_reason: FinishReason | None = None,
ai_model_id: str | None = None,
metadata: dict[str, Any] | None = None,
function_invoke_attempt: int | None = None,
):
"""Create a new instance of StreamingChatMessageContent.
Args:
role: The role of the chat message.
choice_index: The index of the choice that generated this response.
items: The content.
content: The text of the response.
inner_content: The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer can leverage the full thing.
name: The name of the response.
encoding: The encoding of the text.
finish_reason: The reason the response was finished.
metadata: Any metadata that should be attached to the response.
ai_model_id: The id of the AI model that generated this response.
function_invoke_attempt: Tracks the current attempt count for automatically
invoking functions. This value increments with each subsequent automatic invocation attempt.
"""
kwargs: dict[str, Any] = {
"role": role,
"choice_index": choice_index,
"function_invoke_attempt": function_invoke_attempt,
}
if encoding:
kwargs["encoding"] = encoding
if finish_reason:
kwargs["finish_reason"] = finish_reason
if name:
kwargs["name"] = name
if content:
item = StreamingTextContent(
choice_index=choice_index,
ai_model_id=ai_model_id,
inner_content=inner_content,
metadata=metadata or {},
text=content,
encoding=encoding,
)
if items:
items.append(item)
else:
items = [item]
if items:
kwargs["items"] = items
if inner_content:
kwargs["inner_content"] = inner_content
if metadata:
kwargs["metadata"] = metadata
if ai_model_id:
kwargs["ai_model_id"] = ai_model_id
super().__init__(
**kwargs,
)
def __bytes__(self) -> bytes:
"""Return the content of the response encoded in the encoding."""
return self.content.encode(self.encoding if self.encoding else "utf-8") if self.content else b""
def __add__(self, other: "StreamingChatMessageContent") -> "StreamingChatMessageContent":
"""When combining two StreamingChatMessageContent instances, the content fields are combined.
The addition should follow these rules:
1. The inner_content of the two will be combined. If they are not lists, they will be converted to lists.
2. ai_model_id should be the same.
3. encoding should be the same.
4. role should be the same.
5. choice_index should be the same.
6. Metadata will be combined
"""
if not isinstance(other, StreamingChatMessageContent):
raise ContentAdditionException(
f"Cannot add other type to StreamingChatMessageContent, type supplied: {type(other)}"
)
if self.choice_index != other.choice_index:
raise ContentAdditionException("Cannot add StreamingChatMessageContent with different choice_index")
if self.ai_model_id != other.ai_model_id:
raise ContentAdditionException("Cannot add StreamingChatMessageContent from different ai_model_id")
if self.encoding != other.encoding:
raise ContentAdditionException("Cannot add StreamingChatMessageContent with different encoding")
if self.role and other.role and self.role != other.role:
raise ContentAdditionException("Cannot add StreamingChatMessageContent with different role")
return StreamingChatMessageContent(
role=self.role,
items=self._merge_items_lists(other.items),
choice_index=self.choice_index,
inner_content=self._merge_inner_contents(other.inner_content),
ai_model_id=self.ai_model_id,
metadata=self.metadata | other.metadata,
encoding=self.encoding,
finish_reason=self.finish_reason or other.finish_reason,
function_invoke_attempt=self.function_invoke_attempt,
name=self.name or other.name,
)
def to_element(self) -> "Element":
"""Convert the StreamingChatMessageContent to an XML Element.
Args:
root_key: str - The key to use for the root of the XML Element.
Returns:
Element - The XML Element representing the StreamingChatMessageContent.
"""
root = Element(self.tag)
for field in self.model_fields_set:
if field not in ["role", "name", "encoding", "finish_reason", "ai_model_id", "choice_index"]:
continue
value = getattr(self, field)
if isinstance(value, Enum):
value = value.value
if isinstance(value, int):
value = str(value)
root.set(field, value)
for index, item in enumerate(self.items):
root.insert(index, item.to_element())
return root
def __hash__(self) -> int:
"""Return the hash of the streaming chat message content."""
hashable_items = [make_hashable(item) for item in self.items] if self.items else []
return hash((
self.tag,
self.role,
self.content,
self.encoding,
self.finish_reason,
self.choice_index,
self.function_invoke_attempt,
*hashable_items,
))
@@ -0,0 +1,84 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
from abc import ABC, abstractmethod
from typing import Any
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
from semantic_kernel.exceptions.content_exceptions import ContentAdditionException
from semantic_kernel.kernel_pydantic import KernelBaseModel
logger: logging.Logger = logging.getLogger(__name__)
class StreamingContentMixin(KernelBaseModel, ABC):
"""Mixin class for all streaming kernel contents."""
choice_index: int
@abstractmethod
def __bytes__(self) -> bytes:
"""Return the content of the response encoded in the encoding."""
pass
@abstractmethod
def __add__(self, other: Any) -> Self:
"""Combine two streaming contents together."""
pass
def _merge_items_lists(self, other_items: list[Any]) -> list[Any]:
"""Create a new list with the items of the current instance and the given list."""
if not hasattr(self, "items"):
raise ContentAdditionException(f"Cannot merge items for this instance of type: {type(self)}")
# Create a copy of the items list to avoid modifying the original instance.
# Note that the items are not copied, only the list is.
new_items_list = self.items.copy()
if new_items_list or other_items:
for other_item in other_items:
added = False
for id, item in enumerate(new_items_list):
if type(item) is type(other_item) and hasattr(item, "__add__"):
try:
new_item = item + other_item # type: ignore
new_items_list[id] = new_item
added = True
except (ValueError, ContentAdditionException) as ex:
logger.debug(f"Could not add item {other_item} to {item}.", exc_info=ex)
continue
if not added:
logger.debug(f"Could not add item {other_item} to any item in the list. Adding it as a new item.")
new_items_list.append(other_item)
return new_items_list
def _merge_inner_contents(self, other_inner_content: Any | list[Any]) -> list[Any]:
"""Create a new list with the inner content of the current instance and the given one."""
if not hasattr(self, "inner_content"):
raise ContentAdditionException(f"Cannot merge inner content for this instance of type: {type(self)}")
# Create a copy of the inner content list to avoid modifying the original instance.
# Note that the inner content is not copied, only the list is.
# If the inner content is not a list, it is converted to a list.
if isinstance(self.inner_content, list):
new_inner_contents_list = self.inner_content.copy()
else:
new_inner_contents_list = [self.inner_content]
other_inner_content = (
other_inner_content
if isinstance(other_inner_content, list)
else [other_inner_content]
if other_inner_content
else []
)
new_inner_contents_list.extend(other_inner_content)
return new_inner_contents_list
@@ -0,0 +1,53 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from typing import Any, ClassVar, Literal, TypeVar
from xml.etree.ElementTree import Element # nosec
from pydantic import Field
from semantic_kernel.contents.const import STREAMING_FILE_REFERENCE_CONTENT_TAG, ContentTypes
from semantic_kernel.contents.kernel_content import KernelContent
from semantic_kernel.utils.feature_stage_decorator import experimental
logger = logging.getLogger(__name__)
_T = TypeVar("_T", bound="StreamingFileReferenceContent")
@experimental
class StreamingFileReferenceContent(KernelContent):
"""Streaming File reference content."""
content_type: Literal[ContentTypes.STREAMING_FILE_REFERENCE_CONTENT] = Field(
STREAMING_FILE_REFERENCE_CONTENT_TAG, # type: ignore
init=False,
)
tag: ClassVar[str] = STREAMING_FILE_REFERENCE_CONTENT_TAG
file_id: str | None = None
tools: list[Any] = Field(default_factory=list)
data_source: Any | None = None
def __str__(self) -> str:
"""Return the string representation of the file reference content."""
return f"StreamingFileReferenceContent(file_id={self.file_id})"
def to_element(self) -> Element:
"""Convert the file reference content to an Element."""
element = Element(self.tag)
if self.file_id:
element.set("file_id", self.file_id)
return element
@classmethod
def from_element(cls: type[_T], element: Element) -> _T:
"""Create an instance from an Element."""
return cls(
file_id=element.get("file_id"),
)
def to_dict(self) -> dict[str, Any]:
"""Convert the instance to a dictionary."""
return {
"file_id": self.file_id,
}
@@ -0,0 +1,50 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.contents.reasoning_content import ReasoningContent
from semantic_kernel.contents.streaming_content_mixin import StreamingContentMixin
from semantic_kernel.exceptions import ContentAdditionException
class StreamingReasoningContent(StreamingContentMixin, ReasoningContent):
"""This represents streaming reasoning response content.
Args:
choice_index: int - The index of the choice that generated this response.
inner_content: Optional[Any] - The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer can leverage the full thing.
ai_model_id: Optional[str] - The id of the AI model that generated this response.
metadata: Dict[str, Any] - Any metadata that should be attached to the response.
text: Optional[str] - The reasoning text of the response.
Methods:
__str__: Returns the text of the response.
__bytes__: Returns the content of the response encoded as UTF-8.
__add__: Combines two StreamingReasoningContent instances.
"""
def __bytes__(self) -> bytes:
"""Return the content of the response encoded as UTF-8."""
return self.text.encode("utf-8") if self.text else b""
def __add__(self, other: ReasoningContent) -> "StreamingReasoningContent":
"""When combining two StreamingReasoningContent instances, the text fields are combined.
The addition should follow these rules:
1. The inner_content of the two will be combined. If they are not lists, they will be converted to lists.
2. ai_model_id should be the same.
3. choice_index should be the same.
4. Metadata will be combined.
"""
if isinstance(other, StreamingReasoningContent) and self.choice_index != other.choice_index:
raise ContentAdditionException("Cannot add StreamingReasoningContent with different choice_index")
if self.ai_model_id != other.ai_model_id:
raise ContentAdditionException("Cannot add StreamingReasoningContent from different ai_model_id")
return StreamingReasoningContent(
choice_index=self.choice_index,
inner_content=self._merge_inner_contents(other.inner_content),
ai_model_id=self.ai_model_id,
metadata={**self.metadata, **(other.metadata or {})},
text=(self.text or "") + (other.text or ""),
)
@@ -0,0 +1,55 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.contents.streaming_content_mixin import StreamingContentMixin
from semantic_kernel.contents.text_content import TextContent
from semantic_kernel.exceptions import ContentAdditionException
class StreamingTextContent(StreamingContentMixin, TextContent):
"""This represents streaming text response content.
Args:
choice_index: int - The index of the choice that generated this response.
inner_content: Optional[Any] - The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer can leverage the full thing.
ai_model_id: Optional[str] - The id of the AI model that generated this response.
metadata: Dict[str, Any] - Any metadata that should be attached to the response.
text: Optional[str] - The text of the response.
encoding: Optional[str] - The encoding of the text.
Methods:
__str__: Returns the text of the response.
__bytes__: Returns the content of the response encoded in the encoding.
__add__: Combines two StreamingTextContent instances.
"""
def __bytes__(self) -> bytes:
"""Return the content of the response encoded in the encoding."""
return self.text.encode(self.encoding if self.encoding else "utf-8") if self.text else b""
def __add__(self, other: TextContent) -> "StreamingTextContent":
"""When combining two StreamingTextContent instances, the text fields are combined.
The addition should follow these rules:
1. The inner_content of the two will be combined. If they are not lists, they will be converted to lists.
2. ai_model_id should be the same.
3. encoding should be the same.
4. choice_index should be the same.
5. Metadata will be combined.
"""
if isinstance(other, StreamingTextContent) and self.choice_index != other.choice_index:
raise ContentAdditionException("Cannot add StreamingTextContent with different choice_index")
if self.ai_model_id != other.ai_model_id:
raise ContentAdditionException("Cannot add StreamingTextContent from different ai_model_id")
if self.encoding != other.encoding:
raise ContentAdditionException("Cannot add StreamingTextContent with different encoding")
return StreamingTextContent(
choice_index=self.choice_index,
inner_content=self._merge_inner_contents(other.inner_content),
ai_model_id=self.ai_model_id,
metadata=self.metadata,
text=(self.text or "") + (other.text or ""),
encoding=self.encoding,
)
@@ -0,0 +1,63 @@
# Copyright (c) Microsoft. All rights reserved.
from html import unescape
from typing import ClassVar, Literal, TypeVar
from xml.etree.ElementTree import Element # nosec
from pydantic import Field
from semantic_kernel.contents.const import TEXT_CONTENT_TAG, ContentTypes
from semantic_kernel.contents.kernel_content import KernelContent
from semantic_kernel.exceptions.content_exceptions import ContentInitializationError
_T = TypeVar("_T", bound="TextContent")
class TextContent(KernelContent):
"""This represents text response content.
Args:
inner_content: Any - The inner content of the response,
this should hold all the information from the response so even
when not creating a subclass a developer can leverage the full thing.
ai_model_id: str | None - The id of the AI model that generated this response.
metadata: dict[str, Any] - Any metadata that should be attached to the response.
text: str | None - The text of the response.
encoding: str | None - The encoding of the text.
Methods:
__str__: Returns the text of the response.
"""
content_type: Literal[ContentTypes.TEXT_CONTENT] = Field(TEXT_CONTENT_TAG, init=False) # type: ignore
tag: ClassVar[str] = TEXT_CONTENT_TAG
text: str
encoding: str | None = None
def __str__(self) -> str:
"""Return the text of the response."""
return self.text
def to_element(self) -> Element:
"""Convert the instance to an Element."""
element = Element(self.tag)
element.text = self.text
if self.encoding:
element.set("encoding", self.encoding)
return element
@classmethod
def from_element(cls: type[_T], element: Element) -> _T:
"""Create an instance from an Element."""
if element.tag != cls.tag:
raise ContentInitializationError(f"Element tag is not {cls.tag}") # pragma: no cover
return cls(text=unescape(element.text) if element.text else "", encoding=element.get("encoding", None))
def to_dict(self) -> dict[str, str]:
"""Convert the instance to a dictionary."""
return {"type": "text", "text": self.text}
def __hash__(self) -> int:
"""Return the hash of the text content."""
return hash((self.tag, self.text, self.encoding))
@@ -0,0 +1,12 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
class AuthorRole(str, Enum):
"""Author role enum."""
SYSTEM = "system"
USER = "user"
ASSISTANT = "assistant"
TOOL = "tool"
DEVELOPER = "developer"
@@ -0,0 +1,179 @@
# Copyright (c) Microsoft. All rights reserved.
import base64
import binascii
import logging
import re
from collections.abc import Mapping, MutableMapping, Sequence
from typing import Any, TypeVar
from numpy import ndarray
from pydantic import Field, ValidationError, field_validator
from pydantic_core import Url
from semantic_kernel.exceptions import ContentInitializationError
from semantic_kernel.kernel_pydantic import KernelBaseModel
logger = logging.getLogger(__name__)
_T = TypeVar("_T", bound="DataUri")
class DataUri(KernelBaseModel, validate_assignment=True):
"""A class to represent a data uri.
If a array is provided, that will be used as the data since it is the most efficient,
otherwise the bytes will be used, or the string will be converted to bytes.
When updating either array or bytes, the other will not be updated.
Args:
data_bytes: The data as bytes.
data_str: The data as a string.
data_array: The data as a numpy array.
mime_type: The mime type of the data.
parameters: Any parameters for the data.
data_format: The format of the data (e.g. base64).
"""
data_array: ndarray | None = None
data_bytes: bytes | None = None
mime_type: str | None = None
parameters: MutableMapping[str, str] = Field(default_factory=dict)
data_format: str | None = None
def __init__(
self,
data_bytes: bytes | None = None,
data_str: str | None = None,
data_array: ndarray | None = None,
mime_type: str | None = None,
parameters: Sequence[str] | Mapping[str, str] | None = None,
data_format: str | None = None,
**kwargs: Any,
):
"""Initialize the data uri.
Make sure to set the data_format to base64 so that it can be decoded properly.
Args:
data_bytes: The data as bytes.
data_str: The data as a string.
data_array: The data as a numpy array.
mime_type: The mime type of the data.
parameters: Any parameters for the data.
data_format: The format of the data (e.g. base64).
kwargs: Any additional arguments.
"""
args: dict[str, Any] = {}
if data_bytes is not None:
args["data_bytes"] = data_bytes
if data_array is not None:
args["data_array"] = data_array
if mime_type is not None:
args["mime_type"] = mime_type
if parameters is not None:
args["parameters"] = parameters
if data_format is not None:
args["data_format"] = data_format
if data_str is not None and not data_bytes:
if data_format and data_format.lower() == "base64":
try:
args["data_bytes"] = base64.b64decode(data_str, validate=True)
except binascii.Error as exc:
raise ContentInitializationError("Invalid base64 data.") from exc
else:
args["data_bytes"] = data_str.encode("utf-8")
if "data_array" not in args and "data_bytes" not in args:
raise ContentInitializationError("Either data_bytes, data_str or data_array must be provided.")
super().__init__(**args, **kwargs)
def update_data(self, value: str | bytes | ndarray) -> None:
"""Update the data, using either a string or bytes."""
match value:
case ndarray():
self.data_array = value
case str():
if self.data_format and self.data_format.lower() == "base64":
self.data_bytes = base64.b64decode(value, validate=True)
else:
self.data_bytes = value.encode("utf-8")
case _:
self.data_bytes = value
@field_validator("parameters", mode="before")
def _validate_parameters(cls, value: list[str] | dict[str, str] | None) -> dict[str, str]:
if not value:
return {}
if isinstance(value, dict):
return value
new: dict[str, str] = {}
for item in value:
item = item.strip()
if not item:
continue
if "=" not in item:
raise ContentInitializationError("Invalid data uri format. The parameter is missing a value.")
name, val = item.split("=", maxsplit=1)
new[name] = val
return new
@classmethod
def from_data_uri(cls: type[_T], data_uri: str | Url, default_mime_type: str = "text/plain") -> _T:
"""Create a DataUri object from a data URI string or pydantic URL."""
if isinstance(data_uri, str):
try:
data_uri = Url(data_uri)
except ValidationError as exc:
raise ContentInitializationError("Invalid data uri format.") from exc
data = data_uri.path
if not data or "," not in data:
raise ContentInitializationError("Invalid data uri format. The data is missing.")
pattern = "(((?P<mime_type>[a-zA-Z]+/[a-zA-Z-]+)(?P<parameters>(;[a-zA-Z0-9]+=+[a-zA-Z0-9]+)*))?(;+(?P<data_format>.*)))?(,(?P<data_str>.*))" # noqa: E501
match = re.match(pattern, data)
if not match:
raise ContentInitializationError("Invalid data uri format.")
matches = match.groupdict()
if not matches.get("data_format"):
matches.pop("data_format")
if not matches.get("parameters"):
matches.pop("parameters")
else:
matches["parameters"] = matches["parameters"].strip(";").split(";")
if not matches.get("mime_type"):
matches["mime_type"] = default_mime_type
return cls(**matches) # type: ignore
def to_string(self, metadata: dict[str, str] | None = None) -> str:
"""Return the data uri as a string."""
if metadata:
parameters = ";".join([f"{key}={val}" for key, val in metadata.items()])
parameters = f";{parameters}" if parameters else ""
else:
parameters = ""
data_format = f"{self.data_format}" if self.data_format else ""
return f"data:{self.mime_type or ''}{parameters};{data_format},{self._data_str()}"
def __eq__(self, value: object) -> bool:
"""Check if the data uri is equal to another."""
if not isinstance(value, DataUri):
return False
return self.to_string() == value.to_string()
def _data_str(self) -> str:
"""Return the data as a string."""
if self.data_array is not None:
if self.data_format and self.data_format.lower() == "base64":
return base64.b64encode(self.data_array.tobytes()).decode("utf-8")
return self.data_array.tobytes().decode("utf-8")
if self.data_bytes is not None:
if self.data_format and self.data_format.lower() == "base64":
return base64.b64encode(self.data_bytes).decode("utf-8")
return self.data_bytes.decode("utf-8")
return ""
@@ -0,0 +1,12 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
class FinishReason(str, Enum):
"""Finish Reason enum."""
STOP = "stop"
LENGTH = "length"
CONTENT_FILTER = "content_filter"
TOOL_CALLS = "tool_calls"
FUNCTION_CALL = "function_call"
@@ -0,0 +1,52 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from pydantic import BaseModel
def make_hashable(input: Any, visited=None) -> Any:
"""Recursively convert unhashable types to hashable equivalents.
Args:
input: The input to convert to a hashable type.
visited: A dictionary of visited objects to prevent infinite recursion.
Returns:
Any: The input converted to a hashable type.
"""
if visited is None:
visited = {}
# If we've seen this object before, return the stored placeholder or final result
unique_obj_id = id(input)
if unique_obj_id in visited:
return visited[unique_obj_id]
# Handle Pydantic models by manually traversing fields
if isinstance(input, BaseModel):
visited[unique_obj_id] = None
data = {}
for field_name in input.model_fields:
value = getattr(input, field_name)
data[field_name] = make_hashable(value, visited)
result = tuple(sorted(data.items()))
visited[unique_obj_id] = result
return result
# Convert dictionaries
if isinstance(input, dict):
visited[unique_obj_id] = None
items = tuple(sorted((k, make_hashable(v, visited)) for k, v in input.items()))
visited[unique_obj_id] = items
return items
# Convert lists, sets, and tuples to tuples
if isinstance(input, (list, set, tuple)):
visited[unique_obj_id] = None
items = tuple(make_hashable(item, visited) for item in input)
visited[unique_obj_id] = items
return items
# If it's already something hashable, just return it
return input
@@ -0,0 +1,11 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
class Status(str, Enum):
"""Status enum."""
COMPLETED = "completed"
FAILED = "failed"
IN_PROGRESS = "in_progress"
INCOMPLETE = "incomplete"