openhands-sdk 1.9.1__py3-none-any.whl → 1.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands/sdk/agent/agent.py +90 -16
- openhands/sdk/agent/base.py +33 -46
- openhands/sdk/context/condenser/base.py +36 -3
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -24
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
- openhands/sdk/context/skills/skill.py +2 -25
- openhands/sdk/context/view.py +108 -122
- openhands/sdk/conversation/__init__.py +2 -0
- openhands/sdk/conversation/conversation.py +18 -3
- openhands/sdk/conversation/exceptions.py +18 -0
- openhands/sdk/conversation/impl/local_conversation.py +211 -36
- openhands/sdk/conversation/impl/remote_conversation.py +151 -12
- openhands/sdk/conversation/stuck_detector.py +18 -9
- openhands/sdk/critic/impl/api/critic.py +10 -7
- openhands/sdk/event/condenser.py +52 -2
- openhands/sdk/git/cached_repo.py +19 -0
- openhands/sdk/hooks/__init__.py +2 -0
- openhands/sdk/hooks/config.py +44 -4
- openhands/sdk/hooks/executor.py +2 -1
- openhands/sdk/llm/__init__.py +16 -0
- openhands/sdk/llm/auth/__init__.py +28 -0
- openhands/sdk/llm/auth/credentials.py +157 -0
- openhands/sdk/llm/auth/openai.py +762 -0
- openhands/sdk/llm/llm.py +222 -33
- openhands/sdk/llm/message.py +65 -27
- openhands/sdk/llm/options/chat_options.py +2 -1
- openhands/sdk/llm/options/responses_options.py +8 -7
- openhands/sdk/llm/utils/model_features.py +2 -0
- openhands/sdk/mcp/client.py +53 -6
- openhands/sdk/mcp/tool.py +24 -21
- openhands/sdk/mcp/utils.py +31 -23
- openhands/sdk/plugin/__init__.py +12 -1
- openhands/sdk/plugin/fetch.py +118 -14
- openhands/sdk/plugin/loader.py +111 -0
- openhands/sdk/plugin/plugin.py +155 -13
- openhands/sdk/plugin/types.py +163 -1
- openhands/sdk/secret/secrets.py +13 -1
- openhands/sdk/utils/__init__.py +2 -0
- openhands/sdk/utils/async_utils.py +36 -1
- openhands/sdk/utils/command.py +28 -1
- openhands/sdk/workspace/remote/base.py +8 -3
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/METADATA +1 -1
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/RECORD +47 -43
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/WHEEL +1 -1
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/top_level.txt +0 -0
openhands/sdk/agent/agent.py
CHANGED
|
@@ -67,6 +67,10 @@ from openhands.sdk.tool.builtins import (
|
|
|
67
67
|
logger = get_logger(__name__)
|
|
68
68
|
maybe_init_laminar()
|
|
69
69
|
|
|
70
|
+
# Maximum number of events to scan during init_state defensive checks.
|
|
71
|
+
# SystemPromptEvent must appear within this prefix (at index 0 or 1).
|
|
72
|
+
INIT_STATE_PREFIX_SCAN_WINDOW = 3
|
|
73
|
+
|
|
70
74
|
|
|
71
75
|
class Agent(AgentBase):
|
|
72
76
|
"""Main agent implementation for OpenHands.
|
|
@@ -102,24 +106,94 @@ class Agent(AgentBase):
|
|
|
102
106
|
state: ConversationState,
|
|
103
107
|
on_event: ConversationCallbackType,
|
|
104
108
|
) -> None:
|
|
109
|
+
"""Initialize conversation state.
|
|
110
|
+
|
|
111
|
+
Invariants enforced by this method:
|
|
112
|
+
- If a SystemPromptEvent is already present, it must be within the first 3
|
|
113
|
+
events (index 0 or 1 in practice; index 2 is included in the scan window
|
|
114
|
+
to detect a user message appearing before the system prompt).
|
|
115
|
+
- A user MessageEvent should not appear before the SystemPromptEvent.
|
|
116
|
+
|
|
117
|
+
These invariants keep event ordering predictable for downstream components
|
|
118
|
+
(condenser, UI, etc.) and also prevent accidentally materializing the full
|
|
119
|
+
event history during initialization.
|
|
120
|
+
"""
|
|
105
121
|
super().init_state(state, on_event=on_event)
|
|
106
|
-
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
|
|
123
|
+
# Defensive check: Analyze state to detect unexpected initialization scenarios
|
|
124
|
+
# These checks help diagnose issues related to lazy loading and event ordering
|
|
125
|
+
# See: https://github.com/OpenHands/software-agent-sdk/issues/1785
|
|
126
|
+
#
|
|
127
|
+
# NOTE: len() is O(1) for EventLog (file-backed implementation).
|
|
128
|
+
event_count = len(state.events)
|
|
129
|
+
|
|
130
|
+
# NOTE: state.events is intentionally an EventsListBase (Sequence-like), not
|
|
131
|
+
# a plain list. Avoid materializing the full history via list(state.events)
|
|
132
|
+
# here (conversations can reach 30k+ events).
|
|
133
|
+
#
|
|
134
|
+
# Invariant: when init_state is called, SystemPromptEvent (if present) must be
|
|
135
|
+
# at index 0 or 1.
|
|
136
|
+
#
|
|
137
|
+
# Rationale:
|
|
138
|
+
# - Local conversations start empty and init_state is responsible for adding
|
|
139
|
+
# the SystemPromptEvent as the first event.
|
|
140
|
+
# - Remote conversations may receive an initial ConversationStateUpdateEvent
|
|
141
|
+
# from the agent-server immediately after subscription. In a typical remote
|
|
142
|
+
# session prefix you may see:
|
|
143
|
+
# [ConversationStateUpdateEvent, SystemPromptEvent, MessageEvent, ...]
|
|
144
|
+
#
|
|
145
|
+
# We intentionally only inspect the first few events (cheap for both local and
|
|
146
|
+
# remote) to enforce this invariant.
|
|
147
|
+
prefix_events = state.events[:INIT_STATE_PREFIX_SCAN_WINDOW]
|
|
148
|
+
|
|
149
|
+
has_system_prompt = any(isinstance(e, SystemPromptEvent) for e in prefix_events)
|
|
150
|
+
has_user_message = any(
|
|
151
|
+
isinstance(e, MessageEvent) and e.source == "user" for e in prefix_events
|
|
152
|
+
)
|
|
153
|
+
# Log state for debugging initialization order issues
|
|
154
|
+
logger.debug(
|
|
155
|
+
f"init_state called: conversation_id={state.id}, "
|
|
156
|
+
f"event_count={event_count}, "
|
|
157
|
+
f"has_system_prompt={has_system_prompt}, "
|
|
158
|
+
f"has_user_message={has_user_message}"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if has_system_prompt:
|
|
162
|
+
# Restoring/resuming conversations is normal: a system prompt already
|
|
163
|
+
# present means this conversation was initialized previously.
|
|
164
|
+
logger.debug(
|
|
165
|
+
"init_state: SystemPromptEvent already present; skipping init. "
|
|
166
|
+
f"conversation_id={state.id}, event_count={event_count}."
|
|
121
167
|
)
|
|
122
|
-
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Assert: A user message should never appear before the system prompt.
|
|
171
|
+
#
|
|
172
|
+
# NOTE: This is a best-effort check based on the first few events only.
|
|
173
|
+
# Remote conversations can include a ConversationStateUpdateEvent near the
|
|
174
|
+
# start, so we scan a small prefix window.
|
|
175
|
+
if has_user_message:
|
|
176
|
+
event_types = [type(e).__name__ for e in prefix_events]
|
|
177
|
+
logger.error(
|
|
178
|
+
f"init_state: User message found in prefix before SystemPromptEvent! "
|
|
179
|
+
f"conversation_id={state.id}, prefix_events={event_types}"
|
|
180
|
+
)
|
|
181
|
+
raise AssertionError(
|
|
182
|
+
"Unexpected state: user message exists before SystemPromptEvent. "
|
|
183
|
+
f"conversation_id={state.id}, event_count={event_count}, "
|
|
184
|
+
f"prefix_event_types={event_types}."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Prepare system message
|
|
188
|
+
event = SystemPromptEvent(
|
|
189
|
+
source="agent",
|
|
190
|
+
system_prompt=TextContent(text=self.system_message),
|
|
191
|
+
# Tools are stored as ToolDefinition objects and converted to
|
|
192
|
+
# OpenAI format with security_risk parameter during LLM completion.
|
|
193
|
+
# See make_llm_completion() in agent/utils.py for details.
|
|
194
|
+
tools=list(self.tools_map.values()),
|
|
195
|
+
)
|
|
196
|
+
on_event(event)
|
|
123
197
|
|
|
124
198
|
def _should_evaluate_with_critic(self, action: Action | None) -> bool:
|
|
125
199
|
"""Determine if critic should evaluate based on action type and mode."""
|
openhands/sdk/agent/base.py
CHANGED
|
@@ -345,28 +345,28 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
|
|
|
345
345
|
def verify(
|
|
346
346
|
self,
|
|
347
347
|
persisted: AgentBase,
|
|
348
|
-
events: Sequence[Any] | None = None,
|
|
348
|
+
events: Sequence[Any] | None = None, # noqa: ARG002
|
|
349
349
|
) -> AgentBase:
|
|
350
350
|
"""Verify that we can resume this agent from persisted state.
|
|
351
351
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
352
|
+
We do not merge configuration between persisted and runtime Agent
|
|
353
|
+
instances. Instead, we verify compatibility requirements and then
|
|
354
|
+
continue with the runtime-provided Agent.
|
|
355
355
|
|
|
356
356
|
Compatibility requirements:
|
|
357
357
|
- Agent class/type must match.
|
|
358
|
-
- Tools
|
|
359
|
-
- If events are provided, only tools that were actually used in history
|
|
360
|
-
must exist in runtime.
|
|
361
|
-
- If events are not provided, tool names must match exactly.
|
|
358
|
+
- Tools must match exactly (same tool names).
|
|
362
359
|
|
|
363
|
-
|
|
364
|
-
|
|
360
|
+
Tools are part of the system prompt and cannot be changed mid-conversation.
|
|
361
|
+
To use different tools, start a new conversation or use conversation forking
|
|
362
|
+
(see https://github.com/OpenHands/OpenHands/issues/8560).
|
|
363
|
+
|
|
364
|
+
All other configuration (LLM, agent_context, condenser, etc.) can be
|
|
365
|
+
freely changed between sessions.
|
|
365
366
|
|
|
366
367
|
Args:
|
|
367
368
|
persisted: The agent loaded from persisted state.
|
|
368
|
-
events:
|
|
369
|
-
don't match.
|
|
369
|
+
events: Unused, kept for API compatibility.
|
|
370
370
|
|
|
371
371
|
Returns:
|
|
372
372
|
This runtime agent (self) if verification passes.
|
|
@@ -381,52 +381,39 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
|
|
|
381
381
|
f"{self.__class__.__name__}."
|
|
382
382
|
)
|
|
383
383
|
|
|
384
|
+
# Collect explicit tool names
|
|
384
385
|
runtime_names = {tool.name for tool in self.tools}
|
|
385
386
|
persisted_names = {tool.name for tool in persisted.tools}
|
|
386
387
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
used_tools = {
|
|
394
|
-
event.tool_name
|
|
395
|
-
for event in events
|
|
396
|
-
if isinstance(event, ActionEvent) and event.tool_name
|
|
397
|
-
}
|
|
388
|
+
# Add builtin tool names from include_default_tools
|
|
389
|
+
# These are runtime names like 'finish', 'think'
|
|
390
|
+
for tool_class_name in self.include_default_tools:
|
|
391
|
+
tool_class = BUILT_IN_TOOL_CLASSES.get(tool_class_name)
|
|
392
|
+
if tool_class is not None:
|
|
393
|
+
runtime_names.add(tool_class.name)
|
|
398
394
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
tool_class
|
|
403
|
-
if tool_class is not None:
|
|
404
|
-
runtime_names.add(tool_class.name)
|
|
405
|
-
|
|
406
|
-
# Only require tools that were actually used in history.
|
|
407
|
-
missing_used_tools = used_tools - runtime_names
|
|
408
|
-
if missing_used_tools:
|
|
409
|
-
raise ValueError(
|
|
410
|
-
"Cannot resume conversation: tools that were used in history "
|
|
411
|
-
f"are missing from runtime: {sorted(missing_used_tools)}. "
|
|
412
|
-
f"Available tools: {sorted(runtime_names)}"
|
|
413
|
-
)
|
|
395
|
+
for tool_class_name in persisted.include_default_tools:
|
|
396
|
+
tool_class = BUILT_IN_TOOL_CLASSES.get(tool_class_name)
|
|
397
|
+
if tool_class is not None:
|
|
398
|
+
persisted_names.add(tool_class.name)
|
|
414
399
|
|
|
400
|
+
if runtime_names == persisted_names:
|
|
415
401
|
return self
|
|
416
402
|
|
|
417
|
-
#
|
|
403
|
+
# Tools don't match - this is not allowed
|
|
418
404
|
missing_in_runtime = persisted_names - runtime_names
|
|
419
|
-
|
|
405
|
+
added_in_runtime = runtime_names - persisted_names
|
|
420
406
|
|
|
421
407
|
details: list[str] = []
|
|
422
408
|
if missing_in_runtime:
|
|
423
|
-
details.append(f"
|
|
424
|
-
if
|
|
425
|
-
details.append(f"
|
|
409
|
+
details.append(f"removed: {sorted(missing_in_runtime)}")
|
|
410
|
+
if added_in_runtime:
|
|
411
|
+
details.append(f"added: {sorted(added_in_runtime)}")
|
|
426
412
|
|
|
427
|
-
suffix = f" ({'; '.join(details)})" if details else ""
|
|
428
413
|
raise ValueError(
|
|
429
|
-
"
|
|
414
|
+
f"Cannot resume conversation: tools cannot be changed mid-conversation "
|
|
415
|
+
f"({'; '.join(details)}). "
|
|
416
|
+
f"To use different tools, start a new conversation."
|
|
430
417
|
)
|
|
431
418
|
|
|
432
419
|
def model_dump_succint(self, **kwargs):
|
|
@@ -516,5 +503,5 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
|
|
|
516
503
|
RuntimeError: If the agent has not been initialized.
|
|
517
504
|
"""
|
|
518
505
|
if not self._initialized:
|
|
519
|
-
raise RuntimeError("Agent not initialized; call
|
|
506
|
+
raise RuntimeError("Agent not initialized; call _initialize() before use")
|
|
520
507
|
return self._tools
|
|
@@ -103,6 +103,23 @@ class RollingCondenser(PipelinableCondenserBase, ABC):
|
|
|
103
103
|
`View` to be passed to the LLM.
|
|
104
104
|
"""
|
|
105
105
|
|
|
106
|
+
def hard_context_reset(
|
|
107
|
+
self,
|
|
108
|
+
view: View, # noqa: ARG002
|
|
109
|
+
agent_llm: LLM | None = None, # noqa: ARG002
|
|
110
|
+
) -> Condensation | None:
|
|
111
|
+
"""Perform a hard context reset, if supported by the condenser.
|
|
112
|
+
|
|
113
|
+
By default, rolling condensers do not support hard context resets. Override this
|
|
114
|
+
method to implement hard context reset logic by returning a `Condensation`
|
|
115
|
+
object.
|
|
116
|
+
|
|
117
|
+
This method is invoked when:
|
|
118
|
+
- A HARD condensation requirement is triggered (e.g., by user request)
|
|
119
|
+
- But the condenser raises a NoCondensationAvailableException error
|
|
120
|
+
"""
|
|
121
|
+
return None
|
|
122
|
+
|
|
106
123
|
@abstractmethod
|
|
107
124
|
def condensation_requirement(
|
|
108
125
|
self, view: View, agent_llm: LLM | None = None
|
|
@@ -142,9 +159,25 @@ class RollingCondenser(PipelinableCondenserBase, ABC):
|
|
|
142
159
|
# we do so immediately.
|
|
143
160
|
return view
|
|
144
161
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
162
|
+
elif request == CondensationRequirement.HARD:
|
|
163
|
+
# The agent has found itself in a situation where it cannot proceed
|
|
164
|
+
# without condensation, but the condenser cannot provide one. We'll
|
|
165
|
+
# try to recover from this situation by performing a hard context
|
|
166
|
+
# reset, if supported by the condenser.
|
|
167
|
+
try:
|
|
168
|
+
hard_reset_condensation = self.hard_context_reset(
|
|
169
|
+
view, agent_llm=agent_llm
|
|
170
|
+
)
|
|
171
|
+
if hard_reset_condensation is not None:
|
|
172
|
+
return hard_reset_condensation
|
|
173
|
+
|
|
174
|
+
# And if something goes wrong with the hard reset make sure we keep
|
|
175
|
+
# both errors in the stack
|
|
176
|
+
except Exception as hard_reset_exception:
|
|
177
|
+
raise hard_reset_exception from e
|
|
178
|
+
|
|
179
|
+
# In all other situations re-raise the exception.
|
|
180
|
+
raise e
|
|
148
181
|
|
|
149
182
|
# Otherwise we're safe to just return the view.
|
|
150
183
|
else:
|
|
@@ -17,9 +17,13 @@ from openhands.sdk.context.prompts import render_template
|
|
|
17
17
|
from openhands.sdk.context.view import View
|
|
18
18
|
from openhands.sdk.event.base import LLMConvertibleEvent
|
|
19
19
|
from openhands.sdk.event.condenser import Condensation
|
|
20
|
-
from openhands.sdk.event.llm_convertible import MessageEvent
|
|
21
20
|
from openhands.sdk.llm import LLM, Message, TextContent
|
|
21
|
+
from openhands.sdk.logger import get_logger
|
|
22
22
|
from openhands.sdk.observability.laminar import observe
|
|
23
|
+
from openhands.sdk.utils import maybe_truncate
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
class Reason(Enum):
|
|
@@ -48,6 +52,14 @@ class LLMSummarizingCondenser(RollingCondenser):
|
|
|
48
52
|
`keep_first` events in the conversation will never be condensed or summarized.
|
|
49
53
|
"""
|
|
50
54
|
|
|
55
|
+
hard_context_reset_max_retries: int = Field(default=5, gt=0)
|
|
56
|
+
"""Number of attempts to perform hard context reset before raising an error."""
|
|
57
|
+
|
|
58
|
+
hard_context_reset_context_scaling: float = Field(default=0.8, gt=0.0, lt=1.0)
|
|
59
|
+
"""When performing hard context reset, if the summarization fails, reduce the max
|
|
60
|
+
size of each event string by this factor and retry.
|
|
61
|
+
"""
|
|
62
|
+
|
|
51
63
|
@model_validator(mode="after")
|
|
52
64
|
def validate_keep_first_vs_max_size(self):
|
|
53
65
|
events_from_tail = self.max_size // 2 - self.keep_first - 1
|
|
@@ -117,35 +129,20 @@ class LLMSummarizingCondenser(RollingCondenser):
|
|
|
117
129
|
if Reason.REQUEST in reasons:
|
|
118
130
|
return CondensationRequirement.HARD
|
|
119
131
|
|
|
120
|
-
def _get_summary_event_content(self, view: View) -> str:
|
|
121
|
-
"""Extract the text content from the summary event in the view, if any.
|
|
122
|
-
|
|
123
|
-
If there is no summary event or it does not contain text content, returns an
|
|
124
|
-
empty string.
|
|
125
|
-
"""
|
|
126
|
-
summary_event_content: str = ""
|
|
127
|
-
|
|
128
|
-
summary_event = view.summary_event
|
|
129
|
-
if isinstance(summary_event, MessageEvent):
|
|
130
|
-
message_content = summary_event.llm_message.content[0]
|
|
131
|
-
if isinstance(message_content, TextContent):
|
|
132
|
-
summary_event_content = message_content.text
|
|
133
|
-
|
|
134
|
-
return summary_event_content
|
|
135
|
-
|
|
136
132
|
def _generate_condensation(
|
|
137
133
|
self,
|
|
138
|
-
summary_event_content: str,
|
|
139
134
|
forgotten_events: Sequence[LLMConvertibleEvent],
|
|
140
135
|
summary_offset: int,
|
|
136
|
+
max_event_str_length: int | None = None,
|
|
141
137
|
) -> Condensation:
|
|
142
138
|
"""Generate a condensation by using the condenser's LLM to summarize forgotten
|
|
143
139
|
events.
|
|
144
140
|
|
|
145
141
|
Args:
|
|
146
|
-
summary_event_content: The content of the previous summary event.
|
|
147
142
|
forgotten_events: The list of events to be summarized.
|
|
148
143
|
summary_offset: The index where the summary event should be inserted.
|
|
144
|
+
max_event_str_length: Optional maximum length for each event string. If
|
|
145
|
+
provided, event strings longer than this will be truncated.
|
|
149
146
|
|
|
150
147
|
Returns:
|
|
151
148
|
Condensation: The generated condensation object.
|
|
@@ -156,12 +153,14 @@ class LLMSummarizingCondenser(RollingCondenser):
|
|
|
156
153
|
assert len(forgotten_events) > 0, "No events to condense."
|
|
157
154
|
|
|
158
155
|
# Convert events to strings for the template
|
|
159
|
-
event_strings = [
|
|
156
|
+
event_strings = [
|
|
157
|
+
maybe_truncate(str(forgotten_event), truncate_after=max_event_str_length)
|
|
158
|
+
for forgotten_event in forgotten_events
|
|
159
|
+
]
|
|
160
160
|
|
|
161
161
|
prompt = render_template(
|
|
162
162
|
os.path.join(os.path.dirname(__file__), "prompts"),
|
|
163
163
|
"summarizing_prompt.j2",
|
|
164
|
-
previous_summary=summary_event_content,
|
|
165
164
|
events=event_strings,
|
|
166
165
|
)
|
|
167
166
|
|
|
@@ -252,6 +251,51 @@ class LLMSummarizingCondenser(RollingCondenser):
|
|
|
252
251
|
# Summary offset is the same as forgetting_start
|
|
253
252
|
return forgotten_events, forgetting_start
|
|
254
253
|
|
|
254
|
+
@observe(ignore_inputs=["view", "agent_llm"])
|
|
255
|
+
def hard_context_reset(
|
|
256
|
+
self,
|
|
257
|
+
view: View,
|
|
258
|
+
agent_llm: LLM | None = None, # noqa: ARG002
|
|
259
|
+
) -> Condensation | None:
|
|
260
|
+
"""Perform a hard context reset by summarizing all events in the view.
|
|
261
|
+
|
|
262
|
+
Depending on how the hard context reset is triggered, this may fail (e.g., if
|
|
263
|
+
the view is too large for the summarizing LLM to handle). In that case, we keep
|
|
264
|
+
trimming down the contents until a summary can be generated.
|
|
265
|
+
"""
|
|
266
|
+
max_event_str_length: int | None = None
|
|
267
|
+
attempts_remaining: int = self.hard_context_reset_max_retries
|
|
268
|
+
|
|
269
|
+
while attempts_remaining > 0:
|
|
270
|
+
try:
|
|
271
|
+
return self._generate_condensation(
|
|
272
|
+
forgotten_events=view.events,
|
|
273
|
+
summary_offset=0,
|
|
274
|
+
max_event_str_length=max_event_str_length,
|
|
275
|
+
)
|
|
276
|
+
except Exception as e:
|
|
277
|
+
# If we haven't set a max_event_str_length yet, set it as the largest
|
|
278
|
+
# event string length.
|
|
279
|
+
if max_event_str_length is None:
|
|
280
|
+
max_event_str_length = max(len(str(event)) for event in view.events)
|
|
281
|
+
|
|
282
|
+
# Since the summarization failed, reduce the max_event_str_length by 20%
|
|
283
|
+
assert max_event_str_length is not None
|
|
284
|
+
max_event_str_length = int(
|
|
285
|
+
max_event_str_length * self.hard_context_reset_context_scaling
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Log the exception so we can track these failures
|
|
289
|
+
logger.warning(
|
|
290
|
+
f"Hard context reset summarization failed with exception: {e}. "
|
|
291
|
+
f"Reducing max event size to {max_event_str_length} and retrying."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
attempts_remaining -= 1
|
|
295
|
+
|
|
296
|
+
logger.error("Hard context reset summarization failed after multiple attempts.")
|
|
297
|
+
return None
|
|
298
|
+
|
|
255
299
|
@observe(ignore_inputs=["view", "agent_llm"])
|
|
256
300
|
def get_condensation(
|
|
257
301
|
self, view: View, agent_llm: LLM | None = None
|
|
@@ -269,10 +313,7 @@ class LLMSummarizingCondenser(RollingCondenser):
|
|
|
269
313
|
"events. Consider adjusting keep_first or max_size parameters."
|
|
270
314
|
)
|
|
271
315
|
|
|
272
|
-
summary_event_content = self._get_summary_event_content(view)
|
|
273
|
-
|
|
274
316
|
return self._generate_condensation(
|
|
275
|
-
summary_event_content=summary_event_content,
|
|
276
317
|
forgotten_events=forgotten_events,
|
|
277
318
|
summary_offset=summary_offset,
|
|
278
319
|
)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
You are maintaining a context-aware state summary for an interactive agent.
|
|
2
|
-
You will be given a list of events corresponding to actions taken by the agent,
|
|
2
|
+
You will be given a list of events corresponding to actions taken by the agent, which will include previous summaries.
|
|
3
3
|
If the events being summarized contain ANY task-tracking, you MUST include a TASK_TRACKING section to maintain continuity.
|
|
4
4
|
When referencing tasks make sure to preserve exact task IDs and statuses.
|
|
5
5
|
|
|
@@ -46,10 +46,6 @@ COMPLETED: 15 haikus written for results [T,H,T,H,T,H,T,T,H,T,H,T,H,T,H]
|
|
|
46
46
|
PENDING: 5 more haikus needed
|
|
47
47
|
CURRENT_STATE: Last flip: Heads, Haiku count: 15/20
|
|
48
48
|
|
|
49
|
-
<PREVIOUS SUMMARY>
|
|
50
|
-
{{ previous_summary }}
|
|
51
|
-
</PREVIOUS SUMMARY>
|
|
52
|
-
|
|
53
49
|
{% for event in events %}
|
|
54
50
|
<EVENT>
|
|
55
51
|
{{ event }}
|
|
@@ -27,9 +27,10 @@ You can also directly look up a skill's full content by reading its location pat
|
|
|
27
27
|
<CUSTOM_SECRETS>
|
|
28
28
|
### Credential Access
|
|
29
29
|
* Automatic secret injection: When you reference a registered secret key in your bash command, the secret value will be automatically exported as an environment variable before your command executes.
|
|
30
|
-
* How to use secrets: Simply reference the secret key in your command (e.g., `
|
|
30
|
+
* How to use secrets: Simply reference the secret key in your command (e.g., `curl -H "Authorization: Bearer $API_KEY" https://api.example.com`). The system will detect the key name in your command text and export it as environment variable before it executes your command.
|
|
31
31
|
* Secret detection: The system performs case-insensitive matching to find secret keys in your command text. If a registered secret key appears anywhere in your command, its value will be made available as an environment variable.
|
|
32
32
|
* Security: Secret values are automatically masked in command output to prevent accidental exposure. You will see `<secret-hidden>` instead of the actual secret value in the output.
|
|
33
|
+
* Avoid exposing raw secrets: Never echo or print the full value of secrets (e.g., avoid `echo $SECRET`). The conversation history may be logged or shared, and exposing raw secret values could compromise security. Instead, use secrets directly in commands where they serve their intended purpose (e.g., in curl headers or git URLs).
|
|
33
34
|
* Refreshing expired secrets: Some secrets (like GITHUB_TOKEN) may be updated periodically or expire over time. If a secret stops working (e.g., authentication failures), try using it again in a new command - the system should automatically use the refreshed value. For example, if GITHUB_TOKEN was used in a git remote URL and later expired, you can update the remote URL with the current token: `git remote set-url origin https://${GITHUB_TOKEN}@github.com/username/repo.git` to pick up the refreshed token value.
|
|
34
35
|
* If it still fails, report it to the user.
|
|
35
36
|
|
|
@@ -27,15 +27,10 @@ from openhands.sdk.context.skills.utils import (
|
|
|
27
27
|
validate_skill_name,
|
|
28
28
|
)
|
|
29
29
|
from openhands.sdk.logger import get_logger
|
|
30
|
-
from openhands.sdk.utils import maybe_truncate
|
|
31
30
|
|
|
32
31
|
|
|
33
32
|
logger = get_logger(__name__)
|
|
34
33
|
|
|
35
|
-
# Maximum characters for third-party skill files (e.g., AGENTS.md, CLAUDE.md, GEMINI.md)
|
|
36
|
-
# These files are always active, so we want to keep them reasonably sized
|
|
37
|
-
THIRD_PARTY_SKILL_MAX_CHARS = 10_000
|
|
38
|
-
|
|
39
34
|
|
|
40
35
|
class SkillInfo(BaseModel):
|
|
41
36
|
"""Lightweight representation of a skill's essential information.
|
|
@@ -485,32 +480,14 @@ class Skill(BaseModel):
|
|
|
485
480
|
"""Handle third-party skill files (e.g., .cursorrules, AGENTS.md).
|
|
486
481
|
|
|
487
482
|
Creates a Skill with None trigger (always active) if the file type
|
|
488
|
-
is recognized.
|
|
483
|
+
is recognized.
|
|
489
484
|
"""
|
|
490
485
|
skill_name = cls.PATH_TO_THIRD_PARTY_SKILL_NAME.get(path.name.lower())
|
|
491
486
|
|
|
492
487
|
if skill_name is not None:
|
|
493
|
-
truncated_content = maybe_truncate(
|
|
494
|
-
file_content,
|
|
495
|
-
truncate_after=THIRD_PARTY_SKILL_MAX_CHARS,
|
|
496
|
-
truncate_notice=(
|
|
497
|
-
f"\n\n<TRUNCATED><NOTE>The file {path} exceeded the "
|
|
498
|
-
f"maximum length ({THIRD_PARTY_SKILL_MAX_CHARS} "
|
|
499
|
-
f"characters) and has been truncated. Only the "
|
|
500
|
-
f"beginning and end are shown. You can read the full "
|
|
501
|
-
f"file if needed.</NOTE>\n\n"
|
|
502
|
-
),
|
|
503
|
-
)
|
|
504
|
-
|
|
505
|
-
if len(file_content) > THIRD_PARTY_SKILL_MAX_CHARS:
|
|
506
|
-
logger.warning(
|
|
507
|
-
f"Third-party skill file {path} ({len(file_content)} chars) "
|
|
508
|
-
f"exceeded limit ({THIRD_PARTY_SKILL_MAX_CHARS} chars), truncating"
|
|
509
|
-
)
|
|
510
|
-
|
|
511
488
|
return Skill(
|
|
512
489
|
name=skill_name,
|
|
513
|
-
content=
|
|
490
|
+
content=file_content,
|
|
514
491
|
source=str(path),
|
|
515
492
|
trigger=None,
|
|
516
493
|
)
|