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.
Files changed (101) hide show
  1. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/PKG-INFO +1 -1
  2. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/__init__.py +2 -0
  3. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/logging_fix.py +1 -1
  4. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task/manager.py +17 -0
  5. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/utils/command.py +8 -16
  6. openhands_tools-1.27.0/openhands/tools/workflow/__init__.py +24 -0
  7. openhands_tools-1.27.0/openhands/tools/workflow/definition.py +179 -0
  8. openhands_tools-1.27.0/openhands/tools/workflow/impl.py +461 -0
  9. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands_tools.egg-info/PKG-INFO +1 -1
  10. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands_tools.egg-info/SOURCES.txt +6 -0
  11. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/pyproject.toml +1 -1
  12. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/apply_patch/__init__.py +0 -0
  13. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/apply_patch/core.py +0 -0
  14. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/apply_patch/definition.py +0 -0
  15. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/__init__.py +0 -0
  16. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/definition.py +0 -0
  17. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/event_storage.py +0 -0
  18. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/impl.py +0 -0
  19. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/recording.py +0 -0
  20. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/browser_use/server.py +0 -0
  21. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/delegate/__init__.py +0 -0
  22. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/delegate/definition.py +0 -0
  23. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/delegate/impl.py +0 -0
  24. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/delegate/templates/delegate_tool_description.j2 +0 -0
  25. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/delegate/visualizer.py +0 -0
  26. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/__init__.py +0 -0
  27. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/definition.py +0 -0
  28. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/editor.py +0 -0
  29. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/exceptions.py +0 -0
  30. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/impl.py +0 -0
  31. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/__init__.py +0 -0
  32. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/config.py +0 -0
  33. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/constants.py +0 -0
  34. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/diff.py +0 -0
  35. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/encoding.py +0 -0
  36. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/file_cache.py +0 -0
  37. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/history.py +0 -0
  38. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/file_editor/utils/shell.py +0 -0
  39. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/__init__.py +0 -0
  40. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/edit/__init__.py +0 -0
  41. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/edit/definition.py +0 -0
  42. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/edit/impl.py +0 -0
  43. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/list_directory/__init__.py +0 -0
  44. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/list_directory/definition.py +0 -0
  45. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/list_directory/impl.py +0 -0
  46. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/read_file/__init__.py +0 -0
  47. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/read_file/definition.py +0 -0
  48. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/read_file/impl.py +0 -0
  49. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/write_file/__init__.py +0 -0
  50. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/write_file/definition.py +0 -0
  51. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/gemini/write_file/impl.py +0 -0
  52. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/glob/__init__.py +0 -0
  53. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/glob/definition.py +0 -0
  54. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/glob/impl.py +0 -0
  55. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/grep/__init__.py +0 -0
  56. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/grep/definition.py +0 -0
  57. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/grep/impl.py +0 -0
  58. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/planning_file_editor/__init__.py +0 -0
  59. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/planning_file_editor/definition.py +0 -0
  60. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/planning_file_editor/impl.py +0 -0
  61. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/__init__.py +0 -0
  62. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/default.py +0 -0
  63. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/gemini.py +0 -0
  64. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/gpt5.py +0 -0
  65. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/planning.py +0 -0
  66. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/bash_runner.md +0 -0
  67. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/code_explorer.md +0 -0
  68. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/default.md +0 -0
  69. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/preset/subagents/web_researcher.md +0 -0
  70. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/py.typed +0 -0
  71. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task/__init__.py +0 -0
  72. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task/definition.py +0 -0
  73. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task/impl.py +0 -0
  74. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task_tracker/__init__.py +0 -0
  75. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/task_tracker/definition.py +0 -0
  76. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/__init__.py +0 -0
  77. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/constants.py +0 -0
  78. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/definition.py +0 -0
  79. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/descriptions.py +0 -0
  80. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/impl.py +0 -0
  81. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/metadata.py +0 -0
  82. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/__init__.py +0 -0
  83. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/factory.py +0 -0
  84. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/interface.py +0 -0
  85. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/subprocess_terminal.py +0 -0
  86. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/terminal_session.py +0 -0
  87. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/tmux_pane_pool.py +0 -0
  88. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/tmux_terminal.py +0 -0
  89. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/terminal/windows_terminal.py +0 -0
  90. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/timeout_policy.py +0 -0
  91. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/utils/__init__.py +0 -0
  92. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/terminal/utils/escape_filter.py +0 -0
  93. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/tom_consult/__init__.py +0 -0
  94. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/tom_consult/definition.py +0 -0
  95. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/tom_consult/executor.py +0 -0
  96. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/utils/__init__.py +0 -0
  97. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands/tools/utils/timeout.py +0 -0
  98. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands_tools.egg-info/dependency_links.txt +0 -0
  99. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands_tools.egg-info/requires.txt +0 -0
  100. {openhands_tools-1.26.0 → openhands_tools-1.27.0}/openhands_tools.egg-info/top_level.txt +0 -0
  101. {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.26.0
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",
@@ -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.26.0",
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 tree_sitter_bash
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
- tree = _parse(source)
56
- root = tree.root_node
47
+ result = parse(commands)
48
+ root = result.tree.root_node
57
49
 
58
- if root.has_error:
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
- tree = _parse(source)
92
- if tree.root_node.has_error:
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.26.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-tools"
3
- version = "1.26.0"
3
+ version = "1.27.0"
4
4
  description = "OpenHands Tools - Runtime tools for AI agents"
5
5
 
6
6
  requires-python = ">=3.12"