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.

Files changed (124) hide show
  1. openhands-1.0.1.dist-info/METADATA +52 -0
  2. openhands-1.0.1.dist-info/RECORD +31 -0
  3. {openhands-0.0.0.dist-info → openhands-1.0.1.dist-info}/WHEEL +1 -2
  4. openhands-1.0.1.dist-info/entry_points.txt +2 -0
  5. openhands_cli/__init__.py +8 -0
  6. openhands_cli/agent_chat.py +186 -0
  7. openhands_cli/argparsers/main_parser.py +56 -0
  8. openhands_cli/argparsers/serve_parser.py +31 -0
  9. openhands_cli/gui_launcher.py +220 -0
  10. openhands_cli/listeners/__init__.py +4 -0
  11. openhands_cli/listeners/loading_listener.py +63 -0
  12. openhands_cli/listeners/pause_listener.py +83 -0
  13. openhands_cli/llm_utils.py +57 -0
  14. openhands_cli/locations.py +13 -0
  15. openhands_cli/pt_style.py +30 -0
  16. openhands_cli/runner.py +178 -0
  17. openhands_cli/setup.py +116 -0
  18. openhands_cli/simple_main.py +59 -0
  19. openhands_cli/tui/__init__.py +5 -0
  20. openhands_cli/tui/settings/mcp_screen.py +217 -0
  21. openhands_cli/tui/settings/settings_screen.py +202 -0
  22. openhands_cli/tui/settings/store.py +93 -0
  23. openhands_cli/tui/status.py +109 -0
  24. openhands_cli/tui/tui.py +100 -0
  25. openhands_cli/tui/utils.py +14 -0
  26. openhands_cli/user_actions/__init__.py +17 -0
  27. openhands_cli/user_actions/agent_action.py +95 -0
  28. openhands_cli/user_actions/exit_session.py +18 -0
  29. openhands_cli/user_actions/settings_action.py +171 -0
  30. openhands_cli/user_actions/types.py +18 -0
  31. openhands_cli/user_actions/utils.py +199 -0
  32. openhands/__init__.py +0 -1
  33. openhands/sdk/__init__.py +0 -45
  34. openhands/sdk/agent/__init__.py +0 -8
  35. openhands/sdk/agent/agent/__init__.py +0 -6
  36. openhands/sdk/agent/agent/agent.py +0 -349
  37. openhands/sdk/agent/base.py +0 -103
  38. openhands/sdk/context/__init__.py +0 -28
  39. openhands/sdk/context/agent_context.py +0 -153
  40. openhands/sdk/context/condenser/__init__.py +0 -5
  41. openhands/sdk/context/condenser/condenser.py +0 -73
  42. openhands/sdk/context/condenser/no_op_condenser.py +0 -13
  43. openhands/sdk/context/manager.py +0 -5
  44. openhands/sdk/context/microagents/__init__.py +0 -26
  45. openhands/sdk/context/microagents/exceptions.py +0 -11
  46. openhands/sdk/context/microagents/microagent.py +0 -345
  47. openhands/sdk/context/microagents/types.py +0 -70
  48. openhands/sdk/context/utils/__init__.py +0 -8
  49. openhands/sdk/context/utils/prompt.py +0 -52
  50. openhands/sdk/context/view.py +0 -116
  51. openhands/sdk/conversation/__init__.py +0 -12
  52. openhands/sdk/conversation/conversation.py +0 -207
  53. openhands/sdk/conversation/state.py +0 -50
  54. openhands/sdk/conversation/types.py +0 -6
  55. openhands/sdk/conversation/visualizer.py +0 -300
  56. openhands/sdk/event/__init__.py +0 -27
  57. openhands/sdk/event/base.py +0 -148
  58. openhands/sdk/event/condenser.py +0 -49
  59. openhands/sdk/event/llm_convertible.py +0 -265
  60. openhands/sdk/event/types.py +0 -5
  61. openhands/sdk/event/user_action.py +0 -12
  62. openhands/sdk/event/utils.py +0 -30
  63. openhands/sdk/llm/__init__.py +0 -19
  64. openhands/sdk/llm/exceptions.py +0 -108
  65. openhands/sdk/llm/llm.py +0 -867
  66. openhands/sdk/llm/llm_registry.py +0 -116
  67. openhands/sdk/llm/message.py +0 -216
  68. openhands/sdk/llm/metadata.py +0 -34
  69. openhands/sdk/llm/utils/fn_call_converter.py +0 -1049
  70. openhands/sdk/llm/utils/metrics.py +0 -311
  71. openhands/sdk/llm/utils/model_features.py +0 -153
  72. openhands/sdk/llm/utils/retry_mixin.py +0 -122
  73. openhands/sdk/llm/utils/telemetry.py +0 -252
  74. openhands/sdk/logger.py +0 -167
  75. openhands/sdk/mcp/__init__.py +0 -20
  76. openhands/sdk/mcp/client.py +0 -113
  77. openhands/sdk/mcp/definition.py +0 -69
  78. openhands/sdk/mcp/tool.py +0 -104
  79. openhands/sdk/mcp/utils.py +0 -59
  80. openhands/sdk/tests/llm/test_llm.py +0 -447
  81. openhands/sdk/tests/llm/test_llm_fncall_converter.py +0 -691
  82. openhands/sdk/tests/llm/test_model_features.py +0 -221
  83. openhands/sdk/tool/__init__.py +0 -30
  84. openhands/sdk/tool/builtins/__init__.py +0 -34
  85. openhands/sdk/tool/builtins/finish.py +0 -57
  86. openhands/sdk/tool/builtins/think.py +0 -60
  87. openhands/sdk/tool/schema.py +0 -236
  88. openhands/sdk/tool/security_prompt.py +0 -5
  89. openhands/sdk/tool/tool.py +0 -142
  90. openhands/sdk/utils/__init__.py +0 -14
  91. openhands/sdk/utils/discriminated_union.py +0 -210
  92. openhands/sdk/utils/json.py +0 -48
  93. openhands/sdk/utils/truncate.py +0 -44
  94. openhands/tools/__init__.py +0 -44
  95. openhands/tools/execute_bash/__init__.py +0 -30
  96. openhands/tools/execute_bash/constants.py +0 -31
  97. openhands/tools/execute_bash/definition.py +0 -166
  98. openhands/tools/execute_bash/impl.py +0 -38
  99. openhands/tools/execute_bash/metadata.py +0 -101
  100. openhands/tools/execute_bash/terminal/__init__.py +0 -22
  101. openhands/tools/execute_bash/terminal/factory.py +0 -113
  102. openhands/tools/execute_bash/terminal/interface.py +0 -189
  103. openhands/tools/execute_bash/terminal/subprocess_terminal.py +0 -412
  104. openhands/tools/execute_bash/terminal/terminal_session.py +0 -492
  105. openhands/tools/execute_bash/terminal/tmux_terminal.py +0 -160
  106. openhands/tools/execute_bash/utils/command.py +0 -150
  107. openhands/tools/str_replace_editor/__init__.py +0 -17
  108. openhands/tools/str_replace_editor/definition.py +0 -158
  109. openhands/tools/str_replace_editor/editor.py +0 -683
  110. openhands/tools/str_replace_editor/exceptions.py +0 -41
  111. openhands/tools/str_replace_editor/impl.py +0 -66
  112. openhands/tools/str_replace_editor/utils/__init__.py +0 -0
  113. openhands/tools/str_replace_editor/utils/config.py +0 -2
  114. openhands/tools/str_replace_editor/utils/constants.py +0 -9
  115. openhands/tools/str_replace_editor/utils/encoding.py +0 -135
  116. openhands/tools/str_replace_editor/utils/file_cache.py +0 -154
  117. openhands/tools/str_replace_editor/utils/history.py +0 -122
  118. openhands/tools/str_replace_editor/utils/shell.py +0 -72
  119. openhands/tools/task_tracker/__init__.py +0 -16
  120. openhands/tools/task_tracker/definition.py +0 -336
  121. openhands/tools/utils/__init__.py +0 -1
  122. openhands-0.0.0.dist-info/METADATA +0 -3
  123. openhands-0.0.0.dist-info/RECORD +0 -94
  124. 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