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,65 @@
1
+ VERIFIED_OPENAI_MODELS = [
2
+ "gpt-5.2",
3
+ "gpt-5.1",
4
+ "gpt-5.1-codex-max",
5
+ "gpt-5.1-codex",
6
+ "gpt-5.1-codex-mini",
7
+ "gpt-5-codex",
8
+ "gpt-5-2025-08-07",
9
+ "gpt-5-mini-2025-08-07",
10
+ "o4-mini",
11
+ "gpt-4o",
12
+ "gpt-4o-mini",
13
+ "gpt-4-32k",
14
+ "gpt-4.1",
15
+ "gpt-4.1-2025-04-14",
16
+ "o1-mini",
17
+ "o3",
18
+ "codex-mini-latest",
19
+ ]
20
+
21
+ VERIFIED_ANTHROPIC_MODELS = [
22
+ "claude-sonnet-4-5-20250929",
23
+ "claude-haiku-4-5-20251001",
24
+ "claude-opus-4-5-20251101",
25
+ "claude-sonnet-4-20250514",
26
+ "claude-opus-4-20250514",
27
+ "claude-opus-4-1-20250805",
28
+ "claude-3-7-sonnet-20250219",
29
+ "claude-3-sonnet-20240229",
30
+ "claude-3-opus-20240229",
31
+ "claude-3-haiku-20240307",
32
+ "claude-3-5-haiku-20241022",
33
+ "claude-3-5-sonnet-20241022",
34
+ "claude-3-5-sonnet-20240620",
35
+ ]
36
+
37
+ VERIFIED_MISTRAL_MODELS = [
38
+ "devstral-small-2505",
39
+ "devstral-small-2507",
40
+ "devstral-medium-2507",
41
+ "devstral-2512",
42
+ "devstral-medium-2512",
43
+ ]
44
+
45
+ VERIFIED_OPENHANDS_MODELS = [
46
+ "claude-opus-4-5-20251101",
47
+ "claude-sonnet-4-5-20250929",
48
+ "gpt-5.2",
49
+ "gpt-5.1-codex-max",
50
+ "gpt-5.1-codex",
51
+ "gpt-5.1",
52
+ "gemini-3-pro-preview",
53
+ "deekseek-chat",
54
+ "kimi-k2-thinking",
55
+ "devstral-medium-2512",
56
+ "devstral-2512",
57
+ ]
58
+
59
+
60
+ VERIFIED_MODELS = {
61
+ "openhands": VERIFIED_OPENHANDS_MODELS,
62
+ "anthropic": VERIFIED_ANTHROPIC_MODELS,
63
+ "openai": VERIFIED_OPENAI_MODELS,
64
+ "mistral": VERIFIED_MISTRAL_MODELS,
65
+ }
@@ -0,0 +1,22 @@
1
+ from .logger import (
2
+ DEBUG,
3
+ ENV_JSON,
4
+ ENV_LOG_DIR,
5
+ ENV_LOG_LEVEL,
6
+ IN_CI,
7
+ get_logger,
8
+ setup_logging,
9
+ )
10
+ from .rolling import rolling_log_view
11
+
12
+
13
+ __all__ = [
14
+ "get_logger",
15
+ "setup_logging",
16
+ "DEBUG",
17
+ "ENV_JSON",
18
+ "ENV_LOG_LEVEL",
19
+ "ENV_LOG_DIR",
20
+ "IN_CI",
21
+ "rolling_log_view",
22
+ ]
@@ -0,0 +1,195 @@
1
+ # simple_logger.py
2
+ """
3
+ Minimal logger setup that encourages per-module loggers,
4
+ with Rich for humans and JSON for machines.
5
+
6
+ Usage:
7
+ from openhands.sdk.logger import get_logger
8
+ logger = get_logger(__name__)
9
+ logger.info("Hello from this module!")
10
+ """
11
+
12
+ import logging
13
+ import os
14
+ from logging.handlers import TimedRotatingFileHandler
15
+
16
+ import litellm
17
+ from pythonjsonlogger.json import JsonFormatter
18
+ from rich.console import Console
19
+ from rich.logging import RichHandler
20
+
21
+
22
+ # ========= ENV (loaded at import) =========
23
+ LEVEL_MAP = (
24
+ logging.getLevelNamesMapping()
25
+ if hasattr(logging, "getLevelNamesMapping")
26
+ else logging._nameToLevel
27
+ )
28
+
29
+ DEBUG = os.environ.get("DEBUG", "false").lower() in {"1", "true", "yes"}
30
+ ENV_LOG_LEVEL_STR = os.getenv("LOG_LEVEL", "INFO").upper()
31
+ ENV_LOG_LEVEL = LEVEL_MAP.get(ENV_LOG_LEVEL_STR, logging.INFO)
32
+ if DEBUG:
33
+ ENV_LOG_LEVEL = logging.DEBUG
34
+
35
+ ENV_LOG_TO_FILE = os.getenv("LOG_TO_FILE", "false").lower() in {"1", "true", "yes"}
36
+ ENV_LOG_DIR = os.getenv("LOG_DIR", "logs")
37
+ ENV_ROTATE_WHEN = os.getenv("LOG_ROTATE_WHEN", "midnight")
38
+ ENV_BACKUP_COUNT = int(os.getenv("LOG_BACKUP_COUNT", "7"))
39
+
40
+ # Rich vs JSON
41
+ ENV_JSON = os.getenv("LOG_JSON", "false").lower() in {"1", "true", "yes"}
42
+ IN_CI = os.getenv("CI", "false").lower() in {"1", "true", "yes"} or bool(
43
+ os.environ.get("GITHUB_ACTIONS")
44
+ )
45
+ ENV_RICH_TRACEBACKS = os.getenv("LOG_RICH_TRACEBACKS", "true").lower() in {
46
+ "1",
47
+ "true",
48
+ "yes",
49
+ }
50
+
51
+
52
+ ENV_AUTO_CONFIG = os.getenv("LOG_AUTO_CONFIG", "true").lower() in {"1", "true", "yes"}
53
+ ENV_DEBUG_LLM = os.getenv("DEBUG_LLM", "false").lower() in {"1", "true", "yes"}
54
+
55
+
56
+ # ========= LiteLLM controls =========
57
+ _ENABLE_LITELLM_DEBUG = False
58
+ if ENV_DEBUG_LLM:
59
+ confirmation = input(
60
+ "\n⚠️ WARNING: You are enabling DEBUG_LLM which may expose sensitive "
61
+ "information like API keys.\nThis should NEVER be enabled in production.\n"
62
+ "Type 'y' to confirm you understand the risks: "
63
+ )
64
+ if confirmation.lower() == "y":
65
+ _ENABLE_LITELLM_DEBUG = True
66
+ litellm.suppress_debug_info = False
67
+ litellm.set_verbose = True # type: ignore
68
+ else:
69
+ print("DEBUG_LLM disabled due to lack of confirmation")
70
+ litellm.suppress_debug_info = True
71
+ litellm.set_verbose = False # type: ignore
72
+ else:
73
+ litellm.suppress_debug_info = True
74
+ litellm.set_verbose = False # type: ignore
75
+
76
+
77
+ def disable_logger(name: str, level: int = logging.CRITICAL) -> None:
78
+ """Disable or quiet down a specific logger by name."""
79
+ logger = logging.getLogger(name)
80
+ logger.setLevel(level)
81
+ logger.propagate = False
82
+
83
+
84
+ # Quiet chatty third-party loggers
85
+ for name in ["litellm", "LiteLLM", "openai"]:
86
+ disable_logger(name, logging.DEBUG if _ENABLE_LITELLM_DEBUG else logging.ERROR)
87
+ for name in ["httpcore", "httpx", "libtmux"]:
88
+ disable_logger(name, logging.WARNING)
89
+
90
+
91
+ # ========= SETUP =========
92
+ def setup_logging(
93
+ level: int | None = None,
94
+ log_to_file: bool | None = None,
95
+ log_dir: str | None = None,
96
+ fmt: str | None = None,
97
+ when: str | None = None,
98
+ backup_count: int | None = None,
99
+ ) -> None:
100
+ """Configure the root logger. All child loggers inherit this setup."""
101
+ lvl = ENV_LOG_LEVEL if level is None else level
102
+ to_file = ENV_LOG_TO_FILE if log_to_file is None else log_to_file
103
+ directory = ENV_LOG_DIR if log_dir is None else log_dir
104
+ rotate_when = ENV_ROTATE_WHEN if when is None else when
105
+ keep = ENV_BACKUP_COUNT if backup_count is None else backup_count
106
+
107
+ root = logging.getLogger()
108
+ old_level = root.level
109
+ root.setLevel(lvl)
110
+
111
+ # Set the level for any existing logger with the same intial level
112
+ for logger in logging.root.manager.loggerDict.values():
113
+ if isinstance(logger, logging.Logger) and logger.level == old_level:
114
+ logger.setLevel(lvl)
115
+
116
+ # Do NOT clear existing handlers; Uvicorn installs these before importing the app.
117
+ # Only add ours if there isn't already a comparable stream handler.
118
+ has_stream = any(isinstance(h, logging.StreamHandler) for h in root.handlers)
119
+
120
+ if not has_stream:
121
+ if ENV_JSON or IN_CI:
122
+ # JSON console handler
123
+ ch = logging.StreamHandler()
124
+ ch.setLevel(lvl)
125
+ ch.setFormatter(
126
+ JsonFormatter(
127
+ fmt="%(asctime)s %(levelname)s %(name)s "
128
+ "%(filename)s %(lineno)d %(message)s"
129
+ )
130
+ )
131
+ root.addHandler(ch)
132
+ else:
133
+ # Rich console handler
134
+ rich_handler = RichHandler(
135
+ console=Console(stderr=True),
136
+ omit_repeated_times=False,
137
+ rich_tracebacks=ENV_RICH_TRACEBACKS,
138
+ )
139
+ rich_handler.setFormatter(logging.Formatter("%(message)s"))
140
+ rich_handler.setLevel(lvl)
141
+ root.addHandler(rich_handler)
142
+
143
+ if to_file:
144
+ os.makedirs(directory, exist_ok=True)
145
+ fh = TimedRotatingFileHandler(
146
+ os.path.join(directory, "app.log"),
147
+ when=rotate_when,
148
+ backupCount=keep,
149
+ encoding="utf-8",
150
+ )
151
+ fh.setLevel(lvl)
152
+ if ENV_JSON:
153
+ fh.setFormatter(
154
+ JsonFormatter(
155
+ fmt="%(asctime)s %(levelname)s %(name)s "
156
+ "%(filename)s %(lineno)d %(message)s"
157
+ )
158
+ )
159
+ else:
160
+ log_fmt = (
161
+ fmt
162
+ or "%(asctime)s - %(levelname)s - %(name)s "
163
+ "- %(filename)s:%(lineno)d - %(message)s"
164
+ )
165
+ fh.setFormatter(logging.Formatter(log_fmt))
166
+ root.addHandler(fh)
167
+
168
+
169
+ def get_logger(name: str) -> logging.Logger:
170
+ """Get a logger instance for the specified module.
171
+
172
+ This function returns a configured logger that inherits from the root logger
173
+ setup. The logger supports both Rich formatting for human-readable output
174
+ and JSON formatting for machine processing, depending on environment configuration.
175
+
176
+ Args:
177
+ name: The name of the module, typically __name__.
178
+
179
+ Returns:
180
+ A configured Logger instance.
181
+
182
+ Example:
183
+ >>> from openhands.sdk.logger import get_logger
184
+ >>> logger = get_logger(__name__)
185
+ >>> logger.info("This is an info message")
186
+ >>> logger.error("This is an error message")
187
+ """
188
+ logger = logging.getLogger(name)
189
+ logger.propagate = True
190
+ return logger
191
+
192
+
193
+ # Auto-configure if desired
194
+ if ENV_AUTO_CONFIG:
195
+ setup_logging()
@@ -0,0 +1,113 @@
1
+ # rolling_view.py
2
+ import logging
3
+ import sys
4
+ from collections import deque
5
+ from collections.abc import Callable
6
+ from contextlib import contextmanager
7
+
8
+ from rich.live import Live
9
+
10
+ from .logger import ENV_JSON, IN_CI
11
+
12
+
13
+ RenderFnType = Callable[[], str]
14
+
15
+
16
+ class _RollingViewHandler(logging.Handler):
17
+ def __init__(self, max_lines: int, use_live: bool):
18
+ super().__init__()
19
+ self._buf: deque[str] = deque(maxlen=max_lines)
20
+ self._use_live: bool = use_live
21
+ self._live: Live | None = None # set by rolling_log_view when Live is active
22
+ self.render_fn: RenderFnType | None = None
23
+
24
+ def emit(self, record: logging.LogRecord):
25
+ msg = self.format(record)
26
+ self._buf.append(msg)
27
+
28
+ if self._use_live and self._live:
29
+ # Live mode: repaint using either a custom render_fn or the buffer
30
+ self._live.update(
31
+ self.render_fn() if self.render_fn else "\n".join(self._buf)
32
+ )
33
+ return
34
+
35
+ # Non-live paths
36
+ if ENV_JSON:
37
+ # JSON mode: do nothing here; rely on other handlers via propagation
38
+ return
39
+
40
+ # CI / non-TTY plain pass-through (avoid double newlines)
41
+ sys.stdout.write(msg + "\n")
42
+ sys.stdout.flush()
43
+
44
+ @property
45
+ def snapshot(self) -> str:
46
+ return "\n".join(self._buf)
47
+
48
+
49
+ @contextmanager
50
+ def rolling_log_view(
51
+ logger: logging.Logger,
52
+ max_lines: int = 60,
53
+ level: int = logging.INFO,
54
+ propagate: bool = False,
55
+ header: str | None = None,
56
+ footer: str | None = None,
57
+ *,
58
+ json_flush_level: int
59
+ | None = None, # optional: separate level for the final JSON flush
60
+ ):
61
+ """
62
+ Temporarily attach a rolling view handler that renders the last N log lines.
63
+
64
+ - Local TTY & not CI & not JSON: pretty, live-updating view (Rich.Live)
65
+ - CI / non-TTY: plain line-by-line (no terminal control)
66
+ - JSON mode: buffer only; on exit emit ONE large log record with the full snapshot.
67
+ """
68
+ is_tty = sys.stdout.isatty()
69
+ use_live = (not IN_CI) and is_tty and (not ENV_JSON)
70
+
71
+ handler = _RollingViewHandler(max_lines=max_lines, use_live=use_live)
72
+ handler.setLevel(level)
73
+ handler.setFormatter(logging.Formatter("%(message)s"))
74
+
75
+ prev_propagate = logger.propagate
76
+ # Let other handlers (e.g., your JSON handler) run if needed
77
+ logger.propagate = bool(propagate or ENV_JSON)
78
+
79
+ logger.addHandler(handler)
80
+
81
+ def _render() -> str:
82
+ parts: list[str] = []
83
+ if header:
84
+ parts.append(header.rstrip())
85
+ parts.append("\n".join(handler._buf))
86
+ if footer:
87
+ parts.append(footer.rstrip())
88
+ return "\n".join(parts)
89
+
90
+ try:
91
+ if use_live:
92
+ with Live(_render(), refresh_per_second=8) as live:
93
+ handler._live = live
94
+ handler.render_fn = _render
95
+ yield handler
96
+ else:
97
+ yield handler
98
+ finally:
99
+ final_text = _render()
100
+
101
+ # Freeze final frame if Live was active
102
+ if handler._live:
103
+ handler._live.update(final_text)
104
+
105
+ # Detach our handler BEFORE flushing to avoid recursion
106
+ logger.removeHandler(handler)
107
+ logger.propagate = prev_propagate
108
+
109
+ # JSON mode: emit one big record at exit
110
+ if ENV_JSON:
111
+ logger.log(
112
+ json_flush_level if json_flush_level is not None else level, final_text
113
+ )
@@ -0,0 +1,24 @@
1
+ """MCP (Model Context Protocol) integration for agent-sdk."""
2
+
3
+ from openhands.sdk.mcp.client import MCPClient
4
+ from openhands.sdk.mcp.definition import MCPToolAction, MCPToolObservation
5
+ from openhands.sdk.mcp.exceptions import MCPError, MCPTimeoutError
6
+ from openhands.sdk.mcp.tool import (
7
+ MCPToolDefinition,
8
+ MCPToolExecutor,
9
+ )
10
+ from openhands.sdk.mcp.utils import (
11
+ create_mcp_tools,
12
+ )
13
+
14
+
15
+ __all__ = [
16
+ "MCPClient",
17
+ "MCPToolDefinition",
18
+ "MCPToolAction",
19
+ "MCPToolObservation",
20
+ "MCPToolExecutor",
21
+ "create_mcp_tools",
22
+ "MCPError",
23
+ "MCPTimeoutError",
24
+ ]
@@ -0,0 +1,76 @@
1
+ """Minimal sync helpers on top of fastmcp.Client, preserving original behavior."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from fastmcp import Client as AsyncMCPClient
9
+
10
+ from openhands.sdk.utils.async_executor import AsyncExecutor
11
+
12
+
13
+ class MCPClient(AsyncMCPClient):
14
+ """
15
+ Behaves exactly like fastmcp.Client (same constructor & async API),
16
+ but owns a background event loop and offers:
17
+ - call_async_from_sync(awaitable_or_fn, *args, timeout=None, **kwargs)
18
+ - call_sync_from_async(fn, *args, **kwargs) # await this from async code
19
+ """
20
+
21
+ _executor: AsyncExecutor
22
+
23
+ def __init__(self, *args, **kwargs):
24
+ super().__init__(*args, **kwargs)
25
+ self._executor = AsyncExecutor()
26
+
27
+ def call_async_from_sync(
28
+ self,
29
+ awaitable_or_fn: Callable[..., Any] | Any,
30
+ *args,
31
+ timeout: float,
32
+ **kwargs,
33
+ ) -> Any:
34
+ """
35
+ Run a coroutine or async function on this client's loop from sync code.
36
+
37
+ Usage:
38
+ mcp.call_async_from_sync(async_fn, arg1, kw=...)
39
+ mcp.call_async_from_sync(coro)
40
+ """
41
+ return self._executor.run_async(
42
+ awaitable_or_fn, *args, timeout=timeout, **kwargs
43
+ )
44
+
45
+ async def call_sync_from_async(
46
+ self, fn: Callable[..., Any], *args, **kwargs
47
+ ) -> Any:
48
+ """
49
+ Await running a blocking function in the default threadpool from async code.
50
+ """
51
+ loop = asyncio.get_running_loop()
52
+ return await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
53
+
54
+ def sync_close(self) -> None:
55
+ """
56
+ Synchronously close the MCP client and cleanup resources.
57
+
58
+ This will attempt to call the async close() method if available,
59
+ then shutdown the background event loop.
60
+ """
61
+ # Best-effort: try async close if parent provides it
62
+ if hasattr(self, "close") and inspect.iscoroutinefunction(self.close):
63
+ try:
64
+ self._executor.run_async(self.close, timeout=10.0)
65
+ except Exception:
66
+ pass # Ignore close errors during cleanup
67
+
68
+ # Always cleanup the executor
69
+ self._executor.close()
70
+
71
+ def __del__(self):
72
+ """Cleanup on deletion."""
73
+ try:
74
+ self.sync_close()
75
+ except Exception:
76
+ pass # Ignore cleanup errors during deletion
@@ -0,0 +1,106 @@
1
+ """MCPTool definition and implementation."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import mcp.types
7
+ from pydantic import Field
8
+ from rich.text import Text
9
+
10
+ from openhands.sdk.llm import ImageContent, TextContent
11
+ from openhands.sdk.logger import get_logger
12
+ from openhands.sdk.tool import (
13
+ Observation,
14
+ )
15
+ from openhands.sdk.tool.schema import Action
16
+ from openhands.sdk.utils.visualize import display_json
17
+
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ # NOTE: We don't define MCPToolAction because it
23
+ # will be dynamically created from the MCP tool schema.
24
+
25
+
26
+ class MCPToolAction(Action):
27
+ """Schema for MCP input action.
28
+
29
+ It is just a thin wrapper around raw JSON and does
30
+ not do any validation.
31
+
32
+ Validation will be performed by MCPTool.__call__
33
+ by constructing dynamically created Pydantic model
34
+ from the MCP tool input schema.
35
+ """
36
+
37
+ data: dict[str, Any] = Field(
38
+ default_factory=dict, description="Dynamic data fields from the tool call"
39
+ )
40
+
41
+ def to_mcp_arguments(self) -> dict:
42
+ """Return the data field as MCP tool call arguments.
43
+
44
+ This is used to convert this action to MCP tool call arguments.
45
+ The data field contains the dynamic fields from the tool call.
46
+ """
47
+ return self.data
48
+
49
+
50
+ class MCPToolObservation(Observation):
51
+ """Observation from MCP tool execution."""
52
+
53
+ tool_name: str = Field(description="Name of the tool that was called")
54
+
55
+ @classmethod
56
+ def from_call_tool_result(
57
+ cls, tool_name: str, result: mcp.types.CallToolResult
58
+ ) -> "MCPToolObservation":
59
+ """Create an MCPToolObservation from a CallToolResult."""
60
+
61
+ native_content: list[mcp.types.ContentBlock] = result.content
62
+ content: list[TextContent | ImageContent] = [
63
+ TextContent(text=f"[Tool '{tool_name}' executed.]")
64
+ ]
65
+ for block in native_content:
66
+ if isinstance(block, mcp.types.TextContent):
67
+ content.append(TextContent(text=block.text))
68
+ elif isinstance(block, mcp.types.ImageContent):
69
+ content.append(
70
+ ImageContent(
71
+ image_urls=[f"data:{block.mimeType};base64,{block.data}"],
72
+ )
73
+ )
74
+ else:
75
+ logger.warning(
76
+ f"Unsupported MCP content block type: {type(block)}. Ignoring."
77
+ )
78
+
79
+ return cls(
80
+ content=content,
81
+ is_error=result.isError,
82
+ tool_name=tool_name,
83
+ )
84
+
85
+ @property
86
+ def visualize(self) -> Text:
87
+ """Return Rich Text representation of this observation."""
88
+ text = Text()
89
+
90
+ if self.is_error:
91
+ text.append("❌ ", style="red bold")
92
+ text.append(self.ERROR_MESSAGE_HEADER, style="bold red")
93
+
94
+ text.append(f"[MCP Tool '{self.tool_name}' Observation]\n", style="bold")
95
+ for block in self.content:
96
+ if isinstance(block, TextContent):
97
+ # try to see if block.text is a JSON
98
+ try:
99
+ parsed = json.loads(block.text)
100
+ text.append(display_json(parsed))
101
+ continue
102
+ except (json.JSONDecodeError, TypeError):
103
+ text.append(block.text + "\n")
104
+ elif isinstance(block, ImageContent):
105
+ text.append(f"[Image with {len(block.image_urls)} URLs]\n")
106
+ return text
@@ -0,0 +1,19 @@
1
+ """MCP-related exceptions for OpenHands SDK."""
2
+
3
+
4
+ class MCPError(Exception):
5
+ """Base exception for MCP-related errors."""
6
+
7
+ pass
8
+
9
+
10
+ class MCPTimeoutError(MCPError):
11
+ """Exception raised when MCP operations timeout."""
12
+
13
+ timeout: float
14
+ config: dict | None
15
+
16
+ def __init__(self, message: str, timeout: float, config: dict | None = None):
17
+ self.timeout = timeout
18
+ self.config = config
19
+ super().__init__(message)