OpenHands / OpenHands/software-agent-sdk
Incremental view path skips enforce_properties after condensation, leaving orphaned tool_results/tool_uses for the LLM
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 1.1k
- Forks
- 539
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 137
Description
Bug
The incremental view path (ConversationState.view hot path) does not call enforce_properties after applying a Condensation, so a condensation that forgets an action but keeps its observation (or vice versa) leaves an orphaned tool_result (or tool_use) in the view that gets sent to the LLM. Anthropic (and other providers) reject this with a malformed-conversation-history error such as:
tool_useids were found withouttool_resultblocks immediately after
or
each
tool_resultblock must have a correspondingtool_useblock in the previous message
Root cause
View.append_event applies a Condensation by calling event.apply(self.events), which removes forgotten events by ID. This can break action/observation pairs — for example, forgetting an ActionEvent (tool_use) while keeping its ObservationEvent (tool_result), or the other way around.
The ToolCallMatchingProperty and ObservationUniquenessProperty exist precisely to drop such orphans, but enforce_properties only runs on the rebuild path (View.from_events → enforce_properties). The incremental path (ConversationState.view fast path, which calls append_event for each new tail event) intentionally skips enforce_properties for performance (see #3053 and test_hot_path_does_not_call_enforce_properties).
As a result, when a condensation is applied incrementally and breaks a pair, the orphan stays in the cached view and is sent to the LLM on the next step.
Repro
from openhands.sdk.context.view import View
from openhands.sdk.event.condenser import Condensation
from openhands.sdk.event.llm_convertible import ActionEvent, MessageEvent, ObservationEvent
from openhands.sdk.llm import Message, MessageToolCall, TextContent
from openhands.sdk.mcp.definition import MCPToolAction, MCPToolObservation
msg = MessageEvent(llm_message=Message(role="user", content=[TextContent(text="hi")]), source="user")
a1 = ActionEvent(thought=[], action=MCPToolAction(data={}), tool_name="t", tool_call_id="c1",
tool_call=MessageToolCall(id="c1", name="t", arguments="{}", origin="completion"),
llm_response_id="r1", source="agent")
o1 = ObservationEvent(observation=MCPToolObservation.from_text(text="ok", tool_name="t"),
tool_name="t", tool_call_id="c1", action_id="a1", source="environment")
view = View()
for e in [msg, a1, o1]:
view.append_event(e)
# Condensation forgets the action but not the observation.
view.append_event(Condensation(forgotten_event_ids={a1.id}, llm_response_id="r2"))
# Before the fix: view still contains o1 (orphan tool_result).
# After the fix: o1 is dropped by enforce_properties.
ids = {e.id for e in view.events}
assert o1.id not in ids # orphan must be dropped
Before the fix, o1.id is still in the view; View.from_events (rebuild path) would have dropped it.
Fix
Call self.enforce_properties(self.events) at the end of the Condensation branch in View.append_event. Using self.events as the all_events reference is safe: the pair-matching properties (ToolCallMatchingProperty, ObservationUniquenessProperty) only inspect the current view events, and the all_events-dependent properties (BatchAtomicityProperty, ToolLoopAtomicityProperty) become no-ops when all_events == current view events (they detect no missing batches/loops because the reference set is the same). The full enforcement still runs on rebuild_view() (cold load, fork, error recovery).
This does not affect the hot-path performance for normal (non-condensation) appends — enforce_properties is only called after a Condensation is applied.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in openhands/sdk/context/view.py at View.append_event and its Condensation branch. Reproduce the orphan case from the issue, then run the relevant view tests, including test_hot_path_does_not_call_enforce_properties; done means incremental condensation drops unmatched tool events without changing normal append behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- ai, backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 90/100