Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions openhands-sdk/openhands/sdk/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,21 @@ def _should_handle_non_multimodal_image_input(
return _latest_user_message_contains_image(messages) and not llm.vision_is_active()


def _mask_condensation(
condensation: Condensation, conversation: LocalConversation
) -> Condensation:
"""Mask registered secrets in a condensation's summary before it is emitted.

The summary is durable model output, replayed to the agent on every later
step, and the summarizer sees each forgotten action's arguments verbatim --
a token typed into a git URL included. Masked like any agent message (#4783).
"""
if not condensation.summary:
return condensation
mask = conversation.state.secret_registry.mask_secrets_in_output
return condensation.model_copy(update={"summary": mask(condensation.summary)})


@dataclass(frozen=True, slots=True)
class _ActionBatch:
"""Immutable result of preparing a batch of actions for execution.
Expand Down Expand Up @@ -757,7 +772,7 @@ def _step(

# Process condensation event before agent sampels another action
if isinstance(_messages_or_condensation, Condensation):
on_event(_messages_or_condensation)
on_event(_mask_condensation(_messages_or_condensation, conversation))
return

_messages = _messages_or_condensation
Expand Down Expand Up @@ -985,7 +1000,7 @@ async def _astep(
)

if isinstance(_messages_or_condensation, Condensation):
on_event(_messages_or_condensation)
on_event(_mask_condensation(_messages_or_condensation, conversation))
return

_messages = _messages_or_condensation
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
from collections.abc import Sequence
from enum import Enum
from typing import Final
from typing import ClassVar, Final

from pydantic import Field, model_validator

Expand All @@ -13,16 +13,17 @@
from openhands.sdk.context.condenser.utils import (
get_suffix_length_for_token_reduction,
get_total_token_count,
render_event_for_summary,
)
from openhands.sdk.context.prompts import render_template
from openhands.sdk.context.view import View
from openhands.sdk.event.base import LLMConvertibleEvent
from openhands.sdk.event.condenser import Condensation
from openhands.sdk.event.condenser import Condensation, CondensationSummaryEvent
from openhands.sdk.event.llm_convertible import SystemPromptEvent
from openhands.sdk.llm import LLM, Message, TextContent
from openhands.sdk.llm.exceptions.classifier import is_context_window_exceeded
from openhands.sdk.logger import get_logger
from openhands.sdk.observability.laminar import observe
from openhands.sdk.utils import maybe_truncate


logger = get_logger(__name__)
Expand Down Expand Up @@ -78,6 +79,13 @@ class LLMSummarizingCondenser(RollingCondenser):
are treated as errors.
"""

max_event_length: ClassVar[int] = 10_000
"""Longest a single forgotten event may be when shown to the summarizer, in
characters; longer ones keep their head and tail. A previous summary is exempt:
it is the agent's only record of everything condensed before it. A class
constant, not a field, so the condenser's settings schema is unchanged.
"""

hard_context_reset_max_retries: int = Field(default=5, gt=0)
"""Number of attempts to perform hard context reset before raising an error."""

Expand Down Expand Up @@ -221,6 +229,129 @@ def _build_summary_messages(self, event_strings: Sequence[str]) -> list[Message]
Message(role="user", content=[TextContent(text=events_prompt)]),
]

def _render_forgotten_events(
self,
forgotten_events: Sequence[LLMConvertibleEvent],
max_event_str_length: int | None = None,
) -> list[str]:
"""Render forgotten events for the summarizer, whole up to the length caps.

Every event is capped at ``max_event_length``, except a previous summary.
``max_event_str_length`` -- set only when the summarizer overflowed and the
input is being shrunk -- caps every event, the previous summary included.
"""
rendered = []
for event in forgotten_events:
limits = [max_event_str_length]
if not isinstance(event, CondensationSummaryEvent):
limits.append(self.max_event_length)
caps = [limit for limit in limits if limit is not None]
rendered.append(
render_event_for_summary(event, min(caps) if caps else None)
)
return rendered

def _shrunk_event_length(
self,
events: Sequence[LLMConvertibleEvent],
max_event_str_length: int | None,
) -> int:
"""The next per-event cap after the summarizer call failed.

Chosen so the rendered total -- not just the longest event -- falls to
``hard_context_reset_context_scaling`` of what was just sent: scaling the
longest event alone leaves every shorter one untouched, so a prompt made
of many mid-sized events is re-sent almost unchanged. Returns 0 when no
positive cap gets there, i.e. there is nothing left to cut.
"""
lengths = [
len(text)
for text in self._render_forgotten_events(events, max_event_str_length)
]
target = sum(lengths) * self.hard_context_reset_context_scaling
# The largest cap whose capped total fits the target (capping never grows
# an event, so the total is monotonic in the cap).
low, high = 0, max(lengths)
while low < high:
cap = (low + high + 1) // 2
if sum(min(length, cap) for length in lengths) <= target:
low = cap
else:
high = cap - 1
return low

def _summarize(
self, forgotten_events: Sequence[LLMConvertibleEvent], summary_offset: int
) -> Condensation:
"""Summarize the forgotten events whole; shrink them only on overflow.

Whole events can exceed a summarizer whose window is smaller than the
agent's. Rather than fail -- and fall through to a hard context reset that
forgets everything -- retry with every event cut further each time. Any
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.

[Low] _summarize drives its shrink-and-retry loop with hard_context_reset_max_retries, a field documented as the number of hard context reset attempts. A user who lowers it to bound reset cost (e.g. to 1) now also caps summarizer shrink attempts at 1: the first context-window overflow computes a smaller cap but the loop has no second iteration, so it raises and forces the hard reset this PR is trying to avoid. Use a dedicated retry field (or at least document the shared meaning).

try:
return self._generate_condensation(
forgotten_events=forgotten_events,
summary_offset=summary_offset,
max_event_str_length=max_event_str_length,
)
except NoCondensationAvailableException as e:
cause = e.__cause__
if not isinstance(cause, Exception) or not is_context_window_exceeded(
cause
):
raise
max_event_str_length = self._shrunk_event_length(
forgotten_events, max_event_str_length
)
if max_event_str_length <= 0:
raise NoCondensationAvailableException(
"Summarizer context exceeded with nothing left to cut."
) from e
logger.warning(
"Summarizer context exceeded; retrying with each event cut to "
f"{max_event_str_length} characters."
)
raise NoCondensationAvailableException(
"Summarizer context exceeded at every event length tried."
)

async def _asummarize(

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] _asummarize is a line-for-line copy of _summarize (283-320) differing only in await self._agenerate_condensation, and the same overflow-shrink block is repeated in hard_context_reset (514-517) and ahard_context_reset (653-655). Extracting one helper keeps the retry/abort logic (including the <= 0 bail-out) from silently drifting between the four copies.

self, forgotten_events: Sequence[LLMConvertibleEvent], summary_offset: int
) -> Condensation:
"""Async variant of :meth:`_summarize`."""
max_event_str_length: int | None = None
for _ in range(self.hard_context_reset_max_retries):
try:
return await self._agenerate_condensation(
forgotten_events=forgotten_events,
summary_offset=summary_offset,
max_event_str_length=max_event_str_length,
)
except NoCondensationAvailableException as e:
cause = e.__cause__
if not isinstance(cause, Exception) or not is_context_window_exceeded(
cause
):
raise
max_event_str_length = self._shrunk_event_length(
forgotten_events, max_event_str_length
)
if max_event_str_length <= 0:
raise NoCondensationAvailableException(
"Summarizer context exceeded with nothing left to cut."
) from e
logger.warning(
"Summarizer context exceeded; retrying with each event cut to "
f"{max_event_str_length} characters."
)
raise NoCondensationAvailableException(
"Summarizer context exceeded at every event length tried."
)

def _generate_condensation(
self,
forgotten_events: Sequence[LLMConvertibleEvent],
Expand All @@ -244,11 +375,9 @@ def _generate_condensation(
"""
assert len(forgotten_events) > 0, "No events to condense."

# Convert events to strings for the template
event_strings = [
maybe_truncate(str(forgotten_event), truncate_after=max_event_str_length)
for forgotten_event in forgotten_events
]
event_strings = self._render_forgotten_events(
forgotten_events, max_event_str_length
)

messages = self._build_summary_messages(event_strings)

Expand Down Expand Up @@ -382,15 +511,10 @@ def hard_context_reset(
max_event_str_length=max_event_str_length,
)
except Exception as e:
# If we haven't set a max_event_str_length yet, set it as the largest
# event string length.
if max_event_str_length is None:
max_event_str_length = max(len(str(event)) for event in view.events)

# Since the summarization failed, reduce the max_event_str_length by 20%
assert max_event_str_length is not None
max_event_str_length = int(
max_event_str_length * self.hard_context_reset_context_scaling
# 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(forgotten_events, max_event_str_length)
)

# Log the exception so we can track these failures
Expand Down Expand Up @@ -433,10 +557,7 @@ def get_condensation(
"threshold."
)

return self._generate_condensation(
forgotten_events=forgotten_events,
summary_offset=summary_offset,
)
return self._summarize(forgotten_events, summary_offset)

# ------------------------------------------------------------------
# Async variants
Expand All @@ -451,10 +572,9 @@ async def _agenerate_condensation(
"""Async variant of :meth:`_generate_condensation`."""
assert len(forgotten_events) > 0, "No events to condense."

event_strings = [
maybe_truncate(str(fe), truncate_after=max_event_str_length)
for fe in forgotten_events
]
event_strings = self._render_forgotten_events(
forgotten_events, max_event_str_length
)

messages = self._build_summary_messages(event_strings)

Expand Down Expand Up @@ -505,10 +625,7 @@ async def aget_condensation(
"progress threshold."
)

return await self._agenerate_condensation(
forgotten_events=forgotten_events,
summary_offset=summary_offset,
)
return await self._asummarize(forgotten_events, summary_offset)

async def ahard_context_reset(
self,
Expand All @@ -533,11 +650,8 @@ async def ahard_context_reset(
max_event_str_length=max_event_str_length,
)
except Exception as e:
if max_event_str_length is None:
max_event_str_length = max(len(str(ev)) for ev in view.events)
assert max_event_str_length is not None
max_event_str_length = int(
max_event_str_length * self.hard_context_reset_context_scaling
max_event_str_length = max(
1, self._shrunk_event_length(forgotten_events, max_event_str_length)
)
logger.warning(
f"Hard context reset summarization failed: {e}. "
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
You are maintaining a context-aware state summary for an interactive agent.
You will be given a list of events corresponding to actions taken by the agent, which will include previous summaries.
A PREVIOUS SUMMARY is the agent's only memory of the work before it. Carry every fact in it forward (files read and what they contain, commands run and their results, decisions, changes) unless a later event changes it, and never report as not done or not checked what it or an event shows was done.
Keep every request, answer and correction the user gave; quote short answers (ids, names, values) verbatim.
If the events being summarized contain ANY task-tracking, you MUST include a TASK_TRACKING section to maintain continuity.
When referencing tasks make sure to preserve exact task IDs and statuses.

Expand Down
67 changes: 66 additions & 1 deletion openhands-sdk/openhands/sdk/context/condenser/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,73 @@
from collections.abc import Sequence

from openhands.sdk.event.base import LLMConvertibleEvent
from openhands.sdk.event.condenser import CondensationSummaryEvent
from openhands.sdk.event.llm_convertible import (
ActionEvent,
AgentErrorEvent,
MessageEvent,
ObservationEvent,
UserRejectObservation,
)
from openhands.sdk.event.llm_convertible.system import SystemPromptEvent
from openhands.sdk.llm import LLM
from openhands.sdk.llm import LLM, content_to_str
from openhands.sdk.utils import maybe_truncate


def render_event_for_summary(
event: LLMConvertibleEvent, max_length: int | None = None
) -> str:
"""Render one forgotten event as the summarizing LLM should see it.

Not ``str(event)``: that is a display preview capped at ``N_CHAR_PREVIEW``
(500) characters, and it renders an action as its class name alone. Fed to
the summarizer it cut every previous summary to its first ~500 characters
and hid which command ran, which file was read and what a long user message
ended with -- so each condensation forgot the work the last one recorded,
and the agent repeated it.

This shows what the agent itself was shown: a previous summary whole, an
action as its tool and arguments, a result, error or user message as its
text. ``max_length`` caps the rendered event, keeping head and tail; event
types not listed here keep their ``str()`` form.
"""
if isinstance(event, CondensationSummaryEvent):
text = f"PREVIOUS SUMMARY ({event.source})\n{event.summary}"
elif isinstance(event, ActionEvent):
lines = [f"ActionEvent ({event.source})", f" Tool: {event.tool_name}"]
if event.summary:
lines.append(f" Summary: {event.summary}")
thought = " ".join(t.text for t in event.thought).strip()
if thought:
lines.append(f" Thought: {thought}")
lines.append(f" Arguments: {event.tool_call.arguments}")
if event.action is None:
lines.append(" (not executed)")
text = "\n".join(lines)
elif isinstance(event, ObservationEvent):
result = "".join(content_to_str(event.observation.to_llm_content))
text = (
f"ObservationEvent ({event.source})\n"
f" Tool: {event.tool_name}\n Result: {result}"
)
elif isinstance(event, UserRejectObservation):
text = (
f"UserRejectObservation ({event.source})\n Tool: {event.tool_name}\n"
f" Rejected by {event.rejection_source}: {event.rejection_reason}"
)
elif isinstance(event, AgentErrorEvent):
text = (
f"AgentErrorEvent ({event.source})\n Tool: {event.tool_name}\n"
f" Error: {event.error}"
)
elif isinstance(event, MessageEvent):
body = " ".join(content_to_str(event.llm_message.content)) or "[no text]"
text = f"MessageEvent ({event.source})\n {event.llm_message.role}: {body}"
if event.activated_skills:
text += f"\n [Skills: {', '.join(event.activated_skills)}]"
else:
text = str(event)
return maybe_truncate(text, truncate_after=max_length)


def get_total_token_count(
Expand Down
Loading
Loading