openhands-sdk 1.7.3__py3-none-any.whl → 1.8.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.
Files changed (44) hide show
  1. openhands/sdk/__init__.py +2 -0
  2. openhands/sdk/agent/agent.py +31 -1
  3. openhands/sdk/agent/base.py +111 -67
  4. openhands/sdk/agent/prompts/system_prompt.j2 +1 -1
  5. openhands/sdk/agent/utils.py +3 -0
  6. openhands/sdk/context/agent_context.py +45 -3
  7. openhands/sdk/context/condenser/__init__.py +2 -0
  8. openhands/sdk/context/condenser/base.py +59 -8
  9. openhands/sdk/context/condenser/llm_summarizing_condenser.py +38 -10
  10. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +4 -0
  11. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +9 -0
  12. openhands/sdk/context/skills/__init__.py +12 -0
  13. openhands/sdk/context/skills/skill.py +425 -228
  14. openhands/sdk/context/skills/types.py +4 -0
  15. openhands/sdk/context/skills/utils.py +442 -0
  16. openhands/sdk/context/view.py +2 -0
  17. openhands/sdk/conversation/impl/local_conversation.py +42 -14
  18. openhands/sdk/conversation/impl/remote_conversation.py +99 -55
  19. openhands/sdk/conversation/state.py +54 -18
  20. openhands/sdk/event/llm_convertible/action.py +20 -0
  21. openhands/sdk/git/utils.py +31 -6
  22. openhands/sdk/hooks/conversation_hooks.py +57 -10
  23. openhands/sdk/llm/llm.py +59 -76
  24. openhands/sdk/llm/options/chat_options.py +4 -1
  25. openhands/sdk/llm/router/base.py +12 -0
  26. openhands/sdk/llm/utils/telemetry.py +2 -2
  27. openhands/sdk/llm/utils/verified_models.py +1 -1
  28. openhands/sdk/mcp/tool.py +3 -1
  29. openhands/sdk/plugin/__init__.py +22 -0
  30. openhands/sdk/plugin/plugin.py +299 -0
  31. openhands/sdk/plugin/types.py +226 -0
  32. openhands/sdk/tool/__init__.py +7 -1
  33. openhands/sdk/tool/builtins/__init__.py +4 -0
  34. openhands/sdk/tool/schema.py +6 -3
  35. openhands/sdk/tool/tool.py +60 -9
  36. openhands/sdk/utils/models.py +198 -472
  37. openhands/sdk/workspace/base.py +22 -0
  38. openhands/sdk/workspace/local.py +16 -0
  39. openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
  40. openhands/sdk/workspace/remote/base.py +16 -0
  41. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/METADATA +2 -2
  42. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/RECORD +44 -40
  43. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/WHEEL +0 -0
  44. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/top_level.txt +0 -0
openhands/sdk/__init__.py CHANGED
@@ -41,6 +41,7 @@ from openhands.sdk.mcp import (
41
41
  MCPToolObservation,
42
42
  create_mcp_tools,
43
43
  )
44
+ from openhands.sdk.plugin import Plugin
44
45
  from openhands.sdk.tool import (
45
46
  Action,
46
47
  Observation,
@@ -98,6 +99,7 @@ __all__ = [
98
99
  "LLMSummarizingCondenser",
99
100
  "FileStore",
100
101
  "LocalFileStore",
102
+ "Plugin",
101
103
  "register_tool",
102
104
  "resolve_tool",
103
105
  "list_registered_tools",
@@ -27,7 +27,10 @@ from openhands.sdk.event import (
27
27
  TokenEvent,
28
28
  UserRejectObservation,
29
29
  )
30
- from openhands.sdk.event.condenser import Condensation, CondensationRequest
30
+ from openhands.sdk.event.condenser import (
31
+ Condensation,
32
+ CondensationRequest,
33
+ )
31
34
  from openhands.sdk.llm import (
32
35
  LLMResponse,
33
36
  Message,
@@ -359,6 +362,30 @@ class Agent(AgentBase):
359
362
  security_risk = risk.SecurityRisk(raw)
360
363
  return security_risk
361
364
 
365
+ def _extract_summary(self, tool_name: str, arguments: dict) -> str:
366
+ """Extract and validate the summary field from tool arguments.
367
+
368
+ Summary field is always requested but optional - if LLM doesn't provide
369
+ it or provides invalid data, we generate a default summary using the
370
+ tool name and arguments.
371
+
372
+ Args:
373
+ tool_name: Name of the tool being called
374
+ arguments: Dictionary of tool arguments from LLM
375
+
376
+ Returns:
377
+ The summary string - either from LLM or a default generated one
378
+ """
379
+ summary = arguments.pop("summary", None)
380
+
381
+ # If valid summary provided by LLM, use it
382
+ if summary is not None and isinstance(summary, str) and summary.strip():
383
+ return summary
384
+
385
+ # Generate default summary: {tool_name}: {arguments}
386
+ args_str = json.dumps(arguments)
387
+ return f"{tool_name}: {args_str}"
388
+
362
389
  def _get_action_event(
363
390
  self,
364
391
  tool_call: MessageToolCall,
@@ -420,6 +447,8 @@ class Agent(AgentBase):
420
447
  "Unexpected 'security_risk' key found in tool arguments"
421
448
  )
422
449
 
450
+ summary = self._extract_summary(tool.name, arguments)
451
+
423
452
  action: Action = tool.action_from_arguments(arguments)
424
453
  except (json.JSONDecodeError, ValidationError, ValueError) as e:
425
454
  err = (
@@ -459,6 +488,7 @@ class Agent(AgentBase):
459
488
  tool_call=tool_call,
460
489
  llm_response_id=llm_response_id,
461
490
  security_risk=security_risk,
491
+ summary=summary,
462
492
  )
463
493
  on_event(action_event)
464
494
  return action_event
@@ -2,22 +2,32 @@ import os
2
2
  import re
3
3
  import sys
4
4
  from abc import ABC, abstractmethod
5
- from collections.abc import Generator, Iterable
5
+ from collections.abc import Generator, Iterable, Sequence
6
6
  from concurrent.futures import ThreadPoolExecutor
7
7
  from typing import TYPE_CHECKING, Any
8
8
 
9
- from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
9
+ from pydantic import (
10
+ BaseModel,
11
+ ConfigDict,
12
+ Field,
13
+ PrivateAttr,
14
+ )
10
15
 
11
16
  from openhands.sdk.context.agent_context import AgentContext
12
- from openhands.sdk.context.condenser import CondenserBase, LLMSummarizingCondenser
17
+ from openhands.sdk.context.condenser import CondenserBase
13
18
  from openhands.sdk.context.prompts.prompt import render_template
14
19
  from openhands.sdk.llm import LLM
15
20
  from openhands.sdk.llm.utils.model_prompt_spec import get_model_prompt_spec
16
21
  from openhands.sdk.logger import get_logger
17
22
  from openhands.sdk.mcp import create_mcp_tools
18
- from openhands.sdk.tool import BUILT_IN_TOOLS, Tool, ToolDefinition, resolve_tool
23
+ from openhands.sdk.tool import (
24
+ BUILT_IN_TOOL_CLASSES,
25
+ BUILT_IN_TOOLS,
26
+ Tool,
27
+ ToolDefinition,
28
+ resolve_tool,
29
+ )
19
30
  from openhands.sdk.utils.models import DiscriminatedUnionMixin
20
- from openhands.sdk.utils.pydantic_diff import pretty_pydantic_diff
21
31
 
22
32
 
23
33
  if TYPE_CHECKING:
@@ -81,6 +91,17 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
81
91
  " added.",
82
92
  examples=["^(?!repomix)(.*)|^repomix.*pack_codebase.*$"],
83
93
  )
94
+ include_default_tools: list[str] = Field(
95
+ default_factory=lambda: [tool.__name__ for tool in BUILT_IN_TOOLS],
96
+ description=(
97
+ "List of default tool class names to include. By default, the agent "
98
+ "includes 'FinishTool' and 'ThinkTool'. Set to an empty list to disable "
99
+ "all default tools, or provide a subset to include only specific ones. "
100
+ "Example: include_default_tools=['FinishTool'] to only include FinishTool, "
101
+ "or include_default_tools=[] to disable all default tools."
102
+ ),
103
+ examples=[["FinishTool", "ThinkTool"], ["FinishTool"], []],
104
+ )
84
105
  agent_context: AgentContext | None = Field(
85
106
  default=None,
86
107
  description="Optional AgentContext to initialize "
@@ -89,7 +110,7 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
89
110
  {
90
111
  "skills": [
91
112
  {
92
- "name": "repo.md",
113
+ "name": "AGENTS.md",
93
114
  "content": "When you see this message, you should reply like "
94
115
  "you are a grumpy cat forced to use the internet.",
95
116
  "type": "repo",
@@ -155,6 +176,7 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
155
176
 
156
177
  # Runtime materialized tools; private and non-serializable
157
178
  _tools: dict[str, ToolDefinition] = PrivateAttr(default_factory=dict)
179
+ _initialized: bool = PrivateAttr(default=False)
158
180
 
159
181
  @property
160
182
  def prompt_dir(self) -> str:
@@ -219,7 +241,7 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
219
241
  def _initialize(self, state: "ConversationState"):
220
242
  """Create an AgentBase instance from an AgentSpec."""
221
243
 
222
- if self._tools:
244
+ if self._initialized:
223
245
  logger.warning("Agent already initialized; skipping re-initialization.")
224
246
  return
225
247
 
@@ -255,10 +277,17 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
255
277
  f"{[tool.name for tool in tools]}",
256
278
  )
257
279
 
258
- # Always include built-in tools; not subject to filtering
259
- # Instantiate built-in tools using their .create() method
260
- for tool_class in BUILT_IN_TOOLS:
261
- tools.extend(tool_class.create(state))
280
+ # Include default tools from include_default_tools; not subject to regex
281
+ # filtering. Use explicit mapping to resolve tool class names.
282
+ for tool_name in self.include_default_tools:
283
+ tool_class = BUILT_IN_TOOL_CLASSES.get(tool_name)
284
+ if tool_class is None:
285
+ raise ValueError(
286
+ f"Unknown built-in tool class: '{tool_name}'. "
287
+ f"Expected one of: {list(BUILT_IN_TOOL_CLASSES.keys())}"
288
+ )
289
+ tool_instances = tool_class.create(state)
290
+ tools.extend(tool_instances)
262
291
 
263
292
  # Check tool types
264
293
  for tool in tools:
@@ -276,6 +305,7 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
276
305
 
277
306
  # Store tools in a dict for easy access
278
307
  self._tools = {tool.name: tool for tool in tools}
308
+ self._initialized = True
279
309
 
280
310
  @abstractmethod
281
311
  def step(
@@ -300,71 +330,85 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
300
330
  NOTE: state will be mutated in-place.
301
331
  """
302
332
 
303
- def resolve_diff_from_deserialized(self, persisted: "AgentBase") -> "AgentBase":
304
- """
305
- Return a new AgentBase instance equivalent to `persisted` but with
306
- explicitly whitelisted fields (e.g. api_key) taken from `self`.
333
+ def verify(
334
+ self,
335
+ persisted: "AgentBase",
336
+ events: "Sequence[Any] | None" = None,
337
+ ) -> "AgentBase":
338
+ """Verify that we can resume this agent from persisted state.
339
+
340
+ This PR's goal is to *not* reconcile configuration between persisted and
341
+ runtime Agent instances. Instead, we verify compatibility requirements
342
+ and then continue with the runtime-provided Agent.
343
+
344
+ Compatibility requirements:
345
+ - Agent class/type must match.
346
+ - Tools:
347
+ - If events are provided, only tools that were actually used in history
348
+ must exist in runtime.
349
+ - If events are not provided, tool names must match exactly.
350
+
351
+ All other configuration (LLM, agent_context, condenser, system prompts,
352
+ etc.) can be freely changed between sessions.
353
+
354
+ Args:
355
+ persisted: The agent loaded from persisted state.
356
+ events: Optional event sequence to scan for used tools if tool names
357
+ don't match.
358
+
359
+ Returns:
360
+ This runtime agent (self) if verification passes.
361
+
362
+ Raises:
363
+ ValueError: If agent class or tools don't match.
307
364
  """
308
365
  if persisted.__class__ is not self.__class__:
309
366
  raise ValueError(
310
- f"Cannot resolve from deserialized: persisted agent is of type "
367
+ "Cannot load from persisted: persisted agent is of type "
311
368
  f"{persisted.__class__.__name__}, but self is of type "
312
369
  f"{self.__class__.__name__}."
313
370
  )
314
371
 
315
- # Get all LLMs from both self and persisted to reconcile them
316
- new_llm = self.llm.resolve_diff_from_deserialized(persisted.llm)
317
- updates: dict[str, Any] = {"llm": new_llm}
372
+ runtime_names = {tool.name for tool in self.tools}
373
+ persisted_names = {tool.name for tool in persisted.tools}
318
374
 
319
- # Reconcile the condenser's LLM if it exists
320
- if self.condenser is not None and persisted.condenser is not None:
321
- # Check if both condensers are LLMSummarizingCondenser
322
- # (which has an llm field)
375
+ if runtime_names == persisted_names:
376
+ return self
323
377
 
324
- if isinstance(self.condenser, LLMSummarizingCondenser) and isinstance(
325
- persisted.condenser, LLMSummarizingCondenser
326
- ):
327
- new_condenser_llm = self.condenser.llm.resolve_diff_from_deserialized(
328
- persisted.condenser.llm
329
- )
330
- new_condenser = persisted.condenser.model_copy(
331
- update={"llm": new_condenser_llm}
378
+ if events is not None:
379
+ from openhands.sdk.event import ActionEvent
380
+
381
+ used_tools = {
382
+ event.tool_name
383
+ for event in events
384
+ if isinstance(event, ActionEvent) and event.tool_name
385
+ }
386
+
387
+ # Only require tools that were actually used in history.
388
+ missing_used_tools = used_tools - runtime_names
389
+ if missing_used_tools:
390
+ raise ValueError(
391
+ "Cannot resume conversation: tools that were used in history "
392
+ f"are missing from runtime: {sorted(missing_used_tools)}. "
393
+ f"Available tools: {sorted(runtime_names)}"
332
394
  )
333
- updates["condenser"] = new_condenser
334
-
335
- # Reconcile agent_context - always use the current environment's agent_context
336
- # This allows resuming conversations from different directories and handles
337
- # cases where skills, working directory, or other context has changed
338
- if self.agent_context is not None:
339
- updates["agent_context"] = self.agent_context
340
-
341
- # Create maps by tool name for easy lookup
342
- runtime_tools_map = {tool.name: tool for tool in self.tools}
343
- persisted_tools_map = {tool.name: tool for tool in persisted.tools}
344
-
345
- # Check that tool names match
346
- runtime_names = set(runtime_tools_map.keys())
347
- persisted_names = set(persisted_tools_map.keys())
348
-
349
- if runtime_names != persisted_names:
350
- missing_in_runtime = persisted_names - runtime_names
351
- missing_in_persisted = runtime_names - persisted_names
352
- error_msg = "Tools don't match between runtime and persisted agents."
353
- if missing_in_runtime:
354
- error_msg += f" Missing in runtime: {missing_in_runtime}."
355
- if missing_in_persisted:
356
- error_msg += f" Missing in persisted: {missing_in_persisted}."
357
- raise ValueError(error_msg)
358
-
359
- reconciled = persisted.model_copy(update=updates)
360
- if self.model_dump(exclude_none=True) != reconciled.model_dump(
361
- exclude_none=True
362
- ):
363
- raise ValueError(
364
- "The Agent provided is different from the one in persisted state.\n"
365
- f"Diff: {pretty_pydantic_diff(self, reconciled)}"
366
- )
367
- return reconciled
395
+
396
+ return self
397
+
398
+ # No events provided: strict tool name matching.
399
+ missing_in_runtime = persisted_names - runtime_names
400
+ missing_in_persisted = runtime_names - persisted_names
401
+
402
+ details: list[str] = []
403
+ if missing_in_runtime:
404
+ details.append(f"Missing in runtime: {sorted(missing_in_runtime)}")
405
+ if missing_in_persisted:
406
+ details.append(f"Missing in persisted: {sorted(missing_in_persisted)}")
407
+
408
+ suffix = f" ({'; '.join(details)})" if details else ""
409
+ raise ValueError(
410
+ "Tools don't match between runtime and persisted agents." + suffix
411
+ )
368
412
 
369
413
  def model_dump_succint(self, **kwargs):
370
414
  """Like model_dump, but excludes None fields by default."""
@@ -452,6 +496,6 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
452
496
  Raises:
453
497
  RuntimeError: If the agent has not been initialized.
454
498
  """
455
- if not self._tools:
499
+ if not self._initialized:
456
500
  raise RuntimeError("Agent not initialized; call initialize() before use")
457
501
  return self._tools
@@ -6,7 +6,7 @@ You are OpenHands agent, a helpful AI assistant that can interact with a compute
6
6
  </ROLE>
7
7
 
8
8
  <MEMORY>
9
- * Use `.openhands/skills/repo.md` under the repository root as your persistent memory for repository-specific knowledge and context.
9
+ * Use `AGENTS.md` under the repository root as your persistent memory for repository-specific knowledge and context.
10
10
  * Add important insights, patterns, and learnings to this file to improve future task performance.
11
11
  * This repository skill is automatically loaded for every conversation and helps maintain context across sessions.
12
12
  * For more information about skills, see: https://docs.openhands.dev/overview/skills
@@ -209,6 +209,9 @@ def make_llm_completion(
209
209
  configured. This allows weaker models to omit risk field and bypass
210
210
  validation requirements when analyzer is disabled. For detailed logic,
211
211
  see `_extract_security_risk` method in agent.py.
212
+
213
+ Summary field is always added to tool schemas for transparency and
214
+ explainability of agent actions.
212
215
  """
213
216
  if llm.uses_responses_api():
214
217
  return llm.responses(
@@ -11,6 +11,7 @@ from openhands.sdk.context.skills import (
11
11
  SkillKnowledge,
12
12
  load_public_skills,
13
13
  load_user_skills,
14
+ to_prompt,
14
15
  )
15
16
  from openhands.sdk.llm import Message, TextContent
16
17
  from openhands.sdk.llm.utils.model_prompt_spec import get_model_prompt_spec
@@ -167,8 +168,32 @@ class AgentContext(BaseModel):
167
168
  - Runtime information (e.g., available hosts, current date)
168
169
  - Conversation instructions (e.g., user preferences, task details)
169
170
  - Repository-specific instructions (collected from repo skills)
171
+ - Available skills list (for AgentSkills-format and triggered skills)
172
+
173
+ Skill categorization:
174
+ - AgentSkills-format (SKILL.md): Always in <available_skills> (progressive
175
+ disclosure). If has triggers, content is ALSO auto-injected on trigger
176
+ in user prompts.
177
+ - Legacy with trigger=None: Full content in <REPO_CONTEXT> (always active)
178
+ - Legacy with triggers: Listed in <available_skills>, injected on trigger
170
179
  """
171
- repo_skills = [s for s in self.skills if s.trigger is None]
180
+ # Categorize skills based on format and trigger:
181
+ # - AgentSkills-format: always in available_skills (progressive disclosure)
182
+ # - Legacy: trigger=None -> REPO_CONTEXT, else -> available_skills
183
+ repo_skills: list[Skill] = []
184
+ available_skills: list[Skill] = []
185
+
186
+ for s in self.skills:
187
+ if s.is_agentskills_format:
188
+ # AgentSkills: always list (triggers also auto-inject via
189
+ # get_user_message_suffix)
190
+ available_skills.append(s)
191
+ elif s.trigger is None:
192
+ # Legacy OpenHands: no trigger = full content in REPO_CONTEXT
193
+ repo_skills.append(s)
194
+ else:
195
+ # Legacy OpenHands: has trigger = list in available_skills
196
+ available_skills.append(s)
172
197
 
173
198
  # Gate vendor-specific repo skills based on model family.
174
199
  if llm_model or llm_model_canonical:
@@ -189,16 +214,32 @@ class AgentContext(BaseModel):
189
214
  filtered.append(s)
190
215
  repo_skills = filtered
191
216
 
192
- logger.debug(f"Triggered {len(repo_skills)} repository skills: {repo_skills}")
217
+ logger.debug(f"Loaded {len(repo_skills)} repository skills: {repo_skills}")
218
+
219
+ # Generate available skills prompt
220
+ available_skills_prompt = ""
221
+ if available_skills:
222
+ available_skills_prompt = to_prompt(available_skills)
223
+ logger.debug(
224
+ f"Generated available skills prompt for {len(available_skills)} skills"
225
+ )
226
+
193
227
  # Build the workspace context information
194
228
  secret_infos = self.get_secret_infos()
195
- if repo_skills or self.system_message_suffix or secret_infos:
229
+ has_content = (
230
+ repo_skills
231
+ or self.system_message_suffix
232
+ or secret_infos
233
+ or available_skills_prompt
234
+ )
235
+ if has_content:
196
236
  formatted_text = render_template(
197
237
  prompt_dir=str(PROMPT_DIR),
198
238
  template_name="system_message_suffix.j2",
199
239
  repo_skills=repo_skills,
200
240
  system_message_suffix=self.system_message_suffix or "",
201
241
  secret_infos=secret_infos,
242
+ available_skills_prompt=available_skills_prompt,
202
243
  ).strip()
203
244
  return formatted_text
204
245
  elif self.system_message_suffix and self.system_message_suffix.strip():
@@ -245,6 +286,7 @@ class AgentContext(BaseModel):
245
286
  name=skill.name,
246
287
  trigger=trigger,
247
288
  content=skill.content,
289
+ location=skill.source,
248
290
  )
249
291
  )
250
292
  if recalled_knowledge:
@@ -1,5 +1,6 @@
1
1
  from openhands.sdk.context.condenser.base import (
2
2
  CondenserBase,
3
+ NoCondensationAvailableException,
3
4
  RollingCondenser,
4
5
  )
5
6
  from openhands.sdk.context.condenser.llm_summarizing_condenser import (
@@ -15,4 +16,5 @@ __all__ = [
15
16
  "NoOpCondenser",
16
17
  "PipelineCondenser",
17
18
  "LLMSummarizingCondenser",
19
+ "NoCondensationAvailableException",
18
20
  ]
@@ -1,4 +1,5 @@
1
1
  from abc import ABC, abstractmethod
2
+ from enum import Enum
2
3
  from logging import getLogger
3
4
 
4
5
  from openhands.sdk.context.view import View
@@ -66,6 +67,29 @@ class PipelinableCondenserBase(CondenserBase):
66
67
  condenser should not nest another pipeline condenser)"""
67
68
 
68
69
 
70
+ class NoCondensationAvailableException(Exception):
71
+ """Raised when a condenser is asked to provide a condensation but none is available.
72
+
73
+ This can happen if the condenser's `should_condense` method returns True, but due to
74
+ API constraints no condensation can be generated.
75
+
76
+ When this exception is raised from a rolling condenser's `get_condensation` method,
77
+ the agent will fall back to using the uncondensed view for the next agent step.
78
+ """
79
+
80
+
81
+ class CondensationRequirement(Enum):
82
+ """The type of condensation required by a rolling condenser."""
83
+
84
+ HARD = "hard"
85
+ """Indicates that a condensation is required right now, and the agent cannot proceed
86
+ without it.
87
+ """
88
+
89
+ SOFT = "soft"
90
+ """Indicates that a condensation is desired but not strictly required."""
91
+
92
+
69
93
  class RollingCondenser(PipelinableCondenserBase, ABC):
70
94
  """Base class for a specialized condenser strategy that applies condensation to a
71
95
  rolling history.
@@ -73,15 +97,27 @@ class RollingCondenser(PipelinableCondenserBase, ABC):
73
97
  The rolling history is generated by `View.from_events`, which analyzes all events in
74
98
  the history and produces a `View` object representing what will be sent to the LLM.
75
99
 
76
- If `should_condense` says so, the condenser is then responsible for generating a
77
- `Condensation` object from the `View` object. This will be added to the event
78
- history which should -- when given to `get_view` -- produce the condensed `View` to
79
- be passed to the LLM.
100
+ If `condensation_requirement` says so, the condenser is then responsible for
101
+ generating a `Condensation` object from the `View` object. This will be added to the
102
+ event history which should -- when given to `get_view` -- produce the condensed
103
+ `View` to be passed to the LLM.
80
104
  """
81
105
 
82
106
  @abstractmethod
83
- def should_condense(self, view: View, agent_llm: LLM | None = None) -> bool:
84
- """Determine if a view should be condensed."""
107
+ def condensation_requirement(
108
+ self, view: View, agent_llm: LLM | None = None
109
+ ) -> CondensationRequirement | None:
110
+ """Determine how a view should be condensed.
111
+
112
+ Args:
113
+ view: The current view of the conversation history.
114
+ agent_llm: LLM instance used by the agent. Condensers use this for token
115
+ counting purposes. Defaults to None.
116
+
117
+ Returns:
118
+ CondensationRequirement | None: The type of condensation required, or None
119
+ if no condensation is needed.
120
+ """
85
121
 
86
122
  @abstractmethod
87
123
  def get_condensation(
@@ -92,8 +128,23 @@ class RollingCondenser(PipelinableCondenserBase, ABC):
92
128
  def condense(self, view: View, agent_llm: LLM | None = None) -> View | Condensation:
93
129
  # If we trigger the condenser-specific condensation threshold, compute and
94
130
  # return the condensation.
95
- if self.should_condense(view, agent_llm=agent_llm):
96
- return self.get_condensation(view, agent_llm=agent_llm)
131
+ request = self.condensation_requirement(view, agent_llm=agent_llm)
132
+ if request is not None:
133
+ try:
134
+ return self.get_condensation(view, agent_llm=agent_llm)
135
+
136
+ except NoCondensationAvailableException as e:
137
+ logger.debug(f"No condensation available: {e}")
138
+
139
+ if request == CondensationRequirement.SOFT:
140
+ # For soft requests, we can just return the uncondensed view. This
141
+ # request will _eventually_ be handled, but it's not critical that
142
+ # we do so immediately.
143
+ return view
144
+
145
+ # Otherwise re-raise the exception.
146
+ else:
147
+ raise e
97
148
 
98
149
  # Otherwise we're safe to just return the view.
99
150
  else:
@@ -4,7 +4,11 @@ from enum import Enum
4
4
 
5
5
  from pydantic import Field, model_validator
6
6
 
7
- from openhands.sdk.context.condenser.base import RollingCondenser
7
+ from openhands.sdk.context.condenser.base import (
8
+ CondensationRequirement,
9
+ NoCondensationAvailableException,
10
+ RollingCondenser,
11
+ )
8
12
  from openhands.sdk.context.condenser.utils import (
9
13
  get_suffix_length_for_token_reduction,
10
14
  get_total_token_count,
@@ -84,9 +88,30 @@ class LLMSummarizingCondenser(RollingCondenser):
84
88
 
85
89
  return reasons
86
90
 
87
- def should_condense(self, view: View, agent_llm: LLM | None = None) -> bool:
91
+ def condensation_requirement(
92
+ self, view: View, agent_llm: LLM | None = None
93
+ ) -> CondensationRequirement | None:
88
94
  reasons = self.get_condensation_reasons(view, agent_llm)
89
- return reasons != set()
95
+
96
+ # No reasons => no condensation needed.
97
+ if reasons == set():
98
+ return None
99
+
100
+ # If the reasons are for resource constraints, we can treat it as a soft
101
+ # requirement. We want to condense when we can, but there's still space in the
102
+ # context window or we'd also see Reason.REQUEST. That means we can delay the
103
+ # condensation if there isn't one available (based on the view's manipulation
104
+ # indices).
105
+ resource_reasons = {Reason.TOKENS, Reason.EVENTS}
106
+ if reasons.issubset(resource_reasons):
107
+ return CondensationRequirement.SOFT
108
+
109
+ # Requests -- whether they come from the user or the agent -- are always hard
110
+ # requirements. We need to condense now because:
111
+ # 1. the user expects it
112
+ # 2. the agent has no more room in the context window and can't continue
113
+ if Reason.REQUEST in reasons:
114
+ return CondensationRequirement.HARD
90
115
 
91
116
  def _get_summary_event_content(self, view: View) -> str:
92
117
  """Extract the text content from the summary event in the view, if any.
@@ -124,12 +149,7 @@ class LLMSummarizingCondenser(RollingCondenser):
124
149
  Raises:
125
150
  ValueError: If forgotten_events is empty (0 events to condense).
126
151
  """
127
- if len(forgotten_events) == 0:
128
- raise ValueError(
129
- "Cannot condense 0 events. This typically occurs when a tool loop "
130
- "spans almost the entire view, leaving no valid range for forgetting "
131
- "events. Consider adjusting keep_first or max_size parameters."
132
- )
152
+ assert len(forgotten_events) > 0, "No events to condense."
133
153
 
134
154
  # Convert events to strings for the template
135
155
  event_strings = [str(forgotten_event) for forgotten_event in forgotten_events]
@@ -236,11 +256,19 @@ class LLMSummarizingCondenser(RollingCondenser):
236
256
  ) -> Condensation:
237
257
  # The condensation is dependent on the events we want to drop and the previous
238
258
  # summary.
239
- summary_event_content = self._get_summary_event_content(view)
240
259
  forgotten_events, summary_offset = self._get_forgotten_events(
241
260
  view, agent_llm=agent_llm
242
261
  )
243
262
 
263
+ if not forgotten_events:
264
+ raise NoCondensationAvailableException(
265
+ "Cannot condense 0 events. This typically occurs when a tool loop "
266
+ "spans almost the entire view, leaving no valid range for forgetting "
267
+ "events. Consider adjusting keep_first or max_size parameters."
268
+ )
269
+
270
+ summary_event_content = self._get_summary_event_content(view)
271
+
244
272
  return self._generate_condensation(
245
273
  summary_event_content=summary_event_content,
246
274
  forgotten_events=forgotten_events,
@@ -2,6 +2,10 @@
2
2
  <EXTRA_INFO>
3
3
  The following information has been included based on a keyword match for "{{ agent_info.trigger }}".
4
4
  It may or may not be relevant to the user's request.
5
+ {% if agent_info.location %}
6
+ Skill location: {{ agent_info.location }}
7
+ (Use this path to resolve relative file references in the skill content below)
8
+ {% endif %}
5
9
 
6
10
  {{ agent_info.content }}
7
11
  </EXTRA_INFO>
@@ -10,6 +10,15 @@ Please follow them while working.
10
10
  {% endfor %}
11
11
  </REPO_CONTEXT>
12
12
  {% endif %}
13
+ {% if available_skills_prompt %}
14
+ <SKILLS>
15
+ The following skills are available and may be triggered by keywords or task types in your messages.
16
+ When a skill is triggered, you will receive additional context and instructions.
17
+ You can also directly look up a skill's full content by reading its location path, and use the skill's guidance proactively when relevant to your task.
18
+
19
+ {{ available_skills_prompt }}
20
+ </SKILLS>
21
+ {% endif %}
13
22
  {% if system_message_suffix %}
14
23
 
15
24
  {{ system_message_suffix }}