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
Description
create_harness_agent:before_strategycompaction never fires —CompactionProvider.before_runalways sees empty contextcreate_harness_agent()wires aCompactionProviderwith abefore_strategy(ContextWindowCompactionStrategy) intended to compact loaded history before each model call. However, because the harness also setsrequire_per_service_call_history_persistence=True, theHistoryProvider.before_run()is explicitly skipped at the agent level, soCompactionProvider.before_run()always receives an empty message list and returns immediately — thebefore_strategynever executes.Only the
after_strategy(ToolResultCompactionStrategy) fires, inafter_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=Truecreate_harness_agent()constructs anAgentwith this flag:2.
_assemble_compaction_provider()builds both strategiesThe
CompactionProvideris placed second in the provider list (right afterHistoryProvider):3. Agent skips
HistoryProvider.before_run()when per-service-call persistence is enabled4.
CompactionProvider.before_run()sees an empty context and exitscontext.get_messages()returns onlycontext_messages(provider-contributed messages), notinput_messages. SinceHistoryProvider.before_run()was skipped and no prior provider has populatedcontext_messages, the list is empty.5. History is loaded by
PerServiceCallHistoryPersistingMiddlewareinto a separate contextThe
CompactionProvideris aContextProvider, not aHistoryProvider, so it is not included in the per-service-call middleware. It never sees the loaded history during model calls.Impact
before_run(agent level)ContextWindowCompactionStrategy(token-budget: tool eviction + truncation)after_run(agent level)ToolResultCompactionStrategyHistoryProviderloadsCompactionProvidernot involvedConsequences:
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.max_context_window_tokens/max_output_tokensparameters passed tocreate_harness_agent()appear to enable full token-budget compaction, but only theafter_strategy(tool result compaction) actually uses them — and only indirectly (theafter_strategydefaultToolResultCompactionStrategydoes not use token budgets at all).Environment
agent-framework(Python)Reproduction
Suggested fix
Option A: Run
CompactionProvider.before_runinside the per-service-call middlewareInclude the
CompactionProviderinPerServiceCallHistoryPersistingMiddleware._prepare_service_call_context()so that after theHistoryProviderloads messages, theCompactionProvidercompacts them in the same per-call context before the messages reach the model:This would require the middleware to accept
Sequence[ContextProvider](or at least theCompactionProvider) in addition toSequence[HistoryProvider].Option B: Load history at the agent level before compaction, skip per-service-call loading
When a
CompactionProviderwith abefore_strategyis present, load history once at the agent level (don't skipHistoryProvider.before_run()), then letCompactionProvider.before_run()compact it. This is simpler but changes the per-service-call persistence semantics.Option C: Move
before_strategycompaction into a chat middlewareConvert the
before_strategycompaction into aChatMiddlewarethat runs around each model call (likePerServiceCallHistoryPersistingMiddleware), operating on the messages in theChatContextafter history has been loaded. This keeps compaction close to the model call and avoids the context-provider ordering issue entirely.Additional notes
after_strategy(ToolResultCompactionStrategy) inCompactionProvider.after_run()does work correctly — it accessessession.state[history_source_id]["messages"]directly and doesn't depend on thebefore_runcontext.skip_excludedflag onInMemoryHistoryProvider(defaultFalse) controls whether excluded (compacted-out) messages are loaded on the next turn. Sinceafter_runmarks messages as excluded in the stored list, settingskip_excluded=Truewould prevent re-loading them. However, the defaultInMemoryHistoryProvidercreated by the harness does not setskip_excluded=True.HarnessAgent/HarnessAgentOptions) appears to handle this differently — itsCompactionStrategyis applied as a chat-level option, not as a context provider, which may avoid this ordering issue.Code Sample
Language/SDK
Python