openhands-sdk 1.7.3__py3-none-any.whl → 1.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands/sdk/__init__.py +2 -0
- openhands/sdk/agent/agent.py +31 -1
- openhands/sdk/agent/base.py +111 -67
- openhands/sdk/agent/prompts/system_prompt.j2 +1 -1
- openhands/sdk/agent/utils.py +3 -0
- openhands/sdk/context/agent_context.py +45 -3
- openhands/sdk/context/condenser/__init__.py +2 -0
- openhands/sdk/context/condenser/base.py +59 -8
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +38 -10
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +4 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +9 -0
- openhands/sdk/context/skills/__init__.py +12 -0
- openhands/sdk/context/skills/skill.py +425 -228
- openhands/sdk/context/skills/types.py +4 -0
- openhands/sdk/context/skills/utils.py +442 -0
- openhands/sdk/context/view.py +2 -0
- openhands/sdk/conversation/impl/local_conversation.py +42 -14
- openhands/sdk/conversation/impl/remote_conversation.py +99 -55
- openhands/sdk/conversation/state.py +54 -18
- openhands/sdk/event/llm_convertible/action.py +20 -0
- openhands/sdk/git/utils.py +31 -6
- openhands/sdk/hooks/conversation_hooks.py +57 -10
- openhands/sdk/llm/llm.py +59 -76
- openhands/sdk/llm/options/chat_options.py +4 -1
- openhands/sdk/llm/router/base.py +12 -0
- openhands/sdk/llm/utils/telemetry.py +2 -2
- openhands/sdk/llm/utils/verified_models.py +1 -1
- openhands/sdk/mcp/tool.py +3 -1
- openhands/sdk/plugin/__init__.py +22 -0
- openhands/sdk/plugin/plugin.py +299 -0
- openhands/sdk/plugin/types.py +226 -0
- openhands/sdk/tool/__init__.py +7 -1
- openhands/sdk/tool/builtins/__init__.py +4 -0
- openhands/sdk/tool/schema.py +6 -3
- openhands/sdk/tool/tool.py +60 -9
- openhands/sdk/utils/models.py +198 -472
- openhands/sdk/workspace/base.py +22 -0
- openhands/sdk/workspace/local.py +16 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
- openhands/sdk/workspace/remote/base.py +16 -0
- {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/METADATA +2 -2
- {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/RECORD +44 -40
- {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/WHEEL +0 -0
- {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/top_level.txt +0 -0
|
@@ -489,7 +489,23 @@ class RemoteConversation(BaseConversation):
|
|
|
489
489
|
self._hook_processor = None
|
|
490
490
|
self._cleanup_initiated = False
|
|
491
491
|
|
|
492
|
-
|
|
492
|
+
should_create = conversation_id is None
|
|
493
|
+
if conversation_id is not None:
|
|
494
|
+
# Try to attach to existing conversation
|
|
495
|
+
resp = _send_request(
|
|
496
|
+
self._client,
|
|
497
|
+
"GET",
|
|
498
|
+
f"/api/conversations/{conversation_id}",
|
|
499
|
+
acceptable_status_codes={404},
|
|
500
|
+
)
|
|
501
|
+
if resp.status_code == 404:
|
|
502
|
+
# Conversation doesn't exist, we'll create it
|
|
503
|
+
should_create = True
|
|
504
|
+
else:
|
|
505
|
+
# Conversation exists, use the provided ID
|
|
506
|
+
self._id = conversation_id
|
|
507
|
+
|
|
508
|
+
if should_create:
|
|
493
509
|
# Import here to avoid circular imports
|
|
494
510
|
from openhands.sdk.tool.registry import get_tool_module_qualnames
|
|
495
511
|
|
|
@@ -518,6 +534,9 @@ class RemoteConversation(BaseConversation):
|
|
|
518
534
|
else:
|
|
519
535
|
threshold_config = stuck_detection_thresholds
|
|
520
536
|
payload["stuck_detection_thresholds"] = threshold_config.model_dump()
|
|
537
|
+
# Include conversation_id if provided (for creating with specific ID)
|
|
538
|
+
if conversation_id is not None:
|
|
539
|
+
payload["conversation_id"] = str(conversation_id)
|
|
521
540
|
resp = _send_request(
|
|
522
541
|
self._client, "POST", "/api/conversations", json=payload
|
|
523
542
|
)
|
|
@@ -529,11 +548,6 @@ class RemoteConversation(BaseConversation):
|
|
|
529
548
|
"Invalid response from server: missing conversation id"
|
|
530
549
|
)
|
|
531
550
|
self._id = uuid.UUID(cid)
|
|
532
|
-
else:
|
|
533
|
-
# Attach to existing
|
|
534
|
-
self._id = conversation_id
|
|
535
|
-
# Validate it exists
|
|
536
|
-
_send_request(self._client, "GET", f"/api/conversations/{self._id}")
|
|
537
551
|
|
|
538
552
|
# Initialize the remote state
|
|
539
553
|
self._state = RemoteState(self._client, str(self._id))
|
|
@@ -719,12 +733,8 @@ class RemoteConversation(BaseConversation):
|
|
|
719
733
|
|
|
720
734
|
if resp.status_code == 409:
|
|
721
735
|
logger.info("Conversation is already running; skipping run trigger")
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
self._wait_for_run_completion(poll_interval, timeout)
|
|
725
|
-
return
|
|
726
|
-
|
|
727
|
-
logger.info(f"run() triggered successfully: {resp}")
|
|
736
|
+
else:
|
|
737
|
+
logger.info(f"run() triggered successfully: {resp}")
|
|
728
738
|
|
|
729
739
|
if blocking:
|
|
730
740
|
self._wait_for_run_completion(poll_interval, timeout)
|
|
@@ -741,7 +751,9 @@ class RemoteConversation(BaseConversation):
|
|
|
741
751
|
timeout: Maximum time in seconds to wait.
|
|
742
752
|
|
|
743
753
|
Raises:
|
|
744
|
-
ConversationRunError: If the
|
|
754
|
+
ConversationRunError: If the run fails, the conversation disappears,
|
|
755
|
+
or the wait times out. Transient network errors, 429s, and 5xx
|
|
756
|
+
responses are retried until timeout.
|
|
745
757
|
"""
|
|
746
758
|
start_time = time.monotonic()
|
|
747
759
|
|
|
@@ -757,56 +769,88 @@ class RemoteConversation(BaseConversation):
|
|
|
757
769
|
)
|
|
758
770
|
|
|
759
771
|
try:
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
)
|
|
766
|
-
info = resp.json()
|
|
767
|
-
status = info.get("execution_status")
|
|
768
|
-
|
|
769
|
-
if status != ConversationExecutionStatus.RUNNING.value:
|
|
770
|
-
if status == ConversationExecutionStatus.ERROR.value:
|
|
771
|
-
detail = self._get_last_error_detail()
|
|
772
|
-
raise ConversationRunError(
|
|
773
|
-
self._id,
|
|
774
|
-
RuntimeError(
|
|
775
|
-
detail or "Remote conversation ended with error"
|
|
776
|
-
),
|
|
777
|
-
)
|
|
778
|
-
if status == ConversationExecutionStatus.STUCK.value:
|
|
779
|
-
raise ConversationRunError(
|
|
780
|
-
self._id,
|
|
781
|
-
RuntimeError("Remote conversation got stuck"),
|
|
782
|
-
)
|
|
772
|
+
status = self._poll_status_once()
|
|
773
|
+
except Exception as exc:
|
|
774
|
+
self._handle_poll_exception(exc)
|
|
775
|
+
else:
|
|
776
|
+
if self._handle_conversation_status(status):
|
|
783
777
|
logger.info(
|
|
784
|
-
|
|
778
|
+
"Run completed with status: %s (elapsed: %.1fs)",
|
|
779
|
+
status,
|
|
780
|
+
elapsed,
|
|
785
781
|
)
|
|
786
782
|
return
|
|
787
783
|
|
|
788
|
-
except Exception as e:
|
|
789
|
-
# Log but continue polling - transient network errors shouldn't
|
|
790
|
-
# stop us from waiting for the run to complete
|
|
791
|
-
logger.warning(f"Error polling status (will retry): {e}")
|
|
792
|
-
|
|
793
784
|
time.sleep(poll_interval)
|
|
794
785
|
|
|
786
|
+
def _poll_status_once(self) -> str | None:
|
|
787
|
+
"""Fetch the current execution status from the remote conversation."""
|
|
788
|
+
resp = _send_request(
|
|
789
|
+
self._client,
|
|
790
|
+
"GET",
|
|
791
|
+
f"/api/conversations/{self._id}",
|
|
792
|
+
timeout=30,
|
|
793
|
+
)
|
|
794
|
+
info = resp.json()
|
|
795
|
+
return info.get("execution_status")
|
|
796
|
+
|
|
797
|
+
def _handle_conversation_status(self, status: str | None) -> bool:
|
|
798
|
+
"""Handle non-running statuses; return True if the run is complete."""
|
|
799
|
+
if status == ConversationExecutionStatus.RUNNING.value:
|
|
800
|
+
return False
|
|
801
|
+
if status == ConversationExecutionStatus.ERROR.value:
|
|
802
|
+
detail = self._get_last_error_detail()
|
|
803
|
+
raise ConversationRunError(
|
|
804
|
+
self._id,
|
|
805
|
+
RuntimeError(detail or "Remote conversation ended with error"),
|
|
806
|
+
)
|
|
807
|
+
if status == ConversationExecutionStatus.STUCK.value:
|
|
808
|
+
raise ConversationRunError(
|
|
809
|
+
self._id,
|
|
810
|
+
RuntimeError("Remote conversation got stuck"),
|
|
811
|
+
)
|
|
812
|
+
return True
|
|
813
|
+
|
|
814
|
+
def _handle_poll_exception(self, exc: Exception) -> None:
|
|
815
|
+
"""Classify polling exceptions into retryable vs terminal failures."""
|
|
816
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
817
|
+
status_code = exc.response.status_code
|
|
818
|
+
reason = exc.response.reason_phrase
|
|
819
|
+
if status_code == 404:
|
|
820
|
+
raise ConversationRunError(
|
|
821
|
+
self._id,
|
|
822
|
+
RuntimeError(
|
|
823
|
+
"Remote conversation not found (404). "
|
|
824
|
+
"The runtime may have been deleted."
|
|
825
|
+
),
|
|
826
|
+
) from exc
|
|
827
|
+
if 400 <= status_code < 500 and status_code != 429:
|
|
828
|
+
raise ConversationRunError(
|
|
829
|
+
self._id,
|
|
830
|
+
RuntimeError(f"Polling failed with HTTP {status_code} {reason}"),
|
|
831
|
+
) from exc
|
|
832
|
+
logger.warning(
|
|
833
|
+
"Error polling status (will retry): HTTP %d %s",
|
|
834
|
+
status_code,
|
|
835
|
+
reason,
|
|
836
|
+
)
|
|
837
|
+
return
|
|
838
|
+
if isinstance(exc, httpx.RequestError):
|
|
839
|
+
logger.warning(f"Error polling status (will retry): {exc}")
|
|
840
|
+
return
|
|
841
|
+
raise ConversationRunError(self._id, exc) from exc
|
|
842
|
+
|
|
795
843
|
def _get_last_error_detail(self) -> str | None:
|
|
796
844
|
"""Return the most recent ConversationErrorEvent detail, if available."""
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
return detail or code or None
|
|
807
|
-
except Exception as exc:
|
|
808
|
-
logger.debug("Failed to read conversation error detail: %s", exc)
|
|
809
|
-
return None
|
|
845
|
+
events = self._state.events
|
|
846
|
+
for idx in range(len(events) - 1, -1, -1):
|
|
847
|
+
event = events[idx]
|
|
848
|
+
if isinstance(event, ConversationErrorEvent):
|
|
849
|
+
detail = event.detail.strip()
|
|
850
|
+
code = event.code.strip()
|
|
851
|
+
if detail and code:
|
|
852
|
+
return f"{code}: {detail}"
|
|
853
|
+
return detail or code or None
|
|
810
854
|
|
|
811
855
|
def set_confirmation_policy(self, policy: ConfirmationPolicyBase) -> None:
|
|
812
856
|
payload = {"policy": policy.model_dump()}
|
|
@@ -5,7 +5,7 @@ from enum import Enum
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Any, Self
|
|
7
7
|
|
|
8
|
-
from pydantic import
|
|
8
|
+
from pydantic import Field, PrivateAttr, model_validator
|
|
9
9
|
|
|
10
10
|
from openhands.sdk.agent.base import AgentBase
|
|
11
11
|
from openhands.sdk.conversation.conversation_stats import ConversationStats
|
|
@@ -60,7 +60,10 @@ class ConversationState(OpenHandsModel):
|
|
|
60
60
|
)
|
|
61
61
|
workspace: BaseWorkspace = Field(
|
|
62
62
|
...,
|
|
63
|
-
description=
|
|
63
|
+
description=(
|
|
64
|
+
"Workspace used by the agent to execute commands and read/write files. "
|
|
65
|
+
"Not the process working directory."
|
|
66
|
+
),
|
|
64
67
|
)
|
|
65
68
|
persistence_dir: str | None = Field(
|
|
66
69
|
default="workspace/conversations",
|
|
@@ -116,8 +119,6 @@ class ConversationState(OpenHandsModel):
|
|
|
116
119
|
secret_registry: SecretRegistry = Field(
|
|
117
120
|
default_factory=SecretRegistry,
|
|
118
121
|
description="Registry for handling secrets and sensitive data",
|
|
119
|
-
validation_alias=AliasChoices("secret_registry", "secrets_manager"),
|
|
120
|
-
serialization_alias="secret_registry",
|
|
121
122
|
)
|
|
122
123
|
|
|
123
124
|
# ===== Private attrs (NOT Fields) =====
|
|
@@ -133,7 +134,14 @@ class ConversationState(OpenHandsModel):
|
|
|
133
134
|
default_factory=FIFOLock
|
|
134
135
|
) # FIFO lock for thread safety
|
|
135
136
|
|
|
136
|
-
|
|
137
|
+
@model_validator(mode="before")
|
|
138
|
+
@classmethod
|
|
139
|
+
def _handle_secrets_manager_alias(cls, data: Any) -> Any:
|
|
140
|
+
"""Handle legacy 'secrets_manager' field name for backward compatibility."""
|
|
141
|
+
if isinstance(data, dict) and "secrets_manager" in data:
|
|
142
|
+
data["secret_registry"] = data.pop("secrets_manager")
|
|
143
|
+
return data
|
|
144
|
+
|
|
137
145
|
@property
|
|
138
146
|
def events(self) -> EventLog:
|
|
139
147
|
return self._events
|
|
@@ -173,10 +181,35 @@ class ConversationState(OpenHandsModel):
|
|
|
173
181
|
max_iterations: int = 500,
|
|
174
182
|
stuck_detection: bool = True,
|
|
175
183
|
) -> "ConversationState":
|
|
176
|
-
"""
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
184
|
+
"""Create a new conversation state or resume from persistence.
|
|
185
|
+
|
|
186
|
+
This factory method handles both new conversation creation and resumption
|
|
187
|
+
from persisted state.
|
|
188
|
+
|
|
189
|
+
**New conversation:**
|
|
190
|
+
The provided Agent is used directly. Pydantic validation happens via the
|
|
191
|
+
cls() constructor.
|
|
192
|
+
|
|
193
|
+
**Restored conversation:**
|
|
194
|
+
The provided Agent is validated against the persisted agent using
|
|
195
|
+
agent.load(). Tools must match (they may have been used in conversation
|
|
196
|
+
history), but all other configuration can be freely changed: LLM,
|
|
197
|
+
agent_context, condenser, system prompts, etc.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
id: Unique conversation identifier
|
|
201
|
+
agent: The Agent to use (tools must match persisted on restore)
|
|
202
|
+
workspace: Working directory for agent operations
|
|
203
|
+
persistence_dir: Directory for persisting state and events
|
|
204
|
+
max_iterations: Maximum iterations per run
|
|
205
|
+
stuck_detection: Whether to enable stuck detection
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
ConversationState ready for use
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
ValueError: If conversation ID or tools mismatch on restore
|
|
212
|
+
ValidationError: If agent or other fields fail Pydantic validation
|
|
180
213
|
"""
|
|
181
214
|
file_store = (
|
|
182
215
|
LocalFileStore(persistence_dir, cache_limit_size=max_iterations)
|
|
@@ -193,23 +226,28 @@ class ConversationState(OpenHandsModel):
|
|
|
193
226
|
if base_text:
|
|
194
227
|
state = cls.model_validate(json.loads(base_text))
|
|
195
228
|
|
|
196
|
-
#
|
|
229
|
+
# Restore the conversation with the same id
|
|
197
230
|
if state.id != id:
|
|
198
231
|
raise ValueError(
|
|
199
232
|
f"Conversation ID mismatch: provided {id}, "
|
|
200
233
|
f"but persisted state has {state.id}"
|
|
201
234
|
)
|
|
202
235
|
|
|
203
|
-
#
|
|
204
|
-
resolved = agent.resolve_diff_from_deserialized(state.agent)
|
|
205
|
-
|
|
206
|
-
# Attach runtime handles and commit reconciled agent (may autosave)
|
|
236
|
+
# Attach event log early so we can read history for tool verification
|
|
207
237
|
state._fs = file_store
|
|
208
238
|
state._events = EventLog(file_store, dir_path=EVENTS_DIR)
|
|
239
|
+
|
|
240
|
+
# Verify compatibility (agent class + tools)
|
|
241
|
+
agent.verify(state.agent, events=state._events)
|
|
242
|
+
|
|
243
|
+
# Commit runtime-provided values (may autosave)
|
|
209
244
|
state._autosave_enabled = True
|
|
210
|
-
state.agent =
|
|
245
|
+
state.agent = agent
|
|
246
|
+
state.workspace = workspace
|
|
247
|
+
state.max_iterations = max_iterations
|
|
211
248
|
|
|
212
|
-
|
|
249
|
+
# Note: stats are already deserialized from base_state.json above.
|
|
250
|
+
# Do NOT reset stats here - this would lose accumulated metrics.
|
|
213
251
|
|
|
214
252
|
logger.info(
|
|
215
253
|
f"Resumed conversation {state.id} from persistent storage.\n"
|
|
@@ -232,8 +270,6 @@ class ConversationState(OpenHandsModel):
|
|
|
232
270
|
max_iterations=max_iterations,
|
|
233
271
|
stuck_detection=stuck_detection,
|
|
234
272
|
)
|
|
235
|
-
# Record existing analyzer configuration in state
|
|
236
|
-
state.security_analyzer = state.security_analyzer
|
|
237
273
|
state._fs = file_store
|
|
238
274
|
state._events = EventLog(file_store, dir_path=EVENTS_DIR)
|
|
239
275
|
state.stats = ConversationStats()
|
|
@@ -65,6 +65,20 @@ class ActionEvent(LLMConvertibleEvent):
|
|
|
65
65
|
description="The LLM's assessment of the safety risk of this action.",
|
|
66
66
|
)
|
|
67
67
|
|
|
68
|
+
summary: str | None = Field(
|
|
69
|
+
default=None,
|
|
70
|
+
description=(
|
|
71
|
+
"A concise summary (approximately 10 words) of what this action does, "
|
|
72
|
+
"provided by the LLM for explainability and debugging. "
|
|
73
|
+
"Examples of good summaries: "
|
|
74
|
+
"'editing configuration file for deployment settings' | "
|
|
75
|
+
"'searching codebase for authentication function definitions' | "
|
|
76
|
+
"'installing required dependencies from package manifest' | "
|
|
77
|
+
"'running tests to verify bug fix' | "
|
|
78
|
+
"'viewing directory structure to locate source files'"
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
|
|
68
82
|
@property
|
|
69
83
|
def visualize(self) -> Text:
|
|
70
84
|
"""Return Rich Text representation of this action event."""
|
|
@@ -73,6 +87,12 @@ class ActionEvent(LLMConvertibleEvent):
|
|
|
73
87
|
if self.security_risk != risk.SecurityRisk.UNKNOWN:
|
|
74
88
|
content.append(self.security_risk.visualize)
|
|
75
89
|
|
|
90
|
+
# Display summary if available
|
|
91
|
+
if self.summary:
|
|
92
|
+
content.append("Summary: ", style="bold cyan")
|
|
93
|
+
content.append(self.summary)
|
|
94
|
+
content.append("\n\n")
|
|
95
|
+
|
|
76
96
|
# Display reasoning content first if available
|
|
77
97
|
if self.reasoning_content:
|
|
78
98
|
content.append("Reasoning:\n", style="bold")
|
openhands/sdk/git/utils.py
CHANGED
|
@@ -73,6 +73,28 @@ def run_git_command(args: list[str], cwd: str | Path) -> str:
|
|
|
73
73
|
) from e
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
def _repo_has_commits(repo_dir: str | Path) -> bool:
|
|
77
|
+
"""Check if a git repository has any commits.
|
|
78
|
+
|
|
79
|
+
Uses 'git rev-list --count --all' which returns "0" for empty repos
|
|
80
|
+
without failing, avoiding ERROR logs for expected conditions.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
repo_dir: Path to the git repository
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True if the repository has at least one commit, False otherwise
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
count = run_git_command(
|
|
90
|
+
["git", "--no-pager", "rev-list", "--count", "--all"], repo_dir
|
|
91
|
+
)
|
|
92
|
+
return count.strip() != "0"
|
|
93
|
+
except GitCommandError:
|
|
94
|
+
logger.debug("Could not check commit count")
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
76
98
|
def get_valid_ref(repo_dir: str | Path) -> str | None:
|
|
77
99
|
"""Get a valid git reference to compare against.
|
|
78
100
|
|
|
@@ -90,6 +112,12 @@ def get_valid_ref(repo_dir: str | Path) -> str | None:
|
|
|
90
112
|
"""
|
|
91
113
|
refs_to_try = []
|
|
92
114
|
|
|
115
|
+
# Check if repo has any commits first. Empty repos (created with git init)
|
|
116
|
+
# won't have commits or remotes, so we can skip directly to the empty tree fallback.
|
|
117
|
+
if not _repo_has_commits(repo_dir):
|
|
118
|
+
logger.debug("Repository has no commits yet, using empty tree reference")
|
|
119
|
+
return GIT_EMPTY_TREE_HASH
|
|
120
|
+
|
|
93
121
|
# Try current branch's origin
|
|
94
122
|
try:
|
|
95
123
|
current_branch = run_git_command(
|
|
@@ -136,10 +164,6 @@ def get_valid_ref(repo_dir: str | Path) -> str | None:
|
|
|
136
164
|
except GitCommandError:
|
|
137
165
|
logger.debug("Could not get remote information")
|
|
138
166
|
|
|
139
|
-
# Add empty tree as fallback for new repositories
|
|
140
|
-
refs_to_try.append(GIT_EMPTY_TREE_HASH)
|
|
141
|
-
logger.debug(f"Added empty tree reference: {GIT_EMPTY_TREE_HASH}")
|
|
142
|
-
|
|
143
167
|
# Find the first valid reference
|
|
144
168
|
for ref in refs_to_try:
|
|
145
169
|
try:
|
|
@@ -153,8 +177,9 @@ def get_valid_ref(repo_dir: str | Path) -> str | None:
|
|
|
153
177
|
logger.debug(f"Reference not valid: {ref}")
|
|
154
178
|
continue
|
|
155
179
|
|
|
156
|
-
|
|
157
|
-
|
|
180
|
+
# Fallback to empty tree hash (always valid, no verification needed)
|
|
181
|
+
logger.debug(f"Using empty tree reference: {GIT_EMPTY_TREE_HASH}")
|
|
182
|
+
return GIT_EMPTY_TREE_HASH
|
|
158
183
|
|
|
159
184
|
|
|
160
185
|
def validate_git_repository(repo_dir: str | Path) -> Path:
|
|
@@ -6,6 +6,7 @@ from openhands.sdk.event import ActionEvent, Event, MessageEvent, ObservationEve
|
|
|
6
6
|
from openhands.sdk.hooks.config import HookConfig
|
|
7
7
|
from openhands.sdk.hooks.manager import HookManager
|
|
8
8
|
from openhands.sdk.hooks.types import HookEventType
|
|
9
|
+
from openhands.sdk.llm import TextContent
|
|
9
10
|
from openhands.sdk.logger import get_logger
|
|
10
11
|
|
|
11
12
|
|
|
@@ -41,6 +42,9 @@ class HookEventProcessor:
|
|
|
41
42
|
|
|
42
43
|
def on_event(self, event: Event) -> None:
|
|
43
44
|
"""Process an event and run appropriate hooks."""
|
|
45
|
+
# Track the event to pass to callbacks (may be modified by hooks)
|
|
46
|
+
callback_event = event
|
|
47
|
+
|
|
44
48
|
# Run PreToolUse hooks for action events
|
|
45
49
|
if isinstance(event, ActionEvent) and event.action is not None:
|
|
46
50
|
self._handle_pre_tool_use(event)
|
|
@@ -51,11 +55,11 @@ class HookEventProcessor:
|
|
|
51
55
|
|
|
52
56
|
# Run UserPromptSubmit hooks for user messages
|
|
53
57
|
if isinstance(event, MessageEvent) and event.source == "user":
|
|
54
|
-
self._handle_user_prompt_submit(event)
|
|
58
|
+
callback_event = self._handle_user_prompt_submit(event)
|
|
55
59
|
|
|
56
|
-
# Call original callback
|
|
60
|
+
# Call original callback with (possibly modified) event
|
|
57
61
|
if self.original_callback:
|
|
58
|
-
self.original_callback(
|
|
62
|
+
self.original_callback(callback_event)
|
|
59
63
|
|
|
60
64
|
def _handle_pre_tool_use(self, event: ActionEvent) -> None:
|
|
61
65
|
"""Handle PreToolUse hooks. Blocked actions are marked in conversation state."""
|
|
@@ -141,16 +145,18 @@ class HookEventProcessor:
|
|
|
141
145
|
if result.error:
|
|
142
146
|
logger.warning(f"PostToolUse hook error: {result.error}")
|
|
143
147
|
|
|
144
|
-
def _handle_user_prompt_submit(self, event: MessageEvent) ->
|
|
145
|
-
"""Handle UserPromptSubmit hooks before processing a user message.
|
|
148
|
+
def _handle_user_prompt_submit(self, event: MessageEvent) -> MessageEvent:
|
|
149
|
+
"""Handle UserPromptSubmit hooks before processing a user message.
|
|
150
|
+
|
|
151
|
+
Returns the (possibly modified) event. If hooks inject additional_context,
|
|
152
|
+
a new MessageEvent is created with the context appended to extended_content.
|
|
153
|
+
"""
|
|
146
154
|
if not self.hook_manager.has_hooks(HookEventType.USER_PROMPT_SUBMIT):
|
|
147
|
-
return
|
|
155
|
+
return event
|
|
148
156
|
|
|
149
157
|
# Extract message text
|
|
150
158
|
message = ""
|
|
151
159
|
if event.llm_message and event.llm_message.content:
|
|
152
|
-
from openhands.sdk.llm import TextContent
|
|
153
|
-
|
|
154
160
|
for content in event.llm_message.content:
|
|
155
161
|
if isinstance(content, TextContent):
|
|
156
162
|
message += content.text
|
|
@@ -175,9 +181,23 @@ class HookEventProcessor:
|
|
|
175
181
|
"after creating the Conversation."
|
|
176
182
|
)
|
|
177
183
|
|
|
178
|
-
#
|
|
184
|
+
# Inject additional_context into extended_content
|
|
179
185
|
if additional_context:
|
|
180
|
-
logger.
|
|
186
|
+
logger.debug(f"Hook injecting context: {additional_context[:100]}...")
|
|
187
|
+
new_extended_content = list(event.extended_content) + [
|
|
188
|
+
TextContent(text=additional_context)
|
|
189
|
+
]
|
|
190
|
+
# MessageEvent is frozen, so create a new one
|
|
191
|
+
event = MessageEvent(
|
|
192
|
+
source=event.source,
|
|
193
|
+
llm_message=event.llm_message,
|
|
194
|
+
llm_response_id=event.llm_response_id,
|
|
195
|
+
activated_skills=event.activated_skills,
|
|
196
|
+
extended_content=new_extended_content,
|
|
197
|
+
sender=event.sender,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return event
|
|
181
201
|
|
|
182
202
|
def is_action_blocked(self, action_id: str) -> bool:
|
|
183
203
|
"""Check if an action was blocked by a hook."""
|
|
@@ -205,6 +225,33 @@ class HookEventProcessor:
|
|
|
205
225
|
if r.error:
|
|
206
226
|
logger.warning(f"SessionEnd hook error: {r.error}")
|
|
207
227
|
|
|
228
|
+
def run_stop(self, reason: str | None = None) -> tuple[bool, str | None]:
|
|
229
|
+
"""Run Stop hooks. Returns (should_stop, feedback)."""
|
|
230
|
+
if not self.hook_manager.has_hooks(HookEventType.STOP):
|
|
231
|
+
return True, None
|
|
232
|
+
|
|
233
|
+
should_stop, results = self.hook_manager.run_stop(reason=reason)
|
|
234
|
+
|
|
235
|
+
# Log any errors
|
|
236
|
+
for r in results:
|
|
237
|
+
if r.error:
|
|
238
|
+
logger.warning(f"Stop hook error: {r.error}")
|
|
239
|
+
|
|
240
|
+
# Collect feedback if denied
|
|
241
|
+
feedback = None
|
|
242
|
+
if not should_stop:
|
|
243
|
+
reason_text = self.hook_manager.get_blocking_reason(results)
|
|
244
|
+
logger.info(f"Stop hook denied stopping: {reason_text}")
|
|
245
|
+
feedback_parts = [
|
|
246
|
+
r.additional_context for r in results if r.additional_context
|
|
247
|
+
]
|
|
248
|
+
if feedback_parts:
|
|
249
|
+
feedback = "\n".join(feedback_parts)
|
|
250
|
+
elif reason_text:
|
|
251
|
+
feedback = reason_text
|
|
252
|
+
|
|
253
|
+
return should_stop, feedback
|
|
254
|
+
|
|
208
255
|
|
|
209
256
|
def create_hook_callback(
|
|
210
257
|
hook_config: HookConfig | None = None,
|