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,68 @@
1
+ """
2
+ Cipher utility for preventing accidental secret disclosure in serialized data
3
+
4
+ SECURITY WARNINGS:
5
+ - The secret key is a string for ease of use but should contain at least 256
6
+ bits of entropy
7
+ """
8
+
9
+ import hashlib
10
+ from base64 import b64encode
11
+
12
+ from cryptography.fernet import Fernet
13
+ from pydantic import SecretStr
14
+
15
+
16
+ class Cipher:
17
+ """
18
+ Simple encryption utility for preventing accidental secret disclosure.
19
+ """
20
+
21
+ def __init__(self, secret_key: str):
22
+ self.secret_key = secret_key
23
+ self._fernet: Fernet | None = None
24
+
25
+ def encrypt(self, secret: SecretStr | None) -> str | None:
26
+ if secret is None:
27
+ return None
28
+ secret_value = secret.get_secret_value().encode()
29
+ fernet = self._get_fernet()
30
+ result = fernet.encrypt(secret_value).decode()
31
+ return result
32
+
33
+ def decrypt(self, secret: str | None) -> SecretStr | None:
34
+ """
35
+ Decrypt a secret value, returning None if decryption fails.
36
+
37
+ This handles cases where existing conversations were serialized with different
38
+ encryption keys or contain invalid encrypted data. A warning is logged when
39
+ decryption fails and a None is returned. This mimics the case where
40
+ no cipher was defined so secrets where redacted.
41
+ """
42
+ if secret is None:
43
+ return None
44
+ try:
45
+ fernet = self._get_fernet()
46
+ decrypted = fernet.decrypt(secret.encode()).decode()
47
+ return SecretStr(decrypted)
48
+ except Exception as e:
49
+ # Import here to avoid circular imports
50
+ from openhands.sdk.logger import get_logger
51
+
52
+ logger = get_logger(__name__)
53
+ logger.warning(
54
+ f"Failed to decrypt secret value (setting to None): {e}. "
55
+ "This may occur when loading conversations encrypted with a different "
56
+ "key or when upgrading from older versions."
57
+ )
58
+ return None
59
+
60
+ def _get_fernet(self):
61
+ fernet = self._fernet
62
+ if fernet is None:
63
+ secret_key = self.secret_key.encode()
64
+ # Hash the key to make sure we have a 256 bit value
65
+ fernet_key = b64encode(hashlib.sha256(secret_key).digest())
66
+ fernet = Fernet(fernet_key)
67
+ object.__setattr__(self, "_fernet", fernet)
68
+ return fernet
@@ -0,0 +1,90 @@
1
+ import shlex
2
+ import subprocess
3
+ import sys
4
+ import threading
5
+
6
+ from openhands.sdk.logger import get_logger
7
+
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ def execute_command(
13
+ cmd: list[str] | str,
14
+ env: dict[str, str] | None = None,
15
+ cwd: str | None = None,
16
+ timeout: float | None = None,
17
+ print_output: bool = True,
18
+ ) -> subprocess.CompletedProcess:
19
+ # For string commands, use shell=True to handle shell operators properly
20
+ if isinstance(cmd, str):
21
+ cmd_to_run = cmd
22
+ use_shell = True
23
+ logger.info("$ %s", cmd)
24
+ else:
25
+ cmd_to_run = cmd
26
+ use_shell = False
27
+ logger.info("$ %s", " ".join(shlex.quote(c) for c in cmd))
28
+
29
+ proc = subprocess.Popen(
30
+ cmd_to_run,
31
+ cwd=cwd,
32
+ env=env,
33
+ stdout=subprocess.PIPE,
34
+ stderr=subprocess.PIPE,
35
+ text=True,
36
+ bufsize=1,
37
+ shell=use_shell,
38
+ )
39
+ if proc is None:
40
+ raise RuntimeError("Failed to start process")
41
+
42
+ # Read line by line, echo to parent stdout/stderr
43
+ stdout_lines: list[str] = []
44
+ stderr_lines: list[str] = []
45
+ if proc.stdout is None or proc.stderr is None:
46
+ raise RuntimeError("Failed to capture stdout/stderr")
47
+
48
+ def read_stream(stream, lines, output_stream):
49
+ try:
50
+ for line in stream:
51
+ if print_output:
52
+ output_stream.write(line)
53
+ output_stream.flush()
54
+ lines.append(line)
55
+ except Exception as e:
56
+ logger.error(f"Failed to read stream: {e}")
57
+
58
+ # Read stdout and stderr concurrently to avoid deadlock
59
+ stdout_thread = threading.Thread(
60
+ target=read_stream, args=(proc.stdout, stdout_lines, sys.stdout)
61
+ )
62
+ stderr_thread = threading.Thread(
63
+ target=read_stream, args=(proc.stderr, stderr_lines, sys.stderr)
64
+ )
65
+
66
+ stdout_thread.start()
67
+ stderr_thread.start()
68
+
69
+ try:
70
+ proc.wait(timeout=timeout)
71
+ except subprocess.TimeoutExpired:
72
+ proc.kill()
73
+ stdout_thread.join()
74
+ stderr_thread.join()
75
+ return subprocess.CompletedProcess(
76
+ cmd_to_run,
77
+ -1, # Indicate timeout with -1 exit code
78
+ "".join(stdout_lines),
79
+ "".join(stderr_lines),
80
+ )
81
+
82
+ stdout_thread.join(timeout=timeout)
83
+ stderr_thread.join(timeout=timeout)
84
+
85
+ return subprocess.CompletedProcess(
86
+ cmd_to_run,
87
+ proc.returncode,
88
+ "".join(stdout_lines),
89
+ "".join(stderr_lines),
90
+ )
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from collections.abc import Callable
5
+ from datetime import date
6
+ from functools import cache
7
+ from importlib.metadata import PackageNotFoundError, version as get_version
8
+ from typing import Any, TypeVar, cast
9
+
10
+ from deprecation import (
11
+ DeprecatedWarning,
12
+ UnsupportedWarning,
13
+ deprecated as _deprecated,
14
+ )
15
+ from packaging import version as pkg_version
16
+
17
+
18
+ _FuncT = TypeVar("_FuncT", bound=Callable[..., Any])
19
+
20
+
21
+ @cache
22
+ def _current_version() -> str:
23
+ try:
24
+ return get_version("openhands-sdk")
25
+ except PackageNotFoundError:
26
+ return "0.0.0"
27
+
28
+
29
+ def deprecated(
30
+ *,
31
+ deprecated_in: str,
32
+ removed_in: str | date | None,
33
+ current_version: str | None = None,
34
+ details: str = "",
35
+ ) -> Callable[[_FuncT], _FuncT]:
36
+ """Return a decorator that deprecates a callable with explicit metadata.
37
+
38
+ Use this helper when you can annotate a function, method, or property with
39
+ `@deprecated(...)`. It transparently forwards to :func:`deprecation.deprecated`
40
+ while filling in the SDK's current version metadata unless custom values are
41
+ supplied.
42
+ """
43
+
44
+ base_decorator = _deprecated(
45
+ deprecated_in=deprecated_in,
46
+ removed_in=removed_in,
47
+ current_version=current_version or _current_version(),
48
+ details=details,
49
+ )
50
+
51
+ def decorator(func: _FuncT) -> _FuncT:
52
+ return cast(_FuncT, base_decorator(func))
53
+
54
+ return decorator
55
+
56
+
57
+ def _should_warn(
58
+ *,
59
+ deprecated_in: str | None,
60
+ removed_in: str | date | None,
61
+ current_version: str | None,
62
+ ) -> tuple[bool, bool]:
63
+ is_deprecated = False
64
+ is_unsupported = False
65
+
66
+ if isinstance(removed_in, date):
67
+ if date.today() >= removed_in:
68
+ is_unsupported = True
69
+ else:
70
+ is_deprecated = True
71
+ elif current_version:
72
+ current = pkg_version.parse(current_version)
73
+ if removed_in and current >= pkg_version.parse(str(removed_in)):
74
+ is_unsupported = True
75
+ elif deprecated_in and current >= pkg_version.parse(deprecated_in):
76
+ is_deprecated = True
77
+ else:
78
+ is_deprecated = True
79
+
80
+ return is_deprecated, is_unsupported
81
+
82
+
83
+ def warn_deprecated(
84
+ feature: str,
85
+ *,
86
+ deprecated_in: str,
87
+ removed_in: str | date | None,
88
+ current_version: str | None = None,
89
+ details: str = "",
90
+ stacklevel: int = 2,
91
+ ) -> None:
92
+ """Emit a deprecation warning for dynamic access to a legacy feature.
93
+
94
+ Prefer this helper when a decorator is not practical—e.g. attribute accessors,
95
+ data migrations, or other runtime paths that must conditionally warn. Provide
96
+ explicit version metadata so the SDK reports consistent messages and upgrades
97
+ to :class:`deprecation.UnsupportedWarning` after the removal threshold.
98
+ """
99
+
100
+ current_version = current_version or _current_version()
101
+ is_deprecated, is_unsupported = _should_warn(
102
+ deprecated_in=deprecated_in,
103
+ removed_in=removed_in,
104
+ current_version=current_version,
105
+ )
106
+
107
+ if not (is_deprecated or is_unsupported):
108
+ return
109
+
110
+ warning_cls = UnsupportedWarning if is_unsupported else DeprecatedWarning
111
+ warning = warning_cls(feature, deprecated_in, removed_in, details)
112
+ warnings.warn(warning, stacklevel=stacklevel)
113
+
114
+
115
+ def warn_cleanup(
116
+ workaround: str,
117
+ *,
118
+ cleanup_by: str | date,
119
+ current_version: str | None = None,
120
+ details: str = "",
121
+ stacklevel: int = 2,
122
+ ) -> None:
123
+ """Emit a warning for temporary workarounds that need cleanup by a deadline.
124
+
125
+ Use this helper for temporary code that addresses upstream issues, compatibility
126
+ shims, or other workarounds that should be removed once external conditions
127
+ change (e.g., when a library adds support for a feature, or when an API
128
+ stabilizes). The deprecation check workflow will fail when the cleanup deadline
129
+ is reached, ensuring the workaround is removed before the specified version or
130
+ date.
131
+
132
+ Args:
133
+ workaround: Description of the temporary workaround
134
+ cleanup_by: Version string or date when this workaround must be removed
135
+ current_version: Override the detected package version (for testing)
136
+ details: Additional context about why cleanup is needed
137
+ stacklevel: Stack level for warning emission
138
+ """
139
+ current_version = current_version or _current_version()
140
+
141
+ should_cleanup = False
142
+ if isinstance(cleanup_by, date):
143
+ should_cleanup = date.today() >= cleanup_by
144
+ else:
145
+ try:
146
+ current = pkg_version.parse(current_version)
147
+ target = pkg_version.parse(str(cleanup_by))
148
+ should_cleanup = current >= target
149
+ except pkg_version.InvalidVersion:
150
+ pass
151
+
152
+ if should_cleanup:
153
+ message = (
154
+ f"Cleanup required: {workaround}. "
155
+ f"This workaround was scheduled for removal by {cleanup_by}."
156
+ )
157
+ if details:
158
+ message += f" {details}"
159
+ warnings.warn(message, UserWarning, stacklevel=stacklevel)
160
+
161
+
162
+ __all__ = [
163
+ "deprecated",
164
+ "warn_deprecated",
165
+ "warn_cleanup",
166
+ ]
@@ -0,0 +1,44 @@
1
+ """Utility functions for GitHub integrations."""
2
+
3
+ import re
4
+
5
+
6
+ # Zero-width joiner character (U+200D)
7
+ # We use ZWJ instead of ZWSP (U+200B) because:
8
+ # - ZWJ is semantically more appropriate (joins characters without adding space)
9
+ # - ZWJ has better support in modern renderers
10
+ # - ZWJ is invisible and doesn't affect text rendering or selection
11
+ ZWJ = "\u200d"
12
+
13
+
14
+ def sanitize_openhands_mentions(text: str) -> str:
15
+ """Sanitize @OpenHands mentions in text to prevent self-mention loops.
16
+
17
+ This function inserts a zero-width joiner (ZWJ) after the @ symbol in
18
+ @OpenHands mentions, making them non-clickable in GitHub comments while
19
+ preserving readability. The original case of the mention is preserved.
20
+
21
+ Args:
22
+ text: The text to sanitize
23
+
24
+ Returns:
25
+ Text with sanitized @OpenHands mentions (e.g., "@OpenHands" -> "@‍OpenHands")
26
+
27
+ Examples:
28
+ >>> sanitize_openhands_mentions("Thanks @OpenHands for the help!")
29
+ 'Thanks @\\u200dOpenHands for the help!'
30
+ >>> sanitize_openhands_mentions("Check @openhands and @OPENHANDS")
31
+ 'Check @\\u200dopenhands and @\\u200dOPENHANDS'
32
+ >>> sanitize_openhands_mentions("No mention here")
33
+ 'No mention here'
34
+ """
35
+ # Pattern to match @OpenHands mentions at word boundaries
36
+ # Uses re.IGNORECASE so we don't need [Oo]pen[Hh]ands
37
+ # Capture group preserves the original case
38
+ pattern = r"@(OpenHands)\b"
39
+
40
+ # Replace @ with @ + ZWJ while preserving the original case
41
+ # The \1 backreference preserves the matched case
42
+ sanitized = re.sub(pattern, f"@{ZWJ}\\1", text, flags=re.IGNORECASE)
43
+
44
+ return sanitized
@@ -0,0 +1,48 @@
1
+ import json
2
+ from datetime import datetime
3
+ from typing import Any
4
+
5
+ from litellm.types.utils import ModelResponse
6
+
7
+ from openhands.sdk.llm.exceptions import LLMResponseError
8
+ from openhands.sdk.llm.utils.metrics import Metrics
9
+
10
+
11
+ class OpenHandsJSONEncoder(json.JSONEncoder):
12
+ """Custom JSON encoder that handles datetime and other OH objects"""
13
+
14
+ def default(self, o: object) -> Any:
15
+ if isinstance(o, datetime):
16
+ return o.isoformat()
17
+ if isinstance(o, Metrics):
18
+ return o.get()
19
+ if isinstance(o, ModelResponse):
20
+ return o.model_dump()
21
+ return super().default(o)
22
+
23
+
24
+ # Create a single reusable encoder instance
25
+ _json_encoder = OpenHandsJSONEncoder()
26
+
27
+
28
+ def dumps(obj, **kwargs):
29
+ """Serialize an object to str format"""
30
+ if not kwargs:
31
+ return _json_encoder.encode(obj)
32
+
33
+ # Create a copy of the kwargs to avoid modifying the original
34
+ encoder_kwargs = kwargs.copy()
35
+
36
+ # If cls is specified, use it; otherwise use our custom encoder
37
+ if "cls" not in encoder_kwargs:
38
+ encoder_kwargs["cls"] = OpenHandsJSONEncoder
39
+
40
+ return json.dumps(obj, **encoder_kwargs)
41
+
42
+
43
+ def loads(json_str, **kwargs):
44
+ """Create a JSON object from str"""
45
+ try:
46
+ return json.loads(json_str, **kwargs)
47
+ except json.JSONDecodeError:
48
+ raise LLMResponseError("No valid JSON object found in response.")