openhands-sdk 1.7.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 (172) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +607 -0
  4. openhands/sdk/agent/base.py +454 -0
  5. openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
  6. openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
  7. openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
  8. openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
  9. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +3 -0
  10. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
  11. openhands/sdk/agent/prompts/security_policy.j2 +22 -0
  12. openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
  13. openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
  14. openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
  15. openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
  16. openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
  17. openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
  18. openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
  19. openhands/sdk/agent/utils.py +223 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +240 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +95 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +89 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +13 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +55 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/prompts/__init__.py +6 -0
  29. openhands/sdk/context/prompts/prompt.py +114 -0
  30. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  31. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  32. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  33. openhands/sdk/context/skills/__init__.py +28 -0
  34. openhands/sdk/context/skills/exceptions.py +11 -0
  35. openhands/sdk/context/skills/skill.py +630 -0
  36. openhands/sdk/context/skills/trigger.py +36 -0
  37. openhands/sdk/context/skills/types.py +48 -0
  38. openhands/sdk/context/view.py +306 -0
  39. openhands/sdk/conversation/__init__.py +40 -0
  40. openhands/sdk/conversation/base.py +281 -0
  41. openhands/sdk/conversation/conversation.py +146 -0
  42. openhands/sdk/conversation/conversation_stats.py +85 -0
  43. openhands/sdk/conversation/event_store.py +157 -0
  44. openhands/sdk/conversation/events_list_base.py +17 -0
  45. openhands/sdk/conversation/exceptions.py +50 -0
  46. openhands/sdk/conversation/fifo_lock.py +133 -0
  47. openhands/sdk/conversation/impl/__init__.py +5 -0
  48. openhands/sdk/conversation/impl/local_conversation.py +620 -0
  49. openhands/sdk/conversation/impl/remote_conversation.py +883 -0
  50. openhands/sdk/conversation/persistence_const.py +9 -0
  51. openhands/sdk/conversation/response_utils.py +41 -0
  52. openhands/sdk/conversation/secret_registry.py +126 -0
  53. openhands/sdk/conversation/serialization_diff.py +0 -0
  54. openhands/sdk/conversation/state.py +352 -0
  55. openhands/sdk/conversation/stuck_detector.py +311 -0
  56. openhands/sdk/conversation/title_utils.py +191 -0
  57. openhands/sdk/conversation/types.py +45 -0
  58. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  59. openhands/sdk/conversation/visualizer/base.py +67 -0
  60. openhands/sdk/conversation/visualizer/default.py +373 -0
  61. openhands/sdk/critic/__init__.py +15 -0
  62. openhands/sdk/critic/base.py +38 -0
  63. openhands/sdk/critic/impl/__init__.py +12 -0
  64. openhands/sdk/critic/impl/agent_finished.py +83 -0
  65. openhands/sdk/critic/impl/empty_patch.py +49 -0
  66. openhands/sdk/critic/impl/pass_critic.py +42 -0
  67. openhands/sdk/event/__init__.py +42 -0
  68. openhands/sdk/event/base.py +149 -0
  69. openhands/sdk/event/condenser.py +82 -0
  70. openhands/sdk/event/conversation_error.py +25 -0
  71. openhands/sdk/event/conversation_state.py +104 -0
  72. openhands/sdk/event/llm_completion_log.py +39 -0
  73. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  74. openhands/sdk/event/llm_convertible/action.py +139 -0
  75. openhands/sdk/event/llm_convertible/message.py +142 -0
  76. openhands/sdk/event/llm_convertible/observation.py +141 -0
  77. openhands/sdk/event/llm_convertible/system.py +61 -0
  78. openhands/sdk/event/token.py +16 -0
  79. openhands/sdk/event/types.py +11 -0
  80. openhands/sdk/event/user_action.py +21 -0
  81. openhands/sdk/git/exceptions.py +43 -0
  82. openhands/sdk/git/git_changes.py +249 -0
  83. openhands/sdk/git/git_diff.py +129 -0
  84. openhands/sdk/git/models.py +21 -0
  85. openhands/sdk/git/utils.py +189 -0
  86. openhands/sdk/io/__init__.py +6 -0
  87. openhands/sdk/io/base.py +48 -0
  88. openhands/sdk/io/local.py +82 -0
  89. openhands/sdk/io/memory.py +54 -0
  90. openhands/sdk/llm/__init__.py +45 -0
  91. openhands/sdk/llm/exceptions/__init__.py +45 -0
  92. openhands/sdk/llm/exceptions/classifier.py +50 -0
  93. openhands/sdk/llm/exceptions/mapping.py +54 -0
  94. openhands/sdk/llm/exceptions/types.py +101 -0
  95. openhands/sdk/llm/llm.py +1140 -0
  96. openhands/sdk/llm/llm_registry.py +122 -0
  97. openhands/sdk/llm/llm_response.py +59 -0
  98. openhands/sdk/llm/message.py +656 -0
  99. openhands/sdk/llm/mixins/fn_call_converter.py +1243 -0
  100. openhands/sdk/llm/mixins/non_native_fc.py +93 -0
  101. openhands/sdk/llm/options/__init__.py +1 -0
  102. openhands/sdk/llm/options/chat_options.py +93 -0
  103. openhands/sdk/llm/options/common.py +19 -0
  104. openhands/sdk/llm/options/responses_options.py +67 -0
  105. openhands/sdk/llm/router/__init__.py +10 -0
  106. openhands/sdk/llm/router/base.py +117 -0
  107. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  108. openhands/sdk/llm/router/impl/random.py +22 -0
  109. openhands/sdk/llm/streaming.py +9 -0
  110. openhands/sdk/llm/utils/metrics.py +312 -0
  111. openhands/sdk/llm/utils/model_features.py +191 -0
  112. openhands/sdk/llm/utils/model_info.py +90 -0
  113. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  114. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  115. openhands/sdk/llm/utils/telemetry.py +362 -0
  116. openhands/sdk/llm/utils/unverified_models.py +156 -0
  117. openhands/sdk/llm/utils/verified_models.py +66 -0
  118. openhands/sdk/logger/__init__.py +22 -0
  119. openhands/sdk/logger/logger.py +195 -0
  120. openhands/sdk/logger/rolling.py +113 -0
  121. openhands/sdk/mcp/__init__.py +24 -0
  122. openhands/sdk/mcp/client.py +76 -0
  123. openhands/sdk/mcp/definition.py +106 -0
  124. openhands/sdk/mcp/exceptions.py +19 -0
  125. openhands/sdk/mcp/tool.py +270 -0
  126. openhands/sdk/mcp/utils.py +83 -0
  127. openhands/sdk/observability/__init__.py +4 -0
  128. openhands/sdk/observability/laminar.py +166 -0
  129. openhands/sdk/observability/utils.py +20 -0
  130. openhands/sdk/py.typed +0 -0
  131. openhands/sdk/secret/__init__.py +19 -0
  132. openhands/sdk/secret/secrets.py +92 -0
  133. openhands/sdk/security/__init__.py +6 -0
  134. openhands/sdk/security/analyzer.py +111 -0
  135. openhands/sdk/security/confirmation_policy.py +61 -0
  136. openhands/sdk/security/llm_analyzer.py +29 -0
  137. openhands/sdk/security/risk.py +100 -0
  138. openhands/sdk/tool/__init__.py +34 -0
  139. openhands/sdk/tool/builtins/__init__.py +34 -0
  140. openhands/sdk/tool/builtins/finish.py +106 -0
  141. openhands/sdk/tool/builtins/think.py +117 -0
  142. openhands/sdk/tool/registry.py +161 -0
  143. openhands/sdk/tool/schema.py +276 -0
  144. openhands/sdk/tool/spec.py +39 -0
  145. openhands/sdk/tool/tool.py +481 -0
  146. openhands/sdk/utils/__init__.py +22 -0
  147. openhands/sdk/utils/async_executor.py +115 -0
  148. openhands/sdk/utils/async_utils.py +39 -0
  149. openhands/sdk/utils/cipher.py +68 -0
  150. openhands/sdk/utils/command.py +90 -0
  151. openhands/sdk/utils/deprecation.py +166 -0
  152. openhands/sdk/utils/github.py +44 -0
  153. openhands/sdk/utils/json.py +48 -0
  154. openhands/sdk/utils/models.py +570 -0
  155. openhands/sdk/utils/paging.py +63 -0
  156. openhands/sdk/utils/pydantic_diff.py +85 -0
  157. openhands/sdk/utils/pydantic_secrets.py +64 -0
  158. openhands/sdk/utils/truncate.py +117 -0
  159. openhands/sdk/utils/visualize.py +58 -0
  160. openhands/sdk/workspace/__init__.py +17 -0
  161. openhands/sdk/workspace/base.py +158 -0
  162. openhands/sdk/workspace/local.py +189 -0
  163. openhands/sdk/workspace/models.py +35 -0
  164. openhands/sdk/workspace/remote/__init__.py +8 -0
  165. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  166. openhands/sdk/workspace/remote/base.py +164 -0
  167. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  168. openhands/sdk/workspace/workspace.py +49 -0
  169. openhands_sdk-1.7.0.dist-info/METADATA +17 -0
  170. openhands_sdk-1.7.0.dist-info/RECORD +172 -0
  171. openhands_sdk-1.7.0.dist-info/WHEEL +5 -0
  172. openhands_sdk-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ from collections.abc import Mapping
5
+
6
+ from pydantic import BaseModel, Field, field_validator, model_validator
7
+
8
+ from openhands.sdk.context.prompts import render_template
9
+ from openhands.sdk.context.skills import (
10
+ Skill,
11
+ SkillKnowledge,
12
+ load_public_skills,
13
+ load_user_skills,
14
+ )
15
+ from openhands.sdk.llm import Message, TextContent
16
+ from openhands.sdk.logger import get_logger
17
+ from openhands.sdk.secret import SecretSource, SecretValue
18
+
19
+
20
+ logger = get_logger(__name__)
21
+
22
+ PROMPT_DIR = pathlib.Path(__file__).parent / "prompts" / "templates"
23
+
24
+
25
+ class AgentContext(BaseModel):
26
+ """Central structure for managing prompt extension.
27
+
28
+ AgentContext unifies all the contextual inputs that shape how the system
29
+ extends and interprets user prompts. It combines both static environment
30
+ details and dynamic, user-activated extensions from skills.
31
+
32
+ Specifically, it provides:
33
+ - **Repository context / Repo Skills**: Information about the active codebase,
34
+ branches, and repo-specific instructions contributed by repo skills.
35
+ - **Runtime context**: Current execution environment (hosts, working
36
+ directory, secrets, date, etc.).
37
+ - **Conversation instructions**: Optional task- or channel-specific rules
38
+ that constrain or guide the agent’s behavior across the session.
39
+ - **Knowledge Skills**: Extensible components that can be triggered by user input
40
+ to inject knowledge or domain-specific guidance.
41
+
42
+ Together, these elements make AgentContext the primary container responsible
43
+ for assembling, formatting, and injecting all prompt-relevant context into
44
+ LLM interactions.
45
+ """ # noqa: E501
46
+
47
+ skills: list[Skill] = Field(
48
+ default_factory=list,
49
+ description="List of available skills that can extend the user's input.",
50
+ )
51
+ system_message_suffix: str | None = Field(
52
+ default=None, description="Optional suffix to append to the system prompt."
53
+ )
54
+ user_message_suffix: str | None = Field(
55
+ default=None, description="Optional suffix to append to the user's message."
56
+ )
57
+ load_user_skills: bool = Field(
58
+ default=False,
59
+ description=(
60
+ "Whether to automatically load user skills from ~/.openhands/skills/ "
61
+ "and ~/.openhands/microagents/ (for backward compatibility). "
62
+ ),
63
+ )
64
+ load_public_skills: bool = Field(
65
+ default=False,
66
+ description=(
67
+ "Whether to automatically load skills from the public OpenHands "
68
+ "skills repository at https://github.com/OpenHands/skills. "
69
+ "This allows you to get the latest skills without SDK updates."
70
+ ),
71
+ )
72
+ secrets: Mapping[str, SecretValue] | None = Field(
73
+ default=None,
74
+ description=(
75
+ "Dictionary mapping secret keys to values or secret sources. "
76
+ "Secrets are used for authentication and sensitive data handling. "
77
+ "Values can be either strings or SecretSource instances "
78
+ "(str | SecretSource)."
79
+ ),
80
+ )
81
+
82
+ @field_validator("skills")
83
+ @classmethod
84
+ def _validate_skills(cls, v: list[Skill], _info):
85
+ if not v:
86
+ return v
87
+ # Check for duplicate skill names
88
+ seen_names = set()
89
+ for skill in v:
90
+ if skill.name in seen_names:
91
+ raise ValueError(f"Duplicate skill name found: {skill.name}")
92
+ seen_names.add(skill.name)
93
+ return v
94
+
95
+ @model_validator(mode="after")
96
+ def _load_user_skills(self):
97
+ """Load user skills from home directory if enabled."""
98
+ if not self.load_user_skills:
99
+ return self
100
+
101
+ try:
102
+ user_skills = load_user_skills()
103
+ # Merge user skills with explicit skills, avoiding duplicates
104
+ existing_names = {skill.name for skill in self.skills}
105
+ for user_skill in user_skills:
106
+ if user_skill.name not in existing_names:
107
+ self.skills.append(user_skill)
108
+ else:
109
+ logger.warning(
110
+ f"Skipping user skill '{user_skill.name}' "
111
+ f"(already in explicit skills)"
112
+ )
113
+ except Exception as e:
114
+ logger.warning(f"Failed to load user skills: {str(e)}")
115
+
116
+ return self
117
+
118
+ @model_validator(mode="after")
119
+ def _load_public_skills(self):
120
+ """Load public skills from OpenHands skills repository if enabled."""
121
+ if not self.load_public_skills:
122
+ return self
123
+ try:
124
+ public_skills = load_public_skills()
125
+ # Merge public skills with explicit skills, avoiding duplicates
126
+ existing_names = {skill.name for skill in self.skills}
127
+ for public_skill in public_skills:
128
+ if public_skill.name not in existing_names:
129
+ self.skills.append(public_skill)
130
+ else:
131
+ logger.warning(
132
+ f"Skipping public skill '{public_skill.name}' "
133
+ f"(already in existing skills)"
134
+ )
135
+ except Exception as e:
136
+ logger.warning(f"Failed to load public skills: {str(e)}")
137
+ return self
138
+
139
+ def get_secret_infos(self) -> list[dict[str, str]]:
140
+ """Get secret information (name and description) from the secrets field.
141
+
142
+ Returns:
143
+ List of dictionaries with 'name' and 'description' keys.
144
+ Returns an empty list if no secrets are configured.
145
+ Description will be None if not available.
146
+ """
147
+ if not self.secrets:
148
+ return []
149
+ secret_infos = []
150
+ for name, secret_value in self.secrets.items():
151
+ description = None
152
+ if isinstance(secret_value, SecretSource):
153
+ description = secret_value.description
154
+ secret_infos.append({"name": name, "description": description})
155
+ return secret_infos
156
+
157
+ def get_system_message_suffix(self) -> str | None:
158
+ """Get the system message with repo skill content and custom suffix.
159
+
160
+ Custom suffix can typically includes:
161
+ - Repository information (repo name, branch name, PR number, etc.)
162
+ - Runtime information (e.g., available hosts, current date)
163
+ - Conversation instructions (e.g., user preferences, task details)
164
+ - Repository-specific instructions (collected from repo skills)
165
+ """
166
+ repo_skills = [s for s in self.skills if s.trigger is None]
167
+ logger.debug(f"Triggered {len(repo_skills)} repository skills: {repo_skills}")
168
+ # Build the workspace context information
169
+ secret_infos = self.get_secret_infos()
170
+ if repo_skills or self.system_message_suffix or secret_infos:
171
+ # TODO(test): add a test for this rendering to make sure they work
172
+ formatted_text = render_template(
173
+ prompt_dir=str(PROMPT_DIR),
174
+ template_name="system_message_suffix.j2",
175
+ repo_skills=repo_skills,
176
+ system_message_suffix=self.system_message_suffix or "",
177
+ secret_infos=secret_infos,
178
+ ).strip()
179
+ return formatted_text
180
+ elif self.system_message_suffix and self.system_message_suffix.strip():
181
+ return self.system_message_suffix.strip()
182
+ return None
183
+
184
+ def get_user_message_suffix(
185
+ self, user_message: Message, skip_skill_names: list[str]
186
+ ) -> tuple[TextContent, list[str]] | None:
187
+ """Augment the user’s message with knowledge recalled from skills.
188
+
189
+ This works by:
190
+ - Extracting the text content of the user message
191
+ - Matching skill triggers against the query
192
+ - Returning formatted knowledge and triggered skill names if relevant skills were triggered
193
+ """ # noqa: E501
194
+
195
+ user_message_suffix = None
196
+ if self.user_message_suffix and self.user_message_suffix.strip():
197
+ user_message_suffix = self.user_message_suffix.strip()
198
+
199
+ query = "\n".join(
200
+ c.text for c in user_message.content if isinstance(c, TextContent)
201
+ ).strip()
202
+ recalled_knowledge: list[SkillKnowledge] = []
203
+ # skip empty queries, but still return user_message_suffix if it exists
204
+ if not query:
205
+ if user_message_suffix:
206
+ return TextContent(text=user_message_suffix), []
207
+ return None
208
+ # Search for skill triggers in the query
209
+ for skill in self.skills:
210
+ if not isinstance(skill, Skill):
211
+ continue
212
+ trigger = skill.match_trigger(query)
213
+ if trigger and skill.name not in skip_skill_names:
214
+ logger.info(
215
+ "Skill '%s' triggered by keyword '%s'",
216
+ skill.name,
217
+ trigger,
218
+ )
219
+ recalled_knowledge.append(
220
+ SkillKnowledge(
221
+ name=skill.name,
222
+ trigger=trigger,
223
+ content=skill.content,
224
+ )
225
+ )
226
+ if recalled_knowledge:
227
+ formatted_skill_text = render_template(
228
+ prompt_dir=str(PROMPT_DIR),
229
+ template_name="skill_knowledge_info.j2",
230
+ triggered_agents=recalled_knowledge,
231
+ )
232
+ if user_message_suffix:
233
+ formatted_skill_text += "\n" + user_message_suffix
234
+ return TextContent(text=formatted_skill_text), [
235
+ k.name for k in recalled_knowledge
236
+ ]
237
+
238
+ if user_message_suffix:
239
+ return TextContent(text=user_message_suffix), []
240
+ return None
@@ -0,0 +1,18 @@
1
+ from openhands.sdk.context.condenser.base import (
2
+ CondenserBase,
3
+ RollingCondenser,
4
+ )
5
+ from openhands.sdk.context.condenser.llm_summarizing_condenser import (
6
+ LLMSummarizingCondenser,
7
+ )
8
+ from openhands.sdk.context.condenser.no_op_condenser import NoOpCondenser
9
+ from openhands.sdk.context.condenser.pipeline_condenser import PipelineCondenser
10
+
11
+
12
+ __all__ = [
13
+ "CondenserBase",
14
+ "RollingCondenser",
15
+ "NoOpCondenser",
16
+ "PipelineCondenser",
17
+ "LLMSummarizingCondenser",
18
+ ]
@@ -0,0 +1,95 @@
1
+ from abc import ABC, abstractmethod
2
+ from logging import getLogger
3
+
4
+ from openhands.sdk.context.view import View
5
+ from openhands.sdk.event.condenser import Condensation
6
+ from openhands.sdk.utils.models import (
7
+ DiscriminatedUnionMixin,
8
+ )
9
+
10
+
11
+ logger = getLogger(__name__)
12
+
13
+
14
+ class CondenserBase(DiscriminatedUnionMixin, ABC):
15
+ """Abstract condenser interface.
16
+
17
+ Condensers take a list of `Event` objects and reduce them into a potentially smaller
18
+ list.
19
+
20
+ Agents can use condensers to reduce the amount of events they need to consider when
21
+ deciding which action to take. To use a condenser, agents can call the
22
+ `condensed_history` method on the current `State` being considered and use the
23
+ results instead of the full history.
24
+
25
+ If the condenser returns a `Condensation` instead of a `View`, the agent should
26
+ return `Condensation.action` instead of producing its own action. On the next agent
27
+ step the condenser will use that condensation event to produce a new `View`.
28
+ """
29
+
30
+ @abstractmethod
31
+ def condense(self, view: View) -> View | Condensation:
32
+ """Condense a sequence of events into a potentially smaller list.
33
+
34
+ New condenser strategies should override this method to implement their own
35
+ condensation logic. Call `self.add_metadata` in the implementation to record any
36
+ relevant per-condensation diagnostic information.
37
+
38
+ Args:
39
+ view: A view of the history containing all events that should be condensed.
40
+
41
+ Returns:
42
+ View | Condensation: A condensed view of the events or an event indicating
43
+ the history has been condensed.
44
+ """
45
+
46
+ def handles_condensation_requests(self) -> bool:
47
+ """Whether this condenser handles explicit condensation requests.
48
+
49
+ If this returns True, the agent will trigger the condenser whenever a
50
+ CondensationRequest event is added to the history. If False, the condenser will
51
+ only be triggered when the agent's own logic decides to do so (e.g. context
52
+ window exceeded).
53
+
54
+ Returns:
55
+ bool: True if the condenser handles explicit condensation requests, False
56
+ otherwise.
57
+ """
58
+ return False
59
+
60
+
61
+ class PipelinableCondenserBase(CondenserBase):
62
+ """Abstract condenser interface which may be pipelined. (Since a pipeline
63
+ condenser should not nest another pipeline condenser)"""
64
+
65
+
66
+ class RollingCondenser(PipelinableCondenserBase, ABC):
67
+ """Base class for a specialized condenser strategy that applies condensation to a
68
+ rolling history.
69
+
70
+ The rolling history is generated by `View.from_events`, which analyzes all events in
71
+ the history and produces a `View` object representing what will be sent to the LLM.
72
+
73
+ If `should_condense` says so, the condenser is then responsible for generating a
74
+ `Condensation` object from the `View` object. This will be added to the event
75
+ history which should -- when given to `get_view` -- produce the condensed `View` to
76
+ be passed to the LLM.
77
+ """
78
+
79
+ @abstractmethod
80
+ def should_condense(self, view: View) -> bool:
81
+ """Determine if a view should be condensed."""
82
+
83
+ @abstractmethod
84
+ def get_condensation(self, view: View) -> Condensation:
85
+ """Get the condensation from a view."""
86
+
87
+ def condense(self, view: View) -> View | Condensation:
88
+ # If we trigger the condenser-specific condensation threshold, compute and
89
+ # return the condensation.
90
+ if self.should_condense(view):
91
+ return self.get_condensation(view)
92
+
93
+ # Otherwise we're safe to just return the view.
94
+ else:
95
+ return view
@@ -0,0 +1,89 @@
1
+ import os
2
+
3
+ from pydantic import Field, model_validator
4
+
5
+ from openhands.sdk.context.condenser.base import RollingCondenser
6
+ from openhands.sdk.context.prompts import render_template
7
+ from openhands.sdk.context.view import View
8
+ from openhands.sdk.event.condenser import Condensation
9
+ from openhands.sdk.event.llm_convertible import MessageEvent
10
+ from openhands.sdk.llm import LLM, Message, TextContent
11
+ from openhands.sdk.observability.laminar import observe
12
+
13
+
14
+ class LLMSummarizingCondenser(RollingCondenser):
15
+ llm: LLM
16
+ max_size: int = Field(default=120, gt=0)
17
+ keep_first: int = Field(default=4, ge=0)
18
+
19
+ @model_validator(mode="after")
20
+ def validate_keep_first_vs_max_size(self):
21
+ events_from_tail = self.max_size // 2 - self.keep_first - 1
22
+ if events_from_tail <= 0:
23
+ raise ValueError(
24
+ "keep_first must be less than max_size // 2 to leave room for "
25
+ "condensation"
26
+ )
27
+ return self
28
+
29
+ def handles_condensation_requests(self) -> bool:
30
+ return True
31
+
32
+ def should_condense(self, view: View) -> bool:
33
+ if view.unhandled_condensation_request:
34
+ return True
35
+ return len(view) > self.max_size
36
+
37
+ @observe(ignore_inputs=["view"])
38
+ def get_condensation(self, view: View) -> Condensation:
39
+ head = view[: self.keep_first]
40
+ target_size = self.max_size // 2
41
+ if view.unhandled_condensation_request:
42
+ # Condensation triggered by a condensation request
43
+ # should be calculated based on the view size.
44
+ target_size = len(view) // 2
45
+ # Number of events to keep from the tail -- target size, minus however many
46
+ # prefix events from the head, minus one for the summarization event
47
+ events_from_tail = target_size - len(head) - 1
48
+
49
+ summary_event_content: str = ""
50
+
51
+ summary_event = view.summary_event
52
+ if isinstance(summary_event, MessageEvent):
53
+ message_content = summary_event.llm_message.content[0]
54
+ if isinstance(message_content, TextContent):
55
+ summary_event_content = message_content.text
56
+
57
+ # Identify events to be forgotten (those not in head or tail)
58
+ forgotten_events = view[self.keep_first : -events_from_tail]
59
+
60
+ # Convert events to strings for the template
61
+ event_strings = [str(forgotten_event) for forgotten_event in forgotten_events]
62
+
63
+ prompt = render_template(
64
+ os.path.join(os.path.dirname(__file__), "prompts"),
65
+ "summarizing_prompt.j2",
66
+ previous_summary=summary_event_content,
67
+ events=event_strings,
68
+ )
69
+
70
+ messages = [Message(role="user", content=[TextContent(text=prompt)])]
71
+
72
+ # Do not pass extra_body explicitly. The LLM handles forwarding
73
+ # litellm_extra_body only when it is non-empty.
74
+ llm_response = self.llm.completion(
75
+ messages=messages,
76
+ )
77
+ # Extract summary from the LLMResponse message
78
+ summary = None
79
+ if llm_response.message.content:
80
+ first_content = llm_response.message.content[0]
81
+ if isinstance(first_content, TextContent):
82
+ summary = first_content.text
83
+
84
+ return Condensation(
85
+ forgotten_event_ids=[event.id for event in forgotten_events],
86
+ summary=summary,
87
+ summary_offset=self.keep_first,
88
+ llm_response_id=llm_response.id,
89
+ )
@@ -0,0 +1,13 @@
1
+ from openhands.sdk.context.condenser.base import CondenserBase
2
+ from openhands.sdk.context.view import View
3
+ from openhands.sdk.event.condenser import Condensation
4
+
5
+
6
+ class NoOpCondenser(CondenserBase):
7
+ """Simple condenser that returns a view un-manipulated.
8
+
9
+ Primarily intended for testing purposes.
10
+ """
11
+
12
+ def condense(self, view: View) -> View | Condensation:
13
+ return view
@@ -0,0 +1,55 @@
1
+ from openhands.sdk.context.condenser.base import CondenserBase
2
+ from openhands.sdk.context.view import View
3
+ from openhands.sdk.event.condenser import Condensation
4
+
5
+
6
+ class PipelineCondenser(CondenserBase):
7
+ """A condenser that applies a sequence of condensers in order.
8
+
9
+ All condensers are defined primarily by their `condense` method, which takes a
10
+ `View` and returns either a new `View` or a `Condensation` event. That means we can
11
+ chain multiple condensers together by passing `View`s along and exiting early if any
12
+ condenser returns a `Condensation`.
13
+
14
+ For example:
15
+
16
+ # Use the pipeline condenser to chain multiple other condensers together
17
+ condenser = PipelineCondenser(condensers=[
18
+ CondenserA(...),
19
+ CondenserB(...),
20
+ CondenserC(...),
21
+ ])
22
+
23
+ result = condenser.condense(view)
24
+
25
+ # Doing the same thing without the pipeline condenser requires more boilerplate
26
+ # for the monadic chaining
27
+ other_result = view
28
+
29
+ if isinstance(other_result, View):
30
+ other_result = CondenserA(...).condense(other_result)
31
+
32
+ if isinstance(other_result, View):
33
+ other_result = CondenserB(...).condense(other_result)
34
+
35
+ if isinstance(other_result, View):
36
+ other_result = CondenserC(...).condense(other_result)
37
+
38
+ assert result == other_result
39
+ """
40
+
41
+ condensers: list[CondenserBase]
42
+ """The list of condensers to apply in order."""
43
+
44
+ def condense(self, view: View) -> View | Condensation:
45
+ result: View | Condensation = view
46
+ for condenser in self.condensers:
47
+ if isinstance(result, Condensation):
48
+ break
49
+ result = condenser.condense(result)
50
+ return result
51
+
52
+ def handles_condensation_requests(self) -> bool:
53
+ return any(
54
+ condenser.handles_condensation_requests() for condenser in self.condensers
55
+ )
@@ -0,0 +1,59 @@
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, and the most recent previous summary if one exists.
3
+ If the events being summarized contain ANY task-tracking, you MUST include a TASK_TRACKING section to maintain continuity.
4
+ When referencing tasks make sure to preserve exact task IDs and statuses.
5
+
6
+ Track:
7
+
8
+ USER_CONTEXT: (Preserve essential user requirements, goals, and clarifications in concise form)
9
+
10
+ TASK_TRACKING: {Active tasks, their IDs and statuses - PRESERVE TASK IDs}
11
+
12
+ COMPLETED: (Tasks completed so far, with brief results)
13
+ PENDING: (Tasks that still need to be done)
14
+ CURRENT_STATE: (Current variables, data structures, or relevant state)
15
+
16
+ For code-specific tasks, also include:
17
+ CODE_STATE: {File paths, function signatures, data structures}
18
+ TESTS: {Failing cases, error messages, outputs}
19
+ CHANGES: {Code edits, variable updates}
20
+ DEPS: {Dependencies, imports, external calls}
21
+ VERSION_CONTROL_STATUS: {Repository state, current branch, PR status, commit history}
22
+
23
+ PRIORITIZE:
24
+ 1. Adapt tracking format to match the actual task type
25
+ 2. Capture key user requirements and goals
26
+ 3. Distinguish between completed and pending tasks
27
+ 4. Keep all sections concise and relevant
28
+
29
+ SKIP: Tracking irrelevant details for the current task type
30
+
31
+ Example formats:
32
+
33
+ For code tasks:
34
+ USER_CONTEXT: Fix FITS card float representation issue
35
+ COMPLETED: Modified mod_float() in card.py, all tests passing
36
+ PENDING: Create PR, update documentation
37
+ CODE_STATE: mod_float() in card.py updated
38
+ TESTS: test_format() passed
39
+ CHANGES: str(val) replaces f"{val:.16G}"
40
+ DEPS: None modified
41
+ VERSION_CONTROL_STATUS: Branch: fix-float-precision, Latest commit: a1b2c3d
42
+
43
+ For other tasks:
44
+ USER_CONTEXT: Write 20 haikus based on coin flip results
45
+ COMPLETED: 15 haikus written for results [T,H,T,H,T,H,T,T,H,T,H,T,H,T,H]
46
+ PENDING: 5 more haikus needed
47
+ CURRENT_STATE: Last flip: Heads, Haiku count: 15/20
48
+
49
+ <PREVIOUS SUMMARY>
50
+ {{ previous_summary }}
51
+ </PREVIOUS SUMMARY>
52
+
53
+ {% for event in events %}
54
+ <EVENT>
55
+ {{ event }}
56
+ </EVENT>
57
+ {% endfor %}
58
+
59
+ Now summarize the events using the rules above.
@@ -0,0 +1,6 @@
1
+ from openhands.sdk.context.prompts.prompt import render_template
2
+
3
+
4
+ __all__ = [
5
+ "render_template",
6
+ ]