diff --git a/openhands/agenthub/codeact_agent/codeact_agent.py b/openhands/agenthub/codeact_agent/codeact_agent.py index ca397857ff2f..90e1cd949387 100644 --- a/openhands/agenthub/codeact_agent/codeact_agent.py +++ b/openhands/agenthub/codeact_agent/codeact_agent.py @@ -23,6 +23,9 @@ from openhands.agenthub.codeact_agent.tools.apply_patch import ( create_apply_patch_tool, ) +from openhands.agenthub.codeact_agent.tools.stage_hunk import ( + create_stage_hunk_tool, +) from openhands.agenthub.codeact_agent.tools.task_tracker import ( create_task_tracker_tool, ) @@ -146,8 +149,10 @@ def _get_tools(self) -> list['ChatCompletionToolParam']: tools.append(create_view_file_tool()) if self.config.enable_llm_editor: tools.append(LLMBasedFileEditTool) + tools.append(create_stage_hunk_tool()) elif self.config.enable_editor: tools.append(create_apply_patch_tool()) + tools.append(create_stage_hunk_tool()) return tools def reset(self) -> None: diff --git a/openhands/agenthub/codeact_agent/function_calling.py b/openhands/agenthub/codeact_agent/function_calling.py index 48a371a1eb60..e07fd9a45781 100644 --- a/openhands/agenthub/codeact_agent/function_calling.py +++ b/openhands/agenthub/codeact_agent/function_calling.py @@ -32,6 +32,7 @@ AgentFinishAction, AgentThinkAction, ApplyPatchAction, + StageHunkAction, BrowseInteractiveAction, CmdRunAction, FileEditAction, @@ -42,9 +43,14 @@ ) from openhands.events.action.agent import CondensationRequestAction from openhands.events.action.mcp import MCPAction +from openhands.events.action.stage_hunk import StageHunkSelection from openhands.events.event import FileEditSource, FileReadSource from openhands.events.tool import ToolCallMetadata -from openhands.llm.tool_names import APPLY_PATCH_TOOL_NAME, TASK_TRACKER_TOOL_NAME +from openhands.llm.tool_names import ( + APPLY_PATCH_TOOL_NAME, + STAGE_HUNK_TOOL_NAME, + TASK_TRACKER_TOOL_NAME, +) def combine_thought(action: Action, thought: str) -> Action: if not hasattr(action, 'thought'): @@ -177,6 +183,48 @@ def response_to_actions( ) action = ApplyPatchAction(patch=arguments['patch'].rstrip('\n')) set_security_risk(action, arguments) + elif tool_call.function.name == STAGE_HUNK_TOOL_NAME: + selections = None + if 'selections' in arguments: + if not isinstance(arguments['selections'], list): + raise FunctionCallValidationError( + 'The "selections" argument must be an array of selection objects' + ) + selections = [] + for selection in arguments['selections']: + if not isinstance(selection, dict): + raise FunctionCallValidationError( + 'Each stage_hunk selection must be an object' + ) + if 'file' not in selection or 'hunk_id' not in selection: + raise FunctionCallValidationError( + 'Each stage_hunk selection must include "file" and "hunk_id"' + ) + + include_lines = selection.get('include_lines') + if include_lines is not None and not isinstance( + include_lines, list + ): + raise FunctionCallValidationError( + 'If provided, "include_lines" must be an array of integers' + ) + + selections.append( + StageHunkSelection( + file=selection['file'], + hunk_id=selection['hunk_id'], + include_lines=include_lines, + ) + ) + + reset_index = arguments.get('reset_index', False) + if isinstance(reset_index, str): + reset_index = reset_index.lower() == 'true' + + action = StageHunkAction( + reset_index=bool(reset_index), selections=selections + ) + set_security_risk(action, arguments) elif tool_call.function.name == create_view_file_tool()['function']['name']: if 'path' not in arguments: raise FunctionCallValidationError( diff --git a/openhands/agenthub/codeact_agent/tools/__init__.py b/openhands/agenthub/codeact_agent/tools/__init__.py index ea21fcde0e85..b310ffd7d1a2 100644 --- a/openhands/agenthub/codeact_agent/tools/__init__.py +++ b/openhands/agenthub/codeact_agent/tools/__init__.py @@ -1,4 +1,5 @@ from .apply_patch import create_apply_patch_tool +from .stage_hunk import create_stage_hunk_tool from .bash import create_cmd_run_tool from .browser import BrowserTool from .condensation_request import CondensationRequestTool @@ -12,6 +13,7 @@ 'BrowserTool', 'CondensationRequestTool', 'create_apply_patch_tool', + 'create_stage_hunk_tool', 'create_cmd_run_tool', 'FinishTool', 'IPythonTool', diff --git a/openhands/agenthub/codeact_agent/tools/stage_hunk.py b/openhands/agenthub/codeact_agent/tools/stage_hunk.py new file mode 100644 index 000000000000..750e0be7a824 --- /dev/null +++ b/openhands/agenthub/codeact_agent/tools/stage_hunk.py @@ -0,0 +1,77 @@ +from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk + +from openhands.agenthub.codeact_agent.tools.security_utils import ( + RISK_LEVELS, + SECURITY_RISK_DESC, +) +from openhands.llm.tool_names import STAGE_HUNK_TOOL_NAME + +_STAGE_HUNK_DESCRIPTION = """Interactively stage code changes in the git index without running shell commands. + +Usage pattern: +- First call this tool to receive the list of available hunks and line-level options. +- Call the tool again with a "selections" payload that references the provided hunk IDs and line numbers. +- Set "reset_index" to true when you need to clear the index before staging. + +Notes: +- Behaves like `git add -p`, allowing you to stage entire hunks or individual lines. +- Paths must be absolute (starting with /) when specifying files in selections. +- If there are no unstaged changes, the tool will respond with an empty list of available hunks. +""" + + +def create_stage_hunk_tool() -> ChatCompletionToolParam: + return ChatCompletionToolParam( + type="function", + function=ChatCompletionToolParamFunctionChunk( + name=STAGE_HUNK_TOOL_NAME, + description=_STAGE_HUNK_DESCRIPTION, + parameters={ + "type": "object", + "properties": { + "reset_index": { + "type": "boolean", + "description": "If true, run `git reset` before staging selections.", + "default": False, + }, + "selections": { + "type": "array", + "description": ( + "List of staging selections. Each entry references a hunk ID returned by" + " a previous call and may optionally limit staging to specific line numbers" + " within that hunk." + ), + "items": { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "Absolute path to the file that owns the hunk.", + }, + "hunk_id": { + "type": "string", + "description": "Identifier of the hunk to stage, provided by the tool response.", + }, + "include_lines": { + "type": "array", + "description": ( + "Optional 1-based line indexes within the hunk to stage." + " If omitted, the entire hunk is staged." + ), + "items": {"type": "integer", "minimum": 1}, + }, + }, + "required": ["file", "hunk_id"], + }, + }, + "security_risk": { + "type": "string", + "description": SECURITY_RISK_DESC, + "enum": RISK_LEVELS, + }, + }, + "required": ["security_risk"], + }, + ), + ) + diff --git a/openhands/controller/agent_controller.py b/openhands/controller/agent_controller.py index 8ab2fe52a02e..eca8a01e2085 100644 --- a/openhands/controller/agent_controller.py +++ b/openhands/controller/agent_controller.py @@ -63,6 +63,7 @@ ChangeAgentStateAction, CmdRunAction, ApplyPatchAction, + StageHunkAction, FileEditAction, FileReadAction, IPythonRunCellAction, @@ -985,6 +986,7 @@ async def _step(self) -> None: or type(action) is BrowseInteractiveAction or type(action) is FileEditAction or type(action) is ApplyPatchAction + or type(action) is StageHunkAction or type(action) is FileReadAction ): # Handle security risk analysis using the dedicated method diff --git a/openhands/core/schema/action.py b/openhands/core/schema/action.py index e16b6bf4c359..613a4d9d7663 100644 --- a/openhands/core/schema/action.py +++ b/openhands/core/schema/action.py @@ -30,6 +30,10 @@ class ActionType(str, Enum): """Applies a codex-style patch across one or more files. """ + STAGE_HUNK = 'stage_hunk' + """Stages selected hunks or lines into the git index. + """ + RUN = 'run' """Runs a command. """ diff --git a/openhands/core/schema/observation.py b/openhands/core/schema/observation.py index da245da04e41..2fce6d9077c3 100644 --- a/openhands/core/schema/observation.py +++ b/openhands/core/schema/observation.py @@ -62,5 +62,8 @@ class ObservationType(str, Enum): APPLY_PATCH = 'apply_patch' """Result of an apply_patch operation""" + STAGE_HUNK = 'stage_hunk' + """Result of a stage_hunk operation""" + LOOP_DETECTION = 'loop_detection' """Results of a dead-loop detection""" diff --git a/openhands/events/action/__init__.py b/openhands/events/action/__init__.py index e31dda3afbb7..026b3cf04080 100644 --- a/openhands/events/action/__init__.py +++ b/openhands/events/action/__init__.py @@ -22,6 +22,7 @@ FileWriteAction, ) from openhands.events.action.apply_patch import ApplyPatchAction +from openhands.events.action.stage_hunk import StageHunkAction, StageHunkSelection from openhands.events.action.mcp import MCPAction from openhands.events.action.message import MessageAction, SystemMessageAction @@ -35,6 +36,8 @@ 'FileWriteAction', 'FileEditAction', 'ApplyPatchAction', + 'StageHunkSelection', + 'StageHunkAction', 'AgentFinishAction', 'AgentRejectAction', 'AgentDelegateAction', diff --git a/openhands/events/action/stage_hunk.py b/openhands/events/action/stage_hunk.py new file mode 100644 index 000000000000..eb69ddda9e7d --- /dev/null +++ b/openhands/events/action/stage_hunk.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from typing import ClassVar + +from openhands.core.schema import ActionType +from openhands.events.action.action import Action, ActionSecurityRisk + + +@dataclass +class StageHunkSelection: + file: str + hunk_id: str + include_lines: list[int] | None = None + + +@dataclass +class StageHunkAction(Action): + """Stage hunks or specific lines into the git index without running bash commands.""" + + reset_index: bool = False + selections: list[StageHunkSelection] | None = None + thought: str = '' + action: str = ActionType.STAGE_HUNK + runnable: ClassVar[bool] = True + security_risk: ActionSecurityRisk = ActionSecurityRisk.UNKNOWN + + def __repr__(self) -> str: + selection_count = len(self.selections or []) + return ( + "**StageHunkAction**\n" + f"Reset index: {self.reset_index}\n" + f"Selections: {selection_count}" + ) + diff --git a/openhands/events/observation/__init__.py b/openhands/events/observation/__init__.py index 709e0ea6d234..1bb5089d3fbb 100644 --- a/openhands/events/observation/__init__.py +++ b/openhands/events/observation/__init__.py @@ -26,6 +26,7 @@ from openhands.events.observation.mcp import MCPObservation from openhands.events.observation.observation import Observation from openhands.events.observation.apply_patch import ApplyPatchObservation +from openhands.events.observation.stage_hunk import StageHunkObservation from openhands.events.observation.reject import UserRejectObservation from openhands.events.observation.success import SuccessObservation from openhands.events.observation.task_tracking import TaskTrackingObservation @@ -43,6 +44,7 @@ 'FileEditObservation', 'ErrorObservation', 'ApplyPatchObservation', + 'StageHunkObservation', 'AgentStateChangedObservation', 'AgentDelegateObservation', 'SuccessObservation', diff --git a/openhands/events/observation/stage_hunk.py b/openhands/events/observation/stage_hunk.py new file mode 100644 index 000000000000..818a96c30b30 --- /dev/null +++ b/openhands/events/observation/stage_hunk.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass + +from openhands.core.schema import ObservationType +from openhands.events.observation.observation import Observation + + +@dataclass +class StageHunkObservation(Observation): + """Result of a stage_hunk invocation.""" + + staged: list[dict] | None = None + available_hunks: list[dict] | None = None + reset_performed: bool = False + thought: str = '' + observation: str = ObservationType.STAGE_HUNK + + @property + def message(self) -> str: + staged_count = len(self.staged or []) + available_count = len(self.available_hunks or []) + reset_note = 'after reset' if self.reset_performed else 'with current index' + return ( + f'Staged {staged_count} selection(s) {reset_note}. ' + f'{available_count} hunks remain available for staging.' + ) + + def __str__(self) -> str: + return ( + f'[stage_hunk] staged={len(self.staged or [])}, ' + f'available={len(self.available_hunks or [])}, ' + f'reset={self.reset_performed}' + ) + diff --git a/openhands/events/serialization/action.py b/openhands/events/serialization/action.py index cde2e948a048..c939cc5e9e64 100644 --- a/openhands/events/serialization/action.py +++ b/openhands/events/serialization/action.py @@ -26,6 +26,7 @@ FileWriteAction, ) from openhands.events.action.apply_patch import ApplyPatchAction +from openhands.events.action.stage_hunk import StageHunkAction from openhands.events.action.mcp import MCPAction from openhands.events.action.message import MessageAction, SystemMessageAction @@ -39,6 +40,7 @@ FileWriteAction, FileEditAction, ApplyPatchAction, + StageHunkAction, AgentThinkAction, AgentFinishAction, AgentRejectAction, diff --git a/openhands/events/serialization/observation.py b/openhands/events/serialization/observation.py index 2f62a9dcf8b4..8178cce38402 100644 --- a/openhands/events/serialization/observation.py +++ b/openhands/events/serialization/observation.py @@ -30,6 +30,7 @@ from openhands.events.observation.mcp import MCPObservation from openhands.events.observation.observation import Observation from openhands.events.observation.apply_patch import ApplyPatchObservation +from openhands.events.observation.stage_hunk import StageHunkObservation from openhands.events.observation.reject import UserRejectObservation from openhands.events.observation.success import SuccessObservation from openhands.events.observation.task_tracking import TaskTrackingObservation @@ -44,6 +45,7 @@ FileEditObservation, AgentDelegateObservation, ApplyPatchObservation, + StageHunkObservation, SuccessObservation, ErrorObservation, AgentStateChangedObservation, diff --git a/openhands/llm/tool_names.py b/openhands/llm/tool_names.py index ed518a49605d..298b2514b9e1 100644 --- a/openhands/llm/tool_names.py +++ b/openhands/llm/tool_names.py @@ -2,6 +2,7 @@ EXECUTE_BASH_TOOL_NAME = 'execute_bash' APPLY_PATCH_TOOL_NAME = 'apply_patch' +STAGE_HUNK_TOOL_NAME = 'stage_hunk' BROWSER_TOOL_NAME = 'browser' FINISH_TOOL_NAME = 'finish' LLM_BASED_EDIT_TOOL_NAME = 'edit_file' diff --git a/openhands/memory/conversation_memory.py b/openhands/memory/conversation_memory.py index cba4fd7960ec..44dc758f5b3b 100644 --- a/openhands/memory/conversation_memory.py +++ b/openhands/memory/conversation_memory.py @@ -12,6 +12,7 @@ AgentFinishAction, AgentThinkAction, ApplyPatchAction, + StageHunkAction, BrowseInteractiveAction, BrowseURLAction, CmdRunAction, @@ -36,6 +37,7 @@ IPythonRunCellObservation, LoopDetectionObservation, ApplyPatchObservation, + StageHunkObservation, TaskTrackingObservation, UserRejectObservation, ) @@ -229,6 +231,7 @@ def _process_action( AgentDelegateAction, AgentThinkAction, ApplyPatchAction, + StageHunkAction, IPythonRunCellAction, FileEditAction, FileReadAction, diff --git a/openhands/runtime/action_execution_server.py b/openhands/runtime/action_execution_server.py index 83aaa4692177..eb931ff2b4d9 100644 --- a/openhands/runtime/action_execution_server.py +++ b/openhands/runtime/action_execution_server.py @@ -43,6 +43,7 @@ BrowseURLAction, CmdRunAction, ApplyPatchAction, + StageHunkAction, FileEditAction, FileReadAction, FileWriteAction, @@ -60,6 +61,13 @@ IPythonRunCellObservation, Observation, ApplyPatchObservation, + StageHunkObservation, +) +from openhands.utils.stage_hunk import ( + StageHunkError, + gather_available_hunks, + serialize_available_hunks, + stage_selected_hunks, ) from openhands.events.serialization import event_from_dict, event_to_dict from openhands.runtime.browser import browse @@ -612,6 +620,35 @@ async def apply_patch(self, action: ApplyPatchAction) -> Observation: return ErrorObservation(error_text) + async def stage_hunk(self, action: StageHunkAction) -> Observation: + repo_root = Path(self._initial_cwd) + try: + reset_performed = False + if action.reset_index: + reset_proc = subprocess.run( + ['git', 'reset'], + cwd=repo_root, + text=True, + capture_output=True, + ) + reset_performed = True + if reset_proc.returncode != 0: + raise StageHunkError( + reset_proc.stderr.strip() or reset_proc.stdout.strip() + ) + + staged, available = stage_selected_hunks( + repo_root, action.selections or [] + ) + return StageHunkObservation( + content='', + staged=staged, + available_hunks=serialize_available_hunks(available, repo_root), + reset_performed=reset_performed, + ) + except StageHunkError as error: + return ErrorObservation(f'Failed to stage changes: {error}') + async def browse(self, action: BrowseURLAction) -> Observation: if self.browser is None: return ErrorObservation( diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index ff620fe1bf8e..b2a00627e5b9 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -31,6 +31,7 @@ BrowseURLAction, CmdRunAction, ApplyPatchAction, + StageHunkAction, FileEditAction, FileReadAction, FileWriteAction, @@ -1136,6 +1137,10 @@ def edit(self, action: FileEditAction) -> Observation: def apply_patch(self, action: ApplyPatchAction) -> Observation: pass + @abstractmethod + def stage_hunk(self, action: StageHunkAction) -> Observation: + pass + @abstractmethod def browse(self, action: BrowseURLAction) -> Observation: pass diff --git a/openhands/runtime/impl/action_execution/action_execution_client.py b/openhands/runtime/impl/action_execution/action_execution_client.py index 1e10dfdec1dd..709e438eb1b5 100644 --- a/openhands/runtime/impl/action_execution/action_execution_client.py +++ b/openhands/runtime/impl/action_execution/action_execution_client.py @@ -26,6 +26,7 @@ BrowseURLAction, CmdRunAction, ApplyPatchAction, + StageHunkAction, FileEditAction, FileReadAction, FileWriteAction, @@ -360,6 +361,9 @@ def edit(self, action: FileEditAction) -> Observation: def apply_patch(self, action: ApplyPatchAction) -> Observation: return self.send_action_for_execution(action) + def stage_hunk(self, action: StageHunkAction) -> Observation: + return self.send_action_for_execution(action) + def browse(self, action: BrowseURLAction) -> Observation: return self.send_action_for_execution(action) diff --git a/openhands/runtime/impl/cli/cli_runtime.py b/openhands/runtime/impl/cli/cli_runtime.py index b46112d0f44f..f86ba1458c94 100644 --- a/openhands/runtime/impl/cli/cli_runtime.py +++ b/openhands/runtime/impl/cli/cli_runtime.py @@ -32,6 +32,7 @@ BrowseURLAction, CmdRunAction, ApplyPatchAction, + StageHunkAction, FileEditAction, FileReadAction, FileWriteAction, @@ -47,6 +48,7 @@ FileWriteObservation, Observation, ApplyPatchObservation, + StageHunkObservation, ) from openhands.integrations.provider import PROVIDER_TOKEN_TYPE from openhands.llm.llm_registry import LLMRegistry @@ -54,6 +56,12 @@ from openhands.runtime.plugins import PluginRequirement from openhands.runtime.runtime_status import RuntimeStatus from openhands.utils import apply_patch as patch_utils +from openhands.utils.stage_hunk import ( + StageHunkError, + gather_available_hunks, + serialize_available_hunks, + stage_selected_hunks, +) if TYPE_CHECKING: from openhands.runtime.utils.windows_bash import WindowsPowershellSession @@ -707,6 +715,38 @@ def apply_patch(self, action: ApplyPatchAction) -> Observation: f'Failed to apply patch ({error.error_type}): {str(error)}' ) + def stage_hunk(self, action: StageHunkAction) -> Observation: + if not self._runtime_initialized: + return ErrorObservation('Runtime not initialized') + + repo_root = Path(self._workspace_path) + try: + reset_performed = False + if action.reset_index: + reset_proc = subprocess.run( + ['git', 'reset'], + cwd=repo_root, + text=True, + capture_output=True, + ) + reset_performed = True + if reset_proc.returncode != 0: + raise StageHunkError( + reset_proc.stderr.strip() or reset_proc.stdout.strip() + ) + + staged, available = stage_selected_hunks( + repo_root, action.selections or [] + ) + return StageHunkObservation( + content='', + staged=staged, + available_hunks=serialize_available_hunks(available, repo_root), + reset_performed=reset_performed, + ) + except StageHunkError as error: + return ErrorObservation(f'Failed to stage changes: {error}') + async def call_tool_mcp(self, action: MCPAction) -> Observation: """Execute an MCP tool action in CLI runtime. diff --git a/openhands/utils/stage_hunk.py b/openhands/utils/stage_hunk.py new file mode 100644 index 000000000000..861f5ea7006e --- /dev/null +++ b/openhands/utils/stage_hunk.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +from dataclasses import dataclass +import re +import subprocess +from pathlib import Path +from typing import Iterable, Sequence + + +class StageHunkError(Exception): + """Errors raised while staging hunks.""" + + +@dataclass +class HunkLine: + index: int + kind: str + content: str + old_lineno: int | None + new_lineno: int | None + + +@dataclass +class FileHunk: + hunk_id: str + header: str + old_start: int + old_count: int + new_start: int + new_count: int + section: str + lines: list[HunkLine] + + +@dataclass +class FileDiff: + path: Path + hunks: list[FileHunk] + + +def _run_git_diff(repo_root: Path) -> str: + proc = subprocess.run( + ['git', 'diff', '--no-color', '--unified=5'], + cwd=repo_root, + text=True, + capture_output=True, + ) + if proc.returncode != 0: + raise StageHunkError(proc.stderr.strip() or 'git diff failed') + return proc.stdout + + +def _parse_diff_output(diff_output: str) -> list[FileDiff]: + diffs: list[FileDiff] = [] + current_diff: FileDiff | None = None + current_hunk: FileHunk | None = None + old_lineno = 0 + new_lineno = 0 + hunk_index = 0 + + hunk_pattern = re.compile(r'^@@ -(?P\d+),(?P\d+) \+(?P\d+),(?P\d+) @@(?P
.*)$') + + for line in diff_output.splitlines(): + if line.startswith('diff --git '): + parts = line.split() + a_path = parts[2][2:] + b_path = parts[3][2:] + path_str = b_path if b_path != '/dev/null' else a_path + current_diff = FileDiff(path=Path(path_str), hunks=[]) + diffs.append(current_diff) + hunk_index = 0 + continue + + if current_diff is None: + continue + + if line.startswith('@@ '): + match = hunk_pattern.match(line) + if not match: + raise StageHunkError(f'Unable to parse hunk header: {line}') + + old_start = int(match.group('old_start')) + old_count = int(match.group('old_count')) + new_start = int(match.group('new_start')) + new_count = int(match.group('new_count')) + section = match.group('section').strip() + + current_hunk = FileHunk( + hunk_id=f'{current_diff.path}::hunk-{hunk_index}', + header=line, + old_start=old_start, + old_count=old_count, + new_start=new_start, + new_count=new_count, + section=section, + lines=[], + ) + current_diff.hunks.append(current_hunk) + old_lineno = old_start + new_lineno = new_start + hunk_index += 1 + continue + + if current_hunk is None: + continue + + if line.startswith(' '): + current_hunk.lines.append( + HunkLine( + index=len(current_hunk.lines) + 1, + kind='context', + content=line[1:], + old_lineno=old_lineno, + new_lineno=new_lineno, + ) + ) + old_lineno += 1 + new_lineno += 1 + elif line.startswith('+'): + current_hunk.lines.append( + HunkLine( + index=len(current_hunk.lines) + 1, + kind='add', + content=line[1:], + old_lineno=None, + new_lineno=new_lineno, + ) + ) + new_lineno += 1 + elif line.startswith('-'): + current_hunk.lines.append( + HunkLine( + index=len(current_hunk.lines) + 1, + kind='del', + content=line[1:], + old_lineno=old_lineno, + new_lineno=None, + ) + ) + old_lineno += 1 + elif line.startswith('\\'): + # Preserve trailing \ No newline annotations as context + current_hunk.lines.append( + HunkLine( + index=len(current_hunk.lines) + 1, + kind='context', + content=line, + old_lineno=None, + new_lineno=None, + ) + ) + + return diffs + + +def gather_available_hunks(repo_root: Path) -> list[FileDiff]: + diff_output = _run_git_diff(repo_root) + return _parse_diff_output(diff_output) + + +def _normalize_selection_path(selection_path: str, repo_root: Path) -> Path: + candidate = Path(selection_path) + resolved = candidate if candidate.is_absolute() else repo_root / candidate + resolved = resolved.resolve() + try: + return resolved.relative_to(repo_root) + except ValueError as exc: # pragma: no cover - safety guard + raise StageHunkError( + f'Selection path {selection_path} is outside of repository root {repo_root}' + ) from exc + + +def _coerce_selections( + selections: Sequence[object], repo_root: Path +) -> list[tuple[Path, str, set[int] | None]]: + normalized: list[tuple[Path, str, set[int] | None]] = [] + for selection in selections: + if isinstance(selection, dict): + file_value = selection.get('file') + hunk_id = selection.get('hunk_id') + include_lines = selection.get('include_lines') + else: + file_value = getattr(selection, 'file', None) + hunk_id = getattr(selection, 'hunk_id', None) + include_lines = getattr(selection, 'include_lines', None) + + if file_value is None or hunk_id is None: + raise StageHunkError('Each selection must include file and hunk_id') + + rel_path = _normalize_selection_path(str(file_value), repo_root) + + if include_lines is None: + normalized.append((rel_path, str(hunk_id), None)) + continue + + if not isinstance(include_lines, Iterable): + raise StageHunkError('include_lines must be an iterable of integers') + + lines_set: set[int] = set() + for item in include_lines: + if not isinstance(item, int): + raise StageHunkError('include_lines must only contain integers') + if item < 1: + raise StageHunkError('include_lines entries must be 1-based positive integers') + lines_set.add(item) + + normalized.append((rel_path, str(hunk_id), lines_set)) + + return normalized + + +def _build_patch_for_file( + file_path: Path, selected_hunks: list[tuple[FileHunk, set[int] | None]] +) -> list[str]: + if not selected_hunks: + return [] + + patch_lines = [ + f'diff --git a/{file_path} b/{file_path}', + f'--- a/{file_path}', + f'+++ b/{file_path}', + ] + + for hunk, include_lines in selected_hunks: + chosen_lines = include_lines or { + line.index for line in hunk.lines if line.kind in {'add', 'del'} + } + context_indexes = {line.index for line in hunk.lines if line.kind == 'context'} + scope_lines = chosen_lines.union(context_indexes) + + filtered_lines: list[tuple[str, str]] = [] + for line in hunk.lines: + if line.kind == 'context' or line.index in chosen_lines: + prefix = ' ' + if line.kind == 'add': + prefix = '+' + elif line.kind == 'del': + prefix = '-' + filtered_lines.append((prefix, line.content)) + + if not filtered_lines: + continue + + old_candidates = [ + line.old_lineno + for line in hunk.lines + if line.kind in {'context', 'del'} and line.index in scope_lines + ] + new_candidates = [ + line.new_lineno + for line in hunk.lines + if line.kind in {'context', 'add'} and line.index in scope_lines + ] + + old_start = next((ln for ln in old_candidates if ln is not None), hunk.old_start) + new_start = next((ln for ln in new_candidates if ln is not None), hunk.new_start) + + old_count = sum(1 for prefix, _ in filtered_lines if prefix in {' ', '-'}) + new_count = sum(1 for prefix, _ in filtered_lines if prefix in {' ', '+'}) + section_suffix = f' {hunk.section}' if hunk.section else '' + + patch_lines.append( + f'@@ -{old_start},{old_count} +{new_start},{new_count} @@{section_suffix}' + ) + patch_lines.extend([f'{prefix}{content}' for prefix, content in filtered_lines]) + + return patch_lines + + +def stage_selected_hunks( + repo_root: Path, selections: Sequence[object] +) -> tuple[list[dict], list[FileDiff]]: + available_hunks = gather_available_hunks(repo_root) + if not selections: + return [], available_hunks + + normalized_selections = _coerce_selections(selections, repo_root) + file_lookup = {diff.path: diff for diff in available_hunks} + + staged: list[dict] = [] + patch_sections: list[str] = [] + selections_by_file: dict[Path, list[tuple[FileHunk, set[int] | None]]] = {} + + for rel_path, hunk_id, include_lines in normalized_selections: + diff = file_lookup.get(rel_path) + if diff is None: + raise StageHunkError(f'File {rel_path} has no unstaged changes') + + hunk = next((h for h in diff.hunks if h.hunk_id == hunk_id), None) + if hunk is None: + raise StageHunkError( + f'Hunk {hunk_id} for {rel_path} not found in unstaged changes' + ) + + selections_by_file.setdefault(rel_path, []).append((hunk, include_lines)) + staged.append( + { + 'file': str(rel_path), + 'hunk_id': hunk_id, + 'include_lines': sorted(include_lines) if include_lines else None, + } + ) + + for file_path, file_hunks in selections_by_file.items(): + patch_sections.extend(_build_patch_for_file(file_path, file_hunks)) + + if not patch_sections: + return staged, gather_available_hunks(repo_root) + + patch_text = '\n'.join(patch_sections) + '\n' + proc = subprocess.run( + ['git', 'apply', '--cached', '--whitespace=nowarn'], + cwd=repo_root, + text=True, + input=patch_text, + capture_output=True, + ) + if proc.returncode != 0: + raise StageHunkError(proc.stderr.strip() or proc.stdout.strip()) + + return staged, gather_available_hunks(repo_root) + + +def serialize_available_hunks( + available: list[FileDiff], repo_root: Path | None = None +) -> list[dict]: + serialized: list[dict] = [] + for diff in available: + file_path = Path(diff.path) + if repo_root is not None: + file_path = (repo_root / file_path).resolve() + serialized.append( + { + 'file': str(file_path), + 'hunks': [ + { + 'hunk_id': hunk.hunk_id, + 'header': hunk.header, + 'section': hunk.section, + 'old_start': hunk.old_start, + 'old_count': hunk.old_count, + 'new_start': hunk.new_start, + 'new_count': hunk.new_count, + 'lines': [ + { + 'index': line.index, + 'type': line.kind, + 'old_lineno': line.old_lineno, + 'new_lineno': line.new_lineno, + 'content': line.content, + } + for line in hunk.lines + ], + } + for hunk in diff.hunks + ], + } + ) + return serialized + diff --git a/tests/unit/agenthub/test_agents.py b/tests/unit/agenthub/test_agents.py index 9d238f090661..5f8a7affe3fd 100644 --- a/tests/unit/agenthub/test_agents.py +++ b/tests/unit/agenthub/test_agents.py @@ -14,6 +14,7 @@ LLMBasedFileEditTool, ThinkTool, create_apply_patch_tool, + create_stage_hunk_tool, create_cmd_run_tool, create_view_file_tool, ) @@ -200,6 +201,20 @@ def test_apply_patch_tool(): ] +def test_stage_hunk_tool(): + stage_hunk_tool = create_stage_hunk_tool() + assert stage_hunk_tool['type'] == 'function' + assert stage_hunk_tool['function']['name'] == 'stage_hunk' + + properties = stage_hunk_tool['function']['parameters']['properties'] + assert 'reset_index' in properties + assert 'selections' in properties + assert 'security_risk' in properties + assert stage_hunk_tool['function']['parameters']['required'] == [ + 'security_risk' + ] + + def test_browser_tool(): assert BrowserTool['type'] == 'function' assert BrowserTool['function']['name'] == 'browser' diff --git a/tests/unit/agenthub/test_function_calling.py b/tests/unit/agenthub/test_function_calling.py index c1a4e7278bb0..4b63b0e3c632 100644 --- a/tests/unit/agenthub/test_function_calling.py +++ b/tests/unit/agenthub/test_function_calling.py @@ -15,6 +15,7 @@ FileEditAction, FileReadAction, IPythonRunCellAction, + StageHunkAction, ) from openhands.events.event import FileEditSource @@ -169,6 +170,30 @@ def test_apply_patch_missing_required(): assert 'Missing required argument "patch"' in str(exc_info.value) +def test_stage_hunk_with_lines(): + """Test stage_hunk action parsing with line selections and reset flag.""" + response = create_mock_response( + 'stage_hunk', + { + 'reset_index': 'true', + 'selections': [ + { + 'file': '/workspace/foo.py', + 'hunk_id': 'foo.py::hunk-0', + 'include_lines': [1, 3], + } + ], + 'security_risk': 'LOW', + }, + ) + actions = response_to_actions(response) + assert len(actions) == 1 + assert isinstance(actions[0], StageHunkAction) + assert actions[0].reset_index is True + assert actions[0].selections is not None + assert actions[0].selections[0].include_lines == [1, 3] + + def test_view_file_valid(): """Test view_file mapping to FileReadAction.""" response = create_mock_response( diff --git a/tests/unit/events/test_action_serialization.py b/tests/unit/events/test_action_serialization.py index 0f15016cec2c..a35298be70c1 100644 --- a/tests/unit/events/test_action_serialization.py +++ b/tests/unit/events/test_action_serialization.py @@ -6,6 +6,7 @@ BrowseURLAction, CmdRunAction, ApplyPatchAction, + StageHunkAction, FileEditAction, FileReadAction, FileWriteAction, @@ -225,6 +226,25 @@ def test_apply_patch_action_serialization_deserialization(): serialization_deserialization(original_action_dict, ApplyPatchAction) +def test_stage_hunk_action_serialization_deserialization(): + original_action_dict = { + 'action': 'stage_hunk', + 'args': { + 'reset_index': True, + 'selections': [ + { + 'file': '/workspace/foo.py', + 'hunk_id': 'foo.py::hunk-0', + 'include_lines': [1, 2], + } + ], + 'thought': '', + 'security_risk': -1, + }, + } + serialization_deserialization(original_action_dict, StageHunkAction) + + def test_file_edit_action_llm_serialization_deserialization(): original_action_dict = { 'action': 'edit',