openhands-sdk 1.5.0__py3-none-any.whl → 1.7.2__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.
Files changed (56) hide show
  1. openhands/sdk/__init__.py +9 -1
  2. openhands/sdk/agent/agent.py +35 -12
  3. openhands/sdk/agent/base.py +53 -7
  4. openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
  5. openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
  6. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
  7. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
  8. openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
  9. openhands/sdk/agent/prompts/system_prompt.j2 +29 -1
  10. openhands/sdk/agent/utils.py +18 -4
  11. openhands/sdk/context/__init__.py +2 -0
  12. openhands/sdk/context/agent_context.py +42 -10
  13. openhands/sdk/context/condenser/base.py +11 -6
  14. openhands/sdk/context/condenser/llm_summarizing_condenser.py +169 -20
  15. openhands/sdk/context/condenser/no_op_condenser.py +2 -1
  16. openhands/sdk/context/condenser/pipeline_condenser.py +10 -9
  17. openhands/sdk/context/condenser/utils.py +149 -0
  18. openhands/sdk/context/prompts/prompt.py +40 -2
  19. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +3 -3
  20. openhands/sdk/context/skills/__init__.py +2 -0
  21. openhands/sdk/context/skills/skill.py +152 -1
  22. openhands/sdk/context/view.py +287 -27
  23. openhands/sdk/conversation/base.py +17 -0
  24. openhands/sdk/conversation/conversation.py +19 -0
  25. openhands/sdk/conversation/exceptions.py +29 -4
  26. openhands/sdk/conversation/impl/local_conversation.py +126 -9
  27. openhands/sdk/conversation/impl/remote_conversation.py +152 -3
  28. openhands/sdk/conversation/state.py +42 -1
  29. openhands/sdk/conversation/stuck_detector.py +81 -45
  30. openhands/sdk/conversation/types.py +30 -0
  31. openhands/sdk/event/llm_convertible/system.py +16 -20
  32. openhands/sdk/hooks/__init__.py +30 -0
  33. openhands/sdk/hooks/config.py +180 -0
  34. openhands/sdk/hooks/conversation_hooks.py +227 -0
  35. openhands/sdk/hooks/executor.py +155 -0
  36. openhands/sdk/hooks/manager.py +170 -0
  37. openhands/sdk/hooks/types.py +40 -0
  38. openhands/sdk/io/cache.py +85 -0
  39. openhands/sdk/io/local.py +39 -2
  40. openhands/sdk/llm/llm.py +3 -2
  41. openhands/sdk/llm/message.py +4 -3
  42. openhands/sdk/llm/mixins/fn_call_converter.py +61 -16
  43. openhands/sdk/llm/mixins/non_native_fc.py +5 -1
  44. openhands/sdk/llm/utils/model_features.py +64 -24
  45. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  46. openhands/sdk/llm/utils/verified_models.py +6 -4
  47. openhands/sdk/logger/logger.py +1 -1
  48. openhands/sdk/tool/schema.py +10 -0
  49. openhands/sdk/tool/tool.py +2 -2
  50. openhands/sdk/utils/async_executor.py +76 -67
  51. openhands/sdk/utils/models.py +1 -1
  52. openhands/sdk/utils/paging.py +63 -0
  53. {openhands_sdk-1.5.0.dist-info → openhands_sdk-1.7.2.dist-info}/METADATA +3 -3
  54. {openhands_sdk-1.5.0.dist-info → openhands_sdk-1.7.2.dist-info}/RECORD +56 -41
  55. {openhands_sdk-1.5.0.dist-info → openhands_sdk-1.7.2.dist-info}/WHEEL +0 -0
  56. {openhands_sdk-1.5.0.dist-info → openhands_sdk-1.7.2.dist-info}/top_level.txt +0 -0
@@ -40,6 +40,9 @@ class Skill(BaseModel):
40
40
  - None: Always active, for repository-specific guidelines
41
41
  - KeywordTrigger: Activated when keywords appear in user messages
42
42
  - TaskTrigger: Activated for specific tasks, may require user input
43
+
44
+ This model supports both OpenHands-specific fields and AgentSkills standard
45
+ fields (https://agentskills.io/specification) for cross-platform compatibility.
43
46
  """
44
47
 
45
48
  name: str
@@ -74,6 +77,65 @@ class Skill(BaseModel):
74
77
  description="Input metadata for the skill (task skills only)",
75
78
  )
76
79
 
80
+ # AgentSkills standard fields (https://agentskills.io/specification)
81
+ description: str | None = Field(
82
+ default=None,
83
+ description=(
84
+ "A brief description of what the skill does and when to use it. "
85
+ "AgentSkills standard field (max 1024 characters)."
86
+ ),
87
+ )
88
+ license: str | None = Field(
89
+ default=None,
90
+ description=(
91
+ "The license under which the skill is distributed. "
92
+ "AgentSkills standard field (e.g., 'Apache-2.0', 'MIT')."
93
+ ),
94
+ )
95
+ compatibility: str | None = Field(
96
+ default=None,
97
+ description=(
98
+ "Environment requirements or compatibility notes for the skill. "
99
+ "AgentSkills standard field (e.g., 'Requires git and docker')."
100
+ ),
101
+ )
102
+ metadata: dict[str, str] | None = Field(
103
+ default=None,
104
+ description=(
105
+ "Arbitrary key-value metadata for the skill. "
106
+ "AgentSkills standard field for extensibility."
107
+ ),
108
+ )
109
+ allowed_tools: list[str] | None = Field(
110
+ default=None,
111
+ description=(
112
+ "List of pre-approved tools for this skill. "
113
+ "AgentSkills standard field (parsed from space-delimited string)."
114
+ ),
115
+ )
116
+
117
+ @field_validator("allowed_tools", mode="before")
118
+ @classmethod
119
+ def _parse_allowed_tools(cls, v: str | list | None) -> list[str] | None:
120
+ """Parse allowed_tools from space-delimited string or list."""
121
+ if v is None:
122
+ return None
123
+ if isinstance(v, str):
124
+ return v.split()
125
+ if isinstance(v, list):
126
+ return [str(t) for t in v]
127
+ raise SkillValidationError("allowed-tools must be a string or list")
128
+
129
+ @field_validator("metadata", mode="before")
130
+ @classmethod
131
+ def _convert_metadata_values(cls, v: dict | None) -> dict[str, str] | None:
132
+ """Convert metadata values to strings."""
133
+ if v is None:
134
+ return None
135
+ if isinstance(v, dict):
136
+ return {str(k): str(val) for k, val in v.items()}
137
+ raise SkillValidationError("metadata must be a dictionary")
138
+
77
139
  PATH_TO_THIRD_PARTY_SKILL_NAME: ClassVar[dict[str, str]] = {
78
140
  ".cursorrules": "cursorrules",
79
141
  "agents.md": "agents",
@@ -129,6 +191,9 @@ class Skill(BaseModel):
129
191
  """Load a skill from a markdown file with frontmatter.
130
192
 
131
193
  The agent's name is derived from its path relative to the skill_dir.
194
+
195
+ Supports both OpenHands-specific frontmatter fields and AgentSkills
196
+ standard fields (https://agentskills.io/specification).
132
197
  """
133
198
  path = Path(path) if isinstance(path, str) else path
134
199
 
@@ -162,6 +227,23 @@ class Skill(BaseModel):
162
227
  # Use name from frontmatter if provided, otherwise use derived name
163
228
  agent_name = str(metadata_dict.get("name", skill_name))
164
229
 
230
+ # Extract AgentSkills standard fields (Pydantic validators handle
231
+ # transformation). Handle "allowed-tools" to "allowed_tools" key mapping.
232
+ allowed_tools_value = metadata_dict.get(
233
+ "allowed-tools", metadata_dict.get("allowed_tools")
234
+ )
235
+ agentskills_fields = {
236
+ "description": metadata_dict.get("description"),
237
+ "license": metadata_dict.get("license"),
238
+ "compatibility": metadata_dict.get("compatibility"),
239
+ "metadata": metadata_dict.get("metadata"),
240
+ "allowed_tools": allowed_tools_value,
241
+ }
242
+ # Remove None values to avoid passing unnecessary kwargs
243
+ agentskills_fields = {
244
+ k: v for k, v in agentskills_fields.items() if v is not None
245
+ }
246
+
165
247
  # Get trigger keywords from metadata
166
248
  keywords = metadata_dict.get("triggers", [])
167
249
  if not isinstance(keywords, list):
@@ -188,6 +270,7 @@ class Skill(BaseModel):
188
270
  source=str(path),
189
271
  trigger=TaskTrigger(triggers=keywords),
190
272
  inputs=inputs,
273
+ **agentskills_fields,
191
274
  )
192
275
 
193
276
  elif metadata_dict.get("triggers", None):
@@ -196,6 +279,7 @@ class Skill(BaseModel):
196
279
  content=content,
197
280
  source=str(path),
198
281
  trigger=KeywordTrigger(keywords=keywords),
282
+ **agentskills_fields,
199
283
  )
200
284
  else:
201
285
  # No triggers, default to None (always active)
@@ -208,6 +292,7 @@ class Skill(BaseModel):
208
292
  source=str(path),
209
293
  trigger=None,
210
294
  mcp_tools=mcp_tools,
295
+ **agentskills_fields,
211
296
  )
212
297
 
213
298
  # Field-level validation for mcp_tools
@@ -326,7 +411,10 @@ def load_skills_from_dir(
326
411
  # Process all files in one loop
327
412
  for file in chain(special_files, md_files):
328
413
  try:
329
- skill = Skill.load(file, skill_dir)
414
+ skill = Skill.load(
415
+ file,
416
+ skill_dir,
417
+ )
330
418
  if skill.trigger is None:
331
419
  repo_skills[skill.name] = skill
332
420
  else:
@@ -398,6 +486,67 @@ def load_user_skills() -> list[Skill]:
398
486
  return all_skills
399
487
 
400
488
 
489
+ def load_project_skills(work_dir: str | Path) -> list[Skill]:
490
+ """Load skills from project-specific directories.
491
+
492
+ Searches for skills in {work_dir}/.openhands/skills/ and
493
+ {work_dir}/.openhands/microagents/ (legacy). Skills from both
494
+ directories are merged, with skills/ taking precedence for
495
+ duplicate names.
496
+
497
+ Args:
498
+ work_dir: Path to the project/working directory.
499
+
500
+ Returns:
501
+ List of Skill objects loaded from project directories.
502
+ Returns empty list if no skills found or loading fails.
503
+ """
504
+ if isinstance(work_dir, str):
505
+ work_dir = Path(work_dir)
506
+
507
+ all_skills = []
508
+ seen_names = set()
509
+
510
+ # Load project-specific skills from .openhands/skills and legacy microagents
511
+ project_skills_dirs = [
512
+ work_dir / ".openhands" / "skills",
513
+ work_dir / ".openhands" / "microagents", # Legacy support
514
+ ]
515
+
516
+ for project_skills_dir in project_skills_dirs:
517
+ if not project_skills_dir.exists():
518
+ logger.debug(
519
+ f"Project skills directory does not exist: {project_skills_dir}"
520
+ )
521
+ continue
522
+
523
+ try:
524
+ logger.debug(f"Loading project skills from {project_skills_dir}")
525
+ repo_skills, knowledge_skills = load_skills_from_dir(project_skills_dir)
526
+
527
+ # Merge repo and knowledge skills
528
+ for skills_dict in [repo_skills, knowledge_skills]:
529
+ for name, skill in skills_dict.items():
530
+ if name not in seen_names:
531
+ all_skills.append(skill)
532
+ seen_names.add(name)
533
+ else:
534
+ logger.warning(
535
+ f"Skipping duplicate skill '{name}' from "
536
+ f"{project_skills_dir}"
537
+ )
538
+
539
+ except Exception as e:
540
+ logger.warning(
541
+ f"Failed to load project skills from {project_skills_dir}: {str(e)}"
542
+ )
543
+
544
+ logger.debug(
545
+ f"Loaded {len(all_skills)} project skills: {[s.name for s in all_skills]}"
546
+ )
547
+ return all_skills
548
+
549
+
401
550
  # Public skills repository configuration
402
551
  PUBLIC_SKILLS_REPO = "https://github.com/OpenHands/skills"
403
552
  PUBLIC_SKILLS_BRANCH = "main"
@@ -554,6 +703,8 @@ def load_public_skills(
554
703
  path=skill_file,
555
704
  skill_dir=repo_path,
556
705
  )
706
+ if skill is None:
707
+ continue
557
708
  all_skills.append(skill)
558
709
  logger.debug(f"Loaded public skill: {skill.name}")
559
710
  except Exception as e:
@@ -1,8 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
1
4
  from collections.abc import Sequence
5
+ from functools import cached_property
2
6
  from logging import getLogger
3
7
  from typing import overload
4
8
 
5
- from pydantic import BaseModel
9
+ from pydantic import BaseModel, computed_field
6
10
 
7
11
  from openhands.sdk.event import (
8
12
  Condensation,
@@ -21,6 +25,48 @@ from openhands.sdk.event.types import ToolCallID
21
25
  logger = getLogger(__name__)
22
26
 
23
27
 
28
+ class ActionBatch(BaseModel):
29
+ """Represents a batch of ActionEvents grouped by llm_response_id.
30
+
31
+ This is a utility class used to help detect and manage batches of ActionEvents
32
+ that share the same llm_response_id, which indicates they were generated together
33
+ by the LLM. This is important for ensuring atomicity when manipulating events
34
+ in a View, such as during condensation.
35
+ """
36
+
37
+ batches: dict[EventID, list[EventID]]
38
+ """dict mapping llm_response_id to list of ActionEvent IDs"""
39
+
40
+ action_id_to_response_id: dict[EventID, EventID]
41
+ """dict mapping ActionEvent ID to llm_response_id"""
42
+
43
+ action_id_to_tool_call_id: dict[EventID, ToolCallID]
44
+ """dict mapping ActionEvent ID to tool_call_id"""
45
+
46
+ @staticmethod
47
+ def from_events(
48
+ events: Sequence[Event],
49
+ ) -> ActionBatch:
50
+ """Build a map of llm_response_id -> list of ActionEvent IDs."""
51
+ batches: dict[EventID, list[EventID]] = defaultdict(list)
52
+ action_id_to_response_id: dict[EventID, EventID] = {}
53
+ action_id_to_tool_call_id: dict[EventID, ToolCallID] = {}
54
+
55
+ for event in events:
56
+ if isinstance(event, ActionEvent):
57
+ llm_response_id = event.llm_response_id
58
+ batches[llm_response_id].append(event.id)
59
+ action_id_to_response_id[event.id] = llm_response_id
60
+ if event.tool_call_id is not None:
61
+ action_id_to_tool_call_id[event.id] = event.tool_call_id
62
+
63
+ return ActionBatch(
64
+ batches=batches,
65
+ action_id_to_response_id=action_id_to_response_id,
66
+ action_id_to_tool_call_id=action_id_to_tool_call_id,
67
+ )
68
+
69
+
24
70
  class View(BaseModel):
25
71
  """Linearly ordered view of events.
26
72
 
@@ -66,6 +112,161 @@ class View(BaseModel):
66
112
  return event
67
113
  return None
68
114
 
115
+ @computed_field # type: ignore[prop-decorator]
116
+ @cached_property
117
+ def manipulation_indices(self) -> list[int]:
118
+ """Return cached manipulation indices for this view's events.
119
+
120
+ These indices represent boundaries between atomic units where events can be
121
+ safely manipulated (inserted or forgotten). An atomic unit is either:
122
+ - A tool loop: a sequence of batches starting with thinking blocks and
123
+ continuing through all subsequent batches until a non-batch event
124
+ - A batch of ActionEvents with the same llm_response_id and their
125
+ corresponding ObservationBaseEvents (when not part of a tool loop)
126
+ - A single event that is neither an ActionEvent nor an ObservationBaseEvent
127
+
128
+ Tool loops are identified by thinking blocks and must remain atomic to
129
+ preserve Claude API requirements that the final assistant message must
130
+ have thinking blocks when thinking is enabled.
131
+
132
+ The returned indices can be used for:
133
+ - Inserting new events: any returned index is safe
134
+ - Forgetting events: select a range between two consecutive indices
135
+
136
+ Consecutive indices define atomic units that must stay together:
137
+ - events[indices[i]:indices[i+1]] is an atomic unit
138
+
139
+ Returns:
140
+ Sorted list of indices representing atomic unit boundaries. Always
141
+ includes 0 and len(events) as boundaries.
142
+ """
143
+ if not self.events:
144
+ return [0]
145
+
146
+ # Build mapping of llm_response_id -> list of event indices
147
+ batches: dict[EventID, list[int]] = {}
148
+ for idx, event in enumerate(self.events):
149
+ if isinstance(event, ActionEvent):
150
+ llm_response_id = event.llm_response_id
151
+ if llm_response_id not in batches:
152
+ batches[llm_response_id] = []
153
+ batches[llm_response_id].append(idx)
154
+
155
+ # Build mapping of tool_call_id -> observation indices
156
+ observation_indices: dict[ToolCallID, int] = {}
157
+ for idx, event in enumerate(self.events):
158
+ if (
159
+ isinstance(event, ObservationBaseEvent)
160
+ and event.tool_call_id is not None
161
+ ):
162
+ observation_indices[event.tool_call_id] = idx
163
+
164
+ # For each batch, find the range of indices that includes all actions
165
+ # and their corresponding observations, and track if batch has thinking blocks
166
+ batch_ranges: list[tuple[int, int, bool]] = []
167
+ for llm_response_id, action_indices in batches.items():
168
+ min_idx = min(action_indices)
169
+ max_idx = max(action_indices)
170
+
171
+ # Check if this batch has thinking blocks (only first action has them)
172
+ first_action = self.events[min_idx]
173
+ has_thinking = (
174
+ isinstance(first_action, ActionEvent)
175
+ and len(first_action.thinking_blocks) > 0
176
+ )
177
+
178
+ # Extend the range to include all corresponding observations
179
+ for action_idx in action_indices:
180
+ action_event = self.events[action_idx]
181
+ if (
182
+ isinstance(action_event, ActionEvent)
183
+ and action_event.tool_call_id is not None
184
+ ):
185
+ if action_event.tool_call_id in observation_indices:
186
+ obs_idx = observation_indices[action_event.tool_call_id]
187
+ max_idx = max(max_idx, obs_idx)
188
+
189
+ batch_ranges.append((min_idx, max_idx, has_thinking))
190
+
191
+ # Sort batch ranges by start index for tool loop detection
192
+ batch_ranges.sort(key=lambda x: x[0])
193
+
194
+ # Identify tool loops: A tool loop starts with a batch that has thinking
195
+ # blocks and continues through all subsequent batches until we hit a
196
+ # non-ActionEvent/ObservationEvent (like a user MessageEvent).
197
+ tool_loop_ranges: list[tuple[int, int]] = []
198
+ if batch_ranges:
199
+ i = 0
200
+ while i < len(batch_ranges):
201
+ min_idx, max_idx, has_thinking = batch_ranges[i]
202
+
203
+ # If this batch has thinking blocks, start a tool loop
204
+ if has_thinking:
205
+ loop_start = min_idx
206
+ loop_end = max_idx
207
+
208
+ # Continue through ALL subsequent batches until we hit
209
+ # a non-batch event
210
+ j = i + 1
211
+ while j < len(batch_ranges):
212
+ next_min, next_max, _ = batch_ranges[j]
213
+
214
+ # Check if there's a non-batch event between current
215
+ # and next batch
216
+ has_non_batch_between = False
217
+ for k in range(loop_end + 1, next_min):
218
+ event = self.events[k]
219
+ if not isinstance(
220
+ event, (ActionEvent, ObservationBaseEvent)
221
+ ):
222
+ has_non_batch_between = True
223
+ break
224
+
225
+ if has_non_batch_between:
226
+ # Tool loop ends before this non-batch event
227
+ break
228
+
229
+ # Include this batch in the tool loop
230
+ loop_end = max(loop_end, next_max)
231
+ j += 1
232
+
233
+ tool_loop_ranges.append((loop_start, loop_end))
234
+ i = j
235
+ else:
236
+ i += 1
237
+
238
+ # Merge batch ranges that are part of tool loops
239
+ # Create a mapping of batch index ranges to whether they're in a tool loop
240
+ merged_ranges: list[tuple[int, int]] = []
241
+
242
+ if tool_loop_ranges:
243
+ # Add tool loop ranges as atomic units
244
+ merged_ranges.extend(tool_loop_ranges)
245
+
246
+ # Add non-tool-loop batch ranges
247
+ tool_loop_indices = set()
248
+ for loop_start, loop_end in tool_loop_ranges:
249
+ tool_loop_indices.update(range(loop_start, loop_end + 1))
250
+
251
+ for min_idx, max_idx, has_thinking in batch_ranges:
252
+ # Only add if not already covered by a tool loop
253
+ if min_idx not in tool_loop_indices:
254
+ merged_ranges.append((min_idx, max_idx))
255
+ else:
256
+ # No tool loops, just use regular batch ranges
257
+ merged_ranges = [(min_idx, max_idx) for min_idx, max_idx, _ in batch_ranges]
258
+
259
+ # Start with all possible indices (subtractive approach)
260
+ result_indices = set(range(len(self.events) + 1))
261
+
262
+ # Remove indices inside merged ranges (keep only boundaries)
263
+ for min_idx, max_idx in merged_ranges:
264
+ # Remove interior indices, keeping min_idx and max_idx+1 as boundaries
265
+ for idx in range(min_idx + 1, max_idx + 1):
266
+ result_indices.discard(idx)
267
+
268
+ return sorted(result_indices)
269
+
69
270
  # To preserve list-like indexing, we ideally support slicing and position-based
70
271
  # indexing. The only challenge with that is switching the return type based on the
71
272
  # input type -- we can mark the different signatures for MyPy with `@overload`
@@ -91,36 +292,41 @@ class View(BaseModel):
91
292
  @staticmethod
92
293
  def _enforce_batch_atomicity(
93
294
  events: Sequence[Event],
94
- forgotten_event_ids: set[EventID],
295
+ removed_event_ids: set[EventID],
95
296
  ) -> set[EventID]:
96
- """Ensure that if any event in a batch is forgotten, all events in that
97
- batch are forgotten.
297
+ """Ensure that if any ActionEvent in a batch is removed, all ActionEvents
298
+ in that batch are removed.
98
299
 
99
300
  This prevents partial batches from being sent to the LLM, which can cause
100
301
  API errors when thinking blocks are separated from their tool calls.
302
+
303
+ Args:
304
+ events: The original list of events
305
+ removed_event_ids: Set of event IDs that are being removed
306
+
307
+ Returns:
308
+ Updated set of event IDs that should be removed (including all
309
+ ActionEvents in batches where any ActionEvent was removed)
101
310
  """
102
- batches: dict[EventID, list[EventID]] = {}
103
- for event in events:
104
- if isinstance(event, ActionEvent):
105
- llm_response_id = event.llm_response_id
106
- if llm_response_id not in batches:
107
- batches[llm_response_id] = []
108
- batches[llm_response_id].append(event.id)
311
+ action_batch = ActionBatch.from_events(events)
312
+
313
+ if not action_batch.batches:
314
+ return removed_event_ids
109
315
 
110
- updated_forgotten_ids = set(forgotten_event_ids)
316
+ updated_removed_ids = set(removed_event_ids)
111
317
 
112
- for llm_response_id, batch_event_ids in batches.items():
113
- # Check if any event in this batch is being forgotten
114
- if any(event_id in forgotten_event_ids for event_id in batch_event_ids):
115
- # If so, forget all events in this batch
116
- updated_forgotten_ids.update(batch_event_ids)
318
+ for llm_response_id, batch_event_ids in action_batch.batches.items():
319
+ # Check if any ActionEvent in this batch is being removed
320
+ if any(event_id in removed_event_ids for event_id in batch_event_ids):
321
+ # If so, remove all ActionEvents in this batch
322
+ updated_removed_ids.update(batch_event_ids)
117
323
  logger.debug(
118
- f"Enforcing batch atomicity: forgetting entire batch "
324
+ f"Enforcing batch atomicity: removing entire batch "
119
325
  f"with llm_response_id={llm_response_id} "
120
326
  f"({len(batch_event_ids)} events)"
121
327
  )
122
328
 
123
- return updated_forgotten_ids
329
+ return updated_removed_ids
124
330
 
125
331
  @staticmethod
126
332
  def filter_unmatched_tool_calls(
@@ -129,18 +335,49 @@ class View(BaseModel):
129
335
  """Filter out unmatched tool call events.
130
336
 
131
337
  Removes ActionEvents and ObservationEvents that have tool_call_ids
132
- but don't have matching pairs.
338
+ but don't have matching pairs. Also enforces batch atomicity - if any
339
+ ActionEvent in a batch is filtered out, all ActionEvents in that batch
340
+ are also filtered out.
133
341
  """
134
342
  action_tool_call_ids = View._get_action_tool_call_ids(events)
135
343
  observation_tool_call_ids = View._get_observation_tool_call_ids(events)
136
344
 
137
- return [
138
- event
139
- for event in events
140
- if View._should_keep_event(
345
+ # Build batch info for batch atomicity enforcement
346
+ action_batch = ActionBatch.from_events(events)
347
+
348
+ # First pass: identify which events would NOT be kept based on matching
349
+ removed_event_ids: set[EventID] = set()
350
+ for event in events:
351
+ if not View._should_keep_event(
141
352
  event, action_tool_call_ids, observation_tool_call_ids
142
- )
143
- ]
353
+ ):
354
+ removed_event_ids.add(event.id)
355
+
356
+ # Second pass: enforce batch atomicity for ActionEvents
357
+ # If any ActionEvent in a batch is removed, all ActionEvents in that
358
+ # batch should also be removed
359
+ removed_event_ids = View._enforce_batch_atomicity(events, removed_event_ids)
360
+
361
+ # Third pass: also remove ObservationEvents whose ActionEvents were removed
362
+ # due to batch atomicity
363
+ tool_call_ids_to_remove: set[ToolCallID] = set()
364
+ for action_id in removed_event_ids:
365
+ if action_id in action_batch.action_id_to_tool_call_id:
366
+ tool_call_ids_to_remove.add(
367
+ action_batch.action_id_to_tool_call_id[action_id]
368
+ )
369
+
370
+ # Filter out removed events
371
+ result = []
372
+ for event in events:
373
+ if event.id in removed_event_ids:
374
+ continue
375
+ if isinstance(event, ObservationBaseEvent):
376
+ if event.tool_call_id in tool_call_ids_to_remove:
377
+ continue
378
+ result.append(event)
379
+
380
+ return result
144
381
 
145
382
  @staticmethod
146
383
  def _get_action_tool_call_ids(events: list[LLMConvertibleEvent]) -> set[ToolCallID]:
@@ -179,8 +416,31 @@ class View(BaseModel):
179
416
  else:
180
417
  return True
181
418
 
419
+ def find_next_manipulation_index(self, threshold: int, strict: bool = False) -> int:
420
+ """Find the smallest manipulation index greater than (or equal to) a threshold.
421
+
422
+ This is a helper method for condensation logic that needs to find safe
423
+ boundaries for forgetting events. Uses the cached manipulation_indices property.
424
+
425
+ Args:
426
+ threshold: The threshold value to compare against
427
+ strict: If True, finds index > threshold. If False, finds index >= threshold
428
+
429
+ Returns:
430
+ The smallest manipulation index that satisfies the condition, or the
431
+ threshold itself if no such index exists
432
+ """
433
+ for idx in self.manipulation_indices:
434
+ if strict:
435
+ if idx > threshold:
436
+ return idx
437
+ else:
438
+ if idx >= threshold:
439
+ return idx
440
+ return threshold
441
+
182
442
  @staticmethod
183
- def from_events(events: Sequence[Event]) -> "View":
443
+ def from_events(events: Sequence[Event]) -> View:
184
444
  """Create a view from a list of events, respecting the semantics of any
185
445
  condensation events.
186
446
  """
@@ -245,6 +245,23 @@ class BaseConversation(ABC):
245
245
  """
246
246
  ...
247
247
 
248
+ @abstractmethod
249
+ def condense(self) -> None:
250
+ """Force condensation of the conversation history.
251
+
252
+ This method uses the existing condensation request pattern to trigger
253
+ condensation. It adds a CondensationRequest event to the conversation
254
+ and forces the agent to take a single step to process it.
255
+
256
+ The condensation will be applied immediately and will modify the conversation
257
+ state by adding a condensation event to the history.
258
+
259
+ Raises:
260
+ ValueError: If no condenser is configured or the condenser doesn't
261
+ handle condensation requests.
262
+ """
263
+ ...
264
+
248
265
  @staticmethod
249
266
  def compose_callbacks(callbacks: Iterable[CallbackType]) -> CallbackType:
250
267
  """Compose multiple callbacks into a single callback function.