openhands-sdk 1.7.3__py3-none-any.whl → 1.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. openhands/sdk/__init__.py +2 -0
  2. openhands/sdk/agent/agent.py +31 -1
  3. openhands/sdk/agent/base.py +111 -67
  4. openhands/sdk/agent/prompts/system_prompt.j2 +1 -1
  5. openhands/sdk/agent/utils.py +3 -0
  6. openhands/sdk/context/agent_context.py +45 -3
  7. openhands/sdk/context/condenser/__init__.py +2 -0
  8. openhands/sdk/context/condenser/base.py +59 -8
  9. openhands/sdk/context/condenser/llm_summarizing_condenser.py +38 -10
  10. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +4 -0
  11. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +9 -0
  12. openhands/sdk/context/skills/__init__.py +12 -0
  13. openhands/sdk/context/skills/skill.py +425 -228
  14. openhands/sdk/context/skills/types.py +4 -0
  15. openhands/sdk/context/skills/utils.py +442 -0
  16. openhands/sdk/context/view.py +2 -0
  17. openhands/sdk/conversation/impl/local_conversation.py +42 -14
  18. openhands/sdk/conversation/impl/remote_conversation.py +99 -55
  19. openhands/sdk/conversation/state.py +54 -18
  20. openhands/sdk/event/llm_convertible/action.py +20 -0
  21. openhands/sdk/git/utils.py +31 -6
  22. openhands/sdk/hooks/conversation_hooks.py +57 -10
  23. openhands/sdk/llm/llm.py +59 -76
  24. openhands/sdk/llm/options/chat_options.py +4 -1
  25. openhands/sdk/llm/router/base.py +12 -0
  26. openhands/sdk/llm/utils/telemetry.py +2 -2
  27. openhands/sdk/llm/utils/verified_models.py +1 -1
  28. openhands/sdk/mcp/tool.py +3 -1
  29. openhands/sdk/plugin/__init__.py +22 -0
  30. openhands/sdk/plugin/plugin.py +299 -0
  31. openhands/sdk/plugin/types.py +226 -0
  32. openhands/sdk/tool/__init__.py +7 -1
  33. openhands/sdk/tool/builtins/__init__.py +4 -0
  34. openhands/sdk/tool/schema.py +6 -3
  35. openhands/sdk/tool/tool.py +60 -9
  36. openhands/sdk/utils/models.py +198 -472
  37. openhands/sdk/workspace/base.py +22 -0
  38. openhands/sdk/workspace/local.py +16 -0
  39. openhands/sdk/workspace/remote/async_remote_workspace.py +16 -0
  40. openhands/sdk/workspace/remote/base.py +16 -0
  41. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/METADATA +2 -2
  42. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/RECORD +44 -40
  43. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/WHEEL +0 -0
  44. {openhands_sdk-1.7.3.dist-info → openhands_sdk-1.8.0.dist-info}/top_level.txt +0 -0
@@ -489,7 +489,23 @@ class RemoteConversation(BaseConversation):
489
489
  self._hook_processor = None
490
490
  self._cleanup_initiated = False
491
491
 
492
- if conversation_id is None:
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
- if blocking:
723
- # Still wait for the existing run to complete
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 wait times out.
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
- resp = _send_request(
761
- self._client,
762
- "GET",
763
- f"/api/conversations/{self._id}",
764
- timeout=30,
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
- f"Run completed with status: {status} (elapsed: {elapsed:.1f}s)"
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
- try:
798
- events = self._state.events
799
- for idx in range(len(events) - 1, -1, -1):
800
- event = events[idx]
801
- if isinstance(event, ConversationErrorEvent):
802
- detail = event.detail.strip()
803
- code = event.code.strip()
804
- if detail and code:
805
- return f"{code}: {detail}"
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 AliasChoices, Field, PrivateAttr
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="Working directory for agent operations and tool execution",
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
- # ===== Public "events" facade (Sequence[Event]) =====
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
- If base_state.json exists: resume (attach EventLog,
178
- reconcile agent, enforce id).
179
- Else: create fresh (agent required), persist base, and return.
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
- # Enforce conversation id match
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
- # Reconcile agent config with deserialized one
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 = resolved
245
+ state.agent = agent
246
+ state.workspace = workspace
247
+ state.max_iterations = max_iterations
211
248
 
212
- state.stats = ConversationStats()
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")
@@ -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
- logger.warning("No valid git reference found")
157
- return None
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(event)
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) -> None:
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
- # TODO: Inject additional_context into the message
184
+ # Inject additional_context into extended_content
179
185
  if additional_context:
180
- logger.info(f"Hook injected context: {additional_context[:100]}...")
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,