Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
6789d81
Add Windows terminal backend support
SmartManoj Nov 4, 2025
a1f6319
ruff
SmartManoj Nov 5, 2025
4e064b0
Remove platform check and always import fcntl and pty for pyright
SmartManoj Nov 5, 2025
baa383e
pyright
SmartManoj Nov 5, 2025
4efe972
Merge branch 'main' into terminal
SmartManoj Nov 5, 2025
7939e98
ruff
SmartManoj Nov 5, 2025
a7b35dd
Remove demo script
SmartManoj Nov 6, 2025
0eb8eea
Merge branch 'main' into terminal
SmartManoj Nov 6, 2025
4fefd40
Remove stray character from import statement
SmartManoj Nov 6, 2025
d252b38
Update assertions to use obs.text in Windows Terminal tests
SmartManoj Nov 6, 2025
a2123bb
Merge branch 'main' into terminal
SmartManoj Nov 6, 2025
83f31c8
Add platform-specific terminal imports to __init__.py
SmartManoj Nov 6, 2025
ff46c49
Refactor terminal modules and update imports
SmartManoj Nov 6, 2025
d0ca912
Merge branch 'main' into terminal
SmartManoj Nov 15, 2025
78791dd
Add Windows-specific terminal tool description
SmartManoj Nov 18, 2025
bd5e3b9
format
SmartManoj Nov 18, 2025
d0002c4
Merge branch 'main' into terminal
SmartManoj Nov 18, 2025
69990f1
Add Windows tools test job to CI workflow
SmartManoj Nov 19, 2025
b89a96c
Merge branch 'main' into terminal
SmartManoj Nov 19, 2025
486f534
Update tests to use obs.text instead of obs.output
SmartManoj Nov 19, 2025
3b92e5a
Normalize path comparisons in Windows terminal tests
SmartManoj Nov 19, 2025
f24948b
Refactor path normalization in Windows terminal tests
SmartManoj Nov 19, 2025
e33e91c
Use realpath for path normalization in Windows terminal tests
SmartManoj Nov 19, 2025
fddad1c
Remove unnecessary parentheses in path normalization
SmartManoj Nov 19, 2025
cbcf45b
Add coverage configuration to pyproject.toml
SmartManoj Nov 19, 2025
9fb0df0
fix integration tests
ryanhoangt Nov 19, 2025
2101caf
lint
ryanhoangt Nov 19, 2025
54a8749
add a few tests
ryanhoangt Nov 19, 2025
3f2dc44
Remove coverage paths configuration from pyproject.toml
SmartManoj Nov 20, 2025
c28d605
Merge branch 'main' into terminal
SmartManoj Nov 20, 2025
80f1ab5
Expand Windows tools test coverage in CI workflow
SmartManoj Nov 20, 2025
532143d
Remove coverage report config from pyproject.toml
SmartManoj Nov 20, 2025
1774cab
Replace pytest --deselect with --ignore in CI workflow
SmartManoj Nov 20, 2025
5bc7949
Ignore file editor memory usage test in Windows CI
SmartManoj Nov 20, 2025
4a37779
Improve temp_file fixture cleanup on Windows
SmartManoj Nov 20, 2025
2a00f89
Remove unused import in conftest.py
SmartManoj Nov 20, 2025
c4869a6
Remove coverage report config from pyproject.toml
SmartManoj Nov 20, 2025
c40789b
Merge branch 'main' into terminal
SmartManoj Nov 20, 2025
6c4821c
Load terminal tool descriptions from template files
SmartManoj Nov 21, 2025
b2416e0
Merge branch 'main' into terminal
SmartManoj Nov 21, 2025
805630e
Rename terminal description templates to .j2 extension
SmartManoj Nov 21, 2025
2626ec7
Merge branch 'main' into terminal
SmartManoj Dec 1, 2025
ceaae17
Merge branch 'main' into windows-support
SmartManoj Dec 6, 2025
7327088
Enhance working directory persistence test
SmartManoj Dec 6, 2025
7517d95
Add Chromium installation step for browser tests on Windows
SmartManoj Dec 6, 2025
76fce6f
Set PYTHONUTF8 for Windows test workflow
SmartManoj Dec 6, 2025
41a3132
Mock Chromium availability in browser executor tests
SmartManoj Dec 6, 2025
a2f5a4d
Use platform-specific browser executor in tests
SmartManoj Dec 6, 2025
4328c25
Mock _ensure_chromium_available in browser executor fixture
SmartManoj Dec 6, 2025
e3e63f4
Ensure terminal tool executor is closed in tests
SmartManoj Dec 7, 2025
8caf752
Add Windows support to terminal reset test
SmartManoj Dec 7, 2025
d064e88
Update working directory check in terminal tool test
SmartManoj Dec 7, 2025
b75f6b3
Improve working directory checks in terminal reset test
SmartManoj Dec 7, 2025
80435f5
Add platform-specific browser executor selection
SmartManoj Dec 7, 2025
19ebb2d
Refactor directory listing to use Python for cross-platform support
SmartManoj Dec 7, 2025
0311b0f
Fix test paths for platform compatibility in file editor tests
SmartManoj Dec 7, 2025
c8a6044
Improve test cross-platform compatibility
SmartManoj Dec 7, 2025
2bc1cbc
lint
SmartManoj Dec 7, 2025
b185aa8
Merge branch 'main' into windows-support
SmartManoj Dec 7, 2025
e296b0c
Simplify Windows workflow and minor code cleanups
SmartManoj Dec 7, 2025
aa18689
Fix path comparisons in tests for Windows compatibility
SmartManoj Dec 7, 2025
0311cc4
Add executor and working_dir assertions to tests
SmartManoj Dec 7, 2025
37c44bd
Remove duplicate step
SmartManoj Dec 7, 2025
14affd9
Add docstrings to PowerShell command tests
SmartManoj Dec 7, 2025
3adf0b7
Add Windows support to terminal reset test
SmartManoj Dec 7, 2025
82e9113
Merge branch 'main' into terminal
SmartManoj Dec 7, 2025
222e530
Merge branch 'windows-support' into terminal
SmartManoj Dec 7, 2025
509bcbd
fix(ci): increase browser-use timeouts for Windows CI
SmartManoj Dec 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 73 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,75 @@ jobs:
path: coverage-tools.dat
if-no-files-found: warn

tools-tests-windows:
runs-on: windows-latest
env:
PYTHONUTF8: 1
TIMEOUT_BrowserStartEvent: 120
TIMEOUT_BrowserLaunchEvent: 120
TIMEOUT_BrowserConnectedEvent: 120
steps:
- name: Checkout
uses: actions/checkout@v5
with: {fetch-depth: 0}

- name: Detect tools changes
id: changed
uses: tj-actions/changed-files@v47
with:
files: |
openhands-tools/**
tests/tools/**
pyproject.toml
uv.lock
.github/workflows/tests.yml

- name: Install uv
if: steps.changed.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@v7
with:
enable-cache: true

- name: Install deps
if: steps.changed.outputs.any_changed == 'true'
run: uv sync --frozen --group dev

- name: Install Chromium for browser tests
if: steps.changed.outputs.any_changed == 'true'
run: uvx playwright install chromium --with-deps

- name: Run Windows tools tests with coverage
if: steps.changed.outputs.any_changed == 'true'
run: |
# Clean up any existing coverage file
if (Test-Path .coverage) { Remove-Item .coverage }
$env:CI = "true"
uv run python -m pytest -vvs `
--cov=openhands-tools `
--cov-report=term-missing `
--cov-fail-under=0 `
--cov-config=pyproject.toml `
tests/tools `
--ignore=tests/tools/terminal/test_session_factory.py `
--ignore=tests/tools/terminal/test_terminal_session.py `
--ignore=tests/tools/terminal/test_shell_path_configuration.py `
--ignore=tests/tools/terminal/test_shutdown_handling.py `
--ignore=tests/tools/terminal/test_conversation_cleanup.py `
--ignore=tests/tools/terminal/test_terminal_tool_auto_detection.py `
--ignore=tests/tools/file_editor/test_memory_usage.py
if (Test-Path .coverage) {
Move-Item .coverage coverage-tools-windows.dat
Write-Host "Windows tools coverage file prepared for upload"
}

- name: Upload Windows tools coverage
if: steps.changed.outputs.any_changed == 'true' && always()
uses: actions/upload-artifact@v5
with:
name: coverage-tools-windows
path: coverage-tools-windows.dat
if-no-files-found: warn

agent-server-tests:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
Expand Down Expand Up @@ -244,7 +313,7 @@ jobs:

coverage-report:
runs-on: blacksmith-2vcpu-ubuntu-2404
needs: [sdk-tests, tools-tests, agent-server-tests, cross-tests]
needs: [sdk-tests, tools-tests, tools-tests-windows, agent-server-tests, cross-tests]
if: always() && github.event_name == 'pull_request'
steps:
- name: Checkout
Expand Down Expand Up @@ -274,7 +343,9 @@ jobs:
if [[ "$dat_file" == *coverage-sdk.dat ]]; then
cp "$dat_file" .coverage.sdk
elif [[ "$dat_file" == *coverage-tools.dat ]]; then
cp "$dat_file" .coverage.tools
cp "$dat_file" .coverage.tools
elif [[ "$dat_file" == *coverage-tools-windows.dat ]]; then
cp "$dat_file" .coverage.tools-windows
elif [[ "$dat_file" == *coverage-cross.dat ]]; then
cp "$dat_file" .coverage.cross
fi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ a = Analysis(
*collect_data_files("openhands.sdk.context.condenser", includes=["prompts/*.j2"]),
*collect_data_files("openhands.sdk.context.prompts", includes=["templates/*.j2"]),

# OpenHands Tools terminal templates
*collect_data_files("openhands.tools.terminal", includes=["templates/*.j2"]),

# Package metadata for importlib.metadata
*copy_metadata("fastmcp"),
*copy_metadata("litellm"),
Expand Down
10 changes: 10 additions & 0 deletions openhands-tools/openhands/tools/browser_use/impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any

Expand All @@ -20,6 +21,15 @@
from openhands.tools.utils.timeout import TimeoutError, run_with_timeout


def get_browser_executor_class() -> type["BrowserToolExecutor"]:
"""Get the platform-appropriate browser executor class."""
if sys.platform == "win32":
from openhands.tools.browser_use.impl_windows import WindowsBrowserToolExecutor

Comment on lines +24 to +28
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function attempts to import WindowsBrowserToolExecutor from impl_windows module, but this module is not included in the PR. This will cause an ImportError when the function is called on Windows systems.

Since impl_windows.py exists in the repository (as seen in the directory listing), but is not part of this PR's changes, this should either:

  1. Be included in the PR if it's a new file
  2. Be removed if Windows browser support isn't part of this PR's scope

Copilot uses AI. Check for mistakes.
return WindowsBrowserToolExecutor
return BrowserToolExecutor


# Suppress browser-use logging for cleaner integration
if DEBUG:
logging.getLogger("browser_use").setLevel(logging.DEBUG)
Expand Down
102 changes: 75 additions & 27 deletions openhands-tools/openhands/tools/file_editor/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
with_encoding,
)
from openhands.tools.file_editor.utils.history import FileHistoryManager
from openhands.tools.file_editor.utils.shell import run_shell_cmd


logger = get_logger(__name__)
Expand All @@ -43,6 +42,65 @@
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}


def _is_hidden(path: Path) -> bool:
"""Check if a path is hidden (starts with '.')."""
return path.name.startswith(".")


def _list_directory_contents(
base_path: Path, max_depth: int = 2, exclude_hidden: bool = True
) -> tuple[list[str], int]:
"""List directory contents up to max_depth levels deep.

Args:
base_path: The directory to list
max_depth: Maximum depth to traverse (default 2)
exclude_hidden: Whether to exclude hidden files/dirs (default True)

Returns:
Tuple of (list of paths with forward slashes, count of hidden items at depth 1)
"""
result_paths: list[str] = []
hidden_count = 0

def _normalize_path(p: Path) -> str:
"""Normalize path to use forward slashes for consistent output."""
return str(p).replace("\\", "/")

def _walk(current_path: Path, depth: int) -> None:
nonlocal hidden_count
if depth > max_depth:
return

try:
entries = sorted(current_path.iterdir(), key=lambda p: str(p).lower())
except PermissionError:
return

for entry in entries:
is_hidden = _is_hidden(entry)

# Count hidden items at depth 1 only
if depth == 1 and is_hidden:
hidden_count += 1

# Skip hidden entries if excluding
if exclude_hidden and is_hidden:
continue

result_paths.append(_normalize_path(entry))

# Recurse into directories
if entry.is_dir() and depth < max_depth:
_walk(entry, depth + 1)

# Add the base path itself
result_paths.append(_normalize_path(base_path))
_walk(base_path, 1)

return result_paths, hidden_count


class FileEditor:
"""
An filesystem editor tool that allows the agent to
Expand Down Expand Up @@ -283,47 +341,37 @@ def view(
"a directory.",
)

# First count hidden files/dirs in current directory only
# -mindepth 1 excludes . and .. automatically
_, hidden_stdout, _ = run_shell_cmd(
rf"find -L {path} -mindepth 1 -maxdepth 1 -name '.*'"
)
hidden_count = (
len(hidden_stdout.strip().split("\n")) if hidden_stdout.strip() else 0
# Use Python-based directory listing (works on all platforms)
paths, hidden_count = _list_directory_contents(
path, max_depth=2, exclude_hidden=True
)

# Then get files/dirs up to 2 levels deep, excluding hidden entries at
# both depth 1 and 2
_, stdout, stderr = run_shell_cmd(
rf"find -L {path} -maxdepth 2 -not \( -path '{path}/\.*' -o "
rf"-path '{path}/*/\.*' \) | sort",
truncate_notice=DIRECTORY_CONTENT_TRUNCATED_NOTICE,
)
if stderr:
return FileEditorObservation.from_text(
text=stderr,
command="view",
is_error=True,
path=str(path),
prev_exist=True,
)
# Add trailing slashes to directories
paths = stdout.strip().split("\n") if stdout.strip() else []
formatted_paths = []
for p in paths:
if Path(p).is_dir():
formatted_paths.append(f"{p}/")
else:
formatted_paths.append(p)

# Truncate if needed
output = "\n".join(formatted_paths)
output = maybe_truncate(
output,
truncate_after=MAX_RESPONSE_LEN_CHAR,
truncate_notice=DIRECTORY_CONTENT_TRUNCATED_NOTICE,
)

# Normalize header path for consistent output
normalized_path = str(path).replace("\\", "/")
msg = [
f"Here's the files and directories up to 2 levels deep in {path}, "
"excluding hidden items:\n" + "\n".join(formatted_paths)
f"Here's the files and directories up to 2 levels deep in "
f"{normalized_path}, excluding hidden items:\n{output}"
]
if hidden_count > 0:
msg.append(
f"\n{hidden_count} hidden files/directories in this directory "
f"are excluded. You can use 'ls -la {path}' to see them."
f"are excluded. You can use 'ls -la {normalized_path}' to see them."
)
stdout = "\n".join(msg)
return FileEditorObservation.from_text(
Expand Down
43 changes: 15 additions & 28 deletions openhands-tools/openhands/tools/terminal/definition.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Execute bash tool implementation."""

import os
import platform
from collections.abc import Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Literal

from pydantic import Field
Expand Down Expand Up @@ -200,35 +202,15 @@ def visualize(self) -> Text:
return text


TOOL_DESCRIPTION = """Execute a bash command in the terminal within a persistent shell session.
def _load_template(template_name: str) -> str:
"""Load a template file from the templates directory."""
template_dir = Path(__file__).parent / "templates"
template_path = template_dir / template_name
return template_path.read_text(encoding="utf-8")


### Command Execution
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, use `&&` or `;` to chain them together.
* Persistent session: Commands execute in a persistent shell session where environment variables, virtual environments, and working directory persist between commands.
* Soft timeout: Commands have a soft timeout of 10 seconds, once that's reached, you have the option to continue or interrupt the command (see section below for details)
* Shell options: Do NOT use `set -e`, `set -eu`, or `set -euo pipefail` in shell scripts or commands in this environment. The runtime may not support them and can cause unusable shell sessions. If you want to run multi-line bash commands, write the commands to a file and then run it, instead.

### Long-running Commands
* For commands that may run indefinitely, run them in the background and redirect output to a file, e.g. `python3 app.py > server.log 2>&1 &`.
* For commands that may run for a long time (e.g. installation or testing commands), or commands that run for a fixed amount of time (e.g. sleep), you should set the "timeout" parameter of your function call to an appropriate value.
* If a bash command returns exit code `-1`, this means the process hit the soft timeout and is not yet finished. By setting `is_input` to `true`, you can:
- Send empty `command` to retrieve additional logs
- Send text (set `command` to the text) to STDIN of the running process
- Send control commands like `C-c` (Ctrl+C), `C-d` (Ctrl+D), or `C-z` (Ctrl+Z) to interrupt the process
- If you do C-c, you can re-start the process with a longer "timeout" parameter to let it run to completion

### Best Practices
* Directory verification: Before creating new directories or files, first verify the parent directory exists and is the correct location.
* Directory management: Try to maintain working directory by using absolute paths and avoiding excessive use of `cd`.

### Output Handling
* Output truncation: If the output exceeds a maximum length, it will be truncated before being returned.

### Terminal Reset
* Terminal reset: If the terminal becomes unresponsive, you can set the "reset" parameter to `true` to create a new terminal session. This will terminate the current session and start fresh.
* Warning: Resetting the terminal will lose all previously set environment variables, working directory changes, and any running processes. Use this only when the terminal stops responding to commands.
""" # noqa
TOOL_DESCRIPTION_FOR_UNIX = _load_template("unix_description.j2")
TOOL_DESCRIPTION_FOR_WINDOWS = _load_template("windows_description.j2")


class TerminalTool(ToolDefinition[TerminalAction, TerminalObservation]):
Expand Down Expand Up @@ -278,12 +260,17 @@ def create(
full_output_save_dir=conv_state.env_observation_persistence_dir,
)

if platform.system() == "Windows":
tool_description = TOOL_DESCRIPTION_FOR_WINDOWS
else:
tool_description = TOOL_DESCRIPTION_FOR_UNIX

# Initialize the parent ToolDefinition with the executor
return [
cls(
action_type=TerminalAction,
observation_type=TerminalObservation,
description=TOOL_DESCRIPTION,
description=tool_description,
annotations=ToolAnnotations(
title="terminal",
readOnlyHint=False,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Execute a bash command in the terminal within a persistent shell session.


### Command Execution
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, use `&&` or `;` to chain them together.
* Persistent session: Commands execute in a persistent shell session where environment variables, virtual environments, and working directory persist between commands.
* Soft timeout: Commands have a soft timeout of 10 seconds, once that's reached, you have the option to continue or interrupt the command (see section below for details)
* Shell options: Do NOT use `set -e`, `set -eu`, or `set -euo pipefail` in shell scripts or commands in this environment. The runtime may not support them and can cause unusable shell sessions. If you want to run multi-line bash commands, write the commands to a file and then run it, instead.

### Long-running Commands
* For commands that may run indefinitely, run them in the background and redirect output to a file, e.g. `python3 app.py > server.log 2>&1 &`.
* For commands that may run for a long time (e.g. installation or testing commands), or commands that run for a fixed amount of time (e.g. sleep), you should set the "timeout" parameter of your function call to an appropriate value.
* If a bash command returns exit code `-1`, this means the process hit the soft timeout and is not yet finished. By setting `is_input` to `true`, you can:
- Send empty `command` to retrieve additional logs
- Send text (set `command` to the text) to STDIN of the running process
- Send control commands like `C-c` (Ctrl+C), `C-d` (Ctrl+D), or `C-z` (Ctrl+Z) to interrupt the process
- If you do C-c, you can re-start the process with a longer "timeout" parameter to let it run to completion

### Best Practices
* Directory verification: Before creating new directories or files, first verify the parent directory exists and is the correct location.
* Directory management: Try to maintain working directory by using absolute paths and avoiding excessive use of `cd`.

### Output Handling
* Output truncation: If the output exceeds a maximum length, it will be truncated before being returned.

### Terminal Reset
* Terminal reset: If the terminal becomes unresponsive, you can set the "reset" parameter to `true` to create a new terminal session. This will terminate the current session and start fresh.
* Warning: Resetting the terminal will lose all previously set environment variables, working directory changes, and any running processes. Use this only when the terminal stops responding to commands.

Loading
Loading