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
8 changes: 8 additions & 0 deletions openhands/agenthub/opencode_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
from openhands.agenthub.opencode_agent.opencode_agent import OpenCodeAgent
from openhands.agenthub.opencode_agent.subagents.explore import (
OpenCodeExploreSubAgent,
)
from openhands.agenthub.opencode_agent.subagents.general import (
OpenCodeGeneralSubAgent,
)
from openhands.controller.agent import Agent

Agent.register('OpenCodeAgent', OpenCodeAgent)
Agent.register('OpenCodeGeneralSubAgent', OpenCodeGeneralSubAgent)
Agent.register('OpenCodeExploreSubAgent', OpenCodeExploreSubAgent)
28 changes: 28 additions & 0 deletions openhands/agenthub/opencode_agent/function_calling.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from openhands.agenthub.opencode_agent.tools.list_dir import ListDirTool
from openhands.agenthub.opencode_agent.tools.question import QUESTION_TOOL_NAME
from openhands.agenthub.opencode_agent.tools.read import ReadTool
from openhands.agenthub.opencode_agent.tools.task import SUBAGENT_NAME_MAP, TASK_TOOL_NAME
from openhands.agenthub.opencode_agent.tools.think import ThinkTool
from openhands.agenthub.opencode_agent.tools.todo import (
TODO_READ_TOOL_NAME,
Expand All @@ -32,6 +33,7 @@
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
Action,
AgentDelegateAction,
AgentFinishAction,
AgentThinkAction,
ApplyPatchAction,
Expand Down Expand Up @@ -246,6 +248,32 @@ def response_to_actions(
todos=arguments["todos"],
)

# ================================================
# Task (Subagent Delegation)
# ================================================
elif tool_call.function.name == TASK_TOOL_NAME:
if 'prompt' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "prompt" in tool call {tool_call.function.name}'
)
if 'subagent_type' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "subagent_type" in tool call {tool_call.function.name}'
)
agent_name = SUBAGENT_NAME_MAP.get(arguments['subagent_type'])
if not agent_name:
raise FunctionCallValidationError(
f'Unknown subagent_type: {arguments["subagent_type"]}. '
f'Valid types: {", ".join(SUBAGENT_NAME_MAP.keys())}'
)
action = AgentDelegateAction(
agent=agent_name,
inputs={
'task': arguments['prompt'],
'description': arguments.get('description', ''),
},
)

# ================================================
# Think
# ================================================
Expand Down
99 changes: 98 additions & 1 deletion openhands/agenthub/opencode_agent/opencode_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from openhands.agenthub.opencode_agent.tools.list_dir import ListDirTool
from openhands.agenthub.opencode_agent.tools.question import QuestionTool
from openhands.agenthub.opencode_agent.tools.read import ReadTool
from openhands.agenthub.opencode_agent.tools.task import TaskTool
from openhands.agenthub.opencode_agent.tools.think import ThinkTool
from openhands.agenthub.opencode_agent.tools.todo import TodoReadTool, TodoWriteTool
from openhands.agenthub.opencode_agent.tools.write import WriteTool
Expand All @@ -29,8 +30,9 @@
from openhands.core.config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import Message
from openhands.events.action import AgentFinishAction, MessageAction
from openhands.events.action import AgentDelegateAction, AgentFinishAction, MessageAction
from openhands.events.event import Event
from openhands.events.observation.delegate import AgentDelegateObservation
from openhands.llm.llm_utils import check_tools
from openhands.memory.condenser import Condenser
from openhands.memory.condenser.condenser import Condensation, View
Expand Down Expand Up @@ -135,6 +137,9 @@ def _get_tools(self) -> list["ChatCompletionToolParam"]:
tools.append(TodoReadTool)
tools.append(TodoWriteTool)

# Subagent delegation
tools.append(TaskTool)

# Structured editing
# tools.append(ApplyPatchTool)

Expand All @@ -161,6 +166,11 @@ def step(self, state: State) -> "Action":

This includes gathering info on previous steps and prompting the model to make a command to execute.

When the LLM returns multiple task (subagent) tool calls and NO other tool
calls, they are executed in parallel via ``ParallelSubagentRunner``. The
results are injected as synthetic events into state.history and the LLM is
re-called so the agent sees all subagent outputs at once.

Parameters:
- state (State): used to get updated info

Expand Down Expand Up @@ -204,10 +214,97 @@ def step(self, state: State) -> "Action":
logger.debug(f"Response from LLM: {response}")
actions = self.response_to_actions(response)
logger.debug(f"Actions after response_to_actions: {actions}")

# --- Parallel subagent execution ---
# When ALL actions in the batch are AgentDelegateAction (task tool calls)
# and there are ≥2, run them concurrently instead of sequentially.
delegate_actions = [a for a in actions if isinstance(a, AgentDelegateAction)]
if (
len(delegate_actions) >= 2
and len(delegate_actions) == len(actions)
and os.environ.get('OPENHANDS_RUNTIME_URL')
):
return self._handle_parallel_subagents(
delegate_actions, state, response, messages, params
)

for action in actions:
self.pending_actions.append(action)
return self.pending_actions.popleft()

def _handle_parallel_subagents(
self,
delegate_actions: list[AgentDelegateAction],
state: State,
original_response: "ModelResponse",
messages: list[Message],
params: dict,
) -> "Action":
"""Execute multiple subagent task calls in parallel, inject results, re-call LLM.

This method mirrors how opencode handles concurrent task tool calls:
all tasks run simultaneously, their results are returned as tool results,
and the LLM generates its next response based on the combined outputs.
"""
from openhands.agenthub.opencode_agent.subagents.parallel_runner import (
ParallelSubagentRunner,
)

logger.info(
f'Parallel subagent execution: {len(delegate_actions)} task calls detected'
)

runner = ParallelSubagentRunner(
llm_registry=self.llm_registry,
parent_agent_config=self.config,
agent_configs={},
)

results = runner.run_parallel(delegate_actions)

# Inject synthetic events into state.history so the conversation memory
# picks them up correctly on subsequent calls to _get_messages().
base_id = 10_000_000 + len(state.history) * 100
for i, delegate_action in enumerate(delegate_actions):
tc_id = delegate_action.tool_call_metadata.tool_call_id
result = results.get(tc_id, {'content': 'No result', 'outputs': {}})

synth_action = AgentDelegateAction(
agent=delegate_action.agent,
inputs=delegate_action.inputs,
)
synth_action._id = base_id + i * 2
synth_action.tool_call_metadata = delegate_action.tool_call_metadata

synth_obs = AgentDelegateObservation(
outputs=result.get('outputs', {}),
content=result.get('content', ''),
)
synth_obs._id = base_id + i * 2 + 1
synth_obs._cause = synth_action._id
synth_obs.tool_call_metadata = delegate_action.tool_call_metadata

state.history.append(synth_action)
state.history.append(synth_obs)

# Re-build messages including the synthetic events and re-call LLM
condensed_history_new: list[Event] = []
match self.condenser.condensed_history(state):
case View(events=events):
condensed_history_new = events
case Condensation(action=condensation_action):
return condensation_action

initial_user_message = self._get_initial_user_message(state.history)
new_messages = self._get_messages(condensed_history_new, initial_user_message)
params["messages"] = new_messages
new_response = self.llm.completion(**params)
new_actions = self.response_to_actions(new_response)

for action in new_actions:
self.pending_actions.append(action)
return self.pending_actions.popleft()

def _get_initial_user_message(self, history: list[Event]) -> MessageAction:
"""Finds the initial user message action from the full history."""
initial_user_message: MessageAction | None = None
Expand Down
19 changes: 19 additions & 0 deletions openhands/agenthub/opencode_agent/subagents/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""OpenCode subagents for task delegation."""

from openhands.agenthub.opencode_agent.subagents.explore import (
OpenCodeExploreSubAgent,
)
from openhands.agenthub.opencode_agent.subagents.general import (
OpenCodeGeneralSubAgent,
)
from openhands.agenthub.opencode_agent.subagents.mixin import SubagentMixin
from openhands.agenthub.opencode_agent.subagents.parallel_runner import (
ParallelSubagentRunner,
)

__all__ = [
'SubagentMixin',
'OpenCodeGeneralSubAgent',
'OpenCodeExploreSubAgent',
'ParallelSubagentRunner',
]
82 changes: 82 additions & 0 deletions openhands/agenthub/opencode_agent/subagents/explore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Explore subagent for fast, read-only codebase exploration.

Mirrors opencode's "explore" subagent: restricted to read-only tools
(grep, glob, list, bash, read). No write, edit, todo, or task tools.
"""

from __future__ import annotations

import os
from typing import TYPE_CHECKING

from openhands.agenthub.opencode_agent.opencode_agent import OpenCodeAgent
from openhands.agenthub.opencode_agent.subagents.mixin import SubagentMixin
from openhands.agenthub.opencode_agent.tools.bash import create_cmd_run_tool
from openhands.agenthub.opencode_agent.tools.finish import FinishTool
from openhands.agenthub.opencode_agent.tools.glob import GlobTool
from openhands.agenthub.opencode_agent.tools.grep import GrepTool
from openhands.agenthub.opencode_agent.tools.list_dir import ListDirTool
from openhands.agenthub.opencode_agent.tools.read import ReadTool
from openhands.agenthub.opencode_agent.tools.think import ThinkTool
from openhands.controller.state.state import State
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import AgentFinishAction
from openhands.utils.prompt import PromptManager

if TYPE_CHECKING:
from litellm import ChatCompletionToolParam

from openhands.events.action import Action


class OpenCodeExploreSubAgent(SubagentMixin, OpenCodeAgent):
"""Read-only exploration subagent.

Matches opencode's explore subagent permissions:
- Read-only tools: read, glob, grep, list_dir
- Bash (for read-only commands like git log, find, etc.)
- Think and finish
- NO write, edit, todo_read, todo_write, or task
"""

VERSION = '1.0'

@property
def prompt_manager(self) -> PromptManager:
if self._prompt_manager is None:
prompt_dir = os.path.join(os.path.dirname(__file__), 'prompts')
self._prompt_manager = PromptManager(
prompt_dir=prompt_dir,
system_prompt_filename='explore_system_prompt.j2',
)
return self._prompt_manager

def _get_tools(self) -> list['ChatCompletionToolParam']:
tools: list['ChatCompletionToolParam'] = []

tools.append(ReadTool)
tools.append(GlobTool)
tools.append(GrepTool)
tools.append(ListDirTool)
tools.append(ThinkTool)

if self.config.enable_cmd:
use_short_desc = any(
substr in self.llm.config.model
for substr in ['gpt-4', 'o3', 'o1', 'o4']
)
tools.append(create_cmd_run_tool(use_short_description=use_short_desc))

tools.append(FinishTool)
return tools

def step(self, state: State) -> 'Action':
action = super().step(state)

if isinstance(action, AgentFinishAction):
action = self._enrich_finish_action(action, state)
logger.debug(
f'ExploreSubAgent finishing with {len(action.outputs.get("metadata", {}).get("summary", []))} tool executions'
)

return action
87 changes: 87 additions & 0 deletions openhands/agenthub/opencode_agent/subagents/general.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""General-purpose subagent for complex multi-step tasks.

Mirrors opencode's "general" subagent: has all tools except todo_read, todo_write,
and task (no recursive subagent spawning).
"""

from __future__ import annotations

import os
from typing import TYPE_CHECKING

from openhands.agenthub.opencode_agent.opencode_agent import OpenCodeAgent
from openhands.agenthub.opencode_agent.subagents.mixin import SubagentMixin
from openhands.agenthub.opencode_agent.tools.bash import create_cmd_run_tool
from openhands.agenthub.opencode_agent.tools.edit import EditTool
from openhands.agenthub.opencode_agent.tools.finish import FinishTool
from openhands.agenthub.opencode_agent.tools.glob import GlobTool
from openhands.agenthub.opencode_agent.tools.grep import GrepTool
from openhands.agenthub.opencode_agent.tools.list_dir import ListDirTool
from openhands.agenthub.opencode_agent.tools.read import ReadTool
from openhands.agenthub.opencode_agent.tools.think import ThinkTool
from openhands.agenthub.opencode_agent.tools.write import WriteTool
from openhands.controller.state.state import State
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import AgentFinishAction
from openhands.utils.prompt import PromptManager

if TYPE_CHECKING:
from litellm import ChatCompletionToolParam

from openhands.events.action import Action


class OpenCodeGeneralSubAgent(SubagentMixin, OpenCodeAgent):
"""General-purpose subagent with full tools except todo and task.

Matches opencode's general subagent permissions:
- All file operations (read, write, edit)
- All search tools (glob, grep, list_dir)
- Bash execution
- Think and finish
- NO todo_read, todo_write, or task (no recursion)
"""

VERSION = '1.0'

@property
def prompt_manager(self) -> PromptManager:
if self._prompt_manager is None:
prompt_dir = os.path.join(os.path.dirname(__file__), 'prompts')
self._prompt_manager = PromptManager(
prompt_dir=prompt_dir,
system_prompt_filename='general_system_prompt.j2',
)
return self._prompt_manager

def _get_tools(self) -> list['ChatCompletionToolParam']:
tools: list['ChatCompletionToolParam'] = []

tools.append(ReadTool)
tools.append(WriteTool)
tools.append(EditTool)
tools.append(GlobTool)
tools.append(GrepTool)
tools.append(ListDirTool)
tools.append(ThinkTool)

if self.config.enable_cmd:
use_short_desc = any(
substr in self.llm.config.model
for substr in ['gpt-4', 'o3', 'o1', 'o4']
)
tools.append(create_cmd_run_tool(use_short_description=use_short_desc))

tools.append(FinishTool)
return tools

def step(self, state: State) -> 'Action':
action = super().step(state)

if isinstance(action, AgentFinishAction):
action = self._enrich_finish_action(action, state)
logger.debug(
f'GeneralSubAgent finishing with {len(action.outputs.get("metadata", {}).get("summary", []))} tool executions'
)

return action
Loading