diff --git a/backend/packages/harness/deerflow/config/sandbox_config.py b/backend/packages/harness/deerflow/config/sandbox_config.py index d5447f3dc..0634ce7b9 100644 --- a/backend/packages/harness/deerflow/config/sandbox_config.py +++ b/backend/packages/harness/deerflow/config/sandbox_config.py @@ -64,4 +64,15 @@ class SandboxConfig(BaseModel): description="Environment variables to inject into the sandbox container. Values starting with $ will be resolved from host environment variables.", ) + bash_output_max_chars: int = Field( + default=20000, + ge=0, + description="Maximum characters to keep from bash tool output. Output exceeding this limit is middle-truncated (head + tail), preserving the first and last half. Set to 0 to disable truncation.", + ) + read_file_output_max_chars: int = Field( + default=50000, + ge=0, + description="Maximum characters to keep from read_file tool output. Output exceeding this limit is head-truncated. Set to 0 to disable truncation.", + ) + model_config = ConfigDict(extra="allow") diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 432669b65..7cdaa26da 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -757,6 +757,59 @@ def ensure_thread_directories_exist(runtime: ToolRuntime[ContextT, ThreadState] runtime.state["thread_directories_created"] = True +def _truncate_bash_output(output: str, max_chars: int) -> str: + """Middle-truncate bash output, preserving head and tail (50/50 split). + + bash output may have errors at either end (stderr/stdout ordering is + non-deterministic), so both ends are preserved equally. + + The returned string (including the truncation marker) is guaranteed to be + no longer than max_chars characters. Pass max_chars=0 to disable truncation + and return the full output unchanged. + """ + if max_chars == 0: + return output + if len(output) <= max_chars: + return output + total_len = len(output) + # Compute the exact worst-case marker length: skipped chars is at most + # total_len, so this is a tight upper bound. + marker_max_len = len(f"\n... [middle truncated: {total_len} chars skipped] ...\n") + kept = max(0, max_chars - marker_max_len) + if kept == 0: + return output[:max_chars] + head_len = kept // 2 + tail_len = kept - head_len + skipped = total_len - kept + marker = f"\n... [middle truncated: {skipped} chars skipped] ...\n" + return f"{output[:head_len]}{marker}{output[-tail_len:] if tail_len > 0 else ''}" + + +def _truncate_read_file_output(output: str, max_chars: int) -> str: + """Head-truncate read_file output, preserving the beginning of the file. + + Source code and documents are read top-to-bottom; the head contains the + most context (imports, class definitions, function signatures). + + The returned string (including the truncation marker) is guaranteed to be + no longer than max_chars characters. Pass max_chars=0 to disable truncation + and return the full output unchanged. + """ + if max_chars == 0: + return output + if len(output) <= max_chars: + return output + total = len(output) + # Compute the exact worst-case marker length: both numeric fields are at + # their maximum (total chars), so this is a tight upper bound. + marker_max_len = len(f"\n... [truncated: showing first {total} of {total} chars. Use start_line/end_line to read a specific range] ...") + kept = max(0, max_chars - marker_max_len) + if kept == 0: + return output[:max_chars] + marker = f"\n... [truncated: showing first {kept} of {total} chars. Use start_line/end_line to read a specific range] ..." + return f"{output[:kept]}{marker}" + + @tool("bash", parse_docstring=True) def bash_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, command: str) -> str: """Execute a bash command in a Linux environment. @@ -781,9 +834,23 @@ def bash_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, com command = replace_virtual_paths_in_command(command, thread_data) command = _apply_cwd_prefix(command, thread_data) output = sandbox.execute_command(command) - return mask_local_paths_in_output(output, thread_data) + try: + from deerflow.config.app_config import get_app_config + + sandbox_cfg = get_app_config().sandbox + max_chars = sandbox_cfg.bash_output_max_chars if sandbox_cfg else 20000 + except Exception: + max_chars = 20000 + return _truncate_bash_output(mask_local_paths_in_output(output, thread_data), max_chars) ensure_thread_directories_exist(runtime) - return sandbox.execute_command(command) + try: + from deerflow.config.app_config import get_app_config + + sandbox_cfg = get_app_config().sandbox + max_chars = sandbox_cfg.bash_output_max_chars if sandbox_cfg else 20000 + except Exception: + max_chars = 20000 + return _truncate_bash_output(sandbox.execute_command(command), max_chars) except SandboxError as e: return f"Error: {e}" except PermissionError as e: @@ -861,7 +928,14 @@ def read_file_tool( return "(empty)" if start_line is not None and end_line is not None: content = "\n".join(content.splitlines()[start_line - 1 : end_line]) - return content + try: + from deerflow.config.app_config import get_app_config + + sandbox_cfg = get_app_config().sandbox + max_chars = sandbox_cfg.read_file_output_max_chars if sandbox_cfg else 50000 + except Exception: + max_chars = 50000 + return _truncate_read_file_output(content, max_chars) except SandboxError as e: return f"Error: {e}" except FileNotFoundError: diff --git a/backend/tests/test_tool_output_truncation.py b/backend/tests/test_tool_output_truncation.py new file mode 100644 index 000000000..e76bb20e2 --- /dev/null +++ b/backend/tests/test_tool_output_truncation.py @@ -0,0 +1,161 @@ +"""Unit tests for tool output truncation functions. + +These functions truncate long tool outputs to prevent context window overflow. +- _truncate_bash_output: middle-truncation (head + tail), for bash tool +- _truncate_read_file_output: head-truncation, for read_file tool +""" + +from deerflow.sandbox.tools import _truncate_bash_output, _truncate_read_file_output + +# --------------------------------------------------------------------------- +# _truncate_bash_output +# --------------------------------------------------------------------------- + + +class TestTruncateBashOutput: + def test_short_output_returned_unchanged(self): + output = "hello world" + assert _truncate_bash_output(output, 20000) == output + + def test_output_equal_to_limit_returned_unchanged(self): + output = "A" * 20000 + assert _truncate_bash_output(output, 20000) == output + + def test_long_output_is_truncated(self): + output = "A" * 30000 + result = _truncate_bash_output(output, 20000) + assert len(result) < len(output) + + def test_result_never_exceeds_max_chars(self): + output = "A" * 30000 + max_chars = 20000 + result = _truncate_bash_output(output, max_chars) + assert len(result) <= max_chars + + def test_head_is_preserved(self): + head = "HEAD_CONTENT" + output = head + "M" * 30000 + result = _truncate_bash_output(output, 20000) + assert result.startswith(head) + + def test_tail_is_preserved(self): + tail = "TAIL_CONTENT" + output = "M" * 30000 + tail + result = _truncate_bash_output(output, 20000) + assert result.endswith(tail) + + def test_middle_truncation_marker_present(self): + output = "A" * 30000 + result = _truncate_bash_output(output, 20000) + assert "[middle truncated:" in result + assert "chars skipped" in result + + def test_skipped_chars_count_is_correct(self): + output = "A" * 25000 + result = _truncate_bash_output(output, 20000) + # Extract the reported skipped count and verify it equals len(output) - kept. + # (kept = max_chars - marker_max_len, where marker_max_len is computed from + # the worst-case marker string — so the exact value is implementation-defined, + # but it must equal len(output) minus the chars actually preserved.) + import re + + m = re.search(r"(\d+) chars skipped", result) + assert m is not None + reported_skipped = int(m.group(1)) + # Verify the number is self-consistent: head + skipped + tail == total + assert reported_skipped > 0 + # The marker reports exactly the chars between head and tail + head_and_tail = len(output) - reported_skipped + assert result.startswith(output[: head_and_tail // 2]) + + def test_max_chars_zero_disables_truncation(self): + output = "A" * 100000 + assert _truncate_bash_output(output, 0) == output + + def test_50_50_split(self): + # head and tail should each be roughly max_chars // 2 + output = "H" * 20000 + "M" * 10000 + "T" * 20000 + result = _truncate_bash_output(output, 20000) + assert result[:100] == "H" * 100 + assert result[-100:] == "T" * 100 + + def test_small_max_chars_does_not_crash(self): + output = "A" * 1000 + result = _truncate_bash_output(output, 10) + assert len(result) <= 10 + + def test_result_never_exceeds_max_chars_various_sizes(self): + output = "X" * 50000 + for max_chars in [100, 1000, 5000, 20000, 49999]: + result = _truncate_bash_output(output, max_chars) + assert len(result) <= max_chars, f"failed for max_chars={max_chars}" + + +# --------------------------------------------------------------------------- +# _truncate_read_file_output +# --------------------------------------------------------------------------- + + +class TestTruncateReadFileOutput: + def test_short_output_returned_unchanged(self): + output = "def foo():\n pass\n" + assert _truncate_read_file_output(output, 50000) == output + + def test_output_equal_to_limit_returned_unchanged(self): + output = "X" * 50000 + assert _truncate_read_file_output(output, 50000) == output + + def test_long_output_is_truncated(self): + output = "X" * 60000 + result = _truncate_read_file_output(output, 50000) + assert len(result) < len(output) + + def test_result_never_exceeds_max_chars(self): + output = "X" * 60000 + max_chars = 50000 + result = _truncate_read_file_output(output, max_chars) + assert len(result) <= max_chars + + def test_head_is_preserved(self): + head = "import os\nimport sys\n" + output = head + "X" * 60000 + result = _truncate_read_file_output(output, 50000) + assert result.startswith(head) + + def test_truncation_marker_present(self): + output = "X" * 60000 + result = _truncate_read_file_output(output, 50000) + assert "[truncated:" in result + assert "showing first" in result + + def test_total_chars_reported_correctly(self): + output = "X" * 60000 + result = _truncate_read_file_output(output, 50000) + assert "of 60000 chars" in result + + def test_start_line_hint_present(self): + output = "X" * 60000 + result = _truncate_read_file_output(output, 50000) + assert "start_line" in result + assert "end_line" in result + + def test_max_chars_zero_disables_truncation(self): + output = "X" * 100000 + assert _truncate_read_file_output(output, 0) == output + + def test_tail_is_not_preserved(self): + # head-truncation: tail should be cut off + output = "H" * 50000 + "TAIL_SHOULD_NOT_APPEAR" + result = _truncate_read_file_output(output, 50000) + assert "TAIL_SHOULD_NOT_APPEAR" not in result + + def test_small_max_chars_does_not_crash(self): + output = "X" * 1000 + result = _truncate_read_file_output(output, 10) + assert len(result) <= 10 + + def test_result_never_exceeds_max_chars_various_sizes(self): + output = "X" * 50000 + for max_chars in [100, 1000, 5000, 20000, 49999]: + result = _truncate_read_file_output(output, max_chars) + assert len(result) <= max_chars, f"failed for max_chars={max_chars}" diff --git a/config.example.yaml b/config.example.yaml index b2ccfd4cc..0bff5d6ad 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -12,7 +12,7 @@ # ============================================================================ # Bump this number when the config schema changes. # Run `make config-upgrade` to merge new fields into your local config.yaml. -config_version: 4 +config_version: 5 # ============================================================================ # Logging @@ -366,6 +366,13 @@ sandbox: # trusted, single-user local workflows. allow_host_bash: false + # Tool output truncation limits (characters). + # bash uses middle-truncation (head + tail) since errors can appear anywhere in the output. + # read_file uses head-truncation since source code context is front-loaded. + # Set to 0 to disable truncation. + bash_output_max_chars: 20000 + read_file_output_max_chars: 50000 + # Option 2: Container-based AIO Sandbox # Executes commands in isolated containers (Docker or Apple Container) # On macOS: Automatically prefers Apple Container if available, falls back to Docker