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,48 @@
|
|
|
1
|
+
from datetime import UTC, datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InputMetadata(BaseModel):
|
|
7
|
+
"""Metadata for task skill inputs."""
|
|
8
|
+
|
|
9
|
+
name: str = Field(description="Name of the input parameter")
|
|
10
|
+
description: str = Field(description="Description of the input parameter")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SkillKnowledge(BaseModel):
|
|
14
|
+
"""Represents knowledge from a triggered skill."""
|
|
15
|
+
|
|
16
|
+
name: str = Field(description="The name of the skill that was triggered")
|
|
17
|
+
trigger: str = Field(description="The word that triggered this skill")
|
|
18
|
+
content: str = Field(description="The actual content/knowledge from the skill")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SkillResponse(BaseModel):
|
|
22
|
+
"""Response model for skills endpoint.
|
|
23
|
+
|
|
24
|
+
Note: This model only includes basic metadata that can be determined
|
|
25
|
+
without parsing skill content. Use the separate content API
|
|
26
|
+
to get detailed skill information.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name: str = Field(description="The name of the skill")
|
|
30
|
+
path: str = Field(description="The path or identifier of the skill")
|
|
31
|
+
created_at: datetime = Field(
|
|
32
|
+
default_factory=lambda: datetime.now(UTC),
|
|
33
|
+
description="Timestamp when the skill was created",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SkillContentResponse(BaseModel):
|
|
38
|
+
"""Response model for individual skill content endpoint."""
|
|
39
|
+
|
|
40
|
+
content: str = Field(description="The full content of the skill")
|
|
41
|
+
path: str = Field(description="The path or identifier of the skill")
|
|
42
|
+
triggers: list[str] = Field(
|
|
43
|
+
description="List of triggers associated with the skill"
|
|
44
|
+
)
|
|
45
|
+
git_provider: str | None = Field(
|
|
46
|
+
None,
|
|
47
|
+
description="Git provider if the skill is sourced from a Git repository",
|
|
48
|
+
)
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from logging import getLogger
|
|
3
|
+
from typing import overload
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from openhands.sdk.event import (
|
|
8
|
+
Condensation,
|
|
9
|
+
CondensationRequest,
|
|
10
|
+
CondensationSummaryEvent,
|
|
11
|
+
LLMConvertibleEvent,
|
|
12
|
+
)
|
|
13
|
+
from openhands.sdk.event.base import Event, EventID
|
|
14
|
+
from openhands.sdk.event.llm_convertible import (
|
|
15
|
+
ActionEvent,
|
|
16
|
+
ObservationBaseEvent,
|
|
17
|
+
)
|
|
18
|
+
from openhands.sdk.event.types import ToolCallID
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
logger = getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class View(BaseModel):
|
|
25
|
+
"""Linearly ordered view of events.
|
|
26
|
+
|
|
27
|
+
Produced by a condenser to indicate the included events are ready to process as LLM
|
|
28
|
+
input. Also contains fields with information from the condensation process to aid
|
|
29
|
+
in deciding whether further condensation is needed.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
events: list[LLMConvertibleEvent]
|
|
33
|
+
|
|
34
|
+
unhandled_condensation_request: bool = False
|
|
35
|
+
"""Whether there is an unhandled condensation request in the view."""
|
|
36
|
+
|
|
37
|
+
condensations: list[Condensation] = []
|
|
38
|
+
"""A list of condensations that were processed to produce the view."""
|
|
39
|
+
|
|
40
|
+
def __len__(self) -> int:
|
|
41
|
+
return len(self.events)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def most_recent_condensation(self) -> Condensation | None:
|
|
45
|
+
"""Return the most recent condensation, or None if no condensations exist."""
|
|
46
|
+
return self.condensations[-1] if self.condensations else None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def summary_event_index(self) -> int | None:
|
|
50
|
+
"""Return the index of the summary event, or None if no summary exists."""
|
|
51
|
+
recent_condensation = self.most_recent_condensation
|
|
52
|
+
if (
|
|
53
|
+
recent_condensation is not None
|
|
54
|
+
and recent_condensation.summary is not None
|
|
55
|
+
and recent_condensation.summary_offset is not None
|
|
56
|
+
):
|
|
57
|
+
return recent_condensation.summary_offset
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def summary_event(self) -> CondensationSummaryEvent | None:
|
|
62
|
+
"""Return the summary event, or None if no summary exists."""
|
|
63
|
+
if self.summary_event_index is not None:
|
|
64
|
+
event = self.events[self.summary_event_index]
|
|
65
|
+
if isinstance(event, CondensationSummaryEvent):
|
|
66
|
+
return event
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
# To preserve list-like indexing, we ideally support slicing and position-based
|
|
70
|
+
# indexing. The only challenge with that is switching the return type based on the
|
|
71
|
+
# input type -- we can mark the different signatures for MyPy with `@overload`
|
|
72
|
+
# decorators.
|
|
73
|
+
|
|
74
|
+
@overload
|
|
75
|
+
def __getitem__(self, key: slice) -> list[LLMConvertibleEvent]: ...
|
|
76
|
+
|
|
77
|
+
@overload
|
|
78
|
+
def __getitem__(self, key: int) -> LLMConvertibleEvent: ...
|
|
79
|
+
|
|
80
|
+
def __getitem__(
|
|
81
|
+
self, key: int | slice
|
|
82
|
+
) -> LLMConvertibleEvent | list[LLMConvertibleEvent]:
|
|
83
|
+
if isinstance(key, slice):
|
|
84
|
+
start, stop, step = key.indices(len(self))
|
|
85
|
+
return [self[i] for i in range(start, stop, step)]
|
|
86
|
+
elif isinstance(key, int):
|
|
87
|
+
return self.events[key]
|
|
88
|
+
else:
|
|
89
|
+
raise ValueError(f"Invalid key type: {type(key)}")
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _build_action_batches(
|
|
93
|
+
events: Sequence[Event],
|
|
94
|
+
) -> tuple[
|
|
95
|
+
dict[EventID, list[EventID]], dict[EventID, EventID], dict[EventID, ToolCallID]
|
|
96
|
+
]:
|
|
97
|
+
"""Build a map of llm_response_id -> list of ActionEvent IDs.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A tuple of:
|
|
101
|
+
- batches: dict mapping llm_response_id to list of ActionEvent IDs
|
|
102
|
+
- action_id_to_response_id: dict mapping ActionEvent ID to llm_response_id
|
|
103
|
+
- action_id_to_tool_call_id: dict mapping ActionEvent ID to tool_call_id
|
|
104
|
+
"""
|
|
105
|
+
batches: dict[EventID, list[EventID]] = {}
|
|
106
|
+
action_id_to_response_id: dict[EventID, EventID] = {}
|
|
107
|
+
action_id_to_tool_call_id: dict[EventID, ToolCallID] = {}
|
|
108
|
+
|
|
109
|
+
for event in events:
|
|
110
|
+
if isinstance(event, ActionEvent):
|
|
111
|
+
llm_response_id = event.llm_response_id
|
|
112
|
+
if llm_response_id not in batches:
|
|
113
|
+
batches[llm_response_id] = []
|
|
114
|
+
batches[llm_response_id].append(event.id)
|
|
115
|
+
action_id_to_response_id[event.id] = llm_response_id
|
|
116
|
+
action_id_to_tool_call_id[event.id] = event.tool_call_id
|
|
117
|
+
|
|
118
|
+
return batches, action_id_to_response_id, action_id_to_tool_call_id
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _enforce_batch_atomicity(
|
|
122
|
+
events: Sequence[Event],
|
|
123
|
+
removed_event_ids: set[EventID],
|
|
124
|
+
) -> set[EventID]:
|
|
125
|
+
"""Ensure that if any ActionEvent in a batch is removed, all ActionEvents
|
|
126
|
+
in that batch are removed.
|
|
127
|
+
|
|
128
|
+
This prevents partial batches from being sent to the LLM, which can cause
|
|
129
|
+
API errors when thinking blocks are separated from their tool calls.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
events: The original list of events
|
|
133
|
+
removed_event_ids: Set of event IDs that are being removed
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Updated set of event IDs that should be removed (including all
|
|
137
|
+
ActionEvents in batches where any ActionEvent was removed)
|
|
138
|
+
"""
|
|
139
|
+
batches, action_id_to_response_id, _ = View._build_action_batches(events)
|
|
140
|
+
|
|
141
|
+
if not batches:
|
|
142
|
+
return removed_event_ids
|
|
143
|
+
|
|
144
|
+
updated_removed_ids = set(removed_event_ids)
|
|
145
|
+
|
|
146
|
+
for llm_response_id, batch_event_ids in batches.items():
|
|
147
|
+
# Check if any ActionEvent in this batch is being removed
|
|
148
|
+
if any(event_id in removed_event_ids for event_id in batch_event_ids):
|
|
149
|
+
# If so, remove all ActionEvents in this batch
|
|
150
|
+
updated_removed_ids.update(batch_event_ids)
|
|
151
|
+
logger.debug(
|
|
152
|
+
f"Enforcing batch atomicity: removing entire batch "
|
|
153
|
+
f"with llm_response_id={llm_response_id} "
|
|
154
|
+
f"({len(batch_event_ids)} events)"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return updated_removed_ids
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def filter_unmatched_tool_calls(
|
|
161
|
+
events: list[LLMConvertibleEvent],
|
|
162
|
+
) -> list[LLMConvertibleEvent]:
|
|
163
|
+
"""Filter out unmatched tool call events.
|
|
164
|
+
|
|
165
|
+
Removes ActionEvents and ObservationEvents that have tool_call_ids
|
|
166
|
+
but don't have matching pairs. Also enforces batch atomicity - if any
|
|
167
|
+
ActionEvent in a batch is filtered out, all ActionEvents in that batch
|
|
168
|
+
are also filtered out.
|
|
169
|
+
"""
|
|
170
|
+
action_tool_call_ids = View._get_action_tool_call_ids(events)
|
|
171
|
+
observation_tool_call_ids = View._get_observation_tool_call_ids(events)
|
|
172
|
+
|
|
173
|
+
# Build batch info for batch atomicity enforcement
|
|
174
|
+
_, _, action_id_to_tool_call_id = View._build_action_batches(events)
|
|
175
|
+
|
|
176
|
+
# First pass: identify which events would NOT be kept based on matching
|
|
177
|
+
removed_event_ids: set[EventID] = set()
|
|
178
|
+
for event in events:
|
|
179
|
+
if not View._should_keep_event(
|
|
180
|
+
event, action_tool_call_ids, observation_tool_call_ids
|
|
181
|
+
):
|
|
182
|
+
removed_event_ids.add(event.id)
|
|
183
|
+
|
|
184
|
+
# Second pass: enforce batch atomicity for ActionEvents
|
|
185
|
+
# If any ActionEvent in a batch is removed, all ActionEvents in that
|
|
186
|
+
# batch should also be removed
|
|
187
|
+
removed_event_ids = View._enforce_batch_atomicity(events, removed_event_ids)
|
|
188
|
+
|
|
189
|
+
# Third pass: also remove ObservationEvents whose ActionEvents were removed
|
|
190
|
+
# due to batch atomicity
|
|
191
|
+
tool_call_ids_to_remove: set[ToolCallID] = set()
|
|
192
|
+
for action_id in removed_event_ids:
|
|
193
|
+
if action_id in action_id_to_tool_call_id:
|
|
194
|
+
tool_call_ids_to_remove.add(action_id_to_tool_call_id[action_id])
|
|
195
|
+
|
|
196
|
+
# Filter out removed events
|
|
197
|
+
result = []
|
|
198
|
+
for event in events:
|
|
199
|
+
if event.id in removed_event_ids:
|
|
200
|
+
continue
|
|
201
|
+
if isinstance(event, ObservationBaseEvent):
|
|
202
|
+
if event.tool_call_id in tool_call_ids_to_remove:
|
|
203
|
+
continue
|
|
204
|
+
result.append(event)
|
|
205
|
+
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _get_action_tool_call_ids(events: list[LLMConvertibleEvent]) -> set[ToolCallID]:
|
|
210
|
+
"""Extract tool_call_ids from ActionEvents."""
|
|
211
|
+
tool_call_ids = set()
|
|
212
|
+
for event in events:
|
|
213
|
+
if isinstance(event, ActionEvent) and event.tool_call_id is not None:
|
|
214
|
+
tool_call_ids.add(event.tool_call_id)
|
|
215
|
+
return tool_call_ids
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _get_observation_tool_call_ids(
|
|
219
|
+
events: list[LLMConvertibleEvent],
|
|
220
|
+
) -> set[ToolCallID]:
|
|
221
|
+
"""Extract tool_call_ids from ObservationEvents."""
|
|
222
|
+
tool_call_ids = set()
|
|
223
|
+
for event in events:
|
|
224
|
+
if (
|
|
225
|
+
isinstance(event, ObservationBaseEvent)
|
|
226
|
+
and event.tool_call_id is not None
|
|
227
|
+
):
|
|
228
|
+
tool_call_ids.add(event.tool_call_id)
|
|
229
|
+
return tool_call_ids
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _should_keep_event(
|
|
233
|
+
event: LLMConvertibleEvent,
|
|
234
|
+
action_tool_call_ids: set[ToolCallID],
|
|
235
|
+
observation_tool_call_ids: set[ToolCallID],
|
|
236
|
+
) -> bool:
|
|
237
|
+
"""Determine if an event should be kept based on tool call matching."""
|
|
238
|
+
if isinstance(event, ObservationBaseEvent):
|
|
239
|
+
return event.tool_call_id in action_tool_call_ids
|
|
240
|
+
elif isinstance(event, ActionEvent):
|
|
241
|
+
return event.tool_call_id in observation_tool_call_ids
|
|
242
|
+
else:
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def from_events(events: Sequence[Event]) -> "View":
|
|
247
|
+
"""Create a view from a list of events, respecting the semantics of any
|
|
248
|
+
condensation events.
|
|
249
|
+
"""
|
|
250
|
+
forgotten_event_ids: set[EventID] = set()
|
|
251
|
+
condensations: list[Condensation] = []
|
|
252
|
+
for event in events:
|
|
253
|
+
if isinstance(event, Condensation):
|
|
254
|
+
condensations.append(event)
|
|
255
|
+
forgotten_event_ids.update(event.forgotten_event_ids)
|
|
256
|
+
# Make sure we also forget the condensation action itself
|
|
257
|
+
forgotten_event_ids.add(event.id)
|
|
258
|
+
if isinstance(event, CondensationRequest):
|
|
259
|
+
forgotten_event_ids.add(event.id)
|
|
260
|
+
|
|
261
|
+
# Enforce batch atomicity: if any event in a multi-action batch is forgotten,
|
|
262
|
+
# forget all events in that batch to prevent partial batches with thinking
|
|
263
|
+
# blocks separated from their tool calls
|
|
264
|
+
forgotten_event_ids = View._enforce_batch_atomicity(events, forgotten_event_ids)
|
|
265
|
+
|
|
266
|
+
kept_events = [
|
|
267
|
+
event
|
|
268
|
+
for event in events
|
|
269
|
+
if event.id not in forgotten_event_ids
|
|
270
|
+
and isinstance(event, LLMConvertibleEvent)
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
# If we have a summary, insert it at the specified offset.
|
|
274
|
+
summary: str | None = None
|
|
275
|
+
summary_offset: int | None = None
|
|
276
|
+
|
|
277
|
+
# The relevant summary is always in the last condensation event (i.e., the most
|
|
278
|
+
# recent one).
|
|
279
|
+
for event in reversed(events):
|
|
280
|
+
if isinstance(event, Condensation):
|
|
281
|
+
if event.summary is not None and event.summary_offset is not None:
|
|
282
|
+
summary = event.summary
|
|
283
|
+
summary_offset = event.summary_offset
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
if summary is not None and summary_offset is not None:
|
|
287
|
+
logger.debug(f"Inserting summary at offset {summary_offset}")
|
|
288
|
+
|
|
289
|
+
_new_summary_event = CondensationSummaryEvent(summary=summary)
|
|
290
|
+
kept_events.insert(summary_offset, _new_summary_event)
|
|
291
|
+
|
|
292
|
+
# Check for an unhandled condensation request -- these are events closer to the
|
|
293
|
+
# end of the list than any condensation action.
|
|
294
|
+
unhandled_condensation_request = False
|
|
295
|
+
for event in reversed(events):
|
|
296
|
+
if isinstance(event, Condensation):
|
|
297
|
+
break
|
|
298
|
+
if isinstance(event, CondensationRequest):
|
|
299
|
+
unhandled_condensation_request = True
|
|
300
|
+
break
|
|
301
|
+
|
|
302
|
+
return View(
|
|
303
|
+
events=View.filter_unmatched_tool_calls(kept_events),
|
|
304
|
+
unhandled_condensation_request=unhandled_condensation_request,
|
|
305
|
+
condensations=condensations,
|
|
306
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from openhands.sdk.conversation.base import BaseConversation
|
|
2
|
+
from openhands.sdk.conversation.conversation import Conversation
|
|
3
|
+
from openhands.sdk.conversation.event_store import EventLog
|
|
4
|
+
from openhands.sdk.conversation.events_list_base import EventsListBase
|
|
5
|
+
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
|
|
6
|
+
from openhands.sdk.conversation.impl.remote_conversation import RemoteConversation
|
|
7
|
+
from openhands.sdk.conversation.response_utils import get_agent_final_response
|
|
8
|
+
from openhands.sdk.conversation.secret_registry import SecretRegistry
|
|
9
|
+
from openhands.sdk.conversation.state import (
|
|
10
|
+
ConversationExecutionStatus,
|
|
11
|
+
ConversationState,
|
|
12
|
+
)
|
|
13
|
+
from openhands.sdk.conversation.stuck_detector import StuckDetector
|
|
14
|
+
from openhands.sdk.conversation.types import (
|
|
15
|
+
ConversationCallbackType,
|
|
16
|
+
ConversationTokenCallbackType,
|
|
17
|
+
)
|
|
18
|
+
from openhands.sdk.conversation.visualizer import (
|
|
19
|
+
ConversationVisualizerBase,
|
|
20
|
+
DefaultConversationVisualizer,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Conversation",
|
|
26
|
+
"BaseConversation",
|
|
27
|
+
"ConversationState",
|
|
28
|
+
"ConversationExecutionStatus",
|
|
29
|
+
"ConversationCallbackType",
|
|
30
|
+
"ConversationTokenCallbackType",
|
|
31
|
+
"DefaultConversationVisualizer",
|
|
32
|
+
"ConversationVisualizerBase",
|
|
33
|
+
"SecretRegistry",
|
|
34
|
+
"StuckDetector",
|
|
35
|
+
"EventLog",
|
|
36
|
+
"LocalConversation",
|
|
37
|
+
"RemoteConversation",
|
|
38
|
+
"EventsListBase",
|
|
39
|
+
"get_agent_final_response",
|
|
40
|
+
]
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from collections.abc import Iterable, Mapping
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Protocol, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from openhands.sdk.conversation.conversation_stats import ConversationStats
|
|
7
|
+
from openhands.sdk.conversation.events_list_base import EventsListBase
|
|
8
|
+
from openhands.sdk.conversation.secret_registry import SecretValue
|
|
9
|
+
from openhands.sdk.conversation.types import (
|
|
10
|
+
ConversationCallbackType,
|
|
11
|
+
ConversationID,
|
|
12
|
+
ConversationTokenCallbackType,
|
|
13
|
+
)
|
|
14
|
+
from openhands.sdk.llm.llm import LLM
|
|
15
|
+
from openhands.sdk.llm.message import Message
|
|
16
|
+
from openhands.sdk.observability.laminar import (
|
|
17
|
+
end_active_span,
|
|
18
|
+
should_enable_observability,
|
|
19
|
+
start_active_span,
|
|
20
|
+
)
|
|
21
|
+
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
|
|
22
|
+
from openhands.sdk.security.confirmation_policy import (
|
|
23
|
+
ConfirmationPolicyBase,
|
|
24
|
+
NeverConfirm,
|
|
25
|
+
)
|
|
26
|
+
from openhands.sdk.workspace.base import BaseWorkspace
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from openhands.sdk.agent.base import AgentBase
|
|
31
|
+
from openhands.sdk.conversation.state import ConversationExecutionStatus
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
CallbackType = TypeVar(
|
|
35
|
+
"CallbackType",
|
|
36
|
+
ConversationCallbackType,
|
|
37
|
+
ConversationTokenCallbackType,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConversationStateProtocol(Protocol):
|
|
42
|
+
"""Protocol defining the interface for conversation state objects."""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def id(self) -> ConversationID:
|
|
46
|
+
"""The conversation ID."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def events(self) -> EventsListBase:
|
|
51
|
+
"""Access to the events list."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def execution_status(self) -> "ConversationExecutionStatus":
|
|
56
|
+
"""The current conversation execution status."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def confirmation_policy(self) -> ConfirmationPolicyBase:
|
|
61
|
+
"""The confirmation policy."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def security_analyzer(self) -> SecurityAnalyzerBase | None:
|
|
66
|
+
"""The security analyzer."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def activated_knowledge_skills(self) -> list[str]:
|
|
71
|
+
"""List of activated knowledge skills."""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def workspace(self) -> BaseWorkspace:
|
|
76
|
+
"""The workspace for agent operations and tool execution."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def persistence_dir(self) -> str | None:
|
|
81
|
+
"""The persistence directory from the FileStore.
|
|
82
|
+
|
|
83
|
+
If None, it means the conversation is not being persisted.
|
|
84
|
+
"""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def agent(self) -> "AgentBase":
|
|
89
|
+
"""The agent running in the conversation."""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def stats(self) -> ConversationStats:
|
|
94
|
+
"""The conversation statistics."""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class BaseConversation(ABC):
|
|
99
|
+
"""Abstract base class for conversation implementations.
|
|
100
|
+
|
|
101
|
+
This class defines the interface that all conversation implementations must follow.
|
|
102
|
+
Conversations manage the interaction between users and agents, handling message
|
|
103
|
+
exchange, execution control, and state management.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
"""Initialize the base conversation with span tracking."""
|
|
108
|
+
self._span_ended = False
|
|
109
|
+
|
|
110
|
+
def _start_observability_span(self, session_id: str) -> None:
|
|
111
|
+
"""Start an observability span if observability is enabled.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
session_id: The session ID to associate with the span
|
|
115
|
+
"""
|
|
116
|
+
if should_enable_observability():
|
|
117
|
+
start_active_span("conversation", session_id=session_id)
|
|
118
|
+
|
|
119
|
+
def _end_observability_span(self) -> None:
|
|
120
|
+
"""End the observability span if it hasn't been ended already."""
|
|
121
|
+
if not self._span_ended and should_enable_observability():
|
|
122
|
+
end_active_span()
|
|
123
|
+
self._span_ended = True
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
@abstractmethod
|
|
127
|
+
def id(self) -> ConversationID: ...
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
@abstractmethod
|
|
131
|
+
def state(self) -> ConversationStateProtocol: ...
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
@abstractmethod
|
|
135
|
+
def conversation_stats(self) -> ConversationStats: ...
|
|
136
|
+
|
|
137
|
+
@abstractmethod
|
|
138
|
+
def send_message(self, message: str | Message, sender: str | None = None) -> None:
|
|
139
|
+
"""Send a message to the agent.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
message: Either a string (which will be converted to a user message)
|
|
143
|
+
or a Message object
|
|
144
|
+
sender: Optional identifier of the sender. Can be used to track
|
|
145
|
+
message origin in multi-agent scenarios. For example, when
|
|
146
|
+
one agent delegates to another, the sender can be set to
|
|
147
|
+
identify which agent is sending the message.
|
|
148
|
+
"""
|
|
149
|
+
...
|
|
150
|
+
|
|
151
|
+
@abstractmethod
|
|
152
|
+
def run(self) -> None:
|
|
153
|
+
"""Execute the agent to process messages and perform actions.
|
|
154
|
+
|
|
155
|
+
This method runs the agent until it finishes processing the current
|
|
156
|
+
message or reaches the maximum iteration limit.
|
|
157
|
+
"""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
def set_confirmation_policy(self, policy: ConfirmationPolicyBase) -> None:
|
|
162
|
+
"""Set the confirmation policy for the conversation."""
|
|
163
|
+
...
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def confirmation_policy_active(self) -> bool:
|
|
167
|
+
return not isinstance(self.state.confirmation_policy, NeverConfirm)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def is_confirmation_mode_active(self) -> bool:
|
|
171
|
+
"""Check if confirmation mode is active.
|
|
172
|
+
|
|
173
|
+
Returns True if BOTH conditions are met:
|
|
174
|
+
1. The conversation state has a security analyzer set (not None)
|
|
175
|
+
2. The confirmation policy is active
|
|
176
|
+
|
|
177
|
+
"""
|
|
178
|
+
return (
|
|
179
|
+
self.state.security_analyzer is not None and self.confirmation_policy_active
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@abstractmethod
|
|
183
|
+
def reject_pending_actions(
|
|
184
|
+
self, reason: str = "User rejected the action"
|
|
185
|
+
) -> None: ...
|
|
186
|
+
|
|
187
|
+
@abstractmethod
|
|
188
|
+
def pause(self) -> None: ...
|
|
189
|
+
|
|
190
|
+
@abstractmethod
|
|
191
|
+
def update_secrets(self, secrets: Mapping[str, SecretValue]) -> None: ...
|
|
192
|
+
|
|
193
|
+
@abstractmethod
|
|
194
|
+
def close(self) -> None: ...
|
|
195
|
+
|
|
196
|
+
@abstractmethod
|
|
197
|
+
def generate_title(self, llm: LLM | None = None, max_length: int = 50) -> str:
|
|
198
|
+
"""Generate a title for the conversation based on the first user message.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
llm: Optional LLM to use for title generation. If not provided,
|
|
202
|
+
uses the agent's LLM.
|
|
203
|
+
max_length: Maximum length of the generated title.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
A generated title for the conversation.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
ValueError: If no user messages are found in the conversation.
|
|
210
|
+
"""
|
|
211
|
+
...
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def get_persistence_dir(
|
|
215
|
+
persistence_base_dir: str | Path, conversation_id: ConversationID
|
|
216
|
+
) -> str:
|
|
217
|
+
"""Get the persistence directory for the conversation.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
persistence_base_dir: Base directory for persistence. Can be a string
|
|
221
|
+
path or Path object.
|
|
222
|
+
conversation_id: Unique conversation ID.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
String path to the conversation-specific persistence directory.
|
|
226
|
+
Always returns a normalized string path even if a Path was provided.
|
|
227
|
+
"""
|
|
228
|
+
return str(Path(persistence_base_dir) / conversation_id.hex)
|
|
229
|
+
|
|
230
|
+
@abstractmethod
|
|
231
|
+
def ask_agent(self, question: str) -> str:
|
|
232
|
+
"""Ask the agent a simple, stateless question and get a direct LLM response.
|
|
233
|
+
|
|
234
|
+
This bypasses the normal conversation flow and does **not** modify, persist,
|
|
235
|
+
or become part of the conversation state. The request is not remembered by
|
|
236
|
+
the main agent, no events are recorded, and execution status is untouched.
|
|
237
|
+
It is also thread-safe and may be called while `conversation.run()` is
|
|
238
|
+
executing in another thread.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
question: A simple string question to ask the agent
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
A string response from the agent
|
|
245
|
+
"""
|
|
246
|
+
...
|
|
247
|
+
|
|
248
|
+
@abstractmethod
|
|
249
|
+
def condense(self) -> None:
|
|
250
|
+
"""Force condensation of the conversation history.
|
|
251
|
+
|
|
252
|
+
This method uses the existing condensation request pattern to trigger
|
|
253
|
+
condensation. It adds a CondensationRequest event to the conversation
|
|
254
|
+
and forces the agent to take a single step to process it.
|
|
255
|
+
|
|
256
|
+
The condensation will be applied immediately and will modify the conversation
|
|
257
|
+
state by adding a condensation event to the history.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ValueError: If no condenser is configured or the condenser doesn't
|
|
261
|
+
handle condensation requests.
|
|
262
|
+
"""
|
|
263
|
+
...
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def compose_callbacks(callbacks: Iterable[CallbackType]) -> CallbackType:
|
|
267
|
+
"""Compose multiple callbacks into a single callback function.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
callbacks: An iterable of callback functions
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
A single callback function that calls all provided callbacks
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def composed(event) -> None:
|
|
277
|
+
for cb in callbacks:
|
|
278
|
+
if cb:
|
|
279
|
+
cb(event)
|
|
280
|
+
|
|
281
|
+
return cast(CallbackType, composed)
|