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,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)
|