mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
22 Commits
openhands/
...
feat/gemin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8779073946 | ||
|
|
692c69a675 | ||
|
|
42a83360d6 | ||
|
|
d39a9deefd | ||
|
|
bb995a377f | ||
|
|
a90cf2932b | ||
|
|
b2fcb238a5 | ||
|
|
186ab13e57 | ||
|
|
c3a9705874 | ||
|
|
6f8d560e52 | ||
|
|
5f4bae2838 | ||
|
|
056e5fc344 | ||
|
|
12e1a9ab3f | ||
|
|
a95282716b | ||
|
|
8b3f380dd7 | ||
|
|
01c23ff0f2 | ||
|
|
76605d86bc | ||
|
|
b6c8691a1b | ||
|
|
4ef103eef6 | ||
|
|
a57b7d1c0d | ||
|
|
5756a01ee5 | ||
|
|
f140a3996b |
33
docs/gemini-cli-tools-plan.md
Normal file
33
docs/gemini-cli-tools-plan.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Gemini-CLI-aligned tools for Gemini models
|
||||
|
||||
Goal: For Gemini models in OpenHands, expose only: read_file, write_file, replace. Replace is routed to ACI GeminiEditor (with exact Gemini CLI semantics); fallback to OHEditor.str_replace when ACI is missing.
|
||||
|
||||
Why: Gemini models perform better with Gemini-CLI style tools – stricter replace semantics (expected count, CRLF preservation, explicit errors), concise surface API.
|
||||
|
||||
Scope (Phase 1a)
|
||||
- Agent gating: Only Gemini models see read_file, write_file, replace.
|
||||
- Mapper: function_calling maps Gemini replace args: offset/limit -> start/end already removed; use (path, old_string, new_string, expected_replacements?).
|
||||
- Runtime: action_execution_server routes replace to ACI GeminiEditor; parse CLIResult (path, prev_exist, old_content, new_content) and compute diff for observation. Fallback to OHEditor.str_replace if GeminiEditor is not present in ACI.
|
||||
- ACI: New GeminiEditor implementing `replace` with Gemini-CLI semantics:
|
||||
- Absolute path + workspace boundary enforcement
|
||||
- Error if old==new
|
||||
- If file missing and old=="": create file with new content
|
||||
- If file missing and old!="": error
|
||||
- expected_replacements default 1; enforce exact match count; error on 0 or mismatch
|
||||
- literal replace with EOL normalization for matching; preserve original EOL style when writing (CRLF/CR/LF)
|
||||
- return CLIResult with output or error (for parameter/validation errors)
|
||||
|
||||
Tests
|
||||
- Unit: tests/unit/test_gemini_tools.py (OpenHands) – tool exposure & mapping
|
||||
- Runtime: tests/runtime/test_gemini_editor_integration.py – verifies routing, prev_exist, content, and diff present.
|
||||
- ACI unit: tests/unit/editor/test_gemini_editor.py – edge cases for replace semantics, workspace boundaries, CRLF preservation
|
||||
|
||||
Next (Phase 1b)
|
||||
- Add view/list for directories and range reads
|
||||
- Implement insert and undo_edit with Gemini semantics
|
||||
- Shell/web/search parity alignment where beneficial
|
||||
- Consolidate error surface into model-friendly JSON structures where applicable
|
||||
|
||||
Notes
|
||||
- Keep PR self-contained; undraft after CI green across both repos.
|
||||
- Maintain fallback paths; no behavior change for non-Gemini models.
|
||||
@@ -18,6 +18,11 @@ from openhands.agenthub.codeact_agent.tools.condensation_request import (
|
||||
CondensationRequestTool,
|
||||
)
|
||||
from openhands.agenthub.codeact_agent.tools.finish import FinishTool
|
||||
from openhands.agenthub.codeact_agent.tools.gemini import (
|
||||
create_gemini_read_file_tool,
|
||||
create_gemini_replace_tool,
|
||||
create_gemini_write_file_tool,
|
||||
)
|
||||
from openhands.agenthub.codeact_agent.tools.ipython import IPythonTool
|
||||
from openhands.agenthub.codeact_agent.tools.llm_based_edit import LLMBasedFileEditTool
|
||||
from openhands.agenthub.codeact_agent.tools.str_replace_editor import (
|
||||
@@ -141,11 +146,19 @@ class CodeActAgent(Agent):
|
||||
if self.config.enable_llm_editor:
|
||||
tools.append(LLMBasedFileEditTool)
|
||||
elif self.config.enable_editor:
|
||||
# Gemini models: prefer Gemini-CLI compatible tools and disable str_replace_editor
|
||||
if self.llm and 'gemini' in self.llm.config.model.lower():
|
||||
tools.append(create_gemini_read_file_tool())
|
||||
tools.append(create_gemini_write_file_tool())
|
||||
tools.append(create_gemini_replace_tool())
|
||||
return tools
|
||||
# Default editors for non-Gemini models
|
||||
tools.append(
|
||||
create_str_replace_editor_tool(
|
||||
use_short_description=use_short_tool_desc
|
||||
)
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
def reset(self) -> None:
|
||||
|
||||
@@ -33,6 +33,7 @@ from openhands.events.action import (
|
||||
CmdRunAction,
|
||||
FileEditAction,
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
IPythonRunCellAction,
|
||||
MessageAction,
|
||||
TaskTrackingAction,
|
||||
@@ -41,7 +42,12 @@ from openhands.events.action.agent import CondensationRequestAction
|
||||
from openhands.events.action.mcp import MCPAction
|
||||
from openhands.events.event import FileEditSource, FileReadSource
|
||||
from openhands.events.tool import ToolCallMetadata
|
||||
from openhands.llm.tool_names import TASK_TRACKER_TOOL_NAME
|
||||
from openhands.llm.tool_names import (
|
||||
GEMINI_READ_FILE_TOOL_NAME,
|
||||
GEMINI_REPLACE_TOOL_NAME,
|
||||
GEMINI_WRITE_FILE_TOOL_NAME,
|
||||
TASK_TRACKER_TOOL_NAME,
|
||||
)
|
||||
|
||||
|
||||
def combine_thought(action: Action, thought: str) -> Action:
|
||||
@@ -174,12 +180,9 @@ def response_to_actions(
|
||||
)
|
||||
else:
|
||||
if 'view_range' in other_kwargs:
|
||||
# Remove view_range from other_kwargs since it is not needed for FileEditAction
|
||||
other_kwargs.pop('view_range')
|
||||
|
||||
# Filter out unexpected arguments
|
||||
valid_kwargs = {}
|
||||
# Get valid parameters from the str_replace_editor tool definition
|
||||
str_replace_editor_tool = create_str_replace_editor_tool()
|
||||
valid_params = set(
|
||||
str_replace_editor_tool['function']['parameters'][
|
||||
@@ -200,6 +203,67 @@ def response_to_actions(
|
||||
impl_source=FileEditSource.OH_ACI,
|
||||
**valid_kwargs,
|
||||
)
|
||||
|
||||
# ================================================
|
||||
# Gemini-CLI compatible tools
|
||||
# ================================================
|
||||
elif tool_call.function.name == GEMINI_READ_FILE_TOOL_NAME:
|
||||
if 'path' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "path" in tool call {tool_call.function.name}'
|
||||
)
|
||||
# Translate offset/limit (0-based) to start/end (line-based)
|
||||
offset = arguments.get('offset')
|
||||
limit = arguments.get('limit')
|
||||
if (offset is None) != (limit is None):
|
||||
raise FunctionCallValidationError(
|
||||
'Both offset and limit must be provided together if using line slicing.'
|
||||
)
|
||||
start = 0
|
||||
end = -1
|
||||
if offset is not None and limit is not None:
|
||||
if (
|
||||
not isinstance(offset, int)
|
||||
or not isinstance(limit, int)
|
||||
or offset < 0
|
||||
or limit <= 0
|
||||
):
|
||||
raise FunctionCallValidationError(
|
||||
'offset must be >= 0 and limit must be > 0'
|
||||
)
|
||||
start = offset
|
||||
end = offset + limit
|
||||
action = FileReadAction(
|
||||
path=arguments['path'],
|
||||
start=start,
|
||||
end=end,
|
||||
impl_source=FileReadSource.DEFAULT,
|
||||
)
|
||||
elif tool_call.function.name == GEMINI_WRITE_FILE_TOOL_NAME:
|
||||
if 'file_path' not in arguments or 'content' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required arguments in tool call {tool_call.function.name}; need file_path and content'
|
||||
)
|
||||
action = FileWriteAction(
|
||||
path=arguments['file_path'],
|
||||
content=arguments['content'],
|
||||
start=0,
|
||||
end=-1,
|
||||
)
|
||||
elif tool_call.function.name == GEMINI_REPLACE_TOOL_NAME:
|
||||
if 'file_path' not in arguments or 'new_string' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required arguments in tool call {tool_call.function.name}; need file_path and new_string'
|
||||
)
|
||||
expected = arguments.get('expected_replacements')
|
||||
action = FileEditAction(
|
||||
path=arguments['file_path'],
|
||||
command='replace',
|
||||
old_str=arguments.get('old_string', ''),
|
||||
new_str=arguments['new_string'],
|
||||
impl_source=FileEditSource.OH_ACI,
|
||||
expected_replacements=expected,
|
||||
)
|
||||
# ================================================
|
||||
# AgentThinkAction
|
||||
# ================================================
|
||||
|
||||
@@ -2,6 +2,11 @@ from .bash import create_cmd_run_tool
|
||||
from .browser import BrowserTool
|
||||
from .condensation_request import CondensationRequestTool
|
||||
from .finish import FinishTool
|
||||
from .gemini import (
|
||||
create_gemini_read_file_tool,
|
||||
create_gemini_replace_tool,
|
||||
create_gemini_write_file_tool,
|
||||
)
|
||||
from .ipython import IPythonTool
|
||||
from .llm_based_edit import LLMBasedFileEditTool
|
||||
from .str_replace_editor import create_str_replace_editor_tool
|
||||
@@ -14,6 +19,9 @@ __all__ = [
|
||||
'FinishTool',
|
||||
'IPythonTool',
|
||||
'LLMBasedFileEditTool',
|
||||
'create_gemini_read_file_tool',
|
||||
'create_gemini_write_file_tool',
|
||||
'create_gemini_replace_tool',
|
||||
'create_str_replace_editor_tool',
|
||||
'ThinkTool',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from .read_file import create_gemini_read_file_tool
|
||||
from .replace import create_gemini_replace_tool
|
||||
from .write_file import create_gemini_write_file_tool
|
||||
|
||||
__all__ = [
|
||||
'create_gemini_read_file_tool',
|
||||
'create_gemini_write_file_tool',
|
||||
'create_gemini_replace_tool',
|
||||
]
|
||||
31
openhands/agenthub/codeact_agent/tools/gemini/read_file.py
Normal file
31
openhands/agenthub/codeact_agent/tools/gemini/read_file.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
from openhands.llm.tool_names import GEMINI_READ_FILE_TOOL_NAME
|
||||
|
||||
|
||||
def create_gemini_read_file_tool() -> ChatCompletionToolParam:
|
||||
return ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name=GEMINI_READ_FILE_TOOL_NAME,
|
||||
description='Read and return raw file contents. Use absolute paths inside /workspace. For text files you can optionally provide offset & limit (0-based), otherwise the entire file is returned. Images and PDFs are returned as data URLs.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'path': {
|
||||
'description': 'Absolute path to file, e.g. /workspace/file.py',
|
||||
'type': 'string',
|
||||
},
|
||||
'offset': {
|
||||
'description': 'Optional line offset (0-based) for text files. Requires limit to be set.',
|
||||
'type': 'integer',
|
||||
},
|
||||
'limit': {
|
||||
'description': 'Optional max number of lines to read for text files.',
|
||||
'type': 'integer',
|
||||
},
|
||||
},
|
||||
'required': ['path'],
|
||||
},
|
||||
),
|
||||
)
|
||||
35
openhands/agenthub/codeact_agent/tools/gemini/replace.py
Normal file
35
openhands/agenthub/codeact_agent/tools/gemini/replace.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
from openhands.llm.tool_names import GEMINI_REPLACE_TOOL_NAME
|
||||
|
||||
|
||||
def create_gemini_replace_tool() -> ChatCompletionToolParam:
|
||||
return ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name=GEMINI_REPLACE_TOOL_NAME,
|
||||
description='Replace text in a file (Gemini-CLI compatible). old_string must match exactly and uniquely unless expected_replacements > 1. If old_string is empty and file does not exist, create it with new_string.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'file_path': {
|
||||
'description': 'Absolute path to target file.',
|
||||
'type': 'string',
|
||||
},
|
||||
'old_string': {
|
||||
'description': 'Exact literal text to replace. Include ample context for uniqueness. If empty, creates new file with new_string if file does not exist.',
|
||||
'type': 'string',
|
||||
},
|
||||
'new_string': {
|
||||
'description': 'Replacement text.',
|
||||
'type': 'string',
|
||||
},
|
||||
'expected_replacements': {
|
||||
'description': 'Expected number of replacements. Defaults to 1.',
|
||||
'type': 'integer',
|
||||
},
|
||||
},
|
||||
'required': ['file_path', 'new_string'],
|
||||
},
|
||||
),
|
||||
)
|
||||
27
openhands/agenthub/codeact_agent/tools/gemini/write_file.py
Normal file
27
openhands/agenthub/codeact_agent/tools/gemini/write_file.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
from openhands.llm.tool_names import GEMINI_WRITE_FILE_TOOL_NAME
|
||||
|
||||
|
||||
def create_gemini_write_file_tool() -> ChatCompletionToolParam:
|
||||
return ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name=GEMINI_WRITE_FILE_TOOL_NAME,
|
||||
description='Overwrite or create a file with provided content. Entire-file write. Parent directories are created as needed.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'file_path': {
|
||||
'description': 'Absolute path to file to write, e.g. /workspace/file.py',
|
||||
'type': 'string',
|
||||
},
|
||||
'content': {
|
||||
'description': 'Full content to write (UTF-8).',
|
||||
'type': 'string',
|
||||
},
|
||||
},
|
||||
'required': ['file_path', 'content'],
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -60,7 +60,7 @@ class FileWriteAction(Action):
|
||||
|
||||
@dataclass
|
||||
class FileEditAction(Action):
|
||||
"""Edits a file using various commands including view, create, str_replace, insert, and undo_edit.
|
||||
"""Edits a file using various commands including view, create, str_replace, replace, insert, and undo_edit.
|
||||
|
||||
This class supports two main modes of operation:
|
||||
1. LLM-based editing (impl_source = FileEditSource.LLM_BASED_EDIT)
|
||||
@@ -102,6 +102,8 @@ class FileEditAction(Action):
|
||||
new_str: str | None = None
|
||||
insert_line: int | None = None
|
||||
|
||||
expected_replacements: int | None = None
|
||||
|
||||
# LLM-based editing arguments
|
||||
content: str = ''
|
||||
start: int = 1
|
||||
@@ -126,7 +128,7 @@ class FileEditAction(Action):
|
||||
ret += f'Command: {self.command}\n'
|
||||
if self.command == 'create':
|
||||
ret += f'Created File with Text:\n```\n{self.file_text}\n```\n'
|
||||
elif self.command == 'str_replace':
|
||||
elif self.command in ('str_replace', 'replace'):
|
||||
ret += f'Old String: ```\n{self.old_str}\n```\n'
|
||||
ret += f'New String: ```\n{self.new_str}\n```\n'
|
||||
elif self.command == 'insert':
|
||||
|
||||
@@ -124,6 +124,9 @@ def event_to_dict(event: 'Event') -> dict:
|
||||
# Remove task_completed from serialization when it's None (backward compatibility)
|
||||
if 'task_completed' in props and props['task_completed'] is None:
|
||||
props.pop('task_completed')
|
||||
# Remove expected_replacements when it's None to maintain backward compatibility
|
||||
if 'expected_replacements' in props and props['expected_replacements'] is None:
|
||||
props.pop('expected_replacements')
|
||||
if 'action' in d:
|
||||
d['args'] = props
|
||||
if event.timeout is not None:
|
||||
|
||||
@@ -5,4 +5,7 @@ STR_REPLACE_EDITOR_TOOL_NAME = 'str_replace_editor'
|
||||
BROWSER_TOOL_NAME = 'browser'
|
||||
FINISH_TOOL_NAME = 'finish'
|
||||
LLM_BASED_EDIT_TOOL_NAME = 'edit_file'
|
||||
GEMINI_REPLACE_TOOL_NAME = 'replace'
|
||||
GEMINI_READ_FILE_TOOL_NAME = 'read_file'
|
||||
GEMINI_WRITE_FILE_TOOL_NAME = 'write_file'
|
||||
TASK_TRACKER_TOOL_NAME = 'task_tracker'
|
||||
|
||||
@@ -27,6 +27,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.security import APIKeyHeader
|
||||
from openhands_aci.editor.editor import OHEditor
|
||||
from openhands_aci.editor.exceptions import ToolError
|
||||
from openhands_aci.editor.gemini_editor import GeminiEditor
|
||||
from openhands_aci.editor.results import ToolResult
|
||||
from openhands_aci.utils.diff import get_diff
|
||||
from pydantic import BaseModel
|
||||
@@ -191,6 +192,8 @@ class ActionExecutor:
|
||||
self.lock = asyncio.Lock()
|
||||
self.plugins: dict[str, Plugin] = {}
|
||||
self.file_editor = OHEditor(workspace_root=self._initial_cwd)
|
||||
self.gemini_editor = GeminiEditor(workspace_root=self._initial_cwd)
|
||||
|
||||
self.enable_browser = enable_browser
|
||||
self.browser: BrowserEnv | None = None
|
||||
self.browser_init_task: asyncio.Task | None = None
|
||||
@@ -554,26 +557,52 @@ class ActionExecutor:
|
||||
|
||||
async def edit(self, action: FileEditAction) -> Observation:
|
||||
assert action.impl_source == FileEditSource.OH_ACI
|
||||
result_str, (old_content, new_content) = _execute_file_editor(
|
||||
self.file_editor,
|
||||
command=action.command,
|
||||
path=action.path,
|
||||
file_text=action.file_text,
|
||||
old_str=action.old_str,
|
||||
new_str=action.new_str,
|
||||
insert_line=action.insert_line,
|
||||
enable_linting=False,
|
||||
)
|
||||
if action.command == 'replace':
|
||||
try:
|
||||
result = self.gemini_editor(
|
||||
command='replace',
|
||||
path=action.path,
|
||||
old_str=action.old_str or '',
|
||||
new_str=action.new_str or '',
|
||||
expected_replacements=action.expected_replacements,
|
||||
)
|
||||
result_str, (old_content, new_content) = (
|
||||
result.output,
|
||||
(result.old_content, result.new_content),
|
||||
)
|
||||
except ToolError as e:
|
||||
result_str, (old_content, new_content) = (
|
||||
f'ERROR:\n{e.message}',
|
||||
(None, None),
|
||||
)
|
||||
else:
|
||||
result_str, (old_content, new_content) = _execute_file_editor(
|
||||
self.file_editor,
|
||||
command=action.command,
|
||||
path=action.path,
|
||||
file_text=action.file_text,
|
||||
old_str=action.old_str,
|
||||
new_str=action.new_str,
|
||||
insert_line=action.insert_line,
|
||||
enable_linting=False,
|
||||
)
|
||||
|
||||
# Prefer actual file contents from editor result for observation fields
|
||||
obs_old_content = (
|
||||
old_content if old_content is not None else (action.old_str or '')
|
||||
)
|
||||
obs_new_content = (
|
||||
new_content if new_content is not None else (action.new_str or '')
|
||||
)
|
||||
return FileEditObservation(
|
||||
content=result_str,
|
||||
path=action.path,
|
||||
old_content=action.old_str,
|
||||
new_content=action.new_str,
|
||||
old_content=obs_old_content,
|
||||
new_content=obs_new_content,
|
||||
impl_source=FileEditSource.OH_ACI,
|
||||
diff=get_diff(
|
||||
old_contents=old_content or '',
|
||||
new_contents=new_content or '',
|
||||
old_contents=obs_old_content,
|
||||
new_contents=obs_new_content,
|
||||
filepath=action.path,
|
||||
),
|
||||
)
|
||||
|
||||
65
poetry.lock
generated
65
poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -404,7 +404,7 @@ description = "LTS Port of Python audioop"
|
||||
optional = false
|
||||
python-versions = ">=3.13"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.13\""
|
||||
markers = "python_version == \"3.13\""
|
||||
files = [
|
||||
{file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a"},
|
||||
{file = "audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e"},
|
||||
@@ -2997,8 +2997,8 @@ files = [
|
||||
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
|
||||
proto-plus = [
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
]
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
|
||||
|
||||
@@ -3020,8 +3020,8 @@ googleapis-common-protos = ">=1.56.2,<2.0.0"
|
||||
grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
|
||||
grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
|
||||
proto-plus = [
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
]
|
||||
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
|
||||
requests = ">=2.18.0,<3.0.0"
|
||||
@@ -3239,8 +3239,8 @@ google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
|
||||
grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
|
||||
proto-plus = [
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
]
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
|
||||
|
||||
@@ -6561,47 +6561,51 @@ name = "openhands-aci"
|
||||
version = "0.3.2"
|
||||
description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands."
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.12"
|
||||
python-versions = "^3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_aci-0.3.2-py3-none-any.whl", hash = "sha256:a3ff6fe3dd50124598b8bc3aff8d9742d6e75f933f7e7635a9d0b37d45eb826e"},
|
||||
{file = "openhands_aci-0.3.2.tar.gz", hash = "sha256:df7b64df6acb70b45b23e88c13508e7af8f27725bed30c3e88691a0f3d1f7a44"},
|
||||
]
|
||||
files = []
|
||||
develop = false
|
||||
|
||||
[package.dependencies]
|
||||
beautifulsoup4 = ">=4.12.3"
|
||||
binaryornot = ">=0.4.4,<0.5.0"
|
||||
cachetools = ">=5.5.2,<6.0.0"
|
||||
charset-normalizer = ">=3.4.1,<4.0.0"
|
||||
binaryornot = "^0.4.4"
|
||||
cachetools = "^5.5.2"
|
||||
charset-normalizer = "^3.4.1"
|
||||
flake8 = "*"
|
||||
gitpython = "*"
|
||||
grep-ast = ">=0.9.0,<0.10.0"
|
||||
grep-ast = "^0.9.0"
|
||||
libcst = "1.5.0"
|
||||
mammoth = ">=1.8.0"
|
||||
markdownify = ">=0.13.1"
|
||||
matplotlib = ">=3.10.3,<4.0.0"
|
||||
networkx = ">=3.4.2,<4.0.0"
|
||||
openpyxl = ">=3.1.5,<4.0.0"
|
||||
matplotlib = "^3.10.3"
|
||||
networkx = "^3.4.2"
|
||||
openpyxl = "^3.1.5"
|
||||
pandas = "*"
|
||||
pdfminer-six = ">=20240706"
|
||||
puremagic = ">=1.28"
|
||||
pydantic = ">=2.11.3,<3.0.0"
|
||||
pydub = ">=0.25.1,<0.26.0"
|
||||
pydantic = "^2.11.3"
|
||||
pydub = "^0.25.1"
|
||||
pypdf = ">=5.1.0"
|
||||
pypdf2 = ">=3.0.1,<4.0.0"
|
||||
python-pptx = ">=1.0.2,<2.0.0"
|
||||
rapidfuzz = ">=3.13.0,<4.0.0"
|
||||
pypdf2 = "^3.0.1"
|
||||
python-pptx = "^1.0.2"
|
||||
rapidfuzz = "^3.13.0"
|
||||
requests = ">=2.32.3"
|
||||
speechrecognition = ">=3.14.1,<4.0.0"
|
||||
tree-sitter = ">=0.24.0,<0.25.0"
|
||||
speechrecognition = "^3.14.1"
|
||||
tree-sitter = "^0.24.0"
|
||||
tree-sitter-language-pack = "0.7.3"
|
||||
whatthepatch = ">=1.0.6,<2.0.0"
|
||||
xlrd = ">=2.0.1,<3.0.0"
|
||||
whatthepatch = "^1.0.6"
|
||||
xlrd = "^2.0.1"
|
||||
youtube-transcript-api = ">=0.6.2"
|
||||
|
||||
[package.extras]
|
||||
llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0)", "llama-index-retrievers-bm25 (>=0.5.2,<0.6.0)"]
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/enyst/openhands-aci.git"
|
||||
reference = "feat/gemini-cli-tools-phase1a"
|
||||
resolved_reference = "b016a9c098e3362de29c9dfe2f4de72872da5672"
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
version = "3.1.5"
|
||||
@@ -6663,8 +6667,8 @@ files = [
|
||||
[package.dependencies]
|
||||
googleapis-common-protos = ">=1.52,<2.0"
|
||||
grpcio = [
|
||||
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
|
||||
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
|
||||
]
|
||||
opentelemetry-api = ">=1.15,<2.0"
|
||||
opentelemetry-exporter-otlp-proto-common = "1.34.1"
|
||||
@@ -9438,7 +9442,6 @@ files = [
|
||||
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
|
||||
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
|
||||
]
|
||||
markers = {evaluation = "platform_system == \"Linux\" and platform_machine == \"x86_64\""}
|
||||
|
||||
[package.extras]
|
||||
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""]
|
||||
@@ -9682,7 +9685,7 @@ description = "Standard library aifc redistribution. \"dead battery\"."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.13\""
|
||||
markers = "python_version == \"3.13\""
|
||||
files = [
|
||||
{file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"},
|
||||
{file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"},
|
||||
@@ -9699,7 +9702,7 @@ description = "Standard library chunk redistribution. \"dead battery\"."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.13\""
|
||||
markers = "python_version == \"3.13\""
|
||||
files = [
|
||||
{file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"},
|
||||
{file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"},
|
||||
@@ -11879,4 +11882,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "469b54a3f7f5d104f68503fc70a89c016cbb7d9b7dc019226ed62e93ee928b98"
|
||||
content-hash = "8e680ed5dbcae9fae4ce7e24bb5d161c008468e588c05a5614f70127fdf8b0e1"
|
||||
|
||||
@@ -64,7 +64,7 @@ opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
libtmux = ">=0.37,<0.40"
|
||||
pygithub = "^2.5.0"
|
||||
joblib = "*"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-aci = { git = "https://github.com/enyst/openhands-aci.git", branch = "feat/gemini-cli-tools-phase1a" }
|
||||
python-socketio = "^5.11.4"
|
||||
sse-starlette = "^2.1.3"
|
||||
psutil = "*"
|
||||
|
||||
42
tests/runtime/test_gemini_editor_integration.py
Normal file
42
tests/runtime/test_gemini_editor_integration.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.events.action.files import FileEditAction
|
||||
from openhands.events.event import FileEditSource
|
||||
from openhands.runtime.action_execution_server import ActionExecutor
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_routes_replace_to_gemini_editor(tmp_path):
|
||||
# Setup a minimal runtime with a temp workspace
|
||||
work_dir = str(tmp_path)
|
||||
plugins = []
|
||||
executor = ActionExecutor(
|
||||
plugins_to_load=plugins,
|
||||
work_dir=work_dir,
|
||||
# Use root to avoid useradd in test container environments
|
||||
username='root',
|
||||
user_id=0,
|
||||
enable_browser=False,
|
||||
browsergym_eval_env=None,
|
||||
)
|
||||
await executor.ainit()
|
||||
|
||||
# Create a test file
|
||||
p = Path(work_dir) / 'a.txt'
|
||||
p.write_text('hello\nworld\n')
|
||||
|
||||
action = FileEditAction(
|
||||
path=str(p),
|
||||
command='replace',
|
||||
impl_source=FileEditSource.OH_ACI,
|
||||
old_str='world',
|
||||
new_str='there',
|
||||
)
|
||||
|
||||
obs = await executor.edit(action)
|
||||
# Should produce a content string
|
||||
assert isinstance(obs.content, str)
|
||||
# diff should reflect actual contents
|
||||
assert obs.diff is not None and ('-world' in obs.diff or '+there' in obs.diff)
|
||||
100
tests/unit/test_gemini_tools.py
Normal file
100
tests/unit/test_gemini_tools.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import json
|
||||
|
||||
from litellm import ModelResponse
|
||||
|
||||
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
|
||||
from openhands.agenthub.codeact_agent.function_calling import response_to_actions
|
||||
from openhands.core.config import AgentConfig, LLMConfig
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.events.action import FileEditAction, FileReadAction, FileWriteAction
|
||||
from openhands.events.event import FileEditSource, FileReadSource
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
|
||||
|
||||
def create_mock_response(function_name: str, arguments: dict) -> ModelResponse:
|
||||
return ModelResponse(
|
||||
id='mock-id',
|
||||
choices=[
|
||||
{
|
||||
'message': {
|
||||
'tool_calls': [
|
||||
{
|
||||
'function': {
|
||||
'name': function_name,
|
||||
'arguments': json.dumps(arguments),
|
||||
},
|
||||
'id': 'mock-tool-call-id',
|
||||
'type': 'function',
|
||||
}
|
||||
],
|
||||
'content': None,
|
||||
'role': 'assistant',
|
||||
},
|
||||
'index': 0,
|
||||
'finish_reason': 'tool_calls',
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_gemini_tool_mapping_read_file():
|
||||
resp = create_mock_response(
|
||||
'read_file', {'path': '/abs/path/file.txt', 'offset': 2, 'limit': 5}
|
||||
)
|
||||
actions = response_to_actions(resp)
|
||||
assert len(actions) == 1
|
||||
assert isinstance(actions[0], FileReadAction)
|
||||
assert actions[0].path == '/abs/path/file.txt'
|
||||
# DEFAULT path for Gemini read
|
||||
assert actions[0].impl_source == FileReadSource.DEFAULT
|
||||
# Ensure offset/limit translated to start/end
|
||||
assert actions[0].start == 2
|
||||
assert actions[0].end == 7
|
||||
|
||||
|
||||
def test_gemini_tool_mapping_write_file():
|
||||
resp = create_mock_response(
|
||||
'write_file', {'file_path': '/abs/path/file.txt', 'content': 'data'}
|
||||
)
|
||||
actions = response_to_actions(resp)
|
||||
assert len(actions) == 1
|
||||
assert isinstance(actions[0], FileWriteAction)
|
||||
assert actions[0].path == '/abs/path/file.txt'
|
||||
assert actions[0].content == 'data'
|
||||
|
||||
|
||||
def test_gemini_tool_mapping_replace_defaults_expected_1():
|
||||
resp = create_mock_response(
|
||||
'replace',
|
||||
{
|
||||
'file_path': '/abs/path/file.txt',
|
||||
'old_string': 'a',
|
||||
'new_string': 'b',
|
||||
},
|
||||
)
|
||||
actions = response_to_actions(resp)
|
||||
assert len(actions) == 1
|
||||
a = actions[0]
|
||||
assert isinstance(a, FileEditAction)
|
||||
assert a.path == '/abs/path/file.txt'
|
||||
assert a.command == 'replace'
|
||||
assert a.impl_source == FileEditSource.OH_ACI
|
||||
assert a.old_str == 'a' and a.new_str == 'b'
|
||||
# default expected_replacements remains None here; runtime/editor should default to 1
|
||||
|
||||
|
||||
def test_tool_exposure_gemini_models_excludes_str_replace(monkeypatch):
|
||||
# Build a dummy LLM that looks like a gemini model through registry
|
||||
cfg = AgentConfig()
|
||||
oh_cfg = OpenHandsConfig()
|
||||
oh_cfg.set_llm_config(LLMConfig(model='gemini-2.5-pro'))
|
||||
registry = LLMRegistry(config=oh_cfg)
|
||||
agent = CodeActAgent(config=cfg, llm_registry=registry)
|
||||
|
||||
# Get the tool names exposed
|
||||
tool_names = {t['function']['name'] for t in agent.tools}
|
||||
|
||||
assert 'str_replace_editor' not in tool_names
|
||||
assert 'read_file' in tool_names
|
||||
assert 'write_file' in tool_names
|
||||
assert 'replace' in tool_names
|
||||
Reference in New Issue
Block a user