Repo: https://github.com/All-Hands-AI/OpenHands
Commit: bf769d1
Language: Python
OpenHands has the most architecturally distinct compression system of any harness studied. Rather than operating on a chat message history, it operates on an event store — everything is an Event (actions, observations, tool results, agent thoughts) with a persistent numeric ID. Compression is handled by a plugin-based condenser system with 9 pluggable strategies, three of which use LLM calls. The default is a no-LLM window condenser.
The agent itself can also request condensation by calling a tool, allowing the model to self-regulate.
Every step in an OpenHands session produces events: MessageAction, CmdRunAction, BrowserOutputObservation, CondensationAction, etc. All events are persisted with sequential integer IDs.
A View is a filtered list of events computed from the full event store on every agent step:
View.from_events(all_events)This replays all CondensationAction events in order, accumulating a set of forgotten_event_ids. Events in that set are excluded from the view. If the most recent CondensationAction has a summary, an AgentCondensationObservation is inserted at summary_offset (default: 1, after the system message).
class Condenser(ABC):
def condense(self, view: View) -> View | Condensation: ...Returns either:
- A new
View(filtered in-place, e.g. masking condensers) - A
Condensationwrapping aCondensationActionto be added to the event store
On each agent step (codeact_agent.py):
match self.condenser.condensed_history(state):
case View() as events:
condensed_history = events
case Condensation() as condensation:
return condensation.action # ← returned as the agent's "action" this turnIf a Condensation is returned, the agent emits CondensationAction instead of doing real work. On the next step, View.from_events applies it, and the agent proceeds with the condensed view.
This is neither piggyback nor extract in the traditional sense. The mechanism is:
CondensationAction(forgotten_events_start_id=X, forgotten_events_end_id=Y, summary=..., summary_offset=K)is persisted to the event store- On all future steps,
View.from_eventsexcludes event IDs in[X, Y](and theCondensationActionitself) - If
summaryis set, anAgentCondensationObservation(content=summary)is inserted at positionK
Non-destructive: Events are never deleted from the persistent store. The CondensationAction is the only record needed to reconstruct which events are hidden.
| Condenser | Config type | Trigger | Behavior |
|---|---|---|---|
NoOpCondenser |
noop |
Never | Returns view unchanged |
ObservationMaskingCondenser |
observation_masking |
Every step | Replaces observations outside attention_window (default: 5) with <MASKED> in-place. View mutation only. |
BrowserOutputCondenser |
browser_output |
Every step | Replaces old BrowserOutputObservation events outside attention_window (default: 1) with "Visited URL {url}\nContent omitted". Keeps only the most recent browser screenshot/tree. |
RecentEventsCondenser |
recent_events |
Every step | Keeps first keep_first + most recent max_events events. Returns truncated View. |
AmortizedForgettingCondenser |
amortized_forgetting |
len(view) > max_size or condensation request |
Forgets middle events (keeps head + tail = max_size // 2). No summary. |
ConversationWindowCondenser |
conversation_window |
Only on unhandled_condensation_request |
Keeps essential initial events (system msg, first user msg, recall obs) + most recent ~half. No summary. |
| Condenser | Config type | Trigger | Prompt style |
|---|---|---|---|
LLMSummarizingCondenser |
llm |
len(view) > max_size or condensation request |
Free-form text rolling summary |
StructuredSummaryCondenser |
structured_summary |
len(view) > max_size or condensation request |
Forced structured output via function calling (17-field StateSummary) |
LLMAttentionCondenser |
llm_attention |
len(view) > max_size or condensation request |
Ranks events by importance, keeps top-N (no summary text) |
CondenserPipeline (type = "pipeline") chains multiple condensers. Each step receives the output view of the previous. Stops at first Condensation. Useful for combining e.g. BrowserOutputCondenser + LLMSummarizingCondenser.
openhands/core/config/agent_config.py, line 63
condenser: CondenserConfig = Field(
default_factory=lambda: ConversationWindowCondenserConfig()
)ConversationWindowCondenser — no LLM, only fires on unhandled_condensation_request. This means by default, condensation only happens when:
- The agent itself calls
request_condensationtool, OR - The agent is overloaded enough that the model emits a condensation request
Without any explicit user/agent condensation request, no compression happens under the default config.
def should_condense(self, view: View) -> bool:
return len(view) > self.max_size or view.unhandled_condensation_requestmax_sizedefault: 100 events (configurable per condenser)keep_firstdefault: 1 event (system message / first message always kept)- After condensation, history is trimmed to
max_size // 2(= 50 events by default)
def should_condense(self, view: View) -> bool:
return view.unhandled_condensation_requestOnly fires on agent-triggered requests. No event-count limit.
For masking/window condensers (ObservationMaskingCondenser, BrowserOutputCondenser, RecentEventsCondenser)
Fire on every step — they modify the view inline, not via CondensationAction.
Important: None of the condensers check token counts directly. Thresholds are event counts. Token budget management is not built into the condenser system — it relies on the LLM's context window pressure and the agent's self-triggered request_condensation.
The CodeActAgent includes a request_condensation tool in its tool list. The agent model can call this when it feels the context is getting unwieldy:
{
"name": "request_condensation",
"description": "Request a condensation of the conversation history when the context becomes too long..."
}Calling this tool produces a CondensationRequestAction event. View.from_events detects this and sets view.unhandled_condensation_request = True. The condenser then fires on the next step.
This is the primary trigger for the default ConversationWindowCondenser. It's also an additional trigger for all RollingCondenser subclasses (which would fire anyway at max_size, but self-request can happen earlier).
For LLMSummarizingCondenser and StructuredSummaryCondenser:
HEAD (keep_first events)
↓
[AgentCondensationObservation with rolling summary] ← inserted by View.from_events
↓
TAIL (target_size - keep_first - 1 events from end)
On each condensation:
view[keep_first]is checked: if it's already aAgentCondensationObservation, it becomes the<PREVIOUS SUMMARY>in the new promptforgotten_events = view[keep_first : -events_from_tail]— the middle chunk being dropped- LLM summarises
forgotten_eventswith awareness of the previous summary - New
CondensationActionwithsummary=<new_text>andsummary_offset=keep_firstis persisted - Old
CondensationAction(and its summary) are superseded — only the most recent summary is used byView.from_events
This is a rolling/incremental summary: each condensation builds on the previous summary, not on the full history.
Two different approaches exist in parallel:
Event erasure (via CondensationAction): entire events are removed from the view. Used by rolling condensers and AmortizedForgettingCondenser.
Content replacement (in-place View mutation): event objects are replaced with placeholder AgentCondensationObservation objects. Used by:
ObservationMaskingCondenser:<MASKED>BrowserOutputCondenser:"Visited URL {url}\nContent omitted"
Content replacement happens every step (not recorded in event store). Event erasure is persistent.
Via config.toml:
[condenser]
type = "llm" # or: noop, observation_masking, browser_output, recent_events,
# amortized_forgetting, conversation_window,
# structured_summary, llm_attention, pipeline
max_size = 100 # max events before condensation
keep_first = 1 # events always kept at head
max_event_length = 10000 # chars per event before truncation in prompt
llm_config = "condenser_llm" # name of [llm.condenser_llm] section
# Example: use a cheaper model for condensation
[llm.condenser_llm]
model = "gpt-4o-mini"
api_key = "..."For pipeline:
[condenser]
type = "pipeline"
[[condenser.condensers]]
type = "browser_output"
attention_window = 1
[[condenser.condensers]]
type = "llm"
max_size = 80| Scenario | Handling |
|---|---|
| No condensation ever requested (default config) | ConversationWindowCondenser.should_condense() returns False — no condensation occurs |
| Multiple condensations | Rolling summary: each CondensationAction supersedes the previous. View.from_events uses only the last summary. |
LLMSummarizingCondenser with no prior summary |
summary_event.message = "No events summarized" — fed as empty <PREVIOUS SUMMARY> |
StructuredSummaryCondenser tool call parse failure |
Falls back to empty StateSummary() with warning |
LLMSummarizingCondenser — prompt caching |
Disabled explicitly: llm_config.caching_prompt = False (summary changes each time, caching wastes write credits) |
| Event reordering/gaps | forgotten is a set; View.from_events checks event.id not in forgotten_event_ids — handles sparse event IDs |
keep_first >= max_size // 2 |
Raises ValueError at init |
| File | Purpose |
|---|---|
openhands/memory/condenser/condenser.py |
Abstract Condenser, RollingCondenser, Condensation, View types, registry |
openhands/memory/view.py |
View.from_events() — reconstructs filtered view from event store |
openhands/memory/condenser/impl/llm_summarizing_condenser.py |
Text-based rolling LLM summary |
openhands/memory/condenser/impl/structured_summary_condenser.py |
Function-call structured summary (17 fields) |
openhands/memory/condenser/impl/llm_attention_condenser.py |
LLM importance ranking (no summary) |
openhands/memory/condenser/impl/conversation_window_condenser.py |
Default: window drop, no LLM |
openhands/memory/condenser/impl/amortized_forgetting_condenser.py |
Drop middle, no LLM |
openhands/memory/condenser/impl/observation_masking_condenser.py |
Mask old observations in-place |
openhands/memory/condenser/impl/browser_output_condenser.py |
Mask old browser screenshots in-place |
openhands/memory/condenser/impl/pipeline.py |
Chain multiple condensers |
openhands/core/config/condenser_config.py |
All config types, condenser_config_from_toml_section() |
openhands/core/config/agent_config.py |
Default condenser = ConversationWindowCondenserConfig |
openhands/agenthub/codeact_agent/codeact_agent.py |
Agent loop — calls condensed_history(), handles Condensation return |
openhands/events/action/agent.py |
CondensationAction, CondensationRequestAction event types |