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,85 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _normalize(x):
|
|
7
|
+
# Convert Pydantic models to dicts
|
|
8
|
+
if isinstance(x, BaseModel):
|
|
9
|
+
return x.model_dump(exclude_none=True)
|
|
10
|
+
# Recurse mappings and sequences (but not strings/bytes)
|
|
11
|
+
if isinstance(x, Mapping):
|
|
12
|
+
return {k: _normalize(v) for k, v in x.items()}
|
|
13
|
+
if isinstance(x, Sequence) and not isinstance(x, (str, bytes, bytearray)):
|
|
14
|
+
return [_normalize(v) for v in x]
|
|
15
|
+
return x
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _structured_diff(a, b):
|
|
19
|
+
a = _normalize(a)
|
|
20
|
+
b = _normalize(b)
|
|
21
|
+
|
|
22
|
+
# Equal after normalization -> no diff
|
|
23
|
+
if a == b:
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
# Dict vs dict: diff by keys
|
|
27
|
+
if isinstance(a, Mapping) and isinstance(b, Mapping):
|
|
28
|
+
keys = set(a) | set(b)
|
|
29
|
+
out = {}
|
|
30
|
+
for k in sorted(keys, key=lambda x: (str(type(x)), str(x))):
|
|
31
|
+
ak = a.get(k, ...)
|
|
32
|
+
bk = b.get(k, ...)
|
|
33
|
+
if ak is ...:
|
|
34
|
+
out[k] = ("<missing>", bk)
|
|
35
|
+
elif bk is ...:
|
|
36
|
+
out[k] = (ak, "<missing>")
|
|
37
|
+
else:
|
|
38
|
+
sub = _structured_diff(ak, bk)
|
|
39
|
+
out[k] = sub if sub else (ak, bk) if ak != bk else {}
|
|
40
|
+
# Remove entries that ended up equal (empty dicts)
|
|
41
|
+
return {k: v for k, v in out.items() if v != {}}
|
|
42
|
+
|
|
43
|
+
# List/tuple vs list/tuple: diff by index
|
|
44
|
+
if (
|
|
45
|
+
isinstance(a, Sequence)
|
|
46
|
+
and isinstance(b, Sequence)
|
|
47
|
+
and not isinstance(a, (str, bytes, bytearray))
|
|
48
|
+
and not isinstance(b, (str, bytes, bytearray))
|
|
49
|
+
):
|
|
50
|
+
out = {}
|
|
51
|
+
n = max(len(a), len(b))
|
|
52
|
+
for i in range(n):
|
|
53
|
+
ai = a[i] if i < len(a) else ...
|
|
54
|
+
bi = b[i] if i < len(b) else ...
|
|
55
|
+
if ai is ...:
|
|
56
|
+
out[i] = ("<missing>", bi)
|
|
57
|
+
elif bi is ...:
|
|
58
|
+
out[i] = (ai, "<missing>")
|
|
59
|
+
else:
|
|
60
|
+
sub = _structured_diff(ai, bi)
|
|
61
|
+
out[i] = sub if sub else (ai, bi) if ai != bi else {}
|
|
62
|
+
return {k: v for k, v in out.items() if v != {}}
|
|
63
|
+
|
|
64
|
+
# Fallback leaf difference
|
|
65
|
+
return (a, b)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _format_diff(d, indent=0):
|
|
69
|
+
if not isinstance(d, Mapping):
|
|
70
|
+
old, new = d
|
|
71
|
+
return f"{' ' * indent}{old!r} -> {new!r}"
|
|
72
|
+
lines = []
|
|
73
|
+
pad = " " * indent
|
|
74
|
+
for key, val in d.items():
|
|
75
|
+
if isinstance(val, Mapping):
|
|
76
|
+
lines.append(f"{pad}{key}:")
|
|
77
|
+
lines.append(_format_diff(val, indent + 1))
|
|
78
|
+
else:
|
|
79
|
+
lines.append(f"{pad}{key}: {_format_diff(val, indent + 1).lstrip()}")
|
|
80
|
+
return "\n".join(lines)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pretty_pydantic_diff(a: BaseModel, b: BaseModel) -> str:
|
|
84
|
+
diff = _structured_diff(a, b)
|
|
85
|
+
return "No differences" if not diff else _format_diff(diff)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from pydantic import SecretStr
|
|
2
|
+
|
|
3
|
+
from openhands.sdk.utils.cipher import Cipher
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def serialize_secret(v: SecretStr | None, info):
|
|
7
|
+
"""
|
|
8
|
+
Serialize secret fields with encryption or redaction.
|
|
9
|
+
|
|
10
|
+
- If a cipher is provided in context, encrypts the secret value
|
|
11
|
+
- If expose_secrets flag is True in context, exposes the actual value
|
|
12
|
+
- Otherwise, lets Pydantic handle default masking (redaction)
|
|
13
|
+
- This prevents accidental secret disclosure
|
|
14
|
+
""" # noqa: E501
|
|
15
|
+
if v is None:
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
# check if a cipher is supplied
|
|
19
|
+
if info.context and info.context.get("cipher"):
|
|
20
|
+
cipher: Cipher = info.context.get("cipher")
|
|
21
|
+
return cipher.encrypt(v)
|
|
22
|
+
|
|
23
|
+
# Check if the 'expose_secrets' flag is in the serialization context
|
|
24
|
+
if info.context and info.context.get("expose_secrets"):
|
|
25
|
+
return v.get_secret_value()
|
|
26
|
+
|
|
27
|
+
# Let Pydantic handle the default masking
|
|
28
|
+
return v
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_secret(v: str | SecretStr | None, info) -> SecretStr | None:
|
|
32
|
+
"""
|
|
33
|
+
Deserialize secret fields, handling encryption and empty values.
|
|
34
|
+
|
|
35
|
+
Accepts both str and SecretStr inputs, always returns SecretStr | None.
|
|
36
|
+
- Empty secrets are converted to None
|
|
37
|
+
- Plain strings are converted to SecretStr
|
|
38
|
+
- If a cipher is provided in context, attempts to decrypt the value
|
|
39
|
+
- If decryption fails, the cipher returns None and a warning is logged
|
|
40
|
+
- This gracefully handles conversations encrypted with different keys or were redacted
|
|
41
|
+
""" # noqa: E501
|
|
42
|
+
if v is None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
# Handle both SecretStr and string inputs
|
|
46
|
+
if isinstance(v, SecretStr):
|
|
47
|
+
secret_value = v.get_secret_value()
|
|
48
|
+
else:
|
|
49
|
+
secret_value = v
|
|
50
|
+
|
|
51
|
+
# If the secret is empty, whitespace-only or redacted - return None
|
|
52
|
+
if not secret_value or not secret_value.strip() or secret_value == "**********":
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
# check if a cipher is supplied
|
|
56
|
+
if info.context and info.context.get("cipher"):
|
|
57
|
+
cipher: Cipher = info.context.get("cipher")
|
|
58
|
+
return cipher.decrypt(secret_value)
|
|
59
|
+
|
|
60
|
+
# Always return SecretStr
|
|
61
|
+
if isinstance(v, SecretStr):
|
|
62
|
+
return v
|
|
63
|
+
else:
|
|
64
|
+
return SecretStr(secret_value)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Utility functions for truncating text content."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from openhands.sdk.logger import get_logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
# Default truncation limits
|
|
12
|
+
DEFAULT_TEXT_CONTENT_LIMIT = 50_000
|
|
13
|
+
|
|
14
|
+
# Default truncation notice
|
|
15
|
+
DEFAULT_TRUNCATE_NOTICE = (
|
|
16
|
+
"<response clipped><NOTE>Due to the max output limit, only part of the full "
|
|
17
|
+
"response has been shown to you.</NOTE>"
|
|
18
|
+
) # 113 chars
|
|
19
|
+
|
|
20
|
+
DEFAULT_TRUNCATE_NOTICE_WITH_PERSIST = (
|
|
21
|
+
"<response clipped><NOTE>Due to the max output limit, only part of the full "
|
|
22
|
+
"response has been shown to you. The complete output has been saved to "
|
|
23
|
+
"{file_path} - you can use other tools to view the full content (truncated "
|
|
24
|
+
"part starts around line {line_num}).</NOTE>"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _save_full_content(content: str, save_dir: str, tool_prefix: str) -> str | None:
|
|
29
|
+
"""Save full content to the specified directory and return the file path."""
|
|
30
|
+
|
|
31
|
+
save_dir_path = Path(save_dir)
|
|
32
|
+
save_dir_path.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
# Generate hash-based filename for deduplication
|
|
35
|
+
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()[:8]
|
|
36
|
+
filename = f"{tool_prefix}_output_{content_hash}.txt"
|
|
37
|
+
file_path = save_dir_path / filename
|
|
38
|
+
|
|
39
|
+
# Only write if file doesn't exist (deduplication)
|
|
40
|
+
if not file_path.exists():
|
|
41
|
+
try:
|
|
42
|
+
file_path.write_text(content, encoding="utf-8")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.debug(f"Failed to save full content to {file_path}: {e}")
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
return str(file_path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def maybe_truncate(
|
|
51
|
+
content: str,
|
|
52
|
+
truncate_after: int | None = None,
|
|
53
|
+
truncate_notice: str = DEFAULT_TRUNCATE_NOTICE,
|
|
54
|
+
save_dir: str | None = None,
|
|
55
|
+
tool_prefix: str = "output",
|
|
56
|
+
) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Truncate the middle of content if it exceeds the specified length.
|
|
59
|
+
|
|
60
|
+
Keeps the head and tail of the content to preserve context at both ends.
|
|
61
|
+
Optionally saves the full content to a file for later investigation.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
content: The text content to potentially truncate
|
|
65
|
+
truncate_after: Maximum length before truncation. If None, no truncation occurs
|
|
66
|
+
truncate_notice: Notice to insert in the middle when content is truncated
|
|
67
|
+
save_dir: Working directory to save full content file in
|
|
68
|
+
tool_prefix: Prefix for the saved file (e.g., "bash", "browser", "editor")
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Original content if under limit, or truncated content with head and tail
|
|
72
|
+
preserved and reference to saved file if applicable
|
|
73
|
+
"""
|
|
74
|
+
# 1) Early exits: no truncation requested, or content already within limit
|
|
75
|
+
if not truncate_after or len(content) <= truncate_after or truncate_after < 0:
|
|
76
|
+
return content
|
|
77
|
+
|
|
78
|
+
# 2) If even the base notice doesn't fit, return a slice of it
|
|
79
|
+
if len(truncate_notice) >= truncate_after:
|
|
80
|
+
return truncate_notice[:truncate_after]
|
|
81
|
+
|
|
82
|
+
# 3) Calculate proposed head size based on base notice
|
|
83
|
+
# (for consistent line number calc)
|
|
84
|
+
available_chars = truncate_after - len(truncate_notice)
|
|
85
|
+
# Prefer giving the "extra" char to head (ceil split)
|
|
86
|
+
proposed_head = available_chars // 2 + (available_chars % 2)
|
|
87
|
+
|
|
88
|
+
# 4) Optionally save full content, then construct the final notice
|
|
89
|
+
final_notice = truncate_notice
|
|
90
|
+
if save_dir:
|
|
91
|
+
saved_file_path = _save_full_content(content, save_dir, tool_prefix)
|
|
92
|
+
if saved_file_path:
|
|
93
|
+
# Calculate line number where truncation happens (using head_chars)
|
|
94
|
+
head_content_lines = len(content[:proposed_head].splitlines())
|
|
95
|
+
|
|
96
|
+
final_notice = DEFAULT_TRUNCATE_NOTICE_WITH_PERSIST.format(
|
|
97
|
+
file_path=saved_file_path,
|
|
98
|
+
line_num=head_content_lines + 1, # +1 to indicate next line
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# 5) If the final notice (with persist info) alone fills the
|
|
102
|
+
# budget, return a slice of it
|
|
103
|
+
if len(final_notice) >= truncate_after:
|
|
104
|
+
return final_notice[:truncate_after]
|
|
105
|
+
|
|
106
|
+
# 6) Allocate remaining budget to head/tail
|
|
107
|
+
remaining = truncate_after - len(final_notice)
|
|
108
|
+
head_chars = min(
|
|
109
|
+
proposed_head, remaining
|
|
110
|
+
) # Ensure head_chars doesn't exceed remaining
|
|
111
|
+
tail_chars = remaining - head_chars # non-negative due to previous checks
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
content[:head_chars]
|
|
115
|
+
+ final_notice
|
|
116
|
+
+ (content[-tail_chars:] if tail_chars > 0 else "")
|
|
117
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from rich.text import Text
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def display_dict(d) -> Text:
|
|
5
|
+
"""Create a Rich Text representation of a dictionary.
|
|
6
|
+
|
|
7
|
+
This function is deprecated. Use display_json instead.
|
|
8
|
+
"""
|
|
9
|
+
return display_json(d)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def display_json(data) -> Text:
|
|
13
|
+
"""Create a Rich Text representation of JSON data.
|
|
14
|
+
|
|
15
|
+
Handles dictionaries, lists, strings, numbers, booleans, and None values.
|
|
16
|
+
"""
|
|
17
|
+
content = Text()
|
|
18
|
+
|
|
19
|
+
if isinstance(data, dict):
|
|
20
|
+
for field_name, field_value in data.items():
|
|
21
|
+
if field_value is None:
|
|
22
|
+
continue # skip None fields
|
|
23
|
+
content.append(f"\n {field_name}: ", style="bold")
|
|
24
|
+
if isinstance(field_value, str):
|
|
25
|
+
# Handle multiline strings with proper indentation
|
|
26
|
+
if "\n" in field_value:
|
|
27
|
+
content.append("\n")
|
|
28
|
+
for line in field_value.split("\n"):
|
|
29
|
+
content.append(f" {line}\n")
|
|
30
|
+
else:
|
|
31
|
+
content.append(f'"{field_value}"')
|
|
32
|
+
elif isinstance(field_value, (list, dict)):
|
|
33
|
+
content.append(str(field_value))
|
|
34
|
+
else:
|
|
35
|
+
content.append(str(field_value))
|
|
36
|
+
elif isinstance(data, list):
|
|
37
|
+
content.append(f"[List with {len(data)} items]\n")
|
|
38
|
+
for i, item in enumerate(data):
|
|
39
|
+
content.append(f" [{i}]: ", style="bold")
|
|
40
|
+
if isinstance(item, str):
|
|
41
|
+
content.append(f'"{item}"\n')
|
|
42
|
+
else:
|
|
43
|
+
content.append(f"{item}\n")
|
|
44
|
+
elif isinstance(data, str):
|
|
45
|
+
# Handle multiline strings with proper indentation
|
|
46
|
+
if "\n" in data:
|
|
47
|
+
content.append("String:\n")
|
|
48
|
+
for line in data.split("\n"):
|
|
49
|
+
content.append(f" {line}\n")
|
|
50
|
+
else:
|
|
51
|
+
content.append(f'"{data}"')
|
|
52
|
+
elif data is None:
|
|
53
|
+
content.append("null")
|
|
54
|
+
else:
|
|
55
|
+
# Handle numbers, booleans, and other JSON primitives
|
|
56
|
+
content.append(str(data))
|
|
57
|
+
|
|
58
|
+
return content
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .base import BaseWorkspace
|
|
2
|
+
from .local import LocalWorkspace
|
|
3
|
+
from .models import CommandResult, FileOperationResult, PlatformType, TargetType
|
|
4
|
+
from .remote import RemoteWorkspace
|
|
5
|
+
from .workspace import Workspace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseWorkspace",
|
|
10
|
+
"CommandResult",
|
|
11
|
+
"FileOperationResult",
|
|
12
|
+
"LocalWorkspace",
|
|
13
|
+
"PlatformType",
|
|
14
|
+
"RemoteWorkspace",
|
|
15
|
+
"TargetType",
|
|
16
|
+
"Workspace",
|
|
17
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BeforeValidator, Field
|
|
6
|
+
|
|
7
|
+
from openhands.sdk.git.models import GitChange, GitDiff
|
|
8
|
+
from openhands.sdk.logger import get_logger
|
|
9
|
+
from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
|
10
|
+
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _convert_path_to_str(v: str | Path) -> str:
|
|
17
|
+
"""Convert Path objects to string for working_dir."""
|
|
18
|
+
if isinstance(v, Path):
|
|
19
|
+
return str(v)
|
|
20
|
+
return v
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseWorkspace(DiscriminatedUnionMixin, ABC):
|
|
24
|
+
"""Abstract base class for workspace implementations.
|
|
25
|
+
|
|
26
|
+
Workspaces provide a sandboxed environment where agents can execute commands,
|
|
27
|
+
read/write files, and perform other operations. All workspace implementations
|
|
28
|
+
support the context manager protocol for safe resource management.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> with workspace:
|
|
32
|
+
... result = workspace.execute_command("echo 'hello'")
|
|
33
|
+
... content = workspace.read_file("example.txt")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
working_dir: Annotated[
|
|
37
|
+
str,
|
|
38
|
+
BeforeValidator(_convert_path_to_str),
|
|
39
|
+
Field(
|
|
40
|
+
description=(
|
|
41
|
+
"The working directory for agent operations and tool execution. "
|
|
42
|
+
"Accepts both string paths and Path objects. "
|
|
43
|
+
"Path objects are automatically converted to strings."
|
|
44
|
+
)
|
|
45
|
+
),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def __enter__(self) -> "BaseWorkspace":
|
|
49
|
+
"""Enter the workspace context.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Self for use in with statements
|
|
53
|
+
"""
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
57
|
+
"""Exit the workspace context and cleanup resources.
|
|
58
|
+
|
|
59
|
+
Default implementation performs no cleanup. Subclasses should override
|
|
60
|
+
to add cleanup logic (e.g., stopping containers, closing connections).
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
exc_type: Exception type if an exception occurred
|
|
64
|
+
exc_val: Exception value if an exception occurred
|
|
65
|
+
exc_tb: Exception traceback if an exception occurred
|
|
66
|
+
"""
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def execute_command(
|
|
71
|
+
self,
|
|
72
|
+
command: str,
|
|
73
|
+
cwd: str | Path | None = None,
|
|
74
|
+
timeout: float = 30.0,
|
|
75
|
+
) -> CommandResult:
|
|
76
|
+
"""Execute a bash command on the system.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
command: The bash command to execute
|
|
80
|
+
cwd: Working directory for the command (optional)
|
|
81
|
+
timeout: Timeout in seconds (defaults to 30.0)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
CommandResult: Result containing stdout, stderr, exit_code, and other
|
|
85
|
+
metadata
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
Exception: If command execution fails
|
|
89
|
+
"""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def file_upload(
|
|
94
|
+
self,
|
|
95
|
+
source_path: str | Path,
|
|
96
|
+
destination_path: str | Path,
|
|
97
|
+
) -> FileOperationResult:
|
|
98
|
+
"""Upload a file to the system.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
source_path: Path to the source file
|
|
102
|
+
destination_path: Path where the file should be uploaded
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
FileOperationResult: Result containing success status and metadata
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
Exception: If file upload fails
|
|
109
|
+
"""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def file_download(
|
|
114
|
+
self,
|
|
115
|
+
source_path: str | Path,
|
|
116
|
+
destination_path: str | Path,
|
|
117
|
+
) -> FileOperationResult:
|
|
118
|
+
"""Download a file from the system.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
source_path: Path to the source file on the system
|
|
122
|
+
destination_path: Path where the file should be downloaded
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
FileOperationResult: Result containing success status and metadata
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
Exception: If file download fails
|
|
129
|
+
"""
|
|
130
|
+
...
|
|
131
|
+
|
|
132
|
+
@abstractmethod
|
|
133
|
+
def git_changes(self, path: str | Path) -> list[GitChange]:
|
|
134
|
+
"""Get the git changes for the repository at the path given.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
path: Path to the git repository
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
list[GitChange]: List of changes
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
Exception: If path is not a git repository or getting changes failed
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
@abstractmethod
|
|
147
|
+
def git_diff(self, path: str | Path) -> GitDiff:
|
|
148
|
+
"""Get the git diff for the file at the path given.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
path: Path to the file
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
GitDiff: Git diff
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
Exception: If path is not a git repository or getting diff failed
|
|
158
|
+
"""
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from openhands.sdk.git.git_changes import get_git_changes
|
|
6
|
+
from openhands.sdk.git.git_diff import get_git_diff
|
|
7
|
+
from openhands.sdk.git.models import GitChange, GitDiff
|
|
8
|
+
from openhands.sdk.logger import get_logger
|
|
9
|
+
from openhands.sdk.utils.command import execute_command
|
|
10
|
+
from openhands.sdk.workspace.base import BaseWorkspace
|
|
11
|
+
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LocalWorkspace(BaseWorkspace):
|
|
18
|
+
"""Local workspace implementation that operates on the host filesystem.
|
|
19
|
+
|
|
20
|
+
LocalWorkspace provides direct access to the local filesystem and command execution
|
|
21
|
+
environment. It's suitable for development and testing scenarios where the agent
|
|
22
|
+
should operate directly on the host system.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> workspace = LocalWorkspace(working_dir="/path/to/project")
|
|
26
|
+
>>> with workspace:
|
|
27
|
+
... result = workspace.execute_command("ls -la")
|
|
28
|
+
... content = workspace.read_file("README.md")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, working_dir: str | Path, **kwargs: Any):
|
|
32
|
+
# Accept Path in signature for ergonomics and type checkers,
|
|
33
|
+
# but normalize to str for the underlying model field.
|
|
34
|
+
super().__init__(working_dir=str(working_dir), **kwargs)
|
|
35
|
+
|
|
36
|
+
def execute_command(
|
|
37
|
+
self,
|
|
38
|
+
command: str,
|
|
39
|
+
cwd: str | Path | None = None,
|
|
40
|
+
timeout: float = 30.0,
|
|
41
|
+
) -> CommandResult:
|
|
42
|
+
"""Execute a bash command locally.
|
|
43
|
+
|
|
44
|
+
Uses the shared shell execution utility to run commands with proper
|
|
45
|
+
timeout handling, output streaming, and error management.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
command: The bash command to execute
|
|
49
|
+
cwd: Working directory (optional)
|
|
50
|
+
timeout: Timeout in seconds
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
CommandResult: Result with stdout, stderr, exit_code, command, and
|
|
54
|
+
timeout_occurred
|
|
55
|
+
"""
|
|
56
|
+
logger.debug(f"Executing local bash command: {command} in {cwd}")
|
|
57
|
+
result = execute_command(
|
|
58
|
+
command,
|
|
59
|
+
cwd=str(cwd) if cwd is not None else str(self.working_dir),
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
print_output=True,
|
|
62
|
+
)
|
|
63
|
+
return CommandResult(
|
|
64
|
+
command=command,
|
|
65
|
+
exit_code=result.returncode,
|
|
66
|
+
stdout=result.stdout,
|
|
67
|
+
stderr=result.stderr,
|
|
68
|
+
timeout_occurred=result.returncode == -1,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def file_upload(
|
|
72
|
+
self,
|
|
73
|
+
source_path: str | Path,
|
|
74
|
+
destination_path: str | Path,
|
|
75
|
+
) -> FileOperationResult:
|
|
76
|
+
"""Upload (copy) a file locally.
|
|
77
|
+
|
|
78
|
+
For local systems, file upload is implemented as a file copy operation
|
|
79
|
+
using shutil.copy2 to preserve metadata.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
source_path: Path to the source file
|
|
83
|
+
destination_path: Path where the file should be copied
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
FileOperationResult: Result with success status and file information
|
|
87
|
+
"""
|
|
88
|
+
source = Path(source_path)
|
|
89
|
+
destination = Path(destination_path)
|
|
90
|
+
|
|
91
|
+
logger.debug(f"Local file upload: {source} -> {destination}")
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
# Ensure destination directory exists
|
|
95
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
# Copy the file with metadata preservation
|
|
98
|
+
shutil.copy2(source, destination)
|
|
99
|
+
|
|
100
|
+
return FileOperationResult(
|
|
101
|
+
success=True,
|
|
102
|
+
source_path=str(source),
|
|
103
|
+
destination_path=str(destination),
|
|
104
|
+
file_size=destination.stat().st_size,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"Local file upload failed: {e}")
|
|
109
|
+
return FileOperationResult(
|
|
110
|
+
success=False,
|
|
111
|
+
source_path=str(source),
|
|
112
|
+
destination_path=str(destination),
|
|
113
|
+
error=str(e),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def file_download(
|
|
117
|
+
self,
|
|
118
|
+
source_path: str | Path,
|
|
119
|
+
destination_path: str | Path,
|
|
120
|
+
) -> FileOperationResult:
|
|
121
|
+
"""Download (copy) a file locally.
|
|
122
|
+
|
|
123
|
+
For local systems, file download is implemented as a file copy operation
|
|
124
|
+
using shutil.copy2 to preserve metadata.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
source_path: Path to the source file
|
|
128
|
+
destination_path: Path where the file should be copied
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
FileOperationResult: Result with success status and file information
|
|
132
|
+
"""
|
|
133
|
+
source = Path(source_path)
|
|
134
|
+
destination = Path(destination_path)
|
|
135
|
+
|
|
136
|
+
logger.debug(f"Local file download: {source} -> {destination}")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
# Ensure destination directory exists
|
|
140
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
# Copy the file with metadata preservation
|
|
143
|
+
shutil.copy2(source, destination)
|
|
144
|
+
|
|
145
|
+
return FileOperationResult(
|
|
146
|
+
success=True,
|
|
147
|
+
source_path=str(source),
|
|
148
|
+
destination_path=str(destination),
|
|
149
|
+
file_size=destination.stat().st_size,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(f"Local file download failed: {e}")
|
|
154
|
+
return FileOperationResult(
|
|
155
|
+
success=False,
|
|
156
|
+
source_path=str(source),
|
|
157
|
+
destination_path=str(destination),
|
|
158
|
+
error=str(e),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def git_changes(self, path: str | Path) -> list[GitChange]:
|
|
162
|
+
"""Get the git changes for the repository at the path given.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
path: Path to the git repository
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
list[GitChange]: List of changes
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
Exception: If path is not a git repository or getting changes failed
|
|
172
|
+
"""
|
|
173
|
+
path = Path(self.working_dir) / path
|
|
174
|
+
return get_git_changes(path)
|
|
175
|
+
|
|
176
|
+
def git_diff(self, path: str | Path) -> GitDiff:
|
|
177
|
+
"""Get the git diff for the file at the path given.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
path: Path to the file
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
GitDiff: Git diff
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
Exception: If path is not a git repository or getting diff failed
|
|
187
|
+
"""
|
|
188
|
+
path = Path(self.working_dir) / path
|
|
189
|
+
return get_git_diff(path)
|