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.
- openhands/sdk/__init__.py +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +650 -0
- openhands/sdk/agent/base.py +457 -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 +2 -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 +228 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +264 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +100 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
- openhands/sdk/context/condenser/no_op_condenser.py +14 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/condenser/utils.py +149 -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 +720 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +503 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +152 -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 +665 -0
- openhands/sdk/conversation/impl/remote_conversation.py +956 -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 +392 -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/hooks/__init__.py +30 -0
- openhands/sdk/hooks/config.py +180 -0
- openhands/sdk/hooks/conversation_hooks.py +227 -0
- openhands/sdk/hooks/executor.py +155 -0
- openhands/sdk/hooks/manager.py +170 -0
- openhands/sdk/hooks/types.py +40 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/cache.py +85 -0
- openhands/sdk/io/local.py +119 -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 +1288 -0
- openhands/sdk/llm/mixins/non_native_fc.py +97 -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 +192 -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 +65 -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 +184 -0
- openhands/sdk/tool/schema.py +286 -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.3.dist-info/METADATA +17 -0
- openhands_sdk-1.7.3.dist-info/RECORD +180 -0
- openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
- openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
|
@@ -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()
|