Skip to content

Python: [Feature]: Add before_strategy compaction support in create_harness_agent #7011

Description

@antsok

Description

create_harness_agent: before_strategy compaction never fires — CompactionProvider.before_run always sees empty context

create_harness_agent() wires a CompactionProvider with a before_strategy (ContextWindowCompactionStrategy) intended to compact loaded history before each model call. However, because the harness also sets require_per_service_call_history_persistence=True, the HistoryProvider.before_run() is explicitly skipped at the agent level, so CompactionProvider.before_run() always receives an empty message list and returns immediately — the before_strategy never executes.

Only the after_strategy (ToolResultCompactionStrategy) fires, in after_run, compacting tool results in the persisted history after each turn. No token-budget truncation is ever applied to the messages sent to the model.

1. Harness sets require_per_service_call_history_persistence=True

create_harness_agent() constructs an Agent with this flag:

# agent_framework/_harness/_agent.py — create_harness_agent()

agent = Agent(
    client,
    instructions,
    ...
    require_per_service_call_history_persistence=True,   # ← always set
)

2. _assemble_compaction_provider() builds both strategies

# agent_framework/_harness/_agent.py — _assemble_compaction_provider()

before_strategy = ContextWindowCompactionStrategy(
    max_context_window_tokens=max_context_window_tokens,
    max_output_tokens=max_output_tokens,
    tokenizer=tokenizer,
)
after_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=2)

return CompactionProvider(
    before_strategy=before_strategy,
    after_strategy=after_strategy,
    ...
)

The CompactionProvider is placed second in the provider list (right after HistoryProvider):

# _assemble_context_providers()
providers.append(history_provider)       # 1st
if compaction_provider is not None:
    providers.append(compaction_provider) # 2nd

3. Agent skips HistoryProvider.before_run() when per-service-call persistence is enabled

# agent_framework/_agents.py — _prepare_session_context()

per_service_call_history_required = self.require_per_service_call_history_persistence and bool(
    self._get_history_providers()
)

for provider in self.context_providers:
    if per_service_call_history_required and isinstance(provider, HistoryProvider):
        continue   # ← HistoryProvider.before_run() is SKIPPED
    ...
    await provider.before_run(...)

4. CompactionProvider.before_run() sees an empty context and exits

# agent_framework/_compaction.py — CompactionProvider.before_run()

async def before_run(self, *, agent, session, context, state):
    if self.before_strategy is None:
        return
    all_messages: list[Message] = context.get_messages()  # ← returns []
    if not all_messages:
        return   # ← EXITS IMMEDIATELY — before_strategy never called
    ...

context.get_messages() returns only context_messages (provider-contributed messages), not input_messages. Since HistoryProvider.before_run() was skipped and no prior provider has populated context_messages, the list is empty.

5. History is loaded by PerServiceCallHistoryPersistingMiddleware into a separate context

# agent_framework/_sessions.py — PerServiceCallHistoryPersistingMiddleware

class PerServiceCallHistoryPersistingMiddleware(ChatMiddleware):
    def __init__(self, *, providers: Sequence[HistoryProvider], ...):
        self._providers = list(providers)  # ← only HistoryProvider instances

    async def _prepare_service_call_context(self, messages):
        service_call_context = SessionContext(...)  # ← separate context
        for provider in self._providers:             # ← only HistoryProvider
            await provider.before_run(
                ...,
                context=service_call_context,  # ← CompactionProvider not involved
            )
        return service_call_context

The CompactionProvider is a ContextProvider, not a HistoryProvider, so it is not included in the per-service-call middleware. It never sees the loaded history during model calls.

Impact

Phase Strategy Actually fires? Notes
before_run (agent level) ContextWindowCompactionStrategy (token-budget: tool eviction + truncation) No Always sees empty context, returns immediately
after_run (agent level) ToolResultCompactionStrategy Yes Compacts tool results in stored history after each turn
Per-model-call (middleware) N/A — only HistoryProvider loads N/A CompactionProvider not involved

Consequences:

  • No token-budget truncation is ever applied to messages sent to the model. A long conversation with many user/assistant text turns (not tool calls) will grow unbounded until the model itself rejects the request for exceeding its context window.
  • Tool-result compaction only happens after the fact, in after_run. On the next turn, the model sees the already-compacted history (old tool results collapsed into [Tool results: ...] summaries), but no sliding-window or truncation strategy guards against overall token growth.
  • The max_context_window_tokens / max_output_tokens parameters passed to create_harness_agent() appear to enable full token-budget compaction, but only the after_strategy (tool result compaction) actually uses them — and only indirectly (the after_strategy default ToolResultCompactionStrategy does not use token budgets at all).

Environment

  • Package: agent-framework (Python)
  • Version: 1.10.0 (latest release at time of filing; verified identical behavior in 1.0.0rc2 through 1.10.0)

Reproduction

import asyncio
from agent_framework import create_harness_agent
from agent_framework.openai import OpenAIChatClient

async def main():
    agent = create_harness_agent(
        OpenAIChatClient(model="gpt-4o"),
        max_context_window_tokens=128_000,
        max_output_tokens=16_384,
    )
    session = agent.create_session()

    # Simulate a long conversation with many text turns (no tool calls).
    # The before_strategy (ContextWindowCompactionStrategy) should truncate
    # old messages when token count exceeds 80% of the input budget,
    # but it never fires because HistoryProvider.before_run is skipped.
    for i in range(200):
        await agent.run(f"Tell me about topic {i}", session=session)

    # At this point the persisted history is ~400 messages with no truncation.
    # Only tool-result compaction would have fired — but there are no tool calls,
    # so nothing was compacted. The next model call will likely exceed the
    # context window.

asyncio.run(main())

Suggested fix

Option A: Run CompactionProvider.before_run inside the per-service-call middleware

Include the CompactionProvider in PerServiceCallHistoryPersistingMiddleware._prepare_service_call_context() so that after the HistoryProvider loads messages, the CompactionProvider compacts them in the same per-call context before the messages reach the model:

# Pseudocode for _prepare_service_call_context():
service_call_context = SessionContext(...)

# Load history providers
for provider in self._history_providers:
    await provider.before_run(..., context=service_call_context, ...)

# Run non-history context providers (compaction, etc.) that need loaded messages
for provider in self._context_providers:
    if isinstance(provider, HistoryProvider):
        continue
    await provider.before_run(..., context=service_call_context, ...)

return service_call_context

This would require the middleware to accept Sequence[ContextProvider] (or at least the CompactionProvider) in addition to Sequence[HistoryProvider].

Option B: Load history at the agent level before compaction, skip per-service-call loading

When a CompactionProvider with a before_strategy is present, load history once at the agent level (don't skip HistoryProvider.before_run()), then let CompactionProvider.before_run() compact it. This is simpler but changes the per-service-call persistence semantics.

Option C: Move before_strategy compaction into a chat middleware

Convert the before_strategy compaction into a ChatMiddleware that runs around each model call (like PerServiceCallHistoryPersistingMiddleware), operating on the messages in the ChatContext after history has been loaded. This keeps compaction close to the model call and avoids the context-provider ordering issue entirely.

Additional notes

  • The after_strategy (ToolResultCompactionStrategy) in CompactionProvider.after_run() does work correctly — it accesses session.state[history_source_id]["messages"] directly and doesn't depend on the before_run context.
  • The skip_excluded flag on InMemoryHistoryProvider (default False) controls whether excluded (compacted-out) messages are loaded on the next turn. Since after_run marks messages as excluded in the stored list, setting skip_excluded=True would prevent re-loading them. However, the default InMemoryHistoryProvider created by the harness does not set skip_excluded=True.
  • The .NET harness (HarnessAgent / HarnessAgentOptions) appears to handle this differently — its CompactionStrategy is applied as a chat-level option, not as a context provider, which may avoid this ordering issue.

Code Sample

Language/SDK

Python

Metadata

Metadata

Assignees

Labels

.NETUsage: [Issues, PRs], Target: .NetpythonUsage: [Issues, PRs], Target: Python

Fields

No fields configured for Feature.

Projects

Status
No status

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions