openhands-tools 1.28.1__tar.gz → 1.29.2__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.28.1 → openhands_tools-1.29.2}/PKG-INFO +1 -1
  2. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/editor.py +88 -31
  3. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/preset/default.py +3 -9
  4. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/task/manager.py +40 -3
  5. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/terminal/subprocess_terminal.py +4 -0
  6. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/terminal/tmux_terminal.py +4 -0
  7. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/terminal/windows_terminal.py +7 -1
  8. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/workflow/definition.py +6 -0
  9. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/workflow/impl.py +87 -10
  10. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands_tools.egg-info/PKG-INFO +1 -1
  11. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/pyproject.toml +1 -1
  12. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/__init__.py +0 -0
  13. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/apply_patch/__init__.py +0 -0
  14. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/apply_patch/core.py +0 -0
  15. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/apply_patch/definition.py +0 -0
  16. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/browser_use/__init__.py +0 -0
  17. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/browser_use/definition.py +0 -0
  18. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/browser_use/event_storage.py +0 -0
  19. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/browser_use/impl.py +0 -0
  20. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/browser_use/logging_fix.py +0 -0
  21. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/browser_use/recording.py +0 -0
  22. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/browser_use/server.py +0 -0
  23. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/delegate/__init__.py +0 -0
  24. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/delegate/definition.py +0 -0
  25. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/delegate/impl.py +0 -0
  26. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/delegate/templates/delegate_tool_description.j2 +0 -0
  27. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/delegate/visualizer.py +0 -0
  28. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/__init__.py +0 -0
  29. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/definition.py +0 -0
  30. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/exceptions.py +0 -0
  31. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/impl.py +0 -0
  32. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/utils/__init__.py +0 -0
  33. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/utils/config.py +0 -0
  34. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/utils/constants.py +0 -0
  35. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/utils/diff.py +0 -0
  36. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/utils/encoding.py +0 -0
  37. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/utils/file_cache.py +0 -0
  38. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/utils/history.py +0 -0
  39. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/file_editor/utils/shell.py +0 -0
  40. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/__init__.py +0 -0
  41. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/edit/__init__.py +0 -0
  42. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/edit/definition.py +0 -0
  43. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/edit/impl.py +0 -0
  44. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/list_directory/__init__.py +0 -0
  45. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/list_directory/definition.py +0 -0
  46. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/list_directory/impl.py +0 -0
  47. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/read_file/__init__.py +0 -0
  48. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/read_file/definition.py +0 -0
  49. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/read_file/impl.py +0 -0
  50. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/write_file/__init__.py +0 -0
  51. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/write_file/definition.py +0 -0
  52. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/gemini/write_file/impl.py +0 -0
  53. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/glob/__init__.py +0 -0
  54. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/glob/definition.py +0 -0
  55. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/glob/impl.py +0 -0
  56. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/grep/__init__.py +0 -0
  57. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/grep/definition.py +0 -0
  58. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/grep/impl.py +0 -0
  59. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/planning_file_editor/__init__.py +0 -0
  60. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/planning_file_editor/definition.py +0 -0
  61. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/planning_file_editor/impl.py +0 -0
  62. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/preset/__init__.py +0 -0
  63. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/preset/gemini.py +0 -0
  64. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/preset/gpt5.py +0 -0
  65. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/preset/planning.py +0 -0
  66. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/preset/subagents/bash_runner.md +0 -0
  67. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/preset/subagents/code_explorer.md +0 -0
  68. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/preset/subagents/default.md +0 -0
  69. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/preset/subagents/web_researcher.md +0 -0
  70. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/py.typed +0 -0
  71. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/task/__init__.py +0 -0
  72. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/task/definition.py +0 -0
  73. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/task/impl.py +0 -0
  74. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/task_tracker/__init__.py +0 -0
  75. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/task_tracker/definition.py +0 -0
  76. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/__init__.py +0 -0
  77. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/constants.py +0 -0
  78. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/definition.py +0 -0
  79. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/descriptions.py +0 -0
  80. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/impl.py +0 -0
  81. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/metadata.py +0 -0
  82. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/terminal/__init__.py +0 -0
  83. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/terminal/factory.py +0 -0
  84. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/terminal/interface.py +0 -0
  85. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/terminal/terminal_session.py +0 -0
  86. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/terminal/tmux_pane_pool.py +0 -0
  87. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/timeout_policy.py +0 -0
  88. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/utils/__init__.py +0 -0
  89. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/utils/command.py +0 -0
  90. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/terminal/utils/escape_filter.py +0 -0
  91. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/tom_consult/__init__.py +0 -0
  92. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/tom_consult/definition.py +0 -0
  93. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/tom_consult/executor.py +0 -0
  94. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/utils/__init__.py +0 -0
  95. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/utils/timeout.py +0 -0
  96. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands/tools/workflow/__init__.py +0 -0
  97. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands_tools.egg-info/SOURCES.txt +0 -0
  98. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands_tools.egg-info/dependency_links.txt +0 -0
  99. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands_tools.egg-info/requires.txt +0 -0
  100. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/openhands_tools.egg-info/top_level.txt +0 -0
  101. {openhands_tools-1.28.1 → openhands_tools-1.29.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-tools
3
- Version: 1.28.1
3
+ Version: 1.29.2
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
@@ -2,8 +2,9 @@ import base64
2
2
  import mimetypes
3
3
  import os
4
4
  import re
5
- import shutil
6
5
  import tempfile
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
7
8
  from pathlib import Path
8
9
  from typing import get_args
9
10
 
@@ -43,6 +44,15 @@ logger = get_logger(__name__)
43
44
  IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
44
45
 
45
46
 
47
+ def _is_encodable(text: str, encoding: str) -> bool:
48
+ """Return True if text can be encoded with the given encoding."""
49
+ try:
50
+ text.encode(encoding)
51
+ except UnicodeEncodeError:
52
+ return False
53
+ return True
54
+
55
+
46
56
  class FileEditor:
47
57
  """
48
58
  An filesystem editor tool that allows the agent to
@@ -463,12 +473,61 @@ class FileEditor:
463
473
  """
464
474
  self.validate_file(path)
465
475
  try:
466
- # Use open with encoding instead of path.write_text
467
- with open(path, "w", encoding=encoding) as f:
468
- f.write(file_text)
476
+ self._atomic_write(path, file_text, encoding)
469
477
  except Exception as e:
470
478
  raise ToolError(f"Ran into {e} while trying to write to {path}") from None
471
479
 
480
+ def _atomic_write(self, path: Path, file_text: str, encoding: str) -> None:
481
+ """Write file_text to path atomically, never leaving a truncated file.
482
+
483
+ The content is written to a temporary file in the same directory which is
484
+ then Path.replace'd into place, so a failed write can never destroy the
485
+ original file. If the file's detected encoding cannot represent the new
486
+ content, fall back to UTF-8 so an edit may add characters (arrows, emoji,
487
+ CJK, ...) the original single-byte encoding lacks, instead of failing and
488
+ truncating the file. Note that the fallback transcodes the whole file to
489
+ UTF-8.
490
+ """
491
+ default = self._encoding_manager.default_encoding
492
+ if encoding != default and not _is_encodable(file_text, encoding):
493
+ logger.warning(
494
+ f"Detected encoding '{encoding}' cannot represent the new content "
495
+ f"for {path}; writing as '{default}' instead."
496
+ )
497
+ encoding = default
498
+
499
+ with self._temp_file(path, file_text, encoding) as tmp_path:
500
+ # Preserve the original file's permission bits when it already exists.
501
+ if path.exists():
502
+ os.chmod(tmp_path, os.stat(path).st_mode & 0o7777)
503
+ Path.replace(tmp_path, path)
504
+
505
+ @contextmanager
506
+ def _temp_file(self, path: Path, file_text: str, encoding: str) -> Iterator[Path]:
507
+ """Write file_text to a fresh temp file beside path and yield its Path.
508
+
509
+ The temp file is removed on any failure (write, chmod or replace), so the
510
+ original file is never destroyed and no stray temp file is left behind. The
511
+ unlink runs after the file is closed because Windows cannot delete an open
512
+ file.
513
+ """
514
+ tmp = tempfile.NamedTemporaryFile(
515
+ mode="w",
516
+ dir=path.parent,
517
+ prefix=f".{path.name}.",
518
+ suffix=".tmp",
519
+ encoding=encoding,
520
+ delete=False,
521
+ )
522
+ tmp_path = Path(tmp.name)
523
+ try:
524
+ with tmp:
525
+ tmp.write(file_text)
526
+ yield tmp_path
527
+ except BaseException:
528
+ tmp_path.unlink(missing_ok=True)
529
+ raise
530
+
472
531
  @with_encoding
473
532
  def insert(
474
533
  self,
@@ -501,33 +560,31 @@ class FileEditor:
501
560
 
502
561
  new_str_lines = new_str.split("\n")
503
562
 
504
- # Create temporary file for the new content
505
- with tempfile.NamedTemporaryFile(
506
- mode="w", encoding=encoding, delete=False
507
- ) as temp_file:
508
- # Copy lines before insert point and save them for history
509
- history_lines = []
510
- with open(path, encoding=encoding) as f:
511
- for i, line in enumerate(f, 1):
512
- if i > insert_line:
513
- break
514
- temp_file.write(line)
515
- history_lines.append(line)
516
-
517
- # Insert new content
518
- for line in new_str_lines:
519
- temp_file.write(line + "\n")
520
-
521
- # Copy remaining lines and save them for history
522
- with open(path, encoding=encoding) as f:
523
- for i, line in enumerate(f, 1):
524
- if i <= insert_line:
525
- continue
526
- temp_file.write(line)
527
- history_lines.append(line)
528
-
529
- # Move temporary file to original location
530
- shutil.move(temp_file.name, path)
563
+ # Build the new content in memory, then write it atomically. Routing the
564
+ # write through write_file reuses the same atomic, encoding-safe path as
565
+ # every other edit (no truncation, UTF-8 fallback for new characters).
566
+ new_lines: list[str] = []
567
+ history_lines: list[str] = []
568
+ with open(path, encoding=encoding) as f:
569
+ for i, line in enumerate(f, 1):
570
+ if i > insert_line:
571
+ break
572
+ new_lines.append(line)
573
+ history_lines.append(line)
574
+
575
+ # Insert new content
576
+ for line in new_str_lines:
577
+ new_lines.append(line + "\n")
578
+
579
+ # Copy remaining lines and save them for history
580
+ with open(path, encoding=encoding) as f:
581
+ for i, line in enumerate(f, 1):
582
+ if i <= insert_line:
583
+ continue
584
+ new_lines.append(line)
585
+ history_lines.append(line)
586
+
587
+ self.write_file(path, "".join(new_lines))
531
588
 
532
589
  # Read just the snippet range
533
590
  start_line = max(0, insert_line - SNIPPET_CONTEXT_WINDOW)
@@ -3,9 +3,7 @@
3
3
  from pathlib import Path
4
4
 
5
5
  from openhands.sdk import Agent, agent_definition_to_factory, load_agents_from_dir
6
- from openhands.sdk.context.condenser import (
7
- LLMSummarizingCondenser,
8
- )
6
+ from openhands.sdk.context.condenser import default_condenser
9
7
  from openhands.sdk.context.condenser.base import CondenserBase
10
8
  from openhands.sdk.llm.llm import LLM
11
9
  from openhands.sdk.logger import get_logger
@@ -68,12 +66,8 @@ def get_default_tools(
68
66
 
69
67
 
70
68
  def get_default_condenser(llm: LLM) -> CondenserBase:
71
- # Create a condenser to manage the context. The condenser will automatically
72
- # truncate conversation history when it exceeds max_size, and replaces the dropped
73
- # events with an LLM-generated summary.
74
- condenser = LLMSummarizingCondenser(llm=llm, max_size=80, keep_first=4)
75
-
76
- return condenser
69
+ # Shared with spawned sub-agents (see sdk default_condenser) so both stay in sync.
70
+ return default_condenser(llm)
77
71
 
78
72
 
79
73
  def get_default_agent(
@@ -28,6 +28,7 @@ from openhands.sdk.conversation.state import (
28
28
  ConversationExecutionStatus,
29
29
  ConversationState,
30
30
  )
31
+ from openhands.sdk.event.conversation_error import ConversationErrorEvent
31
32
  from openhands.sdk.hooks.config import HookConfig
32
33
  from openhands.sdk.logger import get_logger
33
34
  from openhands.sdk.security import ConfirmationPolicyBase
@@ -251,6 +252,11 @@ class TaskManager:
251
252
  if factory.definition.max_iteration_per_run
252
253
  else self.parent_conversation.max_iteration_per_run
253
254
  )
255
+ # Sub-agent budget: definition value, else inherit the parent's.
256
+ effective_max_budget = (
257
+ factory.definition.max_budget_per_run
258
+ or self.parent_conversation.max_budget_per_run
259
+ )
254
260
 
255
261
  with self._tasks_lock:
256
262
  task_id, conversation_id = self._generate_ids()
@@ -258,6 +264,7 @@ class TaskManager:
258
264
  sub_conversation = self._get_conversation(
259
265
  description=description,
260
266
  max_iteration_per_run=effective_max_iter,
267
+ max_budget_per_run=effective_max_budget,
261
268
  task_id=task_id,
262
269
  worker_agent=worker_agent,
263
270
  conversation_id=conversation_id,
@@ -285,6 +292,7 @@ class TaskManager:
285
292
  conversation_id: uuid.UUID,
286
293
  worker_agent: Agent,
287
294
  hook_config: HookConfig | None = None,
295
+ max_budget_per_run: float | None = None,
288
296
  ) -> LocalConversation:
289
297
  parent = self.parent_conversation
290
298
  parent_visualizer = parent._visualizer
@@ -301,6 +309,7 @@ class TaskManager:
301
309
  persistence_dir=self._persistence_dir,
302
310
  conversation_id=conversation_id,
303
311
  max_iteration_per_run=max_iteration_per_run,
312
+ max_budget_per_run=max_budget_per_run,
304
313
  hook_config=hook_config,
305
314
  delete_on_close=True,
306
315
  )
@@ -346,9 +355,17 @@ class TaskManager:
346
355
  try:
347
356
  task.conversation.send_message(prompt, sender=parent_name)
348
357
  self._run_until_finished(task.id, task.conversation)
349
- result = get_agent_final_response(task.conversation.state.events)
350
- task.set_result(result)
351
- logger.info(f"Task '{task.id}' completed.")
358
+ status = task.conversation.state.execution_status
359
+ if status == ConversationExecutionStatus.FINISHED:
360
+ result = get_agent_final_response(task.conversation.state.events)
361
+ task.set_result(result)
362
+ logger.info(f"Task '{task.id}' completed.")
363
+ else:
364
+ # Any non-FINISHED terminal status (run-limit, stuck, paused, ...)
365
+ # is surfaced as an error, not an empty "completed"; the detail
366
+ # keeps partial output so the parent can use/retry it.
367
+ task.set_error(self._run_stop_detail(task.conversation, status))
368
+ logger.warning(f"Task '{task.id}' stopped: status '{status.value}'.")
352
369
  except Exception as e:
353
370
  task.set_error(str(e))
354
371
  logger.warning(f"Task {task.id} failed with error: {e}")
@@ -358,6 +375,26 @@ class TaskManager:
358
375
 
359
376
  return task
360
377
 
378
+ @staticmethod
379
+ def _run_stop_detail(
380
+ conversation: LocalConversation,
381
+ status: ConversationExecutionStatus,
382
+ ) -> str:
383
+ """Why a sub-agent stopped without finishing (run-limit, stuck, paused, ...),
384
+ plus any partial output so the parent isn't left with nothing to use."""
385
+ errors = [
386
+ e
387
+ for e in conversation.state.events
388
+ if isinstance(e, ConversationErrorEvent)
389
+ ]
390
+ reason = (
391
+ errors[-1].detail
392
+ if errors
393
+ else f"Sub-agent stopped without finishing (status: {status.value})."
394
+ )
395
+ partial = get_agent_final_response(conversation.state.events)
396
+ return f"{reason}\nPartial result:\n{partial}" if partial else reason
397
+
361
398
  def _run_until_finished(
362
399
  self, task_id: str, conversation: LocalConversation
363
400
  ) -> None:
@@ -137,6 +137,10 @@ class SubprocessTerminal(TerminalInterface):
137
137
 
138
138
  # Inherit environment variables from the parent process
139
139
  env = sanitized_env()
140
+ # Disable interactive pagers (git, man, systemctl, ...) so commands that
141
+ # auto-launch `less` on a TTY don't capture the PTY and wedge the session.
142
+ env.setdefault("GIT_PAGER", "cat")
143
+ env.setdefault("PAGER", "cat")
140
144
  env["PS1"] = self.PS1
141
145
  env["PS2"] = ""
142
146
  env["TERM"] = "xterm-256color"
@@ -67,6 +67,10 @@ class TmuxTerminal(TerminalInterface):
67
67
  return
68
68
 
69
69
  env = sanitized_env()
70
+ # Disable interactive pagers (git, man, systemctl, ...) so commands that
71
+ # auto-launch `less` on a TTY don't capture the pane and wedge the session.
72
+ env.setdefault("GIT_PAGER", "cat")
73
+ env.setdefault("PAGER", "cat")
70
74
  # Use a dedicated socket to isolate OpenHands sessions from the user's tmux
71
75
  self.server = libtmux.Server(socket_name=TMUX_SOCKET_NAME, environment=env)
72
76
  _shell_command = "/bin/bash"
@@ -390,6 +390,12 @@ if ($toStop.Count -gt 0) {{ exit 0 }} else {{ exit 1 }}
390
390
  if self.process is None or self.process.poll() is not None:
391
391
  return False
392
392
 
393
+ # Kill descendants while they are still attached to the persistent
394
+ # PowerShell process. CTRL_BREAK can interrupt the waiting script first,
395
+ # leaving launched child processes alive but no longer discoverable as
396
+ # descendants of the shell.
397
+ terminated_children = self._terminate_child_processes()
398
+
393
399
  sent_ctrl_break = False
394
400
  ctrl_break_event = getattr(signal, "CTRL_BREAK_EVENT", None)
395
401
  if platform.system() == "Windows" and ctrl_break_event is not None:
@@ -402,7 +408,7 @@ if ($toStop.Count -gt 0) {{ exit 0 }} else {{ exit 1 }}
402
408
  if sent_ctrl_break:
403
409
  time.sleep(_INTERRUPT_GRACE_SECONDS)
404
410
 
405
- terminated_children = self._terminate_child_processes()
411
+ terminated_children = self._terminate_child_processes() or terminated_children
406
412
  sent_ctrl_c_input = False
407
413
  if not sent_ctrl_break and not terminated_children:
408
414
  try:
@@ -79,6 +79,12 @@ Available `wf` methods:
79
79
  max_concurrency=None, description=None)`
80
80
  - `await wf.reduce_agent(items, prompt, subagent_type="general-purpose",
81
81
  description=None)`
82
+ - `await wf.pipeline(items, *stages)` — run each item through all stages with no
83
+ barrier between stages (a fast item reaches a later stage while a slow item is
84
+ still in an earlier one). The first stage gets the item; each later stage gets
85
+ the previous result. Stages may be sync or async. A stage that raises drops that
86
+ item to `None`. Prefer this over chained `map_agents` calls when per-item stages
87
+ are independent, since `map_agents` fully drains each stage before the next.
82
88
  - `wf.flatten(values)` — flatten one level of nesting (not recursive)
83
89
 
84
90
  `subagent_type` must be a sub-agent type registered in the parent application.
@@ -23,6 +23,7 @@ logger = get_logger(__name__)
23
23
 
24
24
  _MAX_SCRIPT_CHARS = 20_000
25
25
  _MAX_REDUCE_INPUT_CHARS = 12_000
26
+ _TRUNCATION_MARKER = "\n... [truncated workflow intermediate results]"
26
27
  _WORKFLOW_TIMEOUT_SECONDS = 3600.0 # 1 hour; prevents indefinitely hung workflows
27
28
  _UNSAFE_CALLS = frozenset(
28
29
  {
@@ -187,6 +188,40 @@ class WorkflowContext:
187
188
  )
188
189
  return [str(result) for result in results]
189
190
 
191
+ async def pipeline(
192
+ self,
193
+ items: Sequence[Any],
194
+ *stages: Callable[[Any], Any],
195
+ ) -> list[Any]:
196
+ """Run each item through all stages independently, with no barrier between
197
+ stages: a fast item can reach a later stage while a slow item is still in an
198
+ earlier one. The first stage receives the original item; each later stage
199
+ receives the previous stage's result. Stages may be sync or async. A stage
200
+ that raises drops that item to ``None`` and skips its remaining stages; other
201
+ items are unaffected.
202
+
203
+ Concurrency is bounded by the agent calls inside stages (which acquire the
204
+ shared semaphore), so pipeline adds no barrier of its own. Do not have a
205
+ stage re-acquire the semaphore directly; call ``run_agent``/``map_agents``.
206
+ """
207
+ if not stages:
208
+ raise ValueError("pipeline requires at least one stage")
209
+
210
+ async def run_chain(index: int, item: Any) -> Any:
211
+ value: Any = item
212
+ try:
213
+ for stage in stages:
214
+ result = stage(value)
215
+ value = await result if inspect.isawaitable(result) else result
216
+ return value
217
+ except Exception as exc:
218
+ logger.warning("pipeline: item %d failed: %s", index + 1, exc)
219
+ return None
220
+
221
+ return list(
222
+ await asyncio.gather(*(run_chain(i, item) for i, item in enumerate(items)))
223
+ )
224
+
190
225
  async def reduce_agent(
191
226
  self,
192
227
  items: Any,
@@ -248,18 +283,60 @@ def _render_template(
248
283
 
249
284
 
250
285
  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)
286
+ """Serialize reduce input for the reducer prompt, bounded by
287
+ ``_MAX_REDUCE_INPUT_CHARS``. For lists/dicts, drop whole trailing elements
288
+ (keeping valid JSON) and report how many were omitted, instead of slicing
289
+ mid-token; everything else falls back to character truncation."""
290
+ if isinstance(value, (list, dict)):
291
+ return _truncate_structured(value)
292
+ text = value if isinstance(value, str) else jsonlib.dumps(value, default=str)
293
+ return _truncate_text(text)
294
+
295
+
296
+ def _truncate_text(text: str) -> str:
255
297
  if len(text) <= _MAX_REDUCE_INPUT_CHARS:
256
298
  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
- )
299
+ return text[:_MAX_REDUCE_INPUT_CHARS] + _TRUNCATION_MARKER
300
+
301
+
302
+ def _truncate_structured(value: list | dict) -> str:
303
+ """Keep as many leading elements as fit, dropping whole elements so the JSON
304
+ stays valid. Always keeps at least one element; a single oversized element is
305
+ char-truncated as a last resort."""
306
+ full = jsonlib.dumps(value, indent=2, default=str)
307
+ if len(full) <= _MAX_REDUCE_INPUT_CHARS:
308
+ return full
309
+
310
+ is_dict = isinstance(value, dict)
311
+ items = list(value.items()) if is_dict else list(value)
312
+ total = len(items)
313
+
314
+ def render(kept: list) -> str:
315
+ body = jsonlib.dumps(dict(kept) if is_dict else kept, indent=2, default=str)
316
+ dropped = total - len(kept)
317
+ if not dropped:
318
+ return body
319
+ return (
320
+ f"{body}\n... [{dropped} of {total} items omitted to fit the "
321
+ "reduce input limit]"
322
+ )
323
+
324
+ # Greedy fill by per-element estimate, then correct against the actual
325
+ # combined serialization (which is larger than the sum of parts) so the
326
+ # result fits without slicing mid-token.
327
+ kept: list = []
328
+ used = 0
329
+ for item in items:
330
+ chunk = jsonlib.dumps(dict([item]) if is_dict else item, default=str)
331
+ if kept and used + len(chunk) > _MAX_REDUCE_INPUT_CHARS:
332
+ break
333
+ kept.append(item)
334
+ used += len(chunk)
335
+ while len(kept) > 1 and len(render(kept)) > _MAX_REDUCE_INPUT_CHARS:
336
+ kept.pop()
337
+
338
+ # Safety net: a single oversized element can still exceed the budget.
339
+ return _truncate_text(render(kept))
263
340
 
264
341
 
265
342
  def validate_workflow_script(script: str) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-tools
3
- Version: 1.28.1
3
+ Version: 1.29.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-tools"
3
- version = "1.28.1"
3
+ version = "1.29.2"
4
4
  description = "OpenHands Tools - Runtime tools for AI agents"
5
5
 
6
6
  requires-python = ">=3.12"