openhands 0.0.0__py3-none-any.whl → 1.0.1__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.
Potentially problematic release.
This version of openhands might be problematic. Click here for more details.
- openhands-1.0.1.dist-info/METADATA +52 -0
- openhands-1.0.1.dist-info/RECORD +31 -0
- {openhands-0.0.0.dist-info → openhands-1.0.1.dist-info}/WHEEL +1 -2
- openhands-1.0.1.dist-info/entry_points.txt +2 -0
- openhands_cli/__init__.py +8 -0
- openhands_cli/agent_chat.py +186 -0
- openhands_cli/argparsers/main_parser.py +56 -0
- openhands_cli/argparsers/serve_parser.py +31 -0
- openhands_cli/gui_launcher.py +220 -0
- openhands_cli/listeners/__init__.py +4 -0
- openhands_cli/listeners/loading_listener.py +63 -0
- openhands_cli/listeners/pause_listener.py +83 -0
- openhands_cli/llm_utils.py +57 -0
- openhands_cli/locations.py +13 -0
- openhands_cli/pt_style.py +30 -0
- openhands_cli/runner.py +178 -0
- openhands_cli/setup.py +116 -0
- openhands_cli/simple_main.py +59 -0
- openhands_cli/tui/__init__.py +5 -0
- openhands_cli/tui/settings/mcp_screen.py +217 -0
- openhands_cli/tui/settings/settings_screen.py +202 -0
- openhands_cli/tui/settings/store.py +93 -0
- openhands_cli/tui/status.py +109 -0
- openhands_cli/tui/tui.py +100 -0
- openhands_cli/tui/utils.py +14 -0
- openhands_cli/user_actions/__init__.py +17 -0
- openhands_cli/user_actions/agent_action.py +95 -0
- openhands_cli/user_actions/exit_session.py +18 -0
- openhands_cli/user_actions/settings_action.py +171 -0
- openhands_cli/user_actions/types.py +18 -0
- openhands_cli/user_actions/utils.py +199 -0
- openhands/__init__.py +0 -1
- openhands/sdk/__init__.py +0 -45
- openhands/sdk/agent/__init__.py +0 -8
- openhands/sdk/agent/agent/__init__.py +0 -6
- openhands/sdk/agent/agent/agent.py +0 -349
- openhands/sdk/agent/base.py +0 -103
- openhands/sdk/context/__init__.py +0 -28
- openhands/sdk/context/agent_context.py +0 -153
- openhands/sdk/context/condenser/__init__.py +0 -5
- openhands/sdk/context/condenser/condenser.py +0 -73
- openhands/sdk/context/condenser/no_op_condenser.py +0 -13
- openhands/sdk/context/manager.py +0 -5
- openhands/sdk/context/microagents/__init__.py +0 -26
- openhands/sdk/context/microagents/exceptions.py +0 -11
- openhands/sdk/context/microagents/microagent.py +0 -345
- openhands/sdk/context/microagents/types.py +0 -70
- openhands/sdk/context/utils/__init__.py +0 -8
- openhands/sdk/context/utils/prompt.py +0 -52
- openhands/sdk/context/view.py +0 -116
- openhands/sdk/conversation/__init__.py +0 -12
- openhands/sdk/conversation/conversation.py +0 -207
- openhands/sdk/conversation/state.py +0 -50
- openhands/sdk/conversation/types.py +0 -6
- openhands/sdk/conversation/visualizer.py +0 -300
- openhands/sdk/event/__init__.py +0 -27
- openhands/sdk/event/base.py +0 -148
- openhands/sdk/event/condenser.py +0 -49
- openhands/sdk/event/llm_convertible.py +0 -265
- openhands/sdk/event/types.py +0 -5
- openhands/sdk/event/user_action.py +0 -12
- openhands/sdk/event/utils.py +0 -30
- openhands/sdk/llm/__init__.py +0 -19
- openhands/sdk/llm/exceptions.py +0 -108
- openhands/sdk/llm/llm.py +0 -867
- openhands/sdk/llm/llm_registry.py +0 -116
- openhands/sdk/llm/message.py +0 -216
- openhands/sdk/llm/metadata.py +0 -34
- openhands/sdk/llm/utils/fn_call_converter.py +0 -1049
- openhands/sdk/llm/utils/metrics.py +0 -311
- openhands/sdk/llm/utils/model_features.py +0 -153
- openhands/sdk/llm/utils/retry_mixin.py +0 -122
- openhands/sdk/llm/utils/telemetry.py +0 -252
- openhands/sdk/logger.py +0 -167
- openhands/sdk/mcp/__init__.py +0 -20
- openhands/sdk/mcp/client.py +0 -113
- openhands/sdk/mcp/definition.py +0 -69
- openhands/sdk/mcp/tool.py +0 -104
- openhands/sdk/mcp/utils.py +0 -59
- openhands/sdk/tests/llm/test_llm.py +0 -447
- openhands/sdk/tests/llm/test_llm_fncall_converter.py +0 -691
- openhands/sdk/tests/llm/test_model_features.py +0 -221
- openhands/sdk/tool/__init__.py +0 -30
- openhands/sdk/tool/builtins/__init__.py +0 -34
- openhands/sdk/tool/builtins/finish.py +0 -57
- openhands/sdk/tool/builtins/think.py +0 -60
- openhands/sdk/tool/schema.py +0 -236
- openhands/sdk/tool/security_prompt.py +0 -5
- openhands/sdk/tool/tool.py +0 -142
- openhands/sdk/utils/__init__.py +0 -14
- openhands/sdk/utils/discriminated_union.py +0 -210
- openhands/sdk/utils/json.py +0 -48
- openhands/sdk/utils/truncate.py +0 -44
- openhands/tools/__init__.py +0 -44
- openhands/tools/execute_bash/__init__.py +0 -30
- openhands/tools/execute_bash/constants.py +0 -31
- openhands/tools/execute_bash/definition.py +0 -166
- openhands/tools/execute_bash/impl.py +0 -38
- openhands/tools/execute_bash/metadata.py +0 -101
- openhands/tools/execute_bash/terminal/__init__.py +0 -22
- openhands/tools/execute_bash/terminal/factory.py +0 -113
- openhands/tools/execute_bash/terminal/interface.py +0 -189
- openhands/tools/execute_bash/terminal/subprocess_terminal.py +0 -412
- openhands/tools/execute_bash/terminal/terminal_session.py +0 -492
- openhands/tools/execute_bash/terminal/tmux_terminal.py +0 -160
- openhands/tools/execute_bash/utils/command.py +0 -150
- openhands/tools/str_replace_editor/__init__.py +0 -17
- openhands/tools/str_replace_editor/definition.py +0 -158
- openhands/tools/str_replace_editor/editor.py +0 -683
- openhands/tools/str_replace_editor/exceptions.py +0 -41
- openhands/tools/str_replace_editor/impl.py +0 -66
- openhands/tools/str_replace_editor/utils/__init__.py +0 -0
- openhands/tools/str_replace_editor/utils/config.py +0 -2
- openhands/tools/str_replace_editor/utils/constants.py +0 -9
- openhands/tools/str_replace_editor/utils/encoding.py +0 -135
- openhands/tools/str_replace_editor/utils/file_cache.py +0 -154
- openhands/tools/str_replace_editor/utils/history.py +0 -122
- openhands/tools/str_replace_editor/utils/shell.py +0 -72
- openhands/tools/task_tracker/__init__.py +0 -16
- openhands/tools/task_tracker/definition.py +0 -336
- openhands/tools/utils/__init__.py +0 -1
- openhands-0.0.0.dist-info/METADATA +0 -3
- openhands-0.0.0.dist-info/RECORD +0 -94
- openhands-0.0.0.dist-info/top_level.txt +0 -1
|
@@ -1,492 +0,0 @@
|
|
|
1
|
-
"""Unified terminal session using TerminalInterface backends."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
import time
|
|
5
|
-
from enum import Enum
|
|
6
|
-
|
|
7
|
-
from openhands.sdk.logger import get_logger
|
|
8
|
-
from openhands.tools.execute_bash.constants import (
|
|
9
|
-
CMD_OUTPUT_PS1_END,
|
|
10
|
-
NO_CHANGE_TIMEOUT_SECONDS,
|
|
11
|
-
POLL_INTERVAL,
|
|
12
|
-
TIMEOUT_MESSAGE_TEMPLATE,
|
|
13
|
-
)
|
|
14
|
-
from openhands.tools.execute_bash.definition import (
|
|
15
|
-
ExecuteBashAction,
|
|
16
|
-
ExecuteBashObservation,
|
|
17
|
-
)
|
|
18
|
-
from openhands.tools.execute_bash.metadata import CmdOutputMetadata
|
|
19
|
-
from openhands.tools.execute_bash.terminal.interface import (
|
|
20
|
-
TerminalInterface,
|
|
21
|
-
TerminalSessionBase,
|
|
22
|
-
)
|
|
23
|
-
from openhands.tools.execute_bash.utils.command import (
|
|
24
|
-
escape_bash_special_chars,
|
|
25
|
-
split_bash_commands,
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
logger = get_logger(__name__)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class TerminalCommandStatus(Enum):
|
|
33
|
-
"""Status of a terminal command execution."""
|
|
34
|
-
|
|
35
|
-
CONTINUE = "continue"
|
|
36
|
-
COMPLETED = "completed"
|
|
37
|
-
INTERRUPTED = "interrupted"
|
|
38
|
-
NO_CHANGE_TIMEOUT = "no_change_timeout"
|
|
39
|
-
HARD_TIMEOUT = "hard_timeout"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _remove_command_prefix(command_output: str, command: str) -> str:
|
|
43
|
-
return command_output.lstrip().removeprefix(command.lstrip()).lstrip()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class TerminalSession(TerminalSessionBase):
|
|
47
|
-
"""Unified bash session that works with any TerminalInterface backend.
|
|
48
|
-
|
|
49
|
-
This class contains all the session controller logic (timeouts, command parsing,
|
|
50
|
-
output processing) while delegating terminal operations to the TerminalInterface.
|
|
51
|
-
"""
|
|
52
|
-
|
|
53
|
-
def __init__(
|
|
54
|
-
self,
|
|
55
|
-
terminal: TerminalInterface,
|
|
56
|
-
no_change_timeout_seconds: int | None = None,
|
|
57
|
-
):
|
|
58
|
-
"""Initialize the unified session with a terminal backend.
|
|
59
|
-
|
|
60
|
-
Args:
|
|
61
|
-
terminal: The terminal backend to use
|
|
62
|
-
no_change_timeout_seconds: Timeout for no output change
|
|
63
|
-
"""
|
|
64
|
-
super().__init__(
|
|
65
|
-
terminal.work_dir,
|
|
66
|
-
terminal.username,
|
|
67
|
-
no_change_timeout_seconds,
|
|
68
|
-
)
|
|
69
|
-
self.terminal = terminal
|
|
70
|
-
self.no_change_timeout_seconds = (
|
|
71
|
-
no_change_timeout_seconds or NO_CHANGE_TIMEOUT_SECONDS
|
|
72
|
-
)
|
|
73
|
-
# Store the last command for interactive input handling
|
|
74
|
-
self.prev_status: TerminalCommandStatus | None = None
|
|
75
|
-
self.prev_output: str = ""
|
|
76
|
-
|
|
77
|
-
def initialize(self) -> None:
|
|
78
|
-
"""Initialize the terminal backend."""
|
|
79
|
-
self.terminal.initialize()
|
|
80
|
-
self._initialized = True
|
|
81
|
-
logger.debug(f"Unified session initialized with {type(self.terminal).__name__}")
|
|
82
|
-
|
|
83
|
-
def close(self) -> None:
|
|
84
|
-
"""Clean up the terminal backend."""
|
|
85
|
-
if self._closed:
|
|
86
|
-
return
|
|
87
|
-
self.terminal.close()
|
|
88
|
-
self._closed = True
|
|
89
|
-
|
|
90
|
-
def interrupt(self) -> bool:
|
|
91
|
-
"""Interrupt the currently running command (equivalent to Ctrl+C)."""
|
|
92
|
-
return self.terminal.interrupt()
|
|
93
|
-
|
|
94
|
-
def is_running(self) -> bool:
|
|
95
|
-
"""Check if a command is currently running."""
|
|
96
|
-
if not self._initialized:
|
|
97
|
-
return False
|
|
98
|
-
return self.prev_status in {
|
|
99
|
-
TerminalCommandStatus.CONTINUE,
|
|
100
|
-
TerminalCommandStatus.NO_CHANGE_TIMEOUT,
|
|
101
|
-
TerminalCommandStatus.HARD_TIMEOUT,
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
def _is_special_key(self, command: str) -> bool:
|
|
105
|
-
"""Check if the command is a special key."""
|
|
106
|
-
# Special keys are of the form C-<key>
|
|
107
|
-
_command = command.strip()
|
|
108
|
-
return _command.startswith("C-") and len(_command) == 3
|
|
109
|
-
|
|
110
|
-
def _get_command_output(
|
|
111
|
-
self,
|
|
112
|
-
command: str,
|
|
113
|
-
raw_command_output: str,
|
|
114
|
-
metadata: CmdOutputMetadata,
|
|
115
|
-
continue_prefix: str = "",
|
|
116
|
-
) -> str:
|
|
117
|
-
"""Get the command output with the previous command output removed."""
|
|
118
|
-
# remove the previous command output from the new output if any
|
|
119
|
-
if self.prev_output:
|
|
120
|
-
command_output = raw_command_output.removeprefix(self.prev_output)
|
|
121
|
-
metadata.prefix = continue_prefix
|
|
122
|
-
else:
|
|
123
|
-
command_output = raw_command_output
|
|
124
|
-
self.prev_output = raw_command_output # update current command output anyway
|
|
125
|
-
command_output = _remove_command_prefix(command_output, command)
|
|
126
|
-
return command_output.rstrip()
|
|
127
|
-
|
|
128
|
-
def _handle_completed_command(
|
|
129
|
-
self,
|
|
130
|
-
command: str,
|
|
131
|
-
terminal_content: str,
|
|
132
|
-
ps1_matches: list[re.Match],
|
|
133
|
-
) -> ExecuteBashObservation:
|
|
134
|
-
"""Handle a completed command."""
|
|
135
|
-
is_special_key = self._is_special_key(command)
|
|
136
|
-
assert len(ps1_matches) >= 1, (
|
|
137
|
-
f"Expected at least one PS1 metadata block, but got {len(ps1_matches)}.\n"
|
|
138
|
-
f"---FULL OUTPUT---\n{terminal_content!r}\n---END OF OUTPUT---"
|
|
139
|
-
)
|
|
140
|
-
metadata = CmdOutputMetadata.from_ps1_match(ps1_matches[-1])
|
|
141
|
-
|
|
142
|
-
# Special case where the previous command output is truncated
|
|
143
|
-
# due to history limit
|
|
144
|
-
get_content_before_last_match = bool(len(ps1_matches) == 1)
|
|
145
|
-
|
|
146
|
-
# Update the current working directory if it has changed
|
|
147
|
-
if metadata.working_dir != self._cwd and metadata.working_dir:
|
|
148
|
-
self._cwd = metadata.working_dir
|
|
149
|
-
|
|
150
|
-
logger.debug(
|
|
151
|
-
f"[Prev PS1 not matched: {get_content_before_last_match}] "
|
|
152
|
-
f"COMMAND OUTPUT: {terminal_content}"
|
|
153
|
-
)
|
|
154
|
-
# Extract the command output between the two PS1 prompts
|
|
155
|
-
raw_command_output = self._combine_outputs_between_matches(
|
|
156
|
-
terminal_content,
|
|
157
|
-
ps1_matches,
|
|
158
|
-
get_content_before_last_match=get_content_before_last_match,
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
if get_content_before_last_match:
|
|
162
|
-
# Count the number of lines in the truncated output
|
|
163
|
-
num_lines = len(raw_command_output.splitlines())
|
|
164
|
-
metadata.prefix = (
|
|
165
|
-
f"[Previous command outputs are truncated. "
|
|
166
|
-
f"Showing the last {num_lines} lines of the output below.]\n"
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
metadata.suffix = (
|
|
170
|
-
f"\n[The command completed with exit code {metadata.exit_code}.]"
|
|
171
|
-
if not is_special_key
|
|
172
|
-
else (
|
|
173
|
-
f"\n[The command completed with exit code {metadata.exit_code}. "
|
|
174
|
-
f"CTRL+{command[-1].upper()} was sent.]"
|
|
175
|
-
)
|
|
176
|
-
)
|
|
177
|
-
command_output = self._get_command_output(
|
|
178
|
-
command,
|
|
179
|
-
raw_command_output,
|
|
180
|
-
metadata,
|
|
181
|
-
)
|
|
182
|
-
self.prev_status = TerminalCommandStatus.COMPLETED
|
|
183
|
-
self.prev_output = "" # Reset previous command output
|
|
184
|
-
self._ready_for_next_command()
|
|
185
|
-
return ExecuteBashObservation(
|
|
186
|
-
output=command_output,
|
|
187
|
-
command=command,
|
|
188
|
-
metadata=metadata,
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
def _handle_nochange_timeout_command(
|
|
192
|
-
self,
|
|
193
|
-
command: str,
|
|
194
|
-
terminal_content: str,
|
|
195
|
-
ps1_matches: list[re.Match],
|
|
196
|
-
) -> ExecuteBashObservation:
|
|
197
|
-
"""Handle a command that timed out due to no output change."""
|
|
198
|
-
self.prev_status = TerminalCommandStatus.NO_CHANGE_TIMEOUT
|
|
199
|
-
if len(ps1_matches) != 1:
|
|
200
|
-
logger.warning(
|
|
201
|
-
f"Expected exactly one PS1 metadata block BEFORE the execution of a "
|
|
202
|
-
f"command, but got {len(ps1_matches)} PS1 metadata blocks:\n"
|
|
203
|
-
f"---\n{terminal_content!r}\n---"
|
|
204
|
-
)
|
|
205
|
-
raw_command_output = self._combine_outputs_between_matches(
|
|
206
|
-
terminal_content, ps1_matches
|
|
207
|
-
)
|
|
208
|
-
metadata = CmdOutputMetadata() # No metadata available
|
|
209
|
-
metadata.suffix = (
|
|
210
|
-
f"\n[The command has no new output after "
|
|
211
|
-
f"{self.no_change_timeout_seconds} seconds. {TIMEOUT_MESSAGE_TEMPLATE}]"
|
|
212
|
-
)
|
|
213
|
-
command_output = self._get_command_output(
|
|
214
|
-
command,
|
|
215
|
-
raw_command_output,
|
|
216
|
-
metadata,
|
|
217
|
-
continue_prefix="[Below is the output of the previous command.]\n",
|
|
218
|
-
)
|
|
219
|
-
return ExecuteBashObservation(
|
|
220
|
-
output=command_output,
|
|
221
|
-
command=command,
|
|
222
|
-
metadata=metadata,
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
def _handle_hard_timeout_command(
|
|
226
|
-
self,
|
|
227
|
-
command: str,
|
|
228
|
-
terminal_content: str,
|
|
229
|
-
ps1_matches: list[re.Match],
|
|
230
|
-
timeout: float,
|
|
231
|
-
) -> ExecuteBashObservation:
|
|
232
|
-
"""Handle a command that timed out due to hard timeout."""
|
|
233
|
-
self.prev_status = TerminalCommandStatus.HARD_TIMEOUT
|
|
234
|
-
if len(ps1_matches) != 1:
|
|
235
|
-
logger.warning(
|
|
236
|
-
f"Expected exactly one PS1 metadata block BEFORE the execution of a "
|
|
237
|
-
f"command, but got {len(ps1_matches)} PS1 metadata blocks:\n"
|
|
238
|
-
f"---\n{terminal_content!r}\n---"
|
|
239
|
-
)
|
|
240
|
-
raw_command_output = self._combine_outputs_between_matches(
|
|
241
|
-
terminal_content, ps1_matches
|
|
242
|
-
)
|
|
243
|
-
metadata = CmdOutputMetadata() # No metadata available
|
|
244
|
-
metadata.suffix = (
|
|
245
|
-
f"\n[The command timed out after {timeout} seconds. "
|
|
246
|
-
f"{TIMEOUT_MESSAGE_TEMPLATE}]"
|
|
247
|
-
)
|
|
248
|
-
command_output = self._get_command_output(
|
|
249
|
-
command,
|
|
250
|
-
raw_command_output,
|
|
251
|
-
metadata,
|
|
252
|
-
continue_prefix="[Below is the output of the previous command.]\n",
|
|
253
|
-
)
|
|
254
|
-
|
|
255
|
-
return ExecuteBashObservation(
|
|
256
|
-
output=command_output,
|
|
257
|
-
command=command,
|
|
258
|
-
metadata=metadata,
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
def _ready_for_next_command(self) -> None:
|
|
262
|
-
"""Reset the content buffer for a new command."""
|
|
263
|
-
# Clear the current content
|
|
264
|
-
self.terminal.clear_screen()
|
|
265
|
-
|
|
266
|
-
def _combine_outputs_between_matches(
|
|
267
|
-
self,
|
|
268
|
-
terminal_content: str,
|
|
269
|
-
ps1_matches: list[re.Match],
|
|
270
|
-
get_content_before_last_match: bool = False,
|
|
271
|
-
) -> str:
|
|
272
|
-
"""Combine all outputs between PS1 matches."""
|
|
273
|
-
if len(ps1_matches) == 1:
|
|
274
|
-
if get_content_before_last_match:
|
|
275
|
-
# The command output is the content before the last PS1 prompt
|
|
276
|
-
return terminal_content[: ps1_matches[0].start()]
|
|
277
|
-
else:
|
|
278
|
-
# The command output is the content after the last PS1 prompt
|
|
279
|
-
return terminal_content[ps1_matches[0].end() + 1 :]
|
|
280
|
-
elif len(ps1_matches) == 0:
|
|
281
|
-
return terminal_content
|
|
282
|
-
combined_output = ""
|
|
283
|
-
for i in range(len(ps1_matches) - 1):
|
|
284
|
-
# Extract content between current and next PS1 prompt
|
|
285
|
-
output_segment = terminal_content[
|
|
286
|
-
ps1_matches[i].end() + 1 : ps1_matches[i + 1].start()
|
|
287
|
-
]
|
|
288
|
-
combined_output += output_segment + "\n"
|
|
289
|
-
# Add the content after the last PS1 prompt
|
|
290
|
-
combined_output += terminal_content[ps1_matches[-1].end() + 1 :]
|
|
291
|
-
logger.debug(f"COMBINED OUTPUT: {combined_output}")
|
|
292
|
-
return combined_output
|
|
293
|
-
|
|
294
|
-
def execute(self, action: ExecuteBashAction) -> ExecuteBashObservation:
|
|
295
|
-
"""Execute a command using the terminal backend."""
|
|
296
|
-
if not self._initialized:
|
|
297
|
-
raise RuntimeError("Unified session is not initialized")
|
|
298
|
-
|
|
299
|
-
# Strip the command of any leading/trailing whitespace
|
|
300
|
-
logger.debug(f"RECEIVED ACTION: {action}")
|
|
301
|
-
command = action.command.strip()
|
|
302
|
-
is_input: bool = action.is_input
|
|
303
|
-
|
|
304
|
-
# If the previous command is not completed,
|
|
305
|
-
# we need to check if the command is empty
|
|
306
|
-
if self.prev_status not in {
|
|
307
|
-
TerminalCommandStatus.CONTINUE,
|
|
308
|
-
TerminalCommandStatus.NO_CHANGE_TIMEOUT,
|
|
309
|
-
TerminalCommandStatus.HARD_TIMEOUT,
|
|
310
|
-
}:
|
|
311
|
-
if command == "":
|
|
312
|
-
return ExecuteBashObservation(
|
|
313
|
-
output="ERROR: No previous running command to retrieve logs from.",
|
|
314
|
-
error=True,
|
|
315
|
-
)
|
|
316
|
-
if is_input:
|
|
317
|
-
return ExecuteBashObservation(
|
|
318
|
-
output="ERROR: No previous running command to interact with.",
|
|
319
|
-
error=True,
|
|
320
|
-
)
|
|
321
|
-
|
|
322
|
-
# Check if the command is a single command or multiple commands
|
|
323
|
-
splited_commands = split_bash_commands(command)
|
|
324
|
-
if len(splited_commands) > 1:
|
|
325
|
-
return ExecuteBashObservation(
|
|
326
|
-
output=(
|
|
327
|
-
f"ERROR: Cannot execute multiple commands at once.\n"
|
|
328
|
-
f"Please run each command separately OR chain them into a single "
|
|
329
|
-
f"command via && or ;\nProvided commands:\n"
|
|
330
|
-
f"{'\n'.join(f'({i + 1}) {cmd}' for i, cmd in enumerate(splited_commands))}" # noqa: E501
|
|
331
|
-
),
|
|
332
|
-
error=True,
|
|
333
|
-
)
|
|
334
|
-
|
|
335
|
-
# Get initial state before sending command
|
|
336
|
-
initial_terminal_output = self.terminal.read_screen()
|
|
337
|
-
initial_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(
|
|
338
|
-
initial_terminal_output
|
|
339
|
-
)
|
|
340
|
-
initial_ps1_count = len(initial_ps1_matches)
|
|
341
|
-
logger.debug(f"Initial PS1 count: {initial_ps1_count}")
|
|
342
|
-
logger.debug(f"INITIAL TERMINAL OUTPUT: {initial_terminal_output!r}")
|
|
343
|
-
|
|
344
|
-
start_time = time.time()
|
|
345
|
-
last_change_time = start_time
|
|
346
|
-
last_terminal_output = initial_terminal_output
|
|
347
|
-
|
|
348
|
-
# When prev command is still running, and we are trying to send a new command
|
|
349
|
-
if (
|
|
350
|
-
self.prev_status
|
|
351
|
-
in {
|
|
352
|
-
TerminalCommandStatus.HARD_TIMEOUT,
|
|
353
|
-
TerminalCommandStatus.NO_CHANGE_TIMEOUT,
|
|
354
|
-
}
|
|
355
|
-
and not last_terminal_output.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip())
|
|
356
|
-
and not is_input
|
|
357
|
-
and command != ""
|
|
358
|
-
):
|
|
359
|
-
_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_terminal_output)
|
|
360
|
-
# Use initial_ps1_matches if _ps1_matches is empty,
|
|
361
|
-
# otherwise use _ps1_matches. This handles the case where
|
|
362
|
-
# the prompt might be scrolled off screen but existed before
|
|
363
|
-
current_matches_for_output = (
|
|
364
|
-
_ps1_matches if _ps1_matches else initial_ps1_matches
|
|
365
|
-
)
|
|
366
|
-
raw_command_output = self._combine_outputs_between_matches(
|
|
367
|
-
last_terminal_output, current_matches_for_output
|
|
368
|
-
)
|
|
369
|
-
metadata = CmdOutputMetadata() # No metadata available
|
|
370
|
-
metadata.suffix = (
|
|
371
|
-
f'\n[Your command "{command}" is NOT executed. The previous command '
|
|
372
|
-
f"is still running - You CANNOT send new commands until the previous "
|
|
373
|
-
f"command is completed. By setting `is_input` to `true`, you can "
|
|
374
|
-
f"interact with the current process: {TIMEOUT_MESSAGE_TEMPLATE}]"
|
|
375
|
-
)
|
|
376
|
-
logger.debug(f"PREVIOUS COMMAND OUTPUT: {raw_command_output}")
|
|
377
|
-
command_output = self._get_command_output(
|
|
378
|
-
command,
|
|
379
|
-
raw_command_output,
|
|
380
|
-
metadata,
|
|
381
|
-
continue_prefix="[Below is the output of the previous command.]\n",
|
|
382
|
-
)
|
|
383
|
-
obs = ExecuteBashObservation(
|
|
384
|
-
output=command_output,
|
|
385
|
-
command=command,
|
|
386
|
-
metadata=metadata,
|
|
387
|
-
)
|
|
388
|
-
logger.debug(f"RETURNING OBSERVATION (previous-command): {obs}")
|
|
389
|
-
return obs
|
|
390
|
-
|
|
391
|
-
# Send actual command/inputs to the terminal
|
|
392
|
-
if command != "":
|
|
393
|
-
is_special_key = self._is_special_key(command)
|
|
394
|
-
if is_input:
|
|
395
|
-
logger.debug(f"SENDING INPUT TO RUNNING PROCESS: {command!r}")
|
|
396
|
-
self.terminal.send_keys(
|
|
397
|
-
command,
|
|
398
|
-
enter=not is_special_key,
|
|
399
|
-
)
|
|
400
|
-
else:
|
|
401
|
-
# convert command to raw string (for bash terminals)
|
|
402
|
-
if not self.terminal.is_powershell():
|
|
403
|
-
# Only escape for bash terminals, not PowerShell
|
|
404
|
-
command = escape_bash_special_chars(command)
|
|
405
|
-
logger.debug(f"SENDING COMMAND: {command!r}")
|
|
406
|
-
self.terminal.send_keys(
|
|
407
|
-
command,
|
|
408
|
-
enter=not is_special_key,
|
|
409
|
-
)
|
|
410
|
-
|
|
411
|
-
# Loop until the command completes or times out
|
|
412
|
-
while True:
|
|
413
|
-
_start_time = time.time()
|
|
414
|
-
logger.debug(f"GETTING TERMINAL CONTENT at {_start_time}")
|
|
415
|
-
cur_terminal_output = self.terminal.read_screen()
|
|
416
|
-
logger.debug(
|
|
417
|
-
f"TERMINAL CONTENT GOT after {time.time() - _start_time:.2f} seconds"
|
|
418
|
-
)
|
|
419
|
-
logger.debug(
|
|
420
|
-
f"BEGIN OF TERMINAL CONTENT: {cur_terminal_output.split('\n')[:10]}"
|
|
421
|
-
)
|
|
422
|
-
logger.debug(
|
|
423
|
-
f"END OF TERMINAL CONTENT: {cur_terminal_output.split('\n')[-10:]}"
|
|
424
|
-
)
|
|
425
|
-
ps1_matches = CmdOutputMetadata.matches_ps1_metadata(cur_terminal_output)
|
|
426
|
-
current_ps1_count = len(ps1_matches)
|
|
427
|
-
|
|
428
|
-
if cur_terminal_output != last_terminal_output:
|
|
429
|
-
last_terminal_output = cur_terminal_output
|
|
430
|
-
last_change_time = time.time()
|
|
431
|
-
logger.debug(f"CONTENT UPDATED DETECTED at {last_change_time}")
|
|
432
|
-
|
|
433
|
-
# 1) Execution completed:
|
|
434
|
-
# Condition 1: A new prompt has appeared since the command started.
|
|
435
|
-
# Condition 2: The prompt count hasn't increased (potentially because the
|
|
436
|
-
# initial one scrolled off), BUT the *current* visible terminal ends with a
|
|
437
|
-
# prompt, indicating completion.
|
|
438
|
-
if (
|
|
439
|
-
current_ps1_count > initial_ps1_count
|
|
440
|
-
or cur_terminal_output.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip())
|
|
441
|
-
):
|
|
442
|
-
obs = self._handle_completed_command(
|
|
443
|
-
command,
|
|
444
|
-
terminal_content=cur_terminal_output,
|
|
445
|
-
ps1_matches=ps1_matches,
|
|
446
|
-
)
|
|
447
|
-
logger.debug(f"RETURNING OBSERVATION (completed): {obs}")
|
|
448
|
-
return obs
|
|
449
|
-
|
|
450
|
-
# Timeout checks should only trigger if a new prompt hasn't appeared yet.
|
|
451
|
-
|
|
452
|
-
# 2) Execution timed out since there's no change in output
|
|
453
|
-
# for a while (NO_CHANGE_TIMEOUT_SECONDS)
|
|
454
|
-
# We ignore this if the command is *blocking*
|
|
455
|
-
time_since_last_change = time.time() - last_change_time
|
|
456
|
-
is_blocking = action.timeout is not None
|
|
457
|
-
logger.debug(
|
|
458
|
-
f"CHECKING NO CHANGE TIMEOUT ({self.no_change_timeout_seconds}s): "
|
|
459
|
-
f"elapsed {time_since_last_change}. Action blocking: {is_blocking}"
|
|
460
|
-
)
|
|
461
|
-
if (
|
|
462
|
-
not is_blocking
|
|
463
|
-
and time_since_last_change >= self.no_change_timeout_seconds
|
|
464
|
-
):
|
|
465
|
-
obs = self._handle_nochange_timeout_command(
|
|
466
|
-
command,
|
|
467
|
-
terminal_content=cur_terminal_output,
|
|
468
|
-
ps1_matches=ps1_matches,
|
|
469
|
-
)
|
|
470
|
-
logger.debug(f"RETURNING OBSERVATION (nochange-timeout): {obs}")
|
|
471
|
-
return obs
|
|
472
|
-
|
|
473
|
-
# 3) Execution timed out since the command has been running for too long
|
|
474
|
-
# (hard timeout)
|
|
475
|
-
elapsed_time = time.time() - start_time
|
|
476
|
-
logger.debug(
|
|
477
|
-
f"CHECKING HARD TIMEOUT ({action.timeout}s): elapsed {elapsed_time:.2f}"
|
|
478
|
-
)
|
|
479
|
-
if action.timeout is not None:
|
|
480
|
-
time_since_start = time.time() - start_time
|
|
481
|
-
if time_since_start >= action.timeout:
|
|
482
|
-
obs = self._handle_hard_timeout_command(
|
|
483
|
-
command,
|
|
484
|
-
terminal_content=cur_terminal_output,
|
|
485
|
-
ps1_matches=ps1_matches,
|
|
486
|
-
timeout=action.timeout,
|
|
487
|
-
)
|
|
488
|
-
logger.debug(f"RETURNING OBSERVATION (hard-timeout): {obs}")
|
|
489
|
-
return obs
|
|
490
|
-
|
|
491
|
-
# Sleep before next check
|
|
492
|
-
time.sleep(POLL_INTERVAL)
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
"""Tmux-based terminal backend implementation."""
|
|
2
|
-
|
|
3
|
-
import time
|
|
4
|
-
import uuid
|
|
5
|
-
|
|
6
|
-
import libtmux
|
|
7
|
-
|
|
8
|
-
from openhands.sdk.logger import get_logger
|
|
9
|
-
from openhands.tools.execute_bash.constants import HISTORY_LIMIT
|
|
10
|
-
from openhands.tools.execute_bash.metadata import CmdOutputMetadata
|
|
11
|
-
from openhands.tools.execute_bash.terminal import TerminalInterface
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
logger = get_logger(__name__)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class TmuxTerminal(TerminalInterface):
|
|
18
|
-
"""Tmux-based terminal backend.
|
|
19
|
-
|
|
20
|
-
This backend uses tmux to provide a persistent terminal session
|
|
21
|
-
with full screen capture and history management capabilities.
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
def __init__(
|
|
25
|
-
self,
|
|
26
|
-
work_dir: str,
|
|
27
|
-
username: str | None = None,
|
|
28
|
-
):
|
|
29
|
-
super().__init__(work_dir, username)
|
|
30
|
-
self.PS1 = CmdOutputMetadata.to_ps1_prompt()
|
|
31
|
-
|
|
32
|
-
def initialize(self) -> None:
|
|
33
|
-
"""Initialize the tmux terminal session."""
|
|
34
|
-
if self._initialized:
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
self.server = libtmux.Server()
|
|
38
|
-
_shell_command = "/bin/bash"
|
|
39
|
-
if self.username in ["root", "openhands"]:
|
|
40
|
-
# This starts a non-login (new) shell for the given user
|
|
41
|
-
_shell_command = f"su {self.username} -"
|
|
42
|
-
|
|
43
|
-
window_command = _shell_command
|
|
44
|
-
|
|
45
|
-
logger.debug(f"Initializing tmux terminal with command: {window_command}")
|
|
46
|
-
session_name = f"openhands-{self.username}-{uuid.uuid4()}"
|
|
47
|
-
self.session = self.server.new_session(
|
|
48
|
-
session_name=session_name,
|
|
49
|
-
start_directory=self.work_dir,
|
|
50
|
-
kill_session=True,
|
|
51
|
-
x=1000,
|
|
52
|
-
y=1000,
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
# Set history limit to a large number to avoid losing history
|
|
56
|
-
# https://unix.stackexchange.com/questions/43414/unlimited-history-in-tmux
|
|
57
|
-
self.session.set_option("history-limit", str(HISTORY_LIMIT))
|
|
58
|
-
self.session.history_limit = str(HISTORY_LIMIT)
|
|
59
|
-
|
|
60
|
-
# Create a new pane because the initial pane's history limit is (default) 2000
|
|
61
|
-
_initial_window = self.session.active_window
|
|
62
|
-
self.window = self.session.new_window(
|
|
63
|
-
window_name="bash",
|
|
64
|
-
window_shell=window_command,
|
|
65
|
-
start_directory=self.work_dir,
|
|
66
|
-
)
|
|
67
|
-
self.pane = self.window.active_pane
|
|
68
|
-
assert isinstance(self.pane, libtmux.Pane)
|
|
69
|
-
logger.debug(f"pane: {self.pane}; history_limit: {self.session.history_limit}")
|
|
70
|
-
_initial_window.kill()
|
|
71
|
-
|
|
72
|
-
# Configure bash to use simple PS1 and disable PS2
|
|
73
|
-
self.pane.send_keys(
|
|
74
|
-
f'export PROMPT_COMMAND=\'export PS1="{self.PS1}"\'; export PS2=""'
|
|
75
|
-
)
|
|
76
|
-
time.sleep(0.1) # Wait for command to take effect
|
|
77
|
-
|
|
78
|
-
logger.debug(f"Tmux terminal initialized with work dir: {self.work_dir}")
|
|
79
|
-
self._initialized = True
|
|
80
|
-
self.clear_screen()
|
|
81
|
-
|
|
82
|
-
def close(self) -> None:
|
|
83
|
-
"""Clean up the tmux session."""
|
|
84
|
-
if self._closed:
|
|
85
|
-
return
|
|
86
|
-
if hasattr(self, "session"):
|
|
87
|
-
self.session.kill()
|
|
88
|
-
self._closed = True
|
|
89
|
-
|
|
90
|
-
def send_keys(self, text: str, enter: bool = True) -> None:
|
|
91
|
-
"""Send text/keys to the tmux pane.
|
|
92
|
-
|
|
93
|
-
Args:
|
|
94
|
-
text: Text or key sequence to send
|
|
95
|
-
enter: Whether to send Enter key after the text
|
|
96
|
-
"""
|
|
97
|
-
if not self._initialized or not isinstance(self.pane, libtmux.Pane):
|
|
98
|
-
raise RuntimeError("Tmux terminal is not initialized")
|
|
99
|
-
|
|
100
|
-
self.pane.send_keys(text, enter=enter)
|
|
101
|
-
|
|
102
|
-
def read_screen(self) -> str:
|
|
103
|
-
"""Read the current tmux pane content.
|
|
104
|
-
|
|
105
|
-
Returns:
|
|
106
|
-
Current visible content of the tmux pane
|
|
107
|
-
"""
|
|
108
|
-
if not self._initialized or not isinstance(self.pane, libtmux.Pane):
|
|
109
|
-
raise RuntimeError("Tmux terminal is not initialized")
|
|
110
|
-
|
|
111
|
-
content = "\n".join(
|
|
112
|
-
map(
|
|
113
|
-
# avoid double newlines
|
|
114
|
-
lambda line: line.rstrip(),
|
|
115
|
-
self.pane.cmd("capture-pane", "-J", "-pS", "-").stdout,
|
|
116
|
-
)
|
|
117
|
-
)
|
|
118
|
-
return content
|
|
119
|
-
|
|
120
|
-
def clear_screen(self) -> None:
|
|
121
|
-
"""Clear the tmux pane screen and history."""
|
|
122
|
-
if not self._initialized or not isinstance(self.pane, libtmux.Pane):
|
|
123
|
-
raise RuntimeError("Tmux terminal is not initialized")
|
|
124
|
-
|
|
125
|
-
self.pane.send_keys("C-l", enter=False)
|
|
126
|
-
time.sleep(0.1)
|
|
127
|
-
self.pane.cmd("clear-history")
|
|
128
|
-
|
|
129
|
-
def interrupt(self) -> bool:
|
|
130
|
-
"""Send interrupt signal (Ctrl+C) to the tmux pane.
|
|
131
|
-
|
|
132
|
-
Returns:
|
|
133
|
-
True if interrupt was sent successfully, False otherwise
|
|
134
|
-
"""
|
|
135
|
-
if not self._initialized or not isinstance(self.pane, libtmux.Pane):
|
|
136
|
-
return False
|
|
137
|
-
try:
|
|
138
|
-
self.pane.send_keys("C-c", enter=False)
|
|
139
|
-
return True
|
|
140
|
-
except Exception as e:
|
|
141
|
-
logger.error(f"Failed to interrupt command: {e}", exc_info=True)
|
|
142
|
-
return False
|
|
143
|
-
|
|
144
|
-
def is_running(self) -> bool:
|
|
145
|
-
"""Check if a command is currently running.
|
|
146
|
-
|
|
147
|
-
For tmux, we determine this by checking if the terminal
|
|
148
|
-
is ready for new commands (ends with prompt).
|
|
149
|
-
"""
|
|
150
|
-
if not self._initialized:
|
|
151
|
-
return False
|
|
152
|
-
|
|
153
|
-
try:
|
|
154
|
-
content = self.read_screen()
|
|
155
|
-
# If the screen ends with our PS1 prompt, no command is running
|
|
156
|
-
from openhands.tools.execute_bash.constants import CMD_OUTPUT_PS1_END
|
|
157
|
-
|
|
158
|
-
return not content.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip())
|
|
159
|
-
except Exception:
|
|
160
|
-
return False
|