Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
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
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
60 changes: 58 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,60 @@ jobs:
path: coverage-tools.dat
if-no-files-found: warn

tools-tests-windows:
runs-on: windows-latest
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: Run Windows terminal 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/terminal/test_windows_terminal.py
# Rename coverage file for upload
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 +298,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 +328,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
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.

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Execute a PowerShell command in the terminal within a persistent shell session.


### Command Execution
* One command at a time: You can only execute one PowerShell command at a time. If you need to run multiple commands sequentially, use `;` to chain them together.
* Bash compatibility: Basic bash commands like `&&` and `||` are automatically converted to PowerShell `;` separator, though conditional execution semantics are not preserved.
* Persistent session: Commands execute in a persistent PowerShell session where environment variables, modules, 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)
* PowerShell syntax: Use native PowerShell cmdlets (e.g., `Get-ChildItem`, `Set-Location`) or common aliases (e.g., `ls`, `cd`, `dir`) for best results.

### Long-running Commands
* For commands that may run indefinitely, run them in the background using PowerShell jobs, e.g. `Start-Job -ScriptBlock { python app.py } | Out-File server.log`.
* 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. Start-Sleep), you should set the "timeout" parameter of your function call to an appropriate value.
* If a 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) 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 (use `Test-Path`).
* Directory management: Try to maintain working directory by using absolute paths and avoiding excessive use of `cd` or `Set-Location`.
* Path separators: PowerShell handles both forward slashes `/` and backslashes `\\` in paths, but backslashes are native to Windows.

### 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 PowerShell session. This will terminate the current session and start fresh.
* Warning: Resetting the terminal will lose all previously loaded modules, environment variables, working directory changes, and any running processes. Use this only when the terminal stops responding to commands.

42 changes: 29 additions & 13 deletions openhands-tools/openhands/tools/terminal/terminal/__init__.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,40 @@
import platform

from openhands.tools.terminal.terminal.factory import create_terminal_session
from openhands.tools.terminal.terminal.interface import (
TerminalInterface,
TerminalSessionBase,
)
from openhands.tools.terminal.terminal.subprocess_terminal import (
SubprocessTerminal,
)
from openhands.tools.terminal.terminal.terminal_session import (
TerminalCommandStatus,
TerminalSession,
)
from openhands.tools.terminal.terminal.tmux_terminal import TmuxTerminal


__all__ = [
"TerminalInterface",
"TerminalSessionBase",
"TmuxTerminal",
"SubprocessTerminal",
"TerminalSession",
"TerminalCommandStatus",
"create_terminal_session",
]
# Conditionally import platform-specific terminals
if platform.system() == "Windows":
from openhands.tools.terminal.terminal.windows_terminal import WindowsTerminal
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is there any reason why we export things conditionally like this? I think it might break several test files like tests/tools/terminal/test_session_factory.py.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

else, it will raise ModuleNotFoundError: No module named 'fcntl'
https://github.com/OpenHands/OpenHands/issues/11629


__all__ = [
"TerminalInterface",
"TerminalSessionBase",
"WindowsTerminal",
"TerminalSession",
"TerminalCommandStatus",
"create_terminal_session",
]
else:
from openhands.tools.terminal.terminal.subprocess_terminal import (
SubprocessTerminal,
)
from openhands.tools.terminal.terminal.tmux_terminal import TmuxTerminal

__all__ = [
"TerminalInterface",
"TerminalSessionBase",
"TmuxTerminal",
"SubprocessTerminal",
"TerminalSession",
"TerminalCommandStatus",
"create_terminal_session",
]
8 changes: 7 additions & 1 deletion openhands-tools/openhands/tools/terminal/terminal/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,13 @@ def create_terminal_session(
system = platform.system()

if system == "Windows":
raise NotImplementedError("Windows is not supported yet for OpenHands V1.")
from openhands.tools.terminal.terminal.windows_terminal import (
WindowsTerminal,
)

logger.info("Auto-detected: Using WindowsTerminal (Windows system)")
terminal = WindowsTerminal(work_dir, username)
return TerminalSession(terminal, no_change_timeout_seconds)
else:
# On Unix-like systems, prefer tmux if available, otherwise use subprocess
if _is_tmux_available():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ def _handle_completed_command(
self._ready_for_next_command()
return TerminalObservation.from_text(
command=command,
exit_code=metadata.exit_code if metadata.exit_code != -1 else None,
text=command_output,
metadata=metadata,
)
Expand Down
Loading
Loading