Skip to content

fix(condenser): show the summarizer whole events, not their 500-char preview - #2

Merged
YugmaF merged 2 commits into
fix/list-directory-path-aliasfrom
fix/condenser-lossless-summary-input
Oct 17, 2026
Merged

YugmaF merged 2 commits into
fix/list-directory-path-aliasfrom
fix/condenser-lossless-summary-input

Conversation

@YugmaF

@YugmaF YugmaF commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

HUMAN:


AGENT:

Why

LLMSummarizingCondenser built the summarizer's input from str(event). That is a display preview, capped at N_CHAR_PREVIEW = 500 characters. The summary is the agent's only memory of every forgotten event, so the preview limited what the agent could remember:

  • The previous summary was cut to its first 497 characters. In practice only USER_CONTEXT survived, so every condensation after the first dropped the COMPLETED, CODE_STATE and VERSION_CONTROL_STATUS that the last one had recorded.
  • An action was rendered as its thought and class name (Action: TerminalAction), with no command, path or arguments. 70% of actions on our deployment carry no thought text.
  • User messages and tool results lost everything past 500 characters.

Measured on the Ergatis dev DB:

  • 142 of 142 summaries were longer than 500 characters (mean 2,785).
  • 107 condensations, in 24 runs, re-summarised a truncated summary. Those runs hold 57% of all agent actions.
  • Run 1297b899: the summary at seq 403 listed backend HEAD d006196 and every model. By seq 509 the next summary said branches and dependencies "have not been checked yet". The run made 16 condensations and 0 edits in 152 minutes.

Upstream main still has the bug.

Summary

  • render_event_for_summary (context/condenser/utils.py) renders:

    • the previous summary, whole;
    • an action as tool + summary + thought + arguments;
    • results, errors and messages as their text.

    Each event is capped at max_event_length = 10,000 characters, keeping head and tail. It is a ClassVar, so the settings schema does not change. The prompt says to carry the previous summary forward and to quote short user answers verbatim.

  • Overflow: a summarizer context overflow retries with a cap that cuts the whole prompt by hard_context_reset_context_scaling. It raises when nothing is left to cut. Any other error is raised at once. The hard-reset retries use the same cap.

  • _mask_condensation (agent/agent.py): the emitted summary is secret-masked, because the summarizer now sees raw tool arguments (fix(sdk): mask model output in the durable MessageEvent OpenHands/software-agent-sdk#4783).

Issue Number

There is no issue in this fork. Platform side: Ergartis/ergatis#197, which bumps the runners/agent-sdk pin to this branch.

How to Test

uv run pytest tests/sdk/context/condenser tests/sdk/agent/test_condensation_summary_masking.py tests/sdk/test_settings.py
uv run pytest tests/sdk tests/cross -n 6
  • New tests: 17.
    • 9 were red on the base for the reason they state.
    • The overflow and async tests were mutation-checked (each fails when its branch is removed).
    • The many-short-events test was red on a8babaa, as Viper reported.
  • Full suite: the same 17 failures as the unchanged base ae19dedd0, so this adds 0 new ones.
  • End-to-end: not yet run. That needs an agent-server image built from this branch. The DB evidence above is the reproduction: read task_event rows of kind='Condensation' for one run and compare successive summaries.

Type

  • Bug fix

Notes

  • Failing checks shared with fix(mcp): connect multi-server MCP configs concurrently, with a per-server timeout (mirror of upstream #5720) #1 (pre-existing on this fork, not from this change):
    • pre-commit --all-files flags files from the base branch: terminal timeout files, test_acp_agent.py, test_list_directory.py:43, and pyright in older tests and agent.py _tools.
    • cleanup-on-approval needs the upstream secret OPENHANDS_BOT_GITHUB_PAT_PUBLIC, which this fork does not have.
  • A port to ergatis-v1.54.0 follows as a separate PR. That line still uses str(forgotten_event).

…preview

LLMSummarizingCondenser built the summarizer's input from str(event), a UI
preview capped at N_CHAR_PREVIEW (500). The summary is the agent's only
memory of every forgotten event, so that preview bounded what it could
remember:

- the previous summary was cut to its first 497 characters (in practice
  USER_CONTEXT alone), so every condensation after the first dropped the
  COMPLETED / CODE_STATE / VERSION_CONTROL_STATUS the last one recorded;
- an action rendered as its thought and class name ("Action:
  TerminalAction") with no command, path or arguments, and most models
  emit tool calls with no thought text;
- user messages and tool results lost everything past 500 characters.

Measured on an Ergatis deployment: 142 of 142 summaries exceeded 500
characters (mean 2,785); 107 condensations re-summarised a truncated
summary, in runs holding 57% of all agent actions. One run's summary
listed the backend HEAD and every model; two condensations later it said
branches and dependencies "have not been checked yet", and the agent
re-explored for 152 minutes with 0 edits.

render_event_for_summary renders what the agent itself was shown: the
previous summary whole, an action as tool + summary + thought + arguments,
results, errors and messages as their text, each capped at
max_event_length (10,000) head and tail. The prompt now says to carry the
previous summary forward and keep user answers verbatim. A summarizer
context overflow shrinks every event and retries rather than falling to a
hard context reset; other errors raise at once. Hard-reset retries scale
from the rendered length, not the preview's (~400 chars on retry one).

The summarizer now sees raw tool arguments (a token typed into a git URL
included), so the agent masks registered secrets in the emitted summary,
as it does for its own messages (OpenHands#4783).
@viper-review

viper-review Bot commented Oct 17, 2026

Copy link
Copy Markdown

Viper is reviewing this pull request — findings and a summary will be posted shortly.

@github-actions

Copy link
Copy Markdown

Endpoint audit

⚠️ 18 actionable Agent Server contract divergence(s) · report-only

Contract: pinned release artifact

Category Count
Actionable client-only calls 0
Actionable server-only operations 18
Documented non-divergences 13
Agent Server contract operations 129
Audited handwritten client endpoints 119

Actionable client-only calls (0)

none

Actionable server-only operations (18)

  • DELETE /api/canvas-extensions/installed/{}
  • DELETE /api/llm/provider-connections/{}
  • GET /api/canvas-extensions/installed
  • GET /api/canvas-extensions/installed/{}
  • GET /api/canvas-extensions/installed/{}/bundle
  • GET /api/conversations/{}/events
  • GET /api/file/archive
  • GET /api/git/commits
  • GET /api/git/commits/{}/changes
  • GET /api/init
  • GET /api/llm/provider-connections
  • PATCH /api/canvas-extensions/installed/{}
  • PATCH /api/llm/provider-connections/{}
  • POST /api/canvas-extensions/install
  • POST /api/conversations/{}/load_plugin
  • POST /api/file/create_directory
  • POST /api/init
  • POST /api/llm/provider-connections
Documented non-divergences (13)

Client calls intentionally absent from the filtered contract (11)

  • GET /
  • GET /alive
  • GET /health
  • GET /ready
  • GET /server_info

Reason: Operational Agent Server endpoints intentionally excluded from the filtered public release artifact.
Owner: OpenHands runtime maintainers

  • DELETE /api/meta-profiles/{}
  • GET /api/meta-profiles
  • GET /api/meta-profiles/{}
  • POST /api/meta-profiles/{}
  • POST /api/meta-profiles/{}/activate

Reason: Client-ahead API stacked on the pending Agent Server meta-profiles implementation.
Owner: OpenHands SDK maintainers
Tracking: OpenHands#3744

  • POST /api/profiles/{}/validate

Reason: Client-ahead API stacked on the pending Agent Server pre-flight LLM validation endpoint.
Owner: OpenHands TypeScript client maintainers
Tracking: OpenHands#4422

Server operations covered by an exposed browser URL (2)

  • GET /api/conversations/{}/workspace
  • GET /api/conversations/{}/workspace/{}

Reason: RemoteWorkspace.startWorkspaceSession exposes these authenticated URLs for browser iframe and file requests; they are not HttpClient method calls.
Owner: OpenHands TypeScript client maintainers

@viper-review viper-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review comments

other failure is raised at once.
"""
max_event_str_length: int | None = None
for _ in range(self.hard_context_reset_max_retries):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] This retry only lowers the per-event cap (_shrunk_event_length derives it from the longest rendered event). When the overflow comes from the summed size of many events that are already shorter than the cap, each iteration re-sends an essentially unchanged oversized prompt, burns all hard_context_reset_max_retries summarizer calls, and then still raises — so the condensation falls through to the full hard reset this retry was added to avoid. Bail out when the new cap does not reduce the rendered total (or start dropping events) instead of retrying blindly. Same defect in _asummarize at line 271.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 84b438a. _shrunk_event_length now picks the largest cap whose capped total is at most hard_context_reset_context_scaling of the prompt just sent. Each retry therefore cuts the whole prompt by that factor, not just the longest event. When no positive cap gets there, it returns 0 and the retry raises ("nothing left to cut"). This matters because maybe_truncate(text, 0) means no truncation and would re-send the full prompt. The hard-reset loops floor the cap at 1 for the same reason.

New tests:

  • test_summarizer_overflow_shrinks_the_whole_prompt_not_one_event: one 9k result plus forty 1.2k ones. It was red on a8babaa.
  • test_summarizer_overflow_never_resends_untrimmed_events: the 0-cap guard. Mutation-checked: removing the guard turns it red.

@viper-review viper-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review comments



@pytest.mark.asyncio
async def test_async_summarizer_sees_the_whole_previous_summary(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] _asummarize duplicates the sync shrink-and-retry loop line for line, but the only async test covers rendering of a previous summary — its overflow branch (shrink then retry, and non-overflow then re-raise) is never exercised. A copy-paste defect there (wrong exception filter, missing retry, wrong max_event_str_length threading) passes CI. Add aget_condensation/acompletion variants of test_summarizer_overflow_shrinks_events_instead_of_failing and test_summarizer_failure_that_is_not_overflow_is_not_retried.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 84b438a:

  • test_async_summarizer_overflow_shrinks_events_instead_of_failing
  • test_async_summarizer_failure_that_is_not_overflow_is_not_retried

Both drive aget_condensation through acompletion. Mutation-checked against _asummarize:

  • a copy that never retries fails the first test;
  • a copy that retries every error fails the second.

@viper-review viper-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto decision by Viper: aggregated open high=0 (threshold 1), open medium=2 (threshold 3) => APPROVE.

@viper-review

viper-review Bot commented Oct 17, 2026

Copy link
Copy Markdown

Summary

Replaces the 500-char str(event) preview with a dedicated full-content renderer for condensation summaries, plus retry and masking changes. The direction is sound; the retry loop has a bounded-progress gap shared by both sync and async paths.
0 high · 2 medium · 0 nit findings.

Description

LLMSummarizingCondenser previously fed the summarizer str(event), a display preview capped at N_CHAR_PREVIEW (500), truncating prior summaries, tool arguments, user messages and results. This adds render_event_for_summary in context/condenser/utils.py, which emits the previous summary whole and actions as tool + summary + thought + arguments, capping each event at LLMSummarizingCondenser.max_event_length (10,000, a ClassVar so settings schema is unchanged) while retaining head and tail. The prompt gains two lines instructing carry-forward of the previous summary and verbatim quoting of short user answers. Summarizer context overflow now shrinks events and retries instead of falling through to a hard context reset; hard-reset retry sizing scales from rendered length rather than the preview. _mask_condensation in agent/agent.py masks the emitted summary since the summarizer now sees raw tool arguments.

Walkthrough

  • Rendering: new render_event_for_summary and per-event length cap; replaces str() preview.
  • Condenser control flow: overflow detection, shrink-and-retry, retry scaling in llm_summarizing_condenser.py (sync and async variants).
  • Prompt: carry-forward and verbatim-quote instructions in summarizing_prompt.j2.
  • Security: _mask_condensation secret-masking of emitted summaries in agent/agent.py.
  • Tests: 12 new tests across condenser behavior and condensation-summary masking.

Findings Overview

  • Medium — retry progress (2): The overflow retry only lowers the per-event cap, derived from the longest rendered event. Flooding by aggregate size across many sub-cap events re-sends an unchanged oversized prompt, exhausts hard_context_reset_max_retries, then raises into the hard reset the retry was meant to avoid. _asummarize duplicates the same defect.
  • Medium — async coverage (1, same theme): _asummarize is a line-for-line copy of the sync loop, but no async test exercises the overflow/shrink, non-overflow re-raise, or max_event_str_length threading paths, so a copy-paste regression passes CI.

Narrative Summary

This PR fixes a real memory-loss defect: condensation summaries were built from a 500-character display preview, silently discarding prior summaries, tool arguments and long user turns, and the deployment data cited shows the resulting drift. The renderer, prompt changes and summary masking are well-scoped and the settings schema is untouched. The remaining risk is concentrated in the new overflow retry: shrinking only the per-event cap makes no progress when the overflow is aggregate, so the loop burns its retry budget and still lands on the hard reset it was added to prevent. That logic is duplicated verbatim in _asummarize with no async test covering it. Prioritize making the retry bail out when the new cap does not reduce the rendered total, and add async overflow/non-overflow re-raise tests to lock the mirrored behavior.

…est event

Review on #2 (Viper): the shrink-and-retry lowered the per-event cap from
the longest rendered event only. A prompt made of many mid-sized events
was re-sent nearly unchanged, burned every retry, and still fell through
to the hard context reset the retry exists to avoid.

_shrunk_event_length now picks the largest cap whose capped total is at
most hard_context_reset_context_scaling of what was just sent, so each
retry cuts the prompt by that factor. When no positive cap gets there it
returns 0, and the retry raises instead -- maybe_truncate treats 0 as "no
truncation" and would re-send the full prompt. The hard-reset loops floor
the cap at 1 for the same reason.

Tests: the many-short-events shape (red before), async variants of both
overflow tests (mutation-checked: never-retry and retry-everything each
fail one), and the 0-cap guard (mutation-checked).
@viper-review

viper-review Bot commented Oct 17, 2026

Copy link
Copy Markdown

Viper is reviewing this pull request — findings and a summary will be posted shortly.

@viper-review viper-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review comments

Comment on lines +1460 to +1462
assert len(sizes) <= condenser.hard_context_reset_max_retries
assert sizes == sorted(sizes, reverse=True)
assert len(set(sizes)) == len(sizes), "a retry re-sent the same prompt"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] All three sizes assertions are vacuously true on an empty list (0 <= n, [] == sorted([]), len(set([])) == 0 == len([])), so the test still passes if get_condensation raises NoCondensationAvailableException before ever calling the summarizer — and the pytest.raises has no match=, so it can't distinguish that path either. The docstring claims to verify that every retry cuts the prompt, so assert len(sizes) >= 2 before the sorted/uniqueness checks (and match the exception message).

@viper-review viper-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review comments

# Since the summarization failed, cut every event to 80% of the
# longest one as rendered -- not of its 500-character preview.
max_event_str_length = max(
1, self._shrunk_event_length(view.events, max_event_str_length)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] max(1, ...) discards the helper's documented 0 return ('nothing left to cut'): once _shrunk_event_length returns 0, the loop retries at cap 1, and from then on every subsequent retry re-renders the same cap-1 prompt and is guaranteed to overflow again, burning the remaining retries on identical failing calls. _summarize raises NoCondensationAvailableException on this exact condition (lines 275 and 308); break/raise here instead of flooring to 1. Same pattern at line 618.

@viper-review viper-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto decision by Viper: aggregated open high=0 (threshold 1), open medium=3 (threshold 3) => REQUEST_CHANGES.

@viper-review

viper-review Bot commented Oct 17, 2026

Copy link
Copy Markdown

Summary

Update hardens overflow retry logic so retries shrink the entire prompt by a fixed factor rather than only the longest rendered event. 1 medium · 1 low findings.

Description

Replaces the previous per-event cap derivation, which only lowered the cap to the longest rendered event and therefore re-sent many-mid-sized-event prompts nearly unchanged, exhausting retries without relieving context pressure. _shrunk_event_length now selects the largest cap whose capped total is at most hard_context_reset_context_scaling of the prompt just sent, returning 0 when no positive cap achieves the target so the caller raises instead of silently re-sending. The hard-reset loops floor the cap at 1. Tests add the many-short-events shape, async variants of both overflow tests, and a 0-cap guard.

Walkthrough

  • Condenser retry logic (llm_summarizing_condenser.py): new cap-selection semantics in _shrunk_event_length, plus retry-loop handling of the 0 return.
  • Test coverage (test_llm_summarizing_condenser.py): many-short-events regression, async overflow variants (mutation-checked), 0-cap guard (mutation-checked).

Findings Overview

  • Medium: The retry-shrink test's sizes assertions are vacuous on an empty list, so the test passes if get_condensation raises NoCondensationAvailableException before invoking the summarizer; the pytest.raises lacks match=, so the failing path is indistinguishable. Requires len(sizes) >= 2 and a matched exception message to actually verify that every retry cuts the prompt.
  • Low: max(1, ...) at line 477 (and 618) discards the helper's documented 0 return, causing the loop to retry indefinitely at cap 1 with an identical prompt that is guaranteed to overflow — burning remaining retries. _summarize already raises on this exact condition; break or raise here instead.

Narrative Summary

This update corrects a real defect in the overflow retry path: scaling only the longest event left many-event prompts effectively unchanged across retries, defeating the shrink-and-retry mechanism and falling through to the hard context reset it was meant to prevent. The factor-based cap selection and the 0-return semantics are the right shape, and the added async and many-short-events tests exercise previously uncovered branches. The remaining risk is concentrated in test integrity — the primary regression test can pass without ever reaching the summarizer, which weakens the mutation-checked guarantees the description relies on. Fix the vacuous assertions first, then reconcile the max(1, ...) floor with the helper's 0 contract, since that path converts a clean failure into a bounded but pointless retry storm.

@YugmaF
YugmaF merged commit c3f13b1 into fix/list-directory-path-alias Oct 17, 2026
20 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant