chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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)
|
||||
+216
@@ -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))
|
||||
Reference in New Issue
Block a user