Skip to content

Commit 05b6569

Browse files
Python: fix(python): wire ChatHistoryReducer into ChatCompletionAgent invocation (#12303) (#14607)
### Motivation and Context Fixes #12303 1. **Why is this change required?** Currently, configuring a `ChatHistorySummarizationReducer` or `ChatHistoryTruncationReducer` on a `ChatCompletionAgent` (e.g. via `arguments=KernelArguments(chat_history=reducer)` or thread injection) fails to reduce the chat history before dispatching calls to the LLM. The full, unbounded conversation history is passed in the request payload, causing context window overflows and unnecessary token consumption. 2. **What problem does it solve?** - Enables `ChatCompletionAgent` to respect active `ChatHistoryReducer` implementations (such as summarization and truncation reducers). - Allows users and multi-agent orchestrations to manage context size efficiently without token explosion. 3. **What scenario does it contribute to?** Multi-turn agent interactions and multi-agent group chats where long-running conversations require summarizing or truncating older messages to stay within LLM context boundaries. --- ### Description - **Auto-reduction on thread updates:** In `ChatHistoryAgentThread._on_new_message`, check if `self._chat_history` is a `ChatHistoryReducer` with `auto_reduce=True`. If so, invoke `await self._chat_history.add_message_async(new_message)` so the reduction boundary triggers as designed. - **Reducer propagation from KernelArguments:** In `ChatCompletionAgent.get_response()`, `invoke()`, and `invoke_stream()`, check if `arguments` contains an active `chat_history` reducer and pass it into the constructor when initializing `ChatHistoryAgentThread`. - **Pre-invocation reduction pass:** Before constructing the final `chat_history` payload for `_inner_invoke()` / `_inner_invoke_stream()`, check if the active thread contains a `ChatHistoryReducer` and call `await thread.reduce()`. - **Unit test suite:** Added unit tests verifying `ChatCompletionAgent` respects both thread-injected and argument-injected reducers during `get_response()`. --- ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄 --------- Signed-off-by: sparkysolutions10-pixelmy <sparkysolutions10@gmail.com>
1 parent 7a7c92f commit 05b6569

2 files changed

Lines changed: 126 additions & 4 deletions

File tree

‎python/semantic_kernel/agents/chat_completion/chat_completion_agent.py‎

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ async def _on_new_message(self, new_message: str | ChatMessageContent) -> None:
8989
or "thread_id" not in new_message.metadata
9090
or new_message.metadata["thread_id"] != self._id
9191
):
92-
self._chat_history.add_message(new_message)
92+
if isinstance(self._chat_history, ChatHistoryReducer) and self._chat_history.auto_reduce:
93+
await self._chat_history.add_message_async(new_message)
94+
else:
95+
self._chat_history.add_message(new_message)
9396

9497
async def get_messages(self) -> AsyncIterable[ChatMessageContent]:
9598
"""Retrieve the current chat history.
@@ -295,14 +298,23 @@ async def get_response(
295298
Returns:
296299
An AgentResponseItem of type ChatMessageContent.
297300
"""
301+
merged_args = self._merge_arguments(arguments)
302+
initial_history = (
303+
merged_args.get("chat_history")
304+
if merged_args and isinstance(merged_args.get("chat_history"), ChatHistory)
305+
else None
306+
)
298307
thread = await self._ensure_thread_exists_with_messages(
299308
messages=messages,
300309
thread=thread,
301-
construct_thread=lambda: ChatHistoryAgentThread(),
310+
construct_thread=lambda: ChatHistoryAgentThread(chat_history=initial_history),
302311
expected_type=ChatHistoryAgentThread,
303312
)
304313
assert thread.id is not None # nosec
305314

315+
if isinstance(thread, ChatHistoryAgentThread) and isinstance(thread._chat_history, ChatHistoryReducer):
316+
await thread.reduce()
317+
306318
chat_history = ChatHistory()
307319
async for message in thread.get_messages():
308320
chat_history.add_message(message)
@@ -349,14 +361,23 @@ async def invoke(
349361
Returns:
350362
An async iterable of AgentResponseItem of type ChatMessageContent.
351363
"""
364+
merged_args = self._merge_arguments(arguments)
365+
initial_history = (
366+
merged_args.get("chat_history")
367+
if merged_args and isinstance(merged_args.get("chat_history"), ChatHistory)
368+
else None
369+
)
352370
thread = await self._ensure_thread_exists_with_messages(
353371
messages=messages,
354372
thread=thread,
355-
construct_thread=lambda: ChatHistoryAgentThread(),
373+
construct_thread=lambda: ChatHistoryAgentThread(chat_history=initial_history),
356374
expected_type=ChatHistoryAgentThread,
357375
)
358376
assert thread.id is not None # nosec
359377

378+
if isinstance(thread, ChatHistoryAgentThread) and isinstance(thread._chat_history, ChatHistoryReducer):
379+
await thread.reduce()
380+
360381
chat_history = ChatHistory()
361382
async for message in thread.get_messages():
362383
chat_history.add_message(message)
@@ -398,14 +419,23 @@ async def invoke_stream(
398419
Returns:
399420
An async generator of AgentResponseItem of type StreamingChatMessageContent.
400421
"""
422+
merged_args = self._merge_arguments(arguments)
423+
initial_history = (
424+
merged_args.get("chat_history")
425+
if merged_args and isinstance(merged_args.get("chat_history"), ChatHistory)
426+
else None
427+
)
401428
thread = await self._ensure_thread_exists_with_messages(
402429
messages=messages,
403430
thread=thread,
404-
construct_thread=lambda: ChatHistoryAgentThread(),
431+
construct_thread=lambda: ChatHistoryAgentThread(chat_history=initial_history),
405432
expected_type=ChatHistoryAgentThread,
406433
)
407434
assert thread.id is not None # nosec
408435

436+
if isinstance(thread, ChatHistoryAgentThread) and isinstance(thread._chat_history, ChatHistoryReducer):
437+
await thread.reduce()
438+
409439
chat_history = ChatHistory()
410440
async for message in thread.get_messages():
411441
chat_history.add_message(message)

‎python/tests/unit/agents/chat_completion/test_chat_completion_agent.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,3 +469,95 @@ async def test_prepare_agent_chat_history_without_formatted_instructions():
469469
mock_format_instructions.assert_awaited_once_with(dummy_kernel, dummy_args)
470470
assert len(result_history.messages) == 1
471471
assert result_history.messages[0] == user_message
472+
473+
474+
async def test_chat_completion_agent_with_chat_history_reducer():
475+
mock_service = AsyncMock(spec=ChatCompletionClientBase)
476+
mock_service.service_id = "test_service"
477+
mock_service.get_prompt_execution_settings_class.return_value = PromptExecutionSettings
478+
mock_service.get_chat_message_contents.return_value = [
479+
ChatMessageContent(role=AuthorRole.ASSISTANT, content="Agent answer")
480+
]
481+
482+
reducer = ChatHistoryTruncationReducer(
483+
target_count=2,
484+
threshold_count=0,
485+
auto_reduce=True,
486+
)
487+
488+
thread = ChatHistoryAgentThread(chat_history=reducer)
489+
for i in range(5):
490+
await thread.on_new_message(ChatMessageContent(role=AuthorRole.USER, content=f"Message {i}"))
491+
492+
agent = ChatCompletionAgent(
493+
name="ReducerAgent",
494+
service=mock_service,
495+
)
496+
497+
response = await agent.get_response(thread=thread)
498+
assert response.message.content == "Agent answer"
499+
500+
# Verify that the chat_history passed to get_chat_message_contents was reduced
501+
passed_history = mock_service.get_chat_message_contents.call_args.kwargs["chat_history"]
502+
user_messages = [m.content for m in passed_history.messages if m.role == AuthorRole.USER]
503+
assert user_messages == ["Message 3", "Message 4"]
504+
505+
506+
async def test_chat_completion_agent_auto_reduce_via_arguments():
507+
mock_service = AsyncMock(spec=ChatCompletionClientBase)
508+
mock_service.service_id = "test_service"
509+
mock_service.get_prompt_execution_settings_class.return_value = PromptExecutionSettings
510+
mock_service.get_chat_message_contents.return_value = [
511+
ChatMessageContent(role=AuthorRole.ASSISTANT, content="Agent answer")
512+
]
513+
514+
reducer = ChatHistoryTruncationReducer(
515+
target_count=2,
516+
threshold_count=0,
517+
auto_reduce=False,
518+
)
519+
520+
for i in range(6):
521+
reducer.add_message(ChatMessageContent(role=AuthorRole.USER, content=f"Msg {i}"))
522+
523+
agent = ChatCompletionAgent(
524+
name="ReducerAgentArguments",
525+
service=mock_service,
526+
)
527+
528+
response = await agent.get_response(arguments=KernelArguments(chat_history=reducer))
529+
assert response.message.content == "Agent answer"
530+
531+
passed_history = mock_service.get_chat_message_contents.call_args.kwargs["chat_history"]
532+
user_messages = [m.content for m in passed_history.messages if m.role == AuthorRole.USER]
533+
assert user_messages == ["Msg 4", "Msg 5"]
534+
535+
536+
async def test_chat_completion_agent_with_constructor_arguments_reducer():
537+
mock_service = AsyncMock(spec=ChatCompletionClientBase)
538+
mock_service.service_id = "test_service"
539+
mock_service.get_prompt_execution_settings_class.return_value = PromptExecutionSettings
540+
mock_service.get_chat_message_contents.return_value = [
541+
ChatMessageContent(role=AuthorRole.ASSISTANT, content="Agent answer")
542+
]
543+
544+
reducer = ChatHistoryTruncationReducer(
545+
target_count=2,
546+
threshold_count=0,
547+
auto_reduce=False,
548+
)
549+
for i in range(4):
550+
reducer.add_message(ChatMessageContent(role=AuthorRole.USER, content=f"ConstMsg {i}"))
551+
552+
agent = ChatCompletionAgent(
553+
name="ReducerAgentConstructorArgs",
554+
service=mock_service,
555+
arguments=KernelArguments(chat_history=reducer),
556+
)
557+
558+
response = await agent.get_response()
559+
assert response.message.content == "Agent answer"
560+
561+
passed_history = mock_service.get_chat_message_contents.call_args.kwargs["chat_history"]
562+
user_messages = [m.content for m in passed_history.messages if m.role == AuthorRole.USER]
563+
assert user_messages == ["ConstMsg 2", "ConstMsg 3"]

0 commit comments

Comments
 (0)