openhands-sdk 1.9.0__py3-none-any.whl → 1.10.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 +54 -13
- openhands/sdk/agent/base.py +32 -45
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +0 -23
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
- openhands/sdk/context/view.py +108 -122
- openhands/sdk/conversation/__init__.py +2 -0
- openhands/sdk/conversation/conversation.py +13 -3
- openhands/sdk/conversation/exceptions.py +18 -0
- openhands/sdk/conversation/impl/local_conversation.py +192 -23
- openhands/sdk/conversation/impl/remote_conversation.py +141 -12
- 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/llm.py +47 -13
- openhands/sdk/llm/message.py +65 -27
- openhands/sdk/llm/options/chat_options.py +2 -1
- 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/utils/__init__.py +2 -0
- openhands/sdk/utils/async_utils.py +36 -1
- openhands/sdk/utils/command.py +28 -1
- {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/METADATA +1 -1
- {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/RECORD +34 -33
- {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/WHEEL +1 -1
- {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/top_level.txt +0 -0
openhands/sdk/agent/agent.py
CHANGED
|
@@ -106,20 +106,61 @@ class Agent(AgentBase):
|
|
|
106
106
|
# TODO(openhands): we should add test to test this init_state will actually
|
|
107
107
|
# modify state in-place
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
109
|
+
# Defensive check: Analyze state to detect unexpected initialization scenarios
|
|
110
|
+
# These checks help diagnose issues related to lazy loading and event ordering
|
|
111
|
+
# See: https://github.com/OpenHands/software-agent-sdk/issues/1785
|
|
112
|
+
events = list(state.events)
|
|
113
|
+
has_system_prompt = any(isinstance(e, SystemPromptEvent) for e in events)
|
|
114
|
+
has_user_message = any(
|
|
115
|
+
isinstance(e, MessageEvent) and e.source == "user" for e in events
|
|
116
|
+
)
|
|
117
|
+
has_any_llm_event = any(isinstance(e, LLMConvertibleEvent) for e in events)
|
|
118
|
+
|
|
119
|
+
# Log state for debugging initialization order issues
|
|
120
|
+
logger.debug(
|
|
121
|
+
f"init_state called: conversation_id={state.id}, "
|
|
122
|
+
f"event_count={len(events)}, "
|
|
123
|
+
f"has_system_prompt={has_system_prompt}, "
|
|
124
|
+
f"has_user_message={has_user_message}, "
|
|
125
|
+
f"has_any_llm_event={has_any_llm_event}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if has_system_prompt:
|
|
129
|
+
# SystemPromptEvent already exists - this is unexpected during normal flow
|
|
130
|
+
# but could happen in persistence/resume scenarios
|
|
131
|
+
logger.warning(
|
|
132
|
+
f"init_state called but SystemPromptEvent already exists. "
|
|
133
|
+
f"conversation_id={state.id}, event_count={len(events)}. "
|
|
134
|
+
f"This may indicate double initialization or a resume scenario."
|
|
121
135
|
)
|
|
122
|
-
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Assert: If there are user messages but no system prompt, something is wrong
|
|
139
|
+
# The system prompt should always be added before any user messages
|
|
140
|
+
if has_user_message:
|
|
141
|
+
event_types = [type(e).__name__ for e in events]
|
|
142
|
+
logger.error(
|
|
143
|
+
f"init_state: User message exists without SystemPromptEvent! "
|
|
144
|
+
f"conversation_id={state.id}, events={event_types}"
|
|
145
|
+
)
|
|
146
|
+
assert not has_user_message, (
|
|
147
|
+
f"Unexpected state: User message exists before SystemPromptEvent. "
|
|
148
|
+
f"conversation_id={state.id}, event_count={len(events)}, "
|
|
149
|
+
f"event_types={event_types}. "
|
|
150
|
+
f"This indicates an initialization order bug - init_state should be "
|
|
151
|
+
f"called before any user messages are added to the conversation."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Prepare system message
|
|
155
|
+
event = SystemPromptEvent(
|
|
156
|
+
source="agent",
|
|
157
|
+
system_prompt=TextContent(text=self.system_message),
|
|
158
|
+
# Tools are stored as ToolDefinition objects and converted to
|
|
159
|
+
# OpenAI format with security_risk parameter during LLM completion.
|
|
160
|
+
# See make_llm_completion() in agent/utils.py for details.
|
|
161
|
+
tools=list(self.tools_map.values()),
|
|
162
|
+
)
|
|
163
|
+
on_event(event)
|
|
123
164
|
|
|
124
165
|
def _should_evaluate_with_critic(self, action: Action | None) -> bool:
|
|
125
166
|
"""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):
|
|
@@ -17,7 +17,6 @@ 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
|
|
22
21
|
from openhands.sdk.observability.laminar import observe
|
|
23
22
|
|
|
@@ -117,25 +116,8 @@ class LLMSummarizingCondenser(RollingCondenser):
|
|
|
117
116
|
if Reason.REQUEST in reasons:
|
|
118
117
|
return CondensationRequirement.HARD
|
|
119
118
|
|
|
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
119
|
def _generate_condensation(
|
|
137
120
|
self,
|
|
138
|
-
summary_event_content: str,
|
|
139
121
|
forgotten_events: Sequence[LLMConvertibleEvent],
|
|
140
122
|
summary_offset: int,
|
|
141
123
|
) -> Condensation:
|
|
@@ -143,7 +125,6 @@ class LLMSummarizingCondenser(RollingCondenser):
|
|
|
143
125
|
events.
|
|
144
126
|
|
|
145
127
|
Args:
|
|
146
|
-
summary_event_content: The content of the previous summary event.
|
|
147
128
|
forgotten_events: The list of events to be summarized.
|
|
148
129
|
summary_offset: The index where the summary event should be inserted.
|
|
149
130
|
|
|
@@ -161,7 +142,6 @@ class LLMSummarizingCondenser(RollingCondenser):
|
|
|
161
142
|
prompt = render_template(
|
|
162
143
|
os.path.join(os.path.dirname(__file__), "prompts"),
|
|
163
144
|
"summarizing_prompt.j2",
|
|
164
|
-
previous_summary=summary_event_content,
|
|
165
145
|
events=event_strings,
|
|
166
146
|
)
|
|
167
147
|
|
|
@@ -269,10 +249,7 @@ class LLMSummarizingCondenser(RollingCondenser):
|
|
|
269
249
|
"events. Consider adjusting keep_first or max_size parameters."
|
|
270
250
|
)
|
|
271
251
|
|
|
272
|
-
summary_event_content = self._get_summary_event_content(view)
|
|
273
|
-
|
|
274
252
|
return self._generate_condensation(
|
|
275
|
-
summary_event_content=summary_event_content,
|
|
276
253
|
forgotten_events=forgotten_events,
|
|
277
254
|
summary_offset=summary_offset,
|
|
278
255
|
)
|
|
@@ -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 }}
|
openhands/sdk/context/view.py
CHANGED
|
@@ -11,7 +11,6 @@ from pydantic import BaseModel, computed_field
|
|
|
11
11
|
from openhands.sdk.event import (
|
|
12
12
|
Condensation,
|
|
13
13
|
CondensationRequest,
|
|
14
|
-
CondensationSummaryEvent,
|
|
15
14
|
LLMConvertibleEvent,
|
|
16
15
|
)
|
|
17
16
|
from openhands.sdk.event.base import Event, EventID
|
|
@@ -86,32 +85,6 @@ class View(BaseModel):
|
|
|
86
85
|
def __len__(self) -> int:
|
|
87
86
|
return len(self.events)
|
|
88
87
|
|
|
89
|
-
@property
|
|
90
|
-
def most_recent_condensation(self) -> Condensation | None:
|
|
91
|
-
"""Return the most recent condensation, or None if no condensations exist."""
|
|
92
|
-
return self.condensations[-1] if self.condensations else None
|
|
93
|
-
|
|
94
|
-
@property
|
|
95
|
-
def summary_event_index(self) -> int | None:
|
|
96
|
-
"""Return the index of the summary event, or None if no summary exists."""
|
|
97
|
-
recent_condensation = self.most_recent_condensation
|
|
98
|
-
if (
|
|
99
|
-
recent_condensation is not None
|
|
100
|
-
and recent_condensation.summary is not None
|
|
101
|
-
and recent_condensation.summary_offset is not None
|
|
102
|
-
):
|
|
103
|
-
return recent_condensation.summary_offset
|
|
104
|
-
return None
|
|
105
|
-
|
|
106
|
-
@property
|
|
107
|
-
def summary_event(self) -> CondensationSummaryEvent | None:
|
|
108
|
-
"""Return the summary event, or None if no summary exists."""
|
|
109
|
-
if self.summary_event_index is not None:
|
|
110
|
-
event = self.events[self.summary_event_index]
|
|
111
|
-
if isinstance(event, CondensationSummaryEvent):
|
|
112
|
-
return event
|
|
113
|
-
return None
|
|
114
|
-
|
|
115
88
|
@computed_field # type: ignore[prop-decorator]
|
|
116
89
|
@cached_property
|
|
117
90
|
def manipulation_indices(self) -> list[int]:
|
|
@@ -291,46 +264,53 @@ class View(BaseModel):
|
|
|
291
264
|
|
|
292
265
|
@staticmethod
|
|
293
266
|
def _enforce_batch_atomicity(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
) ->
|
|
297
|
-
"""Ensure that if any ActionEvent in a batch is removed
|
|
298
|
-
in that batch are removed.
|
|
267
|
+
view_events: Sequence[LLMConvertibleEvent],
|
|
268
|
+
all_events: Sequence[Event],
|
|
269
|
+
) -> list[LLMConvertibleEvent]:
|
|
270
|
+
"""Ensure that if any ActionEvent in a batch is removed from the view,
|
|
271
|
+
all ActionEvents in that batch are removed.
|
|
299
272
|
|
|
300
273
|
This prevents partial batches from being sent to the LLM, which can cause
|
|
301
274
|
API errors when thinking blocks are separated from their tool calls.
|
|
302
275
|
|
|
303
276
|
Args:
|
|
304
|
-
|
|
305
|
-
|
|
277
|
+
view_events: The list of events that are being kept in the view
|
|
278
|
+
all_events: The complete original list of all events
|
|
306
279
|
|
|
307
280
|
Returns:
|
|
308
|
-
|
|
309
|
-
ActionEvents
|
|
281
|
+
Filtered list of view events with batch atomicity enforced
|
|
282
|
+
(removing all ActionEvents from batches where any ActionEvent
|
|
283
|
+
was already removed)
|
|
310
284
|
"""
|
|
311
|
-
action_batch = ActionBatch.from_events(
|
|
285
|
+
action_batch = ActionBatch.from_events(all_events)
|
|
312
286
|
|
|
313
287
|
if not action_batch.batches:
|
|
314
|
-
return
|
|
288
|
+
return list(view_events)
|
|
289
|
+
|
|
290
|
+
# Get set of event IDs currently in the view
|
|
291
|
+
view_event_ids = {event.id for event in view_events}
|
|
315
292
|
|
|
316
|
-
|
|
293
|
+
# Track which event IDs should be removed due to batch atomicity
|
|
294
|
+
ids_to_remove: set[EventID] = set()
|
|
317
295
|
|
|
318
296
|
for llm_response_id, batch_event_ids in action_batch.batches.items():
|
|
319
|
-
# Check if any ActionEvent in this batch is
|
|
320
|
-
if any(event_id in
|
|
297
|
+
# Check if any ActionEvent in this batch is missing from view
|
|
298
|
+
if any(event_id not in view_event_ids for event_id in batch_event_ids):
|
|
321
299
|
# If so, remove all ActionEvents in this batch
|
|
322
|
-
|
|
300
|
+
ids_to_remove.update(batch_event_ids)
|
|
323
301
|
logger.debug(
|
|
324
302
|
f"Enforcing batch atomicity: removing entire batch "
|
|
325
303
|
f"with llm_response_id={llm_response_id} "
|
|
326
304
|
f"({len(batch_event_ids)} events)"
|
|
327
305
|
)
|
|
328
306
|
|
|
329
|
-
|
|
307
|
+
# Filter out events that need to be removed
|
|
308
|
+
return [event for event in view_events if event.id not in ids_to_remove]
|
|
330
309
|
|
|
331
310
|
@staticmethod
|
|
332
|
-
def
|
|
333
|
-
|
|
311
|
+
def _filter_unmatched_tool_calls(
|
|
312
|
+
view_events: Sequence[LLMConvertibleEvent],
|
|
313
|
+
all_events: Sequence[Event],
|
|
334
314
|
) -> list[LLMConvertibleEvent]:
|
|
335
315
|
"""Filter out unmatched tool call events.
|
|
336
316
|
|
|
@@ -338,49 +318,58 @@ class View(BaseModel):
|
|
|
338
318
|
but don't have matching pairs. Also enforces batch atomicity - if any
|
|
339
319
|
ActionEvent in a batch is filtered out, all ActionEvents in that batch
|
|
340
320
|
are also filtered out.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
view_events: The list of events to filter
|
|
324
|
+
all_events: The complete original list of all events
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
Filtered list of events with unmatched tool calls removed
|
|
341
328
|
"""
|
|
342
|
-
action_tool_call_ids = View._get_action_tool_call_ids(
|
|
343
|
-
observation_tool_call_ids = View._get_observation_tool_call_ids(
|
|
329
|
+
action_tool_call_ids = View._get_action_tool_call_ids(view_events)
|
|
330
|
+
observation_tool_call_ids = View._get_observation_tool_call_ids(view_events)
|
|
344
331
|
|
|
345
332
|
# Build batch info for batch atomicity enforcement
|
|
346
|
-
|
|
333
|
+
batch = ActionBatch.from_events(all_events)
|
|
347
334
|
|
|
348
|
-
# First pass:
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
335
|
+
# First pass: filter out events that don't match based on tool call pairing
|
|
336
|
+
kept_events = [
|
|
337
|
+
event
|
|
338
|
+
for event in view_events
|
|
339
|
+
if View._should_keep_event(
|
|
352
340
|
event, action_tool_call_ids, observation_tool_call_ids
|
|
353
|
-
)
|
|
354
|
-
|
|
341
|
+
)
|
|
342
|
+
]
|
|
355
343
|
|
|
356
344
|
# Second pass: enforce batch atomicity for ActionEvents
|
|
357
345
|
# If any ActionEvent in a batch is removed, all ActionEvents in that
|
|
358
346
|
# batch should also be removed
|
|
359
|
-
|
|
347
|
+
kept_events = View._enforce_batch_atomicity(kept_events, all_events)
|
|
360
348
|
|
|
361
349
|
# Third pass: also remove ObservationEvents whose ActionEvents were removed
|
|
362
350
|
# due to batch atomicity
|
|
363
|
-
|
|
364
|
-
for
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
351
|
+
# Find which action IDs are now missing after batch atomicity enforcement
|
|
352
|
+
kept_event_ids = {event.id for event in kept_events}
|
|
353
|
+
tool_call_ids_to_remove: set[ToolCallID] = {
|
|
354
|
+
tool_call_id
|
|
355
|
+
for action_id, tool_call_id in batch.action_id_to_tool_call_id.items()
|
|
356
|
+
if action_id not in kept_event_ids
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# Filter out ObservationEvents whose ActionEvents were removed
|
|
360
|
+
result = [
|
|
361
|
+
event
|
|
362
|
+
for event in kept_events
|
|
363
|
+
if not (
|
|
364
|
+
isinstance(event, ObservationBaseEvent)
|
|
365
|
+
and event.tool_call_id in tool_call_ids_to_remove
|
|
366
|
+
)
|
|
367
|
+
]
|
|
379
368
|
|
|
380
369
|
return result
|
|
381
370
|
|
|
382
371
|
@staticmethod
|
|
383
|
-
def _get_action_tool_call_ids(events:
|
|
372
|
+
def _get_action_tool_call_ids(events: Sequence[Event]) -> set[ToolCallID]:
|
|
384
373
|
"""Extract tool_call_ids from ActionEvents."""
|
|
385
374
|
tool_call_ids = set()
|
|
386
375
|
for event in events:
|
|
@@ -390,7 +379,7 @@ class View(BaseModel):
|
|
|
390
379
|
|
|
391
380
|
@staticmethod
|
|
392
381
|
def _get_observation_tool_call_ids(
|
|
393
|
-
events:
|
|
382
|
+
events: Sequence[Event],
|
|
394
383
|
) -> set[ToolCallID]:
|
|
395
384
|
"""Extract tool_call_ids from ObservationEvents."""
|
|
396
385
|
tool_call_ids = set()
|
|
@@ -404,7 +393,7 @@ class View(BaseModel):
|
|
|
404
393
|
|
|
405
394
|
@staticmethod
|
|
406
395
|
def _should_keep_event(
|
|
407
|
-
event:
|
|
396
|
+
event: Event,
|
|
408
397
|
action_tool_call_ids: set[ToolCallID],
|
|
409
398
|
observation_tool_call_ids: set[ToolCallID],
|
|
410
399
|
) -> bool:
|
|
@@ -434,67 +423,64 @@ class View(BaseModel):
|
|
|
434
423
|
return idx
|
|
435
424
|
return threshold
|
|
436
425
|
|
|
426
|
+
@staticmethod
|
|
427
|
+
def unhandled_condensation_request_exists(
|
|
428
|
+
events: Sequence[Event],
|
|
429
|
+
) -> bool:
|
|
430
|
+
"""Check if there is an unhandled condensation request in the list of events.
|
|
431
|
+
|
|
432
|
+
An unhandled condensation request is defined as a CondensationRequest event
|
|
433
|
+
that appears after the most recent Condensation event in the list.
|
|
434
|
+
"""
|
|
435
|
+
for event in reversed(events):
|
|
436
|
+
if isinstance(event, Condensation):
|
|
437
|
+
return False
|
|
438
|
+
if isinstance(event, CondensationRequest):
|
|
439
|
+
return True
|
|
440
|
+
return False
|
|
441
|
+
|
|
437
442
|
@staticmethod
|
|
438
443
|
def from_events(events: Sequence[Event]) -> View:
|
|
439
444
|
"""Create a view from a list of events, respecting the semantics of any
|
|
440
445
|
condensation events.
|
|
441
446
|
"""
|
|
442
|
-
|
|
447
|
+
output: list[LLMConvertibleEvent] = []
|
|
443
448
|
condensations: list[Condensation] = []
|
|
449
|
+
|
|
450
|
+
# Generate the LLMConvertibleEvent objects the agent can send to the LLM by
|
|
451
|
+
# removing un-sendable events and applying condensations in order.
|
|
444
452
|
for event in events:
|
|
453
|
+
# By the time we come across a Condensation event, the output list should
|
|
454
|
+
# already reflect the events seen by the agent up to that point. We can
|
|
455
|
+
# therefore apply the condensation semantics directly to the output list.
|
|
445
456
|
if isinstance(event, Condensation):
|
|
446
457
|
condensations.append(event)
|
|
447
|
-
|
|
448
|
-
# Make sure we also forget the condensation action itself
|
|
449
|
-
forgotten_event_ids.add(event.id)
|
|
450
|
-
if isinstance(event, CondensationRequest):
|
|
451
|
-
forgotten_event_ids.add(event.id)
|
|
452
|
-
|
|
453
|
-
# Enforce batch atomicity: if any event in a multi-action batch is forgotten,
|
|
454
|
-
# forget all events in that batch to prevent partial batches with thinking
|
|
455
|
-
# blocks separated from their tool calls
|
|
456
|
-
forgotten_event_ids = View._enforce_batch_atomicity(events, forgotten_event_ids)
|
|
457
|
-
|
|
458
|
-
kept_events = [
|
|
459
|
-
event
|
|
460
|
-
for event in events
|
|
461
|
-
if event.id not in forgotten_event_ids
|
|
462
|
-
and isinstance(event, LLMConvertibleEvent)
|
|
463
|
-
]
|
|
458
|
+
output = event.apply(output)
|
|
464
459
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
summary_offset: int | None = None
|
|
460
|
+
elif isinstance(event, LLMConvertibleEvent):
|
|
461
|
+
output.append(event)
|
|
468
462
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
logger.debug(f"Inserting summary at offset {summary_offset}")
|
|
480
|
-
|
|
481
|
-
_new_summary_event = CondensationSummaryEvent(summary=summary)
|
|
482
|
-
kept_events.insert(summary_offset, _new_summary_event)
|
|
483
|
-
|
|
484
|
-
# Check for an unhandled condensation request -- these are events closer to the
|
|
485
|
-
# end of the list than any condensation action.
|
|
486
|
-
unhandled_condensation_request = False
|
|
487
|
-
|
|
488
|
-
for event in reversed(events):
|
|
489
|
-
if isinstance(event, Condensation):
|
|
490
|
-
break
|
|
463
|
+
# If the event isn't related to condensation and isn't LLMConvertible, it
|
|
464
|
+
# should not be in the resulting view. Examples include certain internal
|
|
465
|
+
# events used for state tracking that the LLM does not need to see -- see,
|
|
466
|
+
# for example, ConversationStateUpdateEvent, PauseEvent, and (relevant here)
|
|
467
|
+
# CondensationRequest.
|
|
468
|
+
else:
|
|
469
|
+
logger.debug(
|
|
470
|
+
f"Skipping non-LLMConvertibleEvent of type {type(event)} "
|
|
471
|
+
f"in View.from_events"
|
|
472
|
+
)
|
|
491
473
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
474
|
+
# Enforce batch atomicity: if any event in a multi-action batch is removed,
|
|
475
|
+
# remove all events in that batch to prevent partial batches with thinking
|
|
476
|
+
# blocks separated from their tool calls
|
|
477
|
+
output = View._enforce_batch_atomicity(output, events)
|
|
478
|
+
output = View._filter_unmatched_tool_calls(output, events)
|
|
495
479
|
|
|
496
480
|
return View(
|
|
497
|
-
events=
|
|
498
|
-
unhandled_condensation_request=
|
|
481
|
+
events=output,
|
|
482
|
+
unhandled_condensation_request=View.unhandled_condensation_request_exists(
|
|
483
|
+
events
|
|
484
|
+
),
|
|
499
485
|
condensations=condensations,
|
|
500
486
|
)
|
|
@@ -2,6 +2,7 @@ from openhands.sdk.conversation.base import BaseConversation
|
|
|
2
2
|
from openhands.sdk.conversation.conversation import Conversation
|
|
3
3
|
from openhands.sdk.conversation.event_store import EventLog
|
|
4
4
|
from openhands.sdk.conversation.events_list_base import EventsListBase
|
|
5
|
+
from openhands.sdk.conversation.exceptions import WebSocketConnectionError
|
|
5
6
|
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
|
|
6
7
|
from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
|
|
7
8
|
from openhands.sdk.conversation.response_utils import get_agent_final_response
|
|
@@ -37,4 +38,5 @@ __all__ = [
|
|
|
37
38
|
"RemoteConversation",
|
|
38
39
|
"EventsListBase",
|
|
39
40
|
"get_agent_final_response",
|
|
41
|
+
"WebSocketConnectionError",
|
|
40
42
|
]
|