openhands-tools 1.52.0__tar.gz → 1.54.0__tar.gz
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_tools-1.52.0 → openhands_tools-1.54.0}/PKG-INFO +1 -1
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/editor.py +25 -13
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/encoding.py +18 -2
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/terminal_session.py +184 -49
- openhands_tools-1.54.0/openhands/tools/terminal/utils/command.py +195 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands_tools.egg-info/PKG-INFO +1 -1
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/pyproject.toml +1 -1
- openhands_tools-1.52.0/openhands/tools/terminal/utils/command.py +0 -111
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/apply_patch/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/apply_patch/core.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/apply_patch/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/ask_oracle/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/ask_oracle/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/ask_oracle/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/event_storage.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/logging_fix.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/recording.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/server.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/delegate/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/delegate/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/delegate/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/delegate/visualizer.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/exceptions.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/config.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/constants.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/diff.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/file_cache.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/history.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/shell.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/edit/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/edit/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/edit/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/file_change.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/list_directory/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/list_directory/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/list_directory/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/read_file/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/read_file/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/read_file/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/write_file/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/write_file/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/write_file/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/glob/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/glob/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/glob/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/grep/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/grep/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/grep/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/planning_file_editor/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/planning_file_editor/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/planning_file_editor/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/default.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/gemini.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/gpt5.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/planning.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/bash_runner.md +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/code_explorer.md +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/default.md +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/web_researcher.md +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/task_outcome.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/py.typed +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/task/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/task/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/task/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/task/manager.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/task_tracker/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/task_tracker/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/constants.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/descriptions.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/env.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/metadata.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/factory.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/interface.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/subprocess_terminal.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/tmux_pane_pool.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/tmux_terminal.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/windows_terminal.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/timeout_policy.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/utils/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/utils/escape_filter.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/tom_consult/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/tom_consult/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/tom_consult/executor.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/utils/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/utils/timeout.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/workflow/__init__.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/workflow/definition.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/workflow/impl.py +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands_tools.egg-info/SOURCES.txt +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands_tools.egg-info/dependency_links.txt +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands_tools.egg-info/requires.txt +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands_tools.egg-info/top_level.txt +0 -0
- {openhands_tools-1.52.0 → openhands_tools-1.54.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-tools
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.54.0
|
|
4
4
|
Summary: OpenHands Tools - Runtime tools for AI agents
|
|
5
5
|
Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
|
|
6
6
|
Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
|
|
@@ -414,11 +414,9 @@ class FileEditor:
|
|
|
414
414
|
)
|
|
415
415
|
|
|
416
416
|
file_content = self.read_file(path, start_line=start_line, end_line=end_line)
|
|
417
|
+
file_content = self._normalize_lines(file_content)
|
|
417
418
|
|
|
418
|
-
|
|
419
|
-
output = self._make_output(
|
|
420
|
-
"\n".join(file_content.splitlines()), str(path), start_line
|
|
421
|
-
) # Remove extra newlines
|
|
419
|
+
output = self._make_output(file_content, str(path), start_line)
|
|
422
420
|
|
|
423
421
|
# Prepend warning if we truncated the end_line
|
|
424
422
|
if warning_message:
|
|
@@ -787,6 +785,17 @@ class FileEditor:
|
|
|
787
785
|
except Exception as e:
|
|
788
786
|
raise ToolError(f"Ran into {e} while trying to read {path}") from None
|
|
789
787
|
|
|
788
|
+
def _normalize_lines(self, content: str) -> str:
|
|
789
|
+
"""Rejoin a template snippet without folding exotic line separators.
|
|
790
|
+
|
|
791
|
+
``read_file`` returns a snippet whose lines are separated by ``\\n``. The
|
|
792
|
+
ranged ``view`` path used to round-trip it through ``str.splitlines()``,
|
|
793
|
+
which also breaks on form feeds and Unicode separators that the rest of
|
|
794
|
+
the editor treats as ordinary characters, shifting line numbers. Joining
|
|
795
|
+
on ``\\n`` instead keeps every other character intact.
|
|
796
|
+
"""
|
|
797
|
+
return "\n".join(content.split("\n"))
|
|
798
|
+
|
|
790
799
|
def _make_output(
|
|
791
800
|
self,
|
|
792
801
|
snippet_content: str,
|
|
@@ -815,14 +824,17 @@ class FileEditor:
|
|
|
815
824
|
truncate_notice=TEXT_FILE_CONTENT_TRUNCATED_NOTICE,
|
|
816
825
|
)
|
|
817
826
|
|
|
827
|
+
# Split on "\n" only, matching how read_file/_count_lines define lines, so that
|
|
828
|
+
# characters splitlines() treats as breaks (\x0c, \u2028, ...) keep line numbers
|
|
829
|
+
# consistent with insert/view_range. A terminating newline ends the last line
|
|
830
|
+
# rather than adding a blank one, so drop the empty field it produces.
|
|
831
|
+
lines = snippet_content.split("\n")
|
|
832
|
+
if lines[-1] == "":
|
|
833
|
+
lines.pop()
|
|
818
834
|
snippet_content = "\n".join(
|
|
819
|
-
[
|
|
820
|
-
f"{i + start_line:6}\t{line}"
|
|
821
|
-
for i, line in enumerate(snippet_content.split("\n"))
|
|
822
|
-
]
|
|
823
|
-
)
|
|
824
|
-
return (
|
|
825
|
-
f"Here's the result of running `cat -n` on {snippet_description}:\n"
|
|
826
|
-
+ snippet_content
|
|
827
|
-
+ "\n"
|
|
835
|
+
[f"{i + start_line:6}\t{line}" for i, line in enumerate(lines)]
|
|
828
836
|
)
|
|
837
|
+
output = f"Here's the result of running `cat -n` on {snippet_description}:\n"
|
|
838
|
+
if snippet_content:
|
|
839
|
+
output += snippet_content + "\n"
|
|
840
|
+
return output
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/encoding.py
RENAMED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Encoding management for file operations."""
|
|
2
2
|
|
|
3
|
+
import codecs
|
|
3
4
|
import functools
|
|
4
5
|
import inspect
|
|
5
6
|
import os
|
|
@@ -22,7 +23,8 @@ BINARY_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0e-\x1f\x7f]")
|
|
|
22
23
|
# file's encoding. Short non-UTF-8 files fall below the detector's confidence
|
|
23
24
|
# threshold and are reported as the default encoding, so the detected value
|
|
24
25
|
# alone is not enough to recover them. Ordered most to least likely for source
|
|
25
|
-
# files; the detector's own guess is tried
|
|
26
|
+
# files; the detector's own guess is tried right after UTF-8 when it is one of
|
|
27
|
+
# these.
|
|
26
28
|
TEXT_DECODE_CANDIDATES = ("utf-8", "cp1252", "latin-1", "cp1251")
|
|
27
29
|
|
|
28
30
|
# charset_normalizer reports some codecs under IANA names; map them to the
|
|
@@ -70,6 +72,16 @@ class EncodingManager:
|
|
|
70
72
|
with open(path, "rb") as f:
|
|
71
73
|
raw_data = f.read(sample_size)
|
|
72
74
|
|
|
75
|
+
if raw_data.startswith(codecs.BOM_UTF8):
|
|
76
|
+
return "utf-8-sig"
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
raw_data.decode(self.default_encoding)
|
|
80
|
+
except UnicodeDecodeError:
|
|
81
|
+
pass
|
|
82
|
+
else:
|
|
83
|
+
return self.default_encoding
|
|
84
|
+
|
|
73
85
|
# Use charset_normalizer instead of chardet
|
|
74
86
|
results = charset_normalizer.detect(raw_data)
|
|
75
87
|
|
|
@@ -116,7 +128,11 @@ class EncodingManager:
|
|
|
116
128
|
|
|
117
129
|
def _recover_encoding(self, path: Path) -> str | None:
|
|
118
130
|
"""Return a codec that decodes ``path`` to control-byte-free text."""
|
|
119
|
-
candidates = (
|
|
131
|
+
candidates = (
|
|
132
|
+
self.default_encoding,
|
|
133
|
+
self._best_guess_encoding(path),
|
|
134
|
+
*TEXT_DECODE_CANDIDATES,
|
|
135
|
+
)
|
|
120
136
|
for candidate in dict.fromkeys(c for c in candidates if c):
|
|
121
137
|
try:
|
|
122
138
|
text = path.read_text(encoding=candidate)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
import time
|
|
5
|
+
import uuid
|
|
5
6
|
from enum import Enum
|
|
6
7
|
|
|
7
8
|
from openhands.sdk.logger import get_logger
|
|
@@ -25,6 +26,7 @@ from openhands.tools.terminal.terminal.interface import (
|
|
|
25
26
|
from openhands.tools.terminal.timeout_policy import foreground_timeout_rejection_for
|
|
26
27
|
from openhands.tools.terminal.utils.command import (
|
|
27
28
|
escape_bash_special_chars,
|
|
29
|
+
needs_heredoc_completion_boundary,
|
|
28
30
|
split_bash_commands,
|
|
29
31
|
)
|
|
30
32
|
from openhands.tools.terminal.utils.escape_filter import TerminalQueryFilter
|
|
@@ -43,8 +45,51 @@ class TerminalCommandStatus(Enum):
|
|
|
43
45
|
HARD_TIMEOUT = "hard_timeout"
|
|
44
46
|
|
|
45
47
|
|
|
48
|
+
def _normalize_echo_line(line: str) -> str:
|
|
49
|
+
"""Collapse an echoed line for comparison against what was sent.
|
|
50
|
+
|
|
51
|
+
Different backends alter the echo: readline drops leading tabs (tmux) or
|
|
52
|
+
substitutes a BEL for each of them (subprocess). Ignore whitespace and
|
|
53
|
+
control characters so the comparison still recognizes the echo.
|
|
54
|
+
"""
|
|
55
|
+
return re.sub(r"[\x00-\x1f\x7f]", "", line).strip()
|
|
56
|
+
|
|
57
|
+
|
|
46
58
|
def _remove_command_prefix(command_output: str, command: str) -> str:
|
|
47
|
-
|
|
59
|
+
"""Strip the terminal's echo of `command` from the front of the output.
|
|
60
|
+
|
|
61
|
+
readline consumes or rewrites leading tabs when echoing a line, so the echo
|
|
62
|
+
can differ from the text that was sent by whitespace alone. An exact prefix
|
|
63
|
+
match then fails and the whole wrapper (plus both halves of the completion
|
|
64
|
+
marker) leaks into the observation, so fall back to comparing line by line
|
|
65
|
+
with whitespace and control characters normalized.
|
|
66
|
+
"""
|
|
67
|
+
stripped = command_output.lstrip()
|
|
68
|
+
cmd = command.lstrip()
|
|
69
|
+
if stripped.startswith(cmd):
|
|
70
|
+
return stripped[len(cmd) :].lstrip()
|
|
71
|
+
|
|
72
|
+
if not cmd:
|
|
73
|
+
return stripped
|
|
74
|
+
out_lines = stripped.split("\n")
|
|
75
|
+
cmd_lines = cmd.split("\n")
|
|
76
|
+
for index, cmd_line in enumerate(cmd_lines):
|
|
77
|
+
if index >= len(out_lines) or _normalize_echo_line(
|
|
78
|
+
out_lines[index]
|
|
79
|
+
) != _normalize_echo_line(cmd_line):
|
|
80
|
+
# Not an echo of this command; leave the output untouched.
|
|
81
|
+
return stripped
|
|
82
|
+
return "\n".join(out_lines[len(cmd_lines) :]).lstrip()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _remove_echoed_line(command_output: str, command: str) -> str:
|
|
86
|
+
"""Remove one generated command echo wherever it appears in the output."""
|
|
87
|
+
target = _normalize_echo_line(command)
|
|
88
|
+
return "".join(
|
|
89
|
+
line
|
|
90
|
+
for line in command_output.splitlines(keepends=True)
|
|
91
|
+
if _normalize_echo_line(line) != target
|
|
92
|
+
)
|
|
48
93
|
|
|
49
94
|
|
|
50
95
|
def _remove_powershell_echo(command_output: str, command: str) -> str:
|
|
@@ -91,6 +136,11 @@ class TerminalSession(TerminalSessionBase):
|
|
|
91
136
|
# Store the last command for interactive input handling
|
|
92
137
|
self.prev_status = None
|
|
93
138
|
self.prev_output = ""
|
|
139
|
+
# Completion suffix for a heredoc script. Keep it on the session so a
|
|
140
|
+
# timeout or follow-up poll can recognize and strip it too.
|
|
141
|
+
self._boundary_marker: re.Pattern[str] | None = None
|
|
142
|
+
self._boundary_echo: str | None = None
|
|
143
|
+
self._wrapper_echo: str | None = None
|
|
94
144
|
# Stateful filter for terminal query sequences (handles split sequences)
|
|
95
145
|
self._query_filter = TerminalQueryFilter()
|
|
96
146
|
|
|
@@ -192,15 +242,18 @@ class TerminalSession(TerminalSessionBase):
|
|
|
192
242
|
command: str,
|
|
193
243
|
terminal_content: str,
|
|
194
244
|
ps1_matches: list[re.Match],
|
|
245
|
+
echoed_command: str | None = None,
|
|
246
|
+
boundary_exit_code: int | None = None,
|
|
195
247
|
) -> TerminalObservation:
|
|
196
248
|
"""Handle a completed command."""
|
|
197
249
|
is_special_key = self._is_special_key(command)
|
|
250
|
+
output_command = echoed_command or command
|
|
198
251
|
|
|
199
252
|
# When PS1 metadata markers are missing (e.g., corrupted by TUI/ANSI
|
|
200
253
|
# output or scrolled off-screen), fall back gracefully instead of
|
|
201
254
|
# crashing. The command likely completed but we can't extract the
|
|
202
255
|
# exit code or working directory.
|
|
203
|
-
if len(ps1_matches) == 0:
|
|
256
|
+
if len(ps1_matches) == 0 and boundary_exit_code is None:
|
|
204
257
|
logger.warning(
|
|
205
258
|
"No PS1 metadata found in terminal output. "
|
|
206
259
|
"Command output may have overwritten the markers "
|
|
@@ -213,7 +266,7 @@ class TerminalSession(TerminalSessionBase):
|
|
|
213
266
|
"PS1 metadata markers.]"
|
|
214
267
|
)
|
|
215
268
|
command_output = self._get_command_output(
|
|
216
|
-
|
|
269
|
+
output_command,
|
|
217
270
|
terminal_content,
|
|
218
271
|
metadata,
|
|
219
272
|
is_final=True,
|
|
@@ -232,35 +285,36 @@ class TerminalSession(TerminalSessionBase):
|
|
|
232
285
|
exit_code=metadata.exit_code,
|
|
233
286
|
)
|
|
234
287
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
288
|
+
if boundary_exit_code is not None:
|
|
289
|
+
metadata = CmdOutputMetadata(
|
|
290
|
+
exit_code=boundary_exit_code, working_dir=self._cwd
|
|
291
|
+
)
|
|
292
|
+
get_content_before_last_match = False
|
|
293
|
+
raw_command_output = self._combine_outputs_between_matches(
|
|
294
|
+
terminal_content, ps1_matches
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
metadata = CmdOutputMetadata.from_ps1_match(ps1_matches[-1])
|
|
298
|
+
get_content_before_last_match = bool(len(ps1_matches) == 1)
|
|
299
|
+
if metadata.working_dir != self._cwd and metadata.working_dir:
|
|
300
|
+
self._cwd = metadata.working_dir
|
|
301
|
+
raw_command_output = self._combine_outputs_between_matches(
|
|
302
|
+
terminal_content,
|
|
303
|
+
ps1_matches,
|
|
304
|
+
get_content_before_last_match=get_content_before_last_match,
|
|
305
|
+
)
|
|
306
|
+
if get_content_before_last_match:
|
|
307
|
+
num_lines = len(raw_command_output.splitlines())
|
|
308
|
+
metadata.prefix = (
|
|
309
|
+
f"[Previous command outputs are truncated. "
|
|
310
|
+
f"Showing the last {num_lines} lines of the output below.]\n"
|
|
311
|
+
)
|
|
244
312
|
|
|
245
313
|
logger.debug(
|
|
246
314
|
"Parsed terminal output (previous_ps1_not_matched=%s, content_length=%s)",
|
|
247
315
|
get_content_before_last_match,
|
|
248
316
|
len(terminal_content),
|
|
249
317
|
)
|
|
250
|
-
# Extract the command output between the two PS1 prompts
|
|
251
|
-
raw_command_output = self._combine_outputs_between_matches(
|
|
252
|
-
terminal_content,
|
|
253
|
-
ps1_matches,
|
|
254
|
-
get_content_before_last_match=get_content_before_last_match,
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
if get_content_before_last_match:
|
|
258
|
-
# Count the number of lines in the truncated output
|
|
259
|
-
num_lines = len(raw_command_output.splitlines())
|
|
260
|
-
metadata.prefix = (
|
|
261
|
-
f"[Previous command outputs are truncated. "
|
|
262
|
-
f"Showing the last {num_lines} lines of the output below.]\n"
|
|
263
|
-
)
|
|
264
318
|
|
|
265
319
|
metadata.suffix = (
|
|
266
320
|
f"\n[The command completed with exit code {metadata.exit_code}.]"
|
|
@@ -271,7 +325,7 @@ class TerminalSession(TerminalSessionBase):
|
|
|
271
325
|
)
|
|
272
326
|
)
|
|
273
327
|
command_output = self._get_command_output(
|
|
274
|
-
|
|
328
|
+
output_command,
|
|
275
329
|
raw_command_output,
|
|
276
330
|
metadata,
|
|
277
331
|
is_final=True, # Command completed, flush filter state
|
|
@@ -296,6 +350,7 @@ class TerminalSession(TerminalSessionBase):
|
|
|
296
350
|
command: str,
|
|
297
351
|
terminal_content: str,
|
|
298
352
|
ps1_matches: list[re.Match],
|
|
353
|
+
echoed_command: str | None = None,
|
|
299
354
|
) -> TerminalObservation:
|
|
300
355
|
"""Handle a command that timed out due to no output change."""
|
|
301
356
|
self.prev_status = TerminalCommandStatus.NO_CHANGE_TIMEOUT
|
|
@@ -315,7 +370,7 @@ class TerminalSession(TerminalSessionBase):
|
|
|
315
370
|
f"{self.no_change_timeout_seconds} seconds. {TIMEOUT_MESSAGE_TEMPLATE}]"
|
|
316
371
|
)
|
|
317
372
|
command_output = self._get_command_output(
|
|
318
|
-
command,
|
|
373
|
+
echoed_command or command,
|
|
319
374
|
raw_command_output,
|
|
320
375
|
metadata,
|
|
321
376
|
continue_prefix="[Below is the output of the previous command.]\n",
|
|
@@ -336,6 +391,7 @@ class TerminalSession(TerminalSessionBase):
|
|
|
336
391
|
terminal_content: str,
|
|
337
392
|
ps1_matches: list[re.Match],
|
|
338
393
|
timeout: float,
|
|
394
|
+
echoed_command: str | None = None,
|
|
339
395
|
) -> TerminalObservation:
|
|
340
396
|
"""Handle a command that timed out due to hard timeout."""
|
|
341
397
|
self.prev_status = TerminalCommandStatus.HARD_TIMEOUT
|
|
@@ -355,7 +411,7 @@ class TerminalSession(TerminalSessionBase):
|
|
|
355
411
|
f"{TIMEOUT_MESSAGE_TEMPLATE}]"
|
|
356
412
|
)
|
|
357
413
|
command_output = self._get_command_output(
|
|
358
|
-
command,
|
|
414
|
+
echoed_command or command,
|
|
359
415
|
raw_command_output,
|
|
360
416
|
metadata,
|
|
361
417
|
continue_prefix="[Below is the output of the previous command.]\n",
|
|
@@ -375,6 +431,24 @@ class TerminalSession(TerminalSessionBase):
|
|
|
375
431
|
# Clear the current content
|
|
376
432
|
self.terminal.clear_screen()
|
|
377
433
|
|
|
434
|
+
def _strip_boundary_wrapper(self, terminal_content: str) -> str:
|
|
435
|
+
"""Remove the heredoc completion suffix from a raw screen read."""
|
|
436
|
+
if self._boundary_marker is not None:
|
|
437
|
+
terminal_content = self._boundary_marker.sub("", terminal_content)
|
|
438
|
+
terminal_content = terminal_content.replace("\x1b[?2004l", "").replace(
|
|
439
|
+
"\x1b[?2004h", ""
|
|
440
|
+
)
|
|
441
|
+
if self._boundary_echo:
|
|
442
|
+
terminal_content = terminal_content.replace(self._boundary_echo, "")
|
|
443
|
+
terminal_content = _remove_echoed_line(
|
|
444
|
+
terminal_content, self._boundary_echo
|
|
445
|
+
)
|
|
446
|
+
if self._wrapper_echo:
|
|
447
|
+
terminal_content = _remove_command_prefix(
|
|
448
|
+
terminal_content, self._wrapper_echo
|
|
449
|
+
)
|
|
450
|
+
return terminal_content
|
|
451
|
+
|
|
378
452
|
def _combine_outputs_between_matches(
|
|
379
453
|
self,
|
|
380
454
|
terminal_content: str,
|
|
@@ -494,6 +568,7 @@ class TerminalSession(TerminalSessionBase):
|
|
|
494
568
|
and not is_input
|
|
495
569
|
and command != ""
|
|
496
570
|
):
|
|
571
|
+
last_terminal_output = self._strip_boundary_wrapper(last_terminal_output)
|
|
497
572
|
_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_terminal_output)
|
|
498
573
|
# Use initial_ps1_matches if _ps1_matches is empty,
|
|
499
574
|
# otherwise use _ps1_matches. This handles the case where
|
|
@@ -533,6 +608,32 @@ class TerminalSession(TerminalSessionBase):
|
|
|
533
608
|
)
|
|
534
609
|
return obs
|
|
535
610
|
|
|
611
|
+
command_to_send = command
|
|
612
|
+
boundary_command: str | None = None
|
|
613
|
+
command_boundary_marker: re.Pattern[str] | None = None
|
|
614
|
+
if command and not is_input:
|
|
615
|
+
self._boundary_marker = None
|
|
616
|
+
self._boundary_echo = None
|
|
617
|
+
self._wrapper_echo = None
|
|
618
|
+
if command and not is_input and not self.terminal.is_powershell():
|
|
619
|
+
if needs_heredoc_completion_boundary(command_to_send):
|
|
620
|
+
marker_id = uuid.uuid4().hex
|
|
621
|
+
marker_start = f"__OH_COMMAND_FINISHED_{marker_id}_"
|
|
622
|
+
marker_end = "__"
|
|
623
|
+
command_boundary_marker = re.compile(
|
|
624
|
+
rf"{re.escape(marker_start)}(?P<exit_code>\d+){marker_end}"
|
|
625
|
+
)
|
|
626
|
+
boundary_command = (
|
|
627
|
+
"(__openhands_status=$?; printf '\\n%s%s%s\\n' "
|
|
628
|
+
f"'{marker_start}' \"$__openhands_status\" '{marker_end}' "
|
|
629
|
+
'> /dev/tty; exit "$__openhands_status")'
|
|
630
|
+
)
|
|
631
|
+
self._boundary_marker = command_boundary_marker
|
|
632
|
+
command_to_send = escape_bash_special_chars(command_to_send)
|
|
633
|
+
if boundary_command is not None:
|
|
634
|
+
self._boundary_echo = boundary_command
|
|
635
|
+
self._wrapper_echo = command_to_send
|
|
636
|
+
|
|
536
637
|
# Send actual command/inputs to the terminal
|
|
537
638
|
sent_command = command != ""
|
|
538
639
|
if command != "":
|
|
@@ -546,15 +647,15 @@ class TerminalSession(TerminalSessionBase):
|
|
|
546
647
|
enter=not is_special_key,
|
|
547
648
|
)
|
|
548
649
|
else:
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
command = escape_bash_special_chars(command)
|
|
553
|
-
logger.debug("Sending command (command_length=%s)", len(command))
|
|
650
|
+
logger.debug(
|
|
651
|
+
"Sending command (command_length=%s)", len(command_to_send)
|
|
652
|
+
)
|
|
554
653
|
self.terminal.send_keys(
|
|
555
|
-
|
|
654
|
+
command_to_send,
|
|
556
655
|
enter=not is_special_key,
|
|
557
656
|
)
|
|
657
|
+
if boundary_command is not None:
|
|
658
|
+
self.terminal.send_keys(boundary_command)
|
|
558
659
|
|
|
559
660
|
# Loop until the command completes or times out
|
|
560
661
|
while True:
|
|
@@ -572,25 +673,47 @@ class TerminalSession(TerminalSessionBase):
|
|
|
572
673
|
output_changed_since_command = (
|
|
573
674
|
cur_terminal_output != initial_terminal_output
|
|
574
675
|
)
|
|
676
|
+
active_boundary_marker = command_boundary_marker or self._boundary_marker
|
|
677
|
+
boundary_match = (
|
|
678
|
+
active_boundary_marker.search(cur_terminal_output)
|
|
679
|
+
if active_boundary_marker is not None
|
|
680
|
+
else None
|
|
681
|
+
)
|
|
682
|
+
command_reached_boundary = (
|
|
683
|
+
active_boundary_marker is None or boundary_match is not None
|
|
684
|
+
)
|
|
575
685
|
|
|
576
686
|
if cur_terminal_output != last_terminal_output:
|
|
577
687
|
last_terminal_output = cur_terminal_output
|
|
578
688
|
last_change_time = time.time()
|
|
579
689
|
logger.debug(f"CONTENT UPDATED DETECTED at {last_change_time}")
|
|
580
690
|
|
|
581
|
-
# 1) Execution completed
|
|
582
|
-
#
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if (
|
|
587
|
-
|
|
588
|
-
or
|
|
691
|
+
# 1) Execution completed when a prompt appears, or when a heredoc
|
|
692
|
+
# suffix reaches its tty boundary after stdout has been redirected.
|
|
693
|
+
prompt_reached = current_ps1_count > initial_ps1_count or (
|
|
694
|
+
cur_terminal_output.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip())
|
|
695
|
+
)
|
|
696
|
+
if (
|
|
697
|
+
command_reached_boundary
|
|
698
|
+
and (not sent_command or output_changed_since_command)
|
|
699
|
+
and (prompt_reached or boundary_match is not None)
|
|
589
700
|
):
|
|
701
|
+
completed_terminal_output = cur_terminal_output
|
|
702
|
+
boundary_exit_code = None
|
|
703
|
+
if active_boundary_marker is not None:
|
|
704
|
+
completed_terminal_output = self._strip_boundary_wrapper(
|
|
705
|
+
completed_terminal_output
|
|
706
|
+
)
|
|
707
|
+
if not prompt_reached and boundary_match is not None:
|
|
708
|
+
boundary_exit_code = int(boundary_match.group("exit_code"))
|
|
709
|
+
completed_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(
|
|
710
|
+
completed_terminal_output
|
|
711
|
+
)
|
|
590
712
|
obs = self._handle_completed_command(
|
|
591
713
|
command,
|
|
592
|
-
terminal_content=
|
|
593
|
-
ps1_matches=
|
|
714
|
+
terminal_content=completed_terminal_output,
|
|
715
|
+
ps1_matches=completed_ps1_matches,
|
|
716
|
+
boundary_exit_code=boundary_exit_code,
|
|
594
717
|
)
|
|
595
718
|
return obs
|
|
596
719
|
|
|
@@ -610,10 +733,14 @@ class TerminalSession(TerminalSessionBase):
|
|
|
610
733
|
and self.no_change_timeout_seconds is not None
|
|
611
734
|
and time_since_last_change >= self.no_change_timeout_seconds
|
|
612
735
|
):
|
|
736
|
+
stripped_output = self._strip_boundary_wrapper(cur_terminal_output)
|
|
613
737
|
obs = self._handle_nochange_timeout_command(
|
|
614
738
|
command,
|
|
615
|
-
terminal_content=
|
|
616
|
-
ps1_matches=
|
|
739
|
+
terminal_content=stripped_output,
|
|
740
|
+
ps1_matches=CmdOutputMetadata.matches_ps1_metadata(stripped_output),
|
|
741
|
+
echoed_command=(
|
|
742
|
+
command_to_send if command_boundary_marker is not None else None
|
|
743
|
+
),
|
|
617
744
|
)
|
|
618
745
|
return obs
|
|
619
746
|
|
|
@@ -626,11 +753,19 @@ class TerminalSession(TerminalSessionBase):
|
|
|
626
753
|
if action.timeout is not None:
|
|
627
754
|
time_since_start = time.time() - start_time
|
|
628
755
|
if time_since_start >= action.timeout:
|
|
756
|
+
stripped_output = self._strip_boundary_wrapper(cur_terminal_output)
|
|
629
757
|
obs = self._handle_hard_timeout_command(
|
|
630
758
|
command,
|
|
631
|
-
terminal_content=
|
|
632
|
-
ps1_matches=
|
|
759
|
+
terminal_content=stripped_output,
|
|
760
|
+
ps1_matches=CmdOutputMetadata.matches_ps1_metadata(
|
|
761
|
+
stripped_output
|
|
762
|
+
),
|
|
633
763
|
timeout=action.timeout,
|
|
764
|
+
echoed_command=(
|
|
765
|
+
command_to_send
|
|
766
|
+
if command_boundary_marker is not None
|
|
767
|
+
else None
|
|
768
|
+
),
|
|
634
769
|
)
|
|
635
770
|
return obs
|
|
636
771
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Command splitting and escape utilities backed by tree-sitter-bash."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from tree_sitter import Node
|
|
6
|
+
|
|
7
|
+
from openhands.sdk.logger import get_logger
|
|
8
|
+
from openhands.sdk.security.shell_parser import parse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
# Regions whose contents bash takes verbatim — escape doubling stops at
|
|
14
|
+
# their boundaries so operators nested inside (e.g.) a double-quoted
|
|
15
|
+
# string remain untouched. Walking does not recurse into these nodes.
|
|
16
|
+
_PRESERVE_TYPES: frozenset[str] = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"string",
|
|
19
|
+
"raw_string",
|
|
20
|
+
"ansi_c_string",
|
|
21
|
+
"translated_string",
|
|
22
|
+
"command_substitution",
|
|
23
|
+
"expansion",
|
|
24
|
+
"simple_expansion",
|
|
25
|
+
"heredoc_body",
|
|
26
|
+
"comment",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_ESCAPE_PATTERN: re.Pattern[bytes] = re.compile(rb"\\([;&|<>])")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ends_with_heredoc(node: Node) -> bool:
|
|
34
|
+
"""Whether ``node``'s final token is a heredoc terminator.
|
|
35
|
+
|
|
36
|
+
Checking only that the last *named* descendant is a ``heredoc_end`` is not
|
|
37
|
+
enough: tree-sitter leaves closing tokens such as ``fi``, ``done``, and
|
|
38
|
+
``)`` unnamed, so a compound statement or substitution that merely
|
|
39
|
+
*contains* a heredoc would match and swallow the newline that actually ends
|
|
40
|
+
the statement. Require the terminator to reach ``node``'s end too.
|
|
41
|
+
"""
|
|
42
|
+
last = node
|
|
43
|
+
while last.named_children:
|
|
44
|
+
last = last.named_children[-1]
|
|
45
|
+
return last.type == "heredoc_end" and last.end_byte == node.end_byte
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _last_heredoc_is_closed(root: Node) -> bool:
|
|
49
|
+
"""Whether the final heredoc in an unparseable input has seen its terminator.
|
|
50
|
+
|
|
51
|
+
tree-sitter-bash reports a parse error for forms bash itself accepts (a
|
|
52
|
+
quoted delimiter, several heredocs on one command, ``&`` before the body)
|
|
53
|
+
and may then omit the ``heredoc_end`` node, leaving the terminator line
|
|
54
|
+
inside the ``heredoc_body`` instead. Compare that final line against the
|
|
55
|
+
delimiter to tell a closed heredoc from one bash is still reading. Deciding
|
|
56
|
+
from the tree keeps the guard identical on every platform instead of
|
|
57
|
+
depending on a ``bash`` executable being present and working.
|
|
58
|
+
"""
|
|
59
|
+
entries: list[tuple[int, str, bytes]] = []
|
|
60
|
+
stack = [root]
|
|
61
|
+
while stack:
|
|
62
|
+
node = stack.pop()
|
|
63
|
+
if node.type in ("heredoc_start", "heredoc_body", "heredoc_end"):
|
|
64
|
+
entries.append((node.start_byte, node.type, node.text or b""))
|
|
65
|
+
stack.extend(node.named_children)
|
|
66
|
+
entries.sort()
|
|
67
|
+
|
|
68
|
+
start = max(
|
|
69
|
+
(
|
|
70
|
+
index
|
|
71
|
+
for index, (_, kind, _) in enumerate(entries)
|
|
72
|
+
if kind == "heredoc_start"
|
|
73
|
+
),
|
|
74
|
+
default=None,
|
|
75
|
+
)
|
|
76
|
+
if start is None:
|
|
77
|
+
return False
|
|
78
|
+
delimiter = re.sub(r"['\"]", "", entries[start][2].decode().strip())
|
|
79
|
+
if delimiter.startswith("-"):
|
|
80
|
+
delimiter = delimiter[1:]
|
|
81
|
+
rest = entries[start + 1 :]
|
|
82
|
+
if any(kind == "heredoc_end" for _, kind, _ in rest):
|
|
83
|
+
return True
|
|
84
|
+
bodies = [text for _, kind, text in rest if kind == "heredoc_body"]
|
|
85
|
+
if not bodies:
|
|
86
|
+
return False
|
|
87
|
+
lines = [line for line in bodies[-1].decode().splitlines() if line.strip()]
|
|
88
|
+
return bool(lines) and lines[-1].strip() == delimiter
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def needs_heredoc_completion_boundary(commands: str) -> bool:
|
|
92
|
+
"""Whether interactive Bash may prompt before this input is fully consumed."""
|
|
93
|
+
source = commands.encode()
|
|
94
|
+
result = parse(commands)
|
|
95
|
+
root = result.tree.root_node
|
|
96
|
+
if result.has_error:
|
|
97
|
+
stack = [root]
|
|
98
|
+
has_heredoc = False
|
|
99
|
+
while stack:
|
|
100
|
+
node = stack.pop()
|
|
101
|
+
has_heredoc = has_heredoc or node.type.startswith("heredoc_")
|
|
102
|
+
stack.extend(node.named_children)
|
|
103
|
+
if not has_heredoc:
|
|
104
|
+
return False
|
|
105
|
+
return _last_heredoc_is_closed(root)
|
|
106
|
+
|
|
107
|
+
statements = [child for child in root.named_children if child.type != "comment"]
|
|
108
|
+
return any(
|
|
109
|
+
_ends_with_heredoc(current)
|
|
110
|
+
and b"\n" in source[current.end_byte : following.start_byte]
|
|
111
|
+
for current, following in zip(statements, statements[1:])
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def split_bash_commands(commands: str) -> list[str]:
|
|
116
|
+
"""Split a multi-statement bash input into top-level statements.
|
|
117
|
+
|
|
118
|
+
Statements separated by a newline (with or without intermediate
|
|
119
|
+
whitespace/comments) become separate entries, except after a completed
|
|
120
|
+
heredoc. Statements joined by ``;``, ``&&``, ``||``, ``|``, or ``&`` stay
|
|
121
|
+
together. Comments and whitespace between two statements are folded into
|
|
122
|
+
the preceding entry. On parse failure the input is returned as a
|
|
123
|
+
single-element list.
|
|
124
|
+
"""
|
|
125
|
+
if not commands.strip():
|
|
126
|
+
return [""]
|
|
127
|
+
|
|
128
|
+
source = commands.encode()
|
|
129
|
+
result = parse(commands)
|
|
130
|
+
root = result.tree.root_node
|
|
131
|
+
|
|
132
|
+
if result.has_error:
|
|
133
|
+
logger.debug(
|
|
134
|
+
"tree-sitter-bash reported parse errors; returning input as-is\n"
|
|
135
|
+
"[input]: %s",
|
|
136
|
+
commands,
|
|
137
|
+
)
|
|
138
|
+
return [commands]
|
|
139
|
+
|
|
140
|
+
statements = [c for c in root.named_children if c.type != "comment"]
|
|
141
|
+
if not statements:
|
|
142
|
+
return [commands]
|
|
143
|
+
|
|
144
|
+
boundaries = [statements[0].start_byte]
|
|
145
|
+
for cur, nxt in zip(statements, statements[1:]):
|
|
146
|
+
if b"\n" in source[cur.end_byte : nxt.start_byte] and not _ends_with_heredoc(
|
|
147
|
+
cur
|
|
148
|
+
):
|
|
149
|
+
boundaries.append(nxt.start_byte)
|
|
150
|
+
boundaries.append(len(source))
|
|
151
|
+
|
|
152
|
+
return [source[a:b].decode().rstrip() for a, b in zip(boundaries, boundaries[1:])]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def escape_bash_special_chars(command: str) -> str:
|
|
156
|
+
r"""Double the escape on ``\;``, ``\&``, ``\|``, ``\<``, ``\>``.
|
|
157
|
+
|
|
158
|
+
Sequences inside regions bash takes verbatim — single- and
|
|
159
|
+
double-quoted strings, command substitutions, parameter expansions,
|
|
160
|
+
heredoc bodies, and comments — are left untouched. On parse failure
|
|
161
|
+
the input is returned unchanged.
|
|
162
|
+
"""
|
|
163
|
+
if command.strip() == "":
|
|
164
|
+
return ""
|
|
165
|
+
|
|
166
|
+
source = command.encode()
|
|
167
|
+
result = parse(command)
|
|
168
|
+
if result.has_error:
|
|
169
|
+
logger.debug(
|
|
170
|
+
"tree-sitter-bash reported parse errors; returning input as-is\n"
|
|
171
|
+
"[input]: %s",
|
|
172
|
+
command,
|
|
173
|
+
)
|
|
174
|
+
return command
|
|
175
|
+
|
|
176
|
+
preserved: list[tuple[int, int]] = []
|
|
177
|
+
|
|
178
|
+
def collect(node: Node) -> None:
|
|
179
|
+
if node.type in _PRESERVE_TYPES:
|
|
180
|
+
preserved.append((node.start_byte, node.end_byte))
|
|
181
|
+
return
|
|
182
|
+
for child in node.children:
|
|
183
|
+
collect(child)
|
|
184
|
+
|
|
185
|
+
collect(result.tree.root_node)
|
|
186
|
+
|
|
187
|
+
out = bytearray()
|
|
188
|
+
cursor = 0
|
|
189
|
+
for start, end in preserved:
|
|
190
|
+
out.extend(_ESCAPE_PATTERN.sub(rb"\\\\\1", source[cursor:start]))
|
|
191
|
+
out.extend(source[start:end])
|
|
192
|
+
cursor = end
|
|
193
|
+
out.extend(_ESCAPE_PATTERN.sub(rb"\\\\\1", source[cursor:]))
|
|
194
|
+
|
|
195
|
+
return out.decode()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-tools
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.54.0
|
|
4
4
|
Summary: OpenHands Tools - Runtime tools for AI agents
|
|
5
5
|
Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
|
|
6
6
|
Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
"""Command splitting and escape utilities backed by tree-sitter-bash."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
|
|
5
|
-
from tree_sitter import Node
|
|
6
|
-
|
|
7
|
-
from openhands.sdk.logger import get_logger
|
|
8
|
-
from openhands.sdk.security.shell_parser import parse
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
logger = get_logger(__name__)
|
|
12
|
-
|
|
13
|
-
# Regions whose contents bash takes verbatim — escape doubling stops at
|
|
14
|
-
# their boundaries so operators nested inside (e.g.) a double-quoted
|
|
15
|
-
# string remain untouched. Walking does not recurse into these nodes.
|
|
16
|
-
_PRESERVE_TYPES: frozenset[str] = frozenset(
|
|
17
|
-
{
|
|
18
|
-
"string",
|
|
19
|
-
"raw_string",
|
|
20
|
-
"ansi_c_string",
|
|
21
|
-
"translated_string",
|
|
22
|
-
"command_substitution",
|
|
23
|
-
"expansion",
|
|
24
|
-
"simple_expansion",
|
|
25
|
-
"heredoc_body",
|
|
26
|
-
"comment",
|
|
27
|
-
}
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
_ESCAPE_PATTERN: re.Pattern[bytes] = re.compile(rb"\\([;&|<>])")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def split_bash_commands(commands: str) -> list[str]:
|
|
34
|
-
"""Split a multi-statement bash input into top-level statements.
|
|
35
|
-
|
|
36
|
-
Statements separated by a newline (with or without intermediate
|
|
37
|
-
whitespace/comments) become separate entries; statements joined by
|
|
38
|
-
``;``, ``&&``, ``||``, ``|``, or ``&`` stay together. Comments and
|
|
39
|
-
whitespace between two statements are folded into the preceding
|
|
40
|
-
entry. On parse failure the input is returned as a single-element
|
|
41
|
-
list.
|
|
42
|
-
"""
|
|
43
|
-
if not commands.strip():
|
|
44
|
-
return [""]
|
|
45
|
-
|
|
46
|
-
source = commands.encode()
|
|
47
|
-
result = parse(commands)
|
|
48
|
-
root = result.tree.root_node
|
|
49
|
-
|
|
50
|
-
if result.has_error:
|
|
51
|
-
logger.debug(
|
|
52
|
-
"tree-sitter-bash reported parse errors; returning input as-is\n"
|
|
53
|
-
"[input]: %s",
|
|
54
|
-
commands,
|
|
55
|
-
)
|
|
56
|
-
return [commands]
|
|
57
|
-
|
|
58
|
-
statements = [c for c in root.named_children if c.type != "comment"]
|
|
59
|
-
if not statements:
|
|
60
|
-
return [commands]
|
|
61
|
-
|
|
62
|
-
boundaries = [statements[0].start_byte]
|
|
63
|
-
for cur, nxt in zip(statements, statements[1:]):
|
|
64
|
-
if b"\n" in source[cur.end_byte : nxt.start_byte]:
|
|
65
|
-
boundaries.append(nxt.start_byte)
|
|
66
|
-
boundaries.append(len(source))
|
|
67
|
-
|
|
68
|
-
return [source[a:b].decode().rstrip() for a, b in zip(boundaries, boundaries[1:])]
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def escape_bash_special_chars(command: str) -> str:
|
|
72
|
-
r"""Double the escape on ``\;``, ``\&``, ``\|``, ``\<``, ``\>``.
|
|
73
|
-
|
|
74
|
-
Sequences inside regions bash takes verbatim — single- and
|
|
75
|
-
double-quoted strings, command substitutions, parameter expansions,
|
|
76
|
-
heredoc bodies, and comments — are left untouched. On parse failure
|
|
77
|
-
the input is returned unchanged.
|
|
78
|
-
"""
|
|
79
|
-
if command.strip() == "":
|
|
80
|
-
return ""
|
|
81
|
-
|
|
82
|
-
source = command.encode()
|
|
83
|
-
result = parse(command)
|
|
84
|
-
if result.has_error:
|
|
85
|
-
logger.debug(
|
|
86
|
-
"tree-sitter-bash reported parse errors; returning input as-is\n"
|
|
87
|
-
"[input]: %s",
|
|
88
|
-
command,
|
|
89
|
-
)
|
|
90
|
-
return command
|
|
91
|
-
|
|
92
|
-
preserved: list[tuple[int, int]] = []
|
|
93
|
-
|
|
94
|
-
def collect(node: Node) -> None:
|
|
95
|
-
if node.type in _PRESERVE_TYPES:
|
|
96
|
-
preserved.append((node.start_byte, node.end_byte))
|
|
97
|
-
return
|
|
98
|
-
for child in node.children:
|
|
99
|
-
collect(child)
|
|
100
|
-
|
|
101
|
-
collect(result.tree.root_node)
|
|
102
|
-
|
|
103
|
-
out = bytearray()
|
|
104
|
-
cursor = 0
|
|
105
|
-
for start, end in preserved:
|
|
106
|
-
out.extend(_ESCAPE_PATTERN.sub(rb"\\\\\1", source[cursor:start]))
|
|
107
|
-
out.extend(source[start:end])
|
|
108
|
-
cursor = end
|
|
109
|
-
out.extend(_ESCAPE_PATTERN.sub(rb"\\\\\1", source[cursor:]))
|
|
110
|
-
|
|
111
|
-
return out.decode()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/event_storage.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/logging_fix.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/config.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/constants.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/file_cache.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/history.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/shell.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/list_directory/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/list_directory/impl.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/read_file/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/read_file/definition.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/write_file/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/gemini/write_file/definition.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/planning_file_editor/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/planning_file_editor/definition.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/planning_file_editor/impl.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/bash_runner.md
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/code_explorer.md
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/default.md
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/web_researcher.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/task_tracker/definition.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/factory.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/interface.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/tmux_terminal.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/timeout_policy.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/utils/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands/tools/terminal/utils/escape_filter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.52.0 → openhands_tools-1.54.0}/openhands_tools.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|