From 0a0d0c0a966fd76e8d9c1bf096c7138d10e41e7e Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 23 Sep 2026 11:04:01 +0200 Subject: [PATCH 1/3] Python: preserve compaction through middleware rewrites --- python/packages/core/AGENTS.md | 2 +- .../core/agent_framework/_compaction.py | 150 +++++++++++-- .../core/agent_framework/_middleware.py | 83 ++++++- .../packages/core/tests/core/test_clients.py | 209 ++++++++++++++++++ .../core/tests/core/test_middleware.py | 22 +- 5 files changed, 442 insertions(+), 24 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 61cd052f99b..74769e81bc9 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -141,7 +141,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`AgentMiddleware`** - Intercepts agent `run()` calls - **`ChatMiddleware`** - Intercepts chat client `get_response()` calls - **`FunctionMiddleware`** - Intercepts function/tool invocations -- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware. A tool can declare a `FunctionInvocationContext` parameter to receive it; `context.tools` is the live, mutable tools list for the run, and `context.add_tools(...)` / `context.remove_tools(...)` enable progressive tool exposure (changes apply on the next function-calling iteration). +- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware. A tool can declare a `FunctionInvocationContext` parameter to receive it; `context.tools` is the live, mutable tools list for the run, and `context.add_tools(...)` / `context.remove_tools(...)` enable progressive tool exposure (changes apply on the next function-calling iteration). Chat middleware that constructs provider-local replacement messages must call `context.record_message_replacement(...)` before downstream compaction so summaries can reconcile to caller-owned messages without persisting the replacements themselves. - **`MessageInjectionMiddleware`** - Session-scoped chat middleware that lets tools or other code enqueue messages for the next model call in the current `AgentSession`; it drains queued messages into the next call and loops only when no function calls need to be handled by the function invocation layer. ### Sessions (`_sessions.py`) diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index 45205866561..45a072ade13 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -423,9 +423,70 @@ def _reconcile_compaction_summaries( # pyright: ignore[reportUnusedFunction] source_messages: list[Message], working_messages: Sequence[Message], source_message_identities: set[int], + *, + message_replacements: Sequence[tuple[Message, Sequence[Message]]] = (), + warn_on_rejection: bool = False, ) -> None: - """Reconcile summaries supported by source-owned messages without persisting unrelated rewrites.""" + """Reconcile summaries rooted in source-owned messages without persisting unrelated rewrites.""" + source_messages_by_identity = {id(message): message for message in source_messages} source_message_ids = {message.message_id for message in source_messages if message.message_id} + + replacement_messages_by_identity: dict[int, Message] = {} + replacement_dependencies: dict[int, set[int]] = {} + for replacement, sources in message_replacements: + replacement_identity = id(replacement) + replacement_messages_by_identity[replacement_identity] = replacement + replacement_dependencies.setdefault(replacement_identity, set()).update(id(source) for source in sources) + + roots_by_identity = { + source_identity: {source_identity} + for source_identity in source_message_identities + if source_identity in source_messages_by_identity + } + pending_replacement_identities = set(replacement_dependencies).difference(roots_by_identity) + while pending_replacement_identities: + newly_supported_replacements = { + replacement_identity + for replacement_identity in pending_replacement_identities + if replacement_dependencies[replacement_identity] + and replacement_dependencies[replacement_identity].issubset(roots_by_identity) + } + if not newly_supported_replacements: + break + for replacement_identity in newly_supported_replacements: + roots_by_identity[replacement_identity] = set().union( + *( + roots_by_identity[source_identity] + for source_identity in replacement_dependencies[replacement_identity] + ) + ) + pending_replacement_identities.difference_update(newly_supported_replacements) + + roots_by_supported_id: dict[str, set[int]] = {} + ambiguous_supported_ids: set[str] = set() + + def add_supported_id(message_id: str, roots: set[int]) -> None: + if message_id in ambiguous_supported_ids: + return + existing_roots = roots_by_supported_id.get(message_id) + if existing_roots is not None and existing_roots != roots: + roots_by_supported_id.pop(message_id, None) + ambiguous_supported_ids.add(message_id) + return + roots_by_supported_id[message_id] = roots + + for source_message in source_messages: + if source_message.message_id: + add_supported_id(source_message.message_id, {id(source_message)}) + + resolved_replacements_by_id: dict[str, list[tuple[Message, set[int]]]] = {} + for replacement_identity, replacement in replacement_messages_by_identity.items(): + roots = roots_by_identity.get(replacement_identity) + if roots is None or not replacement.message_id: + continue + add_supported_id(replacement.message_id, roots) + resolved_replacements_by_id.setdefault(replacement.message_id, []).append((replacement, roots)) + candidates: list[tuple[Message, set[str]]] = [] for message in working_messages: if id(message) in source_message_identities: @@ -443,17 +504,48 @@ def _reconcile_compaction_summaries( # pyright: ignore[reportUnusedFunction] ): candidates.append((message, set(cast("list[str]", summarized_message_ids)))) - dependencies = {message.message_id: summary_ids for message, summary_ids in candidates if message.message_id} - supported_ids = set(source_message_ids) - pending_ids = set(dependencies) - while pending_ids: - newly_supported = {summary_id for summary_id in pending_ids if dependencies[summary_id].issubset(supported_ids)} - if not newly_supported: + dependencies: dict[str, set[str]] = {} + ambiguous_summary_ids: set[str] = set() + for message, summary_ids in candidates: + if not message.message_id: + continue + existing_dependencies = dependencies.get(message.message_id) + if existing_dependencies is not None and existing_dependencies != summary_ids: + ambiguous_summary_ids.add(message.message_id) + continue + dependencies[message.message_id] = summary_ids + + accepted_roots: dict[str, set[int]] = {} + pending_summary_ids = set(dependencies).difference(ambiguous_summary_ids) + while pending_summary_ids: + newly_supported_summaries: dict[str, set[int]] = {} + for summary_id in pending_summary_ids: + dependency_roots: list[set[int]] = [] + for dependency_id in dependencies[summary_id]: + if dependency_id in ambiguous_supported_ids: + break + roots = accepted_roots.get(dependency_id) or roots_by_supported_id.get(dependency_id) + if roots is None: + break + dependency_roots.append(roots) + else: + summary_roots: set[int] = set() + for roots in dependency_roots: + summary_roots.update(roots) + existing_roots = roots_by_supported_id.get(summary_id) + if summary_id not in ambiguous_supported_ids and ( + existing_roots is None or existing_roots == summary_roots + ): + newly_supported_summaries[summary_id] = summary_roots + + if not newly_supported_summaries: break - supported_ids.update(newly_supported) - pending_ids.difference_update(newly_supported) + for summary_id, summary_roots in newly_supported_summaries.items(): + accepted_roots[summary_id] = summary_roots + roots_by_supported_id[summary_id] = summary_roots + pending_summary_ids.difference_update(newly_supported_summaries) - accepted_ids = set(dependencies).difference(pending_ids) + accepted_ids = set(accepted_roots) for message in [*source_messages, *(candidate for candidate, _ in candidates)]: annotation = _read_group_annotation_raw(message) if annotation is None: @@ -465,24 +557,42 @@ def _reconcile_compaction_summaries( # pyright: ignore[reportUnusedFunction] message.additional_properties.pop(EXCLUDED_KEY, None) message.additional_properties.pop(EXCLUDE_REASON_KEY, None) - def source_dependencies(summary_id: str) -> set[str]: - expanded: set[str] = set() - for dependency_id in dependencies[summary_id]: - if dependency_id in source_message_ids: - expanded.add(dependency_id) - elif dependency_id in accepted_ids: - expanded.update(source_dependencies(dependency_id)) - return expanded + if warn_on_rejection: + rejected_count = sum(message.message_id not in accepted_ids for message, _ in candidates) + if rejected_count: + logger.warning( + "Rejected %d compaction summary message(s) because their dependencies were not fully rooted in " + "caller-owned messages or registered middleware replacements.", + rejected_count, + extra={"compaction_rejected_summary_count": rejected_count}, + ) for message, _ in candidates: if message.message_id not in accepted_ids: continue - summarized_source_ids = source_dependencies(message.message_id) + for dependency_id in dependencies[message.message_id]: + for replacement, roots in resolved_replacements_by_id.get(dependency_id, ()): + replacement_annotation = _read_group_annotation_raw(replacement) + if ( + replacement_annotation is None + or replacement_annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY) != message.message_id + or replacement.additional_properties.get(EXCLUDED_KEY) is not True + ): + continue + exclusion_reason = replacement.additional_properties.get(EXCLUDE_REASON_KEY) + for source_identity in roots: + source_message = source_messages_by_identity[source_identity] + _set_group_summarized_by_summary_id(source_message, message.message_id) + source_message.additional_properties[EXCLUDED_KEY] = True + if isinstance(exclusion_reason, str): + source_message.additional_properties[EXCLUDE_REASON_KEY] = exclusion_reason + + summarized_source_identities = accepted_roots[message.message_id] insertion_index = min( ( index for index, source_message in enumerate(source_messages) - if source_message.message_id in summarized_source_ids + if id(source_message) in summarized_source_identities ), default=len(source_messages), ) diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 45e764f2998..5dd383eb047 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -575,6 +575,11 @@ class ChatContext: stream_result_hooks: Hooks applied to the finalized response (after finalizer). stream_cleanup_hooks: Hooks executed after stream consumption (before finalizer). + Middleware that constructs provider-local replacement messages can call + :meth:`record_message_replacement` before downstream middleware runs. The replacement + remains call-local, but compaction summaries derived from it can be reconciled back to + the caller-owned source messages. + Examples: .. code-block:: python @@ -646,6 +651,59 @@ def __init__( self.stream_transform_hooks = list(stream_transform_hooks or []) self.stream_result_hooks = list(stream_result_hooks or []) self.stream_cleanup_hooks = list(stream_cleanup_hooks or []) + self._message_replacements: list[tuple[Message, tuple[Message, ...]]] = [] + self._middleware_message_snapshots: list[list[Message]] = [] + + def record_message_replacement( + self, + replacement: Message, + sources: Message | Sequence[Message], + ) -> Message: + """Record replacement provenance for downstream compaction reconciliation. + + This method does not modify :attr:`messages` or persist ``replacement``. It records + that the replacement carries content derived from caller-owned source messages, so + a downstream compaction summary can durably exclude and summarize those sources. + + Args: + replacement: A middleware-created replacement message. + sources: One or more messages replaced by ``replacement``. + + Returns: + The replacement message, for convenient use while rebuilding a message list. + + Raises: + TypeError: If ``replacement`` or any source is not a :class:`Message`. + ValueError: If no sources are provided or the replacement is also a source. + """ + if not isinstance(replacement, Message): + raise TypeError("replacement must be a Message") + + if isinstance(sources, Message): + source_messages = (sources,) + elif isinstance(sources, Sequence) and not isinstance(sources, (str, bytes, bytearray)): + source_messages = tuple(sources) + else: + raise TypeError("sources must be a Message or a sequence of Message objects") + + if not source_messages: + raise ValueError("sources must contain at least one Message") + + unique_sources: list[Message] = [] + seen_source_identities: set[int] = set() + for source in source_messages: + if not isinstance(source, Message): + raise TypeError("sources must contain only Message objects") + if source is replacement: + raise ValueError("replacement cannot also be one of its sources") + source_identity = id(source) + if source_identity in seen_source_identities: + continue + seen_source_identities.add(source_identity) + unique_sources.append(source) + + self._message_replacements.append((replacement, tuple(unique_sources))) + return replacement class AgentMiddleware(ABC): @@ -1408,7 +1466,12 @@ async def final_wrapper() -> None: async def current_handler() -> None: # MiddlewareTermination bubbles up to execute() to skip post-processing - await self._middleware[index].process(context, create_next_handler(index + 1)) + try: + await self._middleware[index].process(context, create_next_handler(index + 1)) + finally: + context._middleware_message_snapshots.append( # pyright: ignore[reportPrivateUsage] + list(context.messages) + ) return current_handler @@ -1572,10 +1635,26 @@ def _final_handler( if source_messages is not None: from ._compaction import _reconcile_compaction_summaries # pyright: ignore[reportPrivateUsage] + if downstream_messages is not None: + reconciliation_messages = downstream_messages + else: + fallback_messages: list[Message] = [] + seen_message_identities: set[int] = set() + for snapshot in context._middleware_message_snapshots: # pyright: ignore[reportPrivateUsage] + for message in snapshot: + message_identity = id(message) + if message_identity in seen_message_identities: + continue + seen_message_identities.add(message_identity) + fallback_messages.append(message) + reconciliation_messages = fallback_messages or middleware_messages + _reconcile_compaction_summaries( source_messages, - downstream_messages if downstream_messages is not None else middleware_messages, + reconciliation_messages, source_message_identities, + message_replacements=context._message_replacements, # pyright: ignore[reportPrivateUsage] + warn_on_rejection=True, ) if stream: diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index bedf0788f11..169bb590ba6 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -1,15 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. +import logging from typing import Any from unittest.mock import patch import pytest from agent_framework import ( + EXCLUDE_REASON_KEY, EXCLUDED_KEY, GROUP_ANNOTATION_KEY, GROUP_TOKEN_COUNT_KEY, + SUMMARIZED_BY_SUMMARY_ID_KEY, + SUMMARY_OF_MESSAGE_IDS_KEY, BaseChatClient, ChatMiddleware, ChatResponse, @@ -21,7 +25,9 @@ SupportsChatGetResponse, ToolResultCompactionStrategy, TruncationStrategy, + annotate_message_groups, apply_compaction, + included_messages, tool, ) @@ -44,6 +50,37 @@ async def process(self, context: Any, call_next: Any) -> None: context.messages = original_messages +class _CompactAllButLatestChatMiddleware(ChatMiddleware): + def __init__(self, *, terminate: bool = False) -> None: + self.terminate = terminate + + async def process(self, context: Any, call_next: Any) -> None: + messages = list(context.messages) + annotate_message_groups(messages) + summarized = messages[:-1] + summary = Message( + role="user", + contents=[""], + message_id="summary_1", + additional_properties={ + GROUP_ANNOTATION_KEY: { + SUMMARY_OF_MESSAGE_IDS_KEY: [message.message_id for message in summarized if message.message_id] + } + }, + ) + for message in summarized: + annotation = message.additional_properties.setdefault(GROUP_ANNOTATION_KEY, {}) + annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] = summary.message_id + message.additional_properties[EXCLUDED_KEY] = True + message.additional_properties[EXCLUDE_REASON_KEY] = "test_compaction" + messages.insert(0, summary) + context.messages = list(included_messages(messages)) + if self.terminate: + context.result = ChatResponse(messages=[Message(role="assistant", contents=["terminated"])]) + return + await call_next() + + class _FixedSummarizer: async def get_response(self, *args: Any, **kwargs: Any) -> ChatResponse: return ChatResponse(messages=[Message(role="assistant", contents=["SUMMARY"])]) @@ -798,6 +835,178 @@ async def process(self, context: Any, call_next: Any) -> None: assert all(not message.additional_properties.get("_excluded", False) for message in messages) +@pytest.mark.parametrize("stream", [False, True], ids=["non-streaming", "streaming"]) +@pytest.mark.parametrize("record_replacements", [True, False], ids=["registered", "unregistered"]) +async def test_chat_middleware_reconciles_recorded_message_replacements( + chat_client_base: SupportsChatGetResponse, + caplog: pytest.LogCaptureFixture, + stream: bool, + record_replacements: bool, +) -> None: + class _SplitToolMessage(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + rewritten: list[Message] = [] + for message in context.messages: + if message.role != "tool": + rewritten.append(message) + continue + tool_replacement = Message(role="tool", contents=["tool text"]) + promoted_replacement = Message(role="user", contents=["promoted rich content"]) + if record_replacements: + context.record_message_replacement(tool_replacement, message) + context.record_message_replacement(promoted_replacement, message) + rewritten.extend((tool_replacement, promoted_replacement)) + context.messages = rewritten + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _SplitToolMessage(), + _CompactAllButLatestChatMiddleware(), + ] + messages = [ + Message(role="user", contents=["q1"]), + Message(role="tool", contents=["tool output"]), + Message(role="user", contents=["q2"]), + ] + + with caplog.at_level(logging.WARNING, logger="agent_framework"): + if stream: + stream_response = chat_client_base.get_response(messages, stream=True) + async for _ in stream_response: + pass + else: + await chat_client_base.get_response(messages) + + rejection_records = [ + record for record in caplog.records if record.getMessage().startswith("Rejected 1 compaction summary message") + ] + if not record_replacements: + assert [message.text for message in messages] == ["q1", "tool output", "q2"] + assert all(message.additional_properties.get(EXCLUDED_KEY) is not True for message in messages) + assert len(rejection_records) == 1 + assert rejection_records[0].__dict__["compaction_rejected_summary_count"] == 1 + assert all(message.text not in rejection_records[0].getMessage() for message in messages) + return + + assert [message.text for message in messages] == [ + "", + "q1", + "tool output", + "q2", + ] + assert rejection_records == [] + for source_message in messages[1:3]: + assert source_message.additional_properties[EXCLUDED_KEY] is True + assert source_message.additional_properties[EXCLUDE_REASON_KEY] == "test_compaction" + annotation = source_message.additional_properties[GROUP_ANNOTATION_KEY] + assert annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] == "summary_1" + assert messages[3].additional_properties.get(EXCLUDED_KEY) is not True + + +async def test_chat_middleware_reconciles_transitive_message_replacements( + chat_client_base: SupportsChatGetResponse, +) -> None: + class _FirstReplacement(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + rewritten: list[Message] = [] + for message in context.messages: + if message.role == "tool": + rewritten.append( + context.record_message_replacement( + Message(role="tool", contents=["first replacement"]), + message, + ) + ) + else: + rewritten.append(message) + context.messages = rewritten + await call_next() + + class _SecondReplacement(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + rewritten: list[Message] = [] + for message in context.messages: + if message.role == "tool": + rewritten.append( + context.record_message_replacement( + Message(role="tool", contents=["second replacement"]), + message, + ) + ) + else: + rewritten.append(message) + context.messages = rewritten + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _FirstReplacement(), + _SecondReplacement(), + _CompactAllButLatestChatMiddleware(), + ] + messages = [ + Message(role="user", contents=["q1"]), + Message(role="tool", contents=["tool output"]), + Message(role="user", contents=["q2"]), + ] + + await chat_client_base.get_response(messages) + + assert messages[0].message_id == "summary_1" + assert messages[2].text == "tool output" + assert messages[2].additional_properties[EXCLUDED_KEY] is True + annotation = messages[2].additional_properties[GROUP_ANNOTATION_KEY] + assert annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] == "summary_1" + + +async def test_chat_middleware_reconciles_recorded_replacement_before_termination_and_restore( + chat_client_base: SupportsChatGetResponse, +) -> None: + class _RestoreAfterCall(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + original_messages = tuple(context.messages) + await call_next() + context.messages = original_messages + + class _ReplaceToolMessage(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + rewritten: list[Message] = [] + for message in context.messages: + if message.role == "tool": + rewritten.append( + context.record_message_replacement( + Message(role="tool", contents=["replacement"]), + message, + ) + ) + else: + rewritten.append(message) + context.messages = rewritten + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _RestoreAfterCall(), + _ReplaceToolMessage(), + _CompactAllButLatestChatMiddleware(terminate=True), + ] + messages = [ + Message(role="user", contents=["q1"]), + Message(role="tool", contents=["tool output"]), + Message(role="user", contents=["q2"]), + ] + + response = await chat_client_base.get_response(messages) + + assert response.text == "terminated" + assert messages[0].message_id == "summary_1" + assert messages[2].text == "tool output" + assert messages[2].additional_properties[EXCLUDED_KEY] is True + annotation = messages[2].additional_properties[GROUP_ANNOTATION_KEY] + assert annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] == "summary_1" + + @pytest.mark.parametrize("continue_pipeline", [True, False], ids=["call-next", "terminate"]) async def test_chat_middleware_reconciles_compaction_before_downstream( chat_client_base: SupportsChatGetResponse, diff --git a/python/packages/core/tests/core/test_middleware.py b/python/packages/core/tests/core/test_middleware.py index 264d13b87a7..c3e59903317 100644 --- a/python/packages/core/tests/core/test_middleware.py +++ b/python/packages/core/tests/core/test_middleware.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from collections.abc import AsyncIterable, Awaitable, Callable -from typing import Any +from typing import Any, cast from unittest.mock import MagicMock import pytest @@ -131,6 +131,26 @@ def test_init_with_custom_values(self, mock_chat_client: Any) -> None: assert context.stream is True assert context.metadata == metadata + def test_record_message_replacement(self, mock_chat_client: Any) -> None: + """Replacement provenance is validated, deduplicated, and returned.""" + source = Message(role="tool", contents=["source"]) + replacement = Message(role="user", contents=["replacement"]) + context = ChatContext(client=mock_chat_client, messages=[source], options={}) + + result = context.record_message_replacement(replacement, [source, source]) + + assert result is replacement + assert context._message_replacements == [(replacement, (source,))] + + with pytest.raises(ValueError, match="at least one"): + context.record_message_replacement(replacement, []) + with pytest.raises(ValueError, match="cannot also be"): + context.record_message_replacement(replacement, replacement) + with pytest.raises(TypeError, match="replacement must be"): + context.record_message_replacement(cast(Any, "replacement"), source) + with pytest.raises(TypeError, match="only Message"): + context.record_message_replacement(replacement, [cast(Any, "source")]) + class TestAgentMiddlewarePipeline: """Test cases for AgentMiddlewarePipeline.""" From d3585f30103006ae365d6beb277a6d01a90c8410 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 23 Sep 2026 11:26:32 +0200 Subject: [PATCH 2/3] Python: harden replacement summary reconciliation --- python/packages/core/AGENTS.md | 2 +- .../core/agent_framework/_compaction.py | 150 +++++++++++---- .../core/agent_framework/_middleware.py | 29 +-- .../packages/core/tests/core/test_clients.py | 176 ++++++++++++++++++ 4 files changed, 309 insertions(+), 48 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 74769e81bc9..7ec721ec3d5 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -141,7 +141,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`AgentMiddleware`** - Intercepts agent `run()` calls - **`ChatMiddleware`** - Intercepts chat client `get_response()` calls - **`FunctionMiddleware`** - Intercepts function/tool invocations -- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware. A tool can declare a `FunctionInvocationContext` parameter to receive it; `context.tools` is the live, mutable tools list for the run, and `context.add_tools(...)` / `context.remove_tools(...)` enable progressive tool exposure (changes apply on the next function-calling iteration). Chat middleware that constructs provider-local replacement messages must call `context.record_message_replacement(...)` before downstream compaction so summaries can reconcile to caller-owned messages without persisting the replacements themselves. +- **`AgentContext`** / **`ChatContext`** / **`FunctionInvocationContext`** - Context objects passed through middleware. A tool can declare a `FunctionInvocationContext` parameter to receive it; `context.tools` is the live, mutable tools list for the run, and `context.add_tools(...)` / `context.remove_tools(...)` enable progressive tool exposure (changes apply on the next function-calling iteration). Chat middleware that constructs provider-local replacement messages must call `context.record_message_replacement(...)` for every replacement before downstream compaction so complete summaries can reconcile to caller-owned messages without persisting the replacements themselves. - **`MessageInjectionMiddleware`** - Session-scoped chat middleware that lets tools or other code enqueue messages for the next model call in the current `AgentSession`; it drains queued messages into the next call and loops only when no function calls need to be handled by the function invocation layer. ### Sessions (`_sessions.py`) diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index 45a072ade13..c491fd884a4 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -429,6 +429,10 @@ def _reconcile_compaction_summaries( # pyright: ignore[reportUnusedFunction] ) -> None: """Reconcile summaries rooted in source-owned messages without persisting unrelated rewrites.""" source_messages_by_identity = {id(message): message for message in source_messages} + source_messages_by_id: dict[str, list[Message]] = {} + for source_message in source_messages: + if source_message.message_id: + source_messages_by_id.setdefault(source_message.message_id, []).append(source_message) source_message_ids = {message.message_id for message in source_messages if message.message_id} replacement_messages_by_identity: dict[int, Message] = {} @@ -462,29 +466,49 @@ def _reconcile_compaction_summaries( # pyright: ignore[reportUnusedFunction] ) pending_replacement_identities.difference_update(newly_supported_replacements) + resolved_replacement_identities = set(replacement_messages_by_identity).intersection(roots_by_identity) + superseded_replacement_identities = { + source_identity + for replacement_identity in resolved_replacement_identities + for source_identity in replacement_dependencies[replacement_identity] + if source_identity in resolved_replacement_identities + } + leaf_replacement_identities = resolved_replacement_identities.difference(superseded_replacement_identities) + required_leaves_by_source_identity: dict[int, set[int]] = {} + for replacement_identity in leaf_replacement_identities: + for source_identity in roots_by_identity[replacement_identity]: + required_leaves_by_source_identity.setdefault(source_identity, set()).add(replacement_identity) + roots_by_supported_id: dict[str, set[int]] = {} + leaves_by_supported_id: dict[str, set[int]] = {} ambiguous_supported_ids: set[str] = set() - def add_supported_id(message_id: str, roots: set[int]) -> None: + def add_supported_id(message_id: str, roots: set[int], leaves: set[int]) -> None: if message_id in ambiguous_supported_ids: return existing_roots = roots_by_supported_id.get(message_id) - if existing_roots is not None and existing_roots != roots: + existing_leaves = leaves_by_supported_id.get(message_id) + if existing_roots is not None and (existing_roots != roots or existing_leaves != leaves): roots_by_supported_id.pop(message_id, None) + leaves_by_supported_id.pop(message_id, None) ambiguous_supported_ids.add(message_id) return roots_by_supported_id[message_id] = roots + leaves_by_supported_id[message_id] = leaves for source_message in source_messages: if source_message.message_id: - add_supported_id(source_message.message_id, {id(source_message)}) + add_supported_id(source_message.message_id, {id(source_message)}, set()) resolved_replacements_by_id: dict[str, list[tuple[Message, set[int]]]] = {} for replacement_identity, replacement in replacement_messages_by_identity.items(): roots = roots_by_identity.get(replacement_identity) if roots is None or not replacement.message_id: continue - add_supported_id(replacement.message_id, roots) + replacement_leaves: set[int] = ( + {replacement_identity} if replacement_identity in leaf_replacement_identities else set() + ) + add_supported_id(replacement.message_id, roots, replacement_leaves) resolved_replacements_by_id.setdefault(replacement.message_id, []).append((replacement, roots)) candidates: list[tuple[Message, set[str]]] = [] @@ -509,43 +533,120 @@ def add_supported_id(message_id: str, roots: set[int]) -> None: for message, summary_ids in candidates: if not message.message_id: continue - existing_dependencies = dependencies.get(message.message_id) - if existing_dependencies is not None and existing_dependencies != summary_ids: + if message.message_id in dependencies: ambiguous_summary_ids.add(message.message_id) continue dependencies[message.message_id] = summary_ids - accepted_roots: dict[str, set[int]] = {} + resolved_summary_roots: dict[str, set[int]] = {} + resolved_summary_leaves: dict[str, set[int]] = {} pending_summary_ids = set(dependencies).difference(ambiguous_summary_ids) while pending_summary_ids: - newly_supported_summaries: dict[str, set[int]] = {} + newly_supported_summaries: dict[str, tuple[set[int], set[int]]] = {} for summary_id in pending_summary_ids: dependency_roots: list[set[int]] = [] + dependency_leaves: list[set[int]] = [] for dependency_id in dependencies[summary_id]: if dependency_id in ambiguous_supported_ids: break - roots = accepted_roots.get(dependency_id) or roots_by_supported_id.get(dependency_id) + roots: set[int] | None = resolved_summary_roots.get(dependency_id) + dependency_leaf_ids: set[int] | None = resolved_summary_leaves.get(dependency_id) + if roots is None: + roots = roots_by_supported_id.get(dependency_id) + dependency_leaf_ids = leaves_by_supported_id.get(dependency_id) if roots is None: break dependency_roots.append(roots) + dependency_leaves.append(dependency_leaf_ids or set()) else: summary_roots: set[int] = set() for roots in dependency_roots: summary_roots.update(roots) + summary_leaves: set[int] = set() + for leaves in dependency_leaves: + summary_leaves.update(leaves) existing_roots = roots_by_supported_id.get(summary_id) + existing_leaves = leaves_by_supported_id.get(summary_id) if summary_id not in ambiguous_supported_ids and ( - existing_roots is None or existing_roots == summary_roots + existing_roots is None or (existing_roots == summary_roots and existing_leaves == summary_leaves) ): - newly_supported_summaries[summary_id] = summary_roots + newly_supported_summaries[summary_id] = (summary_roots, summary_leaves) if not newly_supported_summaries: break - for summary_id, summary_roots in newly_supported_summaries.items(): - accepted_roots[summary_id] = summary_roots + for summary_id, (summary_roots, summary_leaves) in newly_supported_summaries.items(): + resolved_summary_roots[summary_id] = summary_roots + resolved_summary_leaves[summary_id] = summary_leaves roots_by_supported_id[summary_id] = summary_roots + leaves_by_supported_id[summary_id] = summary_leaves pending_summary_ids.difference_update(newly_supported_summaries) - accepted_ids = set(accepted_roots) + accepted_ids = { + summary_id + for summary_id, summary_roots in resolved_summary_roots.items() + if all( + required_leaves_by_source_identity.get(source_identity, set()).issubset(resolved_summary_leaves[summary_id]) + for source_identity in summary_roots + ) + } + + def nested_summary_ids(summary_id: str) -> set[str]: + nested_ids: set[str] = set() + pending_ids = [summary_id] + while pending_ids: + current_id = pending_ids.pop() + for dependency_id in dependencies[current_id]: + if dependency_id not in resolved_summary_roots or dependency_id in nested_ids: + continue + nested_ids.add(dependency_id) + pending_ids.append(dependency_id) + return nested_ids + + consumed_accepted_ids: set[str] = set() + used_summary_ids = set(accepted_ids) + for summary_id in accepted_ids: + nested_ids = nested_summary_ids(summary_id) + used_summary_ids.update(nested_ids) + consumed_accepted_ids.update(nested_ids.intersection(accepted_ids)) + endpoint_summary_ids = accepted_ids.difference(consumed_accepted_ids) + + def transfer_summary_exclusions(summary_id: str, durable_summary_id: str, visited_ids: set[str]) -> None: + if summary_id in visited_ids: + return + visited_ids.add(summary_id) + for dependency_id in dependencies[summary_id]: + if dependency_id in resolved_summary_roots: + transfer_summary_exclusions(dependency_id, durable_summary_id, visited_ids) + + for source_message in source_messages_by_id.get(dependency_id, ()): + source_annotation = _read_group_annotation_raw(source_message) + if ( + source_annotation is None + or source_annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY) != summary_id + or source_message.additional_properties.get(EXCLUDED_KEY) is not True + ): + continue + _set_group_summarized_by_summary_id(source_message, durable_summary_id) + + for replacement, roots in resolved_replacements_by_id.get(dependency_id, ()): + replacement_annotation = _read_group_annotation_raw(replacement) + if ( + replacement_annotation is None + or replacement_annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY) != summary_id + or replacement.additional_properties.get(EXCLUDED_KEY) is not True + ): + continue + exclusion_reason = replacement.additional_properties.get(EXCLUDE_REASON_KEY) + for source_identity in roots: + source_message = source_messages_by_identity[source_identity] + _set_group_summarized_by_summary_id(source_message, durable_summary_id) + source_message.additional_properties[EXCLUDED_KEY] = True + if isinstance(exclusion_reason, str): + source_message.additional_properties[EXCLUDE_REASON_KEY] = exclusion_reason + + for summary_id in endpoint_summary_ids: + transfer_summary_exclusions(summary_id, summary_id, set()) + for message in [*source_messages, *(candidate for candidate, _ in candidates)]: annotation = _read_group_annotation_raw(message) if annotation is None: @@ -558,7 +659,7 @@ def add_supported_id(message_id: str, roots: set[int]) -> None: message.additional_properties.pop(EXCLUDE_REASON_KEY, None) if warn_on_rejection: - rejected_count = sum(message.message_id not in accepted_ids for message, _ in candidates) + rejected_count = sum(message.message_id not in used_summary_ids for message, _ in candidates) if rejected_count: logger.warning( "Rejected %d compaction summary message(s) because their dependencies were not fully rooted in " @@ -570,24 +671,7 @@ def add_supported_id(message_id: str, roots: set[int]) -> None: for message, _ in candidates: if message.message_id not in accepted_ids: continue - for dependency_id in dependencies[message.message_id]: - for replacement, roots in resolved_replacements_by_id.get(dependency_id, ()): - replacement_annotation = _read_group_annotation_raw(replacement) - if ( - replacement_annotation is None - or replacement_annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY) != message.message_id - or replacement.additional_properties.get(EXCLUDED_KEY) is not True - ): - continue - exclusion_reason = replacement.additional_properties.get(EXCLUDE_REASON_KEY) - for source_identity in roots: - source_message = source_messages_by_identity[source_identity] - _set_group_summarized_by_summary_id(source_message, message.message_id) - source_message.additional_properties[EXCLUDED_KEY] = True - if isinstance(exclusion_reason, str): - source_message.additional_properties[EXCLUDE_REASON_KEY] = exclusion_reason - - summarized_source_identities = accepted_roots[message.message_id] + summarized_source_identities = resolved_summary_roots[message.message_id] insertion_index = min( ( index diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 5dd383eb047..1d2a3171581 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -652,7 +652,7 @@ def __init__( self.stream_result_hooks = list(stream_result_hooks or []) self.stream_cleanup_hooks = list(stream_cleanup_hooks or []) self._message_replacements: list[tuple[Message, tuple[Message, ...]]] = [] - self._middleware_message_snapshots: list[list[Message]] = [] + self._fallback_reconciliation_messages: list[Message] | None = None def record_message_replacement( self, @@ -664,6 +664,8 @@ def record_message_replacement( This method does not modify :attr:`messages` or persist ``replacement``. It records that the replacement carries content derived from caller-owned source messages, so a downstream compaction summary can durably exclude and summarize those sources. + When one source becomes multiple replacement messages, record every replacement; + reconciliation rejects summaries that cover only part of the split. Args: replacement: A middleware-created replacement message. @@ -1469,9 +1471,10 @@ async def current_handler() -> None: try: await self._middleware[index].process(context, create_next_handler(index + 1)) finally: - context._middleware_message_snapshots.append( # pyright: ignore[reportPrivateUsage] - list(context.messages) - ) + if context._fallback_reconciliation_messages is None: # pyright: ignore[reportPrivateUsage] + context._fallback_reconciliation_messages = ( # pyright: ignore[reportPrivateUsage] + context.messages if isinstance(context.messages, list) else list(context.messages) + ) return current_handler @@ -1638,16 +1641,14 @@ def _final_handler( if downstream_messages is not None: reconciliation_messages = downstream_messages else: - fallback_messages: list[Message] = [] - seen_message_identities: set[int] = set() - for snapshot in context._middleware_message_snapshots: # pyright: ignore[reportPrivateUsage] - for message in snapshot: - message_identity = id(message) - if message_identity in seen_message_identities: - continue - seen_message_identities.add(message_identity) - fallback_messages.append(message) - reconciliation_messages = fallback_messages or middleware_messages + fallback_reconciliation_messages = ( + context._fallback_reconciliation_messages # pyright: ignore[reportPrivateUsage] + ) + reconciliation_messages = ( + fallback_reconciliation_messages + if fallback_reconciliation_messages is not None + else middleware_messages + ) _reconcile_compaction_summaries( source_messages, diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index 169bb590ba6..65a7244b42b 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -960,6 +960,182 @@ async def process(self, context: Any, call_next: Any) -> None: assert annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] == "summary_1" +async def test_chat_middleware_rejects_partial_replacement_coverage( + chat_client_base: SupportsChatGetResponse, + caplog: pytest.LogCaptureFixture, +) -> None: + class _SplitToolMessage(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + rewritten: list[Message] = [] + for message in context.messages: + if message.role != "tool": + rewritten.append(message) + continue + rewritten.extend(( + context.record_message_replacement( + Message(role="tool", contents=["tool fragment"]), + message, + ), + context.record_message_replacement( + Message(role="user", contents=["promoted fragment"]), + message, + ), + )) + context.messages = rewritten + await call_next() + + class _CompactOnlyFirstFragment(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + messages = list(context.messages) + annotate_message_groups(messages) + summarized = messages[0] + assert summarized.message_id is not None + summary = Message( + role="user", + contents=[""], + message_id="summary_1", + additional_properties={GROUP_ANNOTATION_KEY: {SUMMARY_OF_MESSAGE_IDS_KEY: [summarized.message_id]}}, + ) + annotation = summarized.additional_properties.setdefault(GROUP_ANNOTATION_KEY, {}) + annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] = summary.message_id + summarized.additional_properties[EXCLUDED_KEY] = True + context.messages = [summary, *messages[1:]] + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _SplitToolMessage(), + _CompactOnlyFirstFragment(), + ] + messages = [ + Message(role="tool", contents=["tool output"]), + Message(role="user", contents=["q2"]), + ] + + with caplog.at_level(logging.WARNING, logger="agent_framework"): + await chat_client_base.get_response(messages) + + assert [message.text for message in messages] == ["tool output", "q2"] + assert all(message.additional_properties.get(EXCLUDED_KEY) is not True for message in messages) + assert any(record.getMessage().startswith("Rejected 1 compaction summary message") for record in caplog.records) + + +async def test_chat_middleware_accepts_nested_complete_replacement_coverage( + chat_client_base: SupportsChatGetResponse, + caplog: pytest.LogCaptureFixture, +) -> None: + class _SplitToolMessage(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + source = context.messages[0] + context.messages = [ + context.record_message_replacement(Message(role="tool", contents=["tool fragment"]), source), + context.record_message_replacement(Message(role="user", contents=["promoted fragment"]), source), + *context.messages[1:], + ] + await call_next() + + class _NestedCompaction(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + messages = list(context.messages) + annotate_message_groups(messages) + first_replacement, second_replacement = messages[:2] + assert first_replacement.message_id is not None + assert second_replacement.message_id is not None + inner_summaries = [ + Message( + role="user", + contents=[f""], + message_id=f"summary_inner_{index}", + additional_properties={ + GROUP_ANNOTATION_KEY: {SUMMARY_OF_MESSAGE_IDS_KEY: [replacement.message_id]} + }, + ) + for index, replacement in enumerate((first_replacement, second_replacement), start=1) + ] + for replacement, summary in zip((first_replacement, second_replacement), inner_summaries, strict=True): + annotation = replacement.additional_properties.setdefault(GROUP_ANNOTATION_KEY, {}) + annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] = summary.message_id + replacement.additional_properties[EXCLUDED_KEY] = True + + outer_summary = Message( + role="user", + contents=[""], + message_id="summary_outer", + additional_properties={ + GROUP_ANNOTATION_KEY: { + SUMMARY_OF_MESSAGE_IDS_KEY: [summary.message_id for summary in inner_summaries] + } + }, + ) + for summary in inner_summaries: + annotation = summary.additional_properties.setdefault(GROUP_ANNOTATION_KEY, {}) + annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] = outer_summary.message_id + summary.additional_properties[EXCLUDED_KEY] = True + + context.messages = [outer_summary, *inner_summaries, *messages[2:]] + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _SplitToolMessage(), + _NestedCompaction(), + ] + messages = [ + Message(role="tool", contents=["tool output"]), + Message(role="user", contents=["q2"]), + ] + + with caplog.at_level(logging.WARNING, logger="agent_framework"): + await chat_client_base.get_response(messages) + + assert [message.text for message in messages] == ["", "tool output", "q2"] + assert messages[1].additional_properties[EXCLUDED_KEY] is True + annotation = messages[1].additional_properties[GROUP_ANNOTATION_KEY] + assert annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] == "summary_outer" + assert not any(record.getMessage().startswith("Rejected ") for record in caplog.records) + + +async def test_chat_middleware_rejects_duplicate_summary_ids( + chat_client_base: SupportsChatGetResponse, + caplog: pytest.LogCaptureFixture, +) -> None: + class _DuplicateSummaryIds(ChatMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + messages = list(context.messages) + annotate_message_groups(messages) + summarized = messages[0] + assert summarized.message_id is not None + summary_properties = {GROUP_ANNOTATION_KEY: {SUMMARY_OF_MESSAGE_IDS_KEY: [summarized.message_id]}} + summaries = [ + Message( + role="user", + contents=[f""], + message_id="summary_1", + additional_properties=summary_properties, + ) + for index in range(2) + ] + annotation = summarized.additional_properties.setdefault(GROUP_ANNOTATION_KEY, {}) + annotation[SUMMARIZED_BY_SUMMARY_ID_KEY] = "summary_1" + summarized.additional_properties[EXCLUDED_KEY] = True + context.messages = [*summaries, *messages[1:]] + await call_next() + + chat_client_base.function_invocation_configuration["enabled"] = False # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.chat_middleware = [_DuplicateSummaryIds()] # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + messages = [ + Message(role="user", contents=["q1"]), + Message(role="user", contents=["q2"]), + ] + + with caplog.at_level(logging.WARNING, logger="agent_framework"): + await chat_client_base.get_response(messages) + + assert [message.text for message in messages] == ["q1", "q2"] + assert messages[0].additional_properties.get(EXCLUDED_KEY) is not True + assert any(record.getMessage().startswith("Rejected 2 compaction summary message") for record in caplog.records) + + async def test_chat_middleware_reconciles_recorded_replacement_before_termination_and_restore( chat_client_base: SupportsChatGetResponse, ) -> None: From d781d9675e838991cdb18a82a63ccc2990b4259a Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 24 Sep 2026 09:20:21 +0200 Subject: [PATCH 3/3] Python: snapshot fallback reconciliation messages --- python/packages/core/agent_framework/_middleware.py | 4 ++-- python/packages/core/tests/core/test_clients.py | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 1d2a3171581..599657baedb 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -1472,8 +1472,8 @@ async def current_handler() -> None: await self._middleware[index].process(context, create_next_handler(index + 1)) finally: if context._fallback_reconciliation_messages is None: # pyright: ignore[reportPrivateUsage] - context._fallback_reconciliation_messages = ( # pyright: ignore[reportPrivateUsage] - context.messages if isinstance(context.messages, list) else list(context.messages) + context._fallback_reconciliation_messages = list( # pyright: ignore[reportPrivateUsage] + context.messages ) return current_handler diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index 65a7244b42b..b122b9c5ebf 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -1136,14 +1136,20 @@ async def process(self, context: Any, call_next: Any) -> None: assert any(record.getMessage().startswith("Rejected 2 compaction summary message") for record in caplog.records) +@pytest.mark.parametrize("restore_in_place", [False, True], ids=["rebind", "in-place"]) async def test_chat_middleware_reconciles_recorded_replacement_before_termination_and_restore( chat_client_base: SupportsChatGetResponse, + restore_in_place: bool, ) -> None: class _RestoreAfterCall(ChatMiddleware): async def process(self, context: Any, call_next: Any) -> None: original_messages = tuple(context.messages) await call_next() - context.messages = original_messages + if restore_in_place: + assert isinstance(context.messages, list) + context.messages[:] = original_messages + else: + context.messages = original_messages class _ReplaceToolMessage(ChatMiddleware): async def process(self, context: Any, call_next: Any) -> None: