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.
- openhands/sdk/__init__.py +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +607 -0
- openhands/sdk/agent/base.py +454 -0
- openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
- openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
- openhands/sdk/agent/prompts/security_policy.j2 +22 -0
- openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
- openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
- openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
- openhands/sdk/agent/utils.py +223 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +240 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +95 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +89 -0
- openhands/sdk/context/condenser/no_op_condenser.py +13 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +55 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/prompts/__init__.py +6 -0
- openhands/sdk/context/prompts/prompt.py +114 -0
- openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
- openhands/sdk/context/skills/__init__.py +28 -0
- openhands/sdk/context/skills/exceptions.py +11 -0
- openhands/sdk/context/skills/skill.py +630 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +306 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +146 -0
- openhands/sdk/conversation/conversation_stats.py +85 -0
- openhands/sdk/conversation/event_store.py +157 -0
- openhands/sdk/conversation/events_list_base.py +17 -0
- openhands/sdk/conversation/exceptions.py +50 -0
- openhands/sdk/conversation/fifo_lock.py +133 -0
- openhands/sdk/conversation/impl/__init__.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +620 -0
- openhands/sdk/conversation/impl/remote_conversation.py +883 -0
- openhands/sdk/conversation/persistence_const.py +9 -0
- openhands/sdk/conversation/response_utils.py +41 -0
- openhands/sdk/conversation/secret_registry.py +126 -0
- openhands/sdk/conversation/serialization_diff.py +0 -0
- openhands/sdk/conversation/state.py +352 -0
- openhands/sdk/conversation/stuck_detector.py +311 -0
- openhands/sdk/conversation/title_utils.py +191 -0
- openhands/sdk/conversation/types.py +45 -0
- openhands/sdk/conversation/visualizer/__init__.py +12 -0
- openhands/sdk/conversation/visualizer/base.py +67 -0
- openhands/sdk/conversation/visualizer/default.py +373 -0
- openhands/sdk/critic/__init__.py +15 -0
- openhands/sdk/critic/base.py +38 -0
- openhands/sdk/critic/impl/__init__.py +12 -0
- openhands/sdk/critic/impl/agent_finished.py +83 -0
- openhands/sdk/critic/impl/empty_patch.py +49 -0
- openhands/sdk/critic/impl/pass_critic.py +42 -0
- openhands/sdk/event/__init__.py +42 -0
- openhands/sdk/event/base.py +149 -0
- openhands/sdk/event/condenser.py +82 -0
- openhands/sdk/event/conversation_error.py +25 -0
- openhands/sdk/event/conversation_state.py +104 -0
- openhands/sdk/event/llm_completion_log.py +39 -0
- openhands/sdk/event/llm_convertible/__init__.py +20 -0
- openhands/sdk/event/llm_convertible/action.py +139 -0
- openhands/sdk/event/llm_convertible/message.py +142 -0
- openhands/sdk/event/llm_convertible/observation.py +141 -0
- openhands/sdk/event/llm_convertible/system.py +61 -0
- openhands/sdk/event/token.py +16 -0
- openhands/sdk/event/types.py +11 -0
- openhands/sdk/event/user_action.py +21 -0
- openhands/sdk/git/exceptions.py +43 -0
- openhands/sdk/git/git_changes.py +249 -0
- openhands/sdk/git/git_diff.py +129 -0
- openhands/sdk/git/models.py +21 -0
- openhands/sdk/git/utils.py +189 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/local.py +82 -0
- openhands/sdk/io/memory.py +54 -0
- openhands/sdk/llm/__init__.py +45 -0
- openhands/sdk/llm/exceptions/__init__.py +45 -0
- openhands/sdk/llm/exceptions/classifier.py +50 -0
- openhands/sdk/llm/exceptions/mapping.py +54 -0
- openhands/sdk/llm/exceptions/types.py +101 -0
- openhands/sdk/llm/llm.py +1140 -0
- openhands/sdk/llm/llm_registry.py +122 -0
- openhands/sdk/llm/llm_response.py +59 -0
- openhands/sdk/llm/message.py +656 -0
- openhands/sdk/llm/mixins/fn_call_converter.py +1243 -0
- openhands/sdk/llm/mixins/non_native_fc.py +93 -0
- openhands/sdk/llm/options/__init__.py +1 -0
- openhands/sdk/llm/options/chat_options.py +93 -0
- openhands/sdk/llm/options/common.py +19 -0
- openhands/sdk/llm/options/responses_options.py +67 -0
- openhands/sdk/llm/router/__init__.py +10 -0
- openhands/sdk/llm/router/base.py +117 -0
- openhands/sdk/llm/router/impl/multimodal.py +76 -0
- openhands/sdk/llm/router/impl/random.py +22 -0
- openhands/sdk/llm/streaming.py +9 -0
- openhands/sdk/llm/utils/metrics.py +312 -0
- openhands/sdk/llm/utils/model_features.py +191 -0
- openhands/sdk/llm/utils/model_info.py +90 -0
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/retry_mixin.py +128 -0
- openhands/sdk/llm/utils/telemetry.py +362 -0
- openhands/sdk/llm/utils/unverified_models.py +156 -0
- openhands/sdk/llm/utils/verified_models.py +66 -0
- openhands/sdk/logger/__init__.py +22 -0
- openhands/sdk/logger/logger.py +195 -0
- openhands/sdk/logger/rolling.py +113 -0
- openhands/sdk/mcp/__init__.py +24 -0
- openhands/sdk/mcp/client.py +76 -0
- openhands/sdk/mcp/definition.py +106 -0
- openhands/sdk/mcp/exceptions.py +19 -0
- openhands/sdk/mcp/tool.py +270 -0
- openhands/sdk/mcp/utils.py +83 -0
- openhands/sdk/observability/__init__.py +4 -0
- openhands/sdk/observability/laminar.py +166 -0
- openhands/sdk/observability/utils.py +20 -0
- openhands/sdk/py.typed +0 -0
- openhands/sdk/secret/__init__.py +19 -0
- openhands/sdk/secret/secrets.py +92 -0
- openhands/sdk/security/__init__.py +6 -0
- openhands/sdk/security/analyzer.py +111 -0
- openhands/sdk/security/confirmation_policy.py +61 -0
- openhands/sdk/security/llm_analyzer.py +29 -0
- openhands/sdk/security/risk.py +100 -0
- openhands/sdk/tool/__init__.py +34 -0
- openhands/sdk/tool/builtins/__init__.py +34 -0
- openhands/sdk/tool/builtins/finish.py +106 -0
- openhands/sdk/tool/builtins/think.py +117 -0
- openhands/sdk/tool/registry.py +161 -0
- openhands/sdk/tool/schema.py +276 -0
- openhands/sdk/tool/spec.py +39 -0
- openhands/sdk/tool/tool.py +481 -0
- openhands/sdk/utils/__init__.py +22 -0
- openhands/sdk/utils/async_executor.py +115 -0
- openhands/sdk/utils/async_utils.py +39 -0
- openhands/sdk/utils/cipher.py +68 -0
- openhands/sdk/utils/command.py +90 -0
- openhands/sdk/utils/deprecation.py +166 -0
- openhands/sdk/utils/github.py +44 -0
- openhands/sdk/utils/json.py +48 -0
- openhands/sdk/utils/models.py +570 -0
- openhands/sdk/utils/paging.py +63 -0
- openhands/sdk/utils/pydantic_diff.py +85 -0
- openhands/sdk/utils/pydantic_secrets.py +64 -0
- openhands/sdk/utils/truncate.py +117 -0
- openhands/sdk/utils/visualize.py +58 -0
- openhands/sdk/workspace/__init__.py +17 -0
- openhands/sdk/workspace/base.py +158 -0
- openhands/sdk/workspace/local.py +189 -0
- openhands/sdk/workspace/models.py +35 -0
- openhands/sdk/workspace/remote/__init__.py +8 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
- openhands/sdk/workspace/remote/base.py +164 -0
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
- openhands/sdk/workspace/workspace.py +49 -0
- openhands_sdk-1.7.0.dist-info/METADATA +17 -0
- openhands_sdk-1.7.0.dist-info/RECORD +172 -0
- openhands_sdk-1.7.0.dist-info/WHEEL +5 -0
- 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.
|