Skip to content

Commit 4c1e1a1

Browse files
authored
feat(sdk/subagent): add confirmation policy (#2345)
1 parent 208a491 commit 4c1e1a1

File tree

7 files changed

+300
-10
lines changed

7 files changed

+300
-10
lines changed

openhands-sdk/openhands/sdk/subagent/schema.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@
44

55
import re
66
from pathlib import Path
7-
from typing import Any, Final
7+
from typing import TYPE_CHECKING, Any, Final
88

99
import frontmatter
1010
from pydantic import BaseModel, Field
1111

1212
from openhands.sdk.hooks.config import HookConfig
1313

1414

15+
if TYPE_CHECKING:
16+
from openhands.sdk.security.confirmation_policy import ConfirmationPolicyBase
17+
18+
1519
KNOWN_FIELDS: Final[set[str]] = {
1620
"name",
1721
"description",
@@ -22,6 +26,13 @@
2226
"max_iteration_per_run",
2327
"hooks",
2428
"profile_store_dir",
29+
"permission_mode",
30+
}
31+
32+
_VALID_PERMISSION_MODES: Final[set[str]] = {
33+
"always_confirm",
34+
"never_confirm",
35+
"confirm_risky",
2536
}
2637

2738

@@ -79,6 +90,20 @@ def _extract_examples(description: str) -> list[str]:
7990
return [m.strip() for m in matches if m.strip()]
8091

8192

93+
def _extract_permission_mode(fm: dict[str, object]) -> str | None:
94+
"""Extract permission_mode from frontmatter, defaulting to None (inherit parent)."""
95+
raw = fm.get("permission_mode")
96+
if raw is None:
97+
return None
98+
value = str(raw).strip().lower()
99+
if value not in _VALID_PERMISSION_MODES:
100+
raise ValueError(
101+
f"Invalid permission_mode '{raw}'. "
102+
f"Must be one of: {', '.join(sorted(_VALID_PERMISSION_MODES))}"
103+
)
104+
return value
105+
106+
82107
def _extract_max_iteration_per_run(fm: dict[str, object]) -> int | None:
83108
"""Extract max iterations per run from frontmatter file."""
84109
max_iter_raw = fm.get("max_iteration_per_run")
@@ -130,6 +155,13 @@ class AgentDefinition(BaseModel):
130155
hooks: HookConfig | None = Field(
131156
default=None, description="Hook configuration for this agent"
132157
)
158+
permission_mode: str | None = Field(
159+
default=None,
160+
description="How the subagent handles permissions. "
161+
"None inherits the parent policy, 'always_confirm' requires "
162+
"confirmation for every action, 'never_confirm' skips all confirmations, "
163+
"'confirm_risky' only confirms actions above a risk threshold.",
164+
)
133165
max_iteration_per_run: int | None = Field(
134166
default=None,
135167
description="Maximum iterations per run. "
@@ -145,6 +177,34 @@ class AgentDefinition(BaseModel):
145177
default_factory=dict, description="Additional metadata from frontmatter"
146178
)
147179

180+
def get_confirmation_policy(self) -> ConfirmationPolicyBase | None:
181+
"""Convert permission_mode to a ConfirmationPolicyBase instance.
182+
183+
Returns None when permission_mode is None (inherit parent policy).
184+
"""
185+
if self.permission_mode is None:
186+
return None
187+
188+
match self.permission_mode:
189+
case "always_confirm":
190+
from openhands.sdk.security.confirmation_policy import AlwaysConfirm
191+
192+
return AlwaysConfirm()
193+
case "never_confirm":
194+
from openhands.sdk.security.confirmation_policy import NeverConfirm
195+
196+
return NeverConfirm()
197+
case "confirm_risky":
198+
from openhands.sdk.security.confirmation_policy import ConfirmRisky
199+
200+
return ConfirmRisky()
201+
case _:
202+
# Should never reach here due to validation
203+
# in _extract_permission_mode()
204+
raise AssertionError(
205+
f"Unexpected permission_mode: {self.permission_mode}"
206+
)
207+
148208
@classmethod
149209
def load(cls, agent_path: Path) -> AgentDefinition:
150210
"""Load an agent definition from a Markdown file.
@@ -156,6 +216,8 @@ def load(cls, agent_path: Path) -> AgentDefinition:
156216
- skills (optional): Comma-separated skill names or list of skill names
157217
- model (optional): Model profile to use (default: 'inherit')
158218
- color (optional): Display color
219+
- permission_mode (optional): How the subagent handles permissions
220+
('always_confirm', 'never_confirm', 'confirm_risky'). None inherits parent.
159221
- max_iterations_per_run: Max iteration per run
160222
- hooks (optional): List of applicable hooks
161223
@@ -180,6 +242,7 @@ def load(cls, agent_path: Path) -> AgentDefinition:
180242
color: str | None = _extract_color(fm)
181243
tools: list[str] = _extract_tools(fm)
182244
skills: list[str] = _extract_skills(fm)
245+
permission_mode: str | None = _extract_permission_mode(fm)
183246
max_iteration_per_run: int | None = _extract_max_iteration_per_run(fm)
184247
profile_store_dir: str | None = _extract_profile_store_dir(fm)
185248
hooks: HookConfig | None = _extract_hooks(fm)
@@ -197,6 +260,7 @@ def load(cls, agent_path: Path) -> AgentDefinition:
197260
color=color,
198261
tools=tools,
199262
skills=skills,
263+
permission_mode=permission_mode,
200264
max_iteration_per_run=max_iteration_per_run,
201265
hooks=hooks,
202266
profile_store_dir=profile_store_dir,

openhands-tools/openhands/tools/delegate/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
DelegateObservation,
66
DelegateTool,
77
)
8-
from openhands.tools.delegate.impl import DelegateExecutor
8+
from openhands.tools.delegate.impl import ConfirmationHandler, DelegateExecutor
99
from openhands.tools.delegate.visualizer import DelegationVisualizer
1010

1111

1212
__all__ = [
13+
"ConfirmationHandler",
1314
"DelegateAction",
1415
"DelegateObservation",
1516
"DelegateExecutor",

openhands-tools/openhands/tools/delegate/definition.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
if TYPE_CHECKING:
2020
from openhands.sdk.conversation.state import ConversationState
21+
from openhands.tools.delegate.impl import ConfirmationHandler
2122

2223

2324
PROMPT_DIR = pathlib.Path(__file__).parent / "templates"
@@ -67,12 +68,18 @@ def create(
6768
cls,
6869
conv_state: "ConversationState",
6970
max_children: int = 5,
71+
confirmation_handler: "ConfirmationHandler | None" = None,
7072
) -> Sequence["DelegateTool"]:
7173
"""Initialize DelegateTool with a DelegateExecutor.
7274
7375
Args:
7476
conv_state: Conversation state (used to get workspace location)
7577
max_children: Maximum number of concurrent sub-agents (default: 5)
78+
confirmation_handler: Optional callback invoked when a sub-agent's
79+
confirmation policy requires user approval. Receives
80+
`(agent_id, pending_actions)` and must return `True` to
81+
approve or `False` to reject. When `None`, pending actions
82+
are auto-approved.
7683
7784
Returns:
7885
List containing a single delegate tool definition
@@ -95,7 +102,10 @@ def create(
95102

96103
# Initialize the executor without parent conversation
97104
# (will be set on first call)
98-
executor = DelegateExecutor(max_children=max_children)
105+
executor = DelegateExecutor(
106+
max_children=max_children,
107+
confirmation_handler=confirmation_handler,
108+
)
99109

100110
# Initialize the parent Tool with the executor
101111
return [

openhands-tools/openhands/tools/delegate/impl.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
11
"""Implementation of delegate tool executor."""
22

33
import threading
4+
from collections.abc import Callable
45
from typing import TYPE_CHECKING
56

67
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
78
from openhands.sdk.conversation.response_utils import get_agent_final_response
9+
from openhands.sdk.conversation.state import (
10+
ConversationExecutionStatus,
11+
ConversationState,
12+
)
813
from openhands.sdk.logger import get_logger
914
from openhands.sdk.subagent import get_agent_factory
1015
from openhands.sdk.tool.tool import ToolExecutor
1116
from openhands.tools.delegate.definition import DelegateObservation
1217

1318

1419
if TYPE_CHECKING:
20+
from openhands.sdk.event import ActionEvent
1521
from openhands.tools.delegate.definition import DelegateAction
1622

1723
logger = get_logger(__name__)
1824

25+
# Called when a sub-agent hits WAITING_FOR_CONFIRMATION.
26+
# Receives (agent_id, pending_actions) and returns True to approve, False to reject.
27+
ConfirmationHandler = Callable[[str, list["ActionEvent"]], bool]
28+
1929

2030
class DelegateExecutor(ToolExecutor):
2131
"""Executor for delegation operations.
@@ -25,11 +35,16 @@ class DelegateExecutor(ToolExecutor):
2535
- Delegating tasks to sub-agents and waiting for results (blocking)
2636
"""
2737

28-
def __init__(self, max_children: int = 5):
38+
def __init__(
39+
self,
40+
max_children: int = 5,
41+
confirmation_handler: ConfirmationHandler | None = None,
42+
):
2943
self._parent_conversation: LocalConversation | None = None
3044
# Map from user-friendly identifier to conversation
3145
self._sub_agents: dict[str, LocalConversation] = {}
3246
self._max_children: int = max_children
47+
self._confirmation_handler = confirmation_handler
3348

3449
@property
3550
def parent_conversation(self) -> LocalConversation:
@@ -79,6 +94,27 @@ def _resolve_agent_type(self, action: "DelegateAction", index: int) -> str:
7994
return "default"
8095
return action.agent_types[index].strip() or "default"
8196

97+
def _run_until_finished(
98+
self, agent_id: str, conversation: LocalConversation
99+
) -> None:
100+
"""Run a sub-agent conversation to completion, handling confirmations."""
101+
conversation.run()
102+
while (
103+
conversation.state.execution_status
104+
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
105+
):
106+
pending = ConversationState.get_unmatched_actions(conversation.state.events)
107+
if not pending:
108+
break
109+
110+
if self._confirmation_handler is None or self._confirmation_handler(
111+
agent_id, pending
112+
):
113+
conversation.run()
114+
else:
115+
conversation.reject_pending_actions("User rejected the actions")
116+
conversation.run()
117+
82118
def _spawn_agents(self, action: "DelegateAction") -> DelegateObservation:
83119
"""Spawn sub-agents with optional agent types."""
84120
if not action.ids:
@@ -159,6 +195,16 @@ def _spawn_agents(self, action: "DelegateAction") -> DelegateObservation:
159195

160196
sub_conversation = LocalConversation(**conv_kwargs)
161197

198+
# Apply permission_mode: explicit mode from definition,
199+
# or inherit the parent's policy when None.
200+
confirmation_policy = factory.definition.get_confirmation_policy()
201+
if confirmation_policy is None:
202+
sub_conversation.set_confirmation_policy(
203+
parent_conversation.state.confirmation_policy
204+
)
205+
else:
206+
sub_conversation.set_confirmation_policy(confirmation_policy)
207+
162208
self._sub_agents[agent_id] = sub_conversation
163209

164210
# Log what type of agent was created
@@ -242,11 +288,9 @@ def run_task(
242288
"""Run a single task on a sub-agent."""
243289
try:
244290
logger.info(f"Sub-agent {agent_id} starting task: {task[:100]}...")
245-
# Pass raw parent_name - visualizer handles formatting
246291
conversation.send_message(task, sender=parent_name)
247-
conversation.run()
292+
self._run_until_finished(agent_id, conversation)
248293

249-
# Extract the final response using get_agent_final_response
250294
final_response = get_agent_final_response(conversation.state.events)
251295
if final_response:
252296
results[agent_id] = final_response

openhands-tools/openhands/tools/task/definition.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
if TYPE_CHECKING:
3030
from openhands.sdk.conversation.state import ConversationState
3131
from openhands.tools.task.impl import TaskExecutor
32+
from openhands.tools.task.manager import ConfirmationHandler
3233

3334

3435
class TaskAction(Action):
@@ -182,11 +183,16 @@ class TaskToolSet(ToolDefinition[TaskAction, TaskObservation]):
182183
def create(
183184
cls,
184185
conv_state: "ConversationState", # noqa: ARG003
186+
confirmation_handler: "ConfirmationHandler | None" = None,
185187
) -> list[ToolDefinition]:
186188
"""Create the task tool.
187189
188190
Args:
189191
conv_state: Conversation state for workspace info.
192+
confirmation_handler: Optional callback invoked when a sub-agent's
193+
confirmation policy requires user approval. Receives
194+
`(task_id, pending_actions)` and must return `True` to
195+
approve or `False` to reject.
190196
191197
Returns:
192198
List containing a single TaskTool.
@@ -199,7 +205,7 @@ def create(
199205
agent_types_info=agent_types_info
200206
)
201207

202-
manager = TaskManager()
208+
manager = TaskManager(confirmation_handler=confirmation_handler)
203209
task_executor = TaskExecutor(manager=manager)
204210

205211
tools: list[ToolDefinition] = []

0 commit comments

Comments
 (0)