openhands-tools 1.26.0__tar.gz → 1.27.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/PKG-INFO +1 -1
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/__init__.py +2 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/logging_fix.py +1 -1
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task/manager.py +17 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/utils/command.py +8 -16
- openhands_tools-1.27.0/openhands/tools/workflow/__init__.py +24 -0
- openhands_tools-1.27.0/openhands/tools/workflow/definition.py +179 -0
- openhands_tools-1.27.0/openhands/tools/workflow/impl.py +461 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands_tools.egg-info/PKG-INFO +1 -1
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands_tools.egg-info/SOURCES.txt +6 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/pyproject.toml +1 -1
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/apply_patch/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/apply_patch/core.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/apply_patch/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/event_storage.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/recording.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/server.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/delegate/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/delegate/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/delegate/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/delegate/templates/delegate_tool_description.j2 +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/delegate/visualizer.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/editor.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/exceptions.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/config.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/constants.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/diff.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/encoding.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/file_cache.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/history.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/shell.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/edit/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/edit/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/edit/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/list_directory/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/list_directory/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/list_directory/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/read_file/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/read_file/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/read_file/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/write_file/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/write_file/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/write_file/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/glob/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/glob/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/glob/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/grep/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/grep/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/grep/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/planning_file_editor/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/planning_file_editor/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/planning_file_editor/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/default.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/gemini.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/gpt5.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/planning.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/bash_runner.md +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/code_explorer.md +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/default.md +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/web_researcher.md +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/py.typed +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task_tracker/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task_tracker/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/constants.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/descriptions.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/impl.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/metadata.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/factory.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/interface.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/subprocess_terminal.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/terminal_session.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/tmux_pane_pool.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/tmux_terminal.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/windows_terminal.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/timeout_policy.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/utils/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/utils/escape_filter.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/tom_consult/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/tom_consult/definition.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/tom_consult/executor.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/utils/__init__.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/utils/timeout.py +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands_tools.egg-info/dependency_links.txt +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands_tools.egg-info/requires.txt +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands_tools.egg-info/top_level.txt +0 -0
- {openhands_tools-1.26.0 → openhands_tools-1.27.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-tools
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.27.0
|
|
4
4
|
Summary: OpenHands Tools - Runtime tools for AI agents
|
|
5
5
|
Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
|
|
6
6
|
Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
|
|
@@ -29,6 +29,7 @@ from openhands.tools.preset.default import (
|
|
|
29
29
|
from openhands.tools.task import TaskToolSet
|
|
30
30
|
from openhands.tools.task_tracker import TaskTrackerTool
|
|
31
31
|
from openhands.tools.terminal import TerminalTool
|
|
32
|
+
from openhands.tools.workflow import WorkflowToolSet
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
try:
|
|
@@ -44,6 +45,7 @@ __all__ = [
|
|
|
44
45
|
"TaskToolSet",
|
|
45
46
|
"TaskTrackerTool",
|
|
46
47
|
"TerminalTool",
|
|
48
|
+
"WorkflowToolSet",
|
|
47
49
|
"get_default_agent",
|
|
48
50
|
"get_default_tools",
|
|
49
51
|
"register_default_tools",
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/logging_fix.py
RENAMED
|
@@ -15,7 +15,7 @@ from openhands.sdk.utils.deprecation import warn_cleanup
|
|
|
15
15
|
|
|
16
16
|
warn_cleanup(
|
|
17
17
|
"Monkey patching to prevent browser_use logging interference",
|
|
18
|
-
cleanup_by="1.
|
|
18
|
+
cleanup_by="1.31.0",
|
|
19
19
|
details=(
|
|
20
20
|
"This workaround should be removed once browser_use fixes the "
|
|
21
21
|
"problematic logging configuration code. The upstream PR #3717 "
|
|
@@ -106,6 +106,23 @@ class TaskManager:
|
|
|
106
106
|
# when the parent persists, otherwise a temporary directory.
|
|
107
107
|
self._persistence_dir: Path | None = None
|
|
108
108
|
|
|
109
|
+
def attach_parent(self, conversation: LocalConversation) -> None:
|
|
110
|
+
"""Attach the parent conversation used to create sub-agent tasks.
|
|
111
|
+
|
|
112
|
+
Idempotent: if a parent conversation is already attached, subsequent
|
|
113
|
+
calls with the same conversation have no effect. Calls with a different
|
|
114
|
+
conversation are also ignored, but log a warning to surface potential
|
|
115
|
+
programming errors where two subsystems try to register different parents.
|
|
116
|
+
"""
|
|
117
|
+
if (
|
|
118
|
+
self._parent_conversation is not None
|
|
119
|
+
and self._parent_conversation is not conversation
|
|
120
|
+
):
|
|
121
|
+
logger.warning(
|
|
122
|
+
"attach_parent called with a different conversation; ignoring."
|
|
123
|
+
)
|
|
124
|
+
self._ensure_parent(conversation)
|
|
125
|
+
|
|
109
126
|
def _ensure_parent(self, conversation: LocalConversation) -> None:
|
|
110
127
|
if self._parent_conversation is None:
|
|
111
128
|
self._parent_conversation = conversation
|
|
@@ -2,16 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
|
|
5
|
-
import
|
|
6
|
-
from tree_sitter import Language, Node, Parser, Tree
|
|
5
|
+
from tree_sitter import Node
|
|
7
6
|
|
|
8
7
|
from openhands.sdk.logger import get_logger
|
|
8
|
+
from openhands.sdk.security.shell_parser import parse
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
logger = get_logger(__name__)
|
|
12
12
|
|
|
13
|
-
_BASH_LANGUAGE = Language(tree_sitter_bash.language())
|
|
14
|
-
|
|
15
13
|
# Regions whose contents bash takes verbatim — escape doubling stops at
|
|
16
14
|
# their boundaries so operators nested inside (e.g.) a double-quoted
|
|
17
15
|
# string remain untouched. Walking does not recurse into these nodes.
|
|
@@ -32,12 +30,6 @@ _PRESERVE_TYPES: frozenset[str] = frozenset(
|
|
|
32
30
|
_ESCAPE_PATTERN: re.Pattern[bytes] = re.compile(rb"\\([;&|<>])")
|
|
33
31
|
|
|
34
32
|
|
|
35
|
-
def _parse(source: bytes) -> Tree:
|
|
36
|
-
# Parser is not thread-safe; the Language is. Construct a fresh
|
|
37
|
-
# Parser per call so concurrent callers cannot trample each other.
|
|
38
|
-
return Parser(_BASH_LANGUAGE).parse(source)
|
|
39
|
-
|
|
40
|
-
|
|
41
33
|
def split_bash_commands(commands: str) -> list[str]:
|
|
42
34
|
"""Split a multi-statement bash input into top-level statements.
|
|
43
35
|
|
|
@@ -52,10 +44,10 @@ def split_bash_commands(commands: str) -> list[str]:
|
|
|
52
44
|
return [""]
|
|
53
45
|
|
|
54
46
|
source = commands.encode()
|
|
55
|
-
|
|
56
|
-
root = tree.root_node
|
|
47
|
+
result = parse(commands)
|
|
48
|
+
root = result.tree.root_node
|
|
57
49
|
|
|
58
|
-
if
|
|
50
|
+
if result.has_error:
|
|
59
51
|
logger.debug(
|
|
60
52
|
"tree-sitter-bash reported parse errors; returning input as-is\n"
|
|
61
53
|
"[input]: %s",
|
|
@@ -88,8 +80,8 @@ def escape_bash_special_chars(command: str) -> str:
|
|
|
88
80
|
return ""
|
|
89
81
|
|
|
90
82
|
source = command.encode()
|
|
91
|
-
|
|
92
|
-
if
|
|
83
|
+
result = parse(command)
|
|
84
|
+
if result.has_error:
|
|
93
85
|
logger.debug(
|
|
94
86
|
"tree-sitter-bash reported parse errors; returning input as-is\n"
|
|
95
87
|
"[input]: %s",
|
|
@@ -106,7 +98,7 @@ def escape_bash_special_chars(command: str) -> str:
|
|
|
106
98
|
for child in node.children:
|
|
107
99
|
collect(child)
|
|
108
100
|
|
|
109
|
-
collect(tree.root_node)
|
|
101
|
+
collect(result.tree.root_node)
|
|
110
102
|
|
|
111
103
|
out = bytearray()
|
|
112
104
|
cursor = 0
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Dynamic workflow tool for sub-agent orchestration."""
|
|
2
|
+
|
|
3
|
+
from openhands.tools.workflow.definition import (
|
|
4
|
+
WorkflowAction,
|
|
5
|
+
WorkflowObservation,
|
|
6
|
+
WorkflowTool,
|
|
7
|
+
WorkflowToolSet,
|
|
8
|
+
)
|
|
9
|
+
from openhands.tools.workflow.impl import (
|
|
10
|
+
WorkflowContext,
|
|
11
|
+
WorkflowExecutor,
|
|
12
|
+
WorkflowScriptError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"WorkflowAction",
|
|
18
|
+
"WorkflowContext",
|
|
19
|
+
"WorkflowExecutor",
|
|
20
|
+
"WorkflowObservation",
|
|
21
|
+
"WorkflowScriptError",
|
|
22
|
+
"WorkflowTool",
|
|
23
|
+
"WorkflowToolSet",
|
|
24
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Dynamic workflow tool definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import TYPE_CHECKING, Final, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from openhands.sdk.tool import (
|
|
11
|
+
Action,
|
|
12
|
+
Observation,
|
|
13
|
+
ToolAnnotations,
|
|
14
|
+
ToolDefinition,
|
|
15
|
+
register_tool,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from openhands.sdk.conversation.state import ConversationState
|
|
21
|
+
from openhands.tools.workflow.impl import WorkflowExecutor
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WorkflowAction(Action):
|
|
25
|
+
"""Schema for running a Python dynamic workflow script."""
|
|
26
|
+
|
|
27
|
+
name: str = Field(description="A short name for this workflow run.")
|
|
28
|
+
script: str = Field(
|
|
29
|
+
description=(
|
|
30
|
+
"Python workflow script to run. It must define `async def main(wf):` "
|
|
31
|
+
"and coordinate work only through the provided `wf` object."
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
max_concurrency: int = Field(
|
|
35
|
+
default=8,
|
|
36
|
+
ge=1,
|
|
37
|
+
le=64,
|
|
38
|
+
description=(
|
|
39
|
+
"Maximum number of sub-agent tasks to run concurrently. "
|
|
40
|
+
"Consider 2–4 for LLM-heavy workflows to avoid hitting API rate limits."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class WorkflowObservation(Observation):
|
|
46
|
+
"""Observation from a dynamic workflow run."""
|
|
47
|
+
|
|
48
|
+
name: str = Field(description="The workflow name that was executed.")
|
|
49
|
+
status: Literal["completed", "error"] = Field(
|
|
50
|
+
description="The workflow execution status."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_WORKFLOW_DESCRIPTION: Final[
|
|
55
|
+
str
|
|
56
|
+
] = """Run a dynamic workflow written as Python orchestration code.
|
|
57
|
+
|
|
58
|
+
Use this tool for large tasks that benefit from parallel sub-agents, such as
|
|
59
|
+
codebase-wide audits, independent plan reviews, security sweeps, or discovery
|
|
60
|
+
work where intermediate results should stay outside the main conversation.
|
|
61
|
+
|
|
62
|
+
Provide a Python script that defines exactly this entry point:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
async def main(wf):
|
|
66
|
+
...
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The script coordinates sub-agents through the `wf` object. It should not read or
|
|
70
|
+
write files, run shell commands, or perform the engineering work directly.
|
|
71
|
+
Sub-agents should do that work through their normal OpenHands tools and security
|
|
72
|
+
policy. Scripts should use only the documented `wf` methods; private `wf`
|
|
73
|
+
attributes are rejected. Large reducer inputs may be truncated before being sent
|
|
74
|
+
to the reducer sub-agent.
|
|
75
|
+
|
|
76
|
+
Available `wf` methods:
|
|
77
|
+
- `await wf.run_agent(prompt, subagent_type="general-purpose", description=None)`
|
|
78
|
+
- `await wf.map_agents(items, prompt, subagent_type="general-purpose",
|
|
79
|
+
max_concurrency=None, description=None)`
|
|
80
|
+
- `await wf.reduce_agent(items, prompt, subagent_type="general-purpose",
|
|
81
|
+
description=None)`
|
|
82
|
+
- `wf.flatten(values)` — flatten one level of nesting (not recursive)
|
|
83
|
+
|
|
84
|
+
`subagent_type` must be a sub-agent type registered in the parent application.
|
|
85
|
+
Use the same type names you registered when building your agent.
|
|
86
|
+
|
|
87
|
+
Scripts must use only the documented `wf` methods listed above; calling
|
|
88
|
+
`wf.close()` or any other undocumented attribute is not supported.
|
|
89
|
+
|
|
90
|
+
`print()` is available for debugging but writes to the server logs, not to
|
|
91
|
+
the workflow observation seen by the LLM; use the return value of `main()` to
|
|
92
|
+
surface results.
|
|
93
|
+
|
|
94
|
+
If one or more `map_agents` items fail, the whole call raises an
|
|
95
|
+
`ExceptionGroup`. The name `ExceptionGroup` is not available by name in the
|
|
96
|
+
workflow sandbox, so scripts cannot use `except*` for selective group handling.
|
|
97
|
+
A plain `except Exception` will still catch the entire group. To handle partial
|
|
98
|
+
failures and collect all results, design sub-agent prompts to return an error
|
|
99
|
+
sentinel value instead of raising.
|
|
100
|
+
|
|
101
|
+
`map_agents` accepts either a callable prompt, such as
|
|
102
|
+
`lambda item: f"Review this finding: {item}"`, or a string template containing
|
|
103
|
+
`{item}`.
|
|
104
|
+
|
|
105
|
+
Example:
|
|
106
|
+
```python
|
|
107
|
+
async def main(wf):
|
|
108
|
+
strategies = ["minimal fix", "test-first", "security-focused"]
|
|
109
|
+
plans = await wf.map_agents(
|
|
110
|
+
items=strategies,
|
|
111
|
+
subagent_type="general-purpose",
|
|
112
|
+
max_concurrency=3,
|
|
113
|
+
prompt=lambda strategy: f"Create a plan using this strategy: {strategy}",
|
|
114
|
+
)
|
|
115
|
+
critiques = await wf.map_agents(
|
|
116
|
+
items=plans,
|
|
117
|
+
subagent_type="code-reviewer",
|
|
118
|
+
prompt=lambda plan: f"Adversarially critique this plan: {plan}",
|
|
119
|
+
)
|
|
120
|
+
return await wf.reduce_agent(
|
|
121
|
+
items={"plans": plans, "critiques": critiques},
|
|
122
|
+
prompt="Synthesize the safest and simplest final plan.",
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
This MVP executes generated Python in-process after best-effort validation. Treat
|
|
127
|
+
running a workflow as approving generated code execution.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class WorkflowTool(ToolDefinition[WorkflowAction, WorkflowObservation]):
|
|
132
|
+
"""Low-level tool for explicit executor injection.
|
|
133
|
+
|
|
134
|
+
Prefer ``WorkflowToolSet`` for standard SDK auto-create usage.
|
|
135
|
+
Use ``WorkflowTool`` when you need to inject a custom executor
|
|
136
|
+
(e.g., in tests or extensions).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def create(
|
|
141
|
+
cls,
|
|
142
|
+
conv_state: ConversationState | None = None, # noqa: ARG003
|
|
143
|
+
executor: WorkflowExecutor | None = None,
|
|
144
|
+
description: str = _WORKFLOW_DESCRIPTION,
|
|
145
|
+
) -> Sequence[WorkflowTool]:
|
|
146
|
+
from openhands.tools.workflow.impl import WorkflowExecutor
|
|
147
|
+
|
|
148
|
+
return [
|
|
149
|
+
cls(
|
|
150
|
+
action_type=WorkflowAction,
|
|
151
|
+
observation_type=WorkflowObservation,
|
|
152
|
+
description=description,
|
|
153
|
+
annotations=ToolAnnotations(
|
|
154
|
+
title="workflow",
|
|
155
|
+
readOnlyHint=False,
|
|
156
|
+
destructiveHint=True,
|
|
157
|
+
idempotentHint=False,
|
|
158
|
+
openWorldHint=True,
|
|
159
|
+
),
|
|
160
|
+
executor=executor if executor is not None else WorkflowExecutor(),
|
|
161
|
+
)
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class WorkflowToolSet(ToolDefinition[WorkflowAction, WorkflowObservation]):
|
|
166
|
+
"""Tool set that creates the dynamic workflow tool."""
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def create(
|
|
170
|
+
cls,
|
|
171
|
+
conv_state: ConversationState, # noqa: ARG003
|
|
172
|
+
) -> Sequence[WorkflowTool]:
|
|
173
|
+
from openhands.tools.workflow.impl import WorkflowExecutor
|
|
174
|
+
|
|
175
|
+
return WorkflowTool.create(executor=WorkflowExecutor())
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
register_tool(WorkflowToolSet.name, WorkflowToolSet)
|
|
179
|
+
register_tool(WorkflowTool.name, WorkflowTool)
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Implementation of the dynamic workflow tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import asyncio
|
|
7
|
+
import inspect
|
|
8
|
+
import json as jsonlib
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
11
|
+
|
|
12
|
+
from openhands.sdk.logger import get_logger
|
|
13
|
+
from openhands.sdk.tool import ToolExecutor
|
|
14
|
+
from openhands.tools.task.manager import TaskManager
|
|
15
|
+
from openhands.tools.workflow.definition import WorkflowObservation
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
|
|
20
|
+
from openhands.tools.workflow.definition import WorkflowAction
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
_MAX_SCRIPT_CHARS = 20_000
|
|
25
|
+
_MAX_REDUCE_INPUT_CHARS = 12_000
|
|
26
|
+
_WORKFLOW_TIMEOUT_SECONDS = 3600.0 # 1 hour; prevents indefinitely hung workflows
|
|
27
|
+
_UNSAFE_CALLS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"breakpoint",
|
|
30
|
+
"compile",
|
|
31
|
+
"delattr",
|
|
32
|
+
"dir",
|
|
33
|
+
"eval",
|
|
34
|
+
"exec",
|
|
35
|
+
"getattr",
|
|
36
|
+
"globals",
|
|
37
|
+
"input",
|
|
38
|
+
"locals",
|
|
39
|
+
"open",
|
|
40
|
+
"setattr",
|
|
41
|
+
"vars",
|
|
42
|
+
"__import__",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
# Attribute-root deny-list is intentionally narrow: scripts cannot import
|
|
46
|
+
# modules, so only names that are pre-injected via _safe_globals() need to
|
|
47
|
+
# be listed here. os and subprocess are the two that would be most harmful
|
|
48
|
+
# if they were ever inadvertently exposed.
|
|
49
|
+
_UNSAFE_ATTRIBUTE_ROOTS = frozenset({"os", "subprocess"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WorkflowScriptError(ValueError):
|
|
53
|
+
"""Raised when a workflow script is invalid or unsafe."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _TaskLike(Protocol):
|
|
57
|
+
result: str | None
|
|
58
|
+
error: str | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _TaskStarter(Protocol):
|
|
62
|
+
def start_task(
|
|
63
|
+
self,
|
|
64
|
+
prompt: str,
|
|
65
|
+
subagent_type: str = "default",
|
|
66
|
+
resume: str | None = None,
|
|
67
|
+
description: str | None = None,
|
|
68
|
+
conversation: LocalConversation | None = None,
|
|
69
|
+
) -> _TaskLike: ...
|
|
70
|
+
|
|
71
|
+
def close(self) -> None: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class WorkflowContext:
|
|
75
|
+
"""Small capability object exposed to generated workflow scripts."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
parent_conversation: LocalConversation,
|
|
80
|
+
max_concurrency: int,
|
|
81
|
+
manager: _TaskStarter | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
if max_concurrency < 1:
|
|
84
|
+
raise ValueError("max_concurrency must be at least 1")
|
|
85
|
+
self._parent_conversation = parent_conversation
|
|
86
|
+
self._max_concurrency = max_concurrency
|
|
87
|
+
if manager is None:
|
|
88
|
+
task_manager = TaskManager()
|
|
89
|
+
task_manager.attach_parent(parent_conversation)
|
|
90
|
+
self._manager = task_manager
|
|
91
|
+
else:
|
|
92
|
+
self._manager = manager
|
|
93
|
+
self._semaphore: asyncio.Semaphore | None = None
|
|
94
|
+
self._closed = False
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def _default_semaphore(self) -> asyncio.Semaphore:
|
|
98
|
+
if self._semaphore is None:
|
|
99
|
+
self._semaphore = asyncio.Semaphore(self._max_concurrency)
|
|
100
|
+
return self._semaphore
|
|
101
|
+
|
|
102
|
+
async def run_agent(
|
|
103
|
+
self,
|
|
104
|
+
prompt: str,
|
|
105
|
+
subagent_type: str = "general-purpose",
|
|
106
|
+
description: str | None = None,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Run a single sub-agent task and return its final result."""
|
|
109
|
+
async with self._default_semaphore:
|
|
110
|
+
return await self._run_agent_task(
|
|
111
|
+
prompt=prompt,
|
|
112
|
+
subagent_type=subagent_type,
|
|
113
|
+
description=description,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def _run_agent_task(
|
|
117
|
+
self,
|
|
118
|
+
prompt: str,
|
|
119
|
+
subagent_type: str,
|
|
120
|
+
description: str | None,
|
|
121
|
+
) -> str:
|
|
122
|
+
# Note: `_TaskStarter.start_task` accepts a `resume` parameter, but
|
|
123
|
+
# workflow sub-agents are always fresh tasks; resumption is intentionally
|
|
124
|
+
# not exposed through WorkflowContext in the MVP.
|
|
125
|
+
if self._closed:
|
|
126
|
+
raise WorkflowScriptError("WorkflowContext is already closed")
|
|
127
|
+
task = await asyncio.to_thread(
|
|
128
|
+
self._manager.start_task,
|
|
129
|
+
prompt=prompt,
|
|
130
|
+
subagent_type=subagent_type,
|
|
131
|
+
description=description,
|
|
132
|
+
conversation=self._parent_conversation,
|
|
133
|
+
)
|
|
134
|
+
if task.error:
|
|
135
|
+
raise RuntimeError(task.error)
|
|
136
|
+
return task.result or ""
|
|
137
|
+
|
|
138
|
+
async def map_agents(
|
|
139
|
+
self,
|
|
140
|
+
items: Sequence[Any],
|
|
141
|
+
prompt: Callable[[Any], str] | str,
|
|
142
|
+
subagent_type: str = "general-purpose",
|
|
143
|
+
max_concurrency: int | None = None,
|
|
144
|
+
description: Callable[[Any], str] | str | None = None,
|
|
145
|
+
) -> list[str]:
|
|
146
|
+
"""Run one sub-agent task per item and return results in item order.
|
|
147
|
+
|
|
148
|
+
A per-call ``max_concurrency`` caps concurrency for this map operation
|
|
149
|
+
only; it is silently capped at the context's ``max_concurrency`` limit.
|
|
150
|
+
"""
|
|
151
|
+
if max_concurrency is not None and max_concurrency < 1:
|
|
152
|
+
raise ValueError("max_concurrency must be at least 1")
|
|
153
|
+
semaphore = (
|
|
154
|
+
asyncio.Semaphore(min(max_concurrency, self._max_concurrency))
|
|
155
|
+
if max_concurrency is not None
|
|
156
|
+
else self._default_semaphore
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def run_one(index: int, item: Any) -> str:
|
|
160
|
+
rendered_prompt = _render_required_template(prompt, item)
|
|
161
|
+
rendered_description = _render_template(description, item)
|
|
162
|
+
async with semaphore:
|
|
163
|
+
try:
|
|
164
|
+
return await self._run_agent_task(
|
|
165
|
+
prompt=rendered_prompt,
|
|
166
|
+
subagent_type=subagent_type,
|
|
167
|
+
description=rendered_description,
|
|
168
|
+
)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
raise RuntimeError(f"[item {index + 1}] {exc}") from exc
|
|
171
|
+
|
|
172
|
+
results = await asyncio.gather(
|
|
173
|
+
*(run_one(i, item) for i, item in enumerate(items)),
|
|
174
|
+
return_exceptions=True,
|
|
175
|
+
)
|
|
176
|
+
failures = [result for result in results if isinstance(result, BaseException)]
|
|
177
|
+
if failures:
|
|
178
|
+
exceptions = [
|
|
179
|
+
failure
|
|
180
|
+
if isinstance(failure, Exception)
|
|
181
|
+
else RuntimeError(str(failure))
|
|
182
|
+
for failure in failures
|
|
183
|
+
]
|
|
184
|
+
raise ExceptionGroup(
|
|
185
|
+
"map_agents: one or more sub-agents failed",
|
|
186
|
+
exceptions,
|
|
187
|
+
)
|
|
188
|
+
return [str(result) for result in results]
|
|
189
|
+
|
|
190
|
+
async def reduce_agent(
|
|
191
|
+
self,
|
|
192
|
+
items: Any,
|
|
193
|
+
prompt: str,
|
|
194
|
+
subagent_type: str = "general-purpose",
|
|
195
|
+
description: str | None = None,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""Run a single reducer sub-agent with serialized intermediate results.
|
|
198
|
+
|
|
199
|
+
Delegates to ``run_agent``, which acquires ``_default_semaphore``.
|
|
200
|
+
Workflow scripts always await operations sequentially, so the semaphore
|
|
201
|
+
is always fully available when ``reduce_agent`` is called.
|
|
202
|
+
"""
|
|
203
|
+
return await self.run_agent(
|
|
204
|
+
prompt=f"{prompt}\n\nInput:\n{_format_value(items)}",
|
|
205
|
+
subagent_type=subagent_type,
|
|
206
|
+
description=description,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def flatten(self, values: list[Any]) -> list[Any]:
|
|
210
|
+
"""Flatten one list level."""
|
|
211
|
+
flattened: list[Any] = []
|
|
212
|
+
for value in values:
|
|
213
|
+
if isinstance(value, list):
|
|
214
|
+
flattened.extend(value)
|
|
215
|
+
else:
|
|
216
|
+
flattened.append(value)
|
|
217
|
+
return flattened
|
|
218
|
+
|
|
219
|
+
def close(self) -> None:
|
|
220
|
+
if self._closed:
|
|
221
|
+
return
|
|
222
|
+
self._closed = True
|
|
223
|
+
self._manager.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _render_required_template(template: Callable[[Any], str] | str, item: Any) -> str:
|
|
227
|
+
if callable(template):
|
|
228
|
+
return str(template(item))
|
|
229
|
+
# Plain replace avoids Python's format mini-language attribute traversal
|
|
230
|
+
# (e.g. "{item._manager}"), which would bypass the AST private-attribute guard.
|
|
231
|
+
if "{item}" not in template:
|
|
232
|
+
logger.debug(
|
|
233
|
+
"map_agents string template does not contain '{item}'; "
|
|
234
|
+
"all sub-agents will receive the same prompt."
|
|
235
|
+
)
|
|
236
|
+
# Use json.dumps for non-str items so dicts/lists and scalars are consistently
|
|
237
|
+
# serialised as JSON (booleans → true/false, None → null), matching reduce_agent.
|
|
238
|
+
serialised = item if isinstance(item, str) else jsonlib.dumps(item, default=str)
|
|
239
|
+
return template.replace("{item}", serialised)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _render_template(
|
|
243
|
+
template: Callable[[Any], str] | str | None, item: Any
|
|
244
|
+
) -> str | None:
|
|
245
|
+
if template is None:
|
|
246
|
+
return None
|
|
247
|
+
return _render_required_template(template, item)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _format_value(value: Any) -> str:
|
|
251
|
+
if isinstance(value, str):
|
|
252
|
+
text = value
|
|
253
|
+
else:
|
|
254
|
+
text = jsonlib.dumps(value, indent=2, default=str)
|
|
255
|
+
if len(text) <= _MAX_REDUCE_INPUT_CHARS:
|
|
256
|
+
return text
|
|
257
|
+
# Character-boundary truncation can split mid-token in JSON; element-boundary
|
|
258
|
+
# truncation for list/dict inputs would be cleaner but is deferred post-MVP.
|
|
259
|
+
return (
|
|
260
|
+
text[:_MAX_REDUCE_INPUT_CHARS]
|
|
261
|
+
+ "\n... [truncated workflow intermediate results]"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def validate_workflow_script(script: str) -> None:
|
|
266
|
+
"""Perform best-effort validation for generated workflow scripts.
|
|
267
|
+
|
|
268
|
+
Note: The private-attribute guard checks the literal name ``wf``, so aliasing
|
|
269
|
+
(e.g. ``x = wf; x._attr``) can bypass the check. The attributes accessible
|
|
270
|
+
through ``WorkflowContext`` do not expose dangerous capabilities, so this is
|
|
271
|
+
a documentation gap rather than a security gap.
|
|
272
|
+
"""
|
|
273
|
+
if len(script) > _MAX_SCRIPT_CHARS:
|
|
274
|
+
raise WorkflowScriptError(
|
|
275
|
+
f"Workflow script is too large: {len(script)} > {_MAX_SCRIPT_CHARS}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
tree = ast.parse(script)
|
|
280
|
+
except SyntaxError as e:
|
|
281
|
+
raise WorkflowScriptError(f"Workflow script has invalid syntax: {e}") from e
|
|
282
|
+
|
|
283
|
+
main_defs = [
|
|
284
|
+
node
|
|
285
|
+
for node in tree.body
|
|
286
|
+
if isinstance(node, ast.AsyncFunctionDef) and node.name == "main"
|
|
287
|
+
]
|
|
288
|
+
if len(main_defs) != 1:
|
|
289
|
+
raise WorkflowScriptError(
|
|
290
|
+
"Workflow script must define exactly one async main(wf)"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
main_args = main_defs[0].args
|
|
294
|
+
if (
|
|
295
|
+
[a.arg for a in main_args.args] != ["wf"]
|
|
296
|
+
or main_args.kwonlyargs
|
|
297
|
+
or main_args.vararg
|
|
298
|
+
or main_args.kwarg
|
|
299
|
+
or main_args.defaults
|
|
300
|
+
or main_args.posonlyargs
|
|
301
|
+
):
|
|
302
|
+
raise WorkflowScriptError("Workflow entry point must be `async def main(wf):`")
|
|
303
|
+
|
|
304
|
+
for node in ast.walk(tree):
|
|
305
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
306
|
+
raise WorkflowScriptError("Workflow scripts may not import modules")
|
|
307
|
+
if isinstance(node, ast.Name) and node.id.startswith("__"):
|
|
308
|
+
raise WorkflowScriptError("Workflow scripts may not access dunder names")
|
|
309
|
+
if isinstance(node, ast.Attribute) and node.attr.startswith("__"):
|
|
310
|
+
raise WorkflowScriptError(
|
|
311
|
+
"Workflow scripts may not access dunder attributes"
|
|
312
|
+
)
|
|
313
|
+
if (
|
|
314
|
+
isinstance(node, ast.Attribute)
|
|
315
|
+
and _attribute_root_name(node) == "wf"
|
|
316
|
+
and (node.attr.startswith("_") or node.attr == "close")
|
|
317
|
+
):
|
|
318
|
+
raise WorkflowScriptError(
|
|
319
|
+
"Workflow scripts may not access private wf attributes"
|
|
320
|
+
" or call wf.close()"
|
|
321
|
+
)
|
|
322
|
+
if (
|
|
323
|
+
isinstance(node, ast.Attribute)
|
|
324
|
+
and _attribute_root_name(node) in _UNSAFE_ATTRIBUTE_ROOTS
|
|
325
|
+
):
|
|
326
|
+
raise WorkflowScriptError("Workflow scripts may not access unsafe modules")
|
|
327
|
+
if (
|
|
328
|
+
isinstance(node, ast.Call)
|
|
329
|
+
and isinstance(node.func, ast.Name)
|
|
330
|
+
and node.func.id in _UNSAFE_CALLS
|
|
331
|
+
):
|
|
332
|
+
raise WorkflowScriptError(f"Workflow scripts may not call `{node.func.id}`")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _attribute_root_name(node: ast.Attribute) -> str | None:
|
|
336
|
+
value = node.value
|
|
337
|
+
while isinstance(value, ast.Attribute):
|
|
338
|
+
value = value.value
|
|
339
|
+
return value.id if isinstance(value, ast.Name) else None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def execute_workflow_script(script: str, context: WorkflowContext) -> Any:
|
|
343
|
+
"""Validate and execute a workflow script from a synchronous context."""
|
|
344
|
+
try:
|
|
345
|
+
asyncio.get_running_loop()
|
|
346
|
+
except RuntimeError:
|
|
347
|
+
pass
|
|
348
|
+
else:
|
|
349
|
+
raise WorkflowScriptError(
|
|
350
|
+
"Workflow scripts must be executed from a synchronous context"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
validate_workflow_script(script)
|
|
354
|
+
namespace: dict[str, Any] = {}
|
|
355
|
+
exec(compile(script, "<dynamic-workflow>", "exec"), _safe_globals(), namespace)
|
|
356
|
+
main = namespace.get("main")
|
|
357
|
+
if not inspect.iscoroutinefunction(main):
|
|
358
|
+
raise WorkflowScriptError("Workflow entry point must be async")
|
|
359
|
+
|
|
360
|
+
async def _run_with_timeout() -> Any:
|
|
361
|
+
async with asyncio.timeout(_WORKFLOW_TIMEOUT_SECONDS):
|
|
362
|
+
return await main(context)
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
return asyncio.run(_run_with_timeout())
|
|
366
|
+
except TimeoutError:
|
|
367
|
+
raise WorkflowScriptError(
|
|
368
|
+
f"Workflow timed out after {_WORKFLOW_TIMEOUT_SECONDS:.0f} seconds"
|
|
369
|
+
) from None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _format_exception(error: Exception) -> str:
|
|
373
|
+
if isinstance(error, ExceptionGroup):
|
|
374
|
+
details = "\n".join(
|
|
375
|
+
f" [{index}] {exception}"
|
|
376
|
+
for index, exception in enumerate(error.exceptions, start=1)
|
|
377
|
+
)
|
|
378
|
+
return f"{error.args[0]}:\n{details}"
|
|
379
|
+
return str(error)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _safe_globals() -> dict[str, Any]:
|
|
383
|
+
safe_builtins = {
|
|
384
|
+
"abs": abs,
|
|
385
|
+
"all": all,
|
|
386
|
+
"any": any,
|
|
387
|
+
"bool": bool,
|
|
388
|
+
"dict": dict,
|
|
389
|
+
"enumerate": enumerate,
|
|
390
|
+
"Exception": Exception,
|
|
391
|
+
"float": float,
|
|
392
|
+
"IndexError": IndexError,
|
|
393
|
+
"int": int,
|
|
394
|
+
"isinstance": isinstance,
|
|
395
|
+
"KeyError": KeyError,
|
|
396
|
+
"len": len,
|
|
397
|
+
"list": list,
|
|
398
|
+
"max": max,
|
|
399
|
+
"min": min,
|
|
400
|
+
"print": print,
|
|
401
|
+
"range": range,
|
|
402
|
+
"repr": repr,
|
|
403
|
+
"round": round,
|
|
404
|
+
"RuntimeError": RuntimeError,
|
|
405
|
+
"set": set,
|
|
406
|
+
"sorted": sorted,
|
|
407
|
+
"str": str,
|
|
408
|
+
"sum": sum,
|
|
409
|
+
"tuple": tuple,
|
|
410
|
+
# type() is included for 1-arg introspection (e.g. type(x).__name__).
|
|
411
|
+
# 3-arg class creation is permitted; methods DEFINED IN THE SCRIPT execute in
|
|
412
|
+
# restricted globals, and the AST validator blocks __dunder__ attribute access
|
|
413
|
+
# (closing __subclasses__()-based escapes). Calls to pre-existing injected
|
|
414
|
+
# objects such as wf are not re-sandboxed, but those expose only public wf API.
|
|
415
|
+
"type": type,
|
|
416
|
+
"TypeError": TypeError,
|
|
417
|
+
"ValueError": ValueError,
|
|
418
|
+
"zip": zip,
|
|
419
|
+
"format": format,
|
|
420
|
+
}
|
|
421
|
+
return {"__builtins__": safe_builtins}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class WorkflowExecutor(ToolExecutor["WorkflowAction", WorkflowObservation]):
|
|
425
|
+
"""Executor for the dynamic workflow tool."""
|
|
426
|
+
|
|
427
|
+
def __call__(
|
|
428
|
+
self,
|
|
429
|
+
action: WorkflowAction,
|
|
430
|
+
conversation: LocalConversation | None = None,
|
|
431
|
+
) -> WorkflowObservation:
|
|
432
|
+
if conversation is None:
|
|
433
|
+
return WorkflowObservation.from_text(
|
|
434
|
+
text="Workflow tool requires a local conversation context.",
|
|
435
|
+
name=action.name,
|
|
436
|
+
status="error",
|
|
437
|
+
is_error=True,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
context = WorkflowContext(
|
|
441
|
+
parent_conversation=conversation,
|
|
442
|
+
max_concurrency=action.max_concurrency,
|
|
443
|
+
)
|
|
444
|
+
try:
|
|
445
|
+
result = execute_workflow_script(action.script, context)
|
|
446
|
+
return WorkflowObservation.from_text(
|
|
447
|
+
text=str(result),
|
|
448
|
+
name=action.name,
|
|
449
|
+
status="completed",
|
|
450
|
+
)
|
|
451
|
+
except Exception as e:
|
|
452
|
+
error_text = _format_exception(e)
|
|
453
|
+
logger.warning("Workflow '%s' failed: %s", action.name, e, exc_info=True)
|
|
454
|
+
return WorkflowObservation.from_text(
|
|
455
|
+
text=error_text,
|
|
456
|
+
name=action.name,
|
|
457
|
+
status="error",
|
|
458
|
+
is_error=True,
|
|
459
|
+
)
|
|
460
|
+
finally:
|
|
461
|
+
context.close()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-tools
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.27.0
|
|
4
4
|
Summary: OpenHands Tools - Runtime tools for AI agents
|
|
5
5
|
Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
|
|
6
6
|
Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
|
|
@@ -89,6 +89,9 @@ pyproject.toml
|
|
|
89
89
|
./openhands/tools/tom_consult/executor.py
|
|
90
90
|
./openhands/tools/utils/__init__.py
|
|
91
91
|
./openhands/tools/utils/timeout.py
|
|
92
|
+
./openhands/tools/workflow/__init__.py
|
|
93
|
+
./openhands/tools/workflow/definition.py
|
|
94
|
+
./openhands/tools/workflow/impl.py
|
|
92
95
|
openhands/tools/__init__.py
|
|
93
96
|
openhands/tools/py.typed
|
|
94
97
|
openhands/tools/apply_patch/__init__.py
|
|
@@ -179,6 +182,9 @@ openhands/tools/tom_consult/definition.py
|
|
|
179
182
|
openhands/tools/tom_consult/executor.py
|
|
180
183
|
openhands/tools/utils/__init__.py
|
|
181
184
|
openhands/tools/utils/timeout.py
|
|
185
|
+
openhands/tools/workflow/__init__.py
|
|
186
|
+
openhands/tools/workflow/definition.py
|
|
187
|
+
openhands/tools/workflow/impl.py
|
|
182
188
|
openhands_tools.egg-info/PKG-INFO
|
|
183
189
|
openhands_tools.egg-info/SOURCES.txt
|
|
184
190
|
openhands_tools.egg-info/dependency_links.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/event_storage.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/config.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/constants.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/encoding.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/file_cache.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/history.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/shell.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/list_directory/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/list_directory/impl.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/read_file/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/read_file/definition.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/write_file/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/write_file/definition.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/planning_file_editor/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/planning_file_editor/definition.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/planning_file_editor/impl.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/bash_runner.md
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/code_explorer.md
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/default.md
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/web_researcher.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task_tracker/definition.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/factory.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/interface.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/tmux_terminal.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/timeout_policy.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/utils/__init__.py
RENAMED
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/utils/escape_filter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands_tools.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|