openhands-tools 1.53.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.
Files changed (107) hide show
  1. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/PKG-INFO +1 -1
  2. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/editor.py +25 -13
  3. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/encoding.py +18 -2
  4. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/terminal_session.py +184 -49
  5. openhands_tools-1.54.0/openhands/tools/terminal/utils/command.py +195 -0
  6. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands_tools.egg-info/PKG-INFO +1 -1
  7. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/pyproject.toml +1 -1
  8. openhands_tools-1.53.0/openhands/tools/terminal/utils/command.py +0 -111
  9. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/__init__.py +0 -0
  10. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/apply_patch/__init__.py +0 -0
  11. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/apply_patch/core.py +0 -0
  12. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/apply_patch/definition.py +0 -0
  13. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/ask_oracle/__init__.py +0 -0
  14. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/ask_oracle/definition.py +0 -0
  15. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/ask_oracle/impl.py +0 -0
  16. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/__init__.py +0 -0
  17. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/definition.py +0 -0
  18. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/event_storage.py +0 -0
  19. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/impl.py +0 -0
  20. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/logging_fix.py +0 -0
  21. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/recording.py +0 -0
  22. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/browser_use/server.py +0 -0
  23. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/delegate/__init__.py +0 -0
  24. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/delegate/definition.py +0 -0
  25. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/delegate/impl.py +0 -0
  26. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/delegate/visualizer.py +0 -0
  27. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/__init__.py +0 -0
  28. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/definition.py +0 -0
  29. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/exceptions.py +0 -0
  30. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/impl.py +0 -0
  31. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/__init__.py +0 -0
  32. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/config.py +0 -0
  33. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/constants.py +0 -0
  34. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/diff.py +0 -0
  35. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/file_cache.py +0 -0
  36. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/history.py +0 -0
  37. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/file_editor/utils/shell.py +0 -0
  38. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/__init__.py +0 -0
  39. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/edit/__init__.py +0 -0
  40. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/edit/definition.py +0 -0
  41. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/edit/impl.py +0 -0
  42. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/file_change.py +0 -0
  43. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/list_directory/__init__.py +0 -0
  44. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/list_directory/definition.py +0 -0
  45. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/list_directory/impl.py +0 -0
  46. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/read_file/__init__.py +0 -0
  47. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/read_file/definition.py +0 -0
  48. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/read_file/impl.py +0 -0
  49. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/write_file/__init__.py +0 -0
  50. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/write_file/definition.py +0 -0
  51. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/gemini/write_file/impl.py +0 -0
  52. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/glob/__init__.py +0 -0
  53. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/glob/definition.py +0 -0
  54. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/glob/impl.py +0 -0
  55. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/grep/__init__.py +0 -0
  56. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/grep/definition.py +0 -0
  57. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/grep/impl.py +0 -0
  58. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/planning_file_editor/__init__.py +0 -0
  59. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/planning_file_editor/definition.py +0 -0
  60. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/planning_file_editor/impl.py +0 -0
  61. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/preset/__init__.py +0 -0
  62. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/preset/default.py +0 -0
  63. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/preset/gemini.py +0 -0
  64. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/preset/gpt5.py +0 -0
  65. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/preset/planning.py +0 -0
  66. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/bash_runner.md +0 -0
  67. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/code_explorer.md +0 -0
  68. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/default.md +0 -0
  69. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/preset/subagents/web_researcher.md +0 -0
  70. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/preset/task_outcome.py +0 -0
  71. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/py.typed +0 -0
  72. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/task/__init__.py +0 -0
  73. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/task/definition.py +0 -0
  74. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/task/impl.py +0 -0
  75. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/task/manager.py +0 -0
  76. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/task_tracker/__init__.py +0 -0
  77. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/task_tracker/definition.py +0 -0
  78. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/__init__.py +0 -0
  79. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/constants.py +0 -0
  80. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/definition.py +0 -0
  81. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/descriptions.py +0 -0
  82. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/env.py +0 -0
  83. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/impl.py +0 -0
  84. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/metadata.py +0 -0
  85. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/__init__.py +0 -0
  86. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/factory.py +0 -0
  87. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/interface.py +0 -0
  88. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/subprocess_terminal.py +0 -0
  89. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/tmux_pane_pool.py +0 -0
  90. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/tmux_terminal.py +0 -0
  91. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/terminal/windows_terminal.py +0 -0
  92. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/timeout_policy.py +0 -0
  93. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/utils/__init__.py +0 -0
  94. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/terminal/utils/escape_filter.py +0 -0
  95. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/tom_consult/__init__.py +0 -0
  96. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/tom_consult/definition.py +0 -0
  97. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/tom_consult/executor.py +0 -0
  98. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/utils/__init__.py +0 -0
  99. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/utils/timeout.py +0 -0
  100. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/workflow/__init__.py +0 -0
  101. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/workflow/definition.py +0 -0
  102. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands/tools/workflow/impl.py +0 -0
  103. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands_tools.egg-info/SOURCES.txt +0 -0
  104. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands_tools.egg-info/dependency_links.txt +0 -0
  105. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands_tools.egg-info/requires.txt +0 -0
  106. {openhands_tools-1.53.0 → openhands_tools-1.54.0}/openhands_tools.egg-info/top_level.txt +0 -0
  107. {openhands_tools-1.53.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.53.0
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
- # Get the detected encoding
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
@@ -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 first when it is one of these.
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 = (self._best_guess_encoding(path), *TEXT_DECODE_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
- return command_output.lstrip().removeprefix(command.lstrip()).lstrip()
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
- command,
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
- metadata = CmdOutputMetadata.from_ps1_match(ps1_matches[-1])
236
-
237
- # Special case where the previous command output is truncated
238
- # due to history limit
239
- get_content_before_last_match = bool(len(ps1_matches) == 1)
240
-
241
- # Update the current working directory if it has changed
242
- if metadata.working_dir != self._cwd and metadata.working_dir:
243
- self._cwd: str = metadata.working_dir
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
- command,
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
- # convert command to raw string (for bash terminals)
550
- if not self.terminal.is_powershell():
551
- # Only escape for bash terminals, not PowerShell
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
- command,
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
- # Condition 1: A new prompt has appeared since the command started.
583
- # Condition 2: The prompt count hasn't increased (potentially because the
584
- # initial one scrolled off), BUT the *current* visible terminal ends with a
585
- # prompt, indicating completion.
586
- if (not sent_command or output_changed_since_command) and (
587
- current_ps1_count > initial_ps1_count
588
- or cur_terminal_output.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip())
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=cur_terminal_output,
593
- ps1_matches=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=cur_terminal_output,
616
- ps1_matches=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=cur_terminal_output,
632
- ps1_matches=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.53.0
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-tools"
3
- version = "1.53.0"
3
+ version = "1.54.0"
4
4
  description = "OpenHands Tools - Runtime tools for AI agents"
5
5
 
6
6
  requires-python = ">=3.12"
@@ -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()