openhands-sdk 1.7.3__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 (180) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +650 -0
  4. openhands/sdk/agent/base.py +457 -0
  5. openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
  6. openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
  7. openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
  8. openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
  9. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
  10. openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
  11. openhands/sdk/agent/prompts/security_policy.j2 +22 -0
  12. openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
  13. openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
  14. openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
  15. openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
  16. openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
  17. openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
  18. openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
  19. openhands/sdk/agent/utils.py +228 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +264 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +100 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +14 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/condenser/utils.py +149 -0
  29. openhands/sdk/context/prompts/__init__.py +6 -0
  30. openhands/sdk/context/prompts/prompt.py +114 -0
  31. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  32. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  33. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  34. openhands/sdk/context/skills/__init__.py +28 -0
  35. openhands/sdk/context/skills/exceptions.py +11 -0
  36. openhands/sdk/context/skills/skill.py +720 -0
  37. openhands/sdk/context/skills/trigger.py +36 -0
  38. openhands/sdk/context/skills/types.py +48 -0
  39. openhands/sdk/context/view.py +503 -0
  40. openhands/sdk/conversation/__init__.py +40 -0
  41. openhands/sdk/conversation/base.py +281 -0
  42. openhands/sdk/conversation/conversation.py +152 -0
  43. openhands/sdk/conversation/conversation_stats.py +85 -0
  44. openhands/sdk/conversation/event_store.py +157 -0
  45. openhands/sdk/conversation/events_list_base.py +17 -0
  46. openhands/sdk/conversation/exceptions.py +50 -0
  47. openhands/sdk/conversation/fifo_lock.py +133 -0
  48. openhands/sdk/conversation/impl/__init__.py +5 -0
  49. openhands/sdk/conversation/impl/local_conversation.py +665 -0
  50. openhands/sdk/conversation/impl/remote_conversation.py +956 -0
  51. openhands/sdk/conversation/persistence_const.py +9 -0
  52. openhands/sdk/conversation/response_utils.py +41 -0
  53. openhands/sdk/conversation/secret_registry.py +126 -0
  54. openhands/sdk/conversation/serialization_diff.py +0 -0
  55. openhands/sdk/conversation/state.py +392 -0
  56. openhands/sdk/conversation/stuck_detector.py +311 -0
  57. openhands/sdk/conversation/title_utils.py +191 -0
  58. openhands/sdk/conversation/types.py +45 -0
  59. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  60. openhands/sdk/conversation/visualizer/base.py +67 -0
  61. openhands/sdk/conversation/visualizer/default.py +373 -0
  62. openhands/sdk/critic/__init__.py +15 -0
  63. openhands/sdk/critic/base.py +38 -0
  64. openhands/sdk/critic/impl/__init__.py +12 -0
  65. openhands/sdk/critic/impl/agent_finished.py +83 -0
  66. openhands/sdk/critic/impl/empty_patch.py +49 -0
  67. openhands/sdk/critic/impl/pass_critic.py +42 -0
  68. openhands/sdk/event/__init__.py +42 -0
  69. openhands/sdk/event/base.py +149 -0
  70. openhands/sdk/event/condenser.py +82 -0
  71. openhands/sdk/event/conversation_error.py +25 -0
  72. openhands/sdk/event/conversation_state.py +104 -0
  73. openhands/sdk/event/llm_completion_log.py +39 -0
  74. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  75. openhands/sdk/event/llm_convertible/action.py +139 -0
  76. openhands/sdk/event/llm_convertible/message.py +142 -0
  77. openhands/sdk/event/llm_convertible/observation.py +141 -0
  78. openhands/sdk/event/llm_convertible/system.py +61 -0
  79. openhands/sdk/event/token.py +16 -0
  80. openhands/sdk/event/types.py +11 -0
  81. openhands/sdk/event/user_action.py +21 -0
  82. openhands/sdk/git/exceptions.py +43 -0
  83. openhands/sdk/git/git_changes.py +249 -0
  84. openhands/sdk/git/git_diff.py +129 -0
  85. openhands/sdk/git/models.py +21 -0
  86. openhands/sdk/git/utils.py +189 -0
  87. openhands/sdk/hooks/__init__.py +30 -0
  88. openhands/sdk/hooks/config.py +180 -0
  89. openhands/sdk/hooks/conversation_hooks.py +227 -0
  90. openhands/sdk/hooks/executor.py +155 -0
  91. openhands/sdk/hooks/manager.py +170 -0
  92. openhands/sdk/hooks/types.py +40 -0
  93. openhands/sdk/io/__init__.py +6 -0
  94. openhands/sdk/io/base.py +48 -0
  95. openhands/sdk/io/cache.py +85 -0
  96. openhands/sdk/io/local.py +119 -0
  97. openhands/sdk/io/memory.py +54 -0
  98. openhands/sdk/llm/__init__.py +45 -0
  99. openhands/sdk/llm/exceptions/__init__.py +45 -0
  100. openhands/sdk/llm/exceptions/classifier.py +50 -0
  101. openhands/sdk/llm/exceptions/mapping.py +54 -0
  102. openhands/sdk/llm/exceptions/types.py +101 -0
  103. openhands/sdk/llm/llm.py +1140 -0
  104. openhands/sdk/llm/llm_registry.py +122 -0
  105. openhands/sdk/llm/llm_response.py +59 -0
  106. openhands/sdk/llm/message.py +656 -0
  107. openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
  108. openhands/sdk/llm/mixins/non_native_fc.py +97 -0
  109. openhands/sdk/llm/options/__init__.py +1 -0
  110. openhands/sdk/llm/options/chat_options.py +93 -0
  111. openhands/sdk/llm/options/common.py +19 -0
  112. openhands/sdk/llm/options/responses_options.py +67 -0
  113. openhands/sdk/llm/router/__init__.py +10 -0
  114. openhands/sdk/llm/router/base.py +117 -0
  115. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  116. openhands/sdk/llm/router/impl/random.py +22 -0
  117. openhands/sdk/llm/streaming.py +9 -0
  118. openhands/sdk/llm/utils/metrics.py +312 -0
  119. openhands/sdk/llm/utils/model_features.py +192 -0
  120. openhands/sdk/llm/utils/model_info.py +90 -0
  121. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  122. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  123. openhands/sdk/llm/utils/telemetry.py +362 -0
  124. openhands/sdk/llm/utils/unverified_models.py +156 -0
  125. openhands/sdk/llm/utils/verified_models.py +65 -0
  126. openhands/sdk/logger/__init__.py +22 -0
  127. openhands/sdk/logger/logger.py +195 -0
  128. openhands/sdk/logger/rolling.py +113 -0
  129. openhands/sdk/mcp/__init__.py +24 -0
  130. openhands/sdk/mcp/client.py +76 -0
  131. openhands/sdk/mcp/definition.py +106 -0
  132. openhands/sdk/mcp/exceptions.py +19 -0
  133. openhands/sdk/mcp/tool.py +270 -0
  134. openhands/sdk/mcp/utils.py +83 -0
  135. openhands/sdk/observability/__init__.py +4 -0
  136. openhands/sdk/observability/laminar.py +166 -0
  137. openhands/sdk/observability/utils.py +20 -0
  138. openhands/sdk/py.typed +0 -0
  139. openhands/sdk/secret/__init__.py +19 -0
  140. openhands/sdk/secret/secrets.py +92 -0
  141. openhands/sdk/security/__init__.py +6 -0
  142. openhands/sdk/security/analyzer.py +111 -0
  143. openhands/sdk/security/confirmation_policy.py +61 -0
  144. openhands/sdk/security/llm_analyzer.py +29 -0
  145. openhands/sdk/security/risk.py +100 -0
  146. openhands/sdk/tool/__init__.py +34 -0
  147. openhands/sdk/tool/builtins/__init__.py +34 -0
  148. openhands/sdk/tool/builtins/finish.py +106 -0
  149. openhands/sdk/tool/builtins/think.py +117 -0
  150. openhands/sdk/tool/registry.py +184 -0
  151. openhands/sdk/tool/schema.py +286 -0
  152. openhands/sdk/tool/spec.py +39 -0
  153. openhands/sdk/tool/tool.py +481 -0
  154. openhands/sdk/utils/__init__.py +22 -0
  155. openhands/sdk/utils/async_executor.py +115 -0
  156. openhands/sdk/utils/async_utils.py +39 -0
  157. openhands/sdk/utils/cipher.py +68 -0
  158. openhands/sdk/utils/command.py +90 -0
  159. openhands/sdk/utils/deprecation.py +166 -0
  160. openhands/sdk/utils/github.py +44 -0
  161. openhands/sdk/utils/json.py +48 -0
  162. openhands/sdk/utils/models.py +570 -0
  163. openhands/sdk/utils/paging.py +63 -0
  164. openhands/sdk/utils/pydantic_diff.py +85 -0
  165. openhands/sdk/utils/pydantic_secrets.py +64 -0
  166. openhands/sdk/utils/truncate.py +117 -0
  167. openhands/sdk/utils/visualize.py +58 -0
  168. openhands/sdk/workspace/__init__.py +17 -0
  169. openhands/sdk/workspace/base.py +158 -0
  170. openhands/sdk/workspace/local.py +189 -0
  171. openhands/sdk/workspace/models.py +35 -0
  172. openhands/sdk/workspace/remote/__init__.py +8 -0
  173. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  174. openhands/sdk/workspace/remote/base.py +164 -0
  175. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  176. openhands/sdk/workspace/workspace.py +49 -0
  177. openhands_sdk-1.7.3.dist-info/METADATA +17 -0
  178. openhands_sdk-1.7.3.dist-info/RECORD +180 -0
  179. openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
  180. openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,9 @@
1
+ import re
2
+
3
+
4
+ BASE_STATE = "base_state.json"
5
+ EVENTS_DIR = "events"
6
+ EVENT_NAME_RE = re.compile(
7
+ r"^event-(?P<idx>\d{5})-(?P<event_id>[0-9a-fA-F\-]{8,})\.json$"
8
+ )
9
+ EVENT_FILE_PATTERN = "event-{idx:05d}-{event_id}.json"
@@ -0,0 +1,41 @@
1
+ """Utility functions for extracting agent responses from conversation events."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from openhands.sdk.event import ActionEvent, MessageEvent
6
+ from openhands.sdk.event.base import Event
7
+ from openhands.sdk.llm.message import content_to_str
8
+ from openhands.sdk.tool.builtins.finish import FinishAction, FinishTool
9
+
10
+
11
+ def get_agent_final_response(events: Sequence[Event]) -> str:
12
+ """Extract the final response from the agent.
13
+
14
+ An agent can end a conversation in two ways:
15
+ 1. By calling the finish tool
16
+ 2. By returning a text message with no tool calls
17
+
18
+ Args:
19
+ events: List of conversation events to search through.
20
+
21
+ Returns:
22
+ The final response message from the agent, or empty string if not found.
23
+ """
24
+ # Find the last finish action or message event from the agent
25
+ for event in reversed(events):
26
+ # Case 1: finish tool call
27
+ if (
28
+ isinstance(event, ActionEvent)
29
+ and event.source == "agent"
30
+ and event.tool_name == FinishTool.name
31
+ ):
32
+ # Extract message from finish tool call
33
+ if event.action is not None and isinstance(event.action, FinishAction):
34
+ return event.action.message
35
+ else:
36
+ break
37
+ # Case 2: text message with no tool calls (MessageEvent)
38
+ elif isinstance(event, MessageEvent) and event.source == "agent":
39
+ text_parts = content_to_str(event.llm_message.content)
40
+ return "".join(text_parts)
41
+ return ""
@@ -0,0 +1,126 @@
1
+ """Secrets manager for handling sensitive data in conversations."""
2
+
3
+ from collections.abc import Mapping
4
+
5
+ from pydantic import Field, PrivateAttr, SecretStr
6
+
7
+ from openhands.sdk.logger import get_logger
8
+ from openhands.sdk.secret import SecretSource, SecretValue, StaticSecret
9
+ from openhands.sdk.utils.models import OpenHandsModel
10
+
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class SecretRegistry(OpenHandsModel):
16
+ """Manages secrets and injects them into bash commands when needed.
17
+
18
+ The secret registry stores a mapping of secret keys to SecretSources
19
+ that retrieve the actual secret values. When a bash command is about to be
20
+ executed, it scans the command for any secret keys and injects the corresponding
21
+ environment variables.
22
+
23
+ Secret sources will redact / encrypt their sensitive values as appropriate when
24
+ serializing, depending on the content of the context. If a context is present
25
+ and contains a 'cipher' object, this is used for encryption. If it contains a
26
+ boolean 'expose_secrets' flag set to True, secrets are dunped in plain text.
27
+ Otherwise secrets are redacted.
28
+
29
+ Additionally, it tracks the latest exported values to enable consistent masking
30
+ even when callable secrets fail on subsequent calls.
31
+ """
32
+
33
+ secret_sources: dict[str, SecretSource] = Field(default_factory=dict)
34
+ _exported_values: dict[str, str] = PrivateAttr(default_factory=dict)
35
+
36
+ def update_secrets(
37
+ self,
38
+ secrets: Mapping[str, SecretValue],
39
+ ) -> None:
40
+ """Add or update secrets in the manager.
41
+
42
+ Args:
43
+ secrets: Dictionary mapping secret keys to either string values
44
+ or callable functions that return string values
45
+ """
46
+ secret_sources = {name: _wrap_secret(value) for name, value in secrets.items()}
47
+ self.secret_sources.update(secret_sources)
48
+
49
+ def find_secrets_in_text(self, text: str) -> set[str]:
50
+ """Find all secret keys mentioned in the given text.
51
+
52
+ Args:
53
+ text: The text to search for secret keys
54
+
55
+ Returns:
56
+ Set of secret keys found in the text
57
+ """
58
+ found_keys = set()
59
+ for key in self.secret_sources.keys():
60
+ if key.lower() in text.lower():
61
+ found_keys.add(key)
62
+ return found_keys
63
+
64
+ def get_secrets_as_env_vars(self, command: str) -> dict[str, str]:
65
+ """Get secrets that should be exported as environment variables for a command.
66
+
67
+ Args:
68
+ command: The bash command to check for secret references
69
+
70
+ Returns:
71
+ Dictionary of environment variables to export (key -> value)
72
+ """
73
+ found_secrets = self.find_secrets_in_text(command)
74
+
75
+ if not found_secrets:
76
+ return {}
77
+
78
+ logger.debug(f"Found secrets in command: {found_secrets}")
79
+
80
+ env_vars = {}
81
+ for key in found_secrets:
82
+ try:
83
+ source = self.secret_sources[key]
84
+ value = source.get_value()
85
+ if value:
86
+ env_vars[key] = value
87
+ # Track successfully exported values for masking
88
+ self._exported_values[key] = value
89
+ except Exception as e:
90
+ logger.error(f"Failed to retrieve secret for key '{key}': {e}")
91
+ continue
92
+
93
+ logger.debug(f"Prepared {len(env_vars)} secrets as environment variables")
94
+ return env_vars
95
+
96
+ def mask_secrets_in_output(self, text: str) -> str:
97
+ """Mask secret values in the given text.
98
+
99
+ This method uses both the current exported values and attempts to get
100
+ fresh values from callables to ensure comprehensive masking.
101
+
102
+ Args:
103
+ text: The text to mask secrets in
104
+
105
+ Returns:
106
+ Text with secret values replaced by <secret-hidden>
107
+ """
108
+ if not text:
109
+ return text
110
+
111
+ masked_text = text
112
+
113
+ # First, mask using currently exported values (always available)
114
+ for value in self._exported_values.values():
115
+ masked_text = masked_text.replace(value, "<secret-hidden>")
116
+
117
+ return masked_text
118
+
119
+
120
+ def _wrap_secret(value: SecretValue) -> SecretSource:
121
+ """Convert the value given to a secret source"""
122
+ if isinstance(value, SecretSource):
123
+ return value
124
+ if isinstance(value, str):
125
+ return StaticSecret(value=SecretStr(value))
126
+ raise ValueError("Invalid SecretValue")
File without changes
@@ -0,0 +1,392 @@
1
+ # state.py
2
+ import json
3
+ from collections.abc import Sequence
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Any, Self
7
+
8
+ from pydantic import AliasChoices, Field, PrivateAttr
9
+
10
+ from openhands.sdk.agent.base import AgentBase
11
+ from openhands.sdk.conversation.conversation_stats import ConversationStats
12
+ from openhands.sdk.conversation.event_store import EventLog
13
+ from openhands.sdk.conversation.fifo_lock import FIFOLock
14
+ from openhands.sdk.conversation.persistence_const import BASE_STATE, EVENTS_DIR
15
+ from openhands.sdk.conversation.secret_registry import SecretRegistry
16
+ from openhands.sdk.conversation.types import ConversationCallbackType, ConversationID
17
+ from openhands.sdk.event import ActionEvent, ObservationEvent, UserRejectObservation
18
+ from openhands.sdk.event.base import Event
19
+ from openhands.sdk.io import FileStore, InMemoryFileStore, LocalFileStore
20
+ from openhands.sdk.logger import get_logger
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.utils.models import OpenHandsModel
27
+ from openhands.sdk.workspace.base import BaseWorkspace
28
+
29
+
30
+ logger = get_logger(__name__)
31
+
32
+
33
+ class ConversationExecutionStatus(str, Enum):
34
+ """Enum representing the current execution state of the conversation."""
35
+
36
+ IDLE = "idle" # Conversation is ready to receive tasks
37
+ RUNNING = "running" # Conversation is actively processing
38
+ PAUSED = "paused" # Conversation execution is paused by user
39
+ WAITING_FOR_CONFIRMATION = (
40
+ "waiting_for_confirmation" # Conversation is waiting for user confirmation
41
+ )
42
+ FINISHED = "finished" # Conversation has completed the current task
43
+ ERROR = "error" # Conversation encountered an error (optional for future use)
44
+ STUCK = "stuck" # Conversation is stuck in a loop or unable to proceed
45
+ DELETING = "deleting" # Conversation is in the process of being deleted
46
+
47
+
48
+ class ConversationState(OpenHandsModel):
49
+ # ===== Public, validated fields =====
50
+ id: ConversationID = Field(description="Unique conversation ID")
51
+
52
+ agent: AgentBase = Field(
53
+ ...,
54
+ description=(
55
+ "The agent running in the conversation. "
56
+ "This is persisted to allow resuming conversations and "
57
+ "check agent configuration to handle e.g., tool changes, "
58
+ "LLM changes, etc."
59
+ ),
60
+ )
61
+ workspace: BaseWorkspace = Field(
62
+ ...,
63
+ description="Working directory for agent operations and tool execution",
64
+ )
65
+ persistence_dir: str | None = Field(
66
+ default="workspace/conversations",
67
+ description="Directory for persisting conversation state and events. "
68
+ "If None, conversation will not be persisted.",
69
+ )
70
+
71
+ max_iterations: int = Field(
72
+ default=500,
73
+ gt=0,
74
+ description="Maximum number of iterations the agent can "
75
+ "perform in a single run.",
76
+ )
77
+ stuck_detection: bool = Field(
78
+ default=True,
79
+ description="Whether to enable stuck detection for the agent.",
80
+ )
81
+
82
+ # Enum-based state management
83
+ execution_status: ConversationExecutionStatus = Field(
84
+ default=ConversationExecutionStatus.IDLE
85
+ )
86
+ confirmation_policy: ConfirmationPolicyBase = NeverConfirm()
87
+ security_analyzer: SecurityAnalyzerBase | None = Field(
88
+ default=None,
89
+ description="Optional security analyzer to evaluate action risks.",
90
+ )
91
+
92
+ activated_knowledge_skills: list[str] = Field(
93
+ default_factory=list,
94
+ description="List of activated knowledge skills name",
95
+ )
96
+
97
+ # Hook-blocked actions: action_id -> blocking reason
98
+ blocked_actions: dict[str, str] = Field(
99
+ default_factory=dict,
100
+ description="Actions blocked by PreToolUse hooks, keyed by action ID",
101
+ )
102
+
103
+ # Hook-blocked messages: message_id -> blocking reason
104
+ blocked_messages: dict[str, str] = Field(
105
+ default_factory=dict,
106
+ description="Messages blocked by UserPromptSubmit hooks, keyed by message ID",
107
+ )
108
+
109
+ # Conversation statistics for LLM usage tracking
110
+ stats: ConversationStats = Field(
111
+ default_factory=ConversationStats,
112
+ description="Conversation statistics for tracking LLM metrics",
113
+ )
114
+
115
+ # Secret registry for handling sensitive data
116
+ secret_registry: SecretRegistry = Field(
117
+ default_factory=SecretRegistry,
118
+ description="Registry for handling secrets and sensitive data",
119
+ validation_alias=AliasChoices("secret_registry", "secrets_manager"),
120
+ serialization_alias="secret_registry",
121
+ )
122
+
123
+ # ===== Private attrs (NOT Fields) =====
124
+ _fs: FileStore = PrivateAttr() # filestore for persistence
125
+ _events: EventLog = PrivateAttr() # now the storage for events
126
+ _autosave_enabled: bool = PrivateAttr(
127
+ default=False
128
+ ) # to avoid recursion during init
129
+ _on_state_change: ConversationCallbackType | None = PrivateAttr(
130
+ default=None
131
+ ) # callback for state changes
132
+ _lock: FIFOLock = PrivateAttr(
133
+ default_factory=FIFOLock
134
+ ) # FIFO lock for thread safety
135
+
136
+ # ===== Public "events" facade (Sequence[Event]) =====
137
+ @property
138
+ def events(self) -> EventLog:
139
+ return self._events
140
+
141
+ @property
142
+ def env_observation_persistence_dir(self) -> str | None:
143
+ """Directory for persisting environment observation files."""
144
+ if self.persistence_dir is None:
145
+ return None
146
+ return str(Path(self.persistence_dir) / "observations")
147
+
148
+ def set_on_state_change(self, callback: ConversationCallbackType | None) -> None:
149
+ """Set a callback to be called when state changes.
150
+
151
+ Args:
152
+ callback: A function that takes an Event (ConversationStateUpdateEvent)
153
+ or None to remove the callback
154
+ """
155
+ self._on_state_change = callback
156
+
157
+ # ===== Base snapshot helpers (same FileStore usage you had) =====
158
+ def _save_base_state(self, fs: FileStore) -> None:
159
+ """
160
+ Persist base state snapshot (no events; events are file-backed).
161
+ """
162
+ payload = self.model_dump_json(exclude_none=True)
163
+ fs.write(BASE_STATE, payload)
164
+
165
+ # ===== Factory: open-or-create (no load/save methods needed) =====
166
+ @classmethod
167
+ def create(
168
+ cls: type["ConversationState"],
169
+ id: ConversationID,
170
+ agent: AgentBase,
171
+ workspace: BaseWorkspace,
172
+ persistence_dir: str | None = None,
173
+ max_iterations: int = 500,
174
+ stuck_detection: bool = True,
175
+ ) -> "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.
180
+ """
181
+ file_store = (
182
+ LocalFileStore(persistence_dir, cache_limit_size=max_iterations)
183
+ if persistence_dir
184
+ else InMemoryFileStore()
185
+ )
186
+
187
+ try:
188
+ base_text = file_store.read(BASE_STATE)
189
+ except FileNotFoundError:
190
+ base_text = None
191
+
192
+ # ---- Resume path ----
193
+ if base_text:
194
+ state = cls.model_validate(json.loads(base_text))
195
+
196
+ # Enforce conversation id match
197
+ if state.id != id:
198
+ raise ValueError(
199
+ f"Conversation ID mismatch: provided {id}, "
200
+ f"but persisted state has {state.id}"
201
+ )
202
+
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)
207
+ state._fs = file_store
208
+ state._events = EventLog(file_store, dir_path=EVENTS_DIR)
209
+ state._autosave_enabled = True
210
+ state.agent = resolved
211
+
212
+ state.stats = ConversationStats()
213
+
214
+ logger.info(
215
+ f"Resumed conversation {state.id} from persistent storage.\n"
216
+ f"State: {state.model_dump(exclude={'agent'})}\n"
217
+ f"Agent: {state.agent.model_dump_succint()}"
218
+ )
219
+ return state
220
+
221
+ # ---- Fresh path ----
222
+ if agent is None:
223
+ raise ValueError(
224
+ "agent is required when initializing a new ConversationState"
225
+ )
226
+
227
+ state = cls(
228
+ id=id,
229
+ agent=agent,
230
+ workspace=workspace,
231
+ persistence_dir=persistence_dir,
232
+ max_iterations=max_iterations,
233
+ stuck_detection=stuck_detection,
234
+ )
235
+ # Record existing analyzer configuration in state
236
+ state.security_analyzer = state.security_analyzer
237
+ state._fs = file_store
238
+ state._events = EventLog(file_store, dir_path=EVENTS_DIR)
239
+ state.stats = ConversationStats()
240
+
241
+ state._save_base_state(file_store) # initial snapshot
242
+ state._autosave_enabled = True
243
+ logger.info(
244
+ f"Created new conversation {state.id}\n"
245
+ f"State: {state.model_dump(exclude={'agent'})}\n"
246
+ f"Agent: {state.agent.model_dump_succint()}"
247
+ )
248
+ return state
249
+
250
+ # ===== Auto-persist base on public field changes =====
251
+ def __setattr__(self, name, value):
252
+ # Only autosave when:
253
+ # - autosave is enabled (set post-init)
254
+ # - the attribute is a *public field* (not a PrivateAttr)
255
+ # - we have a filestore to write to
256
+ _sentinel = object()
257
+ old = getattr(self, name, _sentinel)
258
+ super().__setattr__(name, value)
259
+
260
+ is_field = name in self.__class__.model_fields
261
+ autosave_enabled = getattr(self, "_autosave_enabled", False)
262
+ fs = getattr(self, "_fs", None)
263
+
264
+ if not (autosave_enabled and is_field and fs is not None):
265
+ return
266
+
267
+ if old is _sentinel or old != value:
268
+ try:
269
+ self._save_base_state(fs)
270
+ except Exception as e:
271
+ logger.exception("Auto-persist base_state failed", exc_info=True)
272
+ raise e
273
+
274
+ # Call state change callback if set
275
+ callback = getattr(self, "_on_state_change", None)
276
+ if callback is not None and old is not _sentinel:
277
+ try:
278
+ # Import here to avoid circular imports
279
+ from openhands.sdk.event.conversation_state import (
280
+ ConversationStateUpdateEvent,
281
+ )
282
+
283
+ # Create a ConversationStateUpdateEvent with the changed field
284
+ state_update_event = ConversationStateUpdateEvent(
285
+ key=name, value=value
286
+ )
287
+ callback(state_update_event)
288
+ except Exception:
289
+ logger.exception(
290
+ f"State change callback failed for field {name}", exc_info=True
291
+ )
292
+
293
+ def block_action(self, action_id: str, reason: str) -> None:
294
+ """Persistently record a hook-blocked action."""
295
+ self.blocked_actions = {**self.blocked_actions, action_id: reason}
296
+
297
+ def pop_blocked_action(self, action_id: str) -> str | None:
298
+ """Remove and return a hook-blocked action reason, if present."""
299
+ if action_id not in self.blocked_actions:
300
+ return None
301
+ updated = dict(self.blocked_actions)
302
+ reason = updated.pop(action_id)
303
+ self.blocked_actions = updated
304
+ return reason
305
+
306
+ def block_message(self, message_id: str, reason: str) -> None:
307
+ """Persistently record a hook-blocked user message."""
308
+ self.blocked_messages = {**self.blocked_messages, message_id: reason}
309
+
310
+ def pop_blocked_message(self, message_id: str) -> str | None:
311
+ """Remove and return a hook-blocked message reason, if present."""
312
+ if message_id not in self.blocked_messages:
313
+ return None
314
+ updated = dict(self.blocked_messages)
315
+ reason = updated.pop(message_id)
316
+ self.blocked_messages = updated
317
+ return reason
318
+
319
+ @staticmethod
320
+ def get_unmatched_actions(events: Sequence[Event]) -> list[ActionEvent]:
321
+ """Find actions in the event history that don't have matching observations.
322
+
323
+ This method identifies ActionEvents that don't have corresponding
324
+ ObservationEvents or UserRejectObservations, which typically indicates
325
+ actions that are pending confirmation or execution.
326
+
327
+ Args:
328
+ events: List of events to search through
329
+
330
+ Returns:
331
+ List of ActionEvent objects that don't have corresponding observations,
332
+ in chronological order
333
+ """
334
+ observed_action_ids = set()
335
+ unmatched_actions = []
336
+ # Search in reverse - recent events are more likely to be unmatched
337
+ for event in reversed(events):
338
+ if isinstance(event, (ObservationEvent, UserRejectObservation)):
339
+ observed_action_ids.add(event.action_id)
340
+ elif isinstance(event, ActionEvent):
341
+ # Only executable actions (validated) are considered pending
342
+ if event.action is not None and event.id not in observed_action_ids:
343
+ # Insert at beginning to maintain chronological order in result
344
+ unmatched_actions.insert(0, event)
345
+
346
+ return unmatched_actions
347
+
348
+ # ===== FIFOLock delegation methods =====
349
+ def acquire(self, blocking: bool = True, timeout: float = -1) -> bool:
350
+ """
351
+ Acquire the lock.
352
+
353
+ Args:
354
+ blocking: If True, block until lock is acquired. If False, return
355
+ immediately.
356
+ timeout: Maximum time to wait for lock (ignored if blocking=False).
357
+ -1 means wait indefinitely.
358
+
359
+ Returns:
360
+ True if lock was acquired, False otherwise.
361
+ """
362
+ return self._lock.acquire(blocking=blocking, timeout=timeout)
363
+
364
+ def release(self) -> None:
365
+ """
366
+ Release the lock.
367
+
368
+ Raises:
369
+ RuntimeError: If the current thread doesn't own the lock.
370
+ """
371
+ self._lock.release()
372
+
373
+ def __enter__(self: Self) -> Self:
374
+ """Context manager entry."""
375
+ self._lock.acquire()
376
+ return self
377
+
378
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
379
+ """Context manager exit."""
380
+ self._lock.release()
381
+
382
+ def locked(self) -> bool:
383
+ """
384
+ Return True if the lock is currently held by any thread.
385
+ """
386
+ return self._lock.locked()
387
+
388
+ def owned(self) -> bool:
389
+ """
390
+ Return True if the lock is currently held by the calling thread.
391
+ """
392
+ return self._lock.owned()