From 189a514a29fc75162b26504f280c0e750fb7e4e9 Mon Sep 17 00:00:00 2001 From: enyst Date: Sat, 20 Dec 2025 01:19:32 +0000 Subject: [PATCH 1/9] feat(preset): add GPT-5 preset using ApplyPatchTool (opt-in, not default) - Introduce preset.gpt5 with register/get tools & get_gpt5_agent - Mirrors Gemini preset pattern; does not change global defaults Co-authored-by: openhands --- .../openhands/tools/preset/__init__.py | 3 +- .../openhands/tools/preset/gpt5.py | 78 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 openhands-tools/openhands/tools/preset/gpt5.py diff --git a/openhands-tools/openhands/tools/preset/__init__.py b/openhands-tools/openhands/tools/preset/__init__.py index d66c9f8b0e..dbbaf3d42f 100644 --- a/openhands-tools/openhands/tools/preset/__init__.py +++ b/openhands-tools/openhands/tools/preset/__init__.py @@ -19,7 +19,8 @@ """ from .default import get_default_agent +from .gpt5 import get_gpt5_agent from .planning import get_planning_agent -__all__ = ["get_default_agent", "get_planning_agent"] +__all__ = ["get_default_agent", "get_planning_agent", "get_gpt5_agent"] diff --git a/openhands-tools/openhands/tools/preset/gpt5.py b/openhands-tools/openhands/tools/preset/gpt5.py new file mode 100644 index 0000000000..05ad2f0583 --- /dev/null +++ b/openhands-tools/openhands/tools/preset/gpt5.py @@ -0,0 +1,78 @@ +"""GPT-5 preset configuration for OpenHands agents. + +This preset uses ApplyPatchTool for file edits instead of the default +claude-style FileEditorTool. It mirrors the Gemini preset pattern by +providing optional helpers without changing global defaults. +""" + +from openhands.sdk import Agent +from openhands.sdk.context.condenser import LLMSummarizingCondenser +from openhands.sdk.context.condenser.base import CondenserBase +from openhands.sdk.llm.llm import LLM +from openhands.sdk.logger import get_logger +from openhands.sdk.tool import Tool + + +logger = get_logger(__name__) + + +def register_gpt5_tools(enable_browser: bool = True) -> None: + """Register the GPT-5 tool set (terminal, apply_patch, task_tracker, browser).""" + from openhands.tools.apply_patch import ApplyPatchTool + from openhands.tools.task_tracker import TaskTrackerTool + from openhands.tools.terminal import TerminalTool + + logger.debug(f"Tool: {TerminalTool.name} registered.") + logger.debug(f"Tool: {ApplyPatchTool.name} registered.") + logger.debug(f"Tool: {TaskTrackerTool.name} registered.") + + if enable_browser: + from openhands.tools.browser_use import BrowserToolSet + + logger.debug(f"Tool: {BrowserToolSet.name} registered.") + + +def get_gpt5_tools(enable_browser: bool = True) -> list[Tool]: + """Get the GPT-5 tool specifications using ApplyPatchTool for edits. + + Args: + enable_browser: Whether to include browser tools. + """ + register_gpt5_tools(enable_browser=enable_browser) + + from openhands.tools.apply_patch import ApplyPatchTool + from openhands.tools.task_tracker import TaskTrackerTool + from openhands.tools.terminal import TerminalTool + + tools: list[Tool] = [ + Tool(name=TerminalTool.name), + Tool(name=ApplyPatchTool.name), + Tool(name=TaskTrackerTool.name), + ] + if enable_browser: + from openhands.tools.browser_use import BrowserToolSet + + tools.append(Tool(name=BrowserToolSet.name)) + return tools + + +def get_gpt5_condenser(llm: LLM) -> CondenserBase: + """Get the default condenser for the GPT-5 preset.""" + return LLMSummarizingCondenser(llm=llm, max_size=80, keep_first=4) + + +def get_gpt5_agent(llm: LLM, cli_mode: bool = False) -> Agent: + """Get an Agent configured with ApplyPatchTool-based file editing. + + This does not change defaults globally; users can opt into it explicitly. + """ + tools = get_gpt5_tools(enable_browser=not cli_mode) + agent = Agent( + llm=llm, + tools=tools, + system_prompt_kwargs={"cli_mode": cli_mode}, + condenser=get_gpt5_condenser( + llm=llm.model_copy(update={"usage_id": "condenser"}) + ), + ) + return agent From 1deff92c15837ab751c374201fb2aad81f57f79d Mon Sep 17 00:00:00 2001 From: enyst Date: Sat, 20 Dec 2025 03:14:12 +0000 Subject: [PATCH 2/9] docs(examples): add GPT-5 preset example using ApplyPatchTool - Demonstrates opt-in preset via get_gpt5_agent - Mirrors Gemini example style Co-authored-by: openhands --- .../35_gpt5_apply_patch_preset.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py diff --git a/examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py b/examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py new file mode 100644 index 0000000000..752f9f8fca --- /dev/null +++ b/examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py @@ -0,0 +1,45 @@ +"""Example: Using GPT-5 preset with ApplyPatchTool for file editing. + +This example demonstrates how to enable the GPT-5 preset, which swaps the +standard claude-style FileEditorTool for ApplyPatchTool. This mirrors the +Gemini example, but targets GPT-5 models. + +Usage: + export OPENAI_API_KEY=... # or set LLM_API_KEY + # Optionally set a model (we recommend a mini variant if available): + # export LLM_MODEL=( + # "openai/gpt-5.2-mini" # or fallback: "openai/gpt-5.1-mini" or "openai/gpt-5.1" + # ) + + uv run python examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py +""" + +import os + +from openhands.sdk import LLM, Agent, Conversation +from openhands.tools.preset.gpt5 import get_gpt5_agent + + +# Resolve API key from env +api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY") +if not api_key: + raise SystemExit("Please set OPENAI_API_KEY or LLM_API_KEY to run this example.") + +model = os.getenv("LLM_MODEL", "openai/gpt-5.1") +base_url = os.getenv("LLM_BASE_URL", None) + +llm = LLM(model=model, api_key=api_key, base_url=base_url) + +# Build an agent with the GPT-5 preset (ApplyPatchTool-based editing) +agent: Agent = get_gpt5_agent(llm) + +# Run in the current working directory +cwd = os.getcwd() +conversation = Conversation(agent=agent, workspace=cwd) + +conversation.send_message( + "Create (or update) a file named GPT5_DEMO.txt at the repo root with " + "two short lines describing this repository." +) +conversation.run() +print("All done!") From 115e768cd70a5d9940e9f1090e8a8ff4ea28b21d Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Sat, 20 Dec 2025 05:38:46 +0100 Subject: [PATCH 3/9] Update examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py --- examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py b/examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py index 752f9f8fca..e94a625950 100644 --- a/examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py +++ b/examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py @@ -1,8 +1,7 @@ """Example: Using GPT-5 preset with ApplyPatchTool for file editing. This example demonstrates how to enable the GPT-5 preset, which swaps the -standard claude-style FileEditorTool for ApplyPatchTool. This mirrors the -Gemini example, but targets GPT-5 models. +standard claude-style FileEditorTool for ApplyPatchTool. Usage: export OPENAI_API_KEY=... # or set LLM_API_KEY From ff7c65731e9810c9a47a2e63a32562afbbc9b7da Mon Sep 17 00:00:00 2001 From: enyst Date: Tue, 23 Dec 2025 11:57:24 +0000 Subject: [PATCH 4/9] Fix examples: add EXAMPLE_COST marker and correct file numbering - Add gemini tools and preset from main branch - Add 33_gemini_file_tools.py example with EXAMPLE_COST marker - Rename 35_gpt5_apply_patch_preset.py to 34_gpt5_apply_patch_preset.py - Add EXAMPLE_COST marker to GPT5 example - Update preset __init__.py to export gemini preset functions Co-authored-by: openhands --- .../01_standalone_sdk/33_gemini_file_tools.py | 55 ++++++ ...reset.py => 34_gpt5_apply_patch_preset.py} | 7 +- .../openhands/tools/gemini/__init__.py | 92 +++++++++ .../openhands/tools/gemini/edit/__init__.py | 15 ++ .../openhands/tools/gemini/edit/definition.py | 173 ++++++++++++++++ .../openhands/tools/gemini/edit/impl.py | 187 ++++++++++++++++++ .../tools/gemini/list_directory/__init__.py | 17 ++ .../tools/gemini/list_directory/definition.py | 183 +++++++++++++++++ .../tools/gemini/list_directory/impl.py | 165 ++++++++++++++++ .../tools/gemini/read_file/__init__.py | 15 ++ .../tools/gemini/read_file/definition.py | 148 ++++++++++++++ .../openhands/tools/gemini/read_file/impl.py | 153 ++++++++++++++ .../tools/gemini/write_file/__init__.py | 15 ++ .../tools/gemini/write_file/definition.py | 140 +++++++++++++ .../openhands/tools/gemini/write_file/impl.py | 96 +++++++++ .../openhands/tools/preset/__init__.py | 9 +- .../openhands/tools/preset/gemini.py | 103 ++++++++++ 17 files changed, 1570 insertions(+), 3 deletions(-) create mode 100644 examples/01_standalone_sdk/33_gemini_file_tools.py rename examples/01_standalone_sdk/{35_gpt5_apply_patch_preset.py => 34_gpt5_apply_patch_preset.py} (90%) create mode 100644 openhands-tools/openhands/tools/gemini/__init__.py create mode 100644 openhands-tools/openhands/tools/gemini/edit/__init__.py create mode 100644 openhands-tools/openhands/tools/gemini/edit/definition.py create mode 100644 openhands-tools/openhands/tools/gemini/edit/impl.py create mode 100644 openhands-tools/openhands/tools/gemini/list_directory/__init__.py create mode 100644 openhands-tools/openhands/tools/gemini/list_directory/definition.py create mode 100644 openhands-tools/openhands/tools/gemini/list_directory/impl.py create mode 100644 openhands-tools/openhands/tools/gemini/read_file/__init__.py create mode 100644 openhands-tools/openhands/tools/gemini/read_file/definition.py create mode 100644 openhands-tools/openhands/tools/gemini/read_file/impl.py create mode 100644 openhands-tools/openhands/tools/gemini/write_file/__init__.py create mode 100644 openhands-tools/openhands/tools/gemini/write_file/definition.py create mode 100644 openhands-tools/openhands/tools/gemini/write_file/impl.py create mode 100644 openhands-tools/openhands/tools/preset/gemini.py diff --git a/examples/01_standalone_sdk/33_gemini_file_tools.py b/examples/01_standalone_sdk/33_gemini_file_tools.py new file mode 100644 index 0000000000..d7d2a4534e --- /dev/null +++ b/examples/01_standalone_sdk/33_gemini_file_tools.py @@ -0,0 +1,55 @@ +"""Example: Using Gemini-style file editing tools. + +This example demonstrates how to use gemini-style file editing tools +(read_file, write_file, edit, list_directory) instead of the standard +claude-style file_editor tool. + +The only difference from the standard setup is replacing: + Tool(name=FileEditorTool.name) +with: + *GEMINI_FILE_TOOLS + +This is a one-line change that swaps the claude-style file_editor for +gemini-style tools (read_file, write_file, edit, list_directory). +""" + +import os + +from openhands.sdk import LLM, Agent, Conversation, Tool +from openhands.tools.gemini import GEMINI_FILE_TOOLS +from openhands.tools.terminal import TerminalTool + + +# Route logs based on whether we're using a proxy (Vertex route) or direct Gemini +_log_dir = "logs/vertex" if os.getenv("LLM_BASE_URL") else "logs/gemini" +os.makedirs(_log_dir, exist_ok=True) + +llm = LLM( + model=os.getenv("LLM_MODEL", "gemini/gemini-3-pro-preview"), + api_key=os.getenv("LLM_API_KEY"), + base_url=os.getenv("LLM_BASE_URL", None), + log_completions=True, + log_completions_folder=_log_dir, +) + +agent = Agent( + llm=llm, + tools=[ + Tool(name=TerminalTool.name), + *GEMINI_FILE_TOOLS, # Instead of Tool(name=FileEditorTool.name) + ], +) + +cwd = os.getcwd() +conversation = Conversation(agent=agent, workspace=cwd) + +# Ask the agent to create a file, then delete it afterwards +conversation.send_message("Write 3 facts about the current project into FACTS.txt.") +conversation.run() + +conversation.send_message("Now delete the FACTS.txt file you just created.") +conversation.run() + +# Report cost +cost = llm.metrics.accumulated_cost +print(f"EXAMPLE_COST: {cost}") diff --git a/examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py b/examples/01_standalone_sdk/34_gpt5_apply_patch_preset.py similarity index 90% rename from examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py rename to examples/01_standalone_sdk/34_gpt5_apply_patch_preset.py index e94a625950..7bd273a531 100644 --- a/examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py +++ b/examples/01_standalone_sdk/34_gpt5_apply_patch_preset.py @@ -10,7 +10,7 @@ # "openai/gpt-5.2-mini" # or fallback: "openai/gpt-5.1-mini" or "openai/gpt-5.1" # ) - uv run python examples/01_standalone_sdk/35_gpt5_apply_patch_preset.py + uv run python examples/01_standalone_sdk/34_gpt5_apply_patch_preset.py """ import os @@ -41,4 +41,7 @@ "two short lines describing this repository." ) conversation.run() -print("All done!") + +# Report cost +cost = llm.metrics.accumulated_cost +print(f"EXAMPLE_COST: {cost}") diff --git a/openhands-tools/openhands/tools/gemini/__init__.py b/openhands-tools/openhands/tools/gemini/__init__.py new file mode 100644 index 0000000000..b76600fbeb --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/__init__.py @@ -0,0 +1,92 @@ +"""Gemini-style file editing tools. + +This module provides gemini-style file editing tools as an alternative to +the claude-style file_editor tool. These tools are designed to match the +tool interface used by gemini-cli. + +Tools: + - read_file: Read file content with pagination support + - write_file: Full file overwrite operations + - edit: Find and replace with validation + - list_directory: Directory listing with metadata + +Usage: + To use gemini-style tools instead of the standard FileEditorTool, + replace FileEditorTool with the four gemini tools: + + ```python + from openhands.tools.gemini import GEMINI_FILE_TOOLS + + agent = Agent( + llm=llm, + tools=[ + Tool(name=TerminalTool.name), + *GEMINI_FILE_TOOLS, # Instead of Tool(name=FileEditorTool.name) + ], + ) + ``` + + Or individually: + + ```python + from openhands.tools.gemini import ( + ReadFileTool, WriteFileTool, EditTool, ListDirectoryTool + ) + + agent = Agent( + llm=llm, + tools=[ + Tool(name=TerminalTool.name), + Tool(name=ReadFileTool.name), + Tool(name=WriteFileTool.name), + Tool(name=EditTool.name), + Tool(name=ListDirectoryTool.name), + ], + ) + ``` +""" + +from openhands.sdk import Tool +from openhands.tools.gemini.edit import EditAction, EditObservation, EditTool +from openhands.tools.gemini.list_directory import ( + ListDirectoryAction, + ListDirectoryObservation, + ListDirectoryTool, +) +from openhands.tools.gemini.read_file import ( + ReadFileAction, + ReadFileObservation, + ReadFileTool, +) +from openhands.tools.gemini.write_file import ( + WriteFileAction, + WriteFileObservation, + WriteFileTool, +) + + +# Convenience list for easy replacement of FileEditorTool +GEMINI_FILE_TOOLS: list[Tool] = [ + Tool(name=ReadFileTool.name), + Tool(name=WriteFileTool.name), + Tool(name=EditTool.name), + Tool(name=ListDirectoryTool.name), +] + +__all__ = [ + # Convenience list + "GEMINI_FILE_TOOLS", + # Individual tools + "ReadFileTool", + "ReadFileAction", + "ReadFileObservation", + "WriteFileTool", + "WriteFileAction", + "WriteFileObservation", + "EditTool", + "EditAction", + "EditObservation", + "ListDirectoryTool", + "ListDirectoryAction", + "ListDirectoryObservation", +] diff --git a/openhands-tools/openhands/tools/gemini/edit/__init__.py b/openhands-tools/openhands/tools/gemini/edit/__init__.py new file mode 100644 index 0000000000..547c6e18ca --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/edit/__init__.py @@ -0,0 +1,15 @@ +# Core tool interface +from openhands.tools.gemini.edit.definition import ( + EditAction, + EditObservation, + EditTool, +) +from openhands.tools.gemini.edit.impl import EditExecutor + + +__all__ = [ + "EditTool", + "EditAction", + "EditObservation", + "EditExecutor", +] diff --git a/openhands-tools/openhands/tools/gemini/edit/definition.py b/openhands-tools/openhands/tools/gemini/edit/definition.py new file mode 100644 index 0000000000..811552b5c3 --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/edit/definition.py @@ -0,0 +1,173 @@ +"""Edit tool definition (Gemini-style).""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from pydantic import Field, PrivateAttr +from rich.text import Text + +from openhands.sdk.tool import ( + Action, + Observation, + ToolAnnotations, + ToolDefinition, + register_tool, +) + + +if TYPE_CHECKING: + from openhands.sdk.conversation.state import ConversationState + + +class EditAction(Action): + """Schema for edit operation.""" + + file_path: str = Field(description="The path to the file to modify.") + old_string: str = Field( + description=( + "The text to replace. To create a new file, use an empty string. " + "Must match the exact text in the file including whitespace." + ) + ) + new_string: str = Field(description="The text to replace it with.") + expected_replacements: int = Field( + default=1, + ge=0, + description=( + "Number of replacements expected. Defaults to 1. " + "Use when you want to replace multiple occurrences. " + "The edit will fail if the actual count doesn't match." + ), + ) + + +class EditObservation(Observation): + """Observation from editing a file.""" + + file_path: str | None = Field( + default=None, description="The file path that was edited." + ) + is_new_file: bool = Field( + default=False, description="Whether a new file was created." + ) + replacements_made: int = Field( + default=0, description="Number of replacements actually made." + ) + old_content: str | None = Field( + default=None, description="The content before the edit." + ) + new_content: str | None = Field( + default=None, description="The content after the edit." + ) + + _diff_cache: Text | None = PrivateAttr(default=None) + + @property + def visualize(self) -> Text: + """Return Rich Text representation of this observation.""" + text = Text() + + if self.is_error: + text.append("❌ ", style="red bold") + text.append(self.ERROR_MESSAGE_HEADER, style="bold red") + return super().visualize + + if self.file_path: + if self.is_new_file: + text.append("✨ ", style="green bold") + text.append(f"Created: {self.file_path}\n", style="green") + else: + text.append("✏️ ", style="yellow bold") + text.append( + ( + f"Edited: {self.file_path} " + f"({self.replacements_made} replacement(s))\n" + ), + style="yellow", + ) + + if self.old_content is not None and self.new_content is not None: + from openhands.tools.file_editor.utils.diff import visualize_diff + + if not self._diff_cache: + self._diff_cache = visualize_diff( + self.file_path, + self.old_content, + self.new_content, + n_context_lines=2, + change_applied=True, + ) + text.append(self._diff_cache) + return text + + +TOOL_DESCRIPTION = """Replaces text within a file. + +By default, replaces a single occurrence, but can replace multiple occurrences +when `expected_replacements` is specified. The edit will fail if the actual +number of occurrences doesn't match the expected count. + +This tool is useful for making targeted changes to files without rewriting +the entire content. + +Key behaviors: +- To create a new file: use an empty string for `old_string` +- The `old_string` must match EXACTLY (including whitespace and indentation) +- If 0 occurrences are found, the edit fails with an error +- If the number of occurrences doesn't match `expected_replacements`, the edit fails +- If `old_string` equals `new_string`, no changes are made + +Tips for success: +- Include enough context (3-5 lines) to make `old_string` unique +- Use the `read_file` tool first to verify the exact text to replace +- For large changes affecting many lines, consider `write_file` instead + +Examples: +- Simple replacement: edit(file_path="test.py", old_string="old text", new_string="new text") +- Create file: edit(file_path="new.py", old_string="", new_string="print('hello')") +- Multiple replacements: edit(file_path="test.py", old_string="foo", new_string="bar", expected_replacements=3) +""" # noqa: E501 + + +class EditTool(ToolDefinition[EditAction, EditObservation]): + """Tool for editing files via find/replace.""" + + @classmethod + def create( + cls, + conv_state: "ConversationState", + ) -> Sequence["EditTool"]: + """Initialize EditTool with executor. + + Args: + conv_state: Conversation state to get working directory from. + """ + from openhands.tools.gemini.edit.impl import EditExecutor + + executor = EditExecutor(workspace_root=conv_state.workspace.working_dir) + + working_dir = conv_state.workspace.working_dir + enhanced_description = ( + f"{TOOL_DESCRIPTION}\n\n" + f"Your current working directory is: {working_dir}\n" + f"File paths can be absolute or relative to this directory." + ) + + return [ + cls( + action_type=EditAction, + observation_type=EditObservation, + description=enhanced_description, + annotations=ToolAnnotations( + title="edit", + readOnlyHint=False, + destructiveHint=True, + idempotentHint=False, + openWorldHint=False, + ), + executor=executor, + ) + ] + + +register_tool(EditTool.name, EditTool) diff --git a/openhands-tools/openhands/tools/gemini/edit/impl.py b/openhands-tools/openhands/tools/gemini/edit/impl.py new file mode 100644 index 0000000000..ea0448bfcd --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/edit/impl.py @@ -0,0 +1,187 @@ +"""Edit tool executor implementation.""" + +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from openhands.sdk.tool import ToolExecutor +from openhands.tools.gemini.edit.definition import EditAction, EditObservation + + +if TYPE_CHECKING: + from openhands.sdk.conversation import LocalConversation + + +class EditExecutor(ToolExecutor[EditAction, EditObservation]): + """Executor for edit tool.""" + + def __init__(self, workspace_root: str): + """Initialize executor with workspace root. + + Args: + workspace_root: Root directory for file operations + """ + self.workspace_root = Path(workspace_root) + + def __call__( + self, + action: EditAction, + conversation: "LocalConversation | None" = None, # noqa: ARG002 + ) -> EditObservation: + """Execute edit action. + + Args: + action: EditAction with file_path, old_string, new_string, etc. + conversation: Execution context + + Returns: + EditObservation with result + """ + + file_path = action.file_path + old_string = action.old_string + new_string = action.new_string + expected_replacements = action.expected_replacements + + # Resolve path relative to workspace + if not os.path.isabs(file_path): + resolved_path = self.workspace_root / file_path + else: + resolved_path = Path(file_path) + + # Handle file creation (old_string is empty) + if old_string == "": + if resolved_path.exists(): + return EditObservation.from_text( + is_error=True, + text=( + f"Error: Cannot create file that already exists: " + f"{resolved_path}. " + f"Use write_file to overwrite or provide non-empty old_string." + ), + ) + + try: + # Create parent directories if needed + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + # Write the file + with open(resolved_path, "w", encoding="utf-8") as f: + f.write(new_string) + + return EditObservation.from_text( + text=f"Created new file: {resolved_path}", + file_path=str(resolved_path), + is_new_file=True, + replacements_made=1, + old_content=None, + new_content=new_string, + ) + + except PermissionError: + return EditObservation.from_text( + is_error=True, + text=f"Error: Permission denied: {resolved_path}", + ) + except Exception as e: + return EditObservation.from_text( + is_error=True, + text=f"Error creating file: {e}", + ) + + # Editing existing file + if not resolved_path.exists(): + return EditObservation.from_text( + is_error=True, + text=( + f"Error: File not found: {resolved_path}. " + f"To create a new file, use old_string=''." + ), + ) + + if resolved_path.is_dir(): + return EditObservation.from_text( + is_error=True, + text=f"Error: Path is a directory, not a file: {resolved_path}", + ) + + try: + # Read current content + with open(resolved_path, encoding="utf-8", errors="replace") as f: + old_content = f.read() + + # Check for no-op + if old_string == new_string: + return EditObservation.from_text( + is_error=True, + text=( + "Error: No changes to apply. " + "old_string and new_string are identical." + ), + ) + + # Count occurrences + occurrences = old_content.count(old_string) + + if occurrences == 0: + return EditObservation.from_text( + is_error=True, + text=( + f"Error: Could not find the string to replace. " + f"0 occurrences found in {resolved_path}. " + f"Use read_file to verify the exact text." + ), + file_path=str(resolved_path), + ) + + if occurrences != expected_replacements: + occurrence_word = ( + "occurrence" if expected_replacements == 1 else "occurrences" + ) + return EditObservation.from_text( + is_error=True, + text=( + f"Error: Expected {expected_replacements} {occurrence_word} " + f"but found {occurrences} in {resolved_path}." + ), + file_path=str(resolved_path), + ) + + # Perform replacement + new_content = old_content.replace(old_string, new_string) + + # Check if content actually changed + if old_content == new_content: + return EditObservation.from_text( + is_error=True, + text=( + "Error: No changes made. " + "The new content is identical to the current content." + ), + file_path=str(resolved_path), + ) + + # Write the file + with open(resolved_path, "w", encoding="utf-8") as f: + f.write(new_content) + + msg = f"Successfully edited {resolved_path} ({occurrences} replacement(s))" + return EditObservation.from_text( + text=msg, + file_path=str(resolved_path), + is_new_file=False, + replacements_made=occurrences, + old_content=old_content, + new_content=new_content, + ) + + except PermissionError: + return EditObservation.from_text( + is_error=True, + text=f"Error: Permission denied: {resolved_path}", + ) + except Exception as e: + return EditObservation.from_text( + is_error=True, + text=f"Error editing file: {e}", + ) diff --git a/openhands-tools/openhands/tools/gemini/list_directory/__init__.py b/openhands-tools/openhands/tools/gemini/list_directory/__init__.py new file mode 100644 index 0000000000..c045814f01 --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/list_directory/__init__.py @@ -0,0 +1,17 @@ +# Core tool interface +from openhands.tools.gemini.list_directory.definition import ( + FileEntry, + ListDirectoryAction, + ListDirectoryObservation, + ListDirectoryTool, +) +from openhands.tools.gemini.list_directory.impl import ListDirectoryExecutor + + +__all__ = [ + "ListDirectoryTool", + "ListDirectoryAction", + "ListDirectoryObservation", + "ListDirectoryExecutor", + "FileEntry", +] diff --git a/openhands-tools/openhands/tools/gemini/list_directory/definition.py b/openhands-tools/openhands/tools/gemini/list_directory/definition.py new file mode 100644 index 0000000000..a1051d83db --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/list_directory/definition.py @@ -0,0 +1,183 @@ +"""List directory tool definition (Gemini-style).""" + +from collections.abc import Sequence +from datetime import datetime +from typing import TYPE_CHECKING + +from pydantic import BaseModel, Field +from rich.text import Text + +from openhands.sdk.tool import ( + Action, + Observation, + ToolAnnotations, + ToolDefinition, + register_tool, +) + + +if TYPE_CHECKING: + from openhands.sdk.conversation.state import ConversationState + + +class FileEntry(BaseModel): + """Information about a file or directory.""" + + name: str = Field(description="Name of the file or directory") + path: str = Field(description="Absolute path to the file or directory") + is_directory: bool = Field(description="Whether this entry is a directory") + size: int = Field(description="Size of the file in bytes (0 for directories)") + modified_time: datetime = Field(description="Last modified timestamp") + + +class ListDirectoryAction(Action): + """Schema for list directory operation.""" + + dir_path: str = Field( + default=".", + description="The path to the directory to list. Defaults to current directory.", + ) + recursive: bool = Field( + default=False, + description="Whether to list subdirectories recursively (up to 2 levels).", + ) + + +class ListDirectoryObservation(Observation): + """Observation from listing a directory.""" + + dir_path: str | None = Field( + default=None, description="The directory path that was listed." + ) + entries: list[FileEntry] = Field( + default_factory=list, description="List of files and directories found." + ) + total_count: int = Field(default=0, description="Total number of entries found.") + is_truncated: bool = Field( + default=False, + description="Whether the listing was truncated due to too many entries.", + ) + + @property + def visualize(self) -> Text: + """Return Rich Text representation of this observation.""" + text = Text() + + if self.is_error: + text.append("❌ ", style="red bold") + text.append(self.ERROR_MESSAGE_HEADER, style="bold red") + return super().visualize + + if self.dir_path: + text.append("📁 ", style="blue bold") + text.append(f"Directory: {self.dir_path}\n", style="blue") + + if self.total_count == 0: + text.append("(empty directory)\n", style="dim") + else: + # Build a simple text-based table + lines = [] + lines.append(f"{'Type':<6} {'Name':<40} {'Size':>10} {'Modified':<16}") + lines.append("-" * 76) + + for entry in self.entries[:50]: + entry_type = "📁" if entry.is_directory else "📄" + size_str = ( + "-" if entry.is_directory else self._format_size(entry.size) + ) + modified_str = entry.modified_time.strftime("%Y-%m-%d %H:%M") + # Truncate name if too long + name = ( + entry.name[:38] + ".." if len(entry.name) > 40 else entry.name + ) + lines.append( + f"{entry_type:<6} {name:<40} {size_str:>10} {modified_str:<16}" + ) + + text.append("\n".join(lines) + "\n") + + if self.is_truncated: + text.append( + f"\n⚠️ Showing first 50 of {self.total_count} entries\n", + style="yellow", + ) + + return text + + def _format_size(self, size: int) -> str: + """Format file size in human-readable format.""" + size_float = float(size) + for unit in ["B", "KB", "MB", "GB"]: + if size_float < 1024.0: + return f"{size_float:.1f}{unit}" + size_float /= 1024.0 + return f"{size_float:.1f}TB" + + +TOOL_DESCRIPTION = """Lists the contents of a specified directory. + +Returns detailed information about each file and subdirectory, including: +- Name and path +- Whether it's a file or directory +- File size (in bytes) +- Last modified timestamp + +By default, lists only the immediate contents of the directory. Use `recursive=True` +to list subdirectories up to 2 levels deep. + +Hidden files (starting with .) are included in the listing. + +Examples: +- List current directory: list_directory() +- List specific directory: list_directory(dir_path="/path/to/dir") +- List recursively: list_directory(dir_path="/path/to/dir", recursive=True) +""" + +# Maximum entries to return (to prevent overwhelming the context) +MAX_ENTRIES = 500 + + +class ListDirectoryTool(ToolDefinition[ListDirectoryAction, ListDirectoryObservation]): + """Tool for listing directory contents with metadata.""" + + @classmethod + def create( + cls, + conv_state: "ConversationState", + ) -> Sequence["ListDirectoryTool"]: + """Initialize ListDirectoryTool with executor. + + Args: + conv_state: Conversation state to get working directory from. + """ + from openhands.tools.gemini.list_directory.impl import ListDirectoryExecutor + + executor = ListDirectoryExecutor( + workspace_root=conv_state.workspace.working_dir + ) + + working_dir = conv_state.workspace.working_dir + enhanced_description = ( + f"{TOOL_DESCRIPTION}\n\n" + f"Your current working directory is: {working_dir}\n" + f"Relative paths will be resolved from this directory." + ) + + return [ + cls( + action_type=ListDirectoryAction, + observation_type=ListDirectoryObservation, + description=enhanced_description, + annotations=ToolAnnotations( + title="list_directory", + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), + executor=executor, + ) + ] + + +register_tool(ListDirectoryTool.name, ListDirectoryTool) diff --git a/openhands-tools/openhands/tools/gemini/list_directory/impl.py b/openhands-tools/openhands/tools/gemini/list_directory/impl.py new file mode 100644 index 0000000000..4ca574521d --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/list_directory/impl.py @@ -0,0 +1,165 @@ +"""List directory tool executor implementation.""" + +import os +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING + +from openhands.sdk.tool import ToolExecutor +from openhands.tools.gemini.list_directory.definition import ( + MAX_ENTRIES, + FileEntry, + ListDirectoryAction, + ListDirectoryObservation, +) + + +if TYPE_CHECKING: + from openhands.sdk.conversation import LocalConversation + + +class ListDirectoryExecutor( + ToolExecutor[ListDirectoryAction, ListDirectoryObservation] +): + """Executor for list_directory tool.""" + + def __init__(self, workspace_root: str): + """Initialize executor with workspace root. + + Args: + workspace_root: Root directory for file operations + """ + self.workspace_root = Path(workspace_root) + + def __call__( + self, + action: ListDirectoryAction, + conversation: "LocalConversation | None" = None, # noqa: ARG002 + ) -> ListDirectoryObservation: + """Execute list directory action. + + Args: + action: ListDirectoryAction with dir_path and recursive + conversation: Execution context + + Returns: + ListDirectoryObservation with directory contents + """ + + dir_path = action.dir_path + recursive = action.recursive + + # Resolve path relative to workspace + if not os.path.isabs(dir_path): + resolved_path = self.workspace_root / dir_path + else: + resolved_path = Path(dir_path) + + # Check if directory exists + if not resolved_path.exists(): + return ListDirectoryObservation.from_text( + is_error=True, + text=f"Error: Directory not found: {resolved_path}", + ) + + # Check if it's a directory + if not resolved_path.is_dir(): + return ListDirectoryObservation.from_text( + is_error=True, + text=f"Error: Path is not a directory: {resolved_path}", + ) + + try: + entries = [] + + if recursive: + # List up to 2 levels deep + for root, dirs, files in os.walk(resolved_path): + root_path = Path(root) + depth = len(root_path.relative_to(resolved_path).parts) + if depth >= 2: + dirs.clear() + continue + + # Add directories + for d in sorted(dirs): + d_path = root_path / d + try: + stat = d_path.stat() + entries.append( + FileEntry( + name=d, + path=str(d_path), + is_directory=True, + size=0, + modified_time=datetime.fromtimestamp(stat.st_mtime), + ) + ) + except Exception: + continue + + # Add files + for f in sorted(files): + f_path = root_path / f + try: + stat = f_path.stat() + entries.append( + FileEntry( + name=f, + path=str(f_path), + is_directory=False, + size=stat.st_size, + modified_time=datetime.fromtimestamp(stat.st_mtime), + ) + ) + except Exception: + continue + + if len(entries) >= MAX_ENTRIES: + break + else: + # List only immediate contents + for entry in sorted(resolved_path.iterdir()): + try: + stat = entry.stat() + entries.append( + FileEntry( + name=entry.name, + path=str(entry), + is_directory=entry.is_dir(), + size=0 if entry.is_dir() else stat.st_size, + modified_time=datetime.fromtimestamp(stat.st_mtime), + ) + ) + + if len(entries) >= MAX_ENTRIES: + break + except Exception: + continue + + total_count = len(entries) + is_truncated = total_count >= MAX_ENTRIES + + agent_obs = f"Listed directory: {resolved_path} ({total_count} entries" + if is_truncated: + agent_obs += f", truncated to {MAX_ENTRIES}" + agent_obs += ")" + + return ListDirectoryObservation.from_text( + text=agent_obs, + dir_path=str(resolved_path), + entries=entries[:MAX_ENTRIES], + total_count=total_count, + is_truncated=is_truncated, + ) + + except PermissionError: + return ListDirectoryObservation.from_text( + is_error=True, + text=f"Error: Permission denied: {resolved_path}", + ) + except Exception as e: + return ListDirectoryObservation.from_text( + is_error=True, + text=f"Error listing directory: {e}", + ) diff --git a/openhands-tools/openhands/tools/gemini/read_file/__init__.py b/openhands-tools/openhands/tools/gemini/read_file/__init__.py new file mode 100644 index 0000000000..651e47d677 --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/read_file/__init__.py @@ -0,0 +1,15 @@ +# Core tool interface +from openhands.tools.gemini.read_file.definition import ( + ReadFileAction, + ReadFileObservation, + ReadFileTool, +) +from openhands.tools.gemini.read_file.impl import ReadFileExecutor + + +__all__ = [ + "ReadFileTool", + "ReadFileAction", + "ReadFileObservation", + "ReadFileExecutor", +] diff --git a/openhands-tools/openhands/tools/gemini/read_file/definition.py b/openhands-tools/openhands/tools/gemini/read_file/definition.py new file mode 100644 index 0000000000..e12c1da141 --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/read_file/definition.py @@ -0,0 +1,148 @@ +"""Read file tool definition (Gemini-style).""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from pydantic import Field +from rich.text import Text + +from openhands.sdk.tool import ( + Action, + Observation, + ToolAnnotations, + ToolDefinition, + register_tool, +) + + +if TYPE_CHECKING: + from openhands.sdk.conversation.state import ConversationState + + +class ReadFileAction(Action): + """Schema for read file operation.""" + + file_path: str = Field(description="The path to the file to read.") + offset: int | None = Field( + default=None, + ge=0, + description=( + "Optional: The 0-based line number to start reading from. " + "Use for paginating through large files." + ), + ) + limit: int | None = Field( + default=None, + ge=1, + description=( + "Optional: Maximum number of lines to read. " + "Use with 'offset' to paginate through large files." + ), + ) + + +class ReadFileObservation(Observation): + """Observation from reading a file.""" + + file_path: str = Field(description="The file path that was read.") + file_content: str = Field(default="", description="The content read from the file.") + is_truncated: bool = Field( + default=False, + description="Whether the content was truncated due to size limits.", + ) + lines_shown: tuple[int, int] | None = Field( + default=None, + description=( + "If truncated, the range of lines shown (start, end) - 1-indexed." + ), + ) + total_lines: int | None = Field( + default=None, description="Total number of lines in the file." + ) + + @property + def visualize(self) -> Text: + """Return Rich Text representation of this observation.""" + text = Text() + + if self.is_error: + text.append("❌ ", style="red bold") + text.append(self.ERROR_MESSAGE_HEADER, style="bold red") + return super().visualize + + text.append("📄 ", style="blue bold") + text.append(f"Read: {self.file_path}\n", style="blue") + + if self.is_truncated and self.lines_shown and self.total_lines: + start, end = self.lines_shown + text.append( + ( + f"⚠️ Content truncated: " + f"Showing lines {start}-{end} of {self.total_lines}\n" + ), + style="yellow", + ) + + text.append(self.file_content) + return text + + +TOOL_DESCRIPTION = """Reads and returns the content of a specified file. + +If the file is large, the content will be truncated. The tool's response will +clearly indicate if truncation has occurred and will provide details on how to +read more of the file using the 'offset' and 'limit' parameters. + +For text files, it can read specific line ranges. + +Examples: +- Read entire file: read_file(file_path="/path/to/file.py") +- Read with pagination: read_file(file_path="/path/to/file.py", offset=100, limit=50) +""" + +# Maximum lines to read in one call (to prevent overwhelming the context) +MAX_LINES_PER_READ = 1000 + + +class ReadFileTool(ToolDefinition[ReadFileAction, ReadFileObservation]): + """Tool for reading file contents with pagination support.""" + + @classmethod + def create( + cls, + conv_state: "ConversationState", + ) -> Sequence["ReadFileTool"]: + """Initialize ReadFileTool with executor. + + Args: + conv_state: Conversation state to get working directory from. + """ + from openhands.tools.gemini.read_file.impl import ReadFileExecutor + + executor = ReadFileExecutor(workspace_root=conv_state.workspace.working_dir) + + working_dir = conv_state.workspace.working_dir + enhanced_description = ( + f"{TOOL_DESCRIPTION}\n\n" + f"Your current working directory is: {working_dir}\n" + f"File paths can be absolute or relative to this directory." + ) + + return [ + cls( + action_type=ReadFileAction, + observation_type=ReadFileObservation, + description=enhanced_description, + annotations=ToolAnnotations( + title="read_file", + readOnlyHint=True, + destructiveHint=False, + idempotentHint=True, + openWorldHint=False, + ), + executor=executor, + ) + ] + + +register_tool(ReadFileTool.name, ReadFileTool) diff --git a/openhands-tools/openhands/tools/gemini/read_file/impl.py b/openhands-tools/openhands/tools/gemini/read_file/impl.py new file mode 100644 index 0000000000..4858df72af --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/read_file/impl.py @@ -0,0 +1,153 @@ +"""Read file tool executor implementation.""" + +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from openhands.sdk.tool import ToolExecutor +from openhands.tools.gemini.read_file.definition import ( + MAX_LINES_PER_READ, + ReadFileAction, + ReadFileObservation, +) + + +if TYPE_CHECKING: + from openhands.sdk.conversation import LocalConversation + + +class ReadFileExecutor(ToolExecutor[ReadFileAction, ReadFileObservation]): + """Executor for read_file tool.""" + + def __init__(self, workspace_root: str): + """Initialize executor with workspace root. + + Args: + workspace_root: Root directory for file operations + """ + self.workspace_root = Path(workspace_root) + + def __call__( + self, + action: ReadFileAction, + conversation: "LocalConversation | None" = None, # noqa: ARG002 + ) -> ReadFileObservation: + """Execute read file action. + + Args: + action: ReadFileAction with file_path, offset, and limit + conversation: Execution context + + Returns: + ReadFileObservation with file content + """ + + file_path = action.file_path + offset = action.offset or 0 + limit = action.limit + + # Resolve path relative to workspace + if not os.path.isabs(file_path): + resolved_path = self.workspace_root / file_path + else: + resolved_path = Path(file_path) + + # Check if file exists + if not resolved_path.exists(): + return ReadFileObservation.from_text( + text=f"Error: File not found: {resolved_path}", + is_error=True, + file_path=str(resolved_path), + file_content="", + ) + + # Check if it's a directory + if resolved_path.is_dir(): + return ReadFileObservation.from_text( + text=f"Error: Path is a directory, not a file: {resolved_path}", + is_error=True, + file_path=str(resolved_path), + file_content="", + ) + + try: + # Read file content + with open(resolved_path, encoding="utf-8", errors="replace") as f: + lines = f.readlines() + + total_lines = len(lines) + + # Apply offset and limit + if offset >= total_lines: + return ReadFileObservation.from_text( + text=( + f"Error: Offset {offset} is beyond file length " + f"({total_lines} lines)" + ), + is_error=True, + file_path=str(resolved_path), + file_content="", + ) + + # Determine the range to read + start = offset + if limit: + end = min(start + limit, total_lines) + else: + # If no limit specified, apply default maximum + end = min(start + MAX_LINES_PER_READ, total_lines) + + # Get the lines to return + lines_to_show = lines[start:end] + + # Add line numbers + numbered_lines = [] + for i, line in enumerate(lines_to_show, start=start + 1): + numbered_lines.append(f"{i:6d} {line}") + content_with_numbers = "".join(numbered_lines) + + # Check if truncated + is_truncated = end < total_lines + lines_shown = (start + 1, end) if is_truncated else None + + agent_obs_parts = [f"Read file: {resolved_path}"] + if is_truncated: + agent_obs_parts.append( + f"(showing lines {start + 1}-{end} of {total_lines})" + ) + next_offset = end + agent_obs_parts.append( + f"To read more, use: read_file(file_path='{action.file_path}', " + f"offset={next_offset}, limit={limit or MAX_LINES_PER_READ})" + ) + + return ReadFileObservation.from_text( + text=" ".join(agent_obs_parts) + "\n\n" + content_with_numbers, + file_path=str(resolved_path), + file_content=content_with_numbers, + is_truncated=is_truncated, + lines_shown=lines_shown, + total_lines=total_lines, + ) + + except UnicodeDecodeError: + return ReadFileObservation.from_text( + is_error=True, + text=f"Error: File is not a text file: {resolved_path}", + file_path=str(resolved_path), + file_content="", + ) + except PermissionError: + return ReadFileObservation.from_text( + is_error=True, + text=f"Error: Permission denied: {resolved_path}", + file_path=str(resolved_path), + file_content="", + ) + except Exception as e: + return ReadFileObservation.from_text( + is_error=True, + text=f"Error reading file: {e}", + file_path=str(resolved_path), + file_content="", + ) diff --git a/openhands-tools/openhands/tools/gemini/write_file/__init__.py b/openhands-tools/openhands/tools/gemini/write_file/__init__.py new file mode 100644 index 0000000000..83025eaa92 --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/write_file/__init__.py @@ -0,0 +1,15 @@ +# Core tool interface +from openhands.tools.gemini.write_file.definition import ( + WriteFileAction, + WriteFileObservation, + WriteFileTool, +) +from openhands.tools.gemini.write_file.impl import WriteFileExecutor + + +__all__ = [ + "WriteFileTool", + "WriteFileAction", + "WriteFileObservation", + "WriteFileExecutor", +] diff --git a/openhands-tools/openhands/tools/gemini/write_file/definition.py b/openhands-tools/openhands/tools/gemini/write_file/definition.py new file mode 100644 index 0000000000..9f231e9289 --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/write_file/definition.py @@ -0,0 +1,140 @@ +"""Write file tool definition (Gemini-style).""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from pydantic import Field, PrivateAttr +from rich.text import Text + +from openhands.sdk.tool import ( + Action, + Observation, + ToolAnnotations, + ToolDefinition, + register_tool, +) + + +if TYPE_CHECKING: + from openhands.sdk.conversation.state import ConversationState + + +class WriteFileAction(Action): + """Schema for write file operation.""" + + file_path: str = Field(description="The path to the file to write to.") + content: str = Field(description="The content to write to the file.") + + +class WriteFileObservation(Observation): + """Observation from writing a file.""" + + file_path: str | None = Field( + default=None, description="The file path that was written." + ) + is_new_file: bool = Field( + default=False, description="Whether a new file was created." + ) + old_content: str | None = Field( + default=None, description="The previous content of the file (if it existed)." + ) + new_content: str | None = Field( + default=None, description="The new content written to the file." + ) + + _diff_cache: Text | None = PrivateAttr(default=None) + + @property + def visualize(self) -> Text: + """Return Rich Text representation of this observation.""" + text = Text() + + if self.is_error: + text.append("❌ ", style="red bold") + text.append(self.ERROR_MESSAGE_HEADER, style="bold red") + return super().visualize + + if self.file_path: + if self.is_new_file: + text.append("✨ ", style="green bold") + text.append(f"Created: {self.file_path}\n", style="green") + else: + text.append("✏️ ", style="yellow bold") + text.append(f"Updated: {self.file_path}\n", style="yellow") + + if self.old_content is not None and self.new_content is not None: + from openhands.tools.file_editor.utils.diff import visualize_diff + + if not self._diff_cache: + self._diff_cache = visualize_diff( + self.file_path, + self.old_content, + self.new_content, + n_context_lines=2, + change_applied=True, + ) + text.append(self._diff_cache) + return text + + +TOOL_DESCRIPTION = """Writes content to a specified file in the local filesystem. + +This tool overwrites the entire content of the file. If the file doesn't exist, +it will be created. If it exists, all previous content will be replaced. + +This is useful for: +- Creating new files +- Completely rewriting files when many changes are needed +- Setting initial file content + +For smaller edits to existing files, consider using the 'edit' tool instead, +which allows targeted find/replace operations. + +Examples: +- Create new file: write_file(file_path="/path/to/new.py", content="print('hello')") +- Overwrite file: write_file(file_path="/path/to/existing.py", content="new content") +""" + + +class WriteFileTool(ToolDefinition[WriteFileAction, WriteFileObservation]): + """Tool for writing complete file contents.""" + + @classmethod + def create( + cls, + conv_state: "ConversationState", + ) -> Sequence["WriteFileTool"]: + """Initialize WriteFileTool with executor. + + Args: + conv_state: Conversation state to get working directory from. + """ + from openhands.tools.gemini.write_file.impl import WriteFileExecutor + + executor = WriteFileExecutor(workspace_root=conv_state.workspace.working_dir) + + working_dir = conv_state.workspace.working_dir + enhanced_description = ( + f"{TOOL_DESCRIPTION}\n\n" + f"Your current working directory is: {working_dir}\n" + f"File paths can be absolute or relative to this directory." + ) + + return [ + cls( + action_type=WriteFileAction, + observation_type=WriteFileObservation, + description=enhanced_description, + annotations=ToolAnnotations( + title="write_file", + readOnlyHint=False, + destructiveHint=True, + idempotentHint=False, + openWorldHint=False, + ), + executor=executor, + ) + ] + + +register_tool(WriteFileTool.name, WriteFileTool) diff --git a/openhands-tools/openhands/tools/gemini/write_file/impl.py b/openhands-tools/openhands/tools/gemini/write_file/impl.py new file mode 100644 index 0000000000..901a16e135 --- /dev/null +++ b/openhands-tools/openhands/tools/gemini/write_file/impl.py @@ -0,0 +1,96 @@ +"""Write file tool executor implementation.""" + +import os +from pathlib import Path +from typing import TYPE_CHECKING + +from openhands.sdk.tool import ToolExecutor +from openhands.tools.gemini.write_file.definition import ( + WriteFileAction, + WriteFileObservation, +) + + +if TYPE_CHECKING: + from openhands.sdk.conversation import LocalConversation + + +class WriteFileExecutor(ToolExecutor[WriteFileAction, WriteFileObservation]): + """Executor for write_file tool.""" + + def __init__(self, workspace_root: str): + """Initialize executor with workspace root. + + Args: + workspace_root: Root directory for file operations + """ + self.workspace_root = Path(workspace_root) + + def __call__( + self, + action: WriteFileAction, + conversation: "LocalConversation | None" = None, # noqa: ARG002 + ) -> WriteFileObservation: + """Execute write file action. + + Args: + action: WriteFileAction with file_path and content + conversation: Execution context + + Returns: + WriteFileObservation with result + """ + + file_path = action.file_path + content = action.content + + # Resolve path relative to workspace + if not os.path.isabs(file_path): + resolved_path = self.workspace_root / file_path + else: + resolved_path = Path(file_path) + + # Check if path is a directory + if resolved_path.exists() and resolved_path.is_dir(): + return WriteFileObservation.from_text( + is_error=True, + text=(f"Error: Path is a directory, not a file: {resolved_path}"), + ) + + # Read old content if file exists + is_new_file = not resolved_path.exists() + old_content = None + if not is_new_file: + try: + with open(resolved_path, encoding="utf-8", errors="replace") as f: + old_content = f.read() + except Exception: + pass + + try: + # Create parent directories if needed + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + # Write the file + with open(resolved_path, "w", encoding="utf-8") as f: + f.write(content) + + action_verb = "Created" if is_new_file else "Updated" + return WriteFileObservation.from_text( + text=f"{action_verb} file: {resolved_path}", + file_path=str(resolved_path), + is_new_file=is_new_file, + old_content=old_content, + new_content=content, + ) + + except PermissionError: + return WriteFileObservation.from_text( + is_error=True, + text=f"Error: Permission denied: {resolved_path}", + ) + except Exception as e: + return WriteFileObservation.from_text( + is_error=True, + text=f"Error writing file: {e}", + ) diff --git a/openhands-tools/openhands/tools/preset/__init__.py b/openhands-tools/openhands/tools/preset/__init__.py index dbbaf3d42f..cc44e66ce4 100644 --- a/openhands-tools/openhands/tools/preset/__init__.py +++ b/openhands-tools/openhands/tools/preset/__init__.py @@ -19,8 +19,15 @@ """ from .default import get_default_agent +from .gemini import get_gemini_agent, get_gemini_tools from .gpt5 import get_gpt5_agent from .planning import get_planning_agent -__all__ = ["get_default_agent", "get_planning_agent", "get_gpt5_agent"] +__all__ = [ + "get_default_agent", + "get_gemini_agent", + "get_gemini_tools", + "get_gpt5_agent", + "get_planning_agent", +] diff --git a/openhands-tools/openhands/tools/preset/gemini.py b/openhands-tools/openhands/tools/preset/gemini.py new file mode 100644 index 0000000000..41ce5e28d2 --- /dev/null +++ b/openhands-tools/openhands/tools/preset/gemini.py @@ -0,0 +1,103 @@ +"""Gemini preset configuration for OpenHands agents. + +This preset uses gemini-style file editing tools instead of the default +claude-style file_editor tool. +""" + +from openhands.sdk import Agent +from openhands.sdk.context.condenser import ( + LLMSummarizingCondenser, +) +from openhands.sdk.context.condenser.base import CondenserBase +from openhands.sdk.llm.llm import LLM +from openhands.sdk.logger import get_logger +from openhands.sdk.tool import Tool + + +logger = get_logger(__name__) + + +def register_gemini_tools(enable_browser: bool = True) -> None: + """Register the gemini set of tools.""" + from openhands.tools.gemini import ( + EditTool, + ListDirectoryTool, + ReadFileTool, + WriteFileTool, + ) + from openhands.tools.task_tracker import TaskTrackerTool + from openhands.tools.terminal import TerminalTool + + logger.debug(f"Tool: {TerminalTool.name} registered.") + logger.debug(f"Tool: {ReadFileTool.name} registered.") + logger.debug(f"Tool: {WriteFileTool.name} registered.") + logger.debug(f"Tool: {EditTool.name} registered.") + logger.debug(f"Tool: {ListDirectoryTool.name} registered.") + logger.debug(f"Tool: {TaskTrackerTool.name} registered.") + + if enable_browser: + from openhands.tools.browser_use import BrowserToolSet + + logger.debug(f"Tool: {BrowserToolSet.name} registered.") + + +def get_gemini_tools( + enable_browser: bool = True, +) -> list[Tool]: + """Get the gemini set of tool specifications. + + This uses gemini-style file editing tools (read_file, write_file, edit, + list_directory) instead of the default claude-style file_editor tool. + + Args: + enable_browser: Whether to include browser tools. + """ + register_gemini_tools(enable_browser=enable_browser) + + from openhands.tools.gemini import ( + EditTool, + ListDirectoryTool, + ReadFileTool, + WriteFileTool, + ) + from openhands.tools.task_tracker import TaskTrackerTool + from openhands.tools.terminal import TerminalTool + + tools = [ + Tool(name=TerminalTool.name), + Tool(name=ReadFileTool.name), + Tool(name=WriteFileTool.name), + Tool(name=EditTool.name), + Tool(name=ListDirectoryTool.name), + Tool(name=TaskTrackerTool.name), + ] + if enable_browser: + from openhands.tools.browser_use import BrowserToolSet + + tools.append(Tool(name=BrowserToolSet.name)) + return tools + + +def get_gemini_condenser(llm: LLM) -> CondenserBase: + """Get the default condenser for gemini preset.""" + condenser = LLMSummarizingCondenser(llm=llm, max_size=80, keep_first=4) + return condenser + + +def get_gemini_agent( + llm: LLM, + cli_mode: bool = False, +) -> Agent: + """Get an agent configured with gemini-style file editing tools.""" + tools = get_gemini_tools( + enable_browser=not cli_mode, + ) + agent = Agent( + llm=llm, + tools=tools, + system_prompt_kwargs={"cli_mode": cli_mode}, + condenser=get_gemini_condenser( + llm=llm.model_copy(update={"usage_id": "condenser"}) + ), + ) + return agent From 2d62e525fc6ec2a2ab4d7112ab06f80208369e72 Mon Sep 17 00:00:00 2001 From: enyst Date: Tue, 23 Dec 2025 12:23:20 +0000 Subject: [PATCH 5/9] Fix example file numbering after merge - Add EXAMPLE_COST marker to 30_gemini_file_tools.py (from main) - Remove duplicate 33_gemini_file_tools.py - Rename GPT5 example from 34 to 33 (sequential numbering) Co-authored-by: openhands --- .../01_standalone_sdk/30_gemini_file_tools.py | 5 +- .../01_standalone_sdk/33_gemini_file_tools.py | 55 ------------------- ...reset.py => 33_gpt5_apply_patch_preset.py} | 2 +- 3 files changed, 5 insertions(+), 57 deletions(-) delete mode 100644 examples/01_standalone_sdk/33_gemini_file_tools.py rename examples/01_standalone_sdk/{34_gpt5_apply_patch_preset.py => 33_gpt5_apply_patch_preset.py} (95%) diff --git a/examples/01_standalone_sdk/30_gemini_file_tools.py b/examples/01_standalone_sdk/30_gemini_file_tools.py index 3637f62e2e..d7d2a4534e 100644 --- a/examples/01_standalone_sdk/30_gemini_file_tools.py +++ b/examples/01_standalone_sdk/30_gemini_file_tools.py @@ -49,4 +49,7 @@ conversation.send_message("Now delete the FACTS.txt file you just created.") conversation.run() -print("All done!") + +# Report cost +cost = llm.metrics.accumulated_cost +print(f"EXAMPLE_COST: {cost}") diff --git a/examples/01_standalone_sdk/33_gemini_file_tools.py b/examples/01_standalone_sdk/33_gemini_file_tools.py deleted file mode 100644 index d7d2a4534e..0000000000 --- a/examples/01_standalone_sdk/33_gemini_file_tools.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Example: Using Gemini-style file editing tools. - -This example demonstrates how to use gemini-style file editing tools -(read_file, write_file, edit, list_directory) instead of the standard -claude-style file_editor tool. - -The only difference from the standard setup is replacing: - Tool(name=FileEditorTool.name) -with: - *GEMINI_FILE_TOOLS - -This is a one-line change that swaps the claude-style file_editor for -gemini-style tools (read_file, write_file, edit, list_directory). -""" - -import os - -from openhands.sdk import LLM, Agent, Conversation, Tool -from openhands.tools.gemini import GEMINI_FILE_TOOLS -from openhands.tools.terminal import TerminalTool - - -# Route logs based on whether we're using a proxy (Vertex route) or direct Gemini -_log_dir = "logs/vertex" if os.getenv("LLM_BASE_URL") else "logs/gemini" -os.makedirs(_log_dir, exist_ok=True) - -llm = LLM( - model=os.getenv("LLM_MODEL", "gemini/gemini-3-pro-preview"), - api_key=os.getenv("LLM_API_KEY"), - base_url=os.getenv("LLM_BASE_URL", None), - log_completions=True, - log_completions_folder=_log_dir, -) - -agent = Agent( - llm=llm, - tools=[ - Tool(name=TerminalTool.name), - *GEMINI_FILE_TOOLS, # Instead of Tool(name=FileEditorTool.name) - ], -) - -cwd = os.getcwd() -conversation = Conversation(agent=agent, workspace=cwd) - -# Ask the agent to create a file, then delete it afterwards -conversation.send_message("Write 3 facts about the current project into FACTS.txt.") -conversation.run() - -conversation.send_message("Now delete the FACTS.txt file you just created.") -conversation.run() - -# Report cost -cost = llm.metrics.accumulated_cost -print(f"EXAMPLE_COST: {cost}") diff --git a/examples/01_standalone_sdk/34_gpt5_apply_patch_preset.py b/examples/01_standalone_sdk/33_gpt5_apply_patch_preset.py similarity index 95% rename from examples/01_standalone_sdk/34_gpt5_apply_patch_preset.py rename to examples/01_standalone_sdk/33_gpt5_apply_patch_preset.py index 7bd273a531..c56cc408ae 100644 --- a/examples/01_standalone_sdk/34_gpt5_apply_patch_preset.py +++ b/examples/01_standalone_sdk/33_gpt5_apply_patch_preset.py @@ -10,7 +10,7 @@ # "openai/gpt-5.2-mini" # or fallback: "openai/gpt-5.1-mini" or "openai/gpt-5.1" # ) - uv run python examples/01_standalone_sdk/34_gpt5_apply_patch_preset.py + uv run python examples/01_standalone_sdk/33_gpt5_apply_patch_preset.py """ import os From b191e3fd3f806f6aea358809e61ff2096725ebfe Mon Sep 17 00:00:00 2001 From: enyst Date: Tue, 23 Dec 2025 12:26:11 +0000 Subject: [PATCH 6/9] Rename gemini example to 34 to fix duplicate numbering 30_tom_agent.py already exists, so rename 30_gemini_file_tools.py to 34. Co-authored-by: openhands --- .../{30_gemini_file_tools.py => 34_gemini_file_tools.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examples/01_standalone_sdk/{30_gemini_file_tools.py => 34_gemini_file_tools.py} (100%) diff --git a/examples/01_standalone_sdk/30_gemini_file_tools.py b/examples/01_standalone_sdk/34_gemini_file_tools.py similarity index 100% rename from examples/01_standalone_sdk/30_gemini_file_tools.py rename to examples/01_standalone_sdk/34_gemini_file_tools.py From 98c8774785e2b44d9b82781b3692ce9c6a7b5575 Mon Sep 17 00:00:00 2001 From: enyst Date: Tue, 23 Dec 2025 15:23:36 +0000 Subject: [PATCH 7/9] Move LLM-specific examples to examples/04_llm_specific_tools - Move GPT-5 apply patch preset example to 04_llm_specific_tools/01_gpt5_apply_patch_preset.py - Move Gemini file tools example to 04_llm_specific_tools/02_gemini_file_tools.py - Update usage path in docstring This organizes LLM-specific tool examples into a dedicated folder as suggested in PR #1486 review. Co-authored-by: openhands --- .../01_gpt5_apply_patch_preset.py} | 2 +- .../02_gemini_file_tools.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename examples/{01_standalone_sdk/33_gpt5_apply_patch_preset.py => 04_llm_specific_tools/01_gpt5_apply_patch_preset.py} (94%) rename examples/{01_standalone_sdk/34_gemini_file_tools.py => 04_llm_specific_tools/02_gemini_file_tools.py} (100%) diff --git a/examples/01_standalone_sdk/33_gpt5_apply_patch_preset.py b/examples/04_llm_specific_tools/01_gpt5_apply_patch_preset.py similarity index 94% rename from examples/01_standalone_sdk/33_gpt5_apply_patch_preset.py rename to examples/04_llm_specific_tools/01_gpt5_apply_patch_preset.py index c56cc408ae..e0a8957abe 100644 --- a/examples/01_standalone_sdk/33_gpt5_apply_patch_preset.py +++ b/examples/04_llm_specific_tools/01_gpt5_apply_patch_preset.py @@ -10,7 +10,7 @@ # "openai/gpt-5.2-mini" # or fallback: "openai/gpt-5.1-mini" or "openai/gpt-5.1" # ) - uv run python examples/01_standalone_sdk/33_gpt5_apply_patch_preset.py + uv run python examples/04_llm_specific_tools/01_gpt5_apply_patch_preset.py """ import os diff --git a/examples/01_standalone_sdk/34_gemini_file_tools.py b/examples/04_llm_specific_tools/02_gemini_file_tools.py similarity index 100% rename from examples/01_standalone_sdk/34_gemini_file_tools.py rename to examples/04_llm_specific_tools/02_gemini_file_tools.py From 1fe7bea271619f9b091e560f39fd66c06997e703 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Tue, 23 Dec 2025 16:35:02 +0100 Subject: [PATCH 8/9] Update 02_gemini_file_tools.py --- examples/04_llm_specific_tools/02_gemini_file_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/04_llm_specific_tools/02_gemini_file_tools.py b/examples/04_llm_specific_tools/02_gemini_file_tools.py index d7d2a4534e..65274512fb 100644 --- a/examples/04_llm_specific_tools/02_gemini_file_tools.py +++ b/examples/04_llm_specific_tools/02_gemini_file_tools.py @@ -20,8 +20,8 @@ from openhands.tools.terminal import TerminalTool -# Route logs based on whether we're using a proxy (Vertex route) or direct Gemini -_log_dir = "logs/vertex" if os.getenv("LLM_BASE_URL") else "logs/gemini" +# Route logs in their own directory for easy tracing +_log_dir = "logs/gemini" os.makedirs(_log_dir, exist_ok=True) llm = LLM( From 87ae1b6ea79fbce91db03ab01361c8c76ebc8f52 Mon Sep 17 00:00:00 2001 From: enyst Date: Wed, 24 Dec 2025 05:59:38 +0000 Subject: [PATCH 9/9] CI: exclude examples/04_llm_specific_tools from docs example check\n\n- Update check_documented_examples.py to skip 04_llm_specific_tools\n- Update workflow paths filter to ignore that directory\n\nCo-authored-by: openhands --- .github/scripts/check_documented_examples.py | 5 +++++ .github/workflows/check-documented-examples.yml | 1 + 2 files changed, 6 insertions(+) diff --git a/.github/scripts/check_documented_examples.py b/.github/scripts/check_documented_examples.py index 7cc853dbc5..2210b5f76e 100755 --- a/.github/scripts/check_documented_examples.py +++ b/.github/scripts/check_documented_examples.py @@ -81,6 +81,11 @@ def find_agent_sdk_examples(agent_sdk_path: Path) -> set[str]: if relative_path_str.startswith("examples/03_github_workflows/"): continue + # Skip LLM-specific tools examples: these are intentionally not + # enforced by the docs check. See discussion in PR #1486. + if relative_path_str.startswith("examples/04_llm_specific_tools/"): + continue + examples.add(relative_path_str) return examples diff --git a/.github/workflows/check-documented-examples.yml b/.github/workflows/check-documented-examples.yml index 024e46c11f..09c6bef743 100644 --- a/.github/workflows/check-documented-examples.yml +++ b/.github/workflows/check-documented-examples.yml @@ -8,6 +8,7 @@ on: paths: - examples/**/*.py - '!examples/03_github_workflows/**' + - '!examples/04_llm_specific_tools/**' - .github/workflows/check-documented-examples.yml - .github/scripts/check_documented_examples.py workflow_dispatch: