Skip to content

Python: [Bug]: ChatMiddleware has no supported way to make a durable change to the message list, so compaction silently no-ops behind any middleware that replaces messages #8313

Closed

Description

Description

Follow-up to #7744, fixed by #7912. This is not a request to revert that design — the goal of "reconcile summaries without persisting unrelated rewrites" is right. The gap is that there is now no way for a middleware to declare a rewrite as related, so in practice "don't persist unrelated rewrites" behaves as "don't persist any rewrite made by a middleware that constructs new Message objects."

The rule as it stands

_reconcile_compaction_summaries (_compaction.py:378) accepts an inserted summary only if every id in its SUMMARY_OF_MESSAGE_IDS appears in source_message_ids — the ids of the caller's list (_compaction.py:384, 403-412). When it does not accept, it does not merely skip: it reverts the exclusions (_compaction.py:413-421).

So a ChatMiddleware can durably change message objects (in-place mutation of additional_properties survives, since the objects are shared) but cannot durably change the list, and loses even the object-level changes if any other middleware in the pipeline replaced a message.

Why this bites in a supported configuration

Replacing messages in middleware is not an exotic thing to do — the framework's own client asks for it. agent_framework_openai/_chat_completion_client.py:1139-1146:

OpenAI Chat Completions API does not support rich content (images, audio) in tool results. Rich content items will be omitted. Use the Responses API client for rich tool results.

For deployments pinned to Chat Completions, the answer is a middleware that moves those items into a user message so the vision model can still see them. That produces a message with no counterpart in the caller's list. A compaction middleware ordered after it is then silently disabled: the summary is rejected, the exclusions are reverted, the conversation is unchanged — and because a ChatMiddleware runs once per model call, this repeats on every iteration of the tool loop, paying for a summarizer model call each time.

There is no exception and no log record. The only visible symptom is a context window that keeps growing while compaction appears to be running.

Expected

A ChatMiddleware has some supported way to make a durable change to the message list — opt-in, so the default stays conservative.

Actual

Durable list changes are possible only for a middleware that no other middleware downstream of it disturbs, and the failure to persist is silent.

Steps to reproduce

Run the script below; it needs no network and no model. Only the middleware order differs between the two runs.

Code Sample

"""Repro: a ChatMiddleware cannot make a durable change to the message list."""
import asyncio

from agent_framework import (
    EXCLUDED_KEY,
    GROUP_ANNOTATION_KEY,
    SUMMARIZED_BY_SUMMARY_ID_KEY,
    SUMMARY_OF_MESSAGE_IDS_KEY,
    BaseChatClient,
    ChatMiddleware,
    ChatMiddlewareLayer,
    ChatResponse,
    Message,
    annotate_message_groups,
    included_messages,
)
from agent_framework.observability import ChatTelemetryLayer


class EchoClient(ChatMiddlewareLayer, ChatTelemetryLayer, BaseChatClient):
    async def _inner_get_response(self, *, messages, stream, options, **kwargs):
        return ChatResponse(messages=[Message(role="assistant", contents=["ok"])])


class RewriteMiddleware(ChatMiddleware):
    """Stands in for any middleware that adapts messages for a provider's wire format,
    e.g. moving images out of a tool result for the Chat Completions API."""

    async def process(self, context, call_next):
        context.messages = [
            Message(role=m.role, contents=m.contents) if m.role == "tool" else m
            for m in context.messages
        ]
        await call_next()


class CompactMiddleware(ChatMiddleware):
    """Summarizes everything but the newest group, as CompactionStrategy documents."""

    async def process(self, context, call_next):
        messages = list(context.messages)
        annotate_message_groups(messages)
        summarized = messages[:-1]
        summary = Message(
            role="user",
            contents=["<summary of earlier conversation>"],
            message_id="summary_1",
            additional_properties={
                GROUP_ANNOTATION_KEY: {
                    SUMMARY_OF_MESSAGE_IDS_KEY: [m.message_id for m in summarized if m.message_id]
                }
            },
        )
        for m in summarized:
            m.additional_properties.setdefault(GROUP_ANNOTATION_KEY, {})[
                SUMMARIZED_BY_SUMMARY_ID_KEY
            ] = "summary_1"
            m.additional_properties[EXCLUDED_KEY] = True
        messages.insert(0, summary)
        context.messages = list(included_messages(messages))
        await call_next()


async def main(order: str) -> None:
    middleware = (
        [RewriteMiddleware(), CompactMiddleware()] if order == "rewrite-first"
        else [CompactMiddleware(), RewriteMiddleware()]
    )
    client = EchoClient(middleware=middleware)
    conversation = [
        Message(role="user", contents=["q1"], message_id="m1"),
        Message(role="tool", contents=["tool output"], message_id="m2"),
        Message(role="user", contents=["q2"], message_id="m3"),
    ]
    await client.get_response(conversation)
    excluded = [m.message_id for m in conversation if m.additional_properties.get(EXCLUDED_KEY)]
    has_summary = any(m.message_id == "summary_1" for m in conversation)
    print(f"  order={order:14} caller list len={len(conversation)}  "
          f"summary reconciled={has_summary}  still excluded={excluded}")


print("After one compacted call, what survived in the CALLER's list:")
asyncio.run(main("compact-first"))
asyncio.run(main("rewrite-first"))

Output:

After one compacted call, what survived in the CALLER's list:
  order=compact-first  caller list len=4  summary reconciled=True  still excluded=['m1', 'm2']
  order=rewrite-first  caller list len=3  summary reconciled=False  still excluded=[]

Error Messages / Stack Traces

None — and that is part of the report. A rejected reconciliation produces no exception and no log record.

Package Versions

agent-framework-core: 1.17.0, agent-framework-openai: 1.14.2, agent-framework-anthropic: 1.0.0b260827

Python Version

Python 3.14.6

Additional Context

Proposed fix: let a middleware declare provenance

The reconciliation already resolves a transitive support graph — source_dependencies (_compaction.py:423-432) expands a summary's dependencies through other accepted summaries, so a summary of a summary is handled. A replacement message is the same shape of edge, and the existing fixed-point loop would resolve it unchanged.

Concretely: let a middleware record, on a message it constructs, which source message ids it supersedes — e.g. a SUPERSEDES_MESSAGE_IDS_KEY in the group annotation, the mirror of SUMMARY_OF_MESSAGE_IDS_KEY. source_message_ids would then be seeded with ids reachable through those edges, and a summary covering a promoted message would be accepted because its provenance terminates in the caller's list.

This keeps #7912's guarantee intact: a middleware that says nothing still cannot persist a rewrite. Only a middleware that explicitly claims provenance participates.

Alternatives considered

Minimum useful outcome

If none of the above is wanted, two much smaller changes would still have saved us the investigation:

  1. Document the ordering constraint on ChatMiddleware and CompactionStrategy: a middleware that compacts must run upstream of any middleware that replaces Message objects.
  2. Log when a summary is rejected and exclusions are reverted. A single warning at _compaction.py:413-421 would turn a silent, repeating, billable no-op into something diagnosable.

Relationship to open work

#8099 / #8117 generalize _reconcile_compaction_summaries so the function-invocation loop uses it too. That is adjacent but distinct: it addresses summaries not reaching AgentResponse, whereas this is about a middleware's own rewrites being unable to participate in reconciliation at all. Worth confirming the two designs agree before #8117 lands.

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Sep 11, 2026
  2. added
    agentsUsage: [Issues, PRs], Target: Single agent
    compactionUsage: [Issues, PRs], Target: compaction
    middlewareUsage: [Issues, PRs], Target: middleware related features
    on Sep 11, 2026
  3. removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Sep 11, 2026
  4. manjunathshiva commented on Sep 18, 2026

    @manjunathshiva
    Contributor

    Jae Jimmy Wong (@jjwong0915) your closing line — "worth confirming the two designs agree before #8117 lands" — that checkpoint has passed, and it moved this issue rather than settling it.

    #8117 merged 17 Sep as e24a1f2c4, and it gave _reconcile_compaction_summaries its first call sites in _tools.py: zero before that commit, three now (:4489, :4577, and the local import at :4396). It touched the function itself only to rename a parameter (previous_message_ids → source_message_identities) and reword the docstring — the accept/reject logic and the revert were not revisited. So the silent revert you documented is no longer reachable only through middleware ordering — it is now on the function-invocation loop's own path, called after each model call. Your line numbers drifted with that work: the acceptance check is _compaction.py:422 and the revert is :463-466.

    I re-read the function on current main and the second half of your report still holds exactly: there are zero logger calls anywhere in it, so a rejected summary still pops SUMMARIZED_BY_SUMMARY_ID_KEY, EXCLUDED_KEY and EXCLUDE_REASON_KEY with no exception and no record.

    #8117 also added the In-run compaction summaries row to spec 004's scenario matrix (docs/specs/004-python-function-calling-loop.md:557), which puts this surface squarely under that spec's Contribution ownership clause. So I am asking instead of opening a PR.

    Eduard van Valkenburg (@eavanvalkenburg), two questions:

    1. Should the provenance design — SUPERSEDES_MESSAGE_IDS_KEY, seeding source_message_ids through supersede edges — be settled as part of the reconciliation contract, now that the loop depends on that function too?
    2. Independently of (1): would you accept Jae Jimmy Wong (@jjwong0915)'s own minimum useful outcome item 2 — a single warning at the revert site — as a separate, non-contract diagnosability change? Roughly five lines and a caplog test. It changes no accept/reject decision, so it should not need the matrix row reopened, but that is your call and not mine.

    If (2) is wanted, Jae Jimmy Wong (@jjwong0915) proposed it and gets first refusal. I am happy to write it only if they would rather not.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

agentsUsage: [Issues, PRs], Target: Single agentcompactionUsage: [Issues, PRs], Target: compactionmiddlewareUsage: [Issues, PRs], Target: middleware related featurespythonUsage: [Issues, PRs], Target: Python

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions