chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

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,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))