Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions openhands/agenthub/codeact_agent/codeact_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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:
Expand Down
50 changes: 49 additions & 1 deletion openhands/agenthub/codeact_agent/function_calling.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
AgentFinishAction,
AgentThinkAction,
ApplyPatchAction,
StageHunkAction,
BrowseInteractiveAction,
CmdRunAction,
FileEditAction,
Expand All @@ -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'):
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions openhands/agenthub/codeact_agent/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,6 +13,7 @@
'BrowserTool',
'CondensationRequestTool',
'create_apply_patch_tool',
'create_stage_hunk_tool',
'create_cmd_run_tool',
'FinishTool',
'IPythonTool',
Expand Down
77 changes: 77 additions & 0 deletions openhands/agenthub/codeact_agent/tools/stage_hunk.py
Original file line number Diff line number Diff line change
@@ -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"],
},
),
)

2 changes: 2 additions & 0 deletions openhands/controller/agent_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
ChangeAgentStateAction,
CmdRunAction,
ApplyPatchAction,
StageHunkAction,
FileEditAction,
FileReadAction,
IPythonRunCellAction,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions openhands/core/schema/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down
3 changes: 3 additions & 0 deletions openhands/core/schema/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
3 changes: 3 additions & 0 deletions openhands/events/action/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -35,6 +36,8 @@
'FileWriteAction',
'FileEditAction',
'ApplyPatchAction',
'StageHunkSelection',
'StageHunkAction',
'AgentFinishAction',
'AgentRejectAction',
'AgentDelegateAction',
Expand Down
33 changes: 33 additions & 0 deletions openhands/events/action/stage_hunk.py
Original file line number Diff line number Diff line change
@@ -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}"
)

2 changes: 2 additions & 0 deletions openhands/events/observation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,6 +44,7 @@
'FileEditObservation',
'ErrorObservation',
'ApplyPatchObservation',
'StageHunkObservation',
'AgentStateChangedObservation',
'AgentDelegateObservation',
'SuccessObservation',
Expand Down
33 changes: 33 additions & 0 deletions openhands/events/observation/stage_hunk.py
Original file line number Diff line number Diff line change
@@ -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}'
)

2 changes: 2 additions & 0 deletions openhands/events/serialization/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -39,6 +40,7 @@
FileWriteAction,
FileEditAction,
ApplyPatchAction,
StageHunkAction,
AgentThinkAction,
AgentFinishAction,
AgentRejectAction,
Expand Down
2 changes: 2 additions & 0 deletions openhands/events/serialization/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,6 +45,7 @@
FileEditObservation,
AgentDelegateObservation,
ApplyPatchObservation,
StageHunkObservation,
SuccessObservation,
ErrorObservation,
AgentStateChangedObservation,
Expand Down
1 change: 1 addition & 0 deletions openhands/llm/tool_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions openhands/memory/conversation_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
AgentFinishAction,
AgentThinkAction,
ApplyPatchAction,
StageHunkAction,
BrowseInteractiveAction,
BrowseURLAction,
CmdRunAction,
Expand All @@ -36,6 +37,7 @@
IPythonRunCellObservation,
LoopDetectionObservation,
ApplyPatchObservation,
StageHunkObservation,
TaskTrackingObservation,
UserRejectObservation,
)
Expand Down Expand Up @@ -229,6 +231,7 @@ def _process_action(
AgentDelegateAction,
AgentThinkAction,
ApplyPatchAction,
StageHunkAction,
IPythonRunCellAction,
FileEditAction,
FileReadAction,
Expand Down
Loading
Loading